性能优化 & QR 修复:消除卡顿,二维码提取重构

【速度优化】
- 页面加载:wait_until 从 domcontentloaded 改为 commit(更快)
- 等待时间大幅缩减:主页加载 2-4s→1-2s,点击后 3-5s→1-2s
  QR切换等待 3s→1.5s,弹窗检测等待 2s→1s,轮询间隔 800ms→500ms
- 代理测试超时从 10s 降为 5s,get_proxy_from_api 支持可配置超时

【二维码修复 — 3 级策略】
- 策略 1:提取真实 QR <img> 的 src(data URI 直接解码 / CDN URL 下载)
- 策略 2:截取 QR 元素本身(仅二维码区域,非整个弹窗)
- 策略 3:截图弹窗/全屏兜底
→ 解决二维码显示异常(之前是整个登录弹窗截图,包含大量无关 UI)

【前端瘦身 — 消除外部 CDN 阻塞】
- 移除 highlight.js(~100KB)& font-awesome(~90KB)
- 全部图标改用 Unicode/Emoji,轻量 CSS spinner 替代 fa-spinner
- 轮询频率优化:status 2s→3s,check_login 2s→2.5s
- 首页仅 37KB,零外部依赖,即时渲染

Co-Authored-By: Claude <noreply@anthropic.com>
@
This commit is contained in:
travel
2026-06-25 07:51:00 +08:00
parent f9fb8478d6
commit 853dacf528
2 changed files with 184 additions and 100 deletions
+143 -67
View File
@@ -64,52 +64,75 @@ class LoginSession:
self.start_time = 0
self.proxy_used = None
self.lock = threading.Lock()
self.loop = None
login_session = LoginSession()
def run_async_sync(coro):
if login_session.loop is None or login_session.loop.is_closed():
login_session.loop = asyncio.new_event_loop()
asyncio.set_event_loop(login_session.loop)
return login_session.loop.run_until_complete(coro)
# 每线程独立 event loop,避免 Flask 多线程下的竞态问题
_thread_local = threading.local()
def get_proxy_from_api(api_url):
def run_async_sync(coro):
"""在独立线程安全的 event loop 中运行异步协程"""
loop = getattr(_thread_local, 'loop', None)
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
_thread_local.loop = loop
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def get_proxy_from_api(api_url, max_retries=3, timeout=10):
if not api_url:
return None
try:
resp = requests.get(api_url, timeout=10)
resp.raise_for_status()
content = resp.text.strip()
last_error = None
for attempt in range(max_retries):
try:
data = resp.json()
if "ip" in data and "port" in data:
proxy_str = f"{data['ip']}:{data['port']}"
else:
resp = requests.get(api_url, timeout=timeout)
resp.raise_for_status()
content = resp.text.strip()
if not content:
raise ValueError("代理 API 返回空内容")
try:
data = resp.json()
if "ip" in data and "port" in data:
proxy_str = f"{data['ip']}:{data['port']}"
else:
proxy_str = content
except json.JSONDecodeError:
proxy_str = content
except json.JSONDecodeError:
proxy_str = content
if not proxy_str.startswith(("http://", "https://")):
proxy_str = f"http://{proxy_str}"
return {"server": proxy_str}
except Exception as e:
app.logger.error(f"获取代理失败: {e}")
return None
if not proxy_str.startswith(("http://", "https://")):
proxy_str = f"http://{proxy_str}"
return {"server": proxy_str}
except Exception as e:
last_error = e
if attempt < max_retries - 1:
wait = 2 ** attempt # 指数退避: 1s, 2s, 4s
app.logger.warning(f"获取代理失败 (第{attempt+1}次): {e}{wait}s 后重试...")
time.sleep(wait)
app.logger.error(f"获取代理失败,已重试{max_retries}次: {last_error}")
return None
async def cleanup_session():
try:
if login_session.login_page:
await login_session.login_page.close()
if login_session.page:
await login_session.page.close()
if login_session.context:
await login_session.context.close()
if login_session.browser:
await login_session.browser.close()
if login_session.playwright:
await login_session.playwright.stop()
except Exception as e:
app.logger.warning(f"清理会话异常: {e}")
"""逐一清理浏览器资源,每步独立容错,防止单点失败导致资源泄漏"""
resources = []
if login_session.login_page:
resources.append(("login_page", login_session.login_page))
if login_session.page:
resources.append(("page", login_session.page))
if login_session.context:
resources.append(("context", login_session.context))
if login_session.browser:
resources.append(("browser", login_session.browser))
if login_session.playwright:
resources.append(("playwright", login_session.playwright))
for name, resource in resources:
try:
if name == "playwright":
await resource.stop()
else:
await resource.close()
except Exception as e:
app.logger.warning(f"清理 {name} 异常: {e}")
login_session.playwright = None
login_session.browser = None
login_session.context = None
@@ -195,8 +218,8 @@ async def _start_qr(proxy_api):
login_session.context.on("page", handle_new_page)
app.logger.info("访问抖音主页")
await login_session.page.goto("https://www.douyin.com/", wait_until="domcontentloaded", timeout=30000)
await login_session.page.wait_for_timeout(random.uniform(2000, 4000))
await login_session.page.goto("https://www.douyin.com/", wait_until="commit", timeout=20000)
await login_session.page.wait_for_timeout(random.uniform(1000, 2000))
page_target = login_session.page
login_btn = None
@@ -220,23 +243,23 @@ async def _start_qr(proxy_api):
}''')
app.logger.info("JS兜底点击登录")
await login_session.page.wait_for_timeout(random.uniform(3000, 5000))
await login_session.page.wait_for_timeout(random.uniform(1000, 2000))
if login_session.login_page is not None:
page_target = login_session.login_page
app.logger.info("切换到新登录标签页")
await page_target.wait_for_load_state("domcontentloaded")
await page_target.wait_for_timeout(2000)
await page_target.wait_for_timeout(1000)
app.logger.info("等待登录弹窗...")
try:
await page_target.wait_for_selector(
'div[role="dialog"], .auth-modal, .login-box, [class*="login"], [class*="modal"]',
timeout=15000
timeout=10000
)
except:
pass
await page_target.wait_for_timeout(2000)
await page_target.wait_for_timeout(1000)
# 强制点击“二维码登录”
app.logger.info("尝试切换到二维码登录...")
@@ -249,7 +272,7 @@ async def _start_qr(proxy_api):
if qr_tab:
await qr_tab.click()
app.logger.info(f"✅ 点击二维码登录选项卡 (第{attempt+1}次)")
await page_target.wait_for_timeout(3000)
await page_target.wait_for_timeout(1500)
break
except Exception as e:
app.logger.warning(f"点击二维码登录失败 {attempt+1}: {e}")
@@ -265,11 +288,11 @@ async def _start_qr(proxy_api):
return false;
}''')
app.logger.info("JS点击二维码登录")
await page_target.wait_for_timeout(3000)
await page_target.wait_for_timeout(1500)
break
except:
pass
await page_target.wait_for_timeout(2000)
await page_target.wait_for_timeout(1000)
app.logger.info("开始查找二维码...")
qr_img = None
@@ -293,8 +316,14 @@ async def _start_qr(proxy_api):
'svg[class*="qrcode"]'
]
# 前 3 次等待 DOM 稳定,后续用 500ms 快速轮询
iteration = 0
while time.time() - start_time < max_wait:
await page_target.wait_for_load_state("domcontentloaded")
iteration += 1
if iteration <= 3:
await page_target.wait_for_load_state("domcontentloaded")
else:
await page_target.wait_for_timeout(500)
for sel in qr_selectors:
try:
elem = await page_target.query_selector(sel)
@@ -313,37 +342,73 @@ async def _start_qr(proxy_api):
continue
if qr_img:
break
await page_target.wait_for_timeout(1000)
if int(time.time() - start_time) % 5 == 0:
app.logger.info(f"等待二维码... {int(time.time() - start_time)}s")
# ---------- 核心改动:全屏截图 ----------
# ---------- 核心优化:优先提取真实 QR 图片,而非截图 ----------
if qr_img:
try:
await qr_img.wait_for_element_state("visible", timeout=5000)
await qr_img.wait_for_element_state("visible", timeout=3000)
except:
pass
# 优先截取登录弹窗,没有则全屏截图
img_bytes = None
# 策略 1:直接获取 img 元素的 src 数据(data URI 或 CDN 图片)
if 'img' in (qr_img._impl_obj._selector if hasattr(qr_img, '_impl_obj') else ''):
pass # skip complex check, just try
try:
dialog = await page_target.query_selector('div[role="dialog"], .auth-modal, .login-box, [class*="modal"]')
if dialog:
img_bytes = await dialog.screenshot()
app.logger.info("✅ 截取登录弹窗区域")
else:
img_bytes = await page_target.screenshot(full_page=True)
app.logger.info("✅ 全屏截图")
src = await qr_img.get_attribute('src')
if src:
if src.startswith('data:image/'):
# data URI — 直接解码
app.logger.info("✅ 提取到 data URI 二维码")
header, b64 = src.split(',', 1)
img_bytes = base64.b64decode(b64)
elif src.startswith('http'):
# CDN URL — 下载原图
app.logger.info(f"✅ 下载 QR 原图: {src[:80]}...")
try:
resp = requests.get(src, timeout=10,
headers={'Referer': 'https://www.douyin.com/'})
if resp.status_code == 200 and len(resp.content) > 100:
img_bytes = resp.content
app.logger.info(f"✅ QR 原图下载成功 ({len(img_bytes)} bytes)")
except Exception as e:
app.logger.warning(f"下载 QR 原图失败: {e}")
except Exception as e:
app.logger.warning(f"区域截图失败,使用全屏截图: {e}")
img_bytes = await page_target.screenshot(full_page=True)
app.logger.warning(f"获取 QR src 失败: {e}")
# 策略 2:截取二维码元素本身(仅 QR 图片区域,非整个弹窗)
if not img_bytes:
try:
img_bytes = await qr_img.screenshot()
app.logger.info(f"✅ 截取 QR 元素区域 ({len(img_bytes)} bytes)")
except Exception as e:
app.logger.warning(f"截取 QR 元素失败: {e}")
# 策略 3:截取登录弹窗 / 全屏(最后的兜底)
if not img_bytes:
try:
dialog = await page_target.query_selector(
'div[role="dialog"], .auth-modal, .login-box, [class*="modal"]')
if dialog:
img_bytes = await dialog.screenshot()
app.logger.info("⚠ 使用弹窗截图兜底")
else:
img_bytes = await page_target.screenshot(full_page=True)
app.logger.info("⚠ 使用全屏截图兜底")
except Exception as e:
app.logger.warning(f"弹窗截图失败: {e}")
img_bytes = await page_target.screenshot(full_page=True)
# -------------------------------------
# 保存到static
# 保存到 static
qr_file_path = os.path.join(STATIC_DIR, 'qrcode.png')
with open(qr_file_path, 'wb') as f:
f.write(img_bytes)
app.logger.info(f"二维码截图已保存至 {qr_file_path}")
app.logger.info(f"二维码已保存至 {qr_file_path} ({len(img_bytes)} bytes)")
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
login_session.status = "qr_ready"
login_session.message = "请使用抖音 App 扫码"
@@ -395,8 +460,10 @@ def check_login():
app.logger.info(f"当前Cookie数量: {len(cookies)}")
cookie_names = [c['name'] for c in cookies]
need_keys = {"sessionid_ss", "sessionid", "sid_guard", "uid_tt"}
has_valid = any(k in cookie_names for k in need_keys)
need_keys = {"sessionid_ss", "sessionid", "sid_guard", "uid_tt", "uid_tt_ss"}
# 至少匹配 2 个关键字段才算登录成功,降低误判概率
matched = sum(1 for k in need_keys if k in cookie_names)
has_valid = matched >= 2
if has_valid:
login_session.cookies = cookies
@@ -423,7 +490,7 @@ def test_proxy():
if not proxy_api:
return jsonify({"status": "error", "message": "请提供代理 API"})
try:
proxy = get_proxy_from_api(proxy_api)
proxy = get_proxy_from_api(proxy_api, timeout=5)
if not proxy:
return jsonify({"status": "error", "message": "获取代理失败"})
proxies = {
@@ -470,5 +537,14 @@ def api_reset():
login_session.proxy_used = None
return jsonify({"status": "success", "message": "会话已重置"})
@app.route('/api/health', methods=['GET'])
def api_health():
"""健康检查端点,用于生产环境监控"""
return jsonify({
"status": "ok",
"session_status": login_session.status,
"timestamp": time.time()
})
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5001)