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 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">
|
||||
|
|
|
|||
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;
|
||||
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