@sovovs/bycli 2.1.44 → 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
|
@@ -29408,6 +29408,103 @@
|
|
|
29408
29408
|
"modulePath": "weixin/search.js",
|
|
29409
29409
|
"sourceFile": "weixin/search.js"
|
|
29410
29410
|
},
|
|
29411
|
+
{
|
|
29412
|
+
"site": "weixin",
|
|
29413
|
+
"name": "user-attributes",
|
|
29414
|
+
"description": "读取公众号用户属性,包括性别、年龄、语言、地区、平台和设备品牌",
|
|
29415
|
+
"access": "read",
|
|
29416
|
+
"domain": "mp.weixin.qq.com",
|
|
29417
|
+
"strategy": "cookie",
|
|
29418
|
+
"browser": true,
|
|
29419
|
+
"args": [
|
|
29420
|
+
{
|
|
29421
|
+
"name": "date",
|
|
29422
|
+
"type": "str",
|
|
29423
|
+
"required": false,
|
|
29424
|
+
"help": "快照日期(YYYY-MM-DD);默认昨天"
|
|
29425
|
+
},
|
|
29426
|
+
{
|
|
29427
|
+
"name": "dimension",
|
|
29428
|
+
"type": "str",
|
|
29429
|
+
"default": "all",
|
|
29430
|
+
"required": false,
|
|
29431
|
+
"help": "属性维度;默认返回全部维度",
|
|
29432
|
+
"choices": [
|
|
29433
|
+
"all",
|
|
29434
|
+
"gender",
|
|
29435
|
+
"age",
|
|
29436
|
+
"language",
|
|
29437
|
+
"region",
|
|
29438
|
+
"platform",
|
|
29439
|
+
"brand"
|
|
29440
|
+
]
|
|
29441
|
+
}
|
|
29442
|
+
],
|
|
29443
|
+
"columns": [
|
|
29444
|
+
"date",
|
|
29445
|
+
"dimension",
|
|
29446
|
+
"name",
|
|
29447
|
+
"code",
|
|
29448
|
+
"parent_code",
|
|
29449
|
+
"count",
|
|
29450
|
+
"percent"
|
|
29451
|
+
],
|
|
29452
|
+
"type": "js",
|
|
29453
|
+
"modulePath": "weixin/user-attributes.js",
|
|
29454
|
+
"sourceFile": "weixin/user-attributes.js",
|
|
29455
|
+
"navigateBefore": false
|
|
29456
|
+
},
|
|
29457
|
+
{
|
|
29458
|
+
"site": "weixin",
|
|
29459
|
+
"name": "user-growth",
|
|
29460
|
+
"description": "读取公众号用户增长趋势,可选返回全部渠道并下载官方“全部来源”XLS",
|
|
29461
|
+
"access": "write",
|
|
29462
|
+
"domain": "mp.weixin.qq.com",
|
|
29463
|
+
"strategy": "cookie",
|
|
29464
|
+
"browser": true,
|
|
29465
|
+
"args": [
|
|
29466
|
+
{
|
|
29467
|
+
"name": "begin",
|
|
29468
|
+
"type": "str",
|
|
29469
|
+
"required": false,
|
|
29470
|
+
"help": "开始日期(YYYY-MM-DD);默认 30 天窗口的第一天"
|
|
29471
|
+
},
|
|
29472
|
+
{
|
|
29473
|
+
"name": "end",
|
|
29474
|
+
"type": "str",
|
|
29475
|
+
"required": false,
|
|
29476
|
+
"help": "结束日期(YYYY-MM-DD);默认昨天"
|
|
29477
|
+
},
|
|
29478
|
+
{
|
|
29479
|
+
"name": "source",
|
|
29480
|
+
"type": "str",
|
|
29481
|
+
"default": "all",
|
|
29482
|
+
"required": false,
|
|
29483
|
+
"help": "传播渠道名称或代码;多个值用逗号分隔"
|
|
29484
|
+
},
|
|
29485
|
+
{
|
|
29486
|
+
"name": "output",
|
|
29487
|
+
"type": "str",
|
|
29488
|
+
"required": false,
|
|
29489
|
+
"help": "可选的官方“全部来源”XLS 保存目录;不传则不下载"
|
|
29490
|
+
}
|
|
29491
|
+
],
|
|
29492
|
+
"columns": [
|
|
29493
|
+
"date",
|
|
29494
|
+
"source",
|
|
29495
|
+
"source_code",
|
|
29496
|
+
"new_followers",
|
|
29497
|
+
"unfollows",
|
|
29498
|
+
"net_new_followers",
|
|
29499
|
+
"cumulative_followers",
|
|
29500
|
+
"official_xls_path",
|
|
29501
|
+
"official_xls_size"
|
|
29502
|
+
],
|
|
29503
|
+
"type": "js",
|
|
29504
|
+
"modulePath": "weixin/user-growth.js",
|
|
29505
|
+
"sourceFile": "weixin/user-growth.js",
|
|
29506
|
+
"navigateBefore": false
|
|
29507
|
+
},
|
|
29411
29508
|
{
|
|
29412
29509
|
"site": "weread",
|
|
29413
29510
|
"name": "ai-outline",
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
2
|
+
|
|
3
|
+
const DOMAIN = 'mp.weixin.qq.com';
|
|
4
|
+
const ENDPOINT = `https://${DOMAIN}/misc/useranalysis`;
|
|
5
|
+
|
|
6
|
+
const SOURCE_ENTRIES = [
|
|
7
|
+
['all', 99999999],
|
|
8
|
+
['search', 1],
|
|
9
|
+
['qr', 30],
|
|
10
|
+
['article', 57],
|
|
11
|
+
['card', 17],
|
|
12
|
+
['mini-program', 149],
|
|
13
|
+
['reprint', 161],
|
|
14
|
+
['ad', 100],
|
|
15
|
+
['channels-live', 201],
|
|
16
|
+
['channels', 200],
|
|
17
|
+
['other', 0],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const SOURCE_CODES = new Map(SOURCE_ENTRIES);
|
|
21
|
+
const SOURCE_NAMES = new Map(SOURCE_ENTRIES.map(([name, code]) => [code, name]));
|
|
22
|
+
const DIMENSIONS = ['gender', 'age', 'language', 'region', 'platform', 'brand'];
|
|
23
|
+
|
|
24
|
+
function argument(condition, message) {
|
|
25
|
+
if (!condition) throw new ArgumentError(message);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function execution(condition, message) {
|
|
29
|
+
if (!condition) throw new CommandExecutionError(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function object(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatLocalDate(date) {
|
|
37
|
+
const year = date.getFullYear();
|
|
38
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
39
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
40
|
+
return `${year}-${month}-${day}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function calendarDate(value, name) {
|
|
44
|
+
argument(typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value), `${name} must use YYYY-MM-DD`);
|
|
45
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
46
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
47
|
+
argument(
|
|
48
|
+
parsed.getUTCFullYear() === year
|
|
49
|
+
&& parsed.getUTCMonth() === month - 1
|
|
50
|
+
&& parsed.getUTCDate() === day,
|
|
51
|
+
`${name} must be a valid calendar date`,
|
|
52
|
+
);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function yesterday(now) {
|
|
57
|
+
const date = new Date(now());
|
|
58
|
+
argument(Number.isFinite(date.getTime()), 'current time must be valid');
|
|
59
|
+
date.setHours(12, 0, 0, 0);
|
|
60
|
+
date.setDate(date.getDate() - 1);
|
|
61
|
+
return date;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveGrowthRange(args = {}, options = {}) {
|
|
65
|
+
const now = options.now ?? Date.now;
|
|
66
|
+
const defaultEnd = yesterday(now);
|
|
67
|
+
const end = calendarDate(args.end ?? formatLocalDate(defaultEnd), 'end');
|
|
68
|
+
const [endYear, endMonth, endDay] = end.split('-').map(Number);
|
|
69
|
+
const defaultBegin = new Date(endYear, endMonth - 1, endDay, 12);
|
|
70
|
+
defaultBegin.setDate(defaultBegin.getDate() - 29);
|
|
71
|
+
const begin = calendarDate(args.begin ?? formatLocalDate(defaultBegin), 'begin');
|
|
72
|
+
argument(begin <= end, 'begin must not be after end');
|
|
73
|
+
return { begin, end };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveAttributeDate(value, options = {}) {
|
|
77
|
+
const now = options.now ?? Date.now;
|
|
78
|
+
return calendarDate(value ?? formatLocalDate(yesterday(now)), 'date');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseGrowthSources(value = 'all') {
|
|
82
|
+
if (String(value).trim() === 'all-sources') {
|
|
83
|
+
return SOURCE_ENTRIES.map(([name, code]) => ({ name, code }));
|
|
84
|
+
}
|
|
85
|
+
const rawItems = String(value).split(',').map(item => item.trim()).filter(Boolean);
|
|
86
|
+
argument(rawItems.length > 0, 'source must not be empty');
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
return rawItems.map(raw => {
|
|
89
|
+
let code;
|
|
90
|
+
if (SOURCE_CODES.has(raw)) {
|
|
91
|
+
code = SOURCE_CODES.get(raw);
|
|
92
|
+
} else if (/^\d+$/.test(raw)) {
|
|
93
|
+
code = Number(raw);
|
|
94
|
+
argument(Number.isSafeInteger(code), `source code is too large: ${raw}`);
|
|
95
|
+
} else {
|
|
96
|
+
throw new ArgumentError(`unknown source: ${raw}`);
|
|
97
|
+
}
|
|
98
|
+
argument(!seen.has(code), `duplicate source: ${raw}`);
|
|
99
|
+
seen.add(code);
|
|
100
|
+
return { code, name: SOURCE_NAMES.get(code) ?? `source:${code}` };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseAttributeDimension(value = 'all') {
|
|
105
|
+
const dimension = String(value).trim();
|
|
106
|
+
argument(dimension === 'all' || DIMENSIONS.includes(dimension),
|
|
107
|
+
`dimension must be one of: all, ${DIMENSIONS.join(', ')}`);
|
|
108
|
+
return dimension;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function tokenValue(token) {
|
|
112
|
+
argument(typeof token === 'string' && token.trim().length > 0, 'token is required');
|
|
113
|
+
return token;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildGrowthUrl({ token, begin, end, sourceCodes }) {
|
|
117
|
+
argument(Array.isArray(sourceCodes) && sourceCodes.length > 0, 'sourceCodes must not be empty');
|
|
118
|
+
sourceCodes.forEach(code => argument(Number.isSafeInteger(code) && code >= 0, 'sourceCodes must contain non-negative safe integers'));
|
|
119
|
+
const params = new URLSearchParams({
|
|
120
|
+
begin_date: calendarDate(begin, 'begin'),
|
|
121
|
+
end_date: calendarDate(end, 'end'),
|
|
122
|
+
source: sourceCodes.join(','),
|
|
123
|
+
token: tokenValue(token),
|
|
124
|
+
lang: 'zh_CN',
|
|
125
|
+
f: 'json',
|
|
126
|
+
ajax: '1',
|
|
127
|
+
});
|
|
128
|
+
return `${ENDPOINT}?${params}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildAttributesUrl({ token, date }) {
|
|
132
|
+
const validDate = calendarDate(date, 'date');
|
|
133
|
+
const params = new URLSearchParams({
|
|
134
|
+
action: 'attr',
|
|
135
|
+
begin_date: validDate,
|
|
136
|
+
end_date: validDate,
|
|
137
|
+
token: tokenValue(token),
|
|
138
|
+
lang: 'zh_CN',
|
|
139
|
+
});
|
|
140
|
+
return `${ENDPOINT}?${params}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function integer(value, message, { nonnegative = false } = {}) {
|
|
144
|
+
execution(Number.isSafeInteger(value) && (!nonnegative || value >= 0), message);
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function response(payload, label) {
|
|
149
|
+
execution(object(payload), `WeChat ${label} returned an unreadable response`);
|
|
150
|
+
execution(payload.base_resp?.ret === 0,
|
|
151
|
+
`WeChat ${label} failed (ret=${String(payload.base_resp?.ret ?? 'unknown')})`);
|
|
152
|
+
return payload;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function normalizeGrowthPayload(payload, requestedSources) {
|
|
156
|
+
const data = response(payload, 'user growth');
|
|
157
|
+
execution(Array.isArray(data.category_list), 'WeChat user growth returned an invalid category list');
|
|
158
|
+
const requestedOrder = new Map(requestedSources.map((source, index) => [source.code, index]));
|
|
159
|
+
const requestedNames = new Map(requestedSources.map(source => [source.code, source.name]));
|
|
160
|
+
const rows = [];
|
|
161
|
+
for (const [categoryIndex, category] of data.category_list.entries()) {
|
|
162
|
+
execution(object(category), `WeChat user growth returned an invalid category at index ${categoryIndex}`);
|
|
163
|
+
const sourceCode = integer(category.user_source, `WeChat user growth returned an invalid source at index ${categoryIndex}`, { nonnegative: true });
|
|
164
|
+
execution(Array.isArray(category.list), `WeChat user growth returned an invalid list for source ${sourceCode}`);
|
|
165
|
+
for (const [rowIndex, item] of category.list.entries()) {
|
|
166
|
+
const prefix = `WeChat user growth returned an invalid row at source ${sourceCode} index ${rowIndex}`;
|
|
167
|
+
execution(object(item), prefix);
|
|
168
|
+
const date = typeof item.date === 'string' ? item.date : '';
|
|
169
|
+
try {
|
|
170
|
+
calendarDate(date, 'date');
|
|
171
|
+
} catch {
|
|
172
|
+
throw new CommandExecutionError(prefix);
|
|
173
|
+
}
|
|
174
|
+
rows.push({
|
|
175
|
+
date,
|
|
176
|
+
source: requestedNames.get(sourceCode) ?? SOURCE_NAMES.get(sourceCode) ?? `source:${sourceCode}`,
|
|
177
|
+
sourceCode,
|
|
178
|
+
newFollowers: integer(item.new_user, prefix, { nonnegative: true }),
|
|
179
|
+
unfollows: integer(item.cancel_user, prefix, { nonnegative: true }),
|
|
180
|
+
netNewFollowers: integer(item.netgain_user, prefix),
|
|
181
|
+
cumulativeFollowers: integer(item.cumulate_user, prefix, { nonnegative: true }),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return rows.sort((left, right) => left.date.localeCompare(right.date)
|
|
186
|
+
|| (requestedOrder.get(left.sourceCode) ?? Number.MAX_SAFE_INTEGER)
|
|
187
|
+
- (requestedOrder.get(right.sourceCode) ?? Number.MAX_SAFE_INTEGER));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function percentage(count, total) {
|
|
191
|
+
if (total === 0) return 0;
|
|
192
|
+
return Number(((count * 100) / total).toFixed(2));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function displayName(value) {
|
|
196
|
+
if (value === '' || value === '0' || value === null || value === undefined) return '未知';
|
|
197
|
+
execution(typeof value === 'string', 'WeChat user attributes returned an invalid name');
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function flatDimension(snapshot, dimension, field) {
|
|
202
|
+
const items = snapshot[field] ?? [];
|
|
203
|
+
execution(Array.isArray(items), `WeChat user attributes returned an invalid ${dimension} list`);
|
|
204
|
+
const total = items.reduce((sum, item) => {
|
|
205
|
+
execution(object(item), `WeChat user attributes returned an invalid ${dimension} row`);
|
|
206
|
+
return sum + integer(item.count, `WeChat user attributes returned an invalid ${dimension} count`, { nonnegative: true });
|
|
207
|
+
}, 0);
|
|
208
|
+
return items.map(item => ({
|
|
209
|
+
date: snapshot.date,
|
|
210
|
+
dimension,
|
|
211
|
+
name: displayName(item.name),
|
|
212
|
+
code: null,
|
|
213
|
+
parentCode: null,
|
|
214
|
+
count: item.count,
|
|
215
|
+
percent: percentage(item.count, total),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function flatRegions(snapshot) {
|
|
220
|
+
const items = snapshot.regions ?? [];
|
|
221
|
+
execution(Array.isArray(items), 'WeChat user attributes returned an invalid region list');
|
|
222
|
+
return items.map(item => {
|
|
223
|
+
execution(object(item) && object(item.region), 'WeChat user attributes returned an invalid region row');
|
|
224
|
+
const code = item.region.region_id;
|
|
225
|
+
const parentCode = item.region.parent_region_id;
|
|
226
|
+
execution(typeof code === 'string' && typeof parentCode === 'string', 'WeChat user attributes returned invalid region identifiers');
|
|
227
|
+
return {
|
|
228
|
+
date: snapshot.date,
|
|
229
|
+
dimension: 'region',
|
|
230
|
+
name: displayName(item.region.region_name),
|
|
231
|
+
code,
|
|
232
|
+
parentCode,
|
|
233
|
+
count: integer(item.count, 'WeChat user attributes returned an invalid region count', { nonnegative: true }),
|
|
234
|
+
percent: null,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function normalizeAttributeSnapshot(snapshot, dimension = 'all') {
|
|
240
|
+
execution(object(snapshot), 'WeChat user attributes returned an unreadable snapshot');
|
|
241
|
+
try {
|
|
242
|
+
calendarDate(snapshot.date, 'date');
|
|
243
|
+
} catch {
|
|
244
|
+
throw new CommandExecutionError('WeChat user attributes returned an invalid snapshot date');
|
|
245
|
+
}
|
|
246
|
+
const selected = parseAttributeDimension(dimension);
|
|
247
|
+
const builders = {
|
|
248
|
+
gender: () => flatDimension(snapshot, 'gender', 'genders'),
|
|
249
|
+
age: () => flatDimension(snapshot, 'age', 'ages'),
|
|
250
|
+
language: () => flatDimension(snapshot, 'language', 'langs'),
|
|
251
|
+
region: () => flatRegions(snapshot),
|
|
252
|
+
platform: () => flatDimension(snapshot, 'platform', 'platforms'),
|
|
253
|
+
brand: () => flatDimension(snapshot, 'brand', 'devices'),
|
|
254
|
+
};
|
|
255
|
+
const dimensions = selected === 'all' ? DIMENSIONS : [selected];
|
|
256
|
+
return dimensions.flatMap(key => builders[key]());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function collectGrowth({ page, token, begin, end, sources }) {
|
|
260
|
+
const requestUrl = buildGrowthUrl({ token, begin, end, sourceCodes: sources.map(source => source.code) });
|
|
261
|
+
const landing = new URL(ENDPOINT);
|
|
262
|
+
landing.searchParams.set('token', tokenValue(token));
|
|
263
|
+
landing.searchParams.set('lang', 'zh_CN');
|
|
264
|
+
let payload;
|
|
265
|
+
try {
|
|
266
|
+
await page.goto(landing.toString());
|
|
267
|
+
await page.wait(1);
|
|
268
|
+
payload = await page.evaluate(async url => {
|
|
269
|
+
const result = await fetch(url, { credentials: 'include' });
|
|
270
|
+
if (!result.ok) throw new Error(`HTTP ${result.status}`);
|
|
271
|
+
return result.json();
|
|
272
|
+
}, requestUrl);
|
|
273
|
+
} catch {
|
|
274
|
+
throw new CommandExecutionError('Unable to read WeChat user growth from the authenticated browser session');
|
|
275
|
+
}
|
|
276
|
+
return normalizeGrowthPayload(payload, sources);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function collectAttributes({ page, token, date, dimension }) {
|
|
280
|
+
let snapshot;
|
|
281
|
+
try {
|
|
282
|
+
await page.goto(buildAttributesUrl({ token, date }));
|
|
283
|
+
await page.wait(1);
|
|
284
|
+
snapshot = await page.evaluate(() => window.cgiData?.list?.[0] ?? null);
|
|
285
|
+
} catch {
|
|
286
|
+
throw new CommandExecutionError('Unable to read WeChat user attributes from the authenticated browser session');
|
|
287
|
+
}
|
|
288
|
+
if (snapshot === null) {
|
|
289
|
+
throw new EmptyResultError(
|
|
290
|
+
'weixin user-attributes',
|
|
291
|
+
'No attribute snapshot is available. WeChat publishes it the day after the account reaches 100 followers.',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const rows = normalizeAttributeSnapshot(snapshot, dimension);
|
|
295
|
+
if (rows.length === 0) {
|
|
296
|
+
throw new EmptyResultError('weixin user-attributes', `No ${dimension} attribute rows are available for ${date}.`);
|
|
297
|
+
}
|
|
298
|
+
return rows;
|
|
299
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EmptyResultError } from '@sovovs/bycli/errors';
|
|
2
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
|
+
import { resolveBrowserCredentials } from './_wechat/auth-session.js';
|
|
4
|
+
import {
|
|
5
|
+
collectAttributes,
|
|
6
|
+
parseAttributeDimension,
|
|
7
|
+
resolveAttributeDate,
|
|
8
|
+
} from './_wechat/user-analysis.js';
|
|
9
|
+
|
|
10
|
+
const COLUMNS = ['date', 'dimension', 'name', 'code', 'parent_code', 'count', 'percent'];
|
|
11
|
+
|
|
12
|
+
export const userAttributesCommand = cli({
|
|
13
|
+
site: 'weixin',
|
|
14
|
+
name: 'user-attributes',
|
|
15
|
+
access: 'read',
|
|
16
|
+
domain: 'mp.weixin.qq.com',
|
|
17
|
+
description: '读取公众号用户属性,包括性别、年龄、语言、地区、平台和设备品牌',
|
|
18
|
+
strategy: Strategy.COOKIE,
|
|
19
|
+
browser: true,
|
|
20
|
+
navigateBefore: false,
|
|
21
|
+
args: [
|
|
22
|
+
{ name: 'date', help: '快照日期(YYYY-MM-DD);默认昨天' },
|
|
23
|
+
{
|
|
24
|
+
name: 'dimension',
|
|
25
|
+
default: 'all',
|
|
26
|
+
choices: ['all', 'gender', 'age', 'language', 'region', 'platform', 'brand'],
|
|
27
|
+
help: '属性维度;默认返回全部维度',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
columns: COLUMNS,
|
|
31
|
+
func: async (page, args) => {
|
|
32
|
+
const { token } = await resolveBrowserCredentials(page);
|
|
33
|
+
const date = resolveAttributeDate(args.date);
|
|
34
|
+
const dimension = parseAttributeDimension(args.dimension);
|
|
35
|
+
const rows = await collectAttributes({ page, token, date, dimension });
|
|
36
|
+
if (rows.length === 0) {
|
|
37
|
+
throw new EmptyResultError('weixin user-attributes', `No ${dimension} attribute rows are available for ${date}.`);
|
|
38
|
+
}
|
|
39
|
+
return rows.map(row => ({
|
|
40
|
+
date: row.date,
|
|
41
|
+
dimension: row.dimension,
|
|
42
|
+
name: row.name,
|
|
43
|
+
code: row.code,
|
|
44
|
+
parent_code: row.parentCode,
|
|
45
|
+
count: row.count,
|
|
46
|
+
percent: row.percent,
|
|
47
|
+
}));
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
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 {
|
|
5
|
+
collectGrowth,
|
|
6
|
+
parseGrowthSources,
|
|
7
|
+
resolveGrowthRange,
|
|
8
|
+
} from './_wechat/user-analysis.js';
|
|
9
|
+
import { downloadUserGrowthXls } from './_wechat/user-growth-download.js';
|
|
10
|
+
|
|
11
|
+
const COLUMNS = [
|
|
12
|
+
'date',
|
|
13
|
+
'source',
|
|
14
|
+
'source_code',
|
|
15
|
+
'new_followers',
|
|
16
|
+
'unfollows',
|
|
17
|
+
'net_new_followers',
|
|
18
|
+
'cumulative_followers',
|
|
19
|
+
'official_xls_path',
|
|
20
|
+
'official_xls_size',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const userGrowthCommand = cli({
|
|
24
|
+
site: 'weixin',
|
|
25
|
+
name: 'user-growth',
|
|
26
|
+
access: 'write',
|
|
27
|
+
domain: 'mp.weixin.qq.com',
|
|
28
|
+
description: '读取公众号用户增长趋势,可选返回全部渠道并下载官方“全部来源”XLS',
|
|
29
|
+
strategy: Strategy.COOKIE,
|
|
30
|
+
browser: true,
|
|
31
|
+
navigateBefore: false,
|
|
32
|
+
args: [
|
|
33
|
+
{ name: 'begin', help: '开始日期(YYYY-MM-DD);默认 30 天窗口的第一天' },
|
|
34
|
+
{ name: 'end', help: '结束日期(YYYY-MM-DD);默认昨天' },
|
|
35
|
+
{ name: 'source', default: 'all', help: '传播渠道名称或代码;多个值用逗号分隔' },
|
|
36
|
+
{ name: 'output', help: '可选的官方“全部来源”XLS 保存目录;不传则不下载' },
|
|
37
|
+
],
|
|
38
|
+
columns: COLUMNS,
|
|
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
|
+
}
|
|
44
|
+
const { token } = await resolveBrowserCredentials(page);
|
|
45
|
+
const { begin, end } = resolveGrowthRange(args);
|
|
46
|
+
const sources = parseGrowthSources(args.source);
|
|
47
|
+
const rows = await collectGrowth({ page, token, begin, end, sources });
|
|
48
|
+
if (rows.length === 0) {
|
|
49
|
+
throw new EmptyResultError('weixin user-growth', `No user growth rows are available from ${begin} through ${end}.`);
|
|
50
|
+
}
|
|
51
|
+
const artifact = outputDir
|
|
52
|
+
? await downloadUserGrowthXls(page, { token, begin, end, outputDir })
|
|
53
|
+
: null;
|
|
54
|
+
return rows.map(row => ({
|
|
55
|
+
date: row.date,
|
|
56
|
+
source: row.source,
|
|
57
|
+
source_code: row.sourceCode,
|
|
58
|
+
new_followers: row.newFollowers,
|
|
59
|
+
unfollows: row.unfollows,
|
|
60
|
+
net_new_followers: row.netNewFollowers,
|
|
61
|
+
cumulative_followers: row.cumulativeFollowers,
|
|
62
|
+
official_xls_path: artifact?.path ?? null,
|
|
63
|
+
official_xls_size: artifact?.size ?? null,
|
|
64
|
+
}));
|
|
65
|
+
},
|
|
66
|
+
});
|