@sovovs/bycli 2.1.39 → 2.1.41

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 (47) hide show
  1. package/cli-manifest.json +56 -3
  2. package/clis/ima/knowledge-list.js +48 -0
  3. package/clis/ima/knowledge.js +23 -0
  4. package/clis/ima/native-api.js +67 -10
  5. package/clis/ima/native-client.js +23 -7
  6. package/clis/ima/utils.js +1 -0
  7. package/clis/weixin/_wechat/article-artifact.js +55 -0
  8. package/clis/weixin/_wechat/article-identity.js +27 -0
  9. package/clis/weixin/_wechat/publish-analysis.js +8 -4
  10. package/clis/weixin/_wechat/publish-download.js +3 -1
  11. package/clis/weixin/download-publish-data.js +20 -0
  12. package/clis/weixin/download.js +15 -2
  13. package/dist/src/adapter-coordination.d.ts +26 -0
  14. package/dist/src/adapter-coordination.js +183 -0
  15. package/dist/src/adapter-coordination.test.d.ts +1 -0
  16. package/dist/src/adapter-execution-context.d.ts +6 -0
  17. package/dist/src/adapter-execution-context.js +8 -0
  18. package/dist/src/adapter-scheduler.d.ts +86 -0
  19. package/dist/src/adapter-scheduler.js +349 -0
  20. package/dist/src/adapter-scheduler.test.d.ts +1 -0
  21. package/dist/src/browser/daemon-client.d.ts +11 -0
  22. package/dist/src/browser/daemon-client.js +53 -1
  23. package/dist/src/browser/extension-capabilities.d.ts +1 -0
  24. package/dist/src/browser/extension-capabilities.js +18 -5
  25. package/dist/src/browser/page.d.ts +2 -1
  26. package/dist/src/browser/page.js +3 -0
  27. package/dist/src/build-manifest.js +1 -0
  28. package/dist/src/cli-argv-preprocess.d.ts +3 -0
  29. package/dist/src/cli-argv-preprocess.js +4 -0
  30. package/dist/src/commanderAdapter.js +11 -0
  31. package/dist/src/daemon.js +118 -0
  32. package/dist/src/discovery.js +1 -0
  33. package/dist/src/download/article-download.d.ts +2 -0
  34. package/dist/src/download/article-download.js +30 -4
  35. package/dist/src/errors.d.ts +3 -0
  36. package/dist/src/errors.js +5 -0
  37. package/dist/src/execution.d.ts +2 -0
  38. package/dist/src/execution.js +168 -105
  39. package/dist/src/help.d.ts +1 -0
  40. package/dist/src/help.js +40 -0
  41. package/dist/src/manifest-types.d.ts +4 -0
  42. package/dist/src/registry.d.ts +6 -0
  43. package/dist/src/registry.js +23 -0
  44. package/dist/src/serialization.d.ts +1 -0
  45. package/dist/src/serialization.js +1 -0
  46. package/dist/src/types.d.ts +2 -0
  47. package/package.json +3 -2
package/cli-manifest.json CHANGED
@@ -13570,7 +13570,30 @@
13570
13570
  "title",
13571
13571
  "url",
13572
13572
  "contentType",
13573
- "addedDate"
13573
+ "addedDate",
13574
+ "mediaId",
13575
+ "mediaType",
13576
+ "mediaState",
13577
+ "mediaAuditStatus",
13578
+ "mediaTypeInfo",
13579
+ "sourcePath",
13580
+ "jumpUrl",
13581
+ "createTime",
13582
+ "updateTime",
13583
+ "lastModifyTime",
13584
+ "lastOpenTime",
13585
+ "fileSize",
13586
+ "abstract",
13587
+ "introduction",
13588
+ "tags",
13589
+ "isTop",
13590
+ "accessStatus",
13591
+ "accessStatusUpdateTs",
13592
+ "parseProgress",
13593
+ "parseErrInfo",
13594
+ "summaryState",
13595
+ "coverUrls",
13596
+ "logo"
13574
13597
  ],
13575
13598
  "defaultFormat": "json",
13576
13599
  "type": "js",
@@ -13578,6 +13601,28 @@
13578
13601
  "sourceFile": "ima/knowledge.js",
13579
13602
  "navigateBefore": false
13580
13603
  },
