@sovovs/bycli 2.1.17 → 2.1.19

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.
@@ -0,0 +1,55 @@
1
+ {
2
+ "base_resp": { "ret": 0, "err_msg": "ok" },
3
+ "edit_resp": {
4
+ "id": "900000000000000001",
5
+ "title": "Synthetic article collection",
6
+ "desc": "Synthetic fixture; no account data.",
7
+ "type": 0,
8
+ "cover_url": "https://example.invalid/article.jpg",
9
+ "novel_cover_url": "https://example.invalid/novel.jpg",
10
+ "total": 3,
11
+ "begin": 0,
12
+ "create_time": 1700000000,
13
+ "update_time": 1700003600,
14
+ "continous_read_on": 1,
15
+ "is_updating": 1,
16
+ "is_reverse": 0,
17
+ "is_numbered": 1,
18
+ "need_pay": 1,
19
+ "fee": 6.5,
20
+ "is_ban": 0,
21
+ "can_modify_title": 1,
22
+ "send_quota": 4,
23
+ "subtype": 2,
24
+ "theme_color": "#07c160",
25
+ "update_frequence": { "month": 1 },
26
+ "continue_flag": 0,
27
+ "appmsg_infos": [
28
+ {
29
+ "appmsgid": 70001,
30
+ "itemidx": 1,
31
+ "title": "Synthetic first item",
32
+ "link": "https://example.invalid/items/1",
33
+ "cover": "https://example.invalid/items/1.jpg",
34
+ "create_time": 1700000100,
35
+ "type": 0,
36
+ "status": 2,
37
+ "fail_reason": "",
38
+ "share_page_type": 1,
39
+ "is_pay_subscribe": 1,
40
+ "pay_album_id": "pay-synthetic-1",
41
+ "wecoin_count": 10
42
+ },
43
+ {
44
+ "appmsgid": "70002",
45
+ "itemidx": 2,
46
+ "title": "Synthetic second item",
47
+ "link": "https://example.invalid/items/2",
48
+ "cover": "https://example.invalid/items/2.jpg",
49
+ "create_time": 1700000200,
50
+ "type": 8,
51
+ "status": 0
52
+ }
53
+ ]
54
+ }
55
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "base_resp": { "ret": 0, "err_msg": "ok" },
3
+ "list_resp": {
4
+ "total": 3,
5
+ "items": [
6
+ {
7
+ "id": "900000000000000001",
8
+ "title": "Synthetic article collection",
9
+ "type": 0,
10
+ "total": 12,
11
+ "uv": 3456,
12
+ "continous_read_on": 1,
13
+ "is_updating": 1,
14
+ "is_ban": 0,
15
+ "need_pay": 1,
16
+ "create_time": 1700000000,
17
+ "update_time": 1700003600,
18
+ "cover_url": "https://example.invalid/article.jpg",
19
+ "url": "https://example.invalid/collections/1"
20
+ },
21
+ {
22
+ "id": 9002,
23
+ "title": "Synthetic video collection",
24
+ "type": 5,
25
+ "total": 2,
26
+ "uv": 40,
27
+ "continous_read_on": 0,
28
+ "is_updating": 0,
29
+ "is_ban": 1,
30
+ "need_pay": 0,
31
+ "create_time": 1700010000,
32
+ "update_time": 1700017200,
33
+ "cover_url": "https://example.invalid/video.jpg",
34
+ "url": "https://example.invalid/collections/2"
35
+ }
36
+ ]
37
+ }
38
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "base_resp": {
3
+ "ret": 0
4
+ },
5
+ "publish_page": "{\"total_count\":2,\"publish_list\":[{\"publish_info\":\"{\\\"msgid\\\":9001,\\\"sent_info\\\":{\\\"time\\\":1786032000},\\\"sent_status\\\":{\\\"succ\\\":120,\\\"fail\\\":2},\\\"appmsg_info\\\":[{\\\"appmsgid\\\":1001,\\\"itemidx\\\":1,\\\"title\\\":\\\"Ontology Weekly\\\",\\\"content_url\\\":\\\"https://mp.weixin.qq.com/s/ontology-weekly\\\",\\\"read_num\\\":88,\\\"like_num\\\":7,\\\"share_num\\\":9,\\\"moment_like_num\\\":4,\\\"comment_num\\\":3,\\\"reprint_num\\\":1,\\\"line_info\\\":{\\\"line_count\\\":5}}]}\"},{\"publish_info\":\"{\\\"msgid\\\":9002,\\\"sent_info\\\":{\\\"time\\\":1785945600},\\\"sent_status\\\":{\\\"succ\\\":90,\\\"fail\\\":0},\\\"appmsg_info\\\":[{\\\"appmsgid\\\":1002,\\\"itemidx\\\":1,\\\"title\\\":\\\"Ontology Weekly Special\\\",\\\"content_url\\\":\\\"https://mp.weixin.qq.com/s/ontology-special?scene=1\\\",\\\"read_num\\\":null,\\\"like_num\\\":null,\\\"share_num\\\":null,\\\"moment_like_num\\\":null,\\\"comment_num\\\":null,\\\"reprint_num\\\":null,\\\"line_info\\\":{}}]}\"}]}"
6
+ }
@@ -0,0 +1,165 @@
1
+ import { constants } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { copyFile, link, mkdir, stat, unlink } from 'node:fs/promises';
4
+ import { basename, extname, resolve } from 'node:path';
5
+ import { CommandExecutionError, TimeoutError } from '@sovovs/bycli/errors';
6
+
7
+ const DOWNLOAD_SELECTOR = 'a.target_part[href*="download=1"]';
8
+
9
+ function commandError(message) {
10
+ return new CommandExecutionError(`WeChat publish-data download ${message}`);
11
+ }
12
+
13
+ function trustedDownloadLink(link, detailUrl) {
14
+ let candidate;
15
+ let detail;
16
+ try {
17
+ candidate = new URL(link);
18
+ detail = new URL(detailUrl);
19
+ } catch {
20
+ return false;
21
+ }
22
+
23
+ return candidate.protocol === 'https:'
24
+ && candidate.hostname === 'mp.weixin.qq.com'
25
+ && candidate.port === ''
26
+ && candidate.pathname === '/misc/appmsganalysis'
27
+ && candidate.searchParams.get('action') === 'detailpage'
28
+ && candidate.searchParams.get('msgid') === detail.searchParams.get('msgid')
29
+ && candidate.searchParams.get('publish_date') === detail.searchParams.get('publish_date')
30
+ && candidate.searchParams.get('download') === '1';
31
+ }
32
+
33
+ function safeFilename(filename, title) {
34
+ const fallback = `数据明细(${title}).xls`;
35
+ const clean = value => basename(value)
36
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
37
+ .trim();
38
+ let name = clean(filename || fallback) || clean(fallback) || 'publish-data.xls';
39
+ if (extname(name).toLowerCase() !== '.xls') name += '.xls';
40
+ return name;
41
+ }
42
+
43
+ async function publishExclusively(source, outputDir, filename) {
44
+ const extension = extname(filename);
45
+ const stem = filename.slice(0, -extension.length);
46
+ const temporary = resolve(outputDir, `.bycli-publish-data-${randomUUID()}.tmp`);
47
+ let temporaryCreated = false;
48
+
49
+ try {
50
+ try {
51
+ await copyFile(source, temporary, constants.COPYFILE_EXCL);
52
+ temporaryCreated = true;
53
+ } catch (error) {
54
+ if (error?.code !== 'EEXIST') {
55
+ try {
56
+ await unlink(temporary);
57
+ } catch {
58
+ // COPYFILE_EXCL may fail before creating its destination.
59
+ }
60
+ }
61
+ throw commandError('could not stage the downloaded file');
62
+ }
63
+
64
+ for (let index = 0; index <= 9999; index += 1) {
65
+ const candidate = resolve(outputDir, index === 0 ? filename : `${stem}-${index}${extension}`);
66
+ try {
67
+ await link(temporary, candidate);
68
+ return candidate;
69
+ } catch (error) {
70
+ if (error?.code === 'EEXIST') continue;
71
+ throw commandError('could not publish the downloaded file');
72
+ }
73
+ }
74
+
75
+ throw commandError('could not allocate a destination filename');
76
+ } finally {
77
+ if (temporaryCreated) {
78
+ try {
79
+ await unlink(temporary);
80
+ } catch {
81
+ // The final hard link, once created, remains a complete valid file.
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ export async function downloadPublishData(page, options) {
88
+ if (typeof page?.waitForDownload !== 'function') {
89
+ throw commandError('requires browser download support');
90
+ }
91
+
92
+ await page.goto(options.detailUrl);
93
+ const detail = await page.evaluate(() => ({
94
+ title: document.querySelector('#js_mp_main_content')?.textContent ?? '',
95
+ link: document.querySelector('a.target_part[href*="download=1"]')?.href ?? '',
96
+ }));
97
+
98
+ if (typeof detail?.title !== 'string' || !detail.title.includes(options.title)) {
99
+ throw commandError('opened an unexpected article detail page');
100
+ }
101
+ if (!trustedDownloadLink(detail?.link, options.detailUrl)) {
102
+ throw commandError('rejected an untrusted download link');
103
+ }
104
+ const trustedUrl = new URL(detail.link);
105
+ const msgid = trustedUrl.searchParams.get('msgid');
106
+ const publishDate = trustedUrl.searchParams.get('publish_date');
107
+ if (!msgid) {
108
+ throw commandError('rejected a download link without a message id');
109
+ }
110
+ if (!publishDate) {
111
+ throw commandError('rejected a download link without a publish date');
112
+ }
113
+
114
+ const clickedAfterMs = Date.now();
115
+ await page.click(DOWNLOAD_SELECTOR);
116
+ const downloaded = await page.waitForDownload(
117
+ `&msgid=${encodeURIComponent(msgid)}&publish_date=${encodeURIComponent(publishDate)}&`,
118
+ options.timeoutSeconds * 1000,
119
+ { includeRecent: true, startedAfterMs: clickedAfterMs },
120
+ );
121
+
122
+ if (!downloaded || downloaded.downloaded !== true) {
123
+ throw new TimeoutError('Weixin publish-data download', options.timeoutSeconds);
124
+ }
125
+ if (typeof downloaded.filename !== 'string'
126
+ || downloaded.filename.length === 0
127
+ || downloaded.state !== 'complete'
128
+ || !['safe', 'accepted'].includes(downloaded.danger)) {
129
+ throw commandError('returned incomplete or unsafe download metadata');
130
+ }
131
+ if (![downloaded.url, downloaded.finalUrl]
132
+ .some(url => trustedDownloadLink(url, options.detailUrl))) {
133
+ throw commandError('rejected an unrelated downloaded file');
134
+ }
135
+
136
+ let sourceStat;
137
+ try {
138
+ sourceStat = await stat(downloaded.filename);
139
+ } catch {
140
+ throw commandError('could not read the downloaded file');
141
+ }
142
+ if (!sourceStat.isFile() || sourceStat.size <= 0) {
143
+ throw commandError('returned an empty downloaded file');
144
+ }
145
+
146
+ const outputDir = resolve(options.outputDir);
147
+ try {
148
+ await mkdir(outputDir, { recursive: true });
149
+ } catch {
150
+ throw commandError('could not create the output directory');
151
+ }
152
+
153
+ const target = await publishExclusively(
154
+ downloaded.filename,
155
+ outputDir,
156
+ safeFilename(downloaded.filename, options.title),
157
+ );
158
+ try {
159
+ await unlink(downloaded.filename);
160
+ } catch {
161
+ // The destination was atomically published; source cleanup is best effort.
162
+ }
163
+
164
+ return { status: 'downloaded', path: target, size: sourceStat.size };
165
+ }
@@ -0,0 +1,304 @@
1
+ import {
2
+ ArgumentError,
3
+ AuthRequiredError,
4
+ CommandExecutionError,
5
+ EmptyResultError,
6
+ } from '@sovovs/bycli/errors';
7
+
8
+ const DOMAIN = 'mp.weixin.qq.com';
9
+ const TIME_ZONE = 'Asia/Shanghai';
10
+ const PUBLISH_PATH = '/cgi-bin/appmsgpublish';
11
+ const TRACKING_PARAMS = [
12
+ 'scene',
13
+ 'srcid',
14
+ 'from',
15
+ 'isappinstalled',
16
+ 'sharer_shareinfo',
17
+ 'sharer_shareinfo_first',
18
+ 'exportkey',
19
+ 'pass_ticket',
20
+ 'wx_header',
21
+ ];
22
+
23
+ function commandError(message) {
24
+ return new CommandExecutionError(`WeChat publish records ${message}`);
25
+ }
26
+
27
+ function parseJson(value, label) {
28
+ if (typeof value !== 'string') throw commandError(`returned an invalid ${label}`);
29
+ try {
30
+ const parsed = JSON.parse(value);
31
+ if (!parsed || typeof parsed !== 'object') throw commandError(`returned an invalid ${label}`);
32
+ return parsed;
33
+ } catch (error) {
34
+ if (error instanceof CommandExecutionError) throw error;
35
+ throw commandError(`returned damaged ${label} JSON`);
36
+ }
37
+ }
38
+
39
+ function dateInShanghai(seconds) {
40
+ if (!Number.isFinite(seconds)) throw commandError('returned a record without a publish date');
41
+ const date = new Date(Number(seconds) * 1000);
42
+ if (!Number.isFinite(date.getTime())) throw commandError('returned an invalid publish date');
43
+ const parts = new Intl.DateTimeFormat('en-US', {
44
+ timeZone: TIME_ZONE,
45
+ year: 'numeric',
46
+ month: '2-digit',
47
+ day: '2-digit',
48
+ }).formatToParts(date);
49
+ const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
50
+ if (!values.year || !values.month || !values.day) {
51
+ throw commandError('returned an invalid publish date');
52
+ }
53
+ return `${values.year}-${values.month}-${values.day}`;
54
+ }
55
+
56
+ function finiteNumber(value) {
57
+ return Number.isFinite(value) ? Number(value) : null;
58
+ }
59
+
60
+ function isDeleted(value) {
61
+ return value?.is_deleted === 1 || value?.is_delete === 1 || value?.deleted === true;
62
+ }
63
+
64
+ function routePart(value) {
65
+ if (Number.isFinite(value)) return String(value);
66
+ if (typeof value === 'string' && value.trim()) return value.trim();
67
+ return null;
68
+ }
69
+
70
+ function decodePublishInfo(value) {
71
+ const decoded = parseJson(value, 'publish_info');
72
+ if (!Object.prototype.hasOwnProperty.call(decoded, 'publish_info')) return decoded;
73
+ const nested = decoded.publish_info;
74
+ if (typeof nested === 'string') return parseJson(nested, 'nested publish_info');
75
+ if (nested && typeof nested === 'object') return nested;
76
+ throw commandError('returned an invalid nested publish_info');
77
+ }
78
+
79
+ function parseEntry(info, article) {
80
+ const title = typeof article?.title === 'string' ? article.title.trim() : '';
81
+ const url = typeof article?.content_url === 'string' ? article.content_url.trim() : '';
82
+ if (isDeleted(info) || isDeleted(article) || !title || !url) return null;
83
+
84
+ const msgid = routePart(article?.appmsgid) ?? routePart(article?.msgid) ?? routePart(info?.msgid);
85
+ const itemIdx = routePart(article?.itemidx);
86
+ if (msgid === null || itemIdx === null) {
87
+ throw commandError('returned an article without a detail route');
88
+ }
89
+ const publishedAt = dateInShanghai(info?.sent_info?.time);
90
+ return {
91
+ title,
92
+ publishedAt,
93
+ url,
94
+ notified: finiteNumber(info?.sent_status?.succ),
95
+ failed: finiteNumber(info?.sent_status?.fail),
96
+ reads: finiteNumber(article?.read_num),
97
+ likes: finiteNumber(article?.like_num),
98
+ shares: finiteNumber(article?.share_num),
99
+ recommends: finiteNumber(article?.moment_like_num),
100
+ comments: finiteNumber(article?.comment_num),
101
+ underlines: finiteNumber(article?.line_info?.line_count),
102
+ reprints: finiteNumber(article?.reprint_num),
103
+ msgid,
104
+ itemIdx,
105
+ publishDate: publishedAt,
106
+ };
107
+ }
108
+
109
+ /** @param {unknown} payload */
110
+ export function parsePublishResponse(payload) {
111
+ if (!payload || typeof payload !== 'object') {
112
+ throw commandError('returned an unreadable response');
113
+ }
114
+ const response = /** @type {Record<string, any>} */ (payload);
115
+ const ret = response.base_resp?.ret;
116
+ const message = String(response.base_resp?.err_msg ?? '');
117
+ const normalizedMessage = message.trim().toLowerCase();
118
+ if (ret === 200013 && normalizedMessage === 'invalid credential') {
119
+ throw new AuthRequiredError(DOMAIN, 'WeChat publish credentials have expired');
120
+ }
121
+ if (ret !== 0) {
122
+ throw commandError(`request failed (ret=${String(ret ?? 'unknown')})`);
123
+ }
124
+
125
+ const page = parseJson(response.publish_page, 'publish_page');
126
+ if (!Array.isArray(page.publish_list)) {
127
+ throw commandError('returned an invalid publish list');
128
+ }
129
+ const entries = [];
130
+ for (const rawRecord of page.publish_list) {
131
+ if (!rawRecord || typeof rawRecord !== 'object' || typeof rawRecord.publish_info !== 'string') {
132
+ throw commandError('returned an invalid publish record');
133
+ }
134
+ const info = decodePublishInfo(rawRecord.publish_info);
135
+ if (!Array.isArray(info.appmsg_info)) {
136
+ throw commandError('returned an invalid article list');
137
+ }
138
+ for (const article of info.appmsg_info) {
139
+ const entry = parseEntry(info, article);
140
+ if (entry) entries.push(entry);
141
+ }
142
+ }
143
+
144
+ return {
145
+ totalCount: Number.isSafeInteger(page.total_count) ? page.total_count : entries.length,
146
+ entries,
147
+ };
148
+ }
149
+
150
+ export function positiveSafeInteger(value, name, fallback) {
151
+ const resolved = value ?? fallback;
152
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
153
+ throw new ArgumentError(`${name} must be a positive safe integer`);
154
+ }
155
+ return resolved;
156
+ }
157
+
158
+ /**
159
+ * @param {any} page
160
+ * @param {{token?:string,limit?:number,maxPages?:number,timeout?:number}} [options]
161
+ */
162
+ export async function collectPublishedRecords(page, options = {}) {
163
+ const limit = positiveSafeInteger(options.limit, 'limit', 10);
164
+ const maxPages = positiveSafeInteger(options.maxPages, 'maxPages', 5);
165
+ const timeout = positiveSafeInteger(options.timeout, 'timeout', 30);
166
+ if (typeof page?.fetchJson !== 'function') {
167
+ throw commandError('requires authenticated JSON fetch support');
168
+ }
169
+ const seedUrl = new URL(`https://${DOMAIN}${PUBLISH_PATH}`);
170
+ seedUrl.searchParams.set('sub', 'list');
171
+ seedUrl.searchParams.set('f', 'json');
172
+ seedUrl.searchParams.set('begin', '0');
173
+ seedUrl.searchParams.set('count', '10');
174
+ seedUrl.searchParams.set('token', String(options.token ?? ''));
175
+ seedUrl.searchParams.set('lang', 'zh_CN');
176
+
177
+ const records = [];
178
+ const seen = new Set();
179
+ for (let pageIndex = 0; pageIndex < maxPages && records.length < limit; pageIndex += 1) {
180
+ const requestUrl = new URL(seedUrl.href);
181
+ requestUrl.searchParams.set('begin', String(pageIndex * 10));
182
+ requestUrl.searchParams.set('count', '10');
183
+ const payload = await page.fetchJson(requestUrl.href, { timeoutMs: timeout * 1000 });
184
+ const parsed = parsePublishResponse(payload);
185
+ for (const entry of parsed.entries) {
186
+ const key = `${entry.msgid}:${entry.itemIdx}`;
187
+ if (seen.has(key)) continue;
188
+ seen.add(key);
189
+ records.push(entry);
190
+ if (records.length >= limit) break;
191
+ }
192
+ if ((pageIndex + 1) * 10 >= parsed.totalCount) break;
193
+ }
194
+
195
+ if (records.length === 0) {
196
+ throw new EmptyResultError(
197
+ 'weixin published',
198
+ 'No published records were returned by Weixin.',
199
+ );
200
+ }
201
+ return records.slice(0, limit);
202
+ }
203
+
204
+ function normalizeTitle(value) {
205
+ return String(value ?? '').trim().replace(/\s+/g, ' ');
206
+ }
207
+
208
+ function isCalendarDate(value) {
209
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
210
+ if (!match) return false;
211
+ const [, yearText, monthText, dayText] = match;
212
+ const year = Number(yearText);
213
+ const month = Number(monthText);
214
+ const day = Number(dayText);
215
+ const date = new Date(0);
216
+ date.setUTCHours(0, 0, 0, 0);
217
+ date.setUTCFullYear(year, month - 1, day);
218
+ return date.getUTCFullYear() === year
219
+ && date.getUTCMonth() === month - 1
220
+ && date.getUTCDate() === day;
221
+ }
222
+
223
+ export function validatePublishDate(value) {
224
+ if (value === undefined) return undefined;
225
+ const date = String(value);
226
+ if (!isCalendarDate(date)) throw new ArgumentError('date must use YYYY-MM-DD');
227
+ return date;
228
+ }
229
+
230
+ function parseAbsoluteUrl(value) {
231
+ try {
232
+ return new URL(value);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ function normalizeArticleUrl(value) {
239
+ const url = parseAbsoluteUrl(value);
240
+ if (!url
241
+ || url.protocol !== 'https:'
242
+ || url.hostname !== DOMAIN
243
+ || url.port !== ''
244
+ || url.username !== ''
245
+ || url.password !== '') return null;
246
+ url.hash = '';
247
+ for (const parameter of TRACKING_PARAMS) url.searchParams.delete(parameter);
248
+ url.searchParams.sort();
249
+ return url.href;
250
+ }
251
+
252
+ function ambiguityError(matches) {
253
+ const choices = matches.slice(0, 5)
254
+ .map(record => `${record.publishedAt ?? record.publishDate} ${record.title} ${record.url}`)
255
+ .join('\n');
256
+ return new ArgumentError(
257
+ `Multiple published records matched. Use the complete URL or --date.\n${choices}`,
258
+ );
259
+ }
260
+
261
+ function uniqueMatch(matches) {
262
+ if (matches.length === 1) return matches[0];
263
+ if (matches.length > 1) throw ambiguityError(matches);
264
+ return null;
265
+ }
266
+
267
+ export function matchPublishedRecord(records, query, date) {
268
+ const text = normalizeTitle(query);
269
+ if (!text) throw new ArgumentError('query must not be empty');
270
+ const validatedDate = validatePublishDate(date);
271
+ const candidates = (Array.isArray(records) ? records : [])
272
+ .filter(record => validatedDate === undefined || record.publishedAt === validatedDate);
273
+
274
+ if (parseAbsoluteUrl(text)) {
275
+ const normalizedUrl = normalizeArticleUrl(text);
276
+ if (normalizedUrl) {
277
+ const matched = uniqueMatch(candidates.filter(record => normalizeArticleUrl(record.url) === normalizedUrl));
278
+ if (matched) return matched;
279
+ }
280
+ } else {
281
+ const exact = uniqueMatch(candidates.filter(record => normalizeTitle(record.title) === text));
282
+ if (exact) return exact;
283
+ const substring = uniqueMatch(candidates.filter(record => normalizeTitle(record.title).includes(text)));
284
+ if (substring) return substring;
285
+ }
286
+
287
+ throw new EmptyResultError(
288
+ 'weixin download-publish-data',
289
+ `No published record matched "${text}".`,
290
+ );
291
+ }
292
+
293
+ export function buildDetailUrl(record, token) {
294
+ const parameters = new URLSearchParams({
295
+ action: 'detailpage',
296
+ msgid: `${record.msgid}_${record.itemIdx}`,
297
+ publish_date: record.publishDate,
298
+ type: 'int',
299
+ pageVersion: '1',
300
+ token: String(token),
301
+ lang: 'zh_CN',
302
+ });
303
+ return `https://${DOMAIN}/misc/appmsganalysis?${parameters.toString()}`;
304
+ }
@@ -0,0 +1,75 @@
1
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
+ import { cli, Strategy } from '@sovovs/bycli/registry';
3
+ import { resolveBrowserCredentials } from './_wechat/auth-session.js';
4
+ import { fetchCollectionDetail, findCollectionById } from './_wechat/collections.js';
5
+
6
+ const SAFE_REFERER = 'https://mp.weixin.qq.com/cgi-bin/appmsgalbum?action=list';
7
+ const PAGE_SIZE = 20;
8
+
9
+ export const collectionDetailCommand = cli({
10
+ site: 'weixin',
11
+ name: 'collection-detail',
12
+ access: 'read',
13
+ description: 'Show one WeChat content collection with its settings and items',
14
+ domain: 'mp.weixin.qq.com',
15
+ strategy: Strategy.COOKIE,
16
+ browser: true,
17
+ navigateBefore: false,
18
+ args: [
19
+ { name: 'collectionId', positional: true, required: true, help: 'Collection ID returned by weixin collections' },
20
+ { name: 'max-pages', type: 'int', default: 5, help: 'Maximum number of collection pages to scan' },
21
+ ],
22
+ columns: [
23
+ 'collectionId', 'title', 'description', 'collectionType', 'coverUrl', 'itemCount',
24
+ 'createdAt', 'updatedAt', 'settingsJson', 'itemsJson',
25
+ ],
26
+ func: async (page, args) => {
27
+ const collectionId = String(args.collectionId ?? '').trim();
28
+ if (!collectionId) throw new ArgumentError('collectionId is required');
29
+ const maxPages = args['max-pages'];
30
+ if (!Number.isSafeInteger(maxPages) || maxPages <= 0) {
31
+ throw new ArgumentError('max-pages must be a positive safe integer');
32
+ }
33
+ const limit = maxPages * PAGE_SIZE;
34
+ if (!Number.isSafeInteger(limit)) {
35
+ throw new ArgumentError('max-pages is too large');
36
+ }
37
+ const { token } = await resolveBrowserCredentials(page);
38
+ const found = await findCollectionById({
39
+ page,
40
+ token,
41
+ safeReferer: SAFE_REFERER,
42
+ collectionId,
43
+ pageSize: PAGE_SIZE,
44
+ maxPages,
45
+ });
46
+ if (found === null) {
47
+ throw new EmptyResultError(
48
+ 'weixin collection-detail',
49
+ `Collection ${collectionId} was not found within the scanned collection pages.`,
50
+ );
51
+ }
52
+ const detail = await fetchCollectionDetail({
53
+ page,
54
+ token,
55
+ safeReferer: SAFE_REFERER,
56
+ collectionId,
57
+ collectionType: found.collectionTypeRaw,
58
+ limit,
59
+ pageSize: PAGE_SIZE,
60
+ maxPages,
61
+ });
62
+ return [{
63
+ collectionId: detail.collectionId,
64
+ title: detail.title,
65
+ description: detail.description,
66
+ collectionType: detail.collectionType,
67
+ coverUrl: detail.coverUrl,
68
+ itemCount: detail.itemCount,
69
+ createdAt: detail.createdAt,
70
+ updatedAt: detail.updatedAt,
71
+ settingsJson: JSON.stringify(detail.settings),
72
+ itemsJson: JSON.stringify(detail.items),
73
+ }];
74
+ },
75
+ });