onsale_alert_spider.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/04
  5. """得卡 DECA · 指定商家在售商品提醒(新品上架 + 进度过半,常驻轮询)。
  6. 监控 MERCHANT_ID 商家的在售商品,两类提醒(各发一条独立消息):
  7. 1. 新商品上架:出现库里没有的新 product_code。
  8. 2. 进度过半:售卖进度 sold_count/card_count 首次达到 HALF_THRESHOLD。
  9. 去重靠 deca_onsale_alert_record 两个标记位(new_notified/half_notified),每类每商品只提醒一次。
  10. 发送渠道 SEND_CHANNEL 可切(2026/08/11 起默认企业微信):
  11. - "qywx": 企业微信群机器人(markdown_v2)——发到 WEBHOOK_URL 配置的群,标题加粗、不带链接。
  12. - "pc" : PC 版微信(wxauto4 文本)发给 WX_TARGET——纯文本文案,随时可切回。
  13. 消息不带商品链接(2026/08/11):得卡商品分享落地页(share-detail 接口)只认「登录态(带 token)
  14. 生成的 shareCode」,免 token 详情返回的 shareCode 一律被判失效;为不引入登录态、降账号风控,
  15. 提醒消息直接不挂链接。三类提醒都发:新商品上架 / 拼团进度过半 / 一车结束战报。
  16. 运行时段:仅每天 RUN_START~次日 06:00 轮询(RUN_START/RUN_END 控制),其余时间休眠到下次开窗再跑。
  17. RUN_START 默认 20:30,可用命令行传参覆盖(见下),窗口起点同时是「新上架」时间门槛:只提醒
  18. publishAt 晚于该起点的商品。
  19. 从根目录运行:
  20. python onsale_alert_spider.py # 默认 20:30 开始
  21. python onsale_alert_spider.py 17:00 # 改为 17:00 开始:17:00 后的新上架才提醒
  22. python onsale_alert_spider.py --start 17:00
  23. (默认企微渠道,需 WEBHOOK_URL 配好)
  24. """
  25. import sys
  26. import time
  27. import random
  28. import argparse
  29. from datetime import datetime, time as dtime, timedelta
  30. from loguru import logger
  31. from tenacity import retry, stop_after_attempt, wait_fixed
  32. from mysql_pool import MySQLConnectionPool
  33. import deca_sold_core as core
  34. import deca_wechat # PC 版微信发送(wxauto4)
  35. from auto_send_wx_msg import send_wechat_group_msg # 企微机器人发送(备用渠道)
  36. # 复用 daily 的免 token 全站在售拉取(home/search,只拉不落库);alert 独立自采、不依赖 buy_record 落库
  37. from on_sale.deca_on_sale_daily_spider import fetch_all_onsale
  38. logger.remove()
  39. logger.add("./logs/onsale_alert_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  40. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  41. level="DEBUG", retention="7 day")
  42. # ==================== 配置 ====================
  43. MERCHANT_ID = "881226408" # 监控的商家用户ID
  44. HALF_THRESHOLD = 0.5 # 进度过半阈值(0.5=50%)
  45. SEND_CHANNEL = "qywx" # 发送渠道:qywx=企业微信群机器人(默认) / pc=PC版微信(wxauto4,可切回)
  46. # WX_TARGET = "backup" # PC 微信发送目标(好友备注名/群名),改成实际接收人
  47. WX_TARGET = "得卡-通知" # PC 微信发送目标(好友备注名/群名),改成实际接收人
  48. # COLD_START_PUSH 已废弃(2026/08/08):改为「只提醒本轮窗口起点(20:30)后新上架」,冷启动不再发全量快照
  49. MIN_INTERVAL_SEC = 60 # 轮询间隔随机下限(秒)
  50. MAX_INTERVAL_SEC = 90 # 轮询间隔随机上限(秒),每轮在 [下限,上限] 取随机数,打散规律降风控
  51. RUN_START = dtime(20, 30) # 运行窗口开始默认 20:30;可由命令行传参覆盖(见 _parse_args),同时作为「新上架」时间门槛
  52. RUN_END = dtime(6, 0) # 运行窗口结束:次日 06:00(窗口跨午夜;2026/08/15 由 03:00 延到 06:00,昨天 5 点还在播)
  53. MAX_PROD_PAGES = 100 # 在售商品翻页保护上限
  54. T_ALERT = "deca_onsale_alert_record"
  55. ON_SALE_PATH = "/api/v1/app/groupbuy/merchant/on-sale-list" # 商家在售商品列表(need_auth)
  56. DETAIL_PATH = "/api/v1/app/groupbuy/detail" # 商品详情(取 publishAt 判新品)
  57. # 本进程启动后「见过在售」的本商家 product_code 集合(模块级内存,进程重启即清空):
  58. # 结束战报只认这些 code——即只播报「程序运行之后」才结束的车;程序启动前就已结束的历史车不补发。
  59. # 与持久化的 ended_notified 列配合:_seen_onsale 决定「管不管」,ended_notified 决定「发没发过」。
  60. _seen_onsale: set = set()
  61. def _get_detail_data(log, code: str) -> dict:
  62. """打商品详情接口(免 token)返回 data 层。
  63. Args:
  64. log: 日志对象。
  65. code (str): 商品编码(product_code)。
  66. Returns:
  67. dict: 详情 data 层;请求失败返回空 dict。
  68. """
  69. try:
  70. resp = core.do_request(log, DETAIL_PATH, {"code": code}, need_auth=False) # 详情接口免登录,实测不带token也返回
  71. return (resp or {}).get("data") or {}
  72. except Exception as e:
  73. log.warning(f"取详情失败({code}): {e}")
  74. return {}
  75. def get_publish_at(log, code: str) -> str | None:
  76. """调详情接口取该商品的上架时间(data.publishAt),供新品判断用。
  77. Args:
  78. log: 日志对象。
  79. code (str): 商品编码(product_code)。
  80. Returns:
  81. str | None: publishAt 原文如 "2026-08-08 20:31:00";取不到返回 None。
  82. """
  83. return _get_detail_data(log, code).get("publishAt")
  84. def _window_start(now: datetime) -> datetime:
  85. """本轮运行窗口的起点(最近一个已过去的 RUN_START,默认 20:30),作为「新上架」时间门槛。
  86. RUN_START 可由命令行传参覆盖,故此处读全局值而非写死时刻。
  87. Args:
  88. now (datetime): 当前时间。
  89. Returns:
  90. datetime: 当天时刻 >= RUN_START → 今天 RUN_START;否则 → 昨天 RUN_START(跨午夜窗口取窗口起点那天)。
  91. """
  92. if now.time() >= RUN_START:
  93. return datetime.combine(now.date(), RUN_START)
  94. return datetime.combine(now.date() - timedelta(days=1), RUN_START)
  95. def _is_new_arrival(publish_at_text: str, window_start: datetime) -> bool:
  96. """判断商品上架时间(publishAt)是否晚于窗口起点,即本轮才新上架。
  97. Args:
  98. publish_at_text (str): 详情 publishAt,格式 "%Y-%m-%d %H:%M:%S",可能为空。
  99. window_start (datetime): 本轮窗口起点(20:30)。
  100. Returns:
  101. bool: True=上架时间 >= 窗口起点(本轮新上架);空值/解析失败按 False(保守,不误报老货)。
  102. """
  103. if not publish_at_text:
  104. return False
  105. try:
  106. pub = datetime.strptime(publish_at_text.strip(), "%Y-%m-%d %H:%M:%S")
  107. except (ValueError, TypeError):
  108. return False
  109. return pub >= window_start
  110. def _fmt_money(v) -> str:
  111. """把价格数值格式化为紧凑字符串:整数去掉小数(80.00→80),非整去尾零(1.50→1.5)。
  112. Args:
  113. v: 价格(Decimal/float/int/str)。
  114. Returns:
  115. str: 紧凑价格字符串。
  116. """
  117. f = float(v)
  118. if f == int(f):
  119. return str(int(f))
  120. return f"{f:g}"
  121. def _fmt_price(unit, mn, mx) -> str:
  122. """构造价格文案:区间价(min~max)优先,其次单价,与 App 详情页口径一致。
  123. 得卡「单箱选队」等商品 unitPrice 常为 0,真实价落在 minUnitPrice~maxUnitPrice 区间
  124. (如详情页「¥1.50 ~ 80」),故区间存在且非单点时优先展示区间,避免误显 ¥0.00。
  125. Args:
  126. unit: 单价 unitPrice(可能为 0/None)。
  127. mn: 最低单价 minUnitPrice(可能为 None)。
  128. mx: 最高单价 maxUnitPrice(可能为 None)。
  129. Returns:
  130. str: 价格文案,如 "¥1.5~80" / "¥400" / "¥-"。
  131. """
  132. if mn is not None and mx is not None and float(mn) != float(mx):
  133. return f"¥{_fmt_money(mn)}~{_fmt_money(mx)}" # 区间价(单箱选队等)
  134. if unit is not None and float(unit) > 0:
  135. return f"¥{_fmt_money(unit)}" # 正常单价
  136. if mn is not None and float(mn) > 0:
  137. return f"¥{_fmt_money(mn)}" # unit 为 0/None 时退回 min
  138. return "¥-"
  139. def _fmt_item(r: dict, kind: str, pct: float = None, plain: bool = False) -> str:
  140. """构造一条通知条目:标题 + 价格 + 份数/进度 + 余·共(不带链接)。
  141. 2026/08/11 起消息不挂商品链接(share-detail 只认带 token 生成的 shareCode,免 token 一律失效,
  142. 为不引入登录态直接去链接);markdown 渠道把标题加粗,纯文本渠道直出标题。
  143. Args:
  144. r (dict): parse_product 产出的商品字典。
  145. kind (str): "new"=新品(显示份数) / "half"=过半(显示进度%) / "onsale"=当前在售快照(显示进度%)。
  146. pct (float, optional): 售卖进度百分比(0~100),kind 为 "half"/"onsale" 时用。Defaults to None。
  147. plain (bool, optional): True=纯文本(PC微信)/False=markdown(企微)。Defaults to False。
  148. Returns:
  149. str: 一条通知文案(两行)。
  150. """
  151. # 标题行:markdown 渠道加粗突出,纯文本渠道直出
  152. title_line = r["title"] if plain else f"**{r['title']}**"
  153. # 价格:区间价优先(单箱选队等 unitPrice=0 的商品用 min~max),避免误显 ¥0.00
  154. price_text = _fmt_price(r.get("unit_price"), r.get("min_unit_price"), r.get("max_unit_price"))
  155. # 信息行:新品显示份数,过半/当前在售显示进度百分比
  156. if kind in ("half", "onsale"):
  157. info = (f"💰 {price_text} | 📈 进度{pct:.0f}% | "
  158. f"🎯 余{r['available_stock']}/{r['card_count']}")
  159. else:
  160. info = (f"💰 {price_text} | 📦 {r['card_count']}份 | "
  161. f"🎯 余{r['available_stock']}/{r['card_count']}")
  162. return f"{title_line}\n{info}"
  163. def _dispatch(log, items: list, title: str) -> bool:
  164. """按 SEND_CHANNEL 把一批条目发出去(pc=PC微信整段文本 / qywx=企微 markdown)。
  165. Args:
  166. log: 日志对象。
  167. items (list[str]): 已构造好的通知条目列表。
  168. title (str): 消息标题。
  169. Returns:
  170. bool: 发送成功返回 True;无条目或发送失败返回 False(调用方据此决定是否置位提醒标记)。
  171. """
  172. if not items:
  173. return False
  174. if SEND_CHANNEL == "pc":
  175. # PC 微信:标题 + 编号清单,拼成一整段纯文本,一条消息发给 WX_TARGET
  176. body = [f"{i}. {it}" for i, it in enumerate(items, 1)]
  177. text = title + "\n\n" + "\n----------------------------------\n".join(body)
  178. return bool(deca_wechat.send_text(text, who=WX_TARGET))
  179. # 企微机器人:成功返回 dict、失败返回 None
  180. return bool(send_wechat_group_msg(log=log, items=items, title=title))
  181. def _pc_section(title: str, items: list) -> str:
  182. """把一组条目拼成 PC 微信纯文本的一个分区(小标题 + 编号清单)。
  183. Args:
  184. title (str): 分区小标题(如「新商品上架」)。
  185. items (list[str]): 已构造好的通知条目列表。
  186. Returns:
  187. str: 该分区的纯文本(含小标题与编号清单);items 为空返回空串。
  188. """
  189. if not items:
  190. return ""
  191. body = [f"{i}. {it}" for i, it in enumerate(items, 1)]
  192. return f"{title}({len(items)}款)\n" + "\n----------------------------------\n".join(body)
  193. def _dispatch_pc_combined(log, mname: str, new_items: list, half_items: list) -> bool:
  194. """PC 微信:把新品与过半两类合并为一条纯文本消息发出(减少操作,一次发完)。
  195. Args:
  196. log: 日志对象。
  197. mname (str): 商家名称,用于消息大标题。
  198. new_items (list[str]): 新品上架通知条目。
  199. half_items (list[str]): 进度过半通知条目。
  200. Returns:
  201. bool: 发送成功返回 True;无内容或发送失败返回 False(调用方据此决定是否置位新品标记)。
  202. """
  203. sections = []
  204. new_sec = _pc_section("【新商品上架】", new_items)
  205. half_sec = _pc_section("【拼团进度过半】", half_items)
  206. if new_sec:
  207. sections.append(new_sec)
  208. if half_sec:
  209. sections.append(half_sec)
  210. if not sections:
  211. return False
  212. header = f"得卡 · {mname} 在售提醒"
  213. text = header + "\n\n" + "\n\n==================================\n\n".join(sections)
  214. return bool(deca_wechat.send_text(text, who=WX_TARGET))
  215. def _progress(sold, card) -> float:
  216. """计算售卖进度(0~1),卡片份数缺失或为 0 时返回 0。
  217. Args:
  218. sold (int | None): 已售份数。
  219. card (int | None): 总份数。
  220. Returns:
  221. float: 进度比例 sold/card;无法计算时返回 0.0。
  222. """
  223. if not card or card <= 0 or sold is None:
  224. return 0.0
  225. return sold / card
  226. def fetch_onsale(log) -> tuple[list, bool]:
  227. """免 token 抓全站在售、筛出 MERCHANT_ID 的在售商品(alert 独立自采,不依赖 buy_record 落库)。
  228. 复用 daily 的 fetch_all_onsale(home/search 免 token、只拉不落库)拿全站在售,
  229. 再按 merchant_user_id 筛出本商家、按 product_code 去重。全链路免 token。
  230. Args:
  231. log: 日志对象。
  232. Returns:
  233. tuple[list, bool]: (本商家在售商品字典列表[parse_product 结果,含 title/unit_price/card_count/
  234. sold_count/available_stock/merchant_name 等], 全站是否正常翻到底)。
  235. bool=False 表示全站抓取中途异常/未取全,调用方据此放弃本轮下架/结束对账,避免误判。
  236. """
  237. all_products, ok = fetch_all_onsale(log)
  238. rows = {p["product_code"]: p for p in all_products
  239. if p.get("merchant_user_id") == MERCHANT_ID}
  240. log.info(f"商家 {MERCHANT_ID} 当前在售商品 {len(rows)} 个(全站 {len(all_products)} 筛出,免 token,ok={ok})")
  241. return list(rows.values()), ok
  242. def _insert_alert(pool, r: dict, progress_pct: float, new_notified: int,
  243. half_notified: int):
  244. """新增一条在售监控记录(share_code 列 2026/08/11 起不再写入,消息已去链接)。
  245. Args:
  246. pool (MySQLConnectionPool): MySQL 连接池。
  247. r (dict): parse_product 产出的商品字典。
  248. progress_pct (float): 售卖进度百分比(0~100)。
  249. new_notified (int): 新品提醒标记位 0/1。
  250. half_notified (int): 过半提醒标记位 0/1。
  251. """
  252. sql = (f"INSERT IGNORE INTO {T_ALERT} "
  253. "(product_code, merchant_user_id, merchant_name, title, unit_price, card_count, "
  254. "sold_count, progress, available_stock, groupbuy_status_name, "
  255. "new_notified, half_notified) "
  256. "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)")
  257. args = (r["product_code"], r["merchant_user_id"], r["merchant_name"], r["title"],
  258. r["unit_price"], r["card_count"], r["sold_count"], round(progress_pct, 2),
  259. r["available_stock"], r["groupbuy_status_name"],
  260. new_notified, half_notified)
  261. pool.insert_many(query=sql, args_list=[args])
  262. def _update_progress(pool, r: dict, progress_pct: float, half_notified: int):
  263. """更新已有记录的售卖进度等动态字段(及可选的过半标记位)。
  264. Args:
  265. pool (MySQLConnectionPool): MySQL 连接池。
  266. r (dict): parse_product 产出的商品字典。
  267. progress_pct (float): 售卖进度百分比(0~100)。
  268. half_notified (int): 过半提醒标记位 0/1(命中过半时置 1)。
  269. """
  270. sql = (f"UPDATE {T_ALERT} SET sold_count=%s, progress=%s, available_stock=%s, "
  271. "groupbuy_status_name=%s, half_notified=%s WHERE product_code=%s")
  272. pool.update_one(sql, (r["sold_count"], round(progress_pct, 2), r["available_stock"],
  273. r["groupbuy_status_name"], half_notified, r["product_code"]))
  274. def _mark_new_notified(pool, codes: list):
  275. """把一批商品的 new_notified 置 1(仅在新品提醒发送成功后调用)。
  276. Args:
  277. pool (MySQLConnectionPool): MySQL 连接池。
  278. codes (list[str]): 待置位的 product_code 列表;为空直接返回。
  279. """
  280. if not codes:
  281. return
  282. placeholders = ",".join(["%s"] * len(codes))
  283. pool.update_one(
  284. f"UPDATE {T_ALERT} SET new_notified=1 WHERE product_code IN ({placeholders})",
  285. tuple(codes))
  286. def _is_sale_ended(data: dict, now_ts: int) -> tuple[bool, str]:
  287. """据商品详情 data 判断该车是否售卖结束(售罄 或 已过销售结束时间)。
  288. 与 buy_record_spider.is_sale_ended 同逻辑,内联于此避免 import 采集脚本引入其模块级副作用。
  289. 详情请求失败时 data 为空 dict,两条件均不命中返回未结束——保守,防抓取抖动误判下架。
  290. 预售/未开卖的车:详情接口 availableStock 也返回 0(库存未分配)、soldCount=0、saleStartAt 在未来,
  291. 仅凭 availableStock<=0 会把它误判为「售罄」→ 发出假的「一车结束」战报(2026/08/24 修复 #342
  292. 预售车:publishAt 21:26 发布预告、saleStartAt 22:45 才开卖,其间短暂闪现在售列表又撤下被误判结束)。
  293. 故加两道护栏:① saleStartAt 未到直接判未结束;② 售罄须 soldCount>0(确实卖过)。
  294. Args:
  295. data (dict): 商品详情接口(groupbuy/detail) data 层,可能为空 dict。
  296. now_ts (int): 当前时间戳(秒)。
  297. Returns:
  298. tuple[bool, str]: (是否已结束, 原因文本);未结束时原因为空串。
  299. """
  300. # ① 开卖时间未到 → 预售态,绝不算结束(预售车 availableStock=0 是「未分配」而非「卖光」)
  301. start_text = data.get("saleStartAt")
  302. if start_text:
  303. try:
  304. start_ts = time.mktime(time.strptime(start_text, "%Y-%m-%d %H:%M:%S"))
  305. if now_ts < start_ts:
  306. return False, ""
  307. except (ValueError, OverflowError):
  308. pass
  309. # ② 售罄:库存<=0 且确实卖出过(soldCount>0),避免预售/未开卖车(soldCount=0)被误判售罄
  310. stock = data.get("availableStock")
  311. sold = data.get("soldCount")
  312. if stock is not None and stock <= 0 and sold is not None and sold > 0:
  313. return True, f"售罄(availableStock={stock}, soldCount={sold})"
  314. end_text = data.get("saleEndAt")
  315. if end_text:
  316. try:
  317. end_ts = time.mktime(time.strptime(end_text, "%Y-%m-%d %H:%M:%S"))
  318. if now_ts >= end_ts:
  319. return True, f"已过结束时间(saleEndAt={end_text})"
  320. except (ValueError, OverflowError):
  321. pass
  322. return False, ""
  323. def _fmt_ended(title: str, card, price_text: str, buyers: int, plain: bool = False) -> str:
  324. """构造一条「一车结束」战报正文:标题 + 价格 + 售出总件数 + 参与拆卡人数。
  325. 车结束(拼团成交/售罄)即全部份数售出,故「售出件数」直接取总份数 card_count;
  326. 参与拆卡人数为该车 deca_buy_record 按 user_id 去重的买家数。
  327. Args:
  328. title (str): 商品标题。
  329. card (int | None): 总份数 cardCount,即售出总件数。
  330. price_text (str): 已格式化的价格文案(_fmt_price 产出,区间价优先)。
  331. buyers (int): 去重购买人数(参与拆卡人数)。
  332. plain (bool, optional): True=纯文本(PC微信)/False=markdown(企微,标题加粗)。Defaults to False。
  333. Returns:
  334. str: 战报正文(三行)。
  335. """
  336. card_txt = card if card is not None else "?"
  337. title_line = title if plain else f"**{title}**"
  338. return (f"{title_line}\n"
  339. f"💰 {price_text} | 🎯 售出 {card_txt} 件\n"
  340. f"👥 {buyers} 人参与拆卡")
  341. def _send_ended(log, mname: str, item_text: str) -> bool:
  342. """按 SEND_CHANNEL 发送一条「一车结束」战报(每辆车一条独立消息)。
  343. Args:
  344. log: 日志对象。
  345. mname (str): 商家名称,用于消息标题。
  346. item_text (str): _fmt_ended 产出的战报正文。
  347. Returns:
  348. bool: 发送成功返回 True;失败返回 False(调用方据此决定是否置 ended_notified)。
  349. """
  350. header = f"🏁 得卡 · {mname} 一车结束"
  351. if SEND_CHANNEL == "pc":
  352. return bool(deca_wechat.send_text(header + "\n\n" + item_text, who=WX_TARGET))
  353. return bool(send_wechat_group_msg(log=log, items=[item_text], title=header))
  354. def _mark_ended_notified(pool, code: str):
  355. """把一辆车的 ended_notified 置 1(仅在结束战报发送成功后调用)。
  356. Args:
  357. pool (MySQLConnectionPool): MySQL 连接池。
  358. code (str): 商品编码(product_code)。
  359. """
  360. pool.update_one(f"UPDATE {T_ALERT} SET ended_notified=1 WHERE product_code=%s", (code,))
  361. def _count_buyers(pool, code: str) -> int:
  362. """查 deca_buy_record 中该车按 user_id 去重的购买人数(即参与拆卡人数)。
  363. 数据由 buy_record_spider(独立进程)持续采集;若该车未被采到则返回 0。
  364. Args:
  365. pool (MySQLConnectionPool): MySQL 连接池。
  366. code (str): 商品编码(product_code)。
  367. Returns:
  368. int: 去重购买人数;无记录或查询异常返回 0。
  369. """
  370. rows = pool.select_all(
  371. "SELECT COUNT(DISTINCT user_id) FROM deca_buy_record WHERE product_code=%s", code)
  372. if rows and rows[0] and rows[0][0] is not None:
  373. return int(rows[0][0])
  374. return 0
  375. def _detect_and_report_ended(log, pool, existing: dict, onsale_codes: set):
  376. """检测本进程运行后消失的车、二次确认结束后逐辆发战报并置位 ended_notified。
  377. 候选条件三取交:本进程见过在售(_seen_onsale) ∩ 本轮已不在在售(onsale_codes) ∩ 未播报过结束
  378. (ended_notified=0)。命中的候选再打一次详情,仅 _is_sale_ended 确认售罄/过结束时间才播报——
  379. 防「全站抓取抖动导致某车临时缺席」被误判为结束。发送成功才置位;失败保持 0,下轮重发不丢。
  380. Args:
  381. log: 日志对象。
  382. pool (MySQLConnectionPool): MySQL 连接池。
  383. existing (dict): 库内该商家记录 {code: {ended/title/mname/unit_price/card/sold/...}}。
  384. onsale_codes (set): 本轮在售的 product_code 集合。
  385. """
  386. now_ts = int(time.time())
  387. for code, info in existing.items():
  388. if info["ended"] == 1:
  389. continue # 已播报过结束,跳过
  390. if code not in _seen_onsale:
  391. continue # 本进程运行后没见它在售 → 属「之前的车」,不补发
  392. if code in onsale_codes:
  393. continue # 本轮仍在售,未结束
  394. # 疑似消失:二次打详情确认(失败/仍在售都不判结束,下轮再看,防抓取抖动误报)
  395. data = _get_detail_data(log, code)
  396. ended, reason = _is_sale_ended(data, now_ts)
  397. if not ended:
  398. log.info(f"[疑似消失未确认结束] {code} 详情仍在售或取详情失败,跳过(可能抓取抖动) | {info['title']}")
  399. continue
  400. # 【2026/08/31】soldCount==0 的车不发结束战报:这类是「上架后无人问津、被商家撤下或到 saleEndAt
  401. # 到期」的流车,从没成交(既不在在售 is_on_sale=0、也不在已售成交列表),购买记录必空。旧逻辑
  402. # 「结束即全部售出」会把它虚报成「售出<总份数>件 / 0 人参与拆卡」,属误发(主公反馈老是误发)。
  403. # 故 soldCount==0 时静默不发,并置位 ended_notified 停止下轮重复对账/重打详情。
  404. # 只在详情明确返回 soldCount==0 时静默;soldCount 缺失(None)不静默,保守走正常播报,防抓取抖动误吞真售罄车。
  405. sold_now = data.get("soldCount")
  406. if sold_now == 0:
  407. _mark_ended_notified(pool, code)
  408. log.info(f"[结束静默] {code} soldCount=0 流车(无人成交/撤下),不发战报并置位防重扫 | {info['title']}")
  409. continue
  410. # 售出件数=总份数(结束即全部售出):优先详情最新 cardCount,缺失回退库内旧值
  411. card = data.get("cardCount")
  412. if card is None:
  413. card = info["card"]
  414. # 价格:区间价(minUnitPrice~maxUnitPrice)优先,详情取不到再回退库内 unit_price
  415. price_text = _fmt_price(data.get("unitPrice"), data.get("minUnitPrice"),
  416. data.get("maxUnitPrice"))
  417. if price_text == "¥-" and info["unit_price"] is not None:
  418. price_text = _fmt_price(info["unit_price"], None, None)
  419. buyers = _count_buyers(pool, code) # 该车去重购买人数(参与拆卡人数)
  420. item_text = _fmt_ended(info["title"], card, price_text, buyers,
  421. plain=(SEND_CHANNEL == "pc"))
  422. if _send_ended(log, info["mname"] or MERCHANT_ID, item_text):
  423. _mark_ended_notified(pool, code)
  424. log.info(f"[结束战报已发] {code} {reason} 售出{card}件 {buyers}人 | {info['title']}")
  425. else:
  426. log.warning(f"[结束战报发送失败] {code} 保持 ended_notified=0,下轮重发 | {info['title']}")
  427. def run_once(log, pool):
  428. """跑一轮监控:拉在售 → 结束对账(发一车结束战报) → 比对库内状态 → 发新品/过半提醒 → 落库。
  429. Args:
  430. log: 日志对象。
  431. pool (MySQLConnectionPool): MySQL 连接池。
  432. """
  433. products, ok = fetch_onsale(log)
  434. if not ok:
  435. log.warning("全站在售抓取未取全(请求异常),本轮跳过:不做新品/过半/结束判断,避免误判车下架")
  436. return
  437. # 库内该商家已监控商品:含结束标记与结束战报所需的静态字段(标题/商家名/单价/份数)
  438. existing_rows = pool.select_all(
  439. "SELECT product_code, new_notified, half_notified, ended_notified, "
  440. "title, merchant_name, unit_price, card_count, sold_count "
  441. f"FROM {T_ALERT} WHERE merchant_user_id=%s", MERCHANT_ID) or []
  442. existing = {}
  443. for code, new, half, ended, title, mname_, uprice, ccount, scount in existing_rows:
  444. existing[code] = {"new": new, "half": half, "ended": ended,
  445. "title": title, "mname": mname_, "unit_price": uprice,
  446. "card": ccount, "sold": scount}
  447. is_cold = len(existing) == 0 # 冷启动:库内该商家零记录(仅作日志提示,逻辑与常规轮一致)
  448. if is_cold:
  449. log.info(f"首次运行:库内该商家零记录,只提醒本轮窗口起点({RUN_START:%H:%M})后新上架的商品,老货静默建档")
  450. onsale_codes = {r["product_code"] for r in products} # 本轮在售 code 集合
  451. # 结束对账:只播报本进程运行后见过在售、之后确认结束的车(历史已结束车不补发;置位防重发)
  452. _detect_and_report_ended(log, pool, existing, onsale_codes)
  453. _seen_onsale.update(onsale_codes) # 本轮在售并入「见过在售」集合,供下轮结束对账(实现「只管运行后」)
  454. if not products:
  455. log.info("本商家本轮无在售商品,已完成结束对账,跳过新品/过半")
  456. return
  457. plain = SEND_CHANNEL == "pc" # PC 微信用纯文本,企微用 markdown
  458. new_items = [] # 新品上架提醒文案
  459. half_items = [] # 进度过半提醒文案
  460. pending_new = [] # 待发新品的 product_code:仅在提醒发送成功后才置 new_notified=1
  461. window_start = _window_start(datetime.now()) # 「新上架」时间门槛:本轮窗口起点(最近的 RUN_START,默认 20:30)
  462. for r in products:
  463. code = r["product_code"]
  464. ratio = _progress(r["sold_count"], r["card_count"])
  465. pct = ratio * 100
  466. over_half = ratio >= HALF_THRESHOLD
  467. if code not in existing:
  468. # 库里没有的候选:打一次详情拿上架时间,只有 publishAt 晚于窗口起点(RUN_START,默认 20:30)才算「本轮新上架」
  469. publish_at = get_publish_at(log, code)
  470. if _is_new_arrival(publish_at, window_start):
  471. # 真·本轮新上架:new_notified 先记 0,发送成功再置 1(发失败下轮自动重发,不丢)
  472. _insert_alert(pool, r, pct, new_notified=0,
  473. half_notified=1 if over_half else 0)
  474. new_items.append(_fmt_item(r, "new", plain=plain))
  475. pending_new.append(code)
  476. if over_half: # 新品上架即已过半,一并提示(half 逻辑保持原状)
  477. half_items.append(_fmt_item(r, "half", pct=pct, plain=plain))
  478. else:
  479. # 上架早于窗口起点(或拿不到上架时间)的老货:静默建档、不提醒(new_notified 直接置 1,避免下轮反复判断)
  480. _insert_alert(pool, r, pct, new_notified=1,
  481. half_notified=1 if over_half else 0)
  482. log.info(f"[静默建档] {code} 上架 {publish_at or '未知'} 早于窗口起点 {window_start:%m-%d %H:%M},不提醒 | {r['title']}")
  483. else:
  484. # 已在表:new_notified=0 视为「待发新品」(上次发失败残留 或 人工改回 0),重新纳入新品提醒
  485. if existing[code]["new"] == 0:
  486. new_items.append(_fmt_item(r, "new", plain=plain))
  487. pending_new.append(code)
  488. # 过半逻辑保持原状(half_notified 策略不改动)
  489. already_half = existing[code]["half"] == 1
  490. if over_half and not already_half:
  491. _update_progress(pool, r, pct, half_notified=1)
  492. half_items.append(_fmt_item(r, "half", pct=pct, plain=plain))
  493. else:
  494. _update_progress(pool, r, pct, half_notified=1 if already_half else 0)
  495. mname = products[0].get("merchant_name") or MERCHANT_ID
  496. # 常规轮次推送;sent_ok 记新品提醒是否发送成功,决定要不要置位 new_notified
  497. sent_ok = False
  498. if SEND_CHANNEL == "pc":
  499. # PC 微信:新品 + 过半合并成一条消息发出(减少操作,一次发完)
  500. if new_items or half_items:
  501. log.info(f"合并推送(pc):新品{len(new_items)}个、过半{len(half_items)}个")
  502. sent_ok = _dispatch_pc_combined(log, mname, new_items, half_items)
  503. else:
  504. # 企微渠道:仍按两类各发一条 markdown(新品这条成功与否决定 sent_ok)
  505. if new_items:
  506. log.info(f"新商品上架 {len(new_items)} 个,推送(qywx)")
  507. sent_ok = _dispatch(log, new_items, f"🆕 得卡[{mname}] 新商品上架 {len(new_items)} 个")
  508. if half_items:
  509. log.info(f"进度过半 {len(half_items)} 个,推送(qywx)")
  510. _dispatch(log, half_items, f"🔥 得卡[{mname}] 拼团进度过半 {len(half_items)} 个")
  511. # 新品提醒发送成功后才置位 new_notified=1;失败则保持 0,下一轮继续重发(不丢)
  512. if pending_new:
  513. if sent_ok:
  514. _mark_new_notified(pool, pending_new)
  515. log.info(f"新品提醒发送成功,new_notified 置 1:{len(pending_new)} 个")
  516. else:
  517. log.warning(f"新品提醒发送失败,new_notified 保持 0,下轮重发:{len(pending_new)} 个")
  518. if not new_items and not half_items:
  519. log.info("本轮无新品、无新达标过半商品")
  520. @retry(stop=stop_after_attempt(100), wait=wait_fixed(600), after=core.after_log)
  521. def main_task(log):
  522. """在售监控主函数:建连接池 → 跑一轮监控(挂了每 10 分钟重试)。
  523. Args:
  524. log: 日志对象。
  525. Raises:
  526. RuntimeError: 数据库连接池异常时抛出以触发重试。
  527. """
  528. log.info(f"开始运行 {sys._getframe().f_code.co_name} 在售提醒监控" + "." * 40)
  529. pool = MySQLConnectionPool(log=log)
  530. if not pool.check_pool_health():
  531. log.error("数据库连接池异常")
  532. raise RuntimeError("数据库连接池异常")
  533. try:
  534. run_once(log, pool)
  535. except Exception as e:
  536. log.error(f"{sys._getframe().f_code.co_name} error: {e}")
  537. finally:
  538. log.info(f"在售提醒监控 {sys._getframe().f_code.co_name} 运行结束,等待下一轮" + "." * 20)
  539. def _in_run_window(now: datetime) -> bool:
  540. """判断当前时刻是否在运行窗口 [RUN_START, 次日 RUN_END) 内。
  541. Args:
  542. now (datetime): 当前时间。
  543. Returns:
  544. bool: 在窗口内返回 True。窗口跨午夜,故「晚于开始 或 早于结束」即算命中。
  545. """
  546. t = now.time()
  547. return t >= RUN_START or t < RUN_END
  548. def _seconds_to_window(now: datetime) -> int:
  549. """计算从 now 到下一次窗口开始(当天 RUN_START)的休眠秒数(仅窗口外调用)。
  550. Args:
  551. now (datetime): 当前时间。
  552. Returns:
  553. int: 需休眠的秒数;若当天 RUN_START 已过则顺延到次日。
  554. """
  555. start = now.replace(hour=RUN_START.hour, minute=RUN_START.minute, second=0, microsecond=0)
  556. if start <= now: # 当天 RUN_START 已过 → 顺延到次日同一时刻
  557. start += timedelta(days=1)
  558. return int((start - now).total_seconds())
  559. def schedule_task():
  560. """常驻循环:仅在每天 RUN_START~次日 RUN_END 运行;窗口内每轮随机间隔轮询,窗口外休眠到下次开窗。"""
  561. while True:
  562. now = datetime.now()
  563. if not _in_run_window(now):
  564. wait = _seconds_to_window(now)
  565. logger.info(f"当前不在运行窗口({RUN_START:%H:%M}~次日{RUN_END:%H:%M}),休眠 {wait}s 到 {RUN_START:%H:%M} 再跑")
  566. time.sleep(wait)
  567. continue
  568. main_task(log=logger)
  569. wait = random.randint(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC) # 每轮随机间隔(秒)
  570. logger.info(f"下一轮 {wait}s 后运行")
  571. time.sleep(wait)
  572. def _parse_start_time(text: str) -> dtime:
  573. """把命令行传入的开始时间文本解析为 datetime.time。
  574. Args:
  575. text (str): 开始时间文本,格式 "HH:MM" 或 "HH:MM:SS",如 "20:30" / "17:00"。
  576. Returns:
  577. dtime: 解析出的 time 对象。
  578. Raises:
  579. argparse.ArgumentTypeError: 格式非法(非 HH:MM[:SS] 或时分秒越界)时抛出,供 argparse 提示用户。
  580. """
  581. text = text.strip()
  582. for fmt in ("%H:%M:%S", "%H:%M"):
  583. try:
  584. return datetime.strptime(text, fmt).time()
  585. except ValueError:
  586. continue
  587. raise argparse.ArgumentTypeError(f"开始时间格式非法:{text!r},应为 HH:MM 或 HH:MM:SS,如 20:30")
  588. def _parse_args() -> argparse.Namespace:
  589. """解析命令行参数,取运行窗口开始时间(默认 20:30)。
  590. 支持位置参数与 --start 两种写法,二者等价,方便直接 `python xxx.py 17:00`。
  591. Returns:
  592. argparse.Namespace: 含 start(datetime.time) 属性;未传时为默认 RUN_START。
  593. """
  594. parser = argparse.ArgumentParser(
  595. description="得卡 DECA 在售提醒:可指定运行窗口开始时间(该时间后的新上架才提醒)")
  596. parser.add_argument(
  597. "start", nargs="?", type=_parse_start_time, default=None,
  598. help="运行窗口开始时间 HH:MM[:SS],默认 20:30;位置参数写法,如 17:00")
  599. parser.add_argument(
  600. "--start", dest="start_opt", type=_parse_start_time, default=None,
  601. help="运行窗口开始时间 HH:MM[:SS],与位置参数等价,如 --start 17:00")
  602. return parser.parse_args()
  603. if __name__ == "__main__":
  604. # logger.add(sys.stderr, level="INFO") # 控制台同步输出,便于观察
  605. _args = _parse_args()
  606. # 位置参数优先,其次 --start,都未传则保持默认 RUN_START(20:30)
  607. _start = _args.start or _args.start_opt
  608. if _start is not None:
  609. RUN_START = _start # 覆盖模块级默认,窗口判定与「新上架」门槛均随之改变
  610. logger.info(f"运行窗口开始时间由命令行指定为 {RUN_START:%H:%M}")
  611. schedule_task()