# Weidu

> 微读（weidu）——读 mp.weixin.qq.com 公众号文章的稳定姿势。绕开层层叠叠的反爬：Jina/WebFetch/curl 全被拦或返回验证码页。 名字寓意：「微」=微信，「读」=读得到；两个字直说"让你能读到微信公众号文章"。 方法论：PowerShell HttpWebRequest 拉原始 UTF-8 字节（避开 Invoke-WebRequest 的 UTF-8→GBK 编码污染）+ Python 双容器兜底（js_content 旧版 + content_noencode 新版沉浸式）。 当用户给出 mp.weixin.qq.com 链接、要读 / 解读 / 摘要 / 提取一手数据时使用。 触发词包括但不限于：mp.weixin.qq.com/s/ 链接（任何形式）、读一下这篇微信文章、看下这个公众号、读这个链接、解读这篇文章（链接是公众号时）、提取这个公众号文章的关键信息、摘要一下这篇微信文章、微读看一下、用微读读。 即使用户只是丢一个公众号链接没说话，也应该触发——丢链接 = 想读。 不要用于非 mp.weixin.qq.com 域名（用 WebFetch / Jina 就够）；不要用于需要登录态的微信内容（公众号订阅页、付费内容）；不要用于公众号视频号/图文卡片（容器结构不同）。

- Skill: `dqt-bit/weidu` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add dqt-bit/weidu`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dqt-bit/weidu/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: DQT-bit (https://skillmd.com/u/dqt-bit)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/dqt-bit/weidu

---


# 微读 · weidu | 公众号文章读取工坊

> **工坊规矩**
> 微信反爬不是一面墙，是一堆陷阱。每一层（Jina、WebFetch、Invoke-WebRequest）都会"看起来工作"——返回 200、给你点东西——但内容是错的（验证码页、乱码、空壳）。**只信原始字节 + 自己解码**，别信任何中间层的"它帮你解码好了"。

## 接活：用户给的输入

- **链接形如** `https://mp.weixin.qq.com/s/<id>` —— 直接读
- **链接 + 任务**：「读这个 + 摘要 / 提取观点 / 翻译 / 对比另一篇」—— 先读再做任务
- **多个链接**：批量处理，但每两次 fetch 之间 `Start-Sleep -Seconds 2` 防风控

## 班规总纲

- **先抓原始字节，再解码**。任何"看起来工作但内容错"的中间层（PowerShell `Invoke-WebRequest`、Jina）都不能信。
- **双容器都试，不假设**。`js_content` 在就用，不在就找 `content_noencode`——**不要根据公众号名字猜**。
- **元数据缺失就说缺失，不编**。新版沉浸式文章 `nickname` / `ct` 大概率拿不到，老实写"作者未知"。
- **chars=0 不是成功**。如果两个容器都没匹配上，提取脚本必须报错退出，不能静默返回空 → 给用户假象。
- **抓下来的 HTML 留 sha8**。同一篇文章不同时间抓可能不同（小幅 A/B 测试），sha8 让你后期对比能定位差异。

## Step 1：原始字节下载（PowerShell）

```powershell
$url = "<wx-url>"
$out = "$env:TEMP\wx_raw.html"

$req = [System.Net.HttpWebRequest]::Create($url)
$req.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
$req.Headers.Add("Accept-Language","zh-CN,zh;q=0.9")
$req.Timeout = 30000

$resp = $req.GetResponse()
$ms = New-Object System.IO.MemoryStream
$resp.GetResponseStream().CopyTo($ms)
[System.IO.File]::WriteAllBytes($out, $ms.ToArray())
$resp.Close()

"len=" + $ms.Length
"sha8=" + (Get-FileHash $out -Algorithm SHA256).Hash.Substring(0,8)
```

**为什么必须这样**：`Invoke-WebRequest` 内部对 response body 做编码推断时，会把 UTF-8 字节按系统码页（中文 Windows = GBK / cp936）解码——所有中文字符变乱码。`HttpWebRequest` + `WriteAllBytes` 走原始字节路径，由后续 Python 按 UTF-8 解码，干净。

## Step 2：判断容器类型

抓下来先看是旧版还是新版：

```powershell
$h = [System.IO.File]::ReadAllText("$env:TEMP\wx_raw.html", [System.Text.Encoding]::UTF8)
"js_content present? " + $h.Contains('id="js_content"')
"content_noencode present? " + $h.Contains('content_noencode:')
```

| 类型 | 容器 | 典型公众号 |
|------|------|----------|
| **旧版** | `<div id="js_content">...</div>` | APPSO、知名科技号、传统排版 |
| **新版"沉浸式"流式文章** | inline JS 变量 `content_noencode: '<html-string>'` | 短内容创作者、AI 圈个人号 |

