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