/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→AI 매칭)에 흘려보내고 * 단계별 퍼널·소스별 결과·매칭 상품·검색 원가를 시각화한다. 셀렉터·프록시·매칭 회귀 점검용. * * product_code 는 TEST-<시각> 으로 자동 생성 — 프로덕션 데이터(잡·이력·비용통계)와 필터로 * 구분되고, negodata 는 UUID 만 매핑하므로 연동에 영향 없다. 자격증명은 서버에만 있으며 * 여기 결과엔 집계된 프록시 사용량(바이트·$)만 표시된다. */ import { useMutation, useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { get, post } from "../api/client"; import type { JobDetailRes, JobOutput, SearchRes } from "../api/types"; import { Button, Card, Empty, ErrorNote, Modal, PageHeader, ScrollTable } from "../components/ui"; import { usd, won } from "../lib/format"; const TOP_COLS = [ { key: "source", label: "소스" }, { key: "name", label: "상품명" }, { key: "price", label: "가격", align: "right" }, { key: "ship", label: "배송", align: "right" }, { key: "link", label: "" }, ] as const; const SOURCE_LABEL: Record = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" }; const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s; // 소스(발견 채널) 색 — 시리즈 색 재사용(엔터티 고정). 상품·가격 몰별 비교와 동일 언어. const srcColor = (s: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"; // 배송 표기 — 쿠팡 로켓 계열(로켓배송·판매자로켓·로켓프레시…) / 무료 / 유료 / 미상. function shipInfo(fee?: number | null, type?: string | null): { text: string; tone: "rocket" | "free" | "paid" | "none" } { const t = (type ?? "").toLowerCase(); if (t.startsWith("rocket")) { const label = t.includes("fresh") ? "로켓프레시" : t.includes("wow") ? "로켓와우" : t.includes("global") ? "로켓직구" : (t.includes("merchant") || t.includes("seller")) ? "판매자로켓" : "로켓배송"; return { text: label, tone: "rocket" }; } if (t === "free" || fee === 0) return { text: "무료", tone: "free" }; if (fee != null && fee > 0) return { text: `+${fee.toLocaleString("ko-KR")}원`, tone: "paid" }; return { text: "—", tone: "none" }; } // 소스·몰명 중복 제거 — 몰명이 없거나 소스 라벨과 같으면 소스만. const sourceMall = (source: string, mall?: string) => { const sl = sourceLabel(source); return mall && mall !== sl ? `${sl} · ${mall}` : sl; }; // 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑. const STAGE_LABEL: Record = { filter: "가격/몰 필터", outlier: "이상치 제거", ai_match: "AI 같은상품 판정", top_n: "최저가 top-N", }; function makeTestCode() { // Date.now 로 사람이 읽을 수 있는 시각 코드(중복 방지 + 목록 필터 구분). const d = new Date(); const p = (n: number) => String(n).padStart(2, "0"); return `TEST-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } interface FormState { product_name: string; model: string; specification: string; company: string; price: string; } export default function TestSearch() { const [form, setForm] = useState({ product_name: "", model: "", specification: "", company: "", price: "" }); const [jobId, setJobId] = useState(null); const [testCode, setTestCode] = useState(null); const startedAt = useRef(0); const submit = useMutation({ mutationFn: async () => { const code = makeTestCode(); const res = await post("/v1/lps/search", { data: [{ product_code: code, product_name: form.product_name.trim(), job_type: "manual", model: form.model.trim(), specification: form.specification.trim(), company: form.company.trim(), price: form.price.trim(), }], }); return { res, code }; }, onSuccess: ({ res, code }) => { const entry = res.items[0]; setTestCode(code); startedAt.current = Date.now(); setJobId(entry?.job_id ?? null); }, }); // 잡 완료까지 2초 폴링. DONE/DEAD 면 멈춘다. const job = useQuery({ queryKey: ["test-job", jobId], queryFn: () => get(`/v1/lps/jobs/${jobId}`), enabled: !!jobId, refetchInterval: (q) => { const st = q.state.data?.status; return st === "DONE" || st === "DEAD" ? false : 2000; }, }); const status = job.data?.status; const output = job.data?.output; const done = status === "DONE" || status === "DEAD"; // 진행 중엔 1초마다 경과 시간을 갱신(폴링 간격과 무관하게 매끄럽게), 완료되면 정지. const [elapsed, setElapsed] = useState(0); useEffect(() => { if (!jobId || done) return; const tick = () => setElapsed(Math.floor((Date.now() - startedAt.current) / 1000)); tick(); const t = setInterval(tick, 1000); return () => clearInterval(t); }, [jobId, done]); const pendingTooLong = status === "PENDING" && elapsed > 8; const reset = () => { setJobId(null); setTestCode(null); submit.reset(); }; return (

실제 크롤이라 프록시 비용 발생 · 비용 통계 집계 (상품코드 TEST-)

{/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
{ e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }} > setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
setForm((f) => ({ ...f, model: v }))} placeholder="선택" /> setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" /> setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */} {jobId && ( )}
{submit.isError && }
{jobId && ( {job.isError &&
} {pendingTooLong && (

{elapsed}초째 대기 중 — 워커가 실행 중인지 확인하세요 (./run_local_worker.sh). 워커가 없으면 크롤이 시작되지 않습니다.

)} {status === "DEAD" && (

검색 실패(재시도 소진) — {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"}

)}
)}
{/* 오른쪽: 결과 */}
{status === "DONE" && output ? ( ) : ( {!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요" : status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요" : "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"} )}
); } // ── 진행 스텝퍼 ────────────────────────────────────────────── function Progress({ status, elapsed }: { status?: string; elapsed: number }) { const steps = [ { key: "PENDING", label: "대기" }, { key: "RUNNING", label: "크롤·판정 중" }, { key: "DONE", label: "완료" }, ]; const order = ["PENDING", "RUNNING", "DONE"]; const curIdx = status === "DEAD" ? 1 : Math.max(0, order.indexOf(status ?? "PENDING")); return (
{steps.map((s, i) => { const done = i < curIdx || status === "DONE"; const active = i === curIdx && status !== "DONE"; return (
{active && } {s.label} {i < steps.length - 1 && }
); })} {elapsed}초
); } // ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ────── function ResultView({ output }: { output: JobOutput }) { const found = output.outcome === "found"; const m = output.metrics; const [showJson, setShowJson] = useState(false); const [copied, setCopied] = useState(false); const jsonText = JSON.stringify(output, null, 2); const copyJson = async () => { try { await navigator.clipboard.writeText(jsonText); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 클립보드 권한 없음 — 무시 */ } }; return ( <> {found ? (
같은 상품 찾음 {output.lowest && ( 최저가 {won(output.lowest.price)} ({sourceMall(output.lowest.source, output.lowest.mall_name)}) )}
) : ( 같은 상품 없음 {output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""} )} {output.query &&

최종 검색어: {output.query}

}
{output.stages && output.stages.length > 0 && ( )}
{output.sources && Object.keys(output.sources).length > 0 && ( {output.query && (

각 사이트 검색어 "{output.query}"

)}
    {Object.entries(output.sources).map(([s, v]) => (
  • {sourceLabel(s)} {v.error ? ( 실패 · {v.error.split(":")[0]} ) : ( <>{v.count ?? 0}건 )} {m?.source_ms?.[s] != null && · {(m.source_ms[s] / 1000).toFixed(1)}초}
  • ))}
)} {m && (
  • 총 비용{usd(m.cost?.total_usd)}
  • AI 판정·검색어{usd(m.cost?.ai_usd)}
  • 프록시 대역폭{usd(m.cost?.proxy_usd)}
  • {m.crawl && (
  • 프록시 전송량 {(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}회
  • )}
)}
{output.top && output.top.length > 0 && ( {output.top.map((p, i) => { const src = srcColor(p.source); const ship = shipInfo(p.shipping_fee, p.shipping_type); return ( {sourceLabel(p.source)} {p.name} {won(p.price)} {i === 0 && 최저} {ship.tone === "rocket" ? ( 🚀 {ship.text} ) : ( {ship.text} )} {p.detail_url && ( 사이트 → )} ); })} )}
{showJson && ( setShowJson(false)} actions={}>
{jsonText}
)} ); } // ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ────────── function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: number; out: number }[]; totalFound?: number }) { const rows = [ ...(totalFound != null ? [{ label: "수집", value: totalFound }] : []), ...stages.map((s) => ({ label: STAGE_LABEL[s.stage] ?? s.stage, value: s.out })), ]; const max = Math.max(1, ...rows.map((r) => r.value)); return (
    {rows.map((r, i) => { const prev = i > 0 ? rows[i - 1].value : null; const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소) const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null; return ( // 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
  • {r.label}
    0 ? "3px" : 0 }} aria-hidden />
    {r.value} {/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */} {drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"}
  • ); })}
); } // ── 폼 필드(라벨-인풋 연결, Settings 패턴 준수) ────────────── function Field({ id, label, value, onChange, placeholder, required, inputMode }: { id: string; label: string; value: string; onChange: (v: string) => void; placeholder?: string; required?: boolean; inputMode?: "numeric"; }) { return (
onChange(e.target.value)} placeholder={placeholder} inputMode={inputMode} autoComplete="off" className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
); }