feat(monitor): 博主的粉丝数,以及给作品起备注
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

两件都是「作品栏里把这东西认出来」的延伸:

* **账号级指标**:作品列表只会说「这条涨了多少赞」,说不了「这个人整个
  账号的粉丝在涨还是在掉」。抖音的资料接口本来就有粉丝数/总获赞/作品数,
  每轮顺手记一条快照(`monitor_creator_stat`,粒度 = 任务×博主×轮次,
  和作品指标同形)。组头显示最近一条。

  快照在「一条作品都没采到」的早退**之前**落:作品列表被风控挡住的那一轮,
  正是「粉丝还在涨、但新作品没在发现」最该被看见的时刻。

* **作品备注**:博主备注回答「这个账号是谁」,这条回答「这条我要盯着」。
  一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。键取
  (platform, note_id),跨任务共用一份。

两边都守住同一条口径:**不知道就是 null,不写成 0** —— 0 在趋势图上是一条
砸到底的线,和「还没采到」是两回事。
This commit is contained in:
2026-10-10 18:09:03 +08:00
parent 0a88474c92
commit 20e672834c
15 changed files with 953 additions and 10 deletions
+6
View File
@@ -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")),
+42 -2
View File
@@ -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
+67
View File
@@ -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
+58
View File
@@ -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
View File
@@ -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),
+17
View File
@@ -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
# ---------------------------------------------------------------------------
+6
View File
@@ -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 地址,留空表示停用")