@sovovs/bycli 2.1.62 → 2.1.65

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
@@ -30243,6 +30243,251 @@
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
+ "retention"
30338
+ ],
30339
+ "type": "js",
30340
+ "modulePath": "weixin-channels/post-detail.js",
30341
+ "sourceFile": "weixin-channels/post-detail.js",
30342
+ "navigateBefore": false
30343
+ },
30344
+ {
30345
+ "site": "weixin-channels",
30346
+ "name": "posts",
30347
+ "description": "列出所选发布时间内的视频号作品及后台累计指标,默认最近 30 天,最多 20 条",
30348
+ "access": "read",
30349
+ "domain": "channels.weixin.qq.com",
30350
+ "strategy": "cookie",
30351
+ "browser": true,
30352
+ "args": [
30353
+ {
30354
+ "name": "days",
30355
+ "type": "int",
30356
+ "required": false,
30357
+ "valueRequired": true,
30358
+ "help": "截至上海昨日的发布时间天数(默认 30);不可与自定义日期同时使用"
30359
+ },
30360
+ {
30361
+ "name": "start-date",
30362
+ "type": "str",
30363
+ "required": false,
30364
+ "valueRequired": true,
30365
+ "help": "作品发布时间起始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
30366
+ },
30367
+ {
30368
+ "name": "end-date",
30369
+ "type": "str",
30370
+ "required": false,
30371
+ "valueRequired": true,
30372
+ "help": "作品发布时间结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
30373
+ },
30374
+ {
30375
+ "name": "limit",
30376
+ "type": "int",
30377
+ "default": 20,
30378
+ "required": false,
30379
+ "valueRequired": true,
30380
+ "help": "最多返回的作品数(1–1000 的整数,默认 20)"
30381
+ }
30382
+ ],
30383
+ "columns": [
30384
+ "id",
30385
+ "title",
30386
+ "published_at",
30387
+ "views",
30388
+ "recommendations",
30389
+ "likes",
30390
+ "comments",
30391
+ "shares",
30392
+ "follows",
30393
+ "completion_rate",
30394
+ "avg_play_seconds"
30395
+ ],
30396
+ "type": "js",
30397
+ "modulePath": "weixin-channels/posts.js",
30398
+ "sourceFile": "weixin-channels/posts.js",
30399
+ "navigateBefore": false
30400
+ },
30401
+ {
30402
+ "site": "weixin-channels",
30403
+ "name": "sources",
30404
+ "description": "按视频号后台返回的来源统计账号指标,默认最近 7 天,截至昨日",
30405
+ "access": "read",
30406
+ "domain": "channels.weixin.qq.com",
30407
+ "strategy": "cookie",
30408
+ "browser": true,
30409
+ "args": [
30410
+ {
30411
+ "name": "days",
30412
+ "type": "int",
30413
+ "required": false,
30414
+ "valueRequired": true,
30415
+ "help": "截至上海昨日的天数(默认 7);不可与自定义日期同时使用"
30416
+ },
30417
+ {
30418
+ "name": "start-date",
30419
+ "type": "str",
30420
+ "required": false,
30421
+ "valueRequired": true,
30422
+ "help": "开始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
30423
+ },
30424
+ {
30425
+ "name": "end-date",
30426
+ "type": "str",
30427
+ "required": false,
30428
+ "valueRequired": true,
30429
+ "help": "结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
30430
+ }
30431
+ ],
30432
+ "columns": [
30433
+ "source",
30434
+ "views",
30435
+ "recommendations",
30436
+ "likes",
30437
+ "comments",
30438
+ "shares",
30439
+ "follows"
30440
+ ],
30441
+ "type": "js",
30442
+ "modulePath": "weixin-channels/sources.js",
30443
+ "sourceFile": "weixin-channels/sources.js",
30444
+ "navigateBefore": false
30445
+ },
30446
+ {
30447
+ "site": "weixin-channels",
30448
+ "name": "trend",
30449
+ "description": "读取视频号账号逐日指标趋势,默认最近 7 天,截至昨日",
30450
+ "access": "read",
30451
+ "domain": "channels.weixin.qq.com",
30452
+ "strategy": "cookie",
30453
+ "browser": true,
30454
+ "args": [
30455
+ {
30456
+ "name": "days",
30457
+ "type": "int",
30458
+ "required": false,
30459
+ "valueRequired": true,
30460
+ "help": "截至上海昨日的天数(默认 7);不可与自定义日期同时使用"
30461
+ },
30462
+ {
30463
+ "name": "start-date",
30464
+ "type": "str",
30465
+ "required": false,
30466
+ "valueRequired": true,
30467
+ "help": "开始日期 YYYY-MM-DD(包含),须同时提供 --end-date"
30468
+ },
30469
+ {
30470
+ "name": "end-date",
30471
+ "type": "str",
30472
+ "required": false,
30473
+ "valueRequired": true,
30474
+ "help": "结束日期 YYYY-MM-DD(包含),最晚为上海昨日"
30475
+ }
30476
+ ],
30477
+ "columns": [
30478
+ "date",
30479
+ "views",
30480
+ "recommendations",
30481
+ "likes",
30482
+ "comments",
30483
+ "shares",
30484
+ "follows"
30485
+ ],
30486
+ "type": "js",
30487
+ "modulePath": "weixin-channels/trend.js",
30488
+ "sourceFile": "weixin-channels/trend.js",
30489
+ "navigateBefore": false
30490
+ },
30246
30491
  {
30247
30492
  "site": "weread",
30248
30493
  "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,255 @@
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
+ return data.dataByTabtype.map((item) => {
178
+ if (!isRecord(item) || item.tabType == null || typeof item.tabTypeName !== 'string' || !isRecord(item.data)) {
179
+ throw new CommandExecutionError('Weixin Channels returned a source without its identity or metrics');
180
+ }
181
+ if (typeof item.tabType !== 'string' && !(Number.isSafeInteger(item.tabType) && item.tabType >= 0)) {
182
+ throw new CommandExecutionError('Weixin Channels returned an invalid source identity');
183
+ }
184
+ return {
185
+ ...normalizeOverview({ totalData: item.data }, range),
186
+ source_id: item.tabType,
187
+ source: item.tabTypeName,
188
+ };
189
+ });
190
+ }
191
+
192
+ function postId(value) {
193
+ if (typeof value === 'string' && value.trim()) return value;
194
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value);
195
+ throw new CommandExecutionError('Weixin Channels returned a post without a stable objectId');
196
+ }
197
+
198
+ function publishedAt(value) {
199
+ const seconds = counter(value, 'createTime');
200
+ if (seconds === null) return null;
201
+ const ms = seconds * 1000 + SHANGHAI_OFFSET_MS;
202
+ if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) {
203
+ throw new CommandExecutionError('Weixin Channels returned an invalid createTime timestamp');
204
+ }
205
+ return `${new Date(ms).toISOString().slice(0, 19)}+08:00`;
206
+ }
207
+
208
+ export function normalizePost(item) {
209
+ if (!isRecord(item)) throw new CommandExecutionError('Weixin Channels returned a malformed post');
210
+ const title = typeof item.desc === 'string' ? item.desc
211
+ : (typeof item.desc?.description === 'string' ? item.desc.description : '');
212
+ return {
213
+ id: postId(item.objectId),
214
+ title,
215
+ published_at: publishedAt(item.createTime),
216
+ ...Object.fromEntries(Object.entries(POST_COUNTERS).map(([name, key]) => [name, counter(item[key], key)])),
217
+ // The verified API unit is a 0..1 ratio; business output uses percent.
218
+ completion_rate: percentage(item.fullPlayRate, 'fullPlayRate'),
219
+ avg_play_seconds: roundTwo(decimalMetric(item.avgPlayTimeSec, 'avgPlayTimeSec')),
220
+ };
221
+ }
222
+
223
+ function normalizeRetention(data) {
224
+ if (!isRecord(data) || !Array.isArray(data.items)) {
225
+ throw new CommandExecutionError('Weixin Channels returned malformed retention metrics');
226
+ }
227
+ return {
228
+ video_seconds: counter(data.videoLen, 'videoLen', false),
229
+ max_loss_second: counter(data.maxLostRateSec, 'maxLostRateSec', false),
230
+ points: Array.from(data.items, (item) => {
231
+ if (!isRecord(item)) throw new CommandExecutionError('Weixin Channels returned a malformed retention point');
232
+ return {
233
+ second: counter(item.sec, 'retention second', false),
234
+ retained_pct: percentage(item.rate, 'retention', false),
235
+ };
236
+ }),
237
+ };
238
+ }
239
+
240
+ export function normalizeDetail(data, retention, id, range) {
241
+ if (!isRecord(data) || !Array.isArray(data.feedData) || data.feedData.length !== 1 || !isRecord(data.feedData[0])) {
242
+ throw new CommandExecutionError('Weixin Channels returned an unexpected single-post detail payload');
243
+ }
244
+ const feed = data.feedData[0];
245
+ return {
246
+ id: postId(id),
247
+ start_date: range.startDate,
248
+ end_date: range.endDate,
249
+ overview: normalizeOverview(feed, range),
250
+ trend: normalizeTrend(feed, range),
251
+ sources: normalizeSources(feed, range),
252
+ retention: normalizeRetention(retention),
253
+ fans_breakdown_available: !data.notExistFansData,
254
+ };
255
+ }
@@ -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', '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', '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. Anti-debugger statement trap
184
- // Sites inject debugger statements to detect DevTools/CDP.
185
- // When a CDP debugger is attached, the statement pauses execution
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
- // Patch eval to strip debugger
222
- const _origEval = window.eval;
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)
@@ -9,6 +9,9 @@ commands.
9
9
  - `debugger`: sends CDP commands to byCLI-controlled or bound tabs.
