feat(monitor): 博主的粉丝数,以及给作品起备注
两件都是「作品栏里把这东西认出来」的延伸: * **账号级指标**:作品列表只会说「这条涨了多少赞」,说不了「这个人整个 账号的粉丝在涨还是在掉」。抖音的资料接口本来就有粉丝数/总获赞/作品数, 每轮顺手记一条快照(`monitor_creator_stat`,粒度 = 任务×博主×轮次, 和作品指标同形)。组头显示最近一条。 快照在「一条作品都没采到」的早退**之前**落:作品列表被风控挡住的那一轮, 正是「粉丝还在涨、但新作品没在发现」最该被看见的时刻。 * **作品备注**:博主备注回答「这个账号是谁」,这条回答「这条我要盯着」。 一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。键取 (platform, note_id),跨任务共用一份。 两边都守住同一条口径:**不知道就是 null,不写成 0** —— 0 在趋势图上是一条 砸到底的线,和「还没采到」是两回事。
This commit is contained in:
@@ -425,6 +425,12 @@ async def author_profile(sec_user_id: str, *, cookie: str = "") -> Dict[str, Any
|
||||
f"接口没返回用户数据(status_code={payload.get('status_code')})"
|
||||
)
|
||||
return {
|
||||
# 自报家门。快照表的唯一键是 (任务, creator_hash, 轮次),而作品是靠
|
||||
# `anonymize_user_id(author.uid)` 得到这个哈希的 —— 这里走同一条路,两边才对得上,
|
||||
# 否则快照会和作品分成两个人,界面上永远查不到。
|
||||
"creator_hash": anonymize_user_id(
|
||||
str(user.get("uid") or user.get("sec_uid") or "")
|
||||
),
|
||||
"nickname": user.get("nickname") or "",
|
||||
"unique_id": user.get("unique_id") or "",
|
||||
"fans": _as_int(user.get("follower_count")),
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Sequence
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
||||
|
||||
from tools import utils
|
||||
|
||||
@@ -65,6 +65,9 @@ async def collect(
|
||||
"""
|
||||
notes: List[Dict[str, Any]] = []
|
||||
comments: List[Dict[str, Any]] = []
|
||||
# 博主**账号级**指标(粉丝 / 总获赞 / 作品数)。作品列表之外单独要一次,
|
||||
# 只有博主模式才有 —— 作品模式的目标是一件作品,没有"这个博主是谁"可问。
|
||||
profiles: List[Dict[str, Any]] = []
|
||||
errors: List[str] = []
|
||||
|
||||
# **整个 collect 只去重一次的、跨目标的集合**:退化路径会把「库里已知的全部作品」
|
||||
@@ -89,6 +92,9 @@ async def collect(
|
||||
videos = await _creator_works(
|
||||
external_id, limit, known_aweme_ids, seen_aweme, cookie, errors
|
||||
)
|
||||
profile = await _creator_profile(external_id, videos, cookie, errors)
|
||||
if profile is not None:
|
||||
profiles.append(profile)
|
||||
|
||||
for video in videos:
|
||||
aweme_id = video.get("aweme_id")
|
||||
@@ -107,7 +113,7 @@ async def collect(
|
||||
except douyin_api.DouyinApiError as exc:
|
||||
errors.append(f"拉取作品 {aweme_id} 的评论失败:{exc}")
|
||||
|
||||
jsonl_dir = _write_artifacts(out_dir, platform, mode, notes, comments)
|
||||
jsonl_dir = _write_artifacts(out_dir, platform, mode, notes, comments, profiles)
|
||||
return {
|
||||
"notes": len(notes),
|
||||
"comments": len(comments),
|
||||
@@ -116,6 +122,35 @@ async def collect(
|
||||
}
|
||||
|
||||
|
||||
async def _creator_profile(
|
||||
sec_user_id: str,
|
||||
videos: Sequence[Dict[str, Any]],
|
||||
cookie: str,
|
||||
errors: List[str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""问一次博主的账号级指标。拿不到就算了 —— **不能因为顺手的附加信息失败,
|
||||
就把这一轮本来采到的作品也判成失败。**
|
||||
|
||||
creator_hash 优先取作品自带的那个:作品是靠 ``anonymize_user_id(author.uid)`` 得到
|
||||
哈希的,而快照表和作品必须对得上号,否则界面上永远查不出这个博主的粉丝数。只有当一件
|
||||
作品都没采到时(列表被挡且没有已知作品可刷新),才退回资料接口自己算的哈希 ——
|
||||
那种情况下也只剩它了。
|
||||
"""
|
||||
try:
|
||||
profile = await douyin_api.author_profile(sec_user_id, cookie=cookie)
|
||||
except douyin_api.DouyinApiError as exc:
|
||||
errors.append(f"拉取博主 {sec_user_id} 的资料失败:{exc}")
|
||||
return None
|
||||
|
||||
if videos:
|
||||
profile["creator_hash"] = videos[0].get("creator_hash") or profile["creator_hash"]
|
||||
if not profile.get("creator_hash"):
|
||||
# 哈希都算不出来的快照没人能查到,落下去只是垃圾。
|
||||
errors.append(f"博主 {sec_user_id} 的资料里没有可用的身份标识,跳过账号指标")
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
async def _creator_works(
|
||||
sec_user_id: str,
|
||||
limit: int,
|
||||
@@ -151,6 +186,7 @@ def _write_artifacts(
|
||||
mode: str,
|
||||
notes: List[Dict[str, Any]],
|
||||
comments: List[Dict[str, Any]],
|
||||
profiles: Sequence[Dict[str, Any]] = (),
|
||||
) -> Path:
|
||||
"""按爬虫那套目录与文件名写 jsonl。
|
||||
|
||||
@@ -167,6 +203,10 @@ def _write_artifacts(
|
||||
# 评论文件即使没有评论也建出来:ingest 靠「文件在不在」区分「这一轮没评论」和
|
||||
# 「这一轮什么都没抓到」,两种情况的含义完全不同。
|
||||
_write_jsonl(jsonl_dir / f"{kind}_comments_{date}.jsonl", comments)
|
||||
# 博主资料同样无条件写:空文件表示"问了但没问到",没有文件表示"这次根本没问"
|
||||
# (作品模式)。两者在 ingest 那边走的是同一条路(都不落快照),但留空文件能让
|
||||
# 事后翻 run 目录时看出到底问没问过。
|
||||
_write_jsonl(jsonl_dir / f"{kind}_profile_{date}.jsonl", list(profiles))
|
||||
return jsonl_dir
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from .models import (
|
||||
EVENT_NO_DATA,
|
||||
EVENT_RUN_FAILED,
|
||||
MonitorComment,
|
||||
MonitorCreatorStat,
|
||||
MonitorEvent,
|
||||
MonitorNote,
|
||||
MonitorNoteMetric,
|
||||
@@ -219,6 +220,19 @@ def find_run_files(
|
||||
)
|
||||
|
||||
|
||||
def find_profile_files(out_dir: Path, platform: str = PLATFORM_XHS) -> List[Path]:
|
||||
"""博主**账号级**指标那几行 jsonl(``creator_profile_*.jsonl``)。
|
||||
|
||||
单独一个函数而不是往 ``find_run_files`` 的返回值里塞第三个列表:那个返回值的两个
|
||||
位置是有意义的(contents/comments),加一个会把所有调用点和解包语句都牵动一遍,
|
||||
而这份产物是**可选**的 —— 小红书那条路(爬虫进程)根本不产生它。
|
||||
"""
|
||||
jsonl_dir = out_dir / adapters.artifact_dir(platform) / "jsonl"
|
||||
if not jsonl_dir.is_dir():
|
||||
return []
|
||||
return sorted(jsonl_dir.glob("*_profile_*.jsonl"))
|
||||
|
||||
|
||||
def _misplaced_output_dirs(out_dir: Path, expected: str) -> List[str]:
|
||||
"""在 out_dir 下找「有产物、但目录名不是期望的那个」的目录。
|
||||
|
||||
@@ -593,6 +607,49 @@ async def _ingest_comments(
|
||||
return new_count
|
||||
|
||||
|
||||
async def _ingest_creator_stats(
|
||||
session: AsyncSession,
|
||||
run: MonitorRun,
|
||||
records: Sequence[Dict[str, Any]],
|
||||
) -> int:
|
||||
"""把这一轮问到的博主账号级指标落成快照,返回条数。
|
||||
|
||||
和作品指标一样是**每轮一条**:账号级的粉丝数是缓慢变化的量,「今天比昨天多了 300」
|
||||
才是有用的信号,单看一个绝对值没有意义 —— 所以这里只管记,分析交给查询端。
|
||||
|
||||
**没解析出来的值留 NULL,不写 0**:0 在趋势图上是一条砸到底的线,和「不知道」完全是
|
||||
两回事(见 ``parse_count`` 的注释)。
|
||||
"""
|
||||
now = get_current_timestamp()
|
||||
written = 0
|
||||
seen: set = set()
|
||||
|
||||
for record in records:
|
||||
creator_hash = str(record.get("creator_hash") or "").strip()
|
||||
if not creator_hash or creator_hash in seen:
|
||||
# 一个任务可以配多个目标,退化路径下它们可能指向同一个博主 —— 而唯一键是
|
||||
# (task_id, creator_hash, run_id),重复插入会撞键把整轮炸掉。
|
||||
continue
|
||||
seen.add(creator_hash)
|
||||
|
||||
session.add(
|
||||
MonitorCreatorStat(
|
||||
task_id=run.task_id,
|
||||
run_id=run.id,
|
||||
creator_hash=creator_hash,
|
||||
nickname=str(record.get("nickname") or "")[:128],
|
||||
fans=parse_count(record.get("fans")),
|
||||
total_favorited=parse_count(record.get("total_favorited")),
|
||||
works_count=parse_count(record.get("works")),
|
||||
following=parse_count(record.get("following")),
|
||||
captured_at=now,
|
||||
)
|
||||
)
|
||||
written += 1
|
||||
|
||||
return written
|
||||
|
||||
|
||||
async def ingest_run(
|
||||
session: AsyncSession,
|
||||
run: MonitorRun,
|
||||
@@ -639,6 +696,16 @@ async def ingest_run(
|
||||
run.notes_fetched = len(contents)
|
||||
run.comments_fetched = len(comments)
|
||||
|
||||
# 账号级快照**在「一条作品都没采到」的早退之前**落。博主的粉丝数并不会因为他这个
|
||||
# 月的新作品列表被风控挡住就不存在 —— 那正是最该看到「粉丝还在涨、但新作品没在发现」
|
||||
# 的时刻,跳过它等于在最需要它的那轮把数据丢掉。
|
||||
profiles = [
|
||||
record
|
||||
for path in find_profile_files(out_dir, task.platform)
|
||||
for record in _read_jsonl(path)
|
||||
]
|
||||
await _ingest_creator_stats(session, run, profiles)
|
||||
|
||||
# A bad cookie does NOT fail the process: XHS cookie login is never validated,
|
||||
# so an unauthenticated session just returns zero notes with exit 0 -- and
|
||||
# usually does not even create an output file. Treating that as "the creator
|
||||
|
||||
@@ -343,6 +343,64 @@ class MonitorCreatorAlias(MonitorBase):
|
||||
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class MonitorCreatorStat(MonitorBase):
|
||||
"""博主的**账号级**快照:粉丝数 / 总获赞 / 作品数 / 关注数。
|
||||
|
||||
这是作品列表给不了的东西:作品级指标说"这一条视频涨了多少赞",账号级说"这个人
|
||||
整个账号的粉丝是在涨还是在掉"。两者不互相替代。
|
||||
|
||||
粒度取 ``(任务, 博主, 轮次)``,和作品指标一样的形状 —— 于是趋势、差分、报表那套
|
||||
现成的逻辑换个表就能用。
|
||||
|
||||
目前**只有抖音**会写它:小红书那条走的是爬虫子进程,而它的 ``save_creator()`` 在
|
||||
教学版里是空函数,根本没落过创作者资料。所以表里只有抖音的博主。
|
||||
"""
|
||||
|
||||
__tablename__ = "monitor_creator_stat"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"task_id", "creator_hash", "run_id", name="uq_creator_stat"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("monitor_task.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
run_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
nickname: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||
|
||||
# 都可能为 None:平台没给就留空,**不要伪造成 0** —— 0 是"掉到零",和"不知道"
|
||||
# 在趋势图上是完全不同的两回事。
|
||||
fans: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
total_favorited: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
works_count: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
following: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
|
||||
captured_at: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
|
||||
|
||||
class MonitorNoteAlias(MonitorBase):
|
||||
"""给**作品**起的备注。
|
||||
|
||||
和 ``MonitorCreatorAlias`` 是一对:博主那条回答"这是谁",这条回答"这条我要盯着"。
|
||||
键取 ``(platform, note_id)`` —— 作品 id 本身就带平台语义,但显式带上 platform 才能和
|
||||
博主备注用同一套查询形状。
|
||||
"""
|
||||
|
||||
__tablename__ = "monitor_note_alias"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("platform", "note_id", name="uq_note_alias"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
note_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
alias: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class MonitorSetting(MonitorBase):
|
||||
"""Key/value store. Holds the XHS cookie for unattended runs."""
|
||||
|
||||
|
||||
+94
-1
@@ -19,7 +19,7 @@
|
||||
"""Task CRUD and dashboard queries for the monitoring layer."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
@@ -35,8 +35,10 @@ from .models import (
|
||||
MODE_NOTE,
|
||||
MonitorComment,
|
||||
MonitorCreatorAlias,
|
||||
MonitorCreatorStat,
|
||||
MonitorEvent,
|
||||
MonitorNote,
|
||||
MonitorNoteAlias,
|
||||
MonitorNoteMetric,
|
||||
MonitorRun,
|
||||
MonitorTarget,
|
||||
@@ -389,6 +391,74 @@ def _delta(current: Optional[int], previous: Optional[int]) -> Optional[int]:
|
||||
return current - previous
|
||||
|
||||
|
||||
async def _note_alias_map(session: AsyncSession) -> Dict[tuple, str]:
|
||||
"""``(platform, note_id) -> 作品备注``。和博主备注一个道理,整体读一次。"""
|
||||
rows = (await session.scalars(select(MonitorNoteAlias))).all()
|
||||
return {(row.platform, row.note_id): row.alias for row in rows if row.alias}
|
||||
|
||||
|
||||
async def set_note_alias(
|
||||
session: AsyncSession, platform: str, note_id: str, alias: str
|
||||
) -> None:
|
||||
"""给作品起备注;空串就是删掉这条备注。"""
|
||||
alias = (alias or "").strip()[:128]
|
||||
existing = await session.scalar(
|
||||
select(MonitorNoteAlias).where(
|
||||
MonitorNoteAlias.platform == platform,
|
||||
MonitorNoteAlias.note_id == note_id,
|
||||
)
|
||||
)
|
||||
|
||||
if not alias:
|
||||
if existing is not None:
|
||||
await session.delete(existing)
|
||||
return
|
||||
|
||||
if existing is None:
|
||||
session.add(
|
||||
MonitorNoteAlias(
|
||||
platform=platform,
|
||||
note_id=note_id,
|
||||
alias=alias,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
existing.alias = alias
|
||||
existing.updated_at = get_current_timestamp()
|
||||
|
||||
|
||||
async def _latest_creator_stats(
|
||||
session: AsyncSession,
|
||||
task_ids: Sequence[int],
|
||||
creator_hashes: Sequence[str],
|
||||
) -> Dict[tuple, "MonitorCreatorStat"]:
|
||||
"""``(task_id, creator_hash) -> 最近一条``账号级快照。
|
||||
|
||||
按 run_id 而不是 captured_at 取「最近」:和作品指标用的是同一个口径,两者放一起
|
||||
看才不会出现「作品数据来自第 8 轮、粉丝数来自第 9 轮」这种对不上的情况。
|
||||
"""
|
||||
if not task_ids or not creator_hashes:
|
||||
return {}
|
||||
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(MonitorCreatorStat)
|
||||
.where(
|
||||
MonitorCreatorStat.task_id.in_(list(task_ids)),
|
||||
MonitorCreatorStat.creator_hash.in_(list(creator_hashes)),
|
||||
)
|
||||
.order_by(MonitorCreatorStat.run_id.desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
latest: Dict[tuple, MonitorCreatorStat] = {}
|
||||
for row in rows:
|
||||
latest.setdefault((row.task_id, row.creator_hash), row) # 已按 run_id 倒序
|
||||
return latest
|
||||
|
||||
|
||||
async def list_notes(
|
||||
session: AsyncSession,
|
||||
task_id: Optional[int] = None,
|
||||
@@ -442,6 +512,16 @@ async def list_notes(
|
||||
).all()
|
||||
}
|
||||
aliases = await _creator_alias_map(session)
|
||||
note_aliases = await _note_alias_map(session)
|
||||
# 账号级指标(粉丝 / 总获赞 / 作品数)。**挂在作品上一起返回**,因为界面上就是按博主
|
||||
# 归组显示的 —— 让前端为了一个组头再发一轮请求没道理。同一个博主的所有作品拿到的是
|
||||
# 同一条(键里带 task_id,所以跨任务不会串)。没有的(小红书那条路不产生它)就是 null,
|
||||
# 前端据此整块不显示,而不是显示一个 0。
|
||||
creator_stats = await _latest_creator_stats(
|
||||
session,
|
||||
[note.task_id for note in notes],
|
||||
[note.creator_hash for note in notes if note.creator_hash],
|
||||
)
|
||||
|
||||
for note in notes:
|
||||
series = by_note.get(note.note_id, [])
|
||||
@@ -454,6 +534,8 @@ async def list_notes(
|
||||
if note.first_seen_run_id != latest_run_ids[note.task_id]:
|
||||
continue
|
||||
|
||||
stat = creator_stats.get((note.task_id, note.creator_hash))
|
||||
|
||||
result.append(
|
||||
{
|
||||
"task_id": note.task_id,
|
||||
@@ -474,6 +556,17 @@ async def list_notes(
|
||||
"creator_alias": aliases.get(
|
||||
(task_platform.get(note.task_id, ""), note.creator_hash), ""
|
||||
),
|
||||
# 这条作品自己的备注。和博主备注是两件事:博主备注回答"这是谁",它回答
|
||||
# "这条我要盯着"。
|
||||
"note_alias": note_aliases.get(
|
||||
(task_platform.get(note.task_id, ""), note.note_id), ""
|
||||
),
|
||||
# 博主账号级指标 —— 作品列表给不了的东西。三个值都可能为 null(平台没采
|
||||
# 到、或者这条作品来自不产生它的数据源),前端据此整块不画。
|
||||
"creator_fans": stat.fans if stat else None,
|
||||
"creator_total_favorited": stat.total_favorited if stat else None,
|
||||
"creator_works": stat.works_count if stat else None,
|
||||
"creator_stats_at": stat.captured_at if stat else None,
|
||||
# 优先给本地缓存地址:远程地址带签名、会过期(实测隔天即 403),
|
||||
# 本地那份不会。没有缓存时才退回远程,至少让图先显示出来。
|
||||
"cover": covers.cover_url(note.note_id, note.cover),
|
||||
|
||||
@@ -39,6 +39,7 @@ from ..monitor.models import SETTING_WECOM_WEBHOOK, MonitorTask
|
||||
from ..schemas.monitor import (
|
||||
CookiePayload,
|
||||
CreatorAliasPayload,
|
||||
NoteAliasPayload,
|
||||
MonitorTaskCreate,
|
||||
MonitorTaskUpdate,
|
||||
WebhookPayload,
|
||||
@@ -275,6 +276,22 @@ async def set_creator_alias_endpoint(
|
||||
return {"creator_hash": creator_hash, "alias": payload.alias.strip()}
|
||||
|
||||
|
||||
@router.put("/notes/{note_id}")
|
||||
async def set_note_alias_endpoint(
|
||||
note_id: str,
|
||||
payload: NoteAliasPayload,
|
||||
platform: str = Query(default=PLATFORM_XHS),
|
||||
):
|
||||
"""给**作品**起个备注。
|
||||
|
||||
和上面那条博主备注是一对:博主备注回答「这个账号是谁」,这条回答「这条作品我要盯着」。
|
||||
两者不能合并 —— 一个博主底下常常只有一两件值得盯的作品。
|
||||
"""
|
||||
async with get_session() as session:
|
||||
await service.set_note_alias(session, platform, note_id, payload.alias)
|
||||
return {"note_id": note_id, "alias": payload.alias.strip()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -114,6 +114,12 @@ class CreatorAliasPayload(BaseModel):
|
||||
alias: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class NoteAliasPayload(BaseModel):
|
||||
"""给作品起的备注。空串表示清掉这条备注。"""
|
||||
|
||||
alias: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
url: str = Field(default="", description="企业微信机器人 Webhook 地址,留空表示停用")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user