Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

372 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 '@jackwener/opencli/registry';
import { downloadArticle } from '@jackwener/opencli/download/article-download';
// ============================================================
// URL Normalization
// ============================================================
/**
* Normalize a pasted WeChat article URL.
*/
// Wrapping quote characters to strip from a pasted URL. Covers ASCII plus
// CJK typographic / smart quotes, which are common when users copy URLs from
// Chinese-language environments (WeChat itself, macOS smart-quote
// substitution, Word / Pages, …).
//
// Pairs:
// " " ASCII straight double
// ' ' ASCII straight single
// “ ” curly double (U+201C / U+201D)
// curly single (U+2018 / U+2019)
// 「 」 CJK corner brackets (U+300C / U+300D)
// 『 』 CJK white corner brackets (U+300E / U+300F)
// „ ‟ German-style double quotes (U+201E / U+201F)
// single guillemets (U+2039 / U+203A)
// « » double guillemets (U+00AB / U+00BB)
const WRAPPING_QUOTE_PAIRS = [
['"', '"'],
["'", "'"],
['“', '”'],
['', ''],
['「', '」'],
['『', '』'],
['„', '‟'],
['', ''],
['«', '»'],
];
const LEADING_WRAP_CHARS = new Set(WRAPPING_QUOTE_PAIRS.map(([open]) => open).concat('<'));
const TRAILING_WRAP_CHARS = new Set(WRAPPING_QUOTE_PAIRS.map(([, close]) => close).concat('>'));
function stripBoundaryWrapChars(value) {
let s = value;
for (let i = 0; i < 4; i += 1) {
const before = s;
for (const [open, close] of WRAPPING_QUOTE_PAIRS) {
if (s.length >= 2 && s.startsWith(open) && s.endsWith(close)) {
s = s.slice(open.length, s.length - close.length).trim();
break;
}
}
while (s && LEADING_WRAP_CHARS.has(s[0])) {
s = s.slice(1).trimStart();
}
while (s && TRAILING_WRAP_CHARS.has(s[s.length - 1])) {
s = s.slice(0, -1).trimEnd();
}
if (s === before)
break;
}
return s;
}
export function normalizeWechatUrl(raw) {
let s = (raw || '').trim();
if (!s)
return s;
// Strip quote / angle-bracket characters only at the pasted boundary. This
// handles both paired wrappers ("<url>", "“url”") and common one-sided
// trailing punctuation ("url”") without touching encoded URL content.
s = stripBoundaryWrapChars(s);
// 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;
}
/**
* Format a WeChat article timestamp as a UTC+8 datetime string.
* Accepts either Unix seconds or milliseconds.
*/
export function formatWechatTimestamp(rawTimestamp) {
const ts = Number.parseInt(rawTimestamp, 10);
if (!Number.isFinite(ts) || ts <= 0)
return '';
const timestampMs = rawTimestamp.length === 13 ? ts : ts * 1000;
const d = new Date(timestampMs);
const pad = (n) => String(n).padStart(2, '0');
const utc8 = new Date(d.getTime() + 8 * 3600 * 1000);
return (`${utc8.getUTCFullYear()}-` +
`${pad(utc8.getUTCMonth() + 1)}-` +
`${pad(utc8.getUTCDate())} ` +
`${pad(utc8.getUTCHours())}:` +
`${pad(utc8.getUTCMinutes())}:` +
`${pad(utc8.getUTCSeconds())}`);
}
/**
* Extract the raw create_time value from supported WeChat inline script formats.
*/
export function extractWechatCreateTimeValue(htmlStr) {
const jsDecodeMatch = htmlStr.match(/create_time\s*:\s*JsDecode\('([^']+)'\)(?=[\s,;}]|$)/);
if (jsDecodeMatch)
return jsDecodeMatch[1];
const directValueMatch = htmlStr.match(/create_time\s*[:=]\s*(?:"([^"]+)"|'([^']+)'|([0-9A-Za-z]+))(?=[\s,;}]|$)/);
if (!directValueMatch)
return '';
return directValueMatch[1] || directValueMatch[2] || directValueMatch[3] || '';
}
/**
* Extract the publish time from DOM text first, then fall back to numeric create_time values.
*/
export function extractWechatPublishTime(publishTimeText, htmlStr) {
const normalizedPublishTime = (publishTimeText || '').trim();
if (normalizedPublishTime)
return normalizedPublishTime;
const rawCreateTime = extractWechatCreateTimeValue(htmlStr);
if (!/^\d{10}$|^\d{13}$/.test(rawCreateTime))
return '';
return formatWechatTimestamp(rawCreateTime);
}
/**
* Detect WeChat anti-bot / verification gate pages before we try to parse the article.
*/
export function detectWechatAccessIssue(pageText, htmlStr) {
const normalizedText = (pageText || '').replace(/\s+/g, ' ').trim();
if (/环境异常/.test(normalizedText) &&
/(完成验证后即可继续访问|去验证)/.test(normalizedText)) {
return 'environment verification required';
}
if (/secitptpage\/verify\.html/.test(htmlStr) || /id=["']js_verify["']/.test(htmlStr)) {
return 'environment verification required';
}
return '';
}
export function pickFirstWechatMetaText(...candidates) {
for (const candidate of candidates) {
const normalized = (candidate || '').replace(/\s+/g, ' ').trim();
if (normalized && normalized !== 'Name cleared')
return normalized;
}
return '';
}
/**
* Build a self-contained helper for execution inside page.evaluate().
*/
export function buildExtractWechatPublishTimeJs() {
return `(${function extractWechatPublishTimeInPage(publishTimeText, htmlStr) {
function formatWechatTimestamp(rawTimestamp) {
const ts = Number.parseInt(rawTimestamp, 10);
if (!Number.isFinite(ts) || ts <= 0)
return '';
const timestampMs = rawTimestamp.length === 13 ? ts : ts * 1000;
const d = new Date(timestampMs);
const pad = (n) => String(n).padStart(2, '0');
const utc8 = new Date(d.getTime() + 8 * 3600 * 1000);
return (`${utc8.getUTCFullYear()}-` +
`${pad(utc8.getUTCMonth() + 1)}-` +
`${pad(utc8.getUTCDate())} ` +
`${pad(utc8.getUTCHours())}:` +
`${pad(utc8.getUTCMinutes())}:` +
`${pad(utc8.getUTCSeconds())}`);
}
function extractWechatCreateTimeValue(html) {
const jsDecodeMatch = html.match(/create_time\s*:\s*JsDecode\('([^']+)'\)(?=[\s,;}]|$)/);
if (jsDecodeMatch)
return jsDecodeMatch[1];
const directValueMatch = html.match(/create_time\s*[:=]\s*(?:"([^"]+)"|'([^']+)'|([0-9A-Za-z]+))(?=[\s,;}]|$)/);
if (!directValueMatch)
return '';
return directValueMatch[1] || directValueMatch[2] || directValueMatch[3] || '';
}
const normalizedPublishTime = (publishTimeText || '').trim();
if (normalizedPublishTime)
return normalizedPublishTime;
const rawCreateTime = extractWechatCreateTimeValue(htmlStr);
if (!/^\d{10}$|^\d{13}$/.test(rawCreateTime))
return '';
return formatWechatTimestamp(rawCreateTime);
}.toString()})`;
}
/**
* Build a self-contained access-issue detector for execution inside page.evaluate().
*/
export function buildDetectWechatAccessIssueJs() {
return `(${function detectWechatAccessIssueInPage(pageText, htmlStr) {
const normalizedText = (pageText || '').replace(/\s+/g, ' ').trim();
if (/环境异常/.test(normalizedText) &&
/(完成验证后即可继续访问|去验证)/.test(normalizedText)) {
return 'environment verification required';
}
if (/secitptpage\/verify\.html/.test(htmlStr) || /id=["']js_verify["']/.test(htmlStr)) {
return 'environment verification required';
}
return '';
}.toString()})`;
}
// ============================================================
// CLI Registration
// ============================================================
cli({
site: 'weixin',
name: 'download',
access: 'read',
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', 'saved'],
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: '-', saved: '-' }];
}
// 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: '',
errorHint: '',
contentHtml: '',
codeBlocks: [],
imageUrls: []
};
const pickFirstText = (...selectors) => {
for (const selector of selectors) {
const text = document.querySelector(selector)?.textContent?.replace(/\\s+/g, ' ').trim() || '';
if (text && text !== 'Name cleared') return text;
}
return '';
};
// WeChat has multiple article templates. Newer pages use #js_text_title.
result.title = pickFirstText(
'#activity-name',
'#js_text_title',
'.rich_media_title',
);
result.author = pickFirstText(
'#js_name',
'.wx_follow_nickname',
'#profileBt .profile_nickname',
'.rich_media_meta.rich_media_meta_nickname',
'.rich_media_meta_nickname',
);
// Publish time: prefer the rendered DOM text, then fall back to numeric create_time values.
const publishTimeEl = document.querySelector('#publish_time');
const extractWechatPublishTime = ${buildExtractWechatPublishTimeJs()};
result.publishTime = extractWechatPublishTime(
publishTimeEl ? publishTimeEl.textContent : '',
document.documentElement.innerHTML,
);
const detectWechatAccessIssue = ${buildDetectWechatAccessIssueJs()};
result.errorHint = detectWechatAccessIssue(
document.body ? document.body.innerText : '',
document.documentElement.innerHTML,
);
if (result.errorHint) return result;
// 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;
})()
`);
if (data?.errorHint === 'environment verification required') {
return [{
title: 'Error',
author: '-',
publish_time: '-',
status: 'failed — verification required in WeChat browser page',
size: '-',
saved: '-',
}];
}
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';
},
});
},
});