feat: 监控面板 / 登录鉴权 / 多平台切换 / MySQL
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

在上游 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:
2026-10-07 09:58:40 +08:00
parent 5d547f4586
commit 4e60524f37
88 changed files with 13224 additions and 436 deletions
+286
View File
@@ -0,0 +1,286 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { monitorApi, settingsApi } from '@/lib/api'
import { usePlatformStore } from '@/store/platformStore'
import type { TaskCreatePayload } from '@/types/monitor'
/**
* The platform every monitor hook is scoped to.
*
* Read from the store rather than threaded through props, so switching the
* global platform switcher re-scopes every view at once and no component can
* forget to pass it along.
*/
function usePlatformParam(): string {
return usePlatformStore((state) => state.platform)
}
/** Polling cadence for the dashboard. Runs take minutes, so a few seconds of
* lag is fine and keeps the number of requests low. */
const POLL_MS = 5000
export function useMonitorOverview() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorOverview', platform],
queryFn: async () => (await monitorApi.getOverview(platform)).data,
refetchInterval: POLL_MS,
})
}
export function useMonitorTasks() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorTasks', platform],
queryFn: async () => (await monitorApi.getTasks(platform)).data.tasks,
refetchInterval: POLL_MS,
})
}
export function useCreateTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: TaskCreatePayload) => monitorApi.createTask(payload),
onSuccess: () => {
toast.success('监控任务已创建')
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
queryClient.invalidateQueries({ queryKey: ['monitorOverview'] })
},
onError: (error: Error) => toast.error(`创建失败:${error.message}`),
})
}
export function useUpdateTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<TaskCreatePayload> }) =>
monitorApi.updateTask(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
},
onError: (error: Error) => toast.error(`更新失败:${error.message}`),
})
}
export function useDeleteTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => monitorApi.deleteTask(id),
onSuccess: () => {
toast.success('监控任务已删除')
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
queryClient.invalidateQueries({ queryKey: ['monitorOverview'] })
},
onError: (error: Error) => toast.error(`删除失败:${error.message}`),
})
}
export function useRunTaskNow() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => monitorApi.runTaskNow(id),
onSuccess: () => {
// The crawl itself runs in the background; the run list picks it up on poll.
toast.success('已排队执行,可在下方运行历史查看进度')
queryClient.invalidateQueries({ queryKey: ['monitorRuns'] })
},
onError: (error: Error) => toast.error(`启动失败:${error.message}`),
})
}
export function useTaskRuns(taskId: number | null) {
return useQuery({
queryKey: ['monitorRuns', taskId],
queryFn: async () => (await monitorApi.getRuns(taskId as number)).data.runs,
enabled: taskId !== null,
refetchInterval: POLL_MS,
})
}
export function useMonitorNotes(taskId: number | null, onlyNew: boolean) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorNotes', platform, taskId, onlyNew],
queryFn: async () =>
(await monitorApi.getNotes(taskId ?? undefined, onlyNew, 200, platform)).data.notes,
refetchInterval: POLL_MS,
})
}
export function useNoteSeries(noteId: string | null, taskId: number | null) {
return useQuery({
queryKey: ['monitorNoteSeries', noteId, taskId],
queryFn: async () => (await monitorApi.getNoteSeries(noteId as string, taskId ?? undefined)).data.series,
enabled: noteId !== null,
staleTime: 30_000,
})
}
export function useMonitorComments(taskId: number | null, noteId?: string | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorComments', platform, taskId, noteId ?? null],
queryFn: async () =>
(await monitorApi.getComments(taskId ?? undefined, noteId ?? undefined, 200, platform)).data
.comments,
refetchInterval: POLL_MS,
})
}
export function useMonitorCommentsGrouped(taskId: number | null, noteId?: string | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorCommentsGrouped', platform, taskId, noteId ?? null],
queryFn: async () =>
(await monitorApi.getCommentsGrouped(taskId ?? undefined, noteId ?? undefined, 200, platform))
.data.groups,
refetchInterval: POLL_MS,
})
}
export function useCommentNoteOptions(taskId: number | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorCommentNotes', platform, taskId],
queryFn: async () =>
(await monitorApi.getCommentNotes(taskId ?? undefined, platform)).data.notes,
refetchInterval: POLL_MS,
})
}
export function useMonitorEvents(taskId: number | null) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorEvents', platform, taskId],
queryFn: async () =>
(await monitorApi.getEvents(taskId ?? undefined, 200, platform)).data.events,
refetchInterval: POLL_MS,
})
}
export function useMarkEventsRead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (taskId?: number) => monitorApi.markEventsRead(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitorEvents'] })
queryClient.invalidateQueries({ queryKey: ['monitorTasks'] })
queryClient.invalidateQueries({ queryKey: ['monitorOverview'] })
},
})
}
export function useCookieStatus() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorCookie', platform],
queryFn: async () => (await monitorApi.getCookieStatus(platform)).data,
refetchInterval: 15000,
})
}
export function useSetCookie() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: (cookie: string) => monitorApi.setCookie(cookie, platform),
onSuccess: () => {
toast.success('Cookie 已保存')
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
},
onError: (error: Error) => toast.error(`保存失败:${error.message}`),
})
}
export function useClearCookie() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: () => monitorApi.clearCookie(platform),
onSuccess: () => {
toast.success('Cookie 已清除')
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
},
})
}
// --- Settings -------------------------------------------------------------
export function useSettings() {
const platform = usePlatformParam()
return useQuery({
queryKey: ['settings', platform],
queryFn: async () => (await settingsApi.getAll(platform)).data,
staleTime: 30_000,
})
}
export function useUpdateSettings() {
const queryClient = useQueryClient()
const platform = usePlatformParam()
return useMutation({
mutationFn: (values: Record<string, unknown>) => settingsApi.update(values, platform),
onSuccess: (response) => {
toast.success(`已保存 ${response.data.changed.length} 项设置`)
queryClient.invalidateQueries({ queryKey: ['settings'] })
// Cookie / webhook panels read their own keys.
queryClient.invalidateQueries({ queryKey: ['monitorCookie'] })
queryClient.invalidateQueries({ queryKey: ['monitorWebhook'] })
},
onError: (error: Error) => toast.error(`保存失败:${error.message}`),
})
}
// --- Report ---------------------------------------------------------------
export function useReport(taskIds: number[], startDate: string, endDate: string) {
const platform = usePlatformParam()
return useQuery({
queryKey: ['monitorReport', platform, taskIds, startDate, endDate],
queryFn: async () =>
(await monitorApi.getReport(taskIds, startDate, endDate, platform)).data,
// Reports are read-only aggregates; no need to poll them every few seconds.
staleTime: 30_000,
})
}
// --- WeCom webhook --------------------------------------------------------
export function useWebhookStatus() {
return useQuery({
queryKey: ['monitorWebhook'],
queryFn: async () => (await monitorApi.getWebhook()).data,
refetchInterval: 30_000,
})
}
export function useSetWebhook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (url: string) => monitorApi.setWebhook(url),
onSuccess: () => {
toast.success('Webhook 已保存')
queryClient.invalidateQueries({ queryKey: ['monitorWebhook'] })
},
onError: (error: Error) => toast.error(`保存失败:${error.message}`),
})
}
export function useClearWebhook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => monitorApi.clearWebhook(),
onSuccess: () => {
toast.success('Webhook 已删除')
queryClient.invalidateQueries({ queryKey: ['monitorWebhook'] })
},
})
}
export function useTestWebhook() {
return useMutation({
mutationFn: (url?: string) => monitorApi.testWebhook(url),
onSuccess: () => toast.success('测试消息已发送,请查看企业微信群'),
onError: (error: Error) => toast.error(`发送失败:${error.message}`),
})
}