feat: CDP 接管开关 + 扫码登录面板 + Docker 部署
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

- runner: enable_cdp_mode 从硬编码 False 改为系统设置 cdp_enabled。服务器部署下爬虫接管已开启远程调试的 Chrome(默认 9222),复用其 profile 登录态;本机桌面默认仍为关,行为不变
- qrlogin: 新增 CDP 扫码登录。Chrome 在服务器上跑于 Xvfb,show_qrcode 依赖的 PIL 桌面看图程序不存在,二维码无处可显示;改为经 CDP 从页面取出二维码交给 WebUI 渲染。刻意复用 browser.contexts[0](新建 context 是无痕 profile,扫了也白扫),且绝不调用 browser.close()(会连带关掉操作者自己的 Chrome)
- webui: 设置页新增扫码面板,替换原本跳到采集页看终端二维码的入口
- Dockerfile / .dockerignore / docker-compose.yml: 服务器部署。host 网络是必需而非图省事——容器里 127.0.0.1:9222 必须落到宿主机回环
- UPSTREAM.md: 补充 gitcode 镜像,用于 GitHub 大包传输必断时补历史
This commit is contained in:
2026-10-07 10:41:11 +08:00
parent 4e60524f37
commit 37ca1b1cd6
15 changed files with 872 additions and 13 deletions
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Loader2, QrCode, RefreshCw, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useCancelQrLogin, useQrLoginStatus, useStartQrLogin } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform'
/**
* Scan-to-login for a host with no display.
*
* The crawler's own QR flow prints the code into the terminal via PIL's
* `Image.show()`, which needs a desktop image viewer. On a server Chrome runs
* under Xvfb and there is no such viewer, so the backend reads the code out of
* that same browser over CDP and hands it here instead.
*
* That browser is the one monitor runs attach to, which is the point: scanning
* once leaves the login in the profile that every later unattended run reuses.
*/
export function QrLoginPanel() {
const { capability, platform } = useCurrentPlatform()
const queryClient = useQueryClient()
const [polling, setPolling] = useState(false)
const { data: state } = useQrLoginStatus(polling)
const start = useStartQrLogin()
const cancel = useCancelQrLogin()
const label = capability?.label ?? platform
const status = state?.status ?? 'idle'
// Stop polling the moment the outcome is known, and refresh the cookie panel:
// a completed scan is what makes it start reporting a healthy login.
useEffect(() => {
if (status === 'waiting' || status === 'idle') return
setPolling(false)
if (status === 'success') {
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
}
}, [status, queryClient])
const begin = () => {
setPolling(true)
start.mutate()
}
const busy = start.isPending || cancel.isPending
return (
<div className="rounded-lg glass-panel float-panel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-cyber-neon-cyan" />
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
{label}扫码登录
</span>
{status === 'waiting' && (
<Badge variant="warning" className="text-[10px]">
等待扫码
</Badge>
)}
{status === 'success' && (
<Badge variant="success" className="text-[10px]">
已登录
</Badge>
)}
</div>
{status === 'waiting' && (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setPolling(false)
cancel.mutate()
}}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
{status === 'waiting' && state?.image && (
<div className="flex items-start gap-4">
{/* The code is a data: URL straight from the page, so nothing is
fetched from a third party and no file is written server-side. */}
<img
src={state.image}
alt="登录二维码"
className="w-44 h-44 rounded-md border border-cyber-border-DEFAULT bg-white p-1"
/>
<div className="space-y-2 pt-1">
<p className="text-[11px] font-mono text-cyber-text-secondary leading-relaxed">
用<span className="text-cyber-neon-cyan">{label} App</span>扫码。
扫码成功后登录态会写入服务器上那台 Chrome 的 profile,
之后定时监控无需再登录。
</p>
<p className="text-[11px] font-mono text-cyber-text-muted">
剩余 <span className="text-cyber-neon-cyan">{state.expires_in}</span> 秒
</p>
</div>
</div>
)}
{status === 'waiting' && !state?.image && (
<p className="flex items-center gap-2 text-[11px] font-mono text-cyber-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
正在从浏览器取二维码…
</p>
)}
{status === 'success' && (
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-green leading-relaxed">
<CheckCircle2 className="w-3 h-3 mt-0.5 shrink-0" />
{state?.message || '登录成功'}
</p>
)}
{(status === 'error' || status === 'expired') && (
<div className="space-y-2">
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-orange leading-relaxed">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{state?.message || '获取二维码失败'}
</p>
<Button variant="outline" size="sm" disabled={busy} onClick={begin}>
<RefreshCw className="w-3 h-3 mr-1" />
重新获取
</Button>
</div>
)}
{status === 'idle' && (
<div className="space-y-2">
<p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed">
走 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。
需要先在「系统设置」里打开
<span className="text-cyber-neon-cyan">接管已有 Chrome(CDP)</span>,
并确保那台 Chrome 正以 <span className="text-cyber-neon-cyan">9222</span> 端口运行。
</p>
<Button size="sm" disabled={busy} onClick={begin}>
{start.isPending ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
获取二维码
</Button>
</div>
)}
</div>
)
}
+11 -8
View File
@@ -3,6 +3,7 @@ import { KeyRound, QrCode, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CookiePanel } from '@/components/monitor/CookiePanel'
import { QrLoginPanel } from '@/components/monitor/QrLoginPanel'
import { Section, SettingField } from '@/components/settings/SettingFields'
import { useSettings, useUpdateSettings } from '@/hooks/useMonitor'
import { useCurrentPlatform } from '@/hooks/usePlatform'
@@ -66,17 +67,19 @@ export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => v
</p>
</div>
<Section title="登录态" description={`${label}的登录 Cookie,定时监控必须持久化登录态。`}>
<Section
title="登录态"
description={`${label}的登录态。定时监控必须持久化,扫码一次最省事。`}
>
{/* QR first: it is the one that keeps working unattended, since the
scan lands in the very browser profile the monitor runs reuse. */}
<QrLoginPanel />
<CookiePanel />
{/* Not a duplicate login flow: the crawler has no login-only mode, so a
QR login is a side effect of a real crawl -- which is exactly what
the 采集 page already does. This preselects it rather than
reimplementing it. */}
<div className="pt-3 mt-1 border-t border-cyber-border-subtle space-y-2">
<p className="text-[10px] font-mono text-cyber-text-muted leading-relaxed">
Cookie 不好使时,可以走一次**扫码登录**:二维码会显示在「采集」页的终端里。
扫码成功后浏览器 profile 会被更新,Cookie 的可靠性也会显著提升。
在本机桌面运行时,也可以走「采集」页的扫码:二维码会打印在终端里。
服务器没有显示器,那条路走不通,用上面的面板。
</p>
<Button
variant="outline"
@@ -89,7 +92,7 @@ export function SettingsView({ onNavigate }: { onNavigate?: (view: AppView) => v
}}
>
<QrCode className="w-3 h-3 mr-1" />
去扫码登录
去采集页扫码
</Button>
</div>
</Section>