mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-08-28 19:47:03 +08:00
966 lines
35 KiB
Python
966 lines
35 KiB
Python
import asyncio
|
||
import os
|
||
import shutil
|
||
import time
|
||
import urllib.request
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from urllib.parse import urlsplit
|
||
|
||
from fastapi import FastAPI, HTTPException, Request, Response
|
||
import uvicorn
|
||
from playwright.async_api import async_playwright
|
||
|
||
from core.login import collect_login_result
|
||
|
||
|
||
REMOTE_LOGIN_URL = "https://creator.douyin.com/"
|
||
WWW_SELF_URL = "https://www.douyin.com/user/self"
|
||
DEFAULT_PROFILE_DIR = (
|
||
Path(__file__).resolve().parents[1] / "state" / "login-profile"
|
||
if os.name == "nt"
|
||
else Path("/data/login-profile")
|
||
)
|
||
PROFILE_DIR = Path(os.getenv("LOGIN_PROFILE_DIR", str(DEFAULT_PROFILE_DIR))).expanduser()
|
||
LOGIN_DESKTOP_MODE = str(
|
||
os.getenv("LOGIN_DESKTOP_MODE", "native" if os.name == "nt" else "novnc")
|
||
).strip().lower()
|
||
if LOGIN_DESKTOP_MODE not in {"native", "novnc"}:
|
||
LOGIN_DESKTOP_MODE = "native" if os.name == "nt" else "novnc"
|
||
IDLE_TIMEOUT_SECONDS = max(300, int(os.getenv("LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS", "1800")))
|
||
STOP_AFTER_EXPORT_SECONDS = max(0, int(os.getenv("LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS", "60")))
|
||
STATUS_CACHE_SECONDS = max(1, int(os.getenv("LOGIN_DESKTOP_STATUS_CACHE_SECONDS", "15")))
|
||
LOGIN_NETWORK_MODE = str(os.getenv("LOGIN_DESKTOP_PROXY_MODE", "auto")).strip().lower()
|
||
if LOGIN_NETWORK_MODE not in {"auto", "direct", "proxy"}:
|
||
LOGIN_NETWORK_MODE = "auto"
|
||
LOGIN_PROXY_SERVER = str(os.getenv("LOGIN_DESKTOP_PROXY", "http://proxy:7890")).strip()
|
||
LOGIN_PREFLIGHT_TIMEOUT_SECONDS = max(
|
||
3, int(os.getenv("LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS", "15"))
|
||
)
|
||
LOGIN_NETWORK_CACHE_SECONDS = max(
|
||
0, int(os.getenv("LOGIN_DESKTOP_NETWORK_CACHE_SECONDS", "30"))
|
||
)
|
||
GENERIC_WWW_NAMES = {
|
||
"",
|
||
"我的",
|
||
"我",
|
||
"抖音官网账号",
|
||
"精选",
|
||
"推荐",
|
||
"搜索",
|
||
"关注",
|
||
"朋友",
|
||
"直播",
|
||
"放映厅",
|
||
"短剧",
|
||
"小游戏",
|
||
"客户端",
|
||
"通知",
|
||
"私信",
|
||
"投稿",
|
||
"海量优质视频内容",
|
||
"抖音精选电脑版",
|
||
}
|
||
|
||
|
||
class LoginNetworkError(RuntimeError):
|
||
"""Raised when the login browser cannot reach Douyin."""
|
||
|
||
def __init__(self, message, *, checks=None):
|
||
super().__init__(message)
|
||
self.checks = checks or {}
|
||
|
||
|
||
def _safe_proxy_label(proxy_server):
|
||
if not proxy_server:
|
||
return ""
|
||
try:
|
||
parsed = urlsplit(proxy_server)
|
||
host = parsed.hostname or ""
|
||
port = f":{parsed.port}" if parsed.port else ""
|
||
return f"{host}{port}" if host else "configured proxy"
|
||
except ValueError:
|
||
return "configured proxy"
|
||
|
||
|
||
def _probe_login_target(proxy_server=None, timeout_seconds=15):
|
||
"""Probe Douyin without inheriting the process proxy environment."""
|
||
if proxy_server:
|
||
handlers = [
|
||
urllib.request.ProxyHandler(
|
||
{"http": proxy_server, "https": proxy_server}
|
||
)
|
||
]
|
||
else:
|
||
handlers = [urllib.request.ProxyHandler({})]
|
||
opener = urllib.request.build_opener(*handlers)
|
||
request = urllib.request.Request(
|
||
REMOTE_LOGIN_URL,
|
||
headers={"User-Agent": "DouYinSparkFlow-login-preflight/1"},
|
||
)
|
||
started = time.monotonic()
|
||
try:
|
||
with opener.open(request, timeout=timeout_seconds) as response:
|
||
response.read(256)
|
||
status = int(getattr(response, "status", 200))
|
||
if status >= 400:
|
||
raise RuntimeError(f"HTTP {status}")
|
||
return {
|
||
"ok": True,
|
||
"status": status,
|
||
"latency_ms": round((time.monotonic() - started) * 1000),
|
||
}
|
||
except Exception as exc:
|
||
error = str(exc) or exc.__class__.__name__
|
||
if proxy_server:
|
||
error = error.replace(proxy_server, _safe_proxy_label(proxy_server))
|
||
return {
|
||
"ok": False,
|
||
"error": error[:240],
|
||
"latency_ms": round((time.monotonic() - started) * 1000),
|
||
}
|
||
|
||
|
||
class LoginDesktopManager:
|
||
def __init__(self):
|
||
self._lock = asyncio.Lock()
|
||
self._page_operation_lock = asyncio.Lock()
|
||
self.playwright = None
|
||
self.context = None
|
||
self.page = None
|
||
self._last_activity = time.monotonic()
|
||
self._status_cache = None
|
||
self._status_checked_at = 0.0
|
||
self._idle_monitor_task = None
|
||
self._scheduled_stop_task = None
|
||
self._network_route = None
|
||
self._network_checks = {}
|
||
self._network_checked_at = 0.0
|
||
|
||
def _network_payload(self):
|
||
route = dict(self._network_route or {})
|
||
return {
|
||
"mode": LOGIN_NETWORK_MODE,
|
||
"selected": route.get("mode", ""),
|
||
"proxy": _safe_proxy_label(LOGIN_PROXY_SERVER) if route.get("mode") == "proxy" else "",
|
||
"checked_at": route.get("checked_at", ""),
|
||
"checks": dict(self._network_checks),
|
||
}
|
||
|
||
def _invalidate_network_route(self):
|
||
self._network_route = None
|
||
self._network_checked_at = 0.0
|
||
|
||
async def _select_network_route(self, *, force=False):
|
||
now = time.monotonic()
|
||
if (
|
||
not force
|
||
and self._network_route
|
||
and now - self._network_checked_at < LOGIN_NETWORK_CACHE_SECONDS
|
||
):
|
||
return dict(self._network_route)
|
||
|
||
checks = {}
|
||
candidates = []
|
||
if LOGIN_NETWORK_MODE in {"auto", "direct"}:
|
||
candidates.append(("direct", None))
|
||
if LOGIN_NETWORK_MODE in {"auto", "proxy"} and LOGIN_PROXY_SERVER:
|
||
candidates.append(("proxy", LOGIN_PROXY_SERVER))
|
||
|
||
for mode, proxy_server in candidates:
|
||
result = await asyncio.to_thread(
|
||
_probe_login_target,
|
||
proxy_server,
|
||
LOGIN_PREFLIGHT_TIMEOUT_SECONDS,
|
||
)
|
||
checks[mode] = result
|
||
if result.get("ok"):
|
||
route = {
|
||
"mode": mode,
|
||
"proxy": _safe_proxy_label(proxy_server),
|
||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
}
|
||
self._network_checks = checks
|
||
self._network_route = route
|
||
self._network_checked_at = now
|
||
return dict(route)
|
||
|
||
self._network_checks = checks
|
||
self._network_route = None
|
||
self._network_checked_at = now
|
||
if LOGIN_NETWORK_MODE == "direct":
|
||
message = "无法直连抖音创作者中心,请检查服务器网络出口"
|
||
elif LOGIN_NETWORK_MODE == "proxy":
|
||
message = f"代理 {_safe_proxy_label(LOGIN_PROXY_SERVER)} 无法访问抖音创作者中心"
|
||
else:
|
||
message = "直连和代理都无法访问抖音创作者中心"
|
||
raise LoginNetworkError(message, checks=checks)
|
||
|
||
async def network_preflight(self, *, force=True):
|
||
try:
|
||
route = await self._select_network_route(force=force)
|
||
return {
|
||
"ok": True,
|
||
"route": route,
|
||
"network": self._network_payload(),
|
||
}
|
||
except LoginNetworkError as exc:
|
||
return {
|
||
"ok": False,
|
||
"error": str(exc),
|
||
"network": self._network_payload(),
|
||
}
|
||
|
||
def mark_activity(self):
|
||
self._last_activity = time.monotonic()
|
||
self._status_cache = None
|
||
self._status_checked_at = 0.0
|
||
if self._scheduled_stop_task and not self._scheduled_stop_task.done():
|
||
self._scheduled_stop_task.cancel()
|
||
self._scheduled_stop_task = None
|
||
|
||
async def start_idle_monitor(self):
|
||
if self._idle_monitor_task and not self._idle_monitor_task.done():
|
||
return
|
||
self._idle_monitor_task = asyncio.create_task(self._idle_monitor())
|
||
|
||
async def stop_idle_monitor(self):
|
||
if self._idle_monitor_task and not self._idle_monitor_task.done():
|
||
self._idle_monitor_task.cancel()
|
||
await asyncio.gather(self._idle_monitor_task, return_exceptions=True)
|
||
self._idle_monitor_task = None
|
||
if self._scheduled_stop_task and not self._scheduled_stop_task.done():
|
||
self._scheduled_stop_task.cancel()
|
||
await asyncio.gather(self._scheduled_stop_task, return_exceptions=True)
|
||
self._scheduled_stop_task = None
|
||
|
||
async def _idle_monitor(self):
|
||
while True:
|
||
await asyncio.sleep(60)
|
||
if self.context and time.monotonic() - self._last_activity >= IDLE_TIMEOUT_SECONDS:
|
||
await self.stop(clear_profile=False)
|
||
|
||
def schedule_stop_after_export(self):
|
||
if STOP_AFTER_EXPORT_SECONDS <= 0:
|
||
return
|
||
if self._scheduled_stop_task and not self._scheduled_stop_task.done():
|
||
self._scheduled_stop_task.cancel()
|
||
|
||
async def stop_later():
|
||
await asyncio.sleep(STOP_AFTER_EXPORT_SECONDS)
|
||
await self.stop(clear_profile=False)
|
||
|
||
self._scheduled_stop_task = asyncio.create_task(stop_later())
|
||
|
||
async def reduce_page_activity(self, page):
|
||
try:
|
||
await page.emulate_media(reduced_motion="reduce")
|
||
await page.add_style_tag(
|
||
content="""
|
||
*, *::before, *::after {
|
||
animation-duration: 0s !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0s !important;
|
||
scroll-behavior: auto !important;
|
||
}
|
||
video, canvas[class*="main-animation"] {
|
||
visibility: hidden !important;
|
||
}
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
async def start(self):
|
||
self.mark_activity()
|
||
async with self._lock:
|
||
if self.context and not self._context_is_closed():
|
||
return
|
||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||
if self.playwright:
|
||
try:
|
||
await self.playwright.stop()
|
||
except Exception:
|
||
pass
|
||
self.playwright = None
|
||
route = await self._select_network_route()
|
||
self.playwright = await async_playwright().start()
|
||
launch_args = [
|
||
"--start-maximized",
|
||
"--window-position=0,0",
|
||
"--window-size=1600,1000",
|
||
"--disable-background-networking",
|
||
"--disable-sync",
|
||
"--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication",
|
||
]
|
||
if os.name != "nt":
|
||
launch_args.extend(
|
||
[
|
||
"--disable-dev-shm-usage",
|
||
"--no-sandbox",
|
||
"--disable-gpu",
|
||
"--disable-gpu-compositing",
|
||
"--disable-software-rasterizer",
|
||
"--disable-accelerated-2d-canvas",
|
||
"--disable-accelerated-video-decode",
|
||
"--renderer-process-limit=2",
|
||
]
|
||
)
|
||
launch_options = {
|
||
"headless": False,
|
||
"viewport": {"width": 1600, "height": 1000},
|
||
"args": launch_args,
|
||
}
|
||
if route["mode"] == "proxy":
|
||
launch_options["proxy"] = {"server": LOGIN_PROXY_SERVER}
|
||
else:
|
||
launch_args.append("--no-proxy-server")
|
||
self.context = await self.playwright.chromium.launch_persistent_context(
|
||
str(PROFILE_DIR),
|
||
**launch_options,
|
||
)
|
||
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
|
||
|
||
def _context_is_closed(self):
|
||
return not self.context or getattr(self.context, "_impl_obj", None) is None
|
||
|
||
async def _get_active_page(self):
|
||
await self.ensure_running()
|
||
try:
|
||
if self.page and not self.page.is_closed():
|
||
return self.page
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for candidate in self.context.pages:
|
||
if not candidate.is_closed():
|
||
self.page = candidate
|
||
return candidate
|
||
except Exception:
|
||
pass
|
||
self.page = await self.context.new_page()
|
||
return self.page
|
||
|
||
async def stop(self, clear_profile=False):
|
||
async with self._lock:
|
||
if self.page:
|
||
try:
|
||
await self.page.close()
|
||
except Exception:
|
||
pass
|
||
self.page = None
|
||
if self.context:
|
||
try:
|
||
await self.context.close()
|
||
except Exception:
|
||
pass
|
||
self.context = None
|
||
if self.playwright:
|
||
try:
|
||
await self.playwright.stop()
|
||
except Exception:
|
||
pass
|
||
self.playwright = None
|
||
if clear_profile and PROFILE_DIR.exists():
|
||
shutil.rmtree(PROFILE_DIR, ignore_errors=True)
|
||
self._status_cache = None
|
||
self._status_checked_at = 0.0
|
||
|
||
async def reset(self):
|
||
self.mark_activity()
|
||
await self.stop(clear_profile=True)
|
||
await self.start()
|
||
|
||
async def ensure_running(self):
|
||
if not self.context or self._context_is_closed():
|
||
await self.start()
|
||
|
||
async def focus_browser(self):
|
||
self.mark_activity()
|
||
page = await self._get_active_page()
|
||
try:
|
||
await page.bring_to_front()
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"ok": True,
|
||
"url": page.url,
|
||
"mode": LOGIN_DESKTOP_MODE,
|
||
"network": self._network_payload(),
|
||
}
|
||
|
||
async def status(self):
|
||
now = time.monotonic()
|
||
if self._status_cache is not None and now - self._status_checked_at < STATUS_CACHE_SECONDS:
|
||
return dict(self._status_cache)
|
||
|
||
logged_in = False
|
||
username = ""
|
||
unique_id = ""
|
||
current_url = ""
|
||
|
||
if not self.context or self._context_is_closed():
|
||
payload = {
|
||
"running": False,
|
||
"logged_in": False,
|
||
"username": "",
|
||
"unique_id": "",
|
||
"current_url": "",
|
||
"profile_dir": str(PROFILE_DIR),
|
||
"network": self._network_payload(),
|
||
}
|
||
self._status_cache = payload
|
||
self._status_checked_at = now
|
||
return dict(payload)
|
||
|
||
page = None
|
||
try:
|
||
if self.page and not self.page.is_closed():
|
||
page = self.page
|
||
else:
|
||
for candidate in self.context.pages:
|
||
if not candidate.is_closed():
|
||
self.page = candidate
|
||
page = candidate
|
||
break
|
||
except Exception:
|
||
self.page = None
|
||
self.context = None
|
||
payload = {
|
||
"running": False,
|
||
"logged_in": False,
|
||
"username": "",
|
||
"unique_id": "",
|
||
"current_url": "",
|
||
"profile_dir": str(PROFILE_DIR),
|
||
"network": self._network_payload(),
|
||
}
|
||
self._status_cache = payload
|
||
self._status_checked_at = now
|
||
return dict(payload)
|
||
|
||
if page:
|
||
current_url = page.url
|
||
if not self._page_operation_lock.locked():
|
||
try:
|
||
result = await collect_login_result(page, self.context, timeout_ms=1000)
|
||
logged_in = True
|
||
username = result["username"]
|
||
unique_id = result["unique_id"]
|
||
except Exception:
|
||
pass
|
||
|
||
payload = {
|
||
"running": True,
|
||
"logged_in": logged_in,
|
||
"username": username,
|
||
"unique_id": unique_id,
|
||
"current_url": current_url,
|
||
"profile_dir": str(PROFILE_DIR),
|
||
"network": self._network_payload(),
|
||
}
|
||
self._status_cache = payload
|
||
self._status_checked_at = now
|
||
return dict(payload)
|
||
|
||
async def open_login(self):
|
||
self.mark_activity()
|
||
try:
|
||
await asyncio.wait_for(self._page_operation_lock.acquire(), timeout=5)
|
||
except asyncio.TimeoutError as exc:
|
||
raise RuntimeError("login page is busy; retry shortly") from exc
|
||
try:
|
||
page = await self._get_active_page()
|
||
if page.url.startswith(REMOTE_LOGIN_URL):
|
||
return {"ok": True, "url": page.url, "network": self._network_payload()}
|
||
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
|
||
try:
|
||
await page.goto(refresh_url, wait_until="commit", timeout=30000)
|
||
except Exception:
|
||
await self.stop(clear_profile=False)
|
||
await self.start()
|
||
page = await self._get_active_page()
|
||
await page.goto(refresh_url, wait_until="commit", timeout=30000)
|
||
return {"ok": True, "url": page.url, "network": self._network_payload()}
|
||
finally:
|
||
self._page_operation_lock.release()
|
||
|
||
async def refresh_login_qr(self):
|
||
self.mark_activity()
|
||
try:
|
||
await asyncio.wait_for(self._page_operation_lock.acquire(), timeout=5)
|
||
except asyncio.TimeoutError as exc:
|
||
raise RuntimeError("login page is busy; retry shortly") from exc
|
||
try:
|
||
return await self._refresh_login_qr_locked()
|
||
finally:
|
||
self._page_operation_lock.release()
|
||
|
||
async def _refresh_login_qr_locked(self):
|
||
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
|
||
try:
|
||
page = await self._get_active_page()
|
||
await page.goto(refresh_url, wait_until="commit", timeout=30000)
|
||
except Exception:
|
||
await self.reset()
|
||
page = await self._get_active_page()
|
||
await page.goto(refresh_url, wait_until="commit", timeout=30000)
|
||
|
||
deadline = asyncio.get_running_loop().time() + 45
|
||
logged_in = False
|
||
qr_ready = False
|
||
while asyncio.get_running_loop().time() < deadline:
|
||
for selector in (
|
||
'img[class*="qrcode"]',
|
||
'img[src^="data:image/png;base64"]',
|
||
):
|
||
candidates = page.locator(selector)
|
||
for index in range(await candidates.count()):
|
||
qr = candidates.nth(index)
|
||
try:
|
||
if not await qr.is_visible():
|
||
continue
|
||
box = await qr.bounding_box()
|
||
if not box or box["width"] < 120 or box["height"] < 120:
|
||
continue
|
||
ratio = box["width"] / max(1, box["height"])
|
||
if 0.8 <= ratio <= 1.25:
|
||
qr_ready = True
|
||
break
|
||
except Exception:
|
||
pass
|
||
if qr_ready:
|
||
break
|
||
|
||
if "/creator-micro/" in page.url:
|
||
logged_in = True
|
||
break
|
||
try:
|
||
await collect_login_result(page, self.context, timeout_ms=800)
|
||
logged_in = True
|
||
break
|
||
except Exception:
|
||
pass
|
||
await asyncio.sleep(0.75)
|
||
|
||
if not qr_ready and not logged_in:
|
||
raise RuntimeError("Douyin login page did not expose a QR code or a logged-in session")
|
||
|
||
await self.reduce_page_activity(page)
|
||
if qr_ready:
|
||
try:
|
||
await page.wait_for_function(
|
||
"() => !/\u4e8c\u7ef4\u7801\u5931\u6548|\u4e8c\u7ef4\u7801\u8fc7\u671f/.test(document.body?.innerText || '')",
|
||
timeout=30000,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return {"ok": True, "url": page.url, "logged_in": logged_in, "qr_ready": qr_ready}
|
||
|
||
async def export(self):
|
||
self.mark_activity()
|
||
page = await self._get_active_page()
|
||
result = await collect_login_result(page, self.context, timeout_ms=5000)
|
||
self.schedule_stop_after_export()
|
||
return result
|
||
|
||
|
||
def _clean_www_display_name(value):
|
||
raw = str(value or "").replace("\u200b", "").replace("\ufeff", "")
|
||
name = " ".join(raw.split()).strip(" -_||·•")
|
||
if not name or name in GENERIC_WWW_NAMES:
|
||
return ""
|
||
if len(name) > 40:
|
||
return ""
|
||
if any(token in name for token in ("登录", "注册", "关注", "粉丝", "获赞", "作品", "喜欢", "收藏", "观看历史", "海量优质视频", "抖音旗下")):
|
||
return ""
|
||
return name
|
||
|
||
|
||
async def collect_www_identity_from_page(page):
|
||
return await page.evaluate(
|
||
r"""() => {
|
||
const normalize = (value) => String(value || "")
|
||
.replace(/[\u200b\u200c\u200d\ufeff]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
const bad = new Set(["", "我的", "我", "抖音官网账号", "精选", "推荐", "搜索", "关注", "朋友", "直播", "放映厅", "短剧", "小游戏", "客户端", "通知", "私信", "投稿"]);
|
||
const candidates = [];
|
||
const add = (value, source) => {
|
||
const text = normalize(value).replace(/^@+/, "").trim();
|
||
if (!text || bad.has(text) || text.length > 40) return;
|
||
if (/登录|注册|关注|粉丝|获赞|作品|喜欢|收藏|观看历史/.test(text)) return;
|
||
candidates.push({ text, source });
|
||
};
|
||
|
||
add(document.querySelector('[data-e2e="user-title"]')?.innerText, "data-e2e=user-title");
|
||
add(document.querySelector('[class*="userName"], [class*="UserName"], [class*="nickname"], [class*="Nickname"], h1')?.innerText, "profile-name-selector");
|
||
add(document.title.split(/[||\-]/)[0], "document-title");
|
||
add(document.querySelector('meta[property="og:title"]')?.content?.split(/[||\-]/)[0], "og:title");
|
||
|
||
const selfLink = document.querySelector('a[href*="/user/self"]');
|
||
if (selfLink) {
|
||
const root = selfLink.closest('div')?.parentElement?.parentElement || selfLink;
|
||
const lines = normalize(root.innerText).split(/关注|粉丝|获赞|我的喜欢|我的收藏|观看历史|稍后再看|我的作品|我的预约|我的订单|退出登录/);
|
||
add(lines[0], "self-link-root");
|
||
}
|
||
|
||
if (location.pathname.includes('/user/')) {
|
||
add(document.querySelector('meta[name="description"]')?.content?.split(/[,,。||-]/)[0], "description");
|
||
}
|
||
|
||
return {
|
||
url: location.href,
|
||
title: document.title,
|
||
profileHref: selfLink?.href || "",
|
||
candidates,
|
||
};
|
||
}"""
|
||
)
|
||
|
||
|
||
async def collect_www_login_result(page, context):
|
||
cookies = await context.cookies()
|
||
try:
|
||
await page.goto(WWW_SELF_URL, wait_until="domcontentloaded", timeout=60000)
|
||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||
except Exception:
|
||
pass
|
||
|
||
identity = await collect_www_identity_from_page(page)
|
||
username = ""
|
||
for item in identity.get("candidates") or []:
|
||
username = _clean_www_display_name(item.get("text"))
|
||
if username:
|
||
break
|
||
|
||
if not username:
|
||
identity = await collect_www_identity_from_page(page)
|
||
for item in identity.get("candidates") or []:
|
||
username = _clean_www_display_name(item.get("text"))
|
||
if username:
|
||
break
|
||
|
||
if not username:
|
||
username = "抖音官网账号"
|
||
uid_cookie = ""
|
||
for cookie in cookies:
|
||
if cookie.get("name") in {"uid_tt", "uid_tt_ss", "sid_uid", "passport_csrf_token"}:
|
||
uid_cookie = str(cookie.get("value") or "")
|
||
if uid_cookie:
|
||
break
|
||
suffix = "".join(ch for ch in uid_cookie if ch.isalnum())[:24]
|
||
unique_id = f"web-self-{suffix or username}"
|
||
return {
|
||
"unique_id": unique_id,
|
||
"username": username,
|
||
"cookies": cookies,
|
||
}
|
||
|
||
|
||
manager = LoginDesktopManager()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app: FastAPI):
|
||
await manager.start_idle_monitor()
|
||
try:
|
||
yield
|
||
finally:
|
||
await manager.stop_idle_monitor()
|
||
await manager.stop(clear_profile=False)
|
||
|
||
|
||
app = FastAPI(title="Douyin Login Desktop", lifespan=lifespan)
|
||
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/preflight")
|
||
async def preflight():
|
||
result = await manager.network_preflight(force=True)
|
||
if not result.get("ok"):
|
||
raise HTTPException(status_code=502, detail=result)
|
||
return result
|
||
|
||
|
||
@app.get("/status")
|
||
async def status():
|
||
return await manager.status()
|
||
|
||
|
||
@app.post("/open-login")
|
||
async def open_login():
|
||
try:
|
||
return await manager.open_login()
|
||
except LoginNetworkError as exc:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
|
||
) from exc
|
||
|
||
|
||
@app.post("/reset")
|
||
async def reset():
|
||
await manager.reset()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/close")
|
||
async def close():
|
||
await manager.stop(clear_profile=True)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/focus")
|
||
async def focus():
|
||
return await manager.focus_browser()
|
||
|
||
|
||
@app.post("/refresh-qr")
|
||
async def refresh_qr():
|
||
try:
|
||
return await manager.refresh_login_qr()
|
||
except LoginNetworkError as exc:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
|
||
) from exc
|
||
|
||
|
||
@app.post("/export")
|
||
async def export():
|
||
page = await manager._get_active_page()
|
||
try:
|
||
result = await manager.export()
|
||
except Exception as creator_exc:
|
||
try:
|
||
result = await collect_www_login_result(page, manager.context)
|
||
except Exception as www_exc:
|
||
raise HTTPException(status_code=400, detail=f"creator export failed: {creator_exc}; www export failed: {www_exc}")
|
||
return {"ok": True, "result": result}
|
||
|
||
|
||
@app.get("/qr")
|
||
async def login_qr():
|
||
if manager._page_operation_lock.locked():
|
||
raise HTTPException(status_code=503, detail="login page is busy; retry shortly")
|
||
try:
|
||
page = await manager._get_active_page()
|
||
except LoginNetworkError as exc:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks},
|
||
) from exc
|
||
expired = await page.locator('[class*="qrcode_expired"]').count()
|
||
if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible():
|
||
raise HTTPException(status_code=409, detail="login QR code has expired")
|
||
selectors = (
|
||
'img[class*="qrcode"]',
|
||
'img[src^="data:image/png;base64"]',
|
||
)
|
||
for selector in selectors:
|
||
candidates = page.locator(selector)
|
||
for index in range(await candidates.count()):
|
||
candidate = candidates.nth(index)
|
||
try:
|
||
box = await candidate.bounding_box()
|
||
if not box or box["width"] < 120 or box["height"] < 120:
|
||
continue
|
||
ratio = box["width"] / max(1, box["height"])
|
||
if not 0.8 <= ratio <= 1.25:
|
||
continue
|
||
data = await candidate.screenshot(type="png")
|
||
return Response(
|
||
content=data,
|
||
media_type="image/png",
|
||
headers={"Cache-Control": "no-store, max-age=0"},
|
||
)
|
||
except Exception:
|
||
continue
|
||
raise HTTPException(status_code=202, detail="login QR code is still starting", headers={"Retry-After": "2"})
|
||
|
||
|
||
@app.get("/debug/screenshot")
|
||
async def debug_screenshot():
|
||
page = await manager._get_active_page()
|
||
data = await page.screenshot(full_page=False, type="png")
|
||
return Response(content=data, media_type="image/png")
|
||
|
||
|
||
@app.get("/debug/snapshot")
|
||
async def debug_snapshot():
|
||
page = await manager._get_active_page()
|
||
items = await page.evaluate(
|
||
r"""() => {
|
||
const visible = (el) => {
|
||
const r = el.getBoundingClientRect();
|
||
const s = getComputedStyle(el);
|
||
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
|
||
};
|
||
const textOf = (el) => String(el.innerText || el.textContent || el.getAttribute('aria-label') || el.title || '').replace(/\s+/g, ' ').trim();
|
||
const nodes = [...document.querySelectorAll('button, a, [role="button"], [aria-label], input, textarea, [contenteditable="true"], [class*="message"], [class*="chat"], [class*="im"]')];
|
||
return nodes.filter(visible).slice(0, 300).map((el, i) => {
|
||
const r = el.getBoundingClientRect();
|
||
return {
|
||
i,
|
||
tag: el.tagName.toLowerCase(),
|
||
role: el.getAttribute('role') || '',
|
||
aria: el.getAttribute('aria-label') || '',
|
||
title: el.title || '',
|
||
text: textOf(el).slice(0, 120),
|
||
cls: String(el.className || '').slice(0, 120),
|
||
contenteditable: el.getAttribute('contenteditable') || '',
|
||
rect: {x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height)}
|
||
};
|
||
});
|
||
}"""
|
||
)
|
||
return {"url": page.url, "title": await page.title(), "items": items}
|
||
|
||
|
||
@app.post("/debug/action")
|
||
async def debug_action(request: Request):
|
||
page = await manager._get_active_page()
|
||
payload = await request.json()
|
||
action = payload.get("action")
|
||
if action == "click_text":
|
||
text = str(payload.get("text") or "")
|
||
exact = bool(payload.get("exact", False))
|
||
await page.get_by_text(text, exact=exact).first.click(timeout=int(payload.get("timeout", 5000)))
|
||
elif action == "click_at":
|
||
await page.mouse.click(float(payload["x"]), float(payload["y"]))
|
||
elif action == "wheel":
|
||
await page.mouse.wheel(float(payload.get("dx", 0)), float(payload.get("dy", 0)))
|
||
elif action == "type":
|
||
await page.keyboard.type(str(payload.get("text") or ""), delay=int(payload.get("delay", 20)))
|
||
elif action == "press":
|
||
await page.keyboard.press(str(payload.get("key") or "Enter"))
|
||
elif action == "goto":
|
||
await page.goto(str(payload.get("url") or REMOTE_LOGIN_URL), wait_until="commit", timeout=15000)
|
||
elif action == "eval":
|
||
result = await page.evaluate(str(payload.get("script") or "undefined"))
|
||
return {"ok": True, "result": result, "url": page.url}
|
||
elif action == "list_frames":
|
||
frames = []
|
||
for fr in page.frames:
|
||
frames.append({"url": fr.url, "name": fr.name})
|
||
return {"ok": True, "frames": frames, "url": page.url}
|
||
elif action == "eval_in_frame":
|
||
frame_url_match = str(payload.get("frame_url") or "")
|
||
script = str(payload.get("script") or "undefined")
|
||
target_frame = None
|
||
for fr in page.frames:
|
||
if frame_url_match and frame_url_match in fr.url:
|
||
target_frame = fr
|
||
break
|
||
if not target_frame:
|
||
return {"ok": False, "error": f"no frame matching '{frame_url_match}' found", "url": page.url}
|
||
result = await target_frame.evaluate(script)
|
||
return {"ok": True, "result": result, "url": page.url, "frame_url": target_frame.url}
|
||
elif action == "click_in_frame":
|
||
frame_url_match = str(payload.get("frame_url") or "")
|
||
selector = str(payload.get("selector") or "")
|
||
target_frame = None
|
||
for fr in page.frames:
|
||
if frame_url_match and frame_url_match in fr.url:
|
||
target_frame = fr
|
||
break
|
||
if not target_frame:
|
||
return {"ok": False, "error": f"no frame matching '{frame_url_match}' found"}
|
||
await target_frame.click(selector, timeout=int(payload.get("timeout", 5000)))
|
||
return {"ok": True, "url": page.url, "frame_url": target_frame.url}
|
||
elif action == "type_in_frame":
|
||
frame_url_match = str(payload.get("frame_url") or "")
|
||
selector = str(payload.get("selector") or "")
|
||
text = str(payload.get("text") or "")
|
||
target_frame = None
|
||
for fr in page.frames:
|
||
if frame_url_match and frame_url_match in fr.url:
|
||
target_frame = fr
|
||
break
|
||
if not target_frame:
|
||
return {"ok": False, "error": "no frame found"}
|
||
await target_frame.fill(selector, text, timeout=int(payload.get("timeout", 5000)))
|
||
return {"ok": True, "url": page.url}
|
||
else:
|
||
raise HTTPException(status_code=400, detail=f"unknown action {action!r}")
|
||
return {"ok": True, "url": page.url}
|
||
|
||
|
||
# ---- Network capture (codex-added observability) ----
|
||
_net_log = []
|
||
_net_capturing = False
|
||
_net_max = 500
|
||
|
||
@app.post("/debug/net_capture")
|
||
async def net_capture(request: Request):
|
||
global _net_capturing
|
||
payload = await request.json()
|
||
action_type = payload.get("type", "start")
|
||
page = await manager._get_active_page()
|
||
if action_type == "start":
|
||
_net_log.clear()
|
||
_net_capturing = True
|
||
|
||
async def on_request(req):
|
||
if not _net_capturing:
|
||
return
|
||
if len(_net_log) >= _net_max:
|
||
return
|
||
try:
|
||
_net_log.append({
|
||
"ts": __import__("time").time(),
|
||
"phase": "request",
|
||
"method": req.method,
|
||
"url": req.url,
|
||
"headers": dict(req.headers),
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
async def on_response(resp):
|
||
if not _net_capturing:
|
||
return
|
||
if len(_net_log) >= _net_max:
|
||
return
|
||
try:
|
||
body_preview = None
|
||
try:
|
||
body_preview = (await resp.text())[:300]
|
||
except Exception:
|
||
pass
|
||
_net_log.append({
|
||
"ts": __import__("time").time(),
|
||
"phase": "response",
|
||
"url": resp.url,
|
||
"status": resp.status,
|
||
"headers": dict(resp.headers),
|
||
"body": body_preview,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
page.on("request", lambda req: __import__("asyncio").ensure_future(on_request(req)))
|
||
page.on("response", lambda resp: __import__("asyncio").ensure_future(on_response(resp)))
|
||
return {"ok": True, "msg": "capture started"}
|
||
elif action_type == "stop":
|
||
_net_capturing = False
|
||
return {"ok": True, "count": len(_net_log)}
|
||
elif action_type == "get":
|
||
return {"ok": True, "count": len(_net_log), "log": _net_log.copy()}
|
||
elif action_type == "clear":
|
||
_net_log.clear()
|
||
return {"ok": True}
|
||
return {"ok": False, "error": "unknown type"}
|
||
|
||
@app.get("/debug/net_log")
|
||
async def get_net_log():
|
||
return {"count": len(_net_log), "capturing": _net_capturing, "log": _net_log.copy()}
|
||
|
||
if __name__ == "__main__":
|
||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("LOGIN_DESKTOP_API_PORT", "18090")), reload=False)
|