feat(monitor): 作品标签 —— 词表在设置里维护,作品上贴
和「作品备注」刻意并存,不是一回事:备注是一句话的自由文字,标签是**从一份固定
词表里选的分类**。分类必须封闭,否则「重点 / 重要 / 优先」各写各的,筛就没法用了。
* `monitor_tag`:词表,全局一套(不按平台分)—— 「重点」是给人自己用的心智,不该在
小红书和抖音各定义一遍。颜色存的是**调色板里的名字**,不是色值:Tailwind 的类名是
静态提取的,拼出来的 `text-${color}` 它看不见,线上会静默变无色。
* `monitor_note_tag`:作品↔标签,**多对多**。一条作品可以既是重点又是竞品;只能贴一个
的话人就会跑去备注里写自由文字,这份词表就白建了。全部替换式提交,不是逐个增删。
* 删除标签时**显式删关联**,不靠外键级联 —— 测试跑 SQLite,它默认不开外键约束。
* 组头上的筛选做成平铺开关片,多选是「或」。
顺带:/monitor/notes 现在支持按 tag_id 筛。
This commit is contained in:
@@ -405,6 +405,50 @@ class MonitorNoteAlias(MonitorBase):
|
||||
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class MonitorTag(MonitorBase):
|
||||
"""作品标签的**词表** —— 在设置里维护,贴标签时从这里选。
|
||||
|
||||
刻意做成一张表而不是一个设置项:设置里那个注册表存的是标量(一个数、一段文字),
|
||||
而这是一组能增能删能改名的条目。
|
||||
|
||||
**全局一套,不按平台分。** 「重点」「竞品」这种分类是给人自己用的心智,不该在小红书
|
||||
和抖音各定义一遍;而且同一个词在两个平台上含义不同的话,反而不好比。
|
||||
"""
|
||||
|
||||
__tablename__ = "monitor_tag"
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_tag_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 一个**调色板里的名字**(cyan/pink/green/…),不是颜色值。Tailwind 的类名是静态
|
||||
# 提取的,拼不出 `text-${color}` 那种动态类,所以这里存的是查表用的键。
|
||||
color: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class MonitorNoteTag(MonitorBase):
|
||||
"""作品 ↔ 标签。
|
||||
|
||||
键取 ``(platform, note_id)``,和作品备注同一个口径:作品 id 本身带平台语义,但显式
|
||||
带上 platform 才能和别处用同一套查询形状。
|
||||
|
||||
**多对多**:一条作品可以既是「重点」又是「竞品」—— 标签本来就是可以叠加的分类,
|
||||
一个不够用的话人就会去备注里写自由文字,那这个功能就白做了。
|
||||
"""
|
||||
|
||||
__tablename__ = "monitor_note_tag"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("platform", "note_id", "tag_id", name="uq_note_tag"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
note_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
tag_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
created_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class MonitorSetting(MonitorBase):
|
||||
"""Key/value store. Holds the XHS cookie for unattended runs."""
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ from .models import (
|
||||
MonitorNote,
|
||||
MonitorNoteAlias,
|
||||
MonitorNoteMetric,
|
||||
MonitorNoteTag,
|
||||
MonitorTag,
|
||||
MonitorRun,
|
||||
MonitorTarget,
|
||||
MonitorTask,
|
||||
@@ -429,6 +431,184 @@ async def set_note_alias(
|
||||
existing.updated_at = get_current_timestamp()
|
||||
|
||||
|
||||
# 标签能用的颜色。**必须是写死的字面量** —— Tailwind 靠静态扫描找类名,
|
||||
# `text-cyber-neon-${color}` 这种拼出来的类它看不见,线上会全变成无色。
|
||||
TAG_COLORS: Dict[str, str] = {
|
||||
"cyan": "border-cyber-neon-cyan/40 text-cyber-neon-cyan",
|
||||
"pink": "border-cyber-neon-pink/40 text-cyber-neon-pink",
|
||||
"green": "border-cyber-neon-green/40 text-cyber-neon-green",
|
||||
"orange": "border-cyber-neon-orange/40 text-cyber-neon-orange",
|
||||
"muted": "border-cyber-border-DEFAULT text-cyber-text-secondary",
|
||||
}
|
||||
DEFAULT_TAG_COLOR = "cyan"
|
||||
|
||||
|
||||
async def list_tags(session: AsyncSession) -> List[Dict[str, Any]]:
|
||||
"""标签词表,带上各自被用在多少条作品上。
|
||||
|
||||
带上计数是为了**删之前能看清代价** —— 「这个标签用在 47 条作品上」,和「0 条」,
|
||||
点删除时是完全不同的两个决定。
|
||||
"""
|
||||
counts = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(MonitorNoteTag.tag_id, func.count()).group_by(MonitorNoteTag.tag_id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(MonitorTag).order_by(MonitorTag.sort_order, MonitorTag.id)
|
||||
)
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"color": row.color or DEFAULT_TAG_COLOR,
|
||||
"note_count": counts.get(row.id, 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def create_tag(session: AsyncSession, name: str, color: str = "") -> MonitorTag:
|
||||
"""新建标签。重名直接拒绝 —— 两个「重点」在筛选列表里是灾难。"""
|
||||
name = (name or "").strip()[:32]
|
||||
if not name:
|
||||
raise ValueError("标签名不能为空")
|
||||
|
||||
existing = await session.scalar(select(MonitorTag).where(MonitorTag.name == name))
|
||||
if existing is not None:
|
||||
raise ValueError(f"标签「{name}」已经存在")
|
||||
|
||||
# 排在最后。用 max+1 而不是 count:删过标签之后 count 会撞上已有的 sort_order。
|
||||
last = await session.scalar(select(func.max(MonitorTag.sort_order)))
|
||||
tag = MonitorTag(
|
||||
name=name,
|
||||
color=color if color in TAG_COLORS else DEFAULT_TAG_COLOR,
|
||||
sort_order=(last or 0) + 1,
|
||||
created_at=get_current_timestamp(),
|
||||
)
|
||||
session.add(tag)
|
||||
await session.flush()
|
||||
return tag
|
||||
|
||||
|
||||
async def update_tag(
|
||||
session: AsyncSession, tag_id: int, name: str, color: str = ""
|
||||
) -> MonitorTag:
|
||||
tag = await session.get(MonitorTag, tag_id)
|
||||
if tag is None:
|
||||
raise ValueError(f"标签 {tag_id} 不存在")
|
||||
|
||||
name = (name or "").strip()[:32]
|
||||
if not name:
|
||||
raise ValueError("标签名不能为空")
|
||||
|
||||
clash = await session.scalar(
|
||||
select(MonitorTag).where(MonitorTag.name == name, MonitorTag.id != tag_id)
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValueError(f"标签「{name}」已经存在")
|
||||
|
||||
tag.name = name
|
||||
tag.color = color if color in TAG_COLORS else DEFAULT_TAG_COLOR
|
||||
await session.flush()
|
||||
return tag
|
||||
|
||||
|
||||
async def delete_tag(session: AsyncSession, tag_id: int) -> int:
|
||||
"""删标签,连同它在所有作品上的贴纸(返回清掉了多少条)。
|
||||
|
||||
**显式删关联,不靠外键级联**:测试跑的是 SQLite,而它默认不启用外键约束 ——
|
||||
依赖级联的话,测试全绿、线上才对,或者反过来。
|
||||
"""
|
||||
tag = await session.get(MonitorTag, tag_id)
|
||||
if tag is None:
|
||||
raise ValueError(f"标签 {tag_id} 不存在")
|
||||
|
||||
removed = await session.execute(
|
||||
delete(MonitorNoteTag).where(MonitorNoteTag.tag_id == tag_id)
|
||||
)
|
||||
await session.delete(tag)
|
||||
return removed.rowcount or 0
|
||||
|
||||
|
||||
async def _note_tag_map(session: AsyncSession) -> Dict[tuple, List[Dict[str, Any]]]:
|
||||
"""``(platform, note_id) -> [标签]``。整体读一次,贴纸本来就不多。"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(MonitorNoteTag.platform, MonitorNoteTag.note_id, MonitorTag)
|
||||
.join(MonitorTag, MonitorTag.id == MonitorNoteTag.tag_id)
|
||||
.order_by(MonitorTag.sort_order, MonitorTag.id)
|
||||
)
|
||||
).all()
|
||||
|
||||
result: Dict[tuple, List[Dict[str, Any]]] = {}
|
||||
for platform, note_id, tag in rows:
|
||||
result.setdefault((platform, note_id), []).append(
|
||||
{"id": tag.id, "name": tag.name, "color": tag.color or DEFAULT_TAG_COLOR}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def set_note_tags(
|
||||
session: AsyncSession, platform: str, note_id: str, tag_ids: Sequence[int]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""把一条作品的标签**整体替换**成这一组。
|
||||
|
||||
整体替换而不是增删单条:界面给的是「这条作品现在的标签」,一个复选框对话框点完
|
||||
保存就是一次全量提交,比对齐两边的差集简单也不容易错。
|
||||
"""
|
||||
wanted = {int(tag_id) for tag_id in tag_ids}
|
||||
|
||||
# 只认词表里真有的 id —— 前端传了脏 id 就静静忽略,别在关联表里留下孤儿。
|
||||
known = set(
|
||||
(await session.scalars(select(MonitorTag.id).where(MonitorTag.id.in_(wanted)))).all()
|
||||
if wanted
|
||||
else []
|
||||
)
|
||||
wanted &= known
|
||||
|
||||
existing = set(
|
||||
(
|
||||
await session.scalars(
|
||||
select(MonitorNoteTag.tag_id).where(
|
||||
MonitorNoteTag.platform == platform,
|
||||
MonitorNoteTag.note_id == note_id,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
for tag_id in existing - wanted:
|
||||
await session.execute(
|
||||
delete(MonitorNoteTag).where(
|
||||
MonitorNoteTag.platform == platform,
|
||||
MonitorNoteTag.note_id == note_id,
|
||||
MonitorNoteTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
|
||||
now = get_current_timestamp()
|
||||
for tag_id in wanted - existing:
|
||||
session.add(
|
||||
MonitorNoteTag(
|
||||
platform=platform,
|
||||
note_id=note_id,
|
||||
tag_id=tag_id,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
tags = await _note_tag_map(session)
|
||||
return tags.get((platform, note_id), [])
|
||||
|
||||
|
||||
async def _latest_creator_stats(
|
||||
session: AsyncSession,
|
||||
task_ids: Optional[Sequence[int]] = None,
|
||||
@@ -517,6 +697,7 @@ async def list_notes(
|
||||
platform: Optional[str] = None,
|
||||
*,
|
||||
windowed: bool = False,
|
||||
tag_ids: Optional[Sequence[int]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Tracked notes with their latest metrics and change vs the previous run.
|
||||
|
||||
@@ -537,6 +718,17 @@ async def list_notes(
|
||||
return []
|
||||
query = query.where(MonitorNote.task_id.in_(scoped))
|
||||
|
||||
if tag_ids:
|
||||
# 命中**任意一个**选中的标签。作品 id 在平台内唯一,而这里的 query 本来就已经
|
||||
# 被任务/平台圈定了,所以只按 note_id 关联就够。
|
||||
query = query.where(
|
||||
MonitorNote.note_id.in_(
|
||||
select(MonitorNoteTag.note_id).where(
|
||||
MonitorNoteTag.tag_id.in_([int(t) for t in tag_ids])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
notes = list((await session.scalars(query)).all())
|
||||
if not notes:
|
||||
return []
|
||||
@@ -581,6 +773,7 @@ async def list_notes(
|
||||
}
|
||||
aliases = await _creator_alias_map(session)
|
||||
note_aliases = await _note_alias_map(session)
|
||||
note_tags = await _note_tag_map(session)
|
||||
# 账号级指标(粉丝 / 总获赞 / 作品数)。**挂在作品上一起返回**,因为界面上就是按博主
|
||||
# 归组显示的 —— 让前端为了一个组头再发一轮请求没道理。同一个博主的所有作品拿到的是
|
||||
# 同一条(键里带 task_id,所以跨任务不会串)。没有的(小红书那条路不产生它)就是 null,
|
||||
@@ -629,6 +822,9 @@ async def list_notes(
|
||||
"note_alias": note_aliases.get(
|
||||
(task_platform.get(note.task_id, ""), note.note_id), ""
|
||||
),
|
||||
# 贴在这条作品上的标签。备注是自由文字(一句话说明),标签是从设置里那份
|
||||
# 词表选的分类 —— 两者互补,不是一回事。
|
||||
"tags": note_tags.get((task_platform.get(note.task_id, ""), note.note_id), []),
|
||||
# 博主账号级指标 —— 作品列表给不了的东西。三个值都可能为 null(平台没采
|
||||
# 到、或者这条作品来自不产生它的数据源),前端据此整块不画。
|
||||
"creator_fans": stat.fans if stat else None,
|
||||
|
||||
+60
-1
@@ -40,6 +40,8 @@ from ..schemas.monitor import (
|
||||
CookiePayload,
|
||||
CreatorAliasPayload,
|
||||
NoteAliasPayload,
|
||||
NoteTagsPayload,
|
||||
TagPayload,
|
||||
MonitorTaskCreate,
|
||||
MonitorTaskUpdate,
|
||||
WebhookPayload,
|
||||
@@ -130,13 +132,16 @@ async def list_notes(
|
||||
only_new: bool = False,
|
||||
limit: int = Query(default=200, ge=1, le=2000),
|
||||
platform: Optional[str] = None,
|
||||
tag_id: Optional[List[int]] = Query(
|
||||
default=None, description="可重复传,命中其中任意一个标签即算"
|
||||
),
|
||||
):
|
||||
async with get_session() as session:
|
||||
return {
|
||||
# windowed=True:作品栏是「正在盯的窗口」,每个博主只画最新 max_notes_count 条。
|
||||
# 库里的全量还在,趋势图/报表/导出读的都是它。
|
||||
"notes": await service.list_notes(
|
||||
session, task_id, only_new, limit, platform, windowed=True
|
||||
session, task_id, only_new, limit, platform, windowed=True, tag_ids=tag_id
|
||||
),
|
||||
# 博主**单独给一份**,而不是让前端从作品里推。作品推不出「一条作品都没有的
|
||||
# 博主」—— 那正是最该显示的一类(还在涨粉,只是最近没发)。
|
||||
@@ -299,6 +304,22 @@ async def set_note_alias_endpoint(
|
||||
return {"note_id": note_id, "alias": payload.alias.strip()}
|
||||
|
||||
|
||||
@router.put("/notes/{note_id}/tags")
|
||||
async def set_note_tags_endpoint(
|
||||
note_id: str,
|
||||
payload: NoteTagsPayload,
|
||||
platform: str = Query(default=PLATFORM_XHS),
|
||||
):
|
||||
"""把一条作品的标签**整体替换**成这一组(空列表 = 全摘掉)。
|
||||
|
||||
传的是「这条作品现在该有哪些标签」,而不是「加上/去掉哪一个」—— 界面上是一个
|
||||
复选框对话框,点保存就是一次全量提交。
|
||||
"""
|
||||
async with get_session() as session:
|
||||
tags = await service.set_note_tags(session, platform, note_id, payload.tag_ids)
|
||||
return {"note_id": note_id, "tags": tags}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR login
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -416,6 +437,44 @@ async def _resolve_scope(
|
||||
return [task for task in task_ids if task in platform_ids]
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def list_tags():
|
||||
"""标签词表 + 各自被用在多少条作品上(删之前能看清代价)。"""
|
||||
async with get_session() as session:
|
||||
return {"tags": await service.list_tags(session)}
|
||||
|
||||
|
||||
@router.post("/tags")
|
||||
async def create_tag(payload: TagPayload):
|
||||
async with get_session() as session:
|
||||
try:
|
||||
tag = await service.create_tag(session, payload.name, payload.color)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "note_count": 0}
|
||||
|
||||
|
||||
@router.put("/tags/{tag_id}")
|
||||
async def update_tag(tag_id: int, payload: TagPayload):
|
||||
async with get_session() as session:
|
||||
try:
|
||||
tag = await service.update_tag(session, tag_id, payload.name, payload.color)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"id": tag.id, "name": tag.name, "color": tag.color}
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
async def delete_tag(tag_id: int):
|
||||
"""删标签,连同它在所有作品上的贴纸。"""
|
||||
async with get_session() as session:
|
||||
try:
|
||||
removed = await service.delete_tag(session, tag_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_data(
|
||||
kind: str = Query(..., description="notes | comments | report"),
|
||||
|
||||
@@ -120,6 +120,19 @@ class NoteAliasPayload(BaseModel):
|
||||
alias: str = Field(default="", max_length=128)
|
||||
|
||||
|
||||
class TagPayload(BaseModel):
|
||||
"""新建/改名一个标签。`color` 是调色板里的名字,不认识的会被回落到默认色。"""
|
||||
|
||||
name: str = Field(min_length=1, max_length=32)
|
||||
color: str = Field(default="", max_length=16)
|
||||
|
||||
|
||||
class NoteTagsPayload(BaseModel):
|
||||
"""把一条作品的标签整体替换成这一组。空列表 = 全摘掉。"""
|
||||
|
||||
tag_ids: List[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
url: str = Field(default="", description="企业微信机器人 Webhook 地址,留空表示停用")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user