@sovovs/bycli 2.1.0 → 2.1.2

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 (40) 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/daemon-config.d.ts +2 -0
  21. package/dist/src/daemon-config.js +11 -0
  22. package/dist/src/daemon-config.test.d.ts +1 -0
  23. package/dist/src/daemon.d.ts +1 -1
  24. package/dist/src/daemon.js +5 -3
  25. package/dist/src/download/article-download.d.ts +6 -0
  26. package/dist/src/download/article-download.js +78 -17
  27. package/dist/src/download/wechat-article.d.ts +8 -0
  28. package/dist/src/download/wechat-article.js +137 -0
  29. package/dist/src/download/wechat-article.test.d.ts +1 -0
  30. package/dist/src/recorder/highlevel/verify.d.ts +3 -0
  31. package/dist/src/recorder/highlevel/verify.js +4 -0
  32. package/dist/src/recorder/highlevel/verify.test.d.ts +1 -0
  33. package/dist/src/recorder/http/handlers.js +27 -1
  34. package/dist/src/recorder/runner/runner-port.js +1 -0
  35. package/dist/src/recorder/runner/verify-runner-main.d.ts +17 -2
  36. package/dist/src/recorder/runner/verify-runner-main.js +70 -14
  37. package/dist/src/release-workflow.test.d.ts +1 -0
  38. package/dist/src/weixin-built-in-docs.test.d.ts +1 -0
  39. package/package.json +7 -3
  40. package/scripts/check-package-install.mjs +71 -0
