feat(tradein): /scrapers/avito — 6-я секция Расписание (in-app scheduler UI) (#483)
This commit is contained in:
parent
0202720096
commit
fd3cb4e6ed
2 changed files with 318 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import "@/components/trade-in/trade-in.css";
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSchedules() {
|
||||||
|
return useQuery<ScheduleConfig[]>({
|
||||||
|
queryKey: ["scrape-schedules"],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ScheduleConfig[]>("/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<ScheduleConfig>(`/api/v1/admin/scrape/schedules/${source}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scrape-schedules"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Result panel ───────────────────────────────────────────────────────────
|
// ── Result panel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ResultPanelProps<TData> {
|
interface ResultPanelProps<TData> {
|
||||||
|
|
@ -277,6 +332,33 @@ export default function AvitoScraperPage() {
|
||||||
const sweepRuns = useCitySweepRuns();
|
const sweepRuns = useCitySweepRuns();
|
||||||
const cancelMut = useCancelCitySweep();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Topbar active="avito" />
|
<Topbar active="avito" />
|
||||||
|
|
@ -675,6 +757,194 @@ export default function AvitoScraperPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 6. Расписание автозапуска */}
|
||||||
|
<section className="scraper-section scraper-section--schedule">
|
||||||
|
<h2>6. Расписание автозапуска (in-app scheduler)</h2>
|
||||||
|
<p className="scraper-hint">
|
||||||
|
Backend периодически (каждую минуту) проверяет это расписание и запускает
|
||||||
|
city sweep автоматически в случайное время в указанном окне. Без SSH, без
|
||||||
|
crontab — всё в БД и UI.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{schedulesQ.isPending && <p className="scraper-hint">Загрузка…</p>}
|
||||||
|
{schedulesQ.error && (
|
||||||
|
<p className="scraper-result scraper-result--error">
|
||||||
|
Ошибка загрузки: {schedulesQ.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedulesQ.data && (
|
||||||
|
<>
|
||||||
|
{/* Status preview */}
|
||||||
|
{(() => {
|
||||||
|
const sweep = schedulesQ.data.find(
|
||||||
|
(s) => s.source === "avito_city_sweep",
|
||||||
|
);
|
||||||
|
if (!sweep) return null;
|
||||||
|
return (
|
||||||
|
<div className="schedule-status">
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">Status:</span>
|
||||||
|
<span
|
||||||
|
className={`run-badge run-badge--${sweep.enabled ? "running" : "cancelled"}`}
|
||||||
|
>
|
||||||
|
{sweep.enabled ? "включено" : "отключено"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">Окно (МСК):</span>
|
||||||
|
<span>
|
||||||
|
{formatMskRange(
|
||||||
|
sweep.window_start_hour,
|
||||||
|
sweep.window_end_hour,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="run-muted">
|
||||||
|
({String(sweep.window_start_hour).padStart(2, "0")}:00–
|
||||||
|
{String(sweep.window_end_hour).padStart(2, "0")}:00 UTC)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">
|
||||||
|
Следующий запуск:
|
||||||
|
</span>
|
||||||
|
<span>{formatTime(sweep.next_run_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-status__row">
|
||||||
|
<span className="schedule-status__label">
|
||||||
|
Последний запуск:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{sweep.last_run_at ? formatTime(sweep.last_run_at) : "—"}
|
||||||
|
{sweep.last_run_id && ` (run #${sweep.last_run_id})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Edit form */}
|
||||||
|
<form
|
||||||
|
className="scraper-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={schEnabled}
|
||||||
|
onChange={(e) => setSchEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Включено
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Окно начала (МСК)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
value={schMskStart}
|
||||||
|
onChange={(e) => setSchMskStart(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Окно окончания (МСК)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
value={schMskEnd}
|
||||||
|
onChange={(e) => setSchMskEnd(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Pages/anchor
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={schPages}
|
||||||
|
onChange={(e) => setSchPages(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Detail top-N
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
value={schTopN}
|
||||||
|
onChange={(e) => setSchTopN(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Delay (сек)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.5}
|
||||||
|
min={3}
|
||||||
|
max={15}
|
||||||
|
value={schDelay}
|
||||||
|
onChange={(e) => setSchDelay(parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Радиус (м)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={500}
|
||||||
|
max={5000}
|
||||||
|
step={100}
|
||||||
|
value={schRadius}
|
||||||
|
onChange={(e) => setSchRadius(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={schEnrich}
|
||||||
|
onChange={(e) => setSchEnrich(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Enrich houses
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={updateScheduleMut.isPending}>
|
||||||
|
{updateScheduleMut.isPending
|
||||||
|
? "Сохраняем…"
|
||||||
|
: "Сохранить расписание"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{updateScheduleMut.error && (
|
||||||
|
<div className="scraper-result scraper-result--error">
|
||||||
|
Ошибка: {updateScheduleMut.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateScheduleMut.isSuccess && updateScheduleMut.data && (
|
||||||
|
<div className="scraper-result">
|
||||||
|
✓ Расписание обновлено. Next run:{" "}
|
||||||
|
{formatTime(updateScheduleMut.data.next_run_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -717,3 +987,17 @@ function formatTime(iso: string | null): string {
|
||||||
return iso;
|
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 МСК`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1478,3 +1478,36 @@
|
||||||
background: var(--border-strong, #d1d5db);
|
background: var(--border-strong, #d1d5db);
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue