@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.
- package/README.md +186 -2
- package/api_client.py +65 -0
- package/bin/genduo-desk.js +54 -0
- package/cli.py +632 -0
- package/collect_access.py +162 -0
- package/collect_ads.py +255 -0
- package/ingest_util.py +136 -0
- package/keychain_util.py +115 -0
- package/local_config.py +53 -0
- package/package.json +42 -4
- package/rms_ads_parse.py +99 -0
- package/rms_browser_login.py +213 -0
- package/run.py +303 -0
- package/scripts/postinstall.js +23 -0
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()
|
|
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
|
+
);
|