@sovovs/bycli 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/cli-manifest.json +169 -0
  2. package/clis/weixin/_wechat/args.js +48 -0
  3. package/clis/weixin/_wechat/article-content.js +53 -0
  4. package/clis/weixin/_wechat/article-service.js +124 -0
  5. package/clis/weixin/_wechat/auth-session.js +142 -0
  6. package/clis/weixin/_wechat/fingerprint.js +443 -0
  7. package/clis/weixin/_wechat/fixtures/articles-auth-expired.json +3 -0
  8. package/clis/weixin/_wechat/fixtures/articles-page.json +4 -0
  9. package/clis/weixin/_wechat/fixtures/search-auth-expired.json +4 -0
  10. package/clis/weixin/_wechat/fixtures/search-success.json +7 -0
  11. package/clis/weixin/_wechat/markdown.js +29 -0
  12. package/clis/weixin/_wechat/redact.js +405 -0
  13. package/clis/weixin/_wechat/save-service.js +175 -0
  14. package/clis/weixin/_wechat/search-biz.js +102 -0
  15. package/clis/weixin/_wechat/wechat-api.js +133 -0
  16. package/clis/weixin/accounts.js +38 -0
  17. package/clis/weixin/articles.js +35 -0
  18. package/clis/weixin/download.js +5 -47
  19. package/clis/weixin/save-articles.js +175 -0
  20. package/dist/src/download/article-download.d.ts +6 -0
  21. package/dist/src/download/article-download.js +78 -17
  22. package/dist/src/download/wechat-article.d.ts +8 -0
  23. package/dist/src/download/wechat-article.js +137 -0
  24. package/dist/src/download/wechat-article.test.d.ts +1 -0
  25. package/dist/src/recorder/highlevel/verify.d.ts +3 -0
  26. package/dist/src/recorder/highlevel/verify.js +4 -0
  27. package/dist/src/recorder/highlevel/verify.test.d.ts +1 -0
  28. package/dist/src/recorder/runner/runner-port.js +1 -0
  29. package/dist/src/recorder/runner/verify-runner-main.d.ts +17 -2
  30. package/dist/src/recorder/runner/verify-runner-main.js +70 -14
  31. package/dist/src/weixin-built-in-docs.test.d.ts +1 -0
  32. package/package.json +7 -3
  33. package/scripts/check-package-install.mjs +71 -0
@@ -0,0 +1,133 @@
1
+ import { AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
2
+ import { buildSecretSet, redactText } from './redact.js';
3
+
4
+ const DOMAIN = 'mp.weixin.qq.com';
5
+ const ENDPOINT = `https://${DOMAIN}/cgi-bin/appmsgpublish`;
6
+
7
+ function normalizedMessage(value) {
8
+ return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
9
+ }
10
+
11
+ function commandError(message) {
12
+ return new CommandExecutionError(redactText(message, []));
13
+ }
14
+
15
+ function parseNestedJson(value, label) {
16
+ if (typeof value !== 'string') return value;
17
+ try {
18
+ return JSON.parse(value);
19
+ } catch (error) {
20
+ const detail = error instanceof Error ? error.message : String(error);
21
+ throw commandError(`WeChat ${label} is malformed: ${detail}`);
22
+ }
23
+ }
24
+
25
+ /** @param {unknown} data */
26
+ export function parsePublishData(data) {
27
+ if (!data || typeof data !== 'object') {
28
+ throw new CommandExecutionError('WeChat article history returned an unreadable response');
29
+ }
30
+ const response = /** @type {Record<string, any>} */ (data);
31
+ const ret = response.base_resp?.ret;
32
+ const message = response.base_resp?.err_msg ?? response.base_resp?.err_msg_en ?? '';
33
+ if (ret === 200013 && normalizedMessage(message) === 'invalid credential') {
34
+ throw new AuthRequiredError(DOMAIN, 'WeChat article-history credentials have expired');
35
+ }
36
+ if (ret !== undefined && ret !== 0) {
37
+ throw new CommandExecutionError(`WeChat article history failed (ret=${String(ret)})`);
38
+ }
39
+ if (response.publish_page === undefined || response.publish_page === null || response.publish_page === '') {
40
+ return { total: 0, publishItemCount: 0, articles: [] };
41
+ }
42
+ const page = parseNestedJson(response.publish_page, 'publish_page');
43
+ if (!page || typeof page !== 'object' || !Array.isArray(page.publish_list)) {
44
+ throw new CommandExecutionError('WeChat article history returned an invalid publish page');
45
+ }
46
+ const total = page.total_count === undefined ? 0 : page.total_count;
47
+ if (!Number.isSafeInteger(total) || total < 0) {
48
+ throw new CommandExecutionError('WeChat article history returned invalid total metadata');
49
+ }
50
+ const articles = [];
51
+ for (const item of page.publish_list) {
52
+ const info = parseNestedJson(item?.publish_info ?? {}, 'publish_info');
53
+ if (!info || typeof info !== 'object' || !Array.isArray(info.appmsg_info)) {
54
+ throw new CommandExecutionError('WeChat article history returned invalid publish information');
55
+ }
56
+ const timestamp = info.sent_info?.time ?? info.publish_info?.create_time ?? 0;
57
+ let publishedAt = null;
58
+ if (timestamp !== 0) {
59
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) {
60
+ throw new CommandExecutionError('WeChat article history returned an invalid publish timestamp');
61
+ }
62
+ const date = new Date(timestamp * 1000);
63
+ if (!Number.isFinite(date.getTime())) {
64
+ throw new CommandExecutionError('WeChat article history returned an invalid publish timestamp');
65
+ }
66
+ publishedAt = date.toISOString();
67
+ }
68
+ for (const messageItem of info.appmsg_info) {
69
+ const article = messageItem && typeof messageItem === 'object' ? messageItem : {};
70
+ articles.push({
71
+ title: typeof article.title === 'string' ? article.title : '',
72
+ url: typeof article.content_url === 'string' ? article.content_url : '',
73
+ isDeleted: article.is_deleted === true,
74
+ timestamp,
75
+ publishedAt,
76
+ digest: typeof article.digest === 'string' ? article.digest : '',
77
+ author: typeof article.author === 'string' ? article.author : '',
78
+ });
79
+ }
80
+ }
81
+ return {
82
+ total,
83
+ publishItemCount: page.publish_list.length,
84
+ articles,
85
+ };
86
+ }
87
+
88
+ export function requestHeaders(cookie, token) {
89
+ return {
90
+ Accept: 'application/json, text/javascript, */*; q=0.01',
91
+ Cookie: cookie,
92
+ Origin: `https://${DOMAIN}`,
93
+ Referer: `https://${DOMAIN}/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10&token=${encodeURIComponent(token)}&lang=zh_CN`,
94
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/143 Safari/537.36',
95
+ 'X-Requested-With': 'XMLHttpRequest',
96
+ };
97
+ }
98
+
99
+ /**
100
+ * @param {{token:string,cookie:string,timeoutMs?:number,fetchImpl?:typeof fetch}} options
101
+ */
102
+ export function createWechatApi({ token, cookie, timeoutMs = 30_000, fetchImpl = fetch }) {
103
+ const headers = requestHeaders(cookie, token);
104
+ const secrets = buildSecretSet({ token, cookie });
105
+
106
+ return {
107
+ async fetchPage({ fakeid, begin = 0, count = 10 }) {
108
+ const query = new URLSearchParams({
109
+ sub: 'list', begin: String(begin), count: String(count), fakeid, token,
110
+ lang: 'zh_CN', f: 'json', ajax: '1',
111
+ });
112
+ try {
113
+ const response = await fetchImpl(`${ENDPOINT}?${query}`, {
114
+ headers,
115
+ signal: AbortSignal.timeout(timeoutMs),
116
+ });
117
+ if (!response.ok) {
118
+ throw new CommandExecutionError(`WeChat article history request failed: HTTP ${response.status} ${response.statusText ?? ''}`.trim());
119
+ }
120
+ return parsePublishData(await response.json());
121
+ } catch (error) {
122
+ if (error instanceof AuthRequiredError && error.domain === DOMAIN) throw error;
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ const hint = error && typeof error === 'object' && 'hint' in error && typeof error.hint === 'string'
125
+ ? error.hint : undefined;
126
+ throw new CommandExecutionError(
127
+ `WeChat article history request failed: ${redactText(message, secrets)}`,
128
+ hint ? redactText(hint, secrets) : undefined,
129
+ );
130
+ }
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,38 @@
1
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
+ import { cli, Strategy } from '@sovovs/bycli/registry';
3
+ import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
4
+ import { captureSearchBizFingerprint } from './_wechat/fingerprint.js';
5
+ import { executeSearchBiz } from './_wechat/search-biz.js';
6
+ import { readAuthSource } from './_wechat/args.js';
7
+
8
+ const DOMAIN = 'mp.weixin.qq.com';
9
+ const browserRequired = args => readAuthSource(args) === 'browser';
10
+
11
+ export const accountsCommand = cli({
12
+ site: 'weixin', name: 'accounts', access: 'read', domain: DOMAIN,
13
+ description: 'Search WeChat official accounts and return their fakeids',
14
+ strategy: Strategy.INTERCEPT, browser: browserRequired,
15
+ args: [
16
+ { name: 'query', positional: true, required: true, help: 'Official-account name to search for' },
17
+ { name: 'limit', type: 'int', default: 10, help: 'Maximum number of matching accounts to return' },
18
+ { name: 'auth-source', default: 'browser', choices: ['browser', 'env'], help: 'Credential source: browser session or environment variables' },
19
+ ],
20
+ columns: ['nickname', 'fakeid', 'alias'],
21
+ func: async (page, args) => {
22
+ const query = String(args.query ?? '').trim();
23
+ if (!query) throw new ArgumentError('query is required');
24
+ const limit = args.limit ?? 10;
25
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive safe integer');
26
+ const authSource = readAuthSource(args);
27
+ let credentials;
28
+ if (authSource === 'env') {
29
+ credentials = readEnvironmentCredentials(true);
30
+ } else {
31
+ credentials = await resolveBrowserCredentials(page);
32
+ credentials = { ...credentials, fingerprint: await captureSearchBizFingerprint(page, query) };
33
+ }
34
+ const rows = await executeSearchBiz({ page, source: authSource, credentials, query, limit });
35
+ if (rows.length === 0) throw new EmptyResultError('weixin accounts', `No official accounts matched "${query}".`);
36
+ return rows.map(row => ({ nickname: row.nickname, fakeid: row.fakeid, alias: row.alias || null }));
37
+ },
38
+ });
@@ -0,0 +1,35 @@
1
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
+ import { cli, Strategy } from '@sovovs/bycli/registry';
3
+ import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
4
+ import { collectArticles } from './_wechat/article-service.js';
5
+ import { createWechatApi } from './_wechat/wechat-api.js';
6
+ import { readAuthSource } from './_wechat/args.js';
7
+
8
+ const DOMAIN = 'mp.weixin.qq.com';
9
+ const browserRequired = args => readAuthSource(args) === 'browser';
10
+
11
+ export const articlesCommand = cli({
12
+ site: 'weixin', name: 'articles', access: 'read', domain: DOMAIN,
13
+ description: 'List published articles from a WeChat official account',
14
+ strategy: Strategy.COOKIE, browser: browserRequired,
15
+ args: [
16
+ { name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' },
17
+ { name: 'name', help: 'Optional official-account name for display context' }, { name: 'limit', type: 'int', help: 'Maximum number of articles to return' }, { name: 'max-pages', type: 'int', help: 'Maximum number of history pages to scan' },
18
+ { name: 'auth-source', default: 'browser', choices: ['browser', 'env'], help: 'Credential source: browser session or environment variables' },
19
+ ],
20
+ columns: ['title', 'author', 'digest', 'publishedAt', 'url'],
21
+ func: async (page, args) => {
22
+ const fakeid = String(args.fakeid ?? '').trim();
23
+ if (!fakeid) throw new ArgumentError('fakeid is required');
24
+ const authSource = readAuthSource(args);
25
+ const credentials = authSource === 'env'
26
+ ? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
27
+ const { fetchPage } = createWechatApi(credentials);
28
+ const { articles } = await collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
29
+ if (articles.length === 0) throw new EmptyResultError('weixin articles', `No published articles were found for ${fakeid}.`);
30
+ return articles.map(article => ({
31
+ title: article.title, author: article.author || null, digest: article.digest || null,
32
+ publishedAt: article.publishedAt || null, url: article.url,
33
+ }));
34
+ },
35
+ });
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import { cli, Strategy } from '@sovovs/bycli/registry';
10
10
  import { downloadArticle } from '@sovovs/bycli/download/article-download';
11
+ import { buildExtractWechatArticleContentJs } from './_wechat/article-content.js';
12
+ export { extractWechatArticleContent } from './_wechat/article-content.js';
11
13
  // ============================================================
12
14
  // URL Normalization
13
15
  // ============================================================
@@ -241,53 +243,8 @@ cli({
241
243
  );
242
244
  if (result.errorHint) return result;
243
245
 
244
- // Content processing
245
- const contentEl = document.querySelector('#js_content');
246
- if (!contentEl) return result;
247
-
248
- // Fix lazy-loaded images: data-src -> src
249
- contentEl.querySelectorAll('img').forEach(img => {
250
- const dataSrc = img.getAttribute('data-src');
251
- if (dataSrc) img.setAttribute('src', dataSrc);
252
- });
253
-
254
- // Extract code blocks with placeholder replacement
255
- const codeBlocks = [];
256
- contentEl.querySelectorAll('.code-snippet__fix').forEach(el => {
257
- el.querySelectorAll('.code-snippet__line-index').forEach(li => li.remove());
258
- const pre = el.querySelector('pre[data-lang]');
259
- const lang = pre ? (pre.getAttribute('data-lang') || '') : '';
260
- const lines = [];
261
- el.querySelectorAll('code').forEach(codeTag => {
262
- const text = codeTag.textContent;
263
- if (/^[ce]?ounter\\(line/.test(text)) return;
264
- lines.push(text);
265
- });
266
- if (lines.length === 0) lines.push(el.textContent);
267
- const placeholder = 'CODEBLOCK-PLACEHOLDER-' + codeBlocks.length;
268
- codeBlocks.push({ lang, code: lines.join('\\n') });
269
- const p = document.createElement('p');
270
- p.textContent = placeholder;
271
- el.replaceWith(p);
272
- });
273
- result.codeBlocks = codeBlocks;
274
-
275
- // Remove noise elements
276
- ['script', 'style', '.qr_code_pc', '.reward_area'].forEach(sel => {
277
- contentEl.querySelectorAll(sel).forEach(tag => tag.remove());
278
- });
279
-
280
- // Collect image URLs (deduplicated)
281
- const seen = new Set();
282
- contentEl.querySelectorAll('img[src]').forEach(img => {
283
- const src = img.getAttribute('src');
284
- if (src && !seen.has(src)) {
285
- seen.add(src);
286
- result.imageUrls.push(src);
287
- }
288
- });
289
-
290
- result.contentHtml = contentEl.innerHTML;
246
+ const extractWechatArticleContent = ${buildExtractWechatArticleContentJs()};
247
+ Object.assign(result, extractWechatArticleContent(document));
291
248
  return result;
292
249
  })()
293
250
  `);
@@ -318,6 +275,7 @@ cli({
318
275
  const m = url.match(/wx_fmt=(\w+)/) || url.match(/\.(\w{3,4})(?:\?|$)/);
319
276
  return m ? m[1] : 'png';
320
277
  },
278
+ secureMarkdown: true,
321
279
  });
322
280
  },
323
281
  });
@@ -0,0 +1,175 @@
1
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
2
+ import { MAX_WECHAT_HTML_BYTES } from '@sovovs/bycli/download/wechat-article';
3
+ import { cli, Strategy } from '@sovovs/bycli/registry';
4
+ import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
5
+ import { collectArticles, isTrustedWechatArticleUrl } from './_wechat/article-service.js';
6
+ import { saveArticles } from './_wechat/save-service.js';
7
+ import { createWechatApi } from './_wechat/wechat-api.js';
8
+ import { readAuthSource } from './_wechat/args.js';
9
+
10
+ const DOMAIN = 'mp.weixin.qq.com';
11
+ const browserRequired = args => readAuthSource(args) === 'browser';
12
+
13
+ const MAX_REDIRECTS = 5;
14
+
15
+ async function readBoundedHtml(response) {
16
+ const lengthValue = response.headers?.get?.('content-length');
17
+ if (lengthValue !== null && lengthValue !== undefined && lengthValue !== '') {
18
+ const length = Number(lengthValue);
19
+ if (!Number.isSafeInteger(length) || length < 0 || length > MAX_WECHAT_HTML_BYTES) {
20
+ throw new CommandExecutionError('Article response exceeds the allowed size');
21
+ }
22
+ }
23
+ if (!response.body?.getReader) {
24
+ if (typeof response.text !== 'function') throw new CommandExecutionError('Article response has no readable body');
25
+ const text = await response.text();
26
+ if (new TextEncoder().encode(text).byteLength > MAX_WECHAT_HTML_BYTES) {
27
+ throw new CommandExecutionError('Article response exceeds the allowed size');
28
+ }
29
+ return text;
30
+ }
31
+ const reader = response.body.getReader();
32
+ const chunks = [];
33
+ let total = 0;
34
+ while (true) {
35
+ const { done, value } = await reader.read();
36
+ if (done) break;
37
+ if (!(value instanceof Uint8Array)) throw new CommandExecutionError('Article response returned invalid body data');
38
+ total += value.byteLength;
39
+ if (total > MAX_WECHAT_HTML_BYTES) {
40
+ try { await reader.cancel(); } catch { /* best-effort stream cleanup */ }
41
+ throw new CommandExecutionError('Article response exceeds the allowed size');
42
+ }
43
+ chunks.push(value);
44
+ }
45
+ const bytes = new Uint8Array(total);
46
+ let offset = 0;
47
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
48
+ return new TextDecoder().decode(bytes);
49
+ }
50
+
51
+ export async function fetchArticleHtml(article, { fetchImpl = fetch, timeoutMs = 30_000 } = {}) {
52
+ try {
53
+ if (!isTrustedWechatArticleUrl(article?.url)) throw new CommandExecutionError('Article request rejected an untrusted URL');
54
+ let current = new URL(article.url).href;
55
+ const seen = new Set([current]);
56
+ for (let redirects = 0; ; redirects += 1) {
57
+ const response = await fetchImpl(current, {
58
+ signal: AbortSignal.timeout(timeoutMs), redirect: 'manual',
59
+ });
60
+ if (response.status >= 300 && response.status < 400) {
61
+ if (redirects >= MAX_REDIRECTS) throw new CommandExecutionError('Article request exceeded the redirect limit');
62
+ const location = response.headers?.get?.('location');
63
+ if (!location) throw new CommandExecutionError('Article redirect was missing a destination');
64
+ let next;
65
+ try { next = new URL(location, current).href; } catch { throw new CommandExecutionError('Article redirect was invalid'); }
66
+ if (!isTrustedWechatArticleUrl(next)) throw new CommandExecutionError('Article redirect was rejected');
67
+ if (seen.has(next)) throw new CommandExecutionError('Article redirect loop was rejected');
68
+ seen.add(next);
69
+ current = next;
70
+ continue;
71
+ }
72
+ if (!response.ok) throw new CommandExecutionError(`Article request failed: HTTP ${response.status}`);
73
+ return await readBoundedHtml(response);
74
+ }
75
+ } catch (error) {
76
+ if (error instanceof CommandExecutionError) throw error;
77
+ throw new CommandExecutionError('Article request failed');
78
+ }
79
+ }
80
+
81
+ export async function fetchArticleHtmlInBrowser(article, page) {
82
+ try {
83
+ if (!isTrustedWechatArticleUrl(article?.url)) {
84
+ throw new CommandExecutionError('Article browser request rejected an untrusted URL');
85
+ }
86
+ if (!page || typeof page.goto !== 'function' || typeof page.evaluate !== 'function') {
87
+ throw new CommandExecutionError('Article browser fallback is unavailable');
88
+ }
89
+ await page.goto(article.url);
90
+ await page.wait(5);
91
+ const result = await page.evaluate(({ maxBytes }) => {
92
+ const html = document.documentElement?.outerHTML ?? '';
93
+ const pageText = document.body?.innerText?.replace(/\s+/g, ' ').trim() ?? '';
94
+ const finalUrl = window.location.href;
95
+ const pathname = window.location.pathname;
96
+ const accessIssue = pathname.includes('/mp/wappoc_appmsgcaptcha')
97
+ || (/环境异常/.test(pageText) && /(完成验证后即可继续访问|去验证)/.test(pageText))
98
+ || /secitptpage\/verify\.html/.test(html)
99
+ || /id=["']js_verify["']/.test(html)
100
+ ? 'environment verification required' : '';
101
+ const byteLength = new TextEncoder().encode(html).byteLength;
102
+ return {
103
+ finalUrl,
104
+ accessIssue,
105
+ byteLength,
106
+ tooLarge: byteLength > maxBytes,
107
+ html: byteLength > maxBytes ? '' : html,
108
+ };
109
+ }, { maxBytes: MAX_WECHAT_HTML_BYTES });
110
+ if (result?.accessIssue) throw new CommandExecutionError('Article browser page requires environment verification');
111
+ if (!isTrustedWechatArticleUrl(result?.finalUrl)) {
112
+ throw new CommandExecutionError('Article browser navigation left the trusted article path');
113
+ }
114
+ if (result?.tooLarge || !Number.isSafeInteger(result?.byteLength)
115
+ || result.byteLength < 0 || result.byteLength > MAX_WECHAT_HTML_BYTES) {
116
+ throw new CommandExecutionError('Article response exceeds the allowed size');
117
+ }
118
+ if (typeof result?.html !== 'string' || result.html.length === 0) {
119
+ throw new CommandExecutionError('Article browser page returned no HTML');
120
+ }
121
+ if (new TextEncoder().encode(result.html).byteLength > MAX_WECHAT_HTML_BYTES) {
122
+ throw new CommandExecutionError('Article response exceeds the allowed size');
123
+ }
124
+ return result.html;
125
+ } catch (error) {
126
+ if (error instanceof CommandExecutionError) throw error;
127
+ throw new CommandExecutionError('Article browser request failed');
128
+ }
129
+ }
130
+
131
+ export function createArticleHtmlDownloader({
132
+ authSource,
133
+ page,
134
+ nodeFetcher = fetchArticleHtml,
135
+ browserFetcher = fetchArticleHtmlInBrowser,
136
+ }) {
137
+ return async article => {
138
+ try {
139
+ return await nodeFetcher(article);
140
+ } catch (error) {
141
+ if (authSource !== 'browser') throw error;
142
+ return browserFetcher(article, page);
143
+ }
144
+ };
145
+ }
146
+
147
+ export const saveArticlesCommand = cli({
148
+ site: 'weixin', name: 'save-articles', access: 'write', domain: DOMAIN,
149
+ description: 'Download WeChat official-account articles as Markdown files',
150
+ strategy: Strategy.COOKIE, browser: browserRequired,
151
+ args: [
152
+ { name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' }, { name: 'name', help: 'Official-account name used in Markdown metadata' },
153
+ { name: 'output', default: './weixin-articles', help: 'Directory for saved Markdown files' }, { name: 'limit', type: 'int', help: 'Maximum number of articles to save' },
154
+ { name: 'max-pages', type: 'int', help: 'Maximum number of history pages to scan' }, { name: 'auth-source', default: 'browser', choices: ['browser', 'env'], help: 'Credential source: browser session or environment variables' },
155
+ ],
156
+ columns: ['title', 'status', 'stage', 'path', 'error', 'url'],
157
+ func: async (page, args) => {
158
+ const fakeid = String(args.fakeid ?? '').trim();
159
+ if (!fakeid) throw new ArgumentError('fakeid is required');
160
+ const authSource = readAuthSource(args);
161
+ const credentials = authSource === 'env'
162
+ ? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
163
+ const { fetchPage } = createWechatApi(credentials);
164
+ const { articles } = await collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
165
+ const articleHtmlDownloader = createArticleHtmlDownloader({ authSource, page });
166
+ const rows = await saveArticles({
167
+ articles, accountName: String(args.name ?? '').trim(),
168
+ outputDir: args.output ?? './weixin-articles', fetchArticleHtml: articleHtmlDownloader,
169
+ });
170
+ return rows.map(row => ({
171
+ title: row.title, status: row.status, stage: row.stage || null, path: row.saved || null,
172
+ error: row.error || null, url: row.url,
173
+ }));
174
+ },
175
+ });
@@ -6,6 +6,7 @@
6
6
  * Flow: ArticleData → TurndownService → image download → frontmatter → .md file
7
7
  */
8
8
  import TurndownService from 'turndown';
9
+ export { extractWechatArticleHtml } from './wechat-article.js';
9
10
  export interface ArticleData {
10
11
  title: string;
11
12
  author?: string;
@@ -49,6 +50,8 @@ export interface ArticleDownloadOptions {
49
50
  * as-is so the output is self-contained when piped.
50
51
  */
51
52
  stdout?: boolean;
53
+ /** Opt-in hardened Markdown rules used by HTML-focused adapters. */
54
+ secureMarkdown?: boolean;
52
55
  }
53
56
  export interface ArticleDownloadResult {
54
57
  title: string;
@@ -58,6 +61,9 @@ export interface ArticleDownloadResult {
58
61
  size: string;
59
62
  saved: string;
60
63
  }
64
+ export declare function convertArticleHtmlToMarkdown(contentHtml: string, options?: {
65
+ safeFencedCodeBlocks?: boolean;
66
+ }): string;
61
67
  /**
62
68
  * Download an article to Markdown with optional image localization.
63
69
  *
@@ -11,12 +11,29 @@ import TurndownService from 'turndown';
11
11
  import { gfm } from 'turndown-plugin-gfm';
12
12
  import { httpDownload, sanitizeFilename } from './index.js';
13
13
  import { formatBytes } from './progress.js';
14
+ export { extractWechatArticleHtml } from './wechat-article.js';
14
15
  const IMAGE_CONCURRENCY = 5;
15
16
  const DEFAULT_LABELS = {
16
17
  author: '作者',
17
18
  publishTime: '发布时间',
18
19
  sourceUrl: '原文链接',
19
20
  };
21
+ function escapeMarkdownText(value) {
22
+ return value.replace(/\s+/g, ' ').trim()
23
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
25
+ .replace(/([\\`*_[\]{}()#+.!|>~-])/g, '\\$1');
26
+ }
27
+ function safeHttpUrl(value) {
28
+ const normalized = value.trim().startsWith('//') ? `https:${value.trim()}` : value.trim();
29
+ try {
30
+ const url = new URL(normalized);
31
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
32
+ }
33
+ catch {
34
+ return '';
35
+ }
36
+ }
20
37
  // ============================================================
21
38
  // Markdown Conversion
22
39
  // ============================================================
@@ -36,13 +53,48 @@ const STRIPPED_TAGS = [
36
53
  'form', 'button', 'dialog',
37
54
  'header', 'footer', 'nav', 'aside',
38
55
  ];
39
- function createTurndown(configure, cleanSelectors) {
56
+ function createTurndown(configure, cleanSelectors, secureMarkdown = false) {
40
57
  const td = new TurndownService({
41
58
  headingStyle: 'atx',
42
59
  codeBlockStyle: 'fenced',
43
60
  bulletListMarker: '-',
44
61
  });
45
62
  td.use(gfm);
63
+ if (secureMarkdown) {
64
+ const escapeDestination = (value) => value.replace(/([\\()])/g, '\\$1');
65
+ td.addRule('safeFencedCodeBlock', {
66
+ filter: 'pre',
67
+ replacement: (_content, node) => {
68
+ const element = node;
69
+ const code = element.textContent || '';
70
+ const longest = Math.max(0, ...[...code.matchAll(/`+/g)].map(match => match[0].length));
71
+ const fence = '`'.repeat(Math.max(3, longest + 1));
72
+ const className = element.querySelector('code')?.getAttribute('class') || '';
73
+ const language = element.getAttribute('data-lang')
74
+ || /(?:^|\s)language-([^\s]+)/.exec(className)?.[1]
75
+ || '';
76
+ return `\n${fence}${language}\n${code.replace(/\n$/, '')}\n${fence}\n`;
77
+ },
78
+ });
79
+ td.addRule('safeImage', {
80
+ filter: 'img',
81
+ replacement: (_content, node) => {
82
+ const element = node;
83
+ const alt = escapeMarkdownText(element.getAttribute('alt') || '');
84
+ const src = element.getAttribute('src') || '';
85
+ return src ? `![${alt}](${escapeDestination(src)})` : alt;
86
+ },
87
+ });
88
+ td.addRule('safeLink', {
89
+ filter: 'a',
90
+ replacement: (_content, node) => {
91
+ const element = node;
92
+ const label = escapeMarkdownText(element.textContent || '');
93
+ const href = element.getAttribute('href') || '';
94
+ return href ? `[${label}](${escapeDestination(href)})` : label;
95
+ },
96
+ });
97
+ }
46
98
  td.remove(STRIPPED_TAGS);
47
99
  // turndown-plugin-gfm@1.0.2 emits single-tilde strikethrough (`~x~`), which
48
100
  // is not the canonical GFM form. Override it so exported markdown is
@@ -104,11 +156,14 @@ function createTurndown(configure, cleanSelectors) {
104
156
  filter: (node) => node.nodeName === 'IFRAME',
105
157
  replacement: (_content, node) => {
106
158
  const el = node;
107
- const src = el.getAttribute('src') || '';
159
+ const rawSrc = el.getAttribute('src') || '';
160
+ const src = secureMarkdown ? safeHttpUrl(rawSrc) : rawSrc;
108
161
  if (!src)
109
162
  return '';
110
- const title = el.getAttribute('title') || 'Embedded content';
111
- return `\n[${title}](${src})\n`;
163
+ const rawTitle = el.getAttribute('title') || 'Embedded content';
164
+ const title = secureMarkdown ? escapeMarkdownText(rawTitle) : rawTitle;
165
+ const destination = secureMarkdown ? src.replace(/([\\()])/g, '\\$1') : src;
166
+ return `\n[${title}](${destination})\n`;
112
167
  },
113
168
  });
114
169
  // Per-adapter dirty-node removal. Adapters know their site's specific noise
@@ -139,14 +194,16 @@ function createTurndown(configure, cleanSelectors) {
139
194
  configure(td);
140
195
  return td;
141
196
  }
142
- function convertToMarkdown(contentHtml, codeBlocks, configure, cleanSelectors) {
143
- const td = createTurndown(configure, cleanSelectors);
197
+ function convertToMarkdown(contentHtml, codeBlocks, configure, cleanSelectors, secureMarkdown = false) {
198
+ const td = createTurndown(configure, cleanSelectors, secureMarkdown);
144
199
  let md = td.turndown(contentHtml);
145
- // Restore code block placeholders
146
- codeBlocks.forEach((block, i) => {
147
- const placeholder = `CODEBLOCK-PLACEHOLDER-${i}`;
148
- const fenced = `\n\`\`\`${block.lang}\n${block.code}\n\`\`\`\n`;
149
- md = md.replace(placeholder, fenced);
200
+ // Legacy callers may still supply extracted blocks. New callers preserve
201
+ // real <pre>/<code> nodes so Turndown owns fencing without collision-prone
202
+ // magic text; legacy blocks are appended with safe dynamic fences.
203
+ codeBlocks.forEach((block) => {
204
+ const longest = Math.max(0, ...[...block.code.matchAll(/`+/g)].map(match => match[0].length));
205
+ const fence = '`'.repeat(Math.max(3, longest + 1));
206
+ md += `\n\n${fence}${block.lang}\n${block.code}\n${fence}`;
150
207
  });
151
208
  // Clean up
152
209
  md = md.replace(/\u00a0/g, ' ');
@@ -158,6 +215,9 @@ function convertToMarkdown(contentHtml, codeBlocks, configure, cleanSelectors) {
158
215
  md = md.replace(/\n{3,}/g, '\n\n');
159
216
  return md;
160
217
  }
218
+ export function convertArticleHtmlToMarkdown(contentHtml, options = {}) {
219
+ return convertToMarkdown(contentHtml, [], undefined, undefined, options.safeFencedCodeBlocks === true);
220
+ }
161
221
  function replaceImageUrls(md, urlMap) {
162
222
  return md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, imgUrl) => {
163
223
  const local = urlMap[imgUrl];
@@ -230,7 +290,7 @@ async function downloadImages(imgUrls, imgDir, headers, detectExt) {
230
290
  * 6. File write
231
291
  */
232
292
  export async function downloadArticle(data, options) {
233
- const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, } = options;
293
+ const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, } = options;
234
294
  const labels = { ...DEFAULT_LABELS, ...frontmatterLabels };
235
295
  if (!data.title) {
236
296
  return [{
@@ -253,7 +313,7 @@ export async function downloadArticle(data, options) {
253
313
  }];
254
314
  }
255
315
  // Convert HTML to Markdown
256
- let markdown = convertToMarkdown(data.contentHtml, data.codeBlocks || [], configureTurndown, cleanSelectors);
316
+ let markdown = convertToMarkdown(data.contentHtml, data.codeBlocks || [], configureTurndown, cleanSelectors, secureMarkdown);
257
317
  const safeTitle = sanitizeFilename(data.title, maxTitleLength);
258
318
  // Download images only when writing to disk. In stdout mode remote URLs
259
319
  // stay intact so the piped output is self-contained.
@@ -268,13 +328,14 @@ export async function downloadArticle(data, options) {
268
328
  // Build frontmatter with customizable labels.
269
329
  // Shape: `# Title\n[> meta\n...]\n---\n\n<markdown>` — exactly one blank
270
330
  // line separates every section, so we never produce ≥3 consecutive newlines.
271
- const headerLines = [`# ${data.title}`];
331
+ const headerValue = (value) => secureMarkdown ? escapeMarkdownText(value) : value;
332
+ const headerLines = [`# ${headerValue(data.title)}`];
272
333
  if (data.author)
273
- headerLines.push(`> ${labels.author}: ${data.author}`);
334
+ headerLines.push(`> ${labels.author}: ${headerValue(data.author)}`);
274
335
  if (data.publishTime)
275
- headerLines.push(`> ${labels.publishTime}: ${data.publishTime}`);
336
+ headerLines.push(`> ${labels.publishTime}: ${headerValue(data.publishTime)}`);
276
337
  if (data.sourceUrl)
277
- headerLines.push(`> ${labels.sourceUrl}: ${data.sourceUrl}`);
338
+ headerLines.push(`> ${labels.sourceUrl}: ${headerValue(data.sourceUrl)}`);
278
339
  const frontmatter = headerLines.join('\n') + '\n\n---\n\n';
279
340
  const fullContent = frontmatter + markdown;
280
341
  const size = Buffer.byteLength(fullContent, 'utf-8');
@@ -0,0 +1,8 @@
1
+ export declare const MAX_WECHAT_HTML_BYTES: number;
2
+ export declare const MAX_WECHAT_NODES = 100000;
3
+ export declare const MAX_WECHAT_CODE_BLOCKS = 1000;
4
+ export interface ExtractedWechatArticle {
5
+ contentHtml: string;
6
+ imageUrls: string[];
7
+ }
8
+ export declare function extractWechatArticleHtml(html: string): ExtractedWechatArticle;