10
10
  - `tabs` / `tabGroups`: manages the dedicated byCLI automation container and
11
11
  reports selected tab metadata back to the CLI.
12
+ - `system.display`: reads logical display work areas so owned windows fit the
13
+ usable screen space, including headless virtual displays. It does not change
14
+ display settings.
12
15
  - `cookies`: reads cookies for browser-backed adapters that need authenticated
13
16
  fetches.
14
17
  - `downloads`: surfaces download lifecycle to `bycli browser wait download`.
@@ -17,6 +20,22 @@ commands.
17
20
  filters by the command's filename/URL pattern and timeout, and does not modify,
18
21
  redirect, or persist browser download history.
19
22
 
23
+ ## Owned Window Placement
24
+
25
+ New byCLI windows use up to 1280 × 900 logical pixels, capped by the selected
26
+ display's work area. The last-focused normal Chrome window selects the display;
27
+ the primary display is the fallback. Existing owned normal windows stay on the
28
+ display with their largest intersection, keeping smaller sizes and positions
29
+ that already fit. Bounds are corrected on reuse, lease restoration and display
30
+ changes without changing focus. Borrowed user windows and minimized, maximized
31
+ or fullscreen window states are preserved.
32
+ Dragging an owned tab into a user window does not transfer window ownership
33
+ during lease restoration. A move, resize or state change while display metadata
34
+ is being read also takes precedence over that pending correction.
35
+
36
+ If display metadata is unavailable, byCLI logs a warning. New windows use
37
+ Chrome's natural size and placement, and existing windows retain their bounds.
38
+
20
39
  Suggested Chrome Web Store justification for `downloads`:
