From 9ee4c5d178aa9a7fbf4bc52f9d4236db706ede79 Mon Sep 17 00:00:00 2001
From: Zou Zhao
Date: Fri, 9 Oct 2026 18:44:37 +0800
Subject: [PATCH 1/3] =?UTF-8?q?feat(content):=20=E6=AD=A3=E6=96=87?=
=?UTF-8?q?=E6=8F=90=E5=8F=96=E6=94=B9=E4=B8=BA=20Markdown=20=E8=BE=93?=
=?UTF-8?q?=E5=87=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 5 +-
pnpm-lock.yaml | 112 +++++++++++++++---------
src/commands/content.ts | 2 +-
src/commands/mcp.ts | 2 +-
src/lib/__test__/fetcher.test.ts | 46 ++++++++++
src/lib/fetcher.ts | 6 +-
src/lib/markdown.ts | 34 +++++++
src/tests/fixtures/article-content.html | 50 +++++++++++
src/types/turndown-plugin-gfm.d.ts | 9 ++
9 files changed, 220 insertions(+), 46 deletions(-)
create mode 100644 src/lib/markdown.ts
create mode 100644 src/tests/fixtures/article-content.html
create mode 100644 src/types/turndown-plugin-gfm.d.ts
diff --git a/package.json b/package.json
index 75704fc..8366326 100644
--- a/package.json
+++ b/package.json
@@ -25,13 +25,16 @@
"email": "wszwc3721@163.com"
},
"dependencies": {
+ "@joplin/turndown-plugin-gfm": "^1.0.68",
"@modelcontextprotocol/sdk": "^1.29.0",
"cheerio": "^1.0.0",
- "commander": "^12.0.0"
+ "commander": "^12.0.0",
+ "turndown": "^7.2.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.0.0",
+ "@types/turndown": "^5.0.6",
"eslint": "^10.5.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 63b358b..531a30a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -261,6 +261,9 @@ importers:
.:
dependencies:
+ '@joplin/turndown-plugin-gfm':
+ specifier: ^1.0.68
+ version: 1.0.68
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@4.4.3)
@@ -270,6 +273,9 @@ importers:
commander:
specifier: ^12.0.0
version: 12.1.0
+ turndown:
+ specifier: ^7.2.4
+ version: 7.2.4
devDependencies:
'@eslint/js':
specifier: ^10.0.1
@@ -277,6 +283,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.19
+ '@types/turndown':
+ specifier: ^5.0.6
+ version: 5.0.6
eslint:
specifier: ^10.5.0
version: 10.5.0
@@ -294,7 +303,7 @@ importers:
version: 8.62.0(eslint@10.5.0)(typescript@5.9.3)
vitest:
specifier: ^3.0.0
- version: 3.2.4(@types/node@22.19.19)
+ version: 3.2.7(@types/node@22.19.19)
packages:
@@ -519,9 +528,15 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@joplin/turndown-plugin-gfm@1.0.68':
+ resolution: {integrity: sha512-m8DfAQNC/V7g0j5H6Jv60WhekBBjodD+zTPGrU+g2m+ux/z8ZX07KQIl6kaAmbKwfvsQhC04op52g63I78sVgg==}
+
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@mixmark-io/domino@2.2.0':
+ resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
+
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@@ -691,6 +706,9 @@ packages:
'@types/node@22.19.19':
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
+ '@types/turndown@5.0.6':
+ resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
+
'@typescript-eslint/eslint-plugin@8.62.0':
resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -750,11 +768,11 @@ packages:
resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@vitest/expect@3.2.4':
- resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
+ '@vitest/expect@3.2.7':
+ resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
- '@vitest/mocker@3.2.4':
- resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
+ '@vitest/mocker@3.2.7':
+ resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
@@ -764,20 +782,20 @@ packages:
vite:
optional: true
- '@vitest/pretty-format@3.2.4':
- resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
+ '@vitest/pretty-format@3.2.7':
+ resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
- '@vitest/runner@3.2.4':
- resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
+ '@vitest/runner@3.2.7':
+ resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
- '@vitest/snapshot@3.2.4':
- resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
+ '@vitest/snapshot@3.2.7':
+ resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
- '@vitest/spy@3.2.4':
- resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+ '@vitest/spy@3.2.7':
+ resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
- '@vitest/utils@3.2.4':
- resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
+ '@vitest/utils@3.2.7':
+ resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
@@ -1485,6 +1503,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ turndown@7.2.4:
+ resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==}
+ engines: {node: '>=18', npm: '>=9'}
+
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1568,16 +1590,16 @@ packages:
yaml:
optional: true
- vitest@3.2.4:
- resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
+ vitest@3.2.7:
+ resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
- '@vitest/browser': 3.2.4
- '@vitest/ui': 3.2.4
+ '@vitest/browser': 3.2.7
+ '@vitest/ui': 3.2.7
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
@@ -1768,8 +1790,12 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@joplin/turndown-plugin-gfm@1.0.68': {}
+
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@mixmark-io/domino@2.2.0': {}
+
'@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.27)
@@ -1886,6 +1912,8 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/turndown@5.0.6': {}
+
'@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0)(typescript@5.9.3))(eslint@10.5.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -1977,45 +2005,45 @@ snapshots:
'@typescript-eslint/types': 8.62.0
eslint-visitor-keys: 5.0.1
- '@vitest/expect@3.2.4':
+ '@vitest/expect@3.2.7':
dependencies:
'@types/chai': 5.2.3
- '@vitest/spy': 3.2.4
- '@vitest/utils': 3.2.4
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
chai: 5.3.3
tinyrainbow: 2.0.0
- '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.19))':
+ '@vitest/mocker@3.2.7(vite@7.3.3(@types/node@22.19.19))':
dependencies:
- '@vitest/spy': 3.2.4
+ '@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.3(@types/node@22.19.19)
- '@vitest/pretty-format@3.2.4':
+ '@vitest/pretty-format@3.2.7':
dependencies:
tinyrainbow: 2.0.0
- '@vitest/runner@3.2.4':
+ '@vitest/runner@3.2.7':
dependencies:
- '@vitest/utils': 3.2.4
+ '@vitest/utils': 3.2.7
pathe: 2.0.3
strip-literal: 3.1.0
- '@vitest/snapshot@3.2.4':
+ '@vitest/snapshot@3.2.7':
dependencies:
- '@vitest/pretty-format': 3.2.4
+ '@vitest/pretty-format': 3.2.7
magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@3.2.4':
+ '@vitest/spy@3.2.7':
dependencies:
tinyspy: 4.0.4
- '@vitest/utils@3.2.4':
+ '@vitest/utils@3.2.7':
dependencies:
- '@vitest/pretty-format': 3.2.4
+ '@vitest/pretty-format': 3.2.7
loupe: 3.2.1
tinyrainbow: 2.0.0
@@ -2783,6 +2811,10 @@ snapshots:
dependencies:
typescript: 5.9.3
+ turndown@7.2.4:
+ dependencies:
+ '@mixmark-io/domino': 2.2.0
+
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -2851,16 +2883,16 @@ snapshots:
'@types/node': 22.19.19
fsevents: 2.3.3
- vitest@3.2.4(@types/node@22.19.19):
+ vitest@3.2.7(@types/node@22.19.19):
dependencies:
'@types/chai': 5.2.3
- '@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.19))
- '@vitest/pretty-format': 3.2.4
- '@vitest/runner': 3.2.4
- '@vitest/snapshot': 3.2.4
- '@vitest/spy': 3.2.4
- '@vitest/utils': 3.2.4
+ '@vitest/expect': 3.2.7
+ '@vitest/mocker': 3.2.7(vite@7.3.3(@types/node@22.19.19))
+ '@vitest/pretty-format': 3.2.7
+ '@vitest/runner': 3.2.7
+ '@vitest/snapshot': 3.2.7
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
diff --git a/src/commands/content.ts b/src/commands/content.ts
index f0d3918..dae7f09 100644
--- a/src/commands/content.ts
+++ b/src/commands/content.ts
@@ -4,7 +4,7 @@ import { fetchArticleContent } from "../lib/fetcher.js"
export function setupContentCommand(program: Command): void {
program
.command("content ")
- .description("获取微信公众号文章正文")
+ .description("获取微信公众号文章正文(Markdown)")
.option("-r, --referer ", "请求来源")
.action(async (url, opts) => {
try {
diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts
index 37aa701..bda8e5a 100644
--- a/src/commands/mcp.ts
+++ b/src/commands/mcp.ts
@@ -123,7 +123,7 @@ export function createMcpServer(): Server {
},
{
name: "content",
- description: "获取微信公众号文章正文",
+ description: "获取微信公众号文章正文(Markdown)",
inputSchema: {
type: "object" as const,
properties: {
diff --git a/src/lib/__test__/fetcher.test.ts b/src/lib/__test__/fetcher.test.ts
index 95413a8..d57fbc0 100644
--- a/src/lib/__test__/fetcher.test.ts
+++ b/src/lib/__test__/fetcher.test.ts
@@ -102,4 +102,50 @@ describe("fetchArticleContent", () => {
"获取文章内容失败: 未拿到有效的微信公众号文章链接",
)
})
+
+ it("正文按 Markdown 返回,保留标题、列表、代码块、图片、引用与表格", async () => {
+ const html = readFileSync(join(fixtureDir, "article-content.html"), "utf-8")
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockRes("https://mp.weixin.qq.com/s/a", html)))
+
+ const md = await fetchArticleContent("https://mp.weixin.qq.com/s/a")
+
+ expect(md).toContain("### 小标题")
+ expect(md).toContain("第一段**加粗**文字。")
+ expect(md).toContain("- 条目一")
+ expect(md).toContain("> 引用一段话")
+ expect(md).toContain("```\nnpm install -g mpget\n```")
+ expect(md).toContain("| 列 A | 列 B |")
+ expect(md).toContain("末尾*斜体*结束。")
+ })
+
+ it("图片取 data-src,跳过 data: 占位图", async () => {
+ const html = readFileSync(join(fixtureDir, "article-content.html"), "utf-8")
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockRes("https://mp.weixin.qq.com/s/b", html)))
+
+ const md = await fetchArticleContent("https://mp.weixin.qq.com/s/b")
+
+ expect(md).toContain("")
+ expect(md).not.toContain("data:image/gif")
+ })
+
+ it("排除 script/style 与 js_content 之外的内容", async () => {
+ const html = readFileSync(join(fixtureDir, "article-content.html"), "utf-8")
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockRes("https://mp.weixin.qq.com/s/c", html)))
+
+ const md = await fetchArticleContent("https://mp.weixin.qq.com/s/c")
+
+ expect(md).not.toContain("脚本内容不该出现在正文里")
+ expect(md).not.toContain("display: none")
+ expect(md).not.toContain("正文之外的噪声")
+ })
+
+ it("没有 js_content 时返回失败提示", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(mockRes("https://mp.weixin.qq.com/s/d", "没有正文
")),
+ )
+ expect(await fetchArticleContent("https://mp.weixin.qq.com/s/d")).toBe(
+ "获取文章内容失败: 正文为空",
+ )
+ })
})
diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts
index c0f6c7e..37a7ff0 100644
--- a/src/lib/fetcher.ts
+++ b/src/lib/fetcher.ts
@@ -1,4 +1,5 @@
import * as cheerio from "cheerio"
+import { htmlToMarkdown } from "./markdown.js"
import type { SearchResponse, SearchResult } from "./types.js"
const BASE = "https://weixin.sogou.com/weixin"
@@ -189,9 +190,8 @@ export async function fetchArticleContent(realUrl: string, referer?: string): Pr
const hdrs = headers()
if (referer) hdrs["Referer"] = referer
const res = await fetchWithTimeout(realUrl, { headers: hdrs })
- const $ = cheerio.load(await res.text())
- const text = $("#js_content").text().split(/\s+/).filter(Boolean).join("\n")
- return text || "获取文章内容失败: 正文为空"
+ const markdown = htmlToMarkdown(await res.text())
+ return markdown || "获取文章内容失败: 正文为空"
} catch (e) {
return `获取文章内容失败: ${e instanceof Error ? e.message : String(e)}`
}
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
new file mode 100644
index 0000000..bc59535
--- /dev/null
+++ b/src/lib/markdown.ts
@@ -0,0 +1,34 @@
+import * as cheerio from "cheerio"
+import TurndownService from "turndown"
+import { gfm } from "@joplin/turndown-plugin-gfm"
+
+const turndown = new TurndownService({
+ headingStyle: "atx",
+ hr: "---",
+ bulletListMarker: "-",
+ codeBlockStyle: "fenced",
+ emDelimiter: "*",
+ linkStyle: "inlined",
+})
+
+turndown.use(gfm)
+turndown.remove(["script", "style"])
+
+// 微信正文的图片真实地址在 data-src,src 常是 data: 占位图
+turndown.addRule("wechatImage", {
+ filter: "img",
+ replacement: (_content, node) => {
+ const el = node as unknown as { getAttribute(name: string): string | null }
+ const src = el.getAttribute("data-src") || el.getAttribute("src") || ""
+ if (!src || src.startsWith("data:")) return ""
+ const alt = el.getAttribute("alt") || ""
+ return ``
+ },
+})
+
+export function htmlToMarkdown(html: string): string {
+ const $ = cheerio.load(html)
+ const content = $("#js_content").html()
+ if (!content) return ""
+ return turndown.turndown(content).trim()
+}
diff --git a/src/tests/fixtures/article-content.html b/src/tests/fixtures/article-content.html
new file mode 100644
index 0000000..0c810ef
--- /dev/null
+++ b/src/tests/fixtures/article-content.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+ 小标题
+ 第一段加粗文字。
+
+
+
+
+ 引用一段话
+ npm install -g mpget
+
+
+
+ | 列 A |
+ 列 B |
+
+
+
+
+ | 1 |
+ 2 |
+
+
+
+
+
末尾斜体结束。
+
+ 正文之外的噪声
+
+
diff --git a/src/types/turndown-plugin-gfm.d.ts b/src/types/turndown-plugin-gfm.d.ts
new file mode 100644
index 0000000..a4b3c0a
--- /dev/null
+++ b/src/types/turndown-plugin-gfm.d.ts
@@ -0,0 +1,9 @@
+declare module "@joplin/turndown-plugin-gfm" {
+ import type TurndownService from "turndown"
+
+ export const gfm: TurndownService.Plugin
+ export const tables: TurndownService.Plugin
+ export const strikethrough: TurndownService.Plugin
+ export const taskListItems: TurndownService.Plugin
+ export const highlightedCodeBlock: TurndownService.Plugin
+}
From 40d36708e692b6053337814c3bd58e83a4b2e476 Mon Sep 17 00:00:00 2001
From: Zou Zhao
Date: Fri, 9 Oct 2026 18:44:37 +0800
Subject: [PATCH 2/3] =?UTF-8?q?docs(readme):=20=E5=90=8C=E6=AD=A5=E6=AD=A3?=
=?UTF-8?q?=E6=96=87=E8=BE=93=E5=87=BA=E4=B8=BA=20Markdown=20=E7=9A=84?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 4 ++--
docs/README.zh-CN.md | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 5b54acf..27fd213 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Designed for **agent integration** — structured JSON output, MCP server suppor
- **CLI + MCP dual interface** — use as a command-line tool or as an MCP server for AI agents
- **Agent Skill** — `mpget init` injects a skill file into `.claude/skills/` with version sync
- **Multi-page search** — automatic pagination with configurable max pages and rate limiting
-- **Article content extraction** — fetches and cleans WeChat article body text
+- **Article content extraction** — fetches WeChat article body as Markdown (headings, lists, code blocks, images, quotes, tables)
- **Album article listing** — list all articles in an official account's album (with real mp.weixin.qq.com links)
- **Structured JSON output** — all results to stdout, errors to stderr with typed exit codes
@@ -104,7 +104,7 @@ All commands output JSON to stdout. Errors go to stderr as structured JSON.
```json
{
- "content": "Article body text..."
+ "content": "### 小标题\n\n正文段落,**加粗**文字。\n\n\n\n- 条目一\n- 条目二"
}
```
diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md
index 62e115a..8357cfc 100644
--- a/docs/README.zh-CN.md
+++ b/docs/README.zh-CN.md
@@ -11,7 +11,7 @@ CLI 工具与 MCP 服务,用于搜索和获取微信公众号文章(基于
- **CLI + MCP 双模式** — 命令行工具 + MCP Server,适配多种 AI Agent
- **Agent Skill** — `mpget init` 一键注入 Skill 到 `.claude/skills/`,带版本同步
- **多页搜索** — 自动分页,支持配置最大页数和请求间隔
-- **文章内容提取** — 获取并清洗微信公众号文章正文
+- **文章内容提取** — 正文按 Markdown 返回,保留标题、列表、代码块、图片、引用与表格
- **合集文章列表** — 拉取某公众号合集内全部文章(含真实 mp.weixin.qq.com 链接)
- **结构化 JSON 输出** — 结果输出到 stdout,错误输出到 stderr,带类型化退出码
@@ -104,7 +104,7 @@ mpget init
```json
{
- "content": "文章正文..."
+ "content": "### 小标题\n\n正文段落,**加粗**文字。\n\n\n\n- 条目一\n- 条目二"
}
```
From e328c926d7517e25dc959b84f7b866cd22ad2208 Mon Sep 17 00:00:00 2001
From: Zou Zhao
Date: Fri, 9 Oct 2026 20:13:28 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix(content):=20=E5=9B=BE=E7=89=87=E5=BD=92?=
=?UTF-8?q?=E4=B8=80=E5=8C=96=E7=A7=BB=E5=85=A5=20cheerio=20=E9=98=B6?=
=?UTF-8?q?=E6=AE=B5=EF=BC=8C=E5=8E=BB=E6=8E=89=E8=87=AA=E5=AE=9A=E4=B9=89?=
=?UTF-8?q?=E8=A7=84=E5=88=99=E4=B8=8E=E5=A4=9A=E4=BD=99=E8=BD=AC=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/lib/__test__/fetcher.test.ts | 10 +++++++++
src/lib/markdown.ts | 28 ++++++++++++-------------
src/tests/fixtures/article-content.html | 3 +++
3 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/src/lib/__test__/fetcher.test.ts b/src/lib/__test__/fetcher.test.ts
index d57fbc0..c865d4b 100644
--- a/src/lib/__test__/fetcher.test.ts
+++ b/src/lib/__test__/fetcher.test.ts
@@ -128,6 +128,16 @@ describe("fetchArticleContent", () => {
expect(md).not.toContain("data:image/gif")
})
+ it("图片地址与 alt 按 Markdown 语法转义", async () => {
+ const html = readFileSync(join(fixtureDir, "article-content.html"), "utf-8")
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockRes("https://mp.weixin.qq.com/s/e", html)))
+
+ const md = await fetchArticleContent("https://mp.weixin.qq.com/s/e")
+
+ // 地址含空格与圆括号时 turndown 会加尖括号并转义,alt 里的方括号也会被转义
+ expect(md).toContain("![图\\[1\\]]()")
+ })
+
it("排除 script/style 与 js_content 之外的内容", async () => {
const html = readFileSync(join(fixtureDir, "article-content.html"), "utf-8")
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockRes("https://mp.weixin.qq.com/s/c", html)))
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
index bc59535..85694c8 100644
--- a/src/lib/markdown.ts
+++ b/src/lib/markdown.ts
@@ -14,21 +14,19 @@ const turndown = new TurndownService({
turndown.use(gfm)
turndown.remove(["script", "style"])
-// 微信正文的图片真实地址在 data-src,src 常是 data: 占位图
-turndown.addRule("wechatImage", {
- filter: "img",
- replacement: (_content, node) => {
- const el = node as unknown as { getAttribute(name: string): string | null }
- const src = el.getAttribute("data-src") || el.getAttribute("src") || ""
- if (!src || src.startsWith("data:")) return ""
- const alt = el.getAttribute("alt") || ""
- return ``
- },
-})
-
export function htmlToMarkdown(html: string): string {
const $ = cheerio.load(html)
- const content = $("#js_content").html()
- if (!content) return ""
- return turndown.turndown(content).trim()
+ const content = $("#js_content")
+
+ // 微信正文的图片真实地址在 data-src,src 常是 data: 占位图;地址与 alt 的 Markdown 转义由 turndown 处理
+ content.find("img").each((_, el) => {
+ const img = $(el)
+ const src = img.attr("data-src") || img.attr("src") || ""
+ if (src && !src.startsWith("data:")) img.attr("src", src)
+ else img.remove()
+ })
+
+ const markup = content.html()
+ if (!markup) return ""
+ return turndown.turndown(markup).trim()
}
diff --git a/src/tests/fixtures/article-content.html b/src/tests/fixtures/article-content.html
index 0c810ef..15b7fb5 100644
--- a/src/tests/fixtures/article-content.html
+++ b/src/tests/fixtures/article-content.html
@@ -22,6 +22,9 @@ 小标题
alt="示意图"
/>
+
+
+