# price-boss API — 에이전트용 안내 선물하기 상품이 타사(네이버쇼핑·쿠팡·11번가·G마켓)보다 싼지 확인해 주는 내부 API입니다. **가장 먼저 알아야 할 것**: 이 API는 즉답하지 않습니다. 가격 수집은 자동 크롤링이 아니라 **맥에 있는 브라우저가 사람 속도로 직접 검색**해서 이루어집니다. 상품 하나에 수십 초~수 분이 걸리고, 대기열이 밀리면 더 걸립니다. 그러니 **등록(POST) → 나중에 조회(GET)** 흐름으로 설계하세요. 화면은 "완료된 것부터 점진적으로 채워지는" 형태가 맞습니다. --- ## 1. 인증 모든 `/api/v1/*` 요청에 토큰을 헤더로 보냅니다. ``` Authorization: Bearer pb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` **API 키는 관리자에게 요청**하세요. 키에는 역할이 있고, MD에게 발급되는 키는 `/api/v1/*`만 쓸 수 있습니다(`/api/admin/*`는 수집 워커 전용이라 403). --- ## 2. 핵심 개념 - **체크(check)** — "이 상품 가격 좀 확인해줘" 요청 1건. 등록하면 큐에 들어가고, 맥 워커가 가져가 4개 사이트를 검색한 뒤 결과를 올립니다. - **큐는 전역 공유** — 내 요청만 있는 게 아닙니다. 다른 MD의 요청도 같은 줄에 섭니다. `position_in_queue`로 순번을 볼 수 있지만 완료 시각을 보장하지는 않습니다. - **재체크 최소 간격 1시간** — 같은 상품을 1시간 안에 다시 등록하면 새로 수집하지 않고 기존 결과를 돌려줍니다(`skipped_recent: true`). 중복 등록을 걱정하지 말고 마음껏 호출하세요. - **판정은 보수적** — 같은 상품인지 확신이 없으면 비교에서 제외합니다. `unknown`이 많이 나오는 것이 정상입니다(오답보다 침묵이 낫다는 원칙). --- ## 3. 체크 상태 (queued → checking → done | failed) ``` POST /checks │ ▼ queued ──(맥 워커가 가져감)──▶ checking ──▶ done ← 결과 조회 가능 │ └─▶ failed ← error에 사유 │ └─ position_in_queue 로 순번 확인 ``` | status | 뜻 | 에이전트가 할 일 | |---|---|---| | `queued` | 대기 중 | `position_in_queue`를 보여주고 기다립니다 | | `checking` | 맥이 지금 수집 중 | 곧 끝납니다. 폴링 간격을 늘리세요 | | `done` | 완료 | `GET .../prices`로 결과를 읽습니다 | | `failed` | 실패 | `error`를 확인하세요. 상품 id가 틀렸을 가능성이 큽니다 | `attempts`는 시도 횟수입니다. 3회를 넘기면 자동으로 `failed`가 됩니다(무한 재시도 방지). --- ## 4. 소스별 status (사이트마다 다를 수 있음) 한 체크 안에서도 사이트마다 결과가 다릅니다. 4개 소스 컬럼을 항상 채우려 하지 마세요. | status | 뜻 | 화면 표시 권장 | 응답의 severity | |---|---|---|---| | `ok` | 동일 상품을 찾음 | 가격 + 링크 + 근거 이미지 | `info` | | `no_match` | 검색은 했지만 동일 상품이라 확신할 게 없음 | "동일 상품 없음" + `considered` 목록 | `warn` | | `blocked` | 사이트가 수집을 막음 | "차단" 회색 배지 (에러 아님) | `error` | | `error` | 수집 중 오류 | `error` 문자열 표시 | `error` | | `not_checked` | 이번 체크에서 수행하지 않음 | 빈칸 또는 "—" | `warn` | 응답에 `label`(한글)과 `severity`가 함께 오므로 그대로 화면에 쓰면 됩니다. `skipped_reason`이 있으면 차단 백오프로 잠시 쉬는 중이라는 뜻입니다. --- ## 5. API 레퍼런스 ### 5-1. 체크 등록 — `POST /api/v1/products/{product_id}/checks` 상품 id는 선물하기 URL(`gift.kakao.com/product/<숫자>`)의 숫자입니다. ```json {"check_id": 43, "status": "queued", "merged": false, "skipped_recent": false, "position_in_queue": 3, "estimated_wait_minutes": 9} ``` - `merged: true` — 이미 진행 중인 체크가 있어 그걸 반환했습니다(중복 수집 없음) - `skipped_recent: true` — 1시간 내 결과가 있어 새로 수집하지 않았습니다. `checked_at` 참고 - `estimated_wait_minutes`는 **대략치**입니다. 이 값을 신뢰해 타이머를 만들지 마세요 ### 5-2. 여러 상품 한 번에 등록 — `POST /api/v1/checks/batch` ```bash curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"product_ids":["14040037","14040040"]}' \ https://www.price-boss.click/api/v1/checks/batch ``` ```json {"results": [ {"check_id": 44, "product_id": "14040037", "status": "queued", "merged": false, "skipped_recent": false, "position_in_queue": 1, "estimated_wait_minutes": 3}, {"check_id": 45, "product_id": "14040040", "status": "queued", "merged": false, "skipped_recent": false, "position_in_queue": 2, "estimated_wait_minutes": 6} ]} ``` 한 번에 최대 100개. ### 5-3. 내 상품 목록 — `GET /api/v1/products` ★ 대시보드는 여기서 시작하세요 상품 목록과 각 상품의 최신 판정을 **한 번의 호출로** 가져옵니다. | 쿼리 | 뜻 | |---|---| | `mine=true` | 내 토큰으로 등록한 상품만 | | `result=lose,unknown` | 판정으로 필터 (win/tie/lose/unknown) | | `freshness=stale` | 오래된 결과만 (재체크 대상 찾기) | | `limit`(기본 50, 최대 200) `offset` | 페이지네이션 | | `sort=gap`(기본) 또는 `sort=checked_at` | 정렬. gap은 비싼 것부터 | ```json {"total": 37, "limit": 50, "offset": 0, "products": [ {"product_id": "14040042", "name": "메이플스토리 얼굴 방석 돌의 정령", "gift_price": 27000, "url": "https://gift.kakao.com/product/14040042", "last_check": {"check_id": 12, "checked_at": "2026-08-10T01:22:31+09:00", "freshness": "fresh", "result": "lose", "competitor_min_price": 23000, "competitor_source": "naver", "gap_won": 4000, "gap_pct": 17.39, "source_status": {"naver": "ok", "coupang": "blocked", "st11": "no_match", "gmarket": "not_checked"}}, "pending_check": null} ]} ``` `pending_check`가 있으면 그 상품은 지금 재체크 중입니다. ### 5-4. 가격 비교 결과 — `GET /api/v1/products/{product_id}/prices` ```json {"product": {"id": "14040042", "name": "메이플스토리 얼굴 방석 돌의 정령", "gift_price": 27000, "url": "https://gift.kakao.com/product/14040042", "last_seen_at": "2026-08-10T01:22:31+09:00"}, "check_id": 12, "checked_at": "2026-08-10T01:22:31+09:00", "recheck_available_at": "2026-08-10T02:22:31+09:00", "freshness": "fresh", "sources": { "naver": {"status": "ok", "label": "동일 상품 찾음", "severity": "info", "query": "메이플스토리 얼굴 방석 돌의 정령", "min_price": 23000, "title": "메이플스토리 얼굴 방석 돌의 정령 쿠션", "mall": "나라홈데코", "url": "https://smartstore.naver.com/...", "confidence": 0.92, "reason": "브랜드·제품 종류·구성이 모두 일치", "matched_count": 2, "offers_considered": 10, "evidence_url": "/api/v1/evidence/12/naver.png"}, "coupang": {"status": "blocked", "label": "사이트 차단", "severity": "error", "query": "메이플스토리 얼굴 방석 돌의 정령", "error": "쿠팡 차단/보안문자 감지 — 수집 중단 (우회하지 않음)"}, "st11": {"status": "no_match", "label": "동일 상품 없음", "severity": "warn", "query": "메이플스토리 얼굴 방석 돌의 정령", "offers_considered": 10, "reason": "쿠션 커버만 판매 — 구성이 다름", "considered": [ {"title": "메이플 캐릭터 쿠션커버", "price": 9900, "url": "https://www.11st.co.kr/products/...", "confidence": 0.45, "reason": "쿠션 커버만 판매 — 구성이 다름"} ]}, "gmarket": {"status": "not_checked", "label": "미수행", "severity": "warn", "skipped_reason": "차단 백오프 중 (약 18분 남음)"} }, "summary": {"result": "lose", "competitor_min_price": 23000, "competitor_source": "naver", "gap_won": 4000, "gap_pct": 17.39}} ``` - `gap_won`은 **원 단위**, 양수면 선물하기가 그만큼 **비쌉니다**. `gap_pct`는 **퍼센트**(17.39 = 17.39%) - `result`: `win`(우리가 쌈) / `tie`(같음) / `lose`(우리가 비쌈) / `unknown`(비교 불가) - 이력이 없으면 **404**입니다. 바디에 `pending_check`가 있으면 지금 수집 중이라는 뜻이고, `last_failure`가 있으면 직전 시도가 실패했다는 뜻입니다(상품 id 확인). ### 5-5. 여러 상품 결과 한 번에 — `GET /api/v1/prices?ids=1,2,3` ```json {"results": {"14040042": { /* 5-4와 동일한 구조 */ }}, "missing": ["99999999"]} ``` `missing`은 아직 체크 이력이 없는 id입니다. 최대 100개. ### 5-6. 상품 메타 — `GET /api/v1/products/{product_id}` 체크가 끝나기 전에도 호출할 수 있습니다(로딩 화면에 이름을 띄울 때). 아직 한 번도 수집된 적 없으면 `{"known": false, "hint": "...", "pending_check": {...}}`. ### 5-7. 체크 히스토리 — `GET /api/v1/products/{product_id}/checks` ```json {"product_id": "14040042", "checks": [ {"check_id": 12, "status": "done", "requested_by": "md-team", "requested_at": "2026-08-10T01:10:02+09:00", "claimed_by": "mac-1", "claimed_at": "2026-08-10T01:12:40+09:00", "finished_at": "2026-08-10T01:22:31+09:00", "attempts": 1, "duration_seconds": 591, "error": null, "result_summary": {"result": "lose", "competitor_min_price": 23000, "competitor_source": "naver", "gap_won": 4000, "gap_pct": 17.39}} ]} ``` 가격 추이 그래프는 이 배열을 시계열로 쓰면 됩니다. ### 5-8. 체크 단건 — `GET /api/v1/checks/{check_id}` `result.sources.<소스>.matched_offers`까지 포함한 전체 상세를 줍니다. **매칭이 맞는지 검증하려면 여기를 보세요.** ### 5-9. 근거 이미지 — `GET /api/v1/evidence/{check_id}/{source}.png` 수집 당시 실제 검색 화면 스크린샷입니다. **인증 헤더가 필요합니다**(아래 함정 참고). --- ## 6. 폴링 전략 ``` 등록(POST) → estimated_wait_minutes 만큼 대기 → GET .../checks 로 상태 확인 → 아직이면 지수 백오프: 30초 → 1분 → 2분 → 5분(상한) → status가 done 또는 failed 가 되면 종료 ``` - **10초 간격 타이트 루프를 만들지 마세요.** 수집은 원래 분 단위입니다. - 상품이 여러 개면 개별 폴링 대신 `GET /api/v1/products?mine=true`를 주기적으로 한 번씩 호출해 전체 상태를 갱신하는 편이 훨씬 쌉니다. --- ## 7. 레시피 ### 7-1. 담당 상품 대시보드 (호출 1번) ```bash curl -s -H "Authorization: Bearer $TOKEN" \ "https://www.price-boss.click/api/v1/products?mine=true&limit=100" ``` ```python import os, urllib.request, json def api(path): req = urllib.request.Request( f"https://www.price-boss.click/api/v1/{path}", headers={"Authorization": f"Bearer {os.environ['PB_TOKEN']}"}) with urllib.request.urlopen(req, timeout=20) as r: return json.load(r) data = api("products?mine=true&limit=100") for p in data["products"]: c = p["last_check"] if not c: print(f"{p['product_id']} (아직 결과 없음)") continue mark = {"lose": "비쌈", "win": "쌈", "tie": "동일", "unknown": "판단 불가"}[c["result"]] gap = f"{c['gap_won']:+,}원" if c["gap_won"] is not None else "-" print(f"{p['name'][:30]:32} {p['gift_price']:>8,}원 {mark:6} {gap}") ``` ### 7-2. 비싼 상품만 알림 ```bash curl -s -H "Authorization: Bearer $TOKEN" \ "https://www.price-boss.click/api/v1/products?mine=true&result=lose" \ | python3 -c 'import json,sys for p in json.load(sys.stdin)["products"]: c = p["last_check"] print(f"{p[\"name\"]}: 우리 {p[\"gift_price\"]:,}원 vs {c[\"competitor_source\"]} {c[\"competitor_min_price\"]:,}원 (+{c[\"gap_won\"]:,}원)")' ``` 이 출력을 슬랙 웹훅으로 보내면 알림이 됩니다. ### 7-3. 가격 추이 ```python hist = api("products/14040042/checks")["checks"] series = [(h["finished_at"], h["result_summary"]["competitor_min_price"]) for h in reversed(hist) if h["status"] == "done" and h["result_summary"]] ``` ### 7-4. 오래된 것만 재체크 ```python stale = api("products?mine=true&freshness=stale&limit=100")["products"] ids = [p["product_id"] for p in stale][:100] # POST /api/v1/checks/batch 로 한 번에 등록 ``` --- ## 8. 함정 (실제로 겪는 것들) **근거 이미지는 ``로 못 불러옵니다.** 인증 헤더가 필요한데 img 태그에는 헤더를 실을 수 없습니다. 미리 fetch해서 base64로 임베드하거나, 서버 쪽에서 프록시하세요. ```javascript const res = await fetch(url, {headers: {Authorization: `Bearer ${token}`}}); const blob = await res.blob(); img.src = URL.createObjectURL(blob); ``` **쿠팡·G마켓은 `blocked`가 잦습니다.** 봇 확인이 걸리면 우회하지 않고 그대로 기록합니다. 4개 소스 컬럼을 다 채우는 화면을 전제로 만들면 대부분 비어 보입니다 — 있는 소스만 보여주고, 차단은 회색 배지 정도로 처리하는 편이 낫습니다. **매칭이 틀릴 수 있습니다.** 이름이 비슷하고 가격이 같으면 다른 상품을 같다고 볼 때가 있습니다 (예: '핸드타올'과 '인형키링'). 그래서 응답에 **판단 근거(`reason`)와 매칭된 상품명**을 함께 줍니다. 화면에 이 둘을 노출해 사람이 눈으로 검증할 수 있게 만드세요. ``` 네이버 23,000원 "메이플스토리 얼굴 방석 돌의 정령 쿠션" ← 실제 매칭된 상품명 판단: 브랜드·제품 종류·구성이 모두 일치 ← reason ``` **`unknown`은 실패가 아닙니다.** 확신 없는 매칭을 버린 결과입니다. `no_match`의 `considered` 목록을 펼쳐 보여주면 "왜 못 찾았는지"를 사람이 판단할 수 있습니다. **대기열은 공유됩니다.** 100개를 등록해도 앞에 다른 요청이 있으면 뒤에 섭니다. "지금 당장 전부 최신"은 불가능합니다 — 화면을 점진적으로 채우는 설계로 가세요. --- ## 9. 에러 모든 에러는 같은 형태입니다. ```json {"error": "무엇이 잘못됐는지", "hint": "어떻게 해결하는지"} ``` | 코드 | 상황 | |---|---| | 401 | 토큰이 없거나 유효하지 않음 (폐기됐을 수 있음 — 관리자에게 문의) | | 403 | MD 키로 워커 전용 API(`/api/admin/*`)를 호출함 | | 404 | 체크 이력이 없음. `pending_check`·`last_failure`를 함께 확인하세요 | | 422 | 상품 id 형식 오류(숫자만), 배치 개수 초과 | ## 10. 제한 - 배치 등록·조회: 한 번에 100개 - 상품 목록: 최대 200개 (`limit`) - 히스토리: 최근 20건 - 재체크 최소 간격: 1시간