@genduo/desk 0.0.0-stage → 0.4.0

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/README.md CHANGED
@@ -1,3 +1,187 @@
1
- # Temporary Holding Version
1
+ # 店铺采集 Agent(CLI)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **状态** 门店 CLI 登录授权已落地;**采集循环已落地**(`run.py` 0.4.0)| **下一店** 5RT2 | **中心** `https://dataanalyst.jp` | **POC** 7RT2 已绿
4
+
5
+ ## 目标 UX
6
+
7
+ 1. **超管先备齐**:每店 Agent token + R-Login 两组密码。
8
+ 2. **门店只拿到本 CLI**:运行 `login` 完成登录授权(写入 Keychain、心跳、可选装 LaunchAgent)。
9
+ 3. 不再以「每店手写 plist / 手工 security 命令」作为主路径。
10
+
11
+ ### 安装(门店主路径:npm)
12
+
13
+ 前置:macOS、Node ≥ 18、`python3`(`brew install python`)。
14
+ `--verify-rms` 另需:`pip3 install playwright && python3 -m playwright install chromium`。
15
+
16
+ ```bash
17
+ npm i -g @genduo/desk
18
+ genduo-desk login --shop 5RT2 --verify-rms --install-service
19
+ genduo-desk status
20
+ ```
21
+
22
+ 包名 `@genduo/desk`(全局命令 `genduo-desk`);版本见 `package.json` / `local_config.VERSION`(0.4.0)。
23
+ **Keychain service 名未改**(仍为 `dataanalyst-agent-token` / `dataanalyst/rms/...`),换包名不影响已写入的凭据。
24
+
25
+ ### 开发 / monorepo 路径(次要)
26
+
27
+ ```bash
28
+ # 仓库内直接跑,或 symlink 到 PATH(shim 命令名仍是 dataanalyst-agent)
29
+ ln -sf "$(pwd)/tools/collector-agent/dataanalyst-agent" /usr/local/bin/dataanalyst-agent
30
+ # 或:cd tools/collector-agent && npm link # → 全局命令 genduo-desk
31
+
32
+ genduo-desk login --shop 5RT2 --verify-rms --install-service
33
+ genduo-desk status
34
+ ```
35
+
36
+ ## 命令一览
37
+
38
+ | 命令 | 谁用 | 作用 |
39
+ |---|---|---|
40
+ | `login` | 门店 | 粘贴 token + 输入 R-Login 两组 → Keychain;可选心跳 / RMS 校验 / LaunchAgent |
41
+ | `status` | 门店 | Keychain 是否齐全、LaunchAgent、打一次心跳 |
42
+ | `heartbeat` | 门店 / 调试 | `POST /api/agent/heartbeat` + 拉 jobs |
43
+ | `install-service` | 门店 | 装 Agent + **caffeinate** LaunchAgent(默认) |
44
+ | `prevent-sleep` | 门店 / 超管 | 只装 `jp.dataanalyst.caffeinate`(`caffeinate -dims`),并打印 `pmset` 说明 |
45
+ | `uninstall-service` | 门店 | 卸 Agent **与** caffeinate LaunchAgent |
46
+ | `admin checklist` | 超管 | 打印某店开店清单(含签发 token 命令) |
47
+ | `admin set-rms` | 超管 | 交互写入本机 `dataanalyst/rms/<SHOP>-rlogin` / `-ai` |
48
+ | `admin write-bootstrap` | 超管 | 把 token 写成 600 一次性 JSON,供门店 `login --bootstrap` |
49
+
50
+ 常驻进程入口是 `run.py`(LaunchAgent 调用):进程内 sleep 循环,心跳 + 日次访问 + 月度 RPP → ingest。
51
+
52
+
53
+
54
+ ## 采集循环(run.py)
55
+
56
+ 调度(日历日用本机 `date.today()`;`daemon_state` 时间戳为 JST +09:00):
57
+
58
+ | 任务 | 间隔 | 说明 |
59
+ |---|---|---|
60
+ | 心跳 | ~12 分钟 | `POST /api/agent/heartbeat`;带上 `last_error` |
61
+ | 访问人数 | ~20 小时 | 近 14 天日次 → `POST /api/ingest/rms_access` |
62
+ | RPP 广告 | ~每天检查 | 当月(月初 1–3 日兼上月)→ `POST /api/ingest/rms_ads` |
63
+
64
+ ```bash
65
+ # 冒烟(一轮心跳+采集后退出)
66
+ python3 tools/collector-agent/run.py --once
67
+ # 只用仓库 .venv(含 playwright):
68
+ /Users/gd/workspace/dataanalyst/.venv/bin/python tools/collector-agent/run.py --once
69
+
70
+ # 常驻(前台调试;生产用 install-service)
71
+ python3 tools/collector-agent/run.py
72
+
73
+ # 只心跳 / ingest dry-run
74
+ python3 tools/collector-agent/run.py --once --skip-collect
75
+ python3 tools/collector-agent/run.py --once --dry-run-ingest
76
+ ```
77
+
78
+ 状态:`state/daemon_state.json`(`last_heartbeat` / `last_access_ok` / `last_ads_ok` / `last_error`,无密钥)。
79
+ 失败 payload 进 `state/buffer/`,下轮自动重试。日志:`logs/agent.log`(轮转)。
80
+
81
+ **LaunchAgent**:`KeepAlive` = 崩溃重启(配 `ThrottleInterval=30`),**不是**短周期拉起短命进程。`run.py` 必须进程内 sleep;装服务前请先 `--once` 验过。
82
+
83
+ 依赖:本机 Playwright Chromium + 本地代理(默认 `http://127.0.0.1:7897`,可用 `HTTPS_PROXY` / `DATAANALYST_PROXY` 覆盖);会话 `state/<SHOP>.json`。广告解析需 monorepo `pipeline.rms_ads.parse`。
84
+
85
+ ## 防休眠(重要)
86
+
87
+ 店铺常开 Mac 若进入睡眠/关机,采集会中断。CLI 分两层:
88
+
89
+ | 层级 | 谁做 | 做什么 |
90
+ |---|---|---|
91
+ | **用户级(CLI 自动)** | 门店跑 `login --install-service` / `install-service` / `prevent-sleep` | 安装 KeepAlive LaunchAgent `jp.dataanalyst.caffeinate` → `/usr/bin/caffeinate -dims`(防 display/idle/disk/system 休眠;登录会话内,重启后随用户登录再拉起) |
92
+ | **系统级(需 sudo)** | 超管或有管理员权限的门店同事 | CLI **不会静默 sudo**。需人工执行:`sudo pmset -a sleep 0 disksleep 0 displaysleep 0`;可选 `sudo systemsetup -setrestartpowerfailure on` |
93
+
94
+ ```bash
95
+ # 只要防休眠、暂不装 Agent 常驻:
96
+ genduo-desk prevent-sleep
97
+
98
+ # 开店主路径(装 Agent + caffeinate):
99
+ genduo-desk login --shop 5RT2 --verify-rms --install-service
100
+
101
+ # 只要 Agent、不要 caffeinate(不推荐):
102
+ genduo-desk install-service --skip-caffeinate
103
+ ```
104
+
105
+ `status` 会显示 `caffeinate` plist 是否已装、进程是否在跑。
106
+ 合盖笔记本另议;常开台式 + 登录用户是推荐形态。
107
+
108
+
109
+ ## Keychain 约定(勿改名)
110
+
111
+ | 用途 | service | account |
112
+ |---|---|---|
113
+ | Agent token | `dataanalyst-agent-token` | `$USER` |
114
+ | R-Login 第一组 | `dataanalyst/rms/<SHOP>-rlogin` | R-Login ID |
115
+ | Rakuten SSO 第二组 | `dataanalyst/rms/<SHOP>-ai` | 邮箱 |
116
+
117
+ 与包内 vendored `rms_browser_login.py --from-keychain`(源真相仍是 monorepo `tools/rms_browser_login.py`)、7RT2 POC 一致。
118
+ 本机非机密配置:`~/.config/dataanalyst-agent/config.json`(仅 `shop` / `base`)。
119
+
120
+ ## 中心契约
121
+
122
+ | 方法 | 路径 | 说明 |
123
+ |---|---|---|
124
+ | POST | `/api/ingest/rms_access` | 日次访问人数 |
125
+ | POST | `/api/ingest/rms_ads` | RPP/TDA 期间报表 |
126
+ | POST | `/api/agent/heartbeat` | 心跳 / 版本 / 错误 |
127
+ | GET | `/api/agent/jobs?shop=` | 领取补历史/重跑 |
128
+ | POST | `/api/agent/jobs/{id}/ack` | 回报任务结果 |
129
+
130
+ 鉴权:`Authorization: Bearer da_...`。每店 token:`name=agent-<SHOP>`,
131
+ `shops` 只勾本店,`scopes=collect:view,collect:edit`。明文只创建时显示一次。
132
+
133
+ ## 超管流程(先)
134
+
135
+ ```bash
136
+ # 1) 看清单
137
+ genduo-desk admin checklist --shop 5RT2
138
+
139
+ # 2) 中心签发 token(Web 或 VPS python -m app.tokens …);明文不要进 git/聊天
140
+ # 3) 可选:在店里 Mac 上预写 RMS
141
+ genduo-desk admin set-rms --shop 5RT2
142
+
143
+ # 4) 可选:做一次性 bootstrap 交给门店
144
+ genduo-desk admin write-bootstrap --shop 5RT2 --out /tmp/5RT2.boot.json
145
+ ```
146
+
147
+ ## 门店流程(后)
148
+
149
+ ```bash
150
+ # 交互粘贴 token + 输入两组密码
151
+ genduo-desk login --shop 5RT2 --verify-rms --install-service
152
+
153
+ # 或用超管 bootstrap(读完可删)
154
+ genduo-desk login --shop 5RT2 \
155
+ --bootstrap /tmp/5RT2.boot.json --consume-bootstrap \
156
+ --keep-rms --verify-rms --install-service
157
+
158
+ genduo-desk status
159
+ ```
160
+
161
+ `--keep-token` / `--keep-rms`:Keychain 已有则跳过对应提示(7RT2 POC 复用时有用)。
162
+
163
+ ## 5RT2 下一店清单
164
+
165
+ 1. 超管:签发 `agent-5RT2`(scopes/shops 如上);备齐 R-Login 两组。
166
+ 2. 门店 Mac:装 Playwright Chromium(若要 `--verify-rms`)。
167
+ 3. 门店:`login --shop 5RT2 --verify-rms --install-service`。
168
+ 4. 先只推最近 7 天 `rms_access`;连续 7 天正常后再接 RPP。
169
+ 5. `/sources` / `GET /api/agent/status` 可见 online;重复推送不双计。
170
+
171
+ 7RT2 POC 状态见 `state/POC_STATUS.md`(ingest+心跳已绿)。规划细节见
172
+ `docs/collector-agent.md`。
173
+
174
+ ## 安全
175
+
176
+ - 不打印 token / 密码;bootstrap 文件 mode 600,`--consume-bootstrap` 用后删除。
177
+ - 中心**不保存** R-Login 密码;只收出站 HTTPS ingest / 心跳。
178
+ - RMS 登录阶段禁止 accessibility snapshot / 把密码写入日志;需要人工 2FA 时本机接管。
179
+ - 本 CLI **不会**自行向生产签发新 token。
180
+
181
+ ## 本地冒烟
182
+
183
+ ```bash
184
+ python tests/test_collector_agent.py
185
+ python3 tools/collector-agent/cli.py status --shop 7RT2 --offline
186
+ python3 tools/collector-agent/cli.py admin checklist --shop 5RT2
187
+ ```
package/api_client.py ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """中心 HTTPS 客户端(出站)。不记录 Authorization / token。"""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import socket
8
+ import urllib.error
9
+ import urllib.request
10
+ from typing import Any
11
+ from urllib.parse import urlencode
12
+
13
+
14
+ class ApiError(RuntimeError):
15
+ def __init__(self, method: str, path: str, code: int, detail: str):
16
+ self.method = method
17
+ self.path = path
18
+ self.code = code
19
+ self.detail = detail
20
+ # 截断,避免意外带出敏感体
21
+ super().__init__(f"{method} {path} → {code}: {detail[:300]}")
22
+
23
+
24
+ def call(base: str, method: str, path: str, token: str,
25
+ body: dict | None = None, timeout: int = 60) -> dict:
26
+ data = None if body is None else json.dumps(body).encode()
27
+ req = urllib.request.Request(
28
+ f"{base.rstrip('/')}{path}", data=data, method=method,
29
+ headers={
30
+ "Authorization": f"Bearer {token}",
31
+ "Content-Type": "application/json",
32
+ "Accept": "application/json",
33
+ },
34
+ )
35
+ try:
36
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
37
+ raw = resp.read().decode()
38
+ return json.loads(raw) if raw else {}
39
+ except urllib.error.HTTPError as exc:
40
+ detail = exc.read().decode(errors="replace")
41
+ raise ApiError(method, path, exc.code, detail) from exc
42
+
43
+
44
+ def heartbeat(base: str, token: str, shop: str, version: str,
45
+ capabilities: list[str] | None = None,
46
+ last_error: str | None = None) -> dict:
47
+ return call(base, "POST", "/api/agent/heartbeat", token, {
48
+ "shop": shop,
49
+ "agent_version": version,
50
+ "capabilities": capabilities or ["rms_access", "rms_ads", "heartbeat"],
51
+ "hostname": socket.gethostname(),
52
+ "last_error": last_error,
53
+ })
54
+
55
+
56
+ def jobs(base: str, token: str, shop: str, limit: int = 5) -> Any:
57
+ return call(base, "GET", f"/api/agent/jobs?shop={shop}&limit={limit}", token)
58
+
59
+
60
+ def ingest(base: str, token: str, source: str, body: dict,
61
+ dry_run: bool = False, timeout: int = 120) -> dict:
62
+ """POST /api/ingest/{rms_access|rms_ads}. body: batch_id/collected_at/rows."""
63
+ q = urlencode({"dry_run": "1"}) if dry_run else ""
64
+ path = f"/api/ingest/{source}" + (f"?{q}" if q else "")
65
+ return call(base, "POST", path, token, body, timeout=timeout)
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * genduo-desk — Node bin → python3 cli.py
6
+ * 包根 = bin/ 的上一级(npm 安装后为 node_modules/@genduo/desk)。
7
+ */
8
+ const { spawnSync } = require('child_process');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const pkgRoot = path.resolve(__dirname, '..');
13
+ const cliPy = path.join(pkgRoot, 'cli.py');
14
+
15
+ function findPython3() {
16
+ const pathEnv = process.env.PATH || '';
17
+ const sep = path.delimiter;
18
+ const names = process.platform === 'win32' ? ['python3.exe', 'python.exe'] : ['python3'];
19
+ for (const dir of pathEnv.split(sep)) {
20
+ if (!dir) continue;
21
+ for (const name of names) {
22
+ const full = path.join(dir, name);
23
+ try {
24
+ fs.accessSync(full, fs.constants.X_OK);
25
+ return full;
26
+ } catch {
27
+ /* try next */
28
+ }
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ if (!fs.existsSync(cliPy)) {
35
+ console.error(`找不到 cli.py: ${cliPy}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ const python = findPython3();
40
+ if (!python) {
41
+ console.error('需要 python3,可用 brew install python');
42
+ process.exit(1);
43
+ }
44
+
45
+ const result = spawnSync(python, [cliPy, ...process.argv.slice(2)], {
46
+ stdio: 'inherit',
47
+ env: process.env,
48
+ });
49
+
50
+ if (result.error) {
51
+ console.error(result.error.message);
52
+ process.exit(1);
53
+ }
54
+ process.exit(result.status == null ? 1 : result.status);