From 081d06d3be51100b2f6f6ecd8894bb5d02420956 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 3 Nov 2026 22:05:30 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test:=20CRLF=20=EC=B2=B4=ED=81=AC=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=97=90=EC=84=9C=EB=8F=84=20README=20=EC=B2=AB=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EA=B2=80=EC=82=AC=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pages/discoveryEntry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/pages/discoveryEntry.test.ts b/tests/pages/discoveryEntry.test.ts index 940b2699..ec49fdd9 100644 --- a/tests/pages/discoveryEntry.test.ts +++ b/tests/pages/discoveryEntry.test.ts @@ -14,7 +14,7 @@ it('공식 첫 화면은 연결 설정과 복구 규칙보다 웹 실행 입구 it('접힌 AI 안내 밖의 첫 시작 선택지는 웹 조회이며 기존 이미지와 안내는 보존한다', () => { const readme = readFileSync('README.md', 'utf8'); - expect(readme).toMatch(/^
\nAI 에이전트 사용 안내<\/summary>/); + expect(readme).toMatch(/^
\r?\nAI 에이전트 사용 안내<\/summary>/); const visible = readme.slice(readme.indexOf('
') + '
'.length); const first = [...visible.matchAll(/\*\*\[([^\]]+)\]\(([^)]+)\)/g)][0]; expect(first?.[1]).toBe('ChatGPT에서 바로 조회'); From 4ecdcf94fafc51b1c76c7799053466bcddc4cd18 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 3 Nov 2026 22:05:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=98=AC=EB=A6=AC=EB=B8=8C?= =?UTF-8?q?=EC=98=81=20=EB=8B=A4=EC=9D=8C=20=EC=98=A4=ED=8A=B9=20=EC=98=88?= =?UTF-8?q?=EA=B3=A0=EC=99=80=20=EC=98=A4=ED=8A=B9=20=EC=9B=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mcp-tools.md | 2 +- docs/oliveyoung-today-deals-analysis.md | 6 +- docs/service-reference.md | 20 ++++- src/api/actionsProxy.ts | 5 ++ src/api/oliveyoungTodayDealsHandler.ts | 43 ++++++++++ src/api/routes/oliveyoungRoutes.ts | 16 ++++ src/core/compact/catalog.ts | 2 +- src/pages/apiResult.ts | 8 +- src/pages/browse.ts | 2 +- .../openapiSpecComponentsDaisoOliveyoung.ts | 25 ++++++ src/pages/openapiSpecPathsOliveyoung.ts | 43 ++++++++++ src/pages/promptCoreText.ts | 14 ++++ src/services/oliveyoung/todayDeals.ts | 55 ++++++++++++- .../oliveyoung/tools/getTodayDeals.ts | 38 ++++++--- .../oliveyoung-today-deals-handler.test.ts | 72 +++++++++++++++++ .../oliveyoung/today-deals-coming-soon.html | 26 ++++++ .../pages/browseOliveyoungTodayDeals.test.ts | 81 +++++++++++++++++++ tests/services/oliveyoung/todayDeals.test.ts | 80 ++++++++++++++++++ .../oliveyoung/tools/getTodayDeals.test.ts | 22 +++++ 19 files changed, 540 insertions(+), 20 deletions(-) create mode 100644 src/api/oliveyoungTodayDealsHandler.ts create mode 100644 tests/api/oliveyoung-today-deals-handler.test.ts create mode 100644 tests/fixtures/oliveyoung/today-deals-coming-soon.html create mode 100644 tests/pages/browseOliveyoungTodayDeals.test.ts diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index b2ebf160..f5afead5 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -39,7 +39,7 @@ `get_catalog`는 공개 콘텐츠 조회입니다. `operation`은 필수입니다. `service`를 생략하면 기존 호출과 같이 세븐일레븐이며 `popwords`(인기 검색어) 또는 `snapshot`(상품·이슈·기획전)을 지정합니다. `includeIssues`와 `includeExhibition`은 `snapshot` 전용입니다. 인기 검색어 원본 목록이 비어 있으면 `available=false`이며 외부 오류와 구분합니다. 원본 차단·중계 실패는 오류로 반환하며 빈 성공으로 바꾸지 않습니다. -`service=oliveyoung`, `operation=today_deals`는 올리브영 오특(오늘의 특가) 화면의 `brandDeals`(오늘의 브랜드 특가)와 `todayDeals`(오늘의 특가 전체 목록)를 한국 날짜 기준으로 반환합니다. 가격은 조회 시점 화면 기준이며 `priceFrom=true`는 옵션별 최저가입니다. 실시간 재고가 아니므로 재고는 상품명으로 `check_inventory`(service=oliveyoung)를 조회합니다. 원본 차단·형식 변경은 빈 목록이 아니라 오류입니다. +`service=oliveyoung`, `operation=today_deals`는 올리브영 오특(오늘의 특가) 화면의 `brandDeals`(오늘의 브랜드 특가)와 `todayDeals`(오늘의 특가 전체 목록), `upcomingDeals`(coming soon 다음 오특 예고, 가격 없음)를 한국 날짜 기준으로 반환합니다. 가격은 조회 시점 화면 기준이며 `priceFrom=true`는 옵션별 최저가입니다. 실시간 재고가 아니므로 재고는 상품명으로 `check_inventory`(service=oliveyoung)를 조회합니다. 원본 차단·형식 변경은 빈 목록이 아니라 오류입니다. 다이소 `check_inventory`에서 `storeLimit`은 `pageSize`와 같은 매장 수 조건입니다. 함께 전달하면 두 값이 같아야 합니다. `productLimit`은 상품 후보 수이며 여러 상품의 재고를 모두 조회하는 옵션이 아닙니다. 결과의 `selectedProduct`가 재고 조회 기준 상품입니다. diff --git a/docs/oliveyoung-today-deals-analysis.md b/docs/oliveyoung-today-deals-analysis.md index 15e6d419..76e20971 100644 --- a/docs/oliveyoung-today-deals-analysis.md +++ b/docs/oliveyoung-today-deals-analysis.md @@ -12,13 +12,15 @@ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------- | | 오늘의 브랜드 특가 | `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 안의 배너 | 상품명과 이미지만 있음 | +| coming soon | 페이지 HTML 안의 배너 (`.comingSoonBan .slideItem`) | 상품명과 이미지만 있음 | - JSON API는 확인하지 못했습니다. 두 응답 모두 HTML입니다. - `date`는 한국 날짜이며 페이지 스크립트도 같은 값을 보냅니다. 오특은 한국 자정에 바뀝니다. - 목록 조각은 10개씩 내려오며 마지막 다음 페이지는 공백만 있는 응답입니다. 2026-10-26에는 10, 10, 7개로 27개였습니다. - 브랜드 특가 카드는 페이지 HTML에 처음부터 포함됩니다. 2026-10-26에는 4개였습니다. -- coming soon은 상품번호와 가격이 없어 구현하지 않았습니다. 사용자 관측 기준으로 대략 한국 시간 9시부터 표시됩니다. +- coming soon 배너는 화면에서 숨겨져(`display:none`) 있어도 페이지 HTML에 상품이 들어 있습니다. 2026-10-26 21:17 배너 31개는 10-27 오특 31개(브랜드 4 + 오늘 27)와 상품번호까지 모두 일치했습니다. 2026-10-24 00:49 배너 29개는 당일 오특과 겹치지 않았습니다. 2026-11-03 20시대 실제 경로(로컬 브라우저 릴레이) 조회에서는 배너 31개가 화면 DOM의 상품명·이미지와 모두 일치했고, 당일 오특과 겹친 상품은 0개였습니다. 썸네일 파일명에서 뽑은 상품번호는 표본 3개 모두 상품 검색 결과의 상품번호와 같았습니다. 사용자 관측 기준으로 화면에는 대략 한국 시간 9시부터 표시되고, 22시쯤 "잠시 후 12시 새로운 오특 오픈!" 문구가 보입니다. +- 배너 항목에는 상품명과 썸네일만 있고 가격·할인율·상품번호 속성은 없습니다. 썸네일 파일명(`A00000020241475ko.jpg`)이 상품번호 13자리로 시작해 `goodsNo`로 읽습니다. 서버 HTML에서는 반복이 없었지만 슬라이드 배너라 같은 상품은 한 번만 반환합니다. +- 예고는 보조 정보라 배너가 없거나 항목 구조가 바뀌면 오특 조회를 실패시키지 않고 `upcomingDeals=null`(미확인)로 반환합니다. ## 상품 카드 구조 diff --git a/docs/service-reference.md b/docs/service-reference.md index 270a9b41..ca57151b 100644 --- a/docs/service-reference.md +++ b/docs/service-reference.md @@ -297,16 +297,17 @@ Zyte API 기반으로 올리브영 상품 재고를 조회하고 주변 매장 ### 올리브영 오특 (get_catalog) -공개 도구 `get_catalog {"service":"oliveyoung","operation":"today_deals"}`로 올리브영 오특(오늘의 특가) 화면의 두 목록을 한국 날짜 기준으로 조회합니다. 내부 작업 이름은 `oliveyoung_get_today_deals`입니다. +공개 도구 `get_catalog {"service":"oliveyoung","operation":"today_deals"}`로 올리브영 오특(오늘의 특가) 화면의 두 목록과 다음 오특 예고를 한국 날짜 기준으로 조회합니다. 내부 작업 이름은 `oliveyoung_get_today_deals`입니다. - `brandDeals`: 오늘의 브랜드 특가 - `todayDeals`: 오늘의 특가 전체 목록 (인기순) +- `upcomingDeals`: coming soon 배너의 다음 오특 예고 (가격 없음) | 파라미터 | 필수 | 설명 | | :---------- | :--: | :------------------------------------------------------------------------------- | | `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)에 있습니다. +각 상품은 `rank`, `goodsNo`, `name`, `brand`, `category`, `price`, `priceFrom`, `originalPrice`, `discountRate`, `flags`, `todayDelivery`, `soldOut`, `imageUrl`, `productUrl`을 반환합니다. `priceFrom=true`는 화면에 "원~"으로 표시되는 옵션별 최저가이고, `soldOut=true`는 화면에 "일시품절"로 표시된 상품입니다. 실시간 매장 재고가 아니므로 재고는 상품명으로 `check_inventory`(service=oliveyoung)를 조회합니다. 원본 차단·형식 변경은 빈 목록이 아니라 오류로 반환합니다. `upcomingDeals`의 각 상품은 `goodsNo`, `name`, `imageUrl`, `productUrl`만 반환합니다. 상품번호는 썸네일 파일명에서 읽으며 형식이 다르면 `goodsNo`와 `productUrl`이 `null`입니다. 배너를 읽지 못하면 오특 조회는 성공하고 `upcomingDeals=null`(미확인)로 반환하며, 예고 상품이 없을 때의 빈 목록과 구분합니다. 원본 분석은 [오특 네트워크 분석](./oliveyoung-today-deals-analysis.md)에 있습니다.
@@ -457,6 +458,7 @@ Cloudflare Edge Cache API(`caches.default`)를 사용해 올리브영 REST 응 - `GET /api/oliveyoung/stores`: 24시간 TTL - `GET /api/oliveyoung/inventory`: 10분 TTL +- `GET /api/oliveyoung/today-deals`: 5분 TTL, 오특이 한국 자정에 바뀌므로 캐시 키에 한국 날짜 포함 - 공통: `stale-while-revalidate` 적용, 오류 응답(4xx/5xx)은 캐시하지 않음
@@ -553,6 +555,7 @@ MCP를 지원하지 않는 서비스를 위한 GET 기반 REST API입니다. | `GET /api/emart24/inventory` | 이마트24 재고 확인 | | `GET /api/oliveyoung/stores` | 올리브영 매장 검색 | | `GET /api/oliveyoung/inventory` | 올리브영 재고 확인 | +| `GET /api/oliveyoung/today-deals` | 올리브영 오특(오늘의 특가) 조회 | | `GET /api/megabox/theaters` | 메가박스 주변 지점 조회 | | `GET /api/megabox/movies` | 메가박스 영화/회차 목록 조회 | | `GET /api/megabox/seats` | 메가박스 잔여 좌석 조회 | @@ -675,6 +678,18 @@ GET /api/oliveyoung/inventory?keyword={검색어}&lat={위도}&lng={경도} 응답의 `inventory.products[]`에는 `inStock`, `stockStatus`, `stockSource`, `storeInventory`가 포함될 수 있습니다. `stockSource=nearby_store`이면 `inStock`는 주변 매장 기준 결과입니다. +### 올리브영 오특 조회 + +``` +GET /api/oliveyoung/today-deals +``` + +| 파라미터 | 필수 | 설명 | +| :---------- | :--: | :------------------------------------------------------------------------------- | +| `timeoutMs` | | 페이지 여러 개를 합친 전체 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000) | + +`data`는 MCP `get_catalog {"service":"oliveyoung","operation":"today_deals"}`와 같은 본문입니다(`brandDeals`, `todayDeals`, `upcomingDeals`). 웹 조회 경로는 `/browse/oliveyoung/today-deals`이며 `/browse/quick` 첫 화면에도 링크가 있습니다. + ### 메가박스 주변 지점 조회 ``` @@ -843,6 +858,7 @@ GET /api/cgv/timetable?playDate={YYYYMMDD}&theaterCode={극장코드}&movieCode= | 강남역 다이소 매장 | `/browse/daiso/stores/강남역` | | 선택한 매장의 진열 위치 | `/browse/daiso/display-location/{실제상품ID}/{재고응답의storeCode}` | | 명동 올리브영 매장 | `/browse/oliveyoung/stores/명동` | +| 오늘의 올리브영 오특 | `/browse/oliveyoung/today-deals` | | 강남 CU 과자 재고 | `/browse/cu/inventory/과자/강남` | | 잠실 롯데시네마 극장 | `/browse/lottecinema/theaters/잠실` | | 선택한 CGV 극장 시간표 | `/browse/cgv/timetable/playDate/{YYYYMMDD}/theaterCode/{실제극장코드}` | diff --git a/src/api/actionsProxy.ts b/src/api/actionsProxy.ts index 0ff4da33..a5216fdb 100644 --- a/src/api/actionsProxy.ts +++ b/src/api/actionsProxy.ts @@ -53,6 +53,11 @@ export const ACTION_QUERY_DEFINITIONS: ActionQueryDefinition[] = [ summary: '올리브영 재고 조회', targetPath: '/api/oliveyoung/inventory', }, + { + action: 'oliveyoungGetTodayDeals', + summary: '올리브영 오특(오늘의 특가) 조회', + targetPath: '/api/oliveyoung/today-deals', + }, { action: 'cuFindStores', summary: 'CU 매장 검색', targetPath: '/api/cu/stores' }, { action: 'cuCheckInventory', summary: 'CU 재고 조회', targetPath: '/api/cu/inventory' }, { action: 'emart24FindStores', summary: '이마트24 매장 검색', targetPath: '/api/emart24/stores' }, diff --git a/src/api/oliveyoungTodayDealsHandler.ts b/src/api/oliveyoungTodayDealsHandler.ts new file mode 100644 index 00000000..83012f3c --- /dev/null +++ b/src/api/oliveyoungTodayDealsHandler.ts @@ -0,0 +1,43 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; +/** + * 올리브영 오특(오늘의 특가) GET API 핸들러 + * + * MCP get_catalog(service=oliveyoung, operation=today_deals)와 같은 본문을 반환합니다. + * /browse/oliveyoung/today-deals 웹 조회도 이 핸들러를 사용합니다. + */ + +import { ServiceError } from '../core/errors.js'; +import { fetchOliveyoungTodayDeals } from '../services/oliveyoung/todayDeals.js'; +import { buildTodayDealsBody } from '../services/oliveyoung/tools/getTodayDeals.js'; +import { + type ApiContext, + errorResponse, + serviceErrorResponse, + successResponse, +} from './response.js'; + +/** + * GET /api/oliveyoung/today-deals + */ +export async function handleOliveyoungGetTodayDeals(c: ApiContext) { + const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000; + const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10); + const timeoutMs = + Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs; + + try { + const result = await fetchOliveyoungTodayDeals({ + bypassCache: await isFreshOperationalRequest(c), + relayUrl: c.env?.OY_RELAY_URL, + relayToken: c.env?.OY_RELAY_TOKEN, + accessClientId: c.env?.OY_ACCESS_CLIENT_ID, + accessClientSecret: c.env?.OY_ACCESS_CLIENT_SECRET, + timeout: timeoutMs, + }); + return successResponse(c, buildTodayDealsBody(result)); + } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'today_deals'); + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'OLIVEYOUNG_TODAY_DEALS_FAILED', message, 500); + } +} diff --git a/src/api/routes/oliveyoungRoutes.ts b/src/api/routes/oliveyoungRoutes.ts index a966fbc0..e540aa97 100644 --- a/src/api/routes/oliveyoungRoutes.ts +++ b/src/api/routes/oliveyoungRoutes.ts @@ -12,6 +12,8 @@ import { handleOliveyoungSearchProducts, } from '../handlers.js'; import type { AppBindings } from '../response.js'; +import { handleOliveyoungGetTodayDeals } from '../oliveyoungTodayDealsHandler.js'; +import { toKstDate } from '../../services/oliveyoung/todayDeals.js'; export function registerOliveyoungRoutes(app: Hono<{ Bindings: AppBindings }>): void { app.get('/api/oliveyoung/products', async (c) => @@ -55,4 +57,18 @@ export function registerOliveyoungRoutes(app: Hono<{ Bindings: AppBindings }>): () => handleOliveyoungCheckInventory(c), ), ); + + // 오특은 한국 자정에 바뀌므로 날짜를 캐시 키에 넣어 자정 전후 응답이 섞이지 않게 합니다. + app.get('/api/oliveyoung/today-deals', async (c) => + withEdgeCache( + c.req.url, + { + bypassCache: await isFreshOperationalRequest(c), + ttlSeconds: 60 * 5, + staleWhileRevalidateSeconds: 60, + keyPrefix: `oliveyoung-today-deals-v1-${toKstDate(new Date())}`, + }, + () => handleOliveyoungGetTodayDeals(c), + ), + ); } diff --git a/src/core/compact/catalog.ts b/src/core/compact/catalog.ts index 648512e1..41e9fcaa 100644 --- a/src/core/compact/catalog.ts +++ b/src/core/compact/catalog.ts @@ -117,7 +117,7 @@ export const COMPACT_CATALOG: CompactGroup[] = [ name: 'get_catalog', title: '인기 검색어와 기획전', description: - '공개 카탈로그. operation은 필수입니다. service를 생략하면 세븐일레븐이며 operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. includeIssues/includeExhibition/limit은 snapshot 전용이며 popwords에는 label만 지정합니다. service=oliveyoung, operation=today_deals는 올리브영 오특(오늘의 브랜드 특가와 오늘의 특가)입니다. 실시간 재고를 의미하지 않습니다.', + '공개 카탈로그. operation은 필수입니다. service를 생략하면 세븐일레븐이며 operation=popwords 인기 검색어, snapshot 기획전/이슈 목록입니다. includeIssues/includeExhibition/limit은 snapshot 전용이며 popwords에는 label만 지정합니다. service=oliveyoung, operation=today_deals는 올리브영 오특(오늘의 브랜드 특가, 오늘의 특가, 다음 오특 예고)입니다. 실시간 재고를 의미하지 않습니다.', defaultService: 'seveneleven', routes: [ { service: 'seveneleven', operation: 'popwords', legacy: 'seveneleven_get_search_popwords' }, diff --git a/src/pages/apiResult.ts b/src/pages/apiResult.ts index 0c076d36..8c127d1a 100644 --- a/src/pages/apiResult.ts +++ b/src/pages/apiResult.ts @@ -56,7 +56,13 @@ function renderValue( const rendered = renderValue( item, productLinks, - key === 'products' || key === 'product' || (key === 'data' && productLinks !== null), + key === 'products' || + key === 'product' || + // 올리브영 오특 목록도 상품명·원본 이미지를 카드로 보여줍니다. + key === 'brandDeals' || + key === 'todayDeals' || + key === 'upcomingDeals' || + (key === 'data' && productLinks !== null), source, ); let detail = ''; diff --git a/src/pages/browse.ts b/src/pages/browse.ts index 2b5f952f..b3a8a738 100644 --- a/src/pages/browse.ts +++ b/src/pages/browse.ts @@ -42,7 +42,7 @@ export function registerBrowseRoutes(app: Hono<{ Bindings: AppBindings }>): void }); app.get('/browse', () => page( - `

설치 없이 웹으로 조회하기

${renderQuickBrowseLinks({ mode: 'products' })}

임의 검색어를 글자 링크로 입력하기

웹 읽기(open/WebFetch)는 요청과 일치하는 빠른 상품 검색 링크를 먼저 click하세요. 상품 결과 카드의 매장 지역 선택을 바로 열고 지역도 통째로 선택합니다. 불필요한 상품 상세·전체 경로 탐색은 생략하세요. 모든 결과는 HTML입니다. format이나 쿼리 문자열은 필요 없습니다.

수납박스 상품 3개 조회 · 핫식스 검색 · 강남역 매장 검색

웹 읽기는 글자 입력과 응답의 실제 링크 ID를 선택하세요. 아래는 일반 API 클라이언트용 경로 안내입니다. 주요 검색어 뒤에는 /page/2/pageSize/10처럼 조건 이름과 값을 붙입니다. 주요 검색어 없이 조건만 사용하려면 /browse/query/서비스/기능/조건/값을 사용하세요. 상품 검색은 재고 확인이 아닙니다. 실제 상품 ID와 사용자 지역으로 재고를 별도 조회하세요.

`, + `

설치 없이 웹으로 조회하기

${renderQuickBrowseLinks({ mode: 'products' })}

올리브영 오특(오늘의 특가)

오늘의 올리브영 오특 보기: 오늘의 브랜드 특가, 오늘의 특가 전체, 다음 오특 예고(가격 없음)를 한국 날짜 기준으로 보여줍니다. 실시간 매장 재고가 아닙니다.

임의 검색어를 글자 링크로 입력하기

웹 읽기(open/WebFetch)는 요청과 일치하는 빠른 상품 검색 링크를 먼저 click하세요. 상품 결과 카드의 매장 지역 선택을 바로 열고 지역도 통째로 선택합니다. 불필요한 상품 상세·전체 경로 탐색은 생략하세요. 모든 결과는 HTML입니다. format이나 쿼리 문자열은 필요 없습니다.

수납박스 상품 3개 조회 · 핫식스 검색 · 강남역 매장 검색

웹 읽기는 글자 입력과 응답의 실제 링크 ID를 선택하세요. 아래는 일반 API 클라이언트용 경로 안내입니다. 주요 검색어 뒤에는 /page/2/pageSize/10처럼 조건 이름과 값을 붙입니다. 주요 검색어 없이 조건만 사용하려면 /browse/query/서비스/기능/조건/값을 사용하세요. 상품 검색은 재고 확인이 아닙니다. 실제 상품 ID와 사용자 지역으로 재고를 별도 조회하세요.

`, ), ); app.get('/browse/', (c) => c.redirect('/browse', 307)); diff --git a/src/pages/openapiSpecComponentsDaisoOliveyoung.ts b/src/pages/openapiSpecComponentsDaisoOliveyoung.ts index 4c63d899..8e3d606b 100644 --- a/src/pages/openapiSpecComponentsDaisoOliveyoung.ts +++ b/src/pages/openapiSpecComponentsDaisoOliveyoung.ts @@ -342,4 +342,29 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = { }, }, }, + OliveyoungTodayDealsResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + date: { type: 'string', example: '2026-11-02', description: '한국 날짜' }, + brandDealCount: { type: 'integer' }, + todayDealCount: { type: 'integer' }, + upcomingDealCount: { type: 'integer', nullable: true }, + truncated: { type: 'boolean' }, + brandDeals: { type: 'array', items: { type: 'object' }, description: '오늘의 브랜드 특가' }, + todayDeals: { type: 'array', items: { type: 'object' }, description: '오늘의 특가 전체 (인기순)' }, + upcomingDeals: { + type: 'array', + nullable: true, + items: { type: 'object' }, + description: '다음 오특 예고 (가격 없음). null은 미확인, 빈 목록은 예고 없음', + }, + note: { type: 'string' }, + }, + }, + }, + }, }; diff --git a/src/pages/openapiSpecPathsOliveyoung.ts b/src/pages/openapiSpecPathsOliveyoung.ts index 0e6d356a..fa49f0e4 100644 --- a/src/pages/openapiSpecPathsOliveyoung.ts +++ b/src/pages/openapiSpecPathsOliveyoung.ts @@ -265,4 +265,47 @@ export const OPENAPI_PATHS_OLIVEYOUNG = { }, }, }, + }, + '/api/oliveyoung/today-deals': { + get: { + operationId: 'oliveyoungGetTodayDeals', + summary: '올리브영 오특(오늘의 특가) 조회', + description: + '올리브영 오특 화면의 오늘의 브랜드 특가, 오늘의 특가 전체 목록, 다음 오특 예고(가격 없음)를 한국 날짜 기준으로 조회합니다. 실시간 매장 재고가 아니므로 재고는 상품명으로 재고 조회를 사용하세요.', + parameters: [ + { + name: 'timeoutMs', + in: 'query', + required: false, + description: '페이지 여러 개를 합친 전체 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)', + schema: { type: 'integer', minimum: 1 }, + }, + ], + responses: { + '200': { + description: '조회 성공', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/OliveyoungTodayDealsResponse' }, + }, + }, + }, + '502': { + description: '올리브영 오특 원본 형식 변경', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, + '500': { + description: '올리브영 오특 조회 실패', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, + }, + }, },}; diff --git a/src/pages/promptCoreText.ts b/src/pages/promptCoreText.ts index fba5d27a..7b94b872 100644 --- a/src/pages/promptCoreText.ts +++ b/src/pages/promptCoreText.ts @@ -275,5 +275,19 @@ Base URL: ${baseUrl} --- +### 8. 올리브영 오특(오늘의 특가) + +**설명**: 올리브영 오특 화면의 오늘의 브랜드 특가, 오늘의 특가 전체 목록, coming soon 다음 오특 예고(가격 없음)를 한국 날짜 기준으로 반환합니다. 실시간 매장 재고가 아니므로 재고는 상품명으로 올리브영 재고 확인을 사용하세요. upcomingDeals=null은 예고 미확인, 빈 목록은 예고 없음입니다. + +**URL**: ${baseUrl}/browse/oliveyoung/today-deals + +**선택 파라미터**: +- timeoutMs: 전체 조회 제한 시간(ms) + +**예시**: +- ${baseUrl}/browse/oliveyoung/today-deals + +--- + `; } diff --git a/src/services/oliveyoung/todayDeals.ts b/src/services/oliveyoung/todayDeals.ts index 6551cd4d..cf3be736 100644 --- a/src/services/oliveyoung/todayDeals.ts +++ b/src/services/oliveyoung/todayDeals.ts @@ -31,12 +31,23 @@ export interface OliveyoungTodayDeal { productUrl: string; } +/** coming soon 배너의 다음 오특 예고 상품입니다. 가격과 할인율은 원본에 없습니다. */ +export interface OliveyoungUpcomingDeal { + /** 썸네일 파일명에서 읽은 상품번호입니다. 파일명 형식이 다르면 null입니다. */ + goodsNo: string | null; + name: string; + imageUrl: string; + productUrl: string | null; +} + export interface OliveyoungTodayDeals { date: string; brandDeals: OliveyoungTodayDeal[]; todayDeals: OliveyoungTodayDeal[]; /** 최대 페이지까지 모두 가득 차서 이후 상품이 남아 있을 수 있습니다. */ truncated: boolean; + /** 다음 오특 예고입니다. 배너 구조를 확인하지 못하면 null(미확인)입니다. */ + upcomingDeals: OliveyoungUpcomingDeal[] | null; } // 목록 조각은 한 번에 10개씩 내려옵니다. 이보다 적으면 마지막 페이지입니다. @@ -142,6 +153,44 @@ export function parseOliveyoungTodayDeals(html: string, section?: string): Olive return deals; } +/** + * 오특 페이지의 coming soon 배너에서 다음 오특 예고 상품을 읽습니다. + * + * 배너는 화면에서 숨겨져 있어도 페이지 HTML에 들어 있습니다. 2026-10-26 21:17 배너 31개가 + * 10-27 오특 31개와 상품번호까지 일치했습니다. 썸네일 파일명은 상품번호 13자리로 시작합니다. + * + * @param html - 오특 페이지 + * @returns 예고 상품 목록. 배너가 없거나 항목 구조가 바뀌면 일부 대신 null + */ +export function parseOliveyoungUpcomingDeals(html: string): OliveyoungUpcomingDeal[] | null { + const start = html.indexOf('
').slice(1); + if (items.length !== (banner.match(/class="slideItem\b/g) ?? []).length) return null; + const deals: OliveyoungUpcomingDeal[] = []; + const seen = new Set(); + for (const item of items) { + const name = text(item.match(/([\s\S]*?)<\/span>/)?.[1]); + const imageUrl = decode(item.match(/background-image:\s*url\(['"]?([^'")]+)/)?.[1] ?? ''); + if (!name || !imageUrl) return null; + const goodsNo = imageUrl.match(/\/([A-Z]\d{12})\d*[a-z]*\.(?:jpe?g|png|gif|webp)\b/i)?.[1]; + const key = goodsNo ?? name; + // 슬라이드 배너라 같은 상품이 반복될 수 있습니다. + if (seen.has(key)) continue; + seen.add(key); + deals.push({ + goodsNo: goodsNo ?? null, + name, + imageUrl, + productUrl: goodsNo + ? `${OLIVEYOUNG_API.BASE_URL}/store/goods/getGoodsDetail.do?goodsNo=${goodsNo}` + : null, + }); + } + return deals; +} + function htmlOf(response: { data?: { html?: string } }): string { const html = response.data?.html; if (typeof html !== 'string') invalid(); @@ -174,8 +223,8 @@ export async function fetchOliveyoungTodayDeals( } return requestOliveyoung(path, body, { ...options, timeout }); }; - const page = await request(OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH, { date }); - const brandDeals = parseOliveyoungTodayDeals(htmlOf(page), '스페셜'); + const page = htmlOf(await request(OLIVEYOUNG_API.TODAY_DEALS_PAGE_PATH, { date })); + const brandDeals = parseOliveyoungTodayDeals(page, '스페셜'); if (!brandDeals.length) invalid(); const todayDeals: OliveyoungTodayDeal[] = []; const seen = new Set(); @@ -196,5 +245,7 @@ export async function fetchOliveyoungTodayDeals( brandDeals, todayDeals, truncated, + // 예고는 보조 정보라 배너를 읽지 못해도 오특 조회는 실패시키지 않습니다. + upcomingDeals: parseOliveyoungUpcomingDeals(page), }; } diff --git a/src/services/oliveyoung/tools/getTodayDeals.ts b/src/services/oliveyoung/tools/getTodayDeals.ts index 144304e0..726ccdf6 100644 --- a/src/services/oliveyoung/tools/getTodayDeals.ts +++ b/src/services/oliveyoung/tools/getTodayDeals.ts @@ -3,32 +3,41 @@ import type { OliveyoungRequestOptions } from '../transport.js'; * 올리브영 오특(오늘의 특가) 조회 도구 * * 오늘의 브랜드 특가와 오늘의 특가 전체 목록을 한국 날짜 기준으로 반환합니다. + * coming soon 배너의 다음 오특 예고도 같은 페이지 요청에서 함께 반환합니다. * 공개 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'; +import { fetchOliveyoungTodayDeals, type OliveyoungTodayDeals } 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 = { +/** MCP와 REST(웹 조회)가 같은 오특 응답을 쓰도록 본문을 만듭니다. */ +export function buildTodayDealsBody(result: OliveyoungTodayDeals) { + return { date: result.date, brandDealCount: result.brandDeals.length, todayDealCount: result.todayDeals.length, + upcomingDealCount: result.upcomingDeals?.length ?? null, truncated: result.truncated, brandDeals: result.brandDeals, todayDeals: result.todayDeals, - note: '가격과 할인율은 조회 시점의 오특 화면 기준입니다. priceFrom=true는 옵션별 최저가, soldOut=true는 일시품절입니다. 매장 재고는 check_inventory(service=oliveyoung)에 상품명을 keyword로 조회하세요.', + upcomingDeals: result.upcomingDeals, + note: '가격과 할인율은 조회 시점의 오특 화면 기준입니다. priceFrom=true는 옵션별 최저가, soldOut=true는 일시품절입니다. upcomingDeals는 coming soon 배너의 다음 오특 예고로 가격이 없으며, null이면 예고를 확인하지 못한 것이고 빈 목록이면 예고 상품이 없는 것입니다. 매장 재고는 check_inventory(service=oliveyoung)에 상품명을 keyword로 조회하세요.', }; +} + +async function getTodayDeals( + args: GetTodayDealsArgs, + transport: OliveyoungRequestOptions, +): Promise { + const { timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000) } = args; + const body = buildTodayDealsBody( + await fetchOliveyoungTodayDeals({ ...transport, timeout: timeoutMs }), + ); return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body, @@ -52,6 +61,13 @@ const dealSchema = z.object({ productUrl: z.string(), }); +const upcomingSchema = z.object({ + goodsNo: z.string().nullable(), + name: z.string(), + imageUrl: z.string(), + productUrl: z.string().nullable(), +}); + export function createGetTodayDealsTool( transport: OliveyoungRequestOptions = {}, ): ToolRegistration { @@ -61,7 +77,7 @@ export function createGetTodayDealsTool( metadata: { title: '올리브영 오특 조회', description: - '올리브영 오특(오늘의 특가)의 오늘의 브랜드 특가와 오늘의 특가 상품 목록을 한국 날짜 기준으로 조회합니다. 실시간 매장 재고를 의미하지 않습니다.', + '올리브영 오특(오늘의 특가)의 오늘의 브랜드 특가와 오늘의 특가 상품 목록을 한국 날짜 기준으로 조회하고, coming soon 배너의 다음 오특 예고(가격 없음)를 함께 반환합니다. 실시간 매장 재고를 의미하지 않습니다.', inputSchema: { timeoutMs: z .number() @@ -75,9 +91,11 @@ export function createGetTodayDealsTool( date: z.string(), brandDealCount: z.number(), todayDealCount: z.number(), + upcomingDealCount: z.number().nullable(), truncated: z.boolean(), brandDeals: z.array(dealSchema), todayDeals: z.array(dealSchema), + upcomingDeals: z.array(upcomingSchema).nullable(), note: z.string(), }, }, diff --git a/tests/api/oliveyoung-today-deals-handler.test.ts b/tests/api/oliveyoung-today-deals-handler.test.ts new file mode 100644 index 00000000..8fb0c136 --- /dev/null +++ b/tests/api/oliveyoung-today-deals-handler.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { ServiceError } from '../../src/core/errors.js'; + +const fetchOliveyoungTodayDeals = vi.hoisted(() => vi.fn()); +vi.mock('../../src/services/oliveyoung/todayDeals.js', () => ({ fetchOliveyoungTodayDeals })); +const { handleOliveyoungGetTodayDeals } = + await import('../../src/api/oliveyoungTodayDealsHandler.js'); + +const context = (query: Record = {}, env: Record = {}) => { + const json = vi.fn((body: unknown, status = 200) => ({ body, status })); + return { + c: { + env, + req: { + query: (name: string) => query[name], + header: () => undefined, + raw: new Request('http://x'), + }, + json, + header: vi.fn(), + } as never, + json, + }; +}; + +beforeEach(() => { + fetchOliveyoungTodayDeals.mockReset(); + fetchOliveyoungTodayDeals.mockResolvedValue({ + date: '2026-11-02', + brandDeals: [], + todayDeals: [], + truncated: false, + upcomingDeals: null, + }); +}); + +it('중계 설정과 제한 시간을 전달하고 MCP와 같은 본문을 반환한다', async () => { + const relay = { OY_RELAY_URL: 'https://relay.example', OY_RELAY_TOKEN: 't' }; + const { c, json } = context({}, relay); + await handleOliveyoungGetTodayDeals(c); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith( + expect.objectContaining({ relayUrl: relay.OY_RELAY_URL, relayToken: 't', timeout: 60000 }), + ); + expect(json.mock.calls[0][0]).toMatchObject({ + success: true, + data: { date: '2026-11-02', upcomingDealCount: null, upcomingDeals: null }, + }); + await handleOliveyoungGetTodayDeals(context({ timeoutMs: '9000' }).c); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith( + expect.objectContaining({ timeout: 9000 }), + ); + await handleOliveyoungGetTodayDeals(context({ timeoutMs: 'bad' }).c); + expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith( + expect.objectContaining({ timeout: 15000 }), + ); +}); + +it.each([ + [ + new ServiceError('OLIVEYOUNG_TODAY_DEALS_PARSE_FAILED', '형식 변경', 502, false), + 502, + 'OLIVEYOUNG_TODAY_DEALS_PARSE_FAILED', + ], + [new Error('올리브영 직접 요청 실패'), 500, 'OLIVEYOUNG_TODAY_DEALS_FAILED'], + ['unknown', 500, 'OLIVEYOUNG_TODAY_DEALS_FAILED'], +])('실패를 빈 목록 대신 오류로 반환한다: %s', async (failure, status, code) => { + fetchOliveyoungTodayDeals.mockRejectedValue(failure); + const { c, json } = context(); + await handleOliveyoungGetTodayDeals(c); + expect(json.mock.calls[0][1]).toBe(status); + expect(json.mock.calls[0][0]).toMatchObject({ success: false, error: { code } }); +}); diff --git a/tests/fixtures/oliveyoung/today-deals-coming-soon.html b/tests/fixtures/oliveyoung/today-deals-coming-soon.html new file mode 100644 index 00000000..c73a5e47 --- /dev/null +++ b/tests/fixtures/oliveyoung/today-deals-coming-soon.html @@ -0,0 +1,26 @@ + diff --git a/tests/pages/browseOliveyoungTodayDeals.test.ts b/tests/pages/browseOliveyoungTodayDeals.test.ts new file mode 100644 index 00000000..61ec21e4 --- /dev/null +++ b/tests/pages/browseOliveyoungTodayDeals.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; + +const fixture = (name: string) => + readFileSync(new URL(`../fixtures/oliveyoung/${name}`, import.meta.url), 'utf8'); +const page = fixture('today-deals-page.html') + fixture('today-deals-coming-soon.html'); +const list1 = fixture('today-deals-list-1.html'); +const list3 = fixture('today-deals-list-3.html'); +const origin = (url: string) => + new Response( + url.includes('getHotdealList.do') ? page : url.includes('pageIdx=1') ? list1 : list3, + ); + +afterEach(() => vi.unstubAllGlobals()); + +it('웹 조회 첫 화면에서 오특 결과로 가는 실제 링크를 제공한다', async () => { + const html = await (await app.request('/browse/quick')).text(); + expect(html).toMatch(/href="\/browse\/read\/[0-9a-f-]{36}\/oliveyoung\/today-deals"/); + expect(html).toContain('실시간 매장 재고가 아닙니다'); +}); + +it('오특 웹 조회는 MCP와 같은 본문을 상품 카드와 원본 이미지로 보여준다', async () => { + const fetcher = vi.fn(async (url: string) => origin(url)); + vi.stubGlobal('fetch', fetcher); + const res = await app.request('/browse/oliveyoung/today-deals'); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('조회 성공'); + expect(html).toContain( + '

[10월올영픽] 어노브 실크 헤어 오일 에센스 70ml 더블 기획 (텐더블룸/웜페탈)

', + ); + expect(html).toContain('

VT 피디알엔 리들샷 마이크로 포어 패치 4매

'); + expect(html).toContain('A00000026357916ko.jpg'); + expect(html).toContain('upcomingDeals'); + // 페이지 1번과 목록 2페이지만 원본에 요청합니다. + expect(fetcher).toHaveBeenCalledTimes(3); + + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => origin(url)), + ); + const raw = await app.request('/api/oliveyoung/today-deals'); + expect(raw.headers.get('Content-Type')).toContain('application/json'); + const { data } = await raw.json(); + expect(data).toMatchObject({ + brandDealCount: 4, + todayDealCount: 17, + upcomingDealCount: 3, + truncated: false, + }); + expect(data.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(data.upcomingDeals[0]).toEqual({ + goodsNo: 'A000000202414', + name: '[10월 올영픽/몬치치] 라보에이치 헤어라인앰플 더블기획(+샴푸50ML)', + imageUrl: expect.stringContaining('A00000020241475ko.jpg'), + productUrl: expect.stringContaining('goodsNo=A000000202414'), + }); +}); + +it.each([ + // 브랜드 특가 섹션 이름이 바뀐 실제 오특 페이지 + [page.replaceAll('_스페셜', '_변경됨'), 502, 'OLIVEYOUNG_TODAY_DEALS_PARSE_FAILED'], + // 오특이 아닌 페이지는 직접 요청 단계에서 거절합니다. + ['
원본 구조 변경
', 500, '올리브영 직접 요청 실패'], +])( + '원본 형식이 바뀌면 웹 조회도 빈 목록이 아니라 조회 실패로 보여준다', + async (html, status, text) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(html)), + ); + const res = await app.request('/browse/oliveyoung/today-deals'); + expect(res.status).toBe(status); + const body = await res.text(); + expect(body).toContain('조회 실패'); + expect(body).toContain(text); + expect(body).not.toContain('빈 목록'); + }, +); diff --git a/tests/services/oliveyoung/todayDeals.test.ts b/tests/services/oliveyoung/todayDeals.test.ts index 2770ced6..f3ecf9b5 100644 --- a/tests/services/oliveyoung/todayDeals.test.ts +++ b/tests/services/oliveyoung/todayDeals.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchOliveyoungTodayDeals, parseOliveyoungTodayDeals, + parseOliveyoungUpcomingDeals, toKstDate, } from '../../../src/services/oliveyoung/todayDeals.js'; @@ -13,6 +14,8 @@ 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 21:17 페이지의 coming soon 배너 일부. 배너 31개가 10-27 오특 31개와 상품번호까지 일치 +const comingSoon = fixture('today-deals-coming-soon.html'); // 2026-10-26 10:00 KST const now = new Date('2026-10-26T01:00:00Z'); const card = (goodsNo: string, rank: number, extra = '') => @@ -260,3 +263,80 @@ it.each([ ])('브랜드 카드의 섹션 추적 정보가 불일치해도 일부 결과를 반환하지 않는다', (html) => { expect(() => parseOliveyoungTodayDeals(html, '스페셜')).toThrow('형식이 변경'); }); + +describe('오특 coming soon 예고', () => { + const thumb = + 'https://image.oliveyoung.co.kr/cfimages/cf-goods/uploads/images/thumbnails/10/0000'; + const detail = 'https://www.oliveyoung.co.kr/store/goods/getGoodsDetail.do?goodsNo='; + const empty = comingSoon.replace(/
\s*<\/div>/, ''); + + it('배너의 상품명·이미지와 썸네일 파일명의 상품번호를 읽는다', () => { + expect(parseOliveyoungUpcomingDeals(comingSoon)).toEqual([ + { + goodsNo: 'A000000202414', + name: '[10월 올영픽/몬치치] 라보에이치 헤어라인앰플 더블기획(+샴푸50ML)', + imageUrl: `${thumb}/0020/A00000020241475ko.jpg?l=ko`, + productUrl: `${detail}A000000202414`, + }, + { + goodsNo: 'A000000263579', + name: 'VT 피디알엔 리들샷 마이크로 포어 패치 4매', + imageUrl: `${thumb}/0026/A00000026357916ko.jpg?l=ko`, + productUrl: `${detail}A000000263579`, + }, + { + goodsNo: 'A000000264125', + name: '제로이드 인텐시브 크림 미스트 100ml', + imageUrl: `${thumb}/0026/A00000026412510ko.jpg?l=ko`, + productUrl: `${detail}A000000264125`, + }, + ]); + }); + it('슬라이드에서 반복된 상품은 한 번만 반환한다', () => { + const item = comingSoon.match(/
\s*<\/div>/)![0]; + const repeated = comingSoon.replace(item, `${item}${item}`).replace('', `${item}`); + expect(parseOliveyoungUpcomingDeals(repeated)).toHaveLength(3); + }); + it('썸네일 파일명에 상품번호가 없으면 상품번호와 상품 링크를 비운다', () => { + const [first] = parseOliveyoungUpcomingDeals( + comingSoon.replace('A00000020241475ko.jpg', 'banner.jpg'), + )!; + expect(first).toMatchObject({ goodsNo: null, productUrl: null, name: expect.any(String) }); + }); + it('배너는 있지만 예고 상품이 없으면 빈 목록이다', () => { + expect(parseOliveyoungUpcomingDeals(empty)).toEqual([]); + }); + it.each([ + ['배너 없음', page], + ['상품명 없음', comingSoon.replace(/VT[^<]*<\/span>/, '')], + [ + '이미지 없음', + comingSoon.replace(/style="background-image:url\([^)]*A00000026357916[^)]*\)"/, ''), + ], + [ + '항목 구조 변경', + comingSoon.replace('
', '
'), + ], + ])('%s이면 일부나 빈 목록 대신 미확인(null)으로 반환한다', (_, html) => { + expect(parseOliveyoungUpcomingDeals(html)).toBeNull(); + }); + it('페이지 요청 하나로 예고를 함께 반환하고 예고를 읽지 못해도 오특은 성공한다', async () => { + for (const [html, expected] of [ + [page + comingSoon, 3], + [page, null], + ] as const) { + const fetch = vi.fn( + async (url: string) => + new Response( + url.includes('getHotdealList.do') ? html : url.includes('pageIdx=1') ? list1 : list3, + ), + ); + vi.stubGlobal('fetch', fetch); + const result = await fetchOliveyoungTodayDeals({}, now); + expect(fetch).toHaveBeenCalledTimes(3); + expect(result.todayDeals).toHaveLength(17); + if (expected === null) expect(result.upcomingDeals).toBeNull(); + else expect(result.upcomingDeals).toHaveLength(expected); + } + }); +}); diff --git a/tests/services/oliveyoung/tools/getTodayDeals.test.ts b/tests/services/oliveyoung/tools/getTodayDeals.test.ts index becbc42f..9a0f47fc 100644 --- a/tests/services/oliveyoung/tools/getTodayDeals.test.ts +++ b/tests/services/oliveyoung/tools/getTodayDeals.test.ts @@ -28,6 +28,12 @@ const deal = { imageUrl: 'https://image.oliveyoung.co.kr/a.jpg', productUrl: 'https://www.oliveyoung.co.kr/store/goods/getGoodsDetail.do?goodsNo=A000000171422', }; +const upcoming = { + goodsNo: 'A000000202414', + name: '라보에이치 헤어라인앰플 더블기획', + imageUrl: 'https://image.oliveyoung.co.kr/A00000020241475ko.jpg', + productUrl: 'https://www.oliveyoung.co.kr/store/goods/getGoodsDetail.do?goodsNo=A000000202414', +}; beforeEach(() => { fetchOliveyoungTodayDeals.mockReset(); @@ -36,6 +42,7 @@ beforeEach(() => { brandDeals: [deal], todayDeals: [deal, { ...deal, rank: 2 }], truncated: false, + upcomingDeals: [upcoming, { ...upcoming, goodsNo: null, productUrl: null }], }); }); @@ -50,8 +57,10 @@ describe('createGetTodayDealsTool', () => { date: '2026-10-26', brandDealCount: 1, todayDealCount: 2, + upcomingDealCount: 2, truncated: false, brandDeals: [deal], + upcomingDeals: [upcoming, { ...upcoming, goodsNo: null, productUrl: null }], }); expect(body.note).toContain('check_inventory'); expect(result.structuredContent).toEqual(body); @@ -70,6 +79,19 @@ describe('createGetTodayDealsTool', () => { await createGetTodayDealsTool({ timeout: 9000 }).handler({}); expect(fetchOliveyoungTodayDeals).toHaveBeenLastCalledWith({ timeout: 9000 }); }); + it('예고를 확인하지 못하면 빈 목록이 아니라 미확인(null)으로 반환한다', async () => { + fetchOliveyoungTodayDeals.mockResolvedValue({ + date: '2026-10-26', + brandDeals: [deal], + todayDeals: [deal], + truncated: false, + upcomingDeals: null, + }); + const tool = createGetTodayDealsTool(); + const { structuredContent: body } = await tool.handler({}); + expect(body).toMatchObject({ upcomingDealCount: null, upcomingDeals: null }); + expect(z.object(tool.metadata.outputSchema!).parse(body)).toEqual(body); + }); it('원본 실패를 빈 목록으로 바꾸지 않는다', async () => { fetchOliveyoungTodayDeals.mockRejectedValue(new Error('올리브영 직접 요청 실패')); await expect(createGetTodayDealsTool().handler({})).rejects.toThrow('직접 요청 실패'); From 7458c7f8c98310601e107c63bd2b4266036d95bd Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 3 Nov 2026 22:49:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=98=AC=EB=A6=AC=EB=B8=8C?= =?UTF-8?q?=EC=98=81=20coming=20soon=20=EC=98=88=EA=B3=A0=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=A4=EC=9D=BC=20=EB=8B=A4=EC=9D=8C=20=EB=82=A0=20=EC=98=A4?= =?UTF-8?q?=ED=8A=B9=EA=B3=BC=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/oliveyoung-coming-soon.yml | 78 +++++++++++++ docs/oliveyoung-today-deals-analysis.md | 3 +- scripts/ops/README.md | 1 + scripts/ops/oliveyoung-coming-soon-check.ts | 115 ++++++++++++++++++ scripts/ops/oliveyoung-coming-soon.ts | 35 ++++++ tests/scripts/oliveyoung-coming-soon.test.ts | 116 +++++++++++++++++++ vitest.config.ts | 1 + 7 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/oliveyoung-coming-soon.yml create mode 100644 scripts/ops/oliveyoung-coming-soon-check.ts create mode 100644 scripts/ops/oliveyoung-coming-soon.ts create mode 100644 tests/scripts/oliveyoung-coming-soon.test.ts diff --git a/.github/workflows/oliveyoung-coming-soon.yml b/.github/workflows/oliveyoung-coming-soon.yml new file mode 100644 index 00000000..66fa5327 --- /dev/null +++ b/.github/workflows/oliveyoung-coming-soon.yml @@ -0,0 +1,78 @@ +name: Oliveyoung Coming Soon + +on: + schedule: + # 한국시간 21:10 = UTC 12:10, coming soon 공개 이후 다음 오특 예고 저장 + - cron: '10 12 * * *' + # 한국시간 00:20 = UTC 15:20 (전날), 전날 예고와 실제 오특 대조 + - cron: '20 15 * * *' + workflow_dispatch: + inputs: + mode: + description: 'snapshot(21시 이후 예고 저장) 또는 compare(자정 이후 대조)' + required: true + default: snapshot + type: choice + options: + - snapshot + - compare + +permissions: + contents: read + +concurrency: + group: oliveyoung-coming-soon-${{ github.ref }} + cancel-in-progress: false + +jobs: + coming-soon: + runs-on: ubuntu-latest + timeout-minutes: 10 + name: Coming soon preview check + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + - run: npm ci + + - name: Select mode + id: mode + env: + INPUT_MODE: ${{ inputs.mode }} + SCHEDULE: ${{ github.event.schedule }} + run: | + if [ "${SCHEDULE:-}" = "20 15 * * *" ]; then MODE=compare + elif [ -n "${INPUT_MODE:-}" ]; then MODE="${INPUT_MODE}" + else MODE=snapshot; fi + echo "mode=${MODE}" >> "$GITHUB_OUTPUT" + echo "today=$(TZ=Asia/Seoul date +%F)" >> "$GITHUB_OUTPUT" + echo "tomorrow=$(TZ=Asia/Seoul date -d tomorrow +%F)" >> "$GITHUB_OUTPUT" + + - name: Save upcoming deals + if: steps.mode.outputs.mode == 'snapshot' + run: npx tsx scripts/ops/oliveyoung-coming-soon.ts snapshot coming-soon.json + + - uses: actions/cache/save@v4 + if: steps.mode.outputs.mode == 'snapshot' + with: + path: coming-soon.json + key: oliveyoung-coming-soon-${{ steps.mode.outputs.tomorrow }}-${{ github.run_id }} + + - uses: actions/cache/restore@v4 + if: steps.mode.outputs.mode == 'compare' + id: snapshot + with: + path: coming-soon.json + key: oliveyoung-coming-soon-${{ steps.mode.outputs.today }}- + restore-keys: oliveyoung-coming-soon-${{ steps.mode.outputs.today }}- + + - name: Compare with today deals + if: steps.mode.outputs.mode == 'compare' + run: | + if [ ! -f coming-soon.json ]; then + echo "::warning::${{ steps.mode.outputs.today }} 대상 coming soon 예고 저장본이 없어 대조를 건너뜁니다" + exit 0 + fi + npx tsx scripts/ops/oliveyoung-coming-soon.ts compare coming-soon.json diff --git a/docs/oliveyoung-today-deals-analysis.md b/docs/oliveyoung-today-deals-analysis.md index 76e20971..e2238e75 100644 --- a/docs/oliveyoung-today-deals-analysis.md +++ b/docs/oliveyoung-today-deals-analysis.md @@ -18,7 +18,8 @@ - `date`는 한국 날짜이며 페이지 스크립트도 같은 값을 보냅니다. 오특은 한국 자정에 바뀝니다. - 목록 조각은 10개씩 내려오며 마지막 다음 페이지는 공백만 있는 응답입니다. 2026-10-26에는 10, 10, 7개로 27개였습니다. - 브랜드 특가 카드는 페이지 HTML에 처음부터 포함됩니다. 2026-10-26에는 4개였습니다. -- coming soon 배너는 화면에서 숨겨져(`display:none`) 있어도 페이지 HTML에 상품이 들어 있습니다. 2026-10-26 21:17 배너 31개는 10-27 오특 31개(브랜드 4 + 오늘 27)와 상품번호까지 모두 일치했습니다. 2026-10-24 00:49 배너 29개는 당일 오특과 겹치지 않았습니다. 2026-11-03 20시대 실제 경로(로컬 브라우저 릴레이) 조회에서는 배너 31개가 화면 DOM의 상품명·이미지와 모두 일치했고, 당일 오특과 겹친 상품은 0개였습니다. 썸네일 파일명에서 뽑은 상품번호는 표본 3개 모두 상품 검색 결과의 상품번호와 같았습니다. 사용자 관측 기준으로 화면에는 대략 한국 시간 9시부터 표시되고, 22시쯤 "잠시 후 12시 새로운 오특 오픈!" 문구가 보입니다. +- coming soon 배너는 화면에서 숨겨져(`display:none`) 있어도 페이지 HTML에 상품이 들어 있습니다. 2026-10-26 21:17 배너 31개는 10-27 오특 31개(브랜드 4 + 오늘 27)와 상품번호까지 모두 일치했습니다. 2026-10-24 00:49 배너 29개는 당일 오특과 겹치지 않았습니다. 2026-11-03 20:27 실제 경로(로컬 브라우저 릴레이) 조회에서는 배너 31개가 화면 DOM의 상품명·이미지와 모두 일치했고, 당일 오특과 겹친 상품은 0개였습니다. 같은 날 22시대 조회의 배너도 같은 31개였고 순서만 달랐습니다. 썸네일 파일명에서 뽑은 상품번호는 표본 3개 모두 상품 검색 결과의 상품번호와 같았습니다. 사용자 관측 기준으로 화면에는 대략 한국 시간 오후 9시(21시)부터 표시되고, 22시쯤 "잠시 후 12시 새로운 오특 오픈!" 문구가 보입니다. +- 예고가 다음 날 오특과 같은지는 `Oliveyoung Coming Soon` workflow가 매일 확인합니다. 한국 시간 21:10에 운영 `/api/oliveyoung/today-deals`의 예고를 저장하고, 00:20에 그날 오특(브랜드 + 오늘의 특가)과 상품번호로 대조해 빠지거나 추가된 상품이 있으면 실패합니다. 배너 순서는 바뀔 수 있어 순서는 비교하지 않습니다. - 배너 항목에는 상품명과 썸네일만 있고 가격·할인율·상품번호 속성은 없습니다. 썸네일 파일명(`A00000020241475ko.jpg`)이 상품번호 13자리로 시작해 `goodsNo`로 읽습니다. 서버 HTML에서는 반복이 없었지만 슬라이드 배너라 같은 상품은 한 번만 반환합니다. - 예고는 보조 정보라 배너가 없거나 항목 구조가 바뀌면 오특 조회를 실패시키지 않고 `upcomingDeals=null`(미확인)로 반환합니다. diff --git a/scripts/ops/README.md b/scripts/ops/README.md index ae4bcefd..f3732e2d 100644 --- a/scripts/ops/README.md +++ b/scripts/ops/README.md @@ -7,6 +7,7 @@ - `generate-openapi.ts`: 배포/패키징에 필요한 OpenAPI 산출물을 생성합니다. - `generate-discovery.ts`: 현재 레지스트리에서 공개 HTML·JSON·robots·sitemap·llms 정적 자산을 생성합니다. [웹 발견 배포 절차](../../docs/web-discovery.md)를 참고하세요. - `workers-chart-data.ts`, `workers-chart-helpers.ts`, `update-workers-invocations-chart.ts`: Cloudflare Workers 호출량 차트를 갱신합니다. +- `oliveyoung-coming-soon.ts`, `oliveyoung-coming-soon-check.ts`: 올리브영 coming soon 예고를 한국 시간 21시 이후 저장(`snapshot`)하고 자정 이후 실제 오특과 상품번호로 대조(`compare`)합니다. `Oliveyoung Coming Soon` workflow가 매일 실행하며, `OY_COMING_SOON_URL`로 대상 서버를 바꿀 수 있습니다(기본 `https://mcp.aka.page`). 운영 스크립트는 `npm run` 명령에서 참조되며, 입력과 실패 메시지는 자동화 로그만으로 원인 추적이 가능해야 합니다. diff --git a/scripts/ops/oliveyoung-coming-soon-check.ts b/scripts/ops/oliveyoung-coming-soon-check.ts new file mode 100644 index 00000000..d1310eb4 --- /dev/null +++ b/scripts/ops/oliveyoung-coming-soon-check.ts @@ -0,0 +1,115 @@ +/** + * 올리브영 coming soon(다음 오특 예고) 운영 확인 + * + * 21시 이후 예고를 저장하고, 다음 날 자정 이후 실제 오특과 상품번호를 대조합니다. + * 예고는 화면에 대략 한국 시간 21시부터 공개되므로 그 전 조회는 확인으로 쓰지 않습니다. + */ + +export interface UpcomingDeal { + goodsNo: string | null; + name: string; +} + +export interface TodayDealsBody { + date: string; + brandDeals: Array<{ goodsNo: string; name: string }>; + todayDeals: Array<{ goodsNo: string; name: string }>; + upcomingDeals: UpcomingDeal[] | null; +} + +export interface UpcomingSnapshot { + checkedAt: string; + sourceDate: string; + targetDate: string; + upcomingDeals: UpcomingDeal[]; +} + +export const UPCOMING_PUBLIC_HOUR = 21; +const DAY_MS = 24 * 60 * 60 * 1000; +const kst = (now: number) => new Date(now + 9 * 60 * 60 * 1000); + +export function kstDate(now: number, offsetDays = 0): string { + return kst(now + offsetDays * DAY_MS) + .toISOString() + .slice(0, 10); +} + +/** 21시 이후 오늘 날짜 응답의 예고만 다음 날 대조용으로 저장합니다. */ +export function createUpcomingSnapshot(body: TodayDealsBody, now: number): UpcomingSnapshot { + if (kst(now).getUTCHours() < UPCOMING_PUBLIC_HOUR) { + throw new Error(`coming soon 공개 전입니다 (한국 시간 ${UPCOMING_PUBLIC_HOUR}시 이후 확인)`); + } + if (body.date !== kstDate(now)) { + throw new Error(`오특 날짜가 오늘이 아닙니다: ${body.date}`); + } + if (!Array.isArray(body.upcomingDeals)) { + throw new Error('coming soon 예고를 확인하지 못했습니다 (upcomingDeals=null)'); + } + if (body.upcomingDeals.length === 0) { + throw new Error('coming soon 예고 상품이 없습니다'); + } + return { + checkedAt: new Date(now).toISOString(), + sourceDate: body.date, + targetDate: kstDate(now, 1), + upcomingDeals: body.upcomingDeals, + }; +} + +export interface UpcomingComparison { + ok: boolean; + targetDate: string; + previewCount: number; + dealCount: number; + matched: number; + /** 예고했지만 실제 오특에 없는 상품 */ + notInDeals: UpcomingDeal[]; + /** 실제 오특에 있지만 예고에 없던 상품 */ + notInPreview: Array<{ goodsNo: string; name: string }>; +} + +/** 전날 저장한 예고와 대상 날짜의 실제 오특(브랜드 + 오늘의 특가)을 상품번호로 대조합니다. */ +export function compareUpcoming( + snapshot: UpcomingSnapshot, + body: TodayDealsBody, +): UpcomingComparison { + if (body.date !== snapshot.targetDate) { + throw new Error(`대조 날짜가 다릅니다: 예고 ${snapshot.targetDate}, 오특 ${body.date}`); + } + const deals = [...body.brandDeals, ...body.todayDeals]; + const dealNos = new Set(deals.map((deal) => deal.goodsNo)); + const previewNos = new Set(snapshot.upcomingDeals.map((deal) => deal.goodsNo)); + const notInDeals = snapshot.upcomingDeals.filter( + (deal) => !deal.goodsNo || !dealNos.has(deal.goodsNo), + ); + const notInPreview = deals + .filter((deal) => !previewNos.has(deal.goodsNo)) + .map(({ goodsNo, name }) => ({ goodsNo, name })); + return { + ok: notInDeals.length === 0 && notInPreview.length === 0, + targetDate: snapshot.targetDate, + previewCount: snapshot.upcomingDeals.length, + dealCount: deals.length, + matched: snapshot.upcomingDeals.length - notInDeals.length, + notInDeals, + notInPreview, + }; +} + +/** /api/oliveyoung/today-deals 응답에서 본문을 꺼내고, 실패 응답은 오류로 바꿉니다. */ +export async function fetchTodayDeals( + baseUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl(new URL('/api/oliveyoung/today-deals', baseUrl)); + const payload = (await response.json().catch(() => null)) as { + success?: boolean; + data?: TodayDealsBody; + error?: { code?: string; message?: string }; + } | null; + if (!response.ok || !payload?.success || !payload.data) { + const reason = payload?.error ? `${payload.error.code}: ${payload.error.message}` : '본문 없음'; + throw new Error(`오특 조회 실패 (HTTP ${response.status}, ${reason})`); + } + return payload.data; +} diff --git a/scripts/ops/oliveyoung-coming-soon.ts b/scripts/ops/oliveyoung-coming-soon.ts new file mode 100644 index 00000000..366d4d2f --- /dev/null +++ b/scripts/ops/oliveyoung-coming-soon.ts @@ -0,0 +1,35 @@ +/** + * 올리브영 coming soon 운영 확인 실행 진입점 + * + * npx tsx scripts/ops/oliveyoung-coming-soon.ts snapshot # 21시 이후 예고 저장 + * npx tsx scripts/ops/oliveyoung-coming-soon.ts compare # 자정 이후 실제 오특과 대조 + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { + compareUpcoming, + createUpcomingSnapshot, + fetchTodayDeals, + type UpcomingSnapshot, +} from './oliveyoung-coming-soon-check.js'; + +const [mode, file] = process.argv.slice(2); +const baseUrl = process.env.OY_COMING_SOON_URL || 'https://mcp.aka.page'; + +if (mode === 'snapshot' && file) { + const snapshot = createUpcomingSnapshot(await fetchTodayDeals(baseUrl), Date.now()); + writeFileSync(file, JSON.stringify(snapshot, null, 2)); + console.log( + `status=ok sourceDate=${snapshot.sourceDate} targetDate=${snapshot.targetDate} upcoming=${snapshot.upcomingDeals.length}`, + ); +} else if (mode === 'compare' && file) { + const snapshot = JSON.parse(readFileSync(file, 'utf8')) as UpcomingSnapshot; + const result = compareUpcoming(snapshot, await fetchTodayDeals(baseUrl)); + console.log(JSON.stringify(result, null, 2)); + console.log( + `status=${result.ok ? 'ok' : 'mismatch'} targetDate=${result.targetDate} matched=${result.matched}/${result.previewCount} deals=${result.dealCount}`, + ); + if (!result.ok) process.exit(1); +} else { + console.error('usage: oliveyoung-coming-soon.ts '); + process.exit(2); +} diff --git a/tests/scripts/oliveyoung-coming-soon.test.ts b/tests/scripts/oliveyoung-coming-soon.test.ts new file mode 100644 index 00000000..1d7449cd --- /dev/null +++ b/tests/scripts/oliveyoung-coming-soon.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { + compareUpcoming, + createUpcomingSnapshot, + fetchTodayDeals, + kstDate, + type TodayDealsBody, +} from '../../scripts/ops/oliveyoung-coming-soon-check.js'; + +// 한국 시간 2026-11-03 21:10 +const evening = Date.parse('2026-11-03T12:10:00Z'); +const deal = (goodsNo: string) => ({ goodsNo, name: `상품 ${goodsNo}` }); +const body = (patch: Partial = {}): TodayDealsBody => ({ + date: '2026-11-03', + brandDeals: [deal('A000000000001')], + todayDeals: [deal('A000000000002')], + upcomingDeals: [deal('A000000000003'), deal('A000000000004')], + ...patch, +}); + +describe('coming soon 예고 저장', () => { + it('21시 이후 오늘 응답의 예고를 다음 날 대조용으로 저장한다', () => { + expect(kstDate(Date.parse('2026-11-03T15:30:00Z'))).toBe('2026-11-04'); + expect(createUpcomingSnapshot(body(), evening)).toEqual({ + checkedAt: '2026-11-03T12:10:00.000Z', + sourceDate: '2026-11-03', + targetDate: '2026-11-04', + upcomingDeals: body().upcomingDeals, + }); + }); + + it.each([ + [body(), Date.parse('2026-11-03T11:59:00Z'), '공개 전'], + [body({ date: '2026-11-02' }), evening, '오늘이 아닙니다'], + [body({ upcomingDeals: null }), evening, 'upcomingDeals=null'], + [body({ upcomingDeals: [] }), evening, '예고 상품이 없습니다'], + ])('확인할 수 없는 예고는 저장하지 않고 실패한다: %#', (input, now, message) => { + expect(() => createUpcomingSnapshot(input, now)).toThrow(message); + }); +}); + +describe('다음 날 오특 대조', () => { + const snapshot = createUpcomingSnapshot(body(), evening); + const next = (patch: Partial = {}) => + body({ + date: '2026-11-04', + brandDeals: [deal('A000000000003')], + todayDeals: [deal('A000000000004')], + upcomingDeals: null, + ...patch, + }); + + it('예고 상품번호가 실제 오특과 모두 같으면 통과한다', () => { + expect(compareUpcoming(snapshot, next())).toEqual({ + ok: true, + targetDate: '2026-11-04', + previewCount: 2, + dealCount: 2, + matched: 2, + notInDeals: [], + notInPreview: [], + }); + }); + + it('빠지거나 추가된 상품과 상품번호 없는 예고를 불일치로 보고한다', () => { + const withMissingNo = { + ...snapshot, + upcomingDeals: [...snapshot.upcomingDeals, { goodsNo: null, name: '번호 없음' }], + }; + const result = compareUpcoming(withMissingNo, next({ todayDeals: [deal('A000000000009')] })); + expect(result).toMatchObject({ ok: false, matched: 1, previewCount: 3, dealCount: 2 }); + expect(result.notInDeals.map((d) => d.name)).toEqual(['상품 A000000000004', '번호 없음']); + expect(result.notInPreview).toEqual([deal('A000000000009')]); + }); + + it('대상 날짜가 아닌 오특과는 대조하지 않는다', () => { + expect(() => compareUpcoming(snapshot, next({ date: '2026-11-05' }))).toThrow('대조 날짜'); + }); +}); + +describe('운영 오특 조회', () => { + it('성공 응답의 본문을 반환한다', async () => { + const fetchImpl = vi.fn(async () => Response.json({ success: true, data: body() })); + await expect(fetchTodayDeals('https://mcp.example', fetchImpl)).resolves.toEqual(body()); + expect(String(fetchImpl.mock.calls[0][0])).toBe( + 'https://mcp.example/api/oliveyoung/today-deals', + ); + }); + + it.each([ + [ + Response.json( + { + success: false, + error: { code: 'OLIVEYOUNG_TODAY_DEALS_PARSE_FAILED', message: '형식 변경' }, + }, + { status: 502 }, + ), + 'HTTP 502, OLIVEYOUNG_TODAY_DEALS_PARSE_FAILED: 형식 변경', + ], + [new Response('', { status: 200 }), 'HTTP 200, 본문 없음'], + ])('실패 응답은 오류로 바꾼다: %#', async (response, message) => { + await expect(fetchTodayDeals('https://mcp.example', async () => response)).rejects.toThrow( + message, + ); + }); +}); + +it('workflow는 21시 이후 저장하고 자정 이후 대조한다', () => { + const workflow = readFileSync('.github/workflows/oliveyoung-coming-soon.yml', 'utf8'); + expect(workflow).toContain("cron: '10 12 * * *'"); + expect(workflow).toContain("cron: '20 15 * * *'"); + expect(workflow).toContain('oliveyoung-coming-soon.ts snapshot coming-soon.json'); + expect(workflow).toContain('oliveyoung-coming-soon.ts compare coming-soon.json'); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cf96160a..2617e7de 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ 'scripts/ops/mcp-smoke-validation.ts', 'scripts/relay/daiso-jp.ts', 'scripts/ops/health-check-retry.ts', + 'scripts/ops/oliveyoung-coming-soon-check.ts', 'src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts',