8000 feat: 올리브영 오특(오늘의 특가) 조회 추가 by chuuminggg · Pull Request #331 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ai-instruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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는 미지원입니다.
Expand Down
12 changes: 9 additions & 3 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_ca F438 talog`는 세븐일레븐의 공개 콘텐츠 조회입니다. `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`가 재고 조회 기준 상품입니다.

Expand All @@ -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 |
Expand Down Expand Up @@ -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` | | |

Expand Down
47 changes: 47 additions & 0 deletions docs/oliveyoung-today-deals-analysis.md
Original file line number Diff line number Diff line change
@@ -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시부터 표시됩니다.

## 상품 카드 구조

두 섹션은 같은 `<li data-impression="상품번호^코너^순위">` 카드를 사용합니다. 코너 이름은 브랜드 특가가 `오특_오특상품리스트_스페셜`, 오늘의 특가가 `오특_오특상품리스트_인기순`입니다.

| 값 | 위치 |
| --------------- | ----------------------------------------------- |
| 상품번호 | `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번 상품이 화면에 "일시품절"로 표시되었고, 카드 썸네일 안에 `<span class="status_flag soldout">일시품절</span>`이 있어 `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`를 반환합니다.
15 changes: 15 additions & 0 deletions docs/service-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,21 @@ Zyte API 기반으로 올리브영 상품 재고를 조회하고 주변 매장

<br>

### 올리브영 오특 (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)에 있습니다.

<br>

### megabox_find_nearby_theaters

사용자 좌표 또는 위치 키워드 기준으로 메가박스 주변 지점을 거리순으로 조회합니다.
Expand Down
14 changes: 14 additions & 0 deletions scripts/ops/mcp-smoke-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): 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
: '한국 날짜 기준 오특 상품·가격·품절 정보가 없습니다';
}
8 changes: 8 additions & 0 deletions scripts/ops/mcp-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
/**
Expand Down Expand Up @@ -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는 호출 라우팅에만 사용하고 공개 도구 목록은 늘리지 않습니다.
Expand Down
49 changes: 36 additions & 13 deletions scripts/relay/browser.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -12,29 +17,38 @@ export class BrowserRequestError extends Error {
}
export function createBrowserRunner(page: Pick<Page, 'evaluate'>): 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' 10BC8 ;,
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();
Expand All @@ -53,6 +67,7 @@ export function createBrowserRunner(page: Pick<Page, 'evaluate'>): 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);
Expand All @@ -74,8 +89,16 @@ export function createBrowserRunner(page: Pick<Page, 'evaluate'>): 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 ||
Expand Down
2 changes: 1 addition & 1 deletion scripts/relay/observed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading
0