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