(
+ `/api/v1/admin/scrape/avito-imv?${qs.toString()}`,
+ { method: "POST" },
+ );
+ },
+ });
+
+ return (
+ <>
+
+
1. Search around
+
+
+
+
+
+
2. Houses Catalog enrichment
+
+
+
+
+
+
3. Detail page enrichment
+
+
+
+
+
+
4. IMV evaluation (debug)
+
+
+
+ >
+ );
+}
+
+// ── Cian Cookies section ───────────────────────────────────────────────────
+
+function CianCookiesSection() {
+ const [rawInput, setRawInput] = useState("");
+ const [showInstructions, setShowInstructions] = useState(false);
+
+ const preview = parsePreview(rawInput);
+ const previewKeys = preview.cookies ? Object.keys(preview.cookies) : [];
+
+ const uploadMutation = useMutation({
+ mutationFn: () => {
+ if (!preview.cookies) {
+ throw new Error("Invalid JSON — fix the input first");
+ }
+ return apiFetch(
+ "/api/v1/admin/scrape/cian/upload-cookies",
+ {
+ method: "POST",
+ body: JSON.stringify(preview.cookies),
+ },
+ );
+ },
+ onSuccess: () => setRawInput(""),
+ });
+
+ const testAuthQuery = useQuery({
+ queryKey: ["cian-test-auth"],
+ queryFn: () =>
+ apiFetch("/api/v1/admin/scrape/cian/test-auth"),
+ enabled: false,
+ retry: false,
+ });
+
+ const canUpload =
+ !!preview.cookies && previewKeys.length > 0 && !uploadMutation.isPending;
+
+ return (
+
+ Cookies (DMIR_AUTH)
+
+ Обновление сессии Cian для скрапера оценщика без перезапуска backend.
+
+
+ setShowInstructions((v) => !v)}
+ style={{
+ background: "none",
+ border: "none",
+ padding: "4px 0",
+ cursor: "pointer",
+ fontSize: 14,
+ fontWeight: 600,
+ color: "var(--accent, #1d4ed8)",
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ marginBottom: showInstructions ? 0 : 12,
+ }}
+ >
+ {showInstructions ? "▼" : "▶"}
+ Как получить cookies (инструкция)
+
+ {showInstructions && (
+
+
+ Открой{" "}
+
+ cian.ru
+ {" "}
+ и войди под нужным аккаунтом.
+
+
+ Установи расширение Cookie-Editor (Chrome/Firefox)
+ или DevTools → Application → Cookies → www.cian.ru.
+
+
+ Cookie-Editor: Export → Export as JSON → скопируй массив.
+
+ Вставь JSON в поле ниже — формат определится автоматически.
+ Убедись что preview показывает нужные ключи, нажми Upload.
+
+ )}
+
+
+
+
+
+ {rawInput.trim() && (
+
+ {preview.error ? (
+ JSON error: {preview.error}
+ ) : (
+
+ Detected {previewKeys.length} cookies :{" "}
+ {previewKeys.slice(0, 8).join(", ")}
+ {previewKeys.length > 8 ? ` … +${previewKeys.length - 8} more` : ""}
+
+ )}
+
+ )}
+
+
uploadMutation.mutate()} disabled={!canUpload}>
+ {uploadMutation.isPending ? "Uploading…" : "Upload Cookies"}
+
+
+
{
+ void testAuthQuery.refetch();
+ }}
+ disabled={testAuthQuery.isFetching}
+ style={{
+ background: "var(--bg-card-alt, #f3f4f6)",
+ color: "var(--fg-primary, #1f2937)",
+ border: "1px solid var(--border-strong, #d1d5db)",
+ }}
+ >
+ {testAuthQuery.isFetching ? "Checking…" : "Test Current Session"}
+
+
+
+ {uploadMutation.isSuccess && (
+
+ {uploadMutation.data.ok ? (
+ <>
+ Cookies uploaded. userId={" "}
+ {uploadMutation.data.userId ?? "—"} · cookieCount={" "}
+ {uploadMutation.data.cookieCount ?? previewKeys.length}
+ >
+ ) : (
+ <>
+ Upload failed: {" "}
+ {uploadMutation.data.detail ?? "Unknown error"}
+ >
+ )}
+
+ )}
+ {uploadMutation.isError && (
+
+ Error: {String(uploadMutation.error)}
+
+ )}
+ {(testAuthQuery.isSuccess || testAuthQuery.isError) && (
+
+ {testAuthQuery.isError ? (
+ <>
+ Test auth error: {String(testAuthQuery.error)}
+ >
+ ) : testAuthQuery.data.authenticated ? (
+ <>
+ Authenticated. userId={" "}
+ {testAuthQuery.data.userId ?? "—"}
+ >
+ ) : (
+ <>
+ Not authenticated. {" "}
+ {testAuthQuery.data.reason
+ ? `Reason: ${testAuthQuery.data.reason}`
+ : "Session expired or cookies invalid."}
+ >
+ )}
+
+ )}
+
+ );
+}
+
+// ── Cian advanced triggers ─────────────────────────────────────────────────
+
+function CianAdvancedTriggers() {
+ const [lat, setLat] = useState("56.8400");
+ const [lon, setLon] = useState("60.6050");
+ const [radius, setRadius] = useState("1500");
+ const [multiRoom, setMultiRoom] = useState(false);
+ const aroundMut = useMutation<
+ ScrapeAroundResp,
+ Error,
+ { lat: number; lon: number; radius_m: number; multi_room_cian: boolean }
+ >({
+ mutationFn: (input) =>
+ apiFetch("/api/v1/admin/scrape", {
+ method: "POST",
+ body: JSON.stringify({ ...input, sources: ["cian"] }),
+ }),
+ });
+
+ const [detailUrl, setDetailUrl] = useState("");
+ const [detailListingId, setDetailListingId] = useState("");
+ const detailMut = useMutation<
+ CianDetailTriggerResp,
+ Error,
+ { offer_url: string; listing_id: number | undefined }
+ >({
+ mutationFn: ({ offer_url, listing_id }) => {
+ const qs = new URLSearchParams({ offer_url });
+ if (listing_id !== undefined) qs.set("listing_id", String(listing_id));
+ return apiFetch(
+ `/api/v1/admin/scrape/cian-detail?${qs.toString()}`,
+ { method: "POST" },
+ );
+ },
+ });
+
+ const [nbZhkUrl, setNbZhkUrl] = useState("");
+ const [nbHouseId, setNbHouseId] = useState("");
+ const nbMut = useMutation<
+ CianNewbuildingTriggerResp,
+ Error,
+ { zhk_url: string; house_id: number | undefined }
+ >({
+ mutationFn: ({ zhk_url, house_id }) => {
+ const qs = new URLSearchParams({ zhk_url });
+ if (house_id !== undefined) qs.set("house_id", String(house_id));
+ return apiFetch(
+ `/api/v1/admin/scrape/cian-newbuilding?${qs.toString()}`,
+ { method: "POST" },
+ );
+ },
+ });
+
+ const authQ = useQuery({
+ queryKey: ["cian-test-auth"],
+ queryFn: () => apiFetch("/api/v1/admin/scrape/cian/test-auth"),
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ });
+
+ return (
+ <>
+
+
+ Cian Valuation требует загруженных cookies. Управление — в разделе «Cookies» выше.
+
+
+ Статус сессии:{" "}
+ {authQ.isPending && "проверяем…"}
+ {authQ.isError && ошибка проверки }
+ {authQ.data && (
+
+ {authQ.data.authenticated
+ ? `авторизован (userId: ${authQ.data.userId ?? "—"})`
+ : `не авторизован — ${authQ.data.reason ?? "неизвестная причина"}`}
+
+ )}
+
+
+
+
+
1. Search around
+
+
+
+
+
+
2. Detail page enrichment
+
+
+
+
+
+
3. Новостройки (ЖК)
+
+
+
+
+
+
4. Valuation Calculator
+
+ Вызывается автоматически из /estimate flow (Stage 9). Admin trigger будет добавлен позже.
+
+
+ >
+ );
+}
+
+// ── Yandex advanced triggers ───────────────────────────────────────────────
+
+function YandexAdvancedTriggers() {
+ const [lat, setLat] = useState("56.8400");
+ const [lon, setLon] = useState("60.6050");
+ const [radius, setRadius] = useState("1500");
+ const [multiRoom, setMultiRoom] = useState(false);
+ const [deep, setDeep] = useState(false);
+ const aroundMut = useMutation<
+ ScrapeAroundResp,
+ Error,
+ {
+ lat: number;
+ lon: number;
+ radius_m: number;
+ sources: string[];
+ multi_room_yandex?: boolean;
+ deep_yandex?: boolean;
+ }
+ >({
+ mutationFn: (input) =>
+ apiFetch("/api/v1/admin/scrape", {
+ method: "POST",
+ body: JSON.stringify(input),
+ }),
+ });
+
+ const [detailUrl, setDetailUrl] = useState(
+ "https://realty.yandex.ru/offer/7567094292504417257/",
+ );
+ const detailMut = useMutation<
+ YandexDetailTriggerResp,
+ Error,
+ { offer_url: string }
+ >({
+ mutationFn: ({ offer_url }) =>
+ apiFetch(
+ `/api/v1/admin/scrape/yandex-detail?offer_url=${encodeURIComponent(offer_url)}`,
+ { method: "POST" },
+ ),
+ });
+
+ const [nbSlug, setNbSlug] = useState("tatlin");
+ const [nbId, setNbId] = useState("1592987");
+ const [nbCity, setNbCity] = useState("ekaterinburg");
+ const nbMut = useMutation<
+ YandexNewbuildingTriggerResp,
+ Error,
+ { slug: string; id: string; city: string }
+ >({
+ mutationFn: ({ slug, id, city }) => {
+ const qs = new URLSearchParams({ slug, id, city });
+ return apiFetch(
+ `/api/v1/admin/scrape/yandex-newbuilding?${qs.toString()}`,
+ { method: "POST" },
+ );
+ },
+ });
+
+ const [valAddress, setValAddress] = useState(
+ "Свердловская область, Екатеринбург, улица Учителей, 18",
+ );
+ const [valCategory, setValCategory] = useState("APARTMENT");
+ const [valType, setValType] = useState("SELL");
+ const [valPage, setValPage] = useState("1");
+ const valMut = useMutation<
+ YandexValuationTriggerResp,
+ Error,
+ { address: string; offer_category: string; offer_type: string; page: number }
+ >({
+ mutationFn: ({ address, offer_category, offer_type, page }) => {
+ const qs = new URLSearchParams({ address, offer_category, offer_type, page: String(page) });
+ return apiFetch(
+ `/api/v1/admin/scrape/yandex-valuation?${qs.toString()}`,
+ { method: "POST" },
+ );
+ },
+ });
+
+ return (
+ <>
+
+
1. Search around
+
+
+
+
+
+
2. Detail page enrichment
+
+
+
+
+
+
3. ЖК (Newbuilding) landing
+
+
+
+
+
+
4. Valuation (anonymous history)
+
+
+
+ >
+ );
+}
+
+// ── Per-source delay editor ────────────────────────────────────────────────
+
+interface PerSourceDelayEditorProps {
+ source: ScraperSource;
+ delayMin: number;
+ delayMax: number;
+}
+
+function PerSourceDelayEditor({ source, delayMin, delayMax }: PerSourceDelayEditorProps) {
+ const settingsQ = useScraperSettings();
+ const updateMut = useUpdateScraperSetting();
+
+ const currentSetting = settingsQ.data?.find((s) => s.source === source);
+ const [delayInput, setDelayInput] = useState("5.0");
+ const [initialized, setInitialized] = useState(false);
+
+ if (currentSetting && !initialized) {
+ setDelayInput(String(currentSetting.request_delay_sec));
+ setInitialized(true);
+ }
+
+ return (
+
+ Задержка запросов
+
+ Задержка применяется только к {source} . Изменение вступает в силу немедленно.
+
+
+ {currentSetting && (
+
+
+ Текущая задержка:
+
+ {currentSetting.request_delay_sec} сек
+
+
+
+ )}
+
+
+
+ {updateMut.isError && (
+
+ Ошибка: {updateMut.error.message}
+
+ )}
+ {updateMut.isSuccess && updateMut.data && (
+
+ Сохранено.{" "}
+ {Number(updateMut.data.request_delay_sec).toFixed(1)} сек
+
+ )}
+
+ );
+}
+
+// ── Provider configs ───────────────────────────────────────────────────────
+
+interface ProviderConfig {
+ source: ScraperSource;
+ label: string;
+ scheduleSource: ScheduleSourceKey;
+ paramConfig: SweepParamConfig;
+ delayMin: number;
+ delayMax: number;
+}
+
+const PROVIDER_CONFIGS: ProviderConfig[] = [
+ {
+ source: "avito",
+ label: "Avito",
+ scheduleSource: "avito_city_sweep",
+ paramConfig: {
+ hasDetailParams: true,
+ hasEnrichAddress: false,
+ defaults: {
+ pages_per_anchor: 3,
+ detail_top_n: 20,
+ request_delay_sec: 7,
+ radius_m: 1500,
+ enrich_houses: true,
+ },
+ },
+ delayMin: 3,
+ delayMax: 30,
+ },
+ {
+ source: "cian",
+ label: "Cian",
+ scheduleSource: "cian_city_sweep",
+ paramConfig: {
+ hasDetailParams: true,
+ hasEnrichAddress: false,
+ defaults: {
+ pages_per_anchor: 3,
+ detail_top_n: 20,
+ request_delay_sec: 5,
+ radius_m: 1500,
+ enrich_houses: true,
+ },
+ },
+ delayMin: 3,
+ delayMax: 30,
+ },
+ {
+ source: "yandex",
+ label: "Yandex",
+ scheduleSource: "yandex_city_sweep",
+ paramConfig: {
+ hasDetailParams: false,
+ hasEnrichAddress: true,
+ defaults: {
+ pages_per_anchor: 3,
+ request_delay_sec: 5,
+ radius_m: 1500,
+ enrich_address: false,
+ },
+ },
+ delayMin: 1,
+ delayMax: 60,
+ },
+];
+
+// ── Provider tab content ───────────────────────────────────────────────────
+
+interface ProviderTabContentProps {
+ config: ProviderConfig;
+}
+
+function ProviderTabContent({ config }: ProviderTabContentProps) {
+ const { source, scheduleSource, paramConfig, delayMin, delayMax } = config;
+
+ return (
+
+
+
+
+
+ {source === "cian" &&
}
+
+
+ {source === "avito" &&
}
+ {source === "cian" &&
}
+ {source === "yandex" &&
}
+
+
+
+ );
+}
+
+// ── Tab types ──────────────────────────────────────────────────────────────
+
+type ProviderTab = "avito" | "cian" | "yandex";
+
+const TAB_LABELS: Record = {
+ avito: "Avito",
+ cian: "Cian",
+ yandex: "Yandex",
+};
+
// ── Page ───────────────────────────────────────────────────────────────────
-export default function ScrapersHubPage() {
+export default function ScrapersUnifiedPage() {
+ const [activeProvider, setActiveProvider] = useState("avito");
+
+ const activeConfig = PROVIDER_CONFIGS.find((c) => c.source === activeProvider)!;
+
return (
<>
- Скрапперы — хаб
+ Скрапперы
- Центр управления всеми скрапперами: глобальные настройки задержки,
- сводная таблица расписаний, ссылки на admin-триггеры провайдеров.
+ Центр управления всеми скрапперами: система, расписания, прокси,
+ история прогонов, триггеры.
-
-
-
+ {/* Section 1: Система */}
+
+
+ {/* Section 2: Глобально */}
+
+
+ {/* Tabs: Avito / Cian / Yandex */}
+
+ {/* Tab bar */}
+
+ {(Object.keys(TAB_LABELS) as ProviderTab[]).map((tab) => (
+ setActiveProvider(tab)}
+ style={{
+ padding: "10px 20px",
+ border: "none",
+ borderBottom:
+ activeProvider === tab
+ ? "2px solid var(--accent, #1d4ed8)"
+ : "2px solid transparent",
+ marginBottom: -2,
+ background: "none",
+ cursor: "pointer",
+ fontSize: "0.9rem",
+ fontWeight: activeProvider === tab ? 600 : 400,
+ color:
+ activeProvider === tab
+ ? "var(--accent, #1d4ed8)"
+ : "var(--fg-secondary, #5b6066)",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {TAB_LABELS[tab]}
+
+ ))}
+
+
+ {/* Tab content */}
+
+
>
);
diff --git a/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx b/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx
new file mode 100644
index 00000000..8666bb3d
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/scrapers/ProviderProxySection.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import {
+ useScraperHealth,
+ useRotateIp,
+} from "@/components/scrapers/ProxyHealthCard";
+
+// ── Per-provider proxy section (inside provider tab) ───────────────────────
+
+interface ProviderProxySectionProps {
+ source: string;
+}
+
+export function ProviderProxySection({ source }: ProviderProxySectionProps) {
+ const healthQ = useScraperHealth();
+ const rotateMut = useRotateIp(source);
+
+ const provider = healthQ.data?.providers.find((p) => p.source === source);
+
+ if (healthQ.isPending) {
+ return (
+
+ );
+ }
+
+ if (healthQ.isError || !provider) {
+ return (
+
+ Прокси
+
+ {healthQ.isError
+ ? `Ошибка: ${healthQ.error.message}`
+ : "Провайдер не найден в health-ответе"}
+
+
+ );
+ }
+
+ const proxyStr =
+ provider.proxy_host && provider.proxy_port
+ ? `${provider.proxy_host}:${provider.proxy_port}`
+ : null;
+
+ return (
+
+ Прокси
+
+
+
+ Прокси:
+
+ {proxyStr ? (
+ {proxyStr}
+ ) : (
+
+ не настроен
+
+ )}
+
+
+
+ Текущий IP:
+
+ {provider.current_ip ? (
+
+ {provider.current_ip}
+
+ ) : (
+ —
+ )}
+
+
+
+ Режим:
+
+ {provider.rotate_supported ? "rotate-поддержка" : "статический"}
+
+
+
+
+ {provider.rotate_supported && (
+
+
rotateMut.mutate()}
+ >
+ {rotateMut.isPending ? "Ротируем…" : "Сменить IP"}
+
+
+ {rotateMut.isSuccess && (
+
+ {rotateMut.data.ok ? (
+ <>
+ Новый IP: {rotateMut.data.new_ip ?? "обновлён"}
+ >
+ ) : (
+
+ {rotateMut.data.reason ?? "ошибка ротации"}
+
+ )}
+
+ )}
+
+ {rotateMut.isError && (
+
+ Ошибка: {rotateMut.error.message}
+
+ )}
+
+ )}
+
+ {!provider.rotate_supported && (
+
+ Ротация IP не поддерживается для этого провайдера.
+
+ )}
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx b/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx
new file mode 100644
index 00000000..a12479d3
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { apiFetch } from "@/lib/api";
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface ProviderHealth {
+ source: string;
+ proxy_host: string | null;
+ proxy_port: number | null;
+ rotate_supported: boolean;
+ current_ip: string | null;
+}
+
+export interface BrowserHealth {
+ reachable: boolean;
+ browsers: Record;
+}
+
+export interface ScraperHealthResp {
+ fetch_mode: string;
+ browser: BrowserHealth;
+ providers: ProviderHealth[];
+}
+
+interface RotateIpResp {
+ ok: boolean;
+ new_ip: string | null;
+ reason: string | null;
+}
+
+// ── Hooks ──────────────────────────────────────────────────────────────────
+
+export function useScraperHealth() {
+ return useQuery({
+ queryKey: ["scraper-health"],
+ queryFn: () => apiFetch("/api/v1/admin/scraper/health"),
+ refetchInterval: 30_000,
+ staleTime: 25_000,
+ retry: 1,
+ });
+}
+
+export function useRotateIp(source: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () =>
+ apiFetch(
+ `/api/v1/admin/scraper/${encodeURIComponent(source)}/rotate-ip`,
+ { method: "POST" },
+ ),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["scraper-health"] });
+ },
+ });
+}
+
+// ── SystemSection ──────────────────────────────────────────────────────────
+
+function FetchModeBadge({ mode }: { mode: string }) {
+ const isBrowser = mode === "browser";
+ return (
+
+ {mode}
+
+ );
+}
+
+function BrowserBadge({ reachable }: { reachable: boolean }) {
+ return (
+
+ {reachable ? "доступен" : "недоступен"}
+
+ );
+}
+
+interface ProviderRowProps {
+ provider: ProviderHealth;
+}
+
+function ProviderProxyRow({ provider }: ProviderRowProps) {
+ const rotateMut = useRotateIp(provider.source);
+ const proxyStr =
+ provider.proxy_host && provider.proxy_port
+ ? `${provider.proxy_host}:${provider.proxy_port}`
+ : "—";
+
+ return (
+
+
+ {provider.source}
+
+
+ {proxyStr}
+
+
+
+ {provider.current_ip ?? "—"}
+
+
+
+
+ {provider.rotate_supported ? "rotate" : "статический"}
+
+
+
+ {provider.rotate_supported ? (
+ rotateMut.mutate()}
+ style={{ fontSize: "0.8rem", padding: "3px 10px" }}
+ >
+ {rotateMut.isPending ? "…" : "Сменить IP"}
+
+ ) : (
+
+ —
+
+ )}
+ {rotateMut.isSuccess && rotateMut.data.ok && (
+
+ {rotateMut.data.new_ip ?? "обновлён"}
+
+ )}
+ {rotateMut.isSuccess && !rotateMut.data.ok && (
+
+ {rotateMut.data.reason ?? "ошибка"}
+
+ )}
+ {rotateMut.isError && (
+
+ {rotateMut.error.message}
+
+ )}
+
+
+ );
+}
+
+export function SystemHealthSection() {
+ const healthQ = useScraperHealth();
+
+ return (
+
+ Система
+
+ Статус fetch-режима, browser-сервиса и прокси провайдеров.
+ Автообновление каждые 30 сек.
+
+
+ {healthQ.isPending && (
+ Загрузка состояния системы…
+ )}
+ {healthQ.isError && (
+
+ Ошибка загрузки health: {healthQ.error.message}
+ {" — "}
+
+ API /scraper/health ещё не задеплоен (будет после merge backend PR)
+
+
+ )}
+
+ {healthQ.data && (
+ <>
+ {/* Fetch mode + browser status row */}
+
+
+
+ Режим:
+
+
+
+
+
+ Browser-сервис:
+
+
+
+ {Object.entries(healthQ.data.browser.browsers).map(
+ ([name, ok]) => (
+
+
+ {name}:
+
+
+
+ ),
+ )}
+
+
+ {/* Per-provider proxy table */}
+ {healthQ.data.providers.length > 0 && (
+
+
+
+ Провайдер
+ Прокси (host:port)
+ Текущий IP
+ Режим
+ Действие
+
+
+
+ {healthQ.data.providers.map((p) => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx
new file mode 100644
index 00000000..503265ff
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import React, { useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { apiFetch } from "@/lib/api";
+import {
+ formatTime,
+ translateStatus,
+ useCancelCitySweep,
+ type ScraperSource,
+} from "@/app/scrapers/_components/ScraperPage";
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface ScrapeRunFull {
+ run_id: number;
+ source: string;
+ run_type: string | null;
+ status: string;
+ params: Record | null;
+ counters: Record | null;
+ total_seen: number | null;
+ new_count: number | null;
+ started_at: string | null;
+ finished_at: string | null;
+ heartbeat_at: string | null;
+ error_text: string | null;
+}
+
+interface RunsListResp {
+ total: number;
+ rows: ScrapeRunFull[];
+}
+
+// ── Hook ───────────────────────────────────────────────────────────────────
+
+const RUN_STATUS_ALL = ["", "running", "done", "failed", "cancelled", "zombie", "banned"] as const;
+type RunStatusFilter = (typeof RUN_STATUS_ALL)[number];
+
+function useScraperRuns(
+ source: ScraperSource,
+ status: RunStatusFilter,
+ limit = 20,
+) {
+ return useQuery({
+ queryKey: ["scrape-runs", source, status, limit],
+ queryFn: () => {
+ const qs = new URLSearchParams({ source, limit: String(limit) });
+ if (status) qs.set("status", status);
+ return apiFetch(
+ `/api/v1/admin/scrape/runs?${qs.toString()}`,
+ );
+ },
+ refetchInterval: 8_000,
+ staleTime: 0,
+ retry: 1,
+ });
+}
+
+// ── Status badge ───────────────────────────────────────────────────────────
+
+function RunStatusBadge({ status }: { status: string }) {
+ let variant: "running" | "done" | "danger" | "neutral";
+ switch (status) {
+ case "running":
+ variant = "running";
+ break;
+ case "done":
+ variant = "done";
+ break;
+ case "failed":
+ case "banned":
+ case "zombie":
+ variant = "danger";
+ break;
+ default:
+ variant = "neutral";
+ }
+
+ const styles: Record = {
+ running: {
+ background: "var(--accent-soft, #dbeafe)",
+ color: "var(--accent, #1d4ed8)",
+ },
+ done: {
+ background: "var(--success-soft, #dcfce7)",
+ color: "var(--success, #0a7a3a)",
+ },
+ danger: {
+ background: "var(--danger-soft, #fee2e2)",
+ color: "var(--danger, #b3261e)",
+ },
+ neutral: {
+ background: "var(--bg-card-alt, #fafbfc)",
+ color: "var(--fg-secondary, #5b6066)",
+ },
+ };
+
+ return (
+
+ {translateStatus(status)}
+
+ );
+}
+
+// ── Counters cell ──────────────────────────────────────────────────────────
+
+function CountersCell({
+ row,
+}: {
+ row: ScrapeRunFull;
+}) {
+ const c = row.counters;
+ if (!c) return — ;
+
+ const parts: string[] = [];
+ const lots =
+ c.lots_fetched != null ? `${c.lots_fetched} лотов` : null;
+ const newC =
+ row.new_count != null
+ ? `+${row.new_count} new`
+ : c.lots_inserted != null
+ ? `+${c.lots_inserted} new`
+ : null;
+ const errs =
+ c.errors_count != null && Number(c.errors_count) > 0
+ ? `${c.errors_count} err`
+ : null;
+
+ if (lots) parts.push(lots);
+ if (newC) parts.push(newC);
+ if (errs) parts.push(errs);
+
+ return (
+
+ {parts.length > 0 ? parts.join(" · ") : "—"}
+
+ );
+}
+
+// ── RunsTable ──────────────────────────────────────────────────────────────
+
+interface RunsTableProps {
+ source: ScraperSource;
+}
+
+export function RunsTable({ source }: RunsTableProps) {
+ const [statusFilter, setStatusFilter] =
+ useState("");
+ const qc = useQueryClient();
+ const runsQ = useScraperRuns(source, statusFilter);
+ const cancelMut = useCancelCitySweep(source);
+
+ function handleCancel(runId: number) {
+ cancelMut.mutate(runId, {
+ onSuccess: () => {
+ void qc.invalidateQueries({
+ queryKey: ["scrape-runs", source, statusFilter],
+ });
+ },
+ });
+ }
+
+ return (
+
+ История прогонов
+
+ Последние 20 прогонов. Автообновление каждые 8 сек.
+
+
+ {/* Status filter */}
+
+ {RUN_STATUS_ALL.map((s) => (
+ setStatusFilter(s)}
+ style={{
+ padding: "3px 10px",
+ borderRadius: 6,
+ border: "1px solid",
+ fontSize: "0.8rem",
+ cursor: "pointer",
+ background:
+ statusFilter === s
+ ? "var(--accent, #1d4ed8)"
+ : "var(--bg-card, #fff)",
+ color:
+ statusFilter === s
+ ? "#fff"
+ : "var(--fg-secondary, #5b6066)",
+ borderColor:
+ statusFilter === s
+ ? "var(--accent, #1d4ed8)"
+ : "var(--border-card, #e6e8ec)",
+ }}
+ >
+ {s === "" ? "Все" : translateStatus(s)}
+
+ ))}
+
+
+ {runsQ.isPending && Загрузка…
}
+ {runsQ.isError && (
+
+ Ошибка загрузки: {runsQ.error.message}
+ {" — "}
+
+ API /scrape/runs ещё не задеплоен
+
+
+ )}
+
+ {runsQ.data && runsQ.data.rows.length === 0 && (
+ Прогонов не найдено.
+ )}
+
+ {runsQ.data && runsQ.data.rows.length > 0 && (
+ <>
+
+ Итого: {runsQ.data.total}
+
+
+
+
+ #
+ Тип
+ Статус
+ Старт
+ Финиш
+ Лоты / Ошибки
+ Действие
+
+
+
+ {runsQ.data.rows.map((r) => (
+
+ {r.run_id}
+
+
+ {r.run_type ?? "sweep"}
+
+
+
+
+
+
+ {formatTime(r.started_at)}
+
+
+ {formatTime(r.finished_at)}
+
+
+
+ {r.error_text && (
+
+ {r.error_text}
+
+ )}
+
+
+ {r.status === "running" && (
+ handleCancel(r.run_id)}
+ style={{ fontSize: "0.8rem", padding: "3px 10px" }}
+ >
+ Отменить
+
+ )}
+
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/scrapers/ScheduleControl.tsx b/tradein-mvp/frontend/src/components/scrapers/ScheduleControl.tsx
new file mode 100644
index 00000000..ba1aad0d
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/scrapers/ScheduleControl.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ formatTime,
+ useSchedules,
+ useStartCitySweep,
+ useUpdateSchedule,
+ utcToMsk,
+ mskToUtc,
+ type ScheduleSourceKey,
+ type ScraperSource,
+ type SweepParamConfig,
+} from "@/app/scrapers/_components/ScraperPage";
+
+// ── ScheduleControl ────────────────────────────────────────────────────────
+
+interface ScheduleControlProps {
+ source: ScraperSource;
+ scheduleSource: ScheduleSourceKey;
+ paramConfig: SweepParamConfig;
+}
+
+export function ScheduleControl({
+ source,
+ scheduleSource,
+ paramConfig,
+}: ScheduleControlProps) {
+ const schedulesQ = useSchedules();
+ const updateScheduleMut = useUpdateSchedule();
+ const sweepMut = useStartCitySweep(source);
+
+ const [schEnabled, setSchEnabled] = useState(true);
+ const [schMskStart, setSchMskStart] = useState(5);
+ const [schMskEnd, setSchMskEnd] = useState(8);
+ const [schPages, setSchPages] = useState(
+ paramConfig.defaults.pages_per_anchor,
+ );
+ const [schTopN, setSchTopN] = useState(
+ paramConfig.defaults.detail_top_n ?? 0,
+ );
+ const [schDelay, setSchDelay] = useState(
+ paramConfig.defaults.request_delay_sec,
+ );
+ const [schRadius, setSchRadius] = useState(paramConfig.defaults.radius_m);
+ const [schEnrichHouses, setSchEnrichHouses] = useState(
+ paramConfig.defaults.enrich_houses ?? false,
+ );
+ const [schEnrichAddress, setSchEnrichAddress] = useState(
+ paramConfig.defaults.enrich_address ?? false,
+ );
+
+ useEffect(() => {
+ const sweep = schedulesQ.data?.find((s) => s.source === scheduleSource);
+ 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")
+ setSchEnrichHouses(p.enrich_houses);
+ if (typeof p.enrich_address === "boolean")
+ setSchEnrichAddress(p.enrich_address);
+ }, [schedulesQ.data, scheduleSource]);
+
+ const currentSweep = schedulesQ.data?.find(
+ (s) => s.source === scheduleSource,
+ );
+
+ function handleSweepTrigger() {
+ const body: Record = {
+ pages_per_anchor: schPages,
+ request_delay_sec: schDelay,
+ radius_m: schRadius,
+ };
+ if (paramConfig.hasDetailParams) {
+ body.detail_top_n = schTopN;
+ body.enrich_houses = schEnrichHouses;
+ }
+ if (paramConfig.hasEnrichAddress) {
+ body.enrich_address = schEnrichAddress;
+ }
+ sweepMut.mutate(body);
+ }
+
+ return (
+
+ Расписание и ручной запуск
+
+ {schedulesQ.isPending && (
+ Загрузка расписания…
+ )}
+ {schedulesQ.isError && (
+
+ Ошибка загрузки: {schedulesQ.error.message}
+
+ )}
+
+ {/* Current status */}
+ {currentSweep && (
+
+
+ Статус:
+
+ {currentSweep.enabled ? "включено" : "отключено"}
+
+
+
+ Окно (МСК):
+
+ {String(utcToMsk(currentSweep.window_start_hour)).padStart(
+ 2,
+ "0",
+ )}
+ :00–
+ {String(utcToMsk(currentSweep.window_end_hour)).padStart(
+ 2,
+ "0",
+ )}
+ :00
+
+
+
+ Следующий:
+ {formatTime(currentSweep.next_run_at)}
+
+
+ Последний:
+
+ {currentSweep.last_run_at
+ ? formatTime(currentSweep.last_run_at)
+ : "—"}
+ {currentSweep.last_run_id &&
+ ` (run #${currentSweep.last_run_id})`}
+
+
+
+ )}
+
+ {/* Schedule settings form */}
+
+
+ {updateScheduleMut.isError && (
+
+ Ошибка: {updateScheduleMut.error.message}
+
+ )}
+ {updateScheduleMut.isSuccess && updateScheduleMut.data && (
+
+ Расписание обновлено. Next run:{" "}
+ {formatTime(updateScheduleMut.data.next_run_at)}
+
+ )}
+
+ {/* Manual trigger */}
+
+
+ Ручной запуск city sweep с параметрами выше:
+
+
+ {sweepMut.isPending ? "Запускаем…" : "Запустить sweep вручную"}
+
+ {sweepMut.isError && (
+
+ Ошибка: {sweepMut.error.message}
+
+ )}
+ {sweepMut.isSuccess && sweepMut.data && (
+
+
+ {JSON.stringify(sweepMut.data, null, 2)}
+
+
+ )}
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
index c0c444e6..679a5e70 100644
--- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
@@ -29,8 +29,7 @@ export type ActiveTab =
| "scrapers"
| "avito"
| "cian"
- | "yandex"
- | "cian-cookies";
+ | "yandex";
interface TopbarProps {
active: ActiveTab;
@@ -86,12 +85,6 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс",
},
- {
- key: "cian-cookies",
- href: "/scrapers/cian-cookies",
- scopePath: "/trade-in/api/v1/admin/scrapers/cian-cookies",
- label: "Cian Cookies",
- },
];
export function Topbar({ active }: TopbarProps) {