gendesign/tradein-mvp/frontend/src/app/scrapers/cian-cookies/page.tsx
lekss361 02c2ce6cd3 fix(tradein-ui): move Cian cookies upload page to tradein-mvp frontend (routing fix)
- Move page from main frontend to tradein-mvp/frontend/src/app/scrapers/cian-cookies/
  so /trade-in/api/* Caddy route maps to tradein backend (where
  /admin/scrape/cian/upload-cookies endpoint lives)
- Remove X-Admin-Token header (tradein admin endpoints don't require it;
  auth = Caddy basic_auth) and remove localStorage token state
- Clear textarea on successful upload (security hygiene per review)
- Add Cian Cookies tab to tradein-mvp Topbar nav
- Revert tab entry in main frontend admin layout
2026-05-23 18:16:19 +03:00

351 lines
12 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 { 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";
interface UploadResponse {
ok?: boolean;
userId?: number;
cookieCount?: number;
detail?: string;
}
interface TestAuthResponse {
authenticated: boolean;
userId?: number;
reason?: string;
}
interface CookieEditorEntry {
name: string;
value: string | number | boolean;
[key: string]: unknown;
}
function convertCookiesFormat(input: string): Record<string, string> {
const parsed: unknown = JSON.parse(input.trim());
if (Array.isArray(parsed)) {
// Cookie-Editor array format: [{name, value, domain, ...}, ...]
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)) {
// Already a dict: {"name1": "val1", "name2": "val2"}
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 {
const cookies = convertCookiesFormat(raw);
return { cookies, error: null };
} catch (e) {
return { cookies: null, error: e instanceof Error ? e.message : String(e) };
}
}
export default function CianCookiesPage() {
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<UploadResponse>(
"/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<TestAuthResponse>("/api/v1/admin/scrape/cian/test-auth"),
enabled: false, // only on explicit button click
retry: false,
});
const canUpload =
!!preview.cookies && previewKeys.length > 0 && !uploadMutation.isPending;
return (
<>
<Topbar active="cian-cookies" />
<main className="page scraper-page">
<h1 className="scraper-h1">Cian обновление cookies</h1>
<p className="scraper-subtitle">
Позволяет обновить сессию Cian для скрапера оценщика без перезапуска
backend. Endpoints:{" "}
<code>POST /api/v1/admin/scrape/cian/upload-cookies</code> ·{" "}
<code>GET /api/v1/admin/scrape/cian/test-auth</code>
</p>
{/* Instructions collapsible */}
<div className="scraper-section">
<button
onClick={() => setShowInstructions((v) => !v)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
color: "var(--accent, #1d4ed8)",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span>{showInstructions ? "▼" : "▶"}</span>
Как получить cookies (инструкция)
</button>
{showInstructions && (
<ol
style={{
marginTop: 12,
paddingLeft: 20,
fontSize: 13,
color: "var(--fg, #374151)",
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) <em>или</em> открой DevTools Application
Cookies www.cian.ru.
</li>
<li>
<strong>Cookie-Editor:</strong> нажми Export Export as JSON
скопируй массив{" "}
<code>[{"{"}name, value, ...{"}"}]</code>.
</li>
<li>
<strong>DevTools (ручной способ):</strong> собери объект{" "}
<code>
{"{"}
{'"'}name1{'"'}: {'"'}val1{'"'}, ...{"}"}
</code>
.
</li>
<li>
Вставь JSON в поле ниже формат определится автоматически.
</li>
<li>
Убедись что preview показывает нужные ключи, затем нажми
Upload.
</li>
</ol>
)}
</div>
{/* Cookie input */}
<div className="scraper-section">
<h2>Cookie JSON</h2>
<p className="scraper-hint">
Вставьте Cookie-Editor array или plain dict. Формат определяется
автоматически.
</p>
<div className="scraper-form">
<label className="full">
<textarea
value={rawInput}
onChange={(e) => {
setRawInput(e.target.value);
uploadMutation.reset();
}}
rows={15}
placeholder={`Формат 1 — Cookie-Editor array:\n[\n {"name": "session_key", "value": "abc123", "domain": ".cian.ru"},\n {"name": "_CSRF", "value": "xyz", "domain": ".cian.ru"}\n]\n\ормат 2 — dict (DevTools manual):\n{\n "session_key": "abc123",\n "_CSRF": "xyz"\n}`}
style={{
fontFamily: "var(--font-mono, ui-monospace, monospace)",
fontSize: 12,
resize: "vertical",
minHeight: 240,
padding: "8px 10px",
border: "1px solid var(--border-strong, #d1d5db)",
borderRadius: 6,
background: "#fff",
color: "var(--fg, #111)",
width: "100%",
}}
/>
</label>
{/* Preview */}
{rawInput.trim() && (
<div
className={`scraper-result${preview.error ? " scraper-result--error" : ""}`}
style={{ fontFamily: "inherit" }}
>
{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
onClick={(e) => {
e.preventDefault();
uploadMutation.mutate();
}}
disabled={!canUpload}
title={!preview.cookies ? "Вставьте корректный JSON" : undefined}
>
{uploadMutation.isPending ? "Uploading…" : "Upload Cookies"}
</button>
<button
onClick={(e) => {
e.preventDefault();
void testAuthQuery.refetch();
}}
disabled={testAuthQuery.isFetching}
style={{
background: "var(--surface-2, #f3f4f6)",
color: "var(--fg, #1f2937)",
border: "1px solid var(--border-strong, #d1d5db)",
justifySelf: "start",
}}
>
{testAuthQuery.isFetching ? "Checking…" : "Test Current Session"}
</button>
</div>
{/* Upload result */}
{uploadMutation.isSuccess && (
<div
className="scraper-result"
style={{
fontFamily: "inherit",
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>
)}
{/* Test auth result */}
{(testAuthQuery.isSuccess || testAuthQuery.isError) && (
<div
className="scraper-result"
style={{
fontFamily: "inherit",
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>
)}
</div>
</main>
</>
);
}