o2o-negosium-original/lps-temp-fe/index.html
민헌 a80109cdcf chore(lps): FE 원가 타일 AI/DECODO 비용 분해 + config example cost_per_gb
- 임시 FE: 총 비용 타일에 AI·DECODO 분해, 크롤 트래픽에 프록시 경유 바이트(과금분)
- config.local.toml.example: DecodoConfig.cost_per_gb 안내

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:34:57 +09:00

673 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LPS · 최저가 검색 콘솔 (임시 FE)</title>
<style>
:root{
--bg:#f5f6f8; --surface:#ffffff; --surface-2:#fafbfc;
--border:#e6e8ec; --border-2:#eef0f3;
--text-900:#1a1d24; --text-700:#3d434f; --text-500:#6b7280; --text-400:#9aa1ac;
--primary-600:#4f46e5; --primary-500:#635bff; --primary-50:#eef0ff;
--naver:#03c75a; --coupang:#f0455b; --final:#4f46e5;
--ok-600:#0f9d58; --ok-50:#e7f6ee;
--warn-600:#c77800; --warn-50:#fdf3e3;
--run-600:#2563eb; --run-50:#e8f0ff;
--dead-600:#dc2626; --dead-50:#fdeaea;
--radius:14px; --radius-sm:9px;
--shadow:0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06);
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--bg);color:var(--text-900);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
a{color:var(--primary-600);text-decoration:none}
a:hover{text-decoration:underline}
/* ---- layout ---- */
header.top{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.82);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
.top-inner{max-width:1180px;margin:0 auto;padding:12px 22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16px}
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--primary-500),#8b7bff);display:grid;place-items:center;color:#fff;font-size:15px;font-weight:800}
.brand small{display:block;font-weight:500;font-size:11px;color:var(--text-400);letter-spacing:.02em}
.top-spacer{flex:1}
.stat-badges{display:flex;gap:7px;flex-wrap:wrap}
.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700)}
.badge .dot{width:7px;height:7px;border-radius:50%;background:var(--text-400)}
.badge b{font-variant-numeric:tabular-nums}
.badge.pending .dot{background:var(--warn-600)} .badge.running .dot{background:var(--run-600)}
.badge.done .dot{background:var(--ok-600)} .badge.dead .dot{background:var(--dead-600)}
.health{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--text-500);font-weight:600}
.pulse{width:8px;height:8px;border-radius:50%;background:var(--dead-600)}
.pulse.up{background:var(--ok-600);box-shadow:0 0 0 0 rgba(15,157,88,.5);animation:pulse 2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(15,157,88,.45)}70%{box-shadow:0 0 0 7px rgba(15,157,88,0)}100%{box-shadow:0 0 0 0 rgba(15,157,88,0)}}
main{max-width:1180px;margin:0 auto;padding:22px;display:grid;grid-template-columns:400px 1fr;gap:20px;align-items:start}
@media (max-width:940px){main{grid-template-columns:1fr}}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
.card + .card{margin-top:20px}
.card-head{padding:15px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px}
.card-head h2{margin:0;font-size:14px;font-weight:700;letter-spacing:-.01em}
.card-head .hint{margin-left:auto;font-size:11.5px;color:var(--text-400);font-weight:500}
.card-head .idx{width:22px;height:22px;border-radius:7px;background:var(--primary-50);color:var(--primary-600);display:grid;place-items:center;font-size:12px;font-weight:800}
.card-body{padding:18px}
/* ---- form ---- */
.field{margin-bottom:13px}
.field:last-child{margin-bottom:0}
.field label{display:block;font-size:12px;font-weight:600;color:var(--text-700);margin-bottom:5px}
.field label .req{color:var(--coupang);margin-left:2px}
.field .sub{font-weight:500;color:var(--text-400);font-size:11px}
input,select,textarea{width:100%;padding:9px 11px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;color:var(--text-900);background:var(--surface);transition:border-color .12s,box-shadow .12s}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-500);box-shadow:0 0 0 3px var(--primary-50)}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:11px}
.btn{appearance:none;border:1px solid transparent;border-radius:var(--radius-sm);font-family:inherit;font-size:13px;font-weight:600;padding:10px 15px;cursor:pointer;transition:background .12s,border-color .12s,color .12s;display:inline-flex;align-items:center;justify-content:center;gap:7px}
.btn-primary{background:var(--primary-600);color:#fff;width:100%}
.btn-primary:hover{background:var(--primary-500)}
.btn-primary:disabled{background:#c3c6cf;cursor:not-allowed}
.btn-ghost{background:var(--surface);border-color:var(--border);color:var(--text-700)}
.btn-ghost:hover{background:var(--surface-2)}
.btn-sm{padding:7px 11px;font-size:12px}
.examples{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
.chip{font-size:11.5px;padding:4px 9px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700);cursor:pointer}
.chip:hover{border-color:var(--primary-500);color:var(--primary-600);background:var(--primary-50)}
/* ---- job tracking ---- */
.empty{padding:38px 20px;text-align:center;color:var(--text-400)}
.empty .big{font-size:30px;margin-bottom:8px;opacity:.5}
.job-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.job-id{font-family:var(--mono);font-size:12px;background:var(--surface-2);border:1px solid var(--border-2);padding:4px 8px;border-radius:6px;color:var(--text-700)}
.status-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:700}
.status-pill.PENDING{background:var(--warn-50);color:var(--warn-600)}
.status-pill.RUNNING{background:var(--run-50);color:var(--run-600)}
.status-pill.DONE{background:var(--ok-50);color:var(--ok-600)}
.status-pill.DEAD{background:var(--dead-50);color:var(--dead-600)}
.status-pill .spin{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.outcome-hero{display:flex;align-items:flex-end;gap:14px;padding:16px;border-radius:var(--radius-sm);background:linear-gradient(135deg,#f7f8ff,#f0f3ff);border:1px solid var(--border-2);margin-bottom:16px}
.outcome-hero.nf{background:linear-gradient(135deg,#fbfbfc,#f4f5f7)}
.price-big{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;font-variant-numeric:tabular-nums}
.price-big .won{font-size:18px;font-weight:700;color:var(--text-500);margin-left:2px}
.src-tag{font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px;color:#fff;letter-spacing:.02em;white-space:nowrap}
.src-tag.naver{background:var(--naver)} .src-tag.coupang{background:var(--coupang)}
.src-tag.gmarket{background:#00b25a} .src-tag.auction{background:#e60012} .src-tag.st11{background:#ff0038}
.src-tag.fallback{position:relative}
.src-tag.fallback::after{content:"크롤";position:absolute;top:-6px;right:-6px;font-size:8px;background:#1a1d24;color:#fff;padding:1px 3px;border-radius:4px;letter-spacing:0}
.ship-tag{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px;border:1px solid transparent;white-space:nowrap}
.ship-tag.rocket{background:#eef4ff;color:#2f6fed;border-color:#d5e3ff}
.ship-tag.rocket_merchant{background:#f3eeff;color:#7a4fe0;border-color:#e3d8ff}
.ship-tag.free{background:var(--ok-50);color:var(--ok-600)}
.ship-tag.paid{background:#fbecec;color:var(--dead-600)}
.ship-tag.unknown{background:var(--surface-2);color:var(--text-400);border-color:var(--border-2)}
.outcome-label{font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}
/* funnel / stages */
.stages{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.stage{flex:1;min-width:82px;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px;text-align:center;position:relative}
.stage .st-name{font-size:11px;color:var(--text-500);font-weight:600;margin-bottom:4px;text-transform:capitalize}
.stage .st-io{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums}
.stage .st-io .arr{color:var(--text-400);font-weight:500;margin:0 3px}
.stage .st-drop{font-size:10.5px;color:var(--coupang);font-weight:600;margin-top:2px}
.src-counts{display:flex;gap:8px;margin-bottom:16px}
.src-count{flex:1;border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 12px;background:var(--surface-2)}
.src-count .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px}
.src-count .lbl.naver{color:var(--naver)} .src-count .lbl.coupang{color:var(--coupang)}
.src-count .n{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums}
.src-count .err{font-size:11px;color:var(--dead-600);font-weight:600}
/* 몰별 최저가 분해 */
.mall-bars{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
.mall-bar{display:flex;align-items:center;gap:10px}
.mall-bar .mname{width:112px;flex-shrink:0;font-size:12px;font-weight:600;color:var(--text-700);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:5px}
.mall-bar .mname .sdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.mall-bar .track{flex:1;height:22px;background:var(--surface-2);border-radius:6px;position:relative;overflow:hidden}
.mall-bar .fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;opacity:.16}
.mall-bar .mprice{position:absolute;right:8px;top:0;bottom:0;display:flex;align-items:center;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text-900)}
.mall-bar.best .mprice{color:var(--ok-600)}
.mall-bar .badge-best{position:absolute;left:8px;top:0;bottom:0;display:flex;align-items:center;font-size:10px;font-weight:800;color:var(--ok-600)}
.sec-title{font-size:12px;font-weight:700;color:var(--text-700);margin:0 0 9px;display:flex;align-items:center;gap:7px}
.sec-title .cnt{font-size:11px;color:var(--text-400);font-weight:600}
/* 검색 원가 계측 */
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
@media (max-width:520px){.metrics{grid-template-columns:repeat(2,1fr)}}
.metric{border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 11px;background:var(--surface-2)}
.metric .mk{font-size:10.5px;font-weight:700;color:var(--text-400);text-transform:uppercase;letter-spacing:.03em;margin-bottom:4px}
.metric .mv{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
.metric .mv small{font-size:11px;font-weight:600;color:var(--text-500)}
.metric .msub{font-size:10.5px;color:var(--text-400);font-weight:600;margin-top:2px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.03em;padding:8px 10px;border-bottom:1px solid var(--border-2)}
td{padding:9px 10px;border-bottom:1px solid var(--border-2);vertical-align:top}
tr:last-child td{border-bottom:none}
.price-cell{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
.name-cell{max-width:340px}
.name-cell a{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rank{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:var(--surface-2);border:1px solid var(--border-2);font-size:11px;font-weight:800;color:var(--text-500)}
.rank.top{background:var(--primary-50);color:var(--primary-600);border-color:transparent}
/* ---- chart ---- */
.chart-controls{display:flex;gap:9px;align-items:flex-end;margin-bottom:16px}
.chart-controls .field{flex:1;margin:0}
.chart-wrap{position:relative;width:100%;overflow-x:auto}
svg.chart{display:block;width:100%;height:auto;min-width:520px}
.legend{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
.legend-item{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--text-700);cursor:pointer;user-select:none}
.legend-item .swatch{width:14px;height:3px;border-radius:2px}
.legend-item.off{opacity:.35}
.legend-item .swatch.dashed{background:none!important;border-top:2px dashed}
.chart-tip{position:absolute;pointer-events:none;background:#1a1d24;color:#fff;padding:8px 10px;border-radius:8px;font-size:11.5px;box-shadow:0 6px 24px rgba(0,0,0,.2);opacity:0;transition:opacity .1s;z-index:5;min-width:130px;transform:translate(-50%,-115%)}
.chart-tip .tt-time{color:#9aa1ac;font-size:10.5px;margin-bottom:5px}
.chart-tip .tt-row{display:flex;justify-content:space-between;gap:12px;font-variant-numeric:tabular-nums}
.chart-tip .tt-row .k{display:flex;align-items:center;gap:6px}
.chart-tip .tt-dot{width:7px;height:7px;border-radius:50%}
/* ---- misc ---- */
.toast-wrap{position:fixed;bottom:22px;right:22px;z-index:100;display:flex;flex-direction:column;gap:9px}
.toast{background:#1a1d24;color:#fff;padding:12px 16px;border-radius:10px;font-size:13px;box-shadow:0 8px 30px rgba(0,0,0,.24);display:flex;align-items:center;gap:9px;animation:slideIn .25s ease;max-width:340px}
.toast.err{background:#b91c1c} .toast.ok{background:#0f9d58}
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
.muted{color:var(--text-400)}
.spin-inline{width:13px;height:13px;border:2px solid var(--border);border-right-color:var(--primary-500);border-radius:50%;display:inline-block;animation:spin .7s linear infinite;vertical-align:-2px}
.cfg-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-500)}
.cfg-row input{width:auto;flex:1;padding:6px 9px;font-size:12px;font-family:var(--mono)}
code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:4px;border:1px solid var(--border-2)}
</style>
</head>
<body>
<header class="top">
<div class="top-inner">
<div class="brand">
<div class="logo">₩</div>
<div>LPS 최저가 검색 콘솔
<small>임시 프론트엔드 · API 통신 확인용</small>
</div>
</div>
<div class="top-spacer"></div>
<div class="stat-badges" id="queueBadges">
<span class="badge pending"><span class="dot"></span>PENDING <b>–</b></span>
<span class="badge running"><span class="dot"></span>RUNNING <b>–</b></span>
<span class="badge done"><span class="dot"></span>DONE <b>–</b></span>
<span class="badge dead"><span class="dot"></span>DEAD <b>–</b></span>
</div>
<div class="health"><span class="pulse" id="healthDot"></span><span id="healthTxt">확인 중…</span></div>
</div>
</header>
<main>
<!-- ============ LEFT: 검색 요청 ============ -->
<section>
<div class="card">
<div class="card-head"><span class="idx">1</span><h2>검색 요청</h2><span class="hint">POST /v1/lps/search</span></div>
<div class="card-body">
<div class="field">
<label>빠른 예시</label>
<div class="examples" id="examples"></div>
</div>
<form id="searchForm">
<div class="row2">
<div class="field">
<label>상품 코드 <span class="req">*</span> <span class="sub">이력 키</span></label>
<input name="product_code" required placeholder="T1" autocomplete="off"/>
</div>
<div class="field">
<label>우선순위 <span class="sub">job_type</span></label>
<select name="job_type">
<option value="new_product">new_product · 1 (최우선)</option>
<option value="manual" selected>manual · 2 (기본)</option>
<option value="partner">partner · 3</option>
<option value="batch">batch · 4</option>
</select>
</div>
</div>
<div class="field">
<label>상품명 <span class="req">*</span></label>
<input name="product_name" required placeholder="맥심 모카골드 커피믹스" autocomplete="off"/>
</div>
<div class="field">
<label>규격 <span class="sub">자유 서술 — AI가 해석</span></label>
<input name="specification" placeholder="1박스, 160개입" autocomplete="off"/>
</div>
<div class="row2">
<div class="field">
<label>모델명 <span class="sub">선택</span></label>
<input name="model" placeholder="모카골드" autocomplete="off"/>
</div>
<div class="field">
<label>제조사 <span class="sub">선택</span></label>
<input name="company" placeholder="동서식품" autocomplete="off"/>
</div>
</div>
<div class="field">
<label>현재가 <span class="sub">있으면 가격밴드 필터 기준</span></label>
<input name="price" inputmode="numeric" placeholder="25000" autocomplete="off"/>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">검색 요청 보내기</button>
</form>
</div>
</div>
<div class="card">
<div class="card-head"><h2>API 설정</h2><span class="hint">Base URL</span></div>
<div class="card-body">
<div class="cfg-row">
<input id="baseUrl" value="http://localhost:9600"/>
<button class="btn btn-ghost btn-sm" id="pingBtn">확인</button>
</div>
<p class="muted" style="margin:10px 0 0;font-size:11.5px">
이 페이지는 <code>:5173</code>에서 서빙되어야 CORS가 통과합니다 (<code>serve.sh</code> 참고).
</p>
</div>
</div>
</section>
<!-- ============ RIGHT: 결과 + 그래프 ============ -->
<section>
<!-- 작업 추적 / 상품 정보 -->
<div class="card">
<div class="card-head"><span class="idx">2</span><h2>작업 추적 &amp; 상품 정보</h2><span class="hint" id="jobHint">GET /v1/lps/jobs/{id}</span></div>
<div class="card-body" id="jobBody">
<div class="empty">
<div class="big">📦</div>
왼쪽에서 검색을 요청하면 여기에서<br/>진행 상태와 최저가 결과가 실시간으로 표시됩니다.
</div>
</div>
</div>
<!-- 최저가 이력 그래프 -->
<div class="card">
<div class="card-head"><span class="idx">3</span><h2>최저가 이력 그래프</h2><span class="hint">GET /v1/lps/products/{code}/history</span></div>
<div class="card-body">
<div class="chart-controls">
<div class="field">
<label>상품 코드</label>
<input id="histCode" placeholder="P1" autocomplete="off"/>
</div>
<button class="btn btn-ghost btn-sm" id="loadHistBtn" style="height:37px">이력 불러오기</button>
</div>
<div id="chartArea">
<div class="empty"><div class="big">📈</div>상품 코드를 입력하고 이력을 불러오세요.</div>
</div>
</div>
</div>
</section>
</main>
<div class="toast-wrap" id="toasts"></div>
<script>
"use strict";
const $ = (s,el=document)=>el.querySelector(s);
const $$ = (s,el=document)=>Array.from(el.querySelectorAll(s));
const base = ()=> $("#baseUrl").value.replace(/\/+$/,"");
const won = n => (n==null? "—" : Number(n).toLocaleString("ko-KR"));
const esc = s => (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
/* 배송 뱃지: shipping_type + shipping_fee → 라벨. 네이버는 항상 '배송비 별도'. */
function shipBadge(p){
const t=p.shipping_type, fee=p.shipping_fee;
if(p.source==="naver") return `<span class="ship-tag unknown" title="네이버 오픈API는 배송비 제외 상품가입니다">배송비 별도</span>`;
const map={
rocket:["rocket","🚀 로켓"], rocket_merchant:["rocket_merchant","🚀 판매자로켓"],
free:["free","무료배송"], paid:["paid", fee!=null?`배송비 ${won(fee)}원`:"유료배송"],
};
if(t && map[t]) return `<span class="ship-tag ${map[t][0]}">${map[t][1]}</span>`;
return `<span class="ship-tag unknown">배송비 미확인</span>`;
}
/* 실구매가(상품가+확정 배송비). 미확인이면 null. */
function totalPrice(p){ return (typeof p.shipping_fee==="number") ? p.price+p.shipping_fee : null; }
/* 소스 메타: 표시명·색상. gmarket/auction/st11 은 네이버 폴백 크롤 소스. */
const SRC = {
naver: {label:"NAVER", color:"#03c75a", crawl:false},
coupang:{label:"COUPANG",color:"#f0455b", crawl:false},
gmarket:{label:"G마켓", color:"#00b25a", crawl:true},
auction:{label:"옥션", color:"#e60012", crawl:true},
st11: {label:"11번가", color:"#ff0038", crawl:true},
};
function srcColor(s){ return (SRC[s]||{}).color || "#6b7280"; }
function srcTag(p){
const m=SRC[p.source]||{label:p.source};
return `<span class="src-tag ${p.source}${m.crawl?' fallback':''}" title="${m.crawl?'네이버 미커버 → 실사이트 크롤':''}">${esc(m.label)}</span>`;
}
/* 판매몰 표시명: 네이버 가격비교(mall='네이버')는 '여러 판매자 최저가 롤업'임을 명시. */
function mallLabel(p){
if(p.source==="coupang") return "쿠팡";
const m=p.mall_name||(SRC[p.source]||{}).label||"기타";
return (m==="네이버") ? "네이버 가격비교" : m;
}
function isCatalog(p){ return p.source==="naver" && (p.mall_name==="네이버"); }
/* ---------- toast ---------- */
function toast(msg,type=""){
const t=document.createElement("div"); t.className="toast "+type; t.textContent=msg;
$("#toasts").appendChild(t); setTimeout(()=>{t.style.opacity="0";t.style.transition="opacity .3s";setTimeout(()=>t.remove(),300)},3200);
}
/* ---------- fetch helper ---------- */
async function api(path,opts){
const r = await fetch(base()+path, Object.assign({headers:{"Content-Type":"application/json"}},opts));
const txt = await r.text();
let data; try{ data = txt? JSON.parse(txt):{} }catch(e){ throw new Error("응답 파싱 실패: "+txt.slice(0,120)) }
if(!r.ok) throw new Error("HTTP "+r.status+" · "+(data?.result?.desc||txt.slice(0,120)));
if(data?.result && data.result.success===false) throw new Error(data.result.desc||"API 실패");
return data;
}
/* ---------- health + queue polling ---------- */
async function refreshHealth(){
try{
const t = await api("/healthz");
$("#healthDot").className="pulse up";
$("#healthTxt").textContent = "온라인 · "+(typeof t==="string"?t.replace("T"," ").slice(0,19):"");
}catch(e){
$("#healthDot").className="pulse";
$("#healthTxt").textContent="오프라인";
}
}
async function refreshQueue(){
try{
const d = await api("/v1/lps/queue/stats");
const c = d.counts||{};
const b = $$("#queueBadges .badge b");
b[0].textContent=c.PENDING??0; b[1].textContent=c.RUNNING??0; b[2].textContent=c.DONE??0; b[3].textContent=c.DEAD??0;
}catch(e){}
}
function poll(){ refreshHealth(); refreshQueue(); }
poll(); setInterval(poll, 4000);
$("#pingBtn").addEventListener("click",()=>{ refreshHealth(); refreshQueue(); toast("연결 확인 중…"); });
/* ---------- examples ---------- */
const EXAMPLES = [
{label:"맥심 커피", product_code:"T1", product_name:"맥심 모카골드 커피믹스", specification:"1박스, 160개입", company:"동서식품", model:"모카골드", price:"25000"},
{label:"신라면", product_code:"T2", product_name:"농심 신라면", specification:"1박스 40개입", company:"농심", price:"22000"},
{label:"P1 (샘플이력)", product_code:"P1", product_name:"테스트 상품 P1", specification:"", price:""},
];
const exWrap = $("#examples");
EXAMPLES.forEach(ex=>{
const c=document.createElement("button"); c.type="button"; c.className="chip"; c.textContent=ex.label;
c.addEventListener("click",()=>{ for(const k in ex){ if(k==="label")continue; const f=$(`[name=${k}]`); if(f) f.value=ex[k]; } toast("예시 채움: "+ex.label); });
exWrap.appendChild(c);
});
/* ---------- submit search ---------- */
let pollTimer=null;
$("#searchForm").addEventListener("submit", async e=>{
e.preventDefault();
const fd=new FormData(e.target); const item={};
fd.forEach((v,k)=> item[k]=String(v).trim());
if(!item.product_code||!item.product_name){ toast("상품 코드와 상품명은 필수입니다","err"); return; }
const btn=$("#submitBtn"); btn.disabled=true; btn.innerHTML='<span class="spin-inline"></span> 요청 중…';
try{
const d = await api("/v1/lps/search",{method:"POST",body:JSON.stringify({data:[item]})});
const it = (d.items||[])[0]||{};
if(it.duplicated){ toast("이미 처리 중인 활성 작업이라 중복 접수 생략됨","err"); }
else if(it.job_id){ toast("접수 완료 · 추적 시작","ok"); $("#histCode").value=item.product_code; trackJob(it.job_id, item); }
else { toast("접수됐지만 job_id가 없습니다","err"); }
}catch(err){ toast(err.message,"err"); }
finally{ btn.disabled=false; btn.textContent="검색 요청 보내기"; }
});
/* ---------- job tracking (poll until DONE/DEAD) ---------- */
function trackJob(jobId, submitted){
if(pollTimer) clearInterval(pollTimer);
$("#jobHint").textContent = "job "+jobId.slice(0,8)+"…";
let tries=0;
const tick = async ()=>{
tries++;
try{
const d = await api("/v1/lps/jobs/"+jobId);
renderJob(d, submitted);
if(d.status==="DONE"||d.status==="DEAD"){
clearInterval(pollTimer); pollTimer=null;
if(d.status==="DONE" && d.output?.outcome==="found") loadHistory(submitted.product_code);
}
}catch(err){
renderJobError(err.message);
if(tries>3){ clearInterval(pollTimer); pollTimer=null; }
}
};
tick(); pollTimer=setInterval(tick, 1500);
}
function renderJobError(msg){ $("#jobBody").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(msg)}</div>`; }
function renderJob(d, submitted){
const st=d.status||"PENDING";
const o=d.output||null;
const spinning=(st==="PENDING"||st==="RUNNING");
let html = `<div class="job-meta">
<span class="status-pill ${st}">${spinning?'<span class="spin"></span>':''}${st}</span>
<span class="job-id">${esc(d.job_id||"")}</span>
<span class="muted" style="font-size:12px">시도 ${d.attempts??0}/${d.max_attempts??"–"}</span>
</div>`;
if(!o && spinning){
html += `<div class="empty" style="padding:26px"><span class="spin-inline"></span>&nbsp; 워커가 네이버·쿠팡을 검색하고 AI가 같은 상품을 판정하는 중…
<div class="muted" style="margin-top:8px;font-size:11.5px">쿠팡 크롤링은 수 초~수십 초 걸릴 수 있습니다.</div></div>`;
$("#jobBody").innerHTML=html; return;
}
if(d.last_error && st==="DEAD"){
html += `<div class="outcome-hero nf"><div><div class="outcome-label">작업 실패 (DEAD)</div><div class="muted" style="font-size:13px">${esc(d.last_error)}</div></div></div>`;
}
if(o){
const found = o.outcome==="found";
const low = o.lowest;
// hero
html += `<div class="outcome-hero ${found?'':'nf'}">
<div style="flex:1">
<div class="outcome-label">${found?'최저가 발견':(o.cached?'네거티브 캐시 (최근 없음)':'같은 상품 없음 · not_found')}</div>
${ found && low ? `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<div class="price-big">${won(low.price)}<span class="won">원</span></div>
${srcTag(low)}
${shipBadge(low)}
</div>
<div class="muted" style="font-size:11.5px;margin-top:5px">판매몰 <b style="color:var(--text-700)">${esc(mallLabel(low))}</b>${ isCatalog(low)?` <span title="여러 판매자 중 최저가 롤업 · 배송비 제외">· 여러 판매자 최저가</span>`:``}</div>
${ totalPrice(low)!=null && low.shipping_fee>0 ? `<div class="muted" style="font-size:11.5px;margin-top:2px">실구매가(배송비 포함) <b style="color:var(--text-700)">${won(totalPrice(low))}원</b></div>`:``}
<div class="muted" style="font-size:12px;margin-top:6px">${esc(low.name||"")}</div>`
: `<div class="price-big muted" style="font-size:22px">—</div>` }
</div>
<div style="text-align:right">
<div class="muted" style="font-size:11px">검색어</div>
<div style="font-weight:600;font-size:13px">${esc(o.query||submitted?.product_name||"")}</div>
<div class="muted" style="font-size:11px;margin-top:4px">${o.rounds_tried??0} 라운드${o.round?' · '+esc(o.round):''}</div>
</div>
</div>`;
// source counts
if(o.sources){
html += `<div class="src-counts">`;
for(const src of ["naver","coupang"]){
const s=o.sources[src];
html += `<div class="src-count"><div class="lbl ${src}">${src}</div>`;
if(s && s.error!==undefined) html+=`<div class="err">차단/오류</div>`;
else html += `<div class="n">${s?.count??0}<span class="muted" style="font-size:12px;font-weight:600"> 건</span></div>`;
html+=`</div>`;
}
html += `</div>`;
}
// pipeline stages
if(o.stages && o.stages.length){
html += `<div class="sec-title">파이프라인 단계 <span class="cnt">몇 건이 걸러졌나</span></div><div class="stages">`;
o.stages.forEach(s=>{
const drop=(s.in??0)-(s.out??0);
html += `<div class="stage"><div class="st-name">${esc(s.stage)}</div>
<div class="st-io">${s.in??0}<span class="arr">→</span>${s.out??0}</div>
${drop>0?`<div class="st-drop">−${drop}</div>`:``}</div>`;
});
html += `</div>`;
}
// 검색 원가 계측(리소스/비용/시간)
const mt=o.metrics;
if(mt){
const fmtBytes=b=>{const kb=(b||0)/1024; return kb>=1024?(kb/1024).toFixed(1)+" MB":Math.round(kb)+" KB";};
const c=mt.cost||{}; const total=c.total_usd??(mt.ai?.est_cost_usd||0);
const fc=v=>"$"+(v||0).toFixed((v||0)<0.01?6:4);
const srcMs=mt.source_ms||{};
const srcTxt=Object.entries(srcMs).map(([s,ms])=>`${(SRC[s]||{}).label||s} ${(ms/1000).toFixed(1)}s`).join(" · ");
const crawledTxt=(mt.crawl?.malls_crawled||[]).length?' · 크롤 '+mt.crawl.malls_crawled.map(s=>(SRC[s]||{}).label||s).join(','):'';
html += `<div class="sec-title">검색 원가 <span class="cnt">이 검색 1건이 쓴 리소스·비용·시간</span></div>
<div class="metrics">
<div class="metric"><div class="mk">소요 시간</div><div class="mv">${(mt.duration_ms/1000).toFixed(1)}<small>s</small></div>${srcTxt?`<div class="msub" title="${esc(srcTxt)}">${esc(srcTxt.length>34?srcTxt.slice(0,34)+'…':srcTxt)}</div>`:``}</div>
<div class="metric"><div class="mk">총 비용</div><div class="mv">${fc(total)}</div><div class="msub" title="AI(OpenAI) + DECODO(프록시 대역폭)">AI ${fc(c.ai_usd)} · DECODO ${fc(c.proxy_usd)}</div></div>
<div class="metric"><div class="mk">AI 토큰</div><div class="mv">${won((mt.ai?.prompt_tokens||0)+(mt.ai?.completion_tokens||0))}</div><div class="msub">${mt.ai?.calls||0}회 · in ${won(mt.ai?.prompt_tokens||0)}/out ${won(mt.ai?.completion_tokens||0)}</div></div>
<div class="metric"><div class="mk">크롤 트래픽</div><div class="mv">${fmtBytes(mt.crawl?.html_bytes)}</div><div class="msub" title="프록시 경유 ${fmtBytes(mt.crawl?.proxy_bytes)} (DECODO 과금분)">${mt.crawl?.fetches||0} fetch · 프록시 ${fmtBytes(mt.crawl?.proxy_bytes)}${crawledTxt}</div></div>
</div>`;
}
// 몰별 최저가 분해 (네이버 payload에 이미 들어온 G마켓/옥션/11번가 등을 추가요청 0으로 노출)
const byMall=o.by_mall||[];
if(byMall.length>1){
const maxP=Math.max(...byMall.map(m=>m.price));
html += `<div class="sec-title">몰별 최저가 <span class="cnt">네이버 포함 몰 + 미커버 몰은 직접 크롤(크롤 뱃지)</span></div><div class="mall-bars">`;
byMall.forEach((m,i)=>{
const w=Math.max(8,Math.round(m.price/maxP*100));
const color=srcColor(m.source);
html += `<div class="mall-bar ${i===0?'best':''}">
<div class="mname"><span class="sdot" style="background:${color}"></span>${esc(mallLabel(m))}</div>
<div class="track"><div class="fill" style="width:${w}%;background:${color}"></div>
${i===0?`<div class="badge-best">최저</div>`:``}
<div class="mprice">${won(m.price)}원</div></div>
</div>`;
});
html += `</div>`;
}
// top-N product list
const top=o.top||[];
if(top.length){
html += `<div class="sec-title">같은 상품 최저가 <span class="cnt">상위 ${top.length}개 · 가격은 상품가 기준</span></div>
<table><thead><tr><th>#</th><th>소스 · 판매몰</th><th>상품명</th><th>배송</th><th style="text-align:right">상품가</th></tr></thead><tbody>`;
top.forEach((p,i)=>{
const tot=totalPrice(p);
html += `<tr>
<td><span class="rank ${i===0?'top':''}">${i+1}</span></td>
<td style="white-space:nowrap">${srcTag(p)}<div class="muted" style="font-size:11px;font-weight:600;margin-top:3px">${esc(mallLabel(p))}</div></td>
<td class="name-cell">${p.detail_url?`<a href="${esc(p.detail_url)}" target="_blank" rel="noopener">${esc(p.name)}</a>`:esc(p.name)}</td>
<td>${shipBadge(p)}</td>
<td class="price-cell" style="text-align:right">${won(p.price)}원${ tot!=null&&p.shipping_fee>0?`<div class="muted" style="font-size:10.5px;font-weight:600">+배송 ${won(tot)}</div>`:``}</td>
</tr>`;
});
html += `</tbody></table>`;
}
}
$("#jobBody").innerHTML=html;
}
/* ---------- price history chart (dependency-free SVG) ---------- */
const SERIES=[
{key:"naver", label:"네이버", color:"var(--naver)", raw:"#03c75a", dash:false},
{key:"coupang", label:"쿠팡", color:"var(--coupang)", raw:"#f0455b", dash:false},
{key:"final", label:"최종최저", color:"var(--final)", raw:"#4f46e5", dash:true},
];
const hidden=new Set();
let lastPoints=[];
$("#loadHistBtn").addEventListener("click",()=> loadHistory($("#histCode").value.trim()));
$("#histCode").addEventListener("keydown",e=>{ if(e.key==="Enter") loadHistory(e.target.value.trim()); });
async function loadHistory(code){
if(!code){ toast("상품 코드를 입력하세요","err"); return; }
$("#histCode").value=code;
$("#chartArea").innerHTML=`<div class="empty" style="padding:30px"><span class="spin-inline"></span> 이력 불러오는 중…</div>`;
try{
const d = await api(`/v1/lps/products/${encodeURIComponent(code)}/history?limit=200`);
lastPoints = d.points||[];
renderChart(lastPoints, code);
}catch(err){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(err.message)}</div>`; }
}
function renderChart(points, code){
if(!points.length){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">🗒️</div><b>${esc(code)}</b> 상품의 이력이 아직 없습니다.<div class="muted" style="margin-top:6px;font-size:12px">검색이 완료되면 스냅샷이 쌓입니다.</div></div>`; return; }
const W=760,H=300,padL=62,padR=18,padT=18,padB=46;
const iw=W-padL-padR, ih=H-padT-padB;
const vals=[]; points.forEach(p=>SERIES.forEach(s=>{ if(!hidden.has(s.key)&&p[s.key]!=null) vals.push(p[s.key]); }));
let min = vals.length?Math.min(...vals):0, max=vals.length?Math.max(...vals):1;
if(min===max){ min=min*0.9; max=max*1.1||1; }
const pad=(max-min)*0.12; min=Math.max(0,min-pad); max=max+pad;
const n=points.length;
const X = i => padL + (n===1? iw/2 : iw*i/(n-1));
const Y = v => padT + ih*(1-(v-min)/(max-min));
// y ticks
const ticks=5; let yAxis="";
for(let t=0;t<=ticks;t++){ const v=min+(max-min)*t/ticks; const y=Y(v);
yAxis+=`<line x1="${padL}" y1="${y}" x2="${W-padR}" y2="${y}" stroke="var(--border-2)" stroke-width="1"/>`;
yAxis+=`<text x="${padL-8}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-400)">${won(Math.round(v))}</text>`;
}
// x labels (sparse)
let xAxis=""; const step=Math.ceil(n/6);
points.forEach((p,i)=>{ if(i%step!==0 && i!==n-1) return; const x=X(i);
const t=(p.triggered_at||"").replace("T"," ").slice(5,16);
xAxis+=`<text x="${x}" y="${H-padB+18}" text-anchor="middle" font-size="9.5" fill="var(--text-400)">${esc(t)}</text>`;
xAxis+=`<line x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--border-2)" stroke-width="1" stroke-dasharray="2 3" opacity=".5"/>`;
});
// lines + dots
let paths="",dots="";
SERIES.forEach(s=>{
if(hidden.has(s.key)) return;
// build segments skipping nulls
let seg=[];
const flush=()=>{ if(seg.length>1){ paths+=`<polyline points="${seg.map(pt=>pt.x+","+pt.y).join(" ")}" fill="none" stroke="${s.raw}" stroke-width="${s.dash?2.5:2}" ${s.dash?'stroke-dasharray="6 4"':''} stroke-linejoin="round" stroke-linecap="round"/>`; } seg=[]; };
points.forEach((p,i)=>{ const v=p[s.key]; if(v==null){ flush(); return; } const x=X(i),y=Y(v); seg.push({x,y});
dots+=`<circle cx="${x}" cy="${y}" r="3" fill="#fff" stroke="${s.raw}" stroke-width="2"/>`; });
flush();
});
// hover columns
let hover="";
points.forEach((p,i)=>{ const x=X(i);
hover+=`<rect x="${x-(iw/n/2||14)}" y="${padT}" width="${iw/n||28}" height="${ih}" fill="transparent" data-i="${i}" class="hovcol"/>`;
hover+=`<line class="hovline" data-i="${i}" x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--primary-500)" stroke-width="1" opacity="0"/>`;
});
$("#chartArea").innerHTML=`
<div class="chart-wrap">
<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
${yAxis}${xAxis}${paths}${dots}${hover}
</svg>
<div class="chart-tip" id="chartTip"></div>
</div>
<div class="legend" id="legend"></div>
<p class="muted" style="font-size:11.5px;margin:12px 0 0">스냅샷 ${n}건 · 점선(최종최저)이 네이버·쿠팡 중 낮은 값을 따라갑니다. 값이 빈 구간은 그 소스에 상품이 없던 시점입니다.</p>
`;
// legend
const lg=$("#legend");
SERIES.forEach(s=>{
const el=document.createElement("div"); el.className="legend-item"+(hidden.has(s.key)?" off":"");
el.innerHTML=`<span class="swatch ${s.dash?'dashed':''}" style="${s.dash?`border-color:${s.raw}`:`background:${s.raw}`}"></span>${s.label}`;
el.addEventListener("click",()=>{ if(hidden.has(s.key))hidden.delete(s.key); else hidden.add(s.key); renderChart(lastPoints,code); });
lg.appendChild(el);
});
// hover interaction
const tip=$("#chartTip"), svg=$(".chart");
$$(".hovcol").forEach(col=>{
col.style.cursor="crosshair";
col.addEventListener("mouseenter",()=>{
const i=+col.dataset.i, p=points[i];
$$(".hovline").forEach(l=>l.setAttribute("opacity", (+l.dataset.i===i)?".5":"0"));
let rows="";
SERIES.forEach(s=>{ if(hidden.has(s.key)) return; const v=p[s.key];
rows+=`<div class="tt-row"><span class="k"><span class="tt-dot" style="background:${s.raw}"></span>${s.label}</span><span>${v==null?'—':won(v)+'원'}</span></div>`; });
tip.innerHTML=`<div class="tt-time">${esc((p.triggered_at||'').replace('T',' ').slice(0,19))} · ${esc(p.outcome||'')}</div>${rows}`;
// position
const bb=svg.getBoundingClientRect(); const scale=bb.width/W; const px=(X(i))*scale; const py=padT*scale+8;
tip.style.left=px+"px"; tip.style.top=py+"px"; tip.style.opacity="1";
});
col.addEventListener("mouseleave",()=>{ tip.style.opacity="0"; $$(".hovline").forEach(l=>l.setAttribute("opacity","0")); });
});
}
</script>
</body>
</html>