3
0

test_captcha.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. # coding=utf-8
  2. """PDD 滑块验证码测试 — 云码平台"""
  3. import uiautomator2 as u2
  4. import requests
  5. import base64
  6. import time
  7. import subprocess
  8. import logging
  9. import random
  10. import io
  11. from PIL import Image
  12. # ============================================================
  13. # ★ 填参数
  14. # ============================================================
  15. DEVICE_ID = "ZDQWUSSWBEDI896T"
  16. YUNMA_TOKEN = "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk"
  17. MAX_ROUNDS = 1
  18. # ============================================================
  19. # PDD 验证码 XPath
  20. # ============================================================
  21. SAFE_VERIFY = '//android.widget.TextView[@text="安全验证"]' # 入口按钮(可能没有)
  22. # 验证码入口容器 → 一定存在,检测用
  23. CAPTCHA_ENTRY = '//*[@resource-id="intelVerify"]'
  24. # 真正的验证码弹窗容器 → 截图裁剪用
  25. CAPTCHA_DIALOG = '//*[@resource-id="captcha-dialog"]'
  26. # 弹窗内图标 + 标题 → 两个都有即判定在验证码弹窗内
  27. CAPTCHA_ICON = '//*[@text=""]'
  28. CAPTCHA_TITLE = '//*[@text="拼多多安全验证"]'
  29. # 中间验证页面:同时存在「前往验证」和「安全验证」
  30. GO_TO_VERIFY = '//*[@text="前往验证"]'
  31. # 安全验证文本 XPath 候选列表(intelVerify 找不到时的兜底检测,≥2 命中即判定验证码存在)
  32. SAFE_VERIFY_TEXT_XPATHS = [
  33. '//*[@text="点击下方"安全验证"按钮,完成拼多多官方验证"]',
  34. '//*[@text="b04804a5-893c-4af7-9076-6fc5f223be44"]',
  35. '//android.widget.TextView[@text="安全验证"]',
  36. ]
  37. # 滑块定位候选列表(按优先级依次尝试)
  38. SLIDER_CANDIDATES = [
  39. '//*[@resource-id="slide-button"]',
  40. '(//android.widget.TextView)[6]',
  41. ]
  42. # 距离缩放系数(如果实际拖动距离与云码返回距离有偏差,调整此值)
  43. DIST_SCALE = 1.0
  44. # 计算题验证码
  45. CALC_PROMPT = '//*[@text="请输入图中的计算结果"]'
  46. SUBMIT_BTN_CANDIDATES = [
  47. '//*[@text="确定"]',
  48. '//*[@text="提交"]',
  49. '//*[@text="确认"]',
  50. ]
  51. # 云码
  52. YUNMA_URL = "http://api.jfbym.com/api/YmServer/customApi"
  53. YUNMA_TYPE = "22222" # 滑块
  54. YUNMA_CALC_TYPE = "50100" # 数字计算
  55. logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
  56. log = logging.getLogger(__name__)
  57. def connect(dev):
  58. d = u2.connect_usb(dev)
  59. subprocess.run(f'adb -s {dev} shell /data/local/tmp/atx-agent server -d --stop',
  60. capture_output=True, text=True, shell=True)
  61. time.sleep(2)
  62. subprocess.run(f'adb -s {dev} shell /data/local/tmp/atx-agent server -d',
  63. capture_output=True, text=True, shell=True)
  64. time.sleep(2)
  65. return d
  66. def _find_slider(d):
  67. """在候选列表中定位滑块元素,返回其 bounds,找不到返回 None"""
  68. for xpath in SLIDER_CANDIDATES:
  69. try:
  70. el = d.xpath(xpath)
  71. if el.exists:
  72. b = el.info.get('bounds', {})
  73. # 校验 bounds 合理性:宽高至少 10px
  74. w = b.get('right', 0) - b.get('left', 0)
  75. h = b.get('bottom', 0) - b.get('top', 0)
  76. if w >= 10 and h >= 10:
  77. log.info(f"滑块定位成功: {xpath} → bounds={b}")
  78. return b
  79. except Exception:
  80. continue
  81. return None
  82. def _get_captcha_bounds(d):
  83. """获取验证码弹窗 bounds(captcha-dialog 容器,用于截图裁剪)"""
  84. el = d.xpath(CAPTCHA_DIALOG)
  85. if el.exists:
  86. b = el.info.get('bounds', {})
  87. if b:
  88. log.info(f"captcha-dialog bounds: {b}")
  89. return b
  90. return None
  91. def _is_captcha_entry_visible(d):
  92. """验证码入口容器 intelVerify 是否可见"""
  93. return d.xpath(CAPTCHA_ENTRY).exists
  94. def _is_captcha_dialog_visible(d):
  95. """验证码弹窗是否可见(图标 + 标题两个都有)"""
  96. return d.xpath(CAPTCHA_ICON).exists and d.xpath(CAPTCHA_TITLE).exists
  97. def _is_captcha_detected_by_safe_text(d):
  98. """intelVerify 找不到时的回退检测:统计安全验证文本 XPath 命中数,≥2 即判定验证码存在"""
  99. hits = 0
  100. for xpath in SAFE_VERIFY_TEXT_XPATHS:
  101. try:
  102. if d.xpath(xpath).exists:
  103. hits += 1
  104. log.info(f"安全验证文本命中: {xpath}")
  105. except Exception:
  106. continue
  107. log.info(f"安全验证文本命中数: {hits}/{len(SAFE_VERIFY_TEXT_XPATHS)}")
  108. return hits >= 2
  109. def _is_on_intermediate_verify_page(d):
  110. """检测是否在中间验证页面(同时存在「前往验证」+「安全验证」)"""
  111. has_go = d.xpath(GO_TO_VERIFY).exists
  112. has_safe = d.xpath(SAFE_VERIFY).exists
  113. log.info(f"中间页面检测: 前往验证={has_go}, 安全验证={has_safe}")
  114. return has_go and has_safe
  115. def _wait_for_captcha_dialog(d, timeout=5):
  116. """等待 captcha-dialog + 标题出现,返回 True/False"""
  117. for _ in range(timeout):
  118. has_icon = d.xpath(CAPTCHA_ICON).exists
  119. has_title = d.xpath(CAPTCHA_TITLE).exists
  120. if has_icon and has_title:
  121. log.info("验证码弹窗确认: 图标 + 拼多多安全验证 ✓")
  122. return True
  123. log.info(f"等待弹窗... icon={has_icon}, title={has_title}")
  124. time.sleep(1)
  125. return False
  126. def _enter_captcha_dialog(d):
  127. """等待弹窗出现,等不到则检测中间页面并点击「前往验证」后再等,成功返回 True"""
  128. if _wait_for_captcha_dialog(d):
  129. return True
  130. # 等了5次都没有 → 检测是否跳到中间页面
  131. if _is_on_intermediate_verify_page(d):
  132. log.info("检测到中间页面(前往验证 + 安全验证),点击「前往验证」...")
  133. d.xpath(GO_TO_VERIFY).click()
  134. time.sleep(1.5)
  135. if _wait_for_captcha_dialog(d):
  136. return True
  137. log.warning("中间页面导航后仍未出现弹窗")
  138. return False
  139. log.warning("等待超时,验证码弹窗未出现")
  140. return False
  141. def _screenshot_to_b64(d, crop=False):
  142. """截图并返回 base64,可选裁剪到 captcha-dialog 区域"""
  143. im = d.screenshot()
  144. if crop:
  145. cb = _get_captcha_bounds(d)
  146. if cb:
  147. im = im.crop((
  148. max(0, cb['left']), max(0, cb['top']),
  149. min(im.width, cb['right']), min(im.height, cb['bottom']),
  150. ))
  151. buf = io.BytesIO()
  152. im.save(buf, format='PNG')
  153. return base64.b64encode(buf.getvalue()).decode()
  154. def solve_calc(d):
  155. """解决计算题验证码,成功返回 True(50100 直接返回计算结果)"""
  156. log.info("检测到计算题验证码")
  157. # 截图 → 云码 50100
  158. b64 = _screenshot_to_b64(d, crop=True)
  159. resp = requests.post(YUNMA_URL, json={
  160. "token": YUNMA_TOKEN, "type": YUNMA_CALC_TYPE, "image": b64
  161. }, headers={"Content-Type": "application/json"}, timeout=30).json()
  162. log.info(f"云码(50100): {resp}")
  163. if resp.get("msg") != "识别成功":
  164. log.warning(f"云码 50100 识别失败: {resp.get('msg')}")
  165. return False
  166. data = resp.get("data", {})
  167. result_str = data.get("data", "") if isinstance(data, dict) else str(data or "")
  168. try:
  169. result = int(str(result_str).strip())
  170. except (ValueError, TypeError):
  171. log.warning(f"50100 返回非数字: {result_str}")
  172. return False
  173. log.info(f"计算结果: {result}")
  174. # 找到输入框并输入结果
  175. edit_text = d(className='android.widget.EditText')
  176. if not edit_text.exists:
  177. log.warning("未找到输入框")
  178. return False
  179. edit_text.click()
  180. time.sleep(0.3)
  181. edit_text.set_text(str(result))
  182. time.sleep(0.5)
  183. # 点击确定/提交
  184. clicked = False
  185. for xpath in SUBMIT_BTN_CANDIDATES:
  186. btn = d.xpath(xpath)
  187. if btn.exists:
  188. btn.click()
  189. clicked = True
  190. log.info(f"点击: {xpath}")
  191. break
  192. if not clicked:
  193. d.press('enter')
  194. time.sleep(2)
  195. # 验证是否解开
  196. if not _is_captcha_dialog_visible(d) and not _is_captcha_entry_visible(d):
  197. log.info("计算题验证码已解开 ✓")
  198. return True
  199. else:
  200. log.info("计算题验证码未解开")
  201. return False
  202. def solve(d):
  203. """一次验证码处理,自动识别类型(滑块/计算题),成功返回 True"""
  204. # ── 第一步:检测验证码 ──
  205. # 优先级1: captcha-dialog 弹窗已存在(重入场景,跳过入口)
  206. if _is_captcha_dialog_visible(d):
  207. log.info("已在验证码弹窗内,跳过入口")
  208. # 优先级2: intelVerify 入口容器 → 点按钮进入
  209. elif d.xpath(CAPTCHA_ENTRY).exists:
  210. log.info("检测到验证码入口 intelVerify")
  211. if not d.xpath(SAFE_VERIFY).exists:
  212. log.error("入口容器存在但无「安全验证」按钮,无法触发!")
  213. return False
  214. log.info("点击「安全验证」...")
  215. d.xpath(SAFE_VERIFY).click()
  216. # ── 等待弹窗(等不到则检测中间页面)──
  217. if not _enter_captcha_dialog(d):
  218. return False
  219. else:
  220. # 兜底:intelVerify 找不到时,通过安全验证文本检测(≥2 命中即判定验证码存在)
  221. if _is_captcha_detected_by_safe_text(d):
  222. log.info("兜底检测:安全验证文本命中,确认验证码存在")
  223. # 先点击「安全验证」再等弹窗
  224. if d.xpath(SAFE_VERIFY).exists:
  225. log.info("点击「安全验证」...")
  226. d.xpath(SAFE_VERIFY).click()
  227. if not _enter_captcha_dialog(d):
  228. return False
  229. else:
  230. return False
  231. # ── 第四步:弹窗内循环尝试(失败刷新仍在弹窗内,类型可能变)──
  232. for attempt in range(5):
  233. # 弹窗不在 → 检查是否跳到了中间页面/安全验证入口
  234. if not _is_captcha_dialog_visible(d):
  235. if _is_on_intermediate_verify_page(d):
  236. log.info(f"第 {attempt+1} 次重试: 检测到中间页面,点击「前往验证」...")
  237. d.xpath(GO_TO_VERIFY).click()
  238. time.sleep(1.5)
  239. if not _wait_for_captcha_dialog(d):
  240. log.warning("重新导航失败")
  241. return False
  242. elif d.xpath(SAFE_VERIFY).exists:
  243. log.info(f"第 {attempt+1} 次重试: 检测到安全验证入口,重新等待弹窗...")
  244. if not _wait_for_captcha_dialog(d):
  245. log.warning("重新导航失败")
  246. return False
  247. else:
  248. log.info("弹窗消失,验证码已解开 ✓")
  249. return True
  250. # 每次重试重新判断类型(刷新后类型可能变)
  251. if d.xpath(CALC_PROMPT).exists:
  252. log.info(f"第 {attempt+1} 次尝试 → 计算题")
  253. if solve_calc(d):
  254. return True
  255. else:
  256. log.info(f"第 {attempt+1} 次尝试 → 滑块")
  257. if _solve_slider(d):
  258. return True
  259. log.info(f"第 {attempt+1} 次失败,等待验证码刷新...")
  260. time.sleep(2)
  261. log.warning("重试 5 次仍未解开")
  262. return False
  263. def _solve_slider(d):
  264. """滑块验证码核心逻辑,成功返回 True"""
  265. slider_bounds = None
  266. for _ in range(5):
  267. slider_bounds = _find_slider(d)
  268. if slider_bounds:
  269. break
  270. time.sleep(1)
  271. if not slider_bounds:
  272. log.warning("未找到滑块元素")
  273. return False
  274. sx = (slider_bounds['left'] + slider_bounds['right']) // 2
  275. sy = (slider_bounds['top'] + slider_bounds['bottom']) // 2
  276. # ── 关键:先按住滑块2秒,滑块图会变 ──
  277. log.info(f"按住滑块 ({sx},{sy})")
  278. d.touch.down(sx, sy)
  279. time.sleep(random.uniform(1.5, 2))
  280. # ── 按住状态下截图 → 云码 ──
  281. b64 = _screenshot_to_b64(d, crop=False)
  282. resp = requests.post(YUNMA_URL, json={
  283. "token": YUNMA_TOKEN, "type": YUNMA_TYPE, "image": b64
  284. }, headers={"Content-Type": "application/json"}, timeout=30).json()
  285. log.info(f"云码: {resp}")
  286. if resp.get("msg") != "识别成功":
  287. log.warning(f"云码识别失败: {resp.get('msg')}")
  288. d.touch.up(sx, sy)
  289. return False
  290. dist = resp.get("data", {}).get("data", 0)
  291. dist = float(dist or 0) * DIST_SCALE +random.uniform(-3.1, 3.1)
  292. if not dist:
  293. log.warning("距离为0")
  294. d.touch.up(sx, sy)
  295. return False
  296. target_x = sx + int(dist)
  297. log.info(f"拖动 {dist:.0f}px 从 ({sx},{sy}) 到 ({target_x},{sy})")
  298. # ── 模拟真人轨迹:多点、变速、微小抖动 ──
  299. steps = [
  300. (sx + int(dist * 0.05), sy + random.randint(-2, 2), random.uniform(0.05, 0.1)),
  301. (sx + int(dist * 0.20), sy + random.randint(-2, 2), random.uniform(0.08, 0.15)),
  302. (sx + int(dist * 0.40), sy + random.randint(-1, 1), random.uniform(0.1, 0.2)),
  303. (sx + int(dist * 0.60), sy + random.randint(-1, 1), random.uniform(0.1, 0.2)),
  304. (sx + int(dist * 0.80), sy + random.randint(-1, 1), random.uniform(0.08, 0.15)),
  305. (sx + int(dist * 0.95), sy + random.randint(-1, 1), random.uniform(0.05, 0.1)),
  306. (target_x, sy, 0.3),
  307. ]
  308. for step_x, step_y, delay in steps:
  309. d.touch.move(step_x, step_y)
  310. time.sleep(delay)
  311. d.touch.up(target_x, sy)
  312. time.sleep(2)
  313. # 等待弹窗消失(最多等5秒,弹窗关闭有延迟)
  314. for _ in range(5):
  315. if not _is_captcha_dialog_visible(d):
  316. log.info("滑块验证通过 ✓")
  317. return True
  318. log.info("等待弹窗消失...")
  319. time.sleep(1)
  320. log.info("滑块验证未通过")
  321. return False
  322. if __name__ == "__main__":
  323. if not DEVICE_ID or not YUNMA_TOKEN:
  324. log.error("先填 DEVICE_ID 和 YUNMA_TOKEN")
  325. exit(1)
  326. dev = connect(DEVICE_ID)
  327. log.info(f"已连接 {DEVICE_ID}")
  328. for i in range(MAX_ROUNDS):
  329. try:
  330. if solve(dev):
  331. log.info(f"第 {i+1} 轮完成")
  332. else:
  333. log.info(f"第 {i+1} 轮未检测到")
  334. time.sleep(3)
  335. except KeyboardInterrupt:
  336. break
  337. except Exception as e:
  338. log.error(f"异常: {e}")
  339. time.sleep(3)
  340. log.info("结束")