更新
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
"""抖音一键采集器 · 本地图形化页面服务(stdlib,无额外依赖)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app_paths import ui_html_path
|
||||
|
||||
UI_FILE = ui_html_path()
|
||||
DEFAULT_PORT = 8765
|
||||
|
||||
|
||||
class CollectorState:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self.phase = "waiting"
|
||||
self.message = "正在启动…"
|
||||
self.result = None
|
||||
self.checks: list[dict[str, str]] = []
|
||||
self.result_text = ""
|
||||
self.my_uid = ""
|
||||
self.ok_core = False
|
||||
self.summary = ""
|
||||
self.run_id = uuid.uuid4().hex[:8]
|
||||
self.started_at = int(time.time())
|
||||
self.port = DEFAULT_PORT
|
||||
self.step = 1
|
||||
self.logs: list[str] = []
|
||||
|
||||
def append_log(self, message: str) -> None:
|
||||
line = (message or "").strip()
|
||||
if not line:
|
||||
return
|
||||
with self._lock:
|
||||
self.logs.append(line)
|
||||
if len(self.logs) > 80:
|
||||
self.logs = self.logs[-80:]
|
||||
|
||||
def set_step(self, step: int) -> None:
|
||||
with self._lock:
|
||||
self.step = max(1, min(4, step))
|
||||
|
||||
def set_waiting(self, message: str = "") -> None:
|
||||
with self._lock:
|
||||
self.phase = "waiting"
|
||||
if message:
|
||||
self.message = message
|
||||
if self.step < 2:
|
||||
self.step = 2
|
||||
|
||||
def set_collecting(self, message: str = "") -> None:
|
||||
with self._lock:
|
||||
self.phase = "collecting"
|
||||
self.message = message or "正在采集 Cookie / 签名 / WebSocket…"
|
||||
self.step = 3
|
||||
|
||||
def set_done(self, result: dict, checks: list[dict[str, str]], ok_core: bool, summary: str) -> None:
|
||||
with self._lock:
|
||||
self.phase = "done"
|
||||
self.result = result
|
||||
self.checks = checks
|
||||
self.result_text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
self.my_uid = str(result.get("my_uid") or "")
|
||||
self.ok_core = ok_core
|
||||
self.summary = summary
|
||||
self.message = "采集完成"
|
||||
self.step = 4
|
||||
|
||||
def set_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self.phase = "error"
|
||||
self.message = message
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"message": self.message,
|
||||
"checks": self.checks,
|
||||
"result_text": self.result_text,
|
||||
"my_uid": self.my_uid,
|
||||
"ok_core": self.ok_core,
|
||||
"summary": self.summary,
|
||||
"run_id": self.run_id,
|
||||
"started_at": self.started_at,
|
||||
"port": self.port,
|
||||
"step": self.step,
|
||||
"logs": list(self.logs),
|
||||
}
|
||||
|
||||
|
||||
def append_log(message: str) -> None:
|
||||
STATE.append_log(message)
|
||||
|
||||
|
||||
STATE = CollectorState()
|
||||
_SERVER: HTTPServer | None = None
|
||||
_start_event: threading.Event | None = None
|
||||
_shutdown_event = threading.Event()
|
||||
|
||||
|
||||
def bind_start_event(event: threading.Event) -> None:
|
||||
"""采集主流程注册:网页「开始采集」或自动就绪时触发。"""
|
||||
global _start_event
|
||||
_start_event = event
|
||||
|
||||
|
||||
def request_start_collect() -> bool:
|
||||
if _start_event is None:
|
||||
return False
|
||||
_start_event.set()
|
||||
return True
|
||||
|
||||
|
||||
def is_start_requested() -> bool:
|
||||
return bool(_start_event and _start_event.is_set())
|
||||
|
||||
|
||||
def request_shutdown() -> None:
|
||||
_shutdown_event.set()
|
||||
srv = _SERVER
|
||||
if srv:
|
||||
threading.Thread(target=srv.shutdown, daemon=True).start()
|
||||
|
||||
|
||||
def wait_for_shutdown() -> None:
|
||||
_shutdown_event.wait()
|
||||
|
||||
|
||||
def _free_port(port: int) -> None:
|
||||
"""关闭占用端口的旧采集器进程(避免网页显示上一次失败的错误)。"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
(
|
||||
f"Get-NetTCPConnection -LocalPort {port} -State Listen "
|
||||
f"-ErrorAction SilentlyContinue | ForEach-Object {{ "
|
||||
f"Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }}"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_web_protect(raw: str) -> dict:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(str(raw))
|
||||
if isinstance(obj, str):
|
||||
obj = json.loads(obj)
|
||||
data = obj.get("data") if isinstance(obj, dict) else None
|
||||
if isinstance(data, str):
|
||||
return json.loads(data)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _pick_ls(result: dict, exact: str, contains: tuple[str, ...] = ()) -> str:
|
||||
for origin in result.get("origins") or []:
|
||||
for entry in origin.get("localStorage") or []:
|
||||
name = entry.get("name") or ""
|
||||
val = entry.get("value") or ""
|
||||
if name == exact and val:
|
||||
return str(val)
|
||||
lower = name.lower()
|
||||
if val and contains and any(c in lower for c in contains):
|
||||
return str(val)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_my_uid(result: dict) -> str:
|
||||
uid = result.get("my_uid")
|
||||
if uid and str(uid).isdigit():
|
||||
return str(uid)
|
||||
ws_url = str(result.get("frontier_ws_url") or "")
|
||||
if "aid=2906" in ws_url:
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
dev = parse_qs(urlparse(ws_url).query).get("device_id", [""])[0]
|
||||
if dev and str(dev).isdigit():
|
||||
return str(dev)
|
||||
except Exception:
|
||||
pass
|
||||
tea = _pick_ls(result, "tea_cache_tokens", ("tea_cache_tokens",))
|
||||
if tea:
|
||||
try:
|
||||
uid = json.loads(tea).get("user_unique_id")
|
||||
if uid and str(uid).isdigit():
|
||||
return str(uid)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_result(result: dict) -> dict:
|
||||
try:
|
||||
from credential_normalize import normalize_storage_state_for_im
|
||||
return normalize_storage_state_for_im(result)
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
|
||||
def build_checks(result: dict) -> tuple[list[dict[str, str]], bool, str]:
|
||||
result = normalize_result(result)
|
||||
cookies = result.get("cookies") or []
|
||||
names = {str(c.get("name", "")).lower() for c in cookies}
|
||||
val_protect = _pick_ls(result, "security-sdk/s_sdk_sign_data_key/web_protect", ("web_protect",))
|
||||
val_keys = _pick_ls(result, "security-sdk/s_sdk_crypt_sdk", ("crypt_sdk",))
|
||||
my_uid = _extract_my_uid(result)
|
||||
wp = _parse_web_protect(val_protect)
|
||||
|
||||
has_session = "sessionid" in names or "sessionid_ss" in names
|
||||
has_protect = bool(val_protect)
|
||||
has_keys = bool(val_keys)
|
||||
has_uid = bool(my_uid)
|
||||
has_wp_full = bool(wp.get("ticket") and wp.get("ts_sign") and (wp.get("client_cert") or wp.get("sdk_cert")))
|
||||
has_ws = bool(result.get("frontier_ws_url"))
|
||||
has_sdk_cert = bool(result.get("sdk_cert"))
|
||||
token = ""
|
||||
ws_url = str(result.get("frontier_ws_url") or "")
|
||||
if ws_url:
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
token = parse_qs(urlparse(ws_url).query).get("token", [""])[0]
|
||||
except Exception:
|
||||
pass
|
||||
ws_real = has_ws and (has_sdk_cert or len(token) >= 40)
|
||||
|
||||
def chk(ok: bool, ok_t: str, fail_t: str, critical: bool = True) -> dict[str, str]:
|
||||
if ok:
|
||||
return {"s": "ok", "t": "已包含 " + ok_t}
|
||||
return {"s": "err" if critical else "warn", "t": fail_t}
|
||||
|
||||
checks = [
|
||||
chk(has_session, "sessionid(登录态)", "缺 sessionid:请重新登录并在私信页停留后采集", True),
|
||||
chk(has_uid, f"抖音 UID({my_uid})" if my_uid else "抖音 UID", "未提取到数字 UID", False),
|
||||
chk(has_protect, "web_protect 签名", "缺 web_protect:请在私信页停留 5~10 秒后重采", True),
|
||||
]
|
||||
if has_protect:
|
||||
checks.append(chk(has_wp_full, "web_protect 完整字段", "web_protect 不完整(缺 ticket/ts_sign/cert)", True))
|
||||
checks.append(chk(has_keys, "crypt_sdk 密钥", "缺 crypt_sdk:请确认在已登录页面采集", True))
|
||||
if has_ws and has_sdk_cert:
|
||||
aid = "2906 创作者私信" if "aid=2906" in ws_url else "真实抓包"
|
||||
checks.append({"s": "ok", "t": f"frontier WebSocket({aid},含 sdk_cert)"})
|
||||
elif ws_real:
|
||||
checks.append({"s": "ok", "t": "frontier WebSocket(真实抓包)"})
|
||||
elif has_ws:
|
||||
checks.append({"s": "warn", "t": "WS 无效(token 异常),请重新在私信页采集"})
|
||||
elif has_session:
|
||||
checks.append({"s": "warn", "t": "未捕获 WS:请在私信页等消息列表加载后重采"})
|
||||
else:
|
||||
checks.append({"s": "warn", "t": "未捕获 WS,请先完成登录再采集"})
|
||||
|
||||
ok_core = has_session and has_protect and has_keys and has_wp_full and has_uid
|
||||
if ok_core:
|
||||
summary = f"共 {len(cookies)} 条 Cookie,可直接复制导入系统。"
|
||||
elif has_session and has_protect and has_keys:
|
||||
summary = "核心凭证已有,建议核对红色项后导入;若 IM 异常请重新采集。"
|
||||
else:
|
||||
summary = "关键凭证缺失,请按提示重新登录采集,不要直接导入。"
|
||||
|
||||
return checks, ok_core, summary
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format: str, *args) -> None: # noqa: A003
|
||||
pass
|
||||
|
||||
def _send(self, code: int, body: bytes, content_type: str, *, cors: bool = False) -> None:
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
if cors:
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path in ("/", "/index.html"):
|
||||
if UI_FILE.is_file():
|
||||
self._send(200, UI_FILE.read_bytes(), "text/html; charset=utf-8")
|
||||
else:
|
||||
self._send(404, b"UI not found", "text/plain")
|
||||
return
|
||||
if path == "/api/status":
|
||||
payload = json.dumps(STATE.snapshot(), ensure_ascii=False).encode("utf-8")
|
||||
self._send(200, payload, "application/json; charset=utf-8", cors=True)
|
||||
return
|
||||
if path == "/api/ping":
|
||||
self._send(200, b"ok", "text/plain; charset=utf-8", cors=True)
|
||||
return
|
||||
self._send(404, b"Not Found", "text/plain")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/start":
|
||||
ok = request_start_collect()
|
||||
if ok:
|
||||
STATE.set_collecting("收到开始信号,正在采集…")
|
||||
append_log("用户点击:立即采集")
|
||||
body = json.dumps({"ok": ok}, ensure_ascii=False).encode("utf-8")
|
||||
self._send(200 if ok else 409, body, "application/json; charset=utf-8", cors=True)
|
||||
return
|
||||
if path == "/api/quit":
|
||||
append_log("用户退出采集器")
|
||||
request_shutdown()
|
||||
body = json.dumps({"ok": True}, ensure_ascii=False).encode("utf-8")
|
||||
self._send(200, body, "application/json; charset=utf-8", cors=True)
|
||||
return
|
||||
self._send(404, b"Not Found", "text/plain")
|
||||
|
||||
|
||||
def start_server(port: int = DEFAULT_PORT) -> tuple[HTTPServer, str]:
|
||||
global _SERVER
|
||||
STATE.reset()
|
||||
STATE.set_step(1)
|
||||
append_log("正在启动本地界面…")
|
||||
for attempt in range(10):
|
||||
p = port + attempt
|
||||
_free_port(p)
|
||||
try:
|
||||
srv = HTTPServer(("127.0.0.1", p), _Handler)
|
||||
_SERVER = srv
|
||||
STATE.port = p
|
||||
url = f"http://127.0.0.1:{p}/"
|
||||
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return srv, url
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("无法启动本地页面服务(端口被占用)")
|
||||
|
||||
|
||||
def open_browser(url: str) -> None:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "", url],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
close_fds=True,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def publish_result(result: dict) -> None:
|
||||
normalized = normalize_result(result)
|
||||
checks, ok_core, summary = build_checks(normalized)
|
||||
STATE.set_done(normalized, checks, ok_core, summary)
|
||||
|
||||
|
||||
def publish_error(message: str) -> None:
|
||||
STATE.set_error(message)
|
||||
Reference in New Issue
Block a user