@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.
@@ -0,0 +1,192 @@
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
+ 勿再读 dataanalyst/rms/* Keychain;JDY_MCP 见 jdy_secrets.py(与销量系统一致)。
10
+
11
+ Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
12
+ 1. glogin 填 R-Login ID / 密码(第一组);
13
+ 2. Rakuten 会员填邮箱 / 密码(第二组);
14
+ 3. R-Login 提示页「次へ」→ RMS 主菜单「RMSを利用します」。
15
+
16
+ 凭据从 env 文件读,不打印。会话保存为 600 的 storage_state。
17
+ 简道云 JDY_MCP / API Key 逻辑见 collector-agent/jdy_secrets.py(与销量计算系统一致)。
18
+
19
+ 用法:
20
+ # VPS / env 文件
21
+ PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright \\
22
+ python tools/rms_browser_login.py --shop 7RT2 \\
23
+ --env-file /etc/dataanalyst/rms.env \\
24
+ --out /etc/dataanalyst/rms_session/7RT2.json
25
+
26
+ # 本机:Keychain 仅 JDY_MCP;RMS 凭据从简道云密码表取
27
+ python tools/rms_browser_login.py --shop 7RT2 --from-jdy \\
28
+ --out tools/collector-agent/state/7RT2.json
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import os
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
+
57
+
58
+ def load_from_keychain(shop: str) -> dict:
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
+
66
+
67
+
68
+ def _try_click(page, role: str, name: str, timeout: int = 4000) -> bool:
69
+ try:
70
+ page.get_by_role(role, name=name).first.click(timeout=timeout)
71
+ return True
72
+ except PWTimeout:
73
+ return False
74
+
75
+
76
+ def login(shop: str, env: dict, out: str, headless: bool = True,
77
+ screenshot: str | None = None) -> None:
78
+ prefix = f"RMS_{shop.upper()}_"
79
+ login_id = env.get(prefix + "LOGIN_ID")
80
+ password = env.get(prefix + "PASSWORD")
81
+ sso_user = env.get(prefix + "SHOP_USER")
82
+ sso_pass = env.get(prefix + "SHOP_PASSWORD")
83
+ missing = [k for k, v in {
84
+ "LOGIN_ID": login_id, "PASSWORD": password,
85
+ "SHOP_USER": sso_user, "SHOP_PASSWORD": sso_pass,
86
+ }.items() if not v]
87
+ if missing:
88
+ raise SystemExit(f"{shop} 缺少凭据: {prefix}{','.join(missing)}")
89
+
90
+ with sync_playwright() as p:
91
+ browser = p.chromium.launch(headless=headless,
92
+ args=["--no-sandbox", "--disable-dev-shm-usage"])
93
+ ctx = browser.new_context(locale="ja-JP", viewport={"width": 1440, "height": 1000},
94
+ user_agent=("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
95
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
96
+ "Chrome/124.0.0.0 Safari/537.36"))
97
+ page = ctx.new_page()
98
+ page.goto("https://glogin.rms.rakuten.co.jp/?sp_id=1",
99
+ wait_until="domcontentloaded", timeout=60000)
100
+ page.fill("#rlogin-username-ja", login_id)
101
+ page.fill("#rlogin-password-ja", password)
102
+ page.evaluate("document.querySelector('button[name=submit]')?.click()")
103
+ page.wait_for_timeout(3000)
104
+
105
+ page.fill("input[name=username]", sso_user)
106
+ page.press("input[name=username]", "Enter")
107
+ page.wait_for_timeout(3000)
108
+ page.fill("input[name=password]", sso_pass)
109
+ page.press("input[name=password]", "Enter")
110
+ page.wait_for_timeout(5000)
111
+
112
+ _try_click(page, "button", "次へ")
113
+ page.wait_for_timeout(2500)
114
+ try:
115
+ page.get_by_text("RMSを利用します").click(timeout=5000)
116
+ except PWTimeout:
117
+ pass
118
+ page.wait_for_timeout(3000)
119
+
120
+ url = page.url
121
+ title = page.title()
122
+ print("url", url)
123
+ print("title", title)
124
+ body_snip = ""
125
+ try:
126
+ body_snip = page.inner_text("body")[:500]
127
+ except Exception:
128
+ pass
129
+ # Shop name check (no secrets)
130
+ if "IORI" in body_snip or "iori" in body_snip.lower():
131
+ print("shop_hint", "IORI living (matched)")
132
+ else:
133
+ # try common shop name selectors without dumping full body
134
+ for sel in ["text=IORI living", "text=IORI"]:
135
+ try:
136
+ if page.locator(sel).count():
137
+ print("shop_hint", "IORI living (locator)")
138
+ break
139
+ except Exception:
140
+ pass
141
+ else:
142
+ print("shop_hint", "not found in first 500 chars of body")
143
+
144
+ if "mainmenu.rms.rakuten.co.jp" not in url:
145
+ if screenshot:
146
+ Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
147
+ page.screenshot(path=screenshot, full_page=True)
148
+ print("screenshot", screenshot)
149
+ raise SystemExit("未进入 RMS 主菜单,可能遇到 2FA/验证码;请查看截图")
150
+
151
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
152
+ ctx.storage_state(path=out)
153
+ os.chmod(out, 0o600)
154
+ print("saved", out)
155
+ if screenshot:
156
+ Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
157
+ page.screenshot(path=screenshot, full_page=False)
158
+ os.chmod(screenshot, 0o600)
159
+ print("screenshot", screenshot)
160
+ browser.close()
161
+
162
+
163
+ def main() -> int:
164
+ ap = argparse.ArgumentParser(description="R-Login 浏览器登录并保存会话")
165
+ ap.add_argument("--shop", required=True)
166
+ ap.add_argument("--env-file", default="/etc/dataanalyst/rms.env")
167
+ ap.add_argument("--from-keychain", action="store_true",
168
+ help="已废弃(原 dataanalyst/rms/* Keychain)")
169
+ ap.add_argument("--out", help="默认 tools/collector-agent/state/<shop>.json(本机)")
170
+ ap.add_argument("--screenshot", help="可选截图路径(不含密钥)")
171
+ ap.add_argument("--headed", action="store_true")
172
+ a = ap.parse_args()
173
+ out = a.out or f"tools/collector-agent/state/{a.shop.upper()}.json"
174
+ try:
175
+ if a.from_keychain:
176
+ env = load_from_keychain(a.shop)
177
+ # Confirm accounts only (no passwords)
178
+ prefix = f"RMS_{a.shop.upper()}_"
179
+ print("rlogin_id_set", bool(env.get(prefix + "LOGIN_ID")))
180
+ print("sso_user_set", bool(env.get(prefix + "SHOP_USER")))
181
+ else:
182
+ print("cred_source", "env-file")
183
+ env = load_env(a.env_file)
184
+ login(a.shop, env, out, headless=not a.headed, screenshot=a.screenshot)
185
+ except Exception as e: # noqa: BLE001
186
+ print(f"登录失败: {type(e).__name__}: {e}", file=sys.stderr)
187
+ return 1
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())
package/run.py ADDED
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """店铺 Agent 常驻入口(LaunchAgent 调用)。
4
+
5
+ 调度(本机时区;日期窗口按日历日,与 JST 店铺日基本一致):
6
+ · 心跳:约每 12 分钟
7
+ · 访问人数:约每 20 小时拉近 14 天(幂等 upsert)
8
+ · RPP:约每天检查;拉当月(月初 1–3 日兼拉上月);勿高频狂拉
9
+
10
+ 用法:
11
+ python3 run.py # 常驻循环(进程内 sleep;崩溃由 KeepAlive 拉起)
12
+ python3 run.py --once # 跑一轮心跳+到期采集后退出(冒烟)
13
+ python3 run.py --once --dry-run-ingest
14
+ python3 run.py --once --skip-collect # 只心跳
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import logging
20
+ import signal
21
+ import sys
22
+ import time
23
+ import traceback
24
+ from datetime import date, datetime, timedelta
25
+ from logging.handlers import RotatingFileHandler
26
+ from pathlib import Path
27
+ from zoneinfo import ZoneInfo
28
+
29
+ HERE = Path(__file__).resolve().parent
30
+ sys.path.insert(0, str(HERE))
31
+ # monorepo pipeline(ads parse)
32
+ ROOT = HERE.parents[1]
33
+ if str(ROOT) not in sys.path:
34
+ sys.path.insert(0, str(ROOT))
35
+
36
+ import api_client # noqa: E402
37
+ import collect_access # noqa: E402
38
+ import collect_ads # noqa: E402
39
+ import ingest_util # noqa: E402
40
+ import keychain_util as kc # noqa: E402
41
+ import local_config as cfg # noqa: E402
42
+
43
+ JST = ZoneInfo("Asia/Tokyo")
44
+ LOG_DIR = HERE / "logs"
45
+ STATE_PATH = HERE / "state" / "daemon_state.json"
46
+
47
+ HEARTBEAT_EVERY = 12 * 60 # 12 min
48
+ ACCESS_EVERY = 20 * 3600 # ~20 h
49
+ ADS_CHECK_EVERY = 20 * 3600 # check ~daily; months_to_pull 决定是否拉
50
+ LOOP_SLEEP = 60 # wake every 60s
51
+ ACCESS_LOOKBACK_DAYS = 14
52
+ ERR_MAX_LEN = 240
53
+
54
+ _STOP = False
55
+
56
+
57
+ def _setup_log() -> logging.Logger:
58
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
59
+ logger = logging.getLogger("agent")
60
+ if logger.handlers:
61
+ return logger
62
+ logger.setLevel(logging.INFO)
63
+ fmt = logging.Formatter("[%(asctime)s] %(levelname)s %(message)s",
64
+ datefmt="%Y-%m-%d %H:%M:%S")
65
+ fh = RotatingFileHandler(LOG_DIR / "agent.log",
66
+ maxBytes=2_000_000, backupCount=3,
67
+ encoding="utf-8")
68
+ fh.setFormatter(fmt)
69
+ sh = logging.StreamHandler(sys.stdout)
70
+ sh.setFormatter(fmt)
71
+ logger.addHandler(fh)
72
+ logger.addHandler(sh)
73
+ return logger
74
+
75
+
76
+ def _on_signal(signum, _frame) -> None:
77
+ global _STOP
78
+ _STOP = True
79
+
80
+
81
+ def _ago_ok(state: dict, key: str, every: float) -> bool:
82
+ """True if last success is fresh enough (skip)."""
83
+ raw = state.get(key)
84
+ if not raw:
85
+ return False
86
+ try:
87
+ ts = datetime.fromisoformat(raw)
88
+ if ts.tzinfo is None:
89
+ ts = ts.replace(tzinfo=JST)
90
+ return (datetime.now(JST) - ts).total_seconds() < every
91
+ except (TypeError, ValueError):
92
+ return False
93
+
94
+
95
+ def _set_err(state: dict, msg: str | None) -> None:
96
+ if msg:
97
+ state["last_error"] = msg[:ERR_MAX_LEN]
98
+ state["last_error_at"] = datetime.now(JST).isoformat(timespec="seconds")
99
+ else:
100
+ state["last_error"] = None
101
+
102
+
103
+ def _do_heartbeat(base: str, token: str, shop: str, state: dict,
104
+ log: logging.Logger) -> None:
105
+ last_err = state.get("last_error")
106
+ hb = api_client.heartbeat(base, token, shop, cfg.VERSION,
107
+ last_error=last_err)
108
+ agent = (hb.get("agent") or {}) if isinstance(hb, dict) else {}
109
+ log.info("heartbeat ok shop=%s ver=%s last_seen=%s",
110
+ agent.get("shop") or shop,
111
+ agent.get("agent_version"),
112
+ agent.get("last_seen_at"))
113
+ state["last_heartbeat"] = datetime.now(JST).isoformat(timespec="seconds")
114
+ try:
115
+ jobs = api_client.jobs(base, token, shop, limit=5)
116
+ n = len((jobs or {}).get("jobs") or []) if isinstance(jobs, dict) else 0
117
+ state["last_jobs_count"] = n
118
+ if n:
119
+ log.info("jobs pending=%s", n)
120
+ except api_client.ApiError as exc:
121
+ log.warning("jobs fetch fail HTTP %s", exc.code)
122
+
123
+
124
+ def _do_access(base: str, token: str, shop: str, state: dict,
125
+ log: logging.Logger, *, dry_run: bool) -> None:
126
+ start, end = collect_access.default_window(ACCESS_LOOKBACK_DAYS)
127
+ log.info("access due window=%s..%s", start, end)
128
+ rows = collect_access.collect_access(
129
+ shop, start, end, log=lambda m: log.info("%s", m))
130
+ if not rows:
131
+ log.warning("access: 0 rows")
132
+ state["last_access_ok"] = datetime.now(JST).isoformat(timespec="seconds")
133
+ state["last_access_rows"] = 0
134
+ return
135
+ tag = f"{start.isoformat().replace('-', '')}-{end.isoformat().replace('-', '')}"
136
+ body = ingest_util.build_payload(shop, "rms_access", rows, tag=tag)
137
+ ingest_util.push_ingest(
138
+ base, token, "rms_access", body, dry_run=dry_run,
139
+ buffer_on_fail=not dry_run, log=lambda m: log.info("%s", m))
140
+ state["last_access_ok"] = datetime.now(JST).isoformat(timespec="seconds")
141
+ state["last_access_rows"] = len(rows)
142
+ state["last_access_window"] = f"{start}..{end}"
143
+ _set_err(state, None)
144
+
145
+
146
+ def _do_ads(base: str, token: str, shop: str, state: dict,
147
+ log: logging.Logger, *, dry_run: bool, force: bool) -> None:
148
+ today = date.today()
149
+ months = collect_ads.months_to_pull(today)
150
+ # 若当月已成功且非 force、且非月初兼拉窗口,可跳过
151
+ last_month = state.get("last_ads_month")
152
+ if (not force and last_month == months[-1] and today.day > 3
153
+ and _ago_ok(state, "last_ads_ok", ADS_CHECK_EVERY)):
154
+ log.info("ads skip (month %s already ok)", last_month)
155
+ return
156
+ total = 0
157
+ for month in months:
158
+ log.info("ads due month=%s", month)
159
+ rows = collect_ads.collect_ads(
160
+ shop, month, log=lambda m: log.info("%s", m))
161
+ if not rows:
162
+ log.warning("ads %s: 0 rows", month)
163
+ continue
164
+ body = ingest_util.build_payload(
165
+ shop, "rms_ads", rows, tag=month.replace("-", ""))
166
+ ingest_util.push_ingest(
167
+ base, token, "rms_ads", body, dry_run=dry_run,
168
+ buffer_on_fail=not dry_run, log=lambda m: log.info("%s", m))
169
+ total += len(rows)
170
+ state["last_ads_month"] = month
171
+ state["last_ads_ok"] = datetime.now(JST).isoformat(timespec="seconds")
172
+ state["last_ads_rows"] = total
173
+ _set_err(state, None)
174
+
175
+
176
+ def run_cycle(base: str, token: str, shop: str, state: dict,
177
+ log: logging.Logger, *, dry_run: bool, skip_collect: bool,
178
+ force_collect: bool) -> None:
179
+ # 1) flush buffers
180
+ try:
181
+ summary = ingest_util.flush_buffers(
182
+ base, token, log=lambda m: log.info("%s", m))
183
+ if summary.get("tried"):
184
+ log.info("buffer flush %s", summary)
185
+ except Exception as exc: # noqa: BLE001
186
+ log.warning("buffer flush error: %s", type(exc).__name__)
187
+
188
+ # 2) heartbeat
189
+ if force_collect or not _ago_ok(state, "last_heartbeat", HEARTBEAT_EVERY):
190
+ try:
191
+ _do_heartbeat(base, token, shop, state, log)
192
+ except api_client.ApiError as exc:
193
+ _set_err(state, f"heartbeat HTTP {exc.code}")
194
+ log.error("heartbeat fail: %s", exc)
195
+ raise
196
+
197
+ if skip_collect:
198
+ return
199
+
200
+ # 3) access
201
+ if force_collect or not _ago_ok(state, "last_access_ok", ACCESS_EVERY):
202
+ try:
203
+ _do_access(base, token, shop, state, log, dry_run=dry_run)
204
+ except collect_access.SessionExpiredError as exc:
205
+ _set_err(state, f"access session: {exc}")
206
+ log.error("access session expired — re-login required")
207
+ except Exception as exc: # noqa: BLE001
208
+ _set_err(state, f"access: {type(exc).__name__}: {exc}")
209
+ log.error("access fail: %s: %s", type(exc).__name__, exc)
210
+ log.debug(traceback.format_exc())
211
+
212
+ # 4) ads
213
+ need_ads = force_collect or not _ago_ok(state, "last_ads_ok", ADS_CHECK_EVERY)
214
+ if need_ads:
215
+ try:
216
+ _do_ads(base, token, shop, state, log,
217
+ dry_run=dry_run, force=force_collect)
218
+ except collect_ads.SessionExpiredError as exc:
219
+ _set_err(state, f"ads session: {exc}")
220
+ log.error("ads session expired — re-login required")
221
+ except Exception as exc: # noqa: BLE001
222
+ _set_err(state, f"ads: {type(exc).__name__}: {exc}")
223
+ log.error("ads fail: %s: %s", type(exc).__name__, exc)
224
+ log.debug(traceback.format_exc())
225
+
226
+
227
+ def main(argv: list[str] | None = None) -> int:
228
+ ap = argparse.ArgumentParser(description="dataanalyst shop agent daemon")
229
+ ap.add_argument("--once", action="store_true",
230
+ help="跑一轮后退出(冒烟 / 手动)")
231
+ ap.add_argument("--dry-run-ingest", action="store_true",
232
+ help="ingest 带 dry_run=1,不落库")
233
+ ap.add_argument("--skip-collect", action="store_true",
234
+ help="本轮只心跳,不采集")
235
+ ap.add_argument("--force-collect", action="store_true",
236
+ help="忽略 last_* 间隔,强制采集")
237
+ args = ap.parse_args(argv)
238
+
239
+ log = _setup_log()
240
+ conf = cfg.load()
241
+ shop = (conf.get("shop") or "").upper()
242
+ if not shop:
243
+ log.error("未配置店铺:请先 dataanalyst-agent login --shop <SHOP>")
244
+ return 2
245
+ base = conf.get("base") or cfg.DEFAULT_BASE
246
+ try:
247
+ token = kc.get_agent_token(shop)
248
+ except RuntimeError as exc:
249
+ log.error("%s", exc)
250
+ return 2
251
+
252
+ signal.signal(signal.SIGTERM, _on_signal)
253
+ signal.signal(signal.SIGINT, _on_signal)
254
+
255
+ state = ingest_util.load_daemon_state(STATE_PATH)
256
+ state["shop"] = shop
257
+ state["agent_version"] = cfg.VERSION
258
+ state["started_at"] = datetime.now(JST).isoformat(timespec="seconds")
259
+ log.info("agent start shop=%s ver=%s once=%s dry_run=%s",
260
+ shop, cfg.VERSION, args.once, args.dry_run_ingest)
261
+
262
+ # --once:强制本轮采集(除非 skip)
263
+ force = args.force_collect or args.once
264
+
265
+ while not _STOP:
266
+ try:
267
+ run_cycle(base, token, shop, state, log,
268
+ dry_run=args.dry_run_ingest,
269
+ skip_collect=args.skip_collect,
270
+ force_collect=force)
271
+ except api_client.ApiError:
272
+ # heartbeat 硬失败已记 state;once 模式直接退
273
+ ingest_util.save_daemon_state(state, STATE_PATH)
274
+ if args.once:
275
+ return 1
276
+ except Exception as exc: # noqa: BLE001
277
+ _set_err(state, f"cycle: {type(exc).__name__}: {exc}")
278
+ log.error("cycle crash: %s: %s", type(exc).__name__, exc)
279
+ ingest_util.save_daemon_state(state, STATE_PATH)
280
+ if args.once:
281
+ return 1
282
+ else:
283
+ ingest_util.save_daemon_state(state, STATE_PATH)
284
+
285
+ if args.once:
286
+ log.info("once done last_error=%s", state.get("last_error"))
287
+ return 0 if not state.get("last_error") else 1
288
+
289
+ # 常驻:只在首轮 force;之后按间隔
290
+ force = False
291
+ # 睡眠,可被信号打断
292
+ for _ in range(LOOP_SLEEP):
293
+ if _STOP:
294
+ break
295
+ time.sleep(1)
296
+
297
+ log.info("agent stop")
298
+ ingest_util.save_daemon_state(state, STATE_PATH)
299
+ return 0
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('child_process');
5
+
6
+ if (process.platform !== 'darwin') {
7
+ console.warn(
8
+ '[@genduo/desk] 本包仅支持 macOS(darwin)。当前平台: ' + process.platform
9
+ );
10
+ process.exit(0);
11
+ }
12
+
13
+ const check = spawnSync('python3', ['--version'], { encoding: 'utf8' });
14
+ if (check.error || check.status !== 0) {
15
+ console.warn(
16
+ '[@genduo/desk] 未检测到 python3。请先安装:brew install python'
17
+ );
18
+ process.exit(0);
19
+ }
20
+
21
+ console.log(
22
+ '[@genduo/desk] python3 ok。若要用 --verify-rms:pip3 install playwright && python3 -m playwright install chromium'
23
+ );