fix(wechat): 公众号新版页面抓不到正文,改从 cgiDataNew 里读
CI / backend (push) Canceled after 0s

用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符
("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进
window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content";
三种 UA 拿到的字节数完全一样,也不是 UA 的问题。

- 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义,
  话题标签是双重转义过的 HTML,先 unescape 再走 DOM。
- 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按
  12 张上限补在末尾(图文型文章靠就近配对会漏)。
- 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。

实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。

同时补上用户提的两件:

- 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片,
  只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。
  删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。
- 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它",
  侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
This commit is contained in:
Steven
2026-11-06 02:08:36 +08:00
parent 626f83fb9c
commit 690779c5fb
10 changed files with 720 additions and 15 deletions
+89 -5
View File
@@ -22,7 +22,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import Select, and_, func, or_, select, update
from sqlalchemy import Select, and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
@@ -33,6 +33,7 @@ from ..models_knowledge import (
DOC_TYPES,
KNOWLEDGE_STATUSES,
MAX_CATEGORY_DEPTH,
Chunk,
KnowledgeCategory,
KnowledgeDoc,
)
@@ -96,11 +97,23 @@ class StatusActionIn(BaseModel):
# ── 内部工具 ────────────────────────────────────────────────────────────
async def _load_doc(
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, *, include_deleted: bool = False
) -> tuple[Item, KnowledgeDoc]:
"""取一条知识。
删除是软删(`deleted_at`),所以默认把已删除的当成"不存在":
否则删完还能从收藏的链接、浏览器历史里点开,用户会以为删除没生效。
只有删除接口自己需要 `include_deleted=True`,好让重复删回一句"已经删过了"。
"""
item = await session.get(Item, item_id)
doc = await session.get(KnowledgeDoc, item_id)
if item is None or doc is None or item.workspace_id != ws_id or item.kind != "knowledge":
if (
item is None
or doc is None
or item.workspace_id != ws_id
or item.kind != "knowledge"
or (item.deleted_at is not None and not include_deleted)
):
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
return item, doc
@@ -595,8 +608,39 @@ async def delete_category(
return ok({"deleted": str(category_id), "uncategorized_docs": docs})
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
# 否则 "search" 会被当成 uuid 解析,返回 422。
# 注意路由顺序:`/knowledge/search` 与 `/knowledge/tags` 必须排在
# `/knowledge/{item_id}` 之前,否则会被当成 uuid 解析,返回 422。
@router.get("/knowledge/tags", summary="知识用到的标签(带条数)")
async def list_knowledge_tags(
ctx: MemberDep,
session: SessionDep,
status: Annotated[list[str] | None, Query()] = None,
) -> dict:
"""只统计"还在用"的标签,给右侧那串标签用,点一下按标签筛。
条数是**知识条数**,不是引用次数——同一个标签在一条知识里挂两次也只算一条。
"""
stmt: Select = (
select(Tag.name, func.count(func.distinct(Item.id)).label("docs"))
.join(ItemTag, ItemTag.tag_id == Tag.id)
.join(Item, Item.id == ItemTag.item_id)
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
.where(
Item.workspace_id == ctx.ws_id,
Item.kind == "knowledge",
Item.deleted_at.is_(None),
Tag.workspace_id == ctx.ws_id,
)
.group_by(Tag.name)
.order_by(func.count(func.distinct(Item.id)).desc(), Tag.name)
)
wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES]
if wanted:
stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted))
rows = (await session.execute(stmt)).all()
return ok({"items": [{"name": name, "count": count} for name, count in rows]})
@router.get("/knowledge/search", summary="检索已发布知识")
async def search(
ctx: MemberDep,
@@ -756,6 +800,46 @@ async def patch_knowledge(
return ok(await _knowledge_out(session, ws_id, item, doc))
@router.delete("/knowledge/{item_id}", summary="删除知识(软删,连带删掉检索切片)")
async def delete_knowledge(
item_id: uuid.UUID,
ctx: MemberDep,
session: SessionDep,
confirm: Annotated[bool, Query(description="必须显式带 true,防手滑")] = False,
) -> dict:
"""删除是软删:条目留在库里(活动记录、来源关系都还在),只是不再出现在列表与检索里。
切片必须真删:检索只查 `chunk` 表,留着的话"已经删掉的知识"还能被搜出来,
那比删不掉更吓人。
"""
ws_id = ctx.ws_id
if not confirm:
raise AppError(ErrorCode.VALIDATION_FAILED, "删除不能撤销,带 confirm=true 再来一次")
item, doc = await _load_doc(session, ws_id, item_id, include_deleted=True)
if item.deleted_at is not None:
return ok({"id": str(item.id)}, message="已经删过了")
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有作者本人或管理员能删这条知识")
item.deleted_at = datetime.now(timezone.utc)
item.version = (item.version or 1) + 1
await session.execute(
delete(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id == item.id)
)
doc.chunk_count = 0
await session.flush()
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.deleted",
target_type="item",
target_id=str(item.id),
payload={"title": item.title, "status": doc.status_hint},
)
return ok({"id": str(item.id)}, message="已删除")
# ── 状态动作 ────────────────────────────────────────────────────────────
@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复")
async def change_status(