@sovovs/bycli 2.1.63 → 2.1.66
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 +247 -0
- package/clis/weixin-channels/_shared/client.js +155 -0
- package/clis/weixin-channels/_shared/model.js +266 -0
- package/clis/weixin-channels/overview.js +25 -0
- package/clis/weixin-channels/post-detail.js +31 -0
- package/clis/weixin-channels/posts.js +34 -0
- package/clis/weixin-channels/sources.js +25 -0
- package/clis/weixin-channels/trend.js +25 -0
- package/dist/src/browser/stealth.js +5 -16
- package/dist/src/observation/redaction.js +5 -5
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -30243,6 +30243,253 @@
|
|
|
30243
30243
|
"sourceFile": "weixin/user-info.js",
|
|
30244
30244
|
"navigateBefore": false
|
|
30245
30245
|
},
|
|
30246
|
+
{
|
|
30247
|
+
"site": "weixin-channels",
|
|
30248
|
+
"name": "overview",
|
|
30249
|
+
"description": "读取视频号账号概览:所选日期内播放、推荐、喜欢、评论、分享和新增关注,默认昨日",
|
|
30250
|
+
"access": "read",
|
|
30251
|
+
"domain": "channels.weixin.qq.com",
|
|
30252
|
+
"strategy": "cookie",
|
|
30253
|
+
"browser": true,
|
|
30254
|
+
"args": [
|
|
30255
|
+
{
|
|
30256
|
+
"name": "days",
|
|
30257
|
+
"type": "int",
|
|
30258
|
+
"required": false,
|
|
30259
|
+
"valueRequired": true,
|
|
30260
|
+
"help": "截至上海昨日的天数(默认 1);不可与自定义日期同时使用"
|
|
30261
|
+
},
|
|
30262
|
+
{
|
|
30263
|
+
"name": "start-date",
|
|
30264
|
+
"type": "str",
|
|
30265
|
+
"required": false,
|
|
30266
|
+
"valueRequired": true,
|
|
30267
|
+
"help": "开始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
|
|
30268
|
+
},
|
|
30269
|
+
{
|
|
30270
|
+
"name": "end-date",
|
|
30271
|
+
"type": "str",
|
|
30272
|
+
"required": false,
|
|
30273
|
+
"valueRequired": true,
|
|
30274
|
+
"help": "结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
|
|
30275
|
+
}
|
|
30276
|
+
],
|
|
30277
|
+
"columns": [
|
|
30278
|
+
"start_date",
|
|
30279
|
+
"end_date",
|
|
30280
|
+
"views",
|
|
30281
|
+
"recommendations",
|
|
30282
|
+
"likes",
|
|
30283
|
+
"comments",
|
|
30284
|
+
"shares",
|
|
30285
|
+
"follows"
|
|
30286
|
+
],
|
|
30287
|
+
"type": "js",
|
|
30288
|
+
"modulePath": "weixin-channels/overview.js",
|
|
30289
|
+
"sourceFile": "weixin-channels/overview.js",
|
|
30290
|
+
"navigateBefore": false
|
|
30291
|
+
},
|
|
30292
|
+
{
|
|
30293
|
+
"site": "weixin-channels",
|
|
30294
|
+
"name": "post-detail",
|
|
30295
|
+
"description": "读取单条视频号作品的概览、趋势、来源占比、逐日来源明细和留存,默认最近 7 天;建议使用 --format json",
|
|
30296
|
+
"access": "read",
|
|
30297
|
+
"domain": "channels.weixin.qq.com",
|
|
30298
|
+
"strategy": "cookie",
|
|
30299
|
+
"browser": true,
|
|
30300
|
+
"args": [
|
|
30301
|
+
{
|
|
30302
|
+
"name": "id",
|
|
30303
|
+
"type": "str",
|
|
30304
|
+
"required": true,
|
|
30305
|
+
"positional": true,
|
|
30306
|
+
"help": "posts 返回的作品 ID(objectId),按数字字符串原样传入"
|
|
30307
|
+
},
|
|
30308
|
+
{
|
|
30309
|
+
"name": "days",
|
|
30310
|
+
"type": "int",
|
|
30311
|
+
"required": false,
|
|
30312
|
+
"valueRequired": true,
|
|
30313
|
+
"help": "截至上海昨日的统计天数(默认 7);不可与自定义日期同时使用"
|
|
30314
|
+
},
|
|
30315
|
+
{
|
|
30316
|
+
"name": "start-date",
|
|
30317
|
+
"type": "str",
|
|
30318
|
+
"required": false,
|
|
30319
|
+
"valueRequired": true,
|
|
30320
|
+
"help": "统计开始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
|
|
30321
|
+
},
|
|
30322
|
+
{
|
|
30323
|
+
"name": "end-date",
|
|
30324
|
+
"type": "str",
|
|
30325
|
+
"required": false,
|
|
30326
|
+
"valueRequired": true,
|
|
30327
|
+
"help": "统计结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
|
|
30328
|
+
}
|
|
30329
|
+
],
|
|
30330
|
+
"columns": [
|
|
30331
|
+
"id",
|
|
30332
|
+
"start_date",
|
|
30333
|
+
"end_date",
|
|
30334
|
+
"overview",
|
|
30335
|
+
"trend",
|
|
30336
|
+
"sources",
|
|
30337
|
+
"data_details",
|
|
30338
|
+
"retention"
|
|
30339
|
+
],
|
|
30340
|
+
"type": "js",
|
|
30341
|
+
"modulePath": "weixin-channels/post-detail.js",
|
|
30342
|
+
"sourceFile": "weixin-channels/post-detail.js",
|
|
30343
|
+
"navigateBefore": false
|
|
30344
|
+
},
|
|
30345
|
+
{
|
|
30346
|
+
"site": "weixin-channels",
|
|
30347
|
+
"name": "posts",
|
|
30348
|
+
"description": "列出所选发布时间内的视频号作品及后台累计指标,默认最近 30 天,最多 20 条",
|
|
30349
|
+
"access": "read",
|
|
30350
|
+
"domain": "channels.weixin.qq.com",
|
|
30351
|
+
"strategy": "cookie",
|
|
30352
|
+
"browser": true,
|
|
30353
|
+
"args": [
|
|
30354
|
+
{
|
|
30355
|
+
"name": "days",
|
|
30356
|
+
"type": "int",
|
|
30357
|
+
"required": false,
|
|
30358
|
+
"valueRequired": true,
|
|
30359
|
+
"help": "截至上海昨日的发布时间天数(默认 30);不可与自定义日期同时使用"
|
|
30360
|
+
},
|
|
30361
|
+
{
|
|
30362
|
+
"name": "start-date",
|
|
30363
|
+
"type": "str",
|
|
30364
|
+
"required": false,
|
|
30365
|
+
"valueRequired": true,
|
|
30366
|
+
"help": "作品发布时间起始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
|
|
30367
|
+
},
|
|
30368
|
+
{
|
|
30369
|
+
"name": "end-date",
|
|
30370
|
+
"type": "str",
|
|
30371
|
+
"required": false,
|
|
30372
|
+
"valueRequired": true,
|
|
30373
|
+
"help": "作品发布时间结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
|
|
30374
|
+
},
|
|
30375
|
+
{
|
|
30376
|
+
"name": "limit",
|
|
30377
|
+
"type": "int",
|
|
30378
|
+
"default": 20,
|
|
30379
|
+
"required": false,
|
|
30380
|
+
"valueRequired": true,
|
|
30381
|
+
"help": "最多返回的作品数(1–1000 的整数,默认 20)"
|
|
30382
|
+
}
|
|
30383
|
+
],
|
|
30384
|
+
"columns": [
|
|
30385
|
+
"id",
|
|
30386
|
+
"title",
|
|
30387
|
+
"published_at",
|
|
30388
|
+
"views",
|
|
30389
|
+
"recommendations",
|
|
30390
|
+
"likes",
|
|
30391
|
+
"comments",
|
|
30392
|
+
"shares",
|
|
30393
|
+
"follows",
|
|
30394
|
+
"completion_rate",
|
|
30395
|
+
"avg_play_seconds"
|
|
30396
|
+
],
|
|
30397
|
+
"type": "js",
|
|
30398
|
+
"modulePath": "weixin-channels/posts.js",
|
|
30399
|
+
"sourceFile": "weixin-channels/posts.js",
|
|
30400
|
+
"navigateBefore": false
|
|
30401
|
+
},
|
|
30402
|
+
{
|
|
30403
|
+
"site": "weixin-channels",
|
|
30404
|
+
"name": "sources",
|
|
30405
|
+
"description": "按视频号后台来源汇总账号指标及播放占比,默认最近 7 天,截至昨日",
|
|
30406
|
+
"access": "read",
|
|
30407
|
+
"domain": "channels.weixin.qq.com",
|
|
30408
|
+
"strategy": "cookie",
|
|
30409
|
+
"browser": true,
|
|
30410
|
+
"args": [
|
|
30411
|
+
{
|
|
30412
|
+
"name": "days",
|
|
30413
|
+
"type": "int",
|
|
30414
|
+
"required": false,
|
|
30415
|
+
"valueRequired": true,
|
|
30416
|
+
"help": "截至上海昨日的天数(默认 7);不可与自定义日期同时使用"
|
|
30417
|
+
},
|
|
30418
|
+
{
|
|
30419
|
+
"name": "start-date",
|
|
30420
|
+
"type": "str",
|
|
30421
|
+
"required": false,
|
|
30422
|
+
"valueRequired": true,
|
|
30423
|
+
"help": "开始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
|
|
30424
|
+
},
|
|
30425
|
+
{
|
|
30426
|
+
"name": "end-date",
|
|
30427
|
+
"type": "str",
|
|
30428
|
+
"required": false,
|
|
30429
|
+
"valueRequired": true,
|
|
30430
|
+
"help": "结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
|
|
30431
|
+
}
|
|
30432
|
+
],
|
|
30433
|
+
"columns": [
|
|
30434
|
+
"source",
|
|
30435
|
+
"views",
|
|
30436
|
+
"views_pct",
|
|
30437
|
+
"recommendations",
|
|
30438
|
+
"likes",
|
|
30439
|
+
"comments",
|
|
30440
|
+
"shares",
|
|
30441
|
+
"follows"
|
|
30442
|
+
],
|
|
30443
|
+
"type": "js",
|
|
30444
|
+
"modulePath": "weixin-channels/sources.js",
|
|
30445
|
+
"sourceFile": "weixin-channels/sources.js",
|
|
30446
|
+
"navigateBefore": false
|
|
30447
|
+
},
|
|
30448
|
+
{
|
|
30449
|
+
"site": "weixin-channels",
|
|
30450
|
+
"name": "trend",
|
|
30451
|
+
"description": "读取视频号账号逐日指标趋势,默认最近 7 天,截至昨日",
|
|
30452
|
+
"access": "read",
|
|
30453
|
+
"domain": "channels.weixin.qq.com",
|
|
30454
|
+
"strategy": "cookie",
|
|
30455
|
+
"browser": true,
|
|
30456
|
+
"args": [
|
|
30457
|
+
{
|
|
30458
|
+
"name": "days",
|
|
30459
|
+
"type": "int",
|
|
30460
|
+
"required": false,
|
|
30461
|
+
"valueRequired": true,
|
|
30462
|
+
"help": "截至上海昨日的天数(默认 7);不可与自定义日期同时使用"
|
|
30463
|
+
},
|
|
30464
|
+
{
|
|
30465
|
+
"name": "start-date",
|
|
30466
|
+
"type": "str",
|
|
30467
|
+
"required": false,
|
|
30468
|
+
"valueRequired": true,
|
|
30469
|
+
"help": "开始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
|
|
30470
|
+
},
|
|
30471
|
+
{
|
|
30472
|
+
"name": "end-date",
|
|
30473
|
+
"type": "str",
|
|
30474
|
+
"required": false,
|
|
30475
|
+
"valueRequired": true,
|
|
30476
|
+
"help": "结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
|
|
30477
|
+
}
|
|
30478
|
+
],
|
|
30479
|
+
"columns": [
|
|
30480
|
+
"date",
|
|
30481
|
+
"views",
|
|
30482
|
+
"recommendations",
|
|
30483
|
+
"likes",
|
|
30484
|
+
"comments",
|
|
30485
|
+
"shares",
|
|
30486
|
+
"follows"
|
|
30487
|
+
],
|
|
30488
|
+
"type": "js",
|
|
30489
|
+
"modulePath": "weixin-channels/trend.js",
|
|
30490
|
+
"sourceFile": "weixin-channels/trend.js",
|
|
30491
|
+
"navigateBefore": false
|
|
30492
|
+
},
|
|
30246
30493
|
{
|
|
30247
30494
|
"site": "weread",
|
|
30248
30495
|
"name": "ai-outline",
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@sovovs/bycli/errors';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const DOMAIN = 'channels.weixin.qq.com';
|
|
5
|
+
const PAGE_URL = `https://${DOMAIN}/platform/statistic/post`;
|
|
6
|
+
const API_PREFIX = `/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/`;
|
|
7
|
+
const POLL_ATTEMPTS = 40;
|
|
8
|
+
const POLL_SECONDS = 0.25;
|
|
9
|
+
const AUTH_FIELDS = ['timestamp', '_log_finder_uin', '_log_finder_id', 'rawKeyBuff', 'pluginSessionId', 'scene', 'reqScene'];
|
|
10
|
+
|
|
11
|
+
function requireData(status, body, endpoint) {
|
|
12
|
+
if (status === 401 || status === 403 || body?.errCode === 401 || body?.errCode === 403
|
|
13
|
+
|| (body?.errCode !== 0 && /登录|not.?logged|login|session.*expired/i.test(String(body?.errMsg ?? '')))) {
|
|
14
|
+
throw new AuthRequiredError(DOMAIN);
|
|
15
|
+
}
|
|
16
|
+
// The service returns HTTP 201 for successful read-only queries.
|
|
17
|
+
if (!Number.isInteger(status) || status < 200 || status >= 300 || !body || body.errCode !== 0
|
|
18
|
+
|| !body.data || typeof body.data !== 'object' || Array.isArray(body.data)) {
|
|
19
|
+
throw new CommandExecutionError(`weixin-channels ${endpoint}: invalid API response (HTTP ${Number(status) || 0}, code ${Number(body?.errCode) || 0})`);
|
|
20
|
+
}
|
|
21
|
+
return body.data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseObject(text) {
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(text);
|
|
27
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function isLoginPage(page) {
|
|
32
|
+
return await page.evaluate(`(() => {
|
|
33
|
+
return /\\/login(?:\\/|$)/i.test(window.location.pathname)
|
|
34
|
+
|| /扫码登录|扫描二维码登录|登录视频号助手|Log in/i.test(document.body?.innerText || '');
|
|
35
|
+
})()`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Capture a fresh page request. Credentials are never returned by public helpers. */
|
|
39
|
+
async function bootstrap(page) {
|
|
40
|
+
if (typeof page?.startNetworkCapture !== 'function' || typeof page?.readNetworkCapture !== 'function') {
|
|
41
|
+
throw new CommandExecutionError('weixin-channels requires Browser Bridge network capture', 'Update the byCLI browser extension and log in to 视频号助手.');
|
|
42
|
+
}
|
|
43
|
+
if (await page.startNetworkCapture('/statistic/') === false) {
|
|
44
|
+
throw new CommandExecutionError('weixin-channels network capture is unavailable', 'Update the byCLI browser extension.');
|
|
45
|
+
}
|
|
46
|
+
// A retained tab already at PAGE_URL hits the bridge's no-navigation fast
|
|
47
|
+
// path. A harmless query nonce guarantees a guarded, fresh page request.
|
|
48
|
+
await page.goto(`${PAGE_URL}?bycli_capture=${randomUUID()}`, { stealth: false });
|
|
49
|
+
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
50
|
+
const captures = await page.readNetworkCapture();
|
|
51
|
+
for (const entry of captures) {
|
|
52
|
+
let url;
|
|
53
|
+
try { url = new URL(entry.url); } catch { continue; }
|
|
54
|
+
if (url.protocol !== 'https:' || url.hostname !== DOMAIN || url.pathname !== `${API_PREFIX}new_post_total_data`) continue;
|
|
55
|
+
if (entry.requestBodyTruncated || entry.responseBodyTruncated) {
|
|
56
|
+
throw new CommandExecutionError('weixin-channels bootstrap capture was truncated');
|
|
57
|
+
}
|
|
58
|
+
// Capture reads drain pending requests too. The complete request body is
|
|
59
|
+
// enough to bootstrap; our own API response will verify the session.
|
|
60
|
+
if (entry.responsePreview) requireData(entry.responseStatus, parseObject(entry.responsePreview), 'session');
|
|
61
|
+
const request = parseObject(entry.requestBodyPreview);
|
|
62
|
+
// Cookie-authenticated sessions legitimately send an empty rawKeyBuff
|
|
63
|
+
// and a null pluginSessionId. Preserve the template without inventing auth.
|
|
64
|
+
if (!request || typeof request.rawKeyBuff !== 'string') {
|
|
65
|
+
throw new CommandExecutionError('weixin-channels session request is unavailable', 'Reload 视频号助手 and retry.');
|
|
66
|
+
}
|
|
67
|
+
return Object.fromEntries(AUTH_FIELDS.filter(key => key in request).map(key => [key, request[key]]));
|
|
68
|
+
}
|
|
69
|
+
if (await isLoginPage(page)) throw new AuthRequiredError(DOMAIN);
|
|
70
|
+
await page.wait(POLL_SECONDS);
|
|
71
|
+
}
|
|
72
|
+
throw new TimeoutError('weixin-channels session capture', POLL_ATTEMPTS * POLL_SECONDS, 'Open 视频号助手 in Chrome, finish login, and retry.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function request(page, auth, endpoint, params) {
|
|
76
|
+
const url = `https://${DOMAIN}${API_PREFIX}${endpoint}`;
|
|
77
|
+
const body = JSON.stringify({ ...auth, ...params, timestamp: String(Date.now()) });
|
|
78
|
+
// The statistic app is a same-origin iframe. Use its fetch implementation and
|
|
79
|
+
// referrer, while keeping credentials and raw session fields inside the browser.
|
|
80
|
+
let response;
|
|
81
|
+
try {
|
|
82
|
+
response = await page.evaluate(`(async () => {
|
|
83
|
+
if (window.location.hostname !== ${JSON.stringify(DOMAIN)}) return { status: 401 };
|
|
84
|
+
const frame = document.querySelector('iframe');
|
|
85
|
+
const context = frame?.contentWindow || window;
|
|
86
|
+
if (context.location.hostname !== ${JSON.stringify(DOMAIN)}) return { status: 401 };
|
|
87
|
+
try {
|
|
88
|
+
const response = await context.fetch(${JSON.stringify(url)}, {
|
|
89
|
+
method: 'POST', credentials: 'include',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: ${JSON.stringify(body)},
|
|
92
|
+
});
|
|
93
|
+
let parsed;
|
|
94
|
+
try { parsed = await response.json(); } catch { return { status: response.status }; }
|
|
95
|
+
return { status: response.status, body: parsed };
|
|
96
|
+
} catch { return { status: 0 }; }
|
|
97
|
+
})()`);
|
|
98
|
+
} catch {
|
|
99
|
+
// Transport exceptions may include the script (and session fields).
|
|
100
|
+
throw new CommandExecutionError(`weixin-channels ${endpoint}: browser request failed`, 'Reload 视频号助手 and retry.');
|
|
101
|
+
}
|
|
102
|
+
return requireData(response?.status, response?.body, endpoint);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function periodParams(range) {
|
|
106
|
+
return { startTs: String(range.startTs), endTs: String(range.endTs), interval: 3 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function fetchTotalData(page, range) {
|
|
110
|
+
const auth = await bootstrap(page);
|
|
111
|
+
return request(page, auth, 'new_post_total_data', periodParams(range));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchPosts(page, range, limit = 20) {
|
|
115
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
116
|
+
throw new ArgumentError('--limit must be an integer between 1 and 1000');
|
|
117
|
+
}
|
|
118
|
+
const auth = await bootstrap(page);
|
|
119
|
+
const rows = [], seen = new Set();
|
|
120
|
+
let expected;
|
|
121
|
+
for (let currentPage = 1; currentPage <= Math.ceil(limit / 10); currentPage += 1) {
|
|
122
|
+
const data = await request(page, auth, 'post_list', {
|
|
123
|
+
pageSize: 10, currentPage, sort: 0, order: 0,
|
|
124
|
+
startTime: range.startTs, endTime: range.endTs + 86400,
|
|
125
|
+
});
|
|
126
|
+
if (!Array.isArray(data.list) || !Number.isSafeInteger(data.totalCount) || data.totalCount < 0) {
|
|
127
|
+
throw new CommandExecutionError('weixin-channels post_list: unexpected list response');
|
|
128
|
+
}
|
|
129
|
+
if (expected === undefined) expected = Math.min(data.totalCount, limit);
|
|
130
|
+
if (expected === 0 && data.list.length === 0) return [];
|
|
131
|
+
const before = rows.length;
|
|
132
|
+
for (const row of data.list) {
|
|
133
|
+
if (typeof row?.objectId !== 'string' || !/^\d+$/.test(row.objectId)) {
|
|
134
|
+
throw new CommandExecutionError('weixin-channels post_list: video id is unavailable');
|
|
135
|
+
}
|
|
136
|
+
if (!seen.has(row.objectId)) { seen.add(row.objectId); rows.push(row); }
|
|
137
|
+
}
|
|
138
|
+
if (rows.length >= expected) return rows.slice(0, expected);
|
|
139
|
+
if (rows.length === before || data.list.length < 10) {
|
|
140
|
+
throw new CommandExecutionError('weixin-channels post_list: incomplete pagination', 'Retry after the video list finishes updating.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new CommandExecutionError('weixin-channels post_list: incomplete pagination');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function fetchPostDetail(page, id, range) {
|
|
147
|
+
if (typeof id !== 'string' || !/^\d+$/.test(id)) throw new ArgumentError('--id must be a video id string');
|
|
148
|
+
const auth = await bootstrap(page);
|
|
149
|
+
const data = await request(page, auth, 'feed_aggreagate_data_by_tab_type', { ...periodParams(range), feedId: id });
|
|
150
|
+
const retention = await request(page, auth, 'get_feed_lost_rate', { feedId: id });
|
|
151
|
+
if (retention.baseResp && retention.baseResp.errcode !== 0) {
|
|
152
|
+
throw new CommandExecutionError('weixin-channels get_feed_lost_rate: retention unavailable');
|
|
153
|
+
}
|
|
154
|
+
return { data, retention };
|
|
155
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
2
|
+
|
|
3
|
+
const DAY_MS = 86_400_000;
|
|
4
|
+
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
5
|
+
const DAILY_METRICS = {
|
|
6
|
+
views: 'browse',
|
|
7
|
+
recommendations: 'like',
|
|
8
|
+
likes: 'fav',
|
|
9
|
+
comments: 'comment',
|
|
10
|
+
shares: 'forwardAggregation',
|
|
11
|
+
follows: 'follow',
|
|
12
|
+
};
|
|
13
|
+
// Daily engagement is added actions minus cancellations or deletions.
|
|
14
|
+
const DAILY_NET_METRICS = new Set(['like', 'fav', 'comment', 'follow']);
|
|
15
|
+
const POST_COUNTERS = {
|
|
16
|
+
views: 'readCount',
|
|
17
|
+
recommendations: 'likeCount',
|
|
18
|
+
likes: 'favCount',
|
|
19
|
+
comments: 'commentCount',
|
|
20
|
+
shares: 'forwardAggregationCount',
|
|
21
|
+
follows: 'followCount',
|
|
22
|
+
chat_shares: 'forwardCount',
|
|
23
|
+
ringtone_sets: 'ringsetCount',
|
|
24
|
+
status_sets: 'statusrefCount',
|
|
25
|
+
cover_sets: 'snscoverCount',
|
|
26
|
+
wecom_link_clicks: 'wecomLinkClickCount',
|
|
27
|
+
wecom_link_visitors: 'wecomLinkClickUv',
|
|
28
|
+
wecom_contact_adds: 'wecomContactAddCount',
|
|
29
|
+
wecom_contact_visitors: 'wecomContactAddUv',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function calendarDateMs(value, label) {
|
|
37
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
38
|
+
throw new ArgumentError(`${label} must be YYYY-MM-DD`);
|
|
39
|
+
}
|
|
40
|
+
const ms = Date.parse(`${value}T00:00:00Z`);
|
|
41
|
+
if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== value) {
|
|
42
|
+
throw new ArgumentError(`${label} must be a valid calendar date`);
|
|
43
|
+
}
|
|
44
|
+
return ms;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function positiveDays(value) {
|
|
48
|
+
const validType = typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value));
|
|
49
|
+
const days = validType ? Number(value) : NaN;
|
|
50
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > 366) {
|
|
51
|
+
throw new ArgumentError('--days must be an integer between 1 and 366');
|
|
52
|
+
}
|
|
53
|
+
return days;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Inclusive dates, expressed as Shanghai midnight Unix seconds. */
|
|
57
|
+
export function resolveDateRange(args = {}, now = new Date(), defaultDays = 7) {
|
|
58
|
+
if (!isRecord(args) || !(now instanceof Date) || !Number.isFinite(now.getTime())) {
|
|
59
|
+
throw new ArgumentError('Invalid date range arguments');
|
|
60
|
+
}
|
|
61
|
+
const today = new Date(now.getTime() + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10);
|
|
62
|
+
const todayMs = calendarDateMs(today, 'today');
|
|
63
|
+
const hasStart = args['start-date'] !== undefined;
|
|
64
|
+
const hasEnd = args['end-date'] !== undefined;
|
|
65
|
+
const hasDays = args.days !== undefined;
|
|
66
|
+
if (hasStart !== hasEnd) {
|
|
67
|
+
throw new ArgumentError('--start-date and --end-date must be supplied together');
|
|
68
|
+
}
|
|
69
|
+
if (hasDays && hasStart) {
|
|
70
|
+
throw new ArgumentError('--days cannot be combined with --start-date and --end-date');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let startMs;
|
|
74
|
+
let endMs;
|
|
75
|
+
let days;
|
|
76
|
+
if (hasStart) {
|
|
77
|
+
startMs = calendarDateMs(args['start-date'], '--start-date');
|
|
78
|
+
endMs = calendarDateMs(args['end-date'], '--end-date');
|
|
79
|
+
if (startMs > endMs) throw new ArgumentError('--start-date must not be after --end-date');
|
|
80
|
+
if (endMs >= todayMs) throw new ArgumentError('--end-date must be before today in Asia/Shanghai');
|
|
81
|
+
days = (endMs - startMs) / DAY_MS + 1;
|
|
82
|
+
if (days > 366) throw new ArgumentError('The date range must not exceed 366 days');
|
|
83
|
+
} else {
|
|
84
|
+
days = positiveDays(hasDays ? args.days : defaultDays);
|
|
85
|
+
endMs = todayMs - DAY_MS;
|
|
86
|
+
startMs = endMs - (days - 1) * DAY_MS;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
startDate: new Date(startMs).toISOString().slice(0, 10),
|
|
90
|
+
endDate: new Date(endMs).toISOString().slice(0, 10),
|
|
91
|
+
days,
|
|
92
|
+
startTs: (startMs - SHANGHAI_OFFSET_MS) / 1000,
|
|
93
|
+
endTs: (endMs - SHANGHAI_OFFSET_MS) / 1000,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function counter(value, label, optional = true, allowNegative = false) {
|
|
98
|
+
if (optional && (value === undefined || value === null)) return null;
|
|
99
|
+
const numberString = allowNegative ? /^-?\d+$/ : /^\d+$/;
|
|
100
|
+
const validType = typeof value === 'number' || (typeof value === 'string' && numberString.test(value));
|
|
101
|
+
const number = validType ? Number(value) : NaN;
|
|
102
|
+
if (!Number.isSafeInteger(number) || (!allowNegative && number < 0)) {
|
|
103
|
+
throw new CommandExecutionError(`Weixin Channels returned an invalid ${label} count`);
|
|
104
|
+
}
|
|
105
|
+
return number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function decimalMetric(value, label, optional = true) {
|
|
109
|
+
if (optional && (value === undefined || value === null)) return null;
|
|
110
|
+
const validType = typeof value === 'number'
|
|
111
|
+
|| (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value));
|
|
112
|
+
const number = validType ? Number(value) : NaN;
|
|
113
|
+
if (!Number.isFinite(number) || number < 0 || number > Number.MAX_SAFE_INTEGER) {
|
|
114
|
+
throw new CommandExecutionError(`Weixin Channels returned an invalid ${label} metric`);
|
|
115
|
+
}
|
|
116
|
+
return number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function roundTwo(value) {
|
|
120
|
+
return value === null ? null : Math.round((value + Number.EPSILON) * 100) / 100;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function percentage(value, label, optional = true) {
|
|
124
|
+
const ratio = decimalMetric(value, label, optional);
|
|
125
|
+
if (ratio === null) return null;
|
|
126
|
+
if (ratio > 1) throw new CommandExecutionError(`Weixin Channels returned an invalid ${label} ratio`);
|
|
127
|
+
return roundTwo(ratio * 100);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function dailySeries(data, range) {
|
|
131
|
+
if (!isRecord(data) || (data.totalData != null && !isRecord(data.totalData))) {
|
|
132
|
+
throw new CommandExecutionError('Weixin Channels returned malformed daily metrics');
|
|
133
|
+
}
|
|
134
|
+
const metrics = data.totalData ?? {};
|
|
135
|
+
return Object.fromEntries(Object.entries(DAILY_METRICS).map(([name, key]) => {
|
|
136
|
+
const values = metrics[key];
|
|
137
|
+
if (values === undefined || values === null) return [name, null];
|
|
138
|
+
if (!Array.isArray(values) || (values.length !== 0 && values.length !== range.days)) {
|
|
139
|
+
throw new CommandExecutionError(`Weixin Channels returned an unexpected ${key} series length`);
|
|
140
|
+
}
|
|
141
|
+
if (!values.length) return [name, null];
|
|
142
|
+
return [name, Array.from(values, (value) => counter(value, key, false, DAILY_NET_METRICS.has(key)))];
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** API like = heart/recommendation; API fav = thumb/like. */
|
|
147
|
+
export function normalizeTrend(data, range) {
|
|
148
|
+
const series = dailySeries(data, range);
|
|
149
|
+
const startMs = Date.parse(`${range.startDate}T00:00:00Z`);
|
|
150
|
+
return Array.from({ length: range.days }, (_, index) => ({
|
|
151
|
+
date: new Date(startMs + index * DAY_MS).toISOString().slice(0, 10),
|
|
152
|
+
...Object.fromEntries(Object.entries(series).map(([name, values]) => [name, values === null ? null : values[index]])),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeOverview(data, range) {
|
|
157
|
+
const series = dailySeries(data, range);
|
|
158
|
+
return {
|
|
159
|
+
start_date: range.startDate,
|
|
160
|
+
end_date: range.endDate,
|
|
161
|
+
...Object.fromEntries(Object.entries(series).map(([name, values]) => {
|
|
162
|
+
if (values === null) return [name, null];
|
|
163
|
+
// Exact addition also preserves safe results after large signed changes cancel.
|
|
164
|
+
const sum = Number(values.reduce((total, value) => total + BigInt(value), 0n));
|
|
165
|
+
if (!Number.isSafeInteger(sum)) {
|
|
166
|
+
throw new CommandExecutionError(`Weixin Channels returned an unsafe ${name} total`);
|
|
167
|
+
}
|
|
168
|
+
return [name, sum];
|
|
169
|
+
})),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function normalizeSources(data, range) {
|
|
174
|
+
if (!isRecord(data) || !Array.isArray(data.dataByTabtype)) {
|
|
175
|
+
throw new CommandExecutionError('Weixin Channels returned malformed source metrics');
|
|
176
|
+
}
|
|
177
|
+
const totalViews = normalizeOverview(data, range).views;
|
|
178
|
+
return data.dataByTabtype.map((item) => {
|
|
179
|
+
if (!isRecord(item) || item.tabType == null || typeof item.tabTypeName !== 'string' || !isRecord(item.data)) {
|
|
180
|
+
throw new CommandExecutionError('Weixin Channels returned a source without its identity or metrics');
|
|
181
|
+
}
|
|
182
|
+
if (typeof item.tabType !== 'string' && !(Number.isSafeInteger(item.tabType) && item.tabType >= 0)) {
|
|
183
|
+
throw new CommandExecutionError('Weixin Channels returned an invalid source identity');
|
|
184
|
+
}
|
|
185
|
+
const overview = normalizeOverview({ totalData: item.data }, range);
|
|
186
|
+
return {
|
|
187
|
+
...overview,
|
|
188
|
+
source_id: item.tabType,
|
|
189
|
+
source: item.tabTypeName,
|
|
190
|
+
views_pct: totalViews === null || totalViews === 0 || overview.views === null
|
|
191
|
+
? null : roundTwo(overview.views / totalViews * 100),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function postId(value) {
|
|
197
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
198
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value);
|
|
199
|
+
throw new CommandExecutionError('Weixin Channels returned a post without a stable objectId');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function publishedAt(value) {
|
|
203
|
+
const seconds = counter(value, 'createTime');
|
|
204
|
+
if (seconds === null) return null;
|
|
205
|
+
const ms = seconds * 1000 + SHANGHAI_OFFSET_MS;
|
|
206
|
+
if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) {
|
|
207
|
+
throw new CommandExecutionError('Weixin Channels returned an invalid createTime timestamp');
|
|
208
|
+
}
|
|
209
|
+
return `${new Date(ms).toISOString().slice(0, 19)}+08:00`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function normalizePost(item) {
|
|
213
|
+
if (!isRecord(item)) throw new CommandExecutionError('Weixin Channels returned a malformed post');
|
|
214
|
+
const title = typeof item.desc === 'string' ? item.desc
|
|
215
|
+
: (typeof item.desc?.description === 'string' ? item.desc.description : '');
|
|
216
|
+
return {
|
|
217
|
+
id: postId(item.objectId),
|
|
218
|
+
title,
|
|
219
|
+
published_at: publishedAt(item.createTime),
|
|
220
|
+
...Object.fromEntries(Object.entries(POST_COUNTERS).map(([name, key]) => [name, counter(item[key], key)])),
|
|
221
|
+
// The verified API unit is a 0..1 ratio; business output uses percent.
|
|
222
|
+
completion_rate: percentage(item.fullPlayRate, 'fullPlayRate'),
|
|
223
|
+
avg_play_seconds: roundTwo(decimalMetric(item.avgPlayTimeSec, 'avgPlayTimeSec')),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeRetention(data) {
|
|
228
|
+
if (!isRecord(data) || !Array.isArray(data.items)) {
|
|
229
|
+
throw new CommandExecutionError('Weixin Channels returned malformed retention metrics');
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
video_seconds: counter(data.videoLen, 'videoLen', false),
|
|
233
|
+
max_loss_second: counter(data.maxLostRateSec, 'maxLostRateSec', false),
|
|
234
|
+
points: Array.from(data.items, (item) => {
|
|
235
|
+
if (!isRecord(item)) throw new CommandExecutionError('Weixin Channels returned a malformed retention point');
|
|
236
|
+
return {
|
|
237
|
+
second: counter(item.sec, 'retention second', false),
|
|
238
|
+
retained_pct: percentage(item.rate, 'retention', false),
|
|
239
|
+
};
|
|
240
|
+
}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function normalizeDetail(data, retention, id, range) {
|
|
245
|
+
if (!isRecord(data) || !Array.isArray(data.feedData) || data.feedData.length !== 1 || !isRecord(data.feedData[0])) {
|
|
246
|
+
throw new CommandExecutionError('Weixin Channels returned an unexpected single-post detail payload');
|
|
247
|
+
}
|
|
248
|
+
const feed = data.feedData[0];
|
|
249
|
+
const sources = normalizeSources(feed, range);
|
|
250
|
+
const trend = normalizeTrend(feed, range);
|
|
251
|
+
const sourceTrends = feed.dataByTabtype.map((item) => normalizeTrend({ totalData: item.data }, range));
|
|
252
|
+
return {
|
|
253
|
+
id: postId(id),
|
|
254
|
+
start_date: range.startDate,
|
|
255
|
+
end_date: range.endDate,
|
|
256
|
+
overview: normalizeOverview(feed, range),
|
|
257
|
+
trend,
|
|
258
|
+
sources,
|
|
259
|
+
data_details: trend.flatMap((_, dayIndex) => sources.map((source, sourceIndex) => {
|
|
260
|
+
const { date, ...metrics } = sourceTrends[sourceIndex][dayIndex];
|
|
261
|
+
return { date, source_id: source.source_id, source: source.source, ...metrics };
|
|
262
|
+
})),
|
|
263
|
+
retention: normalizeRetention(retention),
|
|
264
|
+
fans_breakdown_available: !data.notExistFansData,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { fetchTotalData } from './_shared/client.js';
|
|
3
|
+
import { normalizeOverview, resolveDateRange } from './_shared/model.js';
|
|
4
|
+
|
|
5
|
+
export const overviewCommand = cli({
|
|
6
|
+
site: 'weixin-channels',
|
|
7
|
+
name: 'overview',
|
|
8
|
+
access: 'read',
|
|
9
|
+
domain: 'channels.weixin.qq.com',
|
|
10
|
+
description: '读取视频号账号概览:所选日期内播放、推荐、喜欢、评论、分享和新增关注,默认昨日',
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'days', type: 'int', valueRequired: true, help: '截至上海昨日的天数(默认 1);不可与自定义日期同时使用' },
|
|
16
|
+
{ name: 'start-date', type: 'str', valueRequired: true, help: '开始日期 YYYY-MM-DD(包含),须同时提供 --end-date' },
|
|
17
|
+
{ name: 'end-date', type: 'str', valueRequired: true, help: '结束日期 YYYY-MM-DD(包含),最晚为上海昨日' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['start_date', 'end_date', 'views', 'recommendations', 'likes', 'comments', 'shares', 'follows'],
|
|
20
|
+
func: async (page, args = {}) => {
|
|
21
|
+
const range = resolveDateRange(args, new Date(), 1);
|
|
22
|
+
const data = await fetchTotalData(page, range);
|
|
23
|
+
return [normalizeOverview(data, range)];
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ArgumentError } from '@sovovs/bycli/errors';
|
|
2
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
|
+
import { fetchPostDetail } from './_shared/client.js';
|
|
4
|
+
import { normalizeDetail, resolveDateRange } from './_shared/model.js';
|
|
5
|
+
|
|
6
|
+
export const postDetailCommand = cli({
|
|
7
|
+
site: 'weixin-channels',
|
|
8
|
+
name: 'post-detail',
|
|
9
|
+
access: 'read',
|
|
10
|
+
domain: 'channels.weixin.qq.com',
|
|
11
|
+
description: '读取单条视频号作品的概览、趋势、来源占比、逐日来源明细和留存,默认最近 7 天;建议使用 --format json',
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
browser: true,
|
|
14
|
+
navigateBefore: false,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'id', type: 'str', required: true, positional: true, help: 'posts 返回的作品 ID(objectId),按数字字符串原样传入' },
|
|
17
|
+
{ name: 'days', type: 'int', valueRequired: true, help: '截至上海昨日的统计天数(默认 7);不可与自定义日期同时使用' },
|
|
18
|
+
{ name: 'start-date', type: 'str', valueRequired: true, help: '统计开始日期 YYYY-MM-DD(包含),须同时提供 --end-date' },
|
|
19
|
+
{ name: 'end-date', type: 'str', valueRequired: true, help: '统计结束日期 YYYY-MM-DD(包含),最晚为上海昨日' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['id', 'start_date', 'end_date', 'overview', 'trend', 'sources', 'data_details', 'retention'],
|
|
22
|
+
func: async (page, args = {}) => {
|
|
23
|
+
const id = args.id;
|
|
24
|
+
if (typeof id !== 'string' || !/^\d+$/.test(id)) {
|
|
25
|
+
throw new ArgumentError('作品 ID 必须是非空的数字字符串', '请复制 weixin-channels posts 返回的 id,保留完整数字');
|
|
26
|
+
}
|
|
27
|
+
const range = resolveDateRange(args, new Date(), 7);
|
|
28
|
+
const { data, retention } = await fetchPostDetail(page, id, range);
|
|
29
|
+
return [normalizeDetail(data, retention, id, range)];
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ArgumentError } from '@sovovs/bycli/errors';
|
|
2
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
|
+
import { fetchPosts } from './_shared/client.js';
|
|
4
|
+
import { normalizePost, resolveDateRange } from './_shared/model.js';
|
|
5
|
+
|
|
6
|
+
export const postsCommand = cli({
|
|
7
|
+
site: 'weixin-channels',
|
|
8
|
+
name: 'posts',
|
|
9
|
+
access: 'read',
|
|
10
|
+
domain: 'channels.weixin.qq.com',
|
|
11
|
+
description: '列出所选发布时间内的视频号作品及后台累计指标,默认最近 30 天,最多 20 条',
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
browser: true,
|
|
14
|
+
navigateBefore: false,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'days', type: 'int', valueRequired: true, help: '截至上海昨日的发布时间天数(默认 30);不可与自定义日期同时使用' },
|
|
17
|
+
{ name: 'start-date', type: 'str', valueRequired: true, help: '作品发布时间起始日期 YYYY-MM-DD(包含),须同时提供 --end-date' },
|
|
18
|
+
{ name: 'end-date', type: 'str', valueRequired: true, help: '作品发布时间结束日期 YYYY-MM-DD(包含),最晚为上海昨日' },
|
|
19
|
+
{ name: 'limit', type: 'int', valueRequired: true, default: 20, help: '最多返回的作品数(1–1000 的整数,默认 20)' },
|
|
20
|
+
],
|
|
21
|
+
columns: [
|
|
22
|
+
'id', 'title', 'published_at', 'views', 'recommendations', 'likes', 'comments',
|
|
23
|
+
'shares', 'follows', 'completion_rate', 'avg_play_seconds',
|
|
24
|
+
],
|
|
25
|
+
func: async (page, args = {}) => {
|
|
26
|
+
const range = resolveDateRange(args, new Date(), 30);
|
|
27
|
+
const limit = args.limit ?? 20;
|
|
28
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1000) {
|
|
29
|
+
throw new ArgumentError('--limit 必须是 1–1000 的整数');
|
|
30
|
+
}
|
|
31
|
+
const items = await fetchPosts(page, range, limit);
|
|
32
|
+
return items.map(normalizePost);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { fetchTotalData } from './_shared/client.js';
|
|
3
|
+
import { normalizeSources, resolveDateRange } from './_shared/model.js';
|
|
4
|
+
|
|
5
|
+
export const sourcesCommand = cli({
|
|
6
|
+
site: 'weixin-channels',
|
|
7
|
+
name: 'sources',
|
|
8
|
+
access: 'read',
|
|
9
|
+
domain: 'channels.weixin.qq.com',
|
|
10
|
+
description: '按视频号后台来源汇总账号指标及播放占比,默认最近 7 天,截至昨日',
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'days', type: 'int', valueRequired: true, help: '截至上海昨日的天数(默认 7);不可与自定义日期同时使用' },
|
|
16
|
+
{ name: 'start-date', type: 'str', valueRequired: true, help: '开始日期 YYYY-MM-DD(包含),须同时提供 --end-date' },
|
|
17
|
+
{ name: 'end-date', type: 'str', valueRequired: true, help: '结束日期 YYYY-MM-DD(包含),最晚为上海昨日' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['source', 'views', 'views_pct', 'recommendations', 'likes', 'comments', 'shares', 'follows'],
|
|
20
|
+
func: async (page, args = {}) => {
|
|
21
|
+
const range = resolveDateRange(args, new Date(), 7);
|
|
22
|
+
const data = await fetchTotalData(page, range);
|
|
23
|
+
return normalizeSources(data, range);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { fetchTotalData } from './_shared/client.js';
|
|
3
|
+
import { normalizeTrend, resolveDateRange } from './_shared/model.js';
|
|
4
|
+
|
|
5
|
+
export const trendCommand = cli({
|
|
6
|
+
site: 'weixin-channels',
|
|
7
|
+
name: 'trend',
|
|
8
|
+
access: 'read',
|
|
9
|
+
domain: 'channels.weixin.qq.com',
|
|
10
|
+
description: '读取视频号账号逐日指标趋势,默认最近 7 天,截至昨日',
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'days', type: 'int', valueRequired: true, help: '截至上海昨日的天数(默认 7);不可与自定义日期同时使用' },
|
|
16
|
+
{ name: 'start-date', type: 'str', valueRequired: true, help: '开始日期 YYYY-MM-DD(包含),须同时提供 --end-date' },
|
|
17
|
+
{ name: 'end-date', type: 'str', valueRequired: true, help: '结束日期 YYYY-MM-DD(包含),最晚为上海昨日' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['date', 'views', 'recommendations', 'likes', 'comments', 'shares', 'follows'],
|
|
20
|
+
func: async (page, args = {}) => {
|
|
21
|
+
const range = resolveDateRange(args, new Date(), 7);
|
|
22
|
+
const data = await fetchTotalData(page, range);
|
|
23
|
+
return normalizeTrend(data, range);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -180,15 +180,9 @@ export function generateStealthJs() {
|
|
|
180
180
|
return fn;
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
// 8.
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// and the site measures the time gap to confirm automation.
|
|
187
|
-
// We neutralize this by overriding the Function constructor and
|
|
188
|
-
// eval to strip debugger statements from dynamically created code.
|
|
189
|
-
// Note: this does NOT affect static debugger statements in parsed
|
|
190
|
-
// scripts — those require CDP Debugger.setBreakpointsActive(false)
|
|
191
|
-
// which we handle at the extension level.
|
|
183
|
+
// 8. Strip debugger statements from dynamically created Function bodies.
|
|
184
|
+
// Native eval must remain intact to preserve the caller's lexical scope.
|
|
185
|
+
// This does not remove debugger statements from eval or static scripts.
|
|
192
186
|
// Caveat: the regex targets standalone debugger statements (preceded
|
|
193
187
|
// by a statement boundary) to minimise false positives inside string
|
|
194
188
|
// literals, but cannot perfectly distinguish all cases without a
|
|
@@ -218,13 +212,8 @@ export function generateStealthJs() {
|
|
|
218
212
|
_disguise(_PatchedFunction, 'Function');
|
|
219
213
|
try { window.Function = _PatchedFunction; } catch {}
|
|
220
214
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
const _patchedEval = function(code) {
|
|
224
|
-
return _origEval.call(this, _cleanDebugger(code));
|
|
225
|
-
};
|
|
226
|
-
_disguise(_patchedEval, 'eval');
|
|
227
|
-
try { window.eval = _patchedEval; } catch {}
|
|
215
|
+
// Keep native eval: wrapping it changes direct eval to indirect eval,
|
|
216
|
+
// losing the caller's lexical scope and strict-mode behavior.
|
|
228
217
|
} catch {}
|
|
229
218
|
|
|
230
219
|
// 9. Console method fingerprinting defense
|
|
@@ -9,9 +9,9 @@ const SENSITIVE_HEADER_NAMES = new Set([
|
|
|
9
9
|
'x-csrf-token',
|
|
10
10
|
'x-xsrf-token',
|
|
11
11
|
]);
|
|
12
|
-
const SENSITIVE_FIELD_PATTERN = /(password|passwd|pwd|token|secret|authorization|cookie|set-cookie|api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?id|csrf|xsrf)/i;
|
|
13
|
-
const SENSITIVE_URL_PARAMS = /([?&])(token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf)=[^&]*/gi;
|
|
14
|
-
const ENCODED_SENSITIVE_QUERY_ASSIGNMENT = /(%26(?:token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf)(?:%3D|=))(?:(?!%26|["'\s,;}&#]).)+|(\\u0026(?:token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf)(?:%3D|=))(?:(?!\\u0026|["'\s,;}&#]).)+/gi;
|
|
12
|
+
const SENSITIVE_FIELD_PATTERN = /(password|passwd|pwd|token|secret|authorization|cookie|set-cookie|api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?id|csrf|xsrf|^raw[_-]?key[_-]?buff$|^plugin[_-]?session[_-]?id$)/i;
|
|
13
|
+
const SENSITIVE_URL_PARAMS = /([?&])(token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf|raw[_-]?key[_-]?buff|plugin[_-]?session[_-]?id)=[^&]*/gi;
|
|
14
|
+
const ENCODED_SENSITIVE_QUERY_ASSIGNMENT = /(%26(?:token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf|raw[_-]?key[_-]?buff|plugin[_-]?session[_-]?id)(?:%3D|=))(?:(?!%26|["'\s,;}&#]).)+|(\\u0026(?:token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf|raw[_-]?key[_-]?buff|plugin[_-]?session[_-]?id)(?:%3D|=))(?:(?!\\u0026|["'\s,;}&#]).)+/gi;
|
|
15
15
|
function hasFingerprintFieldSegment(name) {
|
|
16
16
|
return name
|
|
17
17
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
@@ -38,9 +38,9 @@ export function redactText(text, opts = {}) {
|
|
|
38
38
|
const max = opts.maxStringLength ?? 50_000;
|
|
39
39
|
let out = text
|
|
40
40
|
.replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]')
|
|
41
|
-
.replace(/(["'])(password|passwd|pwd|token|secret|fingerprint|api_key|apikey|access_token|session_id)\1\s*:\s*(["'])(.*?)\3/gi, '$1$2$1:$3[REDACTED]$3')
|
|
41
|
+
.replace(/(["'])(password|passwd|pwd|token|secret|fingerprint|api_key|apikey|access_token|session_id|raw[_-]?key[_-]?buff|plugin[_-]?session[_-]?id)\1\s*:\s*(["'])(.*?)\3/gi, '$1$2$1:$3[REDACTED]$3')
|
|
42
42
|
.replace(ENCODED_SENSITIVE_QUERY_ASSIGNMENT, (_match, percentPrefix, escapedPrefix) => `${percentPrefix ?? escapedPrefix}[REDACTED]`)
|
|
43
|
-
.replace(/(token|secret|fingerprint|password|api_key|apikey|access_token|session_id)\s*[=:]\s*(?!['"]?\[REDACTED\](?=\\u0026|%26|['"\s,;}&#]|$))['"]?[^'"\s,;}&]+['"]?/gi, '$1=[REDACTED]')
|
|
43
|
+
.replace(/((?<![\w-])(?:raw[_-]?key[_-]?buff|plugin[_-]?session[_-]?id)|token|secret|fingerprint|password|api_key|apikey|access_token|session_id)\s*[=:]\s*(?!['"]?\[REDACTED\](?=\\u0026|%26|['"\s,;}&#]|$))['"]?[^'"\s,;}&]+['"]?/gi, '$1=[REDACTED]')
|
|
44
44
|
.replace(/(cookie[=:]\s*)[^\n;]{3,}/gi, '$1[REDACTED]')
|
|
45
45
|
.replace(/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[REDACTED_JWT]');
|
|
46
46
|
if (out.length > max)
|