175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""抓取快手验证页的网络协议 + JS bundle,评估"扣 JS 纯算"可行性。
|
||
|
||
落盘:
|
||
- out/captcha_net/timeline.json —— 所有 request(method/url/post_data head/headers)
|
||
与 response(status/content-type/url) 时间线
|
||
- out/captcha_net/js_*.js —— 页面加载的所有外部脚本源码
|
||
|
||
重点看:
|
||
1. challenge 拉图请求(哪个端点、什么参数、返回什么);
|
||
2. kSecretApiVerify 的请求体是明文距离/轨迹,还是加密 blob;
|
||
3. 构造 verify 体的 JS 能否单独扒出来 + 是否依赖 DOM/window(补环境成本)。
|
||
|
||
用法:
|
||
uv run python -m tools.captcha_net --mobile 17666663175
|
||
uv run python -m tools.captcha_net --url 'https://...captcha.html?key=...'
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
OUT_DIR = ROOT / "out" / "captcha_net"
|
||
|
||
|
||
def _fresh_error_url(mobile: str, device_profile: str, base_url: str) -> str:
|
||
cmd = [
|
||
sys.executable, "-m", "tools.sms_login_cli",
|
||
"--mobile", mobile, "--base-url", base_url,
|
||
"--code", "000000", "--transport", "okhttp4-android10",
|
||
]
|
||
print("[net] 取新鲜 error_url ...")
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(ROOT))
|
||
out = proc.stdout + proc.stderr
|
||
m = re.search(r'https://app\.m\.kuaishou\.com/verify/captcha\.html\?[^"\s\\]+', out)
|
||
if not m:
|
||
print(out[-1500:])
|
||
raise SystemExit("[net] 未提取到 error_url")
|
||
return m.group(0)
|
||
|
||
|
||
def capture(error_url: str, *, wait: int) -> None:
|
||
import shutil
|
||
import subprocess as sp
|
||
import tempfile
|
||
|
||
from core.captcha_assist import _find_system_chromium, _free_local_port, _wait_for_cdp_endpoint
|
||
from playwright.sync_api import sync_playwright
|
||
|
||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
for f in OUT_DIR.glob("js_*.js"):
|
||
f.unlink()
|
||
|
||
browser_path = _find_system_chromium()
|
||
port = _free_local_port()
|
||
profile_dir = tempfile.mkdtemp(prefix="ksjsb-net-")
|
||
proc = sp.Popen(
|
||
[
|
||
browser_path,
|
||
f"--remote-debugging-port={port}",
|
||
"--remote-debugging-address=127.0.0.1",
|
||
f"--user-data-dir={profile_dir}",
|
||
"--no-first-run", "--no-default-browser-check",
|
||
"--window-size=430,920", "about:blank",
|
||
],
|
||
stdout=sp.DEVNULL, stderr=sp.DEVNULL,
|
||
)
|
||
timeline: list[dict] = []
|
||
js_urls: list[str] = []
|
||
try:
|
||
endpoint = _wait_for_cdp_endpoint(port, timeout=20)
|
||
with sync_playwright() as pw:
|
||
browser = pw.chromium.connect_over_cdp(endpoint, timeout=20000)
|
||
context = browser.contexts[0]
|
||
page = context.pages[0] if context.pages else context.new_page()
|
||
|
||
def on_request(req: object) -> None:
|
||
url = str(getattr(req, "url", "") or "")
|
||
method = str(getattr(req, "method", "") or "")
|
||
post = getattr(req, "post_data", None)
|
||
if isinstance(post, (bytes, bytearray)):
|
||
post = post.decode("utf-8", "replace")
|
||
headers = dict(getattr(req, "headers", {}) or {})
|
||
if any(k in url for k in ("captcha", "sliding", "kSecret", "verify", "/zt/", "/wd/")):
|
||
timeline.append({
|
||
"kind": "req", "method": method, "url": url,
|
||
"post_head": (post or "")[:600],
|
||
"header_keys": sorted(headers),
|
||
})
|
||
if url.endswith(".js") or ".js?" in url:
|
||
js_urls.append(url)
|
||
|
||
def on_response(resp: object) -> None:
|
||
url = str(getattr(resp, "url", "") or "")
|
||
if any(k in url for k in ("captcha", "sliding", "kSecret", "verify", "/zt/", "/wd/")):
|
||
headers = dict(getattr(resp, "headers", {}) or {})
|
||
timeline.append({
|
||
"kind": "resp", "url": url,
|
||
"status": int(getattr(resp, "status", 0) or 0),
|
||
"content_type": headers.get("content-type", ""),
|
||
})
|
||
|
||
page.on("request", on_request)
|
||
page.on("response", on_response)
|
||
print(f"[net] goto {error_url[:90]}...")
|
||
page.goto(error_url, wait_until="domcontentloaded", timeout=30000)
|
||
time.sleep(min(max(wait, 2), 12))
|
||
|
||
# 列出页面实际加载的脚本 src,逐个落盘
|
||
scripts = page.evaluate(
|
||
"Array.from(document.scripts).map(s=>s.src).filter(Boolean)"
|
||
) or []
|
||
# 合并 on_request 捕获到的 .js(含动态注入的)
|
||
all_js = list(dict.fromkeys([*scripts, *js_urls]))
|
||
print(f"[net] 脚本数={len(all_js)},开始下载源码")
|
||
from curl_cffi.requests import get as cffi_get
|
||
for idx, src in enumerate(all_js):
|
||
try:
|
||
r = cffi_get(src, timeout=20, headers={"Referer": error_url})
|
||
body = r.content or b""
|
||
except Exception as exc: # noqa: BLE001
|
||
print(f" [{idx}] 下载失败 {src[:80]}: {exc.__class__.__name__}")
|
||
continue
|
||
name = f"js_{idx:02d}_{re.sub(r'[^a-z0-9]+','_',src.lower())[-40:]}.js"
|
||
(OUT_DIR / name).write_bytes(body)
|
||
print(f" [{idx}] {len(body)}B {src[:90]}")
|
||
finally:
|
||
if proc.poll() is None:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=3)
|
||
except sp.TimeoutExpired:
|
||
proc.kill()
|
||
shutil.rmtree(profile_dir, ignore_errors=True)
|
||
|
||
(OUT_DIR / "timeline.json").write_text(
|
||
json.dumps(timeline, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
print("\n=== 协议时间线(captcha 相关)===")
|
||
for t in timeline:
|
||
if t["kind"] == "req":
|
||
print(f" REQ {t['method']} {t['url'][:95]}")
|
||
if t.get("post_head"):
|
||
print(f" post: {t['post_head'][:300]}")
|
||
else:
|
||
print(f" RESP[{t['status']}] {t.get('content_type','')} {t['url'][:95]}")
|
||
print(f"\n[net] 落盘目录: {OUT_DIR}")
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--mobile", default="17666663175")
|
||
ap.add_argument("--url", default="")
|
||
ap.add_argument("--device-profile", default="out/sms_device_latest.json")
|
||
ap.add_argument("--base-url", default="https://az1-api.ksapisrv.com")
|
||
ap.add_argument("--wait", type=int, default=8)
|
||
args = ap.parse_args()
|
||
error_url = args.url or _fresh_error_url(args.mobile, args.device_profile, args.base_url)
|
||
print(f"[net] error_url = {error_url}")
|
||
capture(error_url, wait=args.wait)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|