booker.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. import os
  2. import time
  3. import json
  4. import threading
  5. import random
  6. from datetime import datetime
  7. from typing import List, Dict, Callable, Optional
  8. from vs_types import GroupConfig, VSPlgConfig, Task, VSQueryResult, AppointmentType, AvailabilityStatus
  9. from vs_plg_factory import VSPlgFactory
  10. from toolkit.thread_pool import ThreadPool
  11. from toolkit.vs_cloud_api import VSCloudApi
  12. from utils.safe_redis_cli import SafeRedisClient
  13. class BaseBookerGCO:
  14. """
  15. Booker 基类,封装公共的基础设施与通用的生命周期管理、逻辑循环等。
  16. """
  17. TAG = "BOOKER"
  18. def __init__(self, cfg: GroupConfig, redis_conf: Dict, logger: Callable[[str], None] = None):
  19. self.m_cfg = cfg
  20. self.m_factory = VSPlgFactory()
  21. self.m_logger = logger
  22. self.m_tasks: List[Task] = []
  23. self.m_lock = threading.RLock()
  24. self.m_stop_event = threading.Event()
  25. self.redis_client = SafeRedisClient(redis_conf, self.m_logger)
  26. self.m_tracker_key = f"vs:worker:tasks_tracker:{self.m_cfg.identifier}"
  27. def _log(self, message: str):
  28. prefix = f'[{self.TAG}] [{self.m_cfg.identifier}]'
  29. if self.m_logger:
  30. self.m_logger(f'{prefix} {message}')
  31. else:
  32. print(f'{prefix} {message}')
  33. def start(self):
  34. if not self.m_cfg.enable:
  35. return
  36. self._log(f"Starting {self.TAG}...")
  37. plugin_name = self.m_cfg.plugin_config.plugin_name
  38. class_name = "".join(part.title() for part in plugin_name.split('_'))
  39. plugin_path = os.path.join(self.m_cfg.plugin_config.lib_path, self.m_cfg.plugin_config.plugin_bin)
  40. self.m_factory.register_plugin(plugin_name, plugin_path, class_name)
  41. threading.Thread(target=self._booking_trigger_loop, daemon=True).start()
  42. threading.Thread(target=self._creator_loop, daemon=True).start()
  43. threading.Thread(target=self._maintain_loop, daemon=True).start()
  44. self._start_additional_threads()
  45. def _start_additional_threads(self):
  46. """子类扩展线程的 Hook"""
  47. pass
  48. def stop(self):
  49. self._log("Stopping Booker...")
  50. self.m_stop_event.set()
  51. self._cleanup_all_tasks("booker stop")
  52. def update_config(self, new_cfg: GroupConfig):
  53. """动态更新配置"""
  54. with self.m_lock:
  55. if self.m_cfg.enable and not new_cfg.enable:
  56. self._log("Config dynamically updated: Group DISABLED. Will stop creating new tasks.")
  57. elif not self.m_cfg.enable and new_cfg.enable:
  58. self._log("Config dynamically updated: Group ENABLED.")
  59. else:
  60. self._log("Config dynamically updated: Parameters refreshed.")
  61. self.m_cfg = new_cfg
  62. def _cleanup_task(self, task: Task, reason: str = ""):
  63. try:
  64. if task and task.instance:
  65. task.instance.cleanup()
  66. ref_str = f" for task={task.task_ref}" if task.task_ref else ""
  67. self._log(f"🧹 Cleaned up instance{ref_str}. Reason: {reason}")
  68. except Exception as e:
  69. self._log(f"Cleanup failed for instance. Reason: {reason}. Error: {e}")
  70. def _remove_task(self, task: Task, reason: str = "", cleanup: bool = True):
  71. removed = False
  72. with self.m_lock:
  73. if task in self.m_tasks:
  74. self.m_tasks.remove(task)
  75. removed = True
  76. self._on_task_removed(task)
  77. if cleanup and removed:
  78. self._cleanup_task(task, reason)
  79. return removed
  80. def _on_task_removed(self, task: Task):
  81. """子类在 task 被移除时的自定义 Hook(如清理缓存)"""
  82. pass
  83. def _cleanup_all_tasks(self, reason: str = ""):
  84. with self.m_lock:
  85. tasks = list(self.m_tasks)
  86. self.m_tasks.clear()
  87. self._on_all_tasks_cleaned()
  88. for task in tasks:
  89. self._cleanup_task(task, reason)
  90. def _on_all_tasks_cleaned(self):
  91. """子类在所有 task 被清空时的自定义 Hook"""
  92. pass
  93. def _get_redis_key(self, routing_key: str) -> str:
  94. return f"vs:signal:{routing_key}"
  95. def _is_within_active_hours(self) -> bool:
  96. """判断当前是否在允许创建实例的时间段内"""
  97. start_str = self.m_cfg.active_time_start
  98. end_str = self.m_cfg.active_time_end
  99. current_bj_time = datetime.utcnow().time()
  100. start_time = datetime.strptime(start_str, "%H:%M").time()
  101. end_time = datetime.strptime(end_str, "%H:%M").time()
  102. return start_time <= current_bj_time <= end_time
  103. def _maintain_loop(self):
  104. self._log("Maintain loop started.")
  105. while not self.m_stop_event.is_set():
  106. try:
  107. time.sleep(1.0)
  108. with self.m_lock:
  109. tasks_to_check = list(self.m_tasks)
  110. if not tasks_to_check:
  111. continue
  112. dead_tasks = []
  113. healthy_tasks = []
  114. now = time.time()
  115. for t in tasks_to_check:
  116. if now >= t.next_remote_ping:
  117. t.instance.keep_alive()
  118. if t.instance.health_check():
  119. healthy_tasks.append(t)
  120. t.next_remote_ping = now + random.gauss(self.m_cfg.booker.keep_alive, 5)
  121. else:
  122. dead_tasks.append(t)
  123. else:
  124. healthy_tasks.append(t)
  125. self._on_maintain_ping(healthy_tasks, dead_tasks)
  126. if dead_tasks:
  127. with self.m_lock:
  128. current_tasks = list(self.m_tasks)
  129. self.m_tasks = [t for t in self.m_tasks if t in healthy_tasks]
  130. for t in dead_tasks:
  131. if t in current_tasks:
  132. self._cleanup_task(t, "unhealthy or keep-alive failed")
  133. else:
  134. with self.m_lock:
  135. self.m_tasks = [t for t in self.m_tasks if t in healthy_tasks]
  136. except Exception as e:
  137. self._log(f'Maintain loop exception: {e}')
  138. def _on_maintain_ping(self, healthy_tasks: List[Task], dead_tasks: List[Task]):
  139. """子类维护循环中对于健康/异常 Task 的额外处理"""
  140. pass
  141. def _is_date_of_interest(self, task: Task, query_result: VSQueryResult) -> bool:
  142. """判断 query_result 中的可用日期是否在 task 意向范围内(默认 True,Order 模式重写)"""
  143. return True
  144. def _booking_trigger_loop(self):
  145. self._log("Trigger loop started.")
  146. while not self.m_stop_event.is_set():
  147. try:
  148. time.sleep(0.1)
  149. now = time.time()
  150. for apt_type in self.m_cfg.appointment_types:
  151. redis_key = self._get_redis_key(apt_type.routing_key)
  152. raw_data = self.redis_client.get(redis_key)
  153. if not raw_data:
  154. continue
  155. try:
  156. data = json.loads(raw_data)
  157. query_result = VSQueryResult.model_validate(data['query_result'])
  158. query_result.apt_type = AppointmentType.model_validate(data['apt_type'])
  159. except Exception as parse_err:
  160. self._log(f"Data parsing error for {redis_key}: {parse_err}. Deleting corrupted signal.")
  161. self.redis_client.delete(redis_key)
  162. continue
  163. matching_tasks = []
  164. with self.m_lock:
  165. for task in self.m_tasks:
  166. if now < task.next_run or not task.book_allowed:
  167. continue
  168. if apt_type.routing_key not in task.acceptable_routing_keys:
  169. continue
  170. if not self._is_date_of_interest(task, query_result):
  171. continue
  172. task.next_run = now + self.m_cfg.booker.booking_cooldown
  173. matching_tasks.append(task)
  174. if matching_tasks:
  175. threads = []
  176. for task in matching_tasks:
  177. self._log(f"🚀 Triggering BOOK for {apt_type.routing_key} | Order Ref: {task.task_ref}")
  178. t = threading.Thread(target=self._execute_book_job, args=(task, query_result))
  179. threads.append(t)
  180. t.start()
  181. for t in threads:
  182. t.join()
  183. except Exception as e:
  184. self._log(f"Booking trigger loop exception: {e}")
  185. def _execute_book_job(self, task: Task, query_result: VSQueryResult):
  186. raise NotImplementedError
  187. def _creator_loop(self):
  188. raise NotImplementedError
  189. def _push_success_notification(self, task_id, order_id, book_res):
  190. """成功落单后同步云端与推送微信通知的公共方法"""
  191. grab_info = {
  192. "account": book_res.account,
  193. "session_id": book_res.session_id,
  194. "urn": book_res.urn,
  195. "slot_date": book_res.book_date,
  196. "slot_time": book_res.book_time,
  197. "timestamp": int(time.time()),
  198. "payment_link": book_res.payment_link
  199. }
  200. def _update_cloud_success():
  201. try:
  202. VSCloudApi.Instance().update_vas_task(str(task_id), {"status": "grabbed", "grabbed_history": grab_info})
  203. push_content = (
  204. f"🎉 【预定成功通知】\n"
  205. f"━━━━━━━━━━━━━━━\n"
  206. f"订单编号: {order_id}\n"
  207. f"预约账号: {book_res.account}\n"
  208. f"预约日期: {book_res.book_date}\n"
  209. f"预约时间: {book_res.book_time}\n"
  210. f"预约编号: {book_res.urn}\n"
  211. f"支付链接: {book_res.payment_link if book_res.payment_link else '无需支付/暂无'}\n"
  212. f"━━━━━━━━━━━━━━━\n"
  213. )
  214. VSCloudApi.Instance().push_weixin_text(push_content)
  215. except Exception as e:
  216. self._log(f"Failed to update success state to cloud: {e}")
  217. ThreadPool.getInstance().enqueue(_update_cloud_success)
  218. self.redis_client.zrem(self.m_tracker_key, task_id)
  219. class BuiltinBookerGCO(BaseBookerGCO):
  220. """
  221. 非绑定模式 (公共内置账号池):
  222. - 只维护全局 target_instances 数量的实例。
  223. - 所有实例热机等待,发现信号后临时去云端 Pop 订单。
  224. """
  225. TAG = "BUILTIN-BOOKER"
  226. def _creator_loop(self):
  227. self._log("Creator loop started.")
  228. while not self.m_stop_event.wait(1.0):
  229. try:
  230. if not self._is_within_active_hours():
  231. continue
  232. with self.m_lock:
  233. current = len(self.m_tasks)
  234. target = self.m_cfg.booker.target_instances
  235. if current < target:
  236. self._spawn_worker()
  237. except Exception as e:
  238. self._log(f'Creator loop exception: {e}')
  239. def _spawn_worker(self):
  240. instance = None
  241. success = False
  242. plg_cfg = None
  243. try:
  244. plg_cfg = VSPlgConfig()
  245. plg_cfg.debug = self.m_cfg.debug
  246. plg_cfg.free_config = self.m_cfg.free_config
  247. plg_cfg.session_max_life = self.m_cfg.session_max_life
  248. if self.m_cfg.need_account:
  249. acc = VSCloudApi.Instance().get_next_account(self.m_cfg.booker.account_pool_id, self.m_cfg.booker.account_cd)
  250. plg_cfg.account = type(plg_cfg.account)(**acc)
  251. if self.m_cfg.need_proxy:
  252. proxy = VSCloudApi.Instance().get_next_proxy(self.m_cfg.proxy_pool, self.m_cfg.proxy_cd)
  253. plg_cfg.proxy = type(plg_cfg.proxy)(**proxy)
  254. instance = self.m_factory.create(self.m_cfg.identifier, self.m_cfg.plugin_config.plugin_name)
  255. instance.set_log(self.m_logger)
  256. instance.set_config(plg_cfg)
  257. instance.create_session()
  258. success = True
  259. with self.m_lock:
  260. all_keys = [apt.routing_key for apt in self.m_cfg.appointment_types]
  261. self.m_tasks.append(
  262. Task(
  263. instance=instance,
  264. next_run=time.time(),
  265. task_ref=None,
  266. acceptable_routing_keys=all_keys,
  267. source_queue="built-in",
  268. book_allowed=True,
  269. next_remote_ping=time.time() + random.gauss(self.m_cfg.booker.keep_alive, 5)
  270. )
  271. )
  272. success = True
  273. self._log(f"+++ Built-in Booker spawned: {plg_cfg.account.username}")
  274. except Exception as e:
  275. err_str = str(e)
  276. self._log(f"Spawn failed: {err_str}")
  277. rate_limited_indicators = [
  278. "42901" in err_str,
  279. "Rate limited" in err_str
  280. ]
  281. if any(rate_limited_indicators):
  282. if plg_cfg and plg_cfg.account.username != "Guest":
  283. VSCloudApi.lock_account(plg_cfg.account.id, self.m_cfg.login_backoff)
  284. finally:
  285. if not success:
  286. if instance:
  287. instance.cleanup()
  288. def _execute_book_job(self, task: Task, query_result: VSQueryResult):
  289. queue_name = f"auto.{query_result.apt_type.routing_key}"
  290. task_id = None
  291. task_data = None
  292. try:
  293. task_data = VSCloudApi.Instance().get_vas_task_pop(queue_name)
  294. if not task_data:
  295. return
  296. task_id = task_data['id']
  297. order_id = task_data.get('order_id')
  298. self.redis_client.zadd(self.m_tracker_key, {str(task_id): time.time() + 30.0})
  299. user_input = task_data.get('user_inputs', {})
  300. book_res = task.instance.book(query_result, user_input)
  301. if book_res.success:
  302. self._log(f"✅ BOOK SUCCESS! Order: {order_id}")
  303. self._push_success_notification(task_id, order_id, book_res)
  304. task.successful_bookings += 1
  305. max_b = self.m_cfg.booker.max_bookings_per_account
  306. if max_b > 0 and task.successful_bookings >= max_b:
  307. self._log(f"Account reached max bookings ({max_b}). Destroying instance.")
  308. self._remove_task(task, "max bookings reached")
  309. else:
  310. self._log(f"❌ BOOK FAILED for Order: {order_id}")
  311. except Exception as e:
  312. err_str = str(e)
  313. self._log(f"Exception during booking: {err_str}")
  314. rate_limited_indicators = [
  315. "42901" in err_str,
  316. "Rate limited" in err_str
  317. ]
  318. if any(rate_limited_indicators):
  319. self._remove_task(task, "booking rate limited")
  320. class OrderBookerGCO(BaseBookerGCO):
  321. """
  322. 绑定模式 (订单自带账号):
  323. - 按城市队列维护热机配额。
  324. - 绝对的 1 对 1 关系:一个实例绑定一个云端订单。
  325. - 预订成功后,实例立即销毁。
  326. """
  327. TAG = "ORDER-BOOKER"
  328. def __init__(self, cfg: GroupConfig, redis_conf: Dict, logger: Callable[[str], None] = None):
  329. super().__init__(cfg, redis_conf, logger)
  330. self.m_task_data_cache: Dict[str, dict] = {}
  331. self.heartbeat_ttl = 2 * 60.0
  332. def _start_additional_threads(self):
  333. threading.Thread(target=self._cache_refresh_loop, daemon=True).start()
  334. def _on_task_removed(self, task: Task):
  335. task_id = task.task_ref
  336. if task_id:
  337. self.m_task_data_cache.pop(str(task_id), None)
  338. def _on_all_tasks_cleaned(self):
  339. self.m_task_data_cache.clear()
  340. def _on_maintain_ping(self, healthy_tasks: List[Task], dead_tasks: List[Task]):
  341. if healthy_tasks:
  342. new_deadline = time.time() + self.heartbeat_ttl
  343. mapping = {str(t.task_ref): new_deadline for t in healthy_tasks}
  344. self.redis_client.bulk_zadd(self.m_tracker_key, mapping)
  345. if dead_tasks:
  346. mapping = {str(t.task_ref): 0 for t in dead_tasks}
  347. self.redis_client.bulk_zadd(self.m_tracker_key, mapping)
  348. def _cache_refresh_loop(self):
  349. self._log("Cache refresh loop started.")
  350. refresh_interval = 15 * 60
  351. while not self.m_stop_event.is_set():
  352. try:
  353. time.sleep(1)
  354. with self.m_lock:
  355. tasks_to_check = {
  356. tid: data.get('_last_refresh', 0)
  357. for tid, data in self.m_task_data_cache.items()
  358. }
  359. if not tasks_to_check:
  360. continue
  361. now = time.time()
  362. for tid, last_refresh in tasks_to_check.items():
  363. if now - last_refresh >= refresh_interval:
  364. fresh_data = VSCloudApi.Instance().get_vas_task(tid)
  365. if fresh_data:
  366. fresh_data['_last_refresh'] = time.time()
  367. with self.m_lock:
  368. if tid in self.m_task_data_cache:
  369. self.m_task_data_cache[tid] = fresh_data
  370. time.sleep(0.5)
  371. except Exception as e:
  372. self._log(f'Cache refresh loop exception: {e}')
  373. def _is_date_of_interest(self, task: Task, query_result: VSQueryResult) -> bool:
  374. if query_result.availability_status != AvailabilityStatus.Available:
  375. return True
  376. task_id = task.task_ref
  377. task_data = self.m_task_data_cache.get(str(task_id), {})
  378. user_input = task_data.get('user_inputs', {})
  379. expected_end_date = (
  380. user_input.get('expected_end_date')
  381. or '2100-01-01'
  382. )
  383. available_date = query_result.earliest_date
  384. dt = available_date.strftime("%Y-%m-%d")
  385. return dt <= expected_end_date
  386. def _execute_book_job(self, task: Task, query_result: VSQueryResult):
  387. task_id = task.task_ref
  388. task_data = None
  389. try:
  390. with self.m_lock:
  391. task_data = self.m_task_data_cache.get(str(task_id))
  392. if not task_data or task_data.get('status') in ['grabbed', 'pause', 'completed', 'cancelled']:
  393. self._log(f"Bound Task={task_id} is no longer valid or already processed. Removing instance.")
  394. self._remove_task(task, "bound task no longer valid")
  395. self.redis_client.zrem(self.m_tracker_key, task_id)
  396. return
  397. order_id = task_data.get('order_id')
  398. user_input = task_data.get('user_inputs', {})
  399. book_res = task.instance.book(query_result, user_input)
  400. if book_res.success:
  401. self._log(f"✅ BOOK SUCCESS! Order: {order_id}. Destroying instance.")
  402. self._push_success_notification(task_id, order_id, book_res)
  403. self._remove_task(task, "booking success")
  404. else:
  405. self._log(f"❌ BOOK FAILED for Order: {order_id}. Will retry on next signal.")
  406. except Exception as e:
  407. err_str = str(e)
  408. self._log(f"Exception during booking: {err_str}")
  409. rate_limited_indicators = [
  410. "42901" in err_str,
  411. "Rate limited" in err_str
  412. ]
  413. if any(rate_limited_indicators):
  414. self._remove_task(task, "booking rate limited")
  415. def _creator_loop(self):
  416. self._log("Creator loop started.")
  417. while not self.m_stop_event.wait(1.0):
  418. try:
  419. if not self._is_within_active_hours():
  420. continue
  421. for apt in self.m_cfg.appointment_types:
  422. r_key = apt.routing_key
  423. with self.m_lock:
  424. active = sum(1 for t in self.m_tasks if t.source_queue == r_key)
  425. target = self.m_cfg.booker.target_instances
  426. if active < target:
  427. self._spawn_worker(r_key)
  428. except Exception as e:
  429. self._log(f'Creator loop exception:{e}')
  430. def _spawn_worker(self, target_routing_key: str):
  431. instance = None
  432. success = False
  433. task_id = None
  434. try:
  435. queue_name = f"auto.{target_routing_key}"
  436. task_data = VSCloudApi.Instance().get_vas_task_pop(queue_name)
  437. if not task_data:
  438. return
  439. task_id = task_data['id']
  440. with self.m_lock:
  441. self.m_task_data_cache[str(task_id)] = task_data
  442. self.redis_client.zadd(self.m_tracker_key, {str(task_id): time.time() + 8*60.0})
  443. user_inputs = task_data.get('user_inputs', {})
  444. plg_cfg = VSPlgConfig()
  445. plg_cfg.debug = self.m_cfg.debug
  446. plg_cfg.free_config = self.m_cfg.free_config
  447. plg_cfg.session_max_life = self.m_cfg.session_max_life
  448. plg_cfg.account.username = user_inputs.get("username", "")
  449. plg_cfg.account.password = user_inputs.get("password", "")
  450. if not plg_cfg.account.username:
  451. return
  452. acceptable_keys = [target_routing_key]
  453. if self.m_cfg.need_proxy:
  454. proxy = VSCloudApi.Instance().get_next_proxy(self.m_cfg.proxy_pool, self.m_cfg.proxy_cd)
  455. plg_cfg.proxy = type(plg_cfg.proxy)(**proxy)
  456. instance = self.m_factory.create(self.m_cfg.identifier, self.m_cfg.plugin_config.plugin_name)
  457. instance.set_log(self.m_logger)
  458. instance.set_config(plg_cfg)
  459. instance.create_session()
  460. with self.m_lock:
  461. self.m_tasks.append(
  462. Task(
  463. instance=instance,
  464. next_run=time.time(),
  465. task_ref=task_id,
  466. acceptable_routing_keys=acceptable_keys,
  467. source_queue=target_routing_key,
  468. book_allowed=True,
  469. next_remote_ping=time.time() + random.gauss(self.m_cfg.booker.keep_alive, 5)
  470. )
  471. )
  472. success = True
  473. self._log(f"+++ Order Booker spawned: {plg_cfg.account.username} (Target: {acceptable_keys})")
  474. except Exception as e:
  475. err_str = str(e)
  476. self._log(f"Order Booker spawn failed: {err_str}")
  477. rate_limited_indicators = [
  478. "42901" in err_str,
  479. "Rate limited" in err_str
  480. ]
  481. if any(rate_limited_indicators):
  482. if task_id is not None:
  483. self.redis_client.zadd(self.m_tracker_key, {str(task_id): time.time() + self.m_cfg.login_backoff})
  484. finally:
  485. if not success:
  486. if task_id:
  487. with self.m_lock:
  488. self.m_task_data_cache.pop(str(task_id), None)
  489. if instance:
  490. instance.cleanup()