"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import "@/components/trade-in/trade-in.css"; import { Topbar } from "@/components/trade-in/Topbar"; import { apiFetch } from "@/lib/api"; // ── Types ────────────────────────────────────────────────────────────────── interface ScrapeAroundResp { total_fetched: number; total_inserted: number; total_updated: number; by_source: Array<{ source: string; fetched: number; inserted: number; updated: number; }>; } interface TestAuthResp { authenticated: boolean; userId: number | null; reason: string | null; } interface CianDetailTriggerResp { ok: boolean; offer_url: string; cian_id: number | null; price_changes_count: number; saved: boolean; listing_id: number | null; } interface CianNewbuildingTriggerResp { ok: boolean; zhk_url: string; cian_internal_house_id: number | null; name: string | null; price_dynamics_count: number; has_management_company: boolean; saved: boolean; house_id: number | null; } interface ScraperSetting { source: string; request_delay_sec: number; description: string | null; updated_at: string | null; } interface ScraperSettingUpdate { request_delay_sec: number; } // ── Hooks ────────────────────────────────────────────────────────────────── function useScrapeAround() { return 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"] }), }), }); } function useTestAuth() { return useQuery({ queryKey: ["cian-test-auth"], queryFn: () => apiFetch("/api/v1/admin/scrape/cian/test-auth"), staleTime: 30_000, refetchInterval: 60_000, }); } function useScraperSettings() { return useQuery({ queryKey: ["scraper-settings"], queryFn: () => apiFetch<{ settings: ScraperSetting[] }>("/api/v1/admin/scraper-settings").then( (r) => (r.settings ?? []).map((s) => ({ ...s, request_delay_sec: Number(s.request_delay_sec), })), ), staleTime: 30_000, }); } function useUpdateScraperSetting() { const qc = useQueryClient(); return useMutation< ScraperSetting, Error, { source: string; payload: ScraperSettingUpdate } >({ mutationFn: ({ source, payload }) => apiFetch(`/api/v1/admin/scraper-settings/${source}`, { method: "PUT", body: JSON.stringify(payload), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["scraper-settings"] }); }, }); } function useScrapeCianDetail() { return 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" }, ); }, }); } function useScrapeCianNewbuilding() { return 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" }, ); }, }); } // ── Result panel ─────────────────────────────────────────────────────────── interface ResultPanelProps { mut: { data?: TData; error: Error | null; isPending: boolean; isSuccess: boolean; }; } function ResultPanel({ mut }: ResultPanelProps) { if (mut.error) { return (
Ошибка: {mut.error.message}
); } if (mut.isSuccess && mut.data) { return (
{JSON.stringify(mut.data, null, 2)}
); } return null; } // ── Page ─────────────────────────────────────────────────────────────────── export default function CianScraperPage() { // Auth status const authQ = useTestAuth(); // 1. Search around const [lat, setLat] = useState("56.8400"); const [lon, setLon] = useState("60.6050"); const [radius, setRadius] = useState("1500"); const [multiRoom, setMultiRoom] = useState(false); const aroundMut = useScrapeAround(); // 2. Detail by URL const [detailUrl, setDetailUrl] = useState(""); const [detailListingId, setDetailListingId] = useState(""); const detailMut = useScrapeCianDetail(); // 3. Newbuilding const [nbZhkUrl, setNbZhkUrl] = useState(""); const [nbHouseId, setNbHouseId] = useState(""); const nbMut = useScrapeCianNewbuilding(); // Global delay const settingsQ = useScraperSettings(); const updateSettingMut = useUpdateScraperSetting(); const [globalDelay, setGlobalDelay] = useState(5); useEffect(() => { const global = settingsQ.data?.find((s) => s.source === "global"); if (global !== undefined) { setGlobalDelay(global.request_delay_sec); } }, [settingsQ.data]); return ( <>

Cian скрапер — admin триггер

Ручной запуск sub-pipeline'ов CianScraper. Для debug и теста после изменений.

{/* Cookies callout */}

Stage 7: Cian Valuation требует загруженных cookies.{" "} Настроить cookies →

Статус сессии:{" "} {authQ.isPending && "проверяем…"} {authQ.error && ( ошибка проверки )} {authQ.data && ( {authQ.data.authenticated ? `авторизован (userId: ${authQ.data.userId ?? "—"})` : `не авторизован — ${authQ.data.reason ?? "неизвестная причина"}`} )}

{/* 1. Search around point */}

1. Search around (общий /admin/scrape)

Запускает Cian SERP pipeline для одного якоря: search → save_listings. Поддерживает multi_room режим (~4× лотов, отдельный запрос на каждое кол-во комнат).

{ e.preventDefault(); aroundMut.mutate({ lat: parseFloat(lat), lon: parseFloat(lon), radius_m: parseInt(radius, 10), multi_room_cian: multiRoom, }); }} >
{/* 2. Detail by URL */}

2. Detail page enrichment

Парсинг конкретного объявления Cian по URL: цена, история цен, сохранение в cian_price_dynamics. Если listing_id не указан — только debug-режим без записи в БД.

{ e.preventDefault(); detailMut.mutate({ offer_url: detailUrl, listing_id: detailListingId !== "" ? parseInt(detailListingId, 10) : undefined, }); }} >
{/* 3. Newbuilding */}

3. Новостройки (ЖК scrape)

Скрейп landing-страницы ЖК Cian: динамика цен, управляющая компания. Если house_id не указан — только debug-режим без записи в БД.

{ e.preventDefault(); nbMut.mutate({ zhk_url: nbZhkUrl, house_id: nbHouseId !== "" ? parseInt(nbHouseId, 10) : undefined, }); }} >
{/* 4. Valuation Calculator */}

4. Valuation Calculator (оценка квартиры)

Calculator вызывается автоматически из /estimate flow (Stage 9). Отдельный admin trigger будет добавлен позже.

{/* 5. Global Request Delay */}

5. Глобальный задержка запросов (Global Request Delay)

Глобальный делэй применяется ко всем scrapers (max с per-source defaults). 0 = только per-source defaults. Изменение вступает в силу немедленно — без перезапуска процесса.

{/* Per-source read-only list */} {settingsQ.isPending && (

Загрузка настроек…

)} {settingsQ.error && (

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

)} {settingsQ.data && settingsQ.data.length > 0 && (

Текущие per-source задержки (read-only):

{settingsQ.data.map((s) => ( ))}
Source Delay (сек) Описание Обновлено
{s.source} {s.request_delay_sec.toFixed(1)} {s.description ?? "—"} {formatTime(s.updated_at)}
)} {/* Global delay edit form */}
{ e.preventDefault(); updateSettingMut.mutate({ source: "global", payload: { request_delay_sec: globalDelay }, }); }} > setGlobalDelay(parseFloat(e.target.value))} style={{ width: "100%", gridColumn: "1 / -1" }} />
{updateSettingMut.error && (
Ошибка: {updateSettingMut.error.message}
)} {updateSettingMut.isSuccess && updateSettingMut.data && (
Delay обновлён: {Number(updateSettingMut.data.request_delay_sec).toFixed(1)} сек{" "} (обновлено {formatTime(updateSettingMut.data.updated_at)})
)}
); } // ── Helpers ──────────────────────────────────────────────────────────────── function formatTime(iso: string | null): string { if (!iso) return "—"; try { return new Date(iso).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } catch { return iso; } }