@sovovs/bycli 2.1.19 → 2.1.21

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 CHANGED
@@ -28094,7 +28094,7 @@
28094
28094
  {
28095
28095
  "site": "weixin",
28096
28096
  "name": "download-publish-data",
28097
- "description": "Match a Weixin published article and download its detail spreadsheet",
28097
+ "description": "Match a Weixin published article and save its content analysis as Markdown",
28098
28098
  "access": "write",
28099
28099
  "domain": "mp.weixin.qq.com",
28100
28100
  "strategy": "intercept",
@@ -28118,7 +28118,7 @@
28118
28118
  "type": "str",
28119
28119
  "default": "./weixin-publish-data",
28120
28120
  "required": false,
28121
- "help": "Directory for downloaded spreadsheets"
28121
+ "help": "Directory for generated Markdown reports"
28122
28122
  },
28123
28123
  {
28124
28124
  "name": "max-pages",
@@ -28132,16 +28132,18 @@
28132
28132
  "type": "int",
28133
28133
  "default": 60,
28134
28134
  "required": false,
28135
- "help": "Maximum seconds for capture and download"
28135
+ "help": "Maximum seconds for page capture"
28136
28136
  }
28137
28137
  ],
28138
28138
  "columns": [
28139
28139
  "title",
28140
- "published_at",
28140
+ "publishedAt",
28141
28141
  "url",
28142
28142
  "status",
28143
- "path",
28144
- "size"
28143
+ "markdownPath",
28144
+ "dataPath",
28145
+ "size",
28146
+ "error"
28145
28147
  ],
28146
28148
  "type": "js",
28147
28149
  "modulePath": "weixin/download-publish-data.js",
@@ -0,0 +1,486 @@
1
+ import { link, mkdir, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { basename, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { CommandExecutionError } from '@sovovs/bycli/errors';
5
+
6
+ const DOMAIN = 'mp.weixin.qq.com';
7
+
8
+ function cell(value) {
9
+ if (value === null || value === undefined) return '';
10
+ if (typeof value === 'object') return JSON.stringify(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
11
+ return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
12
+ }
13
+
14
+ function heading(value) {
15
+ return String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
16
+ }
17
+
18
+ function table(headers, rows) {
19
+ return [`| ${headers.map(cell).join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map(row => `| ${row.map(cell).join(' | ')} |`)];
20
+ }
21
+
22
+ function appendSection(lines, name, value, level = 2) {
23
+ if (value === null || value === undefined) return;
24
+ lines.push(`${'#'.repeat(level)} ${heading(name)}`, '');
25
+ if (Array.isArray(value)) {
26
+ if (value.length === 0) { lines.push('_No data_', ''); return; }
27
+ if (value.every(item => item && typeof item === 'object' && !Array.isArray(item))) {
28
+ const headers = [...new Set(value.flatMap(item => Object.keys(item)))];
29
+ lines.push(...table(headers, value.map(item => headers.map(key => item[key]))), '');
30
+ return;
31
+ }
32
+ lines.push(...table(['Value'], value.map(item => [item])), '');
33
+ return;
34
+ }
35
+ if (value && typeof value === 'object') {
36
+ const entries = Object.entries(value);
37
+ const scalars = entries.filter(([, item]) => item === null || typeof item !== 'object');
38
+ if (scalars.length) lines.push(...table(['Metric', 'Value'], scalars), '');
39
+ for (const [key, item] of entries.filter(([, item]) => item && typeof item === 'object')) appendSection(lines, key, item, level + 1);
40
+ return;
41
+ }
42
+ lines.push(...table(['Metric', 'Value'], [[name, value]]), '');
43
+ }
44
+
45
+ export function formatAnalysisMarkdown({ title, publishedAt, data }) {
46
+ const lines = [`# ${heading(title)}`, '', `Published: ${cell(publishedAt)}`, ''];
47
+ for (const [name, value] of Object.entries(data ?? {})) appendSection(lines, name, value);
48
+ return `${lines.join('\n').trimEnd()}\n`;
49
+ }
50
+
51
+ export function extractAnalysisPayloads(entries) {
52
+ const result = [];
53
+ for (const entry of Array.isArray(entries) ? entries : []) {
54
+ try {
55
+ const url = new URL(String(entry?.url ?? ''));
56
+ if (url.protocol !== 'https:' || url.hostname !== DOMAIN || url.port !== ''
57
+ || !['/misc/appmsganalysis', '/misc/videoanalysis'].includes(url.pathname)
58
+ || Number(entry.responseStatus) !== 200) continue;
59
+ const text = entry.responsePreview;
60
+ if (typeof text !== 'string') continue;
61
+ const data = JSON.parse(text);
62
+ if (!data || typeof data !== 'object') continue;
63
+ result.push({ name: url.pathname.split('/').at(-1), data });
64
+ } catch { /* unrelated or malformed response */ }
65
+ }
66
+ return result;
67
+ }
68
+
69
+ function seriesValue(value) {
70
+ if (value && typeof value === 'object' && 'value' in value) return value.value;
71
+ return value;
72
+ }
73
+
74
+ export function normalizeEchartsOptions(charts) {
75
+ const result = {};
76
+ for (const [index, chart] of (Array.isArray(charts) ? charts : []).entries()) {
77
+ const option = chart?.option;
78
+ const series = Array.isArray(option?.series) ? option.series : [];
79
+ if (series.length === 0) continue;
80
+ const axis = Array.isArray(option?.xAxis) ? option.xAxis[0] : option?.xAxis;
81
+ const labels = Array.isArray(axis?.data) ? axis.data : [];
82
+ const rowCount = Math.max(labels.length, ...series.map(item => Array.isArray(item?.data) ? item.data.length : 0));
83
+ if (rowCount === 0) continue;
84
+ const baseName = String(chart?.title || `图表 ${index + 1}`);
85
+ const name = result[baseName] ? `${baseName} ${index + 1}` : baseName;
86
+ result[name] = Array.from({ length: rowCount }, (_, rowIndex) => Object.fromEntries([
87
+ ['数据点', labels[rowIndex] ?? String(rowIndex + 1)],
88
+ ...series.map((item, seriesIndex) => [String(item?.name || `系列 ${seriesIndex + 1}`), seriesValue(item?.data?.[rowIndex])]),
89
+ ]));
90
+ }
91
+ return result;
92
+ }
93
+
94
+ export function normalizeVisibleMetrics(leaves) {
95
+ const result = new Map();
96
+ const excludedLabels = new Set(['人', '次', '元', '条', 'Chart', '数据指标', '未知', '下一页', '搜一搜', '占比']);
97
+ for (const value of (Array.isArray(leaves) ? leaves : [])) {
98
+ const valueText = String(value?.text ?? '').trim();
99
+ if (value?.inSvg || value?.inTable || !/^(?:--|[0-9][0-9,.]*(?:%|人|次|元|分钟|条)?$)/.test(valueText)) continue;
100
+ const candidates = leaves.filter(label => {
101
+ const labelText = String(label?.text ?? '').trim();
102
+ return label !== value && !label?.inSvg && !label?.inTable && labelText.length >= 2 && labelText.length <= 24
103
+ && !excludedLabels.has(labelText)
104
+ && /[\p{L}]/u.test(labelText) && !/[0-9]/.test(labelText)
105
+ && Number(label?.y) <= Number(value?.y) && Number(value?.y) - Number(label?.y) <= 110
106
+ && Math.abs(Number(label?.x) - Number(value?.x)) <= 260;
107
+ });
108
+ candidates.sort((left, right) => {
109
+ const leftDistance = (Number(value.y) - Number(left.y)) * 2 + Math.abs(Number(value.x) - Number(left.x));
110
+ const rightDistance = (Number(value.y) - Number(right.y)) * 2 + Math.abs(Number(value.x) - Number(right.x));
111
+ return leftDistance - rightDistance;
112
+ });
113
+ if (candidates[0]) {
114
+ const label = String(candidates[0].text).trim();
115
+ const score = (Number(value.y) - Number(candidates[0].y)) * 2 + Math.abs(Number(value.x) - Number(candidates[0].x));
116
+ if (!result.has(label) || score < result.get(label).score) result.set(label, { 指标: label, 数值: valueText, score });
117
+ }
118
+ }
119
+ return [...result.values()].map(({ 指标, 数值 }) => ({ 指标, 数值 }));
120
+ }
121
+
122
+ export function normalizeHighchartsAriaCharts(charts) {
123
+ const result = {};
124
+ for (const [index, chart] of (Array.isArray(charts) ? charts : []).entries()) {
125
+ const rows = [];
126
+ for (const label of (Array.isArray(chart?.points) ? chart.points : [])) {
127
+ const match = /^(.+?), (No value|[-+]?\d+(?:\.\d+)?)\.(?: (.+?)\.)?$/.exec(String(label));
128
+ if (!match || match[2] === 'No value') continue;
129
+ rows.push({ 分类: match[1], 数值: Number(match[2]), 系列: match[3] ?? '' });
130
+ }
131
+ if (rows.length === 0) continue;
132
+ const baseName = String(chart?.title || `图表 ${index + 1}`);
133
+ const name = result[baseName] ? `${baseName} ${index + 1}` : baseName;
134
+ result[name] = rows;
135
+ }
136
+ return result;
137
+ }
138
+
139
+ export function isDatePickerCalendarTable(rows) {
140
+ const weekdays = new Set(['一', '二', '三', '四', '五', '六', '日']);
141
+ const [headers, ...dates] = Array.isArray(rows) ? rows : [];
142
+ return Array.isArray(headers) && headers.length === 7 && headers.every(value => weekdays.has(String(value).trim()))
143
+ && dates.length >= 4 && dates.every(row => Array.isArray(row) && row.length === 7
144
+ && row.every(value => /^\d{1,2}$/.test(String(value).trim())));
145
+ }
146
+
147
+ function isPlaceholderCell(value) {
148
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
149
+ return text === '' || text === 'x' || text === '展开内容' || /^>?(?:\s*x)+\s*展开内容$/.test(text);
150
+ }
151
+
152
+ export function filterPlaceholderTableRows(rows) {
153
+ if (!Array.isArray(rows) || rows.length === 0) return null;
154
+ if (rows[0].every(isPlaceholderCell)) return null;
155
+ return [rows[0], ...rows.slice(1).filter(row => !row.every(isPlaceholderCell))];
156
+ }
157
+
158
+ export function mergePaginatedTables(pages) {
159
+ const merged = new Map();
160
+ for (const tables of (Array.isArray(pages) ? pages : [])) for (const table of (Array.isArray(tables) ? tables : [])) {
161
+ if (!table?.section || !table?.name || !Array.isArray(table.data)) continue;
162
+ const key = `${table.section}\u0000${table.name}`;
163
+ const current = merged.get(key) ?? { ...table, data: [] };
164
+ const existing = new Set(current.data.map(row => JSON.stringify(row)));
165
+ for (const row of table.data) {
166
+ const signature = JSON.stringify(row);
167
+ if (!existing.has(signature)) { existing.add(signature); current.data.push(row); }
168
+ }
169
+ merged.set(key, current);
170
+ }
171
+ return [...merged.values()];
172
+ }
173
+
174
+ const RUNTIME_ANALYSIS_JS = `(() => {
175
+ const ownText = element => [...element.childNodes]
176
+ .filter(node => node.nodeType === Node.TEXT_NODE)
177
+ .map(node => node.textContent || '').join(' ').replace(/\\s+/g, ' ').trim();
178
+ const text = element => String(element?.innerText ?? element?.textContent ?? '').replace(/\\s+/g, ' ').trim();
179
+ const selectorFor = element => {
180
+ if (element.id) return '#' + CSS.escape(element.id);
181
+ const parts = [];
182
+ for (let node = element; node && node !== document.body; node = node.parentElement) {
183
+ const tag = node.tagName.toLowerCase();
184
+ const siblings = [...node.parentElement.children].filter(item => item.tagName === node.tagName);
185
+ parts.unshift(tag + ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')');
186
+ }
187
+ return 'body > ' + parts.join(' > ');
188
+ };
189
+ const leaves = [...document.querySelectorAll('body *')].filter(element => {
190
+ const box = element.getBoundingClientRect();
191
+ return element.children.length === 0 && box.width > 0 && box.height > 0 && text(element).length > 0 && text(element).length <= 80;
192
+ }).map(element => {
193
+ const box = element.getBoundingClientRect();
194
+ return {
195
+ text: text(element), x: Math.round(box.x), y: Math.round(box.y),
196
+ inSvg: Boolean(element.closest('svg')), inTable: Boolean(element.closest('table')),
197
+ };
198
+ }).slice(0, 2000);
199
+ const tables = [];
200
+ const publishedSectionFor = element => {
201
+ if (element.closest('.read_part')) return '阅读分析';
202
+ if (element.closest('.trans_part')) return '转化分析';
203
+ if (element.closest('.share_part')) return '分享分析';
204
+ if (element.closest('.user_part')) return '用户画像';
205
+ return '';
206
+ };
207
+ const weekdayNames = new Set(['一', '二', '三', '四', '五', '六', '日']);
208
+ for (const [index, table] of [...document.querySelectorAll('table')].entries()) {
209
+ const rows = [...table.querySelectorAll('tr')].map(row => [...row.querySelectorAll('th,td')].map(text)).filter(row => row.length > 0);
210
+ if (rows.length < 2 || rows[0].length === 0) continue;
211
+ const [calendarHeader, ...calendarDates] = rows;
212
+ const isDatePicker = calendarHeader.length === 7 && calendarHeader.every(value => weekdayNames.has(value))
213
+ && calendarDates.length >= 4 && calendarDates.every(row => row.length === 7 && row.every(value => /^\\d{1,2}$/.test(value)));
214
+ if (isDatePicker) continue;
215
+ const isPlaceholderCell = value => {
216
+ const valueText = String(value || '').replace(/\\s+/g, ' ').trim();
217
+ return valueText === '' || valueText === 'x' || valueText === '展开内容' || /^>?(?:\\s*x)+\\s*展开内容$/.test(valueText);
218
+ };
219
+ if (rows[0].every(isPlaceholderCell)) continue;
220
+ const keptRows = [rows[0], ...rows.slice(1).filter(row => !row.every(isPlaceholderCell))];
221
+ const headers = keptRows[0];
222
+ const data = keptRows.slice(1).map(row => Object.fromEntries(headers.map((key, column) => [key || '列 ' + (column + 1), row[column] ?? ''])));
223
+ tables.push({
224
+ name: text(table.closest('section,article,div')?.querySelector('h1,h2,h3,h4,[role="heading"]')) || '表格 ' + (index + 1),
225
+ data, section: publishedSectionFor(table), videoDetail: Boolean(table.closest('.video-data__panel')),
226
+ });
227
+ }
228
+ const charts = [];
229
+ const echarts = window.echarts;
230
+ if (echarts?.getInstanceByDom) for (const element of document.querySelectorAll('[_echarts_instance_]')) {
231
+ try {
232
+ const chart = echarts.getInstanceByDom(element); const option = chart?.getOption?.();
233
+ if (!option) continue;
234
+ const container = element.closest('section,article,div');
235
+ const title = text(container?.querySelector('h1,h2,h3,h4,[role="heading"]')) || '图表 ' + (charts.length + 1);
236
+ charts.push({ title, option: { xAxis: option.xAxis, series: option.series } });
237
+ } catch { /* a chart may be destroyed while the page is rendering */ }
238
+ }
239
+ const highcharts = window.Highcharts;
240
+ if (Array.isArray(highcharts?.charts)) for (const chart of highcharts.charts) {
241
+ if (!chart?.series?.length) continue;
242
+ charts.push({ title: chart.title?.textStr || '图表 ' + (charts.length + 1), option: {
243
+ xAxis: [{ data: chart.xAxis?.[0]?.categories ?? [] }],
244
+ series: chart.series.map(series => ({ name: series.name, data: series.yData })),
245
+ } });
246
+ }
247
+ const highchartsAriaCharts = [...document.querySelectorAll('svg.highcharts-root')].map((svg, index) => {
248
+ const panel = svg.closest('.weui-desktop-panel') || svg.parentElement?.parentElement?.parentElement;
249
+ const title = String(panel?.innerText ?? '').split(/\\r?\\n/).map(item => item.trim()).find(item => item && item !== 'Chart')
250
+ || svg.parentElement?.parentElement?.id || '图表 ' + (index + 1);
251
+ return { title, points: [...svg.querySelectorAll('.highcharts-point[aria-label]')].map(point => point.getAttribute('aria-label')), section: publishedSectionFor(svg) };
252
+ });
253
+ const reactOptions = [];
254
+ const seenOptions = new Set();
255
+ const addOption = (value, title) => {
256
+ if (!value || typeof value !== 'object' || !Array.isArray(value.series)) return;
257
+ const option = { xAxis: value.xAxis ?? (value.categories ? [{ data: value.categories }] : undefined), series: value.series };
258
+ try {
259
+ const signature = JSON.stringify(option);
260
+ if (!seenOptions.has(signature)) { seenOptions.add(signature); reactOptions.push({ title, option }); }
261
+ } catch { /* ignore circular component props */ }
262
+ };
263
+ const scanProps = (root, title) => {
264
+ const queue = [[root, 0]]; const seen = new Set();
265
+ while (queue.length) {
266
+ const [value, depth] = queue.shift();
267
+ if (!value || typeof value !== 'object' || seen.has(value) || depth > 5) continue;
268
+ seen.add(value); addOption(value, title);
269
+ for (const key of Object.keys(value).slice(0, 80)) {
270
+ if (!/^(?:option|options|data|series|xAxis|categories|config|chart|props|children)$/i.test(key)) continue;
271
+ try { queue.push([value[key], depth + 1]); } catch { /* guarded host property */ }
272
+ }
273
+ }
274
+ };
275
+ for (const element of document.querySelectorAll('[role="img"], svg, canvas')) {
276
+ const container = element.closest('section,article,div');
277
+ const title = text(container?.querySelector('h1,h2,h3,h4,[role="heading"]')) || '图表 ' + (charts.length + reactOptions.length + 1);
278
+ for (const key of Object.keys(element)) if (key.startsWith('__reactProps$')) scanProps(element[key], title);
279
+ for (const key of Object.keys(element)) if (key.startsWith('__reactFiber$')) {
280
+ let fiber = element[key];
281
+ for (let depth = 0; fiber && depth < 12; depth += 1, fiber = fiber.return) {
282
+ scanProps(fiber.memoizedProps, title); scanProps(fiber.pendingProps, title);
283
+ }
284
+ }
285
+ }
286
+ charts.push(...reactOptions);
287
+ const controls = [...document.querySelectorAll('button,a,[role="button"],div,span')]
288
+ .filter(element => ['发表后7天', '发表后30天'].includes(ownText(element)))
289
+ .map(element => ({ label: ownText(element), selector: selectorFor(element) }));
290
+ const nextPage = [...document.querySelectorAll('button,a,[role="button"],li')]
291
+ .find(element => element.closest('.user_part') && ownText(element) === '下一页');
292
+ const previousPage = [...document.querySelectorAll('button,a,[role="button"],li')]
293
+ .find(element => element.closest('.user_part') && ownText(element) === '上一页');
294
+ const firstPage = document.querySelector('.user_part .weui-desktop-pagination__num');
295
+ const pagination = nextPage ? {
296
+ selector: selectorFor(nextPage), disabled: nextPage.classList.contains('disabled') || nextPage.getAttribute('aria-disabled') === 'true',
297
+ previousSelector: previousPage ? selectorFor(previousPage) : '',
298
+ previousDisabled: !previousPage || previousPage.classList.contains('disabled') || previousPage.getAttribute('aria-disabled') === 'true',
299
+ firstSelector: firstPage ? selectorFor(firstPage) : '',
300
+ firstCurrent: Boolean(firstPage?.classList.contains('weui-desktop-pagination__num_current')),
301
+ } : null;
302
+ return { leaves, tables, charts, highchartsAriaCharts, controls, pagination, visibleText: text(document.body).slice(0, 12000) };
303
+ })()`;
304
+
305
+ function runtimeToAnalysis(runtime) {
306
+ if (!runtime || typeof runtime !== 'object') return {};
307
+ const result = {};
308
+ const metrics = normalizeVisibleMetrics(runtime.leaves);
309
+ if (metrics.length > 0) result['可见指标'] = metrics;
310
+ if (Array.isArray(runtime.tables)) for (const item of runtime.tables) {
311
+ if (item?.name && Array.isArray(item.data)) result[`表格:${item.name}`] = item.data;
312
+ }
313
+ Object.assign(result, normalizeEchartsOptions(runtime.charts));
314
+ Object.assign(result, normalizeHighchartsAriaCharts(runtime.highchartsAriaCharts));
315
+ if (Object.keys(result).length === 0 && runtime.visibleText) result['可见页面内容'] = { 内容: runtime.visibleText };
316
+ return result;
317
+ }
318
+
319
+ function runtimeScopeToAnalysis(runtime, section) {
320
+ const result = {};
321
+ for (const item of runtime?.tables ?? []) {
322
+ if (item?.section === section && item.name && Array.isArray(item.data)) result[`表格:${item.name}`] = item.data;
323
+ }
324
+ Object.assign(result, normalizeHighchartsAriaCharts((runtime?.highchartsAriaCharts ?? []).filter(item => item?.section === section)));
325
+ return result;
326
+ }
327
+
328
+ function publishedRuntimeToAnalysis(runtime) {
329
+ const sections = ['阅读分析', '转化分析', '分享分析', '用户画像'];
330
+ const result = { '图一总的数据': {} };
331
+ const metrics = normalizeVisibleMetrics(runtime?.leaves);
332
+ if (metrics.length > 0) result['图一总的数据']['可见指标'] = metrics;
333
+ for (const section of sections) result[section] = runtimeScopeToAnalysis(runtime, section);
334
+ if (result['转化分析']['分享扩散分析']) {
335
+ result['分享分析']['分享扩散分析'] = result['转化分析']['分享扩散分析'];
336
+ delete result['转化分析']['分享扩散分析'];
337
+ }
338
+ return result;
339
+ }
340
+
341
+ function multimediaRuntimeToAnalysis(runtime, kind) {
342
+ const result = { '昨日关键数据': {} };
343
+ const metrics = normalizeVisibleMetrics(runtime?.leaves);
344
+ if (metrics.length > 0) result['昨日关键数据']['可见指标'] = metrics;
345
+ const tables = runtime?.tables ?? [];
346
+ if (kind === '视频') {
347
+ const yesterday = tables.find(item => Object.keys(item?.data?.[0] ?? {}).includes('视频标题'));
348
+ if (yesterday) result['昨日有播放的视频'] = { [`表格:${yesterday.name}`]: yesterday.data };
349
+ const details = runtimeToAnalysis({ ...runtime, tables: tables.filter(item => item.videoDetail), leaves: [] });
350
+ if (details['数据明细分析']) {
351
+ details['渠道构成'] = details['数据明细分析'];
352
+ delete details['数据明细分析'];
353
+ }
354
+ if (Object.keys(details).length > 0) result['数据明细分析'] = details;
355
+ } else {
356
+ const listening = runtimeToAnalysis({ ...runtime, leaves: [] });
357
+ delete listening['可见页面内容'];
358
+ result['收听分析'] = listening;
359
+ }
360
+ return result;
361
+ }
362
+
363
+ function trustedVideoAnalysisLink(value) {
364
+ try {
365
+ const url = new URL(String(value));
366
+ return url.protocol === 'https:' && url.hostname === DOMAIN && url.port === ''
367
+ && url.pathname === '/misc/videoanalysis' && url.searchParams.get('action') === 'stat_all_video_page';
368
+ } catch { return false; }
369
+ }
370
+
371
+ function trustedAudioAnalysisLink(value) {
372
+ try {
373
+ const url = new URL(String(value));
374
+ return url.protocol === 'https:' && url.hostname === DOMAIN && url.port === ''
375
+ && url.pathname === '/misc/audioanalysis' && url.searchParams.get('action') === 'audio_list_page';
376
+ } catch { return false; }
377
+ }
378
+
379
+ async function collectPeriodAnalysis(page) {
380
+ const initialRuntime = await page.evaluate(RUNTIME_ANALYSIS_JS);
381
+ const periods = {};
382
+ const controls = new Map((initialRuntime?.controls ?? []).map(item => [item.label, item.selector]));
383
+ for (const label of ['发表后7天', '发表后30天']) {
384
+ const selector = controls.get(label);
385
+ if (selector && typeof page.click === 'function') {
386
+ await page.click(selector).catch(() => {});
387
+ await page.wait?.(350);
388
+ }
389
+ let runtime = label === '发表后7天' && !selector ? initialRuntime : await page.evaluate(RUNTIME_ANALYSIS_JS);
390
+ if (runtime.pagination?.firstSelector) {
391
+ await page.click('.user_part .weui-desktop-pagination__num__wrp .weui-desktop-pagination__num:nth-of-type(1)');
392
+ await page.wait?.(350);
393
+ runtime = await page.evaluate(RUNTIME_ANALYSIS_JS);
394
+ }
395
+ const profilePages = [runtime.tables.filter(item => item.section === '用户画像')];
396
+ const signatures = new Set(profilePages[0].map(item => JSON.stringify(item.data)));
397
+ for (let pageIndex = 0; pageIndex < 20 && runtime.pagination && !runtime.pagination.disabled; pageIndex += 1) {
398
+ await page.click(runtime.pagination.selector).catch(() => {});
399
+ await page.wait?.(350);
400
+ const nextRuntime = await page.evaluate(RUNTIME_ANALYSIS_JS);
401
+ const nextTables = nextRuntime.tables.filter(item => item.section === '用户画像');
402
+ const signature = nextTables.map(item => JSON.stringify(item.data)).join('\u0000');
403
+ if (signatures.has(signature)) break;
404
+ signatures.add(signature);
405
+ profilePages.push(nextTables);
406
+ runtime = nextRuntime;
407
+ }
408
+ runtime = {
409
+ ...runtime,
410
+ tables: [...runtime.tables.filter(item => item.section !== '用户画像'), ...mergePaginatedTables(profilePages)],
411
+ };
412
+ const analysis = publishedRuntimeToAnalysis(runtime);
413
+ if (Object.keys(analysis).length > 0) periods[label] = analysis;
414
+ }
415
+ if (Object.keys(periods).length === 0) periods['当前可见数据'] = publishedRuntimeToAnalysis(initialRuntime);
416
+ const result = { '已发表内容-已通知内容': { '图一总的数据': publishedRuntimeToAnalysis(initialRuntime)['图一总的数据'] } };
417
+ for (const section of ['阅读分析', '转化分析', '分享分析']) {
418
+ const values = Object.fromEntries(Object.entries(periods)
419
+ .map(([period, analysis]) => [period, analysis[section] ?? {}]));
420
+ if (Object.keys(values).length > 0) result['已发表内容-已通知内容'][section] = values;
421
+ }
422
+ const userProfile = Object.values(periods).map(analysis => analysis['用户画像']).find(analysis => analysis && Object.keys(analysis).length > 0);
423
+ if (userProfile) result['已发表内容-已通知内容']['用户画像'] = userProfile;
424
+ return result;
425
+ }
426
+
427
+ function safeFilename(title) {
428
+ const name = basename(String(title)).replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').trim() || 'weixin-publish-analysis';
429
+ return `${name}.md`;
430
+ }
431
+
432
+ async function publishMarkdown(outputDir, filename, content) {
433
+ await mkdir(outputDir, { recursive: true });
434
+ const temporary = resolve(outputDir, `.bycli-publish-analysis-${randomUUID()}.tmp`);
435
+ await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
436
+ try {
437
+ const extension = '.md';
438
+ const stem = filename.slice(0, -extension.length);
439
+ for (let index = 0; index <= 9999; index += 1) {
440
+ const path = resolve(outputDir, index === 0 ? filename : `${stem}-${index}${extension}`);
441
+ try { await link(temporary, path); return path; } catch (error) { if (error?.code !== 'EEXIST') throw error; }
442
+ }
443
+ throw new CommandExecutionError('WeChat publish analysis could not allocate a report filename');
444
+ } finally { await unlink(temporary).catch(() => {}); }
445
+ }
446
+
447
+ export async function collectPublishAnalysis(page, { detailUrl, title, publishedAt, outputDir }) {
448
+ if (typeof page?.goto !== 'function' || typeof page?.readNetworkCapture !== 'function') {
449
+ throw new CommandExecutionError('WeChat publish analysis requires browser network capture support');
450
+ }
451
+ const captureStarted = await page.startNetworkCapture?.('mp.weixin.qq.com');
452
+ if (captureStarted === false) throw new CommandExecutionError('WeChat publish analysis requires supported browser network capture');
453
+ await page.goto(detailUrl);
454
+ await page.wait?.(1000);
455
+ const capturedEntries = await page.readNetworkCapture();
456
+ const payloads = extractAnalysisPayloads(capturedEntries);
457
+ let data = Object.fromEntries(payloads.map(({ name, data: value }, index) => [index === 0 ? name : `${name}-${index + 1}`, value]));
458
+ if (typeof page.evaluate === 'function') {
459
+ Object.assign(data, await collectPeriodAnalysis(page));
460
+ const videoUrl = await page.evaluate(`(() => [...document.querySelectorAll('a[href]')]
461
+ .find(link => String(link.textContent || '').trim() === '视频数据')?.href || '')()`);
462
+ if (trustedVideoAnalysisLink(videoUrl)) {
463
+ await page.goto(videoUrl);
464
+ await page.wait?.(1000);
465
+ const videoRuntime = await page.evaluate(RUNTIME_ANALYSIS_JS);
466
+ const videoAnalysis = multimediaRuntimeToAnalysis(videoRuntime, '视频');
467
+ if (Object.keys(videoAnalysis).length > 0) data['多媒体'] = { 视频: videoAnalysis };
468
+ const audioUrl = await page.evaluate(`(() => [...document.querySelectorAll('a[href]')]
469
+ .find(link => String(link.textContent || '').trim() === '音频')?.href || '')()`);
470
+ if (trustedAudioAnalysisLink(audioUrl)) {
471
+ await page.goto(audioUrl);
472
+ await page.wait?.(1000);
473
+ const audioAnalysis = multimediaRuntimeToAnalysis(await page.evaluate(RUNTIME_ANALYSIS_JS), '音频');
474
+ if (Object.keys(audioAnalysis).length > 0) {
475
+ data['多媒体'] ??= {};
476
+ data['多媒体'].音频 = audioAnalysis;
477
+ }
478
+ }
479
+ }
480
+ }
481
+ if (Object.keys(data).length === 0) throw new CommandExecutionError('WeChat publish analysis returned no readable analysis data');
482
+ const content = formatAnalysisMarkdown({ title, publishedAt, data });
483
+ const path = await publishMarkdown(resolve(outputDir), safeFilename(title), content);
484
+ const info = await stat(path);
485
+ return { status: 'saved', path, size: info.size };
486
+ }
@@ -1,11 +1,9 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { copyFile, link, mkdir, stat, unlink } from 'node:fs/promises';
4
- import { basename, extname, resolve } from 'node:path';
4
+ import { extname, resolve } from 'node:path';
5
5
  import { CommandExecutionError, TimeoutError } from '@sovovs/bycli/errors';
6
6
 
7
- const DOWNLOAD_SELECTOR = 'a.target_part[href*="download=1"]';
8
-
9
7
  function commandError(message) {
10
8
  return new CommandExecutionError(`WeChat publish-data download ${message}`);
11
9
  }
@@ -30,12 +28,11 @@ function trustedDownloadLink(link, detailUrl) {
30
28
  && candidate.searchParams.get('download') === '1';
31
29
  }
32
30
 
33
- function safeFilename(filename, title) {
34
- const fallback = `数据明细(${title}).xls`;
35
- const clean = value => basename(value)
31
+ function safeFilename(title) {
32
+ const clean = String(title ?? '')
36
33
  .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
37
34
  .trim();
38
- let name = clean(filename || fallback) || clean(fallback) || 'publish-data.xls';
35
+ let name = clean || 'publish-data';
39
36
  if (extname(name).toLowerCase() !== '.xls') name += '.xls';
40
37
  return name;
41
38
  }
@@ -111,12 +108,12 @@ export async function downloadPublishData(page, options) {
111
108
  throw commandError('rejected a download link without a publish date');
112
109
  }
113
110
 
114
- const clickedAfterMs = Date.now();
115
- await page.click(DOWNLOAD_SELECTOR);
111
+ const startedAfterMs = Date.now();
112
+ await page.goto(detail.link, { waitUntil: 'none' });
116
113
  const downloaded = await page.waitForDownload(
117
114
  `&msgid=${encodeURIComponent(msgid)}&publish_date=${encodeURIComponent(publishDate)}&`,
118
115
  options.timeoutSeconds * 1000,
119
- { includeRecent: true, startedAfterMs: clickedAfterMs },
116
+ { includeRecent: true, startedAfterMs },
120
117
  );
121
118
 
122
119
  if (!downloaded || downloaded.downloaded !== true) {
@@ -153,7 +150,7 @@ export async function downloadPublishData(page, options) {
153
150
  const target = await publishExclusively(
154
151
  downloaded.filename,
155
152
  outputDir,
156
- safeFilename(downloaded.filename, options.title),
153
+ safeFilename(options.title),
157
154
  );
158
155
  try {
159
156
  await unlink(downloaded.filename);
@@ -1,6 +1,8 @@
1
1
  import { ArgumentError } from '@sovovs/bycli/errors';
2
2
  import { cli, Strategy } from '@sovovs/bycli/registry';
3
3
  import { resolveBrowserCredentials } from './_wechat/auth-session.js';
4
+ import { buildSecretSet, redactText } from './_wechat/redact.js';
5
+ import { collectPublishAnalysis } from './_wechat/publish-analysis.js';
4
6
  import { downloadPublishData } from './_wechat/publish-download.js';
5
7
  import {
6
8
  buildDetailUrl,
@@ -10,23 +12,29 @@ import {
10
12
  validatePublishDate,
11
13
  } from './_wechat/publish-records.js';
12
14
 
13
- const COLUMNS = ['title', 'published_at', 'url', 'status', 'path', 'size'];
15
+ const COLUMNS = ['title', 'publishedAt', 'url', 'status', 'markdownPath', 'dataPath', 'size', 'error'];
16
+
17
+ function sanitizedError(error, secrets, fallback) {
18
+ const message = error instanceof Error ? error.message : fallback;
19
+ return redactText(message, secrets)
20
+ .replace(/https?:\/\/mp\.weixin\.qq\.com\/\S*/giu, '[REDACTED]');
21
+ }
14
22
 
15
23
  export const downloadPublishDataCommand = cli({
16
24
  site: 'weixin',
17
25
  name: 'download-publish-data',
18
26
  access: 'write',
19
27
  domain: 'mp.weixin.qq.com',
20
- description: 'Match a Weixin published article and download its detail spreadsheet',
28
+ description: 'Match a Weixin published article and save its content analysis as Markdown',
21
29
  strategy: Strategy.INTERCEPT,
22
30
  browser: true,
23
31
  navigateBefore: false,
24
32
  args: [
25
33
  { name: 'query', positional: true, required: true, help: 'Exact article URL or title text' },
26
34
  { name: 'date', help: 'Optional publication date in YYYY-MM-DD' },
27
- { name: 'output', default: './weixin-publish-data', help: 'Directory for downloaded spreadsheets' },
35
+ { name: 'output', default: './weixin-publish-data', help: 'Directory for generated Markdown reports' },
28
36
  { name: 'max-pages', type: 'int', default: 5, help: 'Maximum published-record pages to scan' },
29
- { name: 'timeout', type: 'int', default: 60, help: 'Maximum seconds for capture and download' },
37
+ { name: 'timeout', type: 'int', default: 60, help: 'Maximum seconds for page capture' },
30
38
  ],
31
39
  columns: COLUMNS,
32
40
  func: async (page, args) => {
@@ -38,7 +46,7 @@ export const downloadPublishDataCommand = cli({
38
46
  const validatedDate = validatePublishDate(args.date);
39
47
  const scanLimit = maxPages * 10;
40
48
  if (!Number.isSafeInteger(scanLimit)) throw new ArgumentError('max-pages is too large');
41
- const { token } = await resolveBrowserCredentials(page);
49
+ const { token, cookie } = await resolveBrowserCredentials(page);
42
50
  const rows = await collectPublishedRecords(page, {
43
51
  token,
44
52
  limit: scanLimit,
@@ -47,20 +55,40 @@ export const downloadPublishDataCommand = cli({
47
55
  });
48
56
  const record = matchPublishedRecord(rows, query, validatedDate);
49
57
  const detailUrl = buildDetailUrl(record, token);
50
- const result = await downloadPublishData(page, {
58
+ const outputDir = args.output ?? './weixin-publish-data';
59
+ const commonOptions = {
51
60
  detailUrl,
52
61
  title: record.title,
53
- outputDir: args.output ?? './weixin-publish-data',
62
+ outputDir,
54
63
  timeoutSeconds,
55
- });
64
+ };
65
+ const secrets = buildSecretSet({ token, cookie });
56
66
 
57
- return [{
58
- title: record.title,
59
- published_at: record.publishedAt,
60
- url: record.url,
61
- status: result.status,
62
- path: result.path,
63
- size: result.size,
64
- }];
67
+ try {
68
+ const result = await downloadPublishData(page, commonOptions);
69
+ return [{ title: record.title, publishedAt: record.publishedAt, url: record.url,
70
+ status: 'saved', markdownPath: null, dataPath: result.path,
71
+ size: result.size, error: null }];
72
+ } catch (downloadError) {
73
+ const downloadMessage = sanitizedError(downloadError, secrets, 'Excel download failed');
74
+ try {
75
+ const result = await collectPublishAnalysis(page, {
76
+ ...commonOptions,
77
+ publishedAt: record.publishedAt,
78
+ });
79
+ return [{ title: record.title, publishedAt: record.publishedAt, url: record.url,
80
+ status: 'saved', markdownPath: result.path, dataPath: null,
81
+ size: result.size, error: downloadMessage }];
82
+ } catch (analysisError) {
83
+ const analysisMessage = sanitizedError(
84
+ analysisError,
85
+ secrets,
86
+ 'Markdown fallback failed',
87
+ );
88
+ return [{ title: record.title, publishedAt: record.publishedAt, url: record.url,
89
+ status: 'failed', markdownPath: null, dataPath: null, size: null,
90
+ error: `Excel download failed: ${downloadMessage}; Markdown fallback failed: ${analysisMessage}` }];
91
+ }
92
+ }
65
93
  },
66
94
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.19",
3
+ "version": "2.1.21",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },