feat(ai): 可配置 User-Agent 绕过 Cloudflare 等 CDN 拦截 (fix #8) (#10)

问题(Issue #8): AI API 反代到 Cloudflare 后,OpenAI SDK 默认 UA
(OpenAI/Python) 被 Bot Fight Mode 拦截,报 'Your request was blocked'。

方案: 后端始终发送浏览器风格 UA,默认内置 Chrome UA(开箱即用);
AI 设置页提供开关 + 随机生成/手动粘贴的自定义 UA。

后端(config.py / settings.py / ai_generator.py / strategy.py):
- 新增 ai_user_agent 配置项,默认桌面 Chrome UA
- 两处 OpenAI client 创建均注入 default_headers={User-Agent}
  (ai_generator 实际生成 + strategy 测试连通性)
- 空 UA 自动回退内置默认,保证默认状态即绕过拦截

前端(api.ts / AI.tsx):
- 自定义 UA 开关:默认关闭(用内置默认),开启才显示输入框
- 随机生成按钮: 产出近期 Chrome 桌面 UA(Win/Mac/Linux 随机)
- 已有自定义 UA 时开关默认开启

不影响直连官方 OpenAI/DeepSeek 的用户(官方不校验 UA)。
This commit is contained in:
wshy
2026-06-25 10:49:10 +08:00
committed by GitHub
parent acd30dd728
commit 7c81a03026
6 changed files with 83 additions and 4 deletions
+5
View File
@@ -58,6 +58,7 @@ def get_settings() -> dict:
"has_ai_key": bool(secrets_store.get_ai_key()),
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
}
@@ -212,6 +213,7 @@ class AiSettingsIn(BaseModel):
api_key: str | None = None
model: str = ""
daily_token_budget: int = 500_000
user_agent: str = ""
@router.post("/ai")
@@ -238,6 +240,9 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
settings.ai_model = req.model
updates["ai_daily_token_budget"] = req.daily_token_budget
settings.ai_daily_token_budget = req.daily_token_budget
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent
if updates:
secrets_store.save(updates)
+6 -1
View File
@@ -324,7 +324,12 @@ async def ai_test(request: Request):
return {"ok": False, "error": "未配置 API Key"}
try:
client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
# User-Agent: 默认浏览器标识,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
client = AsyncOpenAI(
api_key=ai_key,
base_url=settings.ai_base_url,
default_headers={"User-Agent": settings.ai_user_agent or "Mozilla/5.0"},
)
resp = await client.chat.completions.create(
model=settings.ai_model,
messages=[{"role": "user", "content": "回复 OK"}],
+7
View File
@@ -71,6 +71,13 @@ class Settings(BaseSettings):
ai_api_key: str = ""
ai_model: str = "gpt-5.5"
ai_daily_token_budget: int = 5_000_000
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
# 用户可在 AI 设置页按需修改。
ai_user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
# Server
host: str = "0.0.0.0"
+6
View File
@@ -83,11 +83,17 @@ class AIStrategyGenerator:
if not ai_key:
raise RuntimeError("AI API Key 未配置,请在设置页面配置")
# User-Agent: 默认浏览器标识,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
# 用户可在 AI 设置页自定义。
from app.config import settings
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
client = AsyncOpenAI(
api_key=ai_key,
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
timeout=180.0,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
# 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时
stream = await client.chat.completions.create(
+2 -1
View File
@@ -566,6 +566,7 @@ export interface SettingsState {
has_ai_key: boolean
ai_model: string
ai_daily_token_budget: number
ai_user_agent: string
}
/** 保存 TickFlow Key 的响应(先探后存) */
@@ -636,7 +637,7 @@ export const api = {
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number }) =>
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number; user_agent?: string }) =>
request<{ ok: boolean }>('/api/settings/ai', {
method: 'POST',
body: JSON.stringify(ai),
+57 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield } from 'lucide-react'
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield, Shuffle } from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -21,6 +21,10 @@ export function SettingsAIPanel() {
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [tokenBudget, setTokenBudget] = useState(5_000_000)
// 自定义 User-Agent 开关:关闭 → 后端用内置默认浏览器 UA(开箱绕过 CDN 拦截);
// 开启 → 用下方文本框的 UA,留空时随机生成。
const [customUa, setCustomUa] = useState(false)
const [userAgent, setUserAgent] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
@@ -28,17 +32,35 @@ export function SettingsAIPanel() {
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
// 随机生成一个近期桌面端 Chrome UA(Win/Mac/Linux 随机)
const genRandomUa = () => {
const major = 128 + Math.floor(Math.random() * 8) // 128~135
const platforms = [
`Windows NT 10.0; Win64; x64`,
`Macintosh; Intel Mac OS X 10_15_7`,
`X11; Linux x86_64`,
]
const pf = platforms[Math.floor(Math.random() * platforms.length)]
setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`)
}
useEffect(() => {
if (!s) return
setProvider(s.ai_provider ?? 'openai_compat')
setBaseUrl(s.ai_base_url ?? '')
setModel(s.ai_model ?? '')
setTokenBudget(s.ai_daily_token_budget ?? 500_000)
// 有已保存的自定义 UA → 开关默认开启;否则关闭(用后端内置默认)
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
setUserAgent(ua)
}, [s])
const save = useMutation({
mutationFn: () => api.saveAiSettings({
provider, base_url: baseUrl, api_key: apiKey || undefined, model, daily_token_budget: tokenBudget,
// 关闭开关 → 提交空串,后端回退内置默认 UA
user_agent: customUa ? userAgent : '',
}),
onSuccess: () => {
setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
@@ -50,7 +72,7 @@ export function SettingsAIPanel() {
setTesting(true); setTestResult(null)
try {
// 先保存当前配置(不保存 Key 仅用于测试时临时存)
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget })
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget, user_agent: customUa ? userAgent : '' })
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}${r.usage ? ` · 消耗 ${r.usage.prompt + r.usage.completion} tokens` : ''}` : (r.error ?? '未知错误') })
} catch (e: any) {
@@ -178,6 +200,39 @@ export function SettingsAIPanel() {
<span className="text-[10px] text-muted"> AI </span>
</div>
</div>
{/* 请求头 User-Agent */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted/50 uppercase tracking-wider"> User-Agent</label>
<button
type="button"
onClick={() => setCustomUa(v => !v)}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${customUa ? 'bg-accent' : 'bg-elevated'}`}
aria-pressed={customUa}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${customUa ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="text-[10px] text-muted/70 leading-relaxed">
{customUa
? '当前使用下方自定义 UA 调用 AI API。'
: '默认已使用内置浏览器标识,可绕过 Cloudflare 等 CDN/WAF 拦截。仅在默认标识被拦截时才需开启自定义。'}
</div>
{customUa && (
<div className="flex gap-2">
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)}
placeholder="留空点击「随机生成」或直接粘贴浏览器 UA"
className="flex-1 h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
<button type="button" onClick={() => { if (!userAgent) genRandomUa() }}
title={userAgent ? '已存在内容,清空后可重新生成' : '随机生成浏览器 UA'}
className="h-8 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0 disabled:opacity-40"
disabled={!!userAgent}>
<Shuffle className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
</div>