@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/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
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """凭据入口(兼容旧 import 名 keychain_util)。
4
+
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。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Optional
20
+
21
+ import jdy_secrets as jdy
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
27
+
28
+
29
+ def get_mcp_url() -> str:
30
+ return jdy.get_mcp_url()
31
+
32
+
33
+ def get_api_key() -> str:
34
+ """与销量计算系统 jdy_api.get_api_key 相同。勿打印返回值。"""
35
+ return jdy.get_api_key()
36
+
37
+
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()
41
+ if env:
42
+ return env
43
+ raise RuntimeError(
44
+ "未设置 DATAANALYST_AGENT_TOKEN。"
45
+ "请 export、或 genduo-desk login --bootstrap / --token-env"
46
+ )
47
+
48
+
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
69
+
70
+
71
+ def rms_creds_present(shop: str) -> dict:
72
+ """布尔:mcp_url / api_key 不在此探测网络;agent_token / rlogin / ai 看环境变量。"""
73
+ p = rms_env_prefix(shop)
74
+ return {
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()),
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
+ )
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """本机非机密配置(shop / base URL)。不存 token / 密码。"""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ SHOPS = ["0RT1", "3RT1", "5RT1", "3RT2", "5RT2", "7RT2"]
11
+ DEFAULT_BASE = "https://dataanalyst.jp"
12
+ VERSION = "0.5.0"
13
+ HERE = Path(__file__).resolve().parent
14
+ # 优先用户级配置,其次包内 state(POC 兼容)
15
+ USER_CFG = Path.home() / ".config" / "dataanalyst-agent" / "config.json"
16
+ STATE_CFG = HERE / "state" / "config.json"
17
+
18
+
19
+ def config_path() -> Path:
20
+ if USER_CFG.is_file():
21
+ return USER_CFG
22
+ return STATE_CFG
23
+
24
+
25
+ def load() -> dict:
26
+ path = config_path()
27
+ if not path.is_file():
28
+ return {
29
+ "shop": os.environ.get("DATAANALYST_SHOP", "").upper() or None,
30
+ "base": os.environ.get("DATAANALYST_BASE", DEFAULT_BASE).rstrip("/"),
31
+ }
32
+ data = json.loads(path.read_text(encoding="utf-8"))
33
+ shop = (data.get("shop") or os.environ.get("DATAANALYST_SHOP") or "").upper() or None
34
+ base = (data.get("base") or os.environ.get("DATAANALYST_BASE") or DEFAULT_BASE).rstrip("/")
35
+ return {"shop": shop, "base": base}
36
+
37
+
38
+ def save(shop: str, base: str = DEFAULT_BASE) -> Path:
39
+ shop_u = shop.upper()
40
+ if shop_u not in SHOPS:
41
+ raise ValueError(f"非法店铺: {shop}(允许: {', '.join(SHOPS)})")
42
+ path = USER_CFG
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ payload = {"shop": shop_u, "base": base.rstrip("/")}
45
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
46
+ encoding="utf-8")
47
+ os.chmod(path, 0o600)
48
+ # 同步一份到 state,方便 POC 路径发现
49
+ STATE_CFG.parent.mkdir(parents=True, exist_ok=True)
50
+ STATE_CFG.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
51
+ encoding="utf-8")
52
+ os.chmod(STATE_CFG, 0o600)
53
+ return path
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@genduo/desk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "Local Mac desk agent CLI for genduo",
5
+ "bin": {
6
+ "genduo-desk": "bin/genduo-desk.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "scripts/",
11
+ "cli.py",
12
+ "api_client.py",
13
+ "keychain_util.py",
14
+ "jdy_secrets.py",
15
+ "local_config.py",
16
+ "run.py",
17
+ "rms_browser_login.py",
18
+ "README.md",
19
+ "collect_access.py",
20
+ "collect_ads.py",
21
+ "ingest_util.py",
22
+ "rms_ads_parse.py"
23
+ ],
24
+ "scripts": {
25
+ "postinstall": "node scripts/postinstall.js"
26
+ },
27
+ "os": [
28
+ "darwin"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "preferGlobal": true,
34
+ "license": "UNLICENSED",
35
+ "private": false,
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "keywords": [
40
+ "genduo",
41
+ "macos",
42
+ "cli",
43
+ "desk"
44
+ ]
45
+ }
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """RPP 报表 ZIP/CSV 解析。"""
4
+ from __future__ import annotations
5
+
6
+ import csv
7
+ import io
8
+ import re
9
+ import zipfile
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ YM_RE = re.compile(r"(\d{4})年(\d{1,2})月")
14
+ DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
15
+
16
+
17
+ def _num(v):
18
+ if v is None:
19
+ return None
20
+ s = str(v).replace(",", "").replace("%", "").replace("円", "").strip()
21
+ if not s or s in ("-", "--"):
22
+ return None
23
+ try:
24
+ return float(s)
25
+ except ValueError:
26
+ return None
27
+
28
+
29
+ def _period(value: str) -> tuple[date, date] | None:
30
+ m = YM_RE.search(value or "")
31
+ if m:
32
+ y, mo = int(m.group(1)), int(m.group(2))
33
+ start = date(y, mo, 1)
34
+ nxt = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
35
+ return start, date.fromordinal(nxt.toordinal() - 1)
36
+ m = DATE_RE.search(value or "")
37
+ if m:
38
+ d = date(*map(int, m.groups()))
39
+ return d, d
40
+ return None
41
+
42
+
43
+ def _text(buf: bytes) -> str:
44
+ for enc in ("cp932", "utf-8-sig", "utf-8"):
45
+ try:
46
+ return buf.decode(enc)
47
+ except UnicodeDecodeError:
48
+ continue
49
+ return buf.decode("cp932", "replace")
50
+
51
+
52
+ def parse_zip(buf: bytes, ad_type: str, shop: str) -> list[dict]:
53
+ with zipfile.ZipFile(io.BytesIO(buf)) as z:
54
+ names = [n for n in z.namelist() if n.lower().endswith(".csv")]
55
+ if not names:
56
+ return []
57
+ text = _text(z.read(names[0]))
58
+ rows = list(csv.reader(io.StringIO(text)))
59
+ header_idx = next((i for i, r in enumerate(rows)
60
+ if r and "コントロールカラム" in r[0]), None)
61
+ if header_idx is None:
62
+ raise ValueError("找不到表头(コントロールカラム)")
63
+ header = [h.strip() for h in rows[header_idx]]
64
+ idx = {h: i for i, h in enumerate(header)}
65
+
66
+ def col(row, name):
67
+ i = idx.get(name)
68
+ return row[i] if i is not None and i < len(row) else None
69
+
70
+ out = []
71
+ for row in rows[header_idx + 1:]:
72
+ if not row or not any(x.strip() for x in row):
73
+ continue
74
+ d = col(row, "日付")
75
+ period = _period(d or "")
76
+ if not period:
77
+ continue
78
+ item = (col(row, "商品管理番号") or "").strip()
79
+ keyword = (col(row, "キーワード") or "").strip()
80
+ if ad_type == "rpp_item" and not item:
81
+ continue
82
+ if ad_type == "rpp_keyword" and not keyword:
83
+ continue
84
+ start, end = period
85
+ out.append({
86
+ "shop": shop, "ad_type": ad_type, "period_type": "monthly",
87
+ "period_start": start, "period_end": end,
88
+ "item_number": item, "item_name": col(row, "商品名"),
89
+ "keyword": keyword,
90
+ "clicks": int(float(col(row, "クリック数(合計)") or 0)),
91
+ "cost": _num(col(row, "実績額(合計)")),
92
+ "sales": _num(col(row, "売上金額(合計720時間)")),
93
+ "orders": int(float(col(row, "売上件数(合計720時間)") or 0)),
94
+ "cpc": _num(col(row, "CPC実績(合計)")),
95
+ "ctr": _num(col(row, "CTR(%)")),
96
+ "roas": _num(col(row, "ROAS(合計720時間)(%)")),
97
+ "cvr": _num(col(row, "CVR(合計720時間)(%)")),
98
+ })
99
+ return out