feat: CDP 接管开关 + 扫码登录面板 + Docker 部署
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user