ybm_crawl.py 74 KB

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