@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.
- package/cli-manifest.json +56 -3
- package/clis/ima/knowledge-list.js +48 -0
- package/clis/ima/knowledge.js +23 -0
- package/clis/ima/native-api.js +67 -10
- package/clis/ima/native-client.js +23 -7
- package/clis/ima/utils.js +1 -0
- package/clis/weixin/_wechat/article-artifact.js +55 -0
- package/clis/weixin/_wechat/article-identity.js +27 -0
- package/clis/weixin/_wechat/publish-analysis.js +8 -4
- package/clis/weixin/_wechat/publish-download.js +3 -1
- package/clis/weixin/download-publish-data.js +20 -0
- package/clis/weixin/download.js +15 -2
- package/dist/src/adapter-coordination.d.ts +26 -0
- package/dist/src/adapter-coordination.js +183 -0
- package/dist/src/adapter-coordination.test.d.ts +1 -0
- package/dist/src/adapter-execution-context.d.ts +6 -0
- package/dist/src/adapter-execution-context.js +8 -0
- package/dist/src/adapter-scheduler.d.ts +86 -0
- package/dist/src/adapter-scheduler.js +349 -0
- package/dist/src/adapter-scheduler.test.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +11 -0
- package/dist/src/browser/daemon-client.js +53 -1
- package/dist/src/browser/extension-capabilities.d.ts +1 -0
- package/dist/src/browser/extension-capabilities.js +18 -5
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +3 -0
- package/dist/src/build-manifest.js +1 -0
- package/dist/src/cli-argv-preprocess.d.ts +3 -0
- package/dist/src/cli-argv-preprocess.js +4 -0
- package/dist/src/commanderAdapter.js +11 -0
- package/dist/src/daemon.js +118 -0
- package/dist/src/discovery.js +1 -0
- package/dist/src/download/article-download.d.ts +2 -0
- package/dist/src/download/article-download.js +30 -4
- package/dist/src/errors.d.ts +3 -0
- package/dist/src/errors.js +5 -0
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +168 -105
- package/dist/src/help.d.ts +1 -0
- package/dist/src/help.js +40 -0
- package/dist/src/manifest-types.d.ts +4 -0
- package/dist/src/registry.d.ts +6 -0
- package/dist/src/registry.js +23 -0
- package/dist/src/serialization.d.ts +1 -0
- package/dist/src/serialization.js +1 -0
- package/dist/src/types.d.ts +2 -0
- 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
|
+
});
|
package/clis/ima/knowledge.js
CHANGED
|
@@ -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,
|
package/clis/ima/native-api.js
CHANGED
|
@@ -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
|
-
|
|
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(...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
});
|
package/clis/weixin/download.js
CHANGED
|
@@ -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:
|
|
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>;
|