3
0

ybm_crawl.py 71 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593
  1. # ====================================================================================
  2. # ybm_crawl.py — 药帮忙平台爬虫
  3. # 遵循 commons1 调度框架,平台专属逻辑保留在类内
  4. # 由 CollectScheduleRunner / run_scheduled_loop 调度执行
  5. # ====================================================================================
  6. # ==================== SECTION 1: IMPORTS ====================
  7. import base64
  8. import json
  9. import os
  10. import random
  11. import re
  12. import threading
  13. import time
  14. import traceback
  15. from datetime import datetime
  16. from io import BytesIO
  17. import oss2
  18. import requests
  19. from PIL import Image
  20. from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
  21. # ---- commons1 公共能力 ----
  22. from commons.Logger import get_spider_logger
  23. from commons.conn_mysql import MySQLPoolOn2
  24. from commons.feishu_webhook import send_text as feishu_send_text, send_error_card as feishu_send_error_card
  25. from commons.scheduler import CrawlerScheduler
  26. # ---- pipelines (基于 commons) ----
  27. from pipelines.drug_pipelines import DrugPipeline
  28. logger = get_spider_logger("ybm")
  29. # ==================== SECTION 2: PLATFORM CONFIG ====================
  30. # MySQL(供店铺信息直接读写;商品数据走 DrugPipeline)
  31. mysql_pool = MySQLPoolOn2()
  32. # 平台标识
  33. PLATFORM_ID = 9
  34. YBM_DEVICE_ID = "18008650300"
  35. # API(内部调度系统)
  36. # 手动任务(调试用)
  37. USE_MANUAL_TASK = False
  38. MANUAL_TASK = {
  39. "id": 2001,
  40. "collect_task_id": 5000,
  41. "company_id": 1,
  42. "product_name": "三九胃泰颗粒",
  43. "product_specs": "10",
  44. "product_keyword": "",
  45. "product_brand": "999",
  46. "sampling_cycle": 1,
  47. "sampling_start_time": 1778083200,
  48. "sampling_end_time": 1778342399,
  49. "collect_equipment_account_id": 0,
  50. "collect_region_id": 0,
  51. "collect_equipment_id": 2,
  52. "collect_round": 1,
  53. }
  54. # 反爬参数
  55. MIN_CLICK_DELAY = 1.5
  56. MAX_CLICK_DELAY = 3.5
  57. MIN_INPUT_DELAY = 0.1
  58. MAX_INPUT_DELAY = 0.3
  59. MIN_PAGE_DELAY = 2.0
  60. MAX_PAGE_DELAY = 4.0
  61. MIN_KEYWORD_DELAY = 8.0
  62. MAX_KEYWORD_DELAY = 15.0
  63. SCROLL_TARGET_DISTANCE = 400
  64. SCROLL_OFFSET_RANGE = 50
  65. SCROLL_STEP = 50
  66. SCROLL_INTERVAL = 0.05
  67. # Cookie & 登录
  68. COOKIE_FILE_PATH = "ybm_cookies.json"
  69. LOGIN_VALIDATE_URL = "https://www.ybm100.com/new/"
  70. USERNAME = "18008650300"
  71. PASSWORD = "12345678"
  72. TARGET_LOGIN_URL = "https://www.ybm100.com/new/login"
  73. # 选择器
  74. USERNAME_SELECTOR = "input[placeholder*=请输入账号]"
  75. PASSWORD_SELECTOR = "input[placeholder*=请输入密码]"
  76. LOGIN_BTN_SELECTOR = "button:has(span:text('登录'))"
  77. SEARCH_INPUT_SELECTOR = "input[placeholder*='药品名称/厂家名称']"
  78. SEARCH_INPUT_SELECTOR2 = "div.home-search-container-search-head"
  79. SEARCH_BTN_SELECTOR = "div.home-search-container-search-head-btn[data-scmd=\"text-搜索\"]"
  80. PRODUCT_ITEM_SELECTOR = "div.product-list-item"
  81. PRODUCT_TITLE_SELECTOR = "div.product-name"
  82. PRODUCT_PRICE_SELECTOR = "div.main-price"
  83. PRODUCT_STORE_SELECTOR = 'div.prduct-shop-name div.shop-name'
  84. PRODUCT_COMPANY_SELECTOR = "div.product-manufacturer"
  85. PRODUCT_VALIDITY_SELECTOR = "div.product-period"
  86. # 等待时间
  87. ELEMENT_TIMEOUT = 10000
  88. LOGIN_AFTER_CLICK = 5000
  89. SEARCH_BTN_TIMEOUT = 5000
  90. COLLECT_DELAY = 3000
  91. DETAIL_LOAD_TIMEOUT = 5000
  92. # 浏览器
  93. BROWSER_HEADLESS = False
  94. BROWSER_CHANNEL = "chrome"
  95. SLOW_MO_MIN = 50
  96. SLOW_MO_MAX = 100
  97. # 百度 OCR
  98. request_url_config = "https://aip.baidubce.com/rest/2.0/ocr/v1/business_license"
  99. AppKey_config = "tRK2RhyItCSh6BzyT4CNVXQa"
  100. AppSecret_config = "TDgKiPo94i2mOM1sDqOuDnlcK1bG66jh"
  101. token_url_config = 'https://aip.baidubce.com/oauth/2.0/token'
  102. # OSS
  103. OSS_ACCESS_KEY_ID = 'LTAI5tDwjfteBvivYN41r8sJ'
  104. OSS_ACCESS_KEY_SECRET = 'yowuOGi2nYYnrqGpO3qcz94C4brcPp'
  105. OSS_ENDPOINT = "oss-cn-shenzhen.aliyuncs.com"
  106. OSS_BUCKET_NAME = "zhijiayun-jiansuo"
  107. OSS_PREFIX = "scrape_data/"
  108. LOCAL_SCREENSHOT_DIR = "local_screenshots"
  109. LOCAL_CROPPED_DIR = "./local_cropped_screenshots"
  110. IMAGE_COMPRESS_ENABLE = True
  111. IMAGE_COMPRESS_QUALITY = 30
  112. IMAGE_COMPRESS_PNG_LEVEL = 9
  113. # 心跳上报间隔(秒)
  114. HEARTBEAT_INTERVAL_SECONDS = 60
  115. # ==================== SECTION 3: OSS / SCREENSHOT HELPERS ====================
  116. def init_local_screenshot_dir():
  117. if not os.path.exists(LOCAL_SCREENSHOT_DIR):
  118. os.makedirs(LOCAL_SCREENSHOT_DIR)
  119. logger.info(f"本地截图目录已创建: {LOCAL_SCREENSHOT_DIR}")
  120. else:
  121. logger.debug(f"本地截图目录已存在: {LOCAL_SCREENSHOT_DIR}")
  122. def init_oss_bucket():
  123. try:
  124. auth = oss2.Auth(OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET)
  125. bucket = oss2.Bucket(auth, OSS_ENDPOINT, OSS_BUCKET_NAME)
  126. bucket.get_bucket_info()
  127. logger.info("OSS Bucket 初始化成功")
  128. return bucket
  129. except Exception as e:
  130. logger.error(f"OSS Bucket 初始化失败: {str(e)}")
  131. raise
  132. def upload_local_screenshot_to_oss(bucket, local_file_path, oss_file_path=None):
  133. if not os.path.exists(local_file_path):
  134. raise FileNotFoundError(f"本地截图文件不存在: {local_file_path}")
  135. if not oss_file_path:
  136. local_file_name = os.path.basename(local_file_path)
  137. oss_file_path = f"screenshots/{local_file_name}"
  138. try:
  139. bucket.put_object_from_file(oss_file_path, local_file_path)
  140. oss_file_url = f"https://{OSS_BUCKET_NAME}.{OSS_ENDPOINT}/{oss_file_path}"
  141. logger.info(f"截图上传 OSS 成功: {oss_file_url}")
  142. return oss_file_url
  143. except Exception as e:
  144. logger.error(f"截图上传 OSS 失败: {str(e)}")
  145. raise
  146. def crop_local_screenshot(local_file_path, cropped_file_path=None, crop_region=None):
  147. if not os.path.exists(local_file_path):
  148. raise FileNotFoundError(f"原始截图文件不存在: {local_file_path}")
  149. os.makedirs(LOCAL_CROPPED_DIR, exist_ok=True)
  150. if not cropped_file_path:
  151. file_name = os.path.basename(local_file_path)
  152. file_name_no_ext, file_ext = os.path.splitext(file_name)
  153. cropped_file_name = f"{file_name_no_ext}_cropped{file_ext}"
  154. cropped_file_path = os.path.join(LOCAL_CROPPED_DIR, cropped_file_name)
  155. with Image.open(local_file_path) as img:
  156. img_width, img_height = img.size
  157. if not crop_region:
  158. crop_region = (0, 0, int(img_width), int(img_height * 0.3))
  159. c_left, c_upper, c_right, c_lower = crop_region
  160. if c_right > img_width or c_lower > img_height or c_left < 0 or c_upper < 0:
  161. raise ValueError(f"裁剪区域超出图片范围,图片尺寸=({img_width}, {img_height}),裁剪区域={crop_region}")
  162. cropped_img = img.crop(crop_region)
  163. file_ext = os.path.splitext(cropped_file_path)[1].lower()
  164. try:
  165. if IMAGE_COMPRESS_ENABLE:
  166. if file_ext in ['.jpg', '.jpeg']:
  167. cropped_img.save(cropped_file_path, format='JPEG', quality=IMAGE_COMPRESS_QUALITY, optimize=True, progressive=True)
  168. else:
  169. cropped_img.save(cropped_file_path)
  170. else:
  171. cropped_img.save(cropped_file_path, format='JPEG')
  172. except Exception:
  173. cropped_img.save(cropped_file_path, format='JPEG')
  174. try:
  175. if os.path.exists(cropped_file_path):
  176. os.remove(local_file_path)
  177. except OSError:
  178. pass
  179. return cropped_file_path
  180. def screenshot_target_page_to_local_then_oss(target_page, local_file_path=None, oss_file_path=None, full_page=True, crop_region=None):
  181. os.makedirs(LOCAL_SCREENSHOT_DIR, exist_ok=True)
  182. if not local_file_path:
  183. current_time = datetime.now().strftime("%Y%m%d_%H%M%S")
  184. local_file_name = f"{current_time}_p{PLATFORM_ID}_target_page.jpg"
  185. local_file_path = os.path.join(LOCAL_SCREENSHOT_DIR, local_file_name)
  186. logger.info(f"开始页面截图: {local_file_path}")
  187. target_page.screenshot(path=local_file_path, full_page=full_page, omit_background=False, timeout=10000)
  188. cropped_file_path = crop_local_screenshot(local_file_path=local_file_path, crop_region=crop_region)
  189. bucket = init_oss_bucket()
  190. oss_file_url = upload_local_screenshot_to_oss(bucket, cropped_file_path, oss_file_path)
  191. return cropped_file_path, oss_file_url
  192. # ==================== SECTION 4: CITY / PROVINCE MAPPING ====================
  193. CITY_JSON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "commons", "city.json")
  194. PROVINCE_ID_MAP = {}
  195. CITY_ID_MAP = {}
  196. CITY_TO_PROVINCES_MAP = {}
  197. DIRECT_MUNICIPALITIES = {"北京市", "上海市", "天津市", "重庆市"}
  198. DIRECT_MUNICIPALITY_BASE_NAMES = {"北京", "上海", "天津", "重庆"}
  199. DIRECT_MUNICIPALITY_ALIAS = {
  200. "北京": "北京市", "上海": "上海市", "天津": "天津市", "重庆": "重庆市",
  201. }
  202. def load_city_mapping():
  203. global PROVINCE_ID_MAP, CITY_ID_MAP, CITY_TO_PROVINCES_MAP
  204. PROVINCE_ID_MAP.clear()
  205. CITY_ID_MAP.clear()
  206. CITY_TO_PROVINCES_MAP.clear()
  207. if not os.path.exists(CITY_JSON_PATH):
  208. logger.error(f"❌ 城市JSON文件不存在:{CITY_JSON_PATH}")
  209. return
  210. try:
  211. with open(CITY_JSON_PATH, "r", encoding="utf-8") as f:
  212. data = json.load(f)
  213. for province_item in data:
  214. p_name = province_item['name']
  215. PROVINCE_ID_MAP[p_name] = province_item['id']
  216. for city_item in province_item.get('sons', []):
  217. c_name = city_item['name']
  218. CITY_ID_MAP[(p_name, c_name)] = city_item['id']
  219. CITY_TO_PROVINCES_MAP.setdefault(c_name, set()).add(p_name)
  220. logger.info(f"✅ 城市映射加载完成,共 {len(PROVINCE_ID_MAP)} 个省份,{len(CITY_ID_MAP)} 个城市")
  221. except Exception as e:
  222. logger.error(f"❌ 加载城市JSON失败:{str(e)}")
  223. def _clean_province_name(name: str) -> str:
  224. return (name or "").replace("省", "").replace("市", "").replace("自治区", "").replace("特别行政区", "").strip()
  225. def _clean_city_name(name: str) -> str:
  226. return (name or "").replace("市", "").replace("自治州", "").replace("地区", "").replace("盟", "").strip()
  227. def normalize_province_city_names(province_name: str, city_name: str):
  228. province = (province_name or "").strip()
  229. city = (city_name or "").strip()
  230. if province and province not in DIRECT_MUNICIPALITIES and province not in PROVINCE_ID_MAP:
  231. clean_p = _clean_province_name(province)
  232. for standard_name in PROVINCE_ID_MAP.keys():
  233. if clean_p and clean_p == _clean_province_name(standard_name):
  234. province = standard_name
  235. break
  236. if not province and city:
  237. matched_provinces = CITY_TO_PROVINCES_MAP.get(city, set())
  238. if not matched_provinces:
  239. clean_c = _clean_city_name(city)
  240. if clean_c:
  241. matched_provinces = {
  242. p_name
  243. for (p_name, c_name) in CITY_ID_MAP.keys()
  244. if _clean_city_name(c_name) == clean_c
  245. }
  246. if len(matched_provinces) == 1:
  247. province = next(iter(matched_provinces))
  248. elif len(matched_provinces) > 1:
  249. logger.warning(f"⚠️ 城市名存在跨省重名,无法唯一反推省份: city={city}, candidates={sorted(matched_provinces)}")
  250. if province in DIRECT_MUNICIPALITY_BASE_NAMES:
  251. province = DIRECT_MUNICIPALITY_ALIAS[province]
  252. if province and city and (province, city) not in CITY_ID_MAP:
  253. clean_c = _clean_city_name(city)
  254. for (p_name, c_name), _ in CITY_ID_MAP.items():
  255. if _clean_province_name(p_name) == _clean_province_name(province) and clean_c and _clean_city_name(c_name) == clean_c:
  256. city = c_name
  257. break
  258. if province in DIRECT_MUNICIPALITIES and not city:
  259. city = province
  260. return province, city
  261. def get_province_city_ids(province_name, city_name):
  262. province_name, city_name = normalize_province_city_names(province_name, city_name)
  263. province_id = PROVINCE_ID_MAP.get(province_name) if province_name else None
  264. if province_name and province_id is None:
  265. clean_p = _clean_province_name(province_name)
  266. for name, pid in PROVINCE_ID_MAP.items():
  267. if clean_p and clean_p == _clean_province_name(name):
  268. province_id = pid
  269. province_name = name
  270. break
  271. if province_id is None:
  272. logger.warning(f"⚠️ 未找到省份ID: {province_name}")
  273. province_id = 0
  274. elif province_id is None:
  275. province_id = 0
  276. if _clean_province_name(province_name) in DIRECT_MUNICIPALITY_BASE_NAMES and not city_name:
  277. city_name = f"{_clean_province_name(province_name)}市"
  278. city_id = CITY_ID_MAP.get((province_name, city_name)) if province_name and city_name else None
  279. if province_name and city_name and city_id is None:
  280. clean_c = _clean_city_name(city_name)
  281. for (p_name, c_name), cid in CITY_ID_MAP.items():
  282. if p_name == province_name:
  283. if clean_c and clean_c == _clean_city_name(c_name):
  284. city_id = cid
  285. city_name = c_name
  286. break
  287. if city_id is None:
  288. if _clean_province_name(province_name) in DIRECT_MUNICIPALITY_BASE_NAMES and province_id:
  289. city_id = province_id
  290. else:
  291. logger.warning(f"⚠️ 未找到城市ID: {province_name} - {city_name}")
  292. city_id = 0
  293. elif city_id is None:
  294. city_id = 0
  295. return province_id, city_id
  296. def extract_province_city(address):
  297. if not address:
  298. return "", ""
  299. province_pattern = re.compile(r'([^省]+省|.+自治区|北京市|上海市|天津市|重庆市|.+特别行政区)')
  300. province_match = province_pattern.search(address)
  301. province = province_match.group(1) if province_match else ""
  302. address_remain = address.replace(province, "").strip() if province else address.strip()
  303. city_pattern = re.compile(r'([^市]+市|.+自治州|.+地区|.+盟|^[^\d区县镇]+)')
  304. city_match = city_pattern.search(address_remain)
  305. city = city_match.group(1).strip() if city_match else ""
  306. if province in ["北京市", "上海市", "天津市", "重庆市"]:
  307. city = province
  308. if not province and not city:
  309. simple_pattern = re.compile(r'^([^\d区县镇]+)')
  310. simple_match = simple_pattern.search(address)
  311. if simple_match:
  312. city = simple_match.group(1).strip()
  313. if city and province and city != province and province in city:
  314. city = city.replace(province, "").strip()
  315. province, city = normalize_province_city_names(province, city)
  316. return province.strip(), city.strip()
  317. # ==================== SECTION 5: ANTI-CRAWL UTILITIES ====================
  318. def get_current_time():
  319. return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  320. def random_delay(min_seconds, max_seconds):
  321. delay = random.uniform(min_seconds, max_seconds)
  322. time.sleep(delay)
  323. return delay
  324. def save_cookies(context, cookie_path=COOKIE_FILE_PATH):
  325. try:
  326. cookies = context.cookies()
  327. with open(cookie_path, "w", encoding="utf-8") as f:
  328. json.dump(cookies, f, ensure_ascii=False, indent=2)
  329. logger.info(f"Cookie已保存到:{cookie_path}")
  330. return True
  331. except Exception as e:
  332. logger.error(f" 保存Cookie失败:{e}")
  333. return False
  334. def load_cookies(context, cookie_path=COOKIE_FILE_PATH):
  335. if not os.path.exists(cookie_path):
  336. logger.warning(f" Cookie文件不存在:{cookie_path}")
  337. return False
  338. try:
  339. with open(cookie_path, "r", encoding="utf-8") as f:
  340. cookies = json.load(f)
  341. context.add_cookies(cookies)
  342. logger.info(f"✅ 已从{cookie_path}加载Cookie")
  343. return True
  344. except Exception as e:
  345. logger.error(f" 加载Cookie失败:{e}")
  346. return False
  347. def slow_scroll_400px(page, scroll_distance1=400):
  348. try:
  349. scroll_distance = random.randint(scroll_distance1 - SCROLL_OFFSET_RANGE, scroll_distance1 + SCROLL_OFFSET_RANGE)
  350. remaining_distance = scroll_distance
  351. total_steps = int(scroll_distance / SCROLL_STEP)
  352. logger.info(f"📜 开始慢速滚动(目标距离:{scroll_distance}px,总步数:{total_steps},总时长约{total_steps*SCROLL_INTERVAL:.2f}秒)")
  353. for _ in range(total_steps):
  354. step = min(SCROLL_STEP, remaining_distance)
  355. page.evaluate(f"window.scrollBy(0, {step});")
  356. remaining_distance -= step
  357. time.sleep(SCROLL_INTERVAL)
  358. if remaining_distance > 0:
  359. page.evaluate(f"window.scrollBy(0, {remaining_distance});")
  360. time.sleep(SCROLL_INTERVAL)
  361. page.wait_for_load_state("networkidle", timeout=8000)
  362. random_delay(2.0, 3.0)
  363. logger.info(f" 慢速滚动完成,实际滚动距离:{scroll_distance - remaining_distance}px")
  364. return True
  365. except Exception as e:
  366. logger.warning(f" 慢速滚动失败:{e}")
  367. return False
  368. def type_slow(locator, text: str, min_delay=0.06, max_delay=0.18):
  369. for ch in text:
  370. locator.type(ch, delay=int(random.uniform(min_delay, max_delay) * 1000))
  371. def kill_masks(page):
  372. page.evaluate(r"""
  373. () => {
  374. const knownSelectors = [
  375. '.v-modal', '.el-overlay', '.el-overlay-dialog', '.el-dialog__wrapper',
  376. '.el-message-box__wrapper', '.el-loading-mask', '.el-popup-parent--hidden'
  377. ];
  378. for (const sel of knownSelectors) {
  379. document.querySelectorAll(sel).forEach(el => el.remove());
  380. }
  381. const all = Array.from(document.querySelectorAll('body *'));
  382. for (const el of all) {
  383. const s = window.getComputedStyle(el);
  384. if (!s) continue;
  385. const z = parseInt(s.zIndex || '0', 10);
  386. if ((s.position === 'fixed' || s.position === 'absolute') && z >= 1000 && s.pointerEvents !== 'none') {
  387. const r = el.getBoundingClientRect();
  388. const nearFullScreen = r.width >= window.innerWidth * 0.8 && r.height >= window.innerHeight * 0.8;
  389. if (nearFullScreen) {
  390. el.style.pointerEvents = 'none';
  391. el.style.display = 'none';
  392. }
  393. }
  394. }
  395. document.documentElement.style.overflow = 'auto';
  396. document.body.style.overflow = 'auto';
  397. document.body.style.position = 'static';
  398. document.body.style.width = 'auto';
  399. document.body.style.paddingRight = '0px';
  400. document.body.classList.remove('el-popup-parent--hidden');
  401. }
  402. """)
  403. def force_close_popup(page):
  404. try:
  405. for _ in range(5):
  406. btn = page.locator(
  407. "//button[normalize-space()='下一步' or normalize-space()='完成' or normalize-space()='我知道了' or normalize-space()='关闭']"
  408. ).first
  409. if btn.count() > 0 and btn.is_visible():
  410. btn.click(timeout=1500)
  411. page.wait_for_timeout(300)
  412. continue
  413. close_icon = page.locator(
  414. "xpath=//*[contains(@class,'close') or contains(@class,'el-icon-close') or name()='svg' or name()='i'][1]"
  415. ).first
  416. if close_icon.count() > 0 and close_icon.is_visible():
  417. close_icon.click(timeout=1000)
  418. page.wait_for_timeout(300)
  419. continue
  420. break
  421. page.evaluate("""
  422. const selectors = [
  423. '.v-modal', '.el-overlay', '.el-overlay-dialog', '.el-dialog__wrapper',
  424. '[class*="mask"]', '[class*="overlay"]', '[style*="z-index"]'
  425. ];
  426. for (const sel of selectors) {
  427. document.querySelectorAll(sel).forEach(el => {
  428. const s = window.getComputedStyle(el);
  429. if ((s.position === 'fixed' || s.position === 'absolute') && parseInt(s.zIndex || '0', 10) >= 1000) {
  430. el.remove();
  431. }
  432. });
  433. }
  434. """)
  435. except Exception:
  436. pass
  437. def popup_guard(page, tag=""):
  438. try:
  439. page.wait_for_timeout(300)
  440. for _ in range(6):
  441. btn = page.locator(
  442. "xpath=//button[normalize-space()='下一步' or normalize-space()='完成' or normalize-space()='我知道了' or normalize-space()='关闭']"
  443. ).first
  444. if btn.count() > 0 and btn.is_visible():
  445. btn.click(timeout=1500)
  446. page.wait_for_timeout(250)
  447. continue
  448. close_btn = page.locator(
  449. "css=.el-dialog__headerbtn, .el-message-box__headerbtn, .close, .icon-close, .el-icon-close"
  450. ).first
  451. if close_btn.count() > 0 and close_btn.is_visible():
  452. close_btn.click(timeout=1200)
  453. page.wait_for_timeout(250)
  454. continue
  455. break
  456. page.evaluate(r"""
  457. () => {
  458. const selectors = [
  459. '.v-modal', '.el-overlay', '.el-overlay-dialog', '.el-dialog__wrapper',
  460. '.el-message-box__wrapper', '.el-loading-mask'
  461. ];
  462. selectors.forEach(sel => document.querySelectorAll(sel).forEach(e => e.remove()));
  463. const all = Array.from(document.querySelectorAll('body *'));
  464. for (const el of all) {
  465. const s = getComputedStyle(el);
  466. const z = parseInt(s.zIndex || '0', 10);
  467. if ((s.position === 'fixed' || s.position === 'absolute') && z >= 1000 && s.pointerEvents !== 'none') {
  468. const r = el.getBoundingClientRect();
  469. const nearFull = r.width >= innerWidth * 0.8 && r.height >= innerHeight * 0.8;
  470. if (nearFull) {
  471. el.style.pointerEvents = 'none';
  472. el.style.display = 'none';
  473. }
  474. }
  475. }
  476. document.documentElement.style.overflow = 'auto';
  477. document.body.style.overflow = 'auto';
  478. document.body.classList.remove('el-popup-parent--hidden');
  479. }
  480. """)
  481. logger.info("杀除弹窗成功")
  482. except Exception:
  483. pass
  484. # ==================== SECTION 6: OCR HELPERS ====================
  485. def compress_image(image_data, max_size=4*1024*1024):
  486. try:
  487. img = Image.open(BytesIO(image_data))
  488. if img.mode in ('RGBA', 'P'):
  489. bg_img = Image.new('RGB', img.size, (255, 255, 255))
  490. bg_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
  491. img = bg_img
  492. if img.width > 1000:
  493. ratio = 1000 / img.width
  494. img = img.resize((int(img.width * ratio), int(img.height * ratio)), Image.Resampling.LANCZOS)
  495. output = BytesIO()
  496. img.save(output, format='JPEG', quality=80)
  497. compressed_data = output.getvalue()
  498. if len(compressed_data) > max_size:
  499. output2 = BytesIO()
  500. img.save(output2, format='JPEG', quality=60)
  501. compressed_data = output2.getvalue()
  502. return compressed_data
  503. except Exception as e:
  504. logger.debug(f"图片压缩失败:{e}")
  505. return image_data
  506. def download_image_to_base64(image_url, save_dir="./download_images"):
  507. try:
  508. if not os.path.exists(save_dir):
  509. os.makedirs(save_dir)
  510. except Exception as e:
  511. print(f"创建保存目录失败:{str(e)}")
  512. return None
  513. try:
  514. headers = {
  515. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  516. }
  517. response = requests.get(image_url, headers=headers, timeout=15)
  518. response.raise_for_status()
  519. compressed_data = compress_image(response.content)
  520. image_base64 = base64.b64encode(compressed_data).decode("utf-8")
  521. image_data = compressed_data
  522. file_name = image_url.split("/")[-1]
  523. file_name = file_name.replace("?", "").replace("&", "").replace("=", "")
  524. save_path = os.path.join(save_dir, file_name)
  525. with open(save_path, "wb") as f:
  526. f.write(image_data)
  527. print(f"图片已保存到本地:{save_path}")
  528. return image_base64
  529. except requests.exceptions.Timeout:
  530. print(f"下载图片超时:{image_url}")
  531. return None
  532. except requests.exceptions.HTTPError as e:
  533. code = e.response.status_code if e.response is not None else "?"
  534. logger.warning("下载图片 HTTP 错误 url=%s status=%s", image_url, code)
  535. return None
  536. except Exception as e:
  537. print(f"下载图片失败:{str(e)}")
  538. return None
  539. def get_access_token():
  540. url = f"{token_url_config}?grant_type=client_credentials&client_id={AppKey_config}&client_secret={AppSecret_config}"
  541. headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
  542. try:
  543. response = requests.request("POST", url, headers=headers, data="")
  544. response.raise_for_status()
  545. return response.json()['access_token']
  546. except Exception as e:
  547. print(f"获取access_token失败:{str(e)}")
  548. return None
  549. def get_ocr_res(img):
  550. try:
  551. print(f'开始识别图片:{img}')
  552. img_base64 = download_image_to_base64(img)
  553. if not img_base64:
  554. print("图片下载/转Base64失败,终止OCR识别")
  555. return None
  556. access_token = get_access_token()
  557. if not access_token:
  558. print("获取access_token失败,无法调用OCR接口")
  559. return None
  560. request_url = request_url_config + "?access_token=" + access_token
  561. headers = {'content-type': 'application/x-www-form-urlencoded'}
  562. response = requests.post(request_url, data={"image": img_base64}, headers=headers)
  563. if response:
  564. res = response.json()
  565. if "error_code" in res:
  566. print(f"百度OCR接口错误:{res['error_msg']}(错误码:{res['error_code']})")
  567. return None
  568. new_dic = {}
  569. for ite in res['words_result'].keys():
  570. new_dic[ite] = res['words_result'][ite]['words']
  571. print('资质数据信息', new_dic)
  572. return new_dic
  573. else:
  574. print("OCR接口返回空响应")
  575. return None
  576. except requests.exceptions.RequestException as e:
  577. print(f"网络错误(图片下载/OCR请求失败):{str(e)}")
  578. return None
  579. except KeyError as e:
  580. print(f"OCR响应格式异常,缺失字段:{str(e)}")
  581. return None
  582. except Exception as e:
  583. print(f"OCR识别未知错误:{str(e)}")
  584. return None
  585. # ==================== SECTION 7: SHOP HELPERS ====================
  586. def clean_shop_name(raw_shop_name):
  587. if not raw_shop_name:
  588. return ''
  589. pattern = r'【.*?】|\(.*?\)|\[.*?\]'
  590. cleaned = re.sub(pattern, '', raw_shop_name)
  591. cleaned = cleaned.strip().replace('\n', '').replace('\r', '')
  592. cleaned = re.sub(r'\s+', ' ', cleaned)
  593. return cleaned if cleaned else raw_shop_name
  594. def shop_is_exists_database(shop):
  595. query_sql = """
  596. SELECT province, city, business_license_company, qualification_number, business_license_address
  597. FROM retrieve_scrape_shop_info
  598. WHERE shop = %s and platform = %s
  599. """
  600. try:
  601. rows = mysql_pool.select_data(query_sql, (shop, 9))
  602. result = rows[0] if rows else None
  603. logger.debug("店铺存在校验 shop=%r result=%s", shop, result)
  604. is_exists = bool(result)
  605. if is_exists:
  606. logger.info(f"【店铺存在校验】店铺已存在 | 店铺名:{repr(shop)} | 结果:存在(True)不要执行采集店铺")
  607. else:
  608. logger.info(f"【店铺存在校验】店铺不存在 | 店铺名:{repr(shop)} | 结果:不存在(False)")
  609. return is_exists, result
  610. except Exception as e:
  611. logger.error(f"查询店铺失败:{e}")
  612. return False, None
  613. def insert_shop_info_to_db(shop, contact_address, qualification_number, business_license_company,
  614. business_license_address, scrape_date, platform, province, city, create_time, update_time):
  615. sql = """
  616. INSERT INTO retrieve_scrape_shop_info (
  617. shop, contact_address, qualification_number, business_license_company,
  618. business_license_address, scrape_date, platform, province, city, create_time, update_time
  619. ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  620. ON DUPLICATE KEY UPDATE
  621. contact_address = VALUES(contact_address),
  622. qualification_number = VALUES(qualification_number),
  623. business_license_company = VALUES(business_license_company),
  624. business_license_address = VALUES(business_license_address),
  625. scrape_date = VALUES(scrape_date),
  626. platform = VALUES(platform),
  627. province = VALUES(province),
  628. city = VALUES(city),
  629. update_time = VALUES(update_time)
  630. """
  631. params = (shop, contact_address, qualification_number, business_license_company,
  632. business_license_address, scrape_date, platform, province, city, create_time, update_time)
  633. conn = None
  634. cursor = None
  635. try:
  636. conn = mysql_pool.get_conn()
  637. cursor = conn.cursor()
  638. cursor.execute(sql, params)
  639. conn.commit()
  640. logger.info("店铺信息写入成功 shop=%s company=%s", shop, business_license_company)
  641. return True
  642. except Exception as e:
  643. logger.error("插入店铺失败:%s\n%s", e, traceback.format_exc())
  644. if conn:
  645. conn.rollback()
  646. return False
  647. finally:
  648. if cursor:
  649. cursor.close()
  650. if conn:
  651. conn.close()
  652. # ==================== SECTION 8: YbmCrawler CLASS ====================
  653. class YbmCrawler:
  654. """药帮忙平台爬虫 —— 遵循 commons1 调度框架。
  655. 由 CollectScheduleRunner 调用:
  656. crawler = YbmCrawler(task_dict)
  657. count, is_success = crawler.run()
  658. """
  659. def __init__(self, drug_dict=None):
  660. self.task_dict = drug_dict or {}
  661. self.platform_id = PLATFORM_ID
  662. self.username = YBM_DEVICE_ID
  663. self.platform_name = '药帮忙'
  664. # Pipeline(商品数据走 DrugPipeline,做去重入库)
  665. self.pipeline = DrugPipeline("ybm")
  666. self.is_success = True
  667. # 由 CrawlerScheduler 管理心跳和停止信号
  668. self.scheduler = CrawlerScheduler(YBM_DEVICE_ID, str(PLATFORM_ID), heartbeat_interval=HEARTBEAT_INTERVAL_SECONDS)
  669. if self.task_dict:
  670. self._extract_task_params()
  671. # ---------- task params ----------
  672. def _extract_task_params(self):
  673. t = self.task_dict
  674. self.task_id = t.get("id", 0)
  675. self.collect_task_id = t.get("collect_task_id", 0)
  676. self.company_id = t.get("company_id", 0)
  677. self.product_name = t.get("product_name", "")
  678. self.product_specs = t.get("product_specs", "")
  679. self.product_keyword = t.get("product_keyword", "")
  680. self.product_brand = t.get("product_brand", "")
  681. self.sampling_cycle = t.get("sampling_cycle", 1)
  682. self.sampling_start_time = t.get("sampling_start_time", 0)
  683. self.sampling_end_time = t.get("sampling_end_time", 0)
  684. self.collect_equipment_account_id = t.get("collect_equipment_account_id", 0)
  685. self.collect_region_id = t.get("collect_region_id", 0)
  686. self.collect_round = t.get("collect_round", 1)
  687. self.api_current_page = t.get("current_page", 0) or 0
  688. self.keyword = self.product_brand + self.product_name
  689. self.collect_config_info = json.dumps({
  690. "sampling_cycle": self.sampling_cycle,
  691. "sampling_start_time": self.sampling_start_time,
  692. "sampling_end_time": self.sampling_end_time,
  693. }, ensure_ascii=False, default=str)
  694. # ---------- login ----------
  695. def _is_login(self, page):
  696. try:
  697. page.goto(LOGIN_VALIDATE_URL, timeout=300000)
  698. page.wait_for_load_state("networkidle")
  699. if "login" in page.url.lower():
  700. logger.warning(" Cookie失效,需要重新登录")
  701. return False
  702. logger.info(" Cookie有效,已保持登录状态")
  703. return True
  704. except Exception as e:
  705. logger.error(f" 验证登录状态失败:{e}")
  706. return False
  707. def _login_operation(self, page):
  708. try:
  709. page.wait_for_selector(USERNAME_SELECTOR, timeout=ELEMENT_TIMEOUT, state="visible")
  710. page.wait_for_timeout(timeout=3000)
  711. page.fill(USERNAME_SELECTOR, USERNAME)
  712. logger.info(" 已输入登录账号")
  713. page.wait_for_selector(PASSWORD_SELECTOR, timeout=ELEMENT_TIMEOUT, state="visible")
  714. page.wait_for_timeout(timeout=3000)
  715. page.fill(PASSWORD_SELECTOR, PASSWORD)
  716. logger.info(" 已输入登录密码")
  717. random_delay(1, 2)
  718. agree_btn = page.locator('span.el-checkbox__inner')
  719. agree_btn.click()
  720. page.wait_for_selector(LOGIN_BTN_SELECTOR, timeout=ELEMENT_TIMEOUT)
  721. page.wait_for_timeout(timeout=3000)
  722. page.click(LOGIN_BTN_SELECTOR)
  723. logger.info(" 已点击登录按钮")
  724. page.wait_for_timeout(LOGIN_AFTER_CLICK)
  725. return True
  726. except PlaywrightTimeoutError as e:
  727. logger.error(f" 登录失败:元素定位超时 - {str(e)}")
  728. return False
  729. except Exception as e:
  730. logger.error(f" 登录异常:{str(e)}")
  731. return False
  732. # ---------- search ----------
  733. @staticmethod
  734. def _pick_search_input(page):
  735. inputs = page.locator(SEARCH_INPUT_SELECTOR)
  736. cnt = inputs.count()
  737. for i in range(min(cnt, 2)):
  738. candidate = inputs.nth(i)
  739. try:
  740. candidate.wait_for(state="visible", timeout=1500)
  741. if candidate.is_enabled():
  742. return candidate
  743. except PlaywrightTimeoutError:
  744. continue
  745. candidate = page.locator(f"{SEARCH_INPUT_SELECTOR}:visible").first
  746. candidate.wait_for(state="visible", timeout=ELEMENT_TIMEOUT)
  747. return candidate
  748. def _search_operation(self, page, keyword, is_first_search=True):
  749. try:
  750. search_locator = self._pick_search_input(page)
  751. search_locator.wait_for(timeout=ELEMENT_TIMEOUT)
  752. search_locator.click(force=True)
  753. search_locator.fill("")
  754. page.keyboard.down("Control")
  755. page.keyboard.press("a")
  756. page.keyboard.up("Control")
  757. page.keyboard.press("Backspace")
  758. type_slow(search_locator, keyword, min_delay=0.06, max_delay=0.18)
  759. logger.info(f"📝 已输入搜索关键词:{keyword}")
  760. btn = page.locator(SEARCH_BTN_SELECTOR)
  761. btn.wait_for(state="visible", timeout=SEARCH_BTN_TIMEOUT)
  762. page.wait_for_timeout(3000)
  763. detail_page = page
  764. if is_first_search:
  765. try:
  766. with page.context.expect_page(timeout=60000) as new_page_info:
  767. btn.click()
  768. detail_page = new_page_info.value
  769. detail_page.wait_for_load_state("networkidle", timeout=20000)
  770. except PlaywrightTimeoutError:
  771. logger.warning(f"{get_current_time()} 未检测到新标签页")
  772. return None, False
  773. except Exception as e:
  774. logger.warning(f"{get_current_time()} 等待新标签页异常:{e}")
  775. return None, False
  776. else:
  777. btn.click()
  778. page.wait_for_load_state("networkidle", timeout=20000)
  779. detail_page = page
  780. logger.info("✅ 后续搜索:已在原页面完成跳转加载")
  781. test_btn = detail_page.locator("div[data-v-c65c36bc].first-time-highlight-message-btn button")
  782. if test_btn.count() > 0:
  783. test_btn.wait_for(state="attached", timeout=5000)
  784. test_btn.click()
  785. force_close_popup(detail_page)
  786. kill_masks(detail_page)
  787. logger.info("✅ 已触发搜索")
  788. return detail_page, True
  789. except PlaywrightTimeoutError as e:
  790. logger.error(f" 搜索失败:元素定位超时 - {str(e)}")
  791. return None, False
  792. except Exception as e:
  793. logger.error(f" 搜索异常:{str(e)}")
  794. return None, False
  795. # ---------- pagination ----------
  796. @staticmethod
  797. def _goto_next_page(page) -> bool:
  798. try:
  799. next_btn = page.locator("button.btn-next").first
  800. next_btn.wait_for(state="attached", timeout=3000)
  801. aria_disabled = next_btn.get_attribute("aria-disabled")
  802. logger.info(f"下一页按钮 aria-disabled 属性值:{aria_disabled}")
  803. if aria_disabled == "true":
  804. logger.warning("⚠️ 下一页按钮 aria-disabled=true,已无更多页面")
  805. return False
  806. page.wait_for_timeout(500)
  807. if next_btn.is_visible() and next_btn.is_enabled():
  808. next_btn.click(timeout=5000)
  809. else:
  810. next_btn.click(force=True, timeout=5000)
  811. logger.info("✅ 翻页成功,下一页按钮 aria-disabled=false")
  812. return True
  813. except PlaywrightTimeoutError:
  814. logger.warning("⚠️ 下一页按钮加载超时,判定无更多页面")
  815. return False
  816. except Exception as e:
  817. logger.warning(f"⚠️ 翻页操作异常:{e},判定无更多页面")
  818. return False
  819. @staticmethod
  820. def _get_total_pages(page):
  821. """从分页组件提取总页数,失败返回 None"""
  822. try:
  823. total_elem = page.locator("div.filter-panel-tags-pagination-number-total").first
  824. total_elem.wait_for(state="attached", timeout=3000)
  825. if total_elem.count() > 0:
  826. text = total_elem.inner_text(timeout=2000).strip()
  827. total = int(text)
  828. logger.info(f"📊 检测到总页数:{total}")
  829. return total
  830. except PlaywrightTimeoutError:
  831. logger.warning("⚠️ 总页数元素加载超时,降级为盲翻")
  832. except (ValueError, TypeError) as e:
  833. logger.warning(f"⚠️ 总页数解析失败:{e},降级为盲翻")
  834. except Exception as e:
  835. logger.warning(f"⚠️ 获取总页数异常:{e},降级为盲翻")
  836. return None
  837. # ---------- shop license collection ----------
  838. @staticmethod
  839. def _collect_shop_license(target_page, shop):
  840. """采集店铺营业执照信息(OCR识别),返回 dict 或 None"""
  841. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  842. entershop_btn = target_page.locator('div[data-v-5485589c].shop-info-container-left-info')
  843. entershop_btn.wait_for(state="visible", timeout=10000)
  844. entershop_btn.scroll_into_view_if_needed()
  845. entershop_btn.hover()
  846. random_delay(0.2, 0.5)
  847. try:
  848. with target_page.expect_popup(timeout=15000) as pop:
  849. entershop_btn.click()
  850. random_delay(0.05, 0.15)
  851. shop_page = pop.value
  852. except PlaywrightTimeoutError:
  853. logger.warning("⚠️ 店铺弹窗未出现")
  854. return None
  855. shop_page.wait_for_load_state("domcontentloaded")
  856. store_url = shop_page.url
  857. logger.info(f"📌 获取到店铺链接:{store_url}")
  858. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  859. shop_license_page = shop_page.locator(
  860. '//div[contains(@class, "shop-info-container-right-btns-item") and contains(span, "资质/售后")]')
  861. shop_license_page.wait_for(state="attached", timeout=15000)
  862. shop_license_page.scroll_into_view_if_needed()
  863. shop_license_page.hover()
  864. random_delay(0.2, 0.5)
  865. shop_license_page.click()
  866. random_delay(0.05, 0.15)
  867. random_delay(0.05, 0.1)
  868. shop_page.wait_for_load_state("networkidle")
  869. shop_page.wait_for_load_state("load")
  870. shop_license_img = shop_page.locator(
  871. '//span[contains(text(), "企业营业执照") or contains(text(), "营业执照(正本)")]/ancestor::div[@class="shop-info-drawer-zz-tab1-list-item"]/img'
  872. ).first
  873. shop_license_img.wait_for(state="visible", timeout=60000)
  874. shop_license_src = None
  875. ocr_res = None
  876. try:
  877. if shop_license_img.count() > 0:
  878. shop_license_src = shop_license_img.get_attribute('src')
  879. shop_license_src = shop_license_src.strip() if shop_license_src else None
  880. ocr_res = get_ocr_res(shop_license_src)
  881. except Exception as e:
  882. logger.warning(f"提取营业执照图片src失败:{e}")
  883. logger.debug("营业执照图片链接:%s", shop_license_src)
  884. qualification_number = ocr_res.get('社会信用代码', '') if ocr_res else ''
  885. business_license_company = ocr_res.get('单位名称', '') if ocr_res else ''
  886. business_license_address = ocr_res.get('地址', '') if ocr_res else ''
  887. province, city = extract_province_city(business_license_address)
  888. logger.info(f"原始地址:{business_license_address}")
  889. logger.info(f"提取的省份:{province} | 城市:{city}")
  890. current_time = datetime.now().strftime("%Y-%m-%d")
  891. insert_shop_info_to_db(
  892. shop=shop, contact_address=store_url,
  893. qualification_number=qualification_number,
  894. business_license_company=business_license_company,
  895. business_license_address=business_license_address,
  896. scrape_date=current_time, platform='药帮忙',
  897. province=province, city=city,
  898. create_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  899. update_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  900. )
  901. return {
  902. "shop_page": shop_page,
  903. "store_url": store_url,
  904. "province": province,
  905. "city": city,
  906. "business_license_company": business_license_company,
  907. "qualification_number": qualification_number,
  908. "business_license_address": business_license_address,
  909. }
  910. # ---------- core collection ----------
  911. def _collect_data(self, store_page):
  912. """核心采集循环:遍历翻页 → 逐商品采集 → 走 DrugPipeline 入库"""
  913. brand = self.product_brand
  914. name = self.product_name
  915. keyword = self.keyword
  916. collect_result = []
  917. logger.info(f"📊 开始采集「{keyword}」的商品数据")
  918. store_page.wait_for_load_state("networkidle")
  919. page_no = 1
  920. start_page = self.api_current_page + 1 if self.api_current_page > 0 else 1
  921. # 任务续爬:翻到起始页
  922. if start_page > 1:
  923. logger.info(f"⏩ 任务续爬:从第 {start_page} 页开始,正在翻页...")
  924. while page_no < start_page:
  925. if self.scheduler.end:
  926. logger.warning("⛔ 翻页过程中收到停止信号,终止采集")
  927. return collect_result, page_no
  928. if self._goto_next_page(store_page):
  929. page_no += 1
  930. store_page.wait_for_load_state("networkidle")
  931. logger.info(f"⏩ 已翻到第 {page_no} 页")
  932. else:
  933. logger.warning(f"⚠️ 无法翻到第 {start_page} 页(当前仅 {page_no} 页),从当前页开始采集")
  934. break
  935. total_pages = 0
  936. while True:
  937. if self.scheduler.end:
  938. logger.warning("⛔ 收到停止信号,终止采集")
  939. break
  940. logger.info(f"\n📄 「{keyword}」开始采集第 {page_no} 页")
  941. list_page_url = store_page.url
  942. logger.info(f"📌 已记录商品列表页URL:{list_page_url}")
  943. store_page.wait_for_load_state("domcontentloaded")
  944. store_page.wait_for_load_state("networkidle")
  945. store_page.wait_for_timeout(500)
  946. # 首次提取总页数
  947. if total_pages == 0:
  948. extracted_total = self._get_total_pages(store_page)
  949. if extracted_total is not None:
  950. total_pages = extracted_total
  951. # 读到总页数立刻回报进度
  952. prog_status = self.scheduler.report_progress(self.task_id, page_no, total_pages=total_pages)
  953. if prog_status == "limit_reached":
  954. logger.warning("⛔ 进度上报检测到限额,通知停止")
  955. self.scheduler.set_flag(True)
  956. total_limit = store_page.locator(PRODUCT_ITEM_SELECTOR).count()
  957. logger.info(f"📌 「{keyword}」第{page_no}页 初始商品个数(count):{total_limit}")
  958. collected_count = 0
  959. not_found_keywords = store_page.locator("div.filter-panel-container-empty-text")
  960. if not_found_keywords.count() > 0:
  961. logger.warning(f"⚠️ 关键词「{keyword}」无匹配商品,直接跳过整个关键词采集")
  962. return [], 0
  963. for idx in range(total_limit):
  964. if self.scheduler.end:
  965. logger.warning("⛔ 收到停止信号,终止当前页采集")
  966. break
  967. detail_page = None
  968. try:
  969. item = store_page.locator(PRODUCT_ITEM_SELECTOR).nth(idx)
  970. collected_count += 1
  971. store_page.wait_for_load_state("networkidle")
  972. delay = random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  973. logger.info(f"📌 「{keyword}」第{page_no}页 第{collected_count}/{total_limit}个商品 - 等待{delay:.2f}秒后采集(反爬)")
  974. # ---- 初始化默认值 ----
  975. title = ""
  976. shop = ""
  977. expiry_date = "无有效期"
  978. manufacture_date = "无生产日期"
  979. approval_number = "无批准文号"
  980. manufacturer = "未知公司"
  981. spec = "未知规格"
  982. current_time = datetime.now().strftime("%Y-%m-%d")
  983. is_sold_out = 0
  984. business_license_address = ''
  985. # 售罄
  986. sold_locator = item.locator('div.product-status')
  987. if sold_locator.count() > 0:
  988. is_sold_out = 1
  989. logger.warning(f" 「{keyword}」第{page_no}页 第{collected_count}个商品已售罄")
  990. # 商品ID (platform_item_id)
  991. product_id = ''
  992. product_id_elem = item.locator('div.product-card[data-product-id]')
  993. if product_id_elem.count() > 0:
  994. product_id = product_id_elem.get_attribute("data-product-id")
  995. # 标题
  996. product_locator = item.locator(PRODUCT_TITLE_SELECTOR)
  997. if product_locator.count() > 0:
  998. title = product_locator.inner_text(timeout=3000).strip()
  999. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页标题:{title}{'='*10}")
  1000. else:
  1001. logger.warning(f" 「{keyword}」第{collected_count}个商品 - 列表页标题元素未找到")
  1002. # 品牌/名称筛选
  1003. if not (brand in title and name in title):
  1004. logger.warning(f" 「{keyword}」第{collected_count}个商品 - 标题「{title}」不包含品牌「{brand}」、名称「{name}」,跳过")
  1005. continue
  1006. # 价格
  1007. price_int = item.locator('//span[@class="price-int"]').text_content().strip()
  1008. price_decimal_elem = item.locator('//span[@class="price-decimal"]')
  1009. price_decimal = price_decimal_elem.text_content().strip() if price_decimal_elem.count() > 0 else ''
  1010. full_price = f"{price_int}{price_decimal}".strip()
  1011. try:
  1012. full_price_num = float(full_price)
  1013. except ValueError:
  1014. full_price_num = 0.0
  1015. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 列表页价格无法解析 raw={full_price!r},按 0 处理")
  1016. logger.info(f"✅ 提取到价格:{full_price_num}")
  1017. # 公司名
  1018. manufacturer_locator = item.locator(PRODUCT_COMPANY_SELECTOR)
  1019. if manufacturer_locator.count() > 0:
  1020. manufacturer = manufacturer_locator.inner_text(timeout=3000).strip()
  1021. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页公司名:{manufacturer}{'='*10}")
  1022. else:
  1023. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 列表页公司名称元素未找到")
  1024. # 店铺名
  1025. shop_locator = item.locator(PRODUCT_STORE_SELECTOR)
  1026. if shop_locator.count() > 0:
  1027. raw_shop = shop_locator.inner_text(timeout=3000).strip()
  1028. shop = clean_shop_name(raw_shop)
  1029. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 列表页店名:{shop}{'='*10}")
  1030. else:
  1031. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 列表页店铺名称元素未找到")
  1032. # 折扣价
  1033. discount_price_locator = item.locator('span[data-v-4cb6cc1f].discount-int').first
  1034. if discount_price_locator.count() > 0:
  1035. discount_price_val_origin = discount_price_locator.inner_text(timeout=3000).strip()
  1036. match = re.search(r'\d+\.?\d*', str(discount_price_val_origin))
  1037. discount_price_val = float(match.group()) if match else 0.00
  1038. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页折扣价:{discount_price_val}{'='*10}")
  1039. else:
  1040. discount_price_val = full_price_num
  1041. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 折扣价元素未找到,使用采购价兜底:{discount_price_val}")
  1042. # 有效期
  1043. expiry_date_locator = item.locator(PRODUCT_VALIDITY_SELECTOR)
  1044. if expiry_date_locator.count() > 0:
  1045. expiry_date = expiry_date_locator.inner_text(timeout=3000).strip().replace('-', '')
  1046. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页有效期:{expiry_date}{'='*10}")
  1047. else:
  1048. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 有效期元素未找到")
  1049. # ---- 点击进入详情页 ----
  1050. logger.info(f"📌 「{keyword}」第{page_no}页 第{collected_count}个商品「{title}」- 模拟鼠标移动并点击")
  1051. item.hover()
  1052. random_delay(0.2, 0.5)
  1053. item.dispatch_event("mousedown")
  1054. random_delay(0.05, 0.15)
  1055. item.dispatch_event("mouseup")
  1056. random_delay(0.05, 0.1)
  1057. try:
  1058. with store_page.context.expect_page(timeout=60000) as p:
  1059. item.click(delay=random.uniform(0.1, 0.3))
  1060. detail_page = p.value
  1061. except PlaywrightTimeoutError:
  1062. logger.warning(f" 「{keyword}」第{page_no}页 第{collected_count}个商品「{title}」- 未检测到新标签页,使用当前页采集详情")
  1063. detail_page = None
  1064. target_page = detail_page if detail_page else store_page
  1065. target_page.wait_for_load_state("networkidle", timeout=20000)
  1066. delay = random_delay(MIN_PAGE_DELAY, MAX_PAGE_DELAY)
  1067. logger.info(f"📌 「{keyword}」第{page_no}页 第{collected_count}个商品「{title}」- 详情页加载完成,等待{delay:.2f}秒(反爬)")
  1068. # 详情页链接
  1069. product_link = target_page.url
  1070. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页链接:{product_link}{'='*10}")
  1071. # 生产日期
  1072. manufacture_date_locator = target_page.locator(
  1073. '//div[contains(@class, "spec-info-item") and .//div[contains(@class, "spec-info-item-label") and normalize-space(.)="生产日期"]]//div[contains(@class, "spec-info-item-value-text")]')
  1074. if manufacture_date_locator.count() > 0:
  1075. manufacture_date = manufacture_date_locator.inner_text(timeout=3000).strip()
  1076. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页生产日期:{manufacture_date}{'='*10}")
  1077. else:
  1078. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 生产日期元素未找到")
  1079. # 批准文号
  1080. approval_number_locator = target_page.locator(
  1081. '//div[contains(@class, "spec-info-item") and .//div[contains(@class, "spec-info-item-label") and normalize-space(.)="批准文号"]]//div[contains(@class, "spec-info-item-value-text")]')
  1082. if approval_number_locator.count() > 0:
  1083. approval_number = approval_number_locator.inner_text(timeout=3000).strip()
  1084. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页批准文号:{approval_number}{'='*10}")
  1085. else:
  1086. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 批准文号元素未找到")
  1087. # 规格
  1088. spec_locator = target_page.locator(
  1089. '//div[contains(@class, "spec-info-item") and .//div[contains(@class, "spec-info-item-label") and normalize-space(.)="规格"]]//div[contains(@class, "spec-info-item-value-text")]')
  1090. if spec_locator.count() > 0:
  1091. spec = spec_locator.inner_text(timeout=3000).strip()
  1092. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页规格:{spec}{'='*10}")
  1093. else:
  1094. logger.warning(f" 「{keyword}」第{collected_count}个商品「{title}」- 规格元素数量不足")
  1095. # 库存
  1096. storage = ''
  1097. storage_locator = target_page.locator('[data-v-51f0e85d].detail-input-num-right-title')
  1098. if storage_locator.count() > 0:
  1099. storage = storage_locator.inner_text(timeout=3000).strip()
  1100. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页库存:{storage}{'='*10}")
  1101. # 销量
  1102. sell = ''
  1103. sell_locator = target_page.locator('div.detail-info-content-item-value-price-top-right div[data-v-95163d4a]', has_text='已售')
  1104. if sell_locator.count() > 0:
  1105. sell = sell_locator.inner_text(timeout=3000).strip()
  1106. logger.info(f"{'='*10}「{keyword}」第{collected_count}个商品 - 详情页销量:{sell}{'='*10}")
  1107. # 快照
  1108. oss_url = ""
  1109. try:
  1110. local_path, oss_url = screenshot_target_page_to_local_then_oss(target_page=target_page, full_page=True)
  1111. logger.info("详情页快照已上传 local=%s oss=%s", local_path, oss_url)
  1112. except Exception as e:
  1113. logger.warning("详情页快照上传失败:%s", e)
  1114. # ---- 店铺资质采集 ----
  1115. province = ""
  1116. city = ""
  1117. business_license_company = ""
  1118. qualification_number = ''
  1119. shop_exists, shop_info = shop_is_exists_database(shop)
  1120. shop_page = None
  1121. store_url = ''
  1122. if not shop_exists:
  1123. logger.info("数据库没有该店铺的营业执照,开始采集店铺资质")
  1124. license_info = self._collect_shop_license(target_page, shop)
  1125. if license_info:
  1126. shop_page = license_info["shop_page"]
  1127. store_url = license_info["store_url"]
  1128. province = license_info["province"]
  1129. city = license_info["city"]
  1130. business_license_company = license_info["business_license_company"]
  1131. qualification_number = license_info["qualification_number"]
  1132. business_license_address = license_info["business_license_address"]
  1133. else:
  1134. logger.info("数据库有该店名,在数据库拿取对应字段填充")
  1135. if shop_info:
  1136. province = shop_info.get("province", "") or ""
  1137. city = shop_info.get("city", "") or ""
  1138. business_license_company = shop_info.get("business_license_company", "") or ""
  1139. qualification_number = shop_info.get("qualification_number", "") or ""
  1140. business_license_address = shop_info.get("business_license_address", "") or ""
  1141. # 关闭店铺页
  1142. try:
  1143. if shop_page and not shop_page.is_closed():
  1144. random_delay(4, 8)
  1145. shop_page.close()
  1146. logger.info(f"📌 「{keyword}」第{collected_count}个商品 - 已关闭店铺页标签 shop_page")
  1147. except Exception as e:
  1148. logger.warning(f"⚠️ 关闭 shop_page 失败:{e}")
  1149. random_delay(5, 8)
  1150. # 关闭详情页,切回列表页
  1151. if detail_page and not detail_page.is_closed():
  1152. detail_page.close()
  1153. logger.info(f"📌 「{keyword}」第{collected_count}个商品 - 已关闭详情页标签页")
  1154. store_page.bring_to_front()
  1155. store_page.mouse.move(random.randint(100, 300), random.randint(200, 400))
  1156. random_delay(0.5, 1.0)
  1157. store_page.wait_for_load_state("networkidle")
  1158. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1159. logger.info(f" 「{keyword}」第{collected_count}个商品「{title}」- 已切回列表页")
  1160. random_delay(2, 4)
  1161. # 省市ID
  1162. province_id, city_id = get_province_city_ids(province, city)
  1163. # ---- 组装 product dict(兼容 DrugPipeline) ----
  1164. now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  1165. product = {
  1166. "platform": self.platform_id,
  1167. "item_id": product_id,
  1168. "enterprise_id": self.company_id,
  1169. "product_name": title,
  1170. "spec": spec,
  1171. "one_price": discount_price_val,
  1172. "detail_url": product_link,
  1173. "shop_name": shop,
  1174. "anonymous_store_name": "",
  1175. "shop_url": store_url,
  1176. "city_name": city,
  1177. "city_id": city_id,
  1178. "province_name": province,
  1179. "province_id": province_id,
  1180. "shipment_city_name": "",
  1181. "shipment_city_id": 0,
  1182. "shipment_province_name": "",
  1183. "shipment_province_id": 0,
  1184. "area_info": business_license_address,
  1185. "factory_name": manufacturer,
  1186. "scrape_date": current_time,
  1187. "price": discount_price_val,
  1188. "sales": sell,
  1189. "stock_count": storage,
  1190. "snapshot_url": oss_url,
  1191. "approval_num": approval_number,
  1192. "produced_time": manufacture_date,
  1193. "deadline": expiry_date,
  1194. "update_time": now_str,
  1195. "insert_time": now_str,
  1196. "number": 1,
  1197. "product_brand": brand,
  1198. "collect_task_id": self.collect_task_id,
  1199. "search_name": keyword,
  1200. "company_name": business_license_company,
  1201. "collect_config_info": self.collect_config_info,
  1202. "account_id": self.collect_equipment_account_id,
  1203. "collect_region_id": self.collect_region_id,
  1204. "collect_round": self.collect_round,
  1205. "is_sold_out": is_sold_out,
  1206. }
  1207. # 走 DrugPipeline 入库(含去重 + 店铺城市补全)
  1208. self.pipeline.storge_data(product)
  1209. collect_result.append(product)
  1210. logger.info(f" 「{keyword}」第{collected_count}个商品「{title}」采集完成")
  1211. except Exception as e:
  1212. logger.exception(f" 「{keyword}」第{collected_count}个商品采集核心异常:{str(e)}")
  1213. try:
  1214. if detail_page and not detail_page.is_closed():
  1215. detail_page.close()
  1216. if store_page and not store_page.is_closed():
  1217. store_page.bring_to_front()
  1218. store_page.wait_for_load_state("networkidle")
  1219. random_delay(MIN_CLICK_DELAY, MAX_CLICK_DELAY)
  1220. except Exception as e2:
  1221. logger.error(f" 「{keyword}」第{collected_count}个商品详情采集异常(处理时):{str(e2)},原异常:{str(e)}")
  1222. continue
  1223. # 每采6个商品滚动一次
  1224. if collected_count % 6 == 0 and collected_count > 0 and collected_count != total_limit:
  1225. logger.info("采满6个往下滑")
  1226. slow_scroll_400px(store_page)
  1227. store_page.wait_for_load_state("networkidle")
  1228. if self.scheduler.end:
  1229. break
  1230. delay = random_delay(1.5, 3.0)
  1231. logger.info(f"⏳ 翻页前随机等待 {delay:.2f}s(反爬)")
  1232. # 每页结束上报进度
  1233. prog_status = self.scheduler.report_progress(self.task_id, page_no, total_pages=total_pages)
  1234. if prog_status == "limit_reached":
  1235. logger.warning("⛔ 进度上报检测到限额,通知停止")
  1236. self.scheduler.set_flag(True)
  1237. # 翻页
  1238. if self._goto_next_page(store_page):
  1239. logger.info(f"「{keyword}」还有下一页")
  1240. page_no += 1
  1241. store_page.wait_for_load_state("networkidle")
  1242. total_limit = store_page.locator(PRODUCT_ITEM_SELECTOR).count()
  1243. logger.info(f"📌 「{keyword}」第{page_no}页 商品个数更新为:{total_limit}")
  1244. continue
  1245. else:
  1246. logger.info(f" 「{keyword}」已无下一页,关键词采集结束")
  1247. break
  1248. long_delay = random_delay(MIN_KEYWORD_DELAY, MAX_KEYWORD_DELAY)
  1249. logger.info(f" 「{keyword}」采集完成,共{len(collect_result)}条数据(pipeline 实际入库 {self.pipeline.crawl_count} 条),等待{long_delay:.2f}秒后继续下一个关键词(反爬)")
  1250. return collect_result, page_no
  1251. # ---------- main entry ----------
  1252. def run(self):
  1253. """蜘蛛主入口,返回 (crawl_count, is_success)。
  1254. 由 CollectScheduleRunner 调用:
  1255. count, is_success = YbmCrawler(task_dict).run()
  1256. """
  1257. load_city_mapping()
  1258. logger.info("\n" + "=" * 50)
  1259. logger.info("🚀 药帮忙采集程序启动")
  1260. logger.info(f"⏰ 启动时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  1261. logger.info("=" * 50)
  1262. with sync_playwright() as p:
  1263. browser = p.chromium.launch(
  1264. channel="chrome",
  1265. headless=True,
  1266. slow_mo=random.randint(100, 300),
  1267. args=[
  1268. "--disable-blink-features=AutomationControlled",
  1269. "--enable-automation=false",
  1270. "--disable-infobars",
  1271. "--remote-debugging-port=0",
  1272. "--start-maximized",
  1273. "--disable-extensions",
  1274. "--disable-plugins-discovery",
  1275. "--no-sandbox",
  1276. "--disable-dev-shm-usage",
  1277. f"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{random.randint(110, 120)}.0.0.0 Safari/537.36",
  1278. ],
  1279. )
  1280. context = browser.new_context(
  1281. locale="zh-CN",
  1282. timezone_id="Asia/Shanghai",
  1283. geolocation={"latitude": 31.230416, "longitude": 121.473701},
  1284. permissions=["geolocation"],
  1285. user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  1286. viewport={"width": 1800, "height": 1000},
  1287. java_script_enabled=True,
  1288. bypass_csp=True,
  1289. )
  1290. page = context.new_page()
  1291. page.add_init_script("""
  1292. Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
  1293. Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
  1294. Object.defineProperty(navigator, 'mimeTypes', { get: () => [1, 2, 3] });
  1295. window.chrome = { runtime: {}, loadTimes: () => ({}) };
  1296. delete window.navigator.languages;
  1297. window.navigator.languages = ['zh-CN', 'zh'];
  1298. (() => {
  1299. const originalAddEventListener = EventTarget.prototype.addEventListener;
  1300. EventTarget.prototype.addEventListener = function(type, listener) {
  1301. if (type === 'mousemove') {
  1302. return originalAddEventListener.call(this, type, (e) => {
  1303. e._automation = undefined;
  1304. listener(e);
  1305. });
  1306. }
  1307. return originalAddEventListener.call(this, type, listener);
  1308. };
  1309. })();
  1310. """)
  1311. try:
  1312. # 登录
  1313. load_cookies(context)
  1314. if not self._is_login(page):
  1315. page.goto(TARGET_LOGIN_URL)
  1316. page.wait_for_load_state("networkidle")
  1317. logger.info("🔑 开始执行登录流程")
  1318. login_success = self._login_operation(page)
  1319. if not login_success:
  1320. logger.error(" 登录失败,程序终止")
  1321. self.is_success = False
  1322. self._page_no = 0
  1323. return 0, False
  1324. save_cookies(context)
  1325. logger.info(" 登录并保存Cookie成功!")
  1326. # 启动心跳守护线程(CrawlerScheduler)
  1327. self.scheduler.start()
  1328. # 搜索
  1329. popup_guard(page, "before_search")
  1330. store_page, search_success = self._search_operation(page, self.keyword, is_first_search=True)
  1331. if store_page:
  1332. popup_guard(store_page, "after_search")
  1333. if store_page is None:
  1334. logger.warning(f" 「{self.keyword}」搜索页面为空,任务失败")
  1335. self.is_success = False
  1336. self._page_no = 0
  1337. return 0, False
  1338. if not search_success:
  1339. logger.warning(f" 「{self.keyword}」搜索失败,跳过采集")
  1340. self.is_success = False
  1341. self._page_no = 0
  1342. return 0, False
  1343. store_page.wait_for_load_state("domcontentloaded")
  1344. store_page.wait_for_load_state('networkidle')
  1345. # 上报采集开始,检查是否限额
  1346. report_status = self.scheduler.report_start(self.task_id)
  1347. if report_status == "limit_reached":
  1348. logger.warning("⛔ 任务 %s 开始上报时账号已达限额,退出当前任务", self.task_id)
  1349. self.is_success = False
  1350. self._page_no = 0
  1351. return 0, False
  1352. # 核心采集
  1353. data_list, page_no = self._collect_data(store_page)
  1354. self._page_no = page_no
  1355. real_count = len(data_list)
  1356. self.is_success = True
  1357. logger.info(f"关键词「{self.keyword}」采集完成,共 {real_count} 条数据(pipeline 实际入库 {self.pipeline.crawl_count} 条),共 {page_no} 页")
  1358. except Exception as e:
  1359. logger.exception("程序异常:%s", e)
  1360. self.is_success = False
  1361. finally:
  1362. self.scheduler.stop()
  1363. # 上报采集结束
  1364. try:
  1365. self.scheduler.report_end(
  1366. self.task_id,
  1367. success=self.is_success,
  1368. real_count=self.pipeline.crawl_count,
  1369. page_no=getattr(self, '_page_no', 0),
  1370. total_pages=0,
  1371. )
  1372. except Exception:
  1373. pass
  1374. browser.close()
  1375. logger.info(" 浏览器已关闭,程序结束")
  1376. return self.pipeline.crawl_count, self.is_success
  1377. # ==================== SECTION 9: ENTRY POINT (测试/直接运行) ====================
  1378. if __name__ == '__main__':
  1379. # 直接运行:使用手动任务进行测试
  1380. load_city_mapping()
  1381. if USE_MANUAL_TASK:
  1382. task = MANUAL_TASK
  1383. else:
  1384. # 从内部 API 拉取任务
  1385. scheduler = CrawlerScheduler(YBM_DEVICE_ID, str(PLATFORM_ID))
  1386. task = scheduler.get_task() or {}
  1387. if not task:
  1388. logger.error("拉取任务失败")
  1389. if not task:
  1390. logger.error("未获取到任何任务,程序退出")
  1391. else:
  1392. crawler = YbmCrawler(task)
  1393. count, success = crawler.run()
  1394. logger.info(f"测试完成:crawl_count={count}, is_success={success}")
  1395. # 发送飞书通知
  1396. try:
  1397. spec_items = [item.strip() for item in re.split(r"[|、,,\n\r]+", task.get("product_specs", "") or "") if item.strip()]
  1398. display_spec = "、".join(spec_items)
  1399. notice_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  1400. if success:
  1401. feishu_send_text(
  1402. f"{notice_time} 通知:\n"
  1403. f"平台: 药帮忙, 药品: {task.get('product_name', '')}, 品规: {display_spec}, 爬取数据: {count}条"
  1404. )
  1405. else:
  1406. feishu_send_error_card(
  1407. task_name=task.get("product_name", ""),
  1408. err_msg=f"task_id={task.get('id')}, company_id={task.get('company_id')}, crawl_count={count}",
  1409. mention_all=False,
  1410. )
  1411. except Exception as e:
  1412. logger.warning(f"飞书通知发送失败:{e}")