feat(tradein-ui): Yandex admin scraper page — mirror Avito + global delay control #488
1 changed files with 556 additions and 9 deletions
|
|
@ -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: ["yandex"]</code>.
|
||||
<h1 className="scraper-h1">Yandex скрапер — admin триггер</h1>
|
||||
<p className="scraper-subtitle">
|
||||
Ручной запуск sub-pipeline'ов 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>
|
||||
Задержка (сек, 1–60)
|
||||
<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>/{ekaterinburg}/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 /{city}/kupit/novostrojka/{slug}-{id}/ — извлекает 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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue