@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
|
@@ -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
|
+
);
|