"""微信里发链接 → 抓成知识。 不联网:`fetch_article` 与 `store_images` 用 monkeypatch 顶掉,验的是解析规则与落库; 真抓取(trafilatura / 公众号 DOM)在 `parse_article` 的用例里用固定 HTML 覆盖。 """ from __future__ import annotations import uuid from typing import Any import pytest from httpx import AsyncClient from sqlalchemy import select from app.db import SessionFactory, bind_tenant from app.models_integrations import BotChannel from app.models_knowledge import KnowledgeDoc from app.models_task import Item from app.services import article as article_service from app.services.wechat_intake import handle_text from .conftest import create_workspace, login WECHAT_HTML = """

回滚前先停灰度:一次线上事故的复盘

达道技术

事故发生在周五晚高峰,灰度流量上涨后错误率飙升。

第一步先停灰度,把流量打回稳定版本。

第二步核对回滚后的核心指标。

扫码关注
""" BLOCKED_HTML = """

环境异常

完成验证后即可继续访问

""" # 新版公众号页面:没有 #js_content,正文全在 window.cgiDataNew 里; # 换行是 \x0a,话题标签是双重转义过的 HTML 片段,图片字段排在文字前面。 # 正文抽成常量:desc 与 content_noencode 同文(真实页面也是这样,后者更权威), # 写成一行会超行宽,所以拆开拼。 _WECHAT_DATA_BODY = ( r"食材: 蒜瓣500g、小米辣60g\x0a\x0a步骤:\x0a1\u20e3蒜瓣打碎," r"放在冷水里泡10分钟(泡一下蒜不会发苦)\x0a2\u20e3油烧热,转小火把蒜泥炒香" r"\x0a\x0a注意\x26lt;a class=\x26quot;wx_topic_link\x26quot;\x26gt;" r"#调料\x26lt;/a\x26gt;" ) WECHAT_DATA_HTML = ( "\n" '\n' "\n\n\n" ) def test_extract_url_only_when_message_is_a_link() -> None: assert article_service.extract_url("https://mp.weixin.qq.com/s/abc") == ( "https://mp.weixin.qq.com/s/abc" ) # 前后有点标点也算,手机上复制链接常带标点 assert article_service.extract_url(" https://a.com/x 。") == "https://a.com/x" # 带了别的内容就不是"发链接",它是想记条任务 assert article_service.extract_url("看下 https://a.com/x 这个方案") is None assert article_service.extract_url("明天下午三点开会") is None def test_wechat_article_parses_title_body_and_images() -> None: got = article_service.parse_article(WECHAT_HTML, "https://mp.weixin.qq.com/s/abc") assert got.title == "回滚前先停灰度:一次线上事故的复盘" assert got.author == "达道技术" assert got.site == "微信公众号" assert "灰度流量上涨后错误率飙升" in got.body_md # 图片取 data-src,不能丢 assert "https://mmbiz.qpic.cn/mmbiz_png/abc/640?wx_fmt=png" in got.body_md # 正文不能重复输出(嵌套 section 是最容易重复的地方) assert got.body_md.count("第一步先停灰度") == 1 assert got.body_md.count("第二步核对回滚后的核心指标") == 1 # 页面底部二维码之类的噪声不该进来 assert "扫码关注" not in got.body_md def test_new_wechat_page_reads_js_data() -> None: """新版页面没有 #js_content:正文在 cgiDataNew 的 JS 字符串里,得能读出来。""" got = article_service.parse_article(WECHAT_DATA_HTML, "https://mp.weixin.qq.com/s/abc") assert got.title == "自制万能蒜蓉酱" assert got.author == "破冰记的随笔" assert "蒜瓣打碎" in got.body_md # \x0a 要还原成换行,否则整篇挤成一行 assert "\n" in got.body_md # 双重转义的话题标签还原成文字,不能留下 <a 这种碎片 assert "#调料" in got.body_md assert "<" not in got.body_md # 配图要跟着正文进来(正文里换成本地地址之前,先能取到这张图) assert "mmbiz.qpic.cn/mmbiz_jpg/xyz/640" in got.body_md # desc 与 content_noencode 是同一段文字,不能重复两遍 assert got.body_md.count("蒜瓣打碎") == 1 def test_blocked_wechat_page_is_reported() -> None: with pytest.raises(article_service.ArticleError): article_service.parse_article(BLOCKED_HTML, "https://mp.weixin.qq.com/s/abc") def test_generic_page_gets_chrome_trimmed() -> None: html = """

怎么把知识管起来