**两个都可能同时存在**——以 `js_content` 优先（更结构化）。

## Step 3：双容器提取（Python）

```python
import re, html, sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

with open(r"<path-to-html>", encoding="utf-8") as f:
    h = f.read()

def find_meta(name):
    m = re.search(r'<meta[^>]+(?:property|name)="' + name + r'"[^>]+content="([^"]*)"', h)
    return html.unescape(m.group(1)) if m else None

title = find_meta("og:title")

# 路径 A：旧版 js_content（优先）
m = re.search(r'<div[^>]*id="js_content"[^>]*>(.*?)</div>\s*<script', h, re.S)
body_html = ""

if m:
    body_html = m.group(1)
else:
    # 路径 B：新版 content_noencode JS string
    i = h.find("content_noencode:")
    if i > 0:
        i = h.find("'", i)
        j = i + 1
        buf = []
        while j < len(h):
            c = h[j]
            if c == "\\" and j + 1 < len(h):
                nx = h[j+1]
                if nx == "x" and j + 3 < len(h):
                    buf.append(chr(int(h[j+2:j+4], 16))); j += 4; continue
                if nx == "u" and j + 5 < len(h):
                    buf.append(chr(int(h[j+2:j+6], 16))); j += 6; continue
                buf.append({'n':'\n','t':'\t','r':'\r',"'":"'",'"':'"','':'','/':'/'}.get(nx, nx))
                j += 2; continue
            if c == "'":
                break
            buf.append(c); j += 1
        body_html = "".join(buf)

# 班规：chars=0 不是成功
assert body_html, "FATAL: 两个容器都没匹配上——可能是新版变体或反爬命中"

# 去标签
body = re.sub(r'<br[^>]*>', '\n', body_html)
body = re.sub(r'</p>', '\n\n', body)
body = re.sub(r'</(h[1-6]|li|blockquote|section|div)>', '\n', body)
body = re.sub(r'<style[^>]*>.*?</style>', '', body, flags=re.S)
body = re.sub(r'<script[^>]*>.*?</script>', '', body, flags=re.S)
body = re.sub(r'<[^>]+>', '', body)
body = html.unescape(body)
body = re.sub(r'\n{3,}', '\n\n', body).strip()

print(f"TITLE: {title}")
print(f"chars: {len(body)}")
```

## 元数据提取

| 字段 | 出处 |
|------|------|
| 标题 | `<meta property="og:title" content="...">` |
| 公众号名 | `var nickname = htmlDecode("...")`（旧版有，新版常缺） |
| 发布时间 | `var ct = "<unix-timestamp>"`（同上） |
| 原文 URL | `<meta property="og:url" content="...">` |

新版沉浸式文章 `nickname` / `ct` 大概率拿不到——**老实写"作者未知 / 时间未知"，不要编**。

## 已知陷阱

- ❌ 只看 `js_content` 容器 → 新版沉浸式文章一律 0 字符正文
- ❌ Python `urllib.request` 直请 → 微信检测后返回反爬页（无浏览器 UA + Accept-Language）
- ❌ `Invoke-WebRequest -OutFile` → 同样存在编码推断问题
- ❌ 假设 `content_noencode` 是 JSON 编码 → 实际是 JS string 转义（`\x0a` 不是 `\n`）
- ❌ 静默返回空正文 → 给用户假象，下游任务全错
- ⚠️ 连续抓多篇 IP 易被风控 → `Start-Sleep -Seconds 2` 间隔
- ⚠️ 部分文章正文段落是图片 → `<img>` 标签里只有图片 URL，需单独提 `data-src` 走 OCR 或 vision
- ⚠️ 个别公众号会做"会员可见"折叠 → 抓到的只是开头部分，要在班规里向用户报告

## 实测案例

完整案例见 [`examples/`](./examples/) 目录：
- [appso-fable5-pingti.md](./examples/appso-fable5-pingti.md) — APPSO（旧版 `js_content`）
- [zhoutian-claude-songkou.md](./examples/zhoutian-claude-songkou.md) — 周天财经（新版 `content_noencode`）

两个版本一份脚本兜底，跑通。

## 一句话出师证书

> 微信公众号文章读取的核心不是"用什么工具"，是"信哪一层"。**只信原始字节 + 你自己写的解码逻辑**——`Invoke-WebRequest` 给你 GBK 乱码、Jina 给你验证码页、`js_content` 在新版文章里压根不存在。

---

发现日期：2026-06-16 · 打磨日期：2026-06-17（按鲁班 Skill v1.0 思路）