13604
+ {
13605
+ "site": "ima",
13606
+ "name": "knowledge-list",
13607
+ "description": "获取全部 ima 知识库的常用字段与原始元数据",
13608
+ "access": "read",
13609
+ "domain": "ima.qq.com",
13610
+ "strategy": "cookie",
13611
+ "browser": true,
13612
+ "args": [],
13613
+ "columns": [
13614
+ "id",
13615
+ "name",
13616
+ "type",
13617
+ "typeName",
13618
+ "raw"
13619
+ ],
13620
+ "defaultFormat": "json",
13621
+ "type": "js",
13622
+ "modulePath": "ima/knowledge-list.js",
13623
+ "sourceFile": "ima/knowledge-list.js",
13624
+ "navigateBefore": false
13625
+ },
13581
13626
  {
13582
13627
  "site": "imdb",
13583
13628
  "name": "person",
@@ -29035,7 +29080,11 @@
29035
29080
  "type": "js",
29036
29081
  "modulePath": "weixin/download.js",
29037
29082
  "sourceFile": "weixin/download.js",
29038
- "navigateBefore": "https://mp.weixin.qq.com"
29083
+ "navigateBefore": "https://mp.weixin.qq.com",
29084
+ "adapterConcurrency": {
29085
+ "isolatedTabs": true,
29086
+ "maxParallel": 3
29087
+ }
29039
29088
  },
29040
29089
  {
29041
29090
  "site": "weixin",
@@ -29114,7 +29163,11 @@
29114
29163
  "type": "js",
29115
29164
  "modulePath": "weixin/download-publish-data.js",
29116
29165
  "sourceFile": "weixin/download-publish-data.js",
29117
- "navigateBefore": false
29166
+ "navigateBefore": false,
29167
+ "adapterConcurrency": {
29168
+ "isolatedTabs": true,
29169
+ "maxParallel": 3
29170
+ }
29118
29171
  },
29119
29172
  {
29120
29173
  "site": "weixin",
@@ -0,0 +1,48 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { CommandExecutionError, ConfigError } from '@sovovs/bycli/errors';
3
+
4
+ import { readKnowledgeBasesFromChrome } from './native-client.js';
5
+
6
+ const COMMAND = 'ima knowledge-list';
7
+
8
+ function throwDriverError(error) {
9
+ if (error?.code === 'IMA_CHROME_AUTH_REQUIRED') {
10
+ throw new ConfigError(
11
+ error.message || 'ima reader authentication is required',
12
+ 'Open https://ima.qq.com/wikis in Chrome, sign in, then retry with the latest bycli Browser Bridge extension.',
13
+ );
14
+ }
15
+ throw new CommandExecutionError(
16
+ error?.message || `${COMMAND} failed to read knowledge bases`,
17
+ );
18
+ }
19
+
20
+ export async function runKnowledgeListCommand(read) {
21
+ try {
22
+ const items = await read();
23
+ if (!Array.isArray(items)) {
24
+ throw new CommandExecutionError(`${COMMAND} returned malformed knowledge-base rows`);
25
+ }
26
+ return items;
27
+ } catch (error) {
28
+ if (error instanceof ConfigError || error instanceof CommandExecutionError) throw error;
29
+ throwDriverError(error);
30
+ }
31
+ }
32
+
33
+ export const knowledgeListCommand = cli({
34
+ site: 'ima',
35
+ name: 'knowledge-list',
36
+ access: 'read',
37
+ description: '获取全部 ima 知识库的常用字段与原始元数据',
38
+ domain: 'ima.qq.com',
39
+ defaultFormat: 'json',
40
+ args: [],
41
+ columns: ['id', 'name', 'type', 'typeName', 'raw'],
42
+ strategy: Strategy.COOKIE,
43
+ browser: true,
44
+ navigateBefore: false,
45
+ func: async (page) => runKnowledgeListCommand(
46
+ () => readKnowledgeBasesFromChrome(page),
47
+ ),
48
+ });
@@ -84,6 +84,29 @@ export const knowledgeCommand = cli({
84
84
  'url',
85
85
  'contentType',
86
86
  'addedDate',
87
+ 'mediaId',
88
+ 'mediaType',
89
+ 'mediaState',
90
+ 'mediaAuditStatus',
91
+ 'mediaTypeInfo',
92
+ 'sourcePath',
93
+ 'jumpUrl',
94
+ 'createTime',
95
+ 'updateTime',
96
+ 'lastModifyTime',
97
+ 'lastOpenTime',
98
+ 'fileSize',
99
+ 'abstract',
100
+ 'introduction',
101
+ 'tags',
102
+ 'isTop',
103
+ 'accessStatus',
104
+ 'accessStatusUpdateTs',
105
+ 'parseProgress',
106
+ 'parseErrInfo',
107
+ 'summaryState',
108
+ 'coverUrls',
109
+ 'logo',
87
110
  ],
88
111
  strategy: Strategy.COOKIE,
89
112
  browser: true,
@@ -4,6 +4,10 @@ const MEDIA_TYPE_NAMES = new Map([
4
4
  [12, '问答'], [13, 'TXT'], [14, 'XMIND'], [15, '音频'], [16, '视频网站'],
5
5
  [19, '播客'], [20, 'HTML'], [21, 'EPUB'], [98, '源代码'], [99, '文件夹'],
6
6
  ]);
7
+ const KNOWLEDGE_BASE_TYPE_NAMES = new Map([
8
+ [1001, '我的知识库'], [1002, '共享知识库'],
9
+ [1004, '团队知识库'], [1005, '其他知识库'],
10
+ ]);
7
11
  function field(value, camelName, snakeName) {
8
12
  return value?.[camelName] ?? value?.[snakeName];
9
13
  }
@@ -20,6 +24,15 @@ function knowledgeBaseFromRaw(raw) {
20
24
  };
21
25
  }
22
26
 
27
+ function knowledgeBaseRow(raw, type) {
28
+ return {
29
+ ...knowledgeBaseFromRaw(raw),
30
+ type,
31
+ typeName: KNOWLEDGE_BASE_TYPE_NAMES.get(type) ?? String(type),
32
+ raw,
33
+ };
34
+ }
35
+
23
36
  function basesFromGroups(response) {
24
37
  const groups = field(response, 'results', 'results');
25
38
  if (!Array.isArray(groups)) throw new Error('ima API returned malformed knowledge-base groups');
@@ -29,7 +42,17 @@ function basesFromGroups(response) {
29
42
  });
30
43
  }
