@sovovs/bycli 2.1.23 → 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",
@@ -28084,7 +28086,9 @@
28084
28086
  "publish_time",
28085
28087
  "status",
28086
28088
  "size",
28087
- "saved"
28089
+ "saved",
28090
+ "source_url",
28091
+ "resolved_url"
28088
28092
  ],
28089
28093
  "type": "js",
28090
28094
  "modulePath": "weixin/download.js",
@@ -28094,7 +28098,7 @@
28094
28098
  {
28095
28099
  "site": "weixin",
28096
28100
  "name": "download-publish-data",
28097
- "description": "Match a Weixin published article and save its content analysis as Markdown",
28101
+ "description": "Match a Weixin published article and save its Excel data and Markdown analysis",
28098
28102
  "access": "write",
28099
28103
  "domain": "mp.weixin.qq.com",
28100
28104
  "strategy": "intercept",
@@ -28118,7 +28122,7 @@
28118
28122
  "type": "str",
28119
28123
  "default": "./weixin-publish-data",
28120
28124
  "required": false,
28121
- "help": "Directory for generated Markdown reports"
28125
+ "help": "Directory for generated Excel data and Markdown reports"
28122
28126
  },
28123
28127
  {
28124
28128
  "name": "max-pages",
@@ -28141,8 +28145,9 @@
28141
28145
  "url",
28142
28146
  "status",
28143
28147
  "markdownPath",
28148
+ "markdownSize",
28144
28149
  "dataPath",
28145
- "size",
28150
+ "dataSize",
28146
28151
  "error"
28147
28152
  ],
28148
28153
  "type": "js",
@@ -28261,7 +28266,7 @@
28261
28266
  "name": "name",
28262
28267
  "type": "str",
28263
28268
  "required": false,
28264
- "help": "Official-account name used in Markdown metadata"
28269
+ "help": "Official-account name; exact case-insensitive match required for browser Sogou fallback"
28265
28270
  },
28266
28271
  {
28267
28272
  "name": "output",
@@ -28300,7 +28305,9 @@
28300
28305
  "stage",
28301
28306
  "path",
28302
28307
  "error",
28303
- "url"
28308
+ "url",
28309
+ "source",
28310
+ "coverage"
28304
28311
  ],
28305
28312
  "type": "js",
