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
+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