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