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
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
"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>/{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} />
|
||
</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 /{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} />
|
||
</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 />,
|
||
}}
|
||
/>
|
||
);
|
||
}
|