feat(lps-admin): 테스트 검색 페이지 — 임의 상품을 실제 파이프라인에 흘려 점검

셀렉터·프록시·AI 매칭 회귀를 UI 에서 즉석 점검. API 는 lean(브라우저
없음)이라 크롤은 워커에만 있으므로, 기존 POST /v1/lps/search 로 잡을
넣고 GET /jobs/{id} 를 2초 폴링해 결과를 시각화(신규 백엔드 0).

- 폼(상품명 필수+모델/규격/제조사/현재가) → 제출 → 진행 스텝퍼
  (대기→크롤·판정중→완료, 1초 티커 경과시간) → 결과.
- 결과: 판정(최저가·소스), 파이프라인 퍼널(수집→필터→이상치→AI→top-N
  단계별 잔존 수), 소스별 수집(+실패 시 error 표시), 검색 원가
  (AI/프록시 분해·전송량), 매칭 상품 표, 원본 JSON.
- product_code=TEST-<시각> 자동 — 프로덕션과 필터 구분(negodata 는
  UUID 만 매핑, 연동 무영향). 자격증명은 서버에만, 결과엔 집계 사용량만.
- 워커 미실행 시 8초 후 '워커 확인' 힌트, DEAD 시 오류 표시.
- 작업 큐에 '테스트만'(TEST- 접두) 필터 칩 추가.
- 실기동 검증: 맥심 커피 검색 → 퍼널 80→2·쿠팡 9.5s·원가 /bin/zsh.002 렌더,
  콘솔 에러 0, 모바일 hscroll 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-14 09:13:54 +09:00
parent 3c8d0af2e4
commit c126d56631
5 changed files with 404 additions and 1 deletions

View File

@ -54,7 +54,7 @@ export interface JobDetailRes {
status?: JobStatusName;
attempts?: number;
max_attempts?: number;
output?: Record<string, unknown>;
output?: JobOutput;
last_error?: string;
}
@ -117,6 +117,49 @@ export interface SearchRes {
items: { product_code: string; job_id?: string; duplicated: boolean }[];
}
/** 정규화 상품(어댑터 출력) — 잡 결과의 lowest/top 원소. */
export interface NormalizedProduct {
source: string;
name: string;
price: number;
mall_name?: string;
detail_url?: string;
shipping_fee?: number | null;
shipping_type?: string | null;
}
export interface PipelineStage {
stage: string;
in: number;
out: number;
}
export interface SearchMetrics {
duration_ms?: number;
ai?: { calls: number; prompt_tokens: number; completion_tokens: number; est_cost_usd: number };
crawl?: { fetches: number; html_bytes: number; proxy_bytes: number; malls_crawled: string[] };
cost?: { ai_usd: number; proxy_usd: number; total_usd: number };
source_ms?: Record<string, number>;
}
/** 잡 결과(output) — 크롤 테스트가 소비하는 파이프라인 산출물. */
export interface JobOutput {
outcome?: "found" | "not_found";
query?: string;
round?: string;
rounds_tried?: number;
cached?: boolean;
total_found?: number;
kept?: number;
lowest?: NormalizedProduct | null;
top?: NormalizedProduct[];
by_mall?: MallEntry[];
stages?: PipelineStage[];
// 소스별 수집 결과 — 정상이면 {count}, 기술적 실패(차단·예외)면 {error}.
sources?: Record<string, { count?: number; error?: string }>;
metrics?: SearchMetrics;
}
export interface IpSessionRow {
source: string;
proxy_port?: number;

View File

@ -10,6 +10,7 @@ const MENU = [
{ to: "/products", label: "상품·가격" },
{ to: "/crawl", label: "크롤 상태" },
{ to: "/costs", label: "비용" },
{ to: "/test", label: "테스트 검색" },
{ to: "/settings", label: "설정" },
];

View File

@ -9,6 +9,7 @@ import Jobs from "./pages/Jobs";
import Products from "./pages/Products";
import Crawl from "./pages/Crawl";
import Costs from "./pages/Costs";
import TestSearch from "./pages/TestSearch";
import Settings from "./pages/Settings";
import "./index.css";
@ -25,6 +26,7 @@ const router = createBrowserRouter([
{ path: "/products", element: <Products /> },
{ path: "/crawl", element: <Crawl /> },
{ path: "/costs", element: <Costs /> },
{ path: "/test", element: <TestSearch /> },
{ path: "/settings", element: <Settings /> },
],
},

View File

@ -50,6 +50,13 @@ export default function Jobs() {
</button>
))}
</div>
<button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }}
aria-pressed={q === "TEST-"}
className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${
q === "TEST-" ? "border-primary-600 bg-primary-50 text-primary-700" : "border-line-200 text-ink-500 hover:text-ink-700"
}`}>
</button>
<form className="ml-auto flex gap-1.5" onSubmit={(e) => { e.preventDefault(); setQ(qInput.trim()); setPage(0); }}>
<input value={qInput} onChange={(e) => setQInput(e.target.value)} placeholder="상품코드·상품명 검색" aria-label="상품코드·상품명 검색"
className="w-56 rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600" />

View File

@ -0,0 +1,350 @@
/** (·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<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" };
const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s;
// 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑.
const STAGE_LABEL: Record<string, string> = {
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<FormState>({ product_name: "", model: "", specification: "", company: "", price: "" });
const [jobId, setJobId] = useState<string | null>(null);
const [testCode, setTestCode] = useState<string | null>(null);
const startedAt = useRef<number>(0);
const submit = useMutation({
mutationFn: async () => {
const code = makeTestCode();
const res = await post<SearchRes>("/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<JobDetailRes>(`/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 (
<div className="space-y-4">
<h1 className="text-[17px] font-bold tracking-tight"> </h1>
<p className="max-w-2xl text-[12px] leading-relaxed text-ink-500">
··AI .
<b> </b>,
( <code className="rounded bg-surface-2 px-1">TEST-</code> ).
</p>
<div className="grid gap-4 lg:grid-cols-[380px_1fr]">
<Card title="검색 조건">
<form
className="space-y-3"
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim()) submit.mutate(); }}
>
<Field id="pn" label="상품명" required value={form.product_name}
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
<div className="grid grid-cols-2 gap-3">
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
</div>
<Field id="spec" label="규격" value={form.specification}
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
<div className="flex gap-2 pt-1">
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || (!!jobId && status !== "DONE" && status !== "DEAD")}
className="flex-1 rounded-lg bg-primary-600 px-3 py-2 text-[13px] font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{submit.isPending ? "제출 중…" : "검색 실행"}
</button>
{jobId && (
<button type="button" onClick={reset}
className="rounded-lg border border-line-200 px-3 py-2 text-[13px] font-semibold text-ink-700 hover:bg-surface-2">
</button>
)}
</div>
{submit.isError && <ErrorNote error={submit.error} />}
</form>
</Card>
<div className="space-y-4">
{!jobId && (
<Card title="결과"><Empty> </Empty></Card>
)}
{jobId && (
<Card title="진행 상태" hint={testCode ?? undefined}>
<Progress status={status} elapsed={elapsed} />
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
{pendingTooLong && (
<p role="alert" className="mt-3 rounded-lg bg-warn-50 px-3 py-2 text-[12px] font-medium text-warn-700">
{elapsed} (<code>./run_local_worker.sh</code>). .
</p>
)}
{status === "DEAD" && (
<p role="alert" className="mt-3 rounded-lg bg-dead-50 px-3 py-2 text-[12px] font-medium text-dead-600">
( ) {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"}
</p>
)}
</Card>
)}
{status === "DONE" && output && <ResultView output={output} />}
</div>
</div>
</div>
);
}
// ── 진행 스텝퍼 ──────────────────────────────────────────────
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 (
<div className="flex items-center gap-2">
{steps.map((s, i) => {
const done = i < curIdx || status === "DONE";
const active = i === curIdx && status !== "DONE";
return (
<div key={s.key} className="flex items-center gap-2">
<span className={`flex h-6 items-center rounded-full px-2.5 text-[12px] font-semibold ${
done ? "bg-ok-50 text-ok-600" : active ? "bg-run-50 text-run-600" : "bg-surface-2 text-ink-400"
}`}>
{active && <span className="mr-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-run-600" aria-hidden />}
{s.label}
</span>
{i < steps.length - 1 && <span className="h-px w-4 bg-line-200" aria-hidden />}
</div>
);
})}
<span className="ml-auto tnum text-[12px] text-ink-400">{elapsed}</span>
</div>
);
}
// ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ──────
function ResultView({ output }: { output: JobOutput }) {
const found = output.outcome === "found";
const m = output.metrics;
return (
<>
<Card title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
{found ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600"> </span>
{output.lowest && (
<span className="text-[15px] font-bold">
{won(output.lowest.price)}
<span className="ml-1.5 text-[12px] font-medium text-ink-400">
({sourceLabel(output.lowest.source)}{output.lowest.mall_name ? ` · ${output.lowest.mall_name}` : ""})
</span>
</span>
)}
</div>
) : (
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[12px] font-semibold text-ink-500">
{output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""}
</span>
)}
{output.query && <p className="mt-2 text-[12px] text-ink-500"> : <b>{output.query}</b></p>}
</Card>
{output.stages && output.stages.length > 0 && (
<Card title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
<FunnelChart stages={output.stages} totalFound={output.total_found} />
</Card>
)}
<div className="grid gap-4 md:grid-cols-2">
{output.sources && Object.keys(output.sources).length > 0 && (
<Card title="소스별 수집" hint="검색어 매칭 전 원본 건수">
<ul className="space-y-1.5 text-[12px]">
{Object.entries(output.sources).map(([s, v]) => (
<li key={s} className="flex items-baseline justify-between gap-2">
<span className="font-semibold">{sourceLabel(s)}</span>
<span className="tnum text-right">
{v.error ? (
<span className="font-semibold text-dead-600"> · {v.error.split(":")[0]}</span>
) : (
<>{v.count ?? 0}</>
)}
{m?.source_ms?.[s] != null && <span className="ml-1 text-ink-400">· {(m.source_ms[s] / 1000).toFixed(1)}</span>}
</span>
</li>
))}
</ul>
</Card>
)}
{m && (
<Card title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
<ul className="space-y-1.5 text-[12px]">
<li className="flex justify-between"><span className="text-ink-500"> </span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">AI ·</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500"> </span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li>
{m.crawl && (
<li className="flex justify-between border-t border-line-100 pt-1.5">
<span className="text-ink-500"> </span>
<span className="tnum text-ink-400">{(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}</span>
</li>
)}
</ul>
</Card>
)}
</div>
{output.top && output.top.length > 0 && (
<Card title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}`}>
<div className="overflow-x-auto">
<table className="w-full text-left text-[12px]">
<thead>
<tr className="border-b border-line-200 text-[11px] text-ink-400">
<th className="pb-1.5 pr-3 font-semibold"></th>
<th className="pb-1.5 pr-3 font-semibold"></th>
<th className="pb-1.5 pr-3 text-right font-semibold"></th>
<th className="pb-1.5 font-semibold"></th>
</tr>
</thead>
<tbody>
{output.top.map((p, i) => (
<tr key={i} className="border-b border-line-100">
<td className="py-1.5 pr-3 font-semibold">{sourceLabel(p.source)}</td>
<td className="max-w-md truncate py-1.5 pr-3">{p.name}</td>
<td className="tnum py-1.5 pr-3 text-right font-semibold">{won(p.price)}</td>
<td className="py-1.5">
{p.detail_url && <a href={p.detail_url} target="_blank" rel="noreferrer" className="text-primary-600 hover:underline"> </a>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<details>
<summary className="cursor-pointer text-[12px] font-semibold text-ink-400"> (JSON)</summary>
<pre className="mt-1 max-h-72 overflow-auto rounded-lg bg-surface-2 p-2 text-[11px] leading-relaxed">{JSON.stringify(output, null, 2)}</pre>
</details>
</>
);
}
// ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ──────────
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 (
<ul className="space-y-1.5">
{rows.map((r, i) => (
<li key={i} className="flex items-center gap-2 text-[12px]">
<span className="w-28 shrink-0 text-ink-500">{r.label}</span>
<div className="h-5 flex-1 overflow-hidden rounded bg-surface-2">
<div className="h-full rounded bg-primary-600" style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "2px" : 0 }} aria-hidden />
</div>
<span className="tnum w-10 shrink-0 text-right font-semibold">{r.value}</span>
</li>
))}
</ul>
);
}
// ── 폼 필드(라벨-인풋 연결, 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 (
<div>
<label htmlFor={id} className="mb-1 block text-[12px] font-semibold text-ink-700">
{label}{required && <span className="ml-0.5 text-dead-600">*</span>}
</label>
<input id={id} value={value} onChange={(e) => 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" />
</div>
);
}