@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 +186 -2
- package/api_client.py +65 -0
- package/bin/genduo-desk.js +54 -0
- package/cli.py +632 -0
- package/collect_access.py +162 -0
- package/collect_ads.py +255 -0
- package/ingest_util.py +136 -0
- package/keychain_util.py +115 -0
- package/local_config.py +53 -0
- package/package.json +42 -4
- package/rms_ads_parse.py +99 -0
- package/rms_browser_login.py +213 -0
- package/run.py +303 -0
- package/scripts/postinstall.js +23 -0
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 店铺采集 Agent(CLI)
|
|
2
2
|
|
|
3
|
-
|
|
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);
|