@genduo/desk 0.4.0 → 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 +31 -30
- package/cli.py +149 -114
- package/jdy_secrets.py +228 -0
- package/keychain_util.py +83 -90
- package/local_config.py +1 -1
- package/package.json +2 -1
- package/rms_browser_login.py +14 -35
- package/run.py +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# 店铺采集 Agent(CLI)
|
|
2
2
|
|
|
3
|
-
**状态** 门店 CLI 登录授权已落地;**采集循环已落地**(`run.py` 0.
|
|
3
|
+
**状态** 门店 CLI 登录授权已落地;**采集循环已落地**(`run.py` 0.5.0)| **凭据** 与销量计算系统一致| **下一店** 5RT2 | **中心** `https://dataanalyst.jp` | **POC** 7RT2 已绿
|
|
4
4
|
|
|
5
5
|
## 目标 UX
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2. **门店只拿到本 CLI**:运行 `login
|
|
9
|
-
3.
|
|
7
|
+
1. **超管先备齐**:中心签发每店 Agent token;店机维护 `JDY_MCP`(与销量系统相同);RMS 用 env-file。
|
|
8
|
+
2. **门店只拿到本 CLI**:运行 `login`(token 进环境 / bootstrap)、心跳、可选装 LaunchAgent。
|
|
9
|
+
3. 不再以「手工 `security` 写 dataanalyst/*」作为主路径。
|
|
10
10
|
|
|
11
11
|
### 安装(门店主路径:npm)
|
|
12
12
|
|
|
@@ -19,8 +19,8 @@ genduo-desk login --shop 5RT2 --verify-rms --install-service
|
|
|
19
19
|
genduo-desk status
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
包名 `@genduo/desk`(全局命令 `genduo-desk`);版本见 `package.json` / `local_config.VERSION`(0.
|
|
23
|
-
**
|
|
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
24
|
|
|
25
25
|
### 开发 / monorepo 路径(次要)
|
|
26
26
|
|
|
@@ -37,15 +37,16 @@ genduo-desk status
|
|
|
37
37
|
|
|
38
38
|
| 命令 | 谁用 | 作用 |
|
|
39
39
|
|---|---|---|
|
|
40
|
-
| `login` | 门店 |
|
|
41
|
-
| `status` | 门店 |
|
|
40
|
+
| `login` | 门店 | Agent token → 环境;可选心跳 / RMS 校验 / LaunchAgent |
|
|
41
|
+
| `status` | 门店 | JDY_MCP / token 环境 / LaunchAgent / 心跳 |
|
|
42
42
|
| `heartbeat` | 门店 / 调试 | `POST /api/agent/heartbeat` + 拉 jobs |
|
|
43
43
|
| `install-service` | 门店 | 装 Agent + **caffeinate** LaunchAgent(默认) |
|
|
44
44
|
| `prevent-sleep` | 门店 / 超管 | 只装 `jp.dataanalyst.caffeinate`(`caffeinate -dims`),并打印 `pmset` 说明 |
|
|
45
45
|
| `uninstall-service` | 门店 | 卸 Agent **与** caffeinate LaunchAgent |
|
|
46
|
-
| `admin checklist` | 超管 |
|
|
47
|
-
| `admin
|
|
48
|
-
| `admin
|
|
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` |
|
|
49
50
|
|
|
50
51
|
常驻进程入口是 `run.py`(LaunchAgent 调用):进程内 sleep 循环,心跳 + 日次访问 + 月度 RPP → ingest。
|
|
51
52
|
|
|
@@ -106,15 +107,20 @@ genduo-desk install-service --skip-caffeinate
|
|
|
106
107
|
合盖笔记本另议;常开台式 + 登录用户是推荐形态。
|
|
107
108
|
|
|
108
109
|
|
|
109
|
-
##
|
|
110
|
+
## 凭据(与销量计算系统一致)
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
本机 Keychain **只**用于 MCP URL(店机维护;本开发机可以没有):
|
|
113
|
+
|
|
114
|
+
| service | account | 覆盖 |
|
|
112
115
|
|---|---|---|
|
|
113
|
-
|
|
|
114
|
-
|
|
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。
|
|
116
123
|
|
|
117
|
-
与包内 vendored `rms_browser_login.py --from-keychain`(源真相仍是 monorepo `tools/rms_browser_login.py`)、7RT2 POC 一致。
|
|
118
124
|
本机非机密配置:`~/.config/dataanalyst-agent/config.json`(仅 `shop` / `base`)。
|
|
119
125
|
|
|
120
126
|
## 中心契约
|
|
@@ -136,33 +142,27 @@ genduo-desk install-service --skip-caffeinate
|
|
|
136
142
|
# 1) 看清单
|
|
137
143
|
genduo-desk admin checklist --shop 5RT2
|
|
138
144
|
|
|
139
|
-
# 2) 中心签发 token
|
|
140
|
-
# 3)
|
|
141
|
-
genduo-desk admin
|
|
145
|
+
# 2) 中心签发 token(明文勿进 git/聊天)→ 门店 DATAANALYST_AGENT_TOKEN 或 bootstrap
|
|
146
|
+
# 3) 店机维护 JDY_MCP(与销量系统相同;开发机可无)
|
|
147
|
+
genduo-desk admin verify-secrets --shop 5RT2
|
|
142
148
|
|
|
143
|
-
# 4)
|
|
149
|
+
# 4) 可选 bootstrap
|
|
144
150
|
genduo-desk admin write-bootstrap --shop 5RT2 --out /tmp/5RT2.boot.json
|
|
145
151
|
```
|
|
146
152
|
|
|
147
153
|
## 门店流程(后)
|
|
148
154
|
|
|
149
155
|
```bash
|
|
150
|
-
# 交互粘贴 token + 输入两组密码
|
|
151
156
|
genduo-desk login --shop 5RT2 --verify-rms --install-service
|
|
152
157
|
|
|
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
158
|
genduo-desk status
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
`--keep-token` / `--keep-rms
|
|
161
|
+
`--keep-token` / `--keep-rms`:沿用已有环境变量,不重复交互。
|
|
162
162
|
|
|
163
163
|
## 5RT2 下一店清单
|
|
164
164
|
|
|
165
|
-
1. 超管:签发 `agent-5RT2
|
|
165
|
+
1. 超管:签发 `agent-5RT2`;门店 Mac 维护 `JDY_MCP`(与销量系统一致);备齐 RMS env-file。
|
|
166
166
|
2. 门店 Mac:装 Playwright Chromium(若要 `--verify-rms`)。
|
|
167
167
|
3. 门店:`login --shop 5RT2 --verify-rms --install-service`。
|
|
168
168
|
4. 先只推最近 7 天 `rms_access`;连续 7 天正常后再接 RPP。
|
|
@@ -173,7 +173,8 @@ genduo-desk status
|
|
|
173
173
|
|
|
174
174
|
## 安全
|
|
175
175
|
|
|
176
|
-
- 不打印 token / 密码;
|
|
176
|
+
- 不打印 token / 密码;JDY 侧与销量计算系统一致(Keychain 仅 MCP URL → 密码表 API Key)。
|
|
177
|
+
- Agent token / RMS 用环境变量或 bootstrap / env-file,不写 dataanalyst/* Keychain。
|
|
177
178
|
- 中心**不保存** R-Login 密码;只收出站 HTTPS ingest / 心跳。
|
|
178
179
|
- RMS 登录阶段禁止 accessibility snapshot / 把密码写入日志;需要人工 2FA 时本机接管。
|
|
179
180
|
- 本 CLI **不会**自行向生产签发新 token。
|
package/cli.py
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
主 UX(门店):
|
|
6
6
|
genduo-desk login --shop 5RT2
|
|
7
|
-
|
|
7
|
+
本机 Keychain 只需已有 JDY_MCP / 服务器URL;
|
|
8
|
+
Agent token 与 R-Login 两组从简道云密码表经 MCP 读取,
|
|
8
9
|
可选验证 RMS 登录、注册心跳、安装 LaunchAgent + caffeinate 防休眠。
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
超管侧(先备齐简道云 Title + 签发 token 写入密码表):
|
|
11
12
|
genduo-desk admin checklist --shop 5RT2
|
|
12
|
-
genduo-desk admin
|
|
13
|
-
genduo-desk admin write-bootstrap --shop 5RT2 # token 从 stdin,600 文件
|
|
13
|
+
genduo-desk admin verify-secrets --shop 5RT2
|
|
14
14
|
|
|
15
15
|
不打印 token / 密码;不向生产库签发新 token(签发走中心 Web 或
|
|
16
16
|
`python -m app.tokens`,见 admin checklist)。
|
|
@@ -82,13 +82,13 @@ def _resolve_base(explicit: str | None) -> str:
|
|
|
82
82
|
# ---------------------------------------------------------------------------
|
|
83
83
|
|
|
84
84
|
def cmd_login(a: argparse.Namespace) -> None:
|
|
85
|
-
"""
|
|
85
|
+
"""门店授权:Agent token → 环境(及可选写入 LaunchAgent);不写 dataanalyst Keychain。"""
|
|
86
86
|
shop = _resolve_shop(a.shop)
|
|
87
87
|
base = _resolve_base(a.base)
|
|
88
88
|
print(f"店铺 {shop} · 中心 {base}")
|
|
89
|
-
print("
|
|
89
|
+
print("与销量计算系统一致:Keychain 仅 JDY_MCP(本机可无);"
|
|
90
|
+
"不写 dataanalyst-agent-token / dataanalyst/rms/*。")
|
|
90
91
|
|
|
91
|
-
# --- Agent token ---
|
|
92
92
|
token: str | None = None
|
|
93
93
|
if a.bootstrap:
|
|
94
94
|
boot = Path(a.bootstrap).expanduser()
|
|
@@ -107,43 +107,48 @@ def cmd_login(a: argparse.Namespace) -> None:
|
|
|
107
107
|
print("已删除 bootstrap 文件(一次性)")
|
|
108
108
|
except OSError as exc:
|
|
109
109
|
print(f"警告: 未能删除 bootstrap: {exc}", file=sys.stderr)
|
|
110
|
-
elif a.token_env:
|
|
111
|
-
token = os.environ.get("DATAANALYST_AGENT_TOKEN", "").strip()
|
|
112
|
-
if not token:
|
|
110
|
+
elif a.token_env or a.keep_token:
|
|
111
|
+
token = os.environ.get("DATAANALYST_AGENT_TOKEN", "").strip() or None
|
|
112
|
+
if a.token_env and not token:
|
|
113
113
|
_die("DATAANALYST_AGENT_TOKEN 为空")
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
if token:
|
|
115
|
+
print("使用环境变量 DATAANALYST_AGENT_TOKEN")
|
|
116
|
+
if token is None and not a.keep_token:
|
|
117
117
|
print("请粘贴超管签发的 Agent token(da_…,输入时不回显)")
|
|
118
118
|
token = _prompt_secret("Agent token")
|
|
119
119
|
|
|
120
120
|
if token:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
print(f"✓ Agent token → Keychain service={kc.AGENT_TOKEN_SERVICE}")
|
|
126
|
-
# 尽快丢掉明文引用
|
|
121
|
+
if not token.startswith("da_"):
|
|
122
|
+
_die("Agent token 应以 da_ 开头")
|
|
123
|
+
os.environ["DATAANALYST_AGENT_TOKEN"] = token
|
|
124
|
+
print("✓ Agent token → 本进程环境 DATAANALYST_AGENT_TOKEN(不写 Keychain)")
|
|
127
125
|
token = None
|
|
128
126
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
# JDY_MCP:与销量系统相同;本机没有属正常,仅提示
|
|
128
|
+
if kc.has_generic(kc.JDY_MCP_SERVICE):
|
|
129
|
+
print(f"✓ JDY_MCP 钥匙串可见(或已设 JDY_MCP_URL)")
|
|
130
|
+
if not a.skip_secret_check:
|
|
131
|
+
try:
|
|
132
|
+
_ = kc.get_api_key()
|
|
133
|
+
_ = None
|
|
134
|
+
print(f"✓ JDY API Key ← 密码表 Title={kc.JDY_API_KEY_TITLE}")
|
|
135
|
+
except RuntimeError as exc:
|
|
136
|
+
print(f"警告: 未能经 MCP 读取 API Key: {exc}", file=sys.stderr)
|
|
134
137
|
else:
|
|
135
|
-
print("
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
print("(本机无 JDY_MCP / JDY_MCP_URL — 开发机可如此;店机请维护钥匙串)")
|
|
139
|
+
|
|
140
|
+
# RMS:可选交互写入环境变量供本进程 verify-rms(不写 Keychain)
|
|
141
|
+
if a.verify_rms and not a.keep_rms:
|
|
142
|
+
present = kc.rms_creds_present(shop)
|
|
143
|
+
if not (present["rlogin"] and present["ai"]):
|
|
144
|
+
print("R-Login 第一组(写入本进程环境,不写 Keychain)")
|
|
145
|
+
p = kc.rms_env_prefix(shop)
|
|
146
|
+
os.environ[p + "LOGIN_ID"] = _prompt("R-Login ID")
|
|
147
|
+
os.environ[p + "PASSWORD"] = _prompt_secret("R-Login 密码")
|
|
148
|
+
print("Rakuten 会员第二组")
|
|
149
|
+
os.environ[p + "SHOP_USER"] = _prompt("SSO 邮箱 / 用户名")
|
|
150
|
+
os.environ[p + "SHOP_PASSWORD"] = _prompt_secret("SSO 密码")
|
|
151
|
+
print(f"✓ RMS 凭据 → 环境变量 {p}*")
|
|
147
152
|
|
|
148
153
|
path = cfg.save(shop, base)
|
|
149
154
|
print(f"✓ 本机配置 → {path}(无密钥)")
|
|
@@ -164,8 +169,8 @@ def cmd_login(a: argparse.Namespace) -> None:
|
|
|
164
169
|
print("login 完成。常用后续:")
|
|
165
170
|
print(f" {sys.argv[0]} status")
|
|
166
171
|
print(f" {sys.argv[0]} heartbeat")
|
|
167
|
-
print(f" {sys.argv[0]} install-service
|
|
168
|
-
|
|
172
|
+
print(f" {sys.argv[0]} install-service")
|
|
173
|
+
|
|
169
174
|
|
|
170
175
|
|
|
171
176
|
def cmd_status(a: argparse.Namespace) -> None:
|
|
@@ -176,16 +181,20 @@ def cmd_status(a: argparse.Namespace) -> None:
|
|
|
176
181
|
print(f"shop {shop}")
|
|
177
182
|
print(f"base {base}")
|
|
178
183
|
print(f"version {cfg.VERSION}")
|
|
179
|
-
print(f"
|
|
180
|
-
|
|
181
|
-
print(f"
|
|
184
|
+
print(f"jdy_mcp {'有' if present.get('mcp_url') else '无(可预期)'} "
|
|
185
|
+
f"({kc.JDY_MCP_SERVICE}/{kc.JDY_MCP_ACCOUNT} 或 JDY_MCP_URL)")
|
|
186
|
+
print(f"agent_token {'有' if present['agent_token'] else '缺'} "
|
|
187
|
+
f"(env DATAANALYST_AGENT_TOKEN)")
|
|
188
|
+
print(f"rms_env rlogin={'有' if present['rlogin'] else '缺'} "
|
|
189
|
+
f"ai={'有' if present['ai'] else '缺'} ({kc.rms_env_prefix(shop)}*)")
|
|
182
190
|
print(f"launchagent {'已装' if PLIST_PATH.is_file() else '未装'} ({PLIST_PATH})")
|
|
183
191
|
print(f"caffeinate {'已装' if CAFFEINATE_PATH.is_file() else '未装'} ({CAFFEINATE_PATH})")
|
|
184
192
|
caf_alive = _caffeinate_running()
|
|
185
|
-
print(f"caffeinate_ps {'运行中' if caf_alive else '未运行'}
|
|
193
|
+
print(f"caffeinate_ps {'运行中' if caf_alive else '未运行'} "
|
|
194
|
+
f"({CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)})")
|
|
186
195
|
if present["agent_token"] and not a.offline:
|
|
187
196
|
try:
|
|
188
|
-
tok = kc.get_agent_token()
|
|
197
|
+
tok = kc.get_agent_token(shop)
|
|
189
198
|
hb = api_client.heartbeat(base, tok, shop, cfg.VERSION)
|
|
190
199
|
agent = (hb or {}).get("agent") or {}
|
|
191
200
|
print(f"heartbeat ok last_seen={agent.get('last_seen_at')}")
|
|
@@ -193,6 +202,7 @@ def cmd_status(a: argparse.Namespace) -> None:
|
|
|
193
202
|
print(f"heartbeat 失败: {exc}")
|
|
194
203
|
|
|
195
204
|
|
|
205
|
+
|
|
196
206
|
def cmd_heartbeat(a: argparse.Namespace) -> None:
|
|
197
207
|
conf = cfg.load()
|
|
198
208
|
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
@@ -239,77 +249,60 @@ def cmd_admin_checklist(a: argparse.Namespace) -> None:
|
|
|
239
249
|
print(textwrap.dedent(f"""
|
|
240
250
|
======== 超管 · {shop} 开店清单 ========
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
252
|
+
凭据逻辑与「销量计算系统」一致:
|
|
253
|
+
· 店机 Keychain:仅 JDY_MCP / 服务器URL(开发机可无)
|
|
254
|
+
· 或环境变量 JDY_MCP_URL
|
|
255
|
+
· JDY API Key:MCP 密码表 Title={kc.JDY_API_KEY_TITLE}
|
|
256
|
+
· 不再使用 dataanalyst-agent-token / dataanalyst/rms/* Keychain
|
|
245
257
|
|
|
246
258
|
A. 中心签发 Agent token(只显示一次明文)
|
|
247
|
-
· Web
|
|
248
|
-
name
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
·
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
·
|
|
259
|
-
|
|
260
|
-
B. 备齐 RMS 两组密码(中心不存;只给人/Keychain)
|
|
261
|
-
· 第一组 R-Login ID/密码 → Keychain {kc.rms_rlogin_service(shop)}
|
|
262
|
-
· 第二组 Rakuten SSO → Keychain {kc.rms_ai_service(shop)}
|
|
263
|
-
· 超管可在本机预先写入:
|
|
264
|
-
genduo-desk admin set-rms --shop {shop}
|
|
265
|
-
或让门店在 login 时自己输入。
|
|
266
|
-
|
|
267
|
-
C. 交给门店的材料
|
|
268
|
-
1. 本仓库 tools/collector-agent/(或 npm 包 @genduo/desk(命令 genduo-desk))
|
|
269
|
-
2. 店号 {shop}
|
|
270
|
-
3. Agent token(口头 / 密封通道 / bootstrap 文件)
|
|
271
|
-
4.(可选)R-Login 两组,若尚未写入该 Mac Keychain
|
|
259
|
+
· Web / VPS python -m app.tokens …
|
|
260
|
+
name={name} scopes=collect:view,collect:edit shops={shop}
|
|
261
|
+
· 门店用环境变量 DATAANALYST_AGENT_TOKEN,或 login --bootstrap
|
|
262
|
+
|
|
263
|
+
B. RMS 两组
|
|
264
|
+
· 环境变量 {kc.rms_env_prefix(shop)}LOGIN_ID / PASSWORD / SHOP_USER / SHOP_PASSWORD
|
|
265
|
+
· 或 rms_browser_login --env-file
|
|
266
|
+
· login --verify-rms 时可交互写入「本进程环境」(不写 Keychain)
|
|
267
|
+
|
|
268
|
+
C. 店机 Keychain(用户维护;与销量系统相同)
|
|
269
|
+
· service=JDY_MCP account=服务器URL
|
|
270
|
+
· 本开发机没有该条目是正常的
|
|
272
271
|
|
|
273
272
|
D. 门店执行
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
# 门店:
|
|
278
|
-
genduo-desk login --shop {shop} --bootstrap /tmp/{shop}.boot.json \\
|
|
279
|
-
--consume-bootstrap --verify-rms --install-service
|
|
280
|
-
|
|
281
|
-
E. 防休眠(install-service 默认会装 caffeinate)
|
|
282
|
-
· 用户级:LaunchAgent jp.dataanalyst.caffeinate → caffeinate -dims
|
|
283
|
-
(login --install-service / install-service / prevent-sleep)
|
|
284
|
-
· 系统级需管理员(CLI 不静默 sudo):
|
|
285
|
-
sudo pmset -a sleep 0 disksleep 0 displaysleep 0
|
|
286
|
-
sudo systemsetup -setrestartpowerfailure on
|
|
287
|
-
|
|
288
|
-
F. 验收
|
|
273
|
+
genduo-desk login --shop {shop} --verify-rms --install-service
|
|
274
|
+
|
|
275
|
+
E. 验收
|
|
289
276
|
genduo-desk status
|
|
290
|
-
# 应见 caffeinate=已装 且 caffeinate_ps=运行中
|
|
291
|
-
# 中心 /sources 或 GET /api/agent/status 可见 {shop} online
|
|
292
277
|
""").strip())
|
|
293
278
|
|
|
294
279
|
|
|
295
|
-
def
|
|
296
|
-
"""
|
|
280
|
+
def cmd_admin_verify_secrets(a: argparse.Namespace) -> None:
|
|
281
|
+
"""探测 JDY_MCP → get_api_key(与销量系统相同);不打印 Key。"""
|
|
297
282
|
shop = _resolve_shop(a.shop)
|
|
298
|
-
print(f"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
283
|
+
print(f"校验 JDY MCP / API Key(shop={shop} 仅作上下文;不打印 secret)")
|
|
284
|
+
if not kc.has_generic(kc.JDY_MCP_SERVICE):
|
|
285
|
+
print("本机无 JDY_MCP / JDY_MCP_URL(开发机可预期;店机请维护)")
|
|
286
|
+
raise SystemExit(0)
|
|
287
|
+
try:
|
|
288
|
+
_ = kc.get_api_key()
|
|
289
|
+
_ = None
|
|
290
|
+
print(f"✓ API Key 可读 Title={kc.JDY_API_KEY_TITLE}")
|
|
291
|
+
except RuntimeError as exc:
|
|
292
|
+
_die(str(exc))
|
|
307
293
|
if a.verify_rms:
|
|
308
294
|
_do_verify_rms(shop, headed=a.headed)
|
|
309
295
|
|
|
310
296
|
|
|
297
|
+
def cmd_admin_set_rms(a: argparse.Namespace) -> None:
|
|
298
|
+
print("admin set-rms 已废弃:不再写入 dataanalyst/rms/* Keychain。")
|
|
299
|
+
print(f"请设置环境变量 {kc.rms_env_prefix(_resolve_shop(a.shop))}* "
|
|
300
|
+
"或使用 --env-file。")
|
|
301
|
+
raise SystemExit(2)
|
|
302
|
+
|
|
303
|
+
|
|
311
304
|
def cmd_admin_write_bootstrap(a: argparse.Namespace) -> None:
|
|
312
|
-
"""
|
|
305
|
+
"""把 token 写成 600 一次性文件;不进仓库、不写 Keychain。"""
|
|
313
306
|
shop = _resolve_shop(a.shop)
|
|
314
307
|
out = Path(a.out).expanduser() if a.out else Path(f"/tmp/dataanalyst-{shop}-bootstrap.json")
|
|
315
308
|
print("粘贴 Agent token(不回显);将写入仅含 shop+token 的 600 文件")
|
|
@@ -331,13 +324,14 @@ def cmd_admin_write_bootstrap(a: argparse.Namespace) -> None:
|
|
|
331
324
|
f"--bootstrap {out} --consume-bootstrap")
|
|
332
325
|
|
|
333
326
|
|
|
327
|
+
|
|
334
328
|
# ---------------------------------------------------------------------------
|
|
335
329
|
# helpers
|
|
336
330
|
# ---------------------------------------------------------------------------
|
|
337
331
|
|
|
338
332
|
def _do_heartbeat(shop: str, base: str, *, show_jobs: bool = True) -> None:
|
|
339
333
|
try:
|
|
340
|
-
tok = kc.get_agent_token()
|
|
334
|
+
tok = kc.get_agent_token(shop)
|
|
341
335
|
except RuntimeError as exc:
|
|
342
336
|
_die(str(exc))
|
|
343
337
|
try:
|
|
@@ -367,12 +361,36 @@ def _do_verify_rms(shop: str, *, headed: bool = False) -> None:
|
|
|
367
361
|
_die("找不到 rms_browser_login.py(包内 vendored 或 monorepo tools/)")
|
|
368
362
|
out = HERE / "state" / f"{shop}.json"
|
|
369
363
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
-
|
|
371
|
-
|
|
364
|
+
# 把本进程 RMS_* 环境写成 600 临时 env-file(不进 Keychain、用后删)
|
|
365
|
+
try:
|
|
366
|
+
creds = kc.get_rms_creds(shop)
|
|
367
|
+
except RuntimeError as exc:
|
|
368
|
+
_die(str(exc))
|
|
369
|
+
pfx = kc.rms_env_prefix(shop)
|
|
370
|
+
env_path = HERE / "state" / f".{shop}.rms.env"
|
|
371
|
+
lines = [
|
|
372
|
+
f"{pfx}LOGIN_ID={creds['rlogin_id']}",
|
|
373
|
+
f"{pfx}PASSWORD={creds['rlogin_password']}",
|
|
374
|
+
f"{pfx}SHOP_USER={creds['sso_user']}",
|
|
375
|
+
f"{pfx}SHOP_PASSWORD={creds['sso_password']}",
|
|
376
|
+
"",
|
|
377
|
+
]
|
|
378
|
+
env_path.write_text("\n".join(lines), encoding="utf-8")
|
|
379
|
+
os.chmod(env_path, 0o600)
|
|
380
|
+
# 清内存引用
|
|
381
|
+
creds = None # noqa: F841
|
|
382
|
+
cmd = [sys.executable, str(script), "--shop", shop,
|
|
383
|
+
"--env-file", str(env_path), "--out", str(out)]
|
|
372
384
|
if headed:
|
|
373
385
|
cmd.append("--headed")
|
|
374
386
|
print("验证 RMS 登录(Playwright;登录阶段不截密码)…")
|
|
375
|
-
|
|
387
|
+
try:
|
|
388
|
+
r = subprocess.run(cmd)
|
|
389
|
+
finally:
|
|
390
|
+
try:
|
|
391
|
+
env_path.unlink()
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
376
394
|
if r.returncode != 0:
|
|
377
395
|
_die("RMS 登录验证失败", r.returncode)
|
|
378
396
|
try:
|
|
@@ -486,6 +504,14 @@ def _do_install_service(shop: str, base: str, *, force: bool = False,
|
|
|
486
504
|
**不是**短任务轮询——切勿把 run.py 改回「跑完即退」否则会狂刷心跳。
|
|
487
505
|
"""
|
|
488
506
|
cfg.save(shop, base)
|
|
507
|
+
def _plist_esc(s: str) -> str:
|
|
508
|
+
return (s.replace("&", "&").replace("<", "<")
|
|
509
|
+
.replace(">", ">").replace('"', """))
|
|
510
|
+
tok_env = (os.environ.get("DATAANALYST_AGENT_TOKEN") or "").strip()
|
|
511
|
+
tok_xml = (
|
|
512
|
+
f" <key>DATAANALYST_AGENT_TOKEN</key><string>{_plist_esc(tok_env)}</string>\n"
|
|
513
|
+
if tok_env else ""
|
|
514
|
+
)
|
|
489
515
|
python = _pick_agent_python()
|
|
490
516
|
run_py = HERE / "run.py"
|
|
491
517
|
logs = HERE / "logs"
|
|
@@ -511,6 +537,7 @@ def _do_install_service(shop: str, base: str, *, force: bool = False,
|
|
|
511
537
|
" <dict>\n"
|
|
512
538
|
f" <key>DATAANALYST_SHOP</key><string>{shop}</string>\n"
|
|
513
539
|
f" <key>DATAANALYST_BASE</key><string>{base}</string>\n"
|
|
540
|
+
f"{tok_xml}"
|
|
514
541
|
" </dict>\n"
|
|
515
542
|
" <key>RunAtLoad</key><true/>\n"
|
|
516
543
|
" <key>KeepAlive</key><true/>\n"
|
|
@@ -554,16 +581,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
554
581
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
555
582
|
|
|
556
583
|
p = sub.add_parser("login", parents=[common],
|
|
557
|
-
help="门店登录授权:token +
|
|
558
|
-
p.add_argument("--bootstrap", help="
|
|
584
|
+
help="门店登录授权:Agent token 环境 + 可选 RMS 校验")
|
|
585
|
+
p.add_argument("--bootstrap", help="紧急:一次性 JSON(含 agent_token)→ 仅本进程环境")
|
|
559
586
|
p.add_argument("--consume-bootstrap", action="store_true",
|
|
560
587
|
help="login 成功后删除 bootstrap 文件")
|
|
561
588
|
p.add_argument("--token-env", action="store_true",
|
|
562
|
-
help="
|
|
589
|
+
help="要求已设置 DATAANALYST_AGENT_TOKEN(覆盖密码表)")
|
|
563
590
|
p.add_argument("--keep-token", action="store_true",
|
|
564
|
-
help="
|
|
591
|
+
help="沿用已有 DATAANALYST_AGENT_TOKEN,不交互粘贴")
|
|
565
592
|
p.add_argument("--keep-rms", action="store_true",
|
|
566
|
-
help="
|
|
593
|
+
help="沿用已有 RMS_<SHOP>_* 环境变量")
|
|
594
|
+
p.add_argument("--skip-secret-check", action="store_true",
|
|
595
|
+
help="跳过密码表拉取校验(不推荐)")
|
|
567
596
|
p.add_argument("--skip-heartbeat", action="store_true")
|
|
568
597
|
p.add_argument("--verify-rms", action="store_true",
|
|
569
598
|
help="用 Playwright 验证 R-Login 并写 state/<shop>.json")
|
|
@@ -575,7 +604,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
575
604
|
p.set_defaults(func=cmd_login)
|
|
576
605
|
|
|
577
606
|
p = sub.add_parser("status", parents=[common],
|
|
578
|
-
help="查看
|
|
607
|
+
help="查看 JDY_MCP / token 环境 / LaunchAgent / 心跳")
|
|
579
608
|
p.add_argument("--offline", action="store_true", help="不打中心心跳")
|
|
580
609
|
p.set_defaults(func=cmd_status)
|
|
581
610
|
|
|
@@ -600,21 +629,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
600
629
|
help="移除 Agent 与 caffeinate LaunchAgent")
|
|
601
630
|
p.set_defaults(func=cmd_uninstall_service)
|
|
602
631
|
|
|
603
|
-
adm = sub.add_parser("admin", help="
|
|
632
|
+
adm = sub.add_parser("admin", help="超管:清单 / 校验密码表 / 紧急 bootstrap")
|
|
604
633
|
adm_sub = adm.add_subparsers(dest="admin_cmd", required=True)
|
|
605
634
|
|
|
606
635
|
p = adm_sub.add_parser("checklist", parents=[common],
|
|
607
|
-
help="
|
|
636
|
+
help="打印某店开店清单(与销量计算系统凭据说明)")
|
|
608
637
|
p.set_defaults(func=cmd_admin_checklist)
|
|
609
638
|
|
|
639
|
+
p = adm_sub.add_parser("verify-secrets", parents=[common],
|
|
640
|
+
help="探测 JDY_MCP → API Key(与销量系统相同;不打印)")
|
|
641
|
+
p.add_argument("--verify-rms", action="store_true")
|
|
642
|
+
p.add_argument("--headed", action="store_true")
|
|
643
|
+
p.set_defaults(func=cmd_admin_verify_secrets)
|
|
644
|
+
|
|
610
645
|
p = adm_sub.add_parser("set-rms", parents=[common],
|
|
611
|
-
help="
|
|
646
|
+
help="已废弃:等同 verify-secrets")
|
|
612
647
|
p.add_argument("--verify-rms", action="store_true")
|
|
613
648
|
p.add_argument("--headed", action="store_true")
|
|
614
649
|
p.set_defaults(func=cmd_admin_set_rms)
|
|
615
650
|
|
|
616
651
|
p = adm_sub.add_parser("write-bootstrap", parents=[common],
|
|
617
|
-
help="
|
|
652
|
+
help="紧急:把 token 写成 600 文件(正式请写简道云)")
|
|
618
653
|
p.add_argument("--out", help="输出路径(默认 /tmp/dataanalyst-<SHOP>-bootstrap.json)")
|
|
619
654
|
p.set_defaults(func=cmd_admin_write_bootstrap)
|
|
620
655
|
|
package/jdy_secrets.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""简道云密码 / MCP —— 与「销量计算系统」jdy_api.py + config.py 一致。
|
|
4
|
+
|
|
5
|
+
本机钥匙串只存一件(店机由用户维护;本开发机可以没有):
|
|
6
|
+
service = JDY_MCP
|
|
7
|
+
account = 服务器URL
|
|
8
|
+
|
|
9
|
+
环境变量优先:JDY_MCP_URL
|
|
10
|
+
|
|
11
|
+
API Key:MCP member_data_list 查【密码PASSWORDパスワード】,
|
|
12
|
+
Title 精确匹配 JDY_API_KEY_TITLE(= config 中「api key 精细化运营」)。
|
|
13
|
+
不硬编码 Key;不打印 secret。
|
|
14
|
+
|
|
15
|
+
实现说明:逻辑与 zip 内 jdy_api 相同。若未安装 mcp/keyring,
|
|
16
|
+
则用等价的 urllib JSON-RPC / security CLI 完成同一调用(filter / fields / Title)。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
from urllib.error import URLError
|
|
29
|
+
from urllib.request import Request, urlopen
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger("jdy")
|
|
32
|
+
|
|
33
|
+
# ── 与销量计算系统 config.py 同名常量 ─────────────────────
|
|
34
|
+
JDY_PWD_APP_ID = "5a6f241ae24fd5382436f985"
|
|
35
|
+
JDY_PWD_ENTRY_ID = "67188a2824e6cc898a1d7d7f"
|
|
36
|
+
JDY_PWD_F_TITLE = "_widget_1729661480436" # Title (text)
|
|
37
|
+
JDY_PWD_F_PASSWORD = "_widget_1729661480439" # Password (text)
|
|
38
|
+
JDY_API_KEY_TITLE = "api key 精细化运营"
|
|
39
|
+
JDY_MCP_KEYRING_SERVICE = "JDY_MCP"
|
|
40
|
+
JDY_MCP_KEYRING_ACCOUNT = "服务器URL"
|
|
41
|
+
|
|
42
|
+
# 别名(旧调用方)
|
|
43
|
+
JDY_MCP_SERVICE = JDY_MCP_KEYRING_SERVICE
|
|
44
|
+
JDY_MCP_ACCOUNT = JDY_MCP_KEYRING_ACCOUNT
|
|
45
|
+
|
|
46
|
+
_API_KEY: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_mcp_url() -> str:
|
|
50
|
+
"""MCP URL 优先级:环境变量 > 钥匙串(与销量计算系统 jdy_api 一致)"""
|
|
51
|
+
env = os.environ.get("JDY_MCP_URL")
|
|
52
|
+
if env:
|
|
53
|
+
return env
|
|
54
|
+
try:
|
|
55
|
+
import keyring
|
|
56
|
+
v = keyring.get_password(JDY_MCP_KEYRING_SERVICE,
|
|
57
|
+
JDY_MCP_KEYRING_ACCOUNT)
|
|
58
|
+
if v:
|
|
59
|
+
return v
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
# keyring 未装或读失败时:同条目的 security CLI(仍是 JDY_MCP/服务器URL)
|
|
63
|
+
try:
|
|
64
|
+
r = subprocess.run(
|
|
65
|
+
["security", "find-generic-password",
|
|
66
|
+
"-s", JDY_MCP_KEYRING_SERVICE,
|
|
67
|
+
"-a", JDY_MCP_KEYRING_ACCOUNT, "-w"],
|
|
68
|
+
capture_output=True, text=True,
|
|
69
|
+
)
|
|
70
|
+
if r.returncode == 0 and (r.stdout or "").strip():
|
|
71
|
+
return (r.stdout or "").rstrip("\n")
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"未找到 JDY MCP URL:请设置环境变量 JDY_MCP_URL "
|
|
76
|
+
"或钥匙串条目 JDY_MCP/服务器URL"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_mcp_url() -> str:
|
|
81
|
+
return _get_mcp_url()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def mcp_url_present() -> bool:
|
|
85
|
+
try:
|
|
86
|
+
_get_mcp_url()
|
|
87
|
+
return True
|
|
88
|
+
except RuntimeError:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _fetch_api_key_async() -> str:
|
|
93
|
+
"""MCP member_data_list 查询密码表单,按 Title 精确匹配取 Password 字段。"""
|
|
94
|
+
# 优先与销量系统相同的 mcp SDK
|
|
95
|
+
try:
|
|
96
|
+
from mcp import ClientSession
|
|
97
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
98
|
+
except ImportError:
|
|
99
|
+
return _fetch_api_key_urllib()
|
|
100
|
+
|
|
101
|
+
async with streamable_http_client(_get_mcp_url()) as (read, write):
|
|
102
|
+
async with ClientSession(read, write) as session:
|
|
103
|
+
await session.initialize()
|
|
104
|
+
await asyncio.sleep(1) # JDY MCP 限流 3 次/秒
|
|
105
|
+
res = await session.call_tool("member_data_list", {
|
|
106
|
+
"app_id": JDY_PWD_APP_ID,
|
|
107
|
+
"entry_id": JDY_PWD_ENTRY_ID,
|
|
108
|
+
"limit": 20,
|
|
109
|
+
"fields": [JDY_PWD_F_TITLE, JDY_PWD_F_PASSWORD],
|
|
110
|
+
"filter": {"rel": "and", "cond": [
|
|
111
|
+
{"field": JDY_PWD_F_TITLE, "type": "text",
|
|
112
|
+
"method": "like", "value": ["api key"]},
|
|
113
|
+
]},
|
|
114
|
+
})
|
|
115
|
+
if res.is_error:
|
|
116
|
+
raise RuntimeError(f"MCP member_data_list 失败: {res.content}")
|
|
117
|
+
for block in res.content:
|
|
118
|
+
if getattr(block, "type", None) != "text":
|
|
119
|
+
continue
|
|
120
|
+
rows = json.loads(block.text).get("data", [])
|
|
121
|
+
for row in rows:
|
|
122
|
+
if row.get(JDY_PWD_F_TITLE) == JDY_API_KEY_TITLE:
|
|
123
|
+
key = row.get(JDY_PWD_F_PASSWORD)
|
|
124
|
+
if key:
|
|
125
|
+
log.info("JDY API Key 已从密码表单读取: %s",
|
|
126
|
+
JDY_API_KEY_TITLE)
|
|
127
|
+
return key
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
f"密码表单中未找到 Title={JDY_API_KEY_TITLE!r} 的 API Key"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _rpc(url: str, method: str, params: dict) -> dict:
|
|
134
|
+
body = json.dumps(
|
|
135
|
+
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
136
|
+
).encode()
|
|
137
|
+
req = Request(
|
|
138
|
+
url, data=body, method="POST",
|
|
139
|
+
headers={"Content-Type": "application/json",
|
|
140
|
+
"Accept": "application/json, text/event-stream"},
|
|
141
|
+
)
|
|
142
|
+
last_err: Exception | None = None
|
|
143
|
+
for attempt in range(3):
|
|
144
|
+
try:
|
|
145
|
+
with urlopen(req, timeout=40) as resp:
|
|
146
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
147
|
+
break
|
|
148
|
+
except (URLError, TimeoutError, ConnectionError) as exc:
|
|
149
|
+
last_err = exc
|
|
150
|
+
time.sleep(1.5 * (attempt + 1))
|
|
151
|
+
else:
|
|
152
|
+
raise RuntimeError(f"JDY MCP 网络失败: {type(last_err).__name__}")
|
|
153
|
+
m = re.search(r"data:\s*(\{.*\})", raw, re.S)
|
|
154
|
+
data = json.loads(m.group(1) if m else raw)
|
|
155
|
+
if "error" in data:
|
|
156
|
+
err = data["error"]
|
|
157
|
+
msg = err.get("message", err) if isinstance(err, dict) else err
|
|
158
|
+
raise RuntimeError(f"JDY MCP {method} 失败: {msg}")
|
|
159
|
+
return data.get("result", {})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _fetch_api_key_urllib() -> str:
|
|
163
|
+
"""无 mcp 包时:同一 member_data_list 参数,经 JSON-RPC 调用。"""
|
|
164
|
+
url = _get_mcp_url()
|
|
165
|
+
time.sleep(1)
|
|
166
|
+
res = _rpc(url, "tools/call", {
|
|
167
|
+
"name": "member_data_list",
|
|
168
|
+
"arguments": {
|
|
169
|
+
"app_id": JDY_PWD_APP_ID,
|
|
170
|
+
"entry_id": JDY_PWD_ENTRY_ID,
|
|
171
|
+
"limit": 20,
|
|
172
|
+
"fields": [JDY_PWD_F_TITLE, JDY_PWD_F_PASSWORD],
|
|
173
|
+
"filter": {"rel": "and", "cond": [
|
|
174
|
+
{"field": JDY_PWD_F_TITLE, "type": "text",
|
|
175
|
+
"method": "like", "value": ["api key"]},
|
|
176
|
+
]},
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
text = ""
|
|
180
|
+
for c in res.get("content", []) or []:
|
|
181
|
+
if c.get("type") == "text":
|
|
182
|
+
text = c.get("text") or ""
|
|
183
|
+
break
|
|
184
|
+
if not text:
|
|
185
|
+
raise RuntimeError("MCP member_data_list 无 text 内容")
|
|
186
|
+
if text.strip().startswith("API error"):
|
|
187
|
+
raise RuntimeError(f"MCP member_data_list 失败: {text.strip()[:200]}")
|
|
188
|
+
body: Any = json.loads(text)
|
|
189
|
+
rows = body.get("data") if isinstance(body, dict) else body
|
|
190
|
+
for row in rows or []:
|
|
191
|
+
if not isinstance(row, dict):
|
|
192
|
+
continue
|
|
193
|
+
if row.get(JDY_PWD_F_TITLE) == JDY_API_KEY_TITLE:
|
|
194
|
+
key = row.get(JDY_PWD_F_PASSWORD)
|
|
195
|
+
if key:
|
|
196
|
+
log.info("JDY API Key 已从密码表单读取: %s", JDY_API_KEY_TITLE)
|
|
197
|
+
return key
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"密码表单中未找到 Title={JDY_API_KEY_TITLE!r} 的 API Key"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_api_key() -> str:
|
|
204
|
+
"""同步接口:动态读取 JDY API Key(不落盘、不硬编码、不打印)。"""
|
|
205
|
+
global _API_KEY
|
|
206
|
+
if _API_KEY:
|
|
207
|
+
return _API_KEY
|
|
208
|
+
try:
|
|
209
|
+
# mcp SDK 路径
|
|
210
|
+
import mcp # noqa: F401
|
|
211
|
+
key = asyncio.run(_fetch_api_key_async())
|
|
212
|
+
except ImportError:
|
|
213
|
+
key = _fetch_api_key_urllib()
|
|
214
|
+
except Exception:
|
|
215
|
+
# SDK 可用但失败时,再试 urllib 等价路径
|
|
216
|
+
try:
|
|
217
|
+
key = _fetch_api_key_urllib()
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
raise RuntimeError(
|
|
220
|
+
f"读取 JDY API Key 失败: {type(exc).__name__}"
|
|
221
|
+
) from exc
|
|
222
|
+
_API_KEY = key
|
|
223
|
+
return key
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def clear_cache() -> None:
|
|
227
|
+
global _API_KEY
|
|
228
|
+
_API_KEY = None
|
package/keychain_util.py
CHANGED
|
@@ -1,115 +1,108 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
3
|
+
"""凭据入口(兼容旧 import 名 keychain_util)。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
·
|
|
7
|
-
·
|
|
8
|
-
|
|
5
|
+
与「销量计算系统」一致:
|
|
6
|
+
· 本机 Keychain 只用于 JDY_MCP / 服务器URL(店机维护;本开发机可无)
|
|
7
|
+
· JDY API Key 经 MCP 密码表读取(Title = api key 精细化运营)
|
|
8
|
+
|
|
9
|
+
已废弃(不再读写):
|
|
10
|
+
· dataanalyst-agent-token
|
|
11
|
+
· dataanalyst/rms/<SHOP>-rlogin / -ai
|
|
12
|
+
|
|
13
|
+
Agent token:环境变量 DATAANALYST_AGENT_TOKEN(或 login --bootstrap 注入本进程)。
|
|
14
|
+
RMS 登录:env-file / 环境变量 RMS_<SHOP>_*,见 rms_browser_login。
|
|
9
15
|
"""
|
|
10
16
|
from __future__ import annotations
|
|
11
17
|
|
|
12
18
|
import os
|
|
13
|
-
import re
|
|
14
|
-
import subprocess
|
|
15
19
|
from typing import Optional
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
import jdy_secrets as jdy
|
|
18
22
|
|
|
23
|
+
AGENT_TOKEN_SERVICE = "DEPRECATED:dataanalyst-agent-token"
|
|
24
|
+
JDY_MCP_SERVICE = jdy.JDY_MCP_KEYRING_SERVICE
|
|
25
|
+
JDY_MCP_ACCOUNT = jdy.JDY_MCP_KEYRING_ACCOUNT
|
|
26
|
+
JDY_API_KEY_TITLE = jdy.JDY_API_KEY_TITLE
|
|
19
27
|
|
|
20
|
-
def rms_rlogin_service(shop: str) -> str:
|
|
21
|
-
return f"dataanalyst/rms/{shop.upper()}-rlogin"
|
|
22
28
|
|
|
29
|
+
def get_mcp_url() -> str:
|
|
30
|
+
return jdy.get_mcp_url()
|
|
23
31
|
|
|
24
|
-
def rms_ai_service(shop: str) -> str:
|
|
25
|
-
return f"dataanalyst/rms/{shop.upper()}-ai"
|
|
26
32
|
|
|
33
|
+
def get_api_key() -> str:
|
|
34
|
+
"""与销量计算系统 jdy_api.get_api_key 相同。勿打印返回值。"""
|
|
35
|
+
return jdy.get_api_key()
|
|
27
36
|
|
|
28
|
-
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
|
|
29
|
-
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if account:
|
|
35
|
-
cmd += ["-a", account]
|
|
36
|
-
r = _run(cmd, check=False)
|
|
37
|
-
return r.returncode == 0
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def get_password(service: str, account: Optional[str] = None) -> str:
|
|
41
|
-
"""读取密码;失败抛 RuntimeError。调用方不得打印返回值。"""
|
|
42
|
-
cmd = ["security", "find-generic-password", "-s", service, "-w"]
|
|
43
|
-
if account:
|
|
44
|
-
cmd += ["-a", account]
|
|
45
|
-
r = _run(cmd, check=False)
|
|
46
|
-
if r.returncode != 0:
|
|
47
|
-
raise RuntimeError(f"Keychain 缺少条目: {service}")
|
|
48
|
-
return (r.stdout or "").rstrip("\n")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def get_account(service: str) -> str:
|
|
52
|
-
r = _run(["security", "find-generic-password", "-s", service], check=False)
|
|
53
|
-
if r.returncode != 0:
|
|
54
|
-
raise RuntimeError(f"Keychain 缺少条目: {service}")
|
|
55
|
-
m = re.search(r'"acct"<blob>="([^"]*)"', r.stdout or "")
|
|
56
|
-
if not m:
|
|
57
|
-
raise RuntimeError(f"Keychain 缺 account: {service}")
|
|
58
|
-
return m.group(1)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def set_generic(service: str, account: str, secret: str, *,
|
|
62
|
-
label: str = "") -> None:
|
|
63
|
-
"""写入/覆盖 generic password;secret 不进日志。"""
|
|
64
|
-
if not secret:
|
|
65
|
-
raise ValueError(f"拒绝写入空 secret: {service}")
|
|
66
|
-
cmd = ["security", "add-generic-password", "-U",
|
|
67
|
-
"-s", service, "-a", account, "-w", secret]
|
|
68
|
-
if label:
|
|
69
|
-
cmd += ["-l", label]
|
|
70
|
-
r = _run(cmd, check=False)
|
|
71
|
-
if r.returncode != 0:
|
|
72
|
-
err = (r.stderr or r.stdout or "").strip() or f"exit {r.returncode}"
|
|
73
|
-
# 不回显可能含参数的完整命令
|
|
74
|
-
raise RuntimeError(f"Keychain 写入失败 ({service}): {err[:200]}")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def set_agent_token(token: str, *, account: Optional[str] = None) -> None:
|
|
78
|
-
acct = account or os.environ.get("USER", "")
|
|
79
|
-
if not acct:
|
|
80
|
-
raise RuntimeError("无法确定 Keychain account($USER 为空)")
|
|
81
|
-
if not token.startswith("da_"):
|
|
82
|
-
raise ValueError("Agent token 应以 da_ 开头")
|
|
83
|
-
set_generic(AGENT_TOKEN_SERVICE, acct, token,
|
|
84
|
-
label="dataanalyst agent token")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def get_agent_token() -> str:
|
|
88
|
-
env = os.environ.get("DATAANALYST_AGENT_TOKEN", "").strip()
|
|
38
|
+
def get_agent_token(shop: Optional[str] = None) -> str:
|
|
39
|
+
"""中心 Agent token:只从环境变量读(不再走 dataanalyst Keychain / 自创 JDY Title)。"""
|
|
40
|
+
env = (os.environ.get("DATAANALYST_AGENT_TOKEN") or "").strip()
|
|
89
41
|
if env:
|
|
90
42
|
return env
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# 兼容早期未指定 -a 的写法
|
|
96
|
-
return get_password(AGENT_TOKEN_SERVICE)
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
"未设置 DATAANALYST_AGENT_TOKEN。"
|
|
45
|
+
"请 export、或 genduo-desk login --bootstrap / --token-env"
|
|
46
|
+
)
|
|
97
47
|
|
|
98
48
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
49
|
+
def rms_env_prefix(shop: str) -> str:
|
|
50
|
+
return f"RMS_{shop.upper()}_"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_rms_creds(shop: str) -> dict[str, str]:
|
|
54
|
+
"""从环境变量读 RMS 两组。勿打印。"""
|
|
55
|
+
p = rms_env_prefix(shop)
|
|
56
|
+
out = {
|
|
57
|
+
"rlogin_id": (os.environ.get(p + "LOGIN_ID") or "").strip(),
|
|
58
|
+
"rlogin_password": (os.environ.get(p + "PASSWORD") or "").strip(),
|
|
59
|
+
"sso_user": (os.environ.get(p + "SHOP_USER") or "").strip(),
|
|
60
|
+
"sso_password": (os.environ.get(p + "SHOP_PASSWORD") or "").strip(),
|
|
61
|
+
}
|
|
62
|
+
missing = [k for k, v in out.items() if not v]
|
|
63
|
+
if missing:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
f"{shop.upper()} 缺少环境变量 {p}*(缺: {', '.join(missing)});"
|
|
66
|
+
"见 rms_browser_login --env-file"
|
|
67
|
+
)
|
|
68
|
+
return out
|
|
106
69
|
|
|
107
70
|
|
|
108
71
|
def rms_creds_present(shop: str) -> dict:
|
|
109
|
-
"""
|
|
110
|
-
|
|
72
|
+
"""布尔:mcp_url / api_key 不在此探测网络;agent_token / rlogin / ai 看环境变量。"""
|
|
73
|
+
p = rms_env_prefix(shop)
|
|
111
74
|
return {
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
75
|
+
"mcp_url": jdy.mcp_url_present(),
|
|
76
|
+
"agent_token": bool((os.environ.get("DATAANALYST_AGENT_TOKEN") or "").strip()),
|
|
77
|
+
"rlogin": bool((os.environ.get(p + "LOGIN_ID") or "").strip()
|
|
78
|
+
and (os.environ.get(p + "PASSWORD") or "").strip()),
|
|
79
|
+
"ai": bool((os.environ.get(p + "SHOP_USER") or "").strip()
|
|
80
|
+
and (os.environ.get(p + "SHOP_PASSWORD") or "").strip()),
|
|
115
81
|
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def has_generic(service: str, account: Optional[str] = None) -> bool:
|
|
85
|
+
if service == JDY_MCP_SERVICE:
|
|
86
|
+
return jdy.mcp_url_present()
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def set_generic(*_a, **_k) -> None:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"已废弃:不再写入 dataanalyst/* Keychain。"
|
|
93
|
+
"与销量计算系统一致,本机只维护 JDY_MCP / 服务器URL。"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def set_agent_token(*_a, **_k) -> None:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
"已废弃:不再写入 dataanalyst-agent-token。"
|
|
100
|
+
"请使用环境变量 DATAANALYST_AGENT_TOKEN 或 login --bootstrap。"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def set_rms_creds(*_a, **_k) -> None:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"已废弃:不再写入 dataanalyst/rms/* Keychain。"
|
|
107
|
+
"请使用 RMS_<SHOP>_* 环境变量或 rms_browser_login --env-file。"
|
|
108
|
+
)
|
package/local_config.py
CHANGED
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
SHOPS = ["0RT1", "3RT1", "5RT1", "3RT2", "5RT2", "7RT2"]
|
|
11
11
|
DEFAULT_BASE = "https://dataanalyst.jp"
|
|
12
|
-
VERSION = "0.
|
|
12
|
+
VERSION = "0.5.0"
|
|
13
13
|
HERE = Path(__file__).resolve().parent
|
|
14
14
|
# 优先用户级配置,其次包内 state(POC 兼容)
|
|
15
15
|
USER_CFG = Path.home() / ".config" / "dataanalyst-agent" / "config.json"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genduo/desk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Local Mac desk agent CLI for genduo",
|
|
5
5
|
"bin": {
|
|
6
6
|
"genduo-desk": "bin/genduo-desk.js"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"cli.py",
|
|
12
12
|
"api_client.py",
|
|
13
13
|
"keychain_util.py",
|
|
14
|
+
"jdy_secrets.py",
|
|
14
15
|
"local_config.py",
|
|
15
16
|
"run.py",
|
|
16
17
|
"rms_browser_login.py",
|
package/rms_browser_login.py
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
NOTE (npm / shop CLI):
|
|
6
6
|
源真相仍是 monorepo `tools/rms_browser_login.py`(VPS 路径也用那份)。
|
|
7
7
|
本文件是 collector-agent 的 vendored 副本,供 `npm i -g dataanalyst-agent`
|
|
8
|
-
后门店机无 monorepo 也能跑 `--verify-rms
|
|
8
|
+
后门店机无 monorepo 也能跑 `--verify-rms`。打包前若源文件有变,请同步拷贝;
|
|
9
|
+
勿再读 dataanalyst/rms/* Keychain;JDY_MCP 见 jdy_secrets.py(与销量系统一致)。
|
|
9
10
|
|
|
10
11
|
Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
|
|
11
12
|
1. glogin 填 R-Login ID / 密码(第一组);
|
|
12
13
|
2. Rakuten 会员填邮箱 / 密码(第二组);
|
|
13
14
|
3. R-Login 提示页「次へ」→ RMS 主菜单「RMSを利用します」。
|
|
14
15
|
|
|
15
|
-
凭据从 env
|
|
16
|
+
凭据从 env 文件读,不打印。会话保存为 600 的 storage_state。
|
|
17
|
+
简道云 JDY_MCP / API Key 逻辑见 collector-agent/jdy_secrets.py(与销量计算系统一致)。
|
|
16
18
|
|
|
17
19
|
用法:
|
|
18
20
|
# VPS / env 文件
|
|
@@ -21,16 +23,14 @@ Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
|
|
|
21
23
|
--env-file /etc/dataanalyst/rms.env \\
|
|
22
24
|
--out /etc/dataanalyst/rms_session/7RT2.json
|
|
23
25
|
|
|
24
|
-
#
|
|
25
|
-
python tools/rms_browser_login.py --shop 7RT2 --from-
|
|
26
|
+
# 本机:Keychain 仅 JDY_MCP;RMS 凭据从简道云密码表取
|
|
27
|
+
python tools/rms_browser_login.py --shop 7RT2 --from-jdy \\
|
|
26
28
|
--out tools/collector-agent/state/7RT2.json
|
|
27
29
|
"""
|
|
28
30
|
from __future__ import annotations
|
|
29
31
|
|
|
30
32
|
import argparse
|
|
31
33
|
import os
|
|
32
|
-
import re
|
|
33
|
-
import subprocess
|
|
34
34
|
import sys
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
|
|
@@ -53,36 +53,16 @@ def load_env(path: str) -> dict:
|
|
|
53
53
|
return out
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def keychain_get(service: str) -> tuple[str, str]:
|
|
57
|
-
"""Return (account, password) from macOS Keychain. Never prints password."""
|
|
58
|
-
meta = subprocess.check_output(
|
|
59
|
-
["security", "find-generic-password", "-s", service],
|
|
60
|
-
stderr=subprocess.DEVNULL,
|
|
61
|
-
).decode("utf-8", errors="replace")
|
|
62
|
-
m = re.search(r'"acct"<blob>="([^"]*)"', meta)
|
|
63
|
-
if not m:
|
|
64
|
-
raise SystemExit(f"Keychain 缺 account: {service}")
|
|
65
|
-
account = m.group(1)
|
|
66
|
-
password = subprocess.check_output(
|
|
67
|
-
["security", "find-generic-password", "-s", service, "-w"],
|
|
68
|
-
stderr=subprocess.DEVNULL,
|
|
69
|
-
).decode("utf-8", errors="replace").rstrip("\n")
|
|
70
|
-
if not password:
|
|
71
|
-
raise SystemExit(f"Keychain 缺 password: {service}")
|
|
72
|
-
return account, password
|
|
73
56
|
|
|
74
57
|
|
|
75
58
|
def load_from_keychain(shop: str) -> dict:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
prefix + "SHOP_USER": sso_user,
|
|
84
|
-
prefix + "SHOP_PASSWORD": sso_pass,
|
|
85
|
-
}
|
|
59
|
+
"""已废弃:不再读 dataanalyst/rms/* Keychain。请用 --env-file 或环境变量。"""
|
|
60
|
+
raise SystemExit(
|
|
61
|
+
"已废弃 --from-keychain(dataanalyst/rms/*)。"
|
|
62
|
+
"请使用 --env-file 或环境变量 RMS_<SHOP>_LOGIN_ID/PASSWORD/SHOP_USER/SHOP_PASSWORD。"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
86
66
|
|
|
87
67
|
|
|
88
68
|
def _try_click(page, role: str, name: str, timeout: int = 4000) -> bool:
|
|
@@ -185,7 +165,7 @@ def main() -> int:
|
|
|
185
165
|
ap.add_argument("--shop", required=True)
|
|
186
166
|
ap.add_argument("--env-file", default="/etc/dataanalyst/rms.env")
|
|
187
167
|
ap.add_argument("--from-keychain", action="store_true",
|
|
188
|
-
help="
|
|
168
|
+
help="已废弃(原 dataanalyst/rms/* Keychain)")
|
|
189
169
|
ap.add_argument("--out", help="默认 tools/collector-agent/state/<shop>.json(本机)")
|
|
190
170
|
ap.add_argument("--screenshot", help="可选截图路径(不含密钥)")
|
|
191
171
|
ap.add_argument("--headed", action="store_true")
|
|
@@ -193,7 +173,6 @@ def main() -> int:
|
|
|
193
173
|
out = a.out or f"tools/collector-agent/state/{a.shop.upper()}.json"
|
|
194
174
|
try:
|
|
195
175
|
if a.from_keychain:
|
|
196
|
-
print("cred_source", "keychain")
|
|
197
176
|
env = load_from_keychain(a.shop)
|
|
198
177
|
# Confirm accounts only (no passwords)
|
|
199
178
|
prefix = f"RMS_{a.shop.upper()}_"
|
package/run.py
CHANGED
|
@@ -244,7 +244,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
244
244
|
return 2
|
|
245
245
|
base = conf.get("base") or cfg.DEFAULT_BASE
|
|
246
246
|
try:
|
|
247
|
-
token = kc.get_agent_token()
|
|
247
|
+
token = kc.get_agent_token(shop)
|
|
248
248
|
except RuntimeError as exc:
|
|
249
249
|
log.error("%s", exc)
|
|
250
250
|
return 2
|