160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""本机可见浏览器启动核心:注入托管账号登录态并打开抖音。
|
||
|
||
被命令行登录器(launcher.py)与桌面整合版(desktop_app.py)共用。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
|
||
DOUYIN_HOME = "https://www.douyin.com"
|
||
|
||
# 打包(冻结)后让 Playwright 在程序自带目录里找 Chromium:
|
||
# 安装包内置了 chromium,PLAYWRIGHT_BROWSERS_PATH=0 表示用 playwright 包目录下的浏览器,
|
||
# 这样用户无需联网下载,点击账号即可直接打开。
|
||
if getattr(sys, "frozen", False):
|
||
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0")
|
||
|
||
|
||
def _sanitize_cookie(cookie: dict) -> dict | None:
|
||
"""规整单条 cookie,保证 Playwright 能接受(domain/path/sameSite/expires)。"""
|
||
name = cookie.get("name")
|
||
if not name:
|
||
return None
|
||
same_site = cookie.get("sameSite") or "Lax"
|
||
if same_site not in ("Strict", "Lax", "None"):
|
||
same_site = "Lax"
|
||
try:
|
||
expires = float(cookie.get("expires", -1))
|
||
except (TypeError, ValueError):
|
||
expires = -1
|
||
return {
|
||
"name": name,
|
||
"value": cookie.get("value", ""),
|
||
"domain": cookie.get("domain") or ".douyin.com",
|
||
"path": cookie.get("path") or "/",
|
||
"expires": expires,
|
||
"httpOnly": bool(cookie.get("httpOnly", False)),
|
||
"secure": bool(cookie.get("secure", True)),
|
||
"sameSite": same_site,
|
||
}
|
||
|
||
|
||
def normalize_storage_state(cookie_data) -> tuple[dict, str | None]:
|
||
"""把服务器存的 cookie_data(JSON 字符串/字典) 转成 Playwright storage_state。
|
||
|
||
返回 (storage_state, user_agent)。storage_state 只保留 cookies/origins,
|
||
多余字段(如 user_agent) 单独取出,避免 Playwright 校验报错。
|
||
"""
|
||
raw = json.loads(cookie_data) if isinstance(cookie_data, str) else (cookie_data or {})
|
||
if not isinstance(raw, dict):
|
||
raise RuntimeError("账号凭证格式异常,无法解析")
|
||
cookies = [c for c in (_sanitize_cookie(c) for c in raw.get("cookies") or []) if c]
|
||
origins = raw.get("origins") or []
|
||
if not cookies:
|
||
raise RuntimeError("该账号没有可用的登录 Cookie,请先在系统里给它导入/扫码登录")
|
||
storage_state = {"cookies": cookies, "origins": origins}
|
||
user_agent = raw.get("user_agent") or None
|
||
return storage_state, user_agent
|
||
|
||
|
||
def open_logged_in_browser(
|
||
storage_state: dict,
|
||
user_agent: str | None,
|
||
log=print,
|
||
on_ready=None,
|
||
on_error=None,
|
||
) -> None:
|
||
"""在本机启动一个可见浏览器并注入登录态,打开抖音官网。阻塞至浏览器关闭。
|
||
|
||
on_ready(): 浏览器窗口已弹出时回调(用于通知前端「已打开」)。
|
||
on_error(msg): 启动失败时回调(把错误回报给前端展示)。
|
||
"""
|
||
from playwright.sync_api import sync_playwright
|
||
|
||
launch_args = ["--disable-blink-features=AutomationControlled", "--start-maximized"]
|
||
|
||
def _fail(msg: str):
|
||
log(msg)
|
||
if on_error:
|
||
try:
|
||
on_error(msg)
|
||
except Exception:
|
||
pass
|
||
|
||
with sync_playwright() as p:
|
||
browser = None
|
||
last_err = ""
|
||
try:
|
||
browser = p.chromium.launch(headless=False, args=launch_args)
|
||
log("已启动内置 Chromium")
|
||
except Exception as e1:
|
||
last_err = str(e1)
|
||
log(f"内置 Chromium 启动失败({e1}),尝试本机 Edge/Chrome…")
|
||
for channel in ("msedge", "chrome"):
|
||
try:
|
||
browser = p.chromium.launch(
|
||
headless=False, channel=channel, args=launch_args
|
||
)
|
||
log(f"已启动本机 {channel}")
|
||
break
|
||
except Exception as e2:
|
||
last_err = str(e2)
|
||
continue
|
||
if browser is None:
|
||
_fail(
|
||
"无法启动浏览器,请尝试重新安装本软件(已内置 Chromium)。"
|
||
+ (f" 详情:{last_err}" if last_err else "")
|
||
)
|
||
return
|
||
|
||
try:
|
||
context = browser.new_context(
|
||
storage_state=storage_state,
|
||
user_agent=user_agent,
|
||
no_viewport=True,
|
||
)
|
||
page = context.new_page()
|
||
except Exception as e:
|
||
_fail(f"创建浏览器会话失败:{e}")
|
||
try:
|
||
browser.close()
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
# 浏览器窗口此刻已经弹出,先通知前端「已打开」,再去加载页面。
|
||
if on_ready:
|
||
try:
|
||
on_ready()
|
||
except Exception:
|
||
pass
|
||
|
||
log(f"正在打开 {DOUYIN_HOME} …")
|
||
try:
|
||
page.goto(DOUYIN_HOME, wait_until="domcontentloaded", timeout=60000)
|
||
except Exception as e:
|
||
log(f"页面加载提示:{e}(可在浏览器里手动刷新)")
|
||
log("浏览器已打开,登录态已注入。关闭浏览器窗口即可结束。")
|
||
|
||
# 保活等待必须用 Playwright 的 wait_for_timeout(内部持续处理浏览器事件),
|
||
# 不能用 time.sleep:sleep 会卡住 Playwright 客户端线程,导致页面里
|
||
# 新开的标签页(如抖音「视频管理」)永远停在 about:blank 无法加载。
|
||
try:
|
||
while browser.is_connected() and context.pages:
|
||
pg = context.pages[0]
|
||
try:
|
||
pg.wait_for_timeout(1000)
|
||
except Exception:
|
||
# 该页面刚被用户关闭等瞬态情况,稍候重查
|
||
time.sleep(0.2)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
browser.close()
|
||
except Exception:
|
||
pass
|
||
log("浏览器已关闭。")
|