@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 +187 -2
- package/api_client.py +65 -0
- package/bin/genduo-desk.js +54 -0
- package/cli.py +667 -0
- package/collect_access.py +162 -0
- package/collect_ads.py +255 -0
- package/ingest_util.py +136 -0
- package/jdy_secrets.py +228 -0
- package/keychain_util.py +108 -0
- package/local_config.py +53 -0
- package/package.json +43 -4
- package/rms_ads_parse.py +99 -0
- package/rms_browser_login.py +192 -0
- package/run.py +303 -0
- package/scripts/postinstall.js +23 -0
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
ADDED
|
@@ -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
|
+
)
|
package/local_config.py
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/rms_ads_parse.py
ADDED
|
@@ -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
|