31
44
 
32
- export async function findKnowledgeBase(query, request) {
45
+ function entriesFromGroups(response) {
46
+ const groups = field(response, 'results', 'results');
47
+ if (!Array.isArray(groups)) throw new Error('ima API returned malformed knowledge-base groups');
48
+ return groups.flatMap((group) => {
49
+ const list = field(group, 'knowledgeBaseList', 'knowledge_base_list');
50
+ const type = Number(field(group, 'type', 'type'));
51
+ return Array.isArray(list) ? list.map((raw) => ({ raw, type })) : [];
52
+ });
53
+ }
54
+
55
+ async function collectKnowledgeBaseEntries(request) {
33
56
  const initialGroups = [
34
57
  { type: 1001, limit: 20 },
35
58
  { type: 1002, limit: 20 },
@@ -47,7 +70,7 @@ export async function findKnowledgeBase(query, request) {
47
70
  if (Number(response?.code) !== 0) {
48
71
  throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
49
72
  }
50
- all.push(...basesFromGroups(response));
73
+ all.push(...entriesFromGroups(response));
51
74
  const groups = field(response, 'results', 'results');
52
75
  for (const group of groups) {
53
76
  const cursor = field(group, 'nextCursor', 'next_cursor');
@@ -61,19 +84,29 @@ export async function findKnowledgeBase(query, request) {
61
84
  throw new Error(`ima API returned a repeated cursor for knowledge-base group ${type}`);
62
85
  }
63
86
  queuedPages.add(pageKey);
64
- pendingPages.push({
65
- type,
66
- cursor: String(cursor),
67
- limit: 10,
68
- });
87
+ pendingPages.push({ type, cursor: String(cursor), limit: 10 });
69
88
  }
70
89
  }
71
90
  const next = pendingPages.shift();
72
91
  if (!next) break;
73
- response = await request('/get_knowledge_base_list', {
74
- params: [next],
75
- });
92
+ response = await request('/get_knowledge_base_list', { params: [next] });
76
93
  }
94
+ return all;
95
+ }
96
+
97
+ export async function listKnowledgeBases(request) {
98
+ const entries = await collectKnowledgeBaseEntries(request);
99
+ const unique = new Map();
100
+ for (const { raw, type } of entries) {
101
+ const row = knowledgeBaseRow(raw, type);
102
+ if (!unique.has(row.id)) unique.set(row.id, row);
103
+ }
104
+ return [...unique.values()];
105
+ }
106
+
107
+ export async function findKnowledgeBase(query, request) {
108
+ const all = (await collectKnowledgeBaseEntries(request))
109
+ .map(({ raw }) => knowledgeBaseFromRaw(raw));
77
110
 
78
111
  const matches = all.filter((base) => base.id === query || base.name === query);
79
112
  if (matches.length === 0) {
@@ -100,6 +133,30 @@ function articleFromRaw(raw, knowledgeBaseId, knowledgeBaseName, folderPath) {
100
133
  addedDate: field(raw, 'timeWording', 'time_wording')
101
134
  || field(raw, 'createTime', 'create_time')
102
135
  || null,
136
+ mediaId: field(raw, 'mediaId', 'media_id') ?? null,
137
+ mediaType,
138
+ mediaState: field(raw, 'mediaState', 'media_state') ?? null,
139
+ mediaAuditStatus: field(raw, 'mediaAuditStatus', 'media_audit_status') ?? null,
140
+ mediaTypeInfo: field(raw, 'mediaTypeInfo', 'media_type_info') ?? null,
141
+ sourcePath: sourcePath ?? null,
142
+ jumpUrl: jumpUrl ?? null,
143
+ createTime: field(raw, 'createTime', 'create_time') ?? null,
144
+ updateTime: field(raw, 'updateTime', 'update_time') ?? null,
145
+ lastModifyTime: field(raw, 'lastModifyTime', 'last_modify_time') ?? null,
146
+ lastOpenTime: field(raw, 'lastOpenTime', 'last_open_time') ?? null,
147
+ fileSize: field(raw, 'fileSize', 'file_size') ?? null,
148
+ abstract: field(raw, 'abstract', 'abstract') ?? null,
149
+ introduction: field(raw, 'introduction', 'introduction') ?? null,
150
+ tags: Array.isArray(field(raw, 'tags', 'tags')) ? field(raw, 'tags', 'tags') : [],
151
+ isTop: field(raw, 'isTop', 'is_top') ?? null,
152
+ accessStatus: field(raw, 'accessStatus', 'access_status') ?? null,
153
+ accessStatusUpdateTs: field(raw, 'accessStatusUpdateTs', 'access_status_update_ts') ?? null,
154
+ parseProgress: field(raw, 'parseProgress', 'parse_progress') ?? null,
155
+ parseErrInfo: field(raw, 'parseErrInfo', 'parse_err_info') ?? null,
156
+ summaryState: field(raw, 'summaryState', 'summary_state') ?? null,
157
+ coverUrls: Array.isArray(field(raw, 'coverUrls', 'cover_urls'))
158
+ ? field(raw, 'coverUrls', 'cover_urls') : [],
159
+ logo: field(raw, 'logo', 'logo') ?? null,
103
160
  };
104
161
  }
105
162
 
@@ -1,4 +1,4 @@
1
- import { readKnowledgeBaseFromApi } from './native-api.js';
1
+ import { listKnowledgeBases, readKnowledgeBaseFromApi } from './native-api.js';
2
2
 
3
3
  const IMA_WIKIS_URL = 'https://ima.qq.com/wikis';
4
4
 
@@ -33,7 +33,7 @@ async function triggerImaAuthRequest(page, query) {
33
33
  }, query);
34
34
  }
