feat(tradein): Stage 4c — scrapers admin pages (Avito real + Cian/Yandex stubs) (#472)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 22s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped

This commit is contained in:
lekss361 2026-05-23 14:05:56 +00:00
parent 7d649f9239
commit 649034479f
6 changed files with 644 additions and 17 deletions

View file

@ -11,8 +11,8 @@ import { useRouter } from "next/navigation";
import "@/components/trade-in/trade-in.css";
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
import { API_BASE_URL } from "@/lib/api";
import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { Topbar } from "@/components/trade-in/Topbar";
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
import { HeroSummary } from "@/components/trade-in/HeroSummary";
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
@ -79,22 +79,7 @@ export default function TradeInPage() {
return (
<>
{/* Topbar — белый-лейбл бренд */}
<header className="topbar">
<div className="topbar-inner">
<div className="brand">
<span className="brand-mark">TI</span>
<span className="brand-name">PRINZIP</span>
<span className="brand-sep" />
<span className="brand-product">Trade-In</span>
</div>
<nav className="top-nav">
<a href={`${API_BASE_URL}/`} className="is-active">Оценка</a>
<a href={`${API_BASE_URL}/history`}>История</a>
<a href={`${API_BASE_URL}/cache`}>Кэш</a>
</nav>
</div>
</header>
<Topbar active="estimate" />
<main className="page">
<div className="crumbs">

View file

@ -0,0 +1,417 @@
"use client";
import { useState } from "react";
import { useMutation } 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 ScrapeAroundInput {
lat: number;
lon: number;
radius_m: number;
}
interface ScrapeAroundResp {
total_fetched: number;
total_inserted: number;
total_updated: number;
by_source: Array<{
source: string;
fetched: number;
inserted: number;
updated: number;
}>;
}
interface HouseTriggerResp {
ok: boolean;
house_url: string;
counters: Record<string, number | string>;
}
interface DetailTriggerResp {
ok: boolean;
item_id: string;
updated: boolean;
}
interface ImvInput {
address: string;
rooms: number;
area_m2: number;
floor: number;
floor_at_home: number;
house_type: string;
renovation_type: string;
has_balcony: boolean;
has_loggia: boolean;
}
interface ImvResp {
ok: boolean;
cache_key: string;
recommended_price: number;
range: [number, number];
market_count: number | null;
evaluation_id: number;
history_saved: number;
}
// ── Mutations ──────────────────────────────────────────────────────────────
function useScrapeAround() {
return useMutation<ScrapeAroundResp, Error, ScrapeAroundInput>({
mutationFn: (input) =>
apiFetch<ScrapeAroundResp>("/api/v1/admin/scrape", {
method: "POST",
body: JSON.stringify({ ...input, sources: ["avito"] }),
}),
});
}
function useScrapeHouse() {
return useMutation<HouseTriggerResp, Error, { house_url: string }>({
mutationFn: ({ house_url }) =>
apiFetch<HouseTriggerResp>(
`/api/v1/admin/scrape/avito-house?house_url=${encodeURIComponent(house_url)}`,
{ method: "POST" },
),
});
}
function useScrapeDetail() {
return useMutation<DetailTriggerResp, Error, { item_url: string }>({
mutationFn: ({ item_url }) =>
apiFetch<DetailTriggerResp>(
`/api/v1/admin/scrape/avito-detail?item_url=${encodeURIComponent(item_url)}`,
{ method: "POST" },
),
});
}
function useScrapeImv() {
return useMutation<ImvResp, Error, ImvInput>({
mutationFn: (input) => {
const qs = new URLSearchParams({
address: input.address,
rooms: String(input.rooms),
area_m2: String(input.area_m2),
floor: String(input.floor),
floor_at_home: String(input.floor_at_home),
house_type: input.house_type,
renovation_type: input.renovation_type,
has_balcony: String(input.has_balcony),
has_loggia: String(input.has_loggia),
});
return apiFetch<ImvResp>(
`/api/v1/admin/scrape/avito-imv?${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;
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function AvitoScraperPage() {
// 1. Around-point
const [lat, setLat] = useState("56.8400");
const [lon, setLon] = useState("60.6050");
const [radius, setRadius] = useState("1500");
const aroundMut = useScrapeAround();
// 2. Houses Catalog
const [houseUrl, setHouseUrl] = useState(
"/catalog/houses/ekaterinburg/ul_akademika_postovskogo_17a/3171365",
);
const houseMut = useScrapeHouse();
// 3. Detail
const [itemUrl, setItemUrl] = useState(
"/ekaterinburg/kvartiry/3-k._kvartira_75_m_2026_et._7986882804",
);
const detailMut = useScrapeDetail();
// 4. IMV
const [imvAddress, setImvAddress] = useState(
"Свердловская обл., Екатеринбург, ул. Чайковского, 66А",
);
const [imvRooms, setImvRooms] = useState("2");
const [imvArea, setImvArea] = useState("42");
const [imvFloor, setImvFloor] = useState("4");
const [imvTotalFloors, setImvTotalFloors] = useState("5");
const [imvHouseType, setImvHouseType] = useState("panel");
const [imvRenovation, setImvRenovation] = useState("cosmetic");
const [imvBalcony, setImvBalcony] = useState(true);
const [imvLoggia, setImvLoggia] = useState(false);
const imvMut = useScrapeImv();
return (
<>
<Topbar active="avito" />
<main className="page scraper-page">
<h1 className="scraper-h1">Avito скрапер admin триггер</h1>
<p className="scraper-subtitle">
Ручной запуск sub-pipeline&apos;ов AvitoScraper v2. Для бесплатной
разработки и теста после изменений.
</p>
{/* 1. Search around point */}
<section className="scraper-section">
<h2>1. Search around (общий /admin/scrape)</h2>
<p className="scraper-hint">
Запускает классический cron-pipeline для одного якоря: search
save_listings. Использовать как baseline.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
aroundMut.mutate({
lat: parseFloat(lat),
lon: parseFloat(lon),
radius_m: parseInt(radius, 10),
});
}}
>
<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
value={radius}
onChange={(e) => setRadius(e.target.value)}
required
/>
</label>
<button type="submit" disabled={aroundMut.isPending}>
{aroundMut.isPending ? "Скрейпим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={aroundMut} />
</section>
{/* 2. Houses Catalog */}
<section className="scraper-section">
<h2>2. Houses Catalog enrichment</h2>
<p className="scraper-hint">
Парсит /catalog/houses/&lt;slug&gt;/&lt;id&gt; характеристики
дома, отзывы, history.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
houseMut.mutate({ house_url: houseUrl });
}}
>
<label className="full">
house_url
<input
value={houseUrl}
onChange={(e) => setHouseUrl(e.target.value)}
required
placeholder="/catalog/houses/<city>/<slug>/<id>"
/>
</label>
<button type="submit" disabled={houseMut.isPending}>
{houseMut.isPending ? "Парсим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={houseMut} />
</section>
{/* 3. Detail */}
<section className="scraper-section">
<h2>3. Detail page enrichment</h2>
<p className="scraper-hint">
Парсит /ekaterinburg/kvartiry/&lt;...&gt; 30+ полей
(kitchen_area, owners_count, etc). UPDATE listings WHERE source_id
(listing должен уже существовать).
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
detailMut.mutate({ item_url: itemUrl });
}}
>
<label className="full">
item_url
<input
value={itemUrl}
onChange={(e) => setItemUrl(e.target.value)}
required
placeholder="/ekaterinburg/kvartiry/.../...."
/>
</label>
<button type="submit" disabled={detailMut.isPending}>
{detailMut.isPending ? "Парсим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={detailMut} />
</section>
{/* 4. IMV evaluation */}
<section className="scraper-section">
<h2>4. IMV evaluation (debug, без cache)</h2>
<p className="scraper-hint">
Прямой вызов Avito IMV API (2 HTTP requests). Production вызов из
estimator on-demand с 24h cache; здесь для debug.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
imvMut.mutate({
address: imvAddress,
rooms: parseInt(imvRooms, 10),
area_m2: parseFloat(imvArea),
floor: parseInt(imvFloor, 10),
floor_at_home: parseInt(imvTotalFloors, 10),
house_type: imvHouseType,
renovation_type: imvRenovation,
has_balcony: imvBalcony,
has_loggia: imvLoggia,
});
}}
>
<label className="full">
Адрес
<input
value={imvAddress}
onChange={(e) => setImvAddress(e.target.value)}
required
/>
</label>
<label>
Комн.
<input
value={imvRooms}
onChange={(e) => setImvRooms(e.target.value)}
required
/>
</label>
<label>
Площадь (м²)
<input
value={imvArea}
onChange={(e) => setImvArea(e.target.value)}
required
/>
</label>
<label>
Этаж
<input
value={imvFloor}
onChange={(e) => setImvFloor(e.target.value)}
required
/>
</label>
<label>
Этажей в доме
<input
value={imvTotalFloors}
onChange={(e) => setImvTotalFloors(e.target.value)}
required
/>
</label>
<label>
Тип дома
<select
value={imvHouseType}
onChange={(e) => setImvHouseType(e.target.value)}
>
<option value="panel">панель</option>
<option value="brick">кирпич</option>
<option value="monolith">монолит</option>
<option value="monolith_brick">монолит-кирпич</option>
<option value="block">блочный</option>
<option value="wood">дерево</option>
</select>
</label>
<label>
Ремонт
<select
value={imvRenovation}
onChange={(e) => setImvRenovation(e.target.value)}
>
<option value="required">требуется</option>
<option value="cosmetic">косметический</option>
<option value="euro">евро</option>
<option value="designer">дизайнерский</option>
</select>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={imvBalcony}
onChange={(e) => setImvBalcony(e.target.checked)}
/>
Балкон
</label>
<label className="checkbox">
<input
type="checkbox"
checked={imvLoggia}
onChange={(e) => setImvLoggia(e.target.checked)}
/>
Лоджия
</label>
<button type="submit" disabled={imvMut.isPending}>
{imvMut.isPending ? "Запрашиваем Avito…" : "Запустить IMV"}
</button>
</form>
<ResultPanel mut={imvMut} />
</section>
</main>
</>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import "@/components/trade-in/trade-in.css";
import { Topbar } from "@/components/trade-in/Topbar";
export default function CianScraperPage() {
return (
<>
<Topbar active="cian" />
<main className="page scraper-page">
<h1 className="scraper-h1">Cian скрапер</h1>
<p className="scraper-subtitle">В разработке.</p>
<div className="scraper-section">
<p style={{ color: "var(--muted, #5b6066)" }}>
Cian SERP refactor merged (PR #450), но admin UI ещё не подключён.
Используй cron-scrape.sh или прямой curl к{" "}
<code>POST /api/v1/admin/scrape</code> с{" "}
<code>sources: [&quot;cian&quot;]</code>.
</p>
</div>
</main>
</>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import "@/components/trade-in/trade-in.css";
import { Topbar } from "@/components/trade-in/Topbar";
export default function YandexScraperPage() {
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>.
</p>
</div>
</main>
</>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { API_BASE_URL } from "@/lib/api";
type ActiveTab = "estimate" | "history" | "cache" | "avito" | "cian" | "yandex";
interface TopbarProps {
active: ActiveTab;
}
const NAV_ITEMS: Array<{ key: ActiveTab; href: string; label: string }> = [
{ key: "estimate", href: "/", label: "Оценка" },
{ key: "history", href: "/history", label: "История" },
{ key: "cache", href: "/cache", label: "Кэш" },
{ key: "avito", href: "/scrapers/avito", label: "Авито" },
{ key: "cian", href: "/scrapers/cian", label: "Циан" },
{ key: "yandex", href: "/scrapers/yandex", label: "Яндекс" },
];
export function Topbar({ active }: TopbarProps) {
return (
<header className="topbar">
<div className="topbar-inner">
<div className="brand">
<span className="brand-mark">TI</span>
<span className="brand-name">PRINZIP</span>
<span className="brand-sep" />
<span className="brand-product">Trade-In</span>
</div>
<nav className="top-nav">
{NAV_ITEMS.map((item) => (
<a
key={item.key}
href={`${API_BASE_URL}${item.href}`}
className={item.key === active ? "is-active" : undefined}
>
{item.label}
</a>
))}
</nav>
</div>
</header>
);
}

View file

@ -1239,3 +1239,136 @@
color: #6b7280;
font-style: italic;
}
/* ── Stage 4c: scrapers admin pages ── */
.scraper-page {
max-width: 960px;
margin: 0 auto;
padding: 24px 20px 60px;
}
.scraper-h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.scraper-subtitle {
font-size: 14px;
color: var(--fg-secondary, #5b6066);
margin: 0 0 24px;
}
.scraper-section {
margin-bottom: 24px;
padding: 20px;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 12px;
}
.scraper-section h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
color: var(--fg-primary, #111111);
}
.scraper-hint {
font-size: 13px;
color: var(--fg-secondary, #5b6066);
margin: 0 0 16px;
}
.scraper-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
align-items: end;
}
.scraper-form label {
display: flex;
flex-direction: column;
font-size: 12px;
color: var(--fg-secondary, #5b6066);
gap: 4px;
}
.scraper-form label.full {
grid-column: 1 / -1;
}
.scraper-form label.checkbox {
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--fg-primary, #111111);
}
.scraper-form input,
.scraper-form select {
padding: 8px 10px;
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 6px;
font-size: 14px;
background: #fff;
color: var(--fg-primary, #111111);
}
.scraper-form input:focus,
.scraper-form select:focus {
outline: 2px solid var(--accent, #1d4ed8);
outline-offset: -1px;
border-color: var(--accent, #1d4ed8);
}
.scraper-form button {
padding: 8px 16px;
background: var(--accent, #1d4ed8);
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
grid-column: 1 / -1;
justify-self: start;
}
.scraper-form button:hover:not(:disabled) {
background: var(--accent-hover, #1e40af);
}
.scraper-form button:disabled {
background: var(--border-strong, #d1d5db);
cursor: not-allowed;
}
.scraper-result {
margin-top: 16px;
padding: 12px;
background: var(--bg-card-alt, #fafbfc);
border: 1px solid var(--border-soft, #eef0f3);
border-radius: 8px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
max-height: 320px;
overflow: auto;
}
.scraper-result--error {
background: var(--danger-soft, #fee2e2);
border-color: var(--danger, #b3261e);
color: var(--danger, #b3261e);
font-family: inherit;
}
.scraper-result pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}