yzm.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445
  1. import base64
  2. import io
  3. import math
  4. import os
  5. import random
  6. import re
  7. import threading
  8. import time
  9. import cv2
  10. import numpy as np
  11. import requests
  12. from PIL import Image, ImageDraw
  13. import uiautomator2 as u2
  14. API_URL = "http://api.jfbym.com/api/YmServer/customApi"
  15. # 验证码平台 token
  16. API_TOKEN = "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk"
  17. # 原始截图保存路径
  18. SCREENSHOT_PATH = "./a.jpg"
  19. # 裁剪后图片保存路径z
  20. CROP_PATH = "./b.jpg"
  21. # 滑动轨迹图保存目录
  22. TRACK_DIR = "./slider_tracks"
  23. # 验证码截图日志根目录
  24. CAPTCHA_LOG_ROOT = "./captcha_logs"
  25. UNKNOWN_DEVICE_ID = "unknown_device"
  26. _RUNTIME_CONTEXT = threading.local()
  27. SLIDER_METHODS = ("track", "bezier")
  28. # 图标点选验证码
  29. CAPTCHA_ICON_CLICK = "icon_click"
  30. # 空间推理验证码
  31. CAPTCHA_SPACE_REASON = "space_reason"
  32. # 文字点选验证码
  33. CAPTCHA_TEXT_CLICK = "text_click"
  34. # 滑块验证码
  35. CAPTCHA_SLIDER = "slider"
  36. # 图片内容输入验证码
  37. CAPTCHA_TEXT_INPUT = "text_input"
  38. # 输入型验证码的裁剪区域
  39. INPUT_CROP = {
  40. "x_start": 210,
  41. "x_end": 510,
  42. "y_start": 666,
  43. "y_end": 788,
  44. }
  45. # 图标点选验证码的裁剪区域
  46. ICON_CLICK_CROP = {
  47. "x_start": 40,
  48. "x_end": 680,
  49. "y_start": 471,
  50. "y_end": 1143,
  51. }
  52. # 不同验证码类型对应的平台参数配置
  53. VERIFY_CONFIG = {
  54. CAPTCHA_ICON_CLICK: {"type": "88888", "direction": None, "extra": None, "y_offset": 0, "result": "points"},
  55. CAPTCHA_SPACE_REASON: {"type": "88888", "direction": None, "extra": "请点击", "y_offset": 0, "result": "points"},
  56. CAPTCHA_TEXT_CLICK: {"type": "30114", "direction": None, "extra": "phrase", "y_offset": 0, "result": "points"},
  57. CAPTCHA_SLIDER: {"type": "22222", "direction": None, "extra": None, "y_offset": 0, "result": "text"},
  58. CAPTCHA_TEXT_INPUT: {"type": "10103", "direction": None, "extra": None, "y_offset": 0, "result": "text"},
  59. # lianxian : {"type": "10114", "direction": None, "extra": None, "y_offset": 0, "result": "text"}
  60. }
  61. def _safe_name(value, default):
  62. if value is None:
  63. return default
  64. text = str(value).strip()
  65. if not text:
  66. return default
  67. text = re.sub(r"[^0-9A-Za-z_\-.]+", "_", text)
  68. return text or default
  69. def _resolve_device_id(d=None, device_id=None):
  70. if device_id:
  71. return _safe_name(device_id, UNKNOWN_DEVICE_ID)
  72. candidates = []
  73. if d is not None:
  74. for attr in ("serial", "_serial", "device_id"):
  75. value = getattr(d, attr, None)
  76. if value:
  77. candidates.append(value)
  78. try:
  79. info = d.device_info
  80. if isinstance(info, dict):
  81. for key in ("serial", "serialno", "udid", "deviceId"):
  82. value = info.get(key)
  83. if value:
  84. candidates.append(value)
  85. except Exception:
  86. pass
  87. for value in candidates:
  88. normalized = _safe_name(value, "")
  89. if normalized:
  90. return normalized
  91. return UNKNOWN_DEVICE_ID
  92. def _set_runtime_device_id(d=None, device_id=None):
  93. _RUNTIME_CONTEXT.device_id = _resolve_device_id(d=d, device_id=device_id)
  94. def _get_runtime_device_id(d=None):
  95. current = getattr(_RUNTIME_CONTEXT, "device_id", None)
  96. if current:
  97. return current
  98. resolved = _resolve_device_id(d=d)
  99. _RUNTIME_CONTEXT.device_id = resolved
  100. return resolved
  101. def _next_slider_method_order():
  102. idx = getattr(_RUNTIME_CONTEXT, "slider_method_idx", 0)
  103. first = SLIDER_METHODS[idx % len(SLIDER_METHODS)]
  104. second = SLIDER_METHODS[(idx + 1) % len(SLIDER_METHODS)]
  105. _RUNTIME_CONTEXT.slider_method_idx = (idx + 1) % len(SLIDER_METHODS)
  106. return [first, second]
  107. def _ensure_captcha_dir(captcha_type):
  108. safe_type = _safe_name(captcha_type, "unknown_captcha")
  109. folder = os.path.join(CAPTCHA_LOG_ROOT, safe_type)
  110. os.makedirs(folder, exist_ok=True)
  111. return folder
  112. def _build_captcha_image_path(captcha_type, d=None, ext=".png", tag=None):
  113. device_id = _get_runtime_device_id(d=d)
  114. random_part = random.randint(10000000, 99999999)
  115. safe_tag = _safe_name(tag, "") if tag else ""
  116. suffix = f"_{safe_tag}" if safe_tag else ""
  117. filename = f"{device_id}_{random_part}{suffix}{ext}"
  118. return os.path.join(_ensure_captcha_dir(captcha_type), filename)
  119. def _save_debug_screenshot(d, captcha_type, tag="full"):
  120. path = _build_captcha_image_path(captcha_type, d=d, ext=".png", tag=tag)
  121. try:
  122. d.screenshot(path)
  123. print(f"[captcha-shot] saved: {path}")
  124. return path
  125. except Exception as e:
  126. print(f"[captcha-shot] save failed: {e}")
  127. return None
  128. def _compress_to_size(image_path, max_bytes=1024 * 1024):
  129. """
  130. 将图片压缩到指定大小以内(默认 1MB)。
  131. 策略:转 RGB → 循环降低 JPEG 质量 → 仍超则缩小尺寸。
  132. 返回压缩后的字节流,不修改原图。
  133. """
  134. img = Image.open(image_path)
  135. if img.mode != 'RGB':
  136. img = img.convert('RGB')
  137. quality = 90
  138. while quality >= 30:
  139. buf = io.BytesIO()
  140. img.save(buf, format='JPEG', quality=quality)
  141. if buf.tell() <= max_bytes:
  142. return buf.getvalue()
  143. quality -= 15
  144. # 质量到 30 还超 → 缩小尺寸(每次 0.8 倍)
  145. scale = 0.8
  146. while True:
  147. w, h = img.size
  148. img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
  149. buf = io.BytesIO()
  150. img.save(buf, format='JPEG', quality=80)
  151. if buf.tell() <= max_bytes:
  152. return buf.getvalue()
  153. scale -= 0.1
  154. if scale <= 0.4:
  155. break
  156. return buf.getvalue()
  157. def post_api(image_path, captcha_type, extra=None, direction=None, label_image_path=None, timeout=20):
  158. raw = open(image_path, 'rb').read()
  159. # 超过 1MB 先压缩(打码平台限制)
  160. if len(raw) > 1024 * 1024:
  161. print(f"[yzm] 原图 {len(raw) / 1024 / 1024:.1f}MB 超过1MB,压缩中...")
  162. compressed = _compress_to_size(image_path)
  163. image_base64 = base64.b64encode(compressed).decode()
  164. print(f"[yzm] 压缩后 {len(compressed) / 1024:.0f}KB")
  165. else:
  166. image_base64 = base64.b64encode(raw).decode()
  167. data = {
  168. "token": API_TOKEN,
  169. "type": captcha_type,
  170. "image": image_base64,
  171. }
  172. if label_image_path:
  173. with open(label_image_path, 'rb') as f:
  174. data["label_image"] = base64.b64encode(f.read()).decode()
  175. if extra is not None:
  176. data["extra"] = extra
  177. if direction is not None:
  178. data["direction"] = direction
  179. headers = {
  180. "Content-Type": "application/json"
  181. }
  182. response = requests.post(API_URL, headers=headers, json=data, timeout=timeout).json()
  183. print(response)
  184. return response
  185. def parse_points(response, y_offset=0):
  186. tuple_points = []
  187. raw_data = response.get("data", {})
  188. # API 失败时 data 可能是 [] 而非 dict,防御性处理
  189. if not isinstance(raw_data, dict):
  190. return tuple_points
  191. data = raw_data.get("data", "")
  192. if not data:
  193. return tuple_points
  194. for s in data.split('|'):
  195. x, y = s.split(',')
  196. tuple_points.append((int(x), int(y) + y_offset))
  197. return tuple_points
  198. def verify(image_path, captcha_type):
  199. config = VERIFY_CONFIG.get(captcha_type)
  200. if not config:
  201. raise ValueError(f"不支持的验证码类型: {captcha_type}")
  202. response = post_api(
  203. image_path,
  204. config["type"],
  205. extra=config["extra"],
  206. direction=config["direction"]
  207. )
  208. if config["result"] == "points":
  209. return parse_points(response, y_offset=config["y_offset"])
  210. raw_data = response.get("data", {})
  211. if isinstance(raw_data, dict):
  212. return raw_data.get("data")
  213. return None
  214. def crop_image_xy(
  215. image_path,
  216. output_path=None,
  217. x_start=None,
  218. x_end=None,
  219. y_start=471,
  220. y_end=1143
  221. ):
  222. if output_path is None:
  223. dir_name, file_name = os.path.split(image_path)
  224. name, ext = os.path.splitext(file_name)
  225. output_path = os.path.join(dir_name, f"{name}_cropped{ext}")
  226. with Image.open(image_path) as img:
  227. width, height = img.size
  228. if x_start is None:
  229. x_start = 0
  230. if x_end is None:
  231. x_end = width - 1
  232. x_start = max(0, min(x_start, width - 1))
  233. x_end = max(x_start, min(x_end, width - 1))
  234. y_start = max(0, min(y_start, height - 1))
  235. y_end = max(y_start, min(y_end, height - 1))
  236. cropped = img.crop((x_start, y_start, x_end + 1, y_end + 1))
  237. cropped.save(output_path)
  238. return output_path
  239. def _capture_by_bounds(d, xpath_candidates, output_path=None, screenshot_path=None, captcha_type="generic"):
  240. """
  241. 按元素 bounds 截图并裁剪。
  242. return: (cropped_path, bounds_dict) or (None, None)
  243. """
  244. if isinstance(xpath_candidates, str):
  245. xpath_candidates = [xpath_candidates]
  246. if screenshot_path is None:
  247. screenshot_path = _build_captcha_image_path(captcha_type, d=d, ext=".png", tag="full")
  248. if output_path is None:
  249. output_path = _build_captcha_image_path(captcha_type, d=d, ext=".png", tag="crop")
  250. for xpath in xpath_candidates:
  251. try:
  252. node = d.xpath(xpath)
  253. if not node.exists:
  254. continue
  255. bounds = node.info.get("bounds", {})
  256. if not bounds:
  257. continue
  258. d.screenshot(screenshot_path)
  259. cropped = crop_image_xy(
  260. screenshot_path,
  261. output_path=output_path,
  262. x_start=bounds["left"],
  263. x_end=bounds["right"],
  264. y_start=bounds["top"],
  265. y_end=bounds["bottom"],
  266. )
  267. return cropped, bounds
  268. except Exception:
  269. continue
  270. return None, None
  271. def srwz(d):
  272. captcha_image_xpaths = [
  273. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]/android.widget.Image[1]',
  274. '//*[@resource-id="captchaImg"]',
  275. ]
  276. image_path, _ = _capture_by_bounds(d, captcha_image_xpaths, captcha_type=CAPTCHA_TEXT_INPUT)
  277. if not image_path:
  278. return False
  279. data = verify(image_path, CAPTCHA_TEXT_INPUT)
  280. input_box = d.xpath('//*[@hint="请输入验证码"]')
  281. if input_box.exists:
  282. input_box.click()
  283. time.sleep(0.5)
  284. input_box.set_text(data)
  285. d.xpath('//*[@text="验证"]').click()
  286. return True
  287. else:
  288. print("未找到输入框")
  289. return False
  290. def _clamp(value, min_value, max_value):
  291. return max(min_value, min(value, max_value))
  292. def _slider_duration(distance):
  293. if distance <= 90:
  294. return round(random.uniform(0.18, 0.27), 3)
  295. if distance <= 160:
  296. return round(random.uniform(0.23, 0.34), 3)
  297. return round(random.uniform(0.28, 0.42), 3)
  298. def _build_human_slider_track(start_x, start_y, distance):
  299. # Keep the reference shape: mostly flat first, then a single smooth downward bend.
  300. overshoot = random.randint(1, 2) if distance > 140 and random.random() < 0.18 else 0
  301. move_distance = distance + overshoot
  302. steps = int(_clamp(move_distance / random.uniform(6.0, 8.0), 16, 32))
  303. base_y = start_y + random.randint(-1, 1)
  304. flat_ratio = random.uniform(0.30, 0.42) # front section almost horizontal
  305. drop = distance * random.uniform(0.08, 0.14)
  306. if distance > 260:
  307. drop *= random.uniform(1.35, 1.75)
  308. drop = _clamp(drop, 12.0, 58.0) # obvious tail drop
  309. noise_amp = random.uniform(0.03, 0.22)
  310. points = [(start_x, base_y)]
  311. last_x = start_x
  312. for i in range(1, steps + 1):
  313. t = i / steps
  314. progress = 1 - (1 - t) ** 2.0
  315. progress += random.uniform(-0.0018, 0.0018)
  316. progress = _clamp(progress, 0.0, 1.0)
  317. x = start_x + int(move_distance * progress)
  318. if x <= last_x:
  319. x = last_x + 1
  320. last_x = x
  321. if t < flat_ratio:
  322. # slight tiny rise then back, still near flat
  323. u = t / max(flat_ratio, 1e-6)
  324. y_curve = -0.9 * math.sin(math.pi * u)
  325. else:
  326. u = (t - flat_ratio) / max(1 - flat_ratio, 1e-6)
  327. y_curve = drop * (u ** 1.7)
  328. y_noise = random.uniform(-noise_amp, noise_amp)
  329. y = int(round(base_y + y_curve + y_noise))
  330. points.append((x, y))
  331. final_x = start_x + distance
  332. final_y = int(round(base_y + drop + random.uniform(-0.6, 0.6)))
  333. if overshoot > 0:
  334. points.append((start_x + distance + overshoot, final_y))
  335. points.append((final_x, final_y + random.choice([0, 0, 1])))
  336. return points
  337. def _save_slider_track_image(
  338. points,
  339. distance=None,
  340. duration=None,
  341. drag_ok=True,
  342. screenshot_path=SCREENSHOT_PATH,
  343. captcha_type=CAPTCHA_SLIDER
  344. ):
  345. if not points:
  346. return None
  347. output_path = _build_captcha_image_path(captcha_type, ext=".png", tag="track")
  348. try:
  349. if os.path.exists(screenshot_path):
  350. with Image.open(screenshot_path) as img:
  351. canvas = img.convert("RGB")
  352. else:
  353. canvas = Image.new("RGB", (720, 1280), "white")
  354. draw = ImageDraw.Draw(canvas)
  355. if len(points) >= 2:
  356. draw.line(points, fill=(245, 20, 30), width=9)
  357. sx, sy = points[0]
  358. ex, ey = points[-1]
  359. draw.ellipse((sx - 5, sy - 5, sx + 5, sy + 5), fill=(40, 200, 80))
  360. draw.ellipse((ex - 5, ey - 5, ex + 5, ey + 5), fill=(50, 120, 255))
  361. info = f"ok={drag_ok} dist={distance} dur={duration}s points={len(points)}"
  362. draw.rectangle((8, 8, min(canvas.size[0] - 8, 520), 42), fill=(0, 0, 0))
  363. draw.text((14, 14), info, fill=(255, 255, 255))
  364. canvas.save(output_path)
  365. print(f"[slider-track] saved: {output_path}")
  366. return output_path
  367. except Exception as e:
  368. print(f"[slider-track] save failed: {e}")
  369. return None
  370. def _downsample_track_points(points, target_count):
  371. if not points or len(points) <= target_count:
  372. return points[:]
  373. if target_count < 2:
  374. return [points[0], points[-1]]
  375. sampled = []
  376. last_index = len(points) - 1
  377. for i in range(target_count):
  378. idx = int(round(i * last_index / (target_count - 1)))
  379. sampled.append(points[idx])
  380. return sampled
  381. def _execute_track(d, points, total_duration):
  382. if not points or len(points) < 2:
  383. return False, points
  384. duration = max(0.18, float(total_duration))
  385. # Use fewer points to avoid step explosion, keep curve shape.
  386. max_points = random.randint(14, 22)
  387. exec_points = _downsample_track_points(points, max_points)
  388. try:
  389. if hasattr(d, "swipe_points"):
  390. # u2: duration here means time-per-step; steps = duration/0.005.
  391. # To approximate total duration:
  392. # total ~= (duration/0.005) * (len(exec_points)-1) * 0.005 = duration * segments
  393. seg_count = max(1, len(exec_points) - 1)
  394. per_segment = max(0.01, duration / seg_count)
  395. d.swipe_points(exec_points, duration=per_segment)
  396. return True, exec_points
  397. except Exception:
  398. pass
  399. # fallback
  400. try:
  401. sx, sy = exec_points[0]
  402. ex, ey = exec_points[-1]
  403. d.swipe(sx, sy, ex, ey, duration=duration)
  404. return False, exec_points
  405. except Exception:
  406. return False, exec_points
  407. def _slider_still_exists(d):
  408. xpath_candidates = [
  409. '//*[@text="请拖动下方滑块完成拼图"]',
  410. '//*[contains(@text, "拖动下方滑块")]',
  411. '//*[@resource-id="puzzleSliderBox"]',
  412. '//*[@resource-id="puzzleImageMain"]',
  413. ]
  414. for xpath in xpath_candidates:
  415. try:
  416. if d.xpath(xpath).exists:
  417. return True
  418. except Exception:
  419. continue
  420. return False
  421. def _cubic_bezier(t, p0, p1, p2, p3):
  422. one_minus_t = 1 - t
  423. x = (
  424. one_minus_t ** 3 * p0[0]
  425. + 3 * one_minus_t ** 2 * t * p1[0]
  426. + 3 * one_minus_t * t ** 2 * p2[0]
  427. + t ** 3 * p3[0]
  428. )
  429. y = (
  430. one_minus_t ** 3 * p0[1]
  431. + 3 * one_minus_t ** 2 * t * p1[1]
  432. + 3 * one_minus_t * t ** 2 * p2[1]
  433. + t ** 3 * p3[1]
  434. )
  435. return x, y
  436. def _generate_bezier_slider_points(start, end, deviation=30, steps=50):
  437. sx, sy = start
  438. ex, ey = end
  439. mid_x = (sx + ex) / 2
  440. mid_y = (sy + ey) / 2
  441. p1 = (
  442. mid_x - (ex - sx) / 4 + random.uniform(-deviation, deviation),
  443. mid_y - (ey - sy) / 4 + random.uniform(-deviation / 2, deviation / 2),
  444. )
  445. p2 = (
  446. mid_x + (ex - sx) / 4 + random.uniform(-deviation, deviation),
  447. mid_y + (ey - sy) / 4 + random.uniform(-deviation / 2, deviation / 2),
  448. )
  449. points = []
  450. for i in range(steps + 1):
  451. t = i / steps
  452. x, y = _cubic_bezier(t, start, p1, p2, end)
  453. if 0 < i < steps:
  454. x += random.gauss(0, 1.5)
  455. y += random.gauss(0, 1.5)
  456. points.append((int(round(x)), int(round(y))))
  457. return points
  458. def _execute_bezier_slider(d, start_x, start_y, end_x, end_y):
  459. points = _generate_bezier_slider_points(
  460. (int(round(start_x)), int(round(start_y))),
  461. (int(round(end_x)), int(round(end_y))),
  462. deviation=random.randint(20, 40),
  463. steps=50,
  464. )
  465. if len(points) < 2:
  466. return False, points
  467. try:
  468. d.touch.down(points[0][0], points[0][1])
  469. time.sleep(random.uniform(0.1, 0.2))
  470. total = max(1, len(points) - 1)
  471. for i, (x, y) in enumerate(points[1:], 1):
  472. d.touch.move(x, y)
  473. t = i / total
  474. if 0.2 < t < 0.8:
  475. interval = random.uniform(0.02, 0.04)
  476. else:
  477. interval = random.uniform(0.04, 0.08)
  478. time.sleep(interval)
  479. time.sleep(random.uniform(0.05, 0.15))
  480. d.touch.up(points[-1][0], points[-1][1])
  481. return True, points
  482. except Exception:
  483. try:
  484. d.touch.up(points[-1][0], points[-1][1])
  485. except Exception:
  486. pass
  487. return False, points
  488. def _slider_knob_center(d):
  489. slider_xpath = (
  490. '//*[@resource-id="puzzleSliderBox"] | '
  491. '//*[@resource-id="yodaBox"] | '
  492. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[2]/android.view.View[1]'
  493. )
  494. try:
  495. slider_node = d.xpath(slider_xpath)
  496. if slider_node.exists:
  497. bounds = slider_node.info.get("bounds", {})
  498. if bounds:
  499. return (
  500. (bounds["left"] + bounds["right"]) / 2 + random.uniform(-4, 4),
  501. (bounds["top"] + bounds["bottom"]) / 2 + random.uniform(-3, 3),
  502. )
  503. except Exception:
  504. pass
  505. return None
  506. def _first_existing_bounds(d, xpath_candidates):
  507. if isinstance(xpath_candidates, str):
  508. xpath_candidates = [xpath_candidates]
  509. for xpath in xpath_candidates:
  510. try:
  511. node = d.xpath(xpath)
  512. if not node.exists:
  513. continue
  514. bounds = node.info.get("bounds", {})
  515. if bounds:
  516. return xpath, bounds
  517. except Exception:
  518. continue
  519. return None, None
  520. def _click_captcha_close(d, captcha_xpath=None):
  521. """点击验证码右上角关闭按钮;优先点显式关闭控件,失败后按容器右上角估算点位。"""
  522. close_xpaths = [
  523. '//*[@resource-id="com.sankuai.meituan:id/btn_close_verify"]',
  524. '//*[@resource-id="btn_close_verify"]',
  525. '//*[@content-desc="关闭"]',
  526. '//*[@text="关闭"]',
  527. ]
  528. _, close_bounds = _first_existing_bounds(d, close_xpaths)
  529. if close_bounds:
  530. cx = int((close_bounds["left"] + close_bounds["right"]) / 2) + random.randint(-2, 2)
  531. cy = int((close_bounds["top"] + close_bounds["bottom"]) / 2) + random.randint(-2, 2)
  532. d.click(cx, cy)
  533. print(f"[captcha-close] click explicit close at ({cx}, {cy})")
  534. return True
  535. popup_xpaths = [
  536. '//*[@resource-id="com.sankuai.meituan:id/titans_main_layout"]',
  537. '//*[@resource-id="com.sankuai.meituan:id/h5_container"]',
  538. '//*[@resource-id="root"]',
  539. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]',
  540. '//*[@text="身份核实"]/android.view.View[1]',
  541. ]
  542. if captcha_xpath:
  543. popup_xpaths.append(captcha_xpath)
  544. _, popup_bounds = _first_existing_bounds(d, popup_xpaths)
  545. if not popup_bounds:
  546. return False
  547. left = popup_bounds["left"]
  548. right = popup_bounds["right"]
  549. top = popup_bounds["top"]
  550. bottom = popup_bounds["bottom"]
  551. width = max(1, right - left)
  552. height = max(1, bottom - top)
  553. # 参考示例:[40,391][680,1223] -> 右上角叉号中心约(640, 431)。
  554. offset_x = int(_clamp(width * 0.06, 20, 56))
  555. offset_y = int(_clamp(height * 0.05, 20, 56))
  556. click_x = int(right - offset_x) + random.randint(-3, 3)
  557. click_y = int(top + offset_y) + random.randint(-3, 3)
  558. d.click(click_x, click_y)
  559. print(f"[captcha-close] click inferred close at ({click_x}, {click_y})")
  560. return True
  561. def _build_directional_track(start_x, start_y, end_x, end_y):
  562. distance_x = end_x - start_x
  563. distance_y = end_y - start_y
  564. if abs(distance_x) < 2 and abs(distance_y) < 2:
  565. return [(int(start_x), int(start_y)), (int(end_x), int(end_y))]
  566. steps = int(_clamp(abs(distance_x) / random.uniform(7.0, 10.0), 22, 48))
  567. points = [(int(start_x), int(start_y))]
  568. last_x = float(start_x)
  569. for i in range(1, steps + 1):
  570. t = i / steps
  571. progress = 1 - (1 - t) ** random.uniform(1.8, 2.25)
  572. x = start_x + distance_x * progress + random.uniform(-0.9, 0.9)
  573. y = start_y + distance_y * progress + random.uniform(-0.8, 0.8)
  574. if distance_x >= 0:
  575. if x < last_x:
  576. x = last_x + random.uniform(0.2, 1.2)
  577. else:
  578. if x > last_x:
  579. x = last_x - random.uniform(0.2, 1.2)
  580. last_x = x
  581. points.append((int(round(x)), int(round(y))))
  582. points.append((int(round(end_x)), int(round(end_y))))
  583. return points
  584. def _move_with_pressed_touch(d, points):
  585. if not points:
  586. return
  587. for x, y in points:
  588. d.touch.move(x, y)
  589. time.sleep(random.uniform(0.0015, 0.0045))
  590. def _screenshot_to_image(d):
  591. shot = d.screenshot()
  592. if isinstance(shot, Image.Image):
  593. return shot.convert("RGB")
  594. if isinstance(shot, bytes):
  595. return Image.open(io.BytesIO(shot)).convert("RGB")
  596. if isinstance(shot, str) and os.path.exists(shot):
  597. return Image.open(shot).convert("RGB")
  598. if hasattr(shot, "convert"):
  599. return shot.convert("RGB")
  600. fallback_path = _build_captcha_image_path("generic", d=d, ext=".png", tag="fallback")
  601. d.screenshot(fallback_path)
  602. return Image.open(fallback_path).convert("RGB")
  603. def hk(d):
  604. screenshot_path = _build_captcha_image_path(CAPTCHA_SLIDER, d=d, ext=".png", tag="full")
  605. d.screenshot(screenshot_path)
  606. data = verify(screenshot_path, CAPTCHA_SLIDER)
  607. if not data:
  608. return False
  609. try:
  610. raw_distance = float(data)
  611. except (TypeError, ValueError):
  612. return False
  613. if raw_distance <= 0:
  614. return False
  615. image_width = 720
  616. try:
  617. with Image.open(screenshot_path) as img:
  618. image_width = img.size[0] or 720
  619. except Exception:
  620. pass
  621. try:
  622. screen_width = int(d.info.get("displayWidth", image_width))
  623. except Exception:
  624. screen_width = image_width
  625. scale = screen_width / image_width if image_width else 1.0
  626. slide_distance = int(raw_distance * scale)
  627. if slide_distance < 80:
  628. slide_distance += random.randint(3, 6)
  629. elif slide_distance < 160:
  630. slide_distance += random.randint(2, 5)
  631. else:
  632. slide_distance += random.randint(1, 4)
  633. start_x = 84 + random.randint(-1, 1)
  634. start_y = 1034 + random.randint(-2, 2)
  635. knob_center = _slider_knob_center(d)
  636. if knob_center:
  637. start_x, start_y = knob_center
  638. max_target_x = screen_width - random.randint(26, 42)
  639. target_x = _clamp(start_x + slide_distance, start_x + 18, max_target_x)
  640. distance = target_x - start_x
  641. if distance < 18:
  642. return False
  643. method_order = _next_slider_method_order()
  644. for method in method_order:
  645. if method == "track":
  646. points = _build_human_slider_track(int(round(start_x)), int(round(start_y)), int(round(distance)))
  647. duration = _slider_duration(distance)
  648. time.sleep(random.uniform(0.015, 0.05))
  649. drag_ok, exec_points = _execute_track(d, points, duration)
  650. _save_slider_track_image(
  651. exec_points,
  652. distance=distance,
  653. duration=duration,
  654. drag_ok=drag_ok,
  655. screenshot_path=screenshot_path,
  656. captcha_type=CAPTCHA_SLIDER
  657. )
  658. else:
  659. end_x = target_x + random.uniform(-3, 3)
  660. end_y = start_y + random.uniform(-1, 1)
  661. drag_ok, exec_points = _execute_bezier_slider(d, start_x, start_y, end_x, end_y)
  662. _save_slider_track_image(
  663. exec_points,
  664. distance=distance,
  665. duration=None,
  666. drag_ok=drag_ok,
  667. screenshot_path=screenshot_path,
  668. captcha_type=CAPTCHA_SLIDER
  669. )
  670. time.sleep(random.uniform(1.0, 1.8))
  671. if not _slider_still_exists(d):
  672. return True
  673. print(f"[slider] method {method} failed, switch to next")
  674. return False
  675. def dianxuan(d):
  676. click_area_xpaths = [
  677. '//*[@resource-id="com.sankuai.meituan:id/titans_main_layout"]',
  678. '//*[@resource-id="com.sankuai.meituan:id/h5_container"]',
  679. '//*[@resource-id="root"]',
  680. ]
  681. image_path, bounds = _capture_by_bounds(d, click_area_xpaths, captcha_type=CAPTCHA_ICON_CLICK)
  682. if not image_path or not bounds:
  683. return False
  684. left = bounds["left"]
  685. top = bounds["top"]
  686. data = verify(image_path, CAPTCHA_ICON_CLICK)
  687. if not data:
  688. return False
  689. for x, y in data:
  690. time.sleep(random.randint(1, 2))
  691. d.click(left + x + random.randint(-7, 7), top + y + random.randint(-7, 7))
  692. return True
  693. def wenzidianxuan(d):
  694. # 文字点选:按验证码容器 bounds 裁剪后,调用 88888,按返回坐标依次点击
  695. click_area_xpaths = [
  696. '//*[@resource-id="com.sankuai.meituan:id/titans_main_layout"]',
  697. '//*[@resource-id="com.sankuai.meituan:id/h5_container"]',
  698. '//*[@resource-id="root"]',
  699. ]
  700. image_path, bounds = _capture_by_bounds(d, click_area_xpaths, captcha_type=CAPTCHA_TEXT_CLICK)
  701. if not image_path or not bounds:
  702. return False
  703. image_left = bounds["left"]
  704. image_top = bounds["top"]
  705. result = post_api(image_path, "88888")
  706. verify_data = result.get("data", {})
  707. if not (result.get("code") == 10000 and isinstance(verify_data, dict) and verify_data.get("code") == 0):
  708. return False
  709. coords_str = verify_data.get("data", "")
  710. if not coords_str:
  711. return False
  712. clicked = 0
  713. for coord in coords_str.split("|"):
  714. try:
  715. x_img_str, y_img_str = coord.split(",")
  716. x_img = int(x_img_str.strip())
  717. y_img = int(y_img_str.strip())
  718. x_screen = image_left + x_img + random.randint(-5, 5)
  719. y_screen = image_top + y_img + random.randint(-5, 5)
  720. d.click(x_screen, y_screen)
  721. clicked += 1
  722. time.sleep(random.uniform(0.8, 1.6))
  723. except Exception:
  724. continue
  725. return clicked > 0
  726. def click_side(d):
  727. """空间推理验证码(请点击数字)。"""
  728. click_area_xpaths = [
  729. '//*[@resource-id="com.sankuai.meituan:id/titans_main_layout"]',
  730. '//*[@resource-id="com.sankuai.meituan:id/h5_container"]',
  731. '//*[@resource-id="root"]',
  732. ]
  733. image_path, bounds = _capture_by_bounds(d, click_area_xpaths, output_path=CROP_PATH)
  734. if not image_path or not bounds:
  735. return False
  736. left = bounds["left"]
  737. top = bounds["top"]
  738. points = verify(image_path, CAPTCHA_SPACE_REASON)
  739. if not points:
  740. return False
  741. x, y = points[0]
  742. d.click(left + x + random.randint(-2, 2), top + y + random.randint(-2, 2))
  743. # d_list = [
  744. # '//*[@resource-id="com.sankuai.meituan:id/btn_close_verify"]',
  745. # '//*[@resource-id="com.sankuai.meituan:id/yoda_toolbar_title"]',
  746. # '//*[@resource-id="com.sankuai.meituan:id/btn_close_verify"]'
  747. # ]
  748. # for i in d_list:
  749. # d.xpath(i).click()
  750. return True
  751. def Swipe_right(d):
  752. """向右拖动到最右侧(非拼图滑块)。"""
  753. track_xpath = (
  754. '//*[@resource-id="yodaBoxWrapper"] | '
  755. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]'
  756. )
  757. slider_xpath = (
  758. '//*[@resource-id="yodaBox"] | '
  759. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]/android.view.View[1]'
  760. )
  761. if not d.xpath(track_xpath).exists or not d.xpath(slider_xpath).exists:
  762. return False
  763. track_bounds = d.xpath(track_xpath).info.get("bounds", {})
  764. slider_bounds = d.xpath(slider_xpath).info.get("bounds", {})
  765. if not track_bounds or not slider_bounds:
  766. return False
  767. start_x = int((slider_bounds["left"] + slider_bounds["right"]) / 2) + random.randint(-2, 2)
  768. start_y = int((slider_bounds["top"] + slider_bounds["bottom"]) / 2) + random.randint(-2, 2)
  769. right_limit = int(track_bounds["right"]) - random.randint(4, 10)
  770. distance = right_limit - start_x
  771. if distance <= 10:
  772. return False
  773. screenshot_path = _save_debug_screenshot(d, "Swipe_right", tag="full")
  774. points = _build_human_slider_track(start_x, start_y, distance)
  775. duration = _slider_duration(distance)
  776. drag_ok, exec_points = _execute_track(d, points, duration)
  777. _save_slider_track_image(
  778. exec_points,
  779. distance=distance,
  780. duration=duration,
  781. drag_ok=drag_ok,
  782. screenshot_path=screenshot_path or SCREENSHOT_PATH,
  783. captcha_type="Swipe_right"
  784. )
  785. return True
  786. def complexs(d):
  787. """Complex slider flow: move to far-right, OCR with label image, then drag back to target."""
  788. slider_xpath_candidates = [
  789. '//*[@resource-id="yodaBox"]',
  790. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[2]/android.view.View[1]',
  791. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]/android.view.View[1]',
  792. ]
  793. track_xpath_candidates = [
  794. '//*[@resource-id="yodaBoxWrapper"]',
  795. '//*[contains(@text, "请按照说明拖动滑块")]',
  796. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]',
  797. ]
  798. label_xpath_candidates = [
  799. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.widget.TextView[1]',
  800. ]
  801. image_xpath_candidates = [
  802. '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]',
  803. ]
  804. _, slider_bounds = _first_existing_bounds(d, slider_xpath_candidates)
  805. _, track_bounds = _first_existing_bounds(d, track_xpath_candidates)
  806. if not slider_bounds or not track_bounds:
  807. return False
  808. slider_left = slider_bounds["left"]
  809. slider_top = slider_bounds["top"]
  810. slider_right = slider_bounds["right"]
  811. slider_bottom = slider_bounds["bottom"]
  812. slider_width = slider_right - slider_left
  813. slider_center_x = (slider_left + slider_right) / 2
  814. slider_center_y = (slider_top + slider_bottom) / 2
  815. track_left = track_bounds["left"]
  816. track_right = track_bounds["right"]
  817. right_end_center_x = track_right - slider_width / 2
  818. right_end_center_y = slider_center_y
  819. print(f"滑块中心: ({slider_center_x}, {slider_center_y})")
  820. print(f"最右端滑块中心坐标: ({right_end_center_x}, {right_end_center_y})")
  821. touch_down = False
  822. try:
  823. d.touch.down(slider_center_x, slider_center_y)
  824. touch_down = True
  825. time.sleep(random.uniform(0.08, 0.16))
  826. move_right_points = _build_directional_track(
  827. slider_center_x,
  828. slider_center_y,
  829. right_end_center_x,
  830. right_end_center_y,
  831. )
  832. _move_with_pressed_touch(d, move_right_points[1:])
  833. print("滑块已到达最右端")
  834. _, label_bounds = _first_existing_bounds(d, label_xpath_candidates)
  835. _, image_bounds = _first_existing_bounds(d, image_xpath_candidates)
  836. if not label_bounds or not image_bounds:
  837. return False
  838. capture_label_left = label_bounds["left"]
  839. capture_label_top = label_bounds["top"]
  840. capture_label_right = label_bounds["right"]
  841. capture_label_bottom = label_bounds["bottom"]
  842. capture_left = image_bounds["left"]
  843. capture_top = image_bounds["top"]
  844. capture_right = image_bounds["right"]
  845. capture_bottom = image_bounds["bottom"]
  846. print(
  847. "截图区域1(提示文本): "
  848. f"left={capture_label_left}, top={capture_label_top}, "
  849. f"width={capture_label_right - capture_label_left}, "
  850. f"height={capture_label_bottom - capture_label_top}"
  851. )
  852. print(
  853. "截图区域2(图片): "
  854. f"left={capture_left}, top={capture_top}, "
  855. f"width={capture_right - capture_left}, "
  856. f"height={capture_bottom - capture_top}"
  857. )
  858. screenshot_label_path = _build_captcha_image_path("complexs", d=d, ext=".png", tag="label")
  859. screenshot_image_path = _build_captcha_image_path("complexs", d=d, ext=".png", tag="image")
  860. image = _screenshot_to_image(d)
  861. image.crop(
  862. (capture_label_left, capture_label_top, capture_label_right, capture_label_bottom)
  863. ).save(screenshot_label_path)
  864. image.crop(
  865. (capture_left, capture_top, capture_right, capture_bottom)
  866. ).save(screenshot_image_path)
  867. print(f"截图1已保存: {screenshot_label_path}")
  868. print(f"截图2已保存: {screenshot_image_path}")
  869. result = post_api(
  870. screenshot_image_path,
  871. "29013",
  872. label_image_path=screenshot_label_path,
  873. timeout=30,
  874. )
  875. print(f"API返回结果: {result}")
  876. verify_data = result.get("data", {})
  877. print(f"verify_data={verify_data}")
  878. if not (result.get("code") == 10000 and isinstance(verify_data, dict) and verify_data.get("code") == 0):
  879. return False
  880. data_str = verify_data.get("data", "")
  881. if not data_str:
  882. return False
  883. data_value = int(data_str)
  884. print(f"云码返回的像素距离: {data_value}")
  885. slider_target_center_x = track_left + data_value
  886. min_x = track_left + slider_width / 2
  887. max_x = track_right - slider_width / 2
  888. slider_target_center_x = _clamp(slider_target_center_x, min_x, max_x)
  889. print(f"滑块中心目标X坐标: {slider_target_center_x}")
  890. _, current_slider_bounds = _first_existing_bounds(d, slider_xpath_candidates)
  891. if current_slider_bounds:
  892. current_slider_center_x = (current_slider_bounds["left"] + current_slider_bounds["right"]) / 2
  893. else:
  894. current_slider_center_x = right_end_center_x
  895. actual_distance = slider_target_center_x - current_slider_center_x
  896. print(f"实际需要滑动的距离: {actual_distance}")
  897. back_points = _build_directional_track(
  898. current_slider_center_x,
  899. right_end_center_y,
  900. slider_target_center_x,
  901. right_end_center_y,
  902. )
  903. _move_with_pressed_touch(d, back_points[1:])
  904. time.sleep(random.uniform(0.2, 0.4))
  905. d.touch.up(slider_target_center_x, right_end_center_y)
  906. touch_down = False
  907. time.sleep(random.uniform(1.8, 3.2))
  908. return True
  909. except Exception as e:
  910. print(f"complex captcha failed: {e}")
  911. return False
  912. finally:
  913. if touch_down:
  914. try:
  915. d.touch.up(right_end_center_x, right_end_center_y)
  916. except Exception:
  917. pass
  918. def Numbers_English_verify(d):
  919. return srwz(d)
  920. def slider_verify(d):
  921. return hk(d)
  922. def Click_images(d):
  923. # 两种点选入口统一处理
  924. if d.xpath('//*[@text="请按语序依次点击下图文字"]').exists:
  925. return wenzidianxuan(d)
  926. return dianxuan(d)
  927. def Shortest_connection(d):
  928. return lianxian(d)
  929. def _handle_generic_captcha(d, xpath, timeout=60):
  930. """通用验证码处理:等待人工处理完成。"""
  931. start = time.time()
  932. while time.time() - start < timeout:
  933. if xpath and not d.xpath(xpath).exists:
  934. return True
  935. time.sleep(1)
  936. return False
  937. def handle_captcha(d, captcha_type, xpath=None, device_id=None):
  938. _set_runtime_device_id(d=d, device_id=device_id)
  939. handlers = {
  940. "Numbers_English": Numbers_English_verify,
  941. "Swipe_right": Swipe_right,
  942. "Click_images": Click_images,
  943. "slider": slider_verify,
  944. "complexs": complexs,
  945. "Shortest_connection": Shortest_connection,
  946. "click_side": click_side,
  947. }
  948. func = handlers.get(captcha_type)
  949. if func is None:
  950. return _handle_generic_captcha(d, xpath)
  951. return func(d)
  952. def _extract_color_name(api_result):
  953. if not isinstance(api_result, dict):
  954. return ""
  955. if api_result.get("code") == 0 and isinstance(api_result.get("data"), str):
  956. return api_result.get("data", "").strip()
  957. if api_result.get("code") == 10000:
  958. inner = api_result.get("data")
  959. if isinstance(inner, dict) and inner.get("code") == 0:
  960. return str(inner.get("data", "")).strip()
  961. if isinstance(inner, str):
  962. return inner.strip()
  963. return ""
  964. def _normalize_color_name(color_name):
  965. if not color_name:
  966. return ""
  967. alias = {
  968. "红": "红色",
  969. "红的": "红色",
  970. "绿": "绿色",
  971. "蓝": "蓝色",
  972. "黄": "黄色",
  973. "橙": "橙色",
  974. "紫": "紫色",
  975. "黑": "黑色",
  976. "白": "白色",
  977. "棕": "棕色",
  978. "褐": "褐色",
  979. }
  980. if color_name in alias:
  981. return alias[color_name]
  982. for k, v in alias.items():
  983. if k in color_name:
  984. return v
  985. return color_name
  986. def _find_color_coordinates(image_path, color_name):
  987. color_name = _normalize_color_name(color_name)
  988. color_ranges = {
  989. "红色": (([0, 120, 70], [10, 255, 255]), ([170, 120, 70], [180, 255, 255])),
  990. "绿色": (([35, 50, 50], [85, 255, 255]),),
  991. "蓝色": (([90, 50, 50], [130, 255, 255]),),
  992. "黄色": (([20, 100, 100], [30, 255, 255]),),
  993. "橙色": (([5, 100, 100], [18, 255, 255]),),
  994. "紫色": (([130, 50, 50], [165, 255, 255]),),
  995. "黑色": (([0, 0, 0], [180, 255, 50]),),
  996. "白色": (([0, 0, 200], [180, 35, 255]),),
  997. "棕色": (([8, 60, 20], [20, 255, 180]),),
  998. "褐色": (([8, 60, 20], [20, 255, 180]),),
  999. }
  1000. if color_name not in color_ranges:
  1001. return []
  1002. image = cv2.imread(image_path)
  1003. if image is None:
  1004. return []
  1005. hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
  1006. ranges = color_ranges[color_name]
  1007. if len(ranges) == 2:
  1008. lower1 = np.array(ranges[0][0])
  1009. upper1 = np.array(ranges[0][1])
  1010. lower2 = np.array(ranges[1][0])
  1011. upper2 = np.array(ranges[1][1])
  1012. mask = cv2.bitwise_or(cv2.inRange(hsv, lower1, upper1), cv2.inRange(hsv, lower2, upper2))
  1013. else:
  1014. lower = np.array(ranges[0][0])
  1015. upper = np.array(ranges[0][1])
  1016. mask = cv2.inRange(hsv, lower, upper)
  1017. kernel = np.ones((3, 3), np.uint8)
  1018. mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
  1019. mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
  1020. contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  1021. coordinates = []
  1022. for contour in contours:
  1023. area = cv2.contourArea(contour)
  1024. if area < 30:
  1025. continue
  1026. m = cv2.moments(contour)
  1027. if m["m00"] == 0:
  1028. continue
  1029. cx = int(m["m10"] / m["m00"])
  1030. cy = int(m["m01"] / m["m00"])
  1031. coordinates.append((cx, cy))
  1032. return coordinates
  1033. def _nearest_neighbor_path(points):
  1034. if not points:
  1035. return []
  1036. if len(points) <= 2:
  1037. return points[:]
  1038. unvisited = points[:]
  1039. path = [unvisited.pop(0)]
  1040. while unvisited:
  1041. last_x, last_y = path[-1]
  1042. idx = min(
  1043. range(len(unvisited)),
  1044. key=lambda i: math.hypot(last_x - unvisited[i][0], last_y - unvisited[i][1])
  1045. )
  1046. path.append(unvisited.pop(idx))
  1047. return path
  1048. def _human_like_path(points):
  1049. if len(points) < 2:
  1050. return points[:]
  1051. curved = []
  1052. for i in range(len(points) - 1):
  1053. start = points[i]
  1054. end = points[i + 1]
  1055. mid_x = (start[0] + end[0]) / 2
  1056. mid_y = (start[1] + end[1]) / 2
  1057. if abs(end[0] - start[0]) > abs(end[1] - start[1]):
  1058. offset_x = 0
  1059. offset_y = random.uniform(-15, 15)
  1060. else:
  1061. offset_x = random.uniform(-15, 15)
  1062. offset_y = 0
  1063. control_x = mid_x + offset_x
  1064. control_y = mid_y + offset_y
  1065. curved.append(start)
  1066. for t in np.arange(0.1, 1.0, 0.1):
  1067. x = (1 - t) ** 2 * start[0] + 2 * (1 - t) * t * control_x + t ** 2 * end[0]
  1068. y = (1 - t) ** 2 * start[1] + 2 * (1 - t) * t * control_y + t ** 2 * end[1]
  1069. curved.append((int(x), int(y)))
  1070. curved.append(points[-1])
  1071. return curved
  1072. def _simulate_human_drawing(d, path):
  1073. if len(path) < 2:
  1074. return False
  1075. try:
  1076. sx, sy = path[0]
  1077. d.touch.down(sx, sy)
  1078. time.sleep(random.uniform(0.05, 0.1))
  1079. for i in range(1, len(path)):
  1080. x, y = path[i]
  1081. d.touch.move(x + random.randint(-2, 2), y + random.randint(-2, 2))
  1082. time.sleep(random.uniform(0.01, 0.03))
  1083. time.sleep(random.uniform(0.1, 0.2))
  1084. d.touch.up(path[-1][0], path[-1][1])
  1085. return True
  1086. except Exception:
  1087. return False
  1088. def retry_captcha(
  1089. d,
  1090. xpath_text,
  1091. handle_func,
  1092. retry_count=5,
  1093. captcha_name=None,
  1094. allow_close_on_third_fail=True,
  1095. fail_limit_before_close=3
  1096. ):
  1097. # 如果当前页面存在对应验证码,就循环重试处理
  1098. if d.xpath(xpath_text).exists:
  1099. current_name = captcha_name or getattr(handle_func, "__name__", "captcha")
  1100. fail_streak = 0
  1101. for _ in range(retry_count):
  1102. if not d.xpath(xpath_text).exists:
  1103. break
  1104. _save_debug_screenshot(d, current_name, tag="full")
  1105. try:
  1106. handle_func(d)
  1107. except Exception as e:
  1108. print(f"[captcha] {current_name} handler error: {e}")
  1109. time.sleep(3)
  1110. # 验证码消失了,说明处理成功,直接退出
  1111. if not d.xpath(xpath_text).exists:
  1112. break
  1113. fail_streak += 1
  1114. if allow_close_on_third_fail and fail_streak >= fail_limit_before_close:
  1115. closed = _click_captcha_close(d, captcha_xpath=xpath_text)
  1116. print(f"[captcha] {current_name} failed {fail_streak} times, switch captcha: {closed}")
  1117. fail_streak = 0
  1118. time.sleep(1.2)
  1119. def yzm(d=None, device_id=None):
  1120. # 如果没有传设备对象,就默认连接当前设备
  1121. if d is None:
  1122. d = u2.connect()
  1123. _set_runtime_device_id(d=d, device_id=device_id)
  1124. # 向右滑动验证码
  1125. retry_captcha(
  1126. d,
  1127. '//*[contains(@text, "请向右滑动滑块")]',
  1128. Swipe_right,
  1129. captcha_name="Swipe_right",
  1130. allow_close_on_third_fail=False
  1131. )
  1132. # 滑块验证码
  1133. retry_captcha(
  1134. d,
  1135. '//*[@text="请拖动下方滑块完成拼图"]',
  1136. hk,
  1137. captcha_name="slider",
  1138. )
  1139. # 空间推理验证码
  1140. retry_captcha(
  1141. d,
  1142. '//*[contains(@text, "请点击")]',
  1143. click_side,
  1144. captcha_name="click_side",
  1145. allow_close_on_third_fail = False
  1146. )
  1147. # 复杂拖动滑块验证码
  1148. retry_captcha(
  1149. d,
  1150. '//*[contains(@text, "拖动滑块")]',
  1151. complexs,
  1152. captcha_name="complexs",
  1153. allow_close_on_third_fail=False
  1154. )
  1155. # 输入型验证码
  1156. retry_captcha(d, '//*[@text="请输入图片中的内容"]', srwz, captcha_name="text_input")
  1157. # 图标点选验证码
  1158. retry_captcha(d, '//*[@text="请依次点击下图图标"]', dianxuan, captcha_name="icon_click")
  1159. # 文字点选验证码
  1160. retry_captcha(d, '//*[@text="请按语序依次点击下图文字"]', wenzidianxuan, captcha_name="text_click")
  1161. # 最短线连接验证码
  1162. retry_captcha(d, '//*[contains(@text, "用最短线连接")]', lianxian, captcha_name="Shortest_connection")
  1163. def lianxian(d):
  1164. art_text_xpath = '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[1]'
  1165. color_points_xpath = '//*[@text="身份核实"]/android.view.View[1]/android.view.View[1]/android.view.View[2]/android.view.View[1]/android.widget.Image[1]'
  1166. art_text_img_path = _build_captcha_image_path("Shortest_connection", d=d, ext=".png", tag="art_text")
  1167. color_points_img_path = _build_captcha_image_path("Shortest_connection", d=d, ext=".png", tag="color_points")
  1168. art_text_img_path, _ = _capture_by_bounds(
  1169. d,
  1170. art_text_xpath,
  1171. output_path=art_text_img_path,
  1172. captcha_type="Shortest_connection"
  1173. )
  1174. color_points_img_path, color_bounds = _capture_by_bounds(
  1175. d,
  1176. color_points_xpath,
  1177. output_path=color_points_img_path,
  1178. captcha_type="Shortest_connection"
  1179. )
  1180. if not art_text_img_path or not color_points_img_path or not color_bounds:
  1181. return False
  1182. element_left = color_bounds["left"]
  1183. element_top = color_bounds["top"]
  1184. element_width = color_bounds["right"] - color_bounds["left"]
  1185. element_height = color_bounds["bottom"] - color_bounds["top"]
  1186. api_result = post_api(art_text_img_path, "10118")
  1187. color_name = _extract_color_name(api_result)
  1188. if not color_name:
  1189. return False
  1190. relative_points = _find_color_coordinates(color_points_img_path, color_name)
  1191. if len(relative_points) < 2:
  1192. return False
  1193. color_img = cv2.imread(color_points_img_path)
  1194. if color_img is None:
  1195. return False
  1196. img_h, img_w = color_img.shape[:2]
  1197. if img_w <= 0 or img_h <= 0:
  1198. return False
  1199. screen_points = []
  1200. for rx, ry in relative_points:
  1201. sx = element_left + int(rx * (element_width / img_w))
  1202. sy = element_top + int(ry * (element_height / img_h))
  1203. screen_points.append((sx, sy))
  1204. path = _nearest_neighbor_path(screen_points)
  1205. curved = _human_like_path(path)
  1206. return _simulate_human_drawing(d, curved)
  1207. if __name__ == '__main__':
  1208. d = u2.connect("GQIRKB7LVOONM7VW")
  1209. yzm(d)