项目文件夹

文件
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 行
14 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* 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';
},
});
},
});