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