用户发来 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:
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
@@ -55,6 +56,15 @@ WECHAT_UA = (
|
||||
|
||||
IMAGE_EXTS = {"jpeg": "jpg", "jpg": "jpg", "png": "png", "gif": "gif", "webp": "webp"}
|
||||
|
||||
# 公众号新版页面(2024 之后)不再把正文放进 `#js_content`,而是把整篇塞进
|
||||
# `window.cgiDataNew` 这个 JS 对象里,由前端渲染。所以还得会读 JS 字符串字面量:
|
||||
# 换行写成 `\x0a`,话题链接是 `\x26lt;a …\x26gt;`(先转义成实体、再转义成 JS)。
|
||||
_WECHAT_FIELD_RE = re.compile(r"(?:content_noencode|desc)\s*:\s*'")
|
||||
_WECHAT_IMAGE_RE = re.compile(
|
||||
r"""(?:cdn_url|img_url|pic_url)\s*:\s*['"]((?:https?:)?//[^'"]*mmbiz\.qpic\.cn[^'"]*)['"]"""
|
||||
)
|
||||
_JS_ESCAPE_RE = re.compile(r"\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|.)", re.S)
|
||||
|
||||
|
||||
class ArticleError(RuntimeError):
|
||||
"""抓取/解析没成功。调用方据此决定降级方式。"""
|
||||
@@ -174,6 +184,10 @@ def _wechat_meta(html: str) -> tuple[str, str, list[tuple[str, str]]]:
|
||||
author = " ".join(p for p in parts if p)
|
||||
if author:
|
||||
break
|
||||
if not author:
|
||||
# 新版页面没有 #js_name,公众号名在 cgiDataNew 的 nick_name 字段里
|
||||
found = re.search(r"nick_name\s*:\s*'([^']{1,80})'", html)
|
||||
author = _clean_text(found.group(1)) if found else ""
|
||||
return title, author, tree
|
||||
|
||||
|
||||
@@ -244,6 +258,115 @@ def _wechat_body(html: str) -> str:
|
||||
return "\n\n".join(s.strip() for s in "\n".join(out).split("\n\n") if s.strip())
|
||||
|
||||
|
||||
def _js_literal(text: str, quote_pos: int) -> str:
|
||||
"""读一个单引号 JS 字符串的原始内容(`quote_pos` 指向那个开引号)。"""
|
||||
out: list[str] = []
|
||||
i = quote_pos + 1
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch == "\\":
|
||||
out.append(text[i : i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if ch == "'":
|
||||
break
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _unescape_js(raw: str) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
seq = match.group(1)
|
||||
if seq[0] in "xu" and len(seq) > 1:
|
||||
return chr(int(seq[1:], 16))
|
||||
return {"n": "\n", "t": "\t", "r": "\r", "'": "'", '"': '"', "\\": "\\"}.get(seq, seq)
|
||||
|
||||
return _JS_ESCAPE_RE.sub(repl, raw)
|
||||
|
||||
|
||||
def _wechat_data_body(html_text: str) -> str:
|
||||
"""新版公众号:从 `window.cgiDataNew` 的 JS 字段里把正文和配图捞出来。
|
||||
|
||||
一篇文章是若干「图片 + 一段说明文字」拼起来的,JS 里是
|
||||
`cdn_url: '…'` 后面紧跟 `desc` / `content_noencode`,所以按出现顺序取文字,
|
||||
再往回找它自己那张图,配图就跟着段落走。
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
seen_text: set[str] = set()
|
||||
used_images: set[str] = set()
|
||||
for match in _WECHAT_FIELD_RE.finditer(html_text):
|
||||
piece = _fragment_to_markdown(_unescape_js(_js_literal(html_text, match.end() - 1)))
|
||||
if len(piece) < 30 or piece in seen_text:
|
||||
continue
|
||||
seen_text.add(piece)
|
||||
image = _nearby_image(html_text, match.start(), used_images)
|
||||
if image and "
|
||||
piece = f"{piece}\n\n"
|
||||
blocks.append(piece)
|
||||
# 图文型文章("小绿书")的图不在文字附近,靠上面的就近配对会漏掉;
|
||||
# 剩下的正文图统一补在末尾,宁可顺序差点也别丢图。
|
||||
leftover: list[str] = []
|
||||
for match in _WECHAT_IMAGE_RE.finditer(html_text):
|
||||
image = match.group(1)
|
||||
image = image if image.startswith("http") else f"https:{image}"
|
||||
if image in used_images:
|
||||
continue
|
||||
used_images.add(image)
|
||||
leftover.append(f"")
|
||||
if len(leftover) >= MAX_IMAGES:
|
||||
break
|
||||
if leftover:
|
||||
blocks.append("\n\n".join(leftover))
|
||||
return "\n\n".join(blocks).strip()
|
||||
|
||||
|
||||
def _fragment_to_markdown(fragment: str) -> str:
|
||||
"""把「实体转义 + 夹着标签」的正文片段变成 markdown 文本。
|
||||
|
||||
公众号把话题标签写成 `<a class="wx_topic_link" …>#调料</a>`,
|
||||
先 unescape 再当 HTML 处理,标签会被去掉、只留文字;`<img>` 转成 markdown。
|
||||
"""
|
||||
from lxml import html as lxml_html
|
||||
|
||||
fragment = html_mod.unescape(fragment or "")
|
||||
fragment = re.sub(
|
||||
r"""<img[^>]*?(?:data-src|src)\s*=\s*["']([^"']+)["'][^>]*>""",
|
||||
lambda m: f"\n\n})\n\n",
|
||||
fragment,
|
||||
flags=re.I,
|
||||
)
|
||||
fragment = re.sub(r"<br\s*/?>", "\n", fragment, flags=re.I)
|
||||
try:
|
||||
tree = lxml_html.fragment_fromstring(fragment, create_parent="div")
|
||||
text = tree.text_content()
|
||||
except Exception: # 片段结构烂到 lxml 都不认,直接剥标签
|
||||
text = re.sub(r"<[^>]+>", "", fragment)
|
||||
text = text.replace("\u00a0", " ")
|
||||
lines = [re.sub(r"[ \t\u3000]+", " ", line).strip() for line in text.splitlines()]
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
if not line and (not out or not out[-1]):
|
||||
continue
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def _nearby_image(html_text: str, pos: int, used: set[str]) -> str | None:
|
||||
"""这条文字对应的配图:先往后找(新版页面图片字段排在文字之后),再往回找。"""
|
||||
for start, end in ((pos, pos + 2500), (max(0, pos - 2500), pos)):
|
||||
found = [
|
||||
m.group(1)
|
||||
for m in _WECHAT_IMAGE_RE.finditer(html_text, start, min(end, len(html_text)))
|
||||
if m.group(1) not in used
|
||||
]
|
||||
if found:
|
||||
image = found[0]
|
||||
return image if image.startswith("http") else f"https:{image}"
|
||||
return None
|
||||
|
||||
|
||||
def _block_markdown(html: str) -> str:
|
||||
"""兜底的正文提取:找出"装着最多段文字"的那个容器,再按段落拼成 markdown。
|
||||
|
||||
@@ -371,7 +494,7 @@ def parse_article(html: str, url: str) -> Article:
|
||||
raise ArticleError("微信把这篇拦下来了(需要人工验证或链接已失效)")
|
||||
if is_wechat_article(url):
|
||||
title, author, _tree = _wechat_meta(html)
|
||||
body = _wechat_body(html)
|
||||
body = _wechat_body(html) or _wechat_data_body(html)
|
||||
if not body.strip():
|
||||
# 公众号偶尔会换结构,退回通用抽取,宁可少图也别丢正文
|
||||
article = _generic_article(html, url)
|
||||
|
||||
Reference in New Issue
Block a user