test_full_auto_login.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. """End-to-end automated 996box password login via jfbym + wulu-geetest-bypass.
  2. Full chain (cost per run: ~0.008 yuan for one jfbym type-22222 call):
  3. 1. wulu Geetest(risk_type='slide').load() → captcha params (bg/slice image paths, lot_number, process_token, pow_detail)
  4. 2. download bg image
  5. 3. jfbym.solve_distance(bg) → gap distance in px
  6. 4. wulu Geetest generates w (RSA+AES), builds verify query
  7. 5. POST /verify → seccode {captcha_id, lot_number, pass_token, gen_time, captcha_output}
  8. 6. Box996Login.login_with_password(un, pw, captcha={**seccode, event:1, type:"login"})
  9. 7. Save new token to MySQL
  10. Configuration via .env + CLI overrides.
  11. We use the real 996box production captcha_id (captured from saz):
  12. 26e753da4a8206359763bcb9bd327eb4
  13. If it has rotated, the /load will return a 1024 (captchaId expired) and we
  14. can fall back to the official geetest demo captcha.
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import asyncio
  19. import base64
  20. import json
  21. import os
  22. import sys
  23. from pathlib import Path
  24. _HERE = Path(__file__).resolve().parent
  25. sys.path.insert(0, str(_HERE.parent / "src"))
  26. from cq2_sdk.env_loader import load_env
  27. load_env(_HERE.parent / ".env")
  28. # 996box production captcha_id (from 手机号短信验证登录过程.saz)
  29. CAPTCHA_ID_996BOX = "26e753da4a8206359763bcb9bd327eb4"
  30. def b64_json(d: dict) -> str:
  31. """Match what 996box client sends in the _captcha_ field: base64(JSON)."""
  32. return base64.b64encode(json.dumps(d, separators=(",", ":")).encode()).decode()
  33. def run_e2e(
  34. username: str,
  35. password: str,
  36. captcha_id: str,
  37. jfbym_token: str,
  38. use_jfbym: bool = False,
  39. ) -> int:
  40. import httpx
  41. from wulu_geetest_bypass import Geetest
  42. from wreq import Emulation
  43. from cq2_sdk.login.box_996 import Box996Login, Box996Error
  44. from cq2_sdk.login.geetest import ManualGeetestSolver # not used for 2nd call; satisfies Box996Login ctor
  45. from cq2_sdk.store import UserStore, connection
  46. from cq2_sdk.debug_log import DebugLogger
  47. if use_jfbym:
  48. from cq2_sdk.login.geetest_jfbym import make_geetest_jfbym_solver
  49. from cq2_sdk.login.jfbym import JfbymSliderSolver
  50. solver = JfbymSliderSolver(token=jfbym_token)
  51. geetest_solver = make_geetest_jfbym_solver(solver)
  52. Geetest.register_solver("slide", geetest_solver)
  53. else:
  54. # Use wulu's BUILT-IN OpenCV slide solver (no jfbym override).
  55. # Reason: jfbym gap_only override (191 → setLeft=191) produced w that
  56. # geetest server rejected with result: fail. The gap position from
  57. # jfbym != the setLeft value wulu's trajectory math expects. Avoid
  58. # this calibration hazard by delegating to wulu's tested opencv
  59. # path. Cloud jfbym is kept as an OPT-IN (--use-jfbym) for envs
  60. # where opencv is unavailable.
  61. from wulu_geetest_bypass.solver import solve_slide as _open_cv_slide
  62. Geetest.register_solver("slide", _open_cv_slide)
  63. # 2) Set up Geetest with 996box-like referer
  64. log = DebugLogger(extra={"task": "e2e_login", "user": username})
  65. log.info("e2e_start", captcha_id=captcha_id)
  66. headers_996box = {
  67. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 "
  68. "(KHTML, like Gecko) 996Box/5.1.1.9007 Chrome/108.0.5359.215 Electron/",
  69. "Referer": "https://pc.hzwlan.com/",
  70. "Origin": "https://pc.hzwlan.com",
  71. "X-Requested-With": "XMLHttpRequest",
  72. }
  73. g = Geetest(
  74. captcha_id=captcha_id,
  75. risk_type="slide",
  76. client_type="web",
  77. client_options={
  78. "headers": headers_996box,
  79. "emulation": Emulation.Chrome147,
  80. "verify": False, # wulu uses wreq with tls emulation; .996hezi.com chain ok
  81. },
  82. )
  83. async def solve_then_login() -> dict:
  84. # 3) load + verify (one jfbym call inside)
  85. log.info("geetest_resolve_start")
  86. seccode = await g.resolve(retry=2)
  87. log.info("geetest_resolve_ok", seccode_keys=list(seccode.keys()))
  88. # 4) Inject into 996box password login
  89. captcha_payload = {
  90. "loginInfo": "", # unused for password, but 996box client includes it
  91. "channel_id": "",
  92. "game_id": "",
  93. "inviter_uid": "",
  94. "needConfirmLogin": True,
  95. "phone": "",
  96. "code": "",
  97. "_captcha_": b64_json({
  98. "captchaId": seccode["captcha_id"],
  99. "captchaOutput": seccode["captcha_output"],
  100. "genTime": seccode["gen_time"],
  101. "lotNumber": seccode["lot_number"],
  102. "passToken": seccode["pass_token"],
  103. }),
  104. }
  105. log.info("calling_box996_login", has_captcha=bool(captcha_payload["_captcha_"]),
  106. captcha_payload_keys=list(captcha_payload.keys()))
  107. box = Box996Login(
  108. geetest=ManualGeetestSolver(), # not used on 2nd call; harmless
  109. device_id="00:15:5d:0a:04:00",
  110. )
  111. try:
  112. session = await box.login_with_password(
  113. username, password, captcha=captcha_payload,
  114. )
  115. except Box996Error as e:
  116. # Dump raw response so we can see what 996box actually rejected
  117. log.error("box996_login_failed", code=e.code, box_msg=e.msg[:120],
  118. full_resp=str(e).__class__.__name__)
  119. return {"ok": False, "stage": "login", "error": f"{e.code}: {e.msg}"}
  120. except CaptchaRequiredError as e:
  121. log.error("box996_captcha_rejected", code=e.code, box_msg=e.msg[:120])
  122. return {"ok": False, "stage": "login", "error": f"captcha: {e.code}: {e.msg}"}
  123. log.info("box996_login_ok", uid=session.uid, token_len=len(session.token))
  124. return {"ok": True, "session": session, "seccode": seccode}
  125. result = asyncio.run(solve_then_login())
  126. if not result["ok"]:
  127. print(f"\n[FAIL] stage={result['stage']}: {result['error']}")
  128. return 1
  129. session = result["session"]
  130. # 5) Persist new token to MySQL
  131. with connection() as conn:
  132. users = UserStore(conn)
  133. existing = users.get_by_username(username)
  134. if existing is None:
  135. print(f"[FAIL] {username} not in MySQL; run scaffold first")
  136. return 1
  137. updated = users.update_token(user_id=existing.id, token=session.token)
  138. log.info("token_saved", user_id=updated.id, token_prefix=session.token[:24] + "...")
  139. print(f"\n[OK] full auto-login SUCCESS")
  140. print(f" uid = {session.uid}")
  141. print(f" new token = {session.token[:30]}...")
  142. print(f" saved to = users.id={updated.id} (longwu988)")
  143. return 0
  144. def main() -> int:
  145. ap = argparse.ArgumentParser()
  146. ap.add_argument("--user", default=os.environ.get("CQ2_ACCOUNT"))
  147. ap.add_argument("--password", default=os.environ.get("CQ2_PASSWORD"))
  148. ap.add_argument("--captcha-id", default=CAPTCHA_ID_996BOX)
  149. ap.add_argument("--jfbym-token", default=os.environ.get("CQ2_JFBYM_TOKEN"))
  150. ap.add_argument("--use-jfbym", action="store_true",
  151. help="Override wulu's opencv with jfbym cloud recognition (may need calibration)")
  152. args = ap.parse_args()
  153. if not all([args.user, args.password, args.jfbym_token]):
  154. print("ERROR: missing --user / --password / --jfbym-token (or .env CQ2_*)")
  155. return 1
  156. return run_e2e(
  157. args.user, args.password, args.captcha_id, args.jfbym_token, args.use_jfbym,
  158. )
  159. if __name__ == "__main__":
  160. sys.exit(main())