@sovovs/bycli 2.1.25 → 2.1.27

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,5 +1,5 @@
1
1
  import {
2
- AuthRequiredError, CommandExecutionError, EmptyResultError,
2
+ AuthRequiredError, CliError, CommandExecutionError, EmptyResultError, RateLimitedError,
3
3
  } from '@sovovs/bycli/errors';
4
4
  import { buildSecretSet, redactText } from './redact.js';
5
5
 
@@ -16,12 +16,17 @@ function safePhase(error, secrets) {
16
16
  }
17
17
 
18
18
  export function isEligibleArticleFallbackError(error) {
19
- return error instanceof CommandExecutionError || error instanceof EmptyResultError;
19
+ return error instanceof CommandExecutionError
20
+ || error instanceof EmptyResultError
21
+ || (error instanceof CliError && error.code === 'RATE_LIMITED');
20
22
  }
21
23
 
22
24
  export function withMissingFallbackName(operation, error) {
23
25
  const hint = `${error.hint ? `${error.hint} ` : ''}Sogou fallback requires the exact official-account name in --name.`;
24
26
  if (error instanceof EmptyResultError) return new EmptyResultError(operation, hint);
27
+ if (error instanceof CliError && error.code === 'RATE_LIMITED') {
28
+ return new RateLimitedError(error.message, hint);
29
+ }
25
30
  return new CommandExecutionError(error.message, hint);
26
31
  }
27
32
 
@@ -31,11 +36,17 @@ export function combineArticleFallbackErrors({
31
36
  fallbackError,
32
37
  credentials,
33
38
  }) {
34
- if (fallbackError instanceof AuthRequiredError) return fallbackError;
35
39
  const secrets = buildSecretSet(credentials);
36
40
  const primary = safePhase(primaryError, secrets);
37
41
  const fallback = safePhase(fallbackError, secrets);
38
42
  const hint = `Primary (${primary.code}): ${primary.summary}; fallback (${fallback.code}): ${fallback.summary}`;
43
+ if (primaryError instanceof CliError && primaryError.code === 'RATE_LIMITED') {
44
+ return new RateLimitedError(
45
+ 'Weixin article index was rate limited and Sogou fallback failed',
46
+ hint,
47
+ );
48
+ }
49
+ if (fallbackError instanceof AuthRequiredError) return fallbackError;
39
50
  if (primaryError instanceof EmptyResultError && fallbackError instanceof EmptyResultError) {
40
51
  return new EmptyResultError(operation, hint);
41
52
  }
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
1
+ import { AuthRequiredError, CommandExecutionError, RateLimitedError } from '@sovovs/bycli/errors';
2
2
  import { buildSecretSet, redactText } from './redact.js';
3
3
 
4
4
  const DOMAIN = 'mp.weixin.qq.com';
@@ -34,8 +34,8 @@ export function mapArticleIndexPayload(payload) {
34
34
  throw new AuthRequiredError(DOMAIN, 'WeChat article-index credentials have expired');
35
35
  }
36
36
  if (ret === 200013 && normalizedMessage === 'freq control') {
37
- throw commandError(
38
- 'was rate limited (ret=200013)',
37
+ throw new RateLimitedError(
38
+ 'WeChat appmsgpublish was rate limited (ret=200013)',
39
39
  'Wait before retrying the WeChat article-index request; repeated retries may extend frequency control.',
40
40
  );
41
41
  }
@@ -122,6 +122,10 @@ function transportError(error, credentials) {
122
122
  const redactedHint = hint ? redactText(hint, secrets) : undefined;
123
123
  if (error instanceof AuthRequiredError && error.domain === DOMAIN
124
124
  && redactedMessage === message && redactedHint === hint) return error;
125
+ if (error instanceof RateLimitedError) {
126
+ if (redactedMessage === message && redactedHint === hint) return error;
127
+ return new RateLimitedError(redactedMessage, redactedHint);
128
+ }
125
129
  if (isWechatVerificationResponse(redactedMessage, redactedHint)) {
126
130
  return new AuthRequiredError(
127
131
  DOMAIN,
@@ -4,6 +4,7 @@ import {
4
4
  import { resolveWechatArticleUrl } from './article-link.js';
5
5
  import { redactText } from './redact.js';
6
6
  import {
7
+ buildSogouSearchUrl,
7
8
  DEFAULT_SOGOU_MAX_PAGES,
8
9
  normalizePositiveInteger,
9
10
  searchSogouArticlePage,
@@ -92,6 +93,32 @@ function safeResolutionError(error) {
92
93
  return redactText(message, []) || 'Sogou article link resolution failed';
93
94
  }
94
95
 
96
+ async function replaceWithFreshSogouPage(page, accountName) {
97
+ if (typeof page?.closeWindow !== 'function'
98
+ || typeof page?.newTab !== 'function'
99
+ || typeof page?.setActivePage !== 'function') {
100
+ throw new CommandExecutionError(
101
+ 'weixin Sogou fallback cannot create an isolated browser page',
102
+ 'Update byCLI and Browser Bridge, then retry the command.',
103
+ );
104
+ }
105
+ const searchUrl = buildSogouSearchUrl(accountName, 1);
106
+ let createdPage;
107
+ try {
108
+ await page.closeWindow();
109
+ createdPage = await page.newTab(searchUrl);
110
+ if (!createdPage) throw new Error('Browser Bridge returned no page identity');
111
+ page.setActivePage(createdPage);
112
+ } catch (error) {
113
+ const detail = error instanceof Error ? error.message : String(error);
114
+ throw new CommandExecutionError(
115
+ 'weixin Sogou fallback failed to create an isolated browser page',
116
+ detail,
117
+ );
118
+ }
119
+ return searchUrl;
120
+ }
121
+
95
122
  export async function collectSogouAccountArticles({
96
123
  page,
97
124
  accountName,
@@ -101,6 +128,7 @@ export async function collectSogouAccountArticles({
101
128
  resolveUrl = resolveWechatArticleUrl,
102
129
  resolutionPolicy = 'atomic',
103
130
  scanStartedAt = Date.now(),
131
+ freshPage = false,
104
132
  }) {
105
133
  const normalizedName = String(accountName ?? '').trim();
106
134
  if (!normalizedName) {
@@ -125,12 +153,21 @@ export async function collectSogouAccountArticles({
125
153
  let pagesScanned = 0;
126
154
  let coverage = 'max-pages-reached';
127
155
  let firstSeen = 0;
156
+ const preloadedFirstPageUrl = freshPage
157
+ ? await replaceWithFreshSogouPage(page, normalizedName)
158
+ : undefined;
128
159
 
129
160
  for (let pageNo = 1; pageNo <= pageLimit; pageNo += 1) {
130
- const result = await searchPage(page, { query: normalizedName, pageNo });
161
+ const result = await searchPage(page, {
162
+ query: normalizedName,
163
+ pageNo,
164
+ preloadedUrl: pageNo === 1 ? preloadedFirstPageUrl : undefined,
165
+ });
131
166
  pagesScanned += 1;
132
167
  if (result.state === 'empty') {
133
- coverage = 'search-exhausted';
168
+ coverage = result.reason === 'result-cap'
169
+ ? 'result-cap-reached'
170
+ : 'search-exhausted';
134
171
  break;
135
172
  }
136
173
  if (seenFingerprints.has(result.fingerprint)) {
@@ -160,9 +197,14 @@ export async function collectSogouAccountArticles({
160
197
  }
161
198
 
162
199
  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.`;
200
+ let coverageHint;
201
+ if (coverage === 'max-pages-reached') {
202
+ coverageHint = `Scanned ${pagesScanned} pages and reached the page cap; later pages may still contain a match.`;
203
+ } else if (coverage === 'result-cap-reached') {
204
+ coverageHint = 'Sogou stopped anonymous browsing at its 100-result visibility cap; hidden results may still contain a match.';
205
+ } else {
206
+ coverageHint = `Sogou search exhausted after ${pagesScanned} pages.`;
207
+ }
166
208
  throw new EmptyResultError(
167
209
  'weixin Sogou account fallback',
168
210
  `No Sogou articles matched the exact official-account name "${normalizedName}"; similarly named accounts were excluded. ${coverageHint}`,
@@ -4,6 +4,8 @@ import {
4
4
 
5
5
  const SOGOU_WEIXIN_DOMAIN = 'weixin.sogou.com';
6
6
  export const DEFAULT_SOGOU_MAX_PAGES = 50;
7
+ const MAX_SOGOU_SHELL_ATTEMPTS = 2;
8
+ const SOGOU_SHELL_RETRY_DELAY_SECONDS = 2;
7
9
 
8
10
  export function normalizePositiveInteger(value, name, defaultValue, maxValue) {
9
11
  if (value === undefined || value === null) return defaultValue;
@@ -52,9 +54,11 @@ export function buildExtractSogouSearchResultsEvaluate() {
52
54
  }
53
55
  };
54
56
 
55
- const bodyText = clean(document.body && document.body.innerText);
57
+ const bodyText = clean(document.body && (document.body.innerText || document.body.textContent));
56
58
  const blocked = /验证码|安全验证|异常访问|访问过于频繁|请输入验证码/.test(bodyText);
57
- const empty = /没有找到相关的微信文章|未找到相关|暂无相关|没有找到/.test(bodyText)
59
+ const resultCap = /当前只显示\s*100\s*条结果/.test(bodyText);
60
+ const empty = resultCap
61
+ || /没有找到相关的微信文章|未找到相关|暂无相关|没有找到/.test(bodyText)
58
62
  || Boolean(document.querySelector('.no-result, .no_result, .s-noresult'));
59
63
  const cards = Array.from(document.querySelectorAll('.news-list li'));
60
64
  const extracted = cards.map((item) => {
@@ -78,6 +82,7 @@ export function buildExtractSogouSearchResultsEvaluate() {
78
82
  return {
79
83
  blocked,
80
84
  empty,
85
+ resultCap,
81
86
  invalidCount: extracted.length - rows.length,
82
87
  rows,
83
88
  };
@@ -88,56 +93,86 @@ function fingerprintRows(rows) {
88
93
  return rows.map(row => `${row.title}\u0000${row.url}`).join('\u0001');
89
94
  }
90
95
 
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;
96
+ function buildSogouRetryUrl(searchUrl, retryNo) {
97
+ const retryUrl = new URL(searchUrl);
98
+ retryUrl.searchParams.set('_bycli_retry', String(retryNo));
99
+ return retryUrl.toString();
100
+ }
101
+
102
+ async function loadSogouPayload(page, searchUrl, delayBeforeSeconds = 0, navigate = true) {
102
103
  try {
103
- await page.goto(searchUrl);
104
+ if (delayBeforeSeconds > 0) await page.wait(delayBeforeSeconds);
105
+ if (navigate) await page.goto(searchUrl);
104
106
  await page.wait(2);
105
- payload = await page.evaluate(buildExtractSogouSearchResultsEvaluate());
107
+ return await page.evaluate(buildExtractSogouSearchResultsEvaluate());
106
108
  } catch (error) {
107
109
  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
110
  throw new CommandExecutionError(
112
- 'weixin sougousearch returned an unreadable browser payload',
113
- 'Sogou Weixin may have changed its result page structure.',
111
+ 'weixin sougousearch failed while loading Sogou results',
112
+ detail,
114
113
  );
115
114
  }
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.',
115
+ }
116
+
117
+ export async function searchSogouArticlePage(page, { query, pageNo, preloadedUrl }) {
118
+ const normalizedQuery = String(query ?? '').trim();
119
+ if (!normalizedQuery) {
120
+ throw new ArgumentError(
121
+ 'A search query is required.',
122
+ 'Pass a non-empty keyword to search Weixin articles via Sogou.',
126
123
  );
127
124
  }
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.',
125
+ const normalizedPage = normalizePositiveInteger(pageNo, 'page', 1);
126
+ const searchUrl = buildSogouSearchUrl(normalizedQuery, normalizedPage);
127
+ for (let attempt = 1; attempt <= MAX_SOGOU_SHELL_ATTEMPTS; attempt += 1) {
128
+ const usePreloadedPage = attempt === 1 && preloadedUrl === searchUrl;
129
+ const navigationUrl = attempt === 1
130
+ ? searchUrl
131
+ : buildSogouRetryUrl(searchUrl, attempt - 1);
132
+ const payload = await loadSogouPayload(
133
+ page,
134
+ navigationUrl,
135
+ attempt > 1 ? SOGOU_SHELL_RETRY_DELAY_SECONDS : 0,
136
+ !usePreloadedPage,
135
137
  );
138
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) {
139
+ throw new CommandExecutionError(
140
+ 'weixin sougousearch returned an unreadable browser payload',
141
+ 'Sogou Weixin may have changed its result page structure.',
142
+ );
143
+ }
144
+ if (payload.blocked) {
145
+ throw new AuthRequiredError(
146
+ SOGOU_WEIXIN_DOMAIN,
147
+ 'Sogou Weixin requires verification. Complete it in the open browser tab and run the command again.',
148
+ );
149
+ }
150
+ if (payload.invalidCount > 0) {
151
+ throw new CommandExecutionError(
152
+ 'Sogou Weixin returned article cards without required title or URL',
153
+ 'The result page structure may have changed; refusing to return a partial result set.',
154
+ );
155
+ }
156
+ if (payload.rows.length === 0 && payload.empty) {
157
+ return {
158
+ state: 'empty',
159
+ reason: payload.resultCap ? 'result-cap' : 'no-results',
160
+ page: normalizedPage,
161
+ fingerprint: '',
162
+ rows: [],
163
+ };
164
+ }
165
+ if (payload.rows.length > 0) {
166
+ return {
167
+ state: 'results',
168
+ page: normalizedPage,
169
+ fingerprint: fingerprintRows(payload.rows),
170
+ rows: payload.rows,
171
+ };
172
+ }
136
173
  }
137
- return {
138
- state: 'results',
139
- page: normalizedPage,
140
- fingerprint: fingerprintRows(payload.rows),
141
- rows: payload.rows,
142
- };
174
+ throw new CommandExecutionError(
175
+ 'weixin sougousearch did not expose article result cards',
176
+ 'Sogou Weixin returned a transient shell page on both bounded attempts.',
177
+ );
143
178
  }
@@ -48,7 +48,7 @@ export const articlesCommand = cli({
48
48
  if (!accountName) throw withMissingFallbackName('weixin articles', primaryError);
49
49
  try {
50
50
  const fallback = await collectSogouAccountArticles({
51
- page, accountName, limit: args.limit, maxPages: args['max-pages'],
51
+ page, accountName, limit: args.limit, maxPages: args['max-pages'], freshPage: true,
52
52
  });
53
53
  articles = fallback.articles;
54
54
  source = fallback.source;
@@ -1,7 +1,7 @@
1
1
  import * as nodeFs from 'node:fs';
2
2
  import * as nodePath from 'node:path';
3
3
  import { cli, Strategy } from '@sovovs/bycli/registry';
4
- import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
4
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
5
5
 
6
6
  const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
7
7
  const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
@@ -66,13 +66,19 @@ async function navigateToEditor(page) {
66
66
  await page.wait(3);
67
67
  const token = await getToken(page);
68
68
  if (!token) {
69
- throw new CommandExecutionError('Could not extract session token. Please log in to mp.weixin.qq.com');
69
+ throw new AuthRequiredError(
70
+ WEIXIN_DOMAIN,
71
+ 'Could not extract session token. Please log in to mp.weixin.qq.com',
72
+ );
70
73
  }
71
74
  await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&token=${token}&lang=zh_CN`);
72
75
  await page.wait(4);
73
76
  const hasTitle = await page.evaluate('!!document.querySelector("textarea#title")');
74
77
  if (!hasTitle) {
75
- throw new CommandExecutionError('Article editor did not load. Session may have expired');
78
+ throw new AuthRequiredError(
79
+ WEIXIN_DOMAIN,
80
+ 'Article editor did not load. Session may have expired',
81
+ );
76
82
  }
77
83
  }
78
84
 
@@ -196,7 +196,7 @@ export const saveArticlesCommand = cli({
196
196
  try {
197
197
  const fallback = await collectSogouAccountArticles({
198
198
  page, accountName, limit: args.limit, maxPages: args['max-pages'],
199
- resolutionPolicy: 'rows',
199
+ resolutionPolicy: 'rows', freshPage: true,
200
200
  });
201
201
  articles = fallback.articles;
202
202
  resolutionFailures = fallback.resolutionFailures;
@@ -14,7 +14,7 @@
14
14
  * 2 Argument / usage error (ArgumentError)
15
15
  * 66 No input / empty result (EmptyResultError)
16
16
  * 69 Service unavailable (BrowserConnectError, adapter load failures)
17
- * 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL
17
+ * 75 Temporary failure, retry later (TimeoutError, RateLimitedError) EX_TEMPFAIL
18
18
  * 77 Permission denied / auth needed (AuthRequiredError)
19
19
  * 78 Configuration error (ConfigError)
20
20
  * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
@@ -51,6 +51,9 @@ export declare class BrowserConnectError extends CliError {
51
51
  export declare class CommandExecutionError extends CliError {
52
52
  constructor(message: string, hint?: string);
53
53
  }
54
+ export declare class RateLimitedError extends CliError {
55
+ constructor(message: string, hint?: string);
56
+ }
54
57
  export declare class ConfigError extends CliError {
55
58
  constructor(message: string, hint?: string);
56
59
  }
@@ -59,6 +59,11 @@ export class CommandExecutionError extends CliError {
59
59
  super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR);
60
60
  }
61
61
  }
62
+ export class RateLimitedError extends CliError {
63
+ constructor(message, hint) {
64
+ super('RATE_LIMITED', message, hint, EXIT_CODES.TEMPFAIL);
65
+ }
66
+ }
62
67
  export class ConfigError extends CliError {
63
68
  constructor(message, hint) {
64
69
  super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.25",
3
+ "version": "2.1.27",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },