Files
MediaCrawler/webui/src/components/monitor/TaskEditorDialog.tsx
T
butubb f6ddc46d62
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(notify): 通知拆成「新作品」与「异常」两个开关,异常默认开
问题:cookie 过期导致任务失败,但没有任何通知。查下来不是代码问题 ——
notify_enabled 在两个任务上都是 False,而它默认就是关的,事件(run_failed)
也确实生成了,只卡在最后一道闸门。

但那个默认值是错的。代码里的理由是「一条任务列表都推到一个群会很快变吵,所以默认静默」,
这个理由对新作品成立(可能每轮都有),对失败不成立:一次登录态失效意味着这个任务事实上
已经死了,而你不会知道,直到某天发现数据停在几周前。最该被告知的就是这种情况。

现在拆开:
- notify_enabled  —— 推送新作品,可能每轮都有,默认关
- notify_failures —— 推送异常(登录失效/运行失败/没抓到数据),默认开

事件按开关过滤(build_run_message):只勾了「新作品」的任务不该因为一次失败被推消息,
反之亦然,否则拆开开关就没有意义。已有任务由 _ensure_columns 补上 notify_failures=1,
所以会自动开始收到异常推送。

列名 notify_enabled 是历史遗留(它早先是唯一的通知开关),语义已收窄为「新作品」,
用注释写明,不做列重命名 —— 那需要单独的迁移,不值为一个内部工具做。
2026-10-09 13:37:02 +08:00

