gendesign/tradein-mvp/frontend/src/app/scrapers/cian/page.tsx
lekss361 591039493c
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / build-frontend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m45s
Deploy Trade-In / changes (push) Successful in 6s
feat(tradein/cian): admin endpoints + UI for detail+newbuilding history scrape (#525)
2026-05-24 14:05:55 +00:00

560 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
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";
// ── Types ──────────────────────────────────────────────────────────────────
interface ScrapeAroundResp {
total_fetched: number;
total_inserted: number;
total_updated: number;
by_source: Array<{
source: string;
fetched: number;
inserted: number;
updated: number;
}>;
}
interface TestAuthResp {
authenticated: boolean;
userId: number | null;
reason: string | null;
}
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 ScraperSetting {
source: string;
request_delay_sec: number;
description: string | null;
updated_at: string | null;
}
interface ScraperSettingUpdate {
request_delay_sec: number;
}
// ── Hooks ──────────────────────────────────────────────────────────────────
function useScrapeAround() {
return 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"] }),
}),
});
}
function useTestAuth() {
return useQuery<TestAuthResp>({
queryKey: ["cian-test-auth"],
queryFn: () => apiFetch<TestAuthResp>("/api/v1/admin/scrape/cian/test-auth"),
staleTime: 30_000,
refetchInterval: 60_000,
});
}
function useScraperSettings() {
return useQuery<ScraperSetting[]>({
queryKey: ["scraper-settings"],
queryFn: () =>
apiFetch<ScraperSetting[]>("/api/v1/admin/scraper-settings"),
staleTime: 30_000,
});
}
function useUpdateScraperSetting() {
const qc = useQueryClient();
return useMutation<
ScraperSetting,
Error,
{ source: string; payload: ScraperSettingUpdate }
>({
mutationFn: ({ source, payload }) =>
apiFetch<ScraperSetting>(`/api/v1/admin/scraper-settings/${source}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scraper-settings"] });
},
});
}
function useScrapeCianDetail() {
return 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" },
);
},
});
}
function useScrapeCianNewbuilding() {
return 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" },
);
},
});
}
// ── Result panel ───────────────────────────────────────────────────────────
interface ResultPanelProps<TData> {
mut: {
data?: TData;
error: Error | null;
isPending: boolean;
isSuccess: boolean;
};
}
function ResultPanel<TData>({ mut }: ResultPanelProps<TData>) {
if (mut.error) {
return (
<div className="scraper-result scraper-result--error">
<strong>Ошибка:</strong> {mut.error.message}
</div>
);
}
if (mut.isSuccess && mut.data) {
return (
<div className="scraper-result">
<pre>{JSON.stringify(mut.data, null, 2)}</pre>
</div>
);
}
return null;
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function CianScraperPage() {
// Auth status
const authQ = useTestAuth();
// 1. Search around
const [lat, setLat] = useState("56.8400");
const [lon, setLon] = useState("60.6050");
const [radius, setRadius] = useState("1500");
const [multiRoom, setMultiRoom] = useState(false);
const aroundMut = useScrapeAround();
// 2. Detail by URL
const [detailUrl, setDetailUrl] = useState("");
const [detailListingId, setDetailListingId] = useState("");
const detailMut = useScrapeCianDetail();
// 3. Newbuilding
const [nbZhkUrl, setNbZhkUrl] = useState("");
const [nbHouseId, setNbHouseId] = useState("");
const nbMut = useScrapeCianNewbuilding();
// Global delay
const settingsQ = useScraperSettings();
const updateSettingMut = useUpdateScraperSetting();
const [globalDelay, setGlobalDelay] = useState<number>(5);
useEffect(() => {
const global = settingsQ.data?.find((s) => s.source === "global");
if (global !== undefined) {
setGlobalDelay(global.request_delay_sec);
}
}, [settingsQ.data]);
return (
<>
<Topbar active="cian" />
<main className="page scraper-page">
<h1 className="scraper-h1">Cian скрапер admin триггер</h1>
<p className="scraper-subtitle">
Ручной запуск sub-pipeline&apos;ов CianScraper. Для debug и теста после
изменений.
</p>
{/* Cookies callout */}
<div
className="scraper-section"
style={{
background: "var(--surface-2, #f0f4ff)",
borderLeft: "4px solid var(--accent, #3b6cff)",
padding: "12px 16px",
}}
>
<p style={{ margin: 0, fontSize: "0.9rem" }}>
<strong>Stage 7: Cian Valuation</strong> требует загруженных cookies.{" "}
<Link href="/scrapers/cian-cookies">
Настроить cookies
</Link>
</p>
<p style={{ margin: "6px 0 0", fontSize: "0.85rem", color: "var(--muted, #5b6066)" }}>
Статус сессии:{" "}
{authQ.isPending && "проверяем…"}
{authQ.error && (
<span style={{ color: "var(--error, #c0392b)" }}>
ошибка проверки
</span>
)}
{authQ.data && (
<span
style={{
color: authQ.data.authenticated
? "var(--success, #27ae60)"
: "var(--error, #c0392b)",
fontWeight: 600,
}}
>
{authQ.data.authenticated
? `авторизован (userId: ${authQ.data.userId ?? "—"})`
: `не авторизован — ${authQ.data.reason ?? "неизвестная причина"}`}
</span>
)}
</p>
</div>
{/* 1. Search around point */}
<section className="scraper-section">
<h2>1. Search around (общий /admin/scrape)</h2>
<p className="scraper-hint">
Запускает Cian SERP pipeline для одного якоря: search save_listings.
Поддерживает multi_room режим (~4× лотов, отдельный запрос на каждое
кол-во комнат).
</p>
<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} />
</section>
{/* 2. Detail by URL */}
<section className="scraper-section">
<h2>2. Detail page enrichment</h2>
<p className="scraper-hint">
Парсинг конкретного объявления Cian по URL: цена, история цен, сохранение
в cian_price_dynamics. Если <code>listing_id</code> не указан только
debug-режим без записи в БД.
</p>
<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} />
</section>
{/* 3. Newbuilding */}
<section className="scraper-section">
<h2>3. Новостройки (ЖК scrape)</h2>
<p className="scraper-hint">
Скрейп landing-страницы ЖК Cian: динамика цен, управляющая компания.
Если <code>house_id</code> не указан только debug-режим без записи в БД.
</p>
<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} />
</section>
{/* 4. Valuation Calculator */}
<section className="scraper-section">
<h2>4. Valuation Calculator (оценка квартиры)</h2>
<p className="scraper-hint">
Calculator вызывается автоматически из /estimate flow (Stage 9).
Отдельный admin trigger будет добавлен позже.
</p>
</section>
{/* 5. Global Request Delay */}
<section className="scraper-section scraper-section--highlight">
<h2>5. Глобальный задержка запросов (Global Request Delay)</h2>
<p className="scraper-hint">
Глобальный делэй применяется ко всем scrapers (max с per-source
defaults). 0 = только per-source defaults. Изменение вступает в силу
немедленно без перезапуска процесса.
</p>
{/* Per-source read-only list */}
{settingsQ.isPending && (
<p className="scraper-hint">Загрузка настроек</p>
)}
{settingsQ.error && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {settingsQ.error.message}
</p>
)}
{settingsQ.data && settingsQ.data.length > 0 && (
<div style={{ marginBottom: "12px" }}>
<p
style={{
fontSize: "0.85rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
Текущие per-source задержки (read-only):
</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(--muted, #5b6066)",
}}
>
{s.description ?? "—"}
</td>
<td style={{ fontSize: "0.8rem" }}>
{formatTime(s.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Global delay edit form */}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
updateSettingMut.mutate({
source: "global",
payload: { request_delay_sec: globalDelay },
});
}}
>
<label>
Global delay (сек, 030)
<input
type="number"
step={0.5}
min={0}
max={30}
value={globalDelay}
onChange={(e) => setGlobalDelay(parseFloat(e.target.value))}
/>
</label>
<input
type="range"
min={0}
max={30}
step={0.5}
value={globalDelay}
onChange={(e) => setGlobalDelay(parseFloat(e.target.value))}
style={{ width: "100%", gridColumn: "1 / -1" }}
/>
<button
type="submit"
disabled={updateSettingMut.isPending}
style={{ gridColumn: "1 / -1" }}
>
{updateSettingMut.isPending
? "Сохраняем…"
: "Сохранить global delay"}
</button>
</form>
{updateSettingMut.error && (
<div className="scraper-result scraper-result--error">
Ошибка: {updateSettingMut.error.message}
</div>
)}
{updateSettingMut.isSuccess && updateSettingMut.data && (
<div className="scraper-result">
Delay обновлён: <strong>{updateSettingMut.data.request_delay_sec.toFixed(1)} сек</strong>{" "}
(обновлено {formatTime(updateSettingMut.data.updated_at)})
</div>
)}
</section>
</main>
</>
);
}
// ── Helpers ────────────────────────────────────────────────────────────────
function formatTime(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return iso;
}
}