@sovovs/bycli 2.1.45 → 2.1.46

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
@@ -29457,8 +29457,8 @@
29457
29457
  {
29458
29458
  "site": "weixin",
29459
29459
  "name": "user-growth",
29460
- "description": "读取公众号用户增长趋势,包括新增、取消、净增和累计关注人数",
29461
- "access": "read",
29460
+ "description": "读取公众号用户增长趋势,可选返回全部渠道并下载官方“全部来源”XLS",
29461
+ "access": "write",
29462
29462
  "domain": "mp.weixin.qq.com",
29463
29463
  "strategy": "cookie",
29464
29464
  "browser": true,
@@ -29481,6 +29481,12 @@
29481
29481
  "default": "all",
29482
29482
  "required": false,
29483
29483
  "help": "传播渠道名称或代码;多个值用逗号分隔"
29484
+ },
29485
+ {
29486
+ "name": "output",
29487
+ "type": "str",
29488
+ "required": false,
29489
+ "help": "可选的官方“全部来源”XLS 保存目录;不传则不下载"
29484
29490
  }
29485
29491
  ],
29486
29492
  "columns": [
@@ -29490,7 +29496,9 @@
29490
29496
  "new_followers",
29491
29497
  "unfollows",
29492
29498
  "net_new_followers",
29493
- "cumulative_followers"
29499
+ "cumulative_followers",
29500
+ "official_xls_path",
29501
+ "official_xls_size"
29494
29502
  ],
29495
29503
  "type": "js",
29496
29504
  "modulePath": "weixin/user-growth.js",
@@ -79,6 +79,9 @@ export function resolveAttributeDate(value, options = {}) {
79
79
  }
80
80
 
