@genduo/desk 0.0.0-stage → 0.5.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,188 @@
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.5.0)| **凭据** 与销量计算系统一致| **下一店** 5RT2 | **中心** `https://dataanalyst.jp` | **POC** 7RT2 已绿
4
+
5
+ ## 目标 UX
6
+
7
+ 1. **超管先备齐**:中心签发每店 Agent token;店机维护 `JDY_MCP`(与销量系统相同);RMS 用 env-file。
8
+ 2. **门店只拿到本 CLI**:运行 `login`(token 进环境 / bootstrap)、心跳、可选装 LaunchAgent。
9
+ 3. 不再以「手工 `security` 写 dataanalyst/*」作为主路径。
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.5.0)。
23
+ **0.5.0**:凭据获取与销量计算系统一致(`JDY_MCP` → MCP → 密码表 API Key);旧 `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` | 门店 | Agent token → 环境;可选心跳 / RMS 校验 / LaunchAgent |
41
+ | `status` | 门店 | JDY_MCP / token 环境 / 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` | 超管 | 开店清单(与销量系统凭据说明) |
47
+ | `admin verify-secrets` | 超管 | 探测 `JDY_MCP` → API Key(不打印) |
48
+ | `admin set-rms` | 超管 | **已废弃**(勿写 dataanalyst/rms/*) |
49
+ | `admin write-bootstrap` | 超管 | 600 一次性 JSON 给门店 `login --bootstrap` |
50
+
51
+ 常驻进程入口是 `run.py`(LaunchAgent 调用):进程内 sleep 循环,心跳 + 日次访问 + 月度 RPP → ingest。
52
+
53
+
54
+
55
+ ## 采集循环(run.py)
56
+
57
+ 调度(日历日用本机 `date.today()`;`daemon_state` 时间戳为 JST +09:00):
58
+
59
+ | 任务 | 间隔 | 说明 |
60
+ |---|---|---|
61
+ | 心跳 | ~12 分钟 | `POST /api/agent/heartbeat`;带上 `last_error` |
62
+ | 访问人数 | ~20 小时 | 近 14 天日次 → `POST /api/ingest/rms_access` |
63
+ | RPP 广告 | ~每天检查 | 当月(月初 1–3 日兼上月)→ `POST /api/ingest/rms_ads` |
64
+
65
+ ```bash
66
+ # 冒烟(一轮心跳+采集后退出)
67
+ python3 tools/collector-agent/run.py --once
68
+ # 只用仓库 .venv(含 playwright):
69
+ /Users/gd/workspace/dataanalyst/.venv/bin/python tools/collector-agent/run.py --once
70
+
71
+ # 常驻(前台调试;生产用 install-service)
72
+ python3 tools/collector-agent/run.py
73
+
74
+ # 只心跳 / ingest dry-run
75
+ python3 tools/collector-agent/run.py --once --skip-collect
76
+ python3 tools/collector-agent/run.py --once --dry-run-ingest
77
+ ```
78
+
79
+ 状态:`state/daemon_state.json`(`last_heartbeat` / `last_access_ok` / `last_ads_ok` / `last_error`,无密钥)。
80
+ 失败 payload 进 `state/buffer/`,下轮自动重试。日志:`logs/agent.log`(轮转)。
81
+
82
+ **LaunchAgent**:`KeepAlive` = 崩溃重启(配 `ThrottleInterval=30`),**不是**短周期拉起短命进程。`run.py` 必须进程内 sleep;装服务前请先 `--once` 验过。
83
+
84
+ 依赖:本机 Playwright Chromium + 本地代理(默认 `http://127.0.0.1:7897`,可用 `HTTPS_PROXY` / `DATAANALYST_PROXY` 覆盖);会话 `state/<SHOP>.json`。广告解析需 monorepo `pipeline.rms_ads.parse`。
85
+
86
+ ## 防休眠(重要)
87
+
88
+ 店铺常开 Mac 若进入睡眠/关机,采集会中断。CLI 分两层:
89
+
90
+ | 层级 | 谁做 | 做什么 |
91
+ |---|---|---|
92
+ | **用户级(CLI 自动)** | 门店跑 `login --install-service` / `install-service` / `prevent-sleep` | 安装 KeepAlive LaunchAgent `jp.dataanalyst.caffeinate` → `/usr/bin/caffeinate -dims`(防 display/idle/disk/system 休眠;登录会话内,重启后随用户登录再拉起) |
93
+ | **系统级(需 sudo)** | 超管或有管理员权限的门店同事 | CLI **不会静默 sudo**。需人工执行:`sudo pmset -a sleep 0 disksleep 0 displaysleep 0`;可选 `sudo systemsetup -setrestartpowerfailure on` |
94
+
95
+ ```bash
96
+ # 只要防休眠、暂不装 Agent 常驻:
97
+ genduo-desk prevent-sleep
98
+
99
+ # 开店主路径(装 Agent + caffeinate):
100
+ genduo-desk login --shop 5RT2 --verify-rms --install-service
101
+
102
+ # 只要 Agent、不要 caffeinate(不推荐):
103
+ genduo-desk install-service --skip-caffeinate
104
+ ```
105
+
106
+ `status` 会显示 `caffeinate` plist 是否已装、进程是否在跑。
107
+ 合盖笔记本另议;常开台式 + 登录用户是推荐形态。
108
+
109
+
110
+ ## 凭据(与销量计算系统一致)
111
+
112
+ 本机 Keychain **只**用于 MCP URL(店机维护;本开发机可以没有):
113
+
114
+ | service | account | 覆盖 |
115
+ |---|---|---|
116
+ | `JDY_MCP` | `服务器URL` | 环境变量 `JDY_MCP_URL` 优先 |
117
+
118
+ JDY API Key:经 MCP `member_data_list` 查密码表,Title 精确匹配 **`api key 精细化运营`**(同销量系统 `config.JDY_API_KEY_TITLE`)。不硬编码、不打印。
119
+
120
+ Agent token(中心 `da_…`):环境变量 `DATAANALYST_AGENT_TOKEN`,或 `login --bootstrap`。**不写** `dataanalyst-agent-token` Keychain。
121
+
122
+ RMS 两组:`RMS_<SHOP>_LOGIN_ID` / `PASSWORD` / `SHOP_USER` / `SHOP_PASSWORD`,或 `rms_browser_login --env-file`。**不写** `dataanalyst/rms/*` Keychain。
123
+
124
+ 本机非机密配置:`~/.config/dataanalyst-agent/config.json`(仅 `shop` / `base`)。
125
+
126
+ ## 中心契约
127
+
128
+ | 方法 | 路径 | 说明 |
129
+ |---|---|---|
130
+ | POST | `/api/ingest/rms_access` | 日次访问人数 |
131
+ | POST | `/api/ingest/rms_ads` | RPP/TDA 期间报表 |
132
+ | POST | `/api/agent/heartbeat` | 心跳 / 版本 / 错误 |
133
+ | GET | `/api/agent/jobs?shop=` | 领取补历史/重跑 |
134
+ | POST | `/api/agent/jobs/{id}/ack` | 回报任务结果 |
135
+
136
+ 鉴权:`Authorization: Bearer da_...`。每店 token:`name=agent-<SHOP>`,
137
+ `shops` 只勾本店,`scopes=collect:view,collect:edit`。明文只创建时显示一次。
138
+
139
+ ## 超管流程(先)
140
+
141
+ ```bash
142
+ # 1) 看清单
143
+ genduo-desk admin checklist --shop 5RT2
144
+
145
+ # 2) 中心签发 token(明文勿进 git/聊天)→ 门店 DATAANALYST_AGENT_TOKEN 或 bootstrap
146
+ # 3) 店机维护 JDY_MCP(与销量系统相同;开发机可无)
147
+ genduo-desk admin verify-secrets --shop 5RT2
148
+
149
+ # 4) 可选 bootstrap
150
+ genduo-desk admin write-bootstrap --shop 5RT2 --out /tmp/5RT2.boot.json
151
+ ```
152
+
153
+ ## 门店流程(后)
154
+
155
+ ```bash
156
+ genduo-desk login --shop 5RT2 --verify-rms --install-service
157
+
158
+ genduo-desk status
159
+ ```
160
+
161
+ `--keep-token` / `--keep-rms`:沿用已有环境变量,不重复交互。
162
+
163
+ ## 5RT2 下一店清单
164
+
165
+ 1. 超管:签发 `agent-5RT2`;门店 Mac 维护 `JDY_MCP`(与销量系统一致);备齐 RMS env-file。
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 / 密码;JDY 侧与销量计算系统一致(Keychain 仅 MCP URL → 密码表 API Key)。
177
+ - Agent token / RMS 用环境变量或 bootstrap / env-file,不写 dataanalyst/* Keychain。
178
+ - 中心**不保存** R-Login 密码;只收出站 HTTPS ingest / 心跳。
179
+ - RMS 登录阶段禁止 accessibility snapshot / 把密码写入日志;需要人工 2FA 时本机接管。
180
+ - 本 CLI **不会**自行向生产签发新 token。
181
+
182
+ ## 本地冒烟
183
+
184
+ ```bash
185
+ python tests/test_collector_agent.py
186
+ python3 tools/collector-agent/cli.py status --shop 7RT2 --offline
187
+ python3 tools/collector-agent/cli.py admin checklist --shop 5RT2
188
+ ```
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);