feat(monitor): 博主的粉丝数,以及给作品起备注
两件都是「作品栏里把这东西认出来」的延伸: * **账号级指标**:作品列表只会说「这条涨了多少赞」,说不了「这个人整个 账号的粉丝在涨还是在掉」。抖音的资料接口本来就有粉丝数/总获赞/作品数, 每轮顺手记一条快照(`monitor_creator_stat`,粒度 = 任务×博主×轮次, 和作品指标同形)。组头显示最近一条。 快照在「一条作品都没采到」的早退**之前**落:作品列表被风控挡住的那一轮, 正是「粉丝还在涨、但新作品没在发现」最该被看见的时刻。 * **作品备注**:博主备注回答「这个账号是谁」,这条回答「这条我要盯着」。 一个博主底下常常只有一两件值得盯的作品,所以不能合并成一条。键取 (platform, note_id),跨任务共用一份。 两边都守住同一条口径:**不知道就是 null,不写成 0** —— 0 在趋势图上是一条 砸到底的线,和「还没采到」是两回事。
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user