/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→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 { Card, Empty, ErrorNote } from "../components/ui"; import { usd, won } from "../lib/format"; const SOURCE_LABEL: Record = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" }; const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s; // 파이프라인 단계 한글 라벨 — 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 (

테스트 검색

임의의 상품을 실제 검색 파이프라인에 흘려보내 셀렉터·프록시·AI 매칭이 정상인지 점검합니다. 워커가 실제로 크롤하므로 프록시 대역폭 비용이 실제로 발생하고, 결과는 비용 통계에 집계됩니다 (상품코드는 TEST- 접두로 구분).

{ e.preventDefault(); if (form.product_name.trim()) 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="선택 — 있으면 가격밴드 필터 기준" />
{jobId && ( )}
{submit.isError && }
{!jobId && ( 왼쪽에서 상품명을 입력하고 검색을 실행하세요 )} {jobId && ( {job.isError &&
} {pendingTooLong && (

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

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

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

)}
)} {status === "DONE" && output && }
); } // ── 진행 스텝퍼 ────────────────────────────────────────────── 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; return ( <> {found ? (
같은 상품 찾음 {output.lowest && ( 최저가 {won(output.lowest.price)} ({sourceLabel(output.lowest.source)}{output.lowest.mall_name ? ` · ${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 && (
    {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) => ( ))}
소스 상품명 가격 링크
{sourceLabel(p.source)} {p.name} {won(p.price)} {p.detail_url && 열기 ↗}
)}
원본 결과(JSON)
{JSON.stringify(output, null, 2)}
); } // ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ────────── 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) => (
  • {r.label}
    0 ? "2px" : 0 }} aria-hidden />
    {r.value}
  • ))}
); } // ── 폼 필드(라벨-인풋 연결, 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" />
); }