
Wechat Article Fetch
- 71 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Scrapes WeChat public-account articles headlessly with Playwright, handling dynamic loading and saving clean title and body as markdown.
About
Fetches WeChat public-account article content using headless Playwright, handling dynamic and lazy-loaded content. Developers use it to extract an article's title and body and save it as markdown, with optional chained legal-text formatting.
- Headless Playwright with headed-mode fallback
- Auto-saves extracted content as markdown
Wechat Article Fetch by the numbers
- 71 all-time installs (skills.sh)
- Ranked #934 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill wechat-article-fetchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 71 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Scrapes WeChat public-account articles headlessly with Playwright, handling dynamic loading and saving clean title and body as markdown.
Files
微信公众号文章抓取工具
概述
使用 Playwright 抓取微信公众号文章,后台运行无弹窗,自动处理动态加载,提取干净的文章内容,并支持自动保存为 Markdown 文件。
功能特性
- ✅ 无头模式运行: 默认后台抓取,不弹出浏览器窗口
- ✅ 智能回退机制: 无头模式失败时自动切换到有头模式
- ✅ 动态内容支持: 自动等待页面加载完成,处理懒加载图片
- ✅ 自动保存为 Markdown: 支持将抓取结果保存为格式化的 Markdown 文件
- ✅ 内容清洗: 移除HTML标签,保留段落结构,输出纯文本
- ✅ 自动重试: 失败时自动重试3次,提高成功率
- ✅ 错误检测: 识别"参数错误"等异常页面
- ✅ 跨平台支持: 完全支持 Windows、macOS 和 Linux
- ✅ 法律内容自动格式化: 检测到法律内容时自动链式调用 legal-text-format(强制执行协议)
- ✅ 图片下载: 自动下载文章中的所有图片到本地
- ✅ 智能图片筛选: 自动过滤小的装饰性图片(如社交媒体按钮、表情符号)
- ✅ 图片位置保持: 保留图片在原文档中的位置
- ✅ 自动文件命名: 根据文章标题生成文件名和资源文件夹
抓取后法律格式化协议(强制执行)
本节为必须遵守的执行指令,而非可选建议。
法律内容判定清单
完成抓取后,你必须立即按以下清单判定内容是否为法律文本。满足任意一条即为法律内容:
- 标题关键词:案例、裁判、判决、法规、条例、规定、办法、解释、通知、意见、规则、最高法、最高检、法院、检察院、司法解释
- 内容特征:包含案号(如
(202X)XX民初XX号)、法院名称、法律条文引用(如第X条第X款) - 结构特征:符合法律案例典型结构(基本案情、裁判结果、典型意义等)
自动链式调用条件
以下两个条件同时满足时,必须立即调用 `legal-text-format`,不询问用户确认:
1. 上方判定结果为"是法律内容" 2. 本项目中存在 skills/legal-text-format/SKILL.md(通过 ls skills/legal-text-format/SKILL.md 或等效方式检查)
链式调用步骤
条件满足时,按以下顺序执行:
1. 完成抓取,将 Markdown 保存到本技能 archive/ 目录 2. 按判定清单判断内容类型 3. 如果是法律内容,检查 skills/legal-text-format/SKILL.md 是否存在 4. 如果存在,立即调用 `legal-text-format` 技能,传入抓取到的文件路径作为输入 5. 使用下方对应模板汇报最终结果
降级处理
如果 legal-text-format 不存在:正常结束抓取流程,在汇报中附加提示:
已检测到法律内容,但项目中未安装 legal-text-format 格式化技能。如需自动格式化,可通过 skill-manager 安装。结果存储
- 抓取的原始内容:保存在本技能的
archive/目录(默认行为不变) - 格式化后的内容:保存在
legal-text-format的archive/目录(由该技能自行管理)
汇报模板
仅抓取(非法律内容,或 legal-text-format 未安装):
已完成抓取:{标题}
保存位置:{文件路径}
抓取 + 格式化(法律内容且 legal-text-format 已安装):
已完成抓取 + 法律格式化:{标题}
原始内容:{wechat-article-fetch archive 路径}
格式化内容:{legal-text-format archive 路径}
文本类型:{法律条文/法律案例}
使用方法
在 Claude Code 中调用
// 抓取文章(仅返回结果)
const result = await fetchWechatArticle("https://mp.weixin.qq.com/s/xxxxx");
// 抓取文章并自动保存为 Markdown 文件
const result = await fetchWechatArticle(
"https://mp.weixin.qq.com/s/xxxxx",
3, // 重试次数(可选)
"./output.md" // 保存路径(可选)
);
// 返回格式
{
title: "文章标题",
content: "文章正文...",
url: "文章URL"
}命令行调用
# 基本用法(仅输出到控制台)
node scripts/fetch.js "https://mp.weixin.qq.com/s/xxxxx"
# 保存为指定文件
node scripts/fetch.js "https://mp.weixin.qq.com/s/xxxxx" "./articles/my-article.md"
# 保存到目录(自动使用文章标题作为文件名)
node scripts/fetch.js "https://mp.weixin.qq.com/s/xxxxx" "./articles/"输出格式
控制台输出
标题: 文章标题
文章正文第一段...
文章正文第二段...Markdown 文件格式
# 文章标题
> 原文链接: https://mp.weixin.qq.com/s/xxxxx
> 抓取时间: 2026-01-21 20:30:00
---
文章正文第一段...

