start_run_xhs.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """
  2. 小红书 (XHS) 平台采集入口
  3. 调度、数据库、飞书通知全部走 commons。
  4. """
  5. import os
  6. import re
  7. import sys
  8. import random
  9. import time
  10. import subprocess
  11. from datetime import datetime
  12. # xhs main.py 内部 import xhs_config 需要 spiders/xhs 在 path 里
  13. sys.path.insert(0, os.path.join(os.path.dirname(__file__), "spiders", "xhs"))
  14. from commons.scheduler import CrawlerScheduler
  15. from commons.Logger import get_spider_logger
  16. from commons.feishu_webhook import send_text, send_error_card
  17. from commons.conn_mysql import MySQLPoolOn2
  18. from spiders.xhs.main import XHS, XHS_PLATFORM_ID
  19. logger = get_spider_logger("xhs_runner")
  20. PLATFORM_NAME = "小红书"
  21. IDLE_SECONDS_MIN = 30
  22. IDLE_SECONDS_MAX = 90
  23. def get_local_adb_devices():
  24. """获取当前电脑连接的所有 ADB 设备序列号"""
  25. try:
  26. output = subprocess.check_output(['adb', 'devices'], stderr=subprocess.STDOUT).decode('utf-8', errors='ignore')
  27. serials = []
  28. for line in output.splitlines():
  29. parts = line.strip().split()
  30. if len(parts) == 2 and parts[1] == 'device':
  31. serials.append(parts[0])
  32. return serials
  33. except Exception:
  34. return []
  35. def get_device_from_db():
  36. """从 DB 拿一个小红书空闲设备,返回 (username, device_id)
  37. 如果发现有设备卡在 status=1 但物理连接在当前电脑上,自动将其重置为 0
  38. """
  39. db = MySQLPoolOn2()
  40. # 1. 优先尝试直接获取空闲设备
  41. rows = db.select_data(
  42. "SELECT * FROM retrieve_collect_equipment_account WHERE platform = %s AND status = 0 ORDER BY id ASC",
  43. (XHS_PLATFORM_ID,),
  44. )
  45. if rows:
  46. row = rows[0]
  47. values = list(row.values()) if isinstance(row, dict) else row
  48. username = values[4] if len(values) > 4 else None
  49. device_id = values[11] if len(values) > 11 else username
  50. return username, device_id
  51. # 2. 如果没有空闲设备,查找是否由于断电/断网导致设备状态卡在 1(死锁)
  52. stuck_rows = db.select_data(
  53. "SELECT * FROM retrieve_collect_equipment_account WHERE platform = %s AND status = 1 ORDER BY id ASC",
  54. (XHS_PLATFORM_ID,),
  55. )
  56. if stuck_rows:
  57. local_serials = get_local_adb_devices()
  58. for row in stuck_rows:
  59. values = list(row.values()) if isinstance(row, dict) else row
  60. username = values[4] if len(values) > 4 else None
  61. device_id = values[11] if len(values) > 11 else username
  62. # 如果卡死的设备确实插在这台电脑上,说明是前一次运行崩溃留下的僵尸状态
  63. if device_id and device_id in local_serials:
  64. logger.warning(f"发现本地设备 {username}({device_id}) 状态异常卡在 1 (运行中)!")
  65. logger.warning("正在自动将其强制重置为 0 (空闲) 以恢复运行...")
  66. try:
  67. db.update_data(
  68. "UPDATE retrieve_collect_equipment_account SET status = 0 WHERE platform = %s AND account_number = %s",
  69. (XHS_PLATFORM_ID, username)
  70. )
  71. logger.info("重置成功!已恢复该设备状态。")
  72. return username, device_id
  73. except Exception as e:
  74. logger.error(f"尝试自动重置设备死锁失败: {e}")
  75. return None, None
  76. def notify_result(task, crawl_count, success):
  77. """发送飞书通知"""
  78. try:
  79. drug_name = task.get("search_key", task.get("product_name", ""))
  80. notice_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  81. text_msg = (
  82. f"{notice_time} 通知:\n"
  83. f"平台: {PLATFORM_NAME}, 关键词: {drug_name}, 爬取数据: {crawl_count}条"
  84. )
  85. if success:
  86. send_text(text_msg)
  87. else:
  88. send_error_card(
  89. task_name=drug_name,
  90. err_msg=f"task_id={task.get('task_id')}, crawl_count={crawl_count}",
  91. mention_all=False,
  92. )
  93. except Exception as e:
  94. logger.warning("飞书通知发送失败:%s", e)
  95. def update_device_status(username, platform_id, new_status):
  96. """更新设备状态:0=空闲 1=运行中"""
  97. try:
  98. db = MySQLPoolOn2()
  99. sql = "UPDATE retrieve_collect_equipment_account SET status = %s WHERE username = %s AND platform = %s"
  100. db.update_data(sql, (new_status, username, platform_id))
  101. logger.info(f"==== 已将设备 {username} 的状态修改为 {new_status} ====")
  102. except Exception as e:
  103. logger.error(f"更新设备状态失败: {e}")
  104. def main_loop():
  105. """循环拉取任务并执行采集"""
  106. username, device_id = get_device_from_db()
  107. if not username:
  108. logger.error("小红书 无可用设备,退出")
  109. return
  110. # 【1】获取到空闲设备后,立刻把数据库里的状态改成 1(运行中)
  111. update_device_status(username, XHS_PLATFORM_ID, 1)
  112. try:
  113. scheduler = CrawlerScheduler(username, str(XHS_PLATFORM_ID))
  114. idle_seconds = random.randint(IDLE_SECONDS_MIN, IDLE_SECONDS_MAX)
  115. logger.info("小红书采集启动,设备=%s adb=%s 每轮间隔 %s 秒", username, device_id, idle_seconds)
  116. while True:
  117. task_id = None
  118. try:
  119. scheduler.start()
  120. task = scheduler.get_task() or {}
  121. if not task:
  122. logger.info("%s 暂无任务,等待 %s 秒后重试", PLATFORM_NAME, idle_seconds)
  123. time.sleep(idle_seconds)
  124. continue
  125. task_id = task.get("id")
  126. # API 字段映射(模仿 PDD build_task_payload_from_api)
  127. brand = task.get("product_brand", "") or ""
  128. title_key = task.get("product_name", "") or ""
  129. spec_raw = task.get("product_specs", "") or ""
  130. spec_list = [s.strip() for s in re.split(r'[|、,,\n\r]+', spec_raw) if s.strip()]
  131. search_key = f"{brand}{title_key} {spec_raw}".strip()
  132. save_search_key = task.get("product_keyword") or search_key
  133. logger.info("开始执行%s采集任务 task_id=%s keyword=%s", PLATFORM_NAME, task_id, search_key)
  134. xhs = XHS(
  135. search_key=search_key,
  136. device_id=device_id,
  137. title_key=title_key,
  138. spec_list=spec_list,
  139. brand=brand,
  140. save_search_key=save_search_key,
  141. start_page=0, # 取消恢复历史进度,取到任务就从第一个商品开始
  142. end_page=task.get("end_page"),
  143. max_counts_limit=task.get("max_counts_limit"),
  144. collect_config_info=task.get("collect_config_info", ""),
  145. sort=task.get("sort"),
  146. platform=XHS_PLATFORM_ID,
  147. task_id=task_id,
  148. enterprise_id=task.get("company_id"),
  149. collect_round=task.get("collect_round"),
  150. collect_equipment_account_id=task.get("collect_equipment_account_id"),
  151. collect_region_id=task.get("collect_region_id"),
  152. username=username,
  153. )
  154. completed = xhs.main()
  155. crawl_count = getattr(xhs, 'max_counts', 0)
  156. logger.info("%s采集任务完成 task_id=%s count=%s success=%s",
  157. PLATFORM_NAME, task_id, crawl_count, completed)
  158. notify_result(task, crawl_count, completed)
  159. except Exception as e:
  160. logger.error("%s 采集任务执行失败: %s", PLATFORM_NAME, e, exc_info=True)
  161. time.sleep(idle_seconds)
  162. except KeyboardInterrupt:
  163. # 【2】专门捕获手动断开 (如在终端按下 Ctrl+C 等操作)
  164. logger.info("检测到程序被手动停止(Ctrl+C),准备释放设备...")
  165. except Exception as e:
  166. # 捕获其他意料之外的严重崩溃
  167. logger.error("主程序发生崩溃异常: %s", e)
  168. finally:
  169. # 【3】不管是因为报错退出、还是人为强行断开,这里都会尽最大努力执行,改回 0
  170. # 增加重试机制,防止断网瞬间改不回去
  171. for attempt in range(3):
  172. try:
  173. update_device_status(username, XHS_PLATFORM_ID, 0)
  174. break
  175. except Exception as e:
  176. logger.error(f"释放设备状态失败 (尝试 {attempt+1}/3): {e}")
  177. time.sleep(2)
  178. logger.info("设备已安全释放,状态恢复为 0。退出程序。")
  179. if __name__ == '__main__':
  180. main_loop()