81
81
  export function parseGrowthSources(value = 'all') {
82
+ if (String(value).trim() === 'all-sources') {
83
+ return SOURCE_ENTRIES.map(([name, code]) => ({ name, code }));
84
+ }
82
85
  const rawItems = String(value).split(',').map(item => item.trim()).filter(Boolean);
83
86
  argument(rawItems.length > 0, 'source must not be empty');
84
87
  const seen = new Set();
@@ -0,0 +1,152 @@
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 { extname, resolve } from 'node:path';
5
+ import { ArgumentError, CommandExecutionError, TimeoutError } from '@sovovs/bycli/errors';
6
+ import { buildGrowthUrl } from './user-analysis.js';
7
+
8
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
9
+
10
+ function commandError(message) {
11
+ return new CommandExecutionError(`WeChat user-growth XLS download ${message}`);
12
+ }
13
+
14
+ export function buildUserGrowthDownloadUrl({ token, begin, end }) {
15
+ const url = new URL(buildGrowthUrl({
16
+ token,
17
+ begin,
18
+ end,
19
+ sourceCodes: [99999999],
20
+ }));
21
+ url.searchParams.delete('f');
22
+ url.searchParams.delete('ajax');
23
+ url.searchParams.set('download', '1');
24
+ return url.toString();
25
+ }
26
+
27
+ export function isTrustedUserGrowthDownloadUrl(candidateValue, expectedValue) {
28
+ let candidate;
29
+ let expected;
30
+ try {
31
+ candidate = new URL(candidateValue);
32
+ expected = new URL(expectedValue);
33
+ } catch {
34
+ return false;
35
+ }
36
+ const exactParams = ['download', 'begin_date', 'end_date', 'source', 'token'];
37
+ return candidate.protocol === 'https:'
38
+ && candidate.hostname === 'mp.weixin.qq.com'
39
+ && candidate.port === ''
40
+ && candidate.pathname === '/misc/useranalysis'
41
+ && exactParams.every(name => candidate.searchParams.get(name) === expected.searchParams.get(name))
42
+ && candidate.searchParams.get('download') === '1'
43
+ && candidate.searchParams.get('source') === '99999999';
44
+ }
45
+
46
+ async function publishExclusively(source, outputDir, filename, beforePublish) {
47
+ const extension = extname(filename);
48
+ const stem = filename.slice(0, -extension.length);
49
+ const staged = resolve(outputDir, `.bycli-user-growth-${randomUUID()}.tmp`);
50
+ let stagedCreated = false;
51
+ try {
52
+ try {
53
+ await copyFile(source, staged, constants.COPYFILE_EXCL);
54
+ stagedCreated = true;
55
+ } catch {
56
+ try {
57
+ await unlink(staged);
58
+ } catch {
59
+ // COPYFILE_EXCL may fail before creating its destination.
60
+ }
61
+ throw commandError('could not stage the downloaded file');
62
+ }
63
+ for (let index = 0; index <= 9999; index += 1) {
64
+ const candidate = resolve(outputDir, index === 0 ? filename : `${stem}-${index}${extension}`);
65
+ try {
66
+ await beforePublish?.();
67
+ await link(staged, 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
+ throw commandError('could not allocate a destination filename');
75
+ } finally {
76
+ if (stagedCreated) {
77
+ try {
78
+ await unlink(staged);
79
+ } catch {
80
+ // The final hard link remains a complete file.
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ export async function downloadUserGrowthXls(page, options) {
87
+ if (typeof options?.outputDir !== 'string' || !options.outputDir.trim()) {
88
+ throw new ArgumentError('output must be a non-empty directory');
89
+ }
90
+ if (typeof page?.waitForDownload !== 'function') {
91
+ throw commandError('requires browser download support');
92
+ }
93
+
94
+ const expectedUrl = buildUserGrowthDownloadUrl(options);
95
+ const startedAfterMs = Date.now();
96
+ let downloaded;
97
+ try {
98
+ await page.goto(expectedUrl, { waitUntil: 'none' });
99
+ downloaded = await page.waitForDownload('download=1', DOWNLOAD_TIMEOUT_MS, {
100
+ includeRecent: true,
101
+ startedAfterMs,
102
+ });
103
+ } catch (error) {
104
+ if (error instanceof TimeoutError) throw error;
105
+ throw commandError('could not complete the browser download');
106
+ }
107
+
108
+ if (!downloaded || downloaded.downloaded !== true) {
109
+ throw new TimeoutError('Weixin user-growth XLS download', DOWNLOAD_TIMEOUT_MS / 1000);
110
+ }
111
+ if (typeof downloaded.filename !== 'string'
112
+ || !downloaded.filename
113
+ || extname(downloaded.filename).toLowerCase() !== '.xls'
114
+ || downloaded.state !== 'complete'
115
+ || !['safe', 'accepted'].includes(downloaded.danger)) {
116
+ throw commandError('returned incomplete or unsafe download metadata');
117
+ }
118
+ if (![downloaded.url, downloaded.finalUrl]
119
+ .some(value => isTrustedUserGrowthDownloadUrl(value, expectedUrl))) {
120
+ throw commandError('rejected an unrelated downloaded file');
121
+ }
122
+
123
+ let sourceInfo;
124
+ try {
125
+ sourceInfo = await stat(downloaded.filename);
126
+ } catch {
127
+ throw commandError('could not read the downloaded file');
128
+ }
129
+ if (!sourceInfo.isFile() || sourceInfo.size <= 0) {
130
+ throw commandError('returned an empty downloaded file');
131
+ }
132
+
133
+ const outputDir = resolve(options.outputDir);
134
+ try {
135
+ await mkdir(outputDir, { recursive: true });
136
+ } catch {
137
+ throw commandError('could not create the output directory');
138
+ }
139
+ const filename = `weixin-user-growth-${options.begin}-${options.end}-all.xls`;
140
+ const target = await publishExclusively(
141
+ downloaded.filename,
142
+ outputDir,
143
+ filename,
144
+ options.beforePublish,
145
+ );
146
+ try {
147
+ await unlink(downloaded.filename);
148
+ } catch {
149
+ // Destination publication succeeded; browser temporary cleanup is best effort.
150
+ }
151
+ return { status: 'downloaded', path: target, size: sourceInfo.size };
152
+ }
@@ -1,4 +1,4 @@
1
- import { EmptyResultError } from '@sovovs/bycli/errors';
1
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
2
  import { cli, Strategy } from '@sovovs/bycli/registry';
3
3
  import { resolveBrowserCredentials } from './_wechat/auth-session.js';
4
4
  import {
@@ -6,6 +6,7 @@ import {
6
6
  parseGrowthSources,
7
7
  resolveGrowthRange,
8
8
  } from './_wechat/user-analysis.js';
9
+ import { downloadUserGrowthXls } from './_wechat/user-growth-download.js';
9
10
 
10
11
  const COLUMNS = [
11
12
  'date',
@@ -15,14 +16,16 @@ const COLUMNS = [
15
16
  'unfollows',
16
17
  'net_new_followers',
17
18
  'cumulative_followers',
19
+ 'official_xls_path',
20
+ 'official_xls_size',
18
21
  ];
19
22
 
20
23
  export const userGrowthCommand = cli({
21
24
  site: 'weixin',
22
25
  name: 'user-growth',
23
- access: 'read',
26
+ access: 'write',
24
27
  domain: 'mp.weixin.qq.com',
25
- description: '读取公众号用户增长趋势,包括新增、取消、净增和累计关注人数',
28
+ description: '读取公众号用户增长趋势,可选返回全部渠道并下载官方“全部来源”XLS',
26
29
  strategy: Strategy.COOKIE,
27
30
  browser: true,
28
31
  navigateBefore: false,
@@ -30,9 +33,14 @@ export const userGrowthCommand = cli({
30
33
  { name: 'begin', help: '开始日期(YYYY-MM-DD);默认 30 天窗口的第一天' },
31
34
  { name: 'end', help: '结束日期(YYYY-MM-DD);默认昨天' },
32
35
  { name: 'source', default: 'all', help: '传播渠道名称或代码;多个值用逗号分隔' },
36
+ { name: 'output', help: '可选的官方“全部来源”XLS 保存目录;不传则不下载' },
33
37
  ],
34
38
  columns: COLUMNS,
35
39
  func: async (page, args) => {
40
+ const outputDir = typeof args.output === 'string' ? args.output.trim() : null;
41
+ if (args.output !== undefined && (!outputDir || typeof args.output !== 'string')) {
42
+ throw new ArgumentError('output must be a non-empty directory');
43
+ }
36
44
  const { token } = await resolveBrowserCredentials(page);
37
45
  const { begin, end } = resolveGrowthRange(args);
38
46
  const sources = parseGrowthSources(args.source);
@@ -40,6 +48,9 @@ export const userGrowthCommand = cli({
40
48
  if (rows.length === 0) {
41
49
  throw new EmptyResultError('weixin user-growth', `No user growth rows are available from ${begin} through ${end}.`);
42
50
  }
51
+ const artifact = outputDir
52
+ ? await downloadUserGrowthXls(page, { token, begin, end, outputDir })
53
+ : null;
43
54
  return rows.map(row => ({
44
55
  date: row.date,
45
56
  source: row.source,
@@ -48,6 +59,8 @@ export const userGrowthCommand = cli({
48
59
  unfollows: row.unfollows,
49
60
  net_new_followers: row.netNewFollowers,
50
61
  cumulative_followers: row.cumulativeFollowers,
62
+ official_xls_path: artifact?.path ?? null,
63
+ official_xls_size: artifact?.size ?? null,
51
64
  }));
52
65
  },
53
66
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.45",
3
+ "version": "2.1.46",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },