gendesign/tradein-mvp/frontend/src/app/scrapers/page.tsx
bot-backend 00cd30b765
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(scrapers/ui): backfill+full-load триггеры, source-фильтр runs, pacing-слайдер, coverage-таблица
Часть 1 — недостающие триггеры:
- CianBackfillSection: cian-backfill-history, cian-price-history (T8a), cian-full-load
- YandexBackfillSection: yandex-address-backfill (T10), yandex-full-load, yandex-newbuilding-sweep
- CianAutoLoginButton: POST /scrape/cian/auto-login в секцию Cookies
- HouseImvBackfillSection: house-imv-backfill (T7) в системный раздел
- GeocodeSection: geocode-missing-listings trigger + status polling

Часть 2 — source-фильтр в RunsTable:
- dropdown «Все / Avito / Cian / Yandex» + aria-label
- фильтр прокидывается в queryKey и query-param ?source=

Часть 3 — PacingControl:
- GET /api/v1/admin/scraper/pacing → PacingSection
- number-input + range slider (0–60с, шаг 0.5) + PUT /pacing/{source}
- env_default_s подсказка; graceful-degradation на ошибку/404

Часть 4 — DataQualitySection:
- GET /api/v1/admin/scraper/data-quality → таблица source × поле (% fill)
- <50% → красный акцент; th scope="col"/"row" a11y
- блок houses enrichment метрик; graceful-degradation на ошибку/404
2026-06-20 12:50:04 +03:00

2384 lines
77 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 React, { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import "@/components/trade-in/trade-in.css";
import { Topbar } from "@/components/trade-in/Topbar";
import { apiFetch } from "@/lib/api";
import {
formatTime,
useScraperSettings,
useUpdateScraperSetting,
ResultPanel,
type ScraperSource,
type ScheduleSourceKey,
type SweepParamConfig,
} from "@/app/scrapers/_components/ScraperPage";
import { SystemHealthSection } from "@/components/scrapers/ProxyHealthCard";
import { RunsTable } from "@/components/scrapers/RunsTable";
import { ScheduleControl } from "@/components/scrapers/ScheduleControl";
import { ProviderProxySection } from "@/components/scrapers/ProviderProxySection";
import { PacingSection } from "@/components/scrapers/PacingControl";
import { DataQualitySection } from "@/components/scrapers/DataQualitySection";
// ── Types (provider-specific responses) ───────────────────────────────────
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 ImvResp {
ok: boolean;
cache_key: string;
recommended_price: number;
range: [number, number];
market_count: number | null;
evaluation_id: number;
history_saved: number;
}
interface CianDetailTriggerResp {
ok: boolean;
offer_url: string;
cian_id: number | null;
price_changes_count: number;
saved: boolean;
listing_id: number | null;
}
interface CianNewbuildingTriggerResp {
ok: boolean;
zhk_url: string;
cian_internal_house_id: number | null;
name: string | null;
price_dynamics_count: number;
has_management_company: boolean;
saved: boolean;
house_id: number | null;
}
interface TestAuthResp {
authenticated: boolean;
userId: number | null;
reason: string | null;
}
interface UploadCookiesResp {
ok?: boolean;
userId?: number;
cookieCount?: number;
detail?: string;
}
interface CookieEditorEntry {
name: string;
value: string | number | boolean;
[key: string]: unknown;
}
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;
}
// ── Backfill / full-load response types ───────────────────────────────────
interface CianBackfillHistoryResp {
ok: boolean;
listings_total: number;
listings_processed: number;
listings_succeeded: number;
listings_failed_fetch: number;
listings_failed_save: number;
price_changes_attempted: number;
houses_total: number;
houses_processed: number;
houses_succeeded: number;
houses_failed_fetch: number;
houses_failed_save: number;
valuations_total: number;
valuations_processed: number;
valuations_succeeded: number;
valuations_failed: number;
duration_sec: number;
}
interface BackfillCountersResp {
ok: boolean;
checked: number;
saved: number;
skipped: number;
errors: number;
duration_sec: number;
}
interface HouseIMVBackfillResp {
ok: boolean;
checked: number;
saved: number;
skipped: number;
errors: number;
duration_sec: number;
status_counts: Record<string, number>;
}
interface GeocodeStatusResp {
total_pending_listings: number;
unique_addresses_pending: number;
estimated_nominatim_minutes: number;
cache_size_active: number;
per_source: Array<{
source: string;
total: number;
pending_geocode: number;
has_coords: number;
}>;
}
interface GeocodeBackfillResp {
status: string;
batch_size: number;
dry_run: boolean;
addresses_total?: number;
addresses_processed?: number;
addresses_geocoded?: number;
addresses_failed?: number;
listings_updated?: number;
cache_hits?: number;
cache_misses?: number;
duration_sec?: number;
note?: string;
}
interface FullLoadStartResp {
run_id: number;
status: string;
pages_per_anchor: number;
detail_top_n: number;
}
interface YandexNBSweepStartResp {
run_id: number;
status: string;
limit: number;
detail: string;
}
// ── Cookie helpers (Cian) ──────────────────────────────────────────────────
function convertCookiesFormat(input: string): Record<string, string> {
const parsed: unknown = JSON.parse(input.trim());
if (Array.isArray(parsed)) {
return (parsed as CookieEditorEntry[]).reduce<Record<string, string>>(
(acc, c) => {
if (
typeof c === "object" &&
c !== null &&
"name" in c &&
"value" in c
) {
acc[String(c.name)] = String(c.value);
}
return acc;
},
{},
);
}
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
k,
String(v),
]),
);
}
throw new Error("Expected JSON array or plain object");
}
function parsePreview(raw: string): {
cookies: Record<string, string> | null;
error: string | null;
} {
if (!raw.trim()) return { cookies: null, error: null };
try {
return { cookies: convertCookiesFormat(raw), error: null };
} catch (e) {
return {
cookies: null,
error: e instanceof Error ? e.message : String(e),
};
}
}
// ── Section 2: Глобально ──────────────────────────────────────────────────
function GlobalSection() {
const settingsQ = useScraperSettings();
const updateMut = useUpdateScraperSetting();
const globalSetting = settingsQ.data?.find((s) => s.source === "global");
const [delayInput, setDelayInput] = useState("0");
const [initialized, setInitialized] = useState(false);
if (globalSetting && !initialized) {
setDelayInput(String(globalSetting.request_delay_sec));
setInitialized(true);
}
return (
<section className="scraper-section scraper-section--highlight">
<h2>Глобально</h2>
<p className="scraper-hint">
Global delay нижний порог для всех скрапперов. Изменение вступает
в силу немедленно.
</p>
{settingsQ.isPending && (
<p className="scraper-hint">Загрузка настроек</p>
)}
{settingsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {settingsQ.error.message}
</p>
)}
{globalSetting && (
<div className="schedule-status" style={{ marginBottom: 12 }}>
<div className="schedule-status__row">
<span className="schedule-status__label">
Текущий global delay:
</span>
<span>
<strong>{globalSetting.request_delay_sec} сек</strong>
</span>
</div>
{globalSetting.updated_at && (
<div className="schedule-status__row">
<span className="schedule-status__label">Обновлено:</span>
<span>{formatTime(globalSetting.updated_at)}</span>
</div>
)}
</div>
)}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
updateMut.mutate({
source: "global",
request_delay_sec: parseFloat(delayInput),
});
}}
>
<label>
Global delay (сек, 030)
<input
type="number"
step={0.5}
min={0}
max={30}
value={delayInput}
onChange={(e) => setDelayInput(e.target.value)}
/>
</label>
<input
type="range"
min={0}
max={30}
step={0.5}
value={delayInput}
onChange={(e) => setDelayInput(e.target.value)}
style={{ width: "100%", gridColumn: "1 / -1" }}
/>
<button
type="submit"
disabled={updateMut.isPending}
style={{ gridColumn: "1 / -1" }}
>
{updateMut.isPending ? "Сохраняем…" : "Сохранить global delay"}
</button>
</form>
{updateMut.isError && (
<div className="scraper-result scraper-result--error">
Ошибка: {updateMut.error.message}
</div>
)}
{updateMut.isSuccess && updateMut.data && (
<div className="scraper-result">
Global delay обновлён:{" "}
<strong>
{Number(updateMut.data.request_delay_sec).toFixed(1)} сек
</strong>{" "}
(обновлено {formatTime(updateMut.data.updated_at)})
</div>
)}
{/* Per-source delay table (read-only summary) */}
{settingsQ.data && settingsQ.data.length > 0 && (
<div style={{ marginTop: 20 }}>
<p
style={{
fontSize: "0.85rem",
fontWeight: 600,
marginBottom: 8,
}}
>
Per-source задержки:
</p>
<table className="runs-table">
<thead>
<tr>
<th>Source</th>
<th>Delay (сек)</th>
<th>Описание</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{settingsQ.data.map((s) => (
<tr
key={s.source}
className={
s.source === "global"
? "run-row run-row--running"
: "run-row"
}
>
<td>
<code>{s.source}</code>
</td>
<td>{s.request_delay_sec.toFixed(1)}</td>
<td
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{s.description ?? "—"}
</td>
<td style={{ fontSize: "0.8rem" }}>
{formatTime(s.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
// ── Collapsible wrapper ────────────────────────────────────────────────────
interface CollapsibleProps {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
function Collapsible({ title, defaultOpen = false, children }: CollapsibleProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div style={{ marginTop: 8 }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{
background: "none",
border: "none",
padding: "8px 0",
cursor: "pointer",
fontSize: "0.9rem",
fontWeight: 600,
color: "var(--accent, #1d4ed8)",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span>{open ? "▼" : "▶"}</span>
{title}
</button>
{open && <div>{children}</div>}
</div>
);
}
// ── Avito advanced triggers ────────────────────────────────────────────────
function AvitoAdvancedTriggers() {
const [lat, setLat] = useState("56.8400");
const [lon, setLon] = useState("60.6050");
const [radius, setRadius] = useState("1500");
const aroundMut = useMutation<
ScrapeAroundResp,
Error,
{ lat: number; lon: number; radius_m: number }
>({
mutationFn: (input) =>
apiFetch<ScrapeAroundResp>("/api/v1/admin/scrape", {
method: "POST",
body: JSON.stringify({ ...input, sources: ["avito"] }),
}),
});
const [houseUrl, setHouseUrl] = useState(
"/catalog/houses/ekaterinburg/ul_akademika_postovskogo_17a/3171365",
);
const houseMut = useMutation<HouseTriggerResp, Error, { house_url: string }>({
mutationFn: ({ house_url }) =>
apiFetch<HouseTriggerResp>(
`/api/v1/admin/scrape/avito-house?house_url=${encodeURIComponent(house_url)}`,
{ method: "POST" },
),
});
const [itemUrl, setItemUrl] = useState(
"/ekaterinburg/kvartiry/3-k._kvartira_75_m_2026_et._7986882804",
);
const detailMut = useMutation<DetailTriggerResp, Error, { item_url: string }>({
mutationFn: ({ item_url }) =>
apiFetch<DetailTriggerResp>(
`/api/v1/admin/scrape/avito-detail?item_url=${encodeURIComponent(item_url)}`,
{ method: "POST" },
),
});
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 = useMutation<
ImvResp,
Error,
{
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;
}
>({
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" },
);
},
});
return (
<>
<div className="scraper-section">
<h3>1. Search around</h3>
<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} />
</div>
<div className="scraper-section">
<h3>2. Houses Catalog enrichment</h3>
<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} />
</div>
<div className="scraper-section">
<h3>3. Detail page enrichment</h3>
<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} />
</div>
<div className="scraper-section">
<h3>4. IMV evaluation (debug)</h3>
<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} />
</div>
</>
);
}
// ── Cian Auto-Login ────────────────────────────────────────────────────────
interface AutoLoginResp {
ok: boolean;
userId?: number;
cookieCount?: number;
}
function CianAutoLoginButton() {
const qc = useQueryClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [expanded, setExpanded] = useState(false);
const autoLoginMut = useMutation<AutoLoginResp, Error, { email: string; password: string } | null>({
mutationFn: (body) =>
apiFetch<AutoLoginResp>("/api/v1/admin/scrape/cian/auto-login", {
method: "POST",
body: body && (body.email || body.password) ? JSON.stringify(body) : undefined,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["cian-test-auth"] });
},
});
return (
<div style={{ marginTop: 16, borderTop: "1px solid var(--border-soft, #eef0f3)", paddingTop: 16 }}>
<p style={{ fontSize: "0.85rem", color: "var(--fg-secondary, #5b6066)", marginBottom: 8 }}>
<strong>Авто-логин</strong> браузерный логин через camoufox (требует настроенных{" "}
<code>CIAN_LOGIN_EMAIL</code>/<code>PASSWORD</code> в ENV или ввода ниже).
</p>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{
background: "none",
border: "none",
padding: "4px 0",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
color: "var(--accent, #1d4ed8)",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span>{expanded ? "▼" : "▶"}</span>
Переопределить credentials (опционально)
</button>
{expanded && (
<div className="scraper-form" style={{ marginTop: 8 }}>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="из ENV по умолчанию"
/>
</label>
</div>
)}
<div style={{ marginTop: 10 }}>
<button
type="button"
disabled={autoLoginMut.isPending}
onClick={() =>
autoLoginMut.mutate(
email || password ? { email, password } : null,
)
}
>
{autoLoginMut.isPending ? "Логинимся…" : "Авто-логин Cian"}
</button>
</div>
{autoLoginMut.isSuccess && (
<div
className="scraper-result"
style={{
marginTop: 8,
background: autoLoginMut.data.ok
? "var(--success-soft, #f0fdf4)"
: "var(--danger-soft, #fef2f2)",
color: autoLoginMut.data.ok
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
>
{autoLoginMut.data.ok ? (
<>
<strong>Залогинились.</strong> userId={autoLoginMut.data.userId ?? "—"} ·
cookieCount={autoLoginMut.data.cookieCount ?? "—"}
</>
) : (
<strong>Авто-логин не удался.</strong>
)}
</div>
)}
{autoLoginMut.isError && (
<div className="scraper-result scraper-result--error" style={{ marginTop: 8 }}>
<strong>Ошибка:</strong> {autoLoginMut.error.message}
</div>
)}
</div>
);
}
// ── Cian Cookies section ───────────────────────────────────────────────────
function CianCookiesSection() {
const [rawInput, setRawInput] = useState("");
const [showInstructions, setShowInstructions] = useState(false);
const preview = parsePreview(rawInput);
const previewKeys = preview.cookies ? Object.keys(preview.cookies) : [];
const uploadMutation = useMutation({
mutationFn: () => {
if (!preview.cookies) {
throw new Error("Invalid JSON — fix the input first");
}
return apiFetch<UploadCookiesResp>(
"/api/v1/admin/scrape/cian/upload-cookies",
{
method: "POST",
body: JSON.stringify(preview.cookies),
},
);
},
onSuccess: () => setRawInput(""),
});
const testAuthQuery = useQuery({
queryKey: ["cian-test-auth"],
queryFn: () =>
apiFetch<TestAuthResp>("/api/v1/admin/scrape/cian/test-auth"),
enabled: false,
retry: false,
});
const canUpload =
!!preview.cookies && previewKeys.length > 0 && !uploadMutation.isPending;
return (
<section className="scraper-section">
<h2>Cookies (DMIR_AUTH)</h2>
<p className="scraper-hint">
Обновление сессии Cian для скрапера оценщика без перезапуска backend.
</p>
<button
type="button"
onClick={() => setShowInstructions((v) => !v)}
style={{
background: "none",
border: "none",
padding: "4px 0",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
color: "var(--accent, #1d4ed8)",
display: "flex",
alignItems: "center",
gap: 6,
marginBottom: showInstructions ? 0 : 12,
}}
>
<span>{showInstructions ? "▼" : "▶"}</span>
Как получить cookies (инструкция)
</button>
{showInstructions && (
<ol
style={{
marginTop: 12,
marginBottom: 12,
paddingLeft: 20,
fontSize: 13,
lineHeight: 1.7,
}}
>
<li>
Открой{" "}
<a href="https://www.cian.ru/" target="_blank" rel="noopener noreferrer">
cian.ru
</a>{" "}
и войди под нужным аккаунтом.
</li>
<li>
Установи расширение <strong>Cookie-Editor</strong> (Chrome/Firefox)
или DevTools Application Cookies www.cian.ru.
</li>
<li>
<strong>Cookie-Editor:</strong> Export Export as JSON скопируй массив.
</li>
<li>Вставь JSON в поле ниже формат определится автоматически.</li>
<li>Убедись что preview показывает нужные ключи, нажми Upload.</li>
</ol>
)}
<div className="scraper-form">
<label className="full">
<textarea
value={rawInput}
onChange={(e) => {
setRawInput(e.target.value);
uploadMutation.reset();
}}
rows={8}
placeholder={`Формат 1 — Cookie-Editor array:\n[{"name": "session_key", "value": "abc123", "domain": ".cian.ru"}]\n\ормат 2 — dict:\n{"session_key": "abc123"}`}
style={{
fontFamily: "ui-monospace, monospace",
fontSize: 12,
resize: "vertical",
minHeight: 160,
padding: "8px 10px",
border: "1px solid var(--border-strong, #d1d5db)",
borderRadius: 6,
background: "#fff",
width: "100%",
}}
/>
</label>
{rawInput.trim() && (
<div className={`scraper-result${preview.error ? " scraper-result--error" : ""}`}>
{preview.error ? (
<span>JSON error: {preview.error}</span>
) : (
<span style={{ color: "var(--success, #0a7a3a)" }}>
Detected <strong>{previewKeys.length} cookies</strong>:{" "}
{previewKeys.slice(0, 8).join(", ")}
{previewKeys.length > 8 ? ` … +${previewKeys.length - 8} more` : ""}
</span>
)}
</div>
)}
<button type="button" onClick={() => uploadMutation.mutate()} disabled={!canUpload}>
{uploadMutation.isPending ? "Uploading…" : "Upload Cookies"}
</button>
<button
type="button"
onClick={() => {
void testAuthQuery.refetch();
}}
disabled={testAuthQuery.isFetching}
style={{
background: "var(--bg-card-alt, #f3f4f6)",
color: "var(--fg-primary, #1f2937)",
border: "1px solid var(--border-strong, #d1d5db)",
}}
>
{testAuthQuery.isFetching ? "Checking…" : "Test Current Session"}
</button>
</div>
{uploadMutation.isSuccess && (
<div
className="scraper-result"
style={{
background: uploadMutation.data.ok
? "var(--success-soft, #f0fdf4)"
: "var(--danger-soft, #fef2f2)",
borderColor: uploadMutation.data.ok
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
color: uploadMutation.data.ok
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
>
{uploadMutation.data.ok ? (
<>
<strong>Cookies uploaded.</strong> userId={" "}
{uploadMutation.data.userId ?? "—"} · cookieCount={" "}
{uploadMutation.data.cookieCount ?? previewKeys.length}
</>
) : (
<>
<strong>Upload failed:</strong>{" "}
{uploadMutation.data.detail ?? "Unknown error"}
</>
)}
</div>
)}
{uploadMutation.isError && (
<div className="scraper-result scraper-result--error">
<strong>Error:</strong> {String(uploadMutation.error)}
</div>
)}
{(testAuthQuery.isSuccess || testAuthQuery.isError) && (
<div
className="scraper-result"
style={{
background:
testAuthQuery.isSuccess && testAuthQuery.data.authenticated
? "var(--success-soft, #f0fdf4)"
: "var(--danger-soft, #fef2f2)",
borderColor:
testAuthQuery.isSuccess && testAuthQuery.data.authenticated
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
color:
testAuthQuery.isSuccess && testAuthQuery.data.authenticated
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
>
{testAuthQuery.isError ? (
<>
<strong>Test auth error:</strong> {String(testAuthQuery.error)}
</>
) : testAuthQuery.data.authenticated ? (
<>
<strong>Authenticated.</strong> userId={" "}
{testAuthQuery.data.userId ?? "—"}
</>
) : (
<>
<strong>Not authenticated.</strong>{" "}
{testAuthQuery.data.reason
? `Reason: ${testAuthQuery.data.reason}`
: "Session expired or cookies invalid."}
</>
)}
</div>
)}
{/* Auto-login (Variant B) */}
<CianAutoLoginButton />
</section>
);
}
// ── Cian advanced triggers ─────────────────────────────────────────────────
function CianAdvancedTriggers() {
const [lat, setLat] = useState("56.8400");
const [lon, setLon] = useState("60.6050");
const [radius, setRadius] = useState("1500");
const [multiRoom, setMultiRoom] = useState(false);
const aroundMut = useMutation<
ScrapeAroundResp,
Error,
{ lat: number; lon: number; radius_m: number; multi_room_cian: boolean }
>({
mutationFn: (input) =>
apiFetch<ScrapeAroundResp>("/api/v1/admin/scrape", {
method: "POST",
body: JSON.stringify({ ...input, sources: ["cian"] }),
}),
});
const [detailUrl, setDetailUrl] = useState("");
const [detailListingId, setDetailListingId] = useState("");
const detailMut = useMutation<
CianDetailTriggerResp,
Error,
{ offer_url: string; listing_id: number | undefined }
>({
mutationFn: ({ offer_url, listing_id }) => {
const qs = new URLSearchParams({ offer_url });
if (listing_id !== undefined) qs.set("listing_id", String(listing_id));
return apiFetch<CianDetailTriggerResp>(
`/api/v1/admin/scrape/cian-detail?${qs.toString()}`,
{ method: "POST" },
);
},
});
const [nbZhkUrl, setNbZhkUrl] = useState("");
const [nbHouseId, setNbHouseId] = useState("");
const nbMut = useMutation<
CianNewbuildingTriggerResp,
Error,
{ zhk_url: string; house_id: number | undefined }
>({
mutationFn: ({ zhk_url, house_id }) => {
const qs = new URLSearchParams({ zhk_url });
if (house_id !== undefined) qs.set("house_id", String(house_id));
return apiFetch<CianNewbuildingTriggerResp>(
`/api/v1/admin/scrape/cian-newbuilding?${qs.toString()}`,
{ method: "POST" },
);
},
});
const authQ = useQuery<TestAuthResp>({
queryKey: ["cian-test-auth"],
queryFn: () => apiFetch<TestAuthResp>("/api/v1/admin/scrape/cian/test-auth"),
staleTime: 30_000,
refetchInterval: 60_000,
});
return (
<>
<div
className="scraper-section"
style={{
background: "var(--accent-soft, #dbeafe)",
borderLeft: "4px solid var(--accent, #1d4ed8)",
padding: "12px 16px",
}}
>
<p style={{ margin: 0, fontSize: "0.9rem" }}>
<strong>Cian Valuation</strong> требует загруженных cookies. Управление в разделе «Cookies» выше.
</p>
<p style={{ margin: "6px 0 0", fontSize: "0.85rem", color: "var(--fg-secondary, #5b6066)" }}>
Статус сессии:{" "}
{authQ.isPending && "проверяем…"}
{authQ.isError && <span style={{ color: "var(--danger, #b3261e)" }}>ошибка проверки</span>}
{authQ.data && (
<span
style={{
color: authQ.data.authenticated
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
fontWeight: 600,
}}
>
{authQ.data.authenticated
? `авторизован (userId: ${authQ.data.userId ?? "—"})`
: `не авторизован — ${authQ.data.reason ?? "неизвестная причина"}`}
</span>
)}
</p>
</div>
<div className="scraper-section">
<h3>1. Search around</h3>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
aroundMut.mutate({
lat: parseFloat(lat),
lon: parseFloat(lon),
radius_m: parseInt(radius, 10),
multi_room_cian: multiRoom,
});
}}
>
<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>
<label className="checkbox">
<input type="checkbox" checked={multiRoom} onChange={(e) => setMultiRoom(e.target.checked)} />
multi_room_cian (4 запроса по комнатам)
</label>
<button type="submit" disabled={aroundMut.isPending}>
{aroundMut.isPending ? "Скрейпим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={aroundMut} />
</div>
<div className="scraper-section">
<h3>2. Detail page enrichment</h3>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
detailMut.mutate({
offer_url: detailUrl,
listing_id: detailListingId !== "" ? parseInt(detailListingId, 10) : undefined,
});
}}
>
<label className="full">
offer_url
<input
type="url"
value={detailUrl}
onChange={(e) => setDetailUrl(e.target.value)}
required
placeholder="https://ekb.cian.ru/sale/flat/XXXXX/"
/>
</label>
<label>
listing_id (опционально)
<input
type="number"
value={detailListingId}
onChange={(e) => setDetailListingId(e.target.value)}
placeholder="оставьте пустым для debug"
/>
</label>
<button type="submit" disabled={detailMut.isPending}>
{detailMut.isPending ? "Парсим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={detailMut} />
</div>
<div className="scraper-section">
<h3>3. Новостройки (ЖК)</h3>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
nbMut.mutate({
zhk_url: nbZhkUrl,
house_id: nbHouseId !== "" ? parseInt(nbHouseId, 10) : undefined,
});
}}
>
<label className="full">
zhk_url
<input
type="url"
value={nbZhkUrl}
onChange={(e) => setNbZhkUrl(e.target.value)}
required
placeholder="https://zhk-XXX-ekb-i.cian.ru/"
/>
</label>
<label>
house_id (опционально)
<input
type="number"
value={nbHouseId}
onChange={(e) => setNbHouseId(e.target.value)}
placeholder="оставьте пустым для debug"
/>
</label>
<button type="submit" disabled={nbMut.isPending}>
{nbMut.isPending ? "Парсим…" : "Запустить"}
</button>
</form>
<ResultPanel mut={nbMut} />
</div>
<div className="scraper-section">
<h3>4. Valuation Calculator</h3>
<p className="scraper-hint">
Вызывается автоматически из /estimate flow (Stage 9). Admin trigger будет добавлен позже.
</p>
</div>
<CianBackfillSection />
</>
);
}
// ── Cian Backfill section ─────────────────────────────────────────────────
function CianBackfillSection() {
// ── cian-backfill-history ──────────────────────────────────────────────
const [bfBatchSize, setBfBatchSize] = useState("50");
const [bfDoListings, setBfDoListings] = useState(true);
const [bfDoHouses, setBfDoHouses] = useState(true);
const [bfDoValuations, setBfDoValuations] = useState(false);
const [bfDryRun, setBfDryRun] = useState(false);
const backfillHistoryMut = useMutation<CianBackfillHistoryResp, Error>({
mutationFn: () => {
const qs = new URLSearchParams({
batch_size: bfBatchSize,
do_listings: String(bfDoListings),
do_houses: String(bfDoHouses),
do_valuations: String(bfDoValuations),
dry_run: String(bfDryRun),
});
return apiFetch<CianBackfillHistoryResp>(
`/api/v1/admin/scrape/cian-backfill-history?${qs.toString()}`,
{ method: "POST" },
);
},
});
// ── cian-price-history ─────────────────────────────────────────────────
const [phBatchSize, setPhBatchSize] = useState("50");
const [phListingId, setPhListingId] = useState("");
const priceHistoryMut = useMutation<BackfillCountersResp, Error>({
mutationFn: () =>
apiFetch<BackfillCountersResp>("/api/v1/admin/scrape/cian-price-history", {
method: "POST",
body: JSON.stringify({
batch_size: parseInt(phBatchSize, 10),
listing_id: phListingId !== "" ? parseInt(phListingId, 10) : null,
}),
}),
});
// ── cian-full-load ─────────────────────────────────────────────────────
const [flDelay, setFlDelay] = useState("1.5");
const [flConcurrency, setFlConcurrency] = useState("5");
const [flPriceCap, setFlPriceCap] = useState("1400");
const [flResumeRunId, setFlResumeRunId] = useState("");
const fullLoadMut = useMutation<FullLoadStartResp, Error>({
mutationFn: () =>
apiFetch<FullLoadStartResp>("/api/v1/admin/scrape/cian-full-load", {
method: "POST",
body: JSON.stringify({
price_cap_per_bucket: parseInt(flPriceCap, 10),
request_delay_sec: parseFloat(flDelay),
concurrency: parseInt(flConcurrency, 10),
enrich_detail: false,
detail_top_n: 0,
resume_run_id: flResumeRunId !== "" ? parseInt(flResumeRunId, 10) : null,
}),
}),
});
return (
<>
<div className="scraper-section">
<h3>5. Backfill история цен (detail)</h3>
<p className="scraper-hint">
Дозаполняет offer_price_history / houses_price_dynamics для листингов и ЖК без истории.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
backfillHistoryMut.mutate();
}}
>
<label>
batch_size
<input
type="number"
min={1}
max={200}
value={bfBatchSize}
onChange={(e) => setBfBatchSize(e.target.value)}
required
/>
</label>
<label className="checkbox">
<input type="checkbox" checked={bfDoListings} onChange={(e) => setBfDoListings(e.target.checked)} />
listings
</label>
<label className="checkbox">
<input type="checkbox" checked={bfDoHouses} onChange={(e) => setBfDoHouses(e.target.checked)} />
houses (ЖК)
</label>
<label className="checkbox">
<input type="checkbox" checked={bfDoValuations} onChange={(e) => setBfDoValuations(e.target.checked)} />
valuations
</label>
<label className="checkbox">
<input type="checkbox" checked={bfDryRun} onChange={(e) => setBfDryRun(e.target.checked)} />
dry_run
</label>
<button type="submit" disabled={backfillHistoryMut.isPending}>
{backfillHistoryMut.isPending ? "Запускаем…" : "Запустить backfill-history"}
</button>
</form>
<ResultPanel mut={backfillHistoryMut} />
</div>
<div className="scraper-section">
<h3>6. Backfill price-history (T8a)</h3>
<p className="scraper-hint">
T8a: Дозаполняет offer_price_history через detail-страницы (curl_cffi). Идемпотентен.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
priceHistoryMut.mutate();
}}
>
<label>
batch_size
<input
type="number"
min={1}
max={500}
value={phBatchSize}
onChange={(e) => setPhBatchSize(e.target.value)}
required
/>
</label>
<label>
listing_id (опционально, debug)
<input
type="number"
value={phListingId}
onChange={(e) => setPhListingId(e.target.value)}
placeholder="оставьте пустым"
/>
</label>
<button type="submit" disabled={priceHistoryMut.isPending}>
{priceHistoryMut.isPending ? "Запускаем…" : "Запустить price-history backfill"}
</button>
</form>
<ResultPanel mut={priceHistoryMut} />
</div>
<div className="scraper-section">
<h3>7. Full Load (полный обход без anchor'ов)</h3>
<p className="scraper-hint">
Exhaustive Cian ЕКБ: адаптивное деление по цене, пагинация каждого бакета. Запускается в background.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
fullLoadMut.mutate();
}}
>
<label>
price_cap_per_bucket
<input
type="number"
min={200}
max={1500}
value={flPriceCap}
onChange={(e) => setFlPriceCap(e.target.value)}
/>
</label>
<label>
request_delay_sec
<input
type="number"
min={0.5}
max={15}
step={0.5}
value={flDelay}
onChange={(e) => setFlDelay(e.target.value)}
/>
</label>
<label>
concurrency
<input
type="number"
min={1}
max={10}
value={flConcurrency}
onChange={(e) => setFlConcurrency(e.target.value)}
/>
</label>
<label>
resume_run_id (опционально)
<input
type="number"
value={flResumeRunId}
onChange={(e) => setFlResumeRunId(e.target.value)}
placeholder="оставьте пустым для нового прогона"
/>
</label>
<button type="submit" disabled={fullLoadMut.isPending}>
{fullLoadMut.isPending ? "Запускаем…" : "Запустить Cian Full Load"}
</button>
</form>
<ResultPanel mut={fullLoadMut} />
</div>
</>
);
}
// ── Yandex advanced triggers ───────────────────────────────────────────────
function YandexAdvancedTriggers() {
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 = 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),
}),
});
const [detailUrl, setDetailUrl] = useState(
"https://realty.yandex.ru/offer/7567094292504417257/",
);
const detailMut = useMutation<
YandexDetailTriggerResp,
Error,
{ offer_url: string }
>({
mutationFn: ({ offer_url }) =>
apiFetch<YandexDetailTriggerResp>(
`/api/v1/admin/scrape/yandex-detail?offer_url=${encodeURIComponent(offer_url)}`,
{ method: "POST" },
),
});
const [nbSlug, setNbSlug] = useState("tatlin");
const [nbId, setNbId] = useState("1592987");
const [nbCity, setNbCity] = useState("ekaterinburg");
const nbMut = 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" },
);
},
});
const [valAddress, setValAddress] = useState(
"Свердловская область, Екатеринбург, улица Учителей, 18",
);
const [valCategory, setValCategory] = useState("APARTMENT");
const [valType, setValType] = useState("SELL");
const [valPage, setValPage] = useState("1");
const valMut = 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" },
);
},
});
return (
<>
<div className="scraper-section">
<h3>1. Search around</h3>
<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>
<div className="scraper-section">
<h3>2. Detail page enrichment</h3>
<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>
<div className="scraper-section">
<h3>3. ЖК (Newbuilding) landing</h3>
<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>
<div className="scraper-section">
<h3>4. Valuation (anonymous history)</h3>
<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 />
</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>
<YandexBackfillSection />
</>
);
}
// ── Yandex Backfill section ────────────────────────────────────────────────
function YandexBackfillSection() {
// ── yandex-address-backfill ────────────────────────────────────────────
const [abLimit, setAbLimit] = useState("200");
const [abDelay, setAbDelay] = useState("3.0");
const addressBackfillMut = useMutation<BackfillCountersResp, Error>({
mutationFn: () =>
apiFetch<BackfillCountersResp>("/api/v1/admin/scrape/yandex-address-backfill", {
method: "POST",
body: JSON.stringify({
limit: parseInt(abLimit, 10),
request_delay_sec: parseFloat(abDelay),
}),
}),
});
// ── yandex-full-load ──────────────────────────────────────────────────
const [flDelay, setFlDelay] = useState("2.0");
const [flConcurrency, setFlConcurrency] = useState("4");
const [flPriceCap, setFlPriceCap] = useState("500");
const [flResumeRunId, setFlResumeRunId] = useState("");
const fullLoadMut = useMutation<FullLoadStartResp, Error>({
mutationFn: () =>
apiFetch<FullLoadStartResp>("/api/v1/admin/scrape/yandex-full-load", {
method: "POST",
body: JSON.stringify({
price_cap_per_bucket: parseInt(flPriceCap, 10),
request_delay_sec: parseFloat(flDelay),
concurrency: parseInt(flConcurrency, 10),
resume_run_id: flResumeRunId !== "" ? parseInt(flResumeRunId, 10) : null,
}),
}),
});
// ── yandex-newbuilding-sweep ───────────────────────────────────────────
const [nbLimit, setNbLimit] = useState("5");
const [nbDelay, setNbDelay] = useState("8.0");
const [nbCity, setNbCity] = useState("ekaterinburg");
const [nbForce, setNbForce] = useState(false);
const nbSweepMut = useMutation<YandexNBSweepStartResp, Error>({
mutationFn: () =>
apiFetch<YandexNBSweepStartResp>("/api/v1/admin/scrape/yandex-newbuilding-sweep", {
method: "POST",
body: JSON.stringify({
limit: parseInt(nbLimit, 10),
request_delay_sec: parseFloat(nbDelay),
force: nbForce,
city: nbCity,
}),
}),
});
return (
<>
<div className="scraper-section">
<h3>5. Backfill — address (T10)</h3>
<p className="scraper-hint">
Дозаполняет listings.address для Yandex-листингов без номера дома (detail-страница → полный адрес).
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
addressBackfillMut.mutate();
}}
>
<label>
limit
<input
type="number"
min={1}
max={2000}
value={abLimit}
onChange={(e) => setAbLimit(e.target.value)}
required
/>
</label>
<label>
request_delay_sec
<input
type="number"
min={1}
max={15}
step={0.5}
value={abDelay}
onChange={(e) => setAbDelay(e.target.value)}
/>
</label>
<button type="submit" disabled={addressBackfillMut.isPending}>
{addressBackfillMut.isPending ? "Запускаем…" : "Запустить address backfill"}
</button>
</form>
<ResultPanel mut={addressBackfillMut} />
</div>
<div className="scraper-section">
<h3>6. Full Load (полный обход без anchor'ов)</h3>
<p className="scraper-hint">
Exhaustive Yandex ЕКБ: адаптивное деление по цене, пагинация каждого бакета. Background.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
fullLoadMut.mutate();
}}
>
<label>
price_cap_per_bucket
<input
type="number"
min={100}
max={575}
value={flPriceCap}
onChange={(e) => setFlPriceCap(e.target.value)}
/>
</label>
<label>
request_delay_sec
<input
type="number"
min={1}
max={15}
step={0.5}
value={flDelay}
onChange={(e) => setFlDelay(e.target.value)}
/>
</label>
<label>
concurrency
<input
type="number"
min={1}
max={8}
value={flConcurrency}
onChange={(e) => setFlConcurrency(e.target.value)}
/>
</label>
<label>
resume_run_id (опционально)
<input
type="number"
value={flResumeRunId}
onChange={(e) => setFlResumeRunId(e.target.value)}
placeholder="оставьте пустым"
/>
</label>
<button type="submit" disabled={fullLoadMut.isPending}>
{fullLoadMut.isPending ? "Запускаем…" : "Запустить Yandex Full Load"}
</button>
</form>
<ResultPanel mut={fullLoadMut} />
</div>
<div className="scraper-section">
<h3>7. Newbuilding enrichment sweep</h3>
<p className="scraper-hint">
Обогащение pending ЖК-домов: resolve slug fetch_jk UPSERT yandex_jk_enrichment.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
nbSweepMut.mutate();
}}
>
<label>
limit
<input
type="number"
min={1}
max={200}
value={nbLimit}
onChange={(e) => setNbLimit(e.target.value)}
/>
</label>
<label>
request_delay_sec
<input
type="number"
min={3}
max={30}
step={0.5}
value={nbDelay}
onChange={(e) => setNbDelay(e.target.value)}
/>
</label>
<label>
city
<input
value={nbCity}
onChange={(e) => setNbCity(e.target.value)}
placeholder="ekaterinburg"
/>
</label>
<label className="checkbox">
<input type="checkbox" checked={nbForce} onChange={(e) => setNbForce(e.target.checked)} />
force (перезаписать уже обогащённые)
</label>
<button type="submit" disabled={nbSweepMut.isPending}>
{nbSweepMut.isPending ? "Запускаем…" : "Запустить NB sweep"}
</button>
</form>
<ResultPanel mut={nbSweepMut} />
</div>
</>
);
}
// ── Per-source delay editor ────────────────────────────────────────────────
interface PerSourceDelayEditorProps {
source: ScraperSource;
delayMin: number;
delayMax: number;
}
function PerSourceDelayEditor({ source, delayMin, delayMax }: PerSourceDelayEditorProps) {
const settingsQ = useScraperSettings();
const updateMut = useUpdateScraperSetting();
const currentSetting = settingsQ.data?.find((s) => s.source === source);
const [delayInput, setDelayInput] = useState<string>("5.0");
const [initialized, setInitialized] = useState(false);
if (currentSetting && !initialized) {
setDelayInput(String(currentSetting.request_delay_sec));
setInitialized(true);
}
return (
<section className="scraper-section">
<h2>Задержка запросов</h2>
<p className="scraper-hint">
Задержка применяется только к <strong>{source}</strong>. Изменение вступает в силу немедленно.
</p>
{currentSetting && (
<div className="schedule-status" style={{ marginBottom: 12 }}>
<div className="schedule-status__row">
<span className="schedule-status__label">Текущая задержка:</span>
<span>
<strong>{currentSetting.request_delay_sec} сек</strong>
</span>
</div>
</div>
)}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
updateMut.mutate({ source, request_delay_sec: parseFloat(delayInput) });
}}
>
<label>
Задержка (сек, {delayMin}{delayMax})
<input
type="number"
min={delayMin}
max={delayMax}
step={0.5}
value={delayInput}
onChange={(e) => setDelayInput(e.target.value)}
required
/>
</label>
<button type="submit" disabled={updateMut.isPending}>
{updateMut.isPending ? "Сохраняем…" : "Сохранить задержку"}
</button>
</form>
{updateMut.isError && (
<div className="scraper-result scraper-result--error">
<strong>Ошибка:</strong> {updateMut.error.message}
</div>
)}
{updateMut.isSuccess && updateMut.data && (
<div className="scraper-result">
Сохранено.{" "}
<strong>{Number(updateMut.data.request_delay_sec).toFixed(1)} сек</strong>
</div>
)}
</section>
);
}
// ── Cross-source Backfill & Sync (System tab) ─────────────────────────────
function HouseImvBackfillSection() {
const [batchSize, setBatchSize] = useState("50");
const [delay, setDelay] = useState("5.0");
const [onlyStatus, setOnlyStatus] = useState("pending");
const [houseId, setHouseId] = useState("");
const imvMut = useMutation<HouseIMVBackfillResp, Error>({
mutationFn: () =>
apiFetch<HouseIMVBackfillResp>("/api/v1/admin/scrape/house-imv-backfill", {
method: "POST",
body: JSON.stringify({
batch_size: parseInt(batchSize, 10),
request_delay_sec: parseFloat(delay),
only_status: onlyStatus,
house_id: houseId !== "" ? parseInt(houseId, 10) : null,
}),
}),
});
return (
<section className="scraper-section">
<h2>House IMV Backfill (T7)</h2>
<p className="scraper-hint">
Batch оценка домов через Avito IMV API. Итерирует дома по{" "}
<code>imv_status</code>, сохраняет в{" "}
<code>house_imv_evaluations</code> / <code>house_placement_history</code> /{" "}
<code>house_suggestions</code>. Не снижать delay ниже 3s.
</p>
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
imvMut.mutate();
}}
>
<label>
batch_size
<input
type="number"
min={1}
max={500}
value={batchSize}
onChange={(e) => setBatchSize(e.target.value)}
required
/>
</label>
<label>
request_delay_sec
<input
type="number"
min={1}
max={30}
step={0.5}
value={delay}
onChange={(e) => setDelay(e.target.value)}
/>
</label>
<label>
only_status
<select value={onlyStatus} onChange={(e) => setOnlyStatus(e.target.value)}>
<option value="pending">pending (обработать новые)</option>
<option value="transient_error">transient_error (retry failed)</option>
</select>
</label>
<label>
house_id (опционально, debug)
<input
type="number"
value={houseId}
onChange={(e) => setHouseId(e.target.value)}
placeholder="оставьте пустым"
/>
</label>
<button type="submit" disabled={imvMut.isPending} style={{ gridColumn: "1 / -1" }}>
{imvMut.isPending ? "Запускаем IMV backfill…" : "Запустить House IMV backfill"}
</button>
</form>
<ResultPanel mut={imvMut} />
</section>
);
}
function GeocodeSection() {
const [batchSize, setGcBatchSize] = useState("200");
const [dryRun, setGcDryRun] = useState(false);
const [background, setGcBackground] = useState(false);
const statusQ = useQuery<GeocodeStatusResp>({
queryKey: ["geocode-missing-status"],
queryFn: () =>
apiFetch<GeocodeStatusResp>("/api/v1/admin/scrape/geocode-missing-listings/status"),
staleTime: 30_000,
refetchInterval: 60_000,
retry: 1,
});
const geocodeMut = useMutation<GeocodeBackfillResp, Error>({
mutationFn: () => {
const qs = new URLSearchParams({
batch_size: batchSize,
dry_run: String(dryRun),
background: String(background),
});
return apiFetch<GeocodeBackfillResp>(
`/api/v1/admin/scrape/geocode-missing-listings?${qs.toString()}`,
{ method: "POST" },
);
},
});
return (
<section className="scraper-section">
<h2>Geocode missing listings</h2>
<p className="scraper-hint">
Дозаполняет координаты листингов через Nominatim (address-dedup).
Безопасно повторять. Background=true возвращает немедленно, статус в логах.
</p>
{/* Status block */}
{statusQ.isError && (
<p className="scraper-result scraper-result--error" style={{ marginBottom: 8 }}>
Ошибка загрузки статуса: {statusQ.error.message}
</p>
)}
{statusQ.isSuccess && (
<div className="schedule-status" style={{ marginBottom: 12 }}>
<div className="schedule-status__row">
<span className="schedule-status__label">Pending listings:</span>
<span>{statusQ.data.total_pending_listings.toLocaleString("ru-RU")}</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Уникальных адресов:</span>
<span>{statusQ.data.unique_addresses_pending.toLocaleString("ru-RU")}</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">~Минут (Nominatim):</span>
<span>{statusQ.data.estimated_nominatim_minutes}</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Geocode cache (active):</span>
<span>{statusQ.data.cache_size_active.toLocaleString("ru-RU")}</span>
</div>
</div>
)}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
geocodeMut.mutate();
}}
>
<label>
batch_size
<input
type="number"
min={1}
max={2000}
value={batchSize}
onChange={(e) => setGcBatchSize(e.target.value)}
required
/>
</label>
<label className="checkbox">
<input type="checkbox" checked={dryRun} onChange={(e) => setGcDryRun(e.target.checked)} />
dry_run (показать без записи)
</label>
<label className="checkbox">
<input type="checkbox" checked={background} onChange={(e) => setGcBackground(e.target.checked)} />
background (не ждать результат)
</label>
<button type="submit" disabled={geocodeMut.isPending} style={{ gridColumn: "1 / -1" }}>
{geocodeMut.isPending ? "Геокодируем…" : "Запустить geocode backfill"}
</button>
</form>
<ResultPanel mut={geocodeMut} />
</section>
);
}
// ── Provider configs ───────────────────────────────────────────────────────
interface ProviderConfig {
source: ScraperSource;
label: string;
scheduleSource: ScheduleSourceKey;
paramConfig: SweepParamConfig;
delayMin: number;
delayMax: number;
}
const PROVIDER_CONFIGS: ProviderConfig[] = [
{
source: "avito",
label: "Avito",
scheduleSource: "avito_city_sweep",
paramConfig: {
hasDetailParams: true,
hasEnrichAddress: false,
defaults: {
pages_per_anchor: 3,
detail_top_n: 20,
request_delay_sec: 7,
radius_m: 1500,
enrich_houses: true,
},
},
delayMin: 3,
delayMax: 30,
},
{
source: "cian",
label: "Cian",
scheduleSource: "cian_city_sweep",
paramConfig: {
hasDetailParams: true,
hasEnrichAddress: false,
defaults: {
pages_per_anchor: 3,
detail_top_n: 20,
request_delay_sec: 5,
radius_m: 1500,
enrich_houses: true,
},
},
delayMin: 3,
delayMax: 30,
},
{
source: "yandex",
label: "Yandex",
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,
},
];
// ── Provider tab content ───────────────────────────────────────────────────
interface ProviderTabContentProps {
config: ProviderConfig;
}
function ProviderTabContent({ config }: ProviderTabContentProps) {
const { source, scheduleSource, paramConfig, delayMin, delayMax } = config;
return (
<div>
<ScheduleControl source={source} scheduleSource={scheduleSource} paramConfig={paramConfig} />
<ProviderProxySection source={source} />
<RunsTable source={source} />
<PerSourceDelayEditor source={source} delayMin={delayMin} delayMax={delayMax} />
{source === "cian" && <CianCookiesSection />}
<Collapsible title="Расширенное (отдельные триггеры)">
<div style={{ marginTop: 8 }}>
{source === "avito" && <AvitoAdvancedTriggers />}
{source === "cian" && <CianAdvancedTriggers />}
{source === "yandex" && <YandexAdvancedTriggers />}
</div>
</Collapsible>
</div>
);
}
// ── Tab types ──────────────────────────────────────────────────────────────
type ProviderTab = "avito" | "cian" | "yandex";
const TAB_LABELS: Record<ProviderTab, string> = {
avito: "Avito",
cian: "Cian",
yandex: "Yandex",
};
// ── Page ───────────────────────────────────────────────────────────────────
export default function ScrapersUnifiedPage() {
const [activeProvider, setActiveProvider] = useState<ProviderTab>("avito");
const activeConfig = PROVIDER_CONFIGS.find((c) => c.source === activeProvider)!;
return (
<>
<Topbar active="scrapers" />
<main className="page scraper-page">
<h1 className="scraper-h1">Скрапперы</h1>
<p className="scraper-subtitle">
Центр управления всеми скрапперами: система, расписания, прокси,
история прогонов, триггеры.
</p>
{/* Section 1: Система */}
<SystemHealthSection />
{/* Section 2: Pacing */}
<PacingSection />
{/* Section 3: Глобально */}
<GlobalSection />
{/* Section 4: Backfill & Sync (cross-source) */}
<HouseImvBackfillSection />
<GeocodeSection />
{/* Section 5: Качество данных */}
<DataQualitySection />
{/* Tabs: Avito / Cian / Yandex */}
<div style={{ marginTop: 24 }}>
{/* Tab bar */}
<div
style={{
display: "flex",
gap: 0,
borderBottom: "2px solid var(--border-card, #e6e8ec)",
overflowX: "auto",
}}
role="tablist"
>
{(Object.keys(TAB_LABELS) as ProviderTab[]).map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeProvider === tab}
onClick={() => setActiveProvider(tab)}
style={{
padding: "10px 20px",
border: "none",
borderBottom:
activeProvider === tab
? "2px solid var(--accent, #1d4ed8)"
: "2px solid transparent",
marginBottom: -2,
background: "none",
cursor: "pointer",
fontSize: "0.9rem",
fontWeight: activeProvider === tab ? 600 : 400,
color:
activeProvider === tab
? "var(--accent, #1d4ed8)"
: "var(--fg-secondary, #5b6066)",
whiteSpace: "nowrap",
}}
>
{TAB_LABELS[tab]}
</button>
))}
</div>
{/* Tab content */}
<div style={{ marginTop: 24 }}>
<ProviderTabContent config={activeConfig} />
</div>
</div>
</main>
</>
);
}