gendesign/tradein-mvp/frontend/src/app/scrapers/yandex/page.tsx
bot-frontend ee8f72da39
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 36s
feat(tradein): unify scraper admin pages (avito/cian/yandex) + /scrapers hub (#878)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-31 09:31:47 +00:00

424 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import {
ResultPanel,
ScraperPage,
type ScraperPageConfig,
} from "@/app/scrapers/_components/ScraperPage";
// ── Yandex-specific types + hooks ──────────────────────────────────────────
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;
}
function useScrapeAround() {
return useMutation<
ScrapeAroundResp,
Error,
{
lat: number;
lon: number;
radius_m: number;
sources: string[];
multi_room_yandex?: boolean;
deep_yandex?: boolean;
}
>({
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" },
);
},
});
}
// ── Yandex extra triggers ──────────────────────────────────────────────────
function YandexExtraTriggers() {
// 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();
return (
<>
{/* 1. Around point */}
<div className="scraper-section">
<h3>1. Search around (общий /admin/scrape)</h3>
<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} />
</div>
{/* 2. Detail */}
<div className="scraper-section">
<h3>2. Detail page enrichment</h3>
<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} />
</div>
{/* 3. Newbuilding */}
<div className="scraper-section">
<h3>3. ЖК (Newbuilding) landing</h3>
<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} />
</div>
{/* 4. Valuation */}
<div className="scraper-section">
<h3>4. Valuation (anonymous history)</h3>
<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} />
</div>
</>
);
}
// ── Page config + export ───────────────────────────────────────────────────
const YANDEX_CONFIG: ScraperPageConfig = {
source: "yandex",
activeTab: "yandex",
displayName: "Yandex скрапер",
subtitle:
"Ручной запуск sub-pipeline'ов YandexRealtyScraper v1. Для debug и теста после изменений.",
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,
};
export default function YandexScraperPage() {
return (
<ScraperPage
config={{
...YANDEX_CONFIG,
extraTriggers: <YandexExtraTriggers />,
}}
/>
);
}