@sovovs/bycli 2.1.0 → 2.1.1

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 (33) 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/download/article-download.d.ts +6 -0
  21. package/dist/src/download/article-download.js +78 -17
  22. package/dist/src/download/wechat-article.d.ts +8 -0
  23. package/dist/src/download/wechat-article.js +137 -0
  24. package/dist/src/download/wechat-article.test.d.ts +1 -0
  25. package/dist/src/recorder/highlevel/verify.d.ts +3 -0
  26. package/dist/src/recorder/highlevel/verify.js +4 -0
  27. package/dist/src/recorder/highlevel/verify.test.d.ts +1 -0
  28. package/dist/src/recorder/runner/runner-port.js +1 -0
  29. package/dist/src/recorder/runner/verify-runner-main.d.ts +17 -2
  30. package/dist/src/recorder/runner/verify-runner-main.js +70 -14
  31. package/dist/src/weixin-built-in-docs.test.d.ts +1 -0
  32. package/package.json +7 -3
  33. package/scripts/check-package-install.mjs +71 -0
@@ -0,0 +1,443 @@
1
+ import { CliError, CommandExecutionError, TimeoutError } from '@sovovs/bycli/errors';
2
+
3
+ const STATE_KEY = '__bycliWechatSearchBizCapture';
4
+ const POLL_INTERVAL_MS = 100;
5
+ const AUTO_OPEN_POLLS = 50;
6
+ const captureQueues = new WeakMap();
7
+
8
+ function isTrustedSearchBizUrl(url) {
9
+ return url.protocol === 'https:'
10
+ && url.hostname === 'mp.weixin.qq.com'
11
+ && url.port === ''
12
+ && url.username === ''
13
+ && url.password === ''
14
+ && url.pathname === '/cgi-bin/searchbiz';
15
+ }
16
+
17
+ function fingerprintFromNetworkEntries(entries) {
18
+ if (!Array.isArray(entries)) return null;
19
+ for (const entry of entries) {
20
+ if (!entry || typeof entry.url !== 'string') continue;
21
+ try {
22
+ const url = new URL(entry.url);
23
+ if (!isTrustedSearchBizUrl(url)) continue;
24
+ const fingerprint = url.searchParams.get('fingerprint');
25
+ if (fingerprint) return fingerprint;
26
+ } catch { /* Ignore malformed or unrelated capture entries. */ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** @param {any} page @param {string} query @param {number} [timeoutMs] */
32
+ export function captureSearchBizFingerprint(page, query, timeoutMs = 30_000) {
33
+ const previous = captureQueues.get(page) ?? Promise.resolve();
34
+ const run = previous.catch(() => undefined)
35
+ .then(() => captureSearchBizFingerprintOwned(page, query, timeoutMs));
36
+ const tail = run.then(() => undefined, () => undefined);
37
+ captureQueues.set(page, tail);
38
+ return run.finally(() => {
39
+ if (captureQueues.get(page) === tail) captureQueues.delete(page);
40
+ });
41
+ }
42
+
43
+ /** @param {any} page @param {string} query @param {number} timeoutMs */
44
+ async function captureSearchBizFingerprintOwned(page, query, timeoutMs) {
45
+ const startedAt = Date.now();
46
+ let browserNetworkCapture = false;
47
+ let lastSubmitDiagnostics = {
48
+ dialogVisible: false,
49
+ inputCount: 0,
50
+ buttonFound: false,
51
+ clickInvoked: false,
52
+ };
53
+ try {
54
+ if (typeof page.startNetworkCapture === 'function' && typeof page.readNetworkCapture === 'function') {
55
+ try {
56
+ browserNetworkCapture = await page.startNetworkCapture('/cgi-bin/searchbiz') !== false;
57
+ } catch { /* In-page request hooks remain the compatibility fallback. */ }
58
+ }
59
+ await page.evaluate(({ operation, stateKey }) => {
60
+ if (operation !== 'install') return { installed: false };
61
+ const root = /** @type {any} */ (window);
62
+ const originalFetch = root.fetch;
63
+ const originalOpen = root.XMLHttpRequest?.prototype?.open;
64
+ const state = { fingerprint: null };
65
+ Object.defineProperty(root, stateKey, { configurable: true, value: state });
66
+
67
+ const capture = input => {
68
+ try {
69
+ const url = new URL(typeof input === 'string' ? input : input?.url, window.location.href);
70
+ const trusted = url.protocol === 'https:'
71
+ && url.hostname === 'mp.weixin.qq.com'
72
+ && url.port === ''
73
+ && url.username === ''
74
+ && url.password === ''
75
+ && url.pathname === '/cgi-bin/searchbiz';
76
+ if (trusted) state.fingerprint = url.searchParams.get('fingerprint');
77
+ } catch { /* Ignore unrelated or relative malformed requests. */ }
78
+ };
79
+ if (typeof originalFetch === 'function') {
80
+ const wrappedFetch = function wrappedFetch(input, ...args) {
81
+ capture(input);
82
+ return originalFetch.call(this, input, ...args);
83
+ };
84
+ Object.defineProperty(wrappedFetch, '__bycliOriginalFetch', { value: originalFetch });
85
+ root.fetch = wrappedFetch;
86
+ }
87
+ if (typeof originalOpen === 'function') {
88
+ const wrappedOpen = function wrappedOpen(method, url, ...args) {
89
+ capture(url);
90
+ return originalOpen.call(this, method, url, ...args);
91
+ };
92
+ Object.defineProperty(wrappedOpen, '__bycliOriginalOpen', { value: originalOpen });
93
+ root.XMLHttpRequest.prototype.open = wrappedOpen;
94
+ }
95
+ return { installed: true };
96
+ }, { operation: 'install', stateKey: STATE_KEY });
97
+
98
+ let entryClicked = false;
99
+ let overflowClicked = false;
100
+ let submitted = false;
101
+ let focusedForManualOpen = false;
102
+ let automaticPolls = 0;
103
+ while (Date.now() - startedAt < timeoutMs) {
104
+ if (!submitted) {
105
+ const picker = await page.evaluate(({ operation, allowClick, allowOverflowClick }) => {
106
+ if (operation !== 'open-picker') {
107
+ return { dialogVisible: false, entryClicked: false, overflowClicked: false };
108
+ }
109
+ const root = /** @type {any} */ (window);
110
+ const INSERT_TOOL_LABELS = [
111
+ '图片', '视频', '音频', '超链接', '小程序', '模板', '投票', '搜索', '地理位置',
112
+ ];
113
+ const visible = element => {
114
+ const style = window.getComputedStyle(element);
115
+ const rect = element.getBoundingClientRect();
116
+ return style.display !== 'none' && style.visibility !== 'hidden'
117
+ && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
118
+ };
119
+ const exactText = element => (element.textContent ?? '').replace(/\s+/g, '').trim();
120
+ const clickable = element => element.closest?.('button, a, [role="button"], [role="menuitem"]') ?? element;
121
+ let dialogs = Array.from(document.querySelectorAll(
122
+ '.weui-desktop-dialog__wrp.profile_dialog',
123
+ )).filter(visible);
124
+ if (dialogs.length === 0) {
125
+ dialogs = Array.from(document.querySelectorAll(
126
+ '[role="dialog"], .weui-desktop-dialog, [class*="dialog"]',
127
+ )).filter(visible).filter(element => /插入账号名片/.test(element.textContent ?? ''));
128
+ }
129
+ if (dialogs.length === 0) {
130
+ const inferred = new Set();
131
+ const titles = Array.from(document.querySelectorAll(
132
+ 'h1, h2, h3, h4, [class*="title"], [class*="header"], span, div',
133
+ )).filter(visible).filter(element => exactText(element) === '插入账号名片');
134
+ for (const title of titles) {
135
+ let ancestor = title.parentElement;
136
+ for (let depth = 0; ancestor && depth < 8; depth += 1, ancestor = ancestor.parentElement) {
137
+ if (!visible(ancestor) || typeof ancestor.querySelectorAll !== 'function') continue;
138
+ const inputs = Array.from(ancestor.querySelectorAll(
139
+ 'input[type="text"], input[type="search"], input:not([type]), .weui-desktop-search__input',
140
+ )).filter(visible);
141
+ if (inputs.length > 0) {
142
+ inferred.add(ancestor);
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ dialogs = Array.from(inferred);
148
+ }
149
+ if (dialogs.length === 1) {
150
+ return { dialogVisible: true, entryClicked: false, overflowClicked: false };
151
+ }
152
+
153
+ if (allowClick) {
154
+ const menuTargets = new Set();
155
+ const menus = Array.from(document.querySelectorAll(
156
+ '[role="menu"], [class*="menu"], [class*="dropdown"], [class*="popover"]',
157
+ )).filter(visible);
158
+ for (const menu of menus) {
159
+ for (const element of Array.from(menu.querySelectorAll(
160
+ 'button, a, [role="button"], [role="menuitem"], li, [class*="item"]',
161
+ ))) {
162
+ if (visible(element) && exactText(element) === '账号名片') {
163
+ menuTargets.add(clickable(element));
164
+ }
165
+ }
166
+ }
167
+ if (menuTargets.size === 1) {
168
+ const [target] = menuTargets;
169
+ target.click();
170
+ return { dialogVisible: false, entryClicked: true, overflowClicked: false };
171
+ }
172
+ }
173
+
174
+ const selector = [
175
+ 'header button', 'header a', 'header [role="button"]', 'header [class*="tool"]',
176
+ '[role="banner"] button', '[role="banner"] a', '[role="banner"] [role="button"]',
177
+ '.edui-editor-toolbarbox button', '.edui-editor-toolbarbox a',
178
+ '.edui-editor-toolbarbox [role="button"]', '.edui-editor-toolbarbox [class*="tool"]',
179
+ '.weui-desktop-toolbar button', '.weui-desktop-toolbar a',
180
+ '.weui-desktop-toolbar [role="button"]', '.weui-desktop-toolbar [class*="tool"]',
181
+ ].join(', ');
182
+ const maxHeaderTop = Math.max(160, (Number(root.innerHeight) || 800) * 0.25);
183
+ const targets = new Set();
184
+ for (const element of Array.from(document.querySelectorAll(selector))) {
185
+ if (!visible(element)) continue;
186
+ if (exactText(element) !== '账号名片') continue;
187
+ const rect = element.getBoundingClientRect();
188
+ if (Number(rect.top ?? 0) > maxHeaderTop) continue;
189
+ targets.add(clickable(element));
190
+ }
191
+ const genericEntries = Array.from(document.querySelectorAll(
192
+ 'button, a, [role="button"], span, div, li',
193
+ )).filter(element => {
194
+ if (!visible(element) || exactText(element) !== '账号名片') return false;
195
+ const rect = element.getBoundingClientRect();
196
+ if (Number(rect.top ?? 0) > maxHeaderTop) return false;
197
+ let ancestor = element.parentElement;
198
+ for (let depth = 0; ancestor && depth < 8; depth += 1, ancestor = ancestor.parentElement) {
199
+ if (!visible(ancestor)) continue;
200
+ const ancestorRect = ancestor.getBoundingClientRect();
201
+ if (Number(ancestorRect.top ?? 0) > maxHeaderTop) continue;
202
+ const ancestorText = exactText(ancestor);
203
+ const score = INSERT_TOOL_LABELS.filter(label => ancestorText.includes(label)).length;
204
+ if (score >= 3) return true;
205
+ }
206
+ return false;
207
+ });
208
+ const innermostEntries = genericEntries.filter(element => !genericEntries.some(other =>
209
+ other !== element && element.contains?.(other)));
210
+ for (const element of innermostEntries) targets.add(clickable(element));
211
+ if (allowClick && targets.size === 1) {
212
+ const [target] = targets;
213
+ target.click();
214
+ return { dialogVisible: false, entryClicked: true, overflowClicked: false };
215
+ }
216
+
217
+ if (allowOverflowClick) {
218
+ const candidates = Array.from(new Set(document.querySelectorAll(
219
+ 'header, [role="banner"], nav, [class*="toolbar"]',
220
+ ))).filter(visible).map(element => {
221
+ const text = exactText(element);
222
+ const score = INSERT_TOOL_LABELS.filter(label => text.includes(label)).length;
223
+ return { element, score };
224
+ }).filter(candidate => candidate.score >= 3);
225
+ const bestScore = Math.max(0, ...candidates.map(candidate => candidate.score));
226
+ const best = candidates.filter(candidate => candidate.score === bestScore);
227
+ const innermost = best.filter(candidate => !best.some(other =>
228
+ other !== candidate && candidate.element.contains?.(other.element)));
229
+ if (innermost.length === 1) {
230
+ const overflowTargets = new Set();
231
+ for (const element of Array.from(innermost[0].element.querySelectorAll(
232
+ 'button, a, [role="button"], [class*="more"], [class*="ellipsis"]',
233
+ ))) {
234
+ if (!visible(element)) continue;
235
+ const text = exactText(element);
236
+ const attributes = ['aria-label', 'title']
237
+ .map(name => element.getAttribute?.(name) ?? '').join(' ');
238
+ const className = typeof element.className === 'string' ? element.className : '';
239
+ if (!['...', '…', '•••'].includes(text)
240
+ && !/更多|more|ellipsis/i.test(`${attributes} ${className}`)) continue;
241
+ overflowTargets.add(clickable(element));
242
+ }
243
+ if (overflowTargets.size === 1) {
244
+ const [target] = overflowTargets;
245
+ target.click();
246
+ return { dialogVisible: false, entryClicked: false, overflowClicked: true };
247
+ }
248
+ }
249
+ }
250
+ return { dialogVisible: false, entryClicked: false, overflowClicked: false };
251
+ }, {
252
+ operation: 'open-picker',
253
+ allowClick: !entryClicked,
254
+ allowOverflowClick: !overflowClicked,
255
+ });
256
+
257
+ entryClicked ||= picker?.entryClicked === true;
258
+ overflowClicked ||= picker?.overflowClicked === true;
259
+ lastSubmitDiagnostics.dialogVisible = picker?.dialogVisible === true;
260
+ if (picker?.dialogVisible) {
261
+ if (typeof page.fillText === 'function') {
262
+ try {
263
+ await page.fillText(
264
+ '.profile_dialog input.weui-desktop-form__input[placeholder="请输入账号名称或账号ID"]',
265
+ query,
266
+ );
267
+ } catch { /* DOM setter below remains the compatibility fallback. */ }
268
+ }
269
+ const result = await page.evaluate(({ operation, query: searchQuery, stateKey }) => {
270
+ if (operation !== 'submit-search') return { submitted: false, reason: 'operation' };
271
+ const visible = element => {
272
+ const style = window.getComputedStyle(element);
273
+ const rect = element.getBoundingClientRect();
274
+ return style.display !== 'none' && style.visibility !== 'hidden'
275
+ && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
276
+ };
277
+ const exactText = element => (element.textContent ?? '').replace(/\s+/g, '').trim();
278
+ let dialogs = Array.from(document.querySelectorAll(
279
+ '.weui-desktop-dialog__wrp.profile_dialog',
280
+ )).filter(visible);
281
+ if (dialogs.length === 0) {
282
+ dialogs = Array.from(document.querySelectorAll(
283
+ '[role="dialog"], .weui-desktop-dialog, [class*="dialog"]',
284
+ )).filter(visible).filter(element => /插入账号名片/.test(element.textContent ?? ''));
285
+ }
286
+ if (dialogs.length === 0) {
287
+ const inferred = new Set();
288
+ const titles = Array.from(document.querySelectorAll(
289
+ 'h1, h2, h3, h4, [class*="title"], [class*="header"], span, div',
290
+ )).filter(visible).filter(element => exactText(element) === '插入账号名片');
291
+ for (const title of titles) {
292
+ let ancestor = title.parentElement;
293
+ for (let depth = 0; ancestor && depth < 8; depth += 1, ancestor = ancestor.parentElement) {
294
+ if (!visible(ancestor) || typeof ancestor.querySelectorAll !== 'function') continue;
295
+ const candidates = Array.from(ancestor.querySelectorAll(
296
+ 'input[type="text"], input[type="search"], input:not([type]), .weui-desktop-search__input',
297
+ )).filter(visible);
298
+ if (candidates.length > 0) {
299
+ inferred.add(ancestor);
300
+ break;
301
+ }
302
+ }
303
+ }
304
+ dialogs = Array.from(inferred);
305
+ }
306
+ if (dialogs.length !== 1) {
307
+ return { submitted: false, reason: 'dialog', dialogVisible: false, inputCount: 0, buttonFound: false, clickInvoked: false };
308
+ }
309
+ const inputs = Array.from(dialogs[0].querySelectorAll(
310
+ 'input[type="text"], input[type="search"], input:not([type]), .weui-desktop-search__input',
311
+ )).filter(visible);
312
+ if (inputs.length !== 1) {
313
+ return { submitted: false, reason: 'input', dialogVisible: true, inputCount: inputs.length, buttonFound: false, clickInvoked: false };
314
+ }
315
+ const input = inputs[0];
316
+ input.focus();
317
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
318
+ if (setter) setter.call(input, searchQuery); else input.value = searchQuery;
319
+ input.dispatchEvent(new Event('input', { bubbles: true }));
320
+ input.dispatchEvent(new Event('change', { bubbles: true }));
321
+ const exactClassName = 'weui-desktop-search__btn weui-desktop-icon-button weui-desktop-icon-button_stated';
322
+ const exactSearchTarget = document.getElementsByClassName?.(exactClassName)?.[0]
323
+ ?? dialogs[0].querySelector?.('.weui-desktop-search__btn');
324
+ let buttonFound = false;
325
+ let clickInvoked = false;
326
+ if (exactSearchTarget && visible(exactSearchTarget)) {
327
+ buttonFound = true;
328
+ exactSearchTarget.click();
329
+ clickInvoked = true;
330
+ const fingerprint = /** @type {any} */ (window)[stateKey]?.fingerprint;
331
+ if (typeof fingerprint === 'string' && fingerprint.length > 0) {
332
+ return { submitted: true, dialogVisible: true, inputCount: 1, buttonFound, clickInvoked };
333
+ }
334
+ } else {
335
+ const searchTargets = new Set();
336
+ for (const element of Array.from(dialogs[0].querySelectorAll([
337
+ 'button[type="submit"]',
338
+ 'button[aria-label*="搜索"]',
339
+ '[role="button"][aria-label*="搜索"]',
340
+ '[title*="搜索"]',
341
+ '.weui-desktop-search__icon',
342
+ '[class*="search"] button',
343
+ '[class*="search"] [role="button"]',
344
+ ].join(', ')))) {
345
+ if (!visible(element)) continue;
346
+ searchTargets.add(element.closest?.('button, a, [role="button"]') ?? element);
347
+ }
348
+ buttonFound = searchTargets.size > 0;
349
+ if (searchTargets.size === 1) {
350
+ const [searchTarget] = searchTargets;
351
+ searchTarget.click();
352
+ clickInvoked = true;
353
+ const fingerprint = /** @type {any} */ (window)[stateKey]?.fingerprint;
354
+ if (typeof fingerprint === 'string' && fingerprint.length > 0) {
355
+ return { submitted: true, dialogVisible: true, inputCount: 1, buttonFound, clickInvoked };
356
+ }
357
+ }
358
+ }
359
+ for (const type of ['keydown', 'keypress', 'keyup']) {
360
+ input.dispatchEvent(new KeyboardEvent(type, {
361
+ key: 'Enter', code: 'Enter', bubbles: true,
362
+ }));
363
+ }
364
+ return { submitted: true, dialogVisible: true, inputCount: 1, buttonFound, clickInvoked };
365
+ }, { operation: 'submit-search', query, stateKey: STATE_KEY });
366
+ lastSubmitDiagnostics = {
367
+ dialogVisible: result?.dialogVisible === true,
368
+ inputCount: Number(result?.inputCount ?? 0),
369
+ buttonFound: result?.buttonFound === true,
370
+ clickInvoked: result?.clickInvoked === true,
371
+ };
372
+ if (!result?.submitted && result?.reason === 'input') {
373
+ throw new CommandExecutionError(
374
+ 'WeChat account-card search input was not found',
375
+ 'The WeChat account-card dialog layout may have changed; close it and retry',
376
+ );
377
+ }
378
+ submitted = result?.submitted === true;
379
+ } else {
380
+ automaticPolls += 1;
381
+ if (!focusedForManualOpen && automaticPolls >= AUTO_OPEN_POLLS) {
382
+ if (typeof page.focusWindow === 'function') await page.focusWindow();
383
+ focusedForManualOpen = true;
384
+ }
385
+ }
386
+ }
387
+
388
+ if (submitted) {
389
+ if (browserNetworkCapture) {
390
+ try {
391
+ const fingerprint = fingerprintFromNetworkEntries(await page.readNetworkCapture());
392
+ if (fingerprint) return fingerprint;
393
+ } catch {
394
+ browserNetworkCapture = false;
395
+ }
396
+ }
397
+ const fingerprint = await page.evaluate(({ operation, stateKey }) => {
398
+ if (operation !== 'read') return null;
399
+ return /** @type {any} */ (window)[stateKey]?.fingerprint ?? null;
400
+ }, { operation: 'read', stateKey: STATE_KEY });
401
+ if (typeof fingerprint === 'string' && fingerprint.length > 0) return fingerprint;
402
+ }
403
+
404
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
405
+ if (remainingMs <= 0) break;
406
+ await page.wait(Math.min(POLL_INTERVAL_MS, remainingMs) / 1000);
407
+ }
408
+ const diagnostics = [
409
+ `networkCapture=${browserNetworkCapture ? 'browser' : 'page-hook'}`,
410
+ `dialogVisible=${lastSubmitDiagnostics.dialogVisible}`,
411
+ `inputCount=${lastSubmitDiagnostics.inputCount}`,
412
+ `buttonFound=${lastSubmitDiagnostics.buttonFound}`,
413
+ `clickInvoked=${lastSubmitDiagnostics.clickInvoked}`,
414
+ ].join(', ');
415
+ throw new TimeoutError(
416
+ 'WeChat account-card search fingerprint capture',
417
+ timeoutMs / 1000,
418
+ `Diagnostics: ${diagnostics}. Retry the command; increase --timeout only if the request is visibly still loading.`,
419
+ );
420
+ } catch (error) {
421
+ if (error instanceof CliError) throw error;
422
+ throw new CommandExecutionError(
423
+ 'WeChat fingerprint capture failed',
424
+ 'The WeChat editor page or browser bridge failed while capturing the official-account search request; retry the search',
425
+ );
426
+ } finally {
427
+ try {
428
+ await page.evaluate(({ operation, stateKey }) => {
429
+ if (operation !== 'cleanup') return;
430
+ const root = /** @type {any} */ (window);
431
+ const state = root[stateKey];
432
+ if (!state) return;
433
+ const originalFetch = root.fetch?.__bycliOriginalFetch;
434
+ const originalOpen = root.XMLHttpRequest?.prototype?.open?.__bycliOriginalOpen;
435
+ if (originalFetch) root.fetch = originalFetch;
436
+ if (originalOpen && root.XMLHttpRequest?.prototype) {
437
+ root.XMLHttpRequest.prototype.open = originalOpen;
438
+ }
439
+ delete root[stateKey];
440
+ }, { operation: 'cleanup', stateKey: STATE_KEY });
441
+ } catch { /* Cleanup must not hide the primary result or typed error. */ }
442
+ }
443
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "base_resp": { "ret": 200013, "err_msg": "invalid credential" }
3
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "base_resp": { "ret": 0, "err_msg": "ok" },
3
+ "publish_page": "{\"total_count\":2,\"publish_list\":[{\"publish_info\":\"{\\\"sent_info\\\":{\\\"time\\\":1767225600},\\\"appmsg_info\\\":[{\\\"title\\\":\\\"Synthetic article\\\",\\\"content_url\\\":\\\"https://mp.weixin.qq.com/s/synthetic\\\",\\\"digest\\\":\\\"Synthetic digest\\\",\\\"author\\\":\\\"Synthetic author\\\"}]}\"},{\"publish_info\":{\"publish_info\":{\"create_time\":1767312000},\"appmsg_info\":[{\"title\":\"Deleted article\",\"content_url\":\"https://mp.weixin.qq.com/s/deleted\",\"is_deleted\":true}]}}]}"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "base_resp": { "ret": 200013, "err_msg": "invalid credential" },
3
+ "list": []
4
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "base_resp": { "ret": 0, "err_msg": "ok" },
3
+ "list": [
4
+ { "nickname": "微信派", "fakeid": "MzA1", "alias": "wx-pai" },
5
+ { "nickname": "微信派服务号", "fakeid": "MzA2" }
6
+ ]
7
+ }
@@ -0,0 +1,29 @@
1
+ import { convertArticleHtmlToMarkdown, extractWechatArticleHtml } from '@sovovs/bycli/download/article-download';
2
+
3
+ export function cleanMarkdownFilename(title, maxLength = 100, suffix = '') {
4
+ let cleaned = String(title || '')
5
+ .replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, '_')
6
+ .trim().replace(/[. ]+$/g, '');
7
+ let bounded = '';
8
+ for (const char of [...cleaned].slice(0, maxLength)) {
9
+ if (Buffer.byteLength(`${bounded}${char}${suffix}.md`) > 255) break;
10
+ bounded += char;
11
+ }
12
+ cleaned = bounded.replace(/[. ]+$/g, '');
13
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(cleaned)) cleaned = `_${cleaned}`;
14
+ return cleaned || 'untitled';
15
+ }
16
+
17
+ export function wechatArticleToMarkdown({ html, title, accountName, author, publishedAt, url, digest }) {
18
+ const extracted = extractWechatArticleHtml(String(html || ''));
19
+ let markdown = convertArticleHtmlToMarkdown(extracted.contentHtml, { safeFencedCodeBlocks: true });
20
+ markdown = markdown.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
21
+ const safe = value => String(value || '').replace(/\s+/g, ' ').trim()
22
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
24
+ .replace(/([\\`*_[\]{}()#+.!|>~-])/g, '\\$1');
25
+ const metadata = [accountName && `> 公众号: ${safe(accountName)}`, author && `> 作者: ${safe(author)}`,
26
+ publishedAt && `> 发布时间: ${safe(publishedAt)}`, digest && `> 摘要: ${safe(digest)}`,
27
+ url && `> 原文链接: ${safe(url)}`].filter(Boolean);
28
+ return [`# ${safe(title || 'Untitled')}`, ...metadata, '', '---', '', markdown, ''].join('\n');
29
+ }