셀렉터·프록시·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>
351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→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>
|
|
);
|
|
}
|