nine_grid.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """
  2. 九宫格验证码完整流程
  3. ① jfbym type 10 OCR雪花屏 → ② 截图九宫格 → ③ jfbym 30223 返回坐标 → ④ 点击
  4. """
  5. import os, time, base64, json, random
  6. import cv2, numpy as np
  7. import uiautomator2 as u2
  8. import requests
  9. from PIL import Image
  10. # ===== 配置 =====
  11. DEVICE = "O7R4Y9CMPBPBU4VK"
  12. JFBYM_TOKEN = "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk"
  13. CAPTCHA_CROP = (42, 424, 1180, 575) # 雪花屏裁剪
  14. GRID_CROP = (38, 662, 1185, 1817) # 九宫格裁剪
  15. SHOT_COUNT = 20 # 连拍张数
  16. AREA_THRESHOLD = 100 # 连通域面积阈值
  17. JFBYM_URL = "https://api.jfbym.com/api/YmServer/customApi"
  18. OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image", "success")
  19. # =================
  20. def solve_snow_captcha():
  21. """步骤①: 本地降噪 + jfbym type 10 OCR → 返回 (文字, extra, 设备)"""
  22. d = u2.connect(DEVICE)
  23. # 连拍 + 平均降噪
  24. imgs = []
  25. for _ in range(SHOT_COUNT):
  26. full = d.screenshot(format='pillow')
  27. imgs.append(np.array(full.crop(CAPTCHA_CROP).convert('L')))
  28. time.sleep(0.05)
  29. avg = np.mean(imgs, axis=0).astype(np.uint8)
  30. t = np.percentile(avg, 15)
  31. dark = np.where(avg < t, 0, 255).astype(np.uint8)
  32. _, thresh = cv2.threshold(dark, 127, 255, cv2.THRESH_BINARY_INV)
  33. num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(thresh, connectivity=8)
  34. clean = np.full_like(dark, 255)
  35. for i in range(1, num_labels):
  36. if stats[i, cv2.CC_STAT_AREA] > AREA_THRESHOLD:
  37. clean[labels == i] = 0
  38. os.makedirs(OUT_DIR, exist_ok=True)
  39. cleaned_path = os.path.join(OUT_DIR, "cleaned.png")
  40. cv2.imwrite(cleaned_path, clean)
  41. # 发给 jfbym type 10 OCR
  42. with open(cleaned_path, 'rb') as f:
  43. b64 = base64.b64encode(f.read()).decode()
  44. resp = requests.post(JFBYM_URL, json={
  45. "token": JFBYM_TOKEN, "type": "10", "image": b64
  46. }, headers={"Content-Type": "application/json"}, timeout=30).json()
  47. print(f" [jfbym-type10] 返回: {json.dumps(resp, ensure_ascii=False)[:200]}")
  48. extra = resp.get("data", {}).get("data", {})
  49. if isinstance(extra, list):
  50. extra = extra[0] if extra else {}
  51. if isinstance(extra, dict):
  52. text = extra.get("tips", "")
  53. elif isinstance(extra, str):
  54. text = extra
  55. else:
  56. text = ""
  57. print(f"[1] 降噪+OCR: {text}")
  58. return text, extra, d
  59. def get_click_pos(d, extra):
  60. """步骤②③: 截图九宫格 → jfbym 30223 → 返回坐标"""
  61. grid_img = d.screenshot(format='pillow').crop(GRID_CROP)
  62. grid_path = os.path.join(OUT_DIR, "grid.png")
  63. grid_img.save(grid_path)
  64. print(f"[2] 九宫格: {grid_img.size}")
  65. with open(grid_path, 'rb') as f:
  66. grid_b64 = base64.b64encode(f.read()).decode()
  67. resp = requests.post(JFBYM_URL, json={
  68. "token": JFBYM_TOKEN,
  69. "type": "30223",
  70. "image": grid_b64,
  71. "extra": extra
  72. }, headers={"Content-Type": "application/json"}, timeout=30).json()
  73. print(f" [jfbym-30223] 返回: {json.dumps(resp, ensure_ascii=False)[:200]}")
  74. resp_data = resp.get("data", {})
  75. if isinstance(resp_data, list):
  76. data = resp_data[0] if resp_data else {}
  77. else:
  78. data = resp_data.get("data", {})
  79. click_pos = data.get("click_pos", [])
  80. tips = data.get("tips", "")
  81. print(f"[3] tips={tips}, click_pos={click_pos}")
  82. return click_pos, tips
  83. SUBMIT_BTN = (602, 2018) # 提交按钮坐标
  84. REFRESH_WAIT = 1.5 # 点击后等待刷新秒数(加长)
  85. MAX_ROUNDS = 15 # 最大轮数, 防止死循环
  86. CLICK_OFFSET = 18 # 随机偏移范围(±18px)
  87. GRID_W, GRID_H = GRID_CROP[2] - GRID_CROP[0], GRID_CROP[3] - GRID_CROP[1] # 九宫格宽高
  88. STALL_LIMIT = 2 # 连续N轮候选完全不变→判定卡滞, 提前提交
  89. CAPTCHA_KEYWORDS = ("请依次点击", "根据提示", "没有新图片", "提交", "验证失败", "验证码错误")
  90. _ocr_eng = None
  91. def _captcha_still_present(d):
  92. """截图 + OCR 检测九宫格验证码特征词是否仍在页面上"""
  93. from rapidocr_onnxruntime import RapidOCR
  94. global _ocr_eng
  95. if _ocr_eng is None:
  96. _ocr_eng = RapidOCR()
  97. shot = d.screenshot(format='opencv')
  98. if shot is None:
  99. return None
  100. r = _ocr_eng(shot)
  101. if not r or not r[0]:
  102. return False
  103. texts = [item[1] for item in r[0]]
  104. return any(any(kw in t for kw in CAPTCHA_KEYWORDS) for t in texts)
  105. def tap_loop(d, extra):
  106. """步骤④: 每次只点一个→等待刷新→重新截图识别→直到无匹配→提交
  107. 返回 True/False: 提交后延迟一段时间再 OCR 复检验证码特征词是否消失, 以此判定真实成败
  108. (而不是把"识别到的提示文字"当作成败信号——jfbym的tips有时是纯字符串, 会被误判为falsy)。"""
  109. prev_sig = None
  110. stall_count = 0
  111. submitted = False
  112. for round_num in range(1, MAX_ROUNDS + 1):
  113. pos, tips = get_click_pos(d, extra)
  114. if not pos:
  115. print(f"[4] 第{round_num}轮无匹配,点击提交按钮")
  116. d.click(SUBMIT_BTN[0] + random.randint(-8, 8), SUBMIT_BTN[1] + random.randint(-5, 5))
  117. submitted = True
  118. break
  119. sig = (tips, tuple(sorted(pos)))
  120. if sig == prev_sig:
  121. stall_count += 1
  122. else:
  123. stall_count = 0
  124. prev_sig = sig
  125. if stall_count >= STALL_LIMIT:
  126. print(f"[4] 连续{stall_count + 1}轮候选完全未变化,判定检测/点击卡滞,直接提交")
  127. d.click(SUBMIT_BTN[0] + random.randint(-8, 8), SUBMIT_BTN[1] + random.randint(-5, 5))
  128. submitted = True
  129. break
  130. # 只点第一个,点完重新截图
  131. x, y = pos[0]
  132. ox = x + random.randint(-CLICK_OFFSET, CLICK_OFFSET)
  133. oy = y + random.randint(-CLICK_OFFSET, CLICK_OFFSET)
  134. ox = max(5, min(GRID_W - 5, ox))
  135. oy = max(5, min(GRID_H - 5, oy))
  136. print(f"[4] 第{round_num}轮 共{len(pos)}个匹配,先点({x},{y})→偏移({ox},{oy})")
  137. d.click(GRID_CROP[0] + ox, GRID_CROP[1] + oy)
  138. time.sleep(REFRESH_WAIT)
  139. else:
  140. print(f"[4] 超过{MAX_ROUNDS}轮,直接提交")
  141. d.click(SUBMIT_BTN[0] + random.randint(-8, 8), SUBMIT_BTN[1] + random.randint(-5, 5))
  142. submitted = True
  143. if not submitted:
  144. return False
  145. # 提交后不能立刻检测(页面还没刷新完成), 等一段随机间隔再 OCR 复检
  146. time.sleep(random.uniform(1.8, 2.6))
  147. still_present = _captcha_still_present(d)
  148. if still_present is None:
  149. print("[4] 提交后复检失败(截图/OCR异常),保守判定为未通过")
  150. return False
  151. if still_present:
  152. print("[4] 提交后复检: 验证码特征词仍在,判定未通过")
  153. return False
  154. print("[4] 提交后复检: 验证码特征词已消失,判定通过")
  155. return True
  156. def solve(driver=None):
  157. """九宫格验证码求解, 可传入外部driver或自动连接"""
  158. if driver is not None:
  159. d = driver
  160. else:
  161. d = u2.connect(DEVICE)
  162. if not JFBYM_TOKEN:
  163. raise ValueError("请先设置 JFBYM_TOKEN")
  164. text, extra, _ = solve_snow_captcha_with_driver(d)
  165. return tap_loop(d, extra)
  166. def solve_snow_captcha_with_driver(d):
  167. """步骤①: 本地降噪 + jfbym type 10 OCR → 返回 (文字, extra, 设备)"""
  168. # 连拍 + 平均降噪
  169. imgs = []
  170. for _ in range(SHOT_COUNT):
  171. full = d.screenshot(format='pillow')
  172. imgs.append(np.array(full.crop(CAPTCHA_CROP).convert('L')))
  173. time.sleep(0.05)
  174. avg = np.mean(imgs, axis=0).astype(np.uint8)
  175. t = np.percentile(avg, 15)
  176. dark = np.where(avg < t, 0, 255).astype(np.uint8)
  177. _, thresh = cv2.threshold(dark, 127, 255, cv2.THRESH_BINARY_INV)
  178. num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(thresh, connectivity=8)
  179. clean = np.full_like(dark, 255)
  180. for i in range(1, num_labels):
  181. if stats[i, cv2.CC_STAT_AREA] > AREA_THRESHOLD:
  182. clean[labels == i] = 0
  183. os.makedirs(OUT_DIR, exist_ok=True)
  184. cleaned_path = os.path.join(OUT_DIR, "cleaned.png")
  185. cv2.imwrite(cleaned_path, clean)
  186. # 发给 jfbym type 10 OCR
  187. with open(cleaned_path, 'rb') as f:
  188. b64 = base64.b64encode(f.read()).decode()
  189. resp = requests.post(JFBYM_URL, json={
  190. "token": JFBYM_TOKEN, "type": "10", "image": b64
  191. }, headers={"Content-Type": "application/json"}, timeout=30).json()
  192. print(f" [jfbym-type10] 返回: {json.dumps(resp, ensure_ascii=False)[:200]}")
  193. extra = resp.get("data", {}).get("data", {})
  194. if isinstance(extra, list):
  195. extra = extra[0] if extra else {}
  196. text = extra.get("tips", "") if isinstance(extra, dict) else ""
  197. print(f"[1] 降噪+OCR: {text}")
  198. return text, extra, d
  199. if __name__ == '__main__':
  200. print(f"设备: {DEVICE}\n")
  201. print(f"结果: {solve()}")