feat(tradein-ui): Yandex admin scraper page — mirror Avito + global delay control (#488)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled

Replaces 5-line stub at /scrapers/yandex with full admin page mirroring Avito. 5 sections: global delay (umbrella yandex key), search around, detail snapshot, JK newbuilding, valuation. TanStack Query hooks + apiFetch, TS strict, reuses trade-in.css.

Fixup: useScraperSettings unwraps {settings:[...]} response wrapper; PUT body includes required source field (matches backend Pydantic ScraperSettingPayload). useEffect race guarded with initializedRef so 30s refetch doesn't clobber user input mid-edit.

Verdict: APPROVE — deep-code-reviewer (response shape unwrap + source field both applied).
This commit is contained in:
lekss361 2026-05-23 20:07:48 +00:00
parent 52716a46c6
commit 726e774a7f

View file

@ -1,23 +1,570 @@
"use client";
import { useEffect, useRef, useState } from "react";
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 ScraperSetting {
source: string;
request_delay_sec: number;
description: string | null;
updated_at: string | null;
}
interface ScraperSettingUpdate {
source: string;
request_delay_sec: number;
}
interface ScrapeAroundInput {
lat: number;
lon: number;
radius_m: number;
sources: string[];
multi_room_yandex?: boolean;
deep_yandex?: boolean;
}
interface ScrapeAroundResp {
total_fetched: number;
total_inserted: number;
total_updated: number;
by_source: Array<{
source: string;
fetched: number;
inserted: number;
updated: number;
}>;
}
interface YandexDetailTriggerResp {
ok: boolean;
offer_url: string;
offer_id: string | null;
price_rub: number | null;
title: string | null;
photo_count: number;
}
interface YandexNewbuildingTriggerResp {
ok: boolean;
ext_id: string;
ext_slug: string;
name: string | null;
lat: number | null;
lon: number | null;
rating: number | null;
ratings_count: number | null;
text_reviews_count: number | null;
developer_name: string | null;
}
interface YandexValuationTriggerResp {
ok: boolean;
address: string;
year_built: number | null;
total_floors: number | null;
house_type: string | null;
has_lift: boolean | null;
total_objects: number | null;
history_items_count: number;
}
// ── Hooks ──────────────────────────────────────────────────────────────────
function useScraperSettings() {
return useQuery<ScraperSetting[]>({
queryKey: ["scraper-settings"],
queryFn: () =>
apiFetch<{ settings: ScraperSetting[] }>("/api/v1/admin/scraper-settings").then(
(r) => r.settings,
),
refetchInterval: 30_000,
});
}
function useUpdateScraperSetting() {
const qc = useQueryClient();
return useMutation<
ScraperSetting,
Error,
{ source: string; payload: Omit<ScraperSettingUpdate, "source"> }
>({
mutationFn: ({ source, payload }) =>
apiFetch<ScraperSetting>(`/api/v1/admin/scraper-settings/${source}`, {
method: "PUT",
body: JSON.stringify({ source, ...payload }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scraper-settings"] });
},
});
}
function useScrapeAround() {
return useMutation<ScrapeAroundResp, Error, ScrapeAroundInput>({
mutationFn: (input) =>
apiFetch<ScrapeAroundResp>("/api/v1/admin/scrape", {
method: "POST",
body: JSON.stringify(input),
}),
});
}
function useScrapeYandexDetail() {
return useMutation<YandexDetailTriggerResp, Error, { offer_url: string }>({
mutationFn: ({ offer_url }) =>
apiFetch<YandexDetailTriggerResp>(
`/api/v1/admin/scrape/yandex-detail?offer_url=${encodeURIComponent(offer_url)}`,
{ method: "POST" },
),
});
}
function useScrapeYandexNewbuilding() {
return useMutation<
YandexNewbuildingTriggerResp,
Error,
{ slug: string; id: string; city: string }
>({
mutationFn: ({ slug, id, city }) => {
const qs = new URLSearchParams({ slug, id, city });
return apiFetch<YandexNewbuildingTriggerResp>(
`/api/v1/admin/scrape/yandex-newbuilding?${qs.toString()}`,
{ method: "POST" },
);
},
});
}
function useScrapeYandexValuation() {
return 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<YandexValuationTriggerResp>(
`/api/v1/admin/scrape/yandex-valuation?${qs.toString()}`,
{ method: "POST" },
);
},
});
}
// ── Result panel ───────────────────────────────────────────────────────────
interface ResultPanelProps<TData> {
mut: {
data?: TData;
error: Error | null;
isPending: boolean;
isSuccess: boolean;
};
}
function ResultPanel<TData>({ mut }: ResultPanelProps<TData>) {
if (mut.error) {
return (
<div className="scraper-result scraper-result--error">
<strong>Ошибка:</strong> {mut.error.message}
</div>
);
}
if (mut.isSuccess && mut.data) {
return (
<div className="scraper-result">
<pre>{JSON.stringify(mut.data, null, 2)}</pre>
</div>
);
}
return null;
}
// ── 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;
}
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function YandexScraperPage() {
// Settings
const settingsQ = useScraperSettings();
const updateSettingMut = useUpdateScraperSetting();
const [delayInput, setDelayInput] = useState<string>("5.0");
const delayInitializedRef = useRef(false);
useEffect(() => {
const yandex = settingsQ.data?.find((s) => s.source === "yandex");
if (yandex && !delayInitializedRef.current) {
setDelayInput(String(yandex.request_delay_sec));
delayInitializedRef.current = true;
}
}, [settingsQ.data]);
// Around
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 = useScrapeAround();
// Detail
const [detailUrl, setDetailUrl] = useState(
"https://realty.yandex.ru/offer/7567094292504417257/",
);
const detailMut = useScrapeYandexDetail();
// Newbuilding
const [nbSlug, setNbSlug] = useState("tatlin");
const [nbId, setNbId] = useState("1592987");
const [nbCity, setNbCity] = useState("ekaterinburg");
const nbMut = useScrapeYandexNewbuilding();
// Valuation
const [valAddress, setValAddress] = useState(
"Свердловская область, Екатеринбург, улица Учителей, 18",
);
const [valCategory, setValCategory] = useState("APARTMENT");
const [valType, setValType] = useState("SELL");
const [valPage, setValPage] = useState("1");
const valMut = useScrapeYandexValuation();
const yandexSetting = settingsQ.data?.find((s) => s.source === "yandex");
return (
<>
<Topbar active="yandex" />
<main className="page scraper-page">
<h1 className="scraper-h1">Yandex скрапер</h1>
<p className="scraper-subtitle">В разработке.</p>
<div className="scraper-section">
<p style={{ color: "var(--muted, #5b6066)" }}>
Yandex SERP refactor merged (PR #462), но admin UI ещё не подключён.
Используй cron-scrape.sh или прямой curl к{" "}
<code>POST /api/v1/admin/scrape</code> с{" "}
<code>sources: [&quot;yandex&quot;]</code>.
<h1 className="scraper-h1">Yandex скрапер admin триггер</h1>
<p className="scraper-subtitle">
Ручной запуск sub-pipeline&apos;ов YandexRealtyScraper v1 + глобальная настройка
задержки между запросами.
</p>
{/* 0. Global delay */}
<section className="scraper-section scraper-section--highlight">
<h2>Глобальная задержка запросов</h2>
<p className="scraper-hint">
Настройка применяется ко ВСЕМ Yandex-скрейперам (SERP, detail, ЖК, valuation)
при создании следующего инстанса (in-process cache 60s). Меняется без рестарта
backend.
</p>
</div>
{settingsQ.isPending && <p className="scraper-hint">Загрузка настроек</p>}
{settingsQ.error && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки настроек: {settingsQ.error.message}
</p>
)}
{yandexSetting && (
<div className="schedule-status" style={{ marginBottom: "12px" }}>
<div className="schedule-status__row">
<span className="schedule-status__label">Текущая задержка:</span>
<span>
<strong>{yandexSetting.request_delay_sec} сек</strong>
</span>
</div>
{yandexSetting.updated_at && (
<div className="schedule-status__row">
<span className="schedule-status__label">Обновлено:</span>
<span>{formatTime(yandexSetting.updated_at)}</span>
</div>
)}
{yandexSetting.description && (
<div className="schedule-status__row">
<span className="schedule-status__label">Описание:</span>
<span className="run-muted">{yandexSetting.description}</span>
</div>
)}
</div>
)}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
updateSettingMut.mutate({
source: "yandex",
payload: { request_delay_sec: parseFloat(delayInput) },
});
}}
>
<label>
Задержка (сек, 160)
<input
type="number"
min={1}
max={60}
step={0.5}
value={delayInput}
onChange={(e) => setDelayInput(e.target.value)}
required
/>
</label>
<button type="submit" disabled={updateSettingMut.isPending}>
{updateSettingMut.isPending ? "Сохраняем…" : "Сохранить"}
</button>
</form>
{updateSettingMut.error && (
<div className="scraper-result scraper-result--error">
<strong>Ошибка:</strong> {updateSettingMut.error.message}
</div>
)}
{updateSettingMut.isSuccess && updateSettingMut.data && (
<div className="scraper-result">
Сохранено. Новая задержка:{" "}
<strong>{updateSettingMut.data.request_delay_sec} сек</strong>
</div>
)}
</section>
{/* 1. Around point */}
<section className="scraper-section">
<h2>1. Search around (общий /admin/scrape)</h2>
<p className="scraper-hint">
Yandex SERP path-based vtorichka lat/lon/radius логируется, scraper берёт{" "}
<code>/&#123;ekaterinburg&#125;/kupit/kvartira/vtorichniy-rynok/</code>.
multi-room split не реализован для DOM-парсера (запросы будут одинаковыми).
Deep пробует pages × sorts комбинации.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
aroundMut.mutate({
lat: parseFloat(lat),
lon: parseFloat(lon),
radius_m: parseInt(radius, 10),
sources: ["yandex"],
multi_room_yandex: multiRoom,
deep_yandex: deep,
});
}}
>
<label>
Lat
<input
value={lat}
onChange={(e) => setLat(e.target.value)}
required
/>
</label>
<label>
Lon
<input
value={lon}
onChange={(e) => setLon(e.target.value)}
required
/>
</label>
<label>
Radius (м)
<input
type="number"
min={100}
max={10000}
step={100}
value={radius}
onChange={(e) => setRadius(e.target.value)}
required
/>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={multiRoom}
onChange={(e) => setMultiRoom(e.target.checked)}
/>
multi_room_yandex (5 rooms split)
</label>
<label className="checkbox">
<input
type="checkbox"
checked={deep}
onChange={(e) => setDeep(e.target.checked)}
/>
deep_yandex (5 × 3 × 2 = 30 requests)
</label>
<button type="submit" disabled={aroundMut.isPending}>
{aroundMut.isPending ? "Скрейпим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={aroundMut} />
</section>
{/* 2. Detail */}
<section className="scraper-section">
<h2>2. Detail page enrichment</h2>
<p className="scraper-hint">
Product JSON-LD price (exact int) + DOM-секции description/agency/metro/photos.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
detailMut.mutate({ offer_url: detailUrl });
}}
>
<label className="full">
offer_url
<input
value={detailUrl}
onChange={(e) => setDetailUrl(e.target.value)}
required
placeholder="https://realty.yandex.ru/offer/..."
/>
</label>
<button type="submit" disabled={detailMut.isPending}>
{detailMut.isPending ? "Парсим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={detailMut} />
</section>
{/* 3. ЖК Newbuilding */}
<section className="scraper-section">
<h2>3. ЖК (Newbuilding) landing</h2>
<p className="scraper-hint">
URL /&#123;city&#125;/kupit/novostrojka/&#123;slug&#125;-&#123;id&#125;/ извлекает coords inline,
rating, text_reviews_count, developer_name.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
nbMut.mutate({ slug: nbSlug, id: nbId, city: nbCity });
}}
>
<label>
slug
<input
value={nbSlug}
onChange={(e) => setNbSlug(e.target.value)}
required
placeholder="tatlin"
/>
</label>
<label>
id
<input
value={nbId}
onChange={(e) => setNbId(e.target.value)}
required
placeholder="1592987"
/>
</label>
<label>
city
<input
value={nbCity}
onChange={(e) => setNbCity(e.target.value)}
required
placeholder="ekaterinburg"
/>
</label>
<button type="submit" disabled={nbMut.isPending}>
{nbMut.isPending ? "Парсим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={nbMut} />
</section>
{/* 4. Valuation */}
<section className="scraper-section">
<h2>4. Valuation (anonymous history)</h2>
<p className="scraper-hint">
Anonymous GET /otsenka-kvartiry-po-adresu-onlayn/ house meta + до 30 history
items на page.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
valMut.mutate({
address: valAddress,
offer_category: valCategory,
offer_type: valType,
page: parseInt(valPage, 10),
});
}}
>
<label className="full">
Адрес
<input
value={valAddress}
onChange={(e) => setValAddress(e.target.value)}
required
placeholder="Свердловская область, Екатеринбург, улица Учителей, 18"
/>
</label>
<label>
offer_category
<select
value={valCategory}
onChange={(e) => setValCategory(e.target.value)}
>
<option value="APARTMENT">APARTMENT</option>
<option value="HOUSE">HOUSE</option>
<option value="GARAGE">GARAGE</option>
<option value="COMMERCIAL">COMMERCIAL</option>
<option value="ROOM">ROOM</option>
</select>
</label>
<label>
offer_type
<select
value={valType}
onChange={(e) => setValType(e.target.value)}
>
<option value="SELL">SELL</option>
<option value="RENT">RENT</option>
</select>
</label>
<label>
page
<input
type="number"
min={1}
max={100}
value={valPage}
onChange={(e) => setValPage(e.target.value)}
required
/>
</label>
<button type="submit" disabled={valMut.isPending}>
{valMut.isPending ? "Запрашиваем…" : "Запустить"}
</button>
</form>
<ResultPanel mut={valMut} />
</section>
</main>
</>
);