From 0a88474c92b3b9f29c9c2ac584eca66d23ba6363 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sat, 10 Oct 2026 18:00:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20=E7=BB=99=E5=8D=9A=E4=B8=BB?= =?UTF-8?q?=E8=B5=B7=E5=A4=87=E6=B3=A8=20=E2=80=94=E2=80=94=20=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=A0=8F=E9=87=8C=E6=89=8D=E8=AE=A4=E5=BE=97=E5=87=BA?= =?UTF-8?q?=E3=80=8C=E8=BF=99=E6=98=AF=E8=B0=81=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 作品栏按 creator_hash 把作品归到博主的组里,可那是个哈希;creator_name 是平台昵称, 粉丝少的号常常认不出。两样都没法把账号对上人。 * 新表 monitor_creator_alias,键取 **(platform, creator_hash)** 而不是按任务: 哈希对同一个 uid 是稳定的,所以同一个博主出现在多个任务里时,备注只填一次。 * list_notes 带上 creator_alias(整体查一次再在内存里取,不是每条作品查一次)。 * `PUT /monitor/creators/{creator_hash}`,空串即清掉那条备注。 * 作品栏的博主组头:**优先显示备注**,起过备注之后平台昵称降成副标题(它仍是有用的 对照);组头上一个铅笔按钮就地编辑,回车保存、失焦保存、Esc 取消。 Esc 那条要单独处理:取消之后紧接着的失焦会把刚放弃的内容存进去,所以用一个标记让那次 失焦闭嘴。 测试 +6:没起过时是空串、起了会跟着作品返回、**跨任务共用一条**、**不串到别的平台**、 空串清掉、前后空格会被去掉。 --- api/monitor/models.py | 23 ++++ api/monitor/service.py | 60 ++++++++++ api/routers/monitor.py | 22 ++++ api/schemas/monitor.py | 6 + tests/test_monitor_creators.py | 124 ++++++++++++++++++++ webui/src/components/monitor/NotesTable.tsx | 84 +++++++++++-- webui/src/hooks/useMonitor.ts | 19 ++- webui/src/lib/api.ts | 8 ++ webui/src/types/monitor.ts | 7 ++ 9 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 tests/test_monitor_creators.py diff --git a/api/monitor/models.py b/api/monitor/models.py index 091362c..17370a0 100644 --- a/api/monitor/models.py +++ b/api/monitor/models.py @@ -320,6 +320,29 @@ class MonitorEvent(MonitorBase): is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) +class MonitorCreatorAlias(MonitorBase): + """给博主起的备注。 + + 作品栏和评论栏都按 ``creator_hash`` 把作品归到博主名下,可那是个哈希; + ``creator_name`` 是平台上的昵称(而且粉丝少的号常常没有)。两样都认不出"这是谁"。 + 备注是**人自己起的名字**(「竞品A」「自家号-3」),用来把账号对上人。 + + 键取 ``(platform, creator_hash)``:哈希对同一个 uid 是稳定的,所以同一个博主出现在 + 多个任务里时备注也是同一个,不用每个任务各填一遍。 + """ + + __tablename__ = "monitor_creator_alias" + __table_args__ = ( + UniqueConstraint("platform", "creator_hash", name="uq_creator_alias"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + platform: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + creator_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + alias: Mapped[str] = mapped_column(String(128), nullable=False, default="") + updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False) + + class MonitorSetting(MonitorBase): """Key/value store. Holds the XHS cookie for unattended runs.""" diff --git a/api/monitor/service.py b/api/monitor/service.py index 4043ad2..658b35f 100644 --- a/api/monitor/service.py +++ b/api/monitor/service.py @@ -34,6 +34,7 @@ from .models import ( MODE_CREATOR, MODE_NOTE, MonitorComment, + MonitorCreatorAlias, MonitorEvent, MonitorNote, MonitorNoteMetric, @@ -328,6 +329,48 @@ def trigger_manual_run(task_id: int) -> None: # Dashboard queries # --------------------------------------------------------------------------- +async def _creator_alias_map(session: AsyncSession) -> Dict[tuple, str]: + """``(platform, creator_hash) -> 备注``。 + + 整体读一次再在内存里取,而不是每条作品查一次 —— 作品列表动辄几十条。备注本身很少, + 表不会大。 + """ + rows = (await session.scalars(select(MonitorCreatorAlias))).all() + return {(row.platform, row.creator_hash): row.alias for row in rows if row.alias} + + +async def set_creator_alias( + session: AsyncSession, platform: str, creator_hash: str, alias: str +) -> None: + """给博主起备注;传空串就是删掉这条备注(界面上的"清空")。""" + alias = (alias or "").strip()[:128] + existing = await session.scalar( + select(MonitorCreatorAlias).where( + MonitorCreatorAlias.platform == platform, + MonitorCreatorAlias.creator_hash == creator_hash, + ) + ) + + if not alias: + if existing is not None: + await session.delete(existing) + return + + if existing is None: + session.add( + MonitorCreatorAlias( + platform=platform, + creator_hash=creator_hash, + alias=alias, + updated_at=get_current_timestamp(), + ) + ) + return + + existing.alias = alias + existing.updated_at = get_current_timestamp() + + async def _latest_successful_run_id(session: AsyncSession, task_id: int) -> Optional[int]: return await session.scalar( select(MonitorRun.id) @@ -387,6 +430,19 @@ async def list_notes( latest_run_ids: Dict[int, Optional[int]] = {} result: List[Dict[str, Any]] = [] + # 博主的备注。键是 (platform, creator_hash) —— 作品的 platform 挂在它的任务上。 + task_platform = { + row.id: row.platform + for row in ( + await session.execute( + select(MonitorTask.id, MonitorTask.platform).where( + MonitorTask.id.in_({note.task_id for note in notes}) + ) + ) + ).all() + } + aliases = await _creator_alias_map(session) + for note in notes: series = by_note.get(note.note_id, []) current = series[0] if series else None @@ -414,6 +470,10 @@ async def list_notes( # (见 config.MASK_NICKNAME),所以就是原文。 "creator_hash": note.creator_hash, "creator_name": note.creator_name, + # 人自己起的备注,界面上优先显示它 —— 昵称认不出是谁,哈希更认不出。 + "creator_alias": aliases.get( + (task_platform.get(note.task_id, ""), note.creator_hash), "" + ), # 优先给本地缓存地址:远程地址带签名、会过期(实测隔天即 403), # 本地那份不会。没有缓存时才退回远程,至少让图先显示出来。 "cover": covers.cover_url(note.note_id, note.cover), diff --git a/api/routers/monitor.py b/api/routers/monitor.py index 491bcf3..da6c828 100644 --- a/api/routers/monitor.py +++ b/api/routers/monitor.py @@ -38,6 +38,7 @@ from ..monitor.settings import ( from ..monitor.models import SETTING_WECOM_WEBHOOK, MonitorTask from ..schemas.monitor import ( CookiePayload, + CreatorAliasPayload, MonitorTaskCreate, MonitorTaskUpdate, WebhookPayload, @@ -253,6 +254,27 @@ async def clear_cookie_endpoint(platform: str = Query(default=PLATFORM_XHS)): return {"message": "Cookie cleared"} +# --------------------------------------------------------------------------- +# 博主备注 +# --------------------------------------------------------------------------- + + +@router.put("/creators/{creator_hash}") +async def set_creator_alias_endpoint( + creator_hash: str, + payload: CreatorAliasPayload, + platform: str = Query(default=PLATFORM_XHS), +): + """给博主起个备注(界面上的「备注」)。 + + 作品栏按 creator_hash 归组,可那是个哈希、昵称又常常认不出是谁 —— 备注是人自己起的 + 名字。空串表示清掉这条备注。 + """ + async with get_session() as session: + await service.set_creator_alias(session, platform, creator_hash, payload.alias) + return {"creator_hash": creator_hash, "alias": payload.alias.strip()} + + # --------------------------------------------------------------------------- # QR login # --------------------------------------------------------------------------- diff --git a/api/schemas/monitor.py b/api/schemas/monitor.py index 0ee94aa..94eb3a9 100644 --- a/api/schemas/monitor.py +++ b/api/schemas/monitor.py @@ -108,6 +108,12 @@ class CookiePayload(BaseModel): cookie: str = Field(min_length=1) +class CreatorAliasPayload(BaseModel): + """给博主起的备注。空串表示清掉这条备注。""" + + alias: str = Field(default="", max_length=128) + + class WebhookPayload(BaseModel): url: str = Field(default="", description="企业微信机器人 Webhook 地址,留空表示停用") diff --git a/tests/test_monitor_creators.py b/tests/test_monitor_creators.py new file mode 100644 index 0000000..f67a19d --- /dev/null +++ b/tests/test_monitor_creators.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +"""博主备注 —— 作品栏里那一位到底是谁。 + +按 creator_hash 分组、显示 creator_name,两样都认不出人:一个是哈希,一个是平台昵称。 +备注是人自己起的名字。 +""" + +import httpx +import pytest +import pytest_asyncio + +from api.main import app +from api.monitor import db as monitor_db +from api.monitor.models import MODE_CREATOR, MonitorNote, MonitorTask + +CREATOR_HASH = "hash-a" +NICKNAME = "张三" + + +@pytest_asyncio.fixture +async def client(tmp_path): + monitor_db.set_sqlite_path(tmp_path / "monitor.db") + await monitor_db.init_db() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + await monitor_db.dispose_engine() + + +async def _seed(platform: str = "xhs", task_name: str = "任务") -> int: + """一个任务 + 一条作品,博主固定用 CREATOR_HASH / NICKNAME。""" + async with monitor_db.get_session() as session: + task = MonitorTask( + name=task_name, platform=platform, mode=MODE_CREATOR, enabled=True, + interval_minutes=60, max_notes_count=20, enable_comments=False, + max_comments_count=50, run_timeout_seconds=3600, + notify_enabled=False, created_at=0, updated_at=0, + ) + session.add(task) + await session.flush() + session.add( + MonitorNote( + task_id=task.id, note_id=f"{platform}-n1", title="作品", + note_url="", cover="", creator_hash=CREATOR_HASH, + creator_name=NICKNAME, source_kind="", published_at=None, + first_seen_run_id=1, first_seen_at=0, + last_seen_run_id=1, last_seen_at=0, + ) + ) + return task.id + + +async def _notes(client, platform: str = "xhs"): + return (await client.get("/api/monitor/notes", params={"platform": platform})).json()["notes"] + + +async def _set_alias(client, alias: str, platform: str = "xhs", creator_hash=CREATOR_HASH): + return await client.put( + f"/api/monitor/creators/{creator_hash}", + params={"platform": platform}, + json={"alias": alias}, + ) + + +class TestCreatorAlias: + @pytest.mark.asyncio + async def test_notes_start_without_an_alias(self, client): + await _seed() + + assert (await _notes(client))[0]["creator_alias"] == "" + + @pytest.mark.asyncio + async def test_an_alias_comes_back_with_the_notes(self, client): + await _seed() + + response = await _set_alias(client, "竞品A") + + assert response.status_code == 200 + note = (await _notes(client))[0] + assert note["creator_alias"] == "竞品A" + # 备注是**叠加**在昵称之上的,不是替换 —— 昵称仍然是有用的对照。 + assert note["creator_name"] == NICKNAME + + @pytest.mark.asyncio + async def test_an_alias_is_shared_across_tasks(self, client): + """同一个博主出现在两个任务里,备注只该填一次。 + + creator_hash 对同一个 uid 是稳定的,所以键取 (platform, creator_hash) 而不是 + 按任务存 —— 否则每加一个任务都要重新认一遍人。 + """ + await _seed(task_name="任务甲") + await _seed(task_name="任务乙") + await _set_alias(client, "竞品A") + + for note in await _notes(client): + assert note["creator_alias"] == "竞品A" + + @pytest.mark.asyncio + async def test_the_alias_does_not_leak_to_another_platform(self, client): + """同一个哈希在另一个平台上是另一个(或同一个)人 —— 别串味。""" + await _seed(platform="xhs") + await _seed(platform="dy") + + await _set_alias(client, "小红书那边的", platform="xhs") + + assert (await _notes(client, "xhs"))[0]["creator_alias"] == "小红书那边的" + assert (await _notes(client, "dy"))[0]["creator_alias"] == "" + + @pytest.mark.asyncio + async def test_an_empty_alias_clears_it(self, client): + await _seed() + await _set_alias(client, "竞品A") + + await _set_alias(client, "") + + assert (await _notes(client))[0]["creator_alias"] == "" + + @pytest.mark.asyncio + async def test_an_alias_is_trimmed(self, client): + await _seed() + + await _set_alias(client, " 竞品A ") + + assert (await _notes(client))[0]["creator_alias"] == "竞品A" diff --git a/webui/src/components/monitor/NotesTable.tsx b/webui/src/components/monitor/NotesTable.tsx index 1dda1cf..a0f7a2f 100644 --- a/webui/src/components/monitor/NotesTable.tsx +++ b/webui/src/components/monitor/NotesTable.tsx @@ -1,8 +1,8 @@ -import { Fragment, useMemo, useState } from 'react' -import { ChevronDown, ChevronRight, ExternalLink, Users } from 'lucide-react' +import { Fragment, useMemo, useRef, useState } from 'react' +import { ChevronDown, ChevronRight, ExternalLink, Pencil, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' -import { useMonitorNotes } from '@/hooks/useMonitor' +import { useMonitorNotes, useSetCreatorAlias } from '@/hooks/useMonitor' import { formatCount, formatDate, @@ -95,15 +95,40 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { const [expandedNote, setExpandedNote] = useState(null) // 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。 const [collapsed, setCollapsed] = useState>(new Set()) + // 正在改备注的那个博主(creator_hash);null = 没在改。 + const [editingCreator, setEditingCreator] = useState(null) + const [aliasDraft, setAliasDraft] = useState('') + // 按 Escape 是「取消」,而失焦是「保存」—— 取消时得让紧接着那次失焦闭嘴, + // 否则它会把你刚放弃的内容存进去。 + const aliasCancelled = useRef(false) + const setAlias = useSetCreatorAlias() + + const commitAlias = (creatorHash: string) => { + if (aliasCancelled.current) { + aliasCancelled.current = false + setEditingCreator(null) + return + } + setEditingCreator(null) + setAlias.mutate({ creatorHash, alias: aliasDraft }) + } const groups = useMemo(() => { - const byCreator = new Map() + const byCreator = new Map< + string, + { key: string; name: string; alias: string; notes: MonitorNote[] } + >() for (const note of notes ?? []) { // 作品模式下每条作品都会带 creator_hash;真丢了也要有个兜底分组, // 否则那些作品会凭空消失。 const key = note.creator_hash || '__unknown__' if (!byCreator.has(key)) { - byCreator.set(key, { key, name: note.creator_name || '', notes: [] }) + byCreator.set(key, { + key, + name: note.creator_name || '', + alias: note.creator_alias || '', + notes: [], + }) } byCreator.get(key)!.notes.push(note) } @@ -171,13 +196,50 @@ export function NotesTable({ taskId, onlyNew }: NotesTableProps) { )} - - - - {group.name || '未知博主'} + {editingCreator === group.key ? ( + event.stopPropagation()} + onChange={(event) => setAliasDraft(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Enter') commitAlias(group.key) + if (event.key === 'Escape') { + aliasCancelled.current = true + setEditingCreator(null) + } + }} + onBlur={() => commitAlias(group.key)} + className="w-full rounded border border-cyber-neon-cyan/40 bg-cyber-bg-tertiary px-1.5 py-0.5 text-[11px] font-mono text-cyber-text-primary outline-none" + /> + ) : ( + + + + {group.alias || group.name || '未知博主'} + + {/* 起了备注之后,平台昵称降成副标题 —— 它仍是有用的对照。 */} + {group.alias && group.name && ( + {group.name} + )} + + {group.notes.length} 篇 + + - {group.notes.length} 篇 - + )} {METRIC_COLUMNS.map((column) => ( diff --git a/webui/src/hooks/useMonitor.ts b/webui/src/hooks/useMonitor.ts index 7c449ec..2abefd6 100644 --- a/webui/src/hooks/useMonitor.ts +++ b/webui/src/hooks/useMonitor.ts @@ -353,7 +353,24 @@ export function useTestWebhook() { }) } -// --- 上游更新检查 ---------------------------------------------------------- +// --- 博主备注 -------------------------------------------------------------- + +export function useSetCreatorAlias() { + const queryClient = useQueryClient() + const platform = usePlatformParam() + return useMutation({ + mutationFn: ({ creatorHash, alias }: { creatorHash: string; alias: string }) => + monitorApi.setCreatorAlias(creatorHash, alias, platform), + onSuccess: () => { + toast.success('备注已保存') + queryClient.invalidateQueries({ queryKey: ['monitorNotes'] }) + queryClient.invalidateQueries({ queryKey: ['monitorCommentsGrouped'] }) + }, + onError: (error: Error) => toast.error(`备注保存失败:${error.message}`), + }) +} + +// --- 上游更新检查 ----------------------------------------------------------------------------------------------------------------- export function useUpstreamStatus() { return useQuery({ diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index fd8b23b..ed7deaf 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -282,6 +282,14 @@ export const monitorApi = { /** 最近一次上游检查的缓存结果;没有查过时是空对象。 */ getUpstream: () => api.get('/monitor/upstream'), + + /** 给博主起备注(空串 = 清掉)。键是 creator_hash,跨任务同一个博主共用一条。 */ + setCreatorAlias: (creatorHash: string, alias: string, platform?: string) => + api.put( + `/monitor/creators/${encodeURIComponent(creatorHash)}`, + { alias }, + { params: { platform } }, + ), /** * 立刻检查一次。服务端要等 fetch 跑完才回答,而 axios 默认 30 秒对此不够 —— * 一条卡住的 git fetch 能拖到两分钟,这里必须单独放长超时,否则会误报失败。 diff --git a/webui/src/types/monitor.ts b/webui/src/types/monitor.ts index d92ea4b..4c55e16 100644 --- a/webui/src/types/monitor.ts +++ b/webui/src/types/monitor.ts @@ -95,6 +95,13 @@ export interface MonitorNote { */ creator_hash: string creator_name: string + /** + * 人自己给这个博主起的备注。 + * + * 界面上**优先显示它**:`creator_name` 是平台昵称(常常认不出是谁),`creator_hash` + * 更认不出。备注是唯一能把账号对上人的东西。空串表示没起过。 + */ + creator_alias: string /** 作品的**发布**时间(爬虫侧:小红书 time、抖音 create_time)。可能与 * `first_seen_at` 差很远 —— 后者是「我们第一次看到它」的时间。平台没给时为 null。 */ published_at: number | null