@sovovs/bycli 2.1.32 → 2.1.33

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
@@ -28440,6 +28440,17 @@
28440
28440
  "publishedAt",
28441
28441
  "url",
28442
28442
  "status",
28443
+ "readUsers",
28444
+ "avgReadMinutes",
28445
+ "finishedReadRatio",
28446
+ "newFollowers",
28447
+ "listenUsers",
28448
+ "shares",
28449
+ "zaikan",
28450
+ "likes",
28451
+ "rewardYuan",
28452
+ "comments",
28453
+ "collections",
28443
28454
  "markdownPath",
28444
28455
  "markdownSize",
28445
28456
  "dataPath",
@@ -0,0 +1,187 @@
1
+ import { CommandExecutionError } from '@sovovs/bycli/errors';
2
+
3
+ /**
4
+ * Reading and interaction counters shown on the article analysis detail page.
5
+ *
6
+ * @typedef {{
7
+ * readUsers: number | null,
8
+ * avgReadSeconds: number | null,
9
+ * avgReadMinutes: number | null,
10
+ * finishedReadRatio: number | null,
11
+ * newFollowers: number | null,
12
+ * listenUsers: number | null,
13
+ * listenPlays: number | null,
14
+ * shares: number | null,
15
+ * zaikan: number | null,
16
+ * likes: number | null,
17
+ * rewardYuan: number | null,
18
+ * comments: number | null,
19
+ * collections: number | null,
20
+ * }} ArticleMetrics
21
+ */
22
+
23
+ /**
24
+ * Reads `window.wx.cgiData.articleData.article_data_new`, which serves these
25
+ * counters as raw integers, and falls back to the rendered panels when the
26
+ * bootstrap payload is missing. Kept as a string so it can run through
27
+ * `page.evaluate` unchanged.
28
+ *
29
+ * The DOM fallback is deliberately positional: the interaction panel labels
30
+ * 在看 and 点赞 with bare SVG icons, so only their row order identifies them.
31
+ */
32
+ export const ARTICLE_METRICS_SCRIPT = `(() => {
33
+ const digits = value => {
34
+ if (value === null || value === undefined) return null;
35
+ const text = String(value).replace(/[\\s,%]/g, '');
36
+ return /^-?\\d+(?:\\.\\d+)?$/.test(text) ? Number(text) : null;
37
+ };
38
+ const panelRows = () => {
39
+ const heading = Array.from(document.querySelectorAll('.data_list.top_data_list'))
40
+ .find(node => String(node.textContent || '').indexOf('互动') !== -1);
41
+ const panel = heading ? heading.parentElement : null;
42
+ if (!panel) return [];
43
+ return Array.from(panel.querySelectorAll('.data_list'))
44
+ .filter(row => !row.classList.contains('top_data_list'))
45
+ .map(row => {
46
+ const label = row.querySelector('.list_left');
47
+ const value = row.querySelector('.data_num');
48
+ return {
49
+ label: label ? String(label.textContent || '').replace(/\\s+/g, ' ').trim() : '',
50
+ value: digits(value ? value.textContent : null),
51
+ };
52
+ });
53
+ };
54
+ const readingTile = keyword => {
55
+ const tile = Array.from(document.querySelectorAll('.bottom_data_tips')).find(node => {
56
+ const name = node.querySelector('.tips_name');
57
+ return name ? String(name.textContent || '').indexOf(keyword) !== -1 : false;
58
+ });
59
+ if (!tile) return null;
60
+ return digits(tile.querySelector('.tips_val_num') ? tile.querySelector('.tips_val_num').textContent : null);
61
+ };
62
+
63
+ const rows = panelRows();
64
+ const labelled = keyword => {
65
+ const row = rows.find(item => item.label.indexOf(keyword) !== -1);
66
+ return row ? row.value : null;
67
+ };
68
+ // 在看 and 点赞 render as icon-only rows between 分享 and 赞赏.
69
+ const iconRows = rows.filter(row => row.label === '');
70
+
71
+ const cgiData = (window.wx && window.wx.cgiData) || {};
72
+ const articleData = cgiData.articleData || {};
73
+ const metrics = articleData.article_data_new || {};
74
+ const readSeconds = digits(metrics.avg_article_read_time);
75
+ const domReadMinutes = readingTile('平均阅读时长');
76
+ const domFinishedPercent = readingTile('完读率');
77
+ const rewardFen = digits(metrics.praise_money);
78
+
79
+ return {
80
+ readUsers: digits(metrics.read_uv) ?? readingTile('阅读'),
81
+ avgReadSeconds: readSeconds ?? (domReadMinutes === null ? null : domReadMinutes * 60),
82
+ finishedReadRatio: digits(metrics.finished_read_pv_ratio)
83
+ ?? (domFinishedPercent === null ? null : domFinishedPercent / 100),
84
+ newFollowers: digits(metrics.follow_after_read_uv) ?? readingTile('新增关注'),
85
+ listenUsers: digits(metrics.listen_uv) ?? readingTile('听全文'),
86
+ listenPlays: digits(metrics.listen_pv),
87
+ shares: digits(metrics.share_uv) ?? labelled('分享'),
88
+ zaikan: digits(metrics.zaikan_cnt) ?? (iconRows[0] ? iconRows[0].value : null),
89
+ likes: digits(metrics.like_cnt) ?? (iconRows[1] ? iconRows[1].value : null),
90
+ rewardFen,
91
+ rewardYuanFallback: labelled('赞赏'),
92
+ comments: digits(metrics.comment_cnt) ?? labelled('留言'),
93
+ collections: digits(metrics.collection_uv) ?? labelled('收藏'),
94
+ };
95
+ })()`;
96
+
97
+ function finiteNumber(value) {
98
+ return Number.isFinite(value) ? Number(value) : null;
99
+ }
100
+
101
+ function roundTo(value, digits) {
102
+ if (value === null) return null;
103
+ const factor = 10 ** digits;
104
+ return Math.round(value * factor) / factor;
105
+ }
106
+
107
+ /**
108
+ * @param {unknown} payload
109
+ * @returns {ArticleMetrics}
110
+ */
111
+ export function normalizeArticleMetrics(payload) {
112
+ if (!payload || typeof payload !== 'object') {
113
+ throw new CommandExecutionError(
114
+ 'WeChat article metrics returned an unreadable payload',
115
+ 'Reload the article analysis page and run the command again.',
116
+ );
117
+ }
118
+ const raw = /** @type {Record<string, unknown>} */ (payload);
119
+ const number = key => finiteNumber(raw[key]);
120
+
121
+ const avgReadSeconds = number('avgReadSeconds');
122
+ // `praise_money` arrives in fen; the page divides it by 100 before display.
123
+ const rewardFen = number('rewardFen');
124
+ const rewardYuan = rewardFen === null ? number('rewardYuanFallback') : rewardFen / 100;
125
+
126
+ const metrics = {
127
+ readUsers: number('readUsers'),
128
+ avgReadSeconds,
129
+ avgReadMinutes: roundTo(avgReadSeconds === null ? null : avgReadSeconds / 60, 2),
130
+ finishedReadRatio: roundTo(number('finishedReadRatio'), 6),
131
+ newFollowers: number('newFollowers'),
132
+ listenUsers: number('listenUsers'),
133
+ listenPlays: number('listenPlays'),
134
+ shares: number('shares'),
135
+ zaikan: number('zaikan'),
136
+ likes: number('likes'),
137
+ rewardYuan: roundTo(rewardYuan, 2),
138
+ comments: number('comments'),
139
+ collections: number('collections'),
140
+ };
141
+
142
+ if (Object.values(metrics).every(value => value === null)) {
143
+ throw new CommandExecutionError(
144
+ 'WeChat article metrics exposed no counters',
145
+ 'The analysis layout may have changed; check that the article detail page renders its data panels.',
146
+ );
147
+ }
148
+ return metrics;
149
+ }
150
+
151
+ /** @param {ArticleMetrics} metrics */
152
+ export function articleMetricsSections(metrics) {
153
+ const percent = metrics.finishedReadRatio === null
154
+ ? null
155
+ : `${roundTo(metrics.finishedReadRatio * 100, 2)}%`;
156
+ return {
157
+ 阅读: {
158
+ 阅读人数: metrics.readUsers,
159
+ 平均阅读时长分钟: metrics.avgReadMinutes,
160
+ 完读率: percent,
161
+ 新增关注: metrics.newFollowers,
162
+ 听全文人数: metrics.listenUsers,
163
+ },
164
+ 互动: {
165
+ 分享人数: metrics.shares,
166
+ 在看人数: metrics.zaikan,
167
+ 点赞人数: metrics.likes,
168
+ 赞赏金额元: metrics.rewardYuan,
169
+ 留言条数: metrics.comments,
170
+ 收藏人数: metrics.collections,
171
+ },
172
+ };
173
+ }
174
+
175
+ /**
176
+ * @param {any} page
177
+ * @returns {Promise<ArticleMetrics|null>}
178
+ */
179
+ export async function collectArticleMetrics(page) {
180
+ if (typeof page?.evaluate !== 'function') return null;
181
+ try {
182
+ return normalizeArticleMetrics(await page.evaluate(ARTICLE_METRICS_SCRIPT));
183
+ } catch {
184
+ // The counters enrich the report; a layout change must not fail the download.
185
+ return null;
186
+ }
187
+ }
@@ -2,6 +2,7 @@ import { link, mkdir, stat, unlink, writeFile } from 'node:fs/promises';
2
2
  import { basename, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { CommandExecutionError } from '@sovovs/bycli/errors';
5
+ import { articleMetricsSections, collectArticleMetrics } from './article-metrics.js';
5
6
 
6
7
  const DOMAIN = 'mp.weixin.qq.com';
7
8
 
@@ -299,7 +300,7 @@ const RUNTIME_ANALYSIS_JS = `(() => {
299
300
  firstSelector: firstPage ? selectorFor(firstPage) : '',
300
301
  firstCurrent: Boolean(firstPage?.classList.contains('weui-desktop-pagination__num_current')),
301
302
  } : null;
302
- return { leaves, tables, charts, highchartsAriaCharts, controls, pagination, visibleText: text(document.body).slice(0, 12000) };
303
+ return { leaves, tables, charts, highchartsAriaCharts, controls, pagination };
303
304
  })()`;
304
305
 
305
306
  function runtimeToAnalysis(runtime) {
@@ -312,7 +313,6 @@ function runtimeToAnalysis(runtime) {
312
313
  }
313
314
  Object.assign(result, normalizeEchartsOptions(runtime.charts));
314
315
  Object.assign(result, normalizeHighchartsAriaCharts(runtime.highchartsAriaCharts));
315
- if (Object.keys(result).length === 0 && runtime.visibleText) result['可见页面内容'] = { 内容: runtime.visibleText };
316
316
  return result;
317
317
  }
318
318
 
@@ -353,9 +353,7 @@ function multimediaRuntimeToAnalysis(runtime, kind) {
353
353
  }
354
354
  if (Object.keys(details).length > 0) result['数据明细分析'] = details;
355
355
  } else {
356
- const listening = runtimeToAnalysis({ ...runtime, leaves: [] });
357
- delete listening['可见页面内容'];
358
- result['收听分析'] = listening;
356
+ result['收听分析'] = runtimeToAnalysis({ ...runtime, leaves: [] });
359
357
  }
