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:
parent
6c4e5a6809
commit
02c2ce6cd3
4 changed files with 364 additions and 464 deletions
|
|
@ -9,7 +9,6 @@ const TABS = [
|
||||||
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
||||||
{ href: "/admin/scrape/cadastre", label: "🏗 Cadastre bulk harvest" },
|
{ href: "/admin/scrape/cadastre", label: "🏗 Cadastre bulk harvest" },
|
||||||
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
||||||
{ href: "/admin/scrape/cian-cookies", label: "Cian Cookies" },
|
|
||||||
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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\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",
|
|
||||||
};
|
|
||||||
351
tradein-mvp/frontend/src/app/scrapers/cian-cookies/page.tsx
Normal file
351
tradein-mvp/frontend/src/app/scrapers/cian-cookies/page.tsx
Normal 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\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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,14 @@
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
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 {
|
interface TopbarProps {
|
||||||
active: ActiveTab;
|
active: ActiveTab;
|
||||||
|
|
@ -15,6 +22,11 @@ const NAV_ITEMS: Array<{ key: ActiveTab; href: string; label: string }> = [
|
||||||
{ key: "avito", href: "/scrapers/avito", label: "Авито" },
|
{ key: "avito", href: "/scrapers/avito", label: "Авито" },
|
||||||
{ key: "cian", href: "/scrapers/cian", label: "Циан" },
|
{ key: "cian", href: "/scrapers/cian", label: "Циан" },
|
||||||
{ key: "yandex", href: "/scrapers/yandex", label: "Яндекс" },
|
{ key: "yandex", href: "/scrapers/yandex", label: "Яндекс" },
|
||||||
|
{
|
||||||
|
key: "cian-cookies",
|
||||||
|
href: "/scrapers/cian-cookies",
|
||||||
|
label: "Cian Cookies",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Topbar({ active }: TopbarProps) {
|
export function Topbar({ active }: TopbarProps) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue