diff --git a/docs/ai-instruction.md b/docs/ai-instruction.md index d0f355e3..c5133ca8 100644 --- a/docs/ai-instruction.md +++ b/docs/ai-instruction.md @@ -55,7 +55,7 @@ GPT Actions가 이미 연결된 환경은 `queryAction(action=...)`을 사용하 - 상품 검색: `search_products`, 매장 검색: `find_stores`, 재고: `check_inventory`에 `service`를 전달합니다. 지원 서비스별 필수 인수는 입력 스키마를 따릅니다. - 상품 상세·가격·진열 위치: `get_product_details`에 `service`와 `operation`을 전달합니다. - 주변 장소·영화관: `find_places`, 영화·시간표·잔여석: `get_showtimes`에 `service`와 필요한 `operation`을 전달합니다. -- 유가·주유소: `get_fuel_info`, 인기 검색어·기획전: `get_catalog`의 `operation`을 선택합니다. +- 유가·주유소: `get_fuel_info`, 인기 검색어·기획전: `get_catalog`의 `operation`을 선택합니다. 올리브영 오특(오늘의 특가)은 `get_catalog`에 `service=oliveyoung`, `operation=today_deals`를 전달합니다. - 가격 비교: `compare_products`를 사용합니다. - 요청 제출: 사용자가 명시적으로 개발자 전달을 요청한 경우에만 `submit_request`에 `requestType=developer`, `request` 객체를 전달합니다. 오류 발생만으로 자동 제출하지 않습니다. - 일본 다이소는 `service=daiso_jp`의 상품·매장 검색 및 온라인 구매 가능 여부만 지원합니다. 매장 재고는 미지원이고 가격은 JPY입니다. 한국 KRW와 섞지 않습니다. 롯데마트는 지원 중단, MINISO는 미지원입니다. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index f5383387..3ad1a71d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -27,13 +27,17 @@ 일본 상세: `get_product_details {"service":"daiso_jp","operation":"product","id":"4550746075917"}`. 일본 매장별 재고는 지원하지 않습니다. +올리브영 오특: `get_catalog {"service":"oliveyoung","operation":"today_deals"}` + 요청 제출: `submit_request {"requestType":"developer","request":{"title":"문서 개선","description":"개선할 내용을 적습니다","type":"docs"}}`. 현재 개발자 요청 저장만 지원합니다. 다른 요청 유형은 거절하며 요청자의 의도 없이 제출하지 않습니다. 이 도구의 이름과 요청 구분은 미래 유형 확장을 위해 범용으로 유지합니다. 매개변수는 기존 작업의 이름과 형식을 사용합니다. operation은 같은 서비스에 작업이 여러 개인 경우 필수입니다. 예를 들어 CGV 시간표는 service=cgv, operation=timetable, playDate=YYYYMMDD이며 seats 작업은 지원하지 않습니다. 미지원 조합과 알 수 없는 인수·오타는 조회 전에 거절합니다. 공개 스키마에 있지만 선택한 서비스/작업에서 쓰지 않는 옵션은 실행을 막지 않고 응답의 추가 안내와 `_meta.ignoredArguments`로 미적용 사실을 알립니다. 미적용 조건을 충족했다고 안내하면 안 됩니다. 기본값은 선택한 작업의 기본값만 적용합니다. tools/list의 입력/출력 스키마를 참고하세요. ### 카탈로그와 매장 재고 검증 -`get_catalog`는 세븐일레븐의 공개 콘텐츠 조회입니다. `operation`은 필수이며 `popwords`(인기 검색어) 또는 `snapshot`(상품·이슈·기획전)을 지정합니다. `includeIssues`와 `includeExhibition`은 `snapshot` 전용입니다. 인기 검색어 원본 목록이 비어 있으면 `available=false`이며 외부 오류와 구분합니다. 원본 차단·중계 실패는 오류로 반환하며 빈 성공으로 바꾸지 않습니다. +`get_catalog`는 공개 콘텐츠 조회입니다. `operation`은 필수입니다. `service`를 생략하면 기존 호출과 같이 세븐일레븐이며 `popwords`(인기 검색어) 또는 `snapshot`(상품·이슈·기획전)을 지정합니다. `includeIssues`와 `includeExhibition`은 `snapshot` 전용입니다. 인기 검색어 원본 목록이 비어 있으면 `available=false`이며 외부 오류와 구분합니다. 원본 차단·중계 실패는 오류로 반환하며 빈 성공으로 바꾸지 않습니다. + +`service=oliveyoung`, `operation=today_deals`는 올리브영 오특(오늘의 특가) 화면의 `brandDeals`(오늘의 브랜드 특가)와 `todayDeals`(오늘의 특가 전체 목록)를 한국 날짜 기준으로 반환합니다. 가격은 조회 시점 화면 기준이며 `priceFrom=true`는 옵션별 최저가입니다. 실시간 재고가 아니므로 재고는 상품명으로 `check_inventory`(service=oliveyoung)를 조회합니다. 원본 차단·형식 변경은 빈 목록이 아니라 오류입니다. 다이소 `check_inventory`에서 `storeLimit`은 `pageSize`와 같은 매장 수 조건입니다. 함께 전달하면 두 값이 같아야 합니다. `productLimit`은 상품 후보 수이며 여러 상품의 재고를 모두 조회하는 옵션이 아닙니다. 결과의 `selectedProduct`가 재고 조회 기준 상품입니다. @@ -45,6 +49,8 @@ 기존 44개 호출 계약은 유지합니다. compare_products는 공개 이름이 그대로이며 나머지 43개 구형 이름은 deprecated 별칭입니다. 구형 이름도 동일한 / 및 /mcp의 tools/call로 호출할 수 있고 기존 인수/결과/오류 의미를 유지합니다. 성공 또는 반환된 오류 응답의 _meta.deprecated=true와 _meta.replacement는 이동할 공개 도구를 안내합니다. SDK 입력 검증 단계에서 거절된 요청에는 이 추가 정보가 없을 수 있습니다. tools/list와 공개 발견 목록에는 구형 별칭을 노출하지 않습니다. +공개 도구 통합 이후 추가한 내부 작업 `oliveyoung_get_today_deals`는 처음부터 공개 목록에 등록하지 않았으며 `get_catalog`로만 안내합니다. 구형 별칭과 같은 방식으로 호출은 가능하지만 새 연동에서는 사용하지 않습니다. + REST 경로와 CLI 명령은 변경하지 않습니다. 구형 별칭을 삭제하거나 다시 공개 목록에 등록하지 마세요. 새 연동은 아래 오른쪽 도구만 사용합니다. | 기존 이름 | 공개 도구 | service | operation | @@ -89,8 +95,8 @@ REST 경로와 CLI 명령은 변경하지 않습니다. 구형 별칭을 삭제 | `opinet_get_lowest_price_stations` | `get_fuel_info` | | lowest | | `opinet_search_stations_around` | `get_fuel_info` | | around | | `opinet_get_station_detail` | `get_fuel_info` | | detail | -| `seveneleven_get_search_popwords` | `get_catalog` | | popwords | -| `seveneleven_get_catalog_snapshot` | `get_catalog` | | snapshot | +| `seveneleven_get_search_popwords` | `get_catalog` | seveneleven | popwords | +| `seveneleven_get_catalog_snapshot` | `get_catalog` | seveneleven | snapshot | | `compare_products` | `compare_products` | | | | `submit_developer_request` | `submit_request` | | | diff --git a/docs/oliveyoung-today-deals-analysis.md b/docs/oliveyoung-today-deals-analysis.md new file mode 100644 index 00000000..15e6d419 --- /dev/null +++ b/docs/oliveyoung-today-deals-analysis.md @@ -0,0 +1,47 @@ +# 올리브영 오특 네트워크 분석 + +올리브영 오특(오늘의 특가) 화면의 데이터 출처와 응답 구조를 기록합니다. 구현은 `src/services/oliveyoung/todayDeals.ts`, 고정 경로와 응답 판정은 `src/services/oliveyoung/htmlRoute.ts`에 있습니다. + +## 확인 방법 + +2026-10-24와 2026-10-26(Asia/Seoul)에 Playwright 화면 브라우저로 `https://www.oliveyoung.co.kr/store/main/getHotdealList.do`를 열고 네트워크 요청을 기록했습니다. Cloudflare 보안 확인은 사람이 직접 통과했으며 자동으로 우회하지 않았습니다. 같은 날 화면에 표시된 목록과 파싱 결과의 상품명, 순서, 가격, 할인율, 혜택 표시를 대조했습니다. 이 결과는 확인 당시 관측값이며 원본 정책에 따라 바뀔 수 있습니다. + +## 데이터 출처 + +| 화면 섹션 | 요청 | 응답 | +| ------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------- | +| 오늘의 브랜드 특가 | `GET /store/main/getHotdealList.do` | 페이지 HTML (약 147KB) | +| 오늘의 특가 | `GET /store/main/getHotdealPagingListAjax.do?date=YYYYMMDD&pageIdx=N&fltCondition=02&fltDispCatNo=&prdSort=rank` | 상품 카드 HTML 조각 | +| coming soon | 페이지 HTML 안의 배너 | 상품명과 이미지만 있음 | + +- JSON API는 확인하지 못했습니다. 두 응답 모두 HTML입니다. +- `date`는 한국 날짜이며 페이지 스크립트도 같은 값을 보냅니다. 오특은 한국 자정에 바뀝니다. +- 목록 조각은 10개씩 내려오며 마지막 다음 페이지는 공백만 있는 응답입니다. 2026-10-26에는 10, 10, 7개로 27개였습니다. +- 브랜드 특가 카드는 페이지 HTML에 처음부터 포함됩니다. 2026-10-26에는 4개였습니다. +- coming soon은 상품번호와 가격이 없어 구현하지 않았습니다. 사용자 관측 기준으로 대략 한국 시간 9시부터 표시됩니다. + +## 상품 카드 구조 + +두 섹션은 같은 `
  • ` 카드를 사용합니다. 코너 이름은 브랜드 특가가 `오특_오특상품리스트_스페셜`, 오늘의 특가가 `오특_오특상품리스트_인기순`입니다. + +| 값 | 위치 | +| --------------- | ----------------------------------------------- | +| 상품번호 | `data-ref-goodsNo` | +| 상품명 | `.prod-name` | +| 브랜드·카테고리 | `data-ref-goodsbrand`, `data-ref-goodscategory` | +| 할인가 | `.price .total` (끝의 "원~"은 옵션별 최저가) | +| 정가 | `.price .origin` | +| 할인율 | `.flag-badge .Pnum` | +| 혜택 | `.flags .flag` (세일, 쿠폰, 증정, 1+1 등) | +| 오늘드림 | `.price .oneday` | +| 일시품절 | `.status_flag.soldout` | + +상품번호가 `B`로 시작하는 상품은 관측 당시 오늘드림 표시가 없었습니다. 2026-10-27에는 오늘의 특가 27번 상품이 화면에 "일시품절"로 표시되었고, 카드 썸네일 안에 `일시품절`이 있어 `soldOut`으로 반환합니다. 페이지 스타일에는 `status_flag soldend`도 정의되어 있지만 상품 카드에서 관측하지 못해 반환하지 않습니다. + +## 접근 경로와 제한 + +- 같은 날 Node fetch와 curl의 직접 요청은 기존 상품 검색 API와 마찬가지로 HTTP403 보안 확인 화면이었습니다. 운영 환경은 브라우저 릴레이가 필요합니다. +- 릴레이 작업 이름은 `today-deals-page`(`{date}`)와 `today-deals-list`(`{date, pageIdx}`)입니다. 날짜는 자정 전후 캐시 구분용이며 페이지 주소에는 붙이지 않습니다. 정렬·필터 값은 고정입니다. +- 브라우저 릴레이는 고정 주소만 GET으로 읽고 정상 오특 HTML만 `{status:"SUCCESS", data:{html}}`로 감쌉니다. 보안 확인 화면이나 다른 페이지는 실패로 반환합니다. +- Python HTTP 릴레이(`OY_RELAY_TRANSPORT=http`)는 기존 네 JSON 경로만 허용하므로 오특 작업은 실패로 반환합니다. +- 한 번의 조회는 페이지 1회와 목록 페이지 수만큼 요청합니다. 2026-10-26 기준 4회이며 릴레이 호출 한도도 같은 횟수만큼 사용합니다. 목록은 최대 20페이지까지 읽고 모두 가득 차면 `truncated=true`를 반환합니다. diff --git a/docs/service-reference.md b/docs/service-reference.md index a5d581ab..24053bdc 100644 --- a/docs/service-reference.md +++ b/docs/service-reference.md @@ -287,6 +287,21 @@ Zyte API 기반으로 올리브영 상품 재고를 조회하고 주변 매장
    +### 올리브영 오특 (get_catalog) + +공개 도구 `get_catalog {"service":"oliveyoung","operation":"today_deals"}`로 올리브영 오특(오늘의 특가) 화면의 두 목록을 한국 날짜 기준으로 조회합니다. 내부 작업 이름은 `oliveyoung_get_today_deals`입니다. + +- `brandDeals`: 오늘의 브랜드 특가 +- `todayDeals`: 오늘의 특가 전체 목록 (인기순) + +| 파라미터 | 필수 | 설명 | +| :---------- | :--: | :------------------------------------------------------------------------------- | +| `timeoutMs` | | 페이지 여러 개를 합친 전체 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000) | + +각 상품은 `rank`, `goodsNo`, `name`, `brand`, `category`, `price`, `priceFrom`, `originalPrice`, `discountRate`, `flags`, `todayDelivery`, `soldOut`, `imageUrl`, `productUrl`을 반환합니다. `priceFrom=true`는 화면에 "원~"으로 표시되는 옵션별 최저가이고, `soldOut=true`는 화면에 "일시품절"로 표시된 상품입니다. 실시간 매장 재고가 아니므로 재고는 상품명으로 `check_inventory`(service=oliveyoung)를 조회합니다. 원본 차단·형식 변경은 빈 목록이 아니라 오류로 반환합니다. 화면의 coming soon(다음 오특 예고)은 반환하지 않습니다. 원본 분석은 [오특 네트워크 분석](./oliveyoung-today-deals-analysis.md)에 있습니다. + +
    + ### megabox_find_nearby_theaters 사용자 좌표 또는 위치 키워드 기준으로 메가박스 주변 지점을 거리순으로 조회합니다. diff --git a/scripts/ops/mcp-smoke-validation.ts b/scripts/ops/mcp-smoke-validation.ts index 405ecfba..4931bb51 100644 --- a/scripts/ops/mcp-smoke-validation.ts +++ b/scripts/ops/mcp-smoke-validation.ts @@ -165,3 +165,17 @@ export function validateCinemaCollection( }); return schema.safeParse(payload).success ? null : '실제 극장 또는 상영작 목록이 없습니다'; } + +const deal = z.object({ goodsNo: text, name: text, price: count.positive(), soldOut: z.boolean() }); +const deals = z.object({ + date: text, + brandDeals: z.array(deal).min(1), + todayDeals: z.array(deal).min(1), + truncated: z.boolean(), +}); +export function validateOliveyoungTodayDeals(payload: Record): string | null { + const date = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10); + return deals.safeParse(payload).success && payload.date === date + ? null + : '한국 날짜 기준 오특 상품·가격·품절 정보가 없습니다'; +} diff --git a/scripts/ops/mcp-smoke.ts b/scripts/ops/mcp-smoke.ts index 1db17454..b7d0ddec 100644 --- a/scripts/ops/mcp-smoke.ts +++ b/scripts/ops/mcp-smoke.ts @@ -4,6 +4,7 @@ import { validateCatalogSnapshot, validateRetailInventory, validateCinemaCollection, + validateOliveyoungTodayDeals, } from './mcp-smoke-validation.js'; import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; /** @@ -262,6 +263,13 @@ export const MCP_SMOKE_SCENARIOS: McpSmokeScenario[] = [ args: { cinemaCode: '000067', includePlayDates: false, timeoutMs: 15000 }, validate: (payload) => validateCinemaCollection(payload, 'dtryx'), }, + { + service: 'oliveyoung', + label: '올리브영 오늘의 특가', + toolName: 'oliveyoung_get_today_deals', + args: { timeoutMs: 60000 }, + validate: validateOliveyoungTodayDeals, + }, ]; // 이전 시나리오 ID는 호출 라우팅에만 사용하고 공개 도구 목록은 늘리지 않습니다. diff --git a/scripts/relay/browser.ts b/scripts/relay/browser.ts index 8b7605ed..8294888a 100644 --- a/scripts/relay/browser.ts +++ b/scripts/relay/browser.ts @@ -1,5 +1,10 @@ /** 개인 프로필과 분리된 브라우저의 동일 출처 조회. */ import type { Page } from 'playwright'; +import { + buildOliveyoungHtmlPath, + isOliveyoungHtmlPath, + toOliveyoungHtmlResponse, +} from '../../src/services/oliveyoung/htmlRoute.js'; import type { BrowserRunner } from './oliveyoung.js'; export class BrowserRequestError extends Error { constructor( @@ -12,29 +17,38 @@ export class BrowserRequestError extends Error { } export function createBrowserRunner(page: Pick): BrowserRunner { return async (path, body) => { + // 오특은 JSON API가 없어 고정 HTML 경로만 GET으로 읽고 검증한 뒤 JSON으로 감쌉니다. + const html = isOliveyoungHtmlPath(path); + const target = html ? buildOliveyoungHtmlPath(path, body) : path; const result = await page.evaluate( - async ({ path, body, timeout }) => { + async ({ path, body, html, timeout }) => { let status: number | undefined; try { const response = await fetch(`https://www.oliveyoung.co.kr${path}`, { - method: 'POST', + method: html ? 'GET' : 'POST', credentials: 'include', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify(body), + headers: html + ? { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' } + : { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: html ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeout), }); status = response.status; if (status !== 200) { await response.body?.cancel(); const header = response.headers.get('retry-after'); - const seconds = header && /^\d+$/.test(header) ? Number(header) - : header ? Math.ceil((Date.parse(header) - Date.now()) / 1000) : NaN; - const retryAfter = Number.isSafeInteger(seconds) && seconds > 0 - ? Math.min(seconds, 86400) : undefined; + const seconds = + header && /^\d+$/.test(header) + ? Number(header) + : header + ? Math.ceil((Date.parse(header) - Date.now()) / 1000) + : NaN; + const retryAfter = + Number.isSafeInteger(seconds) && seconds > 0 ? Math.min(seconds, 86400) : undefined; return { status, body: null, retryAfter }; } const reader = response.body?.getReader(); @@ -53,6 +67,7 @@ export function createBrowserRunner(page: Pick): BrowserRunner text += decoder.decode(chunk.value, { stream: true }); } text += decoder.decode(); + if (html) return { status: response.status, body: text }; let parsed = null; try { parsed = JSON.parse(text); @@ -74,8 +89,16 @@ export function createBrowserRunner(page: Pick): BrowserRunner return { status, body: null, reason }; } }, - { path, body, timeout: 15000 }, + { path: target, body, html, timeout: 15000 }, ); + if (html && !result.reason && result.status === 200) { + try { + return toOliveyoungHtmlResponse(path, result.body); + } catch { + // 보안 확인 화면이나 다른 페이지를 빈 오특 목록으로 반환하지 않습니다. + throw new BrowserRequestError('invalid-html', result.status); + } + } if (result.reason || result.status !== 200 || result.body?.status !== 'SUCCESS') throw new BrowserRequestError( result.reason || diff --git a/scripts/relay/observed.ts b/scripts/relay/observed.ts index c3077102..ccaa43d2 100644 --- a/scripts/relay/observed.ts +++ b/scripts/relay/observed.ts @@ -22,7 +22,7 @@ export function observeRelay( const forwarded = new Request(request, { headers }); const path = new URL(request.url).pathname; const match = - /^\/v1\/(?:oliveyoung|dtryx)\/(find-store|product-search-v3|stock-goods-info-v3|stock-stores|movies|play-dates|timetable)$/.exec( + /^\/v1\/(?:oliveyoung|dtryx)\/(find-store|product-search-v3|stock-goods-info-v3|stock-stores|today-deals-page|today-deals-list|movies|play-dates|timetable)$/.exec( path, ); const operation = match ? match[1] : 'other'; diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 0bd3386b..a93b30a9 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -1,6 +1,7 @@ /** 고정된 올리브영 조회만 허용하는 인증 릴레이. */ import { randomUUID, timingSafeEqual } from 'node:crypto'; import { OLIVEYOUNG_API } from '../../src/services/oliveyoung/api.js'; +import { OLIVEYOUNG_HTML_OPERATIONS } from '../../src/services/oliveyoung/htmlRoute.js'; import type { OliveyoungApiResponse } from '../../src/services/oliveyoung/types.js'; import { BrowserRequestError } from './browser.js'; import { createConsumerQuota } from './consumer.js'; @@ -18,6 +19,7 @@ const paths: Record = { 'product-search-v3': OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, 'stock-goods-info-v3': OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, 'stock-stores': OLIVEYOUNG_API.STOCK_STORES_PATH, + ...OLIVEYOUNG_HTML_OPERATIONS, }; const fields: Record> = { 'find-store': { @@ -47,6 +49,9 @@ const fields: Record> = mapLat: 'number', mapLon: 'number', }, + // 오특 날짜는 한국 자정 전후 응답을 다른 캐시로 구분합니다. + 'today-deals-page': { date: 'string' }, + 'today-deals-list': { date: 'string', pageIdx: 'number' }, }; const error = (status: number, message: string) => Response.json({ error: message }, { status }); @@ -88,18 +93,30 @@ export function createOliveyoungRelay( 'product-search-v3': 300000, 'stock-goods-info-v3': 600000, 'stock-stores': 60000, + 'today-deals-page': 300000, + 'today-deals-list': 300000, }; const cooldowns = new Map(); const cooldownError = (operation: string) => { const cooldown = cooldowns.get(operation); if (!cooldown || cooldown.until <= Date.now()) return undefined; - return new BrowserRequestError('http-429', 429, Math.ceil((cooldown.until - Date.now()) / 1000)); + return new BrowserRequestError( + 'http-429', + 429, + Math.ceil((cooldown.until - Date.now()) / 1000), + ); }; - const originLimited = (failure: BrowserRequestError, operation: string) => Response.json({ - error: 'Upstream rate limited', code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', - upstreamStatus: 429, retryAfter: failure.retryAfter, - retryAfterSource: cooldowns.get(operation)!.source, - }, { status: 429, headers: { 'retry-after': String(failure.retryAfter) } }); + const originLimited = (failure: BrowserRequestError, operation: string) => + Response.json( + { + error: 'Upstream rate limited', + code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', + upstreamStatus: 429, + retryAfter: failure.retryAfter, + retryAfterSource: cooldowns.get(operation)!.source, + }, + { status: 429, headers: { 'retry-after': String(failure.retryAfter) } }, + ); type Result = OliveyoungApiResponse | null | 'quota' | 'unavailable'; const pending = new Map< string, @@ -239,9 +256,15 @@ export function createOliveyoungRelay( result = await run(paths[operation], body); } catch (failure) { if (failure instanceof BrowserRequestError && failure.upstreamStatus === 429) { - const valid = Number.isInteger(failure.retryAfter) && failure.retryAfter! >= 1 && failure.retryAfter! <= 86400; + const valid = + Number.isInteger(failure.retryAfter) && + failure.retryAfter! >= 1 && + failure.retryAfter! <= 86400; const retryAfter = valid ? failure.retryAfter! : 60; - cooldowns.set(operation, { until: Date.now() + retryAfter * 1000, source: valid ? 'upstream' : 'local-policy' }); + cooldowns.set(operation, { + until: Date.now() + retryAfter * 1000, + source: valid ? 'upstream' : 'local-policy', + }); throw new BrowserRequestError('http-429', 429, retryAfter); } throw failure; diff --git a/src/core/compact/catalog.ts b/src/core/compact/catalog.ts index 2c88965e..648512e1 100644 --- a/src/core/compact/catalog.ts +++ b/src/core/compact/catalog.ts @@ -9,6 +9,8 @@ export interface CompactGroup { title: string; description: string; routes: CompactRoute[]; + /** service를 생략한 기존 호출이 선택할 서비스입니다. 서비스가 나중에 추가된 도구에만 지정합니다. */ + defaultService?: string; } const retail = (names: Record): CompactRoute[] => Object.entries(names).map(([service, legacy]) => ({ service, legacy })); @@ -115,10 +117,12 @@ export const COMPACT_CATALOG: CompactGroup[] = [ name: 'get_catalog', title: '인기 검색어와 기획전', description: - '세븐일레븐 공개 카탈로그. operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. operation은 필수입니다. includeIssues/includeExhibition/limit은 snapshot 전용이며 popwords에는 label만 지정합니다. 실시간 재고를 의미하지 않습니다.', + '공개 카탈로그. operation은 필수입니다. service를 생략하면 세븐일레븐이며 operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. includeIssues/includeExhibition/limit은 snapshot 전용이며 popwords에는 label만 지정합니다. service=oliveyoung, operation=today_deals는 올리브영 오특(오늘의 브랜드 특가와 오늘의 특가)입니다. 실시간 재고를 의미하지 않습니다.', + defaultService: 'seveneleven', routes: [ - { operation: 'popwords', legacy: 'seveneleven_get_search_popwords' }, - { operation: 'snapshot', legacy: 'seveneleven_get_catalog_snapshot' }, + { service: 'seveneleven', operation: 'popwords', legacy: 'seveneleven_get_search_popwords' }, + { service: 'seveneleven', operation: 'snapshot', legacy: 'seveneleven_get_catalog_snapshot' }, + { service: 'oliveyoung', operation: 'today_deals', legacy: 'oliveyoung_get_today_deals' }, ], }, { diff --git a/src/core/compact/tools.ts b/src/core/compact/tools.ts index efdff190..0fe95486 100644 --- a/src/core/compact/tools.ts +++ b/src/core/compact/tools.ts @@ -56,13 +56,18 @@ function mergedInputs(group: CompactGroup, legacy: Map const operations = [ ...new Set(group.routes.flatMap((route) => (route.operation ? [route.operation] : []))), ]; - if (services.length) - shape.service = z.enum(services as [string, ...string[]]).describe('조회할 서비스'); + if (services.length) { + const service = z.enum(services as [string, ...string[]]); + shape.service = group.defaultService + ? service.optional().describe(`조회할 서비스. 생략하면 ${group.defaultService}`) + : service.describe('조회할 서비스'); + } if (operations.length) { const operation = z.enum(operations as [string, ...string[]]); - shape.operation = (services.length ? operation.optional() : operation).describe( - '조회 작업. 같은 서비스에 작업이 여러 개면 필수입니다.', - ); + // 서비스 없이 작업만으로 호출하던 도구는 작업을 계속 필수로 요구합니다. + shape.operation = ( + services.length && !group.defaultService ? operation.optional() : operation + ).describe('조회 작업. 같은 서비스에 작업이 여러 개면 필수입니다.'); } return shape; } @@ -98,7 +103,7 @@ export function createCompactTools(legacyTools: ToolRegistration[]): ToolRegistr handler: async (args: unknown) => { const parsed = z.object(inputSchema).strict().parse(args); if (group.name === 'submit_request') return single.handler(parsed.request); - const { service, operation, ...input } = parsed; + const { service = group.defaultService, operation, ...input } = parsed; const matches = group.routes.filter( (route) => route.service === service && (operation === undefined || route.operation === operation), diff --git a/src/pages/promptReferenceText.ts b/src/pages/promptReferenceText.ts index 25c9e67d..91f47162 100644 --- a/src/pages/promptReferenceText.ts +++ b/src/pages/promptReferenceText.ts @@ -130,7 +130,7 @@ MCP 연결 정보: ${baseUrl}/mcp - find_places: 주변 장소·영화관 검색 - get_showtimes: 영화·시간표·잔여석 - get_fuel_info: 유가·주유소 조회 -- get_catalog: 인기 검색어·기획전 +- get_catalog: 인기 검색어·기획전·올리브영 오특 - compare_products: 상품 비교 - submit_request: 요청 제출 (현재 requestType=developer) diff --git a/src/services/oliveyoung/api.ts b/src/services/oliveyoung/api.ts index fc85334d..e47a5ae2 100644 --- a/src/services/oliveyoung/api.ts +++ b/src/services/oliveyoung/api.ts @@ -8,4 +8,7 @@ export const OLIVEYOUNG_API = { PRODUCT_SEARCH_PATH: '/oystore/api/stock/product-search-v3', STOCK_GOODS_INFO_PATH: '/oystore/api/stock/stock-goods-info-v3', STOCK_STORES_PATH: '/oystore/api/stock/stock-stores', + // 오특(오늘의 특가)은 JSON API 없이 HTML 페이지와 목록 조각으로만 제공됩니다. + TODAY_DEALS_PAGE_PATH: '/store/main/getHotdealList.do', + TODAY_DEALS_LIST_PATH: '/store/main/getHotdealPagingListAjax.do', } as const; diff --git a/src/services/oliveyoung/htmlRoute.ts b/src/services/oliveyoung/htmlRoute.ts new file mode 100644 index 00000000..52b96cdc --- /dev/null +++ b/src/services/oliveyoung/htmlRoute.ts @@ -0,0 +1,78 @@ +/** + * 올리브영 오특 HTML 조회 계약 + * + * 직접 조회와 브라우저 중계가 같은 고정 경로, 인수 검증, 응답 판정을 사용합니다. + */ + +import { OLIVEYOUNG_API } from './api.js'; +import type { OliveyoungApiResponse } from './types.js'; + +/** 중계 작업 이름과 공식 경로입니다. */ +export const OLIVEYOUNG_HTML_OPERATIONS = { + 'today-deals-page': OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH, + 'today-deals-list': OLIVEYOUNG_API.TODAY_DEALS_LIST_PATH, +} as const; + +export const OLIVEYOUNG_TODAY_DEALS_MAX_PAGE = 20; + +// 정상 페이지에는 없는 Cloudflare 보안 확인 화면의 표시입니다. +const CHALLENGE_PATTERN = /_cf_chl|잠시만 기다려/; + +export function isOliveyoungHtmlPath(path: string): boolean { + return Object.values(OLIVEYOUNG_HTML_OPERATIONS).includes(path); +} + +/** 공식 경로에 대응하는 중계 작업 이름입니다. HTML 경로가 아니면 undefined입니다. */ +export function oliveyoungHtmlOperation(path: string): string | undefined { + return Object.entries(OLIVEYOUNG_HTML_OPERATIONS).find(([, value]) => value === path)?.[0]; +} + +/** + * 허용한 날짜와 페이지만 조회 주소에 넣습니다. + * 오특 페이지의 날짜는 자정 전후 캐시를 구분하는 용도이며 주소에는 붙이지 않습니다. + * + * @throws {Error} 경로나 인수가 계약과 다를 때 + */ +export function buildOliveyoungHtmlPath(path: string, body: Record): string { + const { date, pageIdx } = body; + const keys = Object.keys(body).sort().join(','); + if (typeof date !== 'string' || !/^\d{8}$/.test(date)) { + throw new Error('올리브영 오특 날짜 형식 오류'); + } + if (path === OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH && keys === 'date') return path; + if ( + path !== OLIVEYOUNG_API.TODAY_DEALS_LIST_PATH || + keys !== 'date,pageIdx' || + typeof pageIdx !== 'number' || + !Number.isInteger(pageIdx) || + pageIdx < 1 || + pageIdx > OLIVEYOUNG_TODAY_DEALS_MAX_PAGE + ) { + throw new Error('올리브영 오특 요청 형식 오류'); + } + const query = new URLSearchParams({ + date, + pageIdx: String(pageIdx), + fltCondition: '02', + fltDispCatNo: '', + prdSort: 'rank', + }); + return `${path}?${query}`; +} + +/** + * 정상 오특 HTML만 성공 응답으로 감쌉니다. + * 보안 확인 화면이나 다른 페이지를 빈 목록으로 바꾸지 않습니다. + * + * @throws {Error} 오특 HTML이 아닐 때 + */ +export function toOliveyoungHtmlResponse(path: string, html: unknown): OliveyoungApiResponse { + const valid = + typeof html === 'string' && + !CHALLENGE_PATTERN.test(html) && + (path === OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH + ? html.includes('today_specials_inner') + : html.includes('
  • { + const named: Record = { + '&': '&', + '"': '"', + ''': "'", + '<': '<', + '>': '>', + ' ': ' ', + ''': "'", + }; + const key = entity.toLowerCase(); + if (named[key] !== undefined) return named[key]; + const code = key.startsWith('&#x') ? parseInt(key.slice(3), 16) : parseInt(key.slice(2), 10); + return code <= 0x10ffff ? String.fromCodePoint(code) : entity; + }); +} + +function text(value: string | undefined): string { + return decode((value ?? '').replace(/<[^>]*>/g, ' ')) + .replace(/\s+/g, ' ') + .trim(); +} + +function amount(value: string | undefined): number | null { + const digits = (value ?? '').replace(/[^\d]/g, ''); + return digits ? Number(digits) : null; +} + +/** 한국 시간 기준 YYYYMMDD 날짜입니다. 오특은 한국 자정에 바뀝니다. */ +export function toKstDate(now: Date): string { + return new Date(now.getTime() + KST_OFFSET_MS).toISOString().slice(0, 10).replaceAll('-', ''); +} + +/** + * 오특 HTML의 상품 카드를 읽습니다. + * + * @param html - 오특 페이지 또는 목록 조각 + * @param section - data-impression의 코너 이름 끝부분 (예: 스페셜). 생략하면 모든 카드 + * @throws {ServiceError} 상품 카드의 필수 값이 없을 때 + */ +export function parseOliveyoungTodayDeals(html: string, section?: string): OliveyoungTodayDeal[] { + const deals: OliveyoungTodayDeal[] = []; + const source = section + ? [...html.matchAll(/]*class="[^"]*\bprod-list\b[^"]*"[^>]*>[\s\S]*?<\/ul>/g)] + .map((match) => match[0]) + .filter((list) => list.includes(`_${section}`)) + .join('\n') + : html; + const cards = source.split('
  • ([\s\S]*?)<\/span>/)?.[1]); + const total = card.match(/class="total">([\s\S]*?)<\/strong>/)?.[1]; + const price = amount(total); + const rank = Number(impression[2]); + if (!goodsNo || !name || price === null || !Number.isInteger(rank)) invalid(); + const priceBox = card.match(/class="price">([\s\S]*?)<\/div>/)?.[1] ?? ''; + const rate = card.match(/class="Pnum">\s*(\d+)\s*%/)?.[1]; + deals.push({ + rank, + goodsNo, + name, + brand: decode(card.match(/data-ref-goodsbrand="([^"]*)"/)?.[1] ?? ''), + category: decode(card.match(/data-ref-goodscategory="([^"]*)"/)?.[1] ?? ''), + price, + priceFrom: text(total).endsWith('~'), + originalPrice: amount(priceBox.match(/class="origin">([\s\S]*?)<\/span>\s*<\/span>/)?.[1]), + discountRate: rate === undefined ? null : Number(rate), + flags: [...card.matchAll(/class="flag [^"]*">([\s\S]*?)<\/span>/g)].map((m) => text(m[1])), + todayDelivery: priceBox.includes('class="oneday"'), + soldOut: card.includes('class="status_flag soldout"'), + imageUrl: card.match(/ + match[1].endsWith(`_${section}`), + ).length; + if (deals.length !== expected) invalid(); + } + return deals; +} + +function htmlOf(response: { data?: { html?: string } }): string { + const html = response.data?.html; + if (typeof html !== 'string') invalid(); + return html; +} + +/** + * 오늘의 브랜드 특가와 오늘의 특가 전체 목록을 조회합니다. + * + * @param options - 직접 조회 또는 중계 설정 + * @param now - 기준 시각 (테스트용) + * @throws {ServiceError | Error} 원본 차단, 중계 실패, 형식 변경 시 + */ +export async function fetchOliveyoungTodayDeals( + options: OliveyoungRequestOptions = {}, + now = new Date(), +): Promise { + const date = toKstDate(now); + // 페이지 여러 개를 합친 전체 제한 시간입니다. + const deadline = Date.now() + (options.timeout ?? (options.relayUrl ? 60000 : 15000)); + const request = (path: string, body: Record) => { + const timeout = deadline - Date.now(); + if (timeout <= 0) { + throw new ServiceError( + 'OLIVEYOUNG_TODAY_DEALS_TIMEOUT', + '올리브영 오특 조회 시간 초과', + 504, + true, + ); + } + return requestOliveyoung(path, body, { ...options, timeout }); + }; + const page = await request(OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH, { date }); + const brandDeals = parseOliveyoungTodayDeals(htmlOf(page), '스페셜'); + if (!brandDeals.length) invalid(); + const todayDeals: OliveyoungTodayDeal[] = []; + const seen = new Set(); + let truncated = false; + for (let pageIdx = 1; pageIdx <= OLIVEYOUNG_TODAY_DEALS_MAX_PAGE; pageIdx++) { + const list = await request(OLIVEYOUNG_API.TODAY_DEALS_LIST_PATH, { date, pageIdx }); + const items = parseOliveyoungTodayDeals(htmlOf(list)); + for (const item of items) { + if (seen.has(item.goodsNo)) continue; + seen.add(item.goodsNo); + todayDeals.push(item); + } + if (items.length < LIST_PAGE_SIZE) break; + truncated = pageIdx === OLIVEYOUNG_TODAY_DEALS_MAX_PAGE; + } + return { + date: `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6)}`, + brandDeals, + todayDeals, + truncated, + }; +} diff --git a/src/services/oliveyoung/tools/getTodayDeals.ts b/src/services/oliveyoung/tools/getTodayDeals.ts new file mode 100644 index 00000000..144304e0 --- /dev/null +++ b/src/services/oliveyoung/tools/getTodayDeals.ts @@ -0,0 +1,88 @@ +import type { OliveyoungRequestOptions } from '../transport.js'; +/** + * 올리브영 오특(오늘의 특가) 조회 도구 + * + * 오늘의 브랜드 특가와 오늘의 특가 전체 목록을 한국 날짜 기준으로 반환합니다. + * 공개 MCP에서는 get_catalog의 service=oliveyoung, operation=today_deals로 호출합니다. + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { fetchOliveyoungTodayDeals } from '../todayDeals.js'; + +interface GetTodayDealsArgs { + timeoutMs?: number; +} + +async function getTodayDeals( + args: GetTodayDealsArgs, + transport: OliveyoungRequestOptions, +): Promise { + const { timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000) } = args; + const result = await fetchOliveyoungTodayDeals({ ...transport, timeout: timeoutMs }); + const body = { + date: result.date, + brandDealCount: result.brandDeals.length, + todayDealCount: result.todayDeals.length, + truncated: result.truncated, + brandDeals: result.brandDeals, + todayDeals: result.todayDeals, + note: '가격과 할인율은 조회 시점의 오특 화면 기준입니다. priceFrom=true는 옵션별 최저가, soldOut=true는 일시품절입니다. 매장 재고는 check_inventory(service=oliveyoung)에 상품명을 keyword로 조회하세요.', + }; + return { + content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], + structuredContent: body, + }; +} + +const dealSchema = z.object({ + rank: z.number(), + goodsNo: z.string(), + name: z.string(), + brand: z.string(), + category: z.string(), + price: z.number(), + priceFrom: z.boolean(), + originalPrice: z.number().nullable(), + discountRate: z.number().nullable(), + flags: z.array(z.string()), + todayDelivery: z.boolean(), + soldOut: z.boolean(), + imageUrl: z.string(), + productUrl: z.string(), +}); + +export function createGetTodayDealsTool( + transport: OliveyoungRequestOptions = {}, +): ToolRegistration { + const defaultTimeout = transport.timeout ?? (transport.relayUrl ? 60000 : 15000); + return { + name: 'oliveyoung_get_today_deals', + metadata: { + title: '올리브영 오특 조회', + description: + '올리브영 오특(오늘의 특가)의 오늘의 브랜드 특가와 오늘의 특가 상품 목록을 한국 날짜 기준으로 조회합니다. 실시간 매장 재고를 의미하지 않습니다.', + inputSchema: { + timeoutMs: z + .number() + .optional() + .default(defaultTimeout) + .describe( + '전체 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000). 페이지 여러 개를 합친 시간입니다.', + ), + }, + outputSchema: { + date: z.string(), + brandDealCount: z.number(), + todayDealCount: z.number(), + truncated: z.boolean(), + brandDeals: z.array(dealSchema), + todayDeals: z.array(dealSchema), + note: z.string(), + }, + }, + handler: ((args: GetTodayDealsArgs) => getTodayDeals(args, transport)) as ( + args: unknown, + ) => Promise, + }; +} diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 341ddec1..d7ff062c 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -3,9 +3,15 @@ import { routeOperation, type RouteKey } from '../../utils/routeHealth.js'; import { diagnosticHeaders, relayConsumer } from '../../utils/diagnostics.js'; /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */ import { createRelayCooldown, relayCredentialScope } from '../../utils/relayQuota.js'; -import { HttpError, fetchJson } from '../../utils/http.js'; +import { HttpError, fetchJson, fetchText } from '../../utils/http.js'; import { toOliveyoungRelayError } from './errors.js'; import { OLIVEYOUNG_API } from './api.js'; +import { + buildOliveyoungHtmlPath, + isOliveyoungHtmlPath, + oliveyoungHtmlOperation, + toOliveyoungHtmlResponse, +} from './htmlRoute.js'; import type { OliveyoungApiResponse } from './types.js'; const cooldown = createRelayCooldown(); @@ -110,7 +116,8 @@ async function requestOliveyoungRaw( let url = `${OLIVEYOUNG_API.BASE_URL}${path}`; if (relayUrl) { validateRelay(options); - url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`; + const operation = oliveyoungHtmlOperation(path) ?? path.split('/').pop(); + url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${operation}`; Object.assign(headers, diagnosticHeaders()); headers.Authorization = `Bearer ${relayToken}`; if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; @@ -155,17 +162,20 @@ async function requestOliveyoungRaw( 'retry-after': String(cachedQuota.retryAfter), }), ); - result = await fetchJson(url, { - method: 'POST', - // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다. - redirect: 'manual', - headers, - body: JSON.stringify(body), - timeout: remaining, - signal: options.signal, - retries: 0, - expectedStatus: 200, - }); + result = + !relayUrl && isOliveyoungHtmlPath(path) + ? await requestOliveyoungHtml(path, body, headers, remaining, options.signal) + : await fetchJson(url, { + method: 'POST', + // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다. + redirect: 'manual', + headers, + body: JSON.stringify(body), + timeout: remaining, + signal: options.signal, + retries: 0, + expectedStatus: 200, + }); } catch (error) { if (relayUrl) { if (error instanceof HttpError && error.quota) { @@ -200,3 +210,25 @@ async function requestOliveyoungRaw( return result; } } + +/** 오특 HTML은 공식 사이트에서 GET으로만 제공되어 JSON 성공 응답으로 감싸 반환합니다. */ +async function requestOliveyoungHtml( + path: string, + body: Record, + headers: Record, + timeout: number, + signal?: AbortSignal, +): Promise { + const pageHeaders: Record = { ...headers, Accept: 'text/html' }; + delete pageHeaders['Content-Type']; + const html = await fetchText(`${OLIVEYOUNG_API.BASE_URL}${buildOliveyoungHtmlPath(path, body)}`, { + method: 'GET', + redirect: 'manual', + headers: pageHeaders, + timeout, + signal, + retries: 0, + expectedStatus: 200, + }); + return toOliveyoungHtmlResponse(path, html); +} diff --git a/src/services/oliveyoung/types.ts b/src/services/oliveyoung/types.ts index b94aaf5b..72a0aca2 100644 --- a/src/services/oliveyoung/types.ts +++ b/src/services/oliveyoung/types.ts @@ -52,6 +52,8 @@ export interface OliveyoungProductStoreInventory { } interface OliveyoungApiData { + /** 오특 HTML 조회의 원문입니다. */ + html?: string; totalCount?: number; nextPage?: boolean; goodsInfo?: { diff --git a/src/utils/http.ts b/src/utils/http.ts index 03537273..3237cec0 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -9,7 +9,7 @@ export interface FetchOptions extends RequestInit { timeout?: number; /** 모든 시도와 대기 시간을 합친 제한 시간입니다. */ totalTimeout?: number; - /** fetchJson에서 허용할 정확한 HTTP 상태입니다. 생략하면 모든 2xx를 허용합니다. */ + /** fetchJson/fetchText에서 허용할 정확한 HTTP 상태입니다. 생략하면 모든 2xx를 허용합니다. */ expectedStatus?: number; retries?: number; retryDelayMs?: number; @@ -271,7 +271,10 @@ export async function fetchJson(url: string, options: FetchOptions = {}): Pro export async function fetchText(url: string, options: FetchOptions = {}): Promise { const { response, body } = await readTextResponse(url, options); - if (!response.ok) { + if ( + !response.ok || + (options.expectedStatus !== undefined && response.status !== options.expectedStatus) + ) { throw new HttpError(response.status, response.statusText, body, response.headers); } diff --git a/tests/core/compact/tools.test.ts b/tests/core/compact/tools.test.ts index 43dbdc0a..f44da77d 100644 --- a/tests/core/compact/tools.test.ts +++ b/tests/core/compact/tools.test.ts @@ -19,9 +19,9 @@ function fixtures() { })); } describe('공개 MCP 10개 계약', () => { - it('기존44개 작업 전체를 중복 없이 매핑한다', () => { + it('기존44개 작업과 오특 내부 작업 전체를 중복 없이 매핑한다', () => { const tools = fixtures(); - expect(tools).toHaveLength(44); + expect(tools).toHaveLength(45); expect(PUBLIC_TOOL_NAMES).toHaveLength(10); expect(createCompactTools(tools).map((t) => t.name)).toEqual([...PUBLIC_TOOL_NAMES]); expect(() => @@ -84,3 +84,26 @@ describe('공개 MCP 10개 계약', () => { ); }); }); +describe('get_catalog 서비스 확장', () => { + it('service를 생략한 기존 호출은 세븐일레븐으로 보내고 올리브영 오특은 명시해 호출한다', async () => { + const legacy = fixtures(); + const tool = createCompactTools(legacy).find((t) => t.name === 'get_catalog')!; + const schema = z.toJSONSchema(z.object(tool.metadata.inputSchema), { io: 'input' }); + expect(schema.required).toEqual(['operation']); + expect(tool.metadata.inputSchema.service.description).toContain('생략하면 seveneleven'); + const call = async (args: unknown) => + JSON.parse((await tool.handler(args)).content[0].text).name; + expect(await call({ operation: 'popwords' })).toBe('seveneleven_get_search_popwords'); + expect(await call({ service: 'seveneleven', operation: 'snapshot' })).toBe( + 'seveneleven_get_catalog_snapshot', + ); + expect(await call({ service: 'oliveyoung', operation: 'today_deals' })).toBe( + 'oliveyoung_get_today_deals', + ); + await expect(tool.handler({ operation: 'today_deals' })).rejects.toThrow('service/operation'); + await expect(tool.handler({ service: 'oliveyoung', operation: 'popwords' })).rejects.toThrow( + 'service/operation', + ); + await expect(tool.handler({ service: 'oliveyoung' })).rejects.toThrow(); + }); +}); diff --git a/tests/fixtures/oliveyoung/today-deals-list-1.html b/tests/fixtures/oliveyoung/today-deals-list-1.html new file mode 100644 index 00000000..c2dee29c --- /dev/null +++ b/tests/fixtures/oliveyoung/today-deals-list-1.html @@ -0,0 +1,11 @@ + +
  • 44%[26년 성분 업그레이드/보습광채] 달바 퍼스트 스프레이 세럼 펩타이드 100ml 더블기획
  • +
  • 15%[NEW/단독신상] 시튜에아 에어리 무스 벨벳
  • +
  • 21%[청담템/연예인 셀프 케어템] 어반트라이브 노워시 트리트먼트 하이드레이트 리브 인 폼 200ml 단품/기획
  • +
  • 13%[NEW] 올더베러 고운쌀 단백질쉐이크 45g 택1
  • +
  • 62%[호빵맨 콜라보/10월올영픽] 좋은느낌 수퍼소프트 패드/입는오버나이트 기획(+호빵맨 말랑피규어)
  • +
  • 25%[NEW블랙에디션/구매인증이벤트] 무지개맨션 오브제 리퀴드 틴트
  • +
  • 20%[NEW/더마비비] 커브드 스킨 에이징 커브 펩타이드 스킨 핏 비비 세럼 50ml
  • +
  • 25%[1등속눈썹영양제] 코스노리 아이래쉬 세럼 속눈썹영양제 9g (클리어/블랙)
  • +
  • 35%[물방울초음파 원조 1위] 쿼드쎄라 틴즈 뷰티디바이스
  • +
  • 33%[올영픽][호빵맨 콜라보] 듀오버스터 민트볼 더블기획(+미니 타포린백 키링)
  • diff --git a/tests/fixtures/oliveyoung/today-deals-list-3.html b/tests/fixtures/oliveyoung/today-deals-list-3.html new file mode 100644 index 00000000..cb993dca --- /dev/null +++ b/tests/fixtures/oliveyoung/today-deals-list-3.html @@ -0,0 +1,8 @@ + +
  • 33%[골라담기] 덴프스 트루바이타민 맥스 5종 택1
  • +
  • 23%[광채케어/고보습] 어노브 글로우 바디워시 290ml (로지아우라/누바닐라)
  • +
  • 32%라운드어라운드 플라워/선인장/댕댕이 디퓨저 100ml 6종
  • +
  • 26%[저당/저칼로리] 한끼통살 REAL 마녀스프 300g 5종 택1
  • +
  • 23%웰247 페이스튠핏
  • +
  • 22%아츠기 발열타이즈 블랙(80D/110D)
  • +
  • 30%아이코닉 2027 이겨낸다 다이어리 위클리 플래너
  • diff --git a/tests/fixtures/oliveyoung/today-deals-list-soldout.html b/tests/fixtures/oliveyoung/today-deals-list-soldout.html new file mode 100644 index 00000000..0e5959fd --- /dev/null +++ b/tests/fixtures/oliveyoung/today-deals-list-soldout.html @@ -0,0 +1 @@ +
  • 37%[온라인 단독]올더베러 엑스트라버진 올리브오일 캡슐(30일분 x 4개입)일시품절
  • diff --git a/tests/fixtures/oliveyoung/today-deals-page.html b/tests/fixtures/oliveyoung/today-deals-page.html new file mode 100644 index 00000000..15a8a732 --- /dev/null +++ b/tests/fixtures/oliveyoung/today-deals-page.html @@ -0,0 +1,31 @@ + +오특 | 올리브영
    +
    +
    diff --git a/tests/relay/browser.test.ts b/tests/relay/browser.test.ts index 03b4e43e..ac34125b 100644 --- a/tests/relay/browser.test.ts +++ b/tests/relay/browser.test.ts @@ -73,21 +73,94 @@ it('본문 없는 HTTP 응답을 거절한다', async () => { vi.unstubAllGlobals(); } }); -it.each([['17', 17], ['0', undefined], ['1.5', undefined], ['bad', undefined], ['999999', 86400], [null, undefined]])('원본 429의 안전한 숫자 Retry-After %s만 보존한다', async (header, retryAfter) => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('private challenge', { status: 429, headers: header === null ? {} : { 'retry-after': header } }))); +it.each([ + ['17', 17], + ['0', undefined], + ['1.5', undefined], + ['bad', undefined], + ['999999', 86400], + [null, undefined], +])('원본 429의 안전한 숫자 Retry-After %s만 보존한다', async (header, retryAfter) => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response('private challenge', { + status: 429, + headers: header === null ? {} : { 'retry-after': header }, + }), + ), + ); try { const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; - await expect(createBrowserRunner(page)('/p', {})).rejects.toMatchObject({ reason: 'http-429', upstreamStatus: 429, retryAfter }); - } finally { vi.unstubAllGlobals(); } + await expect(createBrowserRunner(page)('/p', {})).rejects.toMatchObject({ + reason: 'http-429', + upstreamStatus: 429, + retryAfter, + }); + } finally { + vi.unstubAllGlobals(); + } }); -it.each([[17000, 17], [17001, 18], [-1000, undefined], [0, undefined], [86401000, 86400]])('원본 Retry-After HTTP 날짜의 남은 초 %s를 안전하게 보존한다', async (remaining, retryAfter) => { +it.each([ + [17000, 17], + [17001, 18], + [-1000, undefined], + [0, undefined], + [86401000, 86400], +])('원본 Retry-After HTTP 날짜의 남은 초 %s를 안전하게 보존한다', async (remaining, retryAfter) => { const now = Date.UTC(2026, 9, 26, 1, 0, 0); const clock = vi.spyOn(Date, 'now').mockReturnValue(now + (remaining === 17001 ? 999 : 0)); const deadline = now + (remaining === 17001 ? 18000 : remaining); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('private challenge', { status: 429, headers: { 'retry-after': new Date(deadline).toUTCString() } }))); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response('private challenge', { + status: 429, + headers: { 'retry-after': new Date(deadline).toUTCString() }, + }), + ), + ); try { const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; - await expect(createBrowserRunner(page)('/p', {})).rejects.toMatchObject({ reason: 'http-429', upstreamStatus: 429, retryAfter }); - } finally { clock.mockRestore(); vi.unstubAllGlobals(); } + await expect(createBrowserRunner(page)('/p', {})).rejects.toMatchObject({ + reason: 'http-429', + upstreamStatus: 429, + retryAfter, + }); + } finally { + clock.mockRestore(); + vi.unstubAllGlobals(); + } +}); +it('오특 HTML은 고정 주소를 GET으로 읽고 검증한 HTML만 성공으로 감싼다', async () => { + const html = '
  • '; + const fetch = vi.fn().mockResolvedValue(new Response(html)); + vi.stubGlobal('fetch', fetch); + const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; + const run = createBrowserRunner(page); + const list = '/store/main/getHotdealPagingListAjax.do'; + try { + expect(await run(list, { date: '20261026', pageIdx: 2 })).toEqual({ + status: 'SUCCESS', + data: { html }, + }); + expect(fetch).toHaveBeenCalledWith( + `https://www.oliveyoung.co.kr${list}?date=20261026&pageIdx=2&fltCondition=02&fltDispCatNo=&prdSort=rank`, + expect.objectContaining({ method: 'GET', credentials: 'include', body: undefined }), + ); + expect(fetch.mock.calls[0][1].headers).not.toHaveProperty('Content-Type'); + fetch.mockResolvedValue(new Response('잠시만 기다려 주세요')); + await expect(run(list, { date: '20261026', pageIdx: 1 })).rejects.toThrow('브라우저 응답 실패'); + fetch.mockResolvedValue(new Response(html, { status: 403 })); + await expect(run(list, { date: '20261026', pageIdx: 1 })).rejects.toThrow('브라우저 응답 실패'); + await expect(run(list, { date: 'today', pageIdx: 1 })).rejects.toThrow('날짜 형식'); + expect(fetch).toHaveBeenCalledTimes(3); + } finally { + vi.unstubAllGlobals(); + } }); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts index de9221fa..f00a90ea 100644 --- a/tests/relay/oliveyoung.test.ts +++ b/tests/relay/oliveyoung.test.ts @@ -1,7 +1,9 @@ import { expect, it, vi } from 'vitest'; import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; let consumerSequence = 0; -const consumerHeaders = () => ({ 'x-relay-consumer': (++consumerSequence).toString(16).padStart(64, '0') }); +const consumerHeaders = () => ({ + 'x-relay-consumer': (++consumerSequence).toString(16).padStart(64, '0'), +}); const payload = { goodsNo: 'A1' }; const request = (path = 'stock-goods-info-v3', body = JSON.stringify(payload), token = 'test') => new Request(`http://localhost/v1/oliveyoung/${path}`, { @@ -412,16 +414,27 @@ it('브라우저 교체 중 39999밀리초 기다린 작업은 실행하고 40 const clock = vi.spyOn(Date, 'now').mockReturnValue(0); const events: unknown[] = []; let release!: (value: { status: string }) => void; - const runner = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve; })) + const runner = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) .mockResolvedValue({ status: 'SUCCESS' }); - const relay = createOliveyoungRelay('test', runner, { onEvent: event => { events.push(event); } }); + const relay = createOliveyoungRelay('test', runner, { + onEvent: (event) => { + events.push(event); + }, + }); const first = relay(request()); await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const expired = relay(request(undefined, JSON.stringify({ goodsNo: 'expired' }))); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); clock.mockReturnValue(1); const valid = relay(request(undefined, JSON.stringify({ goodsNo: 'valid' }))); - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); clock.mockReturnValue(40000); release({ status: 'SUCCESS' }); expect((await first).status).toBe(200); @@ -436,13 +449,25 @@ it('fresh authenticated calls skip cached and pending values while retaining quo const takeQuota = vi.fn().mockResolvedValue(true); const relay = createOliveyoungRelay('test', runner, { takeQuota }); await relay(request()); - const fresh = () => new Request(request(), { headers: { Authorization: 'Bearer test', ...consumerHeaders(), 'x-relay-cache-bypass': 'true' } }); + const fresh = () => + new Request(request(), { + headers: { + Authorization: 'Bearer test', + ...consumerHeaders(), + 'x-relay-cache-bypass': 'true', + }, + }); const response = await relay(fresh()); expect(response.headers.get('x-relay-cache')).toBe('miss'); expect(runner).toHaveBeenCalledTimes(2); expect(takeQuota).toHaveBeenCalledTimes(2); let finish!: (value: unknown) => void; - runner.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + runner.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); const first = relay(fresh()); await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(3)); const second = relay(fresh()); @@ -454,5 +479,32 @@ it('fresh authenticated calls skip cached and pending values while retaining quo takeQuota.mockResolvedValueOnce(false); expect((await relay(fresh())).status).toBe(429); expect(runner).toHaveBeenCalledTimes(4); - expect((await relay(new Request(fresh(), { headers: { Authorization: 'Bearer wrong', 'x-relay-cache-bypass': 'true' } }))).status).toBe(401); + expect( + ( + await relay( + new Request(fresh(), { + headers: { Authorization: 'Bearer wrong', 'x-relay-cache-bypass': 'true' }, + }), + ) + ).status, + ).toBe(401); +}); +it('오특 작업은 날짜·페이지만 받아 공식 경로로 전달하고 다른 인수는 거절한다', async () => { + const result = { status: 'SUCCESS', data: { html: '
  • ' } }; + const runner = vi.fn().mockResolvedValue(result); + const relay = createOliveyoungRelay('test', runner); + const list = JSON.stringify({ date: '20261026', pageIdx: 1 }); + expect(await (await relay(request('today-deals-list', list))).json()).toEqual(result); + expect(runner).toHaveBeenCalledWith('/store/main/getHotdealPagingListAjax.do', { + date: '20261026', + pageIdx: 1, + }); + expect( + (await relay(request('today-deals-page', JSON.stringify({ date: '20261026' })))).status, + ).toBe(200); + expect(runner).toHaveBeenLastCalledWith('/store/main/getHotdealList.do', { date: '20261026' }); + for (const body of [{ date: '20261026' }, { date: '20261026', pageIdx: 1, prdSort: 'x' }]) { + expect((await relay(request('today-deals-list', JSON.stringify(body)))).status).toBe(400); + } + expect(runner).toHaveBeenCalledTimes(2); }); diff --git a/tests/scripts/mcp-smoke-expansion.test.ts b/tests/scripts/mcp-smoke-expansion.test.ts index b41d170f..b7d60c5c 100644 --- a/tests/scripts/mcp-smoke-expansion.test.ts +++ b/tests/scripts/mcp-smoke-expansion.test.ts @@ -11,7 +11,29 @@ const stores = [{ storeCode: 's1', storeName: '강남점' }]; const invoke = async (service: string, payload: unknown) => { const callTool = vi .fn() - .mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify(payload) }] }); + .mockImplementation(({ arguments: args }: { arguments: Record }) => + Promise.resolve({ + content: [ + { + type: 'text', + text: JSON.stringify( + service === 'oliveyoung' && args.operation === 'today_deals' + ? { + date: new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10), + brandDeals: [ + { goodsNo: 'A000000000001', name: '상품', price: 1000, soldOut: false }, + ], + todayDeals: [ + { goodsNo: 'A000000000002', name: '상품2', price: 1000, soldOut: true }, + ], + truncated: false, + } + : payload, + ), + }, + ], + }), + ); const errors: string[] = []; const code = await runMcpSmoke({ service, @@ -166,7 +188,9 @@ describe('전체 서비스 공개 MCP smoke 확장', () => { : { theaters: [{ theaterId: '1', theaterCode: '1', theaterName: '강남' }] }); const { code, callTool } = await invoke(service, payload); expect(code).toBe(0); - expect(callTool).toHaveBeenCalledOnce(); + expect(callTool).toHaveBeenCalledTimes( + MCP_SMOKE_SCENARIOS.filter((s) => s.service === service).length, + ); expect(callTool.mock.calls[0][0].name).toBe(group?.name); expect(PUBLIC_TOOL_NAMES).toContain(callTool.mock.calls[0][0].name); }, diff --git a/tests/scripts/mcp-smoke.test.ts b/tests/scripts/mcp-smoke.test.ts index 9311bd68..c287c66e 100644 --- a/tests/scripts/mcp-smoke.test.ts +++ b/tests/scripts/mcp-smoke.test.ts @@ -36,6 +36,7 @@ describe('runMcpSmoke', () => { 'lottecinema_find_nearby_theaters', 'cgv_find_theaters', 'dtryx_list_now_showing', + 'oliveyoung_get_today_deals', ]); }); @@ -60,6 +61,7 @@ describe('runMcpSmoke', () => { 'lottecinema', 'cgv', 'dtryx', + 'oliveyoung', ]); }); @@ -126,6 +128,16 @@ describe('runMcpSmoke', () => { seveneleven_search_products: { query: '커피', products: [{}] }, emart24_search_products: { keyword: '커피', products: [{ pluCd: 'p1' }] }, opinet_get_average_prices: { provider: 'opinet', prices: [] }, + oliveyoung_get_today_deals: { + date: new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10), + brandDeals: [ + { goodsNo: 'A000000000001', name: '브랜드 상품', price: 1000, soldOut: false }, + ], + todayDeals: [ + { goodsNo: 'A000000000002', name: '오늘 상품', price: 1000, soldOut: true }, + ], + truncated: false, + }, }; return Promise.resolve({ diff --git a/tests/scripts/oliveyoung-deals-smoke.test.ts b/tests/scripts/oliveyoung-deals-smoke.test.ts new file mode 100644 index 00000000..e9287062 --- /dev/null +++ b/tests/scripts/oliveyoung-deals-smoke.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; +import { validateOliveyoungTodayDeals } from '../../scripts/ops/mcp-smoke-validation.js'; +const payload = () => ({ + date: new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10), + brandDeals: [{ goodsNo: 'A000000000001', name: '상품', price: 1000, soldOut: false }], + todayDeals: [{ goodsNo: 'A000000000002', name: '상품2', price: 1000, soldOut: true }], + truncated: false, +}); +it('새 오특 기능도 한국 날짜·실제 가격·품절을 가진 두 목록을 필수 검사한다', () => { + expect(validateOliveyoungTodayDeals(payload())).toBeNull(); + for (const patch of [ + { date: '2000-01-01' }, + { brandDeals: [] }, + { todayDeals: [] }, + { truncated: undefined }, + { todayDeals: [{ goodsNo: 'A000000000002', name: '상품2', price: 0, soldOut: false }] }, + ]) + expect(validateOliveyoungTodayDeals({ ...payload(), ...patch })).not.toBeNull(); +}); diff --git a/tests/services/oliveyoung/htmlRoute.test.ts b/tests/services/oliveyoung/htmlRoute.test.ts new file mode 100644 index 00000000..c9ecec79 --- /dev/null +++ b/tests/services/oliveyoung/htmlRoute.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { OLIVEYOUNG_API } from '../../../src/services/oliveyoung/api.js'; +import { + buildOliveyoungHtmlPath, + isOliveyoungHtmlPath, + oliveyoungHtmlOperation, + toOliveyoungHtmlResponse, +} from '../../../src/services/oliveyoung/htmlRoute.js'; + +const PAGE = OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH; +const LIST = OLIVEYOUNG_API.TODAY_DEALS_LIST_PATH; + +describe('오특 HTML 조회 계약', () => { + it('고정 경로와 중계 작업 이름만 인식한다', () => { + expect(isOliveyoungHtmlPath(PAGE)).toBe(true); + expect(isOliveyoungHtmlPath(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH)).toBe(false); + expect(oliveyoungHtmlOperation(LIST)).toBe('today-deals-list'); + expect(oliveyoungHtmlOperation('/p')).toBeUndefined(); + }); + it('페이지는 날짜를 주소에 붙이지 않고 목록은 고정 정렬 조건을 붙인다', () => { + expect(buildOliveyoungHtmlPath(PAGE, { date: '20261026' })).toBe(PAGE); + expect(buildOliveyoungHtmlPath(LIST, { pageIdx: 2, date: '20261026' })).toBe( + `${LIST}?date=20261026&pageIdx=2&fltCondition=02&fltDispCatNo=&prdSort=rank`, + ); + }); + it.each([ + [PAGE, {}], + [PAGE, { date: '2026-10-26' }], + [PAGE, { date: '20261026', pageIdx: 1 }], + [LIST, { date: '20261026' }], + [LIST, { date: '20261026', pageIdx: '1' }], + [LIST, { date: '20261026', pageIdx: 1.5 }], + [LIST, { date: '20261026', pageIdx: 0 }], + [LIST, { date: '20261026', pageIdx: 21 }], + [LIST, { date: '20261026', pageIdx: 1, extra: 'x' }], + [OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, { date: '20261026', pageIdx: 1 }], + ])('계약 밖 요청 %s %j는 주소를 만들지 않는다', (path, body) => { + expect(() => buildOliveyoungHtmlPath(path, body)).toThrow('형식 오류'); + }); + it('정상 오특 HTML만 성공 응답으로 감싼다', () => { + const page = '
    '; + expect(toOliveyoungHtmlResponse(PAGE, page)).toEqual({ + status: 'SUCCESS', + data: { html: page }, + }); + expect(toOliveyoungHtmlResponse(LIST, '\n ')).toMatchObject({ status: 'SUCCESS' }); + expect(toOliveyoungHtmlResponse(LIST, '
  • ')).toMatchObject({ + status: 'SUCCESS', + }); + for (const [path, html] of [ + [PAGE, '
    다른 페이지
    '], + [ + PAGE, + `${page}`, + ], + [LIST, '
    x
    '], + [LIST, '잠시만 기다려 주세요'], + [LIST, null], + ] as const) { + expect(() => toOliveyoungHtmlResponse(path, html)).toThrow('응답 형식 오류'); + } + }); +}); diff --git a/tests/services/oliveyoung/index.test.ts b/tests/services/oliveyoung/index.test.ts index 94b57226..2dcc27ee 100644 --- a/tests/services/oliveyoung/index.test.ts +++ b/tests/services/oliveyoung/index.test.ts @@ -22,15 +22,16 @@ describe('createOliveyoungService', () => { expect(service.metadata.version).toBe('1.0.0'); }); - it('3개의 도구를 반환한다', () => { + it('4개의 도구를 반환한다', () => { const service = createOliveyoungService({ zyteApiKey: 'test-key' }); const tools = service.getTools(); - expect(tools).toHaveLength(3); + expect(tools).toHaveLength(4); expect(tools.map((t) => t.name)).toEqual([ 'oliveyoung_search_products', 'oliveyoung_find_nearby_stores', 'oliveyoung_check_inventory', + 'oliveyoung_get_today_deals', ]); }); }); diff --git a/tests/services/oliveyoung/todayDeals.test.ts b/tests/services/oliveyoung/todayDeals.test.ts new file mode 100644 index 00000000..2770ced6 --- /dev/null +++ b/tests/services/oliveyoung/todayDeals.test.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + fetchOliveyoungTodayDeals, + parseOliveyoungTodayDeals, + toKstDate, +} from '../../../src/services/oliveyoung/todayDeals.js'; + +const fixture = (name: string) => + readFileSync(new URL(`../../fixtures/oliveyoung/${name}`, import.meta.url), 'utf8'); +const page = fixture('today-deals-page.html'); +const list1 = fixture('today-deals-list-1.html'); +const list3 = fixture('today-deals-list-3.html'); +// 2026-10-27 화면에 일시품절로 표시된 실제 카드 +const soldOutCard = fixture('today-deals-list-soldout.html'); +// 2026-10-26 10:00 KST +const now = new Date('2026-10-26T01:00:00Z'); +const card = (goodsNo: string, rank: number, extra = '') => + `
  • 상품 ${rank}
    1,000원
    ${extra}
  • `; + +afterEach(() => vi.unstubAllGlobals()); + +describe('오특 HTML 파싱', () => { + it('브랜드 특가 카드를 가격·할인·혜택과 함께 읽는다', () => { + const deals = parseOliveyoungTodayDeals(page, '스페셜'); + expect(deals).toHaveLength(4); + expect(deals[0]).toEqual({ + rank: 1, + goodsNo: 'A000000171422', + name: '[10월올영픽] 어노브 실크 헤어 오일 에센스 70ml 더블 기획 (텐더블룸/웜페탈)', + brand: '어노브', + category: '헤어용품>헤어 트리트먼트>헤어에센스', + price: 27200, + priceFrom: true, + originalPrice: 35000, + discountRate: 22, + flags: ['세일', '쿠폰', '증정'], + todayDelivery: true, + soldOut: false, + imageUrl: expect.stringContaining('A00000017142291ko.jpg'), + productUrl: + 'https://www.oliveyoung.co.kr/store/goods/getGoodsDetail.do?goodsNo=A000000171422', + }); + expect(parseOliveyoungTodayDeals(page, '인기순')).toEqual([]); + }); + it('오늘의 특가 목록에서 단일가와 오늘드림 미제공 상품을 구분한다', () => { + const deals = parseOliveyoungTodayDeals(list3); + expect(deals.map((d) => d.rank)).toEqual([21, 22, 23, 24, 25, 26, 27]); + expect(deals[4]).toMatchObject({ + goodsNo: 'B000000265402', + price: 99000, + priceFrom: false, + originalPrice: 129000, + todayDelivery: false, + flags: ['쿠폰'], + }); + expect(parseOliveyoungTodayDeals(list1)[3].flags).toEqual(['1+1', '쿠폰', '증정']); + }); + it('일시품절 표시를 다른 값과 함께 읽는다', () => { + expect(parseOliveyoungTodayDeals(soldOutCard)).toEqual([ + expect.objectContaining({ + rank: 27, + goodsNo: 'A000000263126', + name: '[온라인 단독]올더베러 엑스트라버진 올리브오일 캡슐(30일분 x 4개입)', + price: 49900, + originalPrice: 79900, + discountRate: 37, + todayDelivery: true, + soldOut: true, + }), + ]); + const others = [ + ...parseOliveyoungTodayDeals(page, '스페셜'), + ...parseOliveyoungTodayDeals(list1), + ]; + expect(others.some((d) => d.soldOut)).toBe(false); + }); + it('정가와 할인율이 없는 카드와 HTML 문자 참조를 처리한다', () => { + const [deal] = parseOliveyoungTodayDeals( + card('A000000000001', 1).replace('상품 1', 'A&B 'C'  D © �'), + ); + expect(deal).toMatchObject({ + name: "A&B 'C' D © �", + brand: '', + category: '', + originalPrice: null, + discountRate: null, + flags: [], + imageUrl: '', + }); + const [plain] = parseOliveyoungTodayDeals( + card('A000000000001', 1).replace('
    ', '
    '), + ); + expect(plain).toMatchObject({ price: 1000, originalPrice: null, todayDelivery: false }); + }); + it.each([ + ['상품번호', card('A000000000001', 1).replaceAll('A000000000001"', 'bad"')], + ['상품명', card('A000000000001', 1).replace('상품 1', ' ')], + ['상품명 요소', card('A000000000001', 1).replace('class="prod-name"', 'class="x"')], + ['가격', card('A000000000001', 1).replace('1,000', '')], + ['순위', card('A000000000001', 1).replace('^1"', '^x"')], + ])('%s이 없는 카드는 형식 변경 오류로 거절한다', (_name, html) => { + expect(() => parseOliveyoungTodayDeals(html)).toThrow('형식이 변경'); + }); + it('한국 자정을 기준으로 날짜를 계산한다', () => { + expect(toKstDate(new Date('2026-10-25T14:59:59Z'))).toBe('20261025'); + expect(toKstDate(new Date('2026-10-25T15:00:00Z'))).toBe('20261026'); + }); +}); + +describe('오특 조회', () => { + it('페이지와 목록을 직접 GET으로 읽고 마지막 페이지에서 멈춘다', async () => { + const fetch = vi.fn( + async (url: string) => + new Response( + url.includes('getHotdealList.do') ? page : url.includes('pageIdx=1') ? list1 : list3, + ), + ); + vi.stubGlobal('fetch', fetch); + const result = await fetchOliveyoungTodayDeals({}, now); + expect(result).toMatchObject({ date: '2026-10-26', truncated: false }); + expect(result.brandDeals).toHaveLength(4); + expect(result.todayDeals).toHaveLength(17); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://www.oliveyoung.co.kr/store/main/getHotdealList.do', + 'https://www.oliveyoung.co.kr/store/main/getHotdealPagingListAjax.do?date=20261026&pageIdx=1&fltCondition=02&fltDispCatNo=&prdSort=rank', + 'https://www.oliveyoung.co.kr/store/main/getHotdealPagingListAjax.do?date=20261026&pageIdx=2&fltCondition=02&fltDispCatNo=&prdSort=rank', + ]); + const init = fetch.mock.calls[0][1] as unknown as RequestInit; + expect(init).toMatchObject({ method: 'GET', redirect: 'manual' }); + expect(init.headers).toMatchObject({ Accept: 'text/html' }); + expect(init.headers).not.toHaveProperty('Content-Type'); + }); + it('빈 목록 페이지에서 끝내고 중복 상품은 한 번만 반환한다', async () => { + const full = Array.from({ length: 10 }, (_, i) => card(`A0000000000${10 + i}`, i + 1)).join(''); + const pages = [page, full, full, '\n \n']; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(pages.shift())), + ); + const result = await fetchOliveyoungTodayDeals({}, now); + expect(result.todayDeals).toHaveLength(10); + expect(pages).toHaveLength(0); + }); + it('최대 페이지가 모두 가득 차면 잘림을 알린다', async () => { + let index = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url.includes('getHotdealList.do')) return new Response(page); + return new Response( + Array.from({ length: 10 }, () => + card(`A${String(++index).padStart(12, '0')}`, index), + ).join(''), + ); + }), + ); + const result = await fetchOliveyoungTodayDeals({}, now); + expect(result.truncated).toBe(true); + expect(result.todayDeals).toHaveLength(200); + }); + it('보안 확인 화면, 다른 페이지, HTTP201은 빈 목록이 아니라 실패로 반환한다', async () => { + for (const response of [ + new Response('잠시만 기다리십시오…', { status: 403 }), + new Response('잠시만 기다려 주세요'), + new Response('다른 페이지'), + new Response(page, { status: 201 }), + ]) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + await expect(fetchOliveyoungTodayDeals({}, now)).rejects.toThrow('직접 요청 실패'); + } + vi.stubGlobal( + 'fetch', + vi.fn( + async (url: string) => + new Response(url.includes('getHotdealList.do') ? page : '
    x
    '), + ), + ); + await expect(fetchOliveyoungTodayDeals({}, now)).rejects.toThrow('직접 요청 실패'); + }); + it('릴레이에는 고정 작업 이름과 날짜·페이지만 전달한다', async () => { + const fetch = vi.fn(async (url: string) => + Response.json({ + status: 'SUCCESS', + data: { html: url.endsWith('today-deals-page') ? page : list3 }, + }), + ); + vi.stubGlobal('fetch', fetch); + const result = await fetchOliveyoungTodayDeals( + { relayUrl: 'https://relay.example/', relayToken: 'test' }, + now, + ); + expect(result.todayDeals).toHaveLength(7); + expect(fetch.mock.calls.map(([url, init]) => [url, (init as RequestInit).body])).toEqual([ + ['https://relay.example/v1/oliveyoung/today-deals-page', '{"date":"20261026"}'], + ['https://relay.example/v1/oliveyoung/today-deals-list', '{"date":"20261026","pageIdx":1}'], + ]); + }); + it('릴레이 성공 응답에 HTML이 없으면 형식 오류로 거절한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ status: 'SUCCESS', data: {} })), + ); + await expect( + fetchOliveyoungTodayDeals({ relayUrl: 'https://relay.example', relayToken: 'test' }, now), + ).rejects.toThrow('형식이 변경'); + }); + it('여러 페이지를 합친 전체 제한 시간을 넘기면 다음 요청을 보내지 않는다', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(1000); + const fetch = vi.fn(async () => { + clock.mockReturnValue(7000); + return new Response(page); + }); + vi.stubGlobal('fetch', fetch); + try { + await expect(fetchOliveyoungTodayDeals({ timeout: 5000 }, now)).rejects.toThrow('시간 초과'); + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + clock.mockRestore(); + } + }); +}); + +describe('오특 원본 구조 변경의 부분 성공 방지', () => { + it.each([ + list1.replace(/data-impression="([^"]*)"/g, "data-impression='$1'"), + list1.replace('data-impression="', "data-impression='"), + list1.replace('
  • 원본 구조 변경
  • ', + ])('상품 카드 일부를 생략한 목록은 빈 목록이나 마지막 페이지로 처리하지 않는다', (html) => { + expect(() => parseOliveyoungTodayDeals(html)).toThrow('형식이 변경'); + }); + it('브랜드 섹션이 사라지면 부분 결과 대신 형식 오류를 반환한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async (url: string) => + new Response( + url.includes('getHotdealList.do') ? page.replaceAll('_스페셜', '_변경됨') : '', + { status: 200 }, + ), + ), + ); + await expect(fetchOliveyoungTodayDeals({}, now)).rejects.toThrow('형식이 변경'); + }); +}); + +it.each([ + page.replace(/data-impression="[^"]*"/, ''), + page.replace(/data-impression="[^"]*"/, '').replace(/data-ref-cornernm="[^"]*"/, ''), + page.replace('prod-list v2', 'changed-list'), +])('브랜드 카드 추적 속성이나 목록 구조가 바뀌어도 일부 성공으로 반환하지 않는다', (html) => { + expect(() => parseOliveyoungTodayDeals(html, '스페셜')).toThrow('형식이 변경'); +}); + +it.each([ + page.replaceAll('_스페셜^', '_변경됨^'), + page.replace(/data-impression="[^"]*"/, 'data-impression="A000000171422"'), +])('브랜드 카드의 섹션 추적 정보가 불일치해도 일부 결과를 반환하지 않는다', (html) => { + expect(() => parseOliveyoungTodayDeals(html, '스페셜')).toThrow('형식이 변경'); +}); diff --git a/tests/services/oliveyoung/tools/getTodayDeals.test.ts b/tests/services/oliveyoung/tools/getTodayDeals.test.ts new file mode 100644 index 00000000..becbc42f --- /dev/null +++ b/tests/services/oliveyoung/tools/getTodayDeals.test.ts @@ -0,0 +1,77 @@ +/** + * 올리브영 오특 조회 도구 테스트 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; + +const fetchOliveyoungTodayDeals = vi.hoisted(() => vi.fn()); +vi.mock('../../../../src/services/oliveyoung/todayDeals.js', () => ({ + fetchOliveyoungTodayDeals, +})); +const { createGetTodayDealsTool } = + await import('../../../../src/services/oliveyoung/tools/getTodayDeals.js'); + +const deal = { + rank: 1, + goodsNo: 'A000000171422', + name: '어노브 실크 헤어 오일', + brand: '어노브', + category: '헤어용품', + price: 27200, + priceFrom: true, + originalPrice: 35000, + discountRate: 22, + flags: ['세일'], + todayDelivery: true, + soldOut: false, + imageUrl: 'https://image.oliveyoung.co.kr/a.jpg', + productUrl: 'https://www.oliveyoung.co.kr/store/goods/getGoodsDetail.do?goodsNo=A000000171422', +}; + +beforeEach(() => { + fetchOliveyoungTodayDeals.mockReset(); + fetchOliveyoungTodayDeals.mockResolvedValue({ + date: '2026-10-26', + brandDeals: [deal], + todayDeals: [deal, { ...deal, rank: 2 }], + truncated: false, + }); +}); + +describe('createGetTodayDealsTool', () => { + it('브랜드 특가와 오늘의 특가를 개수와 함께 반환한다', async () => { + const tool = createGetTodayDealsTool(); + expect(tool.name).toBe('oliveyoung_get_today_deals'); + const args = z.object(tool.metadata.inputSchema).parse({}); + const result = await tool.handler(args); + const body = JSON.parse(result.content[0].text); + expect(body).toMatchObject({ + date: '2026-10-26', + brandDealCount: 1, + todayDealCount: 2, + truncated: false, + brandDeals: [deal], + }); + expect(body.note).toContain('check_inventory'); + expect(result.structuredContent).toEqual(body); + expect(z.object(tool.metadata.outputSchema!).parse(body)).toEqual(body); + expect(fetchOliveyoungTodayDeals).toHaveBeenCalledWith({ timeout: 15000 }); + }); + it('릴레이 설정과 제한 시간을 전달한다', async () => { + const transport = { relayUrl: 'https://relay.example', relayToken: 'test' }; + const tool = createGetTodayDealsTool(transport); + await tool.handler(z.object(tool.metadata.inputSchema).parse({})); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith({ ...transport, timeout: 60000 }); + await tool.handler({}); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith({ ...transport, timeout: 60000 }); + await tool.handler({ timeoutMs: 30000 }); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith({ ...transport, timeout: 30000 }); + await createGetTodayDealsTool({ timeout: 9000 }).handler({}); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith({ timeout: 9000 }); + }); + it('원본 실패를 빈 목록으로 바꾸지 않는다', async () => { + fetchOliveyoungTodayDeals.mockRejectedValue(new Error('올리브영 직접 요청 실패')); + await expect(createGetTodayDealsTool().handler({})).rejects.toThrow('직접 요청 실패'); + }); +});