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("![示意图](https://mmbiz.qpic.cn/mmbiz_png/abc/0?wx_fmt=png)") + 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 `![${alt}](${src})` + }, +}) + +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
12
+
+

末尾斜体结束。

+
+
正文之外的噪声
+ + 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![示意图](https://mmbiz.qpic.cn/mmbiz_png/abc/0?wx_fmt=png)\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![示意图](https://mmbiz.qpic.cn/mmbiz_png/abc/0?wx_fmt=png)\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 `![${alt}](${src})` - }, -}) - 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="示意图" />

+

+ 图[1] +

  • 条目一
  • 条目二