jw_onsale_alert.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/19
  5. """集物星球在售监控 / 自动上架提醒:轮询全站在售、过滤关注商家(Jake/九叔),三类告警推企微。
  6. 逻辑与参考项目 deca_spider 的 onsale_alert_spider 一致,只把取数层换成集物的接口:
  7. - 取数:/search/app/index/top 无按商家过滤参数,需对 productType∈{1,2,3,4} 各翻全部页,
  8. 合并去重后按 corp_info_id 客户端过滤出关注商家的在售商品(返回 (list, ok),取数不全整轮跳过)。
  9. - 三类告警:新品上架(new) / 进度过半(half) / 一车结束(ended)。
  10. - 去重靠 jw_onsale_alert_record 的三个标记位 + 进程内内存集合 _seen_onsale:
  11. · new / ended:send-then-mark(发送成功才置位,失败下轮补发);
  12. · half:先置位再发(容忍偶发漏发,简化逻辑);
  13. · _seen_onsale 只让「本进程运行后见过在售」的商品参与结束对账,避免冷启动补发历史老车。
  14. - 新品判断用列表自带 soldTime(上架时间):soldTime ≥ 本场窗口起点(最近一个 RUN_START)才算「本场刚上架」
  15. (对齐 deca 最新逻辑);更早上架的老货静默建档(new_notified=1)不发消息,防冷启动刷屏。
  16. - 结束判定:详情 surplusStock≤0(售罄) 或 已过 offShelfTime(下架/销售结束时间),二者任一即结束(对齐 deca)。
  17. """
  18. import sys
  19. import time
  20. import random
  21. import argparse
  22. from collections import defaultdict
  23. from datetime import datetime, timedelta
  24. from loguru import logger
  25. from tenacity import retry, stop_after_attempt, wait_fixed
  26. from mysql_pool import MySQLConnectionPool
  27. import jiwu_core as core
  28. import auto_send_wx_msg as wx
  29. # ---------- 业务配置 ----------
  30. WATCH_CORPS = {100716: "Jake球星卡", 100715: "九叔的喷火龙"} # 只盯这些商家(corpInfoId→名称)
  31. PRODUCT_TYPES = ["1", "2", "3", "4"] # 商品类型:1福袋 2变风盒 3错版卡 4原盒
  32. SYSTEM_BUSINESS_TYPE = 5 # 业务线:集卡
  33. PAGE_LIMIT = 20 # 翻页每页条数
  34. MAX_PAGES = 200 # 单类型翻页保护上限
  35. HALF_THRESHOLD = 0.5 # 进度过半阈值:已售/总份数 ≥ 0.5
  36. # ---------- 调度配置 ----------
  37. MIN_INTERVAL_SEC = 60 # 每轮跑完最小间隔(秒)
  38. MAX_INTERVAL_SEC = 90 # 每轮跑完最大间隔(秒),随机打散降风控
  39. RUN_ALL_DAY = False # 对齐 deca:只在 [RUN_START, RUN_END] 直播窗口内轮询(可跨午夜);设 True 则全天
  40. RUN_START = "20:30" # 运行窗口起点,同时是「本场新上架」判定的时间门槛(soldTime≥此才算新品)
  41. RUN_END = "06:00" # 运行窗口终点(跨午夜,2026/08/15 得卡由 03:00 延到 06:00)
  42. # 进程内已见在售集合:只有本进程运行后见过在售的 goods_id 才参与结束对账(重启即清空)
  43. _seen_onsale = set()
  44. logger.remove()
  45. logger.add("./logs/onsale_alert_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  46. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="DEBUG", retention="7 day")
  47. def fmt_money(raw) -> str | None:
  48. """把原始金额(×1000000)格式化为「元」字符串:整数去小数,非整去尾零。
  49. Args:
  50. raw (int | float | None): 接口原始金额,÷1000000 为元。
  51. Returns:
  52. str | None: 如 "199"/"12.5";raw 为空返回 None。
  53. """
  54. if raw is None:
  55. return None
  56. y = raw / 1000000
  57. return str(int(y)) if y == int(y) else f"{y:.2f}".rstrip("0").rstrip(".")
  58. def fmt_price(low, high) -> str:
  59. """把最低价/最高价拼成展示价格文案:有区间显区间,否则显单价。
  60. Args:
  61. low (int | None): 最低价原始值(×1000000)。
  62. high (int | None): 最高价原始值(×1000000)。
  63. Returns:
  64. str: 如 "¥199~299"/"¥199";均空返回 "¥-"。
  65. """
  66. lm, hm = fmt_money(low), fmt_money(high)
  67. if lm and hm and lm != hm:
  68. return f"¥{lm}~{hm}"
  69. if hm:
  70. return f"¥{hm}"
  71. if lm:
  72. return f"¥{lm}"
  73. return "¥-"
  74. def parse_dt(v) -> datetime | None:
  75. """把接口的上架时间字段解析为 datetime(兼容字符串日期与毫秒/秒级时间戳)。
  76. Args:
  77. v (str | int | None): soldTime 原始值。
  78. Returns:
  79. datetime | None: 解析成功返回 datetime;空值/无法解析返回 None(新品判断按保守处理,视为非新品)。
  80. """
  81. if not v:
  82. return None
  83. if isinstance(v, (int, float)): # 时间戳:毫秒>1e12 转秒
  84. ts = v / 1000 if v > 1e12 else v
  85. try:
  86. return datetime.fromtimestamp(ts)
  87. except (ValueError, OSError):
  88. return None
  89. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d"):
  90. try:
  91. return datetime.strptime(str(v), fmt)
  92. except ValueError:
  93. continue
  94. return None
  95. def parse_onsale(rec: dict) -> dict:
  96. """把 index/top 一条在售记录抽取为监控所需字段字典。
  97. Args:
  98. rec (dict): index/top 返回 records 里的一条。
  99. Returns:
  100. dict: 含 goods_id/corp_info_id/corp_info_name/goods_name/价格/库存/规格/上架时间。
  101. """
  102. return {
  103. "goods_id": rec.get("goodsId"),
  104. "corp_info_id": rec.get("corpInfoId"),
  105. "corp_info_name": rec.get("corpInfoName"),
  106. "goods_name": rec.get("goodsName"),
  107. "amount": rec.get("amount"),
  108. "highest_price": rec.get("highestPrice"),
  109. "lowest_price": rec.get("lowestPrice"),
  110. "stock_amount": rec.get("stockAmount"),
  111. "residue_stock_amount": rec.get("residueStockAmount"),
  112. "specification_name": rec.get("specificationName"),
  113. "sold_time": rec.get("soldTime"),
  114. }
  115. def compute_ratio(item: dict) -> float:
  116. """计算某在售商品的售出进度比例。
  117. Args:
  118. item (dict): parse_onsale 结果。
  119. Returns:
  120. float: 已售/总份数;总份数缺失或 ≤0 返回 0.0。
  121. """
  122. total = item.get("stock_amount")
  123. residue = item.get("residue_stock_amount")
  124. if not isinstance(total, int) or total <= 0 or not isinstance(residue, int):
  125. return 0.0
  126. sold = total - residue
  127. return sold / total if sold > 0 else 0.0
  128. def _window_start(now: datetime) -> datetime:
  129. """本场监控窗口起点:最近一个已过去的 RUN_START(新上架判定的时间门槛)。
  130. 对齐 deca:当前已过今天 RUN_START 用今天的,否则用昨天的 RUN_START(跨午夜场)。
  131. Args:
  132. now (datetime): 当前时间。
  133. Returns:
  134. datetime: 本场窗口起点时刻。
  135. """
  136. sh, sm = _parse_hhmm(RUN_START)
  137. start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
  138. if now < start: # 今天 RUN_START 还没到 → 用昨天的
  139. start -= timedelta(days=1)
  140. return start
  141. def is_new_arrival(item: dict, now: datetime) -> bool:
  142. """判断某商品是否为「本场新上架的新品」:soldTime ≥ 本场窗口起点(最近的 RUN_START)。
  143. 对齐 deca 最新逻辑:只把「本场监控窗口起点之后上架」的当新品;更早上架的老货静默建档、不提醒;
  144. 空值/解析失败一律视为非新品(保守,不误报老货)。
  145. Args:
  146. item (dict): parse_onsale 结果。
  147. now (datetime): 当前时间。
  148. Returns:
  149. bool: soldTime ≥ 本场窗口起点返回 True;空值/解析失败/老货返回 False。
  150. """
  151. dt = parse_dt(item.get("sold_time"))
  152. if dt is None:
  153. return False
  154. return dt >= _window_start(now)
  155. def fetch_watch_onsale(log) -> tuple[list, bool]:
  156. """轮询全站在售(各 productType 翻全部页),过滤出关注商家的在售商品并按 goods_id 去重。
  157. Args:
  158. log: 日志对象。
  159. Returns:
  160. tuple[list, bool]: (关注商家在售列表, ok)。ok=False 表示取数中途失败、数据不全,本轮应整轮跳过。
  161. """
  162. watched, seen = [], set()
  163. watch_ids = set(WATCH_CORPS.keys())
  164. for pt in PRODUCT_TYPES:
  165. for page in range(1, MAX_PAGES + 1):
  166. j = core.do_request(log, "/search/app/index/top", {
  167. "currentPage": str(page), "limit": str(PAGE_LIMIT),
  168. "productType": pt, "systemBusinessType": SYSTEM_BUSINESS_TYPE})
  169. if not j: # 请求失败:数据不全,标记本轮无效,避免误判下架/漏报
  170. log.warning(f"在售翻页失败 productType={pt} page={page},本轮取数不全")
  171. return watched, False
  172. recs = (j.get("data") or {}).get("records") or []
  173. if not recs:
  174. break
  175. for r in recs:
  176. it = parse_onsale(r)
  177. if it["corp_info_id"] in watch_ids and it["goods_id"] not in seen:
  178. seen.add(it["goods_id"])
  179. watched.append(it)
  180. if len(recs) < PAGE_LIMIT: # 末页
  181. break
  182. time.sleep(0.3)
  183. return watched, True
  184. def load_existing(log, pool) -> dict:
  185. """读取关注商家在监控表里的已建档商品及其三个提醒标记位。
  186. Args:
  187. log: 日志对象。
  188. pool: 数据库连接池。
  189. Returns:
  190. dict: {goods_id: {"corp_info_id","corp_info_name","goods_name","new","half","ended"}}。
  191. """
  192. ids = list(WATCH_CORPS.keys())
  193. ph = ",".join(["%s"] * len(ids))
  194. rows = pool.select_all(
  195. f"SELECT goods_id, corp_info_id, corp_info_name, goods_name, new_notified, half_notified, ended_notified "
  196. f"FROM jw_onsale_alert_record WHERE corp_info_id IN ({ph})", tuple(ids))
  197. return {r[0]: {"corp_info_id": r[1], "corp_info_name": r[2], "goods_name": r[3],
  198. "new": r[4], "half": r[5], "ended": r[6]} for r in rows}
  199. def archive_item(pool, item: dict, new_notified: int) -> None:
  200. """把新出现在监控里的商品建档到 jw_onsale_alert_record(已存在则只刷新名称,不动标记位)。
  201. Args:
  202. pool: 数据库连接池。
  203. item (dict): parse_onsale 结果。
  204. new_notified (int): 建档时的新品提醒标记;1=老货静默建档不发,0=待发新品提醒。
  205. """
  206. pool.update_one(
  207. "INSERT INTO jw_onsale_alert_record (goods_id, corp_info_id, corp_info_name, goods_name, new_notified) "
  208. "VALUES (%s,%s,%s,%s,%s) "
  209. "ON DUPLICATE KEY UPDATE corp_info_name=VALUES(corp_info_name), goods_name=VALUES(goods_name)",
  210. (item["goods_id"], item["corp_info_id"], item["corp_info_name"], item["goods_name"], new_notified))
  211. def mark_flag(pool, goods_id: int, column: str) -> None:
  212. """把某商品的某个提醒标记位置 1。
  213. Args:
  214. pool: 数据库连接池。
  215. goods_id (int): 商品ID。
  216. column (str): 标记列名,取值 new_notified / half_notified / ended_notified。
  217. """
  218. pool.update_one(f"UPDATE jw_onsale_alert_record SET {column}=1 WHERE goods_id=%s", (goods_id,))
  219. def confirm_ended(log, goods_id: int) -> dict | None:
  220. """二次确认某商品是否真的结束:打详情,售罄(surplusStock≤0) 或 已过下架时间(offShelfTime) 才算结束。
  221. 对齐 deca 最新逻辑:结束 = 剩余库存售罄 OR 当前已过销售结束/下架时间(offShelfTime),二者任一命中即结束;
  222. 详情取不到 / 两条件都不满足 → 视为本轮抖动缺席,返回 None、下轮再看(不误判下架)。
  223. Args:
  224. log: 日志对象。
  225. goods_id (int): 商品ID。
  226. Returns:
  227. dict | None: 确认结束返回详情 data(供拼战报);仍在售/详情取不到返回 None。
  228. """
  229. j = core.do_request(log, "/search/app/merchantGoodsId",
  230. {"goodsId": str(goods_id), "systemBusinessType": SYSTEM_BUSINESS_TYPE})
  231. if not j:
  232. return None
  233. data = j.get("data") or {}
  234. residue = data.get("surplusStock") # 详情用 surplusStock(剩余库存),非 index/top 的 residueStockAmount
  235. if isinstance(residue, int) and residue <= 0: # 售罄
  236. return data
  237. end_dt = parse_dt(data.get("offShelfTime")) # 下架/销售结束时间
  238. if end_dt is not None and datetime.now() >= end_dt: # 已过下架时间
  239. return data
  240. return None
  241. def send_new(log, corp_id: int, items: list) -> bool:
  242. """发送某商家的新品上架提醒(一条 markdown,多个新品逐条列出)。
  243. Args:
  244. log: 日志对象。
  245. corp_id (int): 商家ID。
  246. items (list): 待提醒的新品 item 列表。
  247. Returns:
  248. bool: 发送成功返回 True(供 send-then-mark 置位)。
  249. """
  250. corp_name = WATCH_CORPS.get(corp_id, items[0].get("corp_info_name") or str(corp_id))
  251. lines = []
  252. for it in items:
  253. price = fmt_price(it.get("lowest_price"), it.get("highest_price"))
  254. stock = it.get("stock_amount")
  255. residue = it.get("residue_stock_amount")
  256. lines.append(f"**{it.get('goods_name')}**\n💰 {price} | 📦 {stock}份 | 🎯 余{residue}/{stock}")
  257. title = f"🆕 集物星球 · {corp_name} 新商品上架 {len(items)} 个"
  258. return wx.send_wechat_group_msg(log=log, items=lines, title=title) is not None
  259. def send_half(log, corp_id: int, items: list) -> bool:
  260. """发送某商家的进度过半提醒(一条 markdown,多个过半商品逐条列出)。
  261. Args:
  262. log: 日志对象。
  263. corp_id (int): 商家ID。
  264. items (list): 元素为 (item, ratio) 的列表。
  265. Returns:
  266. bool: 发送成功返回 True。
  267. """
  268. corp_name = WATCH_CORPS.get(corp_id, items[0][0].get("corp_info_name") or str(corp_id))
  269. lines = []
  270. for it, ratio in items:
  271. price = fmt_price(it.get("lowest_price"), it.get("highest_price"))
  272. stock = it.get("stock_amount")
  273. residue = it.get("residue_stock_amount")
  274. lines.append(f"**{it.get('goods_name')}**\n💰 {price} | 📈 进度{int(ratio * 100)}% | 🎯 余{residue}/{stock}")
  275. title = f"🔥 集物星球 · {corp_name} 拼团进度过半 {len(items)} 个"
  276. return wx.send_wechat_group_msg(log=log, items=lines, title=title) is not None
  277. def get_buyer_count(log, goods_id: int) -> int:
  278. """取某商品的购买人数(赠品公示·玩家维度接口的 totalCount,需登录)。
  279. Args:
  280. log: 日志对象。
  281. goods_id (int): 商品ID。
  282. Returns:
  283. int: 购买人数;取不到返回 0。
  284. """
  285. j = core.do_request(log, "/order/merchant/app/query/gift/publicity/user/group/pager",
  286. {"currentPage": "1", "giftBusinessName": "", "goodsId": str(goods_id),
  287. "limit": "1", "systemBusinessType": SYSTEM_BUSINESS_TYPE}, need_auth=True)
  288. if not j:
  289. return 0
  290. return (j.get("data") or {}).get("totalCount") or 0
  291. def send_ended(log, goods_id: int, ex: dict, detail: dict) -> bool:
  292. """发送某商品的一车结束战报(标题/价格/售出件数/购买人数)。
  293. Args:
  294. log: 日志对象。
  295. goods_id (int): 商品ID。
  296. ex (dict): 库内已建档信息(商家名、商品名兜底)。
  297. detail (dict): confirm_ended 返回的商品详情 data(详情字段:price/highestPrice/stock)。
  298. Returns:
  299. bool: 发送成功返回 True(供 send-then-mark 置位)。
  300. """
  301. corp_name = WATCH_CORPS.get(ex.get("corp_info_id"), ex.get("corp_info_name") or "")
  302. goods_name = detail.get("goodsName") or ex.get("goods_name")
  303. price = fmt_price(detail.get("price"), detail.get("highestPrice")) # 详情用 price/highestPrice
  304. total = detail.get("stock") # 详情总份数=stock;已售罄即全部售出
  305. buyers = get_buyer_count(log, goods_id) # 玩家维度准确购买人数
  306. line = f"**{goods_name}**\n💰 {price} | 🎯 售出 {total} 件\n👥 {buyers} 人购买"
  307. title = f"🏁 集物星球 · {corp_name} 一车结束"
  308. return wx.send_wechat_group_msg(log=log, items=[line], title=title) is not None
  309. def detect_and_report_ended(log, pool, existing: dict, onsale_codes: set) -> None:
  310. """结束对账:本进程见过在售、本轮已消失、且未播报过的商品,二次确认后发结束战报。
  311. Args:
  312. log: 日志对象。
  313. pool: 数据库连接池。
  314. existing (dict): load_existing 结果(本轮读库快照)。
  315. onsale_codes (set): 本轮抓到的关注商家在售 goods_id 集合。
  316. """
  317. candidates = [gid for gid, ex in existing.items()
  318. if gid in _seen_onsale and gid not in onsale_codes and ex["ended"] == 0]
  319. for gid in candidates:
  320. detail = confirm_ended(log, gid)
  321. if detail is None: # 未确认结束(可能只是本轮抖动缺席),下轮再看
  322. continue
  323. if send_ended(log, gid, existing[gid], detail):
  324. mark_flag(pool, gid, "ended_notified")
  325. log.success(f"结束战报已发 goods_id={gid}")
  326. def run_once(log, pool) -> None:
  327. """跑一轮监控:取数→结束对账→遍历在售判新品/过半→建档并推送。
  328. Args:
  329. log: 日志对象。
  330. pool: 数据库连接池。
  331. """
  332. items, ok = fetch_watch_onsale(log)
  333. if not ok:
  334. log.warning("本轮取数不完整,跳过判断")
  335. return
  336. onsale_codes = {it["goods_id"] for it in items}
  337. log.info(f"本轮关注商家在售 {len(items)} 个")
  338. existing = load_existing(log, pool)
  339. # 1) 先做结束对账(用「旧 existing + 本轮在售」判断谁消失了)
  340. detect_and_report_ended(log, pool, existing, onsale_codes)
  341. # 2) 标记本进程已见过这些在售(此后它们消失才纳入结束对账)
  342. _seen_onsale.update(onsale_codes)
  343. # 3) 遍历在售,收集新品/过半
  344. now = datetime.now()
  345. new_alerts, half_alerts = [], []
  346. for it in items:
  347. gid = it["goods_id"]
  348. ex = existing.get(gid)
  349. if ex is None: # 监控里首次出现的商品:建档
  350. new = is_new_arrival(it, now)
  351. archive_item(pool, it, new_notified=0 if new else 1)
  352. ex = {"corp_info_id": it["corp_info_id"], "new": 0 if new else 1, "half": 0, "ended": 0}
  353. existing[gid] = ex
  354. if new:
  355. new_alerts.append(it)
  356. elif ex["new"] == 0 and ex["ended"] == 0: # 上轮发送失败的新品,补发
  357. new_alerts.append(it)
  358. # 过半判断(未过半、未结束才评估;首次达标即发)
  359. if ex["half"] == 0 and ex["ended"] == 0:
  360. ratio = compute_ratio(it)
  361. if ratio >= HALF_THRESHOLD:
  362. mark_flag(pool, gid, "half_notified") # 先置位再发
  363. half_alerts.append((it, ratio))
  364. # 4) 推送:新品按商家分组,send-then-mark(发成功才置 new_notified)
  365. if new_alerts:
  366. groups = defaultdict(list)
  367. for it in new_alerts:
  368. groups[it["corp_info_id"]].append(it)
  369. for cid, its in groups.items():
  370. if send_new(log, cid, its):
  371. for it in its:
  372. mark_flag(pool, it["goods_id"], "new_notified")
  373. log.info(f"新品上架提醒 {len(new_alerts)} 个")
  374. # 5) 推送:过半按商家分组(已先置位,直接发)
  375. if half_alerts:
  376. hgroups = defaultdict(list)
  377. for it, ratio in half_alerts:
  378. hgroups[it["corp_info_id"]].append((it, ratio))
  379. for cid, its in hgroups.items():
  380. send_half(log, cid, its)
  381. log.info(f"进度过半提醒 {len(half_alerts)} 个")
  382. @retry(stop=stop_after_attempt(100), wait=wait_fixed(600), after=core.after_log)
  383. def main_task(log) -> None:
  384. """监控主流程(挂了每 10 分钟重试,最多 100 次):按运行窗口手写轮询循环。
  385. Args:
  386. log: 日志对象。
  387. Raises:
  388. RuntimeError: 数据库连接池异常时抛出以触发重试。
  389. """
  390. log.info("启动在售监控" + "." * 40)
  391. pool = MySQLConnectionPool(log=log)
  392. if not pool.check_pool_health():
  393. log.error("数据库连接池异常")
  394. raise RuntimeError("数据库连接池异常")
  395. while True:
  396. now = datetime.now()
  397. if not _in_run_window(now):
  398. sleep_s = _seconds_to_next_window(now)
  399. log.info(f"当前不在运行窗口,休眠 {sleep_s} 秒到下次窗口")
  400. time.sleep(sleep_s)
  401. continue
  402. try:
  403. run_once(log, pool)
  404. except Exception as e:
  405. log.error(f"监控单轮异常: {e}")
  406. gap = random.randint(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC)
  407. log.info(f"本轮结束,{gap} 秒后再轮询")
  408. time.sleep(gap)
  409. def _parse_hhmm(s: str) -> tuple[int, int]:
  410. """把 "HH:MM" 解析为 (时, 分)。
  411. Args:
  412. s (str): 形如 "20:30" 的时间串。
  413. Returns:
  414. tuple[int, int]: (小时, 分钟)。
  415. """
  416. h, m = s.split(":")
  417. return int(h), int(m)
  418. def _in_run_window(now: datetime) -> bool:
  419. """判断当前是否在运行窗口内(支持跨午夜窗口)。
  420. Args:
  421. now (datetime): 当前时间。
  422. Returns:
  423. bool: RUN_ALL_DAY 恒 True;否则按 [RUN_START, RUN_END] 判断。
  424. """
  425. if RUN_ALL_DAY:
  426. return True
  427. sh, sm = _parse_hhmm(RUN_START)
  428. eh, em = _parse_hhmm(RUN_END)
  429. start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
  430. end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
  431. if start <= end: # 同日窗口
  432. return start <= now <= end
  433. return now >= start or now <= end # 跨午夜:[start,次日end]
  434. def _seconds_to_next_window(now: datetime) -> int:
  435. """计算距下一次进入运行窗口还有多少秒。
  436. Args:
  437. now (datetime): 当前时间。
  438. Returns:
  439. int: 休眠秒数(至少 1)。
  440. """
  441. sh, sm = _parse_hhmm(RUN_START)
  442. start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
  443. if now >= start: # 今天窗口起点已过,等明天
  444. start += timedelta(days=1)
  445. return max(1, int((start - now).total_seconds()))
  446. def _parse_start_time(text: str) -> str:
  447. """校验并规范化命令行传入的窗口起点时间,返回 "HH:MM" 字符串。
  448. Args:
  449. text (str): 形如 "20:30" 或 "20:30:00" 的时间串。
  450. Returns:
  451. str: 规范化的 "HH:MM"(丢掉秒,供 _parse_hhmm 使用)。
  452. Raises:
  453. argparse.ArgumentTypeError: 格式非法(非 HH:MM / HH:MM:SS)时抛出。
  454. """
  455. text = text.strip()
  456. for fmt in ("%H:%M:%S", "%H:%M"):
  457. try:
  458. return datetime.strptime(text, fmt).strftime("%H:%M")
  459. except ValueError:
  460. continue
  461. raise argparse.ArgumentTypeError(f"开始时间格式非法:{text!r},应为 HH:MM 或 HH:MM:SS,如 20:30")
  462. def _parse_args() -> argparse.Namespace:
  463. """解析命令行参数:可指定运行窗口起点(同时作为「新上架」判定门槛)。
  464. 对齐 deca:位置参数与 --start 等价、位置优先;不传则用默认 RUN_START。
  465. Returns:
  466. argparse.Namespace: 含 start(位置) / start_opt(--start) 两个可选时间(均为规范化 "HH:MM" 或 None)。
  467. """
  468. parser = argparse.ArgumentParser(
  469. description="集物星球在售提醒:可指定运行窗口起点(该时间后的新上架才提醒)")
  470. parser.add_argument("start", nargs="?", type=_parse_start_time, default=None,
  471. help="运行窗口起点 HH:MM[:SS],默认 20:30;位置参数写法,如 17:00")
  472. parser.add_argument("--start", dest="start_opt", type=_parse_start_time, default=None,
  473. help="运行窗口起点 HH:MM[:SS],与位置参数等价,如 --start 17:00")
  474. return parser.parse_args()
  475. def schedule_task():
  476. """监控入口:直接进入 main_task 的手写轮询循环。"""
  477. main_task(log=logger)
  478. if __name__ == "__main__":
  479. _args = _parse_args()
  480. _start = _args.start or _args.start_opt # 位置参数优先,其次 --start;都未传则保持默认 RUN_START
  481. if _start is not None:
  482. RUN_START = _start # 覆盖模块级默认;窗口判定与「新上架」门槛均随之改变(两处均裸读该全局)
  483. logger.info(f"运行窗口起点由命令行指定为 {RUN_START}")
  484. schedule_task()