jd_captcha.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. """
  2. 京东滑块验证码:打码识别 + 轨迹生成 + 拖动。
  3. 多处复用:from spiders.jd.jd_captcha import handle_jd_slider_captcha, JdCaptchaHandler
  4. """
  5. import base64
  6. import math
  7. import random
  8. import time
  9. from contextlib import contextmanager
  10. import requests
  11. from PIL import Image
  12. from commons.config import (
  13. CAPTCHA_API_URL,
  14. CAPTCHA_TOKEN,
  15. CAPTCHA_SCREENSHOT_PATH,
  16. )
  17. DEFAULT_CAPTCHA_TOKEN = CAPTCHA_TOKEN
  18. DEFAULT_SCREENSHOT_PATH = CAPTCHA_SCREENSHOT_PATH
  19. JFBYM_API_URL = CAPTCHA_API_URL
  20. CAPTCHA_MODAL_XPATH = "xpath=//div[@id='captcha_modal']"
  21. CAPTCHA_IMG_XPATH = 'xpath://img[@id="main_img"]'
  22. SLIDER_IMG_XPATH = "xpath://img[@class='move-img']"
  23. @contextmanager
  24. def pause_page_listen(page, clear=True):
  25. """处理验证码时暂停网络监听,避免与滑块拖动抢 CDP 资源(auto_crawl 场景)。"""
  26. listen = getattr(page, "listen", None)
  27. was_listening = bool(listen and getattr(listen, "listening", False))
  28. if was_listening:
  29. listen.pause(clear=clear)
  30. try:
  31. yield
  32. finally:
  33. if was_listening:
  34. listen.resume()
  35. def simulate(target_x, seed=None):
  36. while 1:
  37. x_seq = simulate_x(target_x, seed)
  38. if len(x_seq) < 50 and target_x > 150:
  39. continue
  40. t_seq = _generate_t(x_seq, seed)
  41. y_seq = _generate_y(x_seq, t_seq, seed)
  42. result = []
  43. for x, y, t in zip(x_seq, y_seq, t_seq):
  44. result.append([x, y, t])
  45. return result
  46. def _generate_t(x_seq, seed=None):
  47. if seed is not None:
  48. random.seed(seed + 9999)
  49. n = len(x_seq)
  50. t_seq = [0] * n
  51. for i in range(1, n):
  52. dx = x_seq[i] - x_seq[i - 1]
  53. is_pause = dx == 0
  54. if i == 1:
  55. t_seq[i] = random.randint(50, 95)
  56. elif is_pause:
  57. if random.random() < 0.20:
  58. t_seq[i] = random.choice([16, 24, 33, 40, 58, 71, 74, 90, 96, 150, 200, 264])
  59. else:
  60. t_seq[i] = random.choices([6, 7, 8, 9, 10], weights=[3, 25, 45, 22, 5])[0]
  61. else:
  62. r = random.random()
  63. if r < 0.90:
  64. t_seq[i] = random.choices([6, 7, 8, 9, 10], weights=[3, 25, 45, 22, 5])[0]
  65. elif r < 0.95:
  66. t_seq[i] = random.choice([6, 10])
  67. else:
  68. t_seq[i] = random.choice([16, 24, 25, 33, 40, 58, 71, 74, 90, 96])
  69. return t_seq
  70. def _generate_y(x_seq, t_seq, seed=None):
  71. if seed is not None:
  72. random.seed(seed + 19999)
  73. n = len(x_seq)
  74. y_seq = [0] * n
  75. current_y = 0
  76. direction = 0
  77. dir_remaining = 0
  78. cooldown = 0
  79. for i in range(1, n):
  80. is_abnormal_t = t_seq[i] > 10
  81. if dir_remaining > 0:
  82. dir_remaining -= 1
  83. if dir_remaining == 0:
  84. direction = 0
  85. cooldown = random.randint(4, 8)
  86. elif cooldown > 0:
  87. cooldown -= 1
  88. else:
  89. triggered = False
  90. if is_abnormal_t and random.random() < 0.40:
  91. triggered = True
  92. elif random.random() < 0.025:
  93. triggered = True
  94. if triggered:
  95. if current_y >= 4:
  96. direction = random.choices([-1, 1], weights=[85, 15])[0]
  97. elif current_y <= -4:
  98. direction = random.choices([-1, 1], weights=[15, 85])[0]
  99. else:
  100. direction = random.choice([-1, 1])
  101. dir_remaining = random.choices([1, 2, 3, 4], weights=[35, 35, 20, 10])[0]
  102. current_y += direction
  103. y_seq[i] = current_y
  104. return y_seq
  105. def simulate_x(target_x, seed=None):
  106. if seed is not None:
  107. random.seed(seed)
  108. seq = [0]
  109. step = 1
  110. x = 0
  111. phase = "accelerating"
  112. accel_threshold = target_x * 0.2
  113. cruise_threshold = target_x * 0.5
  114. while x < target_x:
  115. remaining = target_x - x
  116. if phase == "accelerating":
  117. if step >= 9 or x >= accel_threshold:
  118. phase = "cruising"
  119. continue
  120. elif phase == "cruising":
  121. if x >= cruise_threshold:
  122. phase = "decelerating"
  123. continue
  124. elif phase == "decelerating":
  125. if remaining <= 6:
  126. phase = "fine_tuning"
  127. continue
  128. if phase == "accelerating":
  129. delta = random.choices([-1, 0, 1, 2, 3], weights=[5, 10, 30, 35, 20])[0]
  130. step = _clamp(step + delta, 1, 8)
  131. elif phase == "cruising":
  132. if step <= 1:
  133. delta = random.choices([0, 1, 2], weights=[12, 55, 33])[0]
  134. elif step >= 8:
  135. delta = random.choices([-2, -1, 0], weights=[20, 50, 30])[0]
  136. else:
  137. delta = random.choices([-2, -1, 0, 1, 2], weights=[5, 22, 50, 18, 5])[0]
  138. step = _clamp(step + delta, 0, 7)
  139. elif phase == "decelerating":
  140. remaining_ratio = remaining / target_x
  141. max_step = max(3, int(2.5 + 5.5 * remaining_ratio / 0.35))
  142. if remaining_ratio > 0.18:
  143. if step <= 1:
  144. delta = random.choices([0, 1, 2], weights=[12, 50, 38])[0]
  145. elif step >= max_step:
  146. delta = random.choices([-2, -1, 0], weights=[25, 45, 30])[0]
  147. else:
  148. delta = random.choices([-2, -1, 0, 1, 2], weights=[8, 22, 46, 19, 5])[0]
  149. else:
  150. if step <= 0:
  151. delta = random.choices([1, 2], weights=[65, 35])[0]
  152. elif step == 1:
  153. delta = random.choices([-1, 0, 1], weights=[18, 52, 30])[0]
  154. elif step >= max_step:
  155. delta = random.choices([-2, -1, 0], weights=[25, 45, 30])[0]
  156. else:
  157. delta = random.choices([-2, -1, 0, 1], weights=[10, 30, 45, 15])[0]
  158. step = _clamp(step + delta, 0, max_step)
  159. if step == 0 and len(seq) >= 2 and seq[-1] == seq[-2]:
  160. step = 1
  161. elif phase == "fine_tuning":
  162. if remaining <= 0:
  163. break
  164. step = random.choices([0, 1, 2], weights=[10, 70, 20])[0]
  165. step = min(step, remaining)
  166. if step == 0 and len(seq) >= 2 and seq[-1] == seq[-2]:
  167. step = 1 if remaining >= 1 else 0
  168. x += step
  169. if x > target_x:
  170. x = target_x
  171. seq.append(x)
  172. return seq
  173. def _clamp(v, lo, hi):
  174. return max(lo, min(hi, v))
  175. class JdCaptchaHandler:
  176. """京东滑块验证码处理器,绑定 DrissionPage 的 ChromiumPage / Tab。"""
  177. def __init__(self, page, token=None, screenshot_path=None):
  178. self.page = page
  179. self.token = token or DEFAULT_CAPTCHA_TOKEN
  180. self.screenshot_path = screenshot_path or DEFAULT_SCREENSHOT_PATH
  181. @staticmethod
  182. def _safe_float(value, default=0.0):
  183. try:
  184. return float(value)
  185. except (TypeError, ValueError):
  186. return default
  187. def _run_js_safe(self, target, script, default=None):
  188. try:
  189. if hasattr(target, "run_js"):
  190. return target.run_js(script)
  191. if hasattr(target, "run_script"):
  192. return target.run_script(script)
  193. except Exception:
  194. return default
  195. return default
  196. def _get_device_pixel_ratio(self):
  197. ratio = self._run_js_safe(self.page, "return window.devicePixelRatio || 1;", default=1)
  198. ratio = self._safe_float(ratio, 1.0)
  199. return ratio if ratio > 0 else 1.0
  200. def _get_image_width(self, image_path):
  201. try:
  202. with Image.open(image_path) as img:
  203. return float(img.width)
  204. except Exception:
  205. return 0.0
  206. def _get_ele_css_width(self, ele):
  207. width = self._run_js_safe(ele, "return this.getBoundingClientRect().width || 0;", default=0)
  208. width = self._safe_float(width, 0.0)
  209. if width > 0:
  210. return width
  211. try:
  212. size = ele.rect.size
  213. if isinstance(size, (tuple, list)) and len(size) >= 1:
  214. return self._safe_float(size[0], 0.0)
  215. except Exception:
  216. pass
  217. return 0.0
  218. def _normalize_slider_distance(self, raw_distance, capt_ele, slider_ele, screenshot_path):
  219. distance = max(0.0, self._safe_float(raw_distance, 0.0))
  220. capt_css_width = self._get_ele_css_width(capt_ele)
  221. screenshot_width = self._get_image_width(screenshot_path)
  222. natural_width = self._safe_float(
  223. self._run_js_safe(capt_ele, "return this.naturalWidth || 0;", default=0),
  224. 0.0,
  225. )
  226. if capt_css_width > 0 and screenshot_width > 0:
  227. return distance * (capt_css_width / screenshot_width)
  228. if capt_css_width > 0 and natural_width > 0:
  229. return distance * (capt_css_width / natural_width)
  230. dpr = self._get_device_pixel_ratio()
  231. if dpr > 1.0:
  232. return distance / dpr
  233. return distance
  234. def generate_human_track(self, distance):
  235. try:
  236. distance = float(distance)
  237. except (TypeError, ValueError):
  238. return []
  239. if distance <= 0 or not math.isfinite(distance):
  240. return []
  241. tracks = []
  242. current = 0
  243. mid = distance * 0.7
  244. t = 0.2
  245. v = 0
  246. move_points = []
  247. while current < mid:
  248. a = random.uniform(2, 4)
  249. v0 = v
  250. v = v0 + a * t
  251. move = v0 * t + 0.5 * a * t * t
  252. current += move
  253. move_points.append(move)
  254. while current < distance:
  255. a = -random.uniform(0.5, 1.5)
  256. v0 = v
  257. v = v0 + a * t
  258. if v < 0.5:
  259. v = 0.5
  260. move = v0 * t + 0.5 * a * t * t
  261. current += move
  262. move_points.append(move)
  263. total_points = len(move_points)
  264. for i, move in enumerate(move_points):
  265. y_offset = random.randint(-2, 2) if i % random.randint(2, 4) == 0 else 0
  266. if i < total_points * 0.3:
  267. duration = random.uniform(0.01, 0.03)
  268. elif i > total_points * 0.7:
  269. duration = random.uniform(0.03, 0.08)
  270. else:
  271. duration = random.uniform(0.02, 0.05)
  272. if random.random() < 0.05:
  273. duration += random.uniform(0.05, 0.1)
  274. tracks.append((move, y_offset, duration))
  275. if random.random() < 0.7:
  276. tracks.append((-random.randint(1, 3), 0, 0.05))
  277. return tracks
  278. def simulate_slider_drag(self, slider_element, target_distance):
  279. if target_distance <= 0:
  280. return
  281. self.page.actions.move_to(slider_element).wait(0.5)
  282. self.page.actions.hold(slider_element)
  283. for offset_x, offset_y, duration in self.generate_human_track(target_distance):
  284. self.page.actions.move(offset_x, offset_y, duration=duration)
  285. self.page.actions.release()
  286. def verify(self, type_num, image_path=None):
  287. """调用云码平台:type_num=1 坐标点选,2 滑块距离。"""
  288. image_path = image_path or self.screenshot_path
  289. with open(image_path, "rb") as f:
  290. image_b64 = base64.b64encode(f.read()).decode()
  291. if type_num == 1:
  292. data = {
  293. "token": self.token,
  294. "type": "30332",
  295. "direction": "top",
  296. "click_num": 3,
  297. "image": image_b64,
  298. }
  299. else:
  300. data = {
  301. "token": self.token,
  302. "type": "22222",
  303. "image": image_b64,
  304. }
  305. response = requests.post(
  306. JFBYM_API_URL,
  307. headers={"Content-Type": "application/json"},
  308. json=data,
  309. timeout=30,
  310. ).json()
  311. print(response)
  312. return response["data"]["data"]
  313. def handle_slider(
  314. self,
  315. capt_ele=None,
  316. slider_ele=None,
  317. drag_offset=1.5,
  318. inject_track_js=True,
  319. ):
  320. """
  321. 完整滑块流程:截图 -> 打码 -> 注入轨迹 -> 拖动。
  322. 成功返回 True,失败返回 False。
  323. """
  324. capt_ele = capt_ele or self.page.ele(CAPTCHA_IMG_XPATH, timeout=2)
  325. if not capt_ele:
  326. print("未找到验证码背景图")
  327. return False
  328. capt_ele.get_screenshot(self.screenshot_path)
  329. distance = self.verify(2)
  330. try:
  331. distance = float(distance)
  332. except (TypeError, ValueError):
  333. print(f"滑块距离格式异常:{distance}")
  334. return False
  335. print(f"滑块距离(接口原始值):{distance}")
  336. slider_ele = slider_ele or self.page.ele(SLIDER_IMG_XPATH, timeout=2)
  337. if not slider_ele:
  338. print("未找到滑块")
  339. return False
  340. drag_distance = self._normalize_slider_distance(
  341. distance,
  342. capt_ele=capt_ele,
  343. slider_ele=slider_ele,
  344. screenshot_path=self.screenshot_path,
  345. )
  346. drag_px = max(0.0, float(drag_distance) - drag_offset)
  347. if inject_track_js:
  348. result = simulate(math.ceil(int(drag_distance)))
  349. self.page.run_js("window.xxxll = {};".format(result))
  350. time.sleep(3)
  351. self.simulate_slider_drag(slider_ele, drag_px)
  352. return True
  353. def has_captcha_modal(self):
  354. return bool(self.page.ele(CAPTCHA_MODAL_XPATH, timeout=1))
  355. def has_moveslide_modal(self):
  356. capt_cha = "xpath://img[@class='move-img']"
  357. return bool(self.page.ele(capt_cha, timeout=1))
  358. def _wait_for_slider(self, rounds=5):
  359. if self.has_moveslide_modal():
  360. return True
  361. for _ in range(rounds):
  362. time.sleep(1)
  363. if self.has_moveslide_modal():
  364. return True
  365. return False
  366. def handle_slider_until_gone(self, max_attempts=3, wait_after=2, slider_wait_rounds=5, **handle_kwargs):
  367. """
  368. 处理滑块并在每次处理后检查验证码是否仍在页面。
  369. 验证码消失返回 True;达到 max_attempts 仍存在返回 False。
  370. """
  371. if not self.has_captcha_modal():
  372. return True
  373. for attempt in range(1, max_attempts + 1):
  374. print(f"验证码处理 第 {attempt}/{max_attempts} 次")
  375. if not self._wait_for_slider(slider_wait_rounds):
  376. print("验证码弹窗在,但滑块元素未出现(可能非滑块类型)")
  377. if attempt >= max_attempts:
  378. return False
  379. time.sleep(wait_after)
  380. continue
  381. ok = self.handle_slider(**handle_kwargs)
  382. if not ok:
  383. print("本次滑块处理失败")
  384. else:
  385. time.sleep(wait_after)
  386. if not self.has_captcha_modal():
  387. print("验证码已消失")
  388. return True
  389. print("验证码仍在页面")
  390. if attempt >= max_attempts:
  391. break
  392. time.sleep(wait_after)
  393. if self.has_captcha_modal():
  394. print(f"验证码处理失败,已尝试 {max_attempts} 次,弹窗仍在")
  395. return False
  396. return True
  397. def handle_jd_slider_captcha(
  398. page,
  399. token=None,
  400. screenshot_path=None,
  401. max_attempts=3,
  402. wait_after=2,
  403. slider_wait_rounds=5,
  404. pause_listen=True,
  405. **kwargs,
  406. ):
  407. """
  408. 便捷入口:处理当前页面的京东滑块验证码,最多重试 max_attempts 次。
  409. 返回 True:无需验证码或已成功通过;False:处理失败或验证码仍在。
  410. pause_listen:auto_crawl 等已开启 listen 的场景建议 True。
  411. """
  412. # 先检查是否有“快速验证”按钮,有的话先点击触发验证码
  413. verify_btn = page.ele('text=快速验证', timeout=3)
  414. if verify_btn:
  415. print("检测到京东风控拦截(快速验证),准备自动过验证...")
  416. try:
  417. page.run_js('arguments[0].click();', verify_btn)
  418. except:
  419. verify_btn.click()
  420. time.sleep(3) # 等待滑块弹窗出现
  421. handler = JdCaptchaHandler(page, token=token, screenshot_path=screenshot_path)
  422. if not handler.has_captcha_modal():
  423. return True
  424. if pause_listen:
  425. with pause_page_listen(page):
  426. return handler.handle_slider_until_gone(
  427. max_attempts=max_attempts,
  428. wait_after=wait_after,
  429. slider_wait_rounds=slider_wait_rounds,
  430. **kwargs,
  431. )
  432. return handler.handle_slider_until_gone(
  433. max_attempts=max_attempts,
  434. wait_after=wait_after,
  435. slider_wait_rounds=slider_wait_rounds,
  436. **kwargs,
  437. )