1559 lines
50 KiB
TypeScript
1559 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState } from "react";
|
||
import { useMutation, useQuery } 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";
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── 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 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>
|
||
)}
|
||
</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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── 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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── 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>
|
||
);
|
||
}
|
||
|
||
// ── 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: Глобально */}
|
||
<GlobalSection />
|
||
|
||
{/* 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>
|
||
</>
|
||
);
|
||
}
|