360
358
  return result;
361
359
  }
@@ -452,9 +450,11 @@ export async function collectPublishAnalysis(page, { detailUrl, title, published
452
450
  if (captureStarted === false) throw new CommandExecutionError('WeChat publish analysis requires supported browser network capture');
453
451
  await page.goto(detailUrl);
454
452
  await page.wait?.(1000);
453
+ const metrics = await collectArticleMetrics(page);
455
454
  const capturedEntries = await page.readNetworkCapture();
456
455
  const payloads = extractAnalysisPayloads(capturedEntries);
457
456
  let data = Object.fromEntries(payloads.map(({ name, data: value }, index) => [index === 0 ? name : `${name}-${index + 1}`, value]));
457
+ if (metrics) Object.assign(data, articleMetricsSections(metrics));
458
458
  if (typeof page.evaluate === 'function') {
459
459
  Object.assign(data, await collectPeriodAnalysis(page));
460
460
  const videoUrl = await page.evaluate(`(() => [...document.querySelectorAll('a[href]')]
@@ -482,5 +482,5 @@ export async function collectPublishAnalysis(page, { detailUrl, title, published
482
482
  const content = formatAnalysisMarkdown({ title, publishedAt, data });
483
483
  const path = await publishMarkdown(resolve(outputDir), safeFilename(title), content);
484
484
  const info = await stat(path);
485
- return { status: 'saved', path, size: info.size };
485
+ return { status: 'saved', path, size: info.size, metrics };
486
486
  }
@@ -16,8 +16,14 @@ import {
16
16
  validatePublishedQuery,
17
17
  } from './_wechat/publish-records.js';
18
18
 
19
+ const METRIC_COLUMNS = [
20
+ 'readUsers', 'avgReadMinutes', 'finishedReadRatio', 'newFollowers', 'listenUsers',
21
+ 'shares', 'zaikan', 'likes', 'rewardYuan', 'comments', 'collections',
22
+ ];
23
+
19
24
  const COLUMNS = [
20
25
  'title', 'publishedAt', 'url', 'status',
26
+ ...METRIC_COLUMNS,
21
27
  'markdownPath', 'markdownSize', 'dataPath', 'dataSize', 'error',
22
28
  ];
23
29
 
@@ -128,11 +134,13 @@ export const downloadPublishDataCommand = cli({
128
134
 
129
135
  const status = dataResult && markdownResult ? 'downloaded'
130
136
  : dataResult || markdownResult ? 'partial' : 'failed';
137
+ const metrics = markdownResult?.metrics ?? null;
131
138
  return [{
132
139
  title: record.title,
133
140
  publishedAt: record.publishedAt,
134
141
  url: record.url,
135
142
  status,
143
+ ...Object.fromEntries(METRIC_COLUMNS.map(key => [key, metrics?.[key] ?? null])),
136
144
  markdownPath: markdownResult?.path ?? null,
137
145
  markdownSize: markdownResult?.size ?? null,
138
146
  dataPath: dataResult?.path ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.32",
3
+ "version": "2.1.33",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env bash
2
+ # 录制三端管理脚本
3
+ # daemon : 浏览器底座(19825),由 bycli 管理;扩展连这口
4
+ # be : Recorder Local Service(19826),同源托管真实工作台 UI(dashboard/dist)
5
+ # web : Umi dev server(8000),mock 模式,仅前端开发用(无真实录制)
6
+ #
7
+ # 用法:
8
+ # scripts/recorder.sh start [daemon|be|all] # 默认=真实录制环境(daemon+be,自动停 mock)
9
+ # scripts/recorder.sh start --mock # 仅此参数才起 mock 前端(web :8000,假数据)
10
+ # scripts/recorder.sh stop [daemon|be|web|all]
11
+ # scripts/recorder.sh restart [daemon|be|vnc|all] # restart all=daemon+be(不含 mock);改了 .env/dist 后用;vnc=删旧容器换新镜像
12
+ # scripts/recorder.sh status # 看三端
13
+ # scripts/recorder.sh build [core|be|ui|ext|all] # 重建 dist(改源码后;all 含扩展,需手动重载)
14
+ #
15
+ # 真实录制(带 LLM)启动:scripts/recorder.sh start → 打开 http://127.0.0.1:19826/workbench
16
+ #
17
+ # embedded_iframe 录制模式(P2,公开站页内嵌入;**本机默认开**):起 be 默认带 flag——
18
+ # EMBEDDED=0 scripts/recorder.sh start # 显式关闭页内嵌入模式
19
+ # IFRAME_FRAME_SRC=https://juejin.cn scripts/recorder.sh restart be # 只放该 origin(hardened)
20
+ # inline env 经 `env VAR=…` 注入,优先级高于 --env-file(.env 不覆盖已存在的 process env)。
21
+ #
22
+ # vnc 录制模式(容器内 Chromium+扩展+daemon,noVNC 投画面;**本机默认开**,需 podman + 镜像):
23
+ # scripts/recorder.sh build vnc # 构建容器镜像 bycli-verify:latest(需先 build ext + npm run build)
24
+ # scripts/recorder.sh restart vnc # 重启镜像:删旧容器(bycli-vnc),be 下次 bind 用新镜像重建(改镜像后用)
25
+ # VNC=0 scripts/recorder.sh restart be # 显式关闭 vnc 模式
26
+ # 选 VNC 模式后 be 自动 podman run 起容器、前端 iframe 投 noVNC 画面;录的数据走容器网关→be→合成链。
27
+ set -uo pipefail
28
+
29
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
30
+ RUN="$ROOT/.recorder-run"; mkdir -p "$RUN"
31
+ DAEMON_PORT=19825; BE_PORT=19826; WEB_PORT=8000
32
+
33
+ port_pid() { lsof -ti "tcp:$1" -sTCP:LISTEN 2>/dev/null | head -1; }
34
+ alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; }
35
+
36
+ # ───────────────────────── daemon(交给 bycli 管) ─────────────────────────
37
+ need_bycli() { command -v bycli >/dev/null || { echo "✗ bycli 不在 PATH(在仓库根 npm link)"; return 1; }; }
38
+ daemon_start() { need_bycli || return 1; bycli daemon start 2>&1 | tail -1; }
39
+ daemon_stop() { need_bycli && { bycli daemon stop 2>&1 | tail -1; } || true; }
40
+ daemon_restart() { need_bycli || return 1; bycli daemon restart 2>&1 | tail -1; }
41
+ daemon_status() { local p; p="$(port_pid $DAEMON_PORT)"; [ -n "$p" ] && echo "● daemon RUNNING :$DAEMON_PORT pid=$p" || echo "○ daemon stopped :$DAEMON_PORT"; }
42
+
43
+ # ───────────────────────── vnc(podman 容器,be 自动编排) ────────────────
44
+ # 容器名与 vncOrchestrator.ts 保持一致(BYCLI_VNC_CONTAINER 覆盖,默认 bycli-vnc)。
45
+ VNC_CONTAINER="${BYCLI_VNC_CONTAINER:-bycli-vnc}"
46
+ VNC_IMAGE="${BYCLI_VNC_IMAGE:-bycli-verify:latest}"
47
+ need_podman() { command -v podman >/dev/null || { echo "✗ podman 不在 PATH(VNC 模式需 podman)"; return 1; }; }
48
+ # 重启镜像:删旧容器,be 下次 bind 时用当前镜像重建(build vnc 换镜像后调用)。
49
+ vnc_restart() {
50
+ need_podman || return 1
51
+ [ -n "$(podman images -q "$VNC_IMAGE" 2>/dev/null)" ] || { echo "✗ 镜像 $VNC_IMAGE 不存在 → scripts/recorder.sh build vnc"; return 1; }
52
+ if [ -n "$(podman ps -aq -f "name=^${VNC_CONTAINER}$" 2>/dev/null)" ]; then
53
+ podman rm -f "$VNC_CONTAINER" >/dev/null 2>&1 && echo "✓ 已删旧容器 $VNC_CONTAINER(be 下次 bind 用新镜像 $VNC_IMAGE 重建)"
54
+ else
55
+ echo "○ 容器 $VNC_CONTAINER 未运行(be 下次 bind 会用新镜像 $VNC_IMAGE 新建)"
56
+ fi
57
+ }
58
+ vnc_stop() {
59
+ need_podman || return 1
60
+ if [ -n "$(podman ps -aq -f "name=^${VNC_CONTAINER}$" 2>/dev/null)" ]; then
61
+ podman rm -f "$VNC_CONTAINER" >/dev/null 2>&1 && echo "✓ vnc 容器 $VNC_CONTAINER 已删"
62
+ else echo "○ vnc 容器未运行"; fi
63
+ }
64
+ vnc_status() {
65
+ command -v podman >/dev/null || { echo "○ vnc (podman 未装)"; return; }
66
+ local st; st="$(podman inspect "$VNC_CONTAINER" --format '{{.State.Status}}' 2>/dev/null)"
67
+ [ -n "$st" ] && echo "● vnc $st container=$VNC_CONTAINER image=$VNC_IMAGE" || echo "○ vnc no container ($VNC_CONTAINER)"
68
+ }
69
+
70
+ # ───────────────────────── be(node 进程,PID 文件) ──────────────────────
71
+ be_start() {
72
+ [ -f "$ROOT/dashboard-be/dist/server.js" ] || { echo "✗ be 未构建 → scripts/recorder.sh build be"; return 1; }
73
+ [ -f "$ROOT/dashboard-be/.env" ] || { echo "✗ 缺 dashboard-be/.env → cp dashboard-be/.env.example dashboard-be/.env 并填值"; return 1; }
74
+ [ -d "$ROOT/dashboard/dist" ] || echo "⚠ dashboard/dist 不存在,be 将 API-only(无 UI)→ scripts/recorder.sh build ui"
75
+ if [ -n "$(port_pid $BE_PORT)" ]; then echo "● be 已在 :$BE_PORT(先 stop/restart)"; return 0; fi
76
+ # embedded_iframe 模式(P2):EMBEDDED=1 → 注入 flag 开 frame-src + 前端模式选项。
77
+ # 经 `env VAR=…` 内联注入,优先级高于 --env-file(.env 不覆盖已存在的 process env)。
78
+ # 三种录制模式默认全开(本机录制工作台);显式 EMBEDDED=0 / VNC=0 可单独关。
79
+ # 注:只在本机 be 启动注入,不动 recorder-core 的 fail-closed 发布默认(全局 CSP 安全底线不变)。
80
+ local envv=()
81
+ if [ "${EMBEDDED:-1}" = 1 ]; then
82
+ envv+=(FEATURE_EMBEDDED_IFRAME_RECORDING=1)
83
+ [ -n "${IFRAME_FRAME_SRC:-}" ] && envv+=("RECORDER_IFRAME_FRAME_SRC=$IFRAME_FRAME_SRC")
84
+ echo " ⚙ embedded_iframe 模式 ON${IFRAME_FRAME_SRC:+(frame-src=$IFRAME_FRAME_SRC)}"
85
+ fi
86
+ if [ "${VNC:-1}" = 1 ]; then
87
+ envv+=(FEATURE_VNC_RECORDING=1)
88
+ echo " ⚙ vnc 容器模式 ON(be 自动 podman run bycli-verify:latest;需先 build vnc)"
89
+ fi
90
+ ( cd "$ROOT" && nohup env ${envv[@]+"${envv[@]}"} node --env-file=dashboard-be/.env dashboard-be/dist/server.js >"$RUN/be.log" 2>&1 & echo $! >"$RUN/be.pid" )
91
+ sleep 1; be_status; echo " 日志: $RUN/be.log"
92
+ }
93
+ be_stop() {
94
+ # 端口权威:pid 文件 + 实际占 19826 的进程都杀(防重复实例残留)
95
+ local stopped=0 pf; pf="$(cat "$RUN/be.pid" 2>/dev/null)"
96
+ for pid in "$pf" "$(port_pid $BE_PORT)"; do
97
+ if alive "$pid"; then kill "$pid" 2>/dev/null; echo "✓ be 已停(pid=$pid)"; stopped=1; fi
98
+ done
99
+ [ "$stopped" = 0 ] && echo "○ be 未运行"
100
+ rm -f "$RUN/be.pid"
101
+ }
102
+ be_restart() { be_stop; sleep 1; be_start; }
103
+ be_status() { local p; p="$(port_pid $BE_PORT)"; [ -n "$p" ] && echo "● be RUNNING http://127.0.0.1:$BE_PORT/workbench pid=$p" || echo "○ be stopped :$BE_PORT"; }
104
+
105
+ # ───────────────────────── web(Umi dev,mock) ───────────────────────────
106
+ web_start() {
107
+ if [ -n "$(port_pid $WEB_PORT)" ]; then echo "● web 已在 :$WEB_PORT"; return 0; fi
108
+ ( cd "$ROOT/dashboard" && nohup npm run dev >"$RUN/web.log" 2>&1 & echo $! >"$RUN/web.pid" )
109
+ echo "✓ web 启动中(mock,http://127.0.0.1:$WEB_PORT) 日志: $RUN/web.log"
110
+ }
111
+ web_stop() {
112
+ local pid; pid="$(cat "$RUN/web.pid" 2>/dev/null)"; [ -z "$pid" ] && pid="$(port_pid $WEB_PORT)"
113
+ if alive "$pid"; then pkill -P "$pid" 2>/dev/null; kill "$pid" 2>/dev/null; echo "✓ web 已停"; else echo "○ web 未运行"; fi
114
+ rm -f "$RUN/web.pid"
115
+ }
116
+ web_restart() { web_stop; sleep 1; web_start; }
117
+ web_status() { local p; p="$(port_pid $WEB_PORT)"; [ -n "$p" ] && echo "● web RUNNING http://127.0.0.1:$WEB_PORT (mock) pid=$p" || echo "○ web stopped :$WEB_PORT (mock dev)"; }
118
+
119
+ # ───────────────────────── build ────────────────────────────────────────
120
+ do_build() {
121
+ case "${1:-all}" in
122
+ core) npm --prefix "$ROOT/packages/recorder-core" run build ;;
123
+ be) npm --prefix "$ROOT/dashboard-be" run build ;;
124
+ ui) ( cd "$ROOT/dashboard" && npm run build ) ;;
125
+ ext) ( cd "$ROOT/extension" && npm run build ) ;;
126
+ vnc) # VNC 录制模式容器镜像(Chromium+扩展+daemon+x11vnc+websockify+网关);be 起容器时复用 bycli-verify:latest。
127
+ command -v podman >/dev/null || { echo "✗ podman 不在 PATH(VNC 模式需 podman)"; return 1; }
128
+ [ -f "$ROOT/extension/dist/background.js" ] || { echo "✗ 扩展未构建 → scripts/recorder.sh build ext"; return 1; }
129
+ [ -f "$ROOT/dist/src/daemon.js" ] || { echo "✗ dist 未构建 → npm run build"; return 1; }
130
+ echo "▶ 构建 VNC 容器镜像 bycli-verify:latest(首次装 chromium 较慢)…"
131
+ ( cd "$ROOT" && podman build -f podman-verify/Dockerfile -t bycli-verify:latest . ) ;;
132
+ all) npm --prefix "$ROOT/packages/recorder-core" run build \
133
+ && npm --prefix "$ROOT/dashboard-be" run build \
134
+ && ( cd "$ROOT/dashboard" && npm run build ) \
135
+ && ( cd "$ROOT/extension" && npm run build ) \
136
+ && echo "↻ 扩展已重建 → chrome://extensions 重载 byCLI(确认版本号刷新)" ;;
137
+ *) echo "build: core|be|ui|ext|vnc|all"; return 1 ;;
138
+ esac
139
+ }
140
+
141
+ # ───────────────────────── dispatch ─────────────────────────────────────
142
+ action="${1:-}"; shift || true
143
+ case "$action" in
144
+ start)
145
+ # mock 仅在显式 --mock 时启动;其余参数视作服务名
146
+ mock=0; svcs=()
147
+ for a in "$@"; do if [ "$a" = "--mock" ]; then mock=1; else svcs+=("$a"); fi; done
148
+ if [ "$mock" = 1 ]; then
149
+ echo "▶ 启动【mock 前端】(web :$WEB_PORT,假数据,无真实录制)"
150
+ web_start
151
+ elif [ ${#svcs[@]} -eq 0 ]; then
152
+ # 默认 = 真实录制环境:停掉 mock web(防 :8000 误测)→ 起 daemon + be
153
+ echo "▶ 启动【真实录制环境】(daemon + be);mock web 若在跑将被停掉以免混淆"
154
+ [ -n "$(port_pid $WEB_PORT)" ] && web_stop
155
+ daemon_start; be_start
156
+ echo; echo "✅ 真实录制 → http://127.0.0.1:$BE_PORT/workbench(mock 需 start --mock)"
157
+ else
158
+ [ "${svcs[0]}" = "all" ] && svcs=(daemon be)
159
+ for t in "${svcs[@]}"; do
160
+ case "$t" in
161
+ daemon|be) "${t}_start" ;;
162
+ web) echo "✗ web 是 mock,请用:scripts/recorder.sh start --mock" ;;
163
+ *) echo "未知服务: $t(daemon|be|all,mock 用 --mock)" ;;
164
+ esac
165
+ done
166
+ fi ;;
167
+ stop|restart)
168
+ # all 语义:restart 只起真实环境(daemon+be,不复活 mock,与 start 默认一致);
169
+ # stop 则全停(含 mock web,teardown)。mock 启停一律显式 web/--mock。
170
+ if [ $# -eq 0 ]; then targets=(daemon be)
171
+ elif [ "${1:-}" = all ]; then
172
+ [ "$action" = stop ] && targets=(daemon be web) || targets=(daemon be)
173
+ else targets=("$@"); fi
174
+ [ "$action" = stop ] && targets=($(printf '%s\n' "${targets[@]}" | tail -r 2>/dev/null || printf '%s\n' "${targets[@]}"))
175
+ for t in "${targets[@]}"; do
176
+ case "$t" in daemon|be|web|vnc) "${t}_${action}" ;; *) echo "未知服务: $t(daemon|be|web|vnc|all)";; esac
177
+ done ;;
178
+ status)
179
+ daemon_status; be_status; web_status; vnc_status ;;
180
+ build)
181
+ do_build "${1:-all}" ;;
182
+ ""|-h|--help|help)
183
+ awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;;
184
+ *)
185
+ echo "未知命令: $action(start|stop|restart|status|build)"; exit 1 ;;
186
+ esac