jd_auto_crawl_snap2.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. import random
  2. import re
  3. import signal
  4. import socket
  5. import sys
  6. import time
  7. from decimal import Decimal, InvalidOperation
  8. from urllib.parse import quote
  9. from DrissionPage import ChromiumPage, ChromiumOptions
  10. import json
  11. import hashlib
  12. from commons.Logger import get_spider_logger
  13. from commons.conn_mysql import MySQLPoolOn2
  14. from pipelines.drug_pipelines import DrugPipeline
  15. from commons.feishu_webhook import send_text
  16. from spiders.jd.jd_captcha import handle_jd_slider_captcha
  17. from oss_upload.oss_upload import AliyunOSSUploader
  18. from commons.config import (
  19. JD_DEVICE_ID,
  20. CHROME_PATH,
  21. UA_JD,
  22. CRAWLER_TOKEN,
  23. JD_NO_MATCH_THRESHOLD,
  24. JD_COLLECT_MAX_STEPS,
  25. )
  26. import requests
  27. logger = get_spider_logger("jd")
  28. chrome_path = CHROME_PATH
  29. FETCH_TIMEOUT_FIRST = 3
  30. FETCH_TIMEOUT_SCROLL = 3
  31. LISTEN_CLEAR_ROUNDS = 3
  32. LISTEN_CLEAR_TIMEOUT = 0.45
  33. # 「下一页」是否在视口内(条件略宽)
  34. _JS_NEXT_BTN_IN_VIEWPORT = """
  35. var el = arguments[0];
  36. if (!el) return false;
  37. var r = el.getBoundingClientRect();
  38. var h = window.innerHeight || document.documentElement.clientHeight || 800;
  39. var w = window.innerWidth || document.documentElement.clientWidth || 1200;
  40. return r.bottom > 80 && r.top < h - 40 && r.right > 0 && r.left < w;
  41. """
  42. class JdCrawlerV2:
  43. def __init__(self, drug_dict=None, scheduler=None, driver=None, cumulative_pages=0, cumulative_items=0, cumulative_stored=0, cumulative_skipped=0):
  44. self.driver = driver
  45. self.cumulative_pages = cumulative_pages
  46. self.cumulative_items = cumulative_items
  47. self.cumulative_stored = cumulative_stored
  48. self.cumulative_skipped = cumulative_skipped
  49. self.page_stored = 0
  50. self.register_signal_handler()
  51. self.db = MySQLPoolOn2()
  52. self.ip = None
  53. self.account_name = None
  54. self.login_username = None
  55. self.login_password = None
  56. self.platform = 2
  57. self.pipeline = DrugPipeline("jd")
  58. self.task_dict = drug_dict or {}
  59. self.scheduler = scheduler
  60. self.report_data = {}
  61. self.heartbeat_interval = 30
  62. self.ossuploader = AliyunOSSUploader()
  63. self.start_page = 1
  64. self.end_page = 1
  65. if self.task_dict:
  66. self.get_product_data()
  67. self.success = True
  68. self.is_no_prodcut = 0
  69. def get_product_data(self):
  70. self.task_id = self.task_dict["id"]
  71. self.company_id = self.task_dict["company_id"]
  72. self.product = self.task_dict["product_name"]
  73. self.product_desc = self.task_dict.get("product_specs", "")
  74. self.brand = self.task_dict.get("product_brand", "")
  75. self.product_keyword = self.task_dict.get("product_keyword", "")
  76. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  77. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  78. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  79. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  80. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  81. self.account_id = self.task_dict.get("collect_equipment_account_id", "15")
  82. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  83. self.collect_round = self.task_dict.get("collect_round", 1)
  84. self.start_page = self._parse_page(self.task_dict.get("start_page"), 1)
  85. self.end_page = 100
  86. self.report_data = {'task_id': self.task_id, 'platform': self.platform, 'username': self.task_dict.get("username", JD_DEVICE_ID)}
  87. @staticmethod
  88. def _parse_page(value, default=1):
  89. try:
  90. page = int(value)
  91. return page if page >= 1 else default
  92. except (TypeError, ValueError):
  93. return default
  94. @staticmethod
  95. def _get_free_port():
  96. """获取一个当前可用的本地端口,供 Chrome 调试使用。"""
  97. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  98. s.bind(("127.0.0.1", 0))
  99. return s.getsockname()[1]
  100. def init_browser(self):
  101. if self.driver:
  102. logger.info("复用已有浏览器实例")
  103. self._listen_started = False
  104. return
  105. co = ChromiumOptions().set_browser_path(chrome_path)
  106. debug_port = self._get_free_port()
  107. co.set_user_data_path(f"./account_cache/jd/JD_101")
  108. co.set_local_port(debug_port)
  109. co.set_argument(f"--remote-debugging-port={debug_port}")
  110. co.set_argument("--remote-debugging-address=127.0.0.1")
  111. # co.set_argument("--disable-blink-features=AutomationControlled")
  112. co.set_argument("--disable-dev-shm-usage")
  113. co.set_argument("--no-first-run") # 避免首次运行弹窗
  114. co.set_argument("--no-default-browser-check") # 避免默认浏览器检查
  115. if self.ip:
  116. proxy = self.ip.strip()
  117. if not proxy.startswith(("http://", "https://")):
  118. proxy = f"http://{proxy}"
  119. co.set_argument(f"--proxy-server={proxy}")
  120. logger.info("启动浏览器: account=%s, debug_port=%s", self.account_name, debug_port)
  121. self.driver = ChromiumPage(co)
  122. self._listen_started = False
  123. def _start_listen(self):
  124. """登录完成后再开监听,避免干扰登录页/验证码拖动。"""
  125. if self._listen_started or not self.driver:
  126. return
  127. self.driver.listen.start("api?appid=search-pc-java")
  128. self._listen_started = True
  129. logger.info("已启动搜索接口监听")
  130. def register_signal_handler(self):
  131. def handler(signum, frame):
  132. print("\n⚠️ 程序退出")
  133. if self.driver:
  134. self.driver.quit()
  135. sys.exit(0)
  136. signal.signal(signal.SIGINT, handler)
  137. if hasattr(signal, "SIGTERM"):
  138. signal.signal(signal.SIGTERM, handler)
  139. def sleep(self, a, b):
  140. time.sleep(random.uniform(a, b))
  141. def _scroll_page_down(self, delta=900):
  142. self.driver.run_js(f"window.scrollBy(0, {int(delta)});")
  143. time.sleep(random.uniform(0.15, 0.35))
  144. def _scroll_next_into_view(self, el):
  145. if not el:
  146. return
  147. try:
  148. self.driver.run_js(
  149. "arguments[0].scrollIntoView({block:'center',behavior:'instant'});",
  150. el,
  151. )
  152. self.sleep(0.4, 0.8)
  153. except Exception as e:
  154. logger.warning("滚动到下一页按钮失败: %s", e)
  155. try:
  156. el.scroll.to_see()
  157. except Exception:
  158. pass
  159. def _get_scroll_info(self):
  160. return self.driver.run_js("""
  161. return {
  162. scrollY: window.scrollY || window.pageYOffset || 0,
  163. docH: Math.max(document.body.scrollHeight,
  164. document.documentElement.scrollHeight,
  165. document.body.offsetHeight),
  166. viewH: window.innerHeight || document.documentElement.clientHeight || 800
  167. };
  168. """)
  169. def _find_next_btn(self, timeout=0.3):
  170. try:
  171. return self.driver.ele("text=下一页", timeout=timeout)
  172. except Exception:
  173. return None
  174. def _is_next_btn_visible(self, btn):
  175. if not btn:
  176. return False
  177. try:
  178. return bool(self.driver.run_js(_JS_NEXT_BTN_IN_VIEWPORT, btn))
  179. except Exception:
  180. return False
  181. def _human_click(self, element):
  182. """在目标节点上触发 click,避免 move_to + 无目标 actions.click() 因布局位移点到商品链接触发详情页。"""
  183. if not element:
  184. return False
  185. try:
  186. self.sleep(0.3, 0.8)
  187. try:
  188. self.driver.run_js(
  189. "arguments[0].scrollIntoView({block:'center',behavior:'instant'});",
  190. element,
  191. )
  192. except Exception:
  193. pass
  194. self.sleep(0.1, 0.3)
  195. self.driver.run_js("arguments[0].click();", element)
  196. return True
  197. except Exception as e:
  198. logger.warning("点击失败: %s", e)
  199. try:
  200. element.click()
  201. return True
  202. except Exception:
  203. return False
  204. @staticmethod
  205. def _estimated_price(json_data):
  206. fp = json_data.get("finalPrice")
  207. if isinstance(fp, dict):
  208. return fp.get("estimatedPrice", "") or ""
  209. return ""
  210. def get_heshu(self, full_title):
  211. last_box = None
  212. last_bottle = None
  213. for match in re.finditer(r"(\d+)(盒|瓶)", full_title):
  214. if match.group(2) == '盒':
  215. last_box = match
  216. else: # 瓶
  217. last_bottle = match
  218. if last_box:
  219. return int(last_box.group(1))
  220. elif last_bottle:
  221. return int(last_bottle.group(1))
  222. else:
  223. return 1
  224. def _take_snapshot(self, upload_key, ele):
  225. """在指定标签页截图并上传。"""
  226. time.sleep(0.4)
  227. try:
  228. jpg_bytes = ele.get_screenshot(as_bytes="jpg")
  229. if not jpg_bytes:
  230. logger.warning("截图为空 upload_key=%s", upload_key)
  231. return ""
  232. img_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(upload_key))
  233. except Exception:
  234. logger.exception("截图或 OSS 上传失败 upload_key=%s", upload_key)
  235. return ""
  236. if not img_url:
  237. logger.warning("OSS 未返回有效地址 upload_key=%s", upload_key)
  238. return ""
  239. logger.info("截图上传完成 upload_key=%s url=%s", upload_key, img_url)
  240. time.sleep(random.uniform(0.3, 0.8))
  241. return img_url
  242. def parse(self, ware_list, force=False):
  243. unprocessed = []
  244. for w in ware_list:
  245. if not isinstance(w, dict):
  246. continue
  247. sku_id = str(w.get("skuId", ""))
  248. if not sku_id:
  249. continue
  250. ele_xpath = "//div[@id='main_search_conter']//div[contains(@class,'_goodsContainer_')]/div[@data-sku=" + "'" + sku_id + "'" + "]"
  251. ele_screen = self.driver.ele("xpath=" + ele_xpath, timeout=0.2)
  252. if not ele_screen and not force:
  253. unprocessed.append(w)
  254. continue
  255. title = w.get("wareName", "")
  256. title = re.sub(r"<[^>]*>", "", title).strip()
  257. color = w.get("color", "")
  258. full_title = title + " " + color
  259. logger.info(full_title)
  260. if self.product not in full_title:
  261. self.is_no_prodcut += 1
  262. continue
  263. if self.brand not in full_title:
  264. self.is_no_prodcut += 1
  265. continue
  266. if self.product_desc:
  267. if self.product_desc in full_title:
  268. crawl_product_desc = self.product_desc
  269. else:
  270. crawl_product_desc = ""
  271. title = full_title
  272. else:
  273. crawl_product_desc = ""
  274. title = full_title
  275. if "+[" in title:
  276. continue
  277. self.is_no_prodcut = 0
  278. status = 1
  279. if self.product_keyword:
  280. search_keyword_list = self.product_keyword.split(",")
  281. for search_keyword in search_keyword_list:
  282. if search_keyword.strip() not in title:
  283. status = 0
  284. if status == 0:
  285. continue
  286. logger.info(f"商品名:{title}")
  287. sku_id = w.get("skuId", "")
  288. sales = w.get("totalSales", "")
  289. shop_id = w.get("shopId", "")
  290. shop_name = w.get("shopName", "")
  291. heshu_count = self.get_heshu(full_title)
  292. final_price = self._estimated_price(w)
  293. jd_price = w.get("jdPrice", "")
  294. item_url = f"https://item.jd.com/{sku_id}.html"
  295. low_price = final_price if final_price else jd_price
  296. # 获取列表页快照
  297. upload_key = hashlib.md5(item_url.encode("utf-8")).hexdigest()
  298. snap_url = ""
  299. if ele_screen:
  300. for i in range(3):
  301. snap_url = self._take_snapshot(upload_key, ele_screen)
  302. if snap_url != "":
  303. break
  304. else:
  305. logger.warning(f"未找到商品元素无法截图: {sku_id}")
  306. try:
  307. price = Decimal(str(low_price)).quantize(Decimal("0.00"))
  308. except (InvalidOperation, ValueError):
  309. price = Decimal("0.00")
  310. item_url = f"https://item.jd.com/{sku_id}.html"
  311. mall_url = f"https://mall.jd.com/index-{shop_id}.html?from=pc"
  312. # 字段与 yaofangwang_crawl 对齐;键顺序须与 commons.sql_data.RETRIEVE_SCRAPE_INSERT_COLUMNS 一致
  313. now_ts = time.strftime("%Y-%m-%d %H:%M:%S")
  314. product = {
  315. "platform": self.platform,
  316. "item_id": sku_id,
  317. "enterprise_id": self.company_id,
  318. "product_name": title,
  319. "spec": crawl_product_desc,
  320. "one_price": "",
  321. "detail_url": item_url,
  322. "shop_name": shop_name,
  323. "anonymous_store_name": "",
  324. "shop_url": mall_url,
  325. "city_name": "",
  326. "city_id": "",
  327. "province_name": "",
  328. "province_id": "",
  329. "shipment_city_name": "",
  330. "shipment_city_id": "",
  331. "shipment_province_name": "",
  332. "shipment_province_id": "",
  333. "area_info": "",
  334. "factory_name": "",
  335. "scrape_date": time.strftime("%Y-%m-%d"),
  336. "price": price,
  337. "sales": sales,
  338. "stock_count": "",
  339. "snapshot_url": snap_url,
  340. "approval_num": "",
  341. "produced_time": "",
  342. "deadline": "",
  343. "update_time": now_ts,
  344. "insert_time": now_ts,
  345. "number": heshu_count,
  346. "product_brand": self.brand or "",
  347. "collect_task_id": self.collect_task_id,
  348. "task_id": self.task_id,
  349. "search_name": self.product,
  350. "company_name": "",
  351. "collect_config_info": json.dumps(
  352. {
  353. "sampling_cycle": self.sampling_cycle,
  354. "sampling_start_time": self.sampling_start_time,
  355. "sampling_end_time": self.sampling_end_time,
  356. }
  357. ),
  358. "account_id": self.account_id,
  359. "collect_region_id": self.collect_region_id,
  360. "collect_round": self.collect_round,
  361. "is_sold_out": 0
  362. }
  363. try:
  364. affected_rows = self.pipeline.storge_data(product)
  365. if affected_rows and affected_rows > 0:
  366. self.page_stored += 1
  367. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  368. except Exception as e:
  369. logger.exception("写入数据库失败: %s", e)
  370. return unprocessed
  371. @staticmethod
  372. def _response_has_ware_list(data):
  373. if not isinstance(data, dict):
  374. return False
  375. inner_data = data.get("data")
  376. if not isinstance(inner_data, dict):
  377. return False
  378. return bool(inner_data.get("wareList"))
  379. def fetch_items_once(self, timeout=FETCH_TIMEOUT_FIRST):
  380. wares = []
  381. for resp in self.driver.listen.steps(timeout=timeout):
  382. try:
  383. data = resp.response.body
  384. if not self._response_has_ware_list(data):
  385. continue
  386. ware_list = data["data"]["wareList"]
  387. wares.extend(ware_list)
  388. except Exception as e:
  389. logger.warning("解析监听响应失败: %s", e)
  390. return wares
  391. def clear_listen_buffer(self, rounds=LISTEN_CLEAR_ROUNDS, timeout=LISTEN_CLEAR_TIMEOUT):
  392. try:
  393. for _ in range(rounds):
  394. resps = list(self.driver.listen.steps(timeout=timeout))
  395. if not resps:
  396. break
  397. logger.debug("监听缓冲已清空")
  398. except Exception as e:
  399. logger.debug("清空监听缓冲失败: %s", e)
  400. def collect_full_page_items(self, max_steps=JD_COLLECT_MAX_STEPS):
  401. """单次循环:边滑动边收数据,到底 / 看见「下一页」即停。"""
  402. pending_wares = []
  403. total_n = 0
  404. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_FIRST)
  405. total_n += len(new_wares)
  406. pending_wares.extend(new_wares)
  407. pending_wares = self.parse(pending_wares)
  408. stagnant = 0
  409. last_scroll_y = None
  410. for step in range(max_steps):
  411. next_btn = self._find_next_btn(timeout=0.3)
  412. if self._is_next_btn_visible(next_btn):
  413. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  414. total_n += len(new_wares)
  415. pending_wares.extend(new_wares)
  416. pending_wares = self.parse(pending_wares)
  417. self.parse(pending_wares, force=True)
  418. return total_n, next_btn
  419. info = self._get_scroll_info()
  420. scroll_y = info["scrollY"]
  421. doc_h = info["docH"]
  422. view_h = info["viewH"]
  423. at_bottom = (scroll_y + view_h >= doc_h - 20)
  424. if last_scroll_y is not None and abs(scroll_y - last_scroll_y) < 8:
  425. stagnant += 1
  426. else:
  427. stagnant = 0
  428. last_scroll_y = scroll_y
  429. if at_bottom and stagnant >= 2:
  430. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  431. total_n += len(new_wares)
  432. pending_wares.extend(new_wares)
  433. pending_wares = self.parse(pending_wares)
  434. self.parse(pending_wares, force=True)
  435. next_btn = self._find_next_btn(timeout=2)
  436. if next_btn:
  437. self._scroll_next_into_view(next_btn)
  438. return total_n, next_btn
  439. logger.info("已到页面底部且未发现下一页,停止滑动")
  440. return total_n, None
  441. self._scroll_page_down(random.randint(400, 800))
  442. if random.random() < 0.15:
  443. self.driver.run_js(f"window.scrollBy(0, -{random.randint(60, 140)})")
  444. self.sleep(0.3, 0.8)
  445. pending_wares = self.parse(pending_wares)
  446. if step % 3 == 2:
  447. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  448. total_n += len(new_wares)
  449. pending_wares.extend(new_wares)
  450. pending_wares = self.parse(pending_wares)
  451. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  452. total_n += len(new_wares)
  453. pending_wares.extend(new_wares)
  454. pending_wares = self.parse(pending_wares)
  455. self.parse(pending_wares, force=True)
  456. next_btn = self._find_next_btn(timeout=3)
  457. if next_btn and not self._is_next_btn_visible(next_btn):
  458. self._scroll_next_into_view(next_btn)
  459. return total_n, next_btn
  460. def get_account(self):
  461. sql_account = """
  462. SELECT *
  463. FROM `retrieve_collect_equipment_account`
  464. WHERE `id` = %s
  465. and `status` = 0
  466. """
  467. account_list = self.db.select_data(sql_account, self.account_id)
  468. if not account_list:
  469. return False
  470. account_dict = account_list[0]
  471. print(account_dict)
  472. self.ip = account_dict.get("ip")
  473. self.account_name = account_dict.get("username")
  474. self.login_username = account_dict.get("phone") or account_dict.get("username", "")
  475. self.login_password = account_dict.get("password", "")
  476. logger.info("获取到账号: %s, ip: %s", self.account_name, self.ip)
  477. return True
  478. def disable_account(self):
  479. update_sql = f""" UPDATE `retrieve_collect_equipment_account` SET `status`= %s WHERE `name` = %s; """
  480. self.db.execute(update_sql, (1, self.account_name))
  481. def _build_search_keyword(self):
  482. parts = [p for p in (self.brand, self.product, self.product_desc) if p]
  483. return " ".join(parts).strip() or self.product
  484. def _is_logged_out(self):
  485. return bool(self.driver.ele("xpath=//*[@class='link-login']", timeout=2))
  486. def _error_report(self, data):
  487. self.report_data.update(data)
  488. if self.scheduler:
  489. self.scheduler.stop()
  490. self.scheduler.post_report(self.report_data)
  491. def _success_report(self, data):
  492. self.report_data.update(data)
  493. if self.scheduler:
  494. self.scheduler.post_report(self.report_data)
  495. def post_report(self, data):
  496. url = "http://192.168.2.246:8080/api/collect_task/report"
  497. print('传给返回接口的数据', data)
  498. headers = {'X-Crawler-Token': CRAWLER_TOKEN}
  499. response = requests.post(url, json=data, headers=headers)
  500. result = response.json()
  501. if result.get('code') != 'success':
  502. logger.info(f'翻页回传:{result}')
  503. if self.scheduler:
  504. self.scheduler.stop()
  505. print(f'任务进度上传 {result}')
  506. def perform_jd_login(self):
  507. """
  508. 使用已有浏览器实例执行京东账号密码登录(含滑块验证码)。
  509. 成功返回 True,失败返回 False。
  510. """
  511. username = self.login_username
  512. password = self.login_password
  513. login_url = "https://passport.jd.com/new/login.aspx"
  514. self.driver.get(login_url)
  515. input_name = self.driver.ele("xpath=//input[@id='loginname']", timeout=15)
  516. if not input_name:
  517. print("未找到用户名输入框")
  518. return False
  519. input_name.input(username)
  520. time.sleep(random.uniform(1.5, 2.5))
  521. input_pass = self.driver.ele("xpath://input[@name='nloginpwd']", timeout=5)
  522. if not input_pass:
  523. print("未找到密码输入框")
  524. return False
  525. input_pass.input(password)
  526. time.sleep(random.uniform(1.5, 2.5))
  527. login_btn = self.driver.ele("xpath://a[@id='loginsubmit']", timeout=5)
  528. if not login_btn:
  529. print("未找到登录按钮")
  530. return False
  531. login_btn.click()
  532. time.sleep(random.uniform(3, 5))
  533. if not handle_jd_slider_captcha(self.driver):
  534. print("滑块验证码未通过")
  535. return False
  536. return True
  537. def _ensure_logged_in(self):
  538. """未登录时自动走登录流程(账号密码 + 滑块)。"""
  539. if not self._is_logged_out():
  540. return True
  541. logger.info("检测到未登录,开始自动登录: %s", self.account_name)
  542. ok = self.perform_jd_login()
  543. if ok and not self._is_logged_out():
  544. logger.info("自动登录成功: %s", self.account_name)
  545. return True
  546. logger.error("自动登录失败: %s", self.account_name)
  547. return False
  548. def _check_page_blocked(self):
  549. html = self.driver.html or ""
  550. if "抱歉由于访问频繁导致无法搜索" in html:
  551. logger.error("账号无法搜索(访问频繁)")
  552. self.success = False
  553. return True
  554. return False
  555. def _jump_to_page(self, target_page):
  556. """跳转到指定页码,并清空跳转前的监听残留。"""
  557. to_page_input = self.driver.ele(
  558. "xpath=//input[contains(@class, 'pagination-input')] | //div[contains(@class,'_pagination_toPageNum_')]//input[@type='text']",
  559. timeout=3,
  560. )
  561. if not to_page_input:
  562. logger.warning("未找到跳页输入框,无法跳转到第 %s 页", target_page)
  563. return False
  564. self.clear_listen_buffer()
  565. to_page_input.clear()
  566. to_page_input.input(str(target_page))
  567. self.sleep(0.4, 0.8)
  568. confirm_btn = self.driver.ele("xpath=//button[contains(@class, 'pagination-jump-btn')]", timeout=1)
  569. if confirm_btn:
  570. self.driver.run_js("arguments[0].click();", confirm_btn)
  571. else:
  572. self.driver.actions.key_down("enter").key_up("enter")
  573. self.sleep(1.5, 2.5)
  574. self.clear_listen_buffer()
  575. logger.info("已跳转到第 %s 页", target_page)
  576. return True
  577. def _go_next_page(self, next_btn):
  578. self.clear_listen_buffer()
  579. if not self._human_click(next_btn):
  580. logger.warning("点击下一页失败")
  581. return False
  582. self.sleep(1, 2)
  583. return True
  584. def crawl(self):
  585. total = 0
  586. keyword = self._build_search_keyword()
  587. self.driver.get("https://www.jd.com/", timeout=15)
  588. self.sleep(3, 5)
  589. if self._is_logged_out():
  590. # 打开扫码登录页,等人工扫码(实时轮询,登录即继续,最多等5分钟)
  591. logger.info("未登录,打开扫码登录页,请用京东APP扫码……")
  592. self.driver.get("https://passport.jd.com/new/login.aspx", timeout=15)
  593. t0 = time.time()
  594. logged_in = False
  595. while time.time() - t0 < 300:
  596. if "passport.jd.com" not in (self.driver.url or ""):
  597. self.sleep(2, 4)
  598. if not self._is_logged_out():
  599. logged_in = True
  600. break
  601. time.sleep(3)
  602. if logged_in:
  603. logger.info("人工登录成功")
  604. else:
  605. logger.error("等待人工登录超时(5分钟)")
  606. if self.login_password and self.login_username:
  607. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 1})
  608. else:
  609. self.disable_account()
  610. send_text(f"京东:{self.account_name}账号登录失败")
  611. self.success = False
  612. return
  613. kw = quote(str(keyword or ""), safe="")
  614. self._search_kw = kw
  615. # 必须先监听再打开搜索页,否则首屏 wareList(前约 30 条)在监听开启前就返回了
  616. self._start_listen()
  617. self.driver.get(
  618. f"https://search.jd.com/Search?keyword={kw}&enc=utf-8&wq={kw}", timeout=15
  619. )
  620. self.sleep(2, 3)
  621. if self._check_page_blocked():
  622. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 2})
  623. return
  624. if not handle_jd_slider_captcha(self.driver, pause_listen=False):
  625. logger.warning("进入搜索页后滑块验证码处理失败")
  626. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 2})
  627. self.success = False
  628. return
  629. if self.start_page > 1:
  630. if not self._jump_to_page(self.start_page):
  631. logger.warning("跳页失败,将从第 1 页开始采集")
  632. self.start_page = 1
  633. logger.info(
  634. "采集页码范围: %s ~ %s(共 %s 页)",
  635. self.start_page,
  636. self.end_page,
  637. self.end_page - self.start_page + 1,
  638. )
  639. for page_no in range(self.start_page, self.end_page + 1):
  640. if self._is_logged_out():
  641. if not self._ensure_logged_in():
  642. self.success = False
  643. break
  644. self.driver.get(
  645. f"https://search.jd.com/Search?keyword={kw}&enc=utf-8&wq={kw}",
  646. timeout=15,
  647. )
  648. self.sleep(3, 5)
  649. if page_no > 1:
  650. self._jump_to_page(page_no)
  651. if not handle_jd_slider_captcha(self.driver, pause_listen=True):
  652. logger.warning("滑块验证码处理失败,停止采集")
  653. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  654. self.success = False
  655. break
  656. if self._check_page_blocked():
  657. break
  658. logger.info("===== 正在爬取第 %s 页 =====", page_no)
  659. self.page_stored = 0
  660. search_ele = self.driver.ele("xpath=//div[@id='search-condition']", timeout=10)
  661. if not search_ele:
  662. logger.warning("未找到搜索结果区域,停止采集")
  663. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  664. break
  665. page_n, _ = self.collect_full_page_items()
  666. logger.info("本页监听商品条数(含可能重复): %s", page_n)
  667. total += page_n
  668. logger.info("累计监听条数: %s", total)
  669. if self.scheduler and self.scheduler.end:
  670. logger.info('心跳失败')
  671. self._error_report({'is_finished': 0, 'need_reassign': 0, 'current_page': page_no})
  672. break
  673. if self.is_no_prodcut > JD_NO_MATCH_THRESHOLD:
  674. logger.info("连续无匹配商品过多,停止采集")
  675. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  676. break
  677. self.cumulative_pages += 1
  678. self.cumulative_items += page_n
  679. page_skipped = page_n - self.page_stored
  680. self.cumulative_stored += self.page_stored
  681. self.cumulative_skipped += page_skipped
  682. logger.info(
  683. "关键字 %s 第 %s 页获取完成, 本页获取量: %d, 本页已入库: %d, 本页未入库: %d | 账号测试总页数: %d, 账号测试总数据量: %d, 总已入库: %d, 总未入库: %d",
  684. keyword,
  685. page_no,
  686. page_n,
  687. self.page_stored,
  688. page_skipped,
  689. self.cumulative_pages,
  690. self.cumulative_items,
  691. self.cumulative_stored,
  692. self.cumulative_skipped
  693. )
  694. print(f"[{keyword}] 当前页数: {page_no}, 本页获取数据量: {page_n}, 本页已入库: {self.page_stored}, 本页未入库: {page_skipped} | 账号测试总页数: {self.cumulative_pages}, 账号测试总数据量: {self.cumulative_items}, 总已入库: {self.cumulative_stored}, 总未入库: {self.cumulative_skipped}")
  695. if page_no >= self.end_page:
  696. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  697. break
  698. next_btn = self.driver.ele("text=下一页", timeout=2)
  699. if not next_btn:
  700. logger.info("没有下一页(未找到)")
  701. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  702. break
  703. cls_str = next_btn.attr("class") or ""
  704. if "disabled" in cls_str:
  705. logger.info("没有下一页(已禁用)")
  706. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  707. break
  708. if not self._go_next_page(next_btn):
  709. break
  710. if self.scheduler:
  711. self.scheduler.stop()
  712. def run(self):
  713. # 检测账号
  714. if not self.get_account():
  715. logger.info("==================当前无账号可用==================")
  716. self.success = False
  717. return self.pipeline.crawl_count, self.success, self.driver, self.cumulative_pages, self.cumulative_items, self.cumulative_stored, self.cumulative_skipped
  718. logger.info("获取到账号:%s,代理ip:%s", self.account_name, self.ip)
  719. # # # 每次选取账号,立马账号使用时间
  720. update_sql = f""" UPDATE `retrieve_collect_equipment_account` SET `status`= %s, `update_time`= %s WHERE `username` = %s; """
  721. self.db.execute(update_sql, (0, int(time.time()), self.account_name))
  722. try:
  723. self.init_browser()
  724. self.crawl()
  725. except Exception as e:
  726. self.success = False
  727. logger.exception("爬取异常: %s", e)
  728. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 3})
  729. self.sleep(3, 5)
  730. finally:
  731. pass
  732. # if self.driver:
  733. # self.driver.quit()
  734. # self.driver = None
  735. return self.pipeline.crawl_count, self.success, self.driver, self.cumulative_pages, self.cumulative_items, self.cumulative_stored, self.cumulative_skipped