8000 feat: 올리브영 다음 오특 예고와 오특 웹 조회 추가 by chuuminggg · Pull Request #362 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Open
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
78 changes: 78 additions & 0 deletions .github/workflows/oliveyoung-coming-soon.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`가 재고 조회 기준 상품입니다.

Expand Down
7 changes: 5 additions & 2 deletions docs/oliveyoung-today-deals-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
| ------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------- |
| 오늘의 브랜드 특가 | `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: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`(미확인)로 반환합니다.

## 상품 카드 구조

Expand Down
20 changes: 18 additions & 2 deletions docs/service-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)에 있습니다.

<br>

Expand Down Expand Up @@ -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)은 캐시하지 않음

<br>
Expand Down Expand Up @@ -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` | 메가박스 잔여 좌석 조회 |
Expand Down Expand Up @@ -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` 첫 화면에도 링크가 있습니다.

### 메가박스 주변 지점 조회

```
Expand Down Expand Up @@ -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/{실제극장코드}` |
Expand Down
1 change: 1 addition & 0 deletions scripts/ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 명령에서 참조되며, 입력과 실패 메시지는 자동화 로그만으로 원인 추적이 가능해야 합니다.

Expand Down
115 changes: 115 additions & 0 deletions scripts/ops/oliveyoung-coming-soon-check.ts
Original file line number Diff line number Diff line change
@@ -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<TodayDealsBody> {
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;
}
Loading
0