@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.
package/cli-manifest.json CHANGED
@@ -27869,7 +27869,7 @@
27869
27869
  "name": "name",
27870
27870
  "type": "str",
27871
27871
  "required": false,
27872
- "help": "Optional official-account name for display context"
27872
+ "help": "Official-account name; exact case-insensitive match required for browser Sogou fallback"
27873
27873
  },
27874
27874
  {
27875
27875
  "name": "limit",
@@ -27900,7 +27900,9 @@
27900
27900
  "author",
27901
27901
  "digest",
27902
27902
  "publishedAt",
27903
- "url"
27903
+ "url",
27904
+ "source",
27905
+ "coverage"
27904
27906
  ],
27905
27907
  "type": "js",
27906
27908
  "modulePath": "weixin/articles.js",
@@ -28264,7 +28266,7 @@
28264
28266
  "name": "name",
28265
28267
  "type": "str",
28266
28268
  "required": false,
28267
- "help": "Official-account name used in Markdown metadata"
28269
+ "help": "Official-account name; exact case-insensitive match required for browser Sogou fallback"
28268
28270
  },
28269
28271
  {
28270
28272
  "name": "output",
@@ -28303,7 +28305,9 @@
28303
28305
  "stage",
28304
28306
  "path",
28305
28307
  "error",
28306
- "url"
28308
+ "url",
28309
+ "source",
28310
+ "coverage"
28307
28311
  ],
28308
28312
  "type": "js",
28309
28313
  "modulePath": "weixin/save-articles.js",
@@ -0,0 +1,46 @@
1
+ import {
2
+ AuthRequiredError, CommandExecutionError, EmptyResultError,
3
+ } from '@sovovs/bycli/errors';
4
+ import { buildSecretSet, redactText } from './redact.js';
5
+
6
+ function safePhase(error, secrets) {
7
+ const message = error instanceof Error ? error.message : String(error);
8
+ const hint = error && typeof error === 'object' && typeof error.hint === 'string'
9
+ ? error.hint : '';
10
+ const summary = hint ? `${message} (${hint})` : message;
11
+ return {
12
+ code: error && typeof error === 'object' && typeof error.code === 'string'
13
+ ? error.code : 'UNKNOWN',
14
+ summary: redactText(summary, secrets),
15
+ };
16
+ }
17
+
18
+ export function isEligibleArticleFallbackError(error) {
19
+ return error instanceof CommandExecutionError || error instanceof EmptyResultError;
20
+ }
21
+
22
+ export function withMissingFallbackName(operation, error) {
23
+ const hint = `${error.hint ? `${error.hint} ` : ''}Sogou fallback requires the exact official-account name in --name.`;
24
+ if (error instanceof EmptyResultError) return new EmptyResultError(operation, hint);
25
+ return new CommandExecutionError(error.message, hint);
26
+ }
27
+
28
+ export function combineArticleFallbackErrors({
29
+ operation,
30
+ primaryError,
31
+ fallbackError,
32
+ credentials,
33
+ }) {
34
+ if (fallbackError instanceof AuthRequiredError) return fallbackError;
35
+ const secrets = buildSecretSet(credentials);
36
+ const primary = safePhase(primaryError, secrets);
37
+ const fallback = safePhase(fallbackError, secrets);
38
+ const hint = `Primary (${primary.code}): ${primary.summary}; fallback (${fallback.code}): ${fallback.summary}`;
39
+ if (primaryError instanceof EmptyResultError && fallbackError instanceof EmptyResultError) {
40
+ return new EmptyResultError(operation, hint);
41
+ }
42
+ return new CommandExecutionError(
43
+ 'Weixin article index and Sogou fallback both failed',
44
+ hint,
45
+ );
46
+ }
@@ -105,6 +105,14 @@ function buildReferer(token) {
105
105
  return `https://${DOMAIN}/cgi-bin/appmsg?${params}`;
106
106
  }
107
107
 