@@ -0,0 +1,405 @@
1
+ /** @typedef {{token: string, cookie: string, fingerprint?: string}} WechatCredentials */
2
+ /** @typedef {{value: string, cookieNames: ReadonlySet<string>}} SecretCandidate */
3
+
4
+ const REDACTION = '[REDACTED]';
5
+ const CIRCULAR = '[CIRCULAR]';
6
+ const FUNCTION = '[FUNCTION]';
7
+ const BIGINT = '[BIGINT]';
8
+ const SYMBOL = '[SYMBOL]';
9
+ const UNDEFINED = '[UNDEFINED]';
10
+ const NON_FINITE_NUMBER = '[NON_FINITE_NUMBER]';
11
+ const LONG_SECRET_LENGTH = 8;
12
+ function isSafeSecret(value) {
13
+ return typeof value === 'string' && value.length > 0;
14
+ }
15
+ function cookieValues(cookie) {
16
+ return cookie.split(';').flatMap(part => {
17
+ const separator = part.indexOf('=');
18
+ if (separator < 0)
19
+ return [];
20
+ const name = part.slice(0, separator).trim();
21
+ const value = part.slice(separator + 1).trim();
22
+ return isSafeSecret(value) ? [{ name, value }] : [];
23
+ });
24
+ }
25
+ function normalizeSecrets(secrets) {
26
+ return [...new Set(secrets.filter(isSafeSecret))].sort((left, right) => {
27
+ const byLength = right.length - left.length;
28
+ return byLength === 0 ? left.localeCompare(right) : byLength;
29
+ });
30
+ }
31
+ /** @param {WechatCredentials} credentials @returns {string[]} */
32
+ export function buildSecretSet(credentials) {
33
+ const rawSecrets = [
34
+ credentials.token,
35
+ credentials.cookie,
36
+ credentials.fingerprint,
37
+ ...cookieValues(credentials.cookie).map(({ value }) => value),
38
+ ].filter(isSafeSecret);
39
+ return normalizeSecrets(rawSecrets.flatMap(value => [value, encodeURIComponent(value)]));
40
+ }
41
+ function percentCanonical(value) {
42
+ return value.replace(/%[\da-f]{2}/gi, escape => escape.toUpperCase());
43
+ }
44
+ function matchesAt(value, index, candidate) {
45
+ if (index + candidate.length > value.length)
46
+ return false;
47
+ for (let offset = 0; offset < candidate.length; offset += 1) {
48
+ if (candidate[offset] === '%' &&
49
+ offset + 2 < candidate.length &&
50
+ /^[\da-f]{2}$/i.test(candidate.slice(offset + 1, offset + 3))) {
51
+ const inputEscape = value.slice(index + offset, index + offset + 3);
52
+ if (!/^%[\da-f]{2}$/i.test(inputEscape))
53
+ return false;
54
+ if (inputEscape.toUpperCase() !== candidate.slice(offset, offset + 3).toUpperCase()) {
55
+ return false;
56
+ }
57
+ offset += 2;
58
+ continue;
59
+ }
60
+ if (value[index + offset] !== candidate[offset])
61
+ return false;
62
+ }
63
+ return true;
64
+ }
65
+ function candidateList(secrets) {
66
+ const normalized = normalizeSecrets(secrets);
67
+ const cookieNamesByValue = new Map();
68
+ for (const secret of normalized) {
69
+ let representations = [secret];
70
+ try {
71
+ const decoded = decodeURIComponent(secret);
72
+ if (decoded !== secret)
73
+ representations = [secret, decoded];
74
+ }
75
+ catch {
76
+ // A malformed percent escape is still a valid literal secret.
77
+ }
78
+ for (const representation of representations) {
79
+ for (const { name, value } of cookieValues(representation)) {
80
+ if (!name)
81
+ continue;
82
+ for (const variant of [value, encodeURIComponent(value)]) {
83
+ const canonical = percentCanonical(variant);
84
+ const names = cookieNamesByValue.get(canonical) ?? new Set();
85
+ names.add(name.toLowerCase());
86
+ cookieNamesByValue.set(canonical, names);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ const byCanonicalValue = new Map();
92
+ for (const value of normalized) {
93
+ const canonical = percentCanonical(value);
94
+ const cookieNames = cookieNamesByValue.get(canonical) ?? new Set();
95
+ const existing = byCanonicalValue.get(canonical);
96
+ if (existing) {
97
+ byCanonicalValue.set(canonical, {
98
+ value: existing.value,
99
+ cookieNames: new Set([...existing.cookieNames, ...cookieNames]),
100
+ });
101
+ }
102
+ else {
103
+ byCanonicalValue.set(canonical, { value, cookieNames });
104
+ }
105
+ }
106
+ return [...byCanonicalValue.values()].sort((left, right) => {
107
+ const byLength = right.value.length - left.value.length;
108
+ return byLength === 0 ? left.value.localeCompare(right.value) : byLength;
109
+ });
110
+ }
111
+ function isGlobalCandidate(value) {
112
+ if (value.length >= LONG_SECRET_LENGTH)
113
+ return true;
114
+ if (value.length < 4)
115
+ return false;
116
+ const classes = [/[a-z]/.test(value), /[A-Z]/.test(value), /\d/.test(value), /[^\w]/.test(value)];
117
+ return classes.every(Boolean);
118
+ }
119
+ function isSensitiveKey(key) {
120
+ return /^(?:access[-_.]?token|api[-_.]?key|authorization|cookie|fingerprint|password|secret|token)$/i.test(key);
121
+ }
122
+ function hasTokenBoundaries(value, index, length) {
123
+ const before = value[index - 1];
124
+ const after = value[index + length];
125
+ const isBoundary = (character) => character === undefined || /[\s&;,|()[\]{}<>"']/u.test(character);
126
+ return isBoundary(before) && isBoundary(after);
127
+ }
128
+ function isCompleteAssignment(candidate) {
129
+ return /^[\w.-]+(?:=|%3d).+/i.test(candidate);
130
+ }
131
+ function isWhitespace(character) {
132
+ return character !== undefined && /\s/u.test(character);
133
+ }
134
+ function buildTextContext(input) {
135
+ let standaloneStart = 0;
136
+ while (standaloneStart < input.length && isWhitespace(input[standaloneStart])) {
137
+ standaloneStart += 1;
138
+ }
139
+ let standaloneEnd = input.length;
140
+ while (standaloneEnd > standaloneStart && isWhitespace(input[standaloneEnd - 1])) {
141
+ standaloneEnd -= 1;
142
+ }
143
+ const assignmentKeys = new Map();
144
+ for (let start = 0; start < input.length; start += 1) {
145
+ if (start > 0 && !/[?&;,\s]/u.test(input[start - 1]))
146
+ continue;
147
+ if (!/[\w.-]/u.test(input[start]))
148
+ continue;
149
+ let cursor = start;
150
+ while (cursor < input.length && /[\w.-]/u.test(input[cursor]))
151
+ cursor += 1;
152
+ const key = input.slice(start, cursor).toLowerCase();
153
+ while (cursor < input.length && isWhitespace(input[cursor]))
154
+ cursor += 1;
155
+ if (input[cursor] !== '=' && input[cursor] !== ':')
156
+ continue;
157
+ cursor += 1;
158
+ while (cursor < input.length && isWhitespace(input[cursor]))
159
+ cursor += 1;
160
+ assignmentKeys.set(cursor, key);
161
+ }
162
+ const cookieValueEnds = new Map();
163
+ let lineStart = 0;
164
+ while (lineStart < input.length) {
165
+ const newline = input.indexOf('\n', lineStart);
166
+ const lineEnd = newline < 0 ? input.length : newline;
167
+ const line = input.slice(lineStart, lineEnd);
168
+ const header = /^\s*(?:cookie|set-cookie)\s*:\s*/iu.exec(line);
169
+ if (header) {
170
+ let segmentStart = header[0].length;
171
+ while (segmentStart < line.length) {
172
+ const separator = line.indexOf(';', segmentStart);
173
+ const segmentEnd = separator < 0 ? line.length : separator;
174
+ const equals = line.indexOf('=', segmentStart);
175
+ if (equals >= 0 && equals < segmentEnd) {
176
+ let valueStart = equals + 1;
177
+ while (valueStart < segmentEnd && isWhitespace(line[valueStart]))
178
+ valueStart += 1;
179
+ let valueEnd = valueStart;
180
+ while (valueEnd < segmentEnd &&
181
+ !isWhitespace(line[valueEnd]) &&
182
+ line[valueEnd] !== ',') {
183
+ valueEnd += 1;
184
+ }
185
+ if (valueEnd > valueStart) {
186
+ cookieValueEnds.set(lineStart + valueStart, lineStart + valueEnd);
187
+ }
188
+ }
189
+ if (separator < 0)
190
+ break;
191
+ segmentStart = separator + 1;
192
+ }
193
+ }
194
+ if (newline < 0)
195
+ break;
196
+ lineStart = newline + 1;
197
+ }
198
+ return { standaloneStart, standaloneEnd, assignmentKeys, cookieValueEnds };
199
+ }
200
+ function mayRedactCandidate(input, index, candidate, context) {
201
+ if (isGlobalCandidate(candidate.value))
202
+ return true;
203
+ if (index === context.standaloneStart &&
204
+ index + candidate.value.length === context.standaloneEnd) {
205
+ return true;
206
+ }
207
+ if (context.cookieValueEnds.get(index) === index + candidate.value.length)
208
+ return true;
209
+ const assignmentKey = context.assignmentKeys.get(index);
210
+ if (assignmentKey) {
211
+ if (isSensitiveKey(assignmentKey))
212
+ return true;
213
+ if (candidate.cookieNames.has(assignmentKey))
214
+ return true;
215
+ }
216
+ if (isCompleteAssignment(candidate.value) && hasTokenBoundaries(input, index, candidate.value.length)) {
217
+ return true;
218
+ }
219
+ return false;
220
+ }
221
+ function stringifyWithoutHooks(value) {
222
+ if ((typeof value === 'object' && value !== null) || typeof value === 'function') {
223
+ return REDACTION;
224
+ }
225
+ return String(value);
226
+ }
227
+ /** @param {unknown} value @param {readonly string[]} secrets @returns {string} */
228
+ export function redactText(value, secrets) {
229
+ const candidates = candidateList(secrets);
230
+ return redactTextWithCandidates(value, candidates);
231
+ }
232
+ function redactTextWithCandidates(value, candidates) {
233
+ const input = stringifyWithoutHooks(value);
234
+ if (candidates.length === 0)
235
+ return input;
236
+ const context = buildTextContext(input);
237
+ const pieces = [];
238
+ let unchangedStart = 0;
239
+ let index = 0;
240
+ while (index < input.length) {
241
+ const candidate = candidates.find(item => matchesAt(input, index, item.value) &&
242
+ mayRedactCandidate(input, index, item, context));
243
+ if (!candidate) {
244
+ index += 1;
245
+ continue;
246
+ }
247
+ pieces.push(input.slice(unchangedStart, index), REDACTION);
248
+ index += candidate.value.length;
249
+ unchangedStart = index;
250
+ }
251
+ pieces.push(input.slice(unchangedStart));
252
+ return pieces.join('');
253
+ }
254
+ function containsCandidate(value, candidates) {
255
+ for (let index = 0; index < value.length; index += 1) {
256
+ if (candidates.some(candidate => matchesAt(value, index, candidate.value)))
257
+ return true;
258
+ }
259
+ return false;
260
+ }
261
+ function collisionSafeKey(target, key) {
262
+ if (!Object.prototype.hasOwnProperty.call(target, key))
263
+ return key;
264
+ if (typeof key === 'symbol')
265
+ return Symbol();
266
+ let suffix = 2;
267
+ let candidate = `${key}_${suffix}`;
268
+ while (Object.prototype.hasOwnProperty.call(target, candidate)) {
269
+ suffix += 1;
270
+ candidate = `${key}_${suffix}`;
271
+ }
272
+ return candidate;
273
+ }
274
+ function isCanonicalArrayIndex(target, key) {
275
+ if (!Array.isArray(target))
276
+ return false;
277
+ const index = Number(key);
278
+ return Number.isInteger(index)
279
+ && index >= 0
280
+ && index < target.length
281
+ && String(index) === key;
282
+ }
283
+ function shouldSanitizeStringKey(target, key, candidates) {
284
+ if (isCanonicalArrayIndex(target, key))
285
+ return false;
286
+ return redactTextWithCandidates(key, candidates) !== key;
287
+ }
288
+ function copyRedactedDescriptors(descriptors, target, candidates, seen, active, skippedKeys = new Set()) {
289
+ for (const key of Reflect.ownKeys(descriptors)) {
290
+ if (skippedKeys.has(key))
291
+ continue;
292
+ const descriptor = descriptors[key];
293
+ if (!descriptor)
294
+ continue;
295
+ let safeKey = key;
296
+ if (typeof key === 'string') {
297
+ if (shouldSanitizeStringKey(target, key, candidates))
298
+ safeKey = REDACTION;
299
+ }
300
+ else if (key.description !== undefined) {
301
+ if (containsCandidate(key.description, candidates))
302
+ safeKey = Symbol();
303
+ }
304
+ safeKey = collisionSafeKey(target, safeKey);
305
+ if ('value' in descriptor) {
306
+ Object.defineProperty(target, safeKey, {
307
+ configurable: true,
308
+ enumerable: descriptor.enumerable ?? false,
309
+ writable: true,
310
+ value: redactRecursive(descriptor.value, candidates, seen, active),
311
+ });
312
+ }
313
+ else {
314
+ Object.defineProperty(target, safeKey, {
315
+ configurable: true,
316
+ enumerable: descriptor.enumerable ?? false,
317
+ writable: true,
318
+ value: REDACTION,
319
+ });
320
+ }
321
+ }
322
+ }
323
+ function hasErrorPrototype(value) {
324
+ const visited = new Set();
325
+ let prototype = Object.getPrototypeOf(value);
326
+ while (prototype !== null && !visited.has(prototype)) {
327
+ if (prototype === Error.prototype)
328
+ return true;
329
+ visited.add(prototype);
330
+ prototype = Object.getPrototypeOf(prototype);
331
+ }
332
+ return false;
333
+ }
334
+ function inheritedErrorString(value, key) {
335
+ const visited = new Set();
336
+ let prototype = Object.getPrototypeOf(value);
337
+ while (prototype !== null && !visited.has(prototype)) {
338
+ visited.add(prototype);
339
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
340
+ if (descriptor && 'value' in descriptor && typeof descriptor.value === 'string') {
341
+ return descriptor.value;
342
+ }
343
+ prototype = Object.getPrototypeOf(prototype);
344
+ }
345
+ return '';
346
+ }
347
+ function defineErrorDefaults(source, descriptors, target, candidates) {
348
+ for (const key of ['name', 'message']) {
349
+ if (Object.prototype.hasOwnProperty.call(descriptors, key))
350
+ continue;
351
+ Object.defineProperty(target, key, {
352
+ configurable: true,
353
+ enumerable: false,
354
+ writable: true,
355
+ value: redactTextWithCandidates(inheritedErrorString(source, key), candidates),
356
+ });
357
+ }
358
+ }
359
+ function redactRecursive(value, candidates, seen, active) {
360
+ if (typeof value === 'string')
361
+ return redactTextWithCandidates(value, candidates);
362
+ if (typeof value === 'function')
363
+ return FUNCTION;
364
+ if (typeof value === 'bigint')
365
+ return BIGINT;
366
+ if (typeof value === 'symbol')
367
+ return SYMBOL;
368
+ if (typeof value === 'undefined')
369
+ return UNDEFINED;
370
+ if (typeof value === 'number' && !Number.isFinite(value))
371
+ return NON_FINITE_NUMBER;
372
+ if (!value || typeof value !== 'object')
373
+ return value;
374
+ if (active.has(value))
375
+ return CIRCULAR;
376
+ if (seen.has(value))
377
+ return seen.get(value);
378
+ try {
379
+ const array = Array.isArray(value);
380
+ const error = !array && hasErrorPrototype(value);
381
+ const descriptors = Object.getOwnPropertyDescriptors(value);
382
+ const lengthDescriptor = descriptors.length;
383
+ const arrayLength = array && lengthDescriptor && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number'
384
+ ? lengthDescriptor.value
385
+ : 0;
386
+ const output = array ? new Array(arrayLength) : Object.create(null);
387
+ seen.set(value, output);
388
+ active.add(value);
389
+ if (error)
390
+ defineErrorDefaults(value, descriptors, output, candidates);
391
+ copyRedactedDescriptors(descriptors, output, candidates, seen, active, array ? new Set(['length']) : undefined);
392
+ active.delete(value);
393
+ return output;
394
+ }
395
+ catch {
396
+ active.delete(value);
397
+ seen.set(value, REDACTION);
398
+ return REDACTION;
399
+ }
400
+ }
401
+ /** @param {unknown} value @param {readonly string[]} secrets @returns {unknown} */
402
+ export function redactValue(value, secrets) {
403
+ const candidates = candidateList(secrets);
404
+ return redactRecursive(value, candidates, new WeakMap(), new WeakSet());
405
+ }
@@ -0,0 +1,175 @@
1
+ import * as defaultFs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
4
+ import { cleanMarkdownFilename, wechatArticleToMarkdown } from './markdown.js';
5
+
6
+ export const MAX_FILENAME_ATTEMPTS = 100;
7
+
8
+ function commandError(action, error) {
9
+ return new CommandExecutionError(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`);
10
+ }
11
+
12
+ function assertInside(root, target) {
13
+ const relative = path.relative(root, target);
14
+ if (relative === '' || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
15
+ throw new CommandExecutionError('Refusing to save an article outside the output directory');
16
+ }
17
+ }
18
+
19
+ function sameIdentity(left, right) {
20
+ return left.dev === right.dev && left.ino === right.ino;
21
+ }
22
+
23
+ function assertResolvedPathComponents(root, fsImpl) {
24
+ const parsed = path.parse(root);
25
+ let current = parsed.root;
26
+ for (const part of root.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
27
+ current = path.join(current, part);
28
+ const stat = fsImpl.lstatSync(current);
29
+ if (stat.isSymbolicLink?.()) throw new CommandExecutionError('Refusing to save through a symbolic link');
30
+ }
31
+ }
32
+
33
+ function assertRootIdentity(root, rootFd, rootIdentity, fsImpl) {
34
+ assertResolvedPathComponents(root, fsImpl);
35
+ const pathStat = fsImpl.lstatSync(root);
36
+ const fdStat = fsImpl.fstatSync(rootFd);
37
+ if (!pathStat.isDirectory?.() || !fdStat.isDirectory?.()
38
+ || !sameIdentity(pathStat, rootIdentity) || !sameIdentity(fdStat, rootIdentity)) {
39
+ throw new CommandExecutionError('Output directory identity changed during save');
40
+ }
41
+ }
42
+
43
+ function cleanupOpenedTarget(target, openedStat, fsImpl) {
44
+ try {
45
+ const current = fsImpl.lstatSync(target);
46
+ if (sameIdentity(current, openedStat) && !current.isSymbolicLink?.()) fsImpl.unlinkSync(target);
47
+ } catch {
48
+ // Fail closed; cleanup is best effort after identity mismatch.
49
+ }
50
+ }
51
+
52
+ function writeExclusive(root, rootFd, rootIdentity, target, markdown, fsImpl) {
53
+ const noFollow = defaultFs.constants.O_NOFOLLOW;
54
+ if (typeof noFollow !== 'number') {
55
+ throw new CommandExecutionError('Secure article saving is unavailable: O_NOFOLLOW is unsupported');
56
+ }
57
+ // The opened root fd plus its dev/ino is the authorization capability.
58
+ // Path checks detect namespace replacement, but cannot and need not prevent
59
+ // a same-privilege process from renaming that already-authorized inode.
60
+ assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
61
+ let fd;
62
+ let openedStat;
63
+ try {
64
+ fd = fsImpl.openSync(target,
65
+ defaultFs.constants.O_CREAT | defaultFs.constants.O_EXCL | defaultFs.constants.O_WRONLY | noFollow,
66
+ 0o600);
67
+ // Once open succeeds, this fd remains bound to that inode; later renames
68
+ // or symlink swaps cannot redirect its writes into a replacement root.
69
+ openedStat = fsImpl.fstatSync(fd);
70
+ if (!openedStat.isFile?.() || openedStat.isSymbolicLink?.()) {
71
+ throw new CommandExecutionError('Refusing to write a non-regular article target');
72
+ }
73
+ assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
74
+ const body = Buffer.from(markdown, 'utf8');
75
+ let offset = 0;
76
+ while (offset < body.length) {
77
+ const written = fsImpl.writeSync(fd, body, offset, body.length - offset);
78
+ if (!Number.isInteger(written) || written <= 0) throw new CommandExecutionError('Failed to write article bytes');
79
+ offset += written;
80
+ }
81
+ fsImpl.fsyncSync?.(fd);
82
+ assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
83
+ } catch (error) {
84
+ if (openedStat) cleanupOpenedTarget(target, openedStat, fsImpl);
85
+ throw error;
86
+ } finally {
87
+ if (fd !== undefined) fsImpl.closeSync(fd);
88
+ }
89
+ }
90
+
91
+ export async function saveArticles({ articles, accountName, outputDir, fetchArticleHtml, buildMarkdown = wechatArticleToMarkdown, fsImpl = defaultFs }) {
92
+ if (!Array.isArray(articles) || articles.length > 1000) {
93
+ throw new ArgumentError('articles must be an array of at most 1000 items');
94
+ }
95
+ const requestedRoot = path.resolve(outputDir);
96
+ try { fsImpl.mkdirSync(requestedRoot, { recursive: true }); } catch (error) { throw commandError('create output directory', error); }
97
+ let root;
98
+ try { root = fsImpl.realpathSync(requestedRoot); } catch (error) { throw commandError('resolve output directory', error); }
99
+ let rootFd;
100
+ let rootIdentity;
101
+ try {
102
+ assertResolvedPathComponents(root, fsImpl);
103
+ rootIdentity = fsImpl.lstatSync(root);
104
+ if (!rootIdentity.isDirectory?.() || rootIdentity.isSymbolicLink?.()) throw new Error('not a directory');
105
+ rootFd = fsImpl.openSync(root, defaultFs.constants.O_RDONLY);
106
+ const openedRoot = fsImpl.fstatSync(rootFd);
107
+ if (!openedRoot.isDirectory?.() || !sameIdentity(openedRoot, rootIdentity)) {
108
+ throw new CommandExecutionError('Output directory identity changed during secure open');
109
+ }
110
+ assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
111
+ } catch (error) {
112
+ if (rootFd !== undefined) fsImpl.closeSync(rootFd);
113
+ if (error instanceof CommandExecutionError) throw error;
114
+ throw commandError('secure output directory', error);
115
+ }
116
+ const reserved = new Set();
117
+ const rows = [];
118
+
119
+ try {
120
+ for (const article of articles) {
121
+ let articleHtml;
122
+ try {
123
+ articleHtml = await fetchArticleHtml(article);
124
+ } catch {
125
+ rows.push({ title: article.title || '', url: article.url || '', status: 'failed', stage: 'download', saved: '', error: 'article download failed' });
126
+ continue;
127
+ }
128
+ let markdown;
129
+ try {
130
+ markdown = buildMarkdown({ html: articleHtml, title: article.title, accountName,
131
+ author: article.author, publishedAt: article.publishedAt, digest: article.digest, url: article.url });
132
+ } catch {
133
+ rows.push({ title: article.title || '', url: article.url || '', status: 'failed', stage: 'download', saved: '', error: 'invalid article content' });
134
+ continue;
135
+ }
136
+
137
+ let suffix = 1;
138
+ let target;
139
+ while (suffix <= MAX_FILENAME_ATTEMPTS) {
140
+ const suffixText = suffix === 1 ? '' : `-${suffix}`;
141
+ const name = `${cleanMarkdownFilename(article.title, 100, suffixText)}${suffixText}`;
142
+ target = path.resolve(root, `${name}.md`);
143
+ assertInside(root, target);
144
+ if (reserved.has(target)) { suffix += 1; continue; }
145
+ try {
146
+ const stat = fsImpl.lstatSync(target);
147
+ if (stat.isSymbolicLink?.()) throw new CommandExecutionError('Refusing to overwrite a symbolic link');
148
+ suffix += 1;
149
+ continue;
150
+ } catch (error) {
151
+ if (error instanceof CommandExecutionError) throw error;
152
+ if (error?.code !== 'ENOENT') throw commandError('inspect article target', error);
153
+ }
154
+ try {
155
+ writeExclusive(root, rootFd, rootIdentity, target, markdown, fsImpl);
156
+ reserved.add(target);
157
+ break;
158
+ } catch (error) {
159
+ if (error?.code === 'EEXIST') {
160
+ suffix += 1;
161
+ continue;
162
+ }
163
+ throw commandError('write article Markdown', error);
164
+ }
165
+ }
166
+ if (suffix > MAX_FILENAME_ATTEMPTS) {
167
+ throw new CommandExecutionError(`Failed to reserve an article filename after ${MAX_FILENAME_ATTEMPTS} attempts`);
168
+ }
169
+ rows.push({ title: article.title || '', url: article.url || '', status: 'saved', stage: null, saved: target, error: '' });
170
+ }
171
+ } finally {
172
+ fsImpl.closeSync(rootFd);
173
+ }
174
+ return rows;
175
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ AuthRequiredError,
3
+ CommandExecutionError,
4
+ } from '@sovovs/bycli/errors';
5
+ import { buildSecretSet, redactText } from './redact.js';
6
+
7
+ const DOMAIN = 'mp.weixin.qq.com';
8
+ const ENDPOINT = `https://${DOMAIN}/cgi-bin/searchbiz`;
9
+
10
+ /** @param {string} token */
11
+ function buildReferer(token) {
12
+ const params = new URLSearchParams({
13
+ t: 'media/appmsg_edit_v2', action: 'edit', isNew: '1', type: '10',
14
+ token, lang: 'zh_CN',
15
+ });
16
+ return `https://${DOMAIN}/cgi-bin/appmsg?${params}`;
17
+ }
18
+
19
+ /** @param {unknown} payload */
20
+ export function mapSearchBizPayload(payload) {
21
+ if (!payload || typeof payload !== 'object') {
22
+ throw new CommandExecutionError('WeChat search_biz returned an unreadable response');
23
+ }
24
+ const response = /** @type {Record<string, any>} */ (payload);
25
+ const ret = response.base_resp?.ret;
26
+ const message = String(response.base_resp?.err_msg ?? response.base_resp?.err_msg_en ?? '');
27
+ const normalizedMessage = message.trim().toLowerCase().replace(/\s+/g, ' ');
28
+ if (ret === 200013 && normalizedMessage === 'invalid credential') {
29
+ throw new AuthRequiredError(DOMAIN, 'WeChat search credentials have expired');
30
+ }
31
+ if (ret !== 0) {
32
+ throw new CommandExecutionError(`WeChat search_biz failed (ret=${String(ret ?? 'unknown')})`);
33
+ }
34
+ if (!Array.isArray(response.list)) {
35
+ throw new CommandExecutionError('WeChat search_biz returned an invalid account list');
36
+ }
37
+ return response.list.map((item, index) => {
38
+ if (!item || typeof item !== 'object'
39
+ || typeof item.nickname !== 'string' || !item.nickname.trim()
40
+ || typeof item.fakeid !== 'string' || !item.fakeid.trim()) {
41
+ throw new CommandExecutionError(`WeChat search_biz returned an invalid account at index ${index}`);
42
+ }
43
+ return {
44
+ nickname: item.nickname,
45
+ fakeid: item.fakeid,
46
+ alias: typeof item.alias === 'string' && item.alias.length > 0 ? item.alias : null,
47
+ };
48
+ });
49
+ }
50
+
51
+ /** @param {unknown} error @param {{token:string,cookie:string,fingerprint?:string}} credentials */
52
+ function transportError(error, credentials) {
53
+ const secrets = buildSecretSet(credentials);
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ const hint = error && typeof error === 'object' && 'hint' in error && typeof error.hint === 'string'
56
+ ? error.hint : undefined;
57
+ const redactedMessage = redactText(message, secrets);
58
+ const redactedHint = hint ? redactText(hint, secrets) : undefined;
59
+ if (error instanceof AuthRequiredError
60
+ && error.domain === DOMAIN
61
+ && redactedMessage === message
62
+ && redactedHint === hint) return error;
63
+ return new CommandExecutionError(
64
+ `WeChat search_biz request failed: ${redactedMessage}`,
65
+ redactedHint,
66
+ );
67
+ }
68
+
69
+ /**
70
+ * @param {{page:any,source:'browser'|'env',credentials:{token:string,cookie:string,fingerprint?:string},query:string,limit:number,fetchImpl?:typeof fetch,timeoutMs?:number}} input
71
+ */
72
+ export async function executeSearchBiz({ page, source, credentials, query, limit, fetchImpl = fetch, timeoutMs = 30_000 }) {
73
+ const params = new URLSearchParams({
74
+ action: 'search_biz', scene: '1', begin: '0', count: String(limit), query,
75
+ fingerprint: credentials.fingerprint ?? '', token: credentials.token,
76
+ lang: 'zh_CN', f: 'json', ajax: '1',
77
+ });
78
+ const url = `${ENDPOINT}?${params}`;
79
+ const headers = {
80
+ Referer: buildReferer(credentials.token),
81
+ 'X-Requested-With': 'XMLHttpRequest',
82
+ };
83
+
84
+ try {
85
+ let payload;
86
+ if (source === 'browser') {
87
+ payload = await page.fetchJson(url, { headers });
88
+ } else {
89
+ const response = await fetchImpl(url, {
90
+ headers: { ...headers, Cookie: credentials.cookie },
91
+ signal: AbortSignal.timeout(timeoutMs),
92
+ });
93
+ if (!response.ok) {
94
+ throw new CommandExecutionError(`WeChat search_biz request failed: HTTP ${response.status}`);
95
+ }
96
+ payload = await response.json();
97
+ }
98
+ return mapSearchBizPayload(payload);
99
+ } catch (error) {
100
+ throw transportError(error, credentials);
101
+ }
102
+ }