feat(tradein): Stage 4c — scrapers admin pages (Avito real + Cian/Yandex stubs) (#472)
This commit is contained in:
parent
7d649f9239
commit
649034479f
6 changed files with 644 additions and 17 deletions
|
|
@ -11,8 +11,8 @@ import { useRouter } from "next/navigation";
|
||||||
import "@/components/trade-in/trade-in.css";
|
import "@/components/trade-in/trade-in.css";
|
||||||
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
|
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
|
||||||
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
|
||||||
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||||||
|
import { Topbar } from "@/components/trade-in/Topbar";
|
||||||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||||||
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
||||||
|
|
@ -79,22 +79,7 @@ export default function TradeInPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Topbar — белый-лейбл бренд */}
|
<Topbar active="estimate" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<div className="crumbs">
|
<div className="crumbs">
|
||||||
|
|
|
||||||
417
tradein-mvp/frontend/src/app/scrapers/avito/page.tsx
Normal file
417
tradein-mvp/frontend/src/app/scrapers/avito/page.tsx
Normal 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'ов 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/<slug>/<id> — характеристики
|
||||||
|
дома, отзывы, 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/<...> — 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
tradein-mvp/frontend/src/app/scrapers/cian/page.tsx
Normal file
24
tradein-mvp/frontend/src/app/scrapers/cian/page.tsx
Normal 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: ["cian"]</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
tradein-mvp/frontend/src/app/scrapers/yandex/page.tsx
Normal file
24
tradein-mvp/frontend/src/app/scrapers/yandex/page.tsx
Normal 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: ["yandex"]</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
Normal file
44
tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1239,3 +1239,136 @@
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-style: italic;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue