From c126d56631988eb002c30eb741e9f30914ea7c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 14 Jul 2026 09:13:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps-admin):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=84=EC=9D=98=20=EC=83=81=ED=92=88=EC=9D=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=EC=97=90=20=ED=9D=98=EB=A0=A4=20=EC=A0=90=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 셀렉터·프록시·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 --- lps-admin/src/api/types.ts | 45 +++- lps-admin/src/components/Layout.tsx | 1 + lps-admin/src/main.tsx | 2 + lps-admin/src/pages/Jobs.tsx | 7 + lps-admin/src/pages/TestSearch.tsx | 350 ++++++++++++++++++++++++++++ 5 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 lps-admin/src/pages/TestSearch.tsx diff --git a/lps-admin/src/api/types.ts b/lps-admin/src/api/types.ts index fb273c3..dc04af5 100644 --- a/lps-admin/src/api/types.ts +++ b/lps-admin/src/api/types.ts @@ -54,7 +54,7 @@ export interface JobDetailRes { status?: JobStatusName; attempts?: number; max_attempts?: number; - output?: Record; + 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; +} + +/** 잡 결과(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; + metrics?: SearchMetrics; +} + export interface IpSessionRow { source: string; proxy_port?: number; diff --git a/lps-admin/src/components/Layout.tsx b/lps-admin/src/components/Layout.tsx index 9abc16a..ae335ee 100644 --- a/lps-admin/src/components/Layout.tsx +++ b/lps-admin/src/components/Layout.tsx @@ -10,6 +10,7 @@ const MENU = [ { to: "/products", label: "상품·가격" }, { to: "/crawl", label: "크롤 상태" }, { to: "/costs", label: "비용" }, + { to: "/test", label: "테스트 검색" }, { to: "/settings", label: "설정" }, ]; diff --git a/lps-admin/src/main.tsx b/lps-admin/src/main.tsx index c315848..8c8010e 100644 --- a/lps-admin/src/main.tsx +++ b/lps-admin/src/main.tsx @@ -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: }, { path: "/crawl", element: }, { path: "/costs", element: }, + { path: "/test", element: }, { path: "/settings", element: }, ], }, diff --git a/lps-admin/src/pages/Jobs.tsx b/lps-admin/src/pages/Jobs.tsx index e80ab9f..b4f2aa4 100644 --- a/lps-admin/src/pages/Jobs.tsx +++ b/lps-admin/src/pages/Jobs.tsx @@ -50,6 +50,13 @@ export default function Jobs() { ))} +
{ e.preventDefault(); setQ(qInput.trim()); setPage(0); }}> 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" /> diff --git a/lps-admin/src/pages/TestSearch.tsx b/lps-admin/src/pages/TestSearch.tsx new file mode 100644 index 0000000..e860836 --- /dev/null +++ b/lps-admin/src/pages/TestSearch.tsx @@ -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 = { 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" /> +
+ ); +}