@sovovs/bycli 2.1.24 → 2.1.25

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.
@@ -1,8 +1,61 @@
1
+ import * as nodeFs from 'node:fs';
2
+ import * as nodePath from 'node:path';
1
3
  import { cli, Strategy } from '@sovovs/bycli/registry';
2
- import { CommandExecutionError } from '@sovovs/bycli/errors';
4
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
3
5
 
4
6
  const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
5
7
  const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
8
+ const MAX_TITLE_LENGTH = 64;
9
+ const MAX_AUTHOR_LENGTH = 8;
10
+ const SUPPORTED_COVER_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
11
+
12
+ function codePointLength(value) {
13
+ return [...value].length;
14
+ }
15
+
16
+ function requiredText(value, name) {
17
+ const text = String(value ?? '').trim();
18
+ if (!text) throw new ArgumentError(`${name} must not be empty`);
19
+ return text;
20
+ }
21
+
22
+ function validateCoverImage(value) {
23
+ if (value === undefined || value === null) return null;
24
+ const coverPath = nodePath.resolve(requiredText(value, 'cover-image'));
25
+ const extension = nodePath.extname(coverPath).toLowerCase();
26
+ if (!SUPPORTED_COVER_EXTENSIONS.has(extension)) {
27
+ throw new ArgumentError('cover-image must be a jpg, jpeg, png, gif, or webp file');
28
+ }
29
+ const info = nodeFs.statSync(coverPath, { throwIfNoEntry: false });
30
+ if (!info?.isFile() || info.size <= 0) {
31
+ throw new ArgumentError(`cover-image must be a readable non-empty file: ${coverPath}`);
32
+ }
33
+ try {
34
+ nodeFs.accessSync(coverPath, nodeFs.constants.R_OK);
35
+ } catch {
36
+ throw new ArgumentError(`cover-image must be readable: ${coverPath}`);
37
+ }
38
+ return coverPath;
39
+ }
40
+
41
+ function normalizeCreateDraftArgs(kwargs) {
42
+ const title = requiredText(kwargs.title, 'title');
43
+ requiredText(kwargs.content, 'content');
44
+ if (codePointLength(title) > MAX_TITLE_LENGTH) {
45
+ throw new ArgumentError(`title must be at most ${MAX_TITLE_LENGTH} characters`);
46
+ }
47
+ const author = kwargs.author == null ? null : requiredText(kwargs.author, 'author');
48
+ if (author && codePointLength(author) > MAX_AUTHOR_LENGTH) {
49
+ throw new ArgumentError(`author must be at most ${MAX_AUTHOR_LENGTH} characters`);
50
+ }
51
+ return {
52
+ title,
53
+ content: String(kwargs.content),
54
+ author,
55
+ summary: kwargs.summary == null ? null : String(kwargs.summary).trim(),
56
+ coverImage: validateCoverImage(kwargs['cover-image']),
57
+ };
58
+ }
6
59
 
7
60
  async function getToken(page) {
8
61
  return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
@@ -27,15 +80,18 @@ async function fillField(page, selector, value) {
27
80
  return page.evaluate(`(() => {
28
81
  var el = document.querySelector('${selector}');
29
82
  if (!el) return { ok: false, reason: 'not found: ${selector}' };
83
+ var expected = ${JSON.stringify(value)};
30
84
  el.focus();
31
85
  var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
32
86
  var setter = Object.getOwnPropertyDescriptor(proto, 'value');
33
- if (setter && setter.set) setter.set.call(el, ${JSON.stringify(value)});
34
- else el.value = ${JSON.stringify(value)};
35
- el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(value)} }));
87
+ if (setter && setter.set) setter.set.call(el, expected);
88
+ else el.value = expected;
89
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: expected }));
36
90
  el.dispatchEvent(new Event('change', { bubbles: true }));
37
91
  el.blur();
38
- return { ok: true };
92
+ return el.value === expected
93
+ ? { ok: true, value: el.value }
94
+ : { ok: false, reason: 'value mismatch', value: el.value };
39
95
  })()`);
40
96
  }
41
97
 
@@ -49,10 +105,21 @@ async function fillContent(page, text) {
49
105
  document.execCommand('selectAll', false, null);
50
106
  document.execCommand('insertText', false, ${JSON.stringify(text)});
51
107
  editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
52
- return { ok: true };
108
+ var normalize = value => String(value ?? '').replace(/\\r\\n?/g, '\\n').trim();
109
+ var expected = normalize(${JSON.stringify(text)});
110
+ var actual = normalize(editor.innerText ?? editor.textContent ?? '');
111
+ return actual === expected
112
+ ? { ok: true, value: actual }
113
+ : { ok: false, reason: 'value mismatch', value: actual };
53
114
  })()`);
54
115
  }
55
116
 
117
+ function requirePageResult(result, label) {
118
+ if (!result?.ok) {
119
+ throw new CommandExecutionError(`Failed to fill ${label}: ${result?.reason ?? 'unverified page state'}`);
120
+ }
121
+ }
122
+
56
123
  async function uploadContentImage(page, imagePath) {
57
124
  const fs = await import('node:fs');
58
125
  const path = await import('node:path');
@@ -76,15 +143,20 @@ async function uploadContentImage(page, imagePath) {
76
143
  await page.wait(1);
77
144
 
78
145
  await page.setFileInput([absPath], 'input[type="file"][name="file"]');
79
- await page.wait(8);
80
146
 
81
- const cdnCount = await page.evaluate(`(() => {
82
- var editor = document.querySelector('#ueditor_0');
83
- return editor ? editor.querySelectorAll('img[src*="mmbiz"]').length : 0;
84
- })()`);
85
- if (cdnCount === 0) {
86
- throw new CommandExecutionError('Image did not upload to WeChat CDN');
147
+ for (let attempt = 0; attempt < 15; attempt++) {
148
+ await page.wait(1);
149
+ const cdnCount = await page.evaluate(`(() => {
150
+ var editors = document.querySelectorAll('#ueditor_0, div[contenteditable="true"]');
151
+ var count = 0;
152
+ editors.forEach(function(editor) {
153
+ count += editor.querySelectorAll('img[src*="mmbiz"], img[data-src*="mmbiz"]').length;
154
+ });
155
+ return count;
156
+ })()`);
157
+ if (cdnCount > 0) return;
87
158
  }
159
+ throw new CommandExecutionError('Image did not upload to WeChat CDN');
88
160
  }
89
161
 
90
162
  async function selectCoverFromContent(page) {
@@ -134,18 +206,23 @@ async function selectCoverFromContent(page) {
134
206
  if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) { btns[i].click(); return; }
135
207
  }
136
208
  })()`);
137
- await page.wait(2);
138
- const hasCover = await page.evaluate(`(() => {
139
- var area = document.querySelector('#js_cover_area');
140
- if (!area) return false;
141
- var found = false;
142
- area.querySelectorAll('*').forEach(function(el) {
143
- var bg = window.getComputedStyle(el).backgroundImage;
144
- if (bg && bg.includes('mmbiz')) found = true;
145
- });
146
- return found;
147
- })()`);
148
- return hasCover;
209
+ for (let attempt = 0; attempt < 15; attempt++) {
210
+ await page.wait(1);
211
+ const hasCover = await page.evaluate(`(() => {
212
+ var areas = document.querySelectorAll('#js_cover_area, #js_cover_description_area, #appmsgItem');
213
+ var found = false;
214
+ areas.forEach(function(area) {
215
+ if (area.querySelector('img[src*="mmbiz"], img[data-src*="mmbiz"]')) found = true;
216
+ [area].concat(Array.from(area.querySelectorAll('*'))).forEach(function(el) {
217
+ var bg = window.getComputedStyle(el).backgroundImage;
218
+ if (bg && bg.includes('mmbiz')) found = true;
219
+ });
220
+ });
221
+ return found;
222
+ })()`);
223
+ if (hasCover) return true;
224
+ }
225
+ return false;
149
226
  }
150
227
 
151
228
  async function clickSaveDraft(page) {
@@ -167,7 +244,7 @@ async function clickSaveDraft(page) {
167
244
  })()`);
168
245
  if (saved) return true;
169
246
  }
170
- return false;
247
+ throw new CommandExecutionError('Draft save could not be confirmed');
171
248
  }
172
249
 
173
250
  export const createDraftCommand = cli({
@@ -190,37 +267,39 @@ export const createDraftCommand = cli({
190
267
  columns: ['status', 'detail'],
191
268
 
192
269
  func: async (page, kwargs) => {
270
+ const args = normalizeCreateDraftArgs(kwargs);
193
271
  await navigateToEditor(page);
194
272
 
195
- const titleResult = await fillField(page, 'textarea#title', kwargs.title);
196
- if (!titleResult?.ok) throw new CommandExecutionError('Failed to fill title');
273
+ const titleResult = await fillField(page, 'textarea#title', args.title);
274
+ requirePageResult(titleResult, 'title');
197
275
 
198
- if (kwargs.author) {
199
- const authorResult = await fillField(page, 'input#author', kwargs.author);
200
- if (!authorResult?.ok) throw new CommandExecutionError('Failed to fill author');
276
+ if (args.author) {
277
+ const authorResult = await fillField(page, 'input#author', args.author);
278
+ requirePageResult(authorResult, 'author');
201
279
  }
202
280
 
203
- const contentResult = await fillContent(page, kwargs.content);
204
- if (!contentResult?.ok) throw new CommandExecutionError('Failed to fill content');
281
+ const contentResult = await fillContent(page, args.content);
282
+ requirePageResult(contentResult, 'content');
205
283
 
206
- if (kwargs['cover-image']) {
207
- await uploadContentImage(page, kwargs['cover-image']);
284
+ if (args.coverImage) {
285
+ await uploadContentImage(page, args.coverImage);
208
286
  const coverSet = await selectCoverFromContent(page);
209
287
  if (!coverSet) {
210
- // Non-fatal: draft can be saved without cover
288
+ throw new CommandExecutionError('Failed to set the requested cover image');
211
289
  }
212
290
  }
213
291
 
214
- if (kwargs.summary) {
215
- await fillField(page, 'textarea#js_description', kwargs.summary);
292
+ if (args.summary) {
293
+ const summaryResult = await fillField(page, 'textarea#js_description', args.summary);
294
+ requirePageResult(summaryResult, 'summary');
216
295
  }
217
296
 
218
297
  await page.wait(1);
219
- const success = await clickSaveDraft(page);
298
+ await clickSaveDraft(page);
220
299
 
221
300
  return [{
222
- status: success ? 'draft saved' : 'save attempted, check browser to confirm',
223
- detail: `"${kwargs.title}"${kwargs.author ? ` by ${kwargs.author}` : ''}${kwargs['cover-image'] ? ' (with cover)' : ''}`,
301
+ status: 'draft saved',
302
+ detail: `"${args.title}"${args.author ? ` by ${args.author}` : ''}${args.coverImage ? ' (with cover)' : ''}`,
224
303
  }];
225
304
  },
226
305
  });
@@ -1,4 +1,7 @@
1
- import { ArgumentError } from '@sovovs/bycli/errors';
1
+ import { constants } from 'node:fs';
2
+ import { access, stat } from 'node:fs/promises';
3
+ import { extname, resolve } from 'node:path';
4
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
2
5
  import { cli, Strategy } from '@sovovs/bycli/registry';
3
6
  import { resolveBrowserCredentials } from './_wechat/auth-session.js';
4
7
  import { buildSecretSet, redactText } from './_wechat/redact.js';
@@ -10,6 +13,7 @@ import {
10
13
  matchPublishedRecord,
11
14
  positiveSafeInteger,
12
15
  validatePublishDate,
16
+ validatePublishedQuery,
13
17
  } from './_wechat/publish-records.js';
14
18
 
15
19
  const COLUMNS = [
@@ -23,6 +27,33 @@ function sanitizedError(error, secrets, fallback) {
23
27
  .replace(/https?:\/\/mp\.weixin\.qq\.com\/\S*/giu, '[REDACTED]');
24
28
  }
25
29
 
30
+ async function validateArtifact(result, { label, expectedStatus, expectedExtension }) {
31
+ if (!result || result.status !== expectedStatus) {
32
+ throw new CommandExecutionError(`${label} returned an invalid status`);
33
+ }
34
+ if (typeof result.path !== 'string' || !result.path.trim()) {
35
+ throw new CommandExecutionError(`${label} returned no output path`);
36
+ }
37
+ if (!Number.isSafeInteger(result.size) || result.size <= 0) {
38
+ throw new CommandExecutionError(`${label} returned an invalid size`);
39
+ }
40
+ const path = resolve(result.path);
41
+ if (extname(path).toLowerCase() !== expectedExtension) {
42
+ throw new CommandExecutionError(`${label} returned an unexpected file type`);
43
+ }
44
+ let info;
45
+ try {
46
+ await access(path, constants.R_OK);
47
+ info = await stat(path);
48
+ } catch {
49
+ throw new CommandExecutionError(`${label} returned an unreadable file`);
50
+ }
51
+ if (!info.isFile() || info.size <= 0 || info.size !== result.size) {
52
+ throw new CommandExecutionError(`${label} returned an unreadable or mismatched file`);
53
+ }
54
+ return { ...result, path, size: info.size };
55
+ }
56
+
26
57
  export const downloadPublishDataCommand = cli({
27
58
  site: 'weixin',
28
59
  name: 'download-publish-data',
@@ -43,6 +74,7 @@ export const downloadPublishDataCommand = cli({
43
74
  func: async (page, args) => {
44
75
  const query = String(args.query ?? '').trim();
45
76
  if (!query) throw new ArgumentError('query required');
77
+ const validatedQuery = validatePublishedQuery(query);
46
78
 
47
79
  const timeoutSeconds = positiveSafeInteger(args.timeout, 'timeout', 60);
48
80
  const maxPages = positiveSafeInteger(args['max-pages'], 'max-pages', 5);
@@ -56,7 +88,7 @@ export const downloadPublishDataCommand = cli({
56
88
  maxPages,
57
89
  timeout: timeoutSeconds,
58
90
  });
59
- const record = matchPublishedRecord(rows, query, validatedDate);
91
+ const record = matchPublishedRecord(rows, validatedQuery, validatedDate);
60
92
  const detailUrl = buildDetailUrl(record, token);
61
93
  const outputDir = args.output ?? './weixin-publish-data';
62
94
  const commonOptions = {
@@ -71,15 +103,25 @@ export const downloadPublishDataCommand = cli({
71
103
  let markdownResult = null;
72
104
  const errors = [];
73
105
  try {
74
- dataResult = await downloadPublishData(page, commonOptions);
106
+ const result = await downloadPublishData(page, commonOptions);
107
+ dataResult = await validateArtifact(result, {
108
+ label: 'Excel artifact',
109
+ expectedStatus: 'downloaded',
110
+ expectedExtension: '.xls',
111
+ });
75
112
  } catch (error) {
76
113
  errors.push(`Excel download failed: ${sanitizedError(error, secrets, 'Excel download failed')}`);
77
114
  }
78
115
  try {
79
- markdownResult = await collectPublishAnalysis(page, {
116
+ const result = await collectPublishAnalysis(page, {
80
117
  ...commonOptions,
81
118
  publishedAt: record.publishedAt,
82
119
  });
120
+ markdownResult = await validateArtifact(result, {
121
+ label: 'Markdown artifact',
122
+ expectedStatus: 'saved',
123
+ expectedExtension: '.md',
124
+ });
83
125
  } catch (error) {
84
126
  errors.push(`Markdown analysis failed: ${sanitizedError(error, secrets, 'Markdown analysis failed')}`);
85
127
  }
@@ -8,107 +8,21 @@
8
8
  */
9
9
  import { cli, Strategy } from '@sovovs/bycli/registry';
10
10
  import { downloadArticle } from '@sovovs/bycli/download/article-download';
11
- import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
11
+ import { AuthRequiredError } from '@sovovs/bycli/errors';
12
12
  import { buildExtractWechatArticleContentJs } from './_wechat/article-content.js';
13
+ import {
14
+ isTrustedSogouRedirectUrl,
15
+ isTrustedWechatArticleUrl,
16
+ normalizeWechatUrl,
17
+ resolveWechatArticleUrl,
18
+ } from './_wechat/article-link.js';
13
19
  export { extractWechatArticleContent } from './_wechat/article-content.js';
14
- // ============================================================
15
- // URL Normalization
16
- // ============================================================
17
- /**
18
- * Normalize a pasted WeChat article URL.
19
- */
20
- export function normalizeWechatUrl(raw) {
21
- let s = (raw || '').trim();
22
- if (!s)
23
- return s;
24
- // Strip wrapping quotes / angle brackets
25
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
26
- s = s.slice(1, -1).trim();
27
- }
28
- if (s.startsWith('<') && s.endsWith('>')) {
29
- s = s.slice(1, -1).trim();
30
- }
31
- // Remove backslash escapes before URL-significant characters
32
- s = s.replace(/\\+([:/&?=#%])/g, '$1');
33
- // Decode HTML entities
34
- s = s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
35
- // Allow bare hostnames
36
- if (s.startsWith('mp.weixin.qq.com/') || s.startsWith('//mp.weixin.qq.com/')) {
37
- s = 'https://' + s.replace(/^\/+/, '');
38
- }
39
- // Force https for mp.weixin.qq.com
40
- try {
41
- const parsed = new URL(s);
42
- if (['http:', 'https:'].includes(parsed.protocol) && parsed.hostname.toLowerCase() === 'mp.weixin.qq.com') {
43
- parsed.protocol = 'https:';
44
- s = parsed.toString();
45
- }
46
- }
47
- catch {
48
- // Ignore parse errors
49
- }
50
- return s;
51
- }
52
-
53
- function isStrictHttpsUrl(raw, hostname, pathname) {
54
- try {
55
- const url = new URL(raw);
56
- return url.protocol === 'https:' && url.hostname === hostname
57
- && url.port === '' && url.username === '' && url.password === ''
58
- && pathname(url.pathname);
59
- }
60
- catch {
61
- return false;
62
- }
63
- }
64
-
65
- export function isTrustedWechatArticleUrl(raw) {
66
- return isStrictHttpsUrl(raw, 'mp.weixin.qq.com', path => path === '/s' || path.startsWith('/s/'));
67
- }
68
-
69
- export function isTrustedSogouRedirectUrl(raw) {
70
- return isStrictHttpsUrl(raw, 'weixin.sogou.com', path => path === '/link');
71
- }
72
-
73
- export async function resolveWechatDownloadUrl(page, rawUrl) {
74
- const sourceUrl = normalizeWechatUrl(rawUrl);
75
- if (isTrustedWechatArticleUrl(sourceUrl)) {
76
- return { sourceUrl, resolvedUrl: sourceUrl, alreadyNavigated: false };
77
- }
78
- if (!isTrustedSogouRedirectUrl(sourceUrl)) {
79
- throw new ArgumentError(
80
- 'A trusted WeChat article or Sogou Weixin result URL is required.',
81
- 'Pass an https://mp.weixin.qq.com/s/... or https://weixin.sogou.com/link?... URL.',
82
- );
83
- }
84
- try {
85
- await page.goto(sourceUrl);
86
- await page.wait(2);
87
- const result = await page.evaluate(`(() => ({
88
- finalUrl: window.location.href,
89
- pageText: document.body ? document.body.innerText : '',
90
- html: document.documentElement ? document.documentElement.innerHTML : '',
91
- }))()`);
92
- const text = `${result?.pageText || ''} ${result?.html || ''}`;
93
- if (/验证码|安全验证|异常访问|访问过于频繁|请输入验证码/.test(text)) {
94
- throw new AuthRequiredError(
95
- 'weixin.sogou.com',
96
- 'Sogou Weixin requires verification. Complete it in the open browser tab and run the command again.',
97
- );
98
- }
99
- if (!isTrustedWechatArticleUrl(result?.finalUrl)) {
100
- throw new CommandExecutionError(
101
- 'Sogou Weixin did not resolve to a trusted WeChat article URL',
102
- 'Open the search result in a browser and confirm it redirects to mp.weixin.qq.com/s/... before retrying.',
103
- );
104
- }
105
- return { sourceUrl, resolvedUrl: new URL(result.finalUrl).href, alreadyNavigated: true };
106
- }
107
- catch (error) {
108
- if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) throw error;
109
- throw new CommandExecutionError('Failed to resolve the Sogou Weixin result URL');
110
- }
111
- }
20
+ export {
21
+ isTrustedSogouRedirectUrl,
22
+ isTrustedWechatArticleUrl,
23
+ normalizeWechatUrl,
24
+ resolveWechatArticleUrl as resolveWechatDownloadUrl,
25
+ };
112
26
  /**
113
27
  * Format a WeChat article timestamp as a UTC+8 datetime string.
114
28
  * Accepts either Unix seconds or milliseconds.
@@ -245,7 +159,7 @@ cli({
245
159
  ],
246
160
  columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved', 'source_url', 'resolved_url'],
247
161
  func: async (page, kwargs) => {
248
- const { sourceUrl, resolvedUrl, alreadyNavigated } = await resolveWechatDownloadUrl(page, kwargs.url);
162
+ const { sourceUrl, resolvedUrl, alreadyNavigated } = await resolveWechatArticleUrl(page, kwargs.url);
249
163
  // Navigate and wait for content to load. Sogou resolution already lands on the article.
250
164
  if (!alreadyNavigated)
251
165
  await page.goto(resolvedUrl);
@@ -1,13 +1,21 @@
1
- import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
1
+ import {
2
+ ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError,
3
+ } from '@sovovs/bycli/errors';
2
4
  import { MAX_WECHAT_HTML_BYTES } from '@sovovs/bycli/download/wechat-article';
3
5
  import { cli, Strategy } from '@sovovs/bycli/registry';
4
6
  import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
7
+ import {
8
+ combineArticleFallbackErrors,
9
+ isEligibleArticleFallbackError,
10
+ withMissingFallbackName,
11
+ } from './_wechat/article-fallback-policy.js';
5
12
  import { createArticleIndexFetcher } from './_wechat/article-index.js';
6
13
  import {
7
14
  callCrawler, collectArticles, isTrustedWechatArticleUrl, saveArticles,
8
15
  } from './_wechat/crawler-runtime.js';
9
16
  import { readAuthSource } from './_wechat/args.js';
10
17
  import { wechatArticleToMarkdown } from './_wechat/markdown.js';
18
+ import { collectSogouAccountArticles } from './_wechat/sogou-fallback.js';
11
19
 
12
20
  const DOMAIN = 'mp.weixin.qq.com';
13
21
  const browserRequired = args => readAuthSource(args) === 'browser';
@@ -156,11 +164,11 @@ export const saveArticlesCommand = cli({
156
164
  description: 'Download WeChat official-account articles as Markdown files',
157
165
  strategy: Strategy.COOKIE, browser: browserRequired,
158
166
  args: [
159
- { name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' }, { name: 'name', help: 'Official-account name used in Markdown metadata' },
167
+ { name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' }, { name: 'name', help: 'Official-account name; exact case-insensitive match required for browser Sogou fallback' },
160
168
  { name: 'output', default: './weixin-articles', help: 'Directory for saved Markdown files' }, { name: 'limit', type: 'int', help: 'Maximum number of articles to save' },
161
169
  { 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' },
162
170
  ],
163
- columns: ['title', 'status', 'stage', 'path', 'error', 'url'],
171
+ columns: ['title', 'status', 'stage', 'path', 'error', 'url', 'source', 'coverage'],
164
172
  func: async (page, args) => {
165
173
  const fakeid = String(args.fakeid ?? '').trim();
166
174
  if (!fakeid) throw new ArgumentError('fakeid is required');
@@ -169,20 +177,55 @@ export const saveArticlesCommand = cli({
169
177
  ? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
170
178
  const articleHtmlDownloader = createArticleHtmlDownloader({ authSource, page });
171
179
  const fetchPage = createArticleIndexFetcher({ page, source: authSource, credentials });
172
- const rows = await callCrawler(async () => {
173
- const { articles } = await collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
174
- return saveArticles({
180
+ let articles;
181
+ let resolutionFailures = [];
182
+ let source = 'wechat';
183
+ let coverage = null;
184
+ try {
185
+ const result = await callCrawler(() => collectArticles({
186
+ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'],
187
+ }));
188
+ articles = result.articles;
189
+ if (articles.length === 0) {
190
+ throw new EmptyResultError('weixin save-articles', `No published articles were found for ${fakeid}.`);
191
+ }
192
+ } catch (primaryError) {
193
+ if (authSource !== 'browser' || !isEligibleArticleFallbackError(primaryError)) throw primaryError;
194
+ const accountName = String(args.name ?? '').trim();
195
+ if (!accountName) throw withMissingFallbackName('weixin save-articles', primaryError);
196
+ try {
197
+ const fallback = await collectSogouAccountArticles({
198
+ page, accountName, limit: args.limit, maxPages: args['max-pages'],
199
+ resolutionPolicy: 'rows',
200
+ });
201
+ articles = fallback.articles;
202
+ resolutionFailures = fallback.resolutionFailures;
203
+ source = fallback.source;
204
+ coverage = fallback.coverage;
205
+ } catch (fallbackError) {
206
+ throw combineArticleFallbackErrors({
207
+ operation: 'weixin save-articles', primaryError, fallbackError, credentials,
208
+ });
209
+ }
210
+ }
211
+
212
+ const savedRows = articles.length === 0 ? [] : await callCrawler(() => saveArticles({
175
213
  articles, accountName: String(args.name ?? '').trim(),
176
214
  outputDir: args.output ?? './weixin-articles', fetchArticleHtml: articleHtmlDownloader,
177
215
  buildMarkdown: (article, html) => wechatArticleToMarkdown({
178
216
  html, title: article.title, accountName: String(args.name ?? '').trim(), author: article.author,
179
217
  publishedAt: article.publishedAt, digest: article.digest, url: article.url,
180
218
  }), existingFilePolicy: 'suffix',
181
- });
182
- });
183
- return rows.map(row => ({
219
+ }));
220
+ const orderedRows = source === 'sogou'
221
+ ? [
222
+ ...savedRows.map((row, index) => ({ ...row, order: articles[index]?.order ?? index })),
223
+ ...resolutionFailures,
224
+ ].sort((left, right) => left.order - right.order)
225
+ : savedRows;
226
+ return orderedRows.map(row => ({
184
227
  title: row.title, status: row.status, stage: row.stage || null, path: row.saved || null,
185
- error: row.error || null, url: row.url,
228
+ error: row.error || null, url: row.url, source, coverage,
186
229
  }));
187
230
  },
188
231
  });