@sovovs/bycli 2.1.30 → 2.1.32

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
@@ -28485,6 +28485,42 @@
28485
28485
  "sourceFile": "weixin/drafts.js",
28486
28486
  "navigateBefore": false
28487
28487
  },
28488
+ {
28489
+ "site": "weixin",
28490
+ "name": "home-overview",
28491
+ "aliases": [
28492
+ "overview",
28493
+ "dashboard",
28494
+ "fans"
28495
+ ],
28496
+ "description": "读取公众号首页概览:原创内容、总用户数(粉丝数)、昨日阅读/分享/新增关注人数及阅读增长百分比;账号级昨日汇总,单篇文章数据请用 published",
28497
+ "access": "read",
28498
+ "domain": "mp.weixin.qq.com",
28499
+ "strategy": "cookie",
28500
+ "browser": true,
28501
+ "args": [
28502
+ {
28503
+ "name": "settle",
28504
+ "type": "int",
28505
+ "default": 3,
28506
+ "required": false,
28507
+ "help": "打开首页后等待渲染的秒数"
28508
+ }
28509
+ ],
28510
+ "columns": [
28511
+ "original_count",
28512
+ "total_users",
28513
+ "yesterday_reads",
28514
+ "yesterday_reads_change_pct",
28515
+ "yesterday_shares",
28516
+ "yesterday_new_followers",
28517
+ "stat_range"
28518
+ ],
28519
+ "type": "js",
28520
+ "modulePath": "weixin/home-overview.js",
28521
+ "sourceFile": "weixin/home-overview.js",
28522
+ "navigateBefore": false
28523
+ },
28488
28524
  {
28489
28525
  "site": "weixin",
28490
28526
  "name": "published",
@@ -26,20 +26,26 @@ const PAGE_SIZE = 20;
26
26
  const MAX_LIMIT = 200;
27
27
  const MAX_PAGES = 30;
28
28
 
29
- // 掘金对**非 0 的时间窗**(search_type=1/2/3)会间歇性返回空首页:同一 query
30
- // 连打 6 次,2-5 次拿到 `data: []` 而 err_no 仍是 0/success,只有 cursor 里的
31
- // 实例标识不同——像是部分后端分片对时间窗查询答不出来。实测 period=week 命中
32
- // 率只有 1/6 ~ 2/6,但 4 次以内总能拿到数据。
29
+ // 掘金对**非 0 的时间窗**(search_type=1/2/3)会间歇性返回空首页:`data: []`
30
+ // err_no 仍是 0 / err_msg "success",整个响应体除了 cursor 里的实例标识
31
+ // 之外和有数据时完全一致,只有 has_more 跟着变 false——像是部分后端分片对时间
32
+ // 窗查询答不出来。
33
33
  //
34
- // 真实无结果(如 query="鿃鿄鿅鿆")连打 8 次稳定是 0,所以重试只会救回抖动,
35
- // 不会把空态变成假数据。只重试**首页且一行都没拿到**的情形:翻页途中的空页
36
- // 本来就是正常的终止信号。
37
- const EMPTY_RETRY_ATTEMPTS = 4;
38
- const EMPTY_RETRY_DELAY_MS = 400;
39
-
40
- function delay(ms) {
41
- return new Promise((resolve) => { setTimeout(resolve, ms); });
42
- }
34
+ // 单次请求的空概率实测 **76%**(query=rust/period=day,25 次里 19 次空),
35
+ // 且与请求间隔无关(0ms 9/12、400ms 10/12、1500ms 11/12——加延迟只会更差),
36
+ // 所以各次尝试相互独立、退避没有意义。改过 8 种参数组合(去 cursor / 去 limit /
37
+ // spider / 补 version / 换 sort_type / 换 id_type / 最小参数集)空率都在
38
+ // 6-9/10,没有能让它确定化的参数。
39
+ //
40
+ // 首次成功所需次数实测分布(12 轮):中位数 3、p90 5、最大 7。取 12 次把
41
+ // 独立失败概率压到 0.76^12 3%(4 次时是 33%,实测正好对应 4/6 的命中率)。
42
+ //
43
+ // 真实无结果(query="鿃鿄鿅鿆" / "zzzqqqxxx...")连打 12-14 次稳定是 0,所以
44
+ // 重试只会救回抖动,不会把空态变成假数据。注意掘金对**无匹配**的查询会退化成
45
+ // 返回无关的推荐文章,但那属于「有数据」分支,和这里的空首页是两回事。
46
+ //
47
+ // 只重试**首页且一行都没拿到**的情形:翻页途中的空页本来就是正常的终止信号。
48
+ const EMPTY_RETRY_ATTEMPTS = 12;
43
49
 
44
50
  const TYPES = {
45
51
  all: 0,
@@ -327,9 +333,9 @@ cli({
327
333
  pages += 1;
328
334
 
329
335
  // 首页空 → 可能是上面说的分片抖动,有界重试;真实空态重试后仍是空。
336
+ // 不加退避:实测各次尝试独立,延迟只会拖慢且略微更差。
330
337
  if (rows.length === 0 && payload.data.length === 0) {
331
338
  for (let attempt = 1; attempt < EMPTY_RETRY_ATTEMPTS; attempt += 1) {
332
- await delay(EMPTY_RETRY_DELAY_MS);
333
339
  payload = await fetchPage(url);
334
340
  if (payload.data.length > 0) break;
335
341
  }
@@ -0,0 +1,114 @@
1
+ import { CommandExecutionError } from '@sovovs/bycli/errors';
2
+
3
+ const DOMAIN = 'mp.weixin.qq.com';
4
+
5
+ /**
6
+ * @typedef {{
7
+ * originalCount: number | null,
8
+ * totalUsers: number | null,
9
+ * yesterdayReads: number | null,
10
+ * yesterdayReadsChangePct: number | null,
11
+ * yesterdayShares: number | null,
12
+ * yesterdayNewFollowers: number | null,
13
+ * statRange: string | null,
14
+ * }} HomeOverview
15
+ */
16
+
17
+ /** @param {string} token */
18
+ export function buildHomeUrl(token) {
19
+ const parameters = new URLSearchParams({
20
+ t: 'home/index',
21
+ lang: 'zh_CN',
22
+ token: String(token),
23
+ });
24
+ return `https://${DOMAIN}/cgi-bin/home?${parameters.toString()}`;
25
+ }
26
+
27
+ /**
28
+ * Reads the home dashboard counters from `window.wx.cgiData` with a DOM fallback.
29
+ * Kept as a string so it can run through `page.evaluate` unchanged.
30
+ */
31
+ export const HOME_OVERVIEW_SCRIPT = `(() => {
32
+ const digits = value => {
33
+ if (value === null || value === undefined) return null;
34
+ const text = String(value).replace(/[\\s,]/g, '');
35
+ const match = /^-?\\d+(?:\\.\\d+)?$/.exec(text);
36
+ return match ? Number(text) : null;
37
+ };
38
+ const textOf = selector => {
39
+ const node = document.querySelector(selector);
40
+ return node ? node.textContent : null;
41
+ };
42
+ const overviewByTitle = keyword => {
43
+ const items = Array.from(document.querySelectorAll('.weui-desktop-data-overview'));
44
+ const matched = items.find(item => {
45
+ const title = item.querySelector('.weui-desktop-data-overview__title');
46
+ return title ? title.textContent.indexOf(keyword) !== -1 : false;
47
+ });
48
+ if (!matched) return { value: null, changePct: null };
49
+ const desc = matched.querySelector('.weui-desktop-data-overview__desc');
50
+ const tips = matched.querySelector('.tips_num');
51
+ return {
52
+ value: digits(desc ? desc.textContent : null),
53
+ changePct: digits(tips ? tips.textContent.replace('%', '') : null),
54
+ };
55
+ };
56
+
57
+ const cgiData = (window.wx && window.wx.cgiData) || {};
58
+ const summary = cgiData.yesterdaySummary || {};
59
+ const reads = overviewByTitle('昨日阅读');
60
+ const shares = overviewByTitle('昨日分享');
61
+ const followers = overviewByTitle('昨日新增关注');
62
+ const statRange = textOf('.weui-desktop-data_description');
63
+
64
+ return {
65
+ originalCount: digits(textOf('.weui-desktop-user_sum.original_cnt')),
66
+ totalUsers: digits(cgiData.total_friend_cnt) ?? digits(textOf('.weui-desktop-user_num .weui-desktop-user_sum')),
67
+ yesterdayReads: digits(summary.pv) ?? reads.value,
68
+ yesterdayReadsChangePct: digits(summary.pvAnalysis && summary.pvAnalysis.changeRate) ?? reads.changePct,
69
+ yesterdayShares: digits(summary.share) ?? shares.value,
70
+ yesterdayNewFollowers: digits(summary.subscribe) ?? followers.value,
71
+ statRange: statRange ? statRange.trim().replace(/\\s+/g, ' ') : null,
72
+ };
73
+ })()`;
74
+
75
+ /**
76
+ * @param {unknown} payload
77
+ * @returns {HomeOverview}
78
+ */
79
+ export function normalizeHomeOverview(payload) {
80
+ if (!payload || typeof payload !== 'object') {
81
+ throw new CommandExecutionError(
82
+ 'WeChat home overview returned an unreadable payload',
83
+ 'Reload the Official Account home page and run the command again.',
84
+ );
85
+ }
86
+ const raw = /** @type {Record<string, unknown>} */ (payload);
87
+ const number = key => (Number.isFinite(raw[key]) ? Number(raw[key]) : null);
88
+ const overview = {
89
+ originalCount: number('originalCount'),
90
+ totalUsers: number('totalUsers'),
91
+ yesterdayReads: number('yesterdayReads'),
92
+ yesterdayReadsChangePct: number('yesterdayReadsChangePct'),
93
+ yesterdayShares: number('yesterdayShares'),
94
+ yesterdayNewFollowers: number('yesterdayNewFollowers'),
95
+ statRange: typeof raw.statRange === 'string' && raw.statRange.trim()
96
+ ? raw.statRange.trim().replace(/\s+/g, ' ')
97
+ : null,
98
+ };
99
+
100
+ const metrics = [
101
+ overview.originalCount,
102
+ overview.totalUsers,
103
+ overview.yesterdayReads,
104
+ overview.yesterdayShares,
105
+ overview.yesterdayNewFollowers,
106
+ ];
107
+ if (metrics.every(value => value === null)) {
108
+ throw new CommandExecutionError(
109
+ 'WeChat home overview exposed no counters',
110
+ 'The home dashboard layout may have changed; check that the Official Account home page renders its data panel.',
111
+ );
112
+ }
113
+ return overview;
114
+ }
@@ -0,0 +1,51 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { resolveBrowserCredentials } from './_wechat/auth-session.js';
3
+ import {
4
+ HOME_OVERVIEW_SCRIPT,
5
+ buildHomeUrl,
6
+ normalizeHomeOverview,
7
+ } from './_wechat/home-overview.js';
8
+
9
+ const DOMAIN = 'mp.weixin.qq.com';
10
+
11
+ const COLUMNS = [
12
+ 'original_count',
13
+ 'total_users',
14
+ 'yesterday_reads',
15
+ 'yesterday_reads_change_pct',
16
+ 'yesterday_shares',
17
+ 'yesterday_new_followers',
18
+ 'stat_range',
19
+ ];
20
+
21
+ export const homeOverviewCommand = cli({
22
+ site: 'weixin',
23
+ name: 'home-overview',
24
+ access: 'read',
25
+ domain: DOMAIN,
26
+ aliases: ['overview', 'dashboard', 'fans'],
27
+ description: '读取公众号首页概览:原创内容、总用户数(粉丝数)、昨日阅读/分享/新增关注人数及阅读增长百分比;账号级昨日汇总,单篇文章数据请用 published',
28
+ strategy: Strategy.COOKIE,
29
+ browser: true,
30
+ navigateBefore: false,
31
+ args: [
32
+ { name: 'settle', type: 'int', default: 3, help: '打开首页后等待渲染的秒数' },
33
+ ],
34
+ columns: COLUMNS,
35
+ func: async (page, args) => {
36
+ const { token } = await resolveBrowserCredentials(page);
37
+ await page.goto(buildHomeUrl(token));
38
+ const settle = Number(args.settle ?? 3);
39
+ await page.wait(Number.isFinite(settle) && settle > 0 ? settle : 3);
40
+ const overview = normalizeHomeOverview(await page.evaluate(HOME_OVERVIEW_SCRIPT));
41
+ return [{
42
+ original_count: overview.originalCount,
43
+ total_users: overview.totalUsers,
44
+ yesterday_reads: overview.yesterdayReads,
45
+ yesterday_reads_change_pct: overview.yesterdayReadsChangePct,
46
+ yesterday_shares: overview.yesterdayShares,
47
+ yesterday_new_followers: overview.yesterdayNewFollowers,
48
+ stat_range: overview.statRange,
49
+ }];
50
+ },
51
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.30",
3
+ "version": "2.1.32",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },