feat(monitor): 给博主起备注 —— 作品栏里才认得出「这是谁」
作品栏按 creator_hash 把作品归到博主的组里,可那是个哈希;creator_name 是平台昵称,
粉丝少的号常常认不出。两样都没法把账号对上人。
* 新表 monitor_creator_alias,键取 **(platform, creator_hash)** 而不是按任务:
哈希对同一个 uid 是稳定的,所以同一个博主出现在多个任务里时,备注只填一次。
* list_notes 带上 creator_alias(整体查一次再在内存里取,不是每条作品查一次)。
* `PUT /monitor/creators/{creator_hash}`,空串即清掉那条备注。
* 作品栏的博主组头:**优先显示备注**,起过备注之后平台昵称降成副标题(它仍是有用的
对照);组头上一个铅笔按钮就地编辑,回车保存、失焦保存、Esc 取消。
Esc 那条要单独处理:取消之后紧接着的失焦会把刚放弃的内容存进去,所以用一个标记让那次
失焦闭嘴。
测试 +6:没起过时是空串、起了会跟着作品返回、**跨任务共用一条**、**不串到别的平台**、
空串清掉、前后空格会被去掉。
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 地址,留空表示停用")
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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<string | null>(null)
|
||||
// 折叠状态按博主记。默认全展开 —— 藏起来的数据比多滚两屏更糟。
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
// 正在改备注的那个博主(creator_hash);null = 没在改。
|
||||
const [editingCreator, setEditingCreator] = useState<string | null>(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<string, { key: string; name: string; notes: MonitorNote[] }>()
|
||||
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) {
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
{editingCreator === group.key ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={aliasDraft}
|
||||
placeholder="备注,比如「竞品A」"
|
||||
onClick={(event) => 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"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-3 h-3 text-cyber-neon-cyan" />
|
||||
<span className="text-cyber-text-primary">
|
||||
{group.name || '未知博主'}
|
||||
<Users className="w-3 h-3 text-cyber-neon-cyan flex-shrink-0" />
|
||||
<span className="text-cyber-text-primary truncate">
|
||||
{group.alias || group.name || '未知博主'}
|
||||
</span>
|
||||
<span className="text-cyber-text-muted">{group.notes.length} 篇</span>
|
||||
{/* 起了备注之后,平台昵称降成副标题 —— 它仍是有用的对照。 */}
|
||||
{group.alias && group.name && (
|
||||
<span className="text-cyber-text-muted truncate">{group.name}</span>
|
||||
)}
|
||||
<span className="text-cyber-text-muted flex-shrink-0">
|
||||
{group.notes.length} 篇
|
||||
</span>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setAliasDraft(group.alias)
|
||||
setEditingCreator(group.key)
|
||||
}}
|
||||
title="给这个博主起个备注"
|
||||
className="text-cyber-text-muted hover:text-cyber-neon-cyan flex-shrink-0"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{METRIC_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="py-1.5 px-2">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -282,6 +282,14 @@ export const monitorApi = {
|
||||
|
||||
/** 最近一次上游检查的缓存结果;没有查过时是空对象。 */
|
||||
getUpstream: () => api.get<UpstreamStatus>('/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 能拖到两分钟,这里必须单独放长超时,否则会误报失败。
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user