Часть 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
2384 lines
77 KiB
TypeScript
2384 lines
77 KiB
TypeScript
"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 (сек, 0–30)
|
||
<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\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>
|
||
</>
|
||
);
|
||
}
|