108
+ function isWechatVerificationResponse(message, hint) {
109
+ const text = `${message}\n${hint ?? ''}`;
110
+ return /mp\/wappoc_appmsgcaptcha/i.test(text)
111
+ || /secitptpage\/verify\.html/i.test(text)
112
+ || /id=["']js_verify["']/i.test(text)
113
+ || (/环境异常/.test(text) && /(完成验证后即可继续访问|去验证)/.test(text));
114
+ }
115
+
108
116
  function transportError(error, credentials) {
109
117
  const secrets = buildSecretSet(credentials);
110
118
  const message = error instanceof Error ? error.message : String(error);
@@ -114,6 +122,12 @@ function transportError(error, credentials) {
114
122
  const redactedHint = hint ? redactText(hint, secrets) : undefined;
115
123
  if (error instanceof AuthRequiredError && error.domain === DOMAIN
116
124
  && redactedMessage === message && redactedHint === hint) return error;
125
+ if (isWechatVerificationResponse(redactedMessage, redactedHint)) {
126
+ return new AuthRequiredError(
127
+ DOMAIN,
128
+ 'WeChat article index requires environment verification. Complete it in the open browser tab and run the command again.',
129
+ );
130
+ }
117
131
  return new CommandExecutionError(
118
132
  `WeChat appmsgpublish request failed: ${redactedMessage}`,
119
133
  redactedHint,
@@ -0,0 +1,88 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
2
+
3
+ export function normalizeWechatUrl(raw) {
4
+ let value = String(raw ?? '').trim();
5
+ if (!value) return value;
6
+ if ((value.startsWith('"') && value.endsWith('"'))
7
+ || (value.startsWith("'") && value.endsWith("'"))) {
8
+ value = value.slice(1, -1).trim();
9
+ }
10
+ if (value.startsWith('<') && value.endsWith('>')) {
11
+ value = value.slice(1, -1).trim();
12
+ }
13
+ value = value.replace(/\\+([:/&?=#%])/g, '$1');
14
+ value = value.replace(/&amp;/g, '&').replace(/&lt;/g, '<')
15
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"');
16
+ if (value.startsWith('mp.weixin.qq.com/') || value.startsWith('//mp.weixin.qq.com/')) {
17
+ value = `https://${value.replace(/^\/+/, '')}`;
18
+ }
19
+ try {
20
+ const parsed = new URL(value);
21
+ if (['http:', 'https:'].includes(parsed.protocol)
22
+ && parsed.hostname.toLowerCase() === 'mp.weixin.qq.com') {
23
+ parsed.protocol = 'https:';
24
+ value = parsed.toString();
25
+ }
26
+ } catch {
27
+ // Trust validation below reports malformed URLs.
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function isStrictHttpsUrl(raw, hostname, pathname) {
33
+ try {
34
+ const url = new URL(raw);
35
+ return url.protocol === 'https:' && url.hostname === hostname
36
+ && url.port === '' && url.username === '' && url.password === ''
37
+ && pathname(url.pathname);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export function isTrustedWechatArticleUrl(raw) {
44
+ return isStrictHttpsUrl(raw, 'mp.weixin.qq.com', path => path === '/s' || path.startsWith('/s/'));
45
+ }
46
+
47
+ export function isTrustedSogouRedirectUrl(raw) {
48
+ return isStrictHttpsUrl(raw, 'weixin.sogou.com', path => path === '/link');
49
+ }
50
+
51
+ export async function resolveWechatArticleUrl(page, rawUrl) {
52
+ const sourceUrl = normalizeWechatUrl(rawUrl);
53
+ if (isTrustedWechatArticleUrl(sourceUrl)) {
54
+ return { sourceUrl, resolvedUrl: sourceUrl, alreadyNavigated: false };
55
+ }
56
+ if (!isTrustedSogouRedirectUrl(sourceUrl)) {
57
+ throw new ArgumentError(
58
+ 'A trusted WeChat article or Sogou Weixin result URL is required.',
59
+ 'Pass an https://mp.weixin.qq.com/s/... or https://weixin.sogou.com/link?... URL.',
60
+ );
61
+ }
62
+ try {
63
+ await page.goto(sourceUrl);
64
+ await page.wait(2);
65
+ const result = await page.evaluate(`(() => ({
66
+ finalUrl: window.location.href,
67
+ pageText: document.body ? document.body.innerText : '',
68
+ html: document.documentElement ? document.documentElement.innerHTML : '',
69
+ }))()`);
70
+ const text = `${result?.pageText || ''} ${result?.html || ''}`;
71
+ if (/验证码|安全验证|异常访问|访问过于频繁|请输入验证码/.test(text)) {
72
+ throw new AuthRequiredError(
73
+ 'weixin.sogou.com',
74
+ 'Sogou Weixin requires verification. Complete it in the open browser tab and run the command again.',
75
+ );
76
+ }
77
+ if (!isTrustedWechatArticleUrl(result?.finalUrl)) {
78
+ throw new CommandExecutionError(
79
+ 'Sogou Weixin did not resolve to a trusted WeChat article URL',
80
+ 'Open the search result in a browser and confirm it redirects to mp.weixin.qq.com/s/... before retrying.',
81
+ );
82
+ }
83
+ return { sourceUrl, resolvedUrl: new URL(result.finalUrl).href, alreadyNavigated: true };
84
+ } catch (error) {
85
+ if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) throw error;
86
+ throw new CommandExecutionError('Failed to resolve the Sogou Weixin result URL');
87
+ }
88
+ }
@@ -69,6 +69,7 @@ function routePart(value) {
69
69
 
70
70
  function decodePublishInfo(value) {
71
71
  const decoded = parseJson(value, 'publish_info');
72
+ if (Array.isArray(decoded.appmsg_info)) return decoded;
72
73
  if (!Object.prototype.hasOwnProperty.call(decoded, 'publish_info')) return decoded;
73
74
  const nested = decoded.publish_info;
74
75
  if (typeof nested === 'string') return parseJson(nested, 'nested publish_info');
@@ -86,7 +87,7 @@ function parseEntry(info, article) {
86
87
  if (msgid === null || itemIdx === null) {
87
88
  throw commandError('returned an article without a detail route');
88
89
  }
89
- const publishedAt = dateInShanghai(info?.sent_info?.time);
90
+ const publishedAt = dateInShanghai(info?.sent_info?.time ?? info?.publish_info?.create_time);
90
91
  return {
91
92
  title,
92
93
  publishedAt,
@@ -249,6 +250,18 @@ function normalizeArticleUrl(value) {
249
250
  return url.href;
250
251
  }
251
252
 
253
+ export function validatePublishedQuery(value) {
254
+ const text = normalizeTitle(value);
255
+ if (!text) throw new ArgumentError('query must not be empty');
256
+ const parsed = parseAbsoluteUrl(text);
257
+ if (!parsed) return text;
258
+ const trustedPath = parsed.pathname === '/s' || parsed.pathname.startsWith('/s/');
259
+ if (!normalizeArticleUrl(text) || !trustedPath) {
260
+ throw new ArgumentError('query URL must be a trusted WeChat article URL');
261
+ }
262
+ return text;
263
+ }
264
+
252
265
  function ambiguityError(matches) {
253
266
  const choices = matches.slice(0, 5)
254
267
  .map(record => `${record.publishedAt ?? record.publishDate} ${record.title} ${record.url}`)
@@ -280,8 +293,6 @@ export function matchPublishedRecord(records, query, date) {
280
293
  } else {
281
294
  const exact = uniqueMatch(candidates.filter(record => normalizeTitle(record.title) === text));
282
295
  if (exact) return exact;
283
- const substring = uniqueMatch(candidates.filter(record => normalizeTitle(record.title).includes(text)));
284
- if (substring) return substring;
285
296
  }
286
297
 
287
298
  throw new EmptyResultError(
@@ -0,0 +1,223 @@
1
+ import {
2
+ ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError,
3
+ } from '@sovovs/bycli/errors';
4
+ import { resolveWechatArticleUrl } from './article-link.js';
5
+ import { redactText } from './redact.js';
6
+ import {
7
+ DEFAULT_SOGOU_MAX_PAGES,
8
+ normalizePositiveInteger,
9
+ searchSogouArticlePage,
10
+ } from './sogou-search.js';
11
+
12
+ const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
13
+
14
+ export function isExactAccountName(actual, expected) {
15
+ const normalizedActual = String(actual ?? '').trim().toLowerCase();
16
+ const normalizedExpected = String(expected ?? '').trim().toLowerCase();
17
+ return normalizedExpected.length > 0 && normalizedActual === normalizedExpected;
18
+ }
19
+
20
+ function normalizedUrl(raw) {
21
+ try {
22
+ return new URL(raw).href;
23
+ } catch {
24
+ return String(raw ?? '').trim();
25
+ }
26
+ }
27
+
28
+ function comparableTimestamp(raw) {
29
+ const value = Number(raw);
30
+ if (!Number.isFinite(value) || value <= 0) return null;
31
+ return value >= 1_000_000_000_000 ? Math.floor(value / 1000) : value;
32
+ }
33
+
34
+ function validCstEpochSeconds(year, month, day, hour, minute, second) {
35
+ const epochMs = Date.UTC(year, month - 1, day, hour, minute, second) - CST_OFFSET_MS;
36
+ const check = new Date(epochMs + CST_OFFSET_MS);
37
+ if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1
38
+ || check.getUTCDate() !== day || check.getUTCHours() !== hour
39
+ || check.getUTCMinutes() !== minute || check.getUTCSeconds() !== second) return null;
40
+ return Math.floor(epochMs / 1000);
41
+ }
42
+
43
+ export function normalizeSogouPublishTimestamp({
44
+ publishTimestamp,
45
+ publishTime,
46
+ scanStartedAt,
47
+ }) {
48
+ const raw = comparableTimestamp(publishTimestamp);
49
+ if (raw !== null) return Math.floor(raw);
50
+ const startMs = Number(scanStartedAt);
51
+ if (!Number.isFinite(startMs)) return null;
52
+ const text = String(publishTime ?? '').trim();
53
+
54
+ const relative = text.match(/^(\d+)(分钟|小时|天)前$/);
55
+ if (relative) {
56
+ const amount = Number(relative[1]);
57
+ const secondsPerUnit = { 分钟: 60, 小时: 3600, 天: 86400 }[relative[2]];
58
+ if (!Number.isSafeInteger(amount)) return null;
59
+ const timestamp = Math.floor(startMs / 1000) - amount * secondsPerUnit;
60
+ return Number.isSafeInteger(timestamp) && timestamp > 0 ? timestamp : null;
61
+ }
62
+
63
+ const dayWord = text.match(/^(昨天|前天)(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
64
+ if (dayWord) {
65
+ const cstNow = new Date(startMs + CST_OFFSET_MS);
66
+ const dayOffset = dayWord[1] === '昨天' ? 1 : 2;
67
+ const clock = dayWord[2] === undefined
68
+ ? [cstNow.getUTCHours(), cstNow.getUTCMinutes(), cstNow.getUTCSeconds()]
69
+ : [Number(dayWord[2]), Number(dayWord[3]), Number(dayWord[4] ?? 0)];
70
+ const prior = new Date(Date.UTC(
71
+ cstNow.getUTCFullYear(), cstNow.getUTCMonth(), cstNow.getUTCDate() - dayOffset,
72
+ ));
73
+ return validCstEpochSeconds(
74
+ prior.getUTCFullYear(), prior.getUTCMonth() + 1, prior.getUTCDate(), ...clock,
75
+ );
76
+ }
77
+
78
+ const absolute = text.match(
79
+ /^(\d{4})(?:-(\d{1,2})-(\d{1,2})|\/(\d{1,2})\/(\d{1,2})|年(\d{1,2})月(\d{1,2})日)(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/,
80
+ );
81
+ if (!absolute) return null;
82
+ const month = Number(absolute[2] ?? absolute[4] ?? absolute[6]);
83
+ const day = Number(absolute[3] ?? absolute[5] ?? absolute[7]);
84
+ return validCstEpochSeconds(
85
+ Number(absolute[1]), month, day,
86
+ Number(absolute[8] ?? 0), Number(absolute[9] ?? 0), Number(absolute[10] ?? 0),
87
+ );
88
+ }
89
+
90
+ function safeResolutionError(error) {
91
+ const message = error instanceof Error ? error.message : 'Sogou article link resolution failed';
92
+ return redactText(message, []) || 'Sogou article link resolution failed';
93
+ }
94
+
95
+ export async function collectSogouAccountArticles({
96
+ page,
97
+ accountName,
98
+ limit,
99
+ maxPages,
100
+ searchPage = searchSogouArticlePage,
101
+ resolveUrl = resolveWechatArticleUrl,
102
+ resolutionPolicy = 'atomic',
103
+ scanStartedAt = Date.now(),
104
+ }) {
105
+ const normalizedName = String(accountName ?? '').trim();
106
+ if (!normalizedName) {
107
+ throw new ArgumentError(
108
+ 'weixin Sogou fallback requires --name',
109
+ 'Pass the exact official-account name with --name.',
110
+ );
111
+ }
112
+ if (!['atomic', 'rows'].includes(resolutionPolicy)) {
113
+ throw new ArgumentError('Invalid Sogou fallback resolution policy');
114
+ }
115
+ const pageLimit = normalizePositiveInteger(
116
+ maxPages,
117
+ 'max-pages',
118
+ DEFAULT_SOGOU_MAX_PAGES,
119
+ );
120
+ const articleLimit = limit === undefined || limit === null
121
+ ? null : normalizePositiveInteger(limit, 'limit');
122
+ const seenFingerprints = new Set();
123
+ const seenSogouUrls = new Set();
124
+ const candidates = [];
125
+ let pagesScanned = 0;
126
+ let coverage = 'max-pages-reached';
127
+ let firstSeen = 0;
128
+
129
+ for (let pageNo = 1; pageNo <= pageLimit; pageNo += 1) {
130
+ const result = await searchPage(page, { query: normalizedName, pageNo });
131
+ pagesScanned += 1;
132
+ if (result.state === 'empty') {
133
+ coverage = 'search-exhausted';
134
+ break;
135
+ }
136
+ if (seenFingerprints.has(result.fingerprint)) {
137
+ throw new CommandExecutionError(
138
+ 'Sogou Weixin repeated a result page while scanning account articles',
139
+ `Page ${pageNo} repeated an earlier page; refusing to return a partial article index.`,
140
+ );
141
+ }
142
+ seenFingerprints.add(result.fingerprint);
143
+ for (const item of result.rows) {
144
+ if (!isExactAccountName(item.account, normalizedName)) continue;
145
+ const sourceKey = normalizedUrl(item.url);
146
+ if (seenSogouUrls.has(sourceKey)) continue;
147
+ seenSogouUrls.add(sourceKey);
148
+ candidates.push({
149
+ ...item,
150
+ firstSeen,
151
+ sourceKey,
152
+ normalizedTimestamp: normalizeSogouPublishTimestamp({
153
+ publishTimestamp: item.publishTimestamp,
154
+ publishTime: item.publishTime,
155
+ scanStartedAt,
156
+ }),
157
+ });
158
+ firstSeen += 1;
159
+ }
160
+ }
161
+
162
+ if (candidates.length === 0) {
163
+ const coverageHint = coverage === 'max-pages-reached'
164
+ ? `Scanned ${pagesScanned} pages and reached the page cap; later pages may still contain a match.`
165
+ : `Sogou search exhausted after ${pagesScanned} pages.`;
166
+ throw new EmptyResultError(
167
+ 'weixin Sogou account fallback',
168
+ `No Sogou articles matched the exact official-account name "${normalizedName}"; similarly named accounts were excluded. ${coverageHint}`,
169
+ );
170
+ }
171
+
172
+ candidates.sort((left, right) => {
173
+ const leftTime = left.normalizedTimestamp;
174
+ const rightTime = right.normalizedTimestamp;
175
+ if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return rightTime - leftTime;
176
+ if (leftTime !== null && rightTime === null) return -1;
177
+ if (leftTime === null && rightTime !== null) return 1;
178
+ return left.firstSeen - right.firstSeen;
179
+ });
180
+
181
+ const seenResolvedUrls = new Set();
182
+ const articles = [];
183
+ const resolutionFailures = [];
184
+ let terminalCount = 0;
185
+ for (const candidate of candidates) {
186
+ if (articleLimit !== null && terminalCount >= articleLimit) break;
187
+ try {
188
+ const resolved = await resolveUrl(page, candidate.url);
189
+ const resolvedKey = normalizedUrl(resolved.resolvedUrl);
190
+ if (seenResolvedUrls.has(resolvedKey)) continue;
191
+ seenResolvedUrls.add(resolvedKey);
192
+ articles.push({
193
+ title: candidate.title,
194
+ author: null,
195
+ digest: candidate.summary || null,
196
+ publishedAt: candidate.publishTime || null,
197
+ url: resolved.resolvedUrl,
198
+ sourceUrl: resolved.sourceUrl,
199
+ order: terminalCount,
200
+ });
201
+ terminalCount += 1;
202
+ } catch (error) {
203
+ if (error instanceof AuthRequiredError || resolutionPolicy === 'atomic') throw error;
204
+ resolutionFailures.push({
205
+ title: candidate.title,
206
+ status: 'failed',
207
+ stage: 'resolve',
208
+ error: safeResolutionError(error),
209
+ url: candidate.url,
210
+ order: terminalCount,
211
+ });
212
+ terminalCount += 1;
213
+ }
214
+ }
215
+
216
+ return {
217
+ source: 'sogou',
218
+ coverage,
219
+ pagesScanned,
220
+ articles,
221
+ resolutionFailures,
222
+ };
223
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ ArgumentError, AuthRequiredError, CommandExecutionError,
3
+ } from '@sovovs/bycli/errors';
4
+
5
+ const SOGOU_WEIXIN_DOMAIN = 'weixin.sogou.com';
6
+ export const DEFAULT_SOGOU_MAX_PAGES = 50;
7
+
8
+ export function normalizePositiveInteger(value, name, defaultValue, maxValue) {
9
+ if (value === undefined || value === null) return defaultValue;
10
+ const text = String(value).trim();
11
+ if (!/^\d+$/.test(text)) {
12
+ throw new ArgumentError(
13
+ `weixin sougousearch --${name} must be a positive integer`,
14
+ `Pass --${name} as a whole number${maxValue ? ` from 1 to ${maxValue}` : ' greater than 0'}.`,
15
+ );
16
+ }
17
+ const parsed = Number(text);
18
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || (maxValue && parsed > maxValue)) {
19
+ throw new ArgumentError(
20
+ `weixin sougousearch --${name} is out of range`,
21
+ `Pass --${name} as a whole number${maxValue ? ` from 1 to ${maxValue}` : ' greater than 0'}.`,
22
+ );
23
+ }
24
+ return parsed;
25
+ }
26
+
27
+ export function buildSogouSearchUrl(query, pageNo) {
28
+ const searchUrl = new URL('https://weixin.sogou.com/weixin');
29
+ searchUrl.searchParams.set('query', query);
30
+ searchUrl.searchParams.set('type', '2');
31
+ searchUrl.searchParams.set('page', String(pageNo));
32
+ searchUrl.searchParams.set('ie', 'utf8');
33
+ return searchUrl.toString();
34
+ }
35
+
36
+ export function buildExtractSogouSearchResultsEvaluate() {
37
+ return String.raw`(() => {
38
+ const clean = (value) => {
39
+ return (value || '')
40
+ .replace(/\s+/g, ' ')
41
+ .replace(/<!--red_beg-->|<!--red_end-->/g, '')
42
+ .replace(/document\.write\(timeConvert\(['"]\d+['"]\)\)/g, '')
43
+ .trim();
44
+ };
45
+
46
+ const absolutize = (href) => {
47
+ if (!href) return '';
48
+ try {
49
+ return new URL(href, window.location.origin).toString();
50
+ } catch {
51
+ return href;
52
+ }
53
+ };
54
+
55
+ const bodyText = clean(document.body && document.body.innerText);
56
+ const blocked = /验证码|安全验证|异常访问|访问过于频繁|请输入验证码/.test(bodyText);
57
+ const empty = /没有找到相关的微信文章|未找到相关|暂无相关|没有找到/.test(bodyText)
58
+ || Boolean(document.querySelector('.no-result, .no_result, .s-noresult'));
59
+ const cards = Array.from(document.querySelectorAll('.news-list li'));
60
+ const extracted = cards.map((item) => {
61
+ const linkEl = item.querySelector('h3 a[href]');
62
+ const summaryEl = item.querySelector('p.txt-info');
63
+ const accountEl = item.querySelector('.s-p .all-time-y2');
64
+ const timeEl = item.querySelector('.s-p .s2');
65
+ const rawTimeHtml = timeEl && timeEl.innerHTML || '';
66
+ const timestampMatch = rawTimeHtml.match(/timeConvert\(['"](\d{10,13})['"]\)/);
67
+ return {
68
+ title: clean(linkEl && linkEl.textContent),
69
+ account: clean(accountEl && accountEl.textContent),
70
+ url: absolutize(linkEl && linkEl.getAttribute('href')),
71
+ summary: clean(summaryEl && summaryEl.textContent),
72
+ publishTime: clean(timeEl && timeEl.textContent),
73
+ publishTimestamp: timestampMatch ? Number(timestampMatch[1]) : null,
74
+ };
75
+ });
76
+ const rows = extracted.filter((row) => row.title && row.url);
77
+
78
+ return {
79
+ blocked,
80
+ empty,
81
+ invalidCount: extracted.length - rows.length,
82
+ rows,
83
+ };
84
+ })()`;
85
+ }
86
+
87
+ function fingerprintRows(rows) {
88
+ return rows.map(row => `${row.title}\u0000${row.url}`).join('\u0001');
89
+ }
90
+
91
+ export async function searchSogouArticlePage(page, { query, pageNo }) {
92
+ const normalizedQuery = String(query ?? '').trim();
93
+ if (!normalizedQuery) {
94
+ throw new ArgumentError(
95
+ 'A search query is required.',
96
+ 'Pass a non-empty keyword to search Weixin articles via Sogou.',
97
+ );
98
+ }
99
+ const normalizedPage = normalizePositiveInteger(pageNo, 'page', 1);
100
+ const searchUrl = buildSogouSearchUrl(normalizedQuery, normalizedPage);
101
+ let payload;
102
+ try {
103
+ await page.goto(searchUrl);
104
+ await page.wait(2);
105
+ payload = await page.evaluate(buildExtractSogouSearchResultsEvaluate());
106
+ } catch (error) {
107
+ const detail = error instanceof Error ? error.message : String(error);
108
+ throw new CommandExecutionError('weixin sougousearch failed while loading Sogou results', detail);
109
+ }
110
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) {
111
+ throw new CommandExecutionError(
112
+ 'weixin sougousearch returned an unreadable browser payload',
113
+ 'Sogou Weixin may have changed its result page structure.',
114
+ );
115
+ }
116
+ if (payload.blocked) {
117
+ throw new AuthRequiredError(
118
+ SOGOU_WEIXIN_DOMAIN,
119
+ 'Sogou Weixin requires verification. Complete it in the open browser tab and run the command again.',
120
+ );
121
+ }
122
+ if (payload.invalidCount > 0) {
123
+ throw new CommandExecutionError(
124
+ 'Sogou Weixin returned article cards without required title or URL',
125
+ 'The result page structure may have changed; refusing to return a partial result set.',
126
+ );
127
+ }
128
+ if (payload.rows.length === 0 && payload.empty) {
129
+ return { state: 'empty', page: normalizedPage, fingerprint: '', rows: [] };
130
+ }
131
+ if (payload.rows.length === 0) {
132
+ throw new CommandExecutionError(
133
+ 'weixin sougousearch did not expose article result cards',
134
+ 'Sogou Weixin may have changed its selectors or returned a transient shell page.',
135
+ );
136
+ }
137
+ return {
138
+ state: 'results',
139
+ page: normalizedPage,
140
+ fingerprint: fingerprintRows(payload.rows),
141
+ rows: payload.rows,
142
+ };
143
+ }
@@ -1,9 +1,15 @@
1
1
  import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
2
  import { cli, Strategy } from '@sovovs/bycli/registry';
3
3
  import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
4
+ import {
5
+ combineArticleFallbackErrors,
6
+ isEligibleArticleFallbackError,
7
+ withMissingFallbackName,
8
+ } from './_wechat/article-fallback-policy.js';
4
9
  import { createArticleIndexFetcher } from './_wechat/article-index.js';
5
10
  import { callCrawler, collectArticles } from './_wechat/crawler-runtime.js';
6
11
  import { readAuthSource } from './_wechat/args.js';
12
+ import { collectSogouAccountArticles } from './_wechat/sogou-fallback.js';
7
13
 
8
14
  const DOMAIN = 'mp.weixin.qq.com';
9
15
  const browserRequired = args => readAuthSource(args) === 'browser';
@@ -14,10 +20,10 @@ export const articlesCommand = cli({
14
20
  strategy: Strategy.COOKIE, browser: browserRequired,
15
21
  args: [
16
22
  { 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' },
23
+ { name: 'name', help: 'Official-account name; exact case-insensitive match required for browser Sogou fallback' }, { 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
24
  { name: 'auth-source', default: 'browser', choices: ['browser', 'env'], help: 'Credential source: browser session or environment variables' },
19
25
  ],
20
- columns: ['title', 'author', 'digest', 'publishedAt', 'url'],
26
+ columns: ['title', 'author', 'digest', 'publishedAt', 'url', 'source', 'coverage'],
21
27
  func: async (page, args) => {
22
28
  const fakeid = String(args.fakeid ?? '').trim();
23
29
  if (!fakeid) throw new ArgumentError('fakeid is required');
@@ -25,13 +31,37 @@ export const articlesCommand = cli({
25
31
  const credentials = authSource === 'env'
26
32
  ? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
27
33
  const fetchPage = createArticleIndexFetcher({ page, source: authSource, credentials });
28
- const { articles } = await callCrawler(() => collectArticles({
29
- fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'],
30
- }));
31
- if (articles.length === 0) throw new EmptyResultError('weixin articles', `No published articles were found for ${fakeid}.`);
34
+ let articles;
35
+ let source = 'wechat';
36
+ let coverage = null;
37
+ try {
38
+ const result = await callCrawler(() => collectArticles({
39
+ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'],
40
+ }));
41
+ articles = result.articles;
42
+ if (articles.length === 0) {
43
+ throw new EmptyResultError('weixin articles', `No published articles were found for ${fakeid}.`);
44
+ }
45
+ } catch (primaryError) {
46
+ if (authSource !== 'browser' || !isEligibleArticleFallbackError(primaryError)) throw primaryError;
47
+ const accountName = String(args.name ?? '').trim();
48
+ if (!accountName) throw withMissingFallbackName('weixin articles', primaryError);
49
+ try {
50
+ const fallback = await collectSogouAccountArticles({
51
+ page, accountName, limit: args.limit, maxPages: args['max-pages'],
52
+ });
53
+ articles = fallback.articles;
54
+ source = fallback.source;
55
+ coverage = fallback.coverage;
56
+ } catch (fallbackError) {
57
+ throw combineArticleFallbackErrors({
58
+ operation: 'weixin articles', primaryError, fallbackError, credentials,
59
+ });
60
+ }
61
+ }
32
62
  return articles.map(article => ({
33
63
  title: article.title, author: article.author || null, digest: article.digest || null,
34
- publishedAt: article.publishedAt || null, url: article.url,
64
+ publishedAt: article.publishedAt || null, url: article.url, source, coverage,
35
65
  }));
36
66
  },
37
67
  });