@sovovs/bycli 2.1.44 → 2.1.45

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,95 @@
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": "读取公众号用户增长趋势,包括新增、取消、净增和累计关注人数",
29461
+ "access": "read",
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
+ "columns": [
29487
+ "date",
29488
+ "source",
29489
+ "source_code",
29490
+ "new_followers",
29491
+ "unfollows",
29492
+ "net_new_followers",
29493
+ "cumulative_followers"
29494
+ ],
29495
+ "type": "js",
29496
+ "modulePath": "weixin/user-growth.js",
29497
+ "sourceFile": "weixin/user-growth.js",
29498
+ "navigateBefore": false
29499
+ },
29411
29500
  {
29412
29501
  "site": "weread",
29413
29502
  "name": "ai-outline",
@@ -0,0 +1,296 @@
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
+ const rawItems = String(value).split(',').map(item => item.trim()).filter(Boolean);
83
+ argument(rawItems.length > 0, 'source must not be empty');
84
+ const seen = new Set();
85
+ return rawItems.map(raw => {
86
+ let code;
87
+ if (SOURCE_CODES.has(raw)) {
88
+ code = SOURCE_CODES.get(raw);
89
+ } else if (/^\d+$/.test(raw)) {
90
+ code = Number(raw);
91
+ argument(Number.isSafeInteger(code), `source code is too large: ${raw}`);
92
+ } else {
93
+ throw new ArgumentError(`unknown source: ${raw}`);
94
+ }
95
+ argument(!seen.has(code), `duplicate source: ${raw}`);
96
+ seen.add(code);
97
+ return { code, name: SOURCE_NAMES.get(code) ?? `source:${code}` };
98
+ });
99
+ }
100
+
101
+ export function parseAttributeDimension(value = 'all') {
102
+ const dimension = String(value).trim();
103
+ argument(dimension === 'all' || DIMENSIONS.includes(dimension),
104
+ `dimension must be one of: all, ${DIMENSIONS.join(', ')}`);
105
+ return dimension;
106
+ }
107
+
108
+ function tokenValue(token) {
109
+ argument(typeof token === 'string' && token.trim().length > 0, 'token is required');
110
+ return token;
111
+ }
112
+
113
+ export function buildGrowthUrl({ token, begin, end, sourceCodes }) {
114
+ argument(Array.isArray(sourceCodes) && sourceCodes.length > 0, 'sourceCodes must not be empty');
115
+ sourceCodes.forEach(code => argument(Number.isSafeInteger(code) && code >= 0, 'sourceCodes must contain non-negative safe integers'));
116
+ const params = new URLSearchParams({
117
+ begin_date: calendarDate(begin, 'begin'),
118
+ end_date: calendarDate(end, 'end'),
119
+ source: sourceCodes.join(','),
120
+ token: tokenValue(token),
121
+ lang: 'zh_CN',
122
+ f: 'json',
123
+ ajax: '1',
124
+ });
125
+ return `${ENDPOINT}?${params}`;
126
+ }
127
+
128
+ export function buildAttributesUrl({ token, date }) {
129
+ const validDate = calendarDate(date, 'date');
130
+ const params = new URLSearchParams({
131
+ action: 'attr',
132
+ begin_date: validDate,
133
+ end_date: validDate,
134
+ token: tokenValue(token),
135
+ lang: 'zh_CN',
136
+ });
137
+ return `${ENDPOINT}?${params}`;
138
+ }
139
+
140
+ function integer(value, message, { nonnegative = false } = {}) {
141
+ execution(Number.isSafeInteger(value) && (!nonnegative || value >= 0), message);
142
+ return value;
143
+ }
144
+
145
+ function response(payload, label) {
146
+ execution(object(payload), `WeChat ${label} returned an unreadable response`);
147
+ execution(payload.base_resp?.ret === 0,
148
+ `WeChat ${label} failed (ret=${String(payload.base_resp?.ret ?? 'unknown')})`);
149
+ return payload;
150
+ }
151
+
152
+ export function normalizeGrowthPayload(payload, requestedSources) {
153
+ const data = response(payload, 'user growth');
154
+ execution(Array.isArray(data.category_list), 'WeChat user growth returned an invalid category list');
155
+ const requestedOrder = new Map(requestedSources.map((source, index) => [source.code, index]));
156
+ const requestedNames = new Map(requestedSources.map(source => [source.code, source.name]));
157
+ const rows = [];
158
+ for (const [categoryIndex, category] of data.category_list.entries()) {
159
+ execution(object(category), `WeChat user growth returned an invalid category at index ${categoryIndex}`);
160
+ const sourceCode = integer(category.user_source, `WeChat user growth returned an invalid source at index ${categoryIndex}`, { nonnegative: true });
161
+ execution(Array.isArray(category.list), `WeChat user growth returned an invalid list for source ${sourceCode}`);
162
+ for (const [rowIndex, item] of category.list.entries()) {
163
+ const prefix = `WeChat user growth returned an invalid row at source ${sourceCode} index ${rowIndex}`;
164
+ execution(object(item), prefix);
165
+ const date = typeof item.date === 'string' ? item.date : '';
166
+ try {
167
+ calendarDate(date, 'date');
168
+ } catch {
169
+ throw new CommandExecutionError(prefix);
170
+ }
171
+ rows.push({
172
+ date,
173
+ source: requestedNames.get(sourceCode) ?? SOURCE_NAMES.get(sourceCode) ?? `source:${sourceCode}`,
174
+ sourceCode,
175
+ newFollowers: integer(item.new_user, prefix, { nonnegative: true }),
176
+ unfollows: integer(item.cancel_user, prefix, { nonnegative: true }),
177
+ netNewFollowers: integer(item.netgain_user, prefix),
178
+ cumulativeFollowers: integer(item.cumulate_user, prefix, { nonnegative: true }),
179
+ });
180
+ }
181
+ }
182
+ return rows.sort((left, right) => left.date.localeCompare(right.date)
183
+ || (requestedOrder.get(left.sourceCode) ?? Number.MAX_SAFE_INTEGER)
184
+ - (requestedOrder.get(right.sourceCode) ?? Number.MAX_SAFE_INTEGER));
185
+ }
186
+
187
+ function percentage(count, total) {
188
+ if (total === 0) return 0;
189
+ return Number(((count * 100) / total).toFixed(2));
190
+ }
191
+
192
+ function displayName(value) {
193
+ if (value === '' || value === '0' || value === null || value === undefined) return '未知';
194
+ execution(typeof value === 'string', 'WeChat user attributes returned an invalid name');
195
+ return value;
196
+ }
197
+
198
+ function flatDimension(snapshot, dimension, field) {
199
+ const items = snapshot[field] ?? [];
200
+ execution(Array.isArray(items), `WeChat user attributes returned an invalid ${dimension} list`);
201
+ const total = items.reduce((sum, item) => {
202
+ execution(object(item), `WeChat user attributes returned an invalid ${dimension} row`);
203
+ return sum + integer(item.count, `WeChat user attributes returned an invalid ${dimension} count`, { nonnegative: true });
204
+ }, 0);
205
+ return items.map(item => ({
206
+ date: snapshot.date,
207
+ dimension,
208
+ name: displayName(item.name),
209
+ code: null,
210
+ parentCode: null,
211
+ count: item.count,
212
+ percent: percentage(item.count, total),
213
+ }));
214
+ }
215
+
216
+ function flatRegions(snapshot) {
217
+ const items = snapshot.regions ?? [];
218
+ execution(Array.isArray(items), 'WeChat user attributes returned an invalid region list');
219
+ return items.map(item => {
220
+ execution(object(item) && object(item.region), 'WeChat user attributes returned an invalid region row');
221
+ const code = item.region.region_id;
222
+ const parentCode = item.region.parent_region_id;
223
+ execution(typeof code === 'string' && typeof parentCode === 'string', 'WeChat user attributes returned invalid region identifiers');
224
+ return {
225
+ date: snapshot.date,
226
+ dimension: 'region',
227
+ name: displayName(item.region.region_name),
228
+ code,
229
+ parentCode,
230
+ count: integer(item.count, 'WeChat user attributes returned an invalid region count', { nonnegative: true }),
231
+ percent: null,
232
+ };
233
+ });
234
+ }
235
+
236
+ export function normalizeAttributeSnapshot(snapshot, dimension = 'all') {
237
+ execution(object(snapshot), 'WeChat user attributes returned an unreadable snapshot');
238
+ try {
239
+ calendarDate(snapshot.date, 'date');
240
+ } catch {
241
+ throw new CommandExecutionError('WeChat user attributes returned an invalid snapshot date');
242
+ }
243
+ const selected = parseAttributeDimension(dimension);
244
+ const builders = {
245
+ gender: () => flatDimension(snapshot, 'gender', 'genders'),
246
+ age: () => flatDimension(snapshot, 'age', 'ages'),
247
+ language: () => flatDimension(snapshot, 'language', 'langs'),
248
+ region: () => flatRegions(snapshot),
249
+ platform: () => flatDimension(snapshot, 'platform', 'platforms'),
250
+ brand: () => flatDimension(snapshot, 'brand', 'devices'),
251
+ };
252
+ const dimensions = selected === 'all' ? DIMENSIONS : [selected];
253
+ return dimensions.flatMap(key => builders[key]());
254
+ }
255
+
256
+ export async function collectGrowth({ page, token, begin, end, sources }) {
257
+ const requestUrl = buildGrowthUrl({ token, begin, end, sourceCodes: sources.map(source => source.code) });
258
+ const landing = new URL(ENDPOINT);
259
+ landing.searchParams.set('token', tokenValue(token));
260
+ landing.searchParams.set('lang', 'zh_CN');
261
+ let payload;
262
+ try {
263
+ await page.goto(landing.toString());
264
+ await page.wait(1);
265
+ payload = await page.evaluate(async url => {
266
+ const result = await fetch(url, { credentials: 'include' });
267
+ if (!result.ok) throw new Error(`HTTP ${result.status}`);
268
+ return result.json();
269
+ }, requestUrl);
270
+ } catch {
271
+ throw new CommandExecutionError('Unable to read WeChat user growth from the authenticated browser session');
272
+ }
273
+ return normalizeGrowthPayload(payload, sources);
274
+ }
275
+
276
+ export async function collectAttributes({ page, token, date, dimension }) {
277
+ let snapshot;
278
+ try {
279
+ await page.goto(buildAttributesUrl({ token, date }));
280
+ await page.wait(1);
281
+ snapshot = await page.evaluate(() => window.cgiData?.list?.[0] ?? null);
282
+ } catch {
283
+ throw new CommandExecutionError('Unable to read WeChat user attributes from the authenticated browser session');
284
+ }
285
+ if (snapshot === null) {
286
+ throw new EmptyResultError(
287
+ 'weixin user-attributes',
288
+ 'No attribute snapshot is available. WeChat publishes it the day after the account reaches 100 followers.',
289
+ );
290
+ }
291
+ const rows = normalizeAttributeSnapshot(snapshot, dimension);
292
+ if (rows.length === 0) {
293
+ throw new EmptyResultError('weixin user-attributes', `No ${dimension} attribute rows are available for ${date}.`);
294
+ }
295
+ return rows;
296
+ }
@@ -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,53 @@
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
+ collectGrowth,
6
+ parseGrowthSources,
7
+ resolveGrowthRange,
8
+ } from './_wechat/user-analysis.js';
9
+
10
+ const COLUMNS = [
11
+ 'date',
12
+ 'source',
13
+ 'source_code',
14
+ 'new_followers',
15
+ 'unfollows',
16
+ 'net_new_followers',
17
+ 'cumulative_followers',
18
+ ];
19
+
20
+ export const userGrowthCommand = cli({
21
+ site: 'weixin',
22
+ name: 'user-growth',
23
+ access: 'read',
24
+ domain: 'mp.weixin.qq.com',
25
+ description: '读取公众号用户增长趋势,包括新增、取消、净增和累计关注人数',
26
+ strategy: Strategy.COOKIE,
27
+ browser: true,
28
+ navigateBefore: false,
29
+ args: [
30
+ { name: 'begin', help: '开始日期(YYYY-MM-DD);默认 30 天窗口的第一天' },
31
+ { name: 'end', help: '结束日期(YYYY-MM-DD);默认昨天' },
32
+ { name: 'source', default: 'all', help: '传播渠道名称或代码;多个值用逗号分隔' },
33
+ ],
34
+ columns: COLUMNS,
35
+ func: async (page, args) => {
36
+ const { token } = await resolveBrowserCredentials(page);
37
+ const { begin, end } = resolveGrowthRange(args);
38
+ const sources = parseGrowthSources(args.source);
39
+ const rows = await collectGrowth({ page, token, begin, end, sources });
40
+ if (rows.length === 0) {
41
+ throw new EmptyResultError('weixin user-growth', `No user growth rows are available from ${begin} through ${end}.`);
42
+ }
43
+ return rows.map(row => ({
44
+ date: row.date,
45
+ source: row.source,
46
+ source_code: row.sourceCode,
47
+ new_followers: row.newFollowers,
48
+ unfollows: row.unfollows,
49
+ net_new_followers: row.netNewFollowers,
50
+ cumulative_followers: row.cumulativeFollowers,
51
+ }));
52
+ },
53
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.44",
3
+ "version": "2.1.45",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },