@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.
@@ -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.4.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,44 @@
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.4.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
+ "local_config.py",
15
+ "run.py",
16
+ "rms_browser_login.py",
17
+ "README.md",
18
+ "collect_access.py",
19
+ "collect_ads.py",
20
+ "ingest_util.py",
21
+ "rms_ads_parse.py"
22
+ ],
23
+ "scripts": {
24
+ "postinstall": "node scripts/postinstall.js"
25
+ },
26
+ "os": [
27
+ "darwin"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "preferGlobal": true,
33
+ "license": "UNLICENSED",
34
+ "private": false,
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "keywords": [
39
+ "genduo",
40
+ "macos",
41
+ "cli",
42
+ "desk"
43
+ ]
44
+ }
@@ -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
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """R-Login 浏览器登录(Playwright)并保存会话。
4
+
5
+ NOTE (npm / shop CLI):
6
+ 源真相仍是 monorepo `tools/rms_browser_login.py`(VPS 路径也用那份)。
7
+ 本文件是 collector-agent 的 vendored 副本,供 `npm i -g dataanalyst-agent`
8
+ 后门店机无 monorepo 也能跑 `--verify-rms`。打包前若源文件有变,请同步拷贝。
9
+
10
+ Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
11
+ 1. glogin 填 R-Login ID / 密码(第一组);
12
+ 2. Rakuten 会员填邮箱 / 密码(第二组);
13
+ 3. R-Login 提示页「次へ」→ RMS 主菜单「RMSを利用します」。
14
+
15
+ 凭据从 env 文件或 macOS Keychain 读,不打印。会话保存为 600 的 storage_state。
16
+
17
+ 用法:
18
+ # VPS / env 文件
19
+ PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright \\
20
+ python tools/rms_browser_login.py --shop 7RT2 \\
21
+ --env-file /etc/dataanalyst/rms.env \\
22
+ --out /etc/dataanalyst/rms_session/7RT2.json
23
+
24
+ # 本机 Keychain(service: dataanalyst/rms/<shop>-rlogin 与 -ai)
25
+ python tools/rms_browser_login.py --shop 7RT2 --from-keychain \\
26
+ --out tools/collector-agent/state/7RT2.json
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import os
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ # Prefer existing PLAYWRIGHT_BROWSERS_PATH; only default to VPS path if present.
38
+ _vps_browsers = "/opt/ms-playwright"
39
+ if "PLAYWRIGHT_BROWSERS_PATH" not in os.environ and Path(_vps_browsers).is_dir():
40
+ os.environ["PLAYWRIGHT_BROWSERS_PATH"] = _vps_browsers
41
+
42
+ from playwright.sync_api import TimeoutError as PWTimeout # noqa: E402
43
+ from playwright.sync_api import sync_playwright # noqa: E402
44
+
45
+
46
+ def load_env(path: str) -> dict:
47
+ out = {}
48
+ for line in Path(path).read_text(encoding="utf-8").splitlines():
49
+ line = line.strip()
50
+ if line and not line.startswith("#") and "=" in line:
51
+ k, v = line.split("=", 1)
52
+ out[k.strip()] = v.strip()
53
+ return out
54
+
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
+
74
+
75
+ def load_from_keychain(shop: str) -> dict:
76
+ shop_u = shop.upper()
77
+ login_id, password = keychain_get(f"dataanalyst/rms/{shop_u}-rlogin")
78
+ sso_user, sso_pass = keychain_get(f"dataanalyst/rms/{shop_u}-ai")
79
+ prefix = f"RMS_{shop_u}_"
80
+ return {
81
+ prefix + "LOGIN_ID": login_id,
82
+ prefix + "PASSWORD": password,
83
+ prefix + "SHOP_USER": sso_user,
84
+ prefix + "SHOP_PASSWORD": sso_pass,
85
+ }
86
+
87
+
88
+ def _try_click(page, role: str, name: str, timeout: int = 4000) -> bool:
89
+ try:
90
+ page.get_by_role(role, name=name).first.click(timeout=timeout)
91
+ return True
92
+ except PWTimeout:
93
+ return False
94
+
95
+
96
+ def login(shop: str, env: dict, out: str, headless: bool = True,
97
+ screenshot: str | None = None) -> None:
98
+ prefix = f"RMS_{shop.upper()}_"
99
+ login_id = env.get(prefix + "LOGIN_ID")
100
+ password = env.get(prefix + "PASSWORD")
101
+ sso_user = env.get(prefix + "SHOP_USER")
102
+ sso_pass = env.get(prefix + "SHOP_PASSWORD")
103
+ missing = [k for k, v in {
104
+ "LOGIN_ID": login_id, "PASSWORD": password,
105
+ "SHOP_USER": sso_user, "SHOP_PASSWORD": sso_pass,
106
+ }.items() if not v]
107
+ if missing:
108
+ raise SystemExit(f"{shop} 缺少凭据: {prefix}{','.join(missing)}")
109
+
110
+ with sync_playwright() as p:
111
+ browser = p.chromium.launch(headless=headless,
112
+ args=["--no-sandbox", "--disable-dev-shm-usage"])
113
+ ctx = browser.new_context(locale="ja-JP", viewport={"width": 1440, "height": 1000},
114
+ user_agent=("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
115
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
116
+ "Chrome/124.0.0.0 Safari/537.36"))
117
+ page = ctx.new_page()
118
+ page.goto("https://glogin.rms.rakuten.co.jp/?sp_id=1",
119
+ wait_until="domcontentloaded", timeout=60000)
120
+ page.fill("#rlogin-username-ja", login_id)
121
+ page.fill("#rlogin-password-ja", password)
122
+ page.evaluate("document.querySelector('button[name=submit]')?.click()")
123
+ page.wait_for_timeout(3000)
124
+
125
+ page.fill("input[name=username]", sso_user)
126
+ page.press("input[name=username]", "Enter")
127
+ page.wait_for_timeout(3000)
128
+ page.fill("input[name=password]", sso_pass)
129
+ page.press("input[name=password]", "Enter")
130
+ page.wait_for_timeout(5000)
131
+
132
+ _try_click(page, "button", "次へ")
133
+ page.wait_for_timeout(2500)
134
+ try:
135
+ page.get_by_text("RMSを利用します").click(timeout=5000)
136
+ except PWTimeout:
137
+ pass
138
+ page.wait_for_timeout(3000)
139
+
140
+ url = page.url
141
+ title = page.title()
142
+ print("url", url)
143
+ print("title", title)
144
+ body_snip = ""
145
+ try:
146
+ body_snip = page.inner_text("body")[:500]
147
+ except Exception:
148
+ pass
149
+ # Shop name check (no secrets)
150
+ if "IORI" in body_snip or "iori" in body_snip.lower():
151
+ print("shop_hint", "IORI living (matched)")
152
+ else:
153
+ # try common shop name selectors without dumping full body
154
+ for sel in ["text=IORI living", "text=IORI"]:
155
+ try:
156
+ if page.locator(sel).count():
157
+ print("shop_hint", "IORI living (locator)")
158
+ break
159
+ except Exception:
160
+ pass
161
+ else:
162
+ print("shop_hint", "not found in first 500 chars of body")
163
+
164
+ if "mainmenu.rms.rakuten.co.jp" not in url:
165
+ if screenshot:
166
+ Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
167
+ page.screenshot(path=screenshot, full_page=True)
168
+ print("screenshot", screenshot)
169
+ raise SystemExit("未进入 RMS 主菜单,可能遇到 2FA/验证码;请查看截图")
170
+
171
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
172
+ ctx.storage_state(path=out)
173
+ os.chmod(out, 0o600)
174
+ print("saved", out)
175
+ if screenshot:
176
+ Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
177
+ page.screenshot(path=screenshot, full_page=False)
178
+ os.chmod(screenshot, 0o600)
179
+ print("screenshot", screenshot)
180
+ browser.close()
181
+
182
+
183
+ def main() -> int:
184
+ ap = argparse.ArgumentParser(description="R-Login 浏览器登录并保存会话")
185
+ ap.add_argument("--shop", required=True)
186
+ ap.add_argument("--env-file", default="/etc/dataanalyst/rms.env")
187
+ ap.add_argument("--from-keychain", action="store_true",
188
+ help="从 macOS Keychain 读 dataanalyst/rms/<SHOP>-rlogin 与 -ai")
189
+ ap.add_argument("--out", help="默认 tools/collector-agent/state/<shop>.json(本机)")
190
+ ap.add_argument("--screenshot", help="可选截图路径(不含密钥)")
191
+ ap.add_argument("--headed", action="store_true")
192
+ a = ap.parse_args()
193
+ out = a.out or f"tools/collector-agent/state/{a.shop.upper()}.json"
194
+ try:
195
+ if a.from_keychain:
196
+ print("cred_source", "keychain")
197
+ env = load_from_keychain(a.shop)
198
+ # Confirm accounts only (no passwords)
199
+ prefix = f"RMS_{a.shop.upper()}_"
200
+ print("rlogin_id_set", bool(env.get(prefix + "LOGIN_ID")))
201
+ print("sso_user_set", bool(env.get(prefix + "SHOP_USER")))
202
+ else:
203
+ print("cred_source", "env-file")
204
+ env = load_env(a.env_file)
205
+ login(a.shop, env, out, headless=not a.headed, screenshot=a.screenshot)
206
+ except Exception as e: # noqa: BLE001
207
+ print(f"登录失败: {type(e).__name__}: {e}", file=sys.stderr)
208
+ return 1
209
+ return 0
210
+
211
+
212
+ if __name__ == "__main__":
213
+ sys.exit(main())