35
35
 
36
- export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}) {
36
+ async function acquireImaChromeAuth(page, query, dependencies = {}) {
37
37
  if (!page || typeof page.startImaAuthCapture !== 'function'
38
38
  || typeof page.readImaAuth !== 'function' || typeof page.requestImaReader !== 'function'
39
39
  || typeof page.evaluate !== 'function') {
@@ -44,12 +44,11 @@ export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}
44
44
  }
45
45
  const timeoutMs = dependencies.timeoutMs ?? 30_000;
46
46
  const sleep = dependencies.sleep ?? wait;
47
- let authId;
48
47
  try {
49
48
  await page.startImaAuthCapture();
50
49
  await page.goto(IMA_WIKIS_URL);
51
50
  await triggerImaAuthRequest(page, query);
52
- authId = await waitForImaAuth(page, timeoutMs, sleep);
51
+ return await waitForImaAuth(page, timeoutMs, sleep);
53
52
  } catch (error) {
54
53
  if (error?.code === 'IMA_CHROME_AUTH_REQUIRED') throw error;
55
54
  throw codedError(
@@ -57,14 +56,31 @@ export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}
57
56
  `Chrome Browser Bridge could not acquire ima reader authentication: ${error instanceof Error ? error.message : String(error)}`,
58
57
  );
59
58
  }
59
+ }
60
+
61
+ async function releaseImaAuth(page, authId) {
62
+ if (typeof page.releaseImaAuth === 'function') {
63
+ await page.releaseImaAuth(authId).catch(() => {});
64
+ }
65
+ }
66
+
67
+ export async function readKnowledgeBasesFromChrome(page, dependencies = {}) {
68
+ const authId = await acquireImaChromeAuth(page, undefined, dependencies);
69
+ try {
70
+ return await listKnowledgeBases((path, body) => page.requestImaReader(authId, path, body));
71
+ } finally {
72
+ await releaseImaAuth(page, authId);
73
+ }
74
+ }
75
+
76
+ export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}) {
77
+ const authId = await acquireImaChromeAuth(page, query, dependencies);
60
78
  try {
61
79
  return await readKnowledgeBaseFromApi(
62
80
  query,
63
81
  (path, body) => page.requestImaReader(authId, path, body),
64
82
  );
65
83
  } finally {
66
- if (typeof page.releaseImaAuth === 'function') {
67
- await page.releaseImaAuth(authId).catch(() => {});
68
- }
84
+ await releaseImaAuth(page, authId);
69
85
  }
70
86
  }
