feat: add weixin article download adapter & abstract download helpers (#280)

- New: src/clis/weixin/download.ts — WeChat article to Markdown adapter
- New: src/download/article-download.ts — shared article download helper
  (TurndownService, image localization, frontmatter, customizable labels)
- New: src/download/media-download.ts — shared media download helper
  (batch download, ProgressTracker, yt-dlp routing, auto cookie export)
- Refactor: migrate zhihu/download to use downloadArticle()
- Refactor: migrate xiaohongshu/download to use downloadMedia()
- Refactor: migrate twitter/download to use downloadMedia()
- Refactor: migrate bilibili/download to use downloadMedia()
- Docs: add weixin to README, README.zh-CN, download docs, adapter docs
This commit is contained in:
jakevin
2026-03-23 12:11:43 +08:00
committed by GitHub
parent 722c180a0a
commit b7c6c02370
12 changed files with 773 additions and 425 deletions
+5
View File
@@ -115,6 +115,7 @@ Run `opencli list` for the live registry.
| **apple-podcasts** | `search` `episodes` `top` | Public |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
| **zhihu** | `hot` `search` `question` `download` | Browser |
| **weixin** | `download` | Browser |
| **youtube** | `search` `video` `transcript` | Browser |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
| **coupang** | `search` `add-to-cart` | Browser |
@@ -192,6 +193,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
### Prerequisites
@@ -225,6 +227,9 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# Export with local images
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# Export WeChat article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
+5
View File
@@ -117,6 +117,7 @@ npm install -g @jackwener/opencli@latest
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
@@ -194,6 +195,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **知乎** | 文章Markdown | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章Markdown | 导出微信公众号文章为 Markdown |
### 前置依赖
@@ -227,6 +229,9 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# 导出并下载图片
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# 导出微信公众号文章为 Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
+33
View File
@@ -0,0 +1,33 @@
# WeChat (微信公众号)
**Mode**: 🔐 Browser · **Domain**: `mp.weixin.qq.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli weixin download` | 下载微信公众号文章为 Markdown 格式 |
## Usage Examples
```bash
# Export article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
# Export with locally downloaded images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --download-images
# Export without images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --no-download-images
```
## Output
Downloads to `<output>/<article-title>/`:
- `<article-title>.md` — Markdown with frontmatter (title, author, publish time, source URL)
- `images/` — Downloaded images (if `--download-images` is enabled, default: true)
## Prerequisites
- Chrome running and **logged into** mp.weixin.qq.com (for articles behind login wall)
- [Browser Bridge extension](/guide/browser-bridge) installed
+4
View File
@@ -10,6 +10,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
## Prerequisites
@@ -43,6 +44,9 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# Export with local images
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# Export WeChat article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
## Pipeline Step (YAML Adapters)
+23 -81
View File
@@ -8,18 +8,9 @@
* - yt-dlp must be installed: pip install yt-dlp
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import {
ytdlpDownload,
checkYtdlp,
sanitizeFilename,
getTempDir,
exportCookiesToNetscape,
formatCookieHeader,
} from '../../download/index.js';
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
import { checkYtdlp, sanitizeFilename } from '../../download/index.js';
import { downloadMedia } from '../../download/media-download.js';
cli({
site: 'bilibili',
@@ -63,21 +54,8 @@ cli({
const title = sanitizeFilename(data?.title || 'video');
// Extract cookies for authenticated downloads
const cookies = await page.getCookies({ domain: 'bilibili.com' });
const cookieString = formatCookieHeader(cookies);
// Create output directory
fs.mkdirSync(output, { recursive: true });
// Export cookies to Netscape format for yt-dlp
let cookiesFile: string | undefined;
if (cookies.length > 0) {
const tempDir = getTempDir();
fs.mkdirSync(tempDir, { recursive: true });
cookiesFile = path.join(tempDir, `bilibili_cookies_${Date.now()}.txt`);
exportCookiesToNetscape(cookies, cookiesFile);
}
// Extract cookies for yt-dlp
const browserCookies = await page.getCookies({ domain: 'bilibili.com' });
// Build yt-dlp format string based on quality
let format = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best';
@@ -89,62 +67,26 @@ cli({
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
}
const destPath = path.join(output, `${bvid}_${title}.mp4`);
const videoUrl = `https://www.bilibili.com/video/${bvid}`;
const filename = `${bvid}_${title}.mp4`;
const tracker = new DownloadProgressTracker(1, true);
const progressBar = tracker.onFileStart(`${bvid}.mp4`, 0);
const results = await downloadMedia(
[{ type: 'video-ytdlp', url: videoUrl, filename }],
{
output,
browserCookies,
filenamePrefix: bvid,
ytdlpExtraArgs: ['-f', format, '--merge-output-format', 'mp4', '--embed-thumbnail'],
},
);
try {
const result = await ytdlpDownload(
`https://www.bilibili.com/video/${bvid}`,
destPath,
{
cookiesFile,
format,
extraArgs: [
'--merge-output-format', 'mp4',
'--embed-thumbnail',
],
onProgress: (percent) => {
if (progressBar) progressBar.update(percent, 100);
},
},
);
if (progressBar) {
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
}
tracker.onFileComplete(result.success);
tracker.finish();
// Cleanup cookies file
if (cookiesFile && fs.existsSync(cookiesFile)) {
fs.unlinkSync(cookiesFile);
}
return [{
bvid,
title: data?.title || 'video',
status: result.success ? 'success' : 'failed',
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
}];
} catch (err: any) {
if (progressBar) progressBar.fail(err.message);
tracker.onFileComplete(false);
tracker.finish();
// Cleanup cookies file
if (cookiesFile && fs.existsSync(cookiesFile)) {
fs.unlinkSync(cookiesFile);
}
return [{
bvid,
title: data?.title || 'video',
status: 'failed',
size: err.message,
}];
}
// Map results to bilibili-specific columns
const r = results[0] || { status: 'failed', size: '-' };
return [{
bvid,
title: data?.title || 'video',
status: r.status,
size: r.size,
}];
},
});
+13 -111
View File
@@ -6,19 +6,9 @@
* opencli twitter download --tweet-url https://x.com/xxx/status/123 --output ./twitter
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import {
httpDownload,
ytdlpDownload,
checkYtdlp,
sanitizeFilename,
getTempDir,
exportCookiesToNetscape,
formatCookieHeader,
} from '../../download/index.js';
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
import { formatCookieHeader } from '../../download/index.js';
import { downloadMedia } from '../../download/media-download.js';
cli({
site: 'twitter',
@@ -101,32 +91,11 @@ cli({
`);
if (!data || data.length === 0) {
return [{
index: 0,
type: '-',
status: 'failed',
size: 'No media found',
}];
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
}
// Extract cookies
const cookies = await page.getCookies({ domain: 'x.com' });
const cookieString = formatCookieHeader(cookies);
// Create output directory
const outputDir = tweetUrl
? path.join(output, 'tweets')
: path.join(output, username || 'media');
fs.mkdirSync(outputDir, { recursive: true });
// Export cookies for yt-dlp
let cookiesFile: string | undefined;
if (cookies.length > 0) {
const tempDir = getTempDir();
fs.mkdirSync(tempDir, { recursive: true });
cookiesFile = path.join(tempDir, `twitter_cookies_${Date.now()}.txt`);
exportCookiesToNetscape(cookies, cookiesFile);
}
const browserCookies = await page.getCookies({ domain: 'x.com' });
// Deduplicate media
const seen = new Set<string>();
@@ -136,81 +105,14 @@ cli({
return true;
}).slice(0, limit);
const tracker = new DownloadProgressTracker(uniqueMedia.length, true);
const results: any[] = [];
for (let i = 0; i < uniqueMedia.length; i++) {
const media = uniqueMedia[i];
const ext = media.type === 'image' ? 'jpg' : 'mp4';
const filename = `${username || 'tweet'}_${i + 1}.${ext}`;
const destPath = path.join(outputDir, filename);
const progressBar = tracker.onFileStart(filename, i);
try {
let result: { success: boolean; size: number; error?: string };
if (media.type === 'video-tweet' && checkYtdlp()) {
// Use yt-dlp for video tweets
result = await ytdlpDownload(media.url, destPath, {
cookiesFile,
extraArgs: ['--merge-output-format', 'mp4'],
onProgress: (percent) => {
if (progressBar) progressBar.update(percent, 100);
},
});
} else if (media.type === 'image') {
// Direct HTTP download for images
result = await httpDownload(media.url, destPath, {
cookies: cookieString,
timeout: 30000,
onProgress: (received, total) => {
if (progressBar) progressBar.update(received, total);
},
});
} else {
// Direct HTTP download for direct video URLs
result = await httpDownload(media.url, destPath, {
cookies: cookieString,
timeout: 60000,
onProgress: (received, total) => {
if (progressBar) progressBar.update(received, total);
},
});
}
if (progressBar) {
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
}
tracker.onFileComplete(result.success);
results.push({
index: i + 1,
type: media.type === 'video-tweet' ? 'video' : media.type,
status: result.success ? 'success' : 'failed',
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
});
} catch (err: any) {
if (progressBar) progressBar.fail(err.message);
tracker.onFileComplete(false);
results.push({
index: i + 1,
type: media.type,
status: 'failed',
size: err.message,
});
}
}
tracker.finish();
// Cleanup cookies file
if (cookiesFile && fs.existsSync(cookiesFile)) {
fs.unlinkSync(cookiesFile);
}
return results;
const subdir = tweetUrl ? 'tweets' : (username || 'media');
return downloadMedia(uniqueMedia, {
output,
subdir,
cookies: formatCookieHeader(browserCookies),
browserCookies,
filenamePrefix: username || 'tweet',
ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
});
},
});
+199
View File
@@ -0,0 +1,199 @@
/**
* WeChat article download — export WeChat Official Account articles to Markdown.
*
* Ported from jackwener/wechat-article-to-markdown (JS version) to OpenCLI adapter.
*
* Usage:
* opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
*/
import { cli, Strategy } from '../../registry.js';
import { downloadArticle } from '../../download/article-download.js';
// ============================================================
// URL Normalization
// ============================================================
/**
* Normalize a pasted WeChat article URL.
*/
export function normalizeWechatUrl(raw: string): string {
let s = (raw || '').trim();
if (!s) return s;
// Strip wrapping quotes / angle brackets
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1).trim();
}
if (s.startsWith('<') && s.endsWith('>')) {
s = s.slice(1, -1).trim();
}
// Remove backslash escapes before URL-significant characters
s = s.replace(/\\+([:/&?=#%])/g, '$1');
// Decode HTML entities
s = s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
// Allow bare hostnames
if (s.startsWith('mp.weixin.qq.com/') || s.startsWith('//mp.weixin.qq.com/')) {
s = 'https://' + s.replace(/^\/+/, '');
}
// Force https for mp.weixin.qq.com
try {
const parsed = new URL(s);
if (['http:', 'https:'].includes(parsed.protocol) && parsed.hostname.toLowerCase() === 'mp.weixin.qq.com') {
parsed.protocol = 'https:';
s = parsed.toString();
}
} catch {
// Ignore parse errors
}
return s;
}
// ============================================================
// CLI Registration
// ============================================================
cli({
site: 'weixin',
name: 'download',
description: '下载微信公众号文章为 Markdown 格式',
domain: 'mp.weixin.qq.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'url', required: true, help: 'WeChat article URL (mp.weixin.qq.com/s/xxx)' },
{ name: 'output', default: './weixin-articles', help: 'Output directory' },
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
],
columns: ['title', 'author', 'publish_time', 'status', 'size'],
func: async (page, kwargs) => {
const rawUrl = kwargs.url;
const url = normalizeWechatUrl(rawUrl);
if (!url.startsWith('https://mp.weixin.qq.com/')) {
return [{ title: 'Error', author: '-', publish_time: '-', status: 'invalid URL', size: '-' }];
}
// Navigate and wait for content to load
await page.goto(url);
await page.wait(5);
// Extract article data in browser context
const data = await page.evaluate(`
(() => {
const result = {
title: '',
author: '',
publishTime: '',
contentHtml: '',
codeBlocks: [],
imageUrls: []
};
// Title: #activity-name
const titleEl = document.querySelector('#activity-name');
result.title = titleEl ? titleEl.textContent.trim() : '';
// Author (WeChat Official Account name): #js_name
const authorEl = document.querySelector('#js_name');
result.author = authorEl ? authorEl.textContent.trim() : '';
// Publish time: extract create_time from script tags
const htmlStr = document.documentElement.innerHTML;
let timeMatch = htmlStr.match(/create_time\\s*:\\s*JsDecode\\('([^']+)'\\)/);
if (!timeMatch) timeMatch = htmlStr.match(/create_time\\s*:\\s*'(\\d+)'/);
if (!timeMatch) timeMatch = htmlStr.match(/create_time\\s*[:=]\\s*["']?(\\d+)["']?/);
if (timeMatch) {
const ts = parseInt(timeMatch[1], 10);
if (ts > 0) {
const d = new Date(ts * 1000);
const pad = n => String(n).padStart(2, '0');
const utc8 = new Date(d.getTime() + 8 * 3600 * 1000);
result.publishTime =
utc8.getUTCFullYear() + '-' +
pad(utc8.getUTCMonth() + 1) + '-' +
pad(utc8.getUTCDate()) + ' ' +
pad(utc8.getUTCHours()) + ':' +
pad(utc8.getUTCMinutes()) + ':' +
pad(utc8.getUTCSeconds());
}
}
// Content processing
const contentEl = document.querySelector('#js_content');
if (!contentEl) return result;
// Fix lazy-loaded images: data-src -> src
contentEl.querySelectorAll('img').forEach(img => {
const dataSrc = img.getAttribute('data-src');
if (dataSrc) img.setAttribute('src', dataSrc);
});
// Extract code blocks with placeholder replacement
const codeBlocks = [];
contentEl.querySelectorAll('.code-snippet__fix').forEach(el => {
el.querySelectorAll('.code-snippet__line-index').forEach(li => li.remove());
const pre = el.querySelector('pre[data-lang]');
const lang = pre ? (pre.getAttribute('data-lang') || '') : '';
const lines = [];
el.querySelectorAll('code').forEach(codeTag => {
const text = codeTag.textContent;
if (/^[ce]?ounter\\(line/.test(text)) return;
lines.push(text);
});
if (lines.length === 0) lines.push(el.textContent);
const placeholder = 'CODEBLOCK-PLACEHOLDER-' + codeBlocks.length;
codeBlocks.push({ lang, code: lines.join('\\n') });
const p = document.createElement('p');
p.textContent = placeholder;
el.replaceWith(p);
});
result.codeBlocks = codeBlocks;
// Remove noise elements
['script', 'style', '.qr_code_pc', '.reward_area'].forEach(sel => {
contentEl.querySelectorAll(sel).forEach(tag => tag.remove());
});
// Collect image URLs (deduplicated)
const seen = new Set();
contentEl.querySelectorAll('img[src]').forEach(img => {
const src = img.getAttribute('src');
if (src && !seen.has(src)) {
seen.add(src);
result.imageUrls.push(src);
}
});
result.contentHtml = contentEl.innerHTML;
return result;
})()
`);
return downloadArticle(
{
title: data?.title || '',
author: data?.author,
publishTime: data?.publishTime,
sourceUrl: url,
contentHtml: data?.contentHtml || '',
codeBlocks: data?.codeBlocks,
imageUrls: data?.imageUrls,
},
{
output: kwargs.output,
downloadImages: kwargs['download-images'],
imageHeaders: { Referer: 'https://mp.weixin.qq.com/' },
frontmatterLabels: { author: '公众号' },
detectImageExt: (url) => {
const m = url.match(/wx_fmt=(\w+)/) || url.match(/\.(\w{3,4})(?:\?|$)/);
return m ? m[1] : 'png';
},
},
);
},
});
+11 -70
View File
@@ -5,15 +5,9 @@
* opencli xiaohongshu download --note_id abc123 --output ./xhs
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import {
httpDownload,
sanitizeFilename,
formatCookieHeader,
} from '../../download/index.js';
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
import { formatCookieHeader } from '../../download/index.js';
import { downloadMedia } from '../../download/media-download.js';
cli({
site: 'xiaohongshu',
@@ -69,7 +63,6 @@ cli({
if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
// Convert to high quality URL (remove resize parameters)
src = src.split('?')[0];
// Try to get original size
src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
imageUrls.add(src);
}
@@ -88,20 +81,14 @@ cli({
document.querySelectorAll(selector).forEach(v => {
const src = v.src || v.getAttribute('src') || '';
if (src) {
result.media.push({
type: 'video',
url: src
});
result.media.push({ type: 'video', url: src });
}
});
}
// Add images to media
imageUrls.forEach(url => {
result.media.push({
type: 'image',
url: url
});
result.media.push({ type: 'image', url: url });
});
return result;
@@ -115,58 +102,12 @@ cli({
// Extract cookies for authenticated downloads
const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));
// Create output directory
const outputDir = path.join(output, noteId);
fs.mkdirSync(outputDir, { recursive: true });
// Download all media files
const tracker = new DownloadProgressTracker(data.media.length, true);
const results: any[] = [];
for (let i = 0; i < data.media.length; i++) {
const media = data.media[i];
const ext = media.type === 'video' ? 'mp4' : 'jpg';
const filename = `${noteId}_${i + 1}.${ext}`;
const destPath = path.join(outputDir, filename);
const progressBar = tracker.onFileStart(filename, i);
try {
const result = await httpDownload(media.url, destPath, {
cookies,
timeout: 60000,
onProgress: (received, total) => {
if (progressBar) progressBar.update(received, total);
},
});
if (progressBar) {
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
}
tracker.onFileComplete(result.success);
results.push({
index: i + 1,
type: media.type,
status: result.success ? 'success' : 'failed',
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
});
} catch (err: any) {
if (progressBar) progressBar.fail(err.message);
tracker.onFileComplete(false);
results.push({
index: i + 1,
type: media.type,
status: 'failed',
size: err.message,
});
}
}
tracker.finish();
return results;
return downloadMedia(data.media, {
output,
subdir: noteId,
cookies,
filenamePrefix: noteId,
timeout: 60000,
});
},
});
+7 -5
View File
@@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest';
import { htmlToMarkdown } from './download.js';
import TurndownService from 'turndown';
describe('htmlToMarkdown', () => {
describe('article markdown conversion', () => {
it('renders ordered lists with the original list item content', () => {
const html = '<ol><li>First item</li><li>Second item</li></ol>';
const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
const md = td.turndown(html);
expect(htmlToMarkdown(html)).toContain('1. First item');
expect(htmlToMarkdown(html)).toContain('2. Second item');
expect(htmlToMarkdown(html)).not.toContain('$1');
expect(md).toMatch(/1\.\s+First item/);
expect(md).toMatch(/2\.\s+Second item/);
expect(md).not.toContain('$1');
});
});
+23 -158
View File
@@ -5,85 +5,8 @@
* opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import { sanitizeFilename, httpDownload, formatCookieHeader } from '../../download/index.js';
import { formatBytes } from '../../download/progress.js';
/**
* Convert HTML content to Markdown.
* This is a simplified converter for Zhihu article content.
*/
export function htmlToMarkdown(html: string): string {
let md = html;
// Remove script and style tags
md = md.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
md = md.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Convert headers
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
// Convert paragraphs
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n');
// Convert links
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
// Convert images
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, '![$2]($1)');
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, '![]($1)');
// Convert lists
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
return content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n') + '\n';
});
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
let index = 0;
return content.replace(
/<li[^>]*>([\s\S]*?)<\/li>/gi,
(_itemMatch: string, itemContent: string) => `${++index}. ${itemContent}\n`,
) + '\n';
});
// Convert bold and italic
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert code blocks
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '```\n$1\n```\n\n');
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
// Convert blockquotes
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (match, content) => {
return content.split('\n').map((line: string) => `> ${line}`).join('\n') + '\n\n';
});
// Convert line breaks
md = md.replace(/<br\s*\/?>/gi, '\n');
// Remove remaining HTML tags
md = md.replace(/<[^>]+>/g, '');
// Decode HTML entities
md = md.replace(/&nbsp;/g, ' ');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&amp;/g, '&');
md = md.replace(/&quot;/g, '"');
// Clean up extra whitespace
md = md.replace(/\n{3,}/g, '\n\n');
md = md.trim();
return md;
}
import { downloadArticle } from '../../download/article-download.js';
cli({
site: 'zhihu',
@@ -92,18 +15,17 @@ cli({
domain: 'zhuanlan.zhihu.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'url', required: true, positional: true, help: 'Article URL (zhuanlan.zhihu.com/p/xxx)' },
{ name: 'url', required: true, help: 'Article URL (zhuanlan.zhihu.com/p/xxx)' },
{ name: 'output', default: './zhihu-articles', help: 'Output directory' },
{ name: 'download-images', type: 'boolean', default: false, help: 'Download images locally' },
],
columns: ['title', 'author', 'status', 'size'],
columns: ['title', 'author', 'publish_time', 'status', 'size'],
func: async (page, kwargs) => {
const url = kwargs.url;
const output = kwargs.output;
const downloadImages = kwargs['download-images'];
// Navigate to article page
await page.goto(url);
await page.wait(3);
// Extract article content
const data = await page.evaluate(`
@@ -111,9 +33,9 @@ cli({
const result = {
title: '',
author: '',
content: '',
publishTime: '',
images: []
contentHtml: '',
imageUrls: []
};
// Get title
@@ -131,13 +53,13 @@ cli({
// Get content HTML
const contentEl = document.querySelector('.Post-RichTextContainer, .RichText, .ArticleContent');
if (contentEl) {
result.content = contentEl.innerHTML;
result.contentHtml = contentEl.innerHTML;
// Extract image URLs
contentEl.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('data-original') || img.getAttribute('data-actualsrc') || img.src;
if (src && !src.includes('data:image')) {
result.images.push(src);
result.imageUrls.push(src);
}
});
}
@@ -146,77 +68,20 @@ cli({
})()
`);
if (!data || !data.content) {
return [{
title: 'Error',
author: '-',
status: 'failed',
size: 'Could not extract article content',
}];
}
// Create output directory
fs.mkdirSync(output, { recursive: true });
// Convert HTML to Markdown
let markdown = htmlToMarkdown(data.content);
// Create frontmatter
const frontmatter = [
'---',
`title: "${data.title.replace(/"/g, '\\"')}"`,
`author: "${data.author.replace(/"/g, '\\"')}"`,
`source: "${url}"`,
data.publishTime ? `date: "${data.publishTime}"` : '',
'---',
'',
].filter(Boolean).join('\n');
// Download images if requested
if (downloadImages && data.images && data.images.length > 0) {
const imagesDir = path.join(output, 'images');
fs.mkdirSync(imagesDir, { recursive: true });
const cookies = formatCookieHeader(await page.getCookies({ domain: 'zhihu.com' }));
for (let i = 0; i < data.images.length; i++) {
const imgUrl = data.images[i];
const ext = imgUrl.match(/\.(jpg|jpeg|png|gif|webp)/i)?.[1] || 'jpg';
const imgFilename = `img_${i + 1}.${ext}`;
const imgPath = path.join(imagesDir, imgFilename);
try {
await httpDownload(imgUrl, imgPath, {
cookies,
timeout: 30000,
});
// Replace image URL in markdown with local path
markdown = markdown.replace(
new RegExp(imgUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
`./images/${imgFilename}`,
);
} catch {
// Keep original URL if download fails
}
}
}
// Write markdown file
const safeTitle = sanitizeFilename(data.title, 100);
const filename = `${safeTitle}.md`;
const filePath = path.join(output, filename);
const fullContent = frontmatter + '\n' + markdown;
fs.writeFileSync(filePath, fullContent, 'utf-8');
const size = Buffer.byteLength(fullContent, 'utf-8');
return [{
title: data.title,
author: data.author,
status: 'success',
size: formatBytes(size),
}];
return downloadArticle(
{
title: data?.title || '',
author: data?.author,
publishTime: data?.publishTime,
sourceUrl: url,
contentHtml: data?.contentHtml || '',
imageUrls: data?.imageUrls,
},
{
output: kwargs.output,
downloadImages: kwargs['download-images'],
imageHeaders: { Referer: 'https://zhuanlan.zhihu.com/' },
},
);
},
});
+272
View File
@@ -0,0 +1,272 @@
/**
* Article download helper — shared logic for downloading articles as Markdown.
*
* Used by: zhihu/download, weixin/download, and future article adapters.
*
* Flow: ArticleData → TurndownService → image download → frontmatter → .md file
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import TurndownService from 'turndown';
import { httpDownload, sanitizeFilename } from './index.js';
import { formatBytes } from './progress.js';
const IMAGE_CONCURRENCY = 5;
// ============================================================
// Types
// ============================================================
export interface ArticleData {
title: string;
author?: string;
publishTime?: string;
sourceUrl?: string;
contentHtml: string;
/** Pre-extracted code blocks to restore after Markdown conversion */
codeBlocks?: Array<{ lang: string; code: string }>;
/** Image URLs found in the article (pre-collected from DOM) */
imageUrls?: string[];
}
export interface FrontmatterLabels {
author?: string;
publishTime?: string;
sourceUrl?: string;
}
export interface ArticleDownloadOptions {
output: string;
downloadImages?: boolean;
/** Extra headers for image downloads (e.g. { Referer: '...' }) */
imageHeaders?: Record<string, string>;
maxTitleLength?: number;
/** Custom TurndownService configuration callback */
configureTurndown?: (td: TurndownService) => void;
/** Custom image extension detector (default: infer from URL extension) */
detectImageExt?: (url: string) => string;
/** Custom frontmatter labels (default: Chinese labels) */
frontmatterLabels?: FrontmatterLabels;
}
export interface ArticleDownloadResult {
title: string;
author: string;
publish_time: string;
status: string;
size: string;
}
const DEFAULT_LABELS: Required<FrontmatterLabels> = {
author: '作者',
publishTime: '发布时间',
sourceUrl: '原文链接',
};
// ============================================================
// Markdown Conversion
// ============================================================
function createTurndown(configure?: (td: TurndownService) => void): TurndownService {
const td = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
});
td.addRule('linebreak', {
filter: 'br',
replacement: () => '\n',
});
if (configure) configure(td);
return td;
}
function convertToMarkdown(
contentHtml: string,
codeBlocks: Array<{ lang: string; code: string }>,
configure?: (td: TurndownService) => void,
): string {
const td = createTurndown(configure);
let md = td.turndown(contentHtml);
// Restore code block placeholders
codeBlocks.forEach((block, i) => {
const placeholder = `CODEBLOCK-PLACEHOLDER-${i}`;
const fenced = `\n\`\`\`${block.lang}\n${block.code}\n\`\`\`\n`;
md = md.replace(placeholder, fenced);
});
// Clean up
md = md.replace(/\u00a0/g, ' ');
md = md.replace(/\n{4,}/g, '\n\n\n');
md = md.replace(/[ \t]+$/gm, '');
return md;
}
function replaceImageUrls(md: string, urlMap: Record<string, string>): string {
return md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, imgUrl) => {
const local = urlMap[imgUrl];
return local ? `![${alt}](${local})` : match;
});
}
// ============================================================
// Image Downloading
// ============================================================
function defaultDetectImageExt(url: string): string {
const extMatch = url.match(/\.(\w{3,4})(?:\?|$)/);
return extMatch ? extMatch[1] : 'jpg';
}
async function downloadImages(
imgUrls: string[],
imgDir: string,
headers?: Record<string, string>,
detectExt?: (url: string) => string,
): Promise<Record<string, string>> {
const urlMap: Record<string, string> = {};
if (imgUrls.length === 0) return urlMap;
const detect = detectExt || defaultDetectImageExt;
// Deduplicate image URLs
const seen = new Set<string>();
const uniqueUrls = imgUrls.filter(url => {
if (seen.has(url)) return false;
seen.add(url);
return true;
});
for (let i = 0; i < uniqueUrls.length; i += IMAGE_CONCURRENCY) {
const batch = uniqueUrls.slice(i, i + IMAGE_CONCURRENCY);
const results = await Promise.all(
batch.map(async (rawUrl, j) => {
const index = i + j + 1;
let imgUrl = rawUrl;
if (imgUrl.startsWith('//')) imgUrl = `https:${imgUrl}`;
const ext = detect(imgUrl);
const filename = `img_${String(index).padStart(3, '0')}.${ext}`;
const filepath = path.join(imgDir, filename);
try {
const result = await httpDownload(imgUrl, filepath, {
headers,
timeout: 15000,
});
if (result.success) {
return { remoteUrl: rawUrl, localPath: `images/${filename}` };
}
} catch {
// Skip failed downloads
}
return null;
}),
);
for (const r of results) {
if (r) urlMap[r.remoteUrl] = r.localPath;
}
}
return urlMap;
}
// ============================================================
// Main API
// ============================================================
/**
* Download an article to Markdown with optional image localization.
*
* Handles the full pipeline:
* 1. HTML → Markdown (via TurndownService)
* 2. Code block placeholder restoration
* 3. Batch image downloading with concurrency + deduplication
* 4. Image URL replacement in Markdown
* 5. Frontmatter generation (customizable labels)
* 6. File write
*/
export async function downloadArticle(
data: ArticleData,
options: ArticleDownloadOptions,
): Promise<ArticleDownloadResult[]> {
const {
output,
downloadImages: shouldDownloadImages = true,
imageHeaders,
maxTitleLength = 80,
configureTurndown,
detectImageExt,
frontmatterLabels,
} = options;
const labels = { ...DEFAULT_LABELS, ...frontmatterLabels };
if (!data.title) {
return [{
title: 'Error',
author: '-',
publish_time: '-',
status: 'failed — no title',
size: '-',
}];
}
if (!data.contentHtml) {
return [{
title: data.title,
author: data.author || '-',
publish_time: data.publishTime || '-',
status: 'failed — no content',
size: '-',
}];
}
// Convert HTML to Markdown
let markdown = convertToMarkdown(
data.contentHtml,
data.codeBlocks || [],
configureTurndown,
);
// Prepare output directory
const safeTitle = sanitizeFilename(data.title, maxTitleLength);
const articleDir = path.join(output, safeTitle);
fs.mkdirSync(articleDir, { recursive: true });
// Download images
if (shouldDownloadImages && data.imageUrls && data.imageUrls.length > 0) {
const imagesDir = path.join(articleDir, 'images');
fs.mkdirSync(imagesDir, { recursive: true });
const urlMap = await downloadImages(data.imageUrls, imagesDir, imageHeaders, detectImageExt);
markdown = replaceImageUrls(markdown, urlMap);
}
// Build frontmatter with customizable labels
const headerLines = [`# ${data.title}`, ''];
if (data.author) headerLines.push(`> ${labels.author}: ${data.author}`);
if (data.publishTime) headerLines.push(`> ${labels.publishTime}: ${data.publishTime}`);
if (data.sourceUrl) headerLines.push(`> ${labels.sourceUrl}: ${data.sourceUrl}`);
headerLines.push('', '---', '');
const fullContent = headerLines.join('\n') + markdown;
// Write file
const filename = `${safeTitle}.md`;
const filePath = path.join(articleDir, filename);
fs.writeFileSync(filePath, fullContent, 'utf-8');
const size = Buffer.byteLength(fullContent, 'utf-8');
return [{
title: data.title,
author: data.author || '-',
publish_time: data.publishTime || '-',
status: 'success',
size: formatBytes(size),
}];
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Media download helper — shared logic for batch downloading images/videos.
*
* Used by: xiaohongshu/download, twitter/download, bilibili/download,
* and future media adapters.
*
* Flow: MediaItem[] → DownloadProgressTracker → httpDownload/ytdlpDownload → results
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
httpDownload,
ytdlpDownload,
checkYtdlp,
getTempDir,
exportCookiesToNetscape,
} from './index.js';
import type { BrowserCookie } from '../types.js';
import { DownloadProgressTracker, formatBytes } from './progress.js';
// ============================================================
// Types
// ============================================================
export interface MediaItem {
type: 'image' | 'video' | 'video-tweet' | 'video-ytdlp';
url: string;
/** Optional custom filename (without directory) */
filename?: string;
}
export interface MediaDownloadOptions {
output: string;
/** Subdirectory inside output */
subdir?: string;
/** Cookie string for HTTP downloads */
cookies?: string;
/** Raw browser cookies — auto-exported to Netscape for yt-dlp, auto-cleaned up */
browserCookies?: BrowserCookie[];
/** Timeout in ms (default: 30000 for images, 60000 for videos) */
timeout?: number;
/** File name prefix (default: 'download') */
filenamePrefix?: string;
/** Extra yt-dlp args */
ytdlpExtraArgs?: string[];
/** Whether to show progress (default: true) */
verbose?: boolean;
}
export interface MediaDownloadResult {
index: number;
type: string;
status: string;
size: string;
}
// ============================================================
// Main API
// ============================================================
/**
* Batch download media files with progress tracking.
*
* Handles:
* - DownloadProgressTracker for terminal UX
* - Automatic httpDownload vs ytdlpDownload routing via MediaItem.type
* - Cookie export to Netscape format for yt-dlp (auto-cleanup)
* - Directory creation
* - Error handling with per-file results
*/
export async function downloadMedia(
items: MediaItem[],
options: MediaDownloadOptions,
): Promise<MediaDownloadResult[]> {
const {
output,
subdir,
cookies,
browserCookies,
timeout,
filenamePrefix = 'download',
ytdlpExtraArgs = [],
verbose = true,
} = options;
if (!items || items.length === 0) {
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
}
// Create output directory
const outputDir = subdir ? path.join(output, subdir) : output;
fs.mkdirSync(outputDir, { recursive: true });
// Pre-check yt-dlp availability (once, not per-item)
const hasYtdlp = checkYtdlp();
// Auto-export browser cookies to Netscape format for yt-dlp
let cookiesFile: string | undefined;
const needsYtdlp = items.some(m => m.type === 'video-tweet' || m.type === 'video-ytdlp');
if (needsYtdlp && browserCookies && browserCookies.length > 0) {
const tempDir = getTempDir();
fs.mkdirSync(tempDir, { recursive: true });
cookiesFile = path.join(tempDir, `media_cookies_${Date.now()}.txt`);
exportCookiesToNetscape(browserCookies, cookiesFile);
}
const tracker = new DownloadProgressTracker(items.length, verbose);
const results: MediaDownloadResult[] = [];
try {
for (let i = 0; i < items.length; i++) {
const media = items[i];
const isVideo = media.type !== 'image';
const ext = isVideo ? 'mp4' : 'jpg';
const filename = media.filename || `${filenamePrefix}_${i + 1}.${ext}`;
const destPath = path.join(outputDir, filename);
const progressBar = tracker.onFileStart(filename, i);
try {
let result: { success: boolean; size: number; error?: string };
const useYtdlp = (media.type === 'video-tweet' || media.type === 'video-ytdlp') && hasYtdlp;
if (useYtdlp) {
result = await ytdlpDownload(media.url, destPath, {
cookiesFile,
extraArgs: ytdlpExtraArgs,
onProgress: (percent) => {
if (progressBar) progressBar.update(percent, 100);
},
});
} else {
// Direct HTTP download for images and direct video URLs
const dlTimeout = timeout || (isVideo ? 60000 : 30000);
result = await httpDownload(media.url, destPath, {
cookies,
timeout: dlTimeout,
onProgress: (received, total) => {
if (progressBar) progressBar.update(received, total);
},
});
}
if (progressBar) {
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
}
tracker.onFileComplete(result.success);
results.push({
index: i + 1,
type: media.type === 'video-tweet' || media.type === 'video-ytdlp' ? 'video' : media.type,
status: result.success ? 'success' : 'failed',
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
});
} catch (err: any) {
if (progressBar) progressBar.fail(err.message);
tracker.onFileComplete(false);
results.push({
index: i + 1,
type: media.type,
status: 'failed',
size: err.message,
});
}
}
} finally {
tracker.finish();
// Auto-cleanup exported cookies file
if (cookiesFile && fs.existsSync(cookiesFile)) {
fs.unlinkSync(cookiesFile);
}
}
return results;
}