- 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
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
"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\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>
|
||
</>
|
||
);
|
||
}
|