package/clis/ima/utils.js CHANGED
@@ -37,6 +37,7 @@ export function normalizeArticleUrl(value) {
37
37
 
38
38
  export function toKnowledgeRow(item) {
39
39
  return {
40
+ ...item,
40
41
  knowledgeBaseId: item.knowledgeBaseId || null,
41
42
  knowledgeBase: item.knowledgeBase,
42
43
  folderPath: Array.isArray(item.folderPath) ? item.folderPath : [],
@@ -0,0 +1,55 @@
1
+ import { constants } from 'node:fs';
2
+ import { access, readFile, realpath, stat } from 'node:fs/promises';
3
+ import { dirname, extname, relative, resolve } from 'node:path';
4
+ import { CommandExecutionError } from '@sovovs/bycli/errors';
5
+
6
+ function isContained(parent, child) {
7
+ const rel = relative(parent, child);
8
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/') && !rel.startsWith('\\'));
9
+ }
10
+
11
+ async function validateRegularNonEmptyFile(path, label) {
12
+ await access(path, constants.R_OK);
13
+ const info = await stat(path);
14
+ if (!info.isFile() || info.size <= 0) throw new Error(`${label} is empty or not a regular file`);
15
+ return realpath(path);
16
+ }
17
+
18
+ function localMarkdownImageTargets(markdown) {
19
+ const targets = [];
20
+ for (const match of markdown.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
21
+ let target = match[1].trim();
22
+ if (target.startsWith('<') && target.endsWith('>')) target = target.slice(1, -1);
23
+ target = target.split(/\s+["']/u, 1)[0];
24
+ if (!target || /^(?:https?:|data:|#|\/\/)/iu.test(target)) continue;
25
+ targets.push(decodeURI(target));
26
+ }
27
+ return targets;
28
+ }
29
+
30
+ export async function validateDownloadedArticleRows(rows, outputDir) {
31
+ try {
32
+ const successfulRows = rows.filter(row => row && String(row.status).toLowerCase() === 'success');
33
+ if (successfulRows.length === 0) return rows;
34
+ const resolvedOutput = await realpath(resolve(outputDir));
35
+ for (const row of successfulRows) {
36
+ if (typeof row.saved !== 'string' || extname(row.saved).toLowerCase() !== '.md') {
37
+ throw new Error('successful article row returned no Markdown path');
38
+ }
39
+ const saved = await validateRegularNonEmptyFile(resolve(row.saved), 'saved Markdown');
40
+ if (!isContained(resolvedOutput, saved)) throw new Error('saved Markdown escaped the output directory');
41
+ const articleDir = dirname(saved);
42
+ const markdown = await readFile(saved, 'utf8');
43
+ for (const target of localMarkdownImageTargets(markdown)) {
44
+ const image = await validateRegularNonEmptyFile(resolve(articleDir, target), 'local Markdown image');
45
+ if (!isContained(articleDir, image)) throw new Error('local Markdown image escaped the article directory');
46
+ }
47
+ }
48
+ return rows;
49
+ } catch (error) {
50
+ if (error instanceof CommandExecutionError) throw error;
51
+ throw new CommandExecutionError(
52
+ `Downloaded Weixin article artifact validation failed: ${error instanceof Error ? error.message : String(error)}`,
53
+ );
54
+ }
55
+ }
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function hashResourceValue(value) {
4
+ return createHash('sha256').update(String(value)).digest('hex');
5
+ }
6
+
7
+ export function canonicalWechatArticleIdentity(rawUrl) {
8
+ const url = new URL(rawUrl);
9
+ url.hash = '';
10
+ const host = url.hostname.toLowerCase();
11
+ let canonical;
12
+ if (url.pathname.startsWith('/s/')) {
13
+ canonical = `${host}${url.pathname}`;
14
+ } else {
15
+ const tupleNames = ['__biz', 'mid', 'idx', 'sn'];
16
+ const hasTuple = tupleNames.every(name => url.searchParams.has(name));
17
+ if (url.pathname === '/s' && hasTuple) {
18
+ canonical = `${host}/s?${tupleNames.map(name => `${name}=${url.searchParams.get(name)}`).join('&')}`;
19
+ } else {
20
+ const sorted = [...url.searchParams.entries()]
21
+ .sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue));
22
+ const query = new URLSearchParams(sorted).toString();
23
+ canonical = `${host}${url.pathname}${query ? `?${query}` : ''}`;
24
+ }
25
+ }
26
+ return hashResourceValue(canonical);
27
+ }
@@ -427,7 +427,7 @@ function safeFilename(title) {
427
427
  return `${name}.md`;
428
428
  }
429
429
 
430
- async function publishMarkdown(outputDir, filename, content) {
430
+ async function publishMarkdown(outputDir, filename, content, beforePublish) {
431
431
  await mkdir(outputDir, { recursive: true });
432
432
  const temporary = resolve(outputDir, `.bycli-publish-analysis-${randomUUID()}.tmp`);
433
433
  await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
@@ -436,13 +436,17 @@ async function publishMarkdown(outputDir, filename, content) {
436
436
  const stem = filename.slice(0, -extension.length);
437
437
  for (let index = 0; index <= 9999; index += 1) {
438
438
  const path = resolve(outputDir, index === 0 ? filename : `${stem}-${index}${extension}`);
439
- try { await link(temporary, path); return path; } catch (error) { if (error?.code !== 'EEXIST') throw error; }
439
+ try {
440
+ await beforePublish?.();
441
+ await link(temporary, path);
442
+ return path;
443
+ } catch (error) { if (error?.code !== 'EEXIST') throw error; }
440
444
  }
441
445
  throw new CommandExecutionError('WeChat publish analysis could not allocate a report filename');
442
446
  } finally { await unlink(temporary).catch(() => {}); }
443
447
  }
444
448
 
445
- export async function collectPublishAnalysis(page, { detailUrl, title, publishedAt, outputDir }) {
449
+ export async function collectPublishAnalysis(page, { detailUrl, title, publishedAt, outputDir, beforePublish }) {
446
450
  if (typeof page?.goto !== 'function' || typeof page?.readNetworkCapture !== 'function') {
447
451
  throw new CommandExecutionError('WeChat publish analysis requires browser network capture support');
448
452
  }
@@ -480,7 +484,7 @@ export async function collectPublishAnalysis(page, { detailUrl, title, published
480
484
  }
481
485
  if (Object.keys(data).length === 0) throw new CommandExecutionError('WeChat publish analysis returned no readable analysis data');
482
486
  const content = formatAnalysisMarkdown({ title, publishedAt, data });
483
- const path = await publishMarkdown(resolve(outputDir), safeFilename(title), content);
487
+ const path = await publishMarkdown(resolve(outputDir), safeFilename(title), content, beforePublish);
484
488
  const info = await stat(path);
485
489
  return { status: 'saved', path, size: info.size, metrics };
486
490
  }
@@ -37,7 +37,7 @@ function safeFilename(title) {
37
37
  return name;
38
38
  }
39
39
 
40
- async function publishExclusively(source, outputDir, filename) {
40
+ async function publishExclusively(source, outputDir, filename, beforePublish) {
41
41
  const extension = extname(filename);
42
42
  const stem = filename.slice(0, -extension.length);
43
43
  const temporary = resolve(outputDir, `.bycli-publish-data-${randomUUID()}.tmp`);
@@ -61,6 +61,7 @@ async function publishExclusively(source, outputDir, filename) {
61
61
  for (let index = 0; index <= 9999; index += 1) {
62
62
  const candidate = resolve(outputDir, index === 0 ? filename : `${stem}-${index}${extension}`);
63
63
  try {
64
+ await beforePublish?.();
64
65
  await link(temporary, candidate);
65
66
  return candidate;
66
67
  } catch (error) {
@@ -151,6 +152,7 @@ export async function downloadPublishData(page, options) {
151
152
  downloaded.filename,
152
153
  outputDir,
153
154
  safeFilename(options.title),
155
+ options.beforePublish,
154
156
  );
155
157
  try {
156
158
  await unlink(downloaded.filename);
@@ -3,6 +3,7 @@ import { access, stat } from 'node:fs/promises';
3
3
  import { extname, resolve } from 'node:path';
4
4
  import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
5
5
  import { cli, Strategy } from '@sovovs/bycli/registry';
6
+ import { assertCurrentAdapterLease, withAdapterResourceLocks } from '@sovovs/bycli/adapter-coordination';
6
7
  import { resolveBrowserCredentials } from './_wechat/auth-session.js';
7
8
  import { buildSecretSet, redactText } from './_wechat/redact.js';
8
9
  import { collectPublishAnalysis } from './_wechat/publish-analysis.js';
@@ -15,6 +16,7 @@ import {
15
16
  validatePublishDate,
16
17
  validatePublishedQuery,
17
18
  } from './_wechat/publish-records.js';
19
+ import { canonicalWechatArticleIdentity, hashResourceValue } from './_wechat/article-identity.js';
18
20
 
19
21
  const METRIC_COLUMNS = [
20
22
  'readUsers', 'avgReadMinutes', 'finishedReadRatio', 'newFollowers', 'listenUsers',
@@ -35,6 +37,13 @@ function sanitizedError(error, secrets, fallback) {
35
37
  .replace(/https?:\/\/mp\.weixin\.qq\.com\/\S*/giu, '[REDACTED]');
36
38
  }
37
39
 
40
+ function rethrowStopError(error) {
41
+ const code = error && typeof error === 'object' ? error.code : undefined;
42
+ if (code === 'AUTH_REQUIRED' || code === 'RATE_LIMITED' || code === 'CAPTCHA' || code === 'MFA_REQUIRED' || code === 'ADAPTER_LEASE_LOST') {
43
+ throw error;
44
+ }
45
+ }
46
+
38
47
  async function validateArtifact(result, { label, expectedStatus, expectedExtension }) {
39
48
  if (!result || result.status !== expectedStatus) {
40
49
  throw new CommandExecutionError(`${label} returned an invalid status`);
@@ -71,6 +80,7 @@ export const downloadPublishDataCommand = cli({
71
80
  strategy: Strategy.INTERCEPT,
72
81
  browser: true,
73
82
  navigateBefore: false,
83
+ adapterConcurrency: { isolatedTabs: true, maxParallel: 3 },
74
84
  args: [
75
85
  { name: 'query', positional: true, required: true, help: 'Exact article URL or title text' },
76
86
  { name: 'date', help: 'Optional publication date in YYYY-MM-DD' },
@@ -104,9 +114,16 @@ export const downloadPublishDataCommand = cli({
104
114
  title: record.title,
105
115
  outputDir,
106
116
  timeoutSeconds,
117
+ beforePublish: assertCurrentAdapterLease,
107
118
  };
108
119
  const secrets = buildSecretSet({ token, cookie });
109
120
 
121
+ return withAdapterResourceLocks([
122
+ `article:${canonicalWechatArticleIdentity(record.url)}`,
123
+ `data:${hashResourceValue(`${record.msgid}:${record.publishDate ?? record.publishedAt}`)}`,
124
+ `output:${hashResourceValue(resolve(outputDir))}`,
125
+ ], async () => {
126
+
110
127
  let dataResult = null;
111
128
  let markdownResult = null;
112
129
  const errors = [];
@@ -118,6 +135,7 @@ export const downloadPublishDataCommand = cli({
118
135
  expectedExtension: '.xls',
119
136
  });
120
137
  } catch (error) {
138
+ rethrowStopError(error);
121
139
  errors.push(`Excel download failed: ${sanitizedError(error, secrets, 'Excel download failed')}`);
122
140
  }
123
141
  try {
@@ -131,6 +149,7 @@ export const downloadPublishDataCommand = cli({
131
149
  expectedExtension: '.md',
132
150
  });
133
151
  } catch (error) {
152
+ rethrowStopError(error);
134
153
  errors.push(`Markdown analysis failed: ${sanitizedError(error, secrets, 'Markdown analysis failed')}`);
135
154
  }
136
155
 
@@ -175,5 +194,6 @@ export const downloadPublishDataCommand = cli({
175
194
  detailLikes: metrics?.likes ?? null,
176
195
  detailComments: metrics?.comments ?? null,
177
196
  }];
197
+ });
178
198
  },
179
199
  });
@@ -9,6 +9,8 @@
9
9
  import { cli, Strategy } from '@sovovs/bycli/registry';
10
10
  import { downloadArticle } from '@sovovs/bycli/download/article-download';
11
11
  import { AuthRequiredError } from '@sovovs/bycli/errors';
12
+ import { assertCurrentAdapterLease, withAdapterResourceLocks } from '@sovovs/bycli/adapter-coordination';
13
+ import { resolve } from 'node:path';
12
14
  import { buildExtractWechatArticleContentJs } from './_wechat/article-content.js';
13
15
  import {
14
16
  isTrustedSogouRedirectUrl,
@@ -16,6 +18,8 @@ import {
16
18
  normalizeWechatUrl,
17
19
  resolveWechatArticleUrl,
18
20
  } from './_wechat/article-link.js';
21
+ import { canonicalWechatArticleIdentity, hashResourceValue } from './_wechat/article-identity.js';
22
+ import { validateDownloadedArticleRows } from './_wechat/article-artifact.js';
19
23
  export { extractWechatArticleContent } from './_wechat/article-content.js';
20
24
  export {
21
25
  isTrustedSogouRedirectUrl,
@@ -152,6 +156,7 @@ cli({
152
156
  description: '下载微信公众号文章为 Markdown 格式',
153
157
  domain: 'mp.weixin.qq.com',
154
158
  strategy: Strategy.COOKIE,
159
+ adapterConcurrency: { isolatedTabs: true, maxParallel: 3 },
155
160
  args: [
156
161
  { name: 'url', required: true, help: 'WeChat article URL (mp.weixin.qq.com/s/xxx)' },
157
162
  { name: 'output', default: './weixin-articles', help: 'Output directory' },
@@ -160,6 +165,11 @@ cli({
160
165
  columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved', 'source_url', 'resolved_url'],
161
166
  func: async (page, kwargs) => {
162
167
  const { sourceUrl, resolvedUrl, alreadyNavigated } = await resolveWechatArticleUrl(page, kwargs.url);
168
+ const outputDir = resolve(kwargs.output ?? './weixin-articles');
169
+ return withAdapterResourceLocks([
170
+ `article:${canonicalWechatArticleIdentity(resolvedUrl)}`,
171
+ `output:${hashResourceValue(outputDir)}`,
172
+ ], async () => {
163
173
  // Navigate and wait for content to load. Sogou resolution already lands on the article.
164
174
  if (!alreadyNavigated)
165
175
  await page.goto(resolvedUrl);
@@ -235,7 +245,7 @@ cli({
235
245
  codeBlocks: data?.codeBlocks,
236
246
  imageUrls: data?.imageUrls,
237
247
  }, {
238
- output: kwargs.output,
248
+ output: outputDir,
239
249
  downloadImages: kwargs['download-images'],
240
250
  imageHeaders: { Referer: 'https://mp.weixin.qq.com/' },
241
251
  frontmatterLabels: { author: '公众号' },
@@ -244,7 +254,10 @@ cli({
244
254
  return m ? m[1] : 'png';
245
255
  },
246
256
  secureMarkdown: true,
257
+ beforePublish: assertCurrentAdapterLease,
258
+ });
259
+ const validatedRows = await validateDownloadedArticleRows(rows, outputDir);
260
+ return validatedRows.map(row => ({ ...row, source_url: sourceUrl, resolved_url: resolvedUrl }));
247
261
  });
248
- return rows.map(row => ({ ...row, source_url: sourceUrl, resolved_url: resolvedUrl }));
249
262
  },
250
263
  });
@@ -0,0 +1,26 @@
1
+ import type { AdapterLease, AdapterLeaseRelease, AdapterLeaseRequest, AdapterResourceGrant } from './adapter-scheduler.js';
2
+ export interface AdapterCoordinationDependencies {
3
+ acquire?: (request: AdapterLeaseRequest) => Promise<AdapterLease>;
4
+ heartbeat?: (lease: AdapterLease) => Promise<AdapterLease>;
5
+ release?: (release: AdapterLeaseRelease) => Promise<boolean>;
6
+ heartbeatIntervalMs?: number;
7
+ onLeaseLost?: () => Promise<void>;
8
+ warn?: (message: string) => void;
9
+ }
10
+ export declare function getCurrentAdapterLease(): AdapterLease | undefined;
11
+ /**
12
+ * Renew the active lease immediately before an irreversible local publication.
13
+ * A restarted daemon or reclaimed lease rejects this fencing check.
14
+ */
15
+ export declare function assertCurrentAdapterLease(dependencies?: {
16
+ heartbeat?: (lease: AdapterLease) => Promise<AdapterLease>;
17
+ }): Promise<void>;
18
+ export interface AdapterResourceDependencies {
19
+ acquire?: (lease: AdapterLease, keys: string[], timeoutMs: number) => Promise<AdapterResourceGrant>;
20
+ release?: (lease: AdapterLease, grantId: string) => Promise<boolean>;
21
+ timeoutMs?: number;
22
+ warn?: (message: string) => void;
23
+ }
24
+ export declare function settleAdapterOperationAfterTimeout<T>(operation: Promise<T>, timeoutMs: number, timeoutError: Error, stop: () => Promise<void>): Promise<T>;
25
+ export declare function withAdapterResourceLocks<T>(keys: string[], operation: () => Promise<T>, dependencies?: AdapterResourceDependencies): Promise<T>;
26
+ export declare function withAdapterCommandLease<T>(request: AdapterLeaseRequest, operation: () => Promise<T>, dependencies?: AdapterCoordinationDependencies): Promise<T>;