jd_auto_crawl_snap2.py 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465
  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. import os
  13. from base64 import b64decode
  14. from commons.Logger import get_spider_logger
  15. from commons.conn_mysql import MySQLPoolOn2
  16. from pipelines.drug_pipelines import DrugPipeline
  17. from commons.feishu_webhook import send_text
  18. from spiders.jd.jd_captcha import handle_jd_slider_captcha
  19. from oss_upload.oss_upload import AliyunOSSUploader
  20. from commons.config import (
  21. JD_DEVICE_ID,
  22. CHROME_PATH,
  23. UA_JD,
  24. CRAWLER_TOKEN,
  25. JD_NO_MATCH_THRESHOLD,
  26. JD_COLLECT_MAX_STEPS,
  27. )
  28. import requests
  29. logger = get_spider_logger("jd")
  30. chrome_path = CHROME_PATH
  31. FETCH_TIMEOUT_FIRST = 3
  32. FETCH_TIMEOUT_SCROLL = 3
  33. LISTEN_CLEAR_ROUNDS = 3
  34. LISTEN_CLEAR_TIMEOUT = 0.45
  35. # 验证码 URL 特征
  36. CAPTCHA_URL_KEYS = ["risk_handler", "cfe.m.jd.com", "verifycode", "captcha"]
  37. # 验证码页面文案特征
  38. CAPTCHA_MARKERS = ["\u9a8c\u8bc1\u4e00\u4e0b\uff0c\u8d2d\u7269\u65e0\u5fe7", "\u524d\u65b9\u62e5\u6324", "\u62fc\u56fe\u9a8c\u8bc1"]
  39. # 「下一页」是否在视口内(条件略宽)
  40. _JS_NEXT_BTN_IN_VIEWPORT = """
  41. var el = arguments[0];
  42. if (!el) return false;
  43. var r = el.getBoundingClientRect();
  44. var h = window.innerHeight || document.documentElement.clientHeight || 800;
  45. var w = window.innerWidth || document.documentElement.clientWidth || 1200;
  46. return r.bottom > 80 && r.top < h - 40 && r.right > 0 && r.left < w;
  47. """
  48. class _PageInterrupted(Exception):
  49. """采集过程中页面出现异常(验证码/被踢/渲染异常),中断本页采集。"""
  50. def __init__(self, kind):
  51. self.kind = kind # "captcha" / "kicked" / "render"
  52. super().__init__(kind)
  53. class JdCrawlerV2:
  54. def __init__(self, drug_dict=None, scheduler=None, driver=None, cumulative_pages=0, cumulative_items=0, cumulative_stored=0, cumulative_skipped=0):
  55. self.driver = driver
  56. self.cumulative_pages = cumulative_pages
  57. self.cumulative_items = cumulative_items
  58. self.cumulative_stored = cumulative_stored
  59. self.cumulative_skipped = cumulative_skipped
  60. self.page_stored = 0
  61. self.register_signal_handler()
  62. self.db = MySQLPoolOn2()
  63. self.ip = None
  64. self.account_name = None
  65. self.login_username = None
  66. self.login_password = None
  67. self.platform = 2
  68. self.pipeline = DrugPipeline("jd")
  69. self.task_dict = drug_dict or {}
  70. self.scheduler = scheduler
  71. self.report_data = {}
  72. self.heartbeat_interval = 30
  73. self.ossuploader = AliyunOSSUploader()
  74. self.start_page = 1
  75. self.end_page = 1
  76. if self.task_dict:
  77. self.get_product_data()
  78. self.success = True
  79. self.is_no_prodcut = 0
  80. self._mouse = (500, 400) # 虚拟鼠标位置(视口坐标)
  81. self._snap_miss = 0 # 连续找不到商品元素的计数
  82. self._snap_local_count = 1 # 本地快照序号
  83. def get_product_data(self):
  84. self.task_id = self.task_dict["id"]
  85. self.company_id = self.task_dict["company_id"]
  86. self.product = self.task_dict["product_name"]
  87. self.product_desc = self.task_dict.get("product_specs", "")
  88. self.brand = self.task_dict.get("product_brand", "")
  89. self.product_keyword = self.task_dict.get("product_keyword", "")
  90. self.collect_task_id = self.task_dict.get("collect_task_id", "")
  91. self.sampling_cycle = self.task_dict.get("sampling_cycle", "")
  92. self.sampling_start_time = self.task_dict.get("sampling_start_time", "")
  93. self.sampling_end_time = self.task_dict.get("sampling_end_time", "")
  94. self.collect_equipment_id = self.task_dict.get("collect_equipment_id", "")
  95. self.account_id = self.task_dict.get("collect_equipment_account_id", "15")
  96. self.collect_region_id = self.task_dict.get("collect_region_id", "")
  97. self.collect_round = self.task_dict.get("collect_round", 1)
  98. self.start_page = self._parse_page(self.task_dict.get("start_page"), 1)
  99. self.end_page = 100
  100. self.report_data = {'task_id': self.task_id, 'platform': self.platform, 'username': self.task_dict.get("username", JD_DEVICE_ID)}
  101. @staticmethod
  102. def _parse_page(value, default=1):
  103. try:
  104. page = int(value)
  105. return page if page >= 1 else default
  106. except (TypeError, ValueError):
  107. return default
  108. @staticmethod
  109. def _get_free_port():
  110. """获取一个当前可用的本地端口,供 Chrome 调试使用。"""
  111. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  112. s.bind(("127.0.0.1", 0))
  113. return s.getsockname()[1]
  114. def init_browser(self):
  115. if self.driver:
  116. logger.info("复用已有浏览器实例")
  117. self._listen_started = False
  118. return
  119. co = ChromiumOptions().set_browser_path(chrome_path)
  120. debug_port = self._get_free_port()
  121. co.set_user_data_path(f"./account_cache/jd/JD_101")
  122. co.set_local_port(debug_port)
  123. co.set_argument(f"--remote-debugging-port={debug_port}")
  124. co.set_argument("--remote-debugging-address=127.0.0.1")
  125. # co.set_argument("--disable-blink-features=AutomationControlled")
  126. co.set_argument("--disable-dev-shm-usage")
  127. co.set_argument("--no-first-run") # 避免首次运行弹窗
  128. co.set_argument("--no-default-browser-check") # 避免默认浏览器检查
  129. if self.ip:
  130. proxy = self.ip.strip()
  131. if not proxy.startswith(("http://", "https://")):
  132. proxy = f"http://{proxy}"
  133. co.set_argument(f"--proxy-server={proxy}")
  134. logger.info("启动浏览器: account=%s, debug_port=%s", self.account_name, debug_port)
  135. self.driver = ChromiumPage(co)
  136. self._listen_started = False
  137. def _start_listen(self):
  138. """登录完成后再开监听,避免干扰登录页/验证码拖动。"""
  139. if self._listen_started or not self.driver:
  140. return
  141. self.driver.listen.start("api?appid=search-pc-java")
  142. self._listen_started = True
  143. logger.info("已启动搜索接口监听")
  144. def register_signal_handler(self):
  145. def handler(signum, frame):
  146. print("\n⚠️ 程序退出")
  147. if self.driver:
  148. self.driver.quit()
  149. sys.exit(0)
  150. signal.signal(signal.SIGINT, handler)
  151. if hasattr(signal, "SIGTERM"):
  152. signal.signal(signal.SIGTERM, handler)
  153. def sleep(self, a, b):
  154. time.sleep(random.uniform(a, b))
  155. def _scroll_page_down(self, delta=900):
  156. """拟人滚轮:真实 CDP mouseWheel 事件,物理签名。"""
  157. try:
  158. self._human_wheel(int(delta))
  159. except Exception:
  160. self.driver.run_js(f"window.scrollBy(0, {int(delta)});")
  161. time.sleep(random.uniform(0.15, 0.35))
  162. def _scroll_next_into_view(self, el):
  163. if not el:
  164. return
  165. try:
  166. self.driver.run_js(
  167. "arguments[0].scrollIntoView({block:'center',behavior:'instant'});",
  168. el,
  169. )
  170. self.sleep(1, 2)
  171. except Exception as e:
  172. logger.warning("滚动到下一页按钮失败: %s", e)
  173. try:
  174. el.scroll.to_see()
  175. except Exception:
  176. pass
  177. def _get_scroll_info(self):
  178. return self.driver.run_js("""
  179. return {
  180. scrollY: window.scrollY || window.pageYOffset || 0,
  181. docH: Math.max(document.body.scrollHeight,
  182. document.documentElement.scrollHeight,
  183. document.body.offsetHeight),
  184. viewH: window.innerHeight || document.documentElement.clientHeight || 800
  185. };
  186. """)
  187. def _find_next_btn(self, timeout=0.3):
  188. try:
  189. return self.driver.ele("text=下一页", timeout=timeout)
  190. except Exception:
  191. return None
  192. def _is_next_btn_visible(self, btn):
  193. if not btn:
  194. return False
  195. try:
  196. return bool(self.driver.run_js(_JS_NEXT_BTN_IN_VIEWPORT, btn))
  197. except Exception:
  198. return False
  199. def _human_click(self, element):
  200. """拟人点击:贝塞尔移动+悬停+遮挡检测+真实 CDP mousePressed/Released。"""
  201. if not element:
  202. return False
  203. try:
  204. return self._human_click_el(element)
  205. except Exception as e:
  206. logger.warning("拟人点击失败,退回 JS click: %s", e)
  207. try:
  208. self.driver.run_js(
  209. "arguments[0].scrollIntoView({block:'center',behavior:'instant'});",
  210. element,
  211. )
  212. self.driver.run_js("arguments[0].click();", element)
  213. return True
  214. except Exception:
  215. try:
  216. element.click()
  217. return True
  218. except Exception:
  219. return False
  220. @staticmethod
  221. def _estimated_price(json_data):
  222. fp = json_data.get("finalPrice")
  223. if isinstance(fp, dict):
  224. return fp.get("estimatedPrice", "") or ""
  225. return ""
  226. def get_heshu(self, full_title):
  227. last_box = None
  228. last_bottle = None
  229. for match in re.finditer(r"(\d+)(盒|瓶)", full_title):
  230. if match.group(2) == '盒':
  231. last_box = match
  232. else: # 瓶
  233. last_bottle = match
  234. if last_box:
  235. return int(last_box.group(1))
  236. elif last_bottle:
  237. return int(last_bottle.group(1))
  238. else:
  239. return 1
  240. def _take_snapshot(self, upload_key, ele):
  241. """在指定标签页截图并上传。"""
  242. # 拟人:滚轮把元素带进视口 → 鼠标移到商品上(像人在看) → 随机停顿
  243. try:
  244. self._human_scroll_el_into_view(ele)
  245. pt = self._el_click_point(ele)
  246. if pt:
  247. self._human_move_to(*pt)
  248. if random.random() < 0.08:
  249. self.sleep(0.4, 0.9) # 偶尔停下来细看
  250. else:
  251. self.sleep(0.05, 0.2)
  252. except Exception:
  253. pass
  254. time.sleep(0.05) # 快照前固定等待(过短可能截到未加载完的图片)
  255. # CDP clip 截图:避免 ele.get_screenshot 触发 Chrome 视口重排导致页面跳动
  256. try:
  257. rect = self.driver.run_js(
  258. "var r=arguments[0].getBoundingClientRect();"
  259. "return [r.left + window.scrollX, r.top + window.scrollY, r.width, r.height,"
  260. "r.top, r.bottom, r.left, r.right, window.innerHeight, window.innerWidth, window.scrollY];",
  261. ele,
  262. )
  263. if not rect or rect[2] <= 0 or rect[3] <= 0:
  264. logger.warning("元素区域异常 upload_key=%s", upload_key)
  265. return ""
  266. x, y, w, h = rect[0], rect[1], rect[2], rect[3]
  267. clip = {"x": x, "y": y, "width": w, "height": h, "scale": 1}
  268. # 元素完整在视口内(垂直+水平)才不开 captureBeyondViewport
  269. in_view = (rect[4] >= 0 and rect[5] <= rect[8] and rect[6] >= 0 and rect[7] <= rect[9])
  270. if in_view:
  271. data = self.driver.run_cdp(
  272. "Page.captureScreenshot", format="jpeg", quality=100, clip=clip, _timeout=12,
  273. )["data"]
  274. else:
  275. data = self.driver.run_cdp(
  276. "Page.captureScreenshot", format="jpeg", quality=100,
  277. captureBeyondViewport=True, clip=clip, _timeout=12,
  278. )["data"]
  279. self.driver.run_js(
  280. "window.scrollTo({top: %d, behavior: 'instant'});" % int(rect[10])
  281. )
  282. jpg_bytes = b64decode(data)
  283. if not jpg_bytes:
  284. logger.warning("截图为空 upload_key=%s", upload_key)
  285. return ""
  286. self._last_snap_bytes = jpg_bytes # 暂存,供 parse 内本地保存
  287. img_url = self.ossuploader.upload_from_bytes(jpg_bytes, str(upload_key))
  288. except Exception:
  289. logger.exception("截图或 OSS 上传失败 upload_key=%s", upload_key)
  290. return ""
  291. if not img_url:
  292. logger.warning("OSS 未返回有效地址 upload_key=%s", upload_key)
  293. return ""
  294. logger.info("截图上传完成 upload_key=%s url=%s", upload_key, img_url)
  295. time.sleep(random.uniform(0.05, 0.15))
  296. return img_url
  297. def parse(self, ware_list, force=False):
  298. unprocessed = []
  299. for w in ware_list:
  300. # 每5条做一次页面级异常巡检,命中立刻中断本页采集
  301. if len(unprocessed) % 5 == 0:
  302. intr = self._interruption()
  303. if intr:
  304. raise _PageInterrupted(intr)
  305. if not isinstance(w, dict):
  306. continue
  307. sku_id = str(w.get("skuId", ""))
  308. if not sku_id:
  309. continue
  310. ele_xpath = "//div[@id='main_search_conter']//div[contains(@class,'_goodsContainer_')]/div[@data-sku=" + "'" + sku_id + "'" + "]"
  311. ele_screen = self.driver.ele("xpath=" + ele_xpath, timeout=0.2)
  312. if not ele_screen and not force:
  313. unprocessed.append(w)
  314. continue
  315. title = w.get("wareName", "")
  316. title = re.sub(r"<[^>]*>", "", title).strip()
  317. color = w.get("color", "")
  318. full_title = title + " " + color
  319. logger.info(full_title)
  320. if self.product not in full_title:
  321. self.is_no_prodcut += 1
  322. continue
  323. if self.brand not in full_title:
  324. self.is_no_prodcut += 1
  325. continue
  326. if self.product_desc:
  327. if self.product_desc in full_title:
  328. crawl_product_desc = self.product_desc
  329. else:
  330. crawl_product_desc = ""
  331. title = full_title
  332. else:
  333. crawl_product_desc = ""
  334. title = full_title
  335. if "+[" in title:
  336. continue
  337. self.is_no_prodcut = 0
  338. status = 1
  339. if self.product_keyword:
  340. search_keyword_list = self.product_keyword.split(",")
  341. for search_keyword in search_keyword_list:
  342. if search_keyword.strip() not in title:
  343. status = 0
  344. if status == 0:
  345. continue
  346. logger.info(f"商品名:{title}")
  347. sku_id = w.get("skuId", "")
  348. sales = w.get("totalSales", "")
  349. shop_id = w.get("shopId", "")
  350. shop_name = w.get("shopName", "")
  351. heshu_count = self.get_heshu(full_title)
  352. final_price = self._estimated_price(w)
  353. jd_price = w.get("jdPrice", "")
  354. item_url = f"https://item.jd.com/{sku_id}.html"
  355. low_price = final_price if final_price else jd_price
  356. # 获取列表页快照
  357. upload_key = hashlib.md5(item_url.encode("utf-8")).hexdigest()
  358. snap_url = ""
  359. if ele_screen:
  360. for i in range(3):
  361. snap_url = self._take_snapshot(upload_key, ele_screen)
  362. if snap_url != "":
  363. break
  364. else:
  365. logger.warning(f"未找到商品元素无法截图: {sku_id}")
  366. try:
  367. price = Decimal(str(low_price)).quantize(Decimal("0.00"))
  368. except (InvalidOperation, ValueError):
  369. price = Decimal("0.00")
  370. # 本地留存快照用于人工检查质量: img/001_价格.jpg
  371. if snap_url and getattr(self, '_last_snap_bytes', None):
  372. try:
  373. local_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "img")
  374. os.makedirs(local_dir, exist_ok=True)
  375. local_name = f"{self._snap_local_count:03d}_{price}.jpg"
  376. with open(os.path.join(local_dir, local_name), "wb") as lf:
  377. lf.write(self._last_snap_bytes)
  378. self._snap_local_count += 1
  379. self._last_snap_bytes = None
  380. except Exception:
  381. pass
  382. item_url = f"https://item.jd.com/{sku_id}.html"
  383. mall_url = f"https://mall.jd.com/index-{shop_id}.html?from=pc"
  384. # 字段与 yaofangwang_crawl 对齐;键顺序须与 commons.sql_data.RETRIEVE_SCRAPE_INSERT_COLUMNS 一致
  385. now_ts = time.strftime("%Y-%m-%d %H:%M:%S")
  386. product = {
  387. "platform": self.platform,
  388. "item_id": sku_id,
  389. "enterprise_id": self.company_id,
  390. "product_name": title,
  391. "spec": crawl_product_desc,
  392. "one_price": "",
  393. "detail_url": item_url,
  394. "shop_name": shop_name,
  395. "anonymous_store_name": "",
  396. "shop_url": mall_url,
  397. "city_name": "",
  398. "city_id": "",
  399. "province_name": "",
  400. "province_id": "",
  401. "shipment_city_name": "",
  402. "shipment_city_id": "",
  403. "shipment_province_name": "",
  404. "shipment_province_id": "",
  405. "area_info": "",
  406. "factory_name": "",
  407. "scrape_date": time.strftime("%Y-%m-%d"),
  408. "price": price,
  409. "sales": sales,
  410. "stock_count": "",
  411. "snapshot_url": snap_url,
  412. "approval_num": "",
  413. "produced_time": "",
  414. "deadline": "",
  415. "update_time": now_ts,
  416. "insert_time": now_ts,
  417. "number": heshu_count,
  418. "product_brand": self.brand or "",
  419. "collect_task_id": self.collect_task_id,
  420. "task_id": self.task_id,
  421. "search_name": self.product,
  422. "company_name": "",
  423. "collect_config_info": json.dumps(
  424. {
  425. "sampling_cycle": self.sampling_cycle,
  426. "sampling_start_time": self.sampling_start_time,
  427. "sampling_end_time": self.sampling_end_time,
  428. }
  429. ),
  430. "account_id": self.account_id,
  431. "collect_region_id": self.collect_region_id,
  432. "collect_round": self.collect_round,
  433. "is_sold_out": 0
  434. }
  435. try:
  436. affected_rows = self.pipeline.storge_data(product)
  437. if affected_rows and affected_rows > 0:
  438. self.page_stored += 1
  439. logger.info("%s", json.dumps(product, ensure_ascii=False, default=str))
  440. except Exception as e:
  441. logger.exception("写入数据库失败: %s", e)
  442. return unprocessed
  443. @staticmethod
  444. def _response_has_ware_list(data):
  445. if not isinstance(data, dict):
  446. return False
  447. inner_data = data.get("data")
  448. if not isinstance(inner_data, dict):
  449. return False
  450. return bool(inner_data.get("wareList"))
  451. def fetch_items_once(self, timeout=FETCH_TIMEOUT_FIRST):
  452. wares = []
  453. for resp in self.driver.listen.steps(timeout=timeout):
  454. try:
  455. data = resp.response.body
  456. if not self._response_has_ware_list(data):
  457. continue
  458. ware_list = data["data"]["wareList"]
  459. wares.extend(ware_list)
  460. except Exception as e:
  461. logger.warning("解析监听响应失败: %s", e)
  462. return wares
  463. def clear_listen_buffer(self, rounds=LISTEN_CLEAR_ROUNDS, timeout=LISTEN_CLEAR_TIMEOUT):
  464. try:
  465. for _ in range(rounds):
  466. resps = list(self.driver.listen.steps(timeout=timeout))
  467. if not resps:
  468. break
  469. logger.debug("监听缓冲已清空")
  470. except Exception as e:
  471. logger.debug("清空监听缓冲失败: %s", e)
  472. def collect_full_page_items(self, max_steps=JD_COLLECT_MAX_STEPS):
  473. """单次循环:边滑动边收数据,到底 / 看见「下一页」即停。"""
  474. # 页首巡检 + 清理误开标签页
  475. self._close_unexpected_tabs()
  476. intr = self._interruption()
  477. if intr:
  478. raise _PageInterrupted(intr)
  479. pending_wares = []
  480. total_n = 0
  481. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_FIRST)
  482. total_n += len(new_wares)
  483. pending_wares.extend(new_wares)
  484. pending_wares = self.parse(pending_wares)
  485. stagnant = 0
  486. last_scroll_y = None
  487. for step in range(max_steps):
  488. # 每步巡检:中途出验证码/被踢立刻中断本页
  489. intr = self._interruption()
  490. if intr:
  491. raise _PageInterrupted(intr)
  492. next_btn = self._find_next_btn(timeout=0.3)
  493. if self._is_next_btn_visible(next_btn):
  494. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  495. total_n += len(new_wares)
  496. pending_wares.extend(new_wares)
  497. pending_wares = self.parse(pending_wares)
  498. self.parse(pending_wares, force=True)
  499. return total_n, next_btn
  500. info = self._get_scroll_info()
  501. scroll_y = info["scrollY"]
  502. doc_h = info["docH"]
  503. view_h = info["viewH"]
  504. at_bottom = (scroll_y + view_h >= doc_h - 20)
  505. if last_scroll_y is not None and abs(scroll_y - last_scroll_y) < 8:
  506. stagnant += 1
  507. else:
  508. stagnant = 0
  509. last_scroll_y = scroll_y
  510. if at_bottom and stagnant >= 2:
  511. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  512. total_n += len(new_wares)
  513. pending_wares.extend(new_wares)
  514. pending_wares = self.parse(pending_wares)
  515. self.parse(pending_wares, force=True)
  516. next_btn = self._find_next_btn(timeout=2)
  517. if next_btn:
  518. self._scroll_next_into_view(next_btn)
  519. return total_n, next_btn
  520. logger.info("已到页面底部且未发现下一页,停止滑动")
  521. return total_n, None
  522. # 滚动前约1/4概率先把鼠标移近滚动区域(真人边读边滚,鼠标不是死的)
  523. if random.random() < 0.25:
  524. try:
  525. vw, vh = self.driver.run_js("return [window.innerWidth, window.innerHeight];")
  526. self._human_move_to(
  527. vw * random.uniform(0.25, 0.75), vh * random.uniform(0.3, 0.7)
  528. )
  529. except Exception:
  530. pass
  531. self._scroll_page_down(random.randint(600, 1100))
  532. if random.random() < 0.15:
  533. self._human_wheel(-random.randint(60, 140))
  534. self.sleep(0.1, 0.35)
  535. pending_wares = self.parse(pending_wares)
  536. if step % 3 == 2:
  537. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  538. total_n += len(new_wares)
  539. pending_wares.extend(new_wares)
  540. pending_wares = self.parse(pending_wares)
  541. new_wares = self.fetch_items_once(timeout=FETCH_TIMEOUT_SCROLL)
  542. total_n += len(new_wares)
  543. pending_wares.extend(new_wares)
  544. pending_wares = self.parse(pending_wares)
  545. self.parse(pending_wares, force=True)
  546. next_btn = self._find_next_btn(timeout=3)
  547. if next_btn and not self._is_next_btn_visible(next_btn):
  548. self._scroll_next_into_view(next_btn)
  549. return total_n, next_btn
  550. def get_account(self):
  551. sql_account = """
  552. SELECT *
  553. FROM `retrieve_collect_equipment_account`
  554. WHERE `id` = %s
  555. and `status` = 0
  556. """
  557. account_list = self.db.select_data(sql_account, self.account_id)
  558. if not account_list:
  559. return False
  560. account_dict = account_list[0]
  561. print(account_dict)
  562. self.ip = account_dict.get("ip")
  563. self.account_name = account_dict.get("username")
  564. self.login_username = account_dict.get("phone") or account_dict.get("username", "")
  565. self.login_password = account_dict.get("password", "")
  566. logger.info("获取到账号: %s, ip: %s", self.account_name, self.ip)
  567. return True
  568. def disable_account(self):
  569. update_sql = f""" UPDATE `retrieve_collect_equipment_account` SET `status`= %s WHERE `name` = %s; """
  570. self.db.execute(update_sql, (1, self.account_name))
  571. def _build_search_keyword(self):
  572. parts = [p for p in (self.brand, self.product, self.product_desc) if p]
  573. return " ".join(parts).strip() or self.product
  574. def _is_logged_out(self):
  575. return bool(self.driver.ele("xpath=//*[@class='link-login']", timeout=2))
  576. # ==================== 拟人化:鼠标层 ====================
  577. def _human_move_to(self, x, y, duration=None):
  578. """贝塞尔曲线移动鼠标(ease-out),沿途产生真实 CDP mousemove 事件。"""
  579. x0, y0 = self._mouse
  580. dist = ((x - x0) ** 2 + (y - y0) ** 2) ** 0.5
  581. if dist < 2:
  582. self._mouse = (x, y)
  583. return
  584. c1 = (
  585. x0 + (x - x0) * random.uniform(0.15, 0.4) + random.uniform(-0.15, 0.15) * dist,
  586. y0 + (y - y0) * random.uniform(0.15, 0.4) + random.uniform(-0.15, 0.15) * dist,
  587. )
  588. c2 = (
  589. x0 + (x - x0) * random.uniform(0.6, 0.9) + random.uniform(-0.15, 0.15) * dist,
  590. y0 + (y - y0) * random.uniform(0.6, 0.9) + random.uniform(-0.15, 0.15) * dist,
  591. )
  592. steps = max(10, min(35, int(dist / 25)))
  593. if duration is None:
  594. duration = max(0.08, min(0.45, dist / random.uniform(2200, 3800)))
  595. for i in range(1, steps + 1):
  596. t = 1 - (1 - i / steps) ** 2
  597. mt = 1 - t
  598. px = mt ** 3 * x0 + 3 * mt ** 2 * t * c1[0] + 3 * mt * t ** 2 * c2[0] + t ** 3 * x
  599. py = mt ** 3 * y0 + 3 * mt ** 2 * t * c1[1] + 3 * mt * t ** 2 * c2[1] + t ** 3 * y
  600. self.driver.run_cdp("Input.dispatchMouseEvent", type="mouseMoved", x=px, y=py)
  601. time.sleep(duration / steps * random.uniform(0.6, 1.4))
  602. self._mouse = (x, y)
  603. def _el_click_point(self, el):
  604. """元素可视区域内的随机点击点(视口坐标),不在视口返回 None。"""
  605. try:
  606. r = self.driver.run_js(
  607. "var r=arguments[0].getBoundingClientRect();"
  608. "return [r.left, r.top, r.width, r.height, window.innerWidth, window.innerHeight];",
  609. el,
  610. )
  611. except Exception:
  612. return None
  613. if not r:
  614. return None
  615. left, top, w, h, vw, vh = r
  616. x = left + w * random.uniform(0.25, 0.75)
  617. y = top + h * random.uniform(0.3, 0.7)
  618. if not (2 <= x <= vw - 2 and 2 <= y <= vh - 2):
  619. return None
  620. return (x, y)
  621. def _hit_check(self, el, x, y):
  622. """确认 (x,y) 处顶层元素是目标本身/子元素;否则返回遮挡物描述。"""
  623. try:
  624. return self.driver.run_js(
  625. "var t=arguments[2];"
  626. "var h=document.elementFromPoint(arguments[0],arguments[1]);"
  627. "if(!h) return 'none';"
  628. "if(h===t||t.contains(h)||h.contains(t)) return 'ok';"
  629. "var d=(h.tagName||'').toLowerCase();"
  630. "if(h.id) d+='#'+h.id;"
  631. "if(h.className&&typeof h.className==='string') d+='.'+h.className.trim().split(' ')[0];"
  632. "return d;",
  633. x, y, el,
  634. )
  635. except Exception:
  636. return "err"
  637. def _human_click_el(self, el):
  638. """真人点击:自然滚到元素→贝塞尔移动→悬停→遮挡检测→真实 mousePressed/Released。"""
  639. for attempt in range(3):
  640. if not self._human_scroll_el_into_view(el):
  641. try:
  642. self.driver.run_js(
  643. "arguments[0].scrollIntoView({block:'center',behavior:'instant'});", el
  644. )
  645. except Exception:
  646. pass
  647. self.sleep(0.15, 0.35)
  648. self._wait_scroll_settle()
  649. pt = self._el_click_point(el)
  650. if not pt:
  651. return False
  652. self._human_move_to(*pt)
  653. self.sleep(0.2, 0.6)
  654. hit = self._hit_check(el, *pt)
  655. if hit == "ok":
  656. x, y = pt
  657. self.driver.run_cdp(
  658. "Input.dispatchMouseEvent", type="mousePressed",
  659. x=x, y=y, button="left", clickCount=1,
  660. )
  661. time.sleep(random.uniform(0.05, 0.12))
  662. self.driver.run_cdp(
  663. "Input.dispatchMouseEvent", type="mouseReleased",
  664. x=x, y=y, button="left", clickCount=1,
  665. )
  666. self._close_unexpected_tabs()
  667. return True
  668. logger.info("点击被遮挡(%s),按 Esc 关弹层重试(%d/3)", hit, attempt + 1)
  669. self._press_escape()
  670. self.sleep(0.5, 1.2)
  671. if attempt >= 1:
  672. self._hide_floater_at(*pt, el)
  673. logger.warning("多次重试仍被遮挡,放弃本次点击")
  674. return False
  675. def _mouse_wander(self):
  676. """采集间隙随机移动鼠标,模拟真人浏览时的手部小动作。"""
  677. try:
  678. loc = self.driver.run_js(
  679. "return [Math.round(window.scrollX + innerWidth * Math.random())," +
  680. " Math.round(window.scrollY + innerHeight * Math.random())];"
  681. )
  682. self.driver.actions.move_to(loc, duration=random.uniform(0.4, 1.0))
  683. except Exception:
  684. pass
  685. # ==================== 拟人化:滚轮层 ====================
  686. def _human_wheel(self, delta):
  687. """真实滚轮事件:120/格物理签名,1~4格一串,格间隔30~70ms。"""
  688. try:
  689. vw, vh = self.driver.run_js("return [window.innerWidth, window.innerHeight];")
  690. except Exception:
  691. vw, vh = 1200, 800
  692. x, y = self._mouse
  693. if not (0 <= x <= vw and 0 <= y <= vh):
  694. self._human_move_to(vw * random.uniform(0.3, 0.7), vh * random.uniform(0.3, 0.6))
  695. x, y = self._mouse
  696. sign = 1 if delta >= 0 else -1
  697. notches = max(1, round(abs(delta) / 120))
  698. while notches > 0:
  699. burst = min(notches, random.randint(1, 4))
  700. for _ in range(burst):
  701. self.driver.run_cdp(
  702. "Input.dispatchMouseEvent", type="mouseWheel",
  703. x=x, y=y, deltaX=0, deltaY=sign * 120,
  704. )
  705. time.sleep(random.uniform(0.03, 0.07))
  706. notches -= burst
  707. if notches > 0:
  708. time.sleep(random.uniform(0.04, 0.10))
  709. time.sleep(random.uniform(0.05, 0.12))
  710. def _human_scroll_el_into_view(self, el, max_tries=6):
  711. """用自然滚轮把元素带进视口(不用 scrollIntoView 瞬移)。"""
  712. for _ in range(max_tries):
  713. r = self.driver.run_js(
  714. "var r=arguments[0].getBoundingClientRect();"
  715. "return [r.top, r.bottom, r.left, r.right, window.innerHeight, window.innerWidth];",
  716. el,
  717. )
  718. if not r:
  719. return False
  720. top, bottom, left, right, vh, vw = r
  721. # ---- 垂直方向 ----
  722. el_h = bottom - top
  723. if el_h > vh - 60:
  724. # 高元素:视口放不下,顶部对齐即可
  725. v_ok = (8 <= top <= 120)
  726. v_delta = 0 if v_ok else int(top - 40)
  727. else:
  728. # 完整可见才算到位
  729. v_ok = (top >= 8 and bottom <= vh - 8)
  730. v_delta = 0 if v_ok else int((top + bottom) / 2 - vh / 2)
  731. # ---- 水平方向 ----
  732. el_w = right - left
  733. if el_w > vw - 16:
  734. # 宽元素:视口放不下,左边缘对齐即可
  735. h_ok = (0 <= left <= 30)
  736. h_delta = 0 if h_ok else int(left - 10)
  737. else:
  738. # 完整可见才算到位
  739. h_ok = (left >= 0 and right <= vw - 4)
  740. h_delta = 0 if h_ok else int((left + right) / 2 - vw / 2)
  741. if v_ok and h_ok:
  742. return True
  743. # 先调垂直(自然滚轮),再调水平(window.scrollBy:左右滚动没有滚轮物理事件)
  744. if v_delta != 0:
  745. self._human_wheel(max(-600, min(600, v_delta)))
  746. if h_delta != 0:
  747. # 水平滚动:使用 deltaX(触控板/高端鼠标本身就有水平滚轮,不做 Shift 修饰)
  748. sign = 1 if h_delta >= 0 else -1
  749. notches = max(1, round(abs(h_delta) / 120))
  750. while notches > 0:
  751. burst = min(notches, random.randint(1, 4))
  752. for _ in range(burst):
  753. self.driver.run_cdp(
  754. "Input.dispatchMouseEvent", type="mouseWheel",
  755. x=self._mouse[0], y=self._mouse[1], deltaX=sign * 120, deltaY=0,
  756. )
  757. time.sleep(random.uniform(0.03, 0.07))
  758. notches -= burst
  759. if notches > 0:
  760. time.sleep(random.uniform(0.04, 0.10))
  761. time.sleep(random.uniform(0.05, 0.12))
  762. self._wait_scroll_settle() # 等页面滚动惯性完全停下再读坐标,避免振荡
  763. return False
  764. def _wait_scroll_settle(self, timeout=3):
  765. """等滚动惯性停下再取坐标。"""
  766. t0 = time.time()
  767. last = None
  768. while time.time() - t0 < timeout:
  769. try:
  770. y = self.driver.run_js("return Math.round(window.scrollY);")
  771. except Exception:
  772. return
  773. if last is not None and abs(y - last) < 2:
  774. return
  775. last = y
  776. time.sleep(0.15)
  777. # ==================== 拟人化:键盘/浮层/标签页 ====================
  778. def _human_type_digits(self, el, text):
  779. """拟人输入数字:点击聚焦→Ctrl+A全选→逐键 CDP rawKeyDown+char+keyUp。"""
  780. if not self._human_click_el(el):
  781. el.click()
  782. time.sleep(random.uniform(0.15, 0.4))
  783. self.driver.run_cdp(
  784. "Input.dispatchKeyEvent", type="rawKeyDown",
  785. key="a", code="KeyA", modifiers=2, windowsVirtualKeyCode=65,
  786. )
  787. self.driver.run_cdp(
  788. "Input.dispatchKeyEvent", type="keyUp",
  789. key="a", code="KeyA", modifiers=2, windowsVirtualKeyCode=65,
  790. )
  791. time.sleep(random.uniform(0.05, 0.15))
  792. for ch in text:
  793. vk = ord(ch)
  794. code = f"Digit{ch}"
  795. self.driver.run_cdp(
  796. "Input.dispatchKeyEvent", type="rawKeyDown",
  797. key=ch, code=code, windowsVirtualKeyCode=vk,
  798. )
  799. self.driver.run_cdp(
  800. "Input.dispatchKeyEvent", type="char",
  801. text=ch, key=ch, code=code, windowsVirtualKeyCode=vk,
  802. )
  803. self.driver.run_cdp(
  804. "Input.dispatchKeyEvent", type="keyUp",
  805. key=ch, code=code, windowsVirtualKeyCode=vk,
  806. )
  807. time.sleep(random.uniform(0.06, 0.18))
  808. def _search_via_box(self, keyword, kw):
  809. """拟人搜索路径:搜索框点击→全选清空→insertText注入中文→回车提交。"""
  810. search_url = f"https://search.jd.com/Search?keyword={kw}&enc=utf-8&wq={kw}"
  811. self._close_unexpected_tabs()
  812. url_now = self.driver.url or ""
  813. if "www.jd.com" not in url_now and "search.jd.com" not in url_now:
  814. self.driver.get("https://www.jd.com/", timeout=15)
  815. self.sleep(3, 5)
  816. box = None
  817. for loc in ("css=#key", "css=input[placeholder*='\u641c\u7d22']",
  818. "css=.search-m input[type='text']", "css=input[name='keyword']"):
  819. box = self.driver.ele(loc, timeout=2)
  820. if box:
  821. break
  822. if not box:
  823. box = self.driver.run_js(
  824. "var ins=document.querySelectorAll(\"input[type='text'],input:not([type])\");"
  825. "for(var i=0;i<ins.length;i++){var r=ins[i].getBoundingClientRect();"
  826. "if(r.width>100&&r.height>15&&r.top>=-50&&r.top<window.innerHeight){return ins[i];}}"
  827. "return null;"
  828. )
  829. if not box:
  830. logger.info("未找到搜索框(url=%s),退回 URL 直达", self.driver.url)
  831. self.driver.get(search_url, timeout=15)
  832. return search_url
  833. self._human_click_el(box)
  834. self.sleep(0.3, 0.8)
  835. self.driver.run_cdp(
  836. "Input.dispatchKeyEvent", type="rawKeyDown",
  837. key="a", code="KeyA", modifiers=2, windowsVirtualKeyCode=65,
  838. )
  839. self.driver.run_cdp(
  840. "Input.dispatchKeyEvent", type="keyUp",
  841. key="a", code="KeyA", modifiers=2, windowsVirtualKeyCode=65,
  842. )
  843. self.sleep(0.1, 0.3)
  844. # 拟人输入中文:CDP insertText 是模拟 IME 输入的唯一可靠方式
  845. # Chrome 对 CDP 输入事件标记 isTrusted=true,页面无法区分真人和 CDP
  846. # 中文逐键模拟不可行(需要用 IME composition 事件,CDP 不支持),
  847. # 剪贴板方案反而不自然(真人不会凭空有这段文字在剪贴板里)
  848. self.driver.run_cdp("Input.insertText", text=keyword)
  849. self.sleep(0.5, 1.2)
  850. self.driver.run_cdp("Input.dispatchKeyEvent", type="rawKeyDown", key="Enter", code="Enter", windowsVirtualKeyCode=13)
  851. self.driver.run_cdp("Input.dispatchKeyEvent", type="char", text="\r", key="Enter", code="Enter", windowsVirtualKeyCode=13)
  852. self.driver.run_cdp("Input.dispatchKeyEvent", type="keyUp", key="Enter", code="Enter", windowsVirtualKeyCode=13)
  853. self.sleep(4, 7)
  854. if kw in (self.driver.url or ""):
  855. logger.info("已通过搜索框搜索「%s」", keyword)
  856. return search_url
  857. logger.info("搜索框提交后未跳转(url=%s),退回 URL 直达", self.driver.url)
  858. self.driver.get(search_url, timeout=15)
  859. return search_url
  860. def _press_escape(self):
  861. """真实 CDP Esc 按键。"""
  862. try:
  863. self.driver.run_cdp(
  864. "Input.dispatchKeyEvent", type="rawKeyDown",
  865. key="Escape", code="Escape", windowsVirtualKeyCode=27,
  866. )
  867. self.driver.run_cdp(
  868. "Input.dispatchKeyEvent", type="keyUp",
  869. key="Escape", code="Escape", windowsVirtualKeyCode=27,
  870. )
  871. except Exception:
  872. pass
  873. def _hide_floater_at(self, x, y, el):
  874. """把 (x,y) 处挡住目标的浮层 DOM 隐藏(Esc 关不掉的浮层专用)。"""
  875. try:
  876. res = self.driver.run_js(
  877. "var t=arguments[2];"
  878. "var h=document.elementFromPoint(arguments[0],arguments[1]);"
  879. "if(!h||h===t||t.contains(h)||h.contains(t)) return 'ok';"
  880. "var n=h;"
  881. "while(n&&n!==document.body){"
  882. "var p=getComputedStyle(n).position;"
  883. "if(p==='fixed'||p==='absolute'){n.style.display='none';return 'hidden';}"
  884. "n=n.parentElement;}"
  885. "h.style.display='none';return 'hidden-el';",
  886. x, y, el,
  887. )
  888. logger.info("浮层已隐藏(%s)", res)
  889. except Exception:
  890. pass
  891. def _close_unexpected_tabs(self):
  892. """点击后误开新标签页(调查问卷等),关掉并回到原标签。"""
  893. try:
  894. ids = list(self.driver.tab_ids or [])
  895. mine = self.driver.tab_id
  896. extra = [t for t in ids if t != mine]
  897. if not extra:
  898. return
  899. for t in extra:
  900. logger.info("检测到误开新标签页,关闭: %s", t)
  901. try:
  902. self.driver.close_tabs(t)
  903. except Exception:
  904. pass
  905. try:
  906. self.driver.activate_tab(mine)
  907. except Exception:
  908. pass
  909. except Exception:
  910. pass
  911. # ==================== 异常检测 ====================
  912. def _captcha_marker(self):
  913. """命中验证码特征返回描述,否则 None。"""
  914. url = (self.driver.url or "").lower()
  915. if "passport.jd.com" in url:
  916. return None
  917. for key in CAPTCHA_URL_KEYS:
  918. if key in url:
  919. return f"url:{key}"
  920. html = self.driver.html or ""
  921. for marker in CAPTCHA_MARKERS:
  922. if marker in html:
  923. return f"html:{marker}"
  924. return None
  925. def _has_captcha(self):
  926. return self._captcha_marker() is not None
  927. def _interruption(self):
  928. """页面级异常判断:返回 'captcha'/'kicked'/None。"""
  929. url = (self.driver.url or "").lower()
  930. if "passport.jd.com" in url:
  931. return "kicked"
  932. for key in CAPTCHA_URL_KEYS:
  933. if key in url:
  934. return "captcha"
  935. html = self.driver.html or ""
  936. for marker in CAPTCHA_MARKERS:
  937. if marker in html:
  938. return "captcha"
  939. if self.driver.ele("xpath=//*[@class='link-login']", timeout=0.25):
  940. return "kicked"
  941. return None
  942. def _is_empty_result(self):
  943. """空结果页(软性限流):搜索页框架在但主商品区为空。"""
  944. url_now = (self.driver.url or "").lower()
  945. if "passport.jd.com" in url_now or any(k in url_now for k in CAPTCHA_URL_KEYS):
  946. return False
  947. try:
  948. html = self.driver.html or ""
  949. except Exception:
  950. return False
  951. return "\u6682\u672a\u627e\u5230\u76f8\u5173\u5546\u54c1" in html
  952. # ==================== 恢复流程 ====================
  953. def _reposition_to_page(self, page_no, kw):
  954. """导航回指定页码。"""
  955. if page_no <= 1:
  956. self.driver.refresh()
  957. self.sleep(4, 6)
  958. return True
  959. search_url = f"https://search.jd.com/Search?keyword={kw}&enc=utf-8&wq={kw}"
  960. self.driver.get(search_url, timeout=15)
  961. self.sleep(4, 6)
  962. return self._jump_to_page(page_no)
  963. def _wait_captcha_solved(self, timeout=600):
  964. """验证码等待:不操作页面,只轮询,等人工完成。"""
  965. logger.info("[验证码] 页面不做任何操作,一直等人工完成验证……")
  966. t0 = time.time()
  967. last_tip = 0
  968. while time.time() - t0 < timeout:
  969. if self._is_logged_out():
  970. self.sleep(3, 5)
  971. if self._is_logged_out():
  972. logger.info("[验证码] 等待期间变成未登录,转登录流程")
  973. return "kicked"
  974. continue
  975. if not self._has_captcha():
  976. self.sleep(2, 3)
  977. if not self._has_captcha():
  978. logger.info("[验证码] 人工验证已通过")
  979. return True
  980. if time.time() - last_tip > 20:
  981. last_tip = time.time()
  982. logger.info("[验证码] 等待人工验证中…… 已等待 %ds", int(time.time() - t0))
  983. time.sleep(3)
  984. logger.warning("[验证码] 等待人工验证超时")
  985. return False
  986. def _recover_from_captcha(self, page_no, kw):
  987. """验证码恢复:等人工验证通过后跳回原页续采。"""
  988. result = self._wait_captcha_solved()
  989. if result == "kicked":
  990. return self._recover_from_kick(page_no, kw)
  991. if not result:
  992. return False
  993. return self._reposition_to_page(page_no, kw)
  994. def _recover_from_kick(self, page_no, kw):
  995. """被踢恢复:先试自动登录,失败则等人工扫码,成功后跳回原页。"""
  996. logger.info("[被踢] 第 %d 页检测到被踢出登录", page_no)
  997. # 先试自动登录
  998. if self.login_password and self.login_username:
  999. if self._ensure_logged_in():
  1000. logger.info("[被踢] 自动登录成功,跳回第 %d 页", page_no)
  1001. return self._reposition_to_page(page_no, kw)
  1002. # 自动登录失败/无凭据:等人工扫码
  1003. logger.info("[被踢] 请在浏览器中扫码登录,脚本将持续等待……")
  1004. t0 = time.time()
  1005. while time.time() - t0 < 3600:
  1006. url = self.driver.url or ""
  1007. if "passport.jd.com" not in url:
  1008. self.sleep(2, 4)
  1009. if not self._is_logged_out():
  1010. logger.info("[被踢] 人工登录成功,跳回第 %d 页", page_no)
  1011. return self._reposition_to_page(page_no, kw)
  1012. time.sleep(5)
  1013. logger.warning("[被踢] 等待人工登录超时")
  1014. return False
  1015. def _recover_from_empty(self, page_no, kw):
  1016. """空页恢复:像人一样刷新重试,访问频繁时切长间隔。"""
  1017. logger.info("[空页] 第 %d 页搜索返回空结果,像人一样刷新重试", page_no)
  1018. for attempt in range(1, 4):
  1019. if "\u8bbf\u95ee\u9891\u7e41" in (self.driver.html or ""):
  1020. wait = {1: 90, 2: 180, 3: 300}.get(attempt, 300)
  1021. logger.info("[空页] 检测到访问频繁,等 %ds 后重试", wait)
  1022. time.sleep(wait)
  1023. else:
  1024. self.sleep(18, 35)
  1025. self.driver.refresh()
  1026. self.sleep(6, 10)
  1027. if not self._is_empty_result() and not self._has_captcha():
  1028. logger.info("[空页] 第 %d 次刷新后数据恢复", attempt)
  1029. return True
  1030. logger.info("[空页] 第 %d 次刷新后仍为空", attempt)
  1031. logger.warning("[空页] 多次刷新仍无数据,停止(今日额度可能已尽)")
  1032. return False
  1033. def _error_report(self, data):
  1034. self.report_data.update(data)
  1035. if self.scheduler:
  1036. self.scheduler.stop()
  1037. self.scheduler.post_report(self.report_data)
  1038. def _success_report(self, data):
  1039. self.report_data.update(data)
  1040. if self.scheduler:
  1041. self.scheduler.post_report(self.report_data)
  1042. def post_report(self, data):
  1043. url = "http://192.168.2.246:8080/api/collect_task/report"
  1044. print('传给返回接口的数据', data)
  1045. headers = {'X-Crawler-Token': CRAWLER_TOKEN}
  1046. response = requests.post(url, json=data, headers=headers)
  1047. result = response.json()
  1048. if result.get('code') != 'success':
  1049. logger.info(f'翻页回传:{result}')
  1050. if self.scheduler:
  1051. self.scheduler.stop()
  1052. print(f'任务进度上传 {result}')
  1053. def perform_jd_login(self):
  1054. """
  1055. 使用已有浏览器实例执行京东账号密码登录(含滑块验证码)。
  1056. 成功返回 True,失败返回 False。
  1057. """
  1058. username = self.login_username
  1059. password = self.login_password
  1060. login_url = "https://passport.jd.com/new/login.aspx"
  1061. self.driver.get(login_url)
  1062. input_name = self.driver.ele("xpath=//input[@id='loginname']", timeout=15)
  1063. if not input_name:
  1064. print("未找到用户名输入框")
  1065. return False
  1066. input_name.input(username)
  1067. time.sleep(random.uniform(1.5, 2.5))
  1068. input_pass = self.driver.ele("xpath://input[@name='nloginpwd']", timeout=5)
  1069. if not input_pass:
  1070. print("未找到密码输入框")
  1071. return False
  1072. input_pass.input(password)
  1073. time.sleep(random.uniform(1.5, 2.5))
  1074. login_btn = self.driver.ele("xpath://a[@id='loginsubmit']", timeout=5)
  1075. if not login_btn:
  1076. print("未找到登录按钮")
  1077. return False
  1078. login_btn.click()
  1079. time.sleep(random.uniform(3, 5))
  1080. if not handle_jd_slider_captcha(self.driver):
  1081. print("滑块验证码未通过")
  1082. return False
  1083. return True
  1084. def _ensure_logged_in(self):
  1085. """未登录时自动走登录流程(账号密码 + 滑块)。"""
  1086. if not self._is_logged_out():
  1087. return True
  1088. logger.info("检测到未登录,开始自动登录: %s", self.account_name)
  1089. ok = self.perform_jd_login()
  1090. if ok and not self._is_logged_out():
  1091. logger.info("自动登录成功: %s", self.account_name)
  1092. return True
  1093. logger.error("自动登录失败: %s", self.account_name)
  1094. return False
  1095. def _check_page_blocked(self):
  1096. html = self.driver.html or ""
  1097. if "抱歉由于访问频繁导致无法搜索" in html:
  1098. logger.error("账号无法搜索(访问频繁)")
  1099. self.success = False
  1100. return True
  1101. return False
  1102. def _jump_to_page(self, target_page):
  1103. """跳转到指定页码,并清空跳转前的监听残留。"""
  1104. to_page_input = self.driver.ele(
  1105. "xpath=//input[contains(@class, 'pagination-input')] | //div[contains(@class,'_pagination_toPageNum_')]//input[@type='text']",
  1106. timeout=3,
  1107. )
  1108. if not to_page_input:
  1109. logger.warning("未找到跳页输入框,无法跳转到第 %s 页", target_page)
  1110. return False
  1111. self.clear_listen_buffer()
  1112. # 拟人输入:逐键 CDP 打字(失败自动退回直接赋值)
  1113. try:
  1114. self._human_type_digits(to_page_input, str(target_page))
  1115. except Exception:
  1116. to_page_input.clear()
  1117. to_page_input.input(str(target_page))
  1118. self.sleep(0.4, 0.8)
  1119. confirm_btn = self.driver.ele("xpath=//button[contains(@class, 'pagination-jump-btn')]", timeout=1)
  1120. if confirm_btn:
  1121. self.driver.run_js("arguments[0].click();", confirm_btn)
  1122. else:
  1123. self.driver.actions.key_down("enter").key_up("enter")
  1124. self.sleep(1.5, 2.5)
  1125. self.clear_listen_buffer()
  1126. logger.info("已跳转到第 %s 页", target_page)
  1127. return True
  1128. def _go_next_page(self, next_btn):
  1129. self.clear_listen_buffer()
  1130. if not self._human_click(next_btn):
  1131. logger.warning("点击下一页失败")
  1132. return False
  1133. self._close_unexpected_tabs()
  1134. self.sleep(0.6, 1.2)
  1135. return True
  1136. def crawl(self):
  1137. total = 0
  1138. keyword = self._build_search_keyword()
  1139. # 如果已在京东页面(搜索页/首页)直接用当前页的搜索框,不必每次都回首页
  1140. url_now = (self.driver.url or "").lower()
  1141. if "jd.com" not in url_now:
  1142. self.driver.get("https://www.jd.com/", timeout=15)
  1143. self.sleep(3, 5)
  1144. if self._is_logged_out():
  1145. # 打开扫码登录页,等人工扫码(实时轮询,登录即继续,最多等5分钟)
  1146. logger.info("未登录,打开扫码登录页,请用京东APP扫码……")
  1147. self.driver.get("https://passport.jd.com/new/login.aspx", timeout=15)
  1148. t0 = time.time()
  1149. logged_in = False
  1150. while time.time() - t0 < 300: # 最多等5分钟
  1151. if "passport.jd.com" not in (self.driver.url or ""):
  1152. self.sleep(2, 4)
  1153. if not self._is_logged_out():
  1154. logged_in = True
  1155. break
  1156. time.sleep(3)
  1157. if logged_in:
  1158. logger.info("人工登录成功")
  1159. else:
  1160. logger.error("等待人工登录超时(5分钟)")
  1161. if self.login_password and self.login_username:
  1162. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 1})
  1163. else:
  1164. self.disable_account()
  1165. send_text(f"京东:{self.account_name}账号登录失败")
  1166. self.success = False
  1167. return
  1168. kw = quote(str(keyword or ""), safe="")
  1169. self._search_kw = kw
  1170. # 必须先监听再打开搜索页,否则首屏 wareList(前约 30 条)在监听开启前就返回了
  1171. self._start_listen()
  1172. # 拟人搜索:搜索框输入(失败自动退回 URL 直达)
  1173. self._search_via_box(keyword, kw)
  1174. self.sleep(2, 3)
  1175. if self._check_page_blocked():
  1176. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 2})
  1177. return
  1178. if not handle_jd_slider_captcha(self.driver, pause_listen=False):
  1179. logger.warning("进入搜索页后滑块验证码处理失败")
  1180. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 2})
  1181. self.success = False
  1182. return
  1183. if self.start_page > 1:
  1184. if not self._jump_to_page(self.start_page):
  1185. logger.warning("跳页失败,将从第 1 页开始采集")
  1186. self.start_page = 1
  1187. logger.info(
  1188. "采集页码范围: %s ~ %s(共 %s 页)",
  1189. self.start_page,
  1190. self.end_page,
  1191. self.end_page - self.start_page + 1,
  1192. )
  1193. page_no = self.start_page
  1194. while page_no <= self.end_page:
  1195. # ---- 页首异常检测 + 恢复 ----
  1196. intr = self._interruption()
  1197. if intr == "kicked":
  1198. if self._recover_from_kick(page_no, kw):
  1199. continue
  1200. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 1})
  1201. break
  1202. elif intr == "captcha":
  1203. if self._recover_from_captcha(page_no, kw):
  1204. continue
  1205. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  1206. break
  1207. elif self._is_empty_result():
  1208. if self._recover_from_empty(page_no, kw):
  1209. continue
  1210. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  1211. break
  1212. if self._is_logged_out():
  1213. if not self._ensure_logged_in():
  1214. self.success = False
  1215. break
  1216. self._search_via_box(keyword, kw)
  1217. self.sleep(3, 5)
  1218. if page_no > 1:
  1219. self._jump_to_page(page_no)
  1220. if not handle_jd_slider_captcha(self.driver, pause_listen=True):
  1221. logger.warning("滑块验证码处理失败,停止采集")
  1222. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  1223. self.success = False
  1224. break
  1225. if self._check_page_blocked():
  1226. break
  1227. logger.info("===== 正在爬取第 %s 页 =====", page_no)
  1228. self.page_stored = 0
  1229. search_ele = self.driver.ele("xpath=//div[@id='search-condition']", timeout=10)
  1230. if not search_ele:
  1231. logger.warning("未找到搜索结果区域,停止采集")
  1232. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  1233. break
  1234. # ---- 采集 ----
  1235. try:
  1236. page_n, _ = self.collect_full_page_items()
  1237. except _PageInterrupted as pi:
  1238. if pi.kind == "captcha":
  1239. if not self._recover_from_captcha(page_no, kw):
  1240. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 2})
  1241. break
  1242. continue
  1243. if pi.kind == "render":
  1244. logger.warning("第 %d 页渲染异常,重新加载本页重采", page_no)
  1245. if not self._reposition_to_page(page_no, kw):
  1246. break
  1247. continue
  1248. # kicked
  1249. if not self._recover_from_kick(page_no, kw):
  1250. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': page_no, 'exception_type': 1})
  1251. break
  1252. continue
  1253. logger.info("本页监听商品条数(含可能重复): %s", page_n)
  1254. total += page_n
  1255. logger.info("累计监听条数: %s", total)
  1256. if self.scheduler and self.scheduler.end:
  1257. logger.info('心跳失败')
  1258. self._error_report({'is_finished': 0, 'need_reassign': 0, 'current_page': page_no})
  1259. break
  1260. if self.is_no_prodcut > JD_NO_MATCH_THRESHOLD:
  1261. logger.info("连续无匹配商品过多,停止采集")
  1262. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  1263. break
  1264. self.cumulative_pages += 1
  1265. self.cumulative_items += page_n
  1266. page_skipped = page_n - self.page_stored
  1267. self.cumulative_stored += self.page_stored
  1268. self.cumulative_skipped += page_skipped
  1269. logger.info(
  1270. "关键字 %s 第 %s 页获取完成, 本页获取量: %d, 本页已入库: %d, 本页未入库: %d | 账号测试总页数: %d, 账号测试总数据量: %d, 总已入库: %d, 总未入库: %d",
  1271. keyword,
  1272. page_no,
  1273. page_n,
  1274. self.page_stored,
  1275. page_skipped,
  1276. self.cumulative_pages,
  1277. self.cumulative_items,
  1278. self.cumulative_stored,
  1279. self.cumulative_skipped
  1280. )
  1281. print(f"[{keyword}] 当前页数: {page_no}, 本页获取数据量: {page_n}, 本页已入库: {self.page_stored}, 本页未入库: {page_skipped} | 账号测试总页数: {self.cumulative_pages}, 账号测试总数据量: {self.cumulative_items}, 总已入库: {self.cumulative_stored}, 总未入库: {self.cumulative_skipped}")
  1282. if page_no >= self.end_page:
  1283. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  1284. break
  1285. next_btn = self.driver.ele("text=下一页", timeout=2)
  1286. if not next_btn:
  1287. logger.info("没有下一页(未找到)")
  1288. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  1289. break
  1290. cls_str = next_btn.attr("class") or ""
  1291. if "disabled" in cls_str:
  1292. logger.info("没有下一页(已禁用)")
  1293. self._success_report({'is_finished': 1, 'need_reassign': 0, 'current_page': page_no})
  1294. break
  1295. if not self._go_next_page(next_btn):
  1296. break
  1297. page_no += 1
  1298. if self.scheduler:
  1299. self.scheduler.stop()
  1300. def run(self):
  1301. # 检测账号
  1302. if not self.get_account():
  1303. logger.info("==================当前无账号可用==================")
  1304. self.success = False
  1305. return self.pipeline.crawl_count, self.success, self.driver, self.cumulative_pages, self.cumulative_items, self.cumulative_stored, self.cumulative_skipped
  1306. logger.info("获取到账号:%s,代理ip:%s", self.account_name, self.ip)
  1307. # # # 每次选取账号,立马账号使用时间
  1308. update_sql = f""" UPDATE `retrieve_collect_equipment_account` SET `status`= %s, `update_time`= %s WHERE `username` = %s; """
  1309. self.db.execute(update_sql, (0, int(time.time()), self.account_name))
  1310. try:
  1311. self.init_browser()
  1312. self.crawl()
  1313. except Exception as e:
  1314. self.success = False
  1315. logger.exception("爬取异常: %s", e)
  1316. self._error_report({'is_finished': 0, 'need_reassign': 1, 'current_page': self.start_page, 'exception_type': 3})
  1317. self.sleep(3, 5)
  1318. finally:
  1319. pass
  1320. # if self.driver:
  1321. # self.driver.quit()
  1322. # self.driver = None
  1323. return self.pipeline.crawl_count, self.success, self.driver, self.cumulative_pages, self.cumulative_items, self.cumulative_stored, self.cumulative_skipped