From fd3cb4e6ed9af27ca150e42be7b01a7160d56102 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 14:55:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20/scrapers/avito=20=E2=80=94=20?= =?UTF-8?q?6-=D1=8F=20=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D1=8F=20=D0=A0=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20(in-app=20sc?= =?UTF-8?q?heduler=20UI)=20(#483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/app/scrapers/avito/page.tsx | 286 +++++++++++++++++- .../src/components/trade-in/trade-in.css | 33 ++ 2 files changed, 318 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/frontend/src/app/scrapers/avito/page.tsx b/tradein-mvp/frontend/src/app/scrapers/avito/page.tsx index 7c961ecf..0b961ab5 100644 --- a/tradein-mvp/frontend/src/app/scrapers/avito/page.tsx +++ b/tradein-mvp/frontend/src/app/scrapers/avito/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import "@/components/trade-in/trade-in.css"; @@ -203,6 +203,61 @@ function useCancelCitySweep() { }); } +// ── Schedule types + hooks ───────────────────────────────────────────────── + +interface ScheduleConfig { + id: number; + source: string; + enabled: boolean; + window_start_hour: number; + window_end_hour: number; + default_params: { + pages_per_anchor?: number; + detail_top_n?: number; + request_delay_sec?: number; + enrich_houses?: boolean; + radius_m?: number; + }; + last_run_id: number | null; + last_run_at: string | null; + next_run_at: string | null; + updated_at: string | null; +} + +interface ScheduleConfigUpdate { + enabled: boolean; + window_start_hour: number; + window_end_hour: number; + default_params: Record; +} + +function useSchedules() { + return useQuery({ + queryKey: ["scrape-schedules"], + queryFn: () => + apiFetch("/api/v1/admin/scrape/schedules"), + refetchInterval: 30_000, + }); +} + +function useUpdateSchedule() { + const qc = useQueryClient(); + return useMutation< + ScheduleConfig, + Error, + { source: string; payload: ScheduleConfigUpdate } + >({ + mutationFn: ({ source, payload }) => + apiFetch(`/api/v1/admin/scrape/schedules/${source}`, { + method: "PUT", + body: JSON.stringify(payload), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["scrape-schedules"] }); + }, + }); +} + // ── Result panel ─────────────────────────────────────────────────────────── interface ResultPanelProps { @@ -277,6 +332,33 @@ export default function AvitoScraperPage() { const sweepRuns = useCitySweepRuns(); const cancelMut = useCancelCitySweep(); + // Scheduler state + const schedulesQ = useSchedules(); + const updateScheduleMut = useUpdateSchedule(); + + const [schEnabled, setSchEnabled] = useState(true); + const [schMskStart, setSchMskStart] = useState(5); + const [schMskEnd, setSchMskEnd] = useState(8); + const [schPages, setSchPages] = useState(3); + const [schTopN, setSchTopN] = useState(20); + const [schDelay, setSchDelay] = useState(7); + const [schRadius, setSchRadius] = useState(1500); + const [schEnrich, setSchEnrich] = useState(true); + + useEffect(() => { + const sweep = schedulesQ.data?.find((s) => s.source === "avito_city_sweep"); + if (!sweep) return; + setSchEnabled(sweep.enabled); + setSchMskStart(utcToMsk(sweep.window_start_hour)); + setSchMskEnd(utcToMsk(sweep.window_end_hour)); + const p = sweep.default_params || {}; + if (typeof p.pages_per_anchor === "number") setSchPages(p.pages_per_anchor); + if (typeof p.detail_top_n === "number") setSchTopN(p.detail_top_n); + if (typeof p.request_delay_sec === "number") setSchDelay(p.request_delay_sec); + if (typeof p.radius_m === "number") setSchRadius(p.radius_m); + if (typeof p.enrich_houses === "boolean") setSchEnrich(p.enrich_houses); + }, [schedulesQ.data]); + return ( <> @@ -675,6 +757,194 @@ export default function AvitoScraperPage() { )} + + {/* 6. Расписание автозапуска */} +
+

6. Расписание автозапуска (in-app scheduler)

+

+ Backend периодически (каждую минуту) проверяет это расписание и запускает + city sweep автоматически в случайное время в указанном окне. Без SSH, без + crontab — всё в БД и UI. +

+ + {schedulesQ.isPending &&

Загрузка…

} + {schedulesQ.error && ( +

+ Ошибка загрузки: {schedulesQ.error.message} +

+ )} + + {schedulesQ.data && ( + <> + {/* Status preview */} + {(() => { + const sweep = schedulesQ.data.find( + (s) => s.source === "avito_city_sweep", + ); + if (!sweep) return null; + return ( +
+
+ Status: + + {sweep.enabled ? "включено" : "отключено"} + +
+
+ Окно (МСК): + + {formatMskRange( + sweep.window_start_hour, + sweep.window_end_hour, + )} + + + ({String(sweep.window_start_hour).padStart(2, "0")}:00– + {String(sweep.window_end_hour).padStart(2, "0")}:00 UTC) + +
+
+ + Следующий запуск: + + {formatTime(sweep.next_run_at)} +
+
+ + Последний запуск: + + + {sweep.last_run_at ? formatTime(sweep.last_run_at) : "—"} + {sweep.last_run_id && ` (run #${sweep.last_run_id})`} + +
+
+ ); + })()} + + {/* Edit form */} +
{ + e.preventDefault(); + updateScheduleMut.mutate({ + source: "avito_city_sweep", + payload: { + enabled: schEnabled, + window_start_hour: mskToUtc(schMskStart), + window_end_hour: mskToUtc(schMskEnd), + default_params: { + pages_per_anchor: schPages, + detail_top_n: schTopN, + request_delay_sec: schDelay, + enrich_houses: schEnrich, + radius_m: schRadius, + }, + }, + }); + }} + > + + + + + + + + + +
+ + {updateScheduleMut.error && ( +
+ Ошибка: {updateScheduleMut.error.message} +
+ )} + {updateScheduleMut.isSuccess && updateScheduleMut.data && ( +
+ ✓ Расписание обновлено. Next run:{" "} + {formatTime(updateScheduleMut.data.next_run_at)} +
+ )} + + )} +
); @@ -717,3 +987,17 @@ function formatTime(iso: string | null): string { return iso; } } + +function utcToMsk(hour: number): number { + return (hour + 3) % 24; +} + +function mskToUtc(hour: number): number { + return (hour - 3 + 24) % 24; +} + +function formatMskRange(utcStart: number, utcEnd: number): string { + const ms = utcToMsk(utcStart); + const me = utcToMsk(utcEnd); + return `${String(ms).padStart(2, "0")}:00–${String(me).padStart(2, "0")}:00 МСК`; +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index d905993e..041f0390 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -1478,3 +1478,36 @@ background: var(--border-strong, #d1d5db); cursor: not-allowed; } + +/* ── Stage 4e: scheduler section ── */ + +.scraper-section--schedule { + border-color: var(--success, #0a7a3a); + background: var(--success-soft, #dcfce7); +} + +.scraper-section--schedule h2 { + color: var(--success, #0a7a3a); +} + +.schedule-status { + margin-bottom: 16px; + padding: 12px; + background: #ffffff; + border-radius: 6px; + border: 1px solid var(--border-card, #e6e8ec); +} + +.schedule-status__row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + padding: 4px 0; +} + +.schedule-status__label { + min-width: 140px; + color: var(--fg-secondary, #5b6066); + font-weight: 500; +}