feat: 监控面板 / 登录鉴权 / 多平台切换 / MySQL
在上游 MediaCrawler 之上新增一层: - 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、 企业微信通知。每轮采集写入独立目录,差分才成立。 - WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。 WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。 - 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」, 未接通的平台直接拒绝建任务,而不是静默跑空。 - 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4 (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库, 连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。 修复上游缺陷: - xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到, 而那份资料只喂给一个空函数。改为尽力而为,失败不中断。 - xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。 新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。 - requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、 这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。 改动过的上游文件清单及合并方式见 UPSTREAM.md。 测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关)
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useCreateTask, useSettings, useUpdateTask } from '@/hooks/useMonitor'
|
||||
import type { MonitorMode, MonitorTask, TaskCreatePayload } from '@/types/monitor'
|
||||
|
||||
interface TaskEditorDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task?: MonitorTask | null
|
||||
}
|
||||
|
||||
const TEXTAREA_CLASS =
|
||||
'w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary p-2 font-mono text-[11px] text-cyber-text-primary outline-none focus-visible:border-cyber-neon-cyan/50'
|
||||
|
||||
// The backend enforces this floor too; every run launches a browser and hits
|
||||
// XHS several times, so tight polling is what triggers rate limiting.
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: '30', label: '30 分钟' },
|
||||
{ value: '60', label: '1 小时' },
|
||||
{ value: '120', label: '2 小时' },
|
||||
{ value: '360', label: '6 小时' },
|
||||
{ value: '720', label: '12 小时' },
|
||||
{ value: '1440', label: '1 天' },
|
||||
{ value: '4320', label: '3 天' },
|
||||
{ value: '10080', label: '7 天' },
|
||||
]
|
||||
|
||||
export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) {
|
||||
const isEdit = Boolean(task)
|
||||
const createTask = useCreateTask()
|
||||
const updateTask = useUpdateTask()
|
||||
const { data: settings } = useSettings()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [mode, setMode] = useState<MonitorMode>('creator')
|
||||
const [intervalMinutes, setIntervalMinutes] = useState('360')
|
||||
const [maxNotes, setMaxNotes] = useState('20')
|
||||
const [enableComments, setEnableComments] = useState(true)
|
||||
const [maxComments, setMaxComments] = useState('50')
|
||||
const [notifyEnabled, setNotifyEnabled] = useState(false)
|
||||
const [targets, setTargets] = useState('')
|
||||
|
||||
// Reset the form whenever the dialog is (re)opened. For a new task the
|
||||
// starting values come from the Settings page, so those defaults actually
|
||||
// govern rather than being overwritten by hardcoded numbers here.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName(task?.name ?? '')
|
||||
setMode(task?.mode ?? 'creator')
|
||||
setIntervalMinutes(
|
||||
String(task?.interval_minutes ?? settings?.values['collect.default_interval_minutes'] ?? 360),
|
||||
)
|
||||
setMaxNotes(
|
||||
String(task?.max_notes_count ?? settings?.values['collect.default_max_notes'] ?? 20),
|
||||
)
|
||||
setEnableComments(task?.enable_comments ?? true)
|
||||
setMaxComments(
|
||||
String(task?.max_comments_count ?? settings?.values['collect.default_max_comments'] ?? 50),
|
||||
)
|
||||
setNotifyEnabled(task?.notify_enabled ?? false)
|
||||
setTargets(task ? task.targets.map((t) => t.raw_value || t.external_id).join('\n') : '')
|
||||
}, [open, task, settings])
|
||||
|
||||
const targetList = targets
|
||||
.split(/[\n,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const pending = createTask.isPending || updateTask.isPending
|
||||
// Note-mode and creator-mode targets are different shapes, so switching mode
|
||||
// would silently mis-parse the list. The backend decides parsing from the
|
||||
// task's stored mode, hence mode is fixed once created.
|
||||
const canSubmit = name.trim().length > 0 && targetList.length > 0 && !pending
|
||||
|
||||
const handleSubmit = () => {
|
||||
const payload: TaskCreatePayload = {
|
||||
name: name.trim(),
|
||||
mode,
|
||||
interval_minutes: Number(intervalMinutes),
|
||||
max_notes_count: Number(maxNotes),
|
||||
enable_comments: enableComments,
|
||||
max_comments_count: Number(maxComments),
|
||||
run_timeout_seconds: 3600,
|
||||
enabled: true,
|
||||
notify_enabled: notifyEnabled,
|
||||
targets: targetList,
|
||||
}
|
||||
|
||||
const done = () => onOpenChange(false)
|
||||
if (isEdit && task) {
|
||||
updateTask.mutate({ id: task.id, payload }, { onSuccess: done })
|
||||
} else {
|
||||
createTask.mutate(payload, { onSuccess: done })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto terminal-scroll">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">
|
||||
{isEdit ? '编辑监控任务' : '新建监控任务'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">
|
||||
定时重复采集目标,并与上一次快照对比,产出新增作品、新增评论和互动数据变化。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-mono text-cyber-text-secondary">任务名称</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:竞品博主监控"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-mono text-cyber-text-secondary">监控类型</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as MonitorMode)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="creator">博主(监控其作品)</SelectItem>
|
||||
<SelectItem value="note">笔记(批量监控指定内容)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit && (
|
||||
<p className="text-[10px] font-mono text-cyber-text-muted">
|
||||
类型创建后不可更改
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-mono text-cyber-text-secondary">采集间隔</Label>
|
||||
<Select value={intervalMinutes} onValueChange={setIntervalMinutes}>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INTERVAL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-mono text-cyber-text-secondary">
|
||||
{mode === 'creator' ? '博主主页链接或 ID' : '笔记链接或 ID'}
|
||||
</Label>
|
||||
<textarea
|
||||
value={targets}
|
||||
onChange={(event) => setTargets(event.target.value)}
|
||||
rows={5}
|
||||
placeholder={
|
||||
mode === 'creator'
|
||||
? '每行一个,支持完整主页链接或纯 ID:\nhttps://www.xiaohongshu.com/user/profile/5f58bd99...\n5f58bd990000000001003753'
|
||||
: '每行一个,支持完整笔记链接或纯 ID:\nhttps://www.xiaohongshu.com/explore/6aa3d827...'
|
||||
}
|
||||
className={TEXTAREA_CLASS}
|
||||
/>
|
||||
<p className="text-[10px] font-mono text-cyber-text-muted">
|
||||
已识别 <span className="text-cyber-neon-cyan">{targetList.length}</span> 个目标。
|
||||
<span className="text-cyber-neon-orange">建议只填纯 ID</span>
|
||||
——链接里的 xsec_token 会过期,纯 ID 永久有效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-mono text-cyber-text-secondary">
|
||||
每轮最多采集作品数
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxNotes}
|
||||
onChange={(event) => setMaxNotes(event.target.value)}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] font-mono text-cyber-text-muted">
|
||||
只取最新的前 N 条,决定了"该博主的作品"覆盖范围
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-mono text-cyber-text-secondary">每篇评论抓取条数</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxComments}
|
||||
disabled={!enableComments}
|
||||
onChange={(event) => setMaxComments(event.target.value)}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Checkbox
|
||||
id="enable-comments"
|
||||
checked={enableComments}
|
||||
onCheckedChange={(checked) => setEnableComments(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-comments"
|
||||
className="text-[10px] font-mono text-cyber-text-secondary cursor-pointer"
|
||||
>
|
||||
抓评论
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-cyber-neon-orange">
|
||||
接口无时间排序,只能取平台默认排序的前 N 条做对比,N 越大越容易发现新评论
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3">
|
||||
<Checkbox
|
||||
id="notify-enabled"
|
||||
checked={notifyEnabled}
|
||||
onCheckedChange={(checked) => setNotifyEnabled(checked === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="notify-enabled"
|
||||
className="text-xs font-mono text-cyber-text-primary cursor-pointer"
|
||||
>
|
||||
推送企业微信通知
|
||||
</label>
|
||||
<p className="text-[10px] font-mono text-cyber-text-muted">
|
||||
仅在本任务**采集失败 / 登录态失效**或**发现新作品**时推送,
|
||||
一轮只发一条汇总。需先在监控页配置 Webhook 地址。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isEdit ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user