先把重复回答的问题记下来,这一步最便宜,也最容易坚持。

第二步才是建目录,目录只服务于找得到,不要为了好看去分二十个类。

""" got = article_service.parse_article(html, "https://example.com/post") assert "先把重复回答的问题记下来" in got.body_md assert "Sign In" not in got.body_md def test_compose_body_keeps_source_line() -> None: got = article_service.Article( url="https://mp.weixin.qq.com/s/abc", title="标题", body_md="正文", site="微信公众号", author="达道技术", ) body = article_service.compose_body(got) assert body.startswith("> 原文:") assert "微信公众号 · 达道技术" in body assert body.endswith("正文") async def test_private_address_is_refused() -> None: """这个入口谁都能触发,不能当 SSRF 跳板。""" for url in ("http://127.0.0.1:8200/version", "http://192.168.8.188:8200/api"): with pytest.raises(article_service.ArticleError): await article_service.fetch_article(url) async def test_link_message_becomes_knowledge( client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: user = await login(client) ws = await create_workspace(client, user, "收链接") async def fake_fetch(url: str) -> article_service.Article: return article_service.Article( url=url, title="回滚前先停灰度", body_md="先停流量,再退镜像版本。", site="微信公众号", author="达道技术", ) async def fake_store(session: Any, **kwargs: Any) -> tuple[str, list[Any]]: return kwargs["body_md"], [] monkeypatch.setattr(article_service, "fetch_article", fake_fetch) monkeypatch.setattr(article_service, "store_images", fake_store) ws_id = uuid.UUID(ws["id"]) async with SessionFactory() as session: await bind_tenant(session, ws_id, uuid.UUID(user["user_id"])) channel = BotChannel( workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active", created_by=uuid.UUID(user["user_id"]), ) session.add(channel) await session.flush() result = await handle_text( session, channel, "https://mp.weixin.qq.com/s/abc", base_url="http://site", ) await session.commit() assert result["action"] == "knowledge" assert result["reply"] == "已存成知识草稿:回滚前先停灰度" async with SessionFactory() as session: await bind_tenant(session, ws_id, uuid.UUID(user["user_id"])) item = await session.get(Item, result["item_id"]) assert item is not None assert item.kind == "knowledge" assert item.status == "draft" assert "> 原文:" in (item.body_md or "") doc = ( await session.execute(select(KnowledgeDoc).where(KnowledgeDoc.item_id == item.id)) ).scalar_one() assert doc.status_hint == "draft" async def test_link_failure_is_reported_not_silent( client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: user = await login(client) ws = await create_workspace(client, user, "收链接") async def fake_fetch(url: str) -> article_service.Article: # noqa: ARG001 raise article_service.ArticleError("对方站点返回 403") monkeypatch.setattr(article_service, "fetch_article", fake_fetch) ws_id = uuid.UUID(ws["id"]) async with SessionFactory() as session: await bind_tenant(session, ws_id, uuid.UUID(user["user_id"])) channel = BotChannel( workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active", created_by=uuid.UUID(user["user_id"]), ) session.add(channel) await session.flush() result = await handle_text( session, channel, "https://example.com/a", base_url="http://site" ) await session.commit() assert result["action"] == "ignored" assert "没抓下来" in result["reply"] assert "任务:" in result["reply"] async def test_images_are_copied_into_our_storage( client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """文章里的图要落到我们自己的存储里:公众号一改图,正文就是一排红叉。""" user = await login(client) ws = await create_workspace(client, user, "存图") ws_id = uuid.UUID(ws["id"]) async def fake_download(client: Any, url: str) -> tuple[bytes, str]: # noqa: ARG001 return b"\x89PNG\r\n\x1a\nfake", "image/png" monkeypatch.setattr(article_service, "_download_image", fake_download) body = "看图\n\n![](https://mmbiz.qpic.cn/mmbiz_png/abc/640?wx_fmt=png)" async with SessionFactory() as session: await bind_tenant(session, ws_id, uuid.UUID(user["user_id"])) item = Item( workspace_id=ws_id, kind="knowledge", title="带图的一篇", body_md=body, status="draft", creator_id=uuid.UUID(user["user_id"]), ) session.add(item) await session.flush() rewritten, rows = await article_service.store_images( session, ws_id=ws_id, item_id=item.id, uploader_id=uuid.UUID(user["user_id"]), body_md=body, ) await session.commit() assert len(rows) == 1 assert rows[0].filename.startswith("图1.") assert "attachment" in rewritten and "/raw?" in rewritten assert "mmbiz.qpic.cn" not in rewritten