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
This commit is contained in:
lekss361 2026-05-23 18:16:19 +03:00
parent 6c4e5a6809
commit 02c2ce6cd3
4 changed files with 364 additions and 464 deletions

View file

@ -9,7 +9,6 @@ const TABS = [
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
{ href: "/admin/scrape/cadastre", label: "🏗 Cadastre bulk harvest" },
{ href: "/admin/scrape/objective", label: "ETL Objective" },
{ href: "/admin/scrape/cian-cookies", label: "Cian Cookies" },
{ href: "/admin/leads", label: "Лиды PRINZIP" },
];

View file

@ -1,462 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import { cardStyle, inputStyle, labelStyle } from "@/lib/adminStyles";
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 [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [rawInput, setRawInput] = useState("");
const [showInstructions, setShowInstructions] = useState(false);
const saveToken = (v: string) => {
setToken(v);
if (typeof window !== "undefined") {
localStorage.setItem("admin_token", v);
}
};
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",
headers: { "X-Admin-Token": token },
body: JSON.stringify(preview.cookies),
},
);
},
});
const testAuthQuery = useQuery({
queryKey: ["cian-test-auth", token],
queryFn: () =>
apiFetch<TestAuthResponse>("/api/v1/admin/scrape/cian/test-auth", {
headers: { "X-Admin-Token": token },
}),
enabled: false, // only on explicit button click
retry: false,
});
const canUpload =
!!token &&
!!preview.cookies &&
previewKeys.length > 0 &&
!uploadMutation.isPending;
return (
<div style={{ maxWidth: 800 }}>
<h2 style={{ marginTop: 0, fontSize: 18 }}>
Cian Valuation Calculator Upload Cookies
</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Позволяет обновить сессию Циан для скрапера оценщика без перезапуска
backend. Backend endpoints: POST{" "}
<code
style={{
background: "#f3f4f6",
padding: "1px 5px",
borderRadius: 3,
fontSize: 12,
}}
>
/api/v1/admin/scrape/cian/upload-cookies
</code>{" "}
· GET{" "}
<code
style={{
background: "#f3f4f6",
padding: "1px 5px",
borderRadius: 3,
fontSize: 12,
}}
>
/api/v1/admin/scrape/cian/test-auth
</code>
</p>
{/* Instructions collapsible */}
<section style={{ ...cardStyle, marginBottom: 16 }}>
<button
onClick={() => setShowInstructions((v) => !v)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
color: "#1d4ed8",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span>{showInstructions ? "▼" : "▶"}</span>
Как получить cookies (инструкция)
</button>
{showInstructions && (
<ol
style={{
marginTop: 12,
paddingLeft: 20,
fontSize: 13,
color: "#374151",
lineHeight: 1.7,
}}
>
<li>
Открой{" "}
<strong>
<a
href="https://www.cian.ru/"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#1d4ed8" }}
>
cian.ru
</a>
</strong>{" "}
и войди под нужным аккаунтом.
</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
style={{
background: "#f3f4f6",
padding: "1px 4px",
borderRadius: 3,
}}
>
[{"{"}name, value, ...{"}"}]
</code>
.
</li>
<li>
<strong>DevTools (ручной способ):</strong> собери объект{" "}
<code
style={{
background: "#f3f4f6",
padding: "1px 4px",
borderRadius: 3,
}}
>
{"{"}
{'"'}name1{'"'}: {'"'}val1{'"'}, ...{"}"}
</code>
.
</li>
<li>Вставь JSON в поле ниже формат определится автоматически.</li>
<li>
Убедись что preview показывает нужные ключи, затем нажми Upload.
</li>
</ol>
)}
</section>
{/* Admin token input */}
<section style={cardStyle}>
<label style={{ display: "block", marginBottom: 16 }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
{/* Textarea */}
<label style={{ display: "block", marginBottom: 8 }}>
<span style={labelStyle}>Cookie JSON (paste here)</span>
<textarea
value={rawInput}
onChange={(e) => {
setRawInput(e.target.value);
// reset mutation result when input changes
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={{
...inputStyle,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
resize: "vertical",
minHeight: 240,
}}
/>
</label>
{/* Preview */}
{rawInput.trim() && (
<div
style={{
marginBottom: 16,
padding: "10px 12px",
borderRadius: 6,
border: "1px solid",
borderColor: preview.error ? "#fecaca" : "#bbf7d0",
background: preview.error ? "#fef2f2" : "#f0fdf4",
fontSize: 13,
}}
>
{preview.error ? (
<span style={{ color: "#991b1b" }}>
JSON error: {preview.error}
</span>
) : (
<span style={{ color: "#065f46" }}>
Detected <strong>{previewKeys.length} cookies</strong>:{" "}
{previewKeys.slice(0, 8).join(", ")}
{previewKeys.length > 8
? ` … +${previewKeys.length - 8} more`
: ""}
</span>
)}
</div>
)}
{/* Action buttons */}
<div
style={{
display: "flex",
gap: 12,
flexWrap: "wrap",
alignItems: "center",
}}
>
<button
onClick={(e) => {
e.preventDefault();
uploadMutation.mutate();
}}
disabled={!canUpload}
title={
!token
? "Введите Admin Token"
: !preview.cookies
? "Вставьте корректный JSON"
: undefined
}
style={primaryBtn}
>
{uploadMutation.isPending ? "Uploading…" : "Upload Cookies"}
</button>
<button
onClick={(e) => {
e.preventDefault();
void testAuthQuery.refetch();
}}
disabled={!token || testAuthQuery.isFetching}
title={!token ? "Введите Admin Token" : undefined}
style={secondaryBtn}
>
{testAuthQuery.isFetching ? "Checking…" : "Test Current Session"}
</button>
{!token && (
<span style={{ fontSize: 13, color: "#9a6700" }}>
введите Admin Token выше
</span>
)}
</div>
{/* Upload result */}
{uploadMutation.isSuccess && (
<div
style={{
marginTop: 14,
padding: 12,
background: uploadMutation.data.ok ? "#f0fdf4" : "#fef2f2",
border: "1px solid",
borderColor: uploadMutation.data.ok ? "#bbf7d0" : "#fecaca",
borderRadius: 6,
fontSize: 13,
color: uploadMutation.data.ok ? "#065f46" : "#991b1b",
}}
>
{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
style={{
marginTop: 14,
padding: 12,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
fontSize: 13,
color: "#991b1b",
wordBreak: "break-word",
}}
>
<strong>Error:</strong> {String(uploadMutation.error)}
</div>
)}
{/* Test auth result */}
{(testAuthQuery.isSuccess || testAuthQuery.isError) && (
<div
style={{
marginTop: 14,
padding: 12,
background:
testAuthQuery.isSuccess && testAuthQuery.data.authenticated
? "#f0fdf4"
: "#fef2f2",
border: "1px solid",
borderColor:
testAuthQuery.isSuccess && testAuthQuery.data.authenticated
? "#bbf7d0"
: "#fecaca",
borderRadius: 6,
fontSize: 13,
color:
testAuthQuery.isSuccess && testAuthQuery.data.authenticated
? "#065f46"
: "#991b1b",
wordBreak: "break-word",
}}
>
{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>
</div>
);
}
const primaryBtn: React.CSSProperties = {
padding: "10px 18px",
background: "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "8px 14px",
background: "#f3f4f6",
color: "#1f2937",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
};

View file

@ -0,0 +1,351 @@
"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>
</>
);
}

View file

@ -2,7 +2,14 @@
import { API_BASE_URL } from "@/lib/api";
type ActiveTab = "estimate" | "history" | "cache" | "avito" | "cian" | "yandex";
type ActiveTab =
| "estimate"
| "history"
| "cache"
| "avito"
| "cian"
| "yandex"
| "cian-cookies";
interface TopbarProps {
active: ActiveTab;
@ -15,6 +22,11 @@ const NAV_ITEMS: Array<{ key: ActiveTab; href: string; label: string }> = [
{ key: "avito", href: "/scrapers/avito", label: "Авито" },
{ key: "cian", href: "/scrapers/cian", label: "Циан" },
{ key: "yandex", href: "/scrapers/yandex", label: "Яндекс" },
{
key: "cian-cookies",
href: "/scrapers/cian-cookies",
label: "Cian Cookies",
},
];
export function Topbar({ active }: TopbarProps) {