文章正文第二段...文件结构
当文章包含图片时,会自动生成以下文件结构:
输出目录/
├── 文章标题.md # Markdown 文件
└── 文章标题_assets/ # 图片资源文件夹
├── image_xxx_0.jpg
├── image_xxx_1.jpg
└── ...图片筛选
默认启用智能图片筛选,自动过滤小于 15KB 的装饰性图片(如社交媒体按钮、表情符号等)。
可以在 scripts/fetch.js 中修改筛选配置:
const IMAGE_FILTER_CONFIG = {
minFileSize: 15 * 1024, // 最小文件大小(字节)
enabled: true // 是否启用筛选
};技术实现
依赖要求
- Playwright (
npx playwright install chromium) - Node.js >= 14.0.0
抓取流程
1. 检测并安装 Playwright(如需要) 2. 启动 Playwright headless 浏览器 3. 设置反检测参数(User-Agent, webdriver隐藏等) 4. 导航到目标URL,等待网络空闲 5. 滚动页面触发懒加载 6. 提取 #js_content或 .rich_media_content区域 7. 清理HTML标签,保留段落结构 8. 返回标题和纯文本内容 9. 如果指定了保存路径,自动保存为 Markdown 文件 10. 如果无头模式失败,自动回退到有头模式重试
错误处理
- 自动重试3次,每次失败后等待3秒
- 无头模式失败后自动回退到有头模式
- 检测错误页面(参数错误、访问异常)
- 超时设置30秒
- Windows 平台特殊处理(路径、命令格式)
跨平台兼容性
- Windows: 自动检测并使用
cmd.exe运行 npx 命令 - macOS/Linux: 直接使用 npx 命令
- 路径处理: 自动规范化路径分隔符
- 文件名处理: 自动移除 Windows 非法字符
适用场景
- 内容转换工具的输入源
- 文章分析和处理
- 自动化内容抓取
- 批量文章下载
- 文章归档和本地保存
- Markdown 格式转换
- 法律文档自动格式化(检测到法律内容时)
- 图文文章完整保存(包含图片的离线归档)
- 图片资源管理(自动下载并组织文章中的图片)
使用示例
示例 1: 批量抓取并保存
const urls = [
"https://mp.weixin.qq.com/s/xxxx1",
"https://mp.weixin.qq.com/s/xxxx2",
"https://mp.weixin.qq.com/s/xxxx3"
];
for (const url of urls) {
const result = await fetchWechatArticle(url, 3, "./articles/");
console.log(`已保存: ${result.title}`);
}示例 2: 在 Claude Code 中直接使用
请帮我抓取这个微信公众号文章并保存为 Markdown 文件:
https://mp.weixin.qq.com/s/xxxxx注意事项
⚠️ 仅用于个人学习和研究,请遵守网站服务条款 ⚠️ 频繁抓取可能被限流,建议控制请求频率 ⚠️ 抓取的内容版权归原作者所有 ⚠️ 有头模式会弹出浏览器窗口,可能干扰工作流程 ⚠️ Windows 用户首次使用需要安装 Playwright(会自动安装)
更新日志
[1.3.1] - 2026-04-20
新增
- ✨ 法律内容自动格式化协议:抓取法律内容后自动链式调用
legal-text-format - ✨ 法律内容判定清单:基于标题关键词、内容特征和结构特征的明确判定标准
- ✨ 降级提示:
legal-text-format未安装时给出安装建议
改进
- 🔧 将"智能工作流"从描述性说明升级为必须遵守的执行指令
- 🔧 添加标准汇报模板,区分"仅抓取"和"抓取+格式化"两种结果
- 🔧 技能存在性检测:通过检查
skills/legal-text-format/SKILL.md判断是否可链式调用
---
[1.3.0] - 2026-04-20
新增
- ✨ 内置 Archive 机制:默认保存到 skill 内部
archive/{日期}_{标题}/目录 - ✨ 自包含存储:所有抓取内容和图片资源都保存在 skill 目录内
- ✨ 自动目录创建:未指定保存路径时自动创建带时间戳的归档目录
改进
- 🔧 默认保存行为:不指定路径时自动保存到 skill 内部 archive
- 🔧 目录结构:
archive/
└── 20260420_种业知识产权典型案例/
├── 最高人民法院发布第六批人民法院...md
└── 最高人民法院发布第六批人民法院..._assets/技术细节
- 新增
SKILL_ROOT常量:指向 skill 根目录 - 新增
getDefaultArchiveDir()函数:生成带时间戳的归档目录路径 - 修改
fetchWechatArticle()函数:无路径参数时自动使用 archive 目录
---
[1.2.0] - 2026-01-29
新增
- ✨ 图片下载功能:自动下载文章中的所有图片到本地
- ✨ 智能图片筛选:自动过滤小于指定大小的装饰性图片(默认 < 15KB)
- ✨ 图片位置保持:使用占位符机制,保留图片在原文档中的位置
- ✨ 自动文件命名:根据文章标题自动生成 Markdown 文件名和资源文件夹名
- ✨ 资源文件夹:图片保存到
{文件名}_assets文件夹中
改进
- 🔧 图片引用格式:在 Markdown 中使用
格式引用本地图片 - 🔧 筛选配置:支持自定义文件大小阈值和启用/禁用筛选
- 🔧 占位符清理:被过滤的图片占位符自动移除,不显示错误提示
- 🔧 下载进度显示:显示图片下载进度和筛选统计信息
技术细节
- 新增
downloadImage()函数:下载单个图片,支持重定向和超时处理 - 新增
downloadImages()函数:批量下载图片,支持文件大小筛选 - 新增
IMAGE_FILTER_CONFIG配置对象:控制筛选行为 - 改进
saveAsMarkdown()函数:下载图片并替换占位符为 Markdown 图片引用 - 图片命名使用时间戳和索引,避免重名冲突
使用示例
# 保存文章并自动下载图片
node fetch.js "文章URL" "/path/to/directory/"
# 生成的文件结构:
# ├── 文章标题.md
# └── 文章标题_assets/
# ├── image_xxx_0.jpg
# └── image_xxx_1.jpg---
[1.1.0] - 2026-01-22
新增
- ✨ 智能工作流说明:检测法律内容时自动调用
legal-text-format - ✨ "与其他技能的协作"章节,说明智能判断逻辑
- ✨ 法律内容检测特征说明(标题关键词、内容特征、结构特征)
- ✨ 默认保存位置说明(项目根目录)
改进
- 📝 完善技能文档,明确工作流程和协作方式
- 🔧 保持工具通用性:仅 AI 智能判断为法律内容时才调用格式化
- 🔧 添加保存位置示例,说明不同路径的处理方式
- 🔧 明确工具定位:专注于文章抓取,格式化由专门的 skill 处理
技术细节
- 智能判断基于:标题关键词、内容特征、结构特征
- 支持法律内容自动触发格式化工作流
- 非法律内容保持原有抓取功能
---
[1.0.0] - 2026-01-21
新增
- ✨ 自动保存为 Markdown 文件功能,支持指定文件路径或目录
- ✨ 智能回退机制:无头模式失败时自动切换到有头模式重试
- ✨ 完整的 Windows 平台支持
修复
- 🐛 修复 Windows 平台上 npx 命令执行问题
- 🐛 修复 ES 模块路径检测在 Windows 上的兼容性问题
- 🐛 修复路径分隔符在 Windows 上的处理问题
- 🐛 修复文件名包含 Windows 非法字符时的保存问题
改进
- 📝 更新技能文档,添加详细的使用示例
- 🔧 增强错误处理,提供更清晰的错误提示
- 🔧 优化命令行参数处理,支持可选的输出路径
技术细节
- 添加
getNpxCommand()函数,根据平台自动选择正确的命令格式 - 实现
isMainModuleCheck()函数,使用路径规范化处理跨平台兼容性 - 实现
saveAsMarkdown()函数,自动处理文件名和目录创建 - 增强
fetchWechatArticle()函数,支持可选的自动保存参数
---
[0.1.1] - 2025-01-13
新增
- 完善技能文件结构,符合 legal-skills 项目规范
- 添加
.claude-plugin/plugin.json元数据文件 - 添加
package.json支持 npm 模块化 - 添加
LICENSE(MIT) 许可证文件 - 创建
archive/目录结构
修复
- 修复主模块检测条件,支持带空格路径的脚本执行
- 修复 Playwright API 兼容性问题,使用 newContext 替代直接 setUserAgent
- 更新到最新的 Playwright API (v1.40+)
测试
- 完成实际微信公众号文章抓取测试
- 验证所有功能正常运行
待办事项
- [ ] 添加批量抓取功能
- [x] 支持保存抓取结果到文件(已在 v1.0.0 实现)
- [ ] 添加单元测试
- [ ] 优化错误页面检测逻辑
---
[0.1.0] - 2025-01-13
新增
- 实现微信公众号文章抓取功能
- 支持 Playwright headless 模式抓取
- 自动检测并安装 Playwright 依赖
- 支持自动重试机制(最多3次)
- 反检测设置,避免被反爬虫机制识别
- 动态加载懒加载内容
- 提取文章标题和正文内容
- 返回纯文本格式,便于后续处理
技术优化
- 使用 ES6+ 模块化语法
- 完善的错误处理和重试逻辑
- 自动安装浏览器依赖
- 优化的 User-Agent 和视口设置
MIT License
Copyright (c) 2025 杨卫薪律师(微信ywxlaw)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "wechat-article-fetch",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.59.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
{
"dependencies": {
"playwright": "^1.59.1"
}
}
#!/usr/bin/env node
/**
* 微信公众号文章抓取脚本
* 使用 Playwright headless 模式,无弹窗后台抓取
* 自动检测并安装 Playwright
*
* 用法: node fetch.js <URL> [output.md]
*/
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join, basename } from 'path';
import { writeFile, mkdir, stat, unlink } from 'fs/promises';
import { existsSync, createWriteStream } from 'fs';
import https from 'https';
import http from 'http';
// 获取当前文件路径(兼容 Windows)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 获取 skill 根目录(archive 将创建在此目录下)
const SKILL_ROOT = __dirname;
// 检测平台
const isWindows = process.platform === 'win32';
/**
* 获取默认的 archive 目录路径
* @param {string} title - 文章标题
* @returns {string} archive 目录的完整路径
*/
function getDefaultArchiveDir(title) {
const now = new Date();
const dateStr = now.toISOString().replace(/[-:T]/g, '').substring(0, 14); // YYYYMMDD_HHMMSS
const safeTitle = title
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, '_')
.substring(0, 30);
return join(SKILL_ROOT, 'archive', `${dateStr}_${safeTitle}`);
}
// 图片筛选配置
const IMAGE_FILTER_CONFIG = {
// 最小文件大小(字节),小于此值的图片将被过滤
// 默认 15KB,可以过滤掉小的表情符号、按钮图标等
minFileSize: 15 * 1024,
// 是否启用筛选
enabled: true
};
// 获取适当的命令和参数
function getNpxCommand() {
if (isWindows) {
// Windows: 使用 cmd.exe
return {
command: 'cmd',
args: ['/c', 'npx', '-y', 'playwright', 'install', 'chromium'],
shell: false
};
} else {
// Unix-like: 直接使用 npx
return {
command: 'npx',
args: ['-y', 'playwright', 'install', 'chromium'],
shell: false
};
}
}
// 检查并安装 Playwright
async function ensurePlaywright() {
try {
// 尝试导入 playwright
await import('playwright');
return true;
} catch (error) {
console.log('⚠️ 未检测到 Playwright,正在自动安装...');
console.log('这可能需要几分钟时间,请耐心等待...\n');
return new Promise((resolve, reject) => {
const { command, args, shell } = getNpxCommand();
// 安装 playwright
const install = spawn(command, args, {
stdio: 'inherit',
shell
});
install.on('close', (code) => {
if (code === 0) {
console.log('\n✅ Playwright 安装完成!');
resolve(true);
} else {
console.error('\n❌ Playwright 安装失败');
reject(new Error('Playwright installation failed'));
}
});
install.on('error', (err) => {
console.error('\n❌ 启动安装进程失败:', err.message);
reject(err);
});
});
}
}
async function fetchWechatArticle(url, retries = 3, autoSavePath = null) {
// 确保 Playwright 已安装
await ensurePlaywright();
// 动态导入 playwright
const { chromium } = await import('playwright');
// 首先尝试无头模式
for (let attempt = 1; attempt <= retries; attempt++) {
try {
console.log(`尝试 ${attempt}/${retries}: 抓取 ${url}`);
const result = await attemptFetch(chromium, url, { headless: true });
console.log('✅ 抓取成功!');
// 如果没有指定保存路径,默认保存到 archive 目录
const savePath = autoSavePath || getDefaultArchiveDir(result.title);
// 确保 archive 目录存在
const archiveDir = typeof savePath === 'string' && !savePath.endsWith('.md')
? savePath
: join(getDefaultArchiveDir(result.title).replace(/\.md$/, ''));
await mkdir(archiveDir, { recursive: true });
await saveAsMarkdown(result, savePath);
return result;
} catch (error) {
console.error(`❌ 尝试 ${attempt} 失败:`, error.message);
if (attempt === retries) {
console.log('⚠️ 无头模式失败,尝试使用有头模式...');
try {
const result = await attemptFetch(chromium, url, { headless: false });
console.log('✅ 有头模式抓取成功!');
// 如果没有指定保存路径,默认保存到 archive 目录
const savePath = autoSavePath || getDefaultArchiveDir(result.title);
const archiveDir = typeof savePath === 'string' && !savePath.endsWith('.md')
? savePath
: join(getDefaultArchiveDir(result.title).replace(/\.md$/, ''));
await mkdir(archiveDir, { recursive: true });
await saveAsMarkdown(result, savePath);
return result;
} catch (headedError) {
console.error('❌ 有头模式也失败了:', headedError.message);
throw headedError;
}
}
console.log(`⏳ 等待 3 秒后重试...`);
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
}
async function attemptFetch(chromium, url, options = {}) {
const { headless = true } = options;
const browser = await chromium.launch({
headless,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-web-security',
'--disable-features=VizDisplayCompositor'
]
});
try {
// 创建浏览器上下文,指定 User-Agent
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1366, height: 768 }
});
// 创建页面
const page = await context.newPage();
// 反检测设置
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
window.chrome = { runtime: {} };
});
console.log('正在访问:', url);
await page.goto(url, {
waitUntil: 'networkidle',
timeout: 30000
});
// 等待页面加载完成
await page.waitForTimeout(3000);
// 滚动页面触发懒加载
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
await page.waitForTimeout(2000);
// 提取文章内容和图片信息
const content = await page.evaluate(() => {
// 获取微信公众号文章主体
const article = document.querySelector('#js_content') ||
document.querySelector('.rich_media_content') ||
document.body;
const rawHtml = article.innerHTML;
// 检测错误页面
const isErrorPage = rawHtml.includes('参数错误') ||
rawHtml.includes('访问异常') ||
rawHtml.includes('此内容无法查看') ||
document.title === '微信公众平台';
if (isErrorPage) {
throw new Error('检测到错误页面,可能URL无效或需要登录');
}
// 提取所有图片信息
const images = [];
const imgElements = article.querySelectorAll('img');
imgElements.forEach((img, index) => {
const src = img.getAttribute('data-src') || img.src || img.getAttribute('src');
const alt = img.alt || `图片${index + 1}`;
if (src && !src.startsWith('data:')) {
images.push({
url: src,
alt: alt,
index: index
});
}
});
// 清理HTML,保留段落结构和图片位置
let processedContent = rawHtml;
// 将图片标签替换为占位符,保留图片在文档中的位置
let imageIndex = 0;
processedContent = processedContent.replace(/<img[^>]*>/gi, (match) => {
// 提取图片的 data-src 或 src
const srcMatch = match.match(/data-src=["']([^"']+)["']/) ||
match.match(/src=["']([^"']+)["']/);
if (srcMatch) {
const placeholder = `{{IMAGE_${imageIndex}}}`;
imageIndex++;
return `\n\n${placeholder}\n\n`;
}
return '';
});
// 清理剩余的HTML标签,保留结构
let cleanText = processedContent
// 段落标签替换为双换行
.replace(/<p[^>]*>/gi, '\n\n')
.replace(/<\/p>/gi, '')
// 标题标签
.replace(/<h[1-6][^>]*>/gi, '\n\n### ')
.replace(/<\/h[1-6]>/gi, '\n\n')
// br标签替换为换行
.replace(/<br\s*\/?>/gi, '\n')
// 移除剩余HTML标签(不包括图片占位符)
.replace(/<[^>]+>/g, '')
// 处理HTML实体
.replace(/ /g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, "'")
// 清理多余空行(最多保留两个连续换行)
.replace(/\n{3,}/g, '\n\n')
.replace(/^\n+/, '')
.replace(/\n+$/, '')
.trim();
return {
title: document.title.replace('微信公众平台', '').trim(),
content: cleanText,
url: window.location.href,
images: images
};
});
console.log('抓取成功!');
console.log('标题:', content.title);
console.log('内容长度:', content.content.length, '字符');
return content;
} catch (error) {
console.error('抓取失败:', error.message);
throw error;
} finally {
await browser.close();
}
}
/**
* 下载单个图片
* @param {string} url - 图片URL
* @param {string} filepath - 保存路径
* @returns {Promise<void>}
*/
function downloadImage(url, filepath) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const request = protocol.get(url, (response) => {
// 处理重定向
if (response.statusCode === 301 || response.statusCode === 302) {
downloadImage(response.headers.location, filepath).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`下载图片失败: ${response.statusCode}`));
return;
}
const fileStream = createWriteStream(filepath);
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve();
});
fileStream.on('error', (err) => {
// 删除不完整的文件
try {
const fs = require('fs');
fs.unlink(filepath, () => {});
} catch (e) {}
reject(err);
});
});
request.on('error', reject);
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('下载图片超时'));
});
});
}
/**
* 批量下载图片
* @param {Array} images - 图片信息数组 [{url, alt, index}]
* @param {string} imagesDir - 图片保存目录
* @returns {Promise<Object>} 图片索引到文件名的映射
*/
async function downloadImages(images, imagesDir) {
if (!images || images.length === 0) {
return {};
}
console.log(`\n📥 发现 ${images.length} 张图片,开始下载...`);
// 确保图片目录存在
if (!existsSync(imagesDir)) {
await mkdir(imagesDir, { recursive: true });
}
const imageMap = {};
let successCount = 0;
let failCount = 0;
let filteredCount = 0;
for (let i = 0; i < images.length; i++) {
const img = images[i];
try {
// 从 URL 中提取文件扩展名,如果没有则使用 .jpg
let ext = '.jpg';
const urlMatch = img.url.match(/\.([a-z]{3,4})(?:\?|$)/i);
if (urlMatch) {
ext = '.' + urlMatch[1].toLowerCase();
}
// 生成文件名:使用时间戳和索引避免重名
const filename = `image_${Date.now()}_${i}${ext}`;
const filepath = join(imagesDir, filename);
// 下载图片
await downloadImage(img.url, filepath);
// 检查文件大小,过滤掉太小的图片
if (IMAGE_FILTER_CONFIG.enabled) {
const stats = await stat(filepath);
const fileSize = stats.size;
if (fileSize < IMAGE_FILTER_CONFIG.minFileSize) {
// 删除太小的图片
await unlink(filepath);
filteredCount++;
const sizeKB = (fileSize / 1024).toFixed(2);
console.log(` 🔍 [${i + 1}/${images.length}] 已过滤 (${sizeKB}KB < ${IMAGE_FILTER_CONFIG.minFileSize / 1024}KB): ${img.alt}`);
continue;
}
}
imageMap[i] = {
filename: filename,
alt: img.alt
};
successCount++;
console.log(` ✅ [${i + 1}/${images.length}] ${img.alt}`);
} catch (error) {
failCount++;
console.log(` ❌ [${i + 1}/${images.length}] 下载失败: ${error.message}`);
}
}
console.log(`📊 图片下载完成: 成功 ${successCount} 张, 过滤 ${filteredCount} 张, 失败 ${failCount} 张\n`);
return imageMap;
}
/**
* 将抓取的文章保存为 Markdown 文件
* @param {Object} article - 文章对象 {title, content, url, images}
* @param {string} outputPath - 输出文件路径
*/
async function saveAsMarkdown(article, outputPath) {
try {
// 规范化文件名(移除非法字符)
const safeTitle = article.title
.replace(/[<>:"/\\|?*]/g, '') // 移除 Windows 非法字符
.replace(/\s+/g, '_') // 空格替换为下划线
.substring(0, 100); // 限制长度
// 检查是目录还是文件路径
let finalPath = outputPath;
try {
const stats = await stat(outputPath);
if (stats.isDirectory()) {
// 如果是目录,使用标题作为文件名
finalPath = join(outputPath, `${safeTitle}.md`);
}
} catch {
// 路径不存在或不是目录,直接使用给定的路径
// 确保路径以 .md 结尾
if (!finalPath.endsWith('.md')) {
finalPath = `${finalPath}.md`;
}
}
// 确保目录存在
const dir = dirname(finalPath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
// 下载图片并获取图片映射
let content = article.content;
let imagesDir = null;
if (article.images && article.images.length > 0) {
// 创建图片保存目录(与 Markdown 文件同名)
const mdFileBasename = finalPath.replace(/\.md$/, '');
imagesDir = `${mdFileBasename}_assets`;
const imageMap = await downloadImages(article.images, imagesDir);
// 替换内容中的图片占位符
content = content.replace(/\{\{IMAGE_(\d+)\}\}/g, (match, index) => {
const imgIndex = parseInt(index);
if (imageMap[imgIndex]) {
const { filename, alt } = imageMap[imgIndex];
// 计算相对路径
const relativePath = join(basename(imagesDir), filename);
return ``;
}
// 被过滤掉的图片,移除占位符
return '';
});
// 清理多余的空行(移除图片后可能产生的连续空行)
content = content.replace(/\n{3,}/g, '\n\n');
}
// 生成 Markdown 内容
const markdown = `# ${article.title}
> 原文链接: ${article.url}
> 抓取时间: ${new Date().toLocaleString('zh-CN')}
---
${content}
`;
// 写入文件
await writeFile(finalPath, markdown, 'utf-8');
console.log(`✅ 文章已保存到: ${finalPath}`);
if (imagesDir) {
console.log(`📁 图片已保存到: ${imagesDir}`);
}
return finalPath;
} catch (error) {
console.error('❌ 保存文件失败:', error.message);
throw error;
}
}
/**
* 检测是否为主模块(兼容 Windows)
*/
function isMainModuleCheck() {
try {
// 方法1: 直接路径比较(Windows 兼容)
const mainPath = fileURLToPath(import.meta.url);
const argvPath = process.argv[1];
// 规范化路径后再比较
const normalizedMain = mainPath.replace(/\\/g, '/');
const normalizedArgv = argvPath.replace(/\\/g, '/');
if (normalizedMain === normalizedArgv) {
return true;
}
// 方法2: 检查是否包含文件名(备用方案)
const mainFileName = basename(mainPath);
const argvFileName = basename(argvPath);
return mainFileName === argvFileName && argvFileName.includes('fetch.js');
} catch (error) {
// 如果路径检测失败,回退到简单检查
return process.argv[1].includes('fetch.js');
}
}
// 命令行调用
const isMainModule = isMainModuleCheck();
if (isMainModule) {
const url = process.argv[2];
const outputPath = process.argv[3]; // 可选的输出路径
if (!url) {
console.error('用法: node fetch.js <微信公众号文章URL> [输出路径]');
console.error('');
console.error('参数:');
console.error(' URL 微信公众号文章链接(必填)');
console.error(' 输出路径 保存为 Markdown 文件的路径(可选)');
console.error(' 可以是文件路径或目录,如果是目录则使用文章标题作为文件名');
console.error('');
console.error('示例:');
console.error(' node fetch.js "https://mp.weixin.qq.com/s/xxxxx"');
console.error(' node fetch.js "https://mp.weixin.qq.com/s/xxxxx" "./articles/my-article.md"');
console.error(' node fetch.js "https://mp.weixin.qq.com/s/xxxxx" "./articles/"');
process.exit(1);
}
fetchWechatArticle(url, 3, outputPath)
.then(result => {
console.log('\n=== 抓取结果 ===');
console.log('标题:', result.title);
console.log('URL:', result.url);
console.log('\n=== 文章内容 ===');
console.log(result.content);
console.log('\n✅ 完成!');
})
.catch(error => {
console.error('\n❌ 错误:', error.message);
process.exit(1);
});
}
// 导出供其他模块使用
export { fetchWechatArticle };