526 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type ReactNode } 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 { describeSchedule } from '@/lib/monitorFormat'
import type {
MonitorMode,
MonitorTask,
ScheduleMode,
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 天' },
]
const SCHEDULE_MODE_OPTIONS: Array<{ value: ScheduleMode; label: string }> = [
{ value: 'interval', label: '固定间隔' },
{ value: 'daily', label: '每天定时' },
{ value: 'weekly', label: '每周定时' },
]
const HOURS = Array.from({ length: 24 }, (_, hour) => hour)
/**
* Above this many works per run, warn about the request volume.
*
* The runner serialises crawls (max_concurrency_num=1) and each note also pulls
* up to `max_comments_count` comments, so the cost is works × (1 + comments) and
* the default run timeout is an hour.
*/
const NOTE_VOLUME_WARN = 500
const MINUTES = Array.from({ length: 60 }, (_, minute) => minute)
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const pad = (value: number) => String(value).padStart(2, '0')
function toggleNumber(values: number[], value: number): number[] {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value].sort((a, b) => a - b)
}
/** A selectable pill. Used for the hours of the day and the weekdays. */
function Chip({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className={`px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors ${
active
? 'border-cyber-neon-cyan/60 bg-cyber-neon-cyan/20 text-cyber-neon-cyan'
: 'border-cyber-border-DEFAULT text-cyber-text-muted hover:text-cyber-text-secondary'
}`}
>
{children}
</button>
)
}
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 [scheduleMode, setScheduleMode] = useState<ScheduleMode>('interval')
const [scheduleHours, setScheduleHours] = useState<number[]>([])
const [scheduleDays, setScheduleDays] = useState<number[]>([])
const [scheduleMinute, setScheduleMinute] = useState('0')
const [maxNotes, setMaxNotes] = useState('20')
const [enableComments, setEnableComments] = useState(true)
const [maxComments, setMaxComments] = useState('50')
const [notifyEnabled, setNotifyEnabled] = useState(false)
// 异常推送默认开:失败意味着这个任务从此默默采不到东西,而你不会知道。
const [notifyFailures, setNotifyFailures] = useState(true)
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),
)
setScheduleMode(task?.schedule_mode ?? 'interval')
setScheduleHours(task?.schedule_hours ?? [])
setScheduleDays(task?.schedule_days ?? [])
setScheduleMinute(String(task?.schedule_minute ?? 0))
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)
setNotifyFailures(task?.notify_failures ?? true)
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)
// Switching into a clock mode with nothing chosen would be invalid, so seed a
// sensible starting point -- the operator then edits rather than fills a blank.
const selectScheduleMode = (next: ScheduleMode) => {
setScheduleMode(next)
if (next !== 'interval' && scheduleHours.length === 0) setScheduleHours([9])
if (next === 'weekly' && scheduleDays.length === 0) setScheduleDays([0, 1, 2, 3, 4])
}
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 clockIncomplete =
scheduleMode !== 'interval' &&
(scheduleHours.length === 0 || (scheduleMode === 'weekly' && scheduleDays.length === 0))
const canSubmit =
name.trim().length > 0 && targetList.length > 0 && !pending && !clockIncomplete
const handleSubmit = () => {
const payload: TaskCreatePayload = {
name: name.trim(),
mode,
interval_minutes: Number(intervalMinutes),
schedule_mode: scheduleMode,
// Interval mode has no clock times, so send none rather than leave stale
// ones behind from a mode the operator tried and abandoned.
schedule_hours: scheduleMode === 'interval' ? [] : scheduleHours,
schedule_days: scheduleMode === 'weekly' ? scheduleDays : [],
schedule_minute: Number(scheduleMinute),
max_notes_count: Number(maxNotes),
enable_comments: enableComments,
max_comments_count: Number(maxComments),
run_timeout_seconds: 3600,
enabled: true,
notify_enabled: notifyEnabled,
notify_failures: notifyFailures,
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>
<div className="flex gap-1">
{SCHEDULE_MODE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => selectScheduleMode(option.value)}
className={`flex-1 rounded border px-1 py-1.5 text-[10px] font-mono transition-colors ${
scheduleMode === option.value
? 'border-cyber-neon-cyan/60 bg-cyber-neon-cyan/20 text-cyber-neon-cyan'
: 'border-cyber-border-DEFAULT text-cyber-text-muted hover:text-cyber-text-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{scheduleMode === 'interval' ? (
<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>
<p className="text-[10px] font-mono text-cyber-text-muted">
从上一轮<span className="text-cyber-text-secondary">开始</span>计时,
所以某轮跑得久也不会让下一轮紧接着触发。
</p>
</div>
) : (
<div className="space-y-3 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3">
{scheduleMode === 'weekly' && (
<div className="space-y-1.5">
<div className="text-[10px] font-mono text-cyber-text-secondary">星期</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((label, day) => (
<Chip
key={day}
active={scheduleDays.includes(day)}
onClick={() => setScheduleDays((prev) => toggleNumber(prev, day))}
>
{label}
</Chip>
))}
</div>
</div>
)}
<div className="space-y-1.5">
<div className="text-[10px] font-mono text-cyber-text-secondary">
时间(可多选,点一次选中、再点取消)
</div>
<div className="flex flex-wrap gap-1">
{HOURS.map((hour) => (
<Chip
key={hour}
active={scheduleHours.includes(hour)}
onClick={() => setScheduleHours((prev) => toggleNumber(prev, hour))}
>
{pad(hour)}
</Chip>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-cyber-text-secondary">分钟</span>
<Select value={scheduleMinute} onValueChange={setScheduleMinute}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-56">
{MINUTES.map((minute) => (
<SelectItem key={minute} value={String(minute)}>
{pad(minute)}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] font-mono text-cyber-text-muted">
所有时间点共用
</span>
</div>
<div className="flex items-center justify-between gap-3 border-t border-cyber-border-subtle pt-2">
<span className="text-[11px] font-mono text-cyber-neon-cyan">
{describeSchedule(
scheduleMode,
Number(intervalMinutes),
scheduleHours,
scheduleDays,
Number(scheduleMinute),
)}
</span>
<span className="text-[10px] font-mono text-cyber-text-muted">
按服务器本地时间
</span>
</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">
{mode === 'creator' ? '每个博主最多采集作品数' : '最多采集作品数'}
</Label>
<Input
type="number"
min={1}
value={maxNotes}
onChange={(event) => setMaxNotes(event.target.value)}
disabled={mode === 'note'}
className="h-9 text-xs"
/>
{mode === 'creator' ? (
<>
{/* 这是「每个博主」的上限,不是一轮的总量 —— 爬虫里这个值是在
per-creator 的函数内比较的(client.py get_all_notes_by_creator),
而外层 for 循环会遍历全部目标。所以 100 个目标 × 20 篇 = 单轮
最多 2000 篇。标签写成「每轮最多」会让人以为超出的会被丢弃。 */}
<p className="text-[10px] font-mono text-cyber-text-muted">
这是<span className="text-cyber-text-secondary">每个博主</span>的上限,
不是一轮的总量。只取该博主最新的前 N 条,超出的不会补抓。
</p>
<p className="text-[10px] font-mono text-cyber-text-secondary">
{targetList.length} 个目标 × {maxNotes} 篇 × 每人 1 次
→ 单轮最多{' '}
<span className="text-cyber-neon-cyan">
{targetList.length * (Number(maxNotes) || 0)}
</span>{' '}
篇
</p>
{targetList.length * (Number(maxNotes) || 0) > NOTE_VOLUME_WARN && (
<p className="text-[10px] font-mono text-cyber-neon-orange leading-relaxed">
单轮量偏大:每篇还要抓最多 {maxComments} 条评论,且并发为 1。
容易触发平台限流,也可能跑不完就被任务超时(默认 1 小时)中断。
建议调低这个数,或拆成几个任务。
</p>
)}
</>
) : (
<p className="text-[10px] font-mono text-cyber-neon-orange">
笔记模式下此项不生效:你列出的每个链接都会被逐条抓取。
</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="rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 p-3 space-y-3">
{/* 两类通知的性质完全不同,所以分成两个开关:
异常低频且意味着任务已经停止工作 —— 默认开;
新作品可能每轮都有 —— 默认关,否则会刷屏。 */}
<div className="flex items-start gap-2">
<Checkbox
id="notify-failures"
checked={notifyFailures}
onCheckedChange={(checked) => setNotifyFailures(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="notify-failures"
className="text-xs font-mono text-cyber-text-primary cursor-pointer"
>
推送异常通知(建议保持开启)
</label>
<p className="text-[10px] font-mono text-cyber-text-muted leading-relaxed">
登录态失效、采集进程失败、一篇都没抓到时推送。
<span className="text-cyber-neon-cyan">
异常意味着这个任务从此默默采不到任何东西
</span>
—— 关掉的话你不会知道,直到某天发现数据停在几周前。
</p>
</div>
</div>
<div className="flex items-start gap-2 border-t border-cyber-border-subtle pt-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 leading-relaxed">
发现新作品时推送。监控多个博主时可能每轮都有,容易刷屏,所以默认关闭。
</p>
</div>
</div>
<p className="border-t border-cyber-border-subtle pt-2 text-[10px] font-mono text-cyber-text-muted">
两者都是一轮只发一条汇总。需先在右上角「系统设置」里配置企业微信 Webhook 地址。
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isEdit ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}