21
40
 
22
41
  > This extension uses `chrome.downloads` to surface download lifecycle
@@ -1558,6 +1558,46 @@ function checkUrlSyntax(input, opts = {}) {
1558
1558
  return { ok: true, url: parsed.toString(), hostname, isIpLiteral: false };
1559
1559
  }
1560
1560
 
1561
+ function hasUsableBounds(bounds) {
1562
+ return !!bounds && Number.isFinite(bounds.left) && Number.isFinite(bounds.top) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width >= 1 && bounds.height >= 1;
1563
+ }
1564
+ function selectWindowWorkArea(displays, reference) {
1565
+ const usable = displays.filter((display) => display.isEnabled && display.activeState !== "inactive" && hasUsableBounds(display.workArea));
1566
+ if (usable.length === 0) return null;
1567
+ let selected = usable.find((display) => display.isPrimary) ?? usable[0];
1568
+ let largestIntersection = 0;
1569
+ if (hasUsableBounds(reference)) {
1570
+ for (const display of usable) {
1571
+ const area = display.workArea;
1572
+ const width = Math.max(0, Math.min(reference.left + reference.width, area.left + area.width) - Math.max(reference.left, area.left));
1573
+ const height = Math.max(0, Math.min(reference.top + reference.height, area.top + area.height) - Math.max(reference.top, area.top));
1574
+ const intersection = width * height;
1575
+ if (intersection > largestIntersection) {
1576
+ largestIntersection = intersection;
1577
+ selected = display;
1578
+ }
1579
+ }
1580
+ }
1581
+ return selected.workArea;
1582
+ }
1583
+ function createOwnedWindowBounds(area) {
1584
+ return {
1585
+ left: area.left,
1586
+ top: area.top,
1587
+ width: Math.min(1280, area.width),
1588
+ height: Math.min(900, area.height)
1589
+ };
1590
+ }
1591
+ function constrainOwnedWindowBounds(window, area) {
1592
+ if (!hasUsableBounds(window)) return null;
1593
+ const width = Math.min(window.width, area.width);
1594
+ const height = Math.min(window.height, area.height);
1595
+ const left = Math.min(Math.max(window.left, area.left), area.left + area.width - width);
1596
+ const top = Math.min(Math.max(window.top, area.top), area.top + area.height - height);
1597
+ if (width === window.width && height === window.height && left === window.left && top === window.top) return null;
1598
+ return { left, top, width, height };
1599
+ }
1600
+
1561
1601
  const EXTENSION_CAPABILITIES = ["focus-window-v1", "ima-reader-v1"];