28306
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
+ }
@@ -0,0 +1,177 @@
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 commandError(message, hint) {
8
+ return new CommandExecutionError(`WeChat appmsgpublish ${message}`, hint);
9
+ }
10
+
11
+ function parseNestedJson(value, field) {
12
+ if (typeof value !== 'string') return value;
13
+ try {
14
+ return JSON.parse(value);
15
+ } catch {
16
+ throw commandError(`returned invalid ${field} JSON`);
17
+ }
18
+ }
19
+
20
+ function requireRecord(value, field) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
22
+ throw commandError(`returned invalid ${field}`);
23
+ }
24
+ return value;
25
+ }
26
+
27
+ export function mapArticleIndexPayload(payload) {
28
+ const response = requireRecord(payload, 'response');
29
+ const baseResponse = requireRecord(response.base_resp, 'base_resp');
30
+ const ret = baseResponse.ret;
31
+ const normalizedMessage = typeof baseResponse.err_msg === 'string'
32
+ ? baseResponse.err_msg.trim().toLowerCase().replace(/\s+/g, ' ') : '';
33
+ if (ret === 200013 && normalizedMessage === 'invalid credential') {
34
+ throw new AuthRequiredError(DOMAIN, 'WeChat article-index credentials have expired');
35
+ }
36
+ if (ret === 200013 && normalizedMessage === 'freq control') {
37
+ throw commandError(
38
+ 'was rate limited (ret=200013)',
39
+ 'Wait before retrying the WeChat article-index request; repeated retries may extend frequency control.',
40
+ );
41
+ }
42
+ if (!Number.isInteger(ret) || ret !== 0) {
43
+ throw commandError(`failed (ret=${String(ret ?? 'unknown')})`);
44
+ }
45
+ if (response.publish_page === undefined || response.publish_page === null
46
+ || response.publish_page === '') {
47
+ return { total: 0, publishItemCount: 0, articles: [] };
48
+ }
49
+
50
+ const page = requireRecord(parseNestedJson(response.publish_page, 'publish_page'), 'publish_page');
51
+ if (!Number.isInteger(page.total_count) || page.total_count < 0) {
52
+ throw commandError('returned invalid total_count');
53
+ }
54
+ if (!Array.isArray(page.publish_list)) {
55
+ throw commandError('returned invalid publish_list');
56
+ }
57
+
58
+ const articles = [];
59
+ for (const [publishIndex, rawItem] of page.publish_list.entries()) {
60
+ const item = requireRecord(rawItem, `publish_list[${publishIndex}]`);
61
+ if (!Object.prototype.hasOwnProperty.call(item, 'publish_info')) {
62
+ throw commandError(`returned missing publish_info at index ${publishIndex}`);
63
+ }
64
+ const info = requireRecord(
65
+ parseNestedJson(item.publish_info, `publish_info at index ${publishIndex}`),
66
+ `publish_info at index ${publishIndex}`,
67
+ );
68
+ if (!Array.isArray(info.appmsg_info)) {
69
+ throw commandError(`returned invalid appmsg_info at index ${publishIndex}`);
70
+ }
71
+ if (info.sent_info !== undefined) requireRecord(info.sent_info, `sent_info at index ${publishIndex}`);
72
+ if (info.publish_info !== undefined) requireRecord(info.publish_info, `publish metadata at index ${publishIndex}`);
73
+ const timestamp = info.sent_info?.time ?? info.publish_info?.create_time ?? 0;
74
+ if (!Number.isInteger(timestamp) || timestamp < 0) {
75
+ throw commandError(`returned invalid timestamp at index ${publishIndex}`);
76
+ }
77
+
78
+ for (const [articleIndex, rawArticle] of info.appmsg_info.entries()) {
79
+ const article = requireRecord(rawArticle, `appmsg_info[${articleIndex}]`);
80
+ for (const field of ['title', 'content_url', 'digest', 'author']) {
81
+ if (article[field] !== undefined && typeof article[field] !== 'string') {
82
+ throw commandError(`returned invalid ${field} at article index ${articleIndex}`);
83
+ }
84
+ }
85
+ articles.push({
86
+ title: article.title || '',
87
+ url: article.content_url || '',
88
+ isDeleted: article.is_deleted === true,
89
+ timestamp,
90
+ publishedAt: timestamp > 0 ? new Date(timestamp * 1000).toISOString() : null,
91
+ digest: article.digest || '',
92
+ author: article.author || '',
93
+ });
94
+ }
95
+ }
96
+
97
+ return { total: page.total_count, publishItemCount: page.publish_list.length, articles };
98
+ }
99
+
100
+ function buildReferer(token) {
101
+ const params = new URLSearchParams({
102
+ t: 'media/appmsg_edit_v2', action: 'edit', isNew: '1', type: '10',
103
+ token, lang: 'zh_CN',
104
+ });
105
+ return `https://${DOMAIN}/cgi-bin/appmsg?${params}`;
106
+ }
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
+
116
+ function transportError(error, credentials) {
117
+ const secrets = buildSecretSet(credentials);
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ const hint = error && typeof error === 'object' && 'hint' in error
120
+ && typeof error.hint === 'string' ? error.hint : undefined;
121
+ const redactedMessage = redactText(message, secrets);
122
+ const redactedHint = hint ? redactText(hint, secrets) : undefined;
123
+ if (error instanceof AuthRequiredError && error.domain === DOMAIN
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
+ }
131
+ return new CommandExecutionError(
132
+ `WeChat appmsgpublish request failed: ${redactedMessage}`,
133
+ redactedHint,
134
+ );
135
+ }
136
+
137
+ export function createArticleIndexFetcher({
138
+ page,
139
+ source,
140
+ credentials,
141
+ fetchImpl = fetch,
142
+ timeoutMs = 30_000,
143
+ }) {
144
+ return async function fetchPage({ fakeid, begin = 0, count = 10 }) {
145
+ const params = new URLSearchParams({
146
+ sub: 'list', begin: String(begin), count: String(count), fakeid,
147
+ token: credentials.token, lang: 'zh_CN', f: 'json', ajax: '1',
148
+ });
149
+ const url = `${ENDPOINT}?${params}`;
150
+ const headers = {
151
+ Referer: buildReferer(credentials.token),
152
+ 'X-Requested-With': 'XMLHttpRequest',
153
+ };
154
+
155
+ try {
156
+ let payload;
157
+ if (source === 'browser') {
158
+ if (!page || typeof page.fetchJson !== 'function') {
159
+ throw new CommandExecutionError('Browser page.fetchJson is unavailable');
160
+ }
161
+ payload = await page.fetchJson(url, { headers });
162
+ } else {
163
+ const response = await fetchImpl(url, {
164
+ headers: { ...headers, Cookie: credentials.cookie },
165
+ signal: AbortSignal.timeout(timeoutMs),
166
+ });
167
+ if (!response.ok) {
168
+ throw new CommandExecutionError(`HTTP ${String(response.status ?? 'unknown')}`);
169
+ }
170
+ payload = await response.json();
171
+ }
172
+ return mapArticleIndexPayload(payload);
173
+ } catch (error) {
174
+ throw transportError(error, credentials);
175
+ }
176
+ };
177
+ }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, BrowserConnectError } from '@sovovs/bycli/errors';
1
+ import { AuthRequiredError, BrowserConnectError, CommandExecutionError } from '@sovovs/bycli/errors';
2
2
 
3
3
  const DOMAIN = 'mp.weixin.qq.com';
4
4
  const LOGIN_URL = `https://${DOMAIN}/`;
@@ -46,6 +46,20 @@ export function isLoggedInPreflight(state) {
46
46
  }
47
47
  }
48
48
 
49
+ /** @param {PreflightState} state @returns {boolean} */
50
+ export function isLoggedInMiniProgramPreflight(state) {
51
+ if (state.url === null || state.hasLoginUi) return false;
52
+
53
+ try {
54
+ const url = new URL(state.url);
55
+ return url.origin === `https://${DOMAIN}`
56
+ && url.pathname.startsWith('/wxamp/')
57
+ && Boolean(url.searchParams.get('token')?.trim());
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
49
63
  /** @param {AuthPage} page @returns {Promise<PreflightState>} */
50
64
  async function readPreflight(page) {
51
65
  const result = await page.evaluate(() => {
@@ -100,6 +114,12 @@ export async function resolveBrowserCredentials(page, options = {}) {
100
114
  state = await readPreflight(page);
101
115
 
102
116
  if (!isLoggedInPreflight(state)) {
117
+ if (isLoggedInMiniProgramPreflight(state)) {
118
+ throw new CommandExecutionError(
119
+ 'The connected WeChat session is authenticated as a Mini Program account',
120
+ 'Switch to a WeChat Official Account in the same browser profile before running bycli weixin commands.',
121
+ );
122
+ }
103
123
  if (!page.focusWindow) {
104
124
  throw new BrowserConnectError(
105
125
  'The connected browser cannot be focused for WeChat login',
@@ -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(