Add Ohou today deal skill

This commit is contained in:
lee-ji-hong 2026-05-18 13:02:07 +09:00
commit ca5aefd990
10 changed files with 752 additions and 2 deletions

View file

@ -91,6 +91,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 항공권 가격 조회 | `flight-ticket-search` | `fast-flights` 기반 Google Flights 공개 검색으로 항공권 후보, 예약 검색 링크, 날짜/월/연도별 최저가·평균가 비교 (조회 전용, 예매·결제 없음) | 불필요 | [항공권 가격 조회 가이드](docs/features/flight-ticket-search.md) |
| 택배 배송조회 | `delivery-tracking` | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | `coupang-product-search` | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 선택사항 (운영 키 있으면 로컬 HMAC 경로, 없으면 hosted fallback) | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 오늘의집 오늘의딜 조회 | `ohou-today-deal` | 오늘의집 공개 오늘의딜 특가 상품의 할인율·가격·리뷰·링크 조회 | 불필요 | [오늘의집 오늘의딜 조회 가이드](docs/features/ohou-today-deal.md) |
| 번개장터 검색 | `bunjang-search` | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
| 당근 중고거래 검색 | `daangn-used-goods-search` | 당근 중고거래 공개 웹 데이터 표면으로 키워드·지역 기반 매물 검색과 상세 조회 | 불필요 | [당근 중고거래 검색 가이드](docs/features/daangn-used-goods-search.md) |
| 당근부동산 검색 | `daangn-realty-search` | 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인 | 불필요 | [당근부동산 검색 가이드](docs/features/daangn-realty-search.md) |
@ -206,6 +207,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [항공권 가격 조회 가이드](docs/features/flight-ticket-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [오늘의집 오늘의딜 조회](docs/features/ohou-today-deal.md)
- [번개장터 검색 가이드](docs/features/bunjang-search.md)
- [당근 중고거래 검색 가이드](docs/features/daangn-used-goods-search.md)
- [당근부동산 검색 가이드](docs/features/daangn-realty-search.md)

View file

@ -0,0 +1,76 @@
# 오늘의집 오늘의딜 조회 가이드
## 이 기능으로 할 수 있는 일
`ohou-today-deal`은 오늘의집 공개 오늘의딜 페이지에서 특가 상품 정보를 읽어 할인율, 가격, 리뷰, 무료배송 여부, 링크를 정리하는 읽기 전용 스킬이다.
- 오늘의딜/스페셜딜 상품 목록 조회
- 할인율 높은 순, 낮은 가격 순, 리뷰 많은 순 정렬
- 키워드, 최소 할인율, 무료배송 필터
- 상품 링크 제공
## 먼저 필요한 것
- `python3`
- 인터넷 연결
- 별도 로그인/API 키 없음
## 공개 접근 경로
- 브라우저용 공개 URL: `https://ohou.se/commerces/today_deals`
- 페이지가 노출하는 canonical/OG URL: `https://store.ohou.se/today_deals`
- 데이터 표면: HTML 안의 Next.js `__NEXT_DATA__``today-deal-feed`
이 기능은 화면 클릭, 로그인 세션, 장바구니, 결제 자동화를 하지 않는다.
## 예시
할인율 높은 오늘의딜 상위 5개:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--sort discount \
--limit 5
```
러그 관련 무료배송 특가:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--query 러그 \
--free-delivery \
--limit 5
```
30% 이상 할인 상품:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--min-discount 30 \
--limit 10
```
오프라인 fixture로 검증:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--html-file ./today-deals.html \
--limit 3
```
## 출력에서 확인할 점
- `items[].title`: 상품명
- `items[].brand`: 브랜드
- `items[].original_price`, `items[].selling_price`: 기본 가격
- `items[].best_price`, `items[].best_discount_rate`: 쿠폰/결제혜택 반영 최저가가 있을 때의 가격과 할인율
- `items[].review_count`, `items[].review_average`: 리뷰 정보
- `items[].free_delivery`: 무료배송 여부
- `items[].url`: 상품 페이지
## 주의할 점
- 가격, 쿠폰, 결제혜택, 품절 여부는 실시간으로 바뀔 수 있다.
- `best_price`는 오늘의집 페이지가 노출한 혜택 기준이며, 사용자별 쿠폰/결제수단에 따라 실제 결제가는 달라질 수 있다.
- HTML 구조나 `__NEXT_DATA__` 스키마가 바뀌면 파서 수정이 필요하다.
- 구매, 장바구니, 결제는 사용자가 직접 진행해야 한다.

View file

@ -91,6 +91,7 @@ npx --yes skills add <owner/repo> \
--skill zipcode-search \
--skill delivery-tracking \
--skill coupang-product-search \
--skill ohou-today-deal \
--skill bunjang-search \
--skill used-car-price-search \
--skill korean-spell-check \

View file

@ -46,6 +46,7 @@
- 한국어 맞춤법 검사 스킬 출시
- 한국어 글자 수 세기 스킬 출시
- 긱뉴스 조회 스킬 출시
- 오늘의집 오늘의딜 조회 스킬 출시
## v1.5 candidates

View file

@ -142,6 +142,9 @@
- coupang_partners local MCP contract: local://coupang-mcp
- coupang_partners hosted fallback (credentialless, allowlist-gated): https://a.retn.kr/v1/public/assist
- coupang_partners hosted fallback PR (merged): https://github.com/retention-corp/coupang_partners/pull/1
- 오늘의집 오늘의딜 공개 페이지: https://ohou.se/commerces/today_deals
- 오늘의집 오늘의딜 canonical/OG URL: https://store.ohou.se/today_deals
- 오늘의집 오늘의딜 데이터 표면: HTML `__NEXT_DATA__``today-deal-feed`
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
- bunjang-cli repo: https://github.com/pinion05/bunjangcli
- 당근 메인: https://www.daangn.com/

189
ohou-today-deal/SKILL.md Normal file
View file

@ -0,0 +1,189 @@
---
name: ohou-today-deal
description: 오늘의집 공개 오늘의딜 페이지에서 로그인 없이 특가 상품을 조회하고 할인율, 가격, 리뷰, 링크를 정리하는 읽기 전용 스킬.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# 오늘의집 오늘의딜 조회
## What this skill does
오늘의집 공개 오늘의딜 페이지(`https://ohou.se/commerces/today_deals`)의 서버 렌더링 초기 데이터(`__NEXT_DATA__`)를 읽어 특가 상품을 조회한다.
- 오늘의딜/스페셜딜 상품 목록 조회
- 할인율, 원가, 판매가, 쿠폰/결제혜택 반영 최저가 정리
- 브랜드, 리뷰 수, 평점, 무료배송 여부, 상품 링크 확인
- 키워드, 최소 할인율, 무료배송 필터
## When to use
- "오늘의집 오늘의딜 뭐 있어?"
- "오늘의집에서 할인율 높은 특가 상품 3개 보여줘"
- "오늘의집 무료배송 특가만 골라줘"
- "오늘의집에서 러그 특가 찾아줘"
## When not to use
- 로그인, 장바구니, 구매, 결제 자동화 — 이 스킬은 의도적으로 구매 플로우를 포함하지 않는다.
- 개인화 추천, 사용자별 쿠폰 적용 확정, 실시간 재고 보장.
- 법적 증빙 수준의 가격 확정 — 조회 시점 기준 참고용이다.
- 차단 우회, CAPTCHA 우회 — 표준 라이브러리 `urllib` 한 호출로 안 되면 실패 모드로 처리한다.
## Required inputs
별도 입력 없이 실행 가능. 선택적으로 아래를 지정할 수 있다:
- `--query`: 상품명/브랜드 키워드
- `--min-discount`: 최소 할인율 (정수)
- `--free-delivery`: 무료배송 상품만
- `--sort`: 정렬 기준 (`discount`, `price`, `review`, `annual-sales`)
- `--limit`: 결과 개수 (기본 10)
- `--html-file`: 오프라인 HTML/JSON fixture 경로
## Official/public surface
- 오늘의집 오늘의딜 페이지: `https://ohou.se/commerces/today_deals`
- 현재 웹 페이지는 canonical/OG URL로 `https://store.ohou.se/today_deals`를 노출하지만, 브라우저 접근용 공개 URL은 `ohou.se/commerces/today_deals`다.
- 응답 HTML의 Next.js `__NEXT_DATA__``today-deal-feed` 데이터만 읽는다.
## Prerequisites
- `python3`
- 별도 로그인/API 키 없음
## Workflow
### 1. 오늘의딜 상품 조회
오늘의집 오늘의딜 공개 페이지에서 상품 목록을 가져온다. 기본 정렬은 할인율 높은 순이다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list --limit 10
```
응답 예시:
```json
{
"source": {
"name": "ohou-today-deal",
"url": "https://ohou.se/commerces/today_deals",
"fetched_at": "2026-05-18T01:44:16+00:00",
"surface": "__NEXT_DATA__ today-deal-feed"
},
"filters": {"query": null, "min_discount": null, "free_delivery": false, "sort": "discount", "limit": 10},
"count": 10,
"total_count": 72,
"filtered_count": 72,
"items": [
{
"id": "823405",
"title": "삼익가구 BEST상품 총집합",
"brand": "삼익가구",
"url": "https://ohou.se/productions/823405/selling",
"original_price": 449000,
"selling_price": 132000,
"discount_rate": 70,
"best_price": 118800,
"best_discount_rate": 73,
"best_discount_description": "쿠폰 할인가",
"review_count": 53818,
"review_average": 4.7,
"free_delivery": false,
"sold_out": false
}
]
}
```
### 2. 할인율 높은 순 정렬
`bestDiscountPrice.discountRate`(쿠폰/결제혜택 반영 할인율)가 있으면 우선 사용하고, 없으면 상품 기본 `discountRate`를 사용한다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--sort discount \
--limit 5
```
정렬 옵션: `discount`(할인율), `price`(낮은 가격), `review`(리뷰 많은 순), `annual-sales`(연간 판매량).
### 3. 키워드·할인율·무료배송 필터
상품명 또는 브랜드에 키워드가 포함된 상품만 걸러내고, 최소 할인율과 무료배송 조건을 조합할 수 있다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--query 러그 \
--min-discount 30 \
--free-delivery \
--limit 5
```
### 4. 오프라인 fixture로 검증
실제 네트워크 없이 저장된 HTML/JSON 파일로 동일한 파싱을 테스트한다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--html-file ./today-deals.html \
--limit 3
```
## Output format
기본 출력은 들여쓰기 JSON (`indent=2`). 파이프/스크립트에서 사용할 때는 출력을 `jq` 등으로 후처리한다.
주요 필드:
| 필드 | 설명 |
|---|---|
| `source.fetched_at` | 조회 시각 (UTC ISO 8601) |
| `count` | 반환된 상품 수 |
| `total_count` | 전체 오늘의딜 상품 수 |
| `filtered_count` | 필터 적용 후 상품 수 |
| `items[].best_price` | 쿠폰/결제혜택 반영 최저가 (없으면 null) |
| `items[].best_discount_rate` | 혜택 반영 할인율 (없으면 null) |
| `items[].free_delivery` | 무료배송 여부 |
| `items[].sold_out` | 품절 여부 |
## Endpoints used
이 스킬이 호출하는 공개 endpoint:
| Method | URL | 용도 |
|---|---|---|
| GET | `https://ohou.se/commerces/today_deals` | 오늘의딜 공개 HTML (서버 렌더링) |
비로그인 / 무인증. 헤더는 `User-Agent` + `Accept` 만.
## Response policy
- 상위 3~5개만 먼저 보여준다.
- 상품명, 브랜드, 할인가, 원가, 할인율, 평점/리뷰 수, 무료배송 여부, 링크를 정리한다.
- 가격, 할인, 품절, 쿠폰/결제혜택은 "조회 시각 기준"으로 변동 가능하다고 명시한다.
- 구매/장바구니/결제는 자동화하지 말고 상품 링크만 제공한다.
- "지금 사라" 같은 행위 유도 금지 — 사용자가 직접 페이지에서 구매한다.
## Done when
- 오늘의딜 상품 후보가 JSON 또는 요약 목록으로 반환된다.
- 할인율/가격 기준과 조회 시점이 분리되어 설명된다.
- 로그인, 구매, 결제, 개인화 기능을 시도하지 않았다.
## Failure modes
- **`__NEXT_DATA__` 미발견**: 오늘의집이 Next.js SSR 구조를 변경하거나, 서버 렌더링 대신 클라이언트 렌더링으로 전환하면 `ValueError` 발생. 스킬 파서 수정이 필요하다.
- **HTTP 4xx/5xx**: 봇 차단 또는 일시 장애. 우회 시도하지 않고 에러 출력 후 종료.
- **빈 응답 (`total_count: 0`)**: 오늘의딜이 아직 업데이트되지 않았거나, 페이지 구조가 바뀐 경우. 브라우저에서 직접 확인을 안내한다.
- **가격/쿠폰 변동**: `best_price`는 조회 시점 기준이며, 사용자별 쿠폰/결제수단에 따라 실제 결제가는 다를 수 있다.
- **필드 누락**: 일부 상품에 `bestDiscountPrice`, `badgeProperties.isFreeDelivery`, `scrapInfo` 등이 없을 수 있다. null로 처리된다.
## Notes
- read-only 스킬이다.
- 화면 선택자보다 서버 렌더링 초기 JSON을 우선한다.
- 새 dependency 없이 Python 표준 라이브러리만 사용한다.

View file

@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""ohou-today-deal — 오늘의집 공개 오늘의딜 특가 상품 조회 CLI.
조회 전용. 로그인·장바구니·구매·결제 자동화 없음.
__NEXT_DATA__ 서버 렌더링 초기 데이터만 읽는 read-only 스킬.
Usage:
ohou-today-deal list [--limit N] [--sort discount|price|review|annual-sales]
ohou-today-deal list --query 러그 --min-discount 30 --free-delivery
ohou-today-deal list --html-file ./fixture.html
Supported surface:
https://ohou.se/commerces/today_deals (공개 HTML)
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import urllib.request
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
DEFAULT_URL = "https://ohou.se/commerces/today_deals"
@dataclass(frozen=True)
class OhouDeal:
id: str
title: str
brand: str | None
url: str
image_url: str | None
original_price: int | None
selling_price: int | None
discount_rate: int | None
best_price: int | None
best_discount_rate: int | None
best_discount_description: str | None
review_count: int
review_average: float | None
scrap_count: int | None
annual_sales: int | None
free_delivery: bool
sold_out: bool
start_at: str | None
end_at: str | None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _to_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _to_float(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def fetch_html(url: str = DEFAULT_URL, timeout: int = 20) -> str:
request = urllib.request.Request(
url,
headers={
"User-Agent": "k-skill-ohou-today-deal/1.0",
"Accept": "text/html,application/json",
},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
return response.read().decode(charset, errors="replace")
def extract_next_data(document: str) -> dict[str, Any]:
stripped = document.lstrip()
if stripped.startswith("{"):
return json.loads(stripped)
match = re.search(
r'<script\b[^>]*\bid=["\']__NEXT_DATA__[^>]*>(.*?)</script>',
document,
re.DOTALL,
)
if not match:
raise ValueError("Could not find __NEXT_DATA__ in Today Deal HTML")
return json.loads(html.unescape(match.group(1)))
def _walk(value: Any):
"""스택 기반 DFS로 JSON 트리의 모든 dict 노드를 순회한다.
__NEXT_DATA__는 깊고 거대한 트리 구조를 가질 있어
재귀 대신 반복문을 사용해 sys.getrecursionlimit() 제한을 회피한다.
"""
stack = [value]
while stack:
curr = stack.pop()
if isinstance(curr, dict):
yield curr
stack.extend(curr.values())
elif isinstance(curr, list):
stack.extend(reversed(curr))
def _looks_like_deal_node(node: dict[str, Any]) -> bool:
deal = node.get("deal")
return (
node.get("type") == "DEAL"
and isinstance(deal, dict)
and bool(deal.get("id"))
and bool(deal.get("name"))
)
def _normalize_deal(node: dict[str, Any]) -> OhouDeal:
deal = node.get("deal", {})
price = deal.get("price") or {}
best_price = node.get("bestDiscountPrice") or {}
brand = deal.get("brand") or {}
review = deal.get("reviewStatistic") or {}
scrap = deal.get("scrapInfo") or {}
badge = deal.get("badgeProperties") or {}
annual_sales = node.get("salesStats", {}).get("annualCumulativeSales")
deal_id = str(deal.get("id", ""))
return OhouDeal(
id=deal_id,
title=str(deal.get("name") or node.get("title") or ""),
brand=brand.get("name"),
url=f"https://ohou.se/productions/{deal_id}/selling",
image_url=deal.get("imageUrl"),
original_price=_to_int(price.get("representativeOriginalPrice")),
selling_price=_to_int(price.get("representativeSellingPrice")),
discount_rate=_to_int(price.get("discountRate")),
best_price=_to_int(best_price.get("price")),
best_discount_rate=_to_int(best_price.get("discountRate")),
best_discount_description=best_price.get("discountPlanDescription"),
review_count=_to_int(review.get("reviewCount")) or 0,
review_average=_to_float(review.get("reviewAverage")),
scrap_count=_to_int(scrap.get("scrapCount")),
annual_sales=_to_int(annual_sales),
free_delivery=bool(badge.get("isFreeDelivery")),
sold_out=bool(deal.get("isSoldOut")),
start_at=node.get("startAt"),
end_at=node.get("endAt"),
)
def extract_deals(payload: dict[str, Any]) -> list[OhouDeal]:
seen: set[str] = set()
deals: list[OhouDeal] = []
for node in _walk(payload):
if not _looks_like_deal_node(node):
continue
deal = _normalize_deal(node)
if deal.id in seen:
continue
seen.add(deal.id)
deals.append(deal)
return deals
def filter_deals(
deals: list[OhouDeal],
*,
query: str | None = None,
min_discount: int | None = None,
free_delivery: bool = False,
include_sold_out: bool = False,
) -> list[OhouDeal]:
"""단일 루프로 모든 필터 조건을 검사한다."""
needle = query.casefold() if query else None
filtered: list[OhouDeal] = []
for deal in deals:
if not include_sold_out and deal.sold_out:
continue
if needle and needle not in deal.title.casefold() and needle not in (deal.brand or "").casefold():
continue
if min_discount is not None and (deal.best_discount_rate or deal.discount_rate or 0) < min_discount:
continue
if free_delivery and not deal.free_delivery:
continue
filtered.append(deal)
return filtered
def sort_deals(deals: list[OhouDeal], sort_key: str) -> list[OhouDeal]:
if sort_key == "discount":
return sorted(
deals,
key=lambda deal: (deal.best_discount_rate or deal.discount_rate or -1, deal.review_count),
reverse=True,
)
if sort_key == "price":
return sorted(deals, key=lambda deal: deal.best_price or deal.selling_price or sys.maxsize)
if sort_key == "review":
return sorted(deals, key=lambda deal: (deal.review_count, deal.review_average or 0), reverse=True)
if sort_key == "annual-sales":
return sorted(deals, key=lambda deal: deal.annual_sales or -1, reverse=True)
return deals
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
document = Path(args.html_file).read_text(encoding="utf-8") if args.html_file else fetch_html(args.url)
payload = extract_next_data(document)
deals = extract_deals(payload)
filtered = filter_deals(
deals,
query=args.query,
min_discount=args.min_discount,
free_delivery=args.free_delivery,
include_sold_out=args.include_sold_out,
)
sorted_deals = sort_deals(filtered, args.sort)
limited_deals = sorted_deals[: args.limit]
kst = timezone(timedelta(hours=9))
now_utc = datetime.now(timezone.utc)
return {
"source": {
"name": "ohou-today-deal",
"url": args.url,
"fetched_at": now_utc.isoformat(),
"fetched_at_kst": now_utc.astimezone(kst).strftime("%Y-%m-%d %H:%M:%S KST"),
"surface": "__NEXT_DATA__ today-deal-feed",
},
"filters": {
"query": args.query,
"min_discount": args.min_discount,
"free_delivery": args.free_delivery,
"include_sold_out": args.include_sold_out,
"sort": args.sort,
"limit": args.limit,
},
"count": len(limited_deals),
"total_count": len(deals),
"filtered_count": len(filtered),
"items": [deal.to_dict() for deal in limited_deals],
}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Read Ohouse today deal products from public HTML.")
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser("list", help="오늘의집 오늘의딜 상품 목록")
list_parser.add_argument("--url", default=DEFAULT_URL)
list_parser.add_argument("--html-file", help="테스트/오프라인 검증용 HTML 또는 JSON 파일")
list_parser.add_argument("--query", help="상품명 또는 브랜드 키워드")
list_parser.add_argument("--min-discount", type=int, help="최소 할인율")
list_parser.add_argument("--free-delivery", action="store_true", help="무료배송 상품만")
list_parser.add_argument("--include-sold-out", action="store_true", help="품절 상품 포함")
list_parser.add_argument(
"--sort",
choices=["default", "discount", "price", "review", "annual-sales"],
default="discount",
)
list_parser.add_argument("--limit", type=int, default=10)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
if args.command == "list":
print(json.dumps(build_payload(args), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View file

@ -10,9 +10,9 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -1405,6 +1405,49 @@ test("coupang-product-search docs drop non-allowlisted coupang-mcp-fallback and
}
});
test("repository docs advertise the ohou-today-deal skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "ohou-today-deal.md");
const skillPath = path.join(repoRoot, "ohou-today-deal", "SKILL.md");
const helperPath = path.join(repoRoot, "ohou-today-deal", "scripts", "ohou_today_deal.py");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/ohou-today-deal.md to exist");
assert.ok(fs.existsSync(skillPath), "expected ohou-today-deal/SKILL.md to exist");
assert.ok(fs.existsSync(helperPath), "expected ohou-today-deal helper script to exist");
assert.match(readme, /\| 오늘의집 오늘의딜 조회 \| `ohou-today-deal` \|/);
assert.match(readme, /\[오늘의집 오늘의딜 조회 가이드\]\(docs\/features\/ohou-today-deal\.md\)/);
assert.match(install, /--skill ohou-today-deal/);
assert.match(roadmap, /오늘의집 오늘의딜 조회 스킬 출시/);
assert.match(sources, /ohou\.se\/commerces\/today_deals/);
assert.match(sources, /store\.ohou\.se\/today_deals/);
});
test("ohou-today-deal docs lock the public Next data read-only workflow", () => {
const skill = read(path.join("ohou-today-deal", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "ohou-today-deal.md"));
const helper = read(path.join("ohou-today-deal", "scripts", "ohou_today_deal.py"));
const sources = read(path.join("docs", "sources.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /https:\/\/ohou\.se\/commerces\/today_deals/);
assert.match(doc, /https:\/\/store\.ohou\.se\/today_deals/);
assert.match(doc, /__NEXT_DATA__/);
assert.match(doc, /today-deal-feed/);
assert.match(doc, /ohou_today_deal\.py list/);
assert.match(doc, /(로그인|API key|API 키).*(불필요|없음)|(불필요|없음).*(로그인|API key|API 키)/);
assert.match(doc, /(구매|장바구니|결제).*자동화.*(하지 않는다|하지 말고|제외)/);
}
assert.match(helper, /DEFAULT_URL = "https:\/\/ohou\.se\/commerces\/today_deals"/);
assert.match(helper, /__NEXT_DATA__/);
assert.match(helper, /today-deal-feed/);
assert.match(sources, /ohou\.se\/commerces\/today_deals/);
assert.match(sources, /store\.ohou\.se\/today_deals/);
});
test("root pack:dry-run script covers all publishable workspaces", () => {
const packageJson = readJson("package.json");
const packScript = packageJson.scripts["pack:dry-run"];

View file

@ -0,0 +1,148 @@
import contextlib
import importlib.util
import io
import json
import sys
import tempfile
from pathlib import Path
import unittest
REPO_ROOT = Path(__file__).resolve().parent.parent
HELPER_PATH = REPO_ROOT / "ohou-today-deal" / "scripts" / "ohou_today_deal.py"
spec = importlib.util.spec_from_file_location("ohou_today_deal", HELPER_PATH)
ohou_today_deal = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules["ohou_today_deal"] = ohou_today_deal
spec.loader.exec_module(ohou_today_deal)
def sample_payload():
return {
"pageProps": {
"dehydratedState": {
"queries": [
{
"state": {
"data": {
"feed": [
{
"title": "러그 특가",
"startAt": "2026-05-17T15:00:00Z",
"endAt": "2026-05-20T15:00:00Z",
"type": "DEAL",
"deal": {
"id": "1215312",
"name": "디아망 방수러그",
"imageUrl": "https://example.com/rug.png",
"isSoldOut": False,
"price": {
"representativeOriginalPrice": "41040",
"representativeSellingPrice": "24800",
"discountRate": "39",
},
"brand": {"name": "체고루루"},
"badgeProperties": {"isFreeDelivery": True},
"reviewStatistic": {"reviewCount": 7504, "reviewAverage": 4.8},
"scrapInfo": {"scrapCount": 64757},
},
"salesStats": {"annualCumulativeSales": "1000"},
"bestDiscountPrice": {
"price": "21500",
"discountRate": "47",
"discountPlanDescription": "쿠폰 할인가",
},
},
{
"title": "식기 특가",
"type": "DEAL",
"deal": {
"id": "4070154",
"name": "식탁 위에 핀 꽃 bowl",
"isSoldOut": False,
"price": {
"representativeOriginalPrice": "50000",
"representativeSellingPrice": "50000",
"discountRate": "0",
},
"brand": {"name": "미브래"},
"badgeProperties": {"isFreeDelivery": False},
"reviewStatistic": {"reviewCount": 0, "reviewAverage": 0},
},
"bestDiscountPrice": {"price": "43500", "discountRate": "13"},
},
]
}
}
}
]
}
}
}
class OhouTodayDealTest(unittest.TestCase):
def test_extract_deals_normalizes_public_today_deal_shape(self):
deals = ohou_today_deal.extract_deals(sample_payload())
self.assertEqual(len(deals), 2)
first = deals[0]
self.assertEqual(first.id, "1215312")
self.assertEqual(first.title, "디아망 방수러그")
self.assertEqual(first.brand, "체고루루")
self.assertEqual(first.original_price, 41040)
self.assertEqual(first.selling_price, 24800)
self.assertEqual(first.best_price, 21500)
self.assertEqual(first.best_discount_rate, 47)
self.assertTrue(first.free_delivery)
self.assertEqual(first.url, "https://ohou.se/productions/1215312/selling")
def test_filter_and_sort_deals(self):
deals = ohou_today_deal.extract_deals(sample_payload())
filtered = ohou_today_deal.filter_deals(
deals,
query="러그",
min_discount=40,
free_delivery=True,
)
sorted_deals = ohou_today_deal.sort_deals(deals, "discount")
self.assertEqual([deal.id for deal in filtered], ["1215312"])
self.assertEqual([deal.id for deal in sorted_deals], ["1215312", "4070154"])
def test_extract_next_data_accepts_html_script(self):
html_doc = (
'<html><script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(sample_payload(), ensure_ascii=False)
+ "</script></html>"
)
payload = ohou_today_deal.extract_next_data(html_doc)
self.assertEqual(
payload["pageProps"]["dehydratedState"]["queries"][0]["state"]["data"]["feed"][0]["deal"]["id"],
"1215312",
)
def test_cli_prints_json_from_html_file(self):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".html") as fixture:
fixture.write(
'<script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(sample_payload(), ensure_ascii=False)
+ "</script>"
)
fixture.flush()
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
ohou_today_deal.main(["list", "--html-file", fixture.name, "--limit", "1"])
output = json.loads(stdout.getvalue())
self.assertEqual(output["count"], 1)
self.assertEqual(output["items"][0]["id"], "1215312")
if __name__ == "__main__":
unittest.main()