1562
1602
  let ws = null;
1563
1603
  let reconnectTimer = null;
@@ -2047,7 +2087,86 @@ async function focusOwnedWindowIfRequested(windowId, mode) {
2047
2087
  if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => {
2048
2088
  });
2049
2089
  }
2090
+ async function getOwnedWindowWorkArea(reference) {
2091
+ try {
2092
+ if (typeof chrome.system?.display?.getInfo !== "function") throw new Error("system.display API unavailable");
2093
+ const displays = await chrome.system.display.getInfo();
2094
+ const area = selectWindowWorkArea(displays, reference);
2095
+ if (!area) throw new Error("no enabled displays with a valid work area");
2096
+ return area;
2097
+ } catch (err) {
2098
+ console.warn(`[bycli] Unable to query display work area: ${err instanceof Error ? err.message : String(err)}`);
2099
+ return null;
2100
+ }
2101
+ }
2102
+ async function getNewOwnedWindowBounds() {
2103
+ let reference;
2104
+ try {
2105
+ if (typeof chrome.windows.getLastFocused === "function") {
2106
+ const lastFocused = await chrome.windows.getLastFocused({ windowTypes: ["normal"] });
2107
+ if (lastFocused.type === "normal") reference = lastFocused;
2108
+ }
2109
+ } catch {
2110
+ }
2111
+ const area = await getOwnedWindowWorkArea(reference);
2112
+ return area ? createOwnedWindowBounds(area) : void 0;
2113
+ }
2114
+ const ownedWindowGeometryUpdates = /* @__PURE__ */ new Map();
2115
+ const excludedOwnedWindowIds = /* @__PURE__ */ new Set();
2116
+ function isKnownOwnedWindow(windowId) {
2117
+ return Object.values(ownedContainers).some((container) => container.windowId === windowId) || [...automationSessions.values()].some((session) => session.owned && session.windowId === windowId);
2118
+ }
2119
+ function rememberOwnedTabDrift(tab, ownedWindowId) {
2120
+ if (tab.windowId !== ownedWindowId && !isKnownOwnedWindow(tab.windowId)) {
2121
+ excludedOwnedWindowIds.add(tab.windowId);
2122
+ }
2123
+ }
2124
+ async function rememberOwnedGroupDrift(groupId, ownedWindowId) {
2125
+ if (groupId === null) return;
2126
+ try {
2127
+ const group = await chrome.tabGroups.get(groupId);
2128
+ if (group.windowId !== ownedWindowId && !isKnownOwnedWindow(group.windowId)) {
2129
+ excludedOwnedWindowIds.add(group.windowId);
2130
+ }
2131
+ } catch {
2132
+ }
2133
+ }
2134
+ async function constrainOwnedWindowGeometry(windowId) {
2135
+ const previous = ownedWindowGeometryUpdates.get(windowId) ?? Promise.resolve();
2136
+ const next = previous.catch(() => void 0).then(async () => {
2137
+ if (!isKnownOwnedWindow(windowId)) return;
2138
+ try {
2139
+ const window = await chrome.windows.get(windowId);
2140
+ if (window.type !== "normal" || window.state !== "normal") return;
2141
+ const originalBounds = { left: window.left, top: window.top, width: window.width, height: window.height };
2142
+ const area = await getOwnedWindowWorkArea(window);
2143
+ if (!area || !isKnownOwnedWindow(windowId)) return;
2144
+ const current = await chrome.windows.get(windowId);
2145
+ if (!isKnownOwnedWindow(windowId) || current.type !== "normal" || current.state !== "normal" || current.left !== originalBounds.left || current.top !== originalBounds.top || current.width !== originalBounds.width || current.height !== originalBounds.height) return;
2146
+ const bounds = constrainOwnedWindowBounds(current, area);
2147
+ if (bounds) await chrome.windows.update(windowId, bounds);
2148
+ } catch (err) {
2149
+ console.warn(`[bycli] Failed to constrain owned window ${windowId} geometry: ${err instanceof Error ? err.message : String(err)}`);
2150
+ }
2151
+ });
2152
+ const tracked = next.finally(() => {
2153
+ if (ownedWindowGeometryUpdates.get(windowId) === tracked) ownedWindowGeometryUpdates.delete(windowId);
2154
+ });
2155
+ ownedWindowGeometryUpdates.set(windowId, tracked);
2156
+ await tracked;
2157
+ }
2158
+ chrome.system?.display?.onDisplayChanged?.addListener(async () => {
2159
+ const ids = /* @__PURE__ */ new Set();
2160
+ for (const container of Object.values(ownedContainers)) {
2161
+ if (container.windowId !== null) ids.add(container.windowId);
2162
+ }
2163
+ for (const session of automationSessions.values()) {
2164
+ if (session.owned) ids.add(session.windowId);
2165
+ }
2166
+ await Promise.all([...ids].map(constrainOwnedWindowGeometry));
2167
+ });
2050
2168
  async function toOwnedContainerDiscoveryCandidate(group) {
2169
+ if (excludedOwnedWindowIds.has(group.windowId)) return null;
2051
2170
  try {
2052
2171
  const chromeWindow = await chrome.windows.get(group.windowId);
2053
2172
  const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id);
@@ -2074,6 +2193,7 @@ async function discoverOwnedContainerFromTabGroup(role) {
2074
2193
  if (container.groupId !== null) {
2075
2194
  try {
2076
2195
  const group = await chrome.tabGroups.get(container.groupId);
2196
+ if (excludedOwnedWindowIds.has(group.windowId)) throw new Error("Owned group moved to a user window");
2077
2197
  await chrome.windows.get(group.windowId);
2078
2198
  container.windowId = group.windowId;
2079
2199
  return { windowId: group.windowId, groupId: group.id };
@@ -2139,8 +2259,11 @@ async function ensureOwnedContainerWindow(role, initialUrl, mode = "background")
2139
2259
  async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "background") {
2140
2260
  const container = ownedContainers[role];
2141
2261
  if (container.windowId !== null) {
2262
+ const knownWindowId = container.windowId;
2263
+ const knownGroupId = container.groupId;
2142
2264
  try {
2143
2265
  await chrome.windows.get(container.windowId);
2266
+ await constrainOwnedWindowGeometry(container.windowId);
2144
2267
  await focusOwnedWindowIfRequested(container.windowId, mode);
2145
2268
  const initialTabId2 = await findReusableOwnedContainerTab(container.windowId, await getOwnedContainerGroupId(role, container.windowId));
2146
2269
  await ensureOwnedContainerTabGroup(role, container.windowId, [initialTabId2]);
@@ -2149,12 +2272,16 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back
2149
2272
  initialTabId: initialTabId2
2150
2273
  };
2151
2274
  } catch {
2152
- container.windowId = null;
2153
- container.groupId = null;
2275
+ await rememberOwnedGroupDrift(knownGroupId, knownWindowId);
2276
+ if (container.windowId === knownWindowId && container.groupId === knownGroupId) {
2277
+ container.windowId = null;
2278
+ container.groupId = null;
2279
+ }
2154
2280
  }
2155
2281
  }
2156
2282
  const discovered = await discoverOwnedContainerFromTabGroup(role);
2157
2283
  if (discovered) {
2284
+ await constrainOwnedWindowGeometry(discovered.windowId);
2158
2285
  await focusOwnedWindowIfRequested(discovered.windowId, mode);
2159
2286
  const initialTabId2 = await findReusableOwnedContainerTab(discovered.windowId, discovered.groupId);
2160
2287
  await ensureOwnedContainerTabGroup(role, discovered.windowId, [initialTabId2]);
@@ -2168,8 +2295,7 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back
2168
2295
  const win = await chrome.windows.create({
2169
2296
  url: startUrl,
2170
2297
  focused: mode === "foreground",
2171
- width: 1280,
2172
- height: 900,
2298
+ ...await getNewOwnedWindowBounds(),
2173
2299
  type: "normal"
2174
2300
  });
2175
2301
  container.windowId = win.id;
@@ -2264,9 +2390,14 @@ async function getAutomationWindow(leaseKey, initialUrl) {
2264
2390
  const tabId = existing.preferredTabId;
2265
2391
  if (tabId !== null) {
2266
2392
  const tab = await chrome.tabs.get(tabId);
2267
- if (isDebuggableUrl(tab.url)) return tab.windowId;
2393
+ rememberOwnedTabDrift(tab, existing.windowId);
2394
+ if (isDebuggableUrl(tab.url) && tab.windowId === existing.windowId) {
2395
+ await constrainOwnedWindowGeometry(existing.windowId);
2396
+ return existing.windowId;
2397
+ }
2268
2398
  }
2269
2399
  await chrome.windows.get(existing.windowId);
2400
+ await constrainOwnedWindowGeometry(existing.windowId);
2270
2401
  return existing.windowId;
2271
2402
  } catch {
2272
2403
  await removeLeaseSession(leaseKey);
@@ -2277,10 +2408,15 @@ async function getAutomationWindow(leaseKey, initialUrl) {
2277
2408
  return (await ensureOwnedContainerWindow(role, initialUrl, getWindowModeForOwnedPage(leaseKey, url))).windowId;
2278
2409
  }
2279
2410
  chrome.windows.onRemoved.addListener(async (windowId) => {
2411
+ excludedOwnedWindowIds.delete(windowId);
2280
2412
  for (const container of Object.values(ownedContainers)) {
2281
2413
  if (container.windowId === windowId) {
2282
- container.windowId = null;
2283
- container.groupId = null;
2414
+ const knownGroupId = container.groupId;
2415
+ await rememberOwnedGroupDrift(knownGroupId, windowId);
2416
+ if (container.windowId === windowId && container.groupId === knownGroupId) {
2417
+ container.windowId = null;
2418
+ container.groupId = null;
2419
+ }
2284
2420
  }
2285
2421
  }
2286
2422
  for (const [leaseKey, expected] of [...automationSessions.entries()]) {
@@ -2535,8 +2671,14 @@ async function resolveTabUnlocked(tabId, leaseKey, initialUrl, blankFirst = fals
2535
2671
  try {
2536
2672
  const tab = await chrome.tabs.get(tabId);
2537
2673
  const session = existingSession;
2674
+ if (session?.owned && (session.preferredTabId === tabId || session.preferredTabId === null)) {
2675
+ rememberOwnedTabDrift(tab, session.windowId);
2676
+ }
2538
2677
  const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false;
2539
- if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
2678
+ if (isDebuggableUrl(tab.url) && matchesSession) {
2679
+ if (session?.owned && tab.windowId === session.windowId) await constrainOwnedWindowGeometry(session.windowId);
2680
+ return { tabId, tab };
2681
+ }
2540
2682
  if (session && !session.owned) {
2541
2683
  throw new CommandFailure(
2542
2684
  matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch",
@@ -2550,6 +2692,7 @@ async function resolveTabUnlocked(tabId, leaseKey, initialUrl, blankFirst = fals
2550
2692
  await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
2551
2693
  const moved = await chrome.tabs.get(tabId);
2552
2694
  if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
2695
+ if (session.owned) await constrainOwnedWindowGeometry(session.windowId);
2553
2696
  return { tabId, tab: moved };
2554
2697
  }
2555
2698
  } catch (moveErr) {
@@ -2576,7 +2719,11 @@ async function resolveTabUnlocked(tabId, leaseKey, initialUrl, blankFirst = fals
2576
2719
  const session = existingSession;
2577
2720
  try {
2578
2721
  const preferredTab = await chrome.tabs.get(existingPreferredTabId);
2579
- if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab };
2722
+ if (session.owned) rememberOwnedTabDrift(preferredTab, session.windowId);
2723
+ if (isDebuggableUrl(preferredTab.url)) {
2724
+ if (session.owned && preferredTab.windowId === session.windowId) await constrainOwnedWindowGeometry(session.windowId);
2725
+ return { tabId: preferredTab.id, tab: preferredTab };
2726
+ }
2580
2727
  if (!session.owned) {
2581
2728
  throw new CommandFailure(
2582
2729
  "bound_tab_not_debuggable",
@@ -2871,6 +3018,8 @@ async function handleFocusUnlocked(cmd, leaseKey) {
2871
3018
  if (activatedTab.id !== tabId || activatedTab.windowId !== focusLease.windowId) {
2872
3019
  return { id: cmd.id, ok: false, error: "Current automation session tab changed window during focus" };
2873
3020
  }
3021
+ await constrainOwnedWindowGeometry(focusLease.windowId);
3022
+ if (!isFocusLeaseCurrent(leaseKey, focusLease)) return focusLeaseChangedResult(cmd.id);
2874
3023
  await updateWindow(focusLease.windowId, { focused: true });
2875
3024
  if (!isFocusLeaseCurrent(leaseKey, focusLease)) return focusLeaseChangedResult(cmd.id);
2876
3025
  return { id: cmd.id, ok: true, data: { focused: true } };
@@ -2964,12 +3113,16 @@ async function handleTabSelectUnlocked(cmd, leaseKey) {
2964
3113
  if (!session || tab.windowId !== session.windowId) {
2965
3114
  return { id: cmd.id, ok: false, error: "Page is not in the automation container" };
2966
3115
  }
3116
+ if (session.owned) await constrainOwnedWindowGeometry(session.windowId);
2967
3117
  await chrome.tabs.update(cmdTabId, { active: true });
2968
3118
  return pageScopedResult(cmd.id, cmdTabId, { selected: true });
2969
3119
  }
2970
3120
  const tabs = await listAutomationWebTabsUnlocked(leaseKey);
2971
3121
  const target = tabs[cmd.index];
2972
3122
  if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
3123
+ if (lockedSession?.owned && target.windowId === lockedSession.windowId) {
3124
+ await constrainOwnedWindowGeometry(lockedSession.windowId);
3125
+ }
2973
3126
  await chrome.tabs.update(target.id, { active: true });
2974
3127
  return pageScopedResult(cmd.id, target.id, { selected: true });
2975
3128
  }
@@ -3285,13 +3438,20 @@ async function reconcileTargetLeaseRegistry() {
3285
3438
  for (const role of Object.keys(ownedContainers)) {
3286
3439
  ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null;
3287
3440
  ownedContainers[role].groupId = registry.ownedContainers[role]?.groupId ?? null;
3441
+ }
3442
+ for (const role of Object.keys(ownedContainers)) {
3288
3443
  const windowId = ownedContainers[role].windowId;
3444
+ const groupId = ownedContainers[role].groupId;
3289
3445
  if (windowId !== null) {
3290
3446
  try {
3291
3447
  await chrome.windows.get(windowId);
3448
+ await constrainOwnedWindowGeometry(windowId);
3292
3449
  } catch {
3293
- ownedContainers[role].windowId = null;
3294
- ownedContainers[role].groupId = null;
3450
+ await rememberOwnedGroupDrift(groupId, windowId);
3451
+ if (ownedContainers[role].windowId === windowId && ownedContainers[role].groupId === groupId) {
3452
+ ownedContainers[role].windowId = null;
3453
+ ownedContainers[role].groupId = null;
3454
+ }
3295
3455
  }
3296
3456
  }
3297
3457
  }
@@ -3302,6 +3462,8 @@ async function reconcileTargetLeaseRegistry() {
3302
3462
  try {
3303
3463
  const tab = await chrome.tabs.get(tabId);
3304
3464
  if (!isDebuggableUrl(tab.url)) continue;
3465
+ if (stored.owned && (!Number.isInteger(stored.windowId) || stored.windowId < 0)) continue;
3466
+ if (stored.owned) rememberOwnedTabDrift(tab, stored.windowId);
3305
3467
  if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") {
3306
3468
  sessionLifecycleOverrides.set(leaseKey, stored.lifecycle);
3307
3469
  }
@@ -3309,7 +3471,9 @@ async function reconcileTargetLeaseRegistry() {
3309
3471
  session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey),
3310
3472
  surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey),
3311
3473
  kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned",
3312
- windowId: tab.windowId,
3474
+ // A leased tab can be dragged into a user window while the worker is
3475
+ // stopped. Its current location does not transfer window ownership.
3476
+ windowId: stored.owned ? stored.windowId : tab.windowId,
3313
3477
  owned: stored.owned,
3314
3478
  preferredTabId: tabId
3315
3479
  });
@@ -3319,9 +3483,10 @@ async function reconcileTargetLeaseRegistry() {
3319
3483
  idleTimer: null,
3320
3484
  idleDeadlineAt: stored.idleDeadlineAt
3321
3485
  });
3322
- if (session.owned) {
3486
+ if (session.owned && tab.windowId === session.windowId) {
3323
3487
  const role = getOwnedWindowRole(leaseKey);
3324
3488
  if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId;
3489
+ await constrainOwnedWindowGeometry(tab.windowId);
3325
3490
  await ensureOwnedContainerTabGroup(role, tab.windowId, [tabId]);
3326
3491
  }
3327
3492
  const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "byCLI",
4
- "version": "2.1.23",
4
+ "version": "2.1.24",
5
5
  "description": "Browser automation bridge for the byCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -11,6 +11,7 @@
11
11
  "alarms",
12
12
  "storage",
13
13
  "tabGroups",
14
+ "system.display",
14
15
  "downloads"
15
16
  ],
16
17
  "host_permissions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.62",
3
+ "version": "2.1.65",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },