先把结论摆在这儿:Claude Code、Codex 这类跑在终端里的编码助手,处理本地代码早就是熟手了,但一碰到微信公众号文章就抓瞎——链接贴进去,多半给你一句"无法访问该页面"或者干脆读出一堆残缺的样式标签。这事卡了很久,直到最近我把抓取、清洗、转换这一整套流程封装成一个能被调用的工具挂上去,才算真正打通。接下来写的东西,就是我在反复折腾里踩出来的完整路径。
这套东西解决的核心问题其实就一句话:把公众号页面里那坨被内联样式裹得严严实实的 HTML,变成模型能直接消化、结构清晰的 Markdown,再通过工具调用的方式交给 AI 编码助手。写完之后,你在 Claude Code 里丢一句"帮我读一下这篇文章,把里面的实现思路整理成任务清单",它能真的读进去、理解、然后落成代码。适合谁看?写过一点脚本、会用命令行、想让 AI 助手把技术文章当参考资料用起来的人。完全没有编程基础也能看懂设计思路,代码部分照着改改就能跑。
1. 需求背后到底卡在哪:先把痛点掰开
1.1 公众号是个"内容孤岛",AI 工具天生够不着
先说清楚为什么会出现"读不了"这件事。微信公众号的文章页面不是普通的静态网页,它把正文、图片、样式全部塞在一个高度嵌套的 DOM 结构里,几乎所有视觉呈现都靠内联style属性硬写,而不是用外部 CSS 类。这种设计的初衷是让页面在客户端里渲染效果一致,但对自动化工具极不友好。
更麻烦的是访问层。直接发一个裸的 HTTP 请求过去,服务器会看你的User-Agent、Referer、Accept这些请求头,稍微不像正常浏览器访问,就可能返回"环境异常,请稍后在微信客户端打开"这类提示页。而且页面里的图片资源有防盗链校验,Referer不对直接 403。你就算把 HTML 抓下来了,图片全是裂的。
AI 编码助手的工作方式决定了它不可能自己去点浏览器。它的能力边界就在文件系统和它被授权的工具上。你给它一个 URL,它顶多调用一次普通的网页抓取,拿回来的内容往往是被裁剪过的摘要或者干脆是报错。所以问题的本质不是"模型不够聪明",而是"它手里没有一把能开这把锁的钥匙"。
我最初的几次尝试都很朴素:手动打开文章,全选复制,粘贴到一个.md文件里,然后让助手去读。这个土办法能用,但代价太大——一篇文章从打开到整理成可读文本,五分钟起步,而且粘贴进编辑器的时候,代码块的语言标识全丢了,行号错乱,图片变成一串没有任何意义的占位符。做得多了就明白,这个环节必须自动化,否则"让 AI 读文章"就是个伪需求。
1.2 就算能打开,正文也不是干净的文本
假设你已经绕过了访问层,真正难的才开始。我见过太多人以为抓公众号就是requests.get()加一个 CSS 选择器搞定,结果拿到的 Markdown 惨不忍睹。
第一个坑是正文节点。文章主体的容器是一个固定 id 的元素,但它的父级链条上挂着一堆装饰性节点:底部的二维码、点赞引导、作者名片、相关推荐、广告位。这些内容如果不清理,转换出来就是一坨噪音,白白消耗上下文长度,还会干扰模型对正文结构的判断。
第二个坑是懒加载图片。页面里的<img>标签,src属性指向的往往是一张 1×1 的透明占位图,真实地址藏在>python -m venv .venv source .venv/bin/activate pip install requests beautifulsoup4 lxml markdownify mcp
浏览器降级方案另外装:
pip install playwright python -m playwright install chromium注意最后这条要单独跑一次,它才是真正下载浏览器内核的步骤,光装 Python 包是不够的。这一步下载体积不小,第一次装会等一会儿。
如果你更习惯 JavaScript 生态,等价组合是undici或node-fetch做请求、cheerio做解析、turndown做转换、playwright做降级。逻辑完全一样,我就不重复写了,下面全部以 Python 版本为例。
4.2 核心脚本:抓取、清洗、转换一条龙
先写请求部分。请求头这块别偷懒,该带的都带上:
import requests UA = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ) def build_session(): s = requests.Session() s.headers.update({ "User-Agent": UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://mp.weixin.qq.com/", }) return s def fetch_html(url, session=None, timeout=15): s = session or build_session() resp = s.get(url, timeout=timeout) resp.raise_for_status() resp.encoding = "utf-8" return resp.textresp.encoding = "utf-8"这行一定要写,原因前面讲过。别看它不起眼,漏了之后中文全乱,你还得回头重来。
接下来是核心的提取函数。顺序很重要:先取图片真实地址,再清属性,最后转换。
from bs4 import BeautifulSoup from markdownify import markdownify DROP_SELECTORS = [ "script", "style", "noscript", ".qr_code_pc", "#js_pc_qr_code", ".rich_media_tool", ".reward_area", ".weui-msg", "mp-common-profile", "mp-miniprogram", "qqmusic", ] IMG_ATTRS = ("data-src", "data-lazy-src", "data-original", "data-backsrc") def extract_article(html, keep_images_link=True): soup = BeautifulSoup(html, "lxml") # 标题 title = "" meta = soup.find("meta", attrs={"property": "og:title"}) if meta and meta.get("content"): title = meta["content"].strip() if not title: node = soup.find(id="activity-name") if node: title = node.get_text(strip=True) body = soup.find(id="js_content") if body is None: raise ValueError("未找到正文节点,可能是验证页或文章不可见") for sel in DROP_SELECTORS: for tag in body.select(sel): tag.decompose() # 先处理图片真实地址 for img in body.find_all("img"): real = "" for attr in IMG_ATTRS: if img.get(attr): real = img[attr] break if not real: real = img.get("src", "") if real.startswith("//"): real = "https:" + real img["src"] = real img["alt"] = img.get("alt", "").strip() for comment in body.find_all(string=lambda t: isinstance(t, type(soup.Comment))): comment.extract() # 清属性,保留必要的 for tag in body.find_all(True): keep = {} for attr in ("src", "alt", "href"): if tag.has_attr(attr): keep[attr] = tag[attr] tag.attrs = keep md = markdownify( str(body), heading_style="ATX", bullets="-", strip=["span"], ) # 折叠多余空行 lines = [ln.rstrip() for ln in md.splitlines()] out, blank = [], 0 for ln in lines: if not ln: blank += 1 if blank > 1: continue else: blank = 0 out.append(ln) return title or "untitled", "\n".join(out).strip()几个点值得单独说。
strip=["span"]这个参数挺关键。公众号正文里 span 用得极其泛滥,几乎每个字都被包一层。转换库默认会把 span 当行内元素处理,结果输出里到处是零碎的换行。指定剥离之后,文本会重新连成整句。
删除名单里的mp-common-profile和mp-miniprogram是自定义标签名,删它们需要解析器支持自定义标签,lxml没问题。如果你用的是纯 Python 解析器,可能得换一种匹配方式。
标签属性的白名单只留src、alt、href三个,其他全扔。这样链接和图片能保留,样式和事件相关的属性一律不进入输出。
4.3 失败识别与浏览器降级
判断第一层是否真的成功了,不能只看有没有抛异常。有些情况是"成功返回了 HTML,但内容是验证页"。我的做法是检查两个信号:正文节点是否存在,正文文本长度是否超过一个阈值。
def looks_like_article(html, min_len=200): if 'id="js_content"' not in html: return False soup = BeautifulSoup(html, "lxml") body = soup.find(id="js_content") if body is None: return False return len(body.get_text(strip=True)) >= min_len降级路径用 Playwright:
from playwright.sync_api import sync_playwright def fetch_with_browser(url, wait_ms=1500): with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context( user_agent=UA, locale="zh-CN", viewport={"width": 1280, "height": 900}, ) page = ctx.new_page() page.goto(url, wait_until="domcontentloaded", timeout=30000) try: page.wait_for_load_state("networkidle", timeout=wait_ms) except Exception: pass html = page.content() browser.close() return htmlwait_for_load_state外面包了 try,因为有些页面永远等不到网络空闲(比如挂着长轮询),不能让它把整个请求拖死。超时就直接取当前 DOM,够用了。
4.4 封装成工具接口挂到助手侧
这部分是我觉得最有价值的一步。脚本跑通只是自娱自乐,挂成工具才是真正接进工作流。
用官方 SDK 写一个最小服务:
import hashlib, os, pathlib from mcp.server.fastmcp import FastMCP mcp = FastMCP("wechat-reader") CACHE = pathlib.Path.home() / ".cache" / "wechat-reader" CACHE.mkdir(parents=True, exist_ok=True) @mcp.tool() def read_wechat_article(url: str, force_browser: bool = False) -> str: """读取微信公众号文章正文,返回 Markdown 文本。 Args: url: 文章链接,支持 /s/ 短链和带 sn 参数的完整链接 force_browser: 为 True 时直接用浏览器渲染,跳过 HTTP 尝试 """ key = hashlib.sha1(url.encode()).hexdigest()[:16] cache_file = CACHE / f"{key}.md" if cache_file.exists(): return cache_file.read_text(encoding="utf-8") html = fetch_html(url) if force_browser or not looks_like_article(html): html = fetch_with_browser(url) title, md = extract_article(html) result = f"# {title}\n\n{md}" cache_file.write_text(result, encoding="utf-8") return result if __name__ == "__main__": mcp.run()工具的描述文字(docstring)不要敷衍。模型就是靠这段文字判断什么时候该调用、参数怎么填。写得含糊,它要么不敢调,要么乱填参数。
注册到 Claude Code 里,项目根目录放一个.mcp.json:
{ "mcpServers": { "wechat-reader": { "command": "python", "args": ["-m", "wechat_reader.server"], "env": { "PYTHONPATH": "/absolute/path/to/project" } } } }Codex 侧用的是 TOML 风格的配置:
[mcp_servers.wechat-reader] command = "python" args = ["-m", "wechat_reader.server"]路径用绝对路径,别用相对路径,我在这上面栽过——助手进程的工作目录跟你终端里的不一样,相对路径必然找不到。
4.5 参数与容量估算
几个数值我实测过,直接给参考。
请求超时设 15 秒。低于 8 秒,遇到稍慢的页面就开始误判失败;高于 20 秒,网络真出问题时你等得心焦。批量场景下建议并发控制在 2 到 3,请求间隔随机 1 到 3 秒。按这个节奏,一百篇文章大概十几分钟跑完,期间基本不会被判成异常流量。
缓存键我用的是链接的哈希。更好的做法是优先提取链接里的文章唯一标识参数,用它做键——同一篇文章可能通过不同的短链形式访问,用标识参数才能正确命中。链接里没有那个参数的时候,再退回哈希。
容量估算前面提过一次,这里给完整版。纯文本的中文文章,一万字大约对应七千到一万 token。一篇典型的公众号技术文三五千字,加代码块和结构标记,转换后的 Markdown 大概在八千到一万五千 token 之间。这个体量完全在常规上下文窗口范围内,不需要分段。
但一旦把图片内联进来,账就变了。单张 300KB 的图内联后十万 token 量级,三张就把舒适区吃光。所以我的红线是:内联图片总数不超过两张,且只在内联后总 token 估算低于五万时才执行。超过就自动降级成外链。
还有一个隐形开销是文件读写。如果你让助手直接读本地文件而不是通过工具返回字符串,它会先列目录、再读文件、可能还要分页读,来回好几轮。短文章直接返回字符串更省事,长文章才落盘给路径。
5. 提示词与工作流:让它真正读懂,而不只是读到
5.1 三段式提示词
工具能调通了,不等于模型用得对。我摸索出来的做法是把任务拆成三段,一次只让它干一件事。
第一段只做结构识别。"这是一篇文章的 Markdown 原文,请只输出它的章节大纲,保留原始标题层级,不要总结内容。" 这一步的目的是让模型先建立全局地图。直接让它总结全文,长文很容易顾头不顾尾,中段内容被略过。
第二段做定点提取。"根据上一步的大纲,逐节提取技术方案要点,每节不超过五行,保留原文中的参数、命令、代码片段。"
第三段才是落地。"把第二段里提到的方案,改写成能在本地跑起来的分步任务清单,每一步标注依赖和验证方式。"
这三段可以放在一个会话里连续问,也可以拆成三次调用。我的体验是拆开更可靠,尤其是文章超过一万 token 的时候。会话里前面塞太多内容,后面推理质量会肉眼可见地下降。
还有一条规则我固定加在系统提示里:遇到无法确定的内容,明确标注"原文未说明",不要自己编。公众号文章经常有前后文省略的情况,模型补全得很顺,但那不是原文的意思,用作参考会出问题。
5.2 长文的分段策略
超过一万五千 token 的文章,我会在抓取阶段就按二级标题切开,每段单独送进去。切分点选标题是因为那里语义边界最清晰,不容易把一句话劈成两半。
具体做法是在转换完成后扫一遍输出,找到所有以两个井号开头的行,按位置切成若干块。每块前面补一个上下文头,写清楚"这是第几段,属于什么主题",这样模型处理每一块的时候都知道自己在整体里的位置。
分段之后还有个合并问题。让模型分别输出各段要点,最后再来一次汇总,比一次性喂全文效果好不少。汇总那一步可以把前面各段的输出拼起来,此时总长度已经压到原文的三分之一以下,模型处理起来轻松很多。
5.3 一个能直接抄的工作流
我日常用的流程大概是这样:
第一步,在项目目录里新建一个refs/文件夹,专门放参考资料。这个目录加进.gitignore,避免把别人的文章内容提交到仓库里。
第二步,在助手会话里说:"用 wechat-reader 读一下这个链接,存到 refs 下,文件名用文章标题。" 它会调用工具、拿到 Markdown、写文件。文件名里的标点符号它会自己处理,但我一般会补一句"文件名里的冒号和斜杠替换成短横线",省得它生成非法路径。
第三步,另起一轮:"读 refs 下刚存的那篇,列出它提到的所有技术选型,每项一句话说明它的取舍理由。"
第四步,如果要落地:"按第三点的结论,给我一个最小可跑的目录结构和关键文件内容。"
这个流程跑下来,从看到文章到拿到可执行的方案,中间不需要手动复制任何东西。这是我折腾这套东西最大的收益。
6. 常见问题与排查实录
6.1 抓取阶段的典型故障
返回内容是一段提示页,正文节点找不到。九成是请求头的问题,先检查User-Agent是不是太老或者太假,再检查有没有带Referer。如果都正常,多半是请求频率触发了限制,隔几分钟重试,或者直接走浏览器降级。
浏览器里能打开,脚本里打不开。差异通常出在 Cookie。部分文章页面在无登录态下只返回摘要。这种情况我不建议在脚本里硬闯,正确做法是在本地浏览器里正常访问一次,导出 Cookie 存到本地配置文件,脚本读取后带上。这份文件必须排除在版本控制之外,也不要在任何地方明文写出来。
图片全部是空白。检查三件事:>