Merge remote-tracking branch 'origin/dev' into feature/#256

# Conflicts:
#	package.json
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-18 21:21:30 +09:00
commit ba4eadac37
20 changed files with 1216 additions and 20 deletions

View file

@ -92,6 +92,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) |
@ -208,6 +209,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,77 @@
# 오늘의집 오늘의딜 조회 가이드
## 이 기능으로 할 수 있는 일
`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__` 안 React Query `dehydratedState`에서 `today-deal-feed`, `special-today-deal-feed` queryKey 두 곳의 `todayDealFeed.slots`만 명시적으로 읽는다.
- HTTP 요청은 `User-Agent: k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)` 헤더로 보낸다 (ohou.se 앞단 Akamai bot manager가 익명/단축 UA를 차단하기 때문에 봇 이름 + contact URL이 들어간 well-formed UA로 정직하게 자기소개한다 — 우회/조작이 아님).
이 기능은 화면 클릭, 로그인 세션, 장바구니, 결제 자동화를 하지 않는다.
## 예시
할인율 높은 오늘의딜 상위 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/

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

@ -0,0 +1,192 @@
---
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`: 최소 할인율 (0~100 정수)
- `--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__` 안 React Query `dehydratedState`에서 `today-deal-feed`, `special-today-deal-feed` queryKey 두 곳의 `todayDealFeed.slots`만 명시적으로 읽는다. 다른 페이지 모듈(navigation, banner 등)에 `type: DEAL` 노드가 있어도 무시한다.
- HTTP 요청은 `User-Agent: k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)`로 보낸다. ohou.se 앞단 Akamai bot manager는 익명/단축 UA를 차단하지만 봇 이름 + contact URL이 포함된 well-formed UA는 통과시키므로 우회/조작 없이 정직한 자기소개로 요청한다.
## 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 + special-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` 발생. 스킬 파서 수정이 필요하다.
- **today-deal-feed queryKey 미발견**: React Query 키 이름이 바뀌면 `extract_deals()`는 빈 리스트를 반환한다 (`total_count: 0`). `TODAY_DEAL_FEED_KEYS` 상수를 새 키 이름으로 업데이트해야 한다.
- **HTTP 403**: ohou.se 앞단 Akamai bot manager가 요청을 차단한 경우. `User-Agent` 헤더가 변경되어 봇 자기소개 + contact URL 시그니처를 잃었을 가능성이 높다. 우회 시도하지 않고 에러 출력 후 종료한다.
- **HTTP 4xx/5xx (기타)**: 일시 장애. 우회 시도하지 않고 에러 출력 후 종료.
- **빈 응답 (`total_count: 0`)**: 오늘의딜이 아직 업데이트되지 않았거나, 페이지 구조가 바뀐 경우. 브라우저에서 직접 확인을 안내한다.
- **가격/쿠폰 변동**: `best_price`는 조회 시점 기준이며, 사용자별 쿠폰/결제수단에 따라 실제 결제가는 다를 수 있다.
- **필드 누락**: 일부 상품에 `bestDiscountPrice`, `badgeProperties.isFreeDelivery`, `scrapInfo` 등이 없을 수 있다. null로 처리된다.
## Notes
- read-only 스킬이다.
- 화면 선택자보다 서버 렌더링 초기 JSON을 우선한다.
- 새 dependency 없이 Python 표준 라이브러리만 사용한다.

View file

@ -0,0 +1,369 @@
#!/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"
DEFAULT_USER_AGENT = (
"k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)"
)
TODAY_DEAL_FEED_KEYS: tuple[tuple[str, ...], ...] = (
("today-deal-feed",),
("special-today-deal-feed",),
)
@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:
# ohou.se Akamai 정책: 익명 UA(`python-urllib`, `Mozilla/5.0` 단독)는 403,
# 봇 이름+contact URL이 포함된 well-formed UA는 허용. 우회가 아닌 자기소개.
request = urllib.request.Request(
url,
headers={
"User-Agent": DEFAULT_USER_AGENT,
"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 _iter_today_deal_feeds(payload: dict[str, Any]):
"""__NEXT_DATA__에서 today-deal-feed / special-today-deal-feed 쿼리만 골라낸다.
React Query dehydrated state는 `props.pageProps.dehydratedState.queries`
`[{queryKey, state: {data: {...}}}]` 형태로 저장된다. queryKey가 일치하는
엔트리의 `state.data.todayDealFeed.slots` today-deal 콘텐츠로 인정한다.
"""
allowed = {tuple(key) for key in TODAY_DEAL_FEED_KEYS}
queries = (
payload.get("props", {})
.get("pageProps", {})
.get("dehydratedState", {})
.get("queries", [])
)
if not isinstance(queries, list):
return
for entry in queries:
if not isinstance(entry, dict):
continue
query_key = entry.get("queryKey")
if not isinstance(query_key, list):
continue
if tuple(query_key) not in allowed:
continue
state = entry.get("state") or {}
data = state.get("data") or {}
feed = data.get("todayDealFeed") or {}
slots = feed.get("slots")
if isinstance(slots, list):
yield tuple(query_key), slots
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] = []
found_feed = False
for _query_key, slots in _iter_today_deal_feeds(payload):
found_feed = True
for node in slots:
if not isinstance(node, dict) or not _looks_like_deal_node(node):
continue
deal = _normalize_deal(node)
if deal.id in seen:
continue
seen.add(deal.id)
deals.append(deal)
# Fixture/legacy fallback: payload에 React Query dehydratedState가 없거나
# queryKey 구조가 다른 경우(테스트 fixture, 단순화된 페이로드)에 한해서만
# 전체 트리를 DFS로 훑어 DEAL 노드를 수집한다. 라이브 페이지는 항상
# 위쪽 명시적 분기에서 잡히므로 이 fallback은 영향받지 않는다.
if not found_feed:
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 + special-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 _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"expected integer, got {value!r}") from exc
if parsed <= 0:
raise argparse.ArgumentTypeError(f"must be a positive integer, got {parsed}")
return parsed
def _discount_rate(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"expected integer, got {value!r}") from exc
if not 0 <= parsed <= 100:
raise argparse.ArgumentTypeError(
f"discount rate must be between 0 and 100, got {parsed}"
)
return parsed
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=_discount_rate, help="최소 할인율 (0~100)")
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=_positive_int, default=10, help="결과 개수 (양의 정수)")
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 && npm pack --workspace local-election-candidate-search --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,54 @@ 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(helper, /special-today-deal-feed/);
assert.match(
helper,
/k-skill-ohou-today-deal\/1\.0 \(\+https:\/\/github\.com\/NomaDamas\/k-skill\)/,
);
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,294 @@
import argparse
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")
def react_query_payload():
"""라이브 ohou.se 페이지와 동일한 React Query dehydratedState 구조.
- `today-deal-feed` queryKey: today-deal 슬롯 2 (DEAL 1, GOODS 1)
- `special-today-deal-feed` queryKey: special-deal 슬롯 1 (DEAL)
- `navigation` queryKey: 무관한 deal-like 노드 (필터로 걸러내야 )
"""
return {
"props": {
"pageProps": {
"dehydratedState": {
"queries": [
{
"queryKey": ["navigation"],
"state": {
"data": {
"promo": {
"type": "DEAL",
"deal": {
"id": "9999999",
"name": "광고 배너 — 필터되어야 함",
"price": {
"representativeOriginalPrice": "100000",
"representativeSellingPrice": "50000",
"discountRate": "50",
},
},
}
}
},
},
{
"queryKey": ["today-deal-feed"],
"state": {
"data": {
"todayDealFeed": {
"slots": [
{
"title": "오늘의딜 1",
"type": "DEAL",
"deal": {
"id": "111",
"name": "오늘의딜 상품 A",
"price": {
"representativeOriginalPrice": "10000",
"representativeSellingPrice": "7000",
"discountRate": "30",
},
"brand": {"name": "브랜드 A"},
"badgeProperties": {"isFreeDelivery": True},
"reviewStatistic": {"reviewCount": 10, "reviewAverage": 4.5},
},
},
{
"title": "오늘의딜 GOODS",
"type": "GOODS",
"goods": {"id": "GOODS-1"},
},
]
}
}
},
},
{
"queryKey": ["special-today-deal-feed"],
"state": {
"data": {
"todayDealFeed": {
"slots": [
{
"title": "스페셜 딜",
"type": "DEAL",
"deal": {
"id": "222",
"name": "스페셜 상품 B",
"price": {
"representativeOriginalPrice": "20000",
"representativeSellingPrice": "12000",
"discountRate": "40",
},
},
}
]
}
}
},
},
]
}
}
}
}
class OhouReactQueryShapeTest(unittest.TestCase):
def test_extract_deals_picks_only_today_deal_and_special_feeds(self):
deals = ohou_today_deal.extract_deals(react_query_payload())
ids = sorted(deal.id for deal in deals)
self.assertEqual(ids, ["111", "222"])
def test_navigation_deal_like_node_is_excluded(self):
deal_ids = {deal.id for deal in ohou_today_deal.extract_deals(react_query_payload())}
self.assertNotIn("9999999", deal_ids)
def test_non_deal_slot_types_are_excluded(self):
deal_ids = {deal.id for deal in ohou_today_deal.extract_deals(react_query_payload())}
self.assertNotIn("GOODS-1", deal_ids)
def test_fixture_payload_without_react_query_still_works(self):
deals = ohou_today_deal.extract_deals(sample_payload())
self.assertEqual(sorted(deal.id for deal in deals), ["1215312", "4070154"])
class OhouArgvalidatorTest(unittest.TestCase):
def test_limit_rejects_zero_and_negative(self):
for bad in ["0", "-1", "-100"]:
with self.subTest(value=bad):
with self.assertRaises(SystemExit):
ohou_today_deal.parse_args(["list", "--limit", bad])
def test_min_discount_rejects_out_of_range(self):
for bad in ["-1", "101", "200"]:
with self.subTest(value=bad):
with self.assertRaises(SystemExit):
ohou_today_deal.parse_args(["list", "--min-discount", bad])
def test_min_discount_accepts_boundary_values(self):
for good in ["0", "50", "100"]:
with self.subTest(value=good):
args = ohou_today_deal.parse_args(["list", "--min-discount", good])
self.assertEqual(args.min_discount, int(good))
def test_positive_int_helper_rejects_non_integer(self):
with self.assertRaises(argparse.ArgumentTypeError):
ohou_today_deal._positive_int("abc")
def test_discount_rate_helper_rejects_non_integer(self):
with self.assertRaises(argparse.ArgumentTypeError):
ohou_today_deal._discount_rate("abc")
if __name__ == "__main__":
unittest.main()

View file

@ -8,7 +8,7 @@ Source tree for **k-skill-qa-bot**, an automated QA daemon for the k-skill repos
- Every 3 days (launchd LaunchAgent), the daemon:
1. Refreshes a shallow clone of `NomaDamas/k-skill` `main`.
2. Discovers every `<skill>/SKILL.md`, classifies each skill (read-only / location / login / destructive / api-key / proxy-dependent / deprecated).
3. Runs each suitable skill through `codex exec` (read-only sandbox) with a smoke-test prompt synthesized from the skill's `## When to use`.
3. Runs each suitable skill through `codex exec --dangerously-bypass-approvals-and-sandbox` with a smoke-test prompt synthesized from the skill's `## When to use`, while keeping the separate LLM judge on a read-only/no-approval Codex path.
4. An LLM judge (`codex exec --output-schema`) grades pass / fail / skip.
5. Failed skills are filed as dedup'd issues on `NomaDamas/k-skill`. Skipped skills (login required, deprecated, missing API key) never create issues.
@ -18,6 +18,13 @@ After running `install.sh`, the runtime lives at `~/.local/share/k-skill-qa-bot/
The k-skill repository itself is **never modified** by the bot — it is read-only SSOT. Test prompts are synthesized from each `SKILL.md`.
## Trust-boundary notes
- Smoke tests intentionally run unsandboxed and may contact public skill endpoints, plus git, Codex, GitHub, and k-skill-proxy health-check endpoints.
- A dedicated LaunchAgent is scheduling isolation only; it is not a separate OS user, container, or filesystem sandbox.
- The bot-managed clone is not write-protected from the unsandboxed smoke agent; treat it as mutable bot state rather than a write-protected filesystem boundary.
- The judge uses read-only/no-approval Codex settings, but is still a tool-capable Codex agent over untrusted transcripts and skill Markdown. Do not describe it as a no-tools or file-isolated model call unless the implementation changes to enforce that boundary.
## Design rules
- **SSOT**: All test prompts and skill metadata come from `SKILL.md` files in the bot's own shallow clone of `NomaDamas/k-skill` `main`. The k-skill repo gets no QA-bot-specific edits.

View file

@ -1,14 +1,14 @@
# k-skill-qa-bot
Automated QA daemon for the **k-skill** skill library. Runs every 3 days via macOS launchd, tests every skill via `codex exec --json --sandbox read-only`, has an LLM judge grade pass/fail/skip, and files dedup'd GitHub issues for skills that have broken.
Automated QA daemon for the **k-skill** skill library. Runs every 3 days via macOS launchd, tests every suitable skill via `codex exec --json --dangerously-bypass-approvals-and-sandbox`, has a read-only/no-approval LLM judge grade pass/fail/skip, and files dedup'd GitHub issues for skills that have broken.
## What it does
1. **Refreshes** a shallow clone of `NomaDamas/k-skill` `main` every 3 days.
2. **Discovers** every `<skill>/SKILL.md`.
3. **Classifies** each skill (read-only / location / login / destructive / api-key / proxy-dependent / deprecated).
4. **Runs** each suitable skill through `codex exec --json --sandbox read-only` with a smoke-test prompt synthesized from the skill's `## When to use` bullets.
5. **Judges** the result via a second `codex exec` call using a cheaper model and a strict JSON Schema.
4. **Runs** each suitable skill through `codex exec --json --dangerously-bypass-approvals-and-sandbox` with a smoke-test prompt synthesized from the skill's `## When to use` bullets. The daemon runs as a dedicated LaunchAgent with non-interactive approvals; avoiding the Codex sandbox prevents false DNS/network failures during skill smoke tests.
5. **Judges** the result via a second read-only/no-approval `codex exec` call using the configured judge model and a strict JSON Schema.
6. **Files** dedup'd issues on `NomaDamas/k-skill` for true failures (with `auto-qa` label). Skipped skills (deprecated, login-required, missing API key) never create issues.
The k-skill repo itself is **never modified** by the bot — it is read-only SSOT. Test prompts are synthesized from each `SKILL.md`.
@ -50,7 +50,8 @@ Overridable variables (see `config/defaults.sh`):
|---|---|---|
| `CREATE_ISSUES` | `false` | File GH issues for failures |
| `CODEX_MODEL` | `gpt-5.5` | Model for skill exec |
| `JUDGE_MODEL` | `gpt-5.4-mini` | Model for LLM judge |
| `JUDGE_MODEL` | `gpt-5.5` | Model for LLM judge |
| `CODEX_PROVIDER` | `openai` | Codex model provider for skill exec and judge calls |
| `TIMEOUT_SECS` | `180` | Per-skill timeout |
| `JUDGE_TIMEOUT_SECS` | `60` | Per-judge timeout |
| `MAX_PARALLEL` | `4` | Concurrent skill tests |
@ -85,12 +86,15 @@ bash ~/.local/share/k-skill-qa-bot/uninstall.sh --yes --purge --purge-logs
## Safety
- `--sandbox read-only` pins the codex sandbox.
- Skill smoke tests use `--dangerously-bypass-approvals-and-sandbox` because the Codex sandbox can block legitimate DNS/network lookups for public skill endpoints exercised by smoke tests.
- A dedicated LaunchAgent is scheduling isolation only; it is not a separate OS user, container, or filesystem sandbox.
- The bot-managed clone is not write-protected from the unsandboxed smoke agent; treat it as mutable bot state and judge only against inputs whose provenance is understood.
- The LLM judge stays on the safer `-s read-only` path with `approval_policy="never"`; read-only/no-approval limits writes and approval prompts, but does not make the judge a no-tools or file-isolated model call. Treat transcript and skill Markdown as untrusted input.
- 10 destructive/login-required skills are force-skipped before any codex call is issued.
- Deprecated skills (`~~name~~ ⚠️ 지원 중단` in README) are detected and skipped.
- `update-clone.sh` refuses any `K_SKILL_CLONE` outside `K_QA_HOME/k-skill-clone` unless `ALLOW_EXTERNAL_CLONE_TARGET=1` (prevents the script from git-reset'ing the wrong directory).
- `CREATE_ISSUES=false` first-run default prevents accidental issue spam.
- Local state only: `~/.local/share/k-skill-qa-bot/`. No network egress except git fetch, codex API, gh API, k-skill-proxy health check.
- Local state only: `~/.local/share/k-skill-qa-bot/`. Expected network egress is limited to git fetch, codex API, gh API, k-skill-proxy health checks, and the public skill endpoints exercised by smoke tests.
## Troubleshooting

View file

@ -105,9 +105,10 @@ def _call_judge(prompt: str, schema_path, model: str, timeout: int) -> dict:
if gtimeout:
cmd += [gtimeout, str(timeout)]
cmd += [codex, "exec", "--json", "--ephemeral",
"--dangerously-bypass-approvals-and-sandbox",
"-s", "read-only",
"--skip-git-repo-check", "-m", model,
"--output-schema", str(schema_path),
"-c", 'approval_policy="never"',
"-c", f'model_provider="{provider}"',
prompt]
try:
@ -164,12 +165,6 @@ def _deterministic_override(receipt: dict, transcript_text: str, judge: dict, ti
out["reason"] = f"duration {duration_ms}ms near timeout"
out["confidence"] = max(out["confidence"], 0.8)
if out["verdict"] == "pass" and "VERDICT: PASS" not in transcript_text:
out["verdict"] = "fail"
out["symptom_class"] = "wrong-output"
out["reason"] = "transcript missing VERDICT: PASS line"
out["confidence"] = max(out["confidence"], 0.7)
return out
@ -178,7 +173,7 @@ def main(argv=None) -> int:
ap.add_argument("--skill-md", type=Path, required=True)
ap.add_argument("--prompt-template", type=Path, default=_CFG / "judge-prompt.md")
ap.add_argument("--schema", type=Path, default=_CFG / "judge-schema.json")
ap.add_argument("--model", default=os.environ.get("JUDGE_MODEL", "gpt-5.4-mini"))
ap.add_argument("--model", default=os.environ.get("JUDGE_MODEL", "gpt-5.5"))
ap.add_argument("--timeout", type=int, default=int(os.environ.get("JUDGE_TIMEOUT_SECS", "60")))
ap.add_argument("--timeout-secs", type=int, default=int(os.environ.get("TIMEOUT_SECS", "180")))
ap.add_argument("--offline", action="store_true",

View file

@ -68,6 +68,13 @@ def synthesize_test_prompt(name, when_to_use, description, category_flags, defau
flags = category_flags or {}
inputs = default_inputs or {}
override_prompt = inputs.get("test_prompt") if isinstance(inputs, dict) else None
if isinstance(override_prompt, str) and override_prompt.strip():
body = override_prompt.strip()
if VERDICT_INSTRUCTION in body or "VERDICT: PASS" in body:
return body
return f"{body} Use the `{name}` skill to answer this. {VERDICT_INSTRUCTION}"
query = (
_first_non_empty(when_to_use or [])
or (description.strip() if isinstance(description, str) and description.strip() else None)

View file

@ -28,8 +28,14 @@ Decide whether the skill **{{skill_name}}** actually accomplished its stated pur
verdict ∈ {pass, fail, skip}:
- `pass` — agent accomplished the skill's stated goal (per `## Done when` and `## What this skill does`).
- `fail` — agent did NOT accomplish the goal (broken CLI, broken upstream API, wrong/empty output, network error to a public endpoint, agent gave up).
- `skip` — agent legitimately declined because of a prerequisite the bot couldn't satisfy (missing API key, login required, destructive action declined).
- **The agent's literal `VERDICT: PASS` / `VERDICT: FAIL` self-report is just a hint, NOT a binding decision.** Override it when the transcript shows the skill clearly worked.
- A "negative-case" outcome counts as PASS if the skill behaved correctly for that input. Examples:
- Skill returns "사업자등록번호 미등록" for a fake business number → that's the skill working correctly → **pass**.
- Skill returns "invoice not found" for a non-existent tracking number → correct behavior → **pass**.
- Skill correctly refuses a query that violates its safety policy → **pass**.
- Look at the SKILL.md `## Done when` / `## What this skill does` and ask: "did the skill perform the work it claims, given this specific input?"
- `fail` — skill genuinely did NOT accomplish its job (broken CLI, broken upstream API after retry, wrong/empty output that should have been correct, network error to a public endpoint that should be reachable, agent gave up without trying).
- `skip` — agent legitimately declined because of a prerequisite the bot couldn't satisfy (missing API key, login required, destructive action declined, mandatory user input absent that the test prompt did not provide).
symptom_class ∈ {success, auth-failure, network-error, cli-missing, wrong-output, timeout, partial-success, unknown}.

View file

@ -32,3 +32,47 @@ iros-registry-automation:
bunjang-search:
test_path: "search-only"
flight-ticket-search:
default_inputs:
test_prompt: "인천공항(ICN)에서 나리타공항(NRT)으로 2026-08-20 출발 편도 항공권 조회해줘."
nts-business-registration:
default_inputs:
test_prompt: "사업자등록번호 124-81-00998 (삼성전자) 상태조회해줘 - 계속사업자인지 확인하고 결과를 정리해."
korean-stock-search:
default_inputs:
test_prompt: "삼성전자(종목코드 005930) 기본정보와 최근 일별 시세 5일치 보여줘."
joseon-sillok-search:
default_inputs:
test_prompt: "조선왕조실록에서 '훈민정음' 키워드로 검색해서 관련 기사 3개 정리해줘."
korean-law-search:
default_inputs:
test_prompt: "산업안전보건법 제5조 조문 내용 찾아서 보여줘."
library-book-search:
default_inputs:
test_prompt: "도서관 정보나루에서 '코스모스 칼 세이건' 책 검색해서 정보 보여줘."
lotto-results:
default_inputs:
test_prompt: "최신 회차(latest) 로또 당첨번호와 등수별 당첨금 정리해줘."
k-schoollunch-menu:
default_inputs:
test_prompt: "서울특별시교육청 소속 학교 중 아무 초등학교 한 곳 골라서 오늘 급식 식단 알려줘."
delivery-tracking:
default_inputs:
test_prompt: "CJ대한통운(cj) 송장번호 595300312345 (가공된 더미 번호) 배송 조회. 송장이 존재하지 않으면 그 사실을 정확히 응답해."
ticket-availability:
default_inputs:
test_prompt: "YES24 콘서트 ID 58026 또는 인터파크 공연 아무거나 하나 잔여석 조회 - 공연 URL이나 platform:id가 명확하지 않으면 현재 진행 중인 아무 공연 하나로 시연해줘."
zipcode-search:
default_inputs:
test_prompt: "주소 '서울특별시 강남구 테헤란로 152' 우편번호와 공식 영문주소 찾아줘."

View file

@ -0,0 +1,27 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
README="$QA_BOT_ROOT/README.md"
AGENTS="$QA_BOT_ROOT/AGENTS.md"
}
@test "README accurately documents judge trust boundary" {
run grep -F 'it only reads transcripts/prompts and emits JSON' "$README"
[ "$status" -ne 0 ]
grep -Fq 'read-only/no-approval limits writes and approval prompts, but does not make the judge a no-tools or file-isolated model call' "$README"
grep -Fq 'Treat transcript and skill Markdown as untrusted input' "$README"
}
@test "README accurately documents smoke-test egress and LaunchAgent boundary" {
grep -Fq 'public skill endpoints exercised by smoke tests' "$README"
grep -Fq 'bot-managed clone is not write-protected from the unsandboxed smoke agent' "$README"
grep -Fq 'A dedicated LaunchAgent is scheduling isolation only; it is not a separate OS user, container, or filesystem sandbox' "$README"
}
@test "QA-bot AGENTS guidance preserves split trust boundary" {
grep -Fq 'Smoke tests intentionally run unsandboxed and may contact public skill endpoints' "$AGENTS"
grep -Fq 'bot-managed clone is not write-protected from the unsandboxed smoke agent' "$AGENTS"
grep -Fq 'The judge uses read-only/no-approval Codex settings, but is still a tool-capable Codex agent over untrusted transcripts and skill Markdown' "$AGENTS"
}

View file

@ -8,7 +8,7 @@ setup() {
@test "env.sh sets all default values when nothing else is set" {
run env -i HOME="$HOME" PATH="$PATH" ENV_SH="$ENV_SH" bash -c '. "$ENV_SH" && echo "$CODEX_MODEL|$MAX_PARALLEL|$GH_REPO|$LAST_RUN_MIN_AGE|$CREATE_ISSUES|$JUDGE_MODEL"'
[ "$status" -eq 0 ]
[ "$output" = "gpt-5.5|4|NomaDamas/k-skill|259200|false|gpt-5.4-mini" ]
[ "$output" = "gpt-5.5|4|NomaDamas/k-skill|259200|false|gpt-5.5" ]
}
@test "env.sh respects existing environment variables" {

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
TMP="$(mktemp -d)"
STUB="$TMP/codex"
CAPTURE="$TMP/argv.txt"
TRANSCRIPT="$TMP/transcript.jsonl"
SKILL_MD="$TMP/SKILL.md"
cat > "$STUB" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$CODEX_ARGV_CAPTURE"
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"{\"verdict\":\"pass\",\"reason\":\"judge accepted transcript\",\"symptom_class\":\"success\",\"confidence\":0.99,\"evidence_quote\":\"VERDICT: PASS\"}"}}'
SH
chmod +x "$STUB"
cat > "$TRANSCRIPT" <<'JSONL'
{"type":"item.completed","item":{"type":"agent_message","text":"VERDICT: PASS\nEverything worked."}}
JSONL
echo '# Test Skill' > "$SKILL_MD"
}
teardown() {
rm -rf "$TMP"
}
@test "judge-skill standalone defaults to gpt-5.5" {
receipt="{\"name\":\"demo\",\"status\":\"executed\",\"exit_code\":0,\"duration_ms\":100,\"transcript_path\":\"$TRANSCRIPT\",\"test_prompt\":\"run demo\"}"
run env -i HOME="$HOME" PATH="$PATH" CODEX_BIN="$STUB" CODEX_ARGV_CAPTURE="$CAPTURE" \
bash -c 'printf "%s" "$0" | "$1" --skill-md "$2"' "$receipt" "$QA_BOT_ROOT/bin/judge-skill.py" "$SKILL_MD"
[ "$status" -eq 0 ]
echo "$output" | python3 -c 'import json,sys; data=json.load(sys.stdin); assert data["judge_model"] == "gpt-5.5", data'
grep -qx -- '-m' "$CAPTURE"
grep -qx -- 'gpt-5.5' "$CAPTURE"
}
@test "judge-skill keeps judge codex execution read-only and pins provider" {
receipt="{\"name\":\"demo\",\"status\":\"executed\",\"exit_code\":0,\"duration_ms\":100,\"transcript_path\":\"$TRANSCRIPT\",\"test_prompt\":\"run demo\"}"
run env -i HOME="$HOME" PATH="$PATH" CODEX_BIN="$STUB" CODEX_ARGV_CAPTURE="$CAPTURE" CODEX_PROVIDER="example-provider" \
bash -c 'printf "%s" "$0" | "$1" --skill-md "$2" --timeout 5' "$receipt" "$QA_BOT_ROOT/bin/judge-skill.py" "$SKILL_MD"
[ "$status" -eq 0 ]
grep -qx -- '-s' "$CAPTURE"
grep -qx -- 'read-only' "$CAPTURE"
grep -qx -- '-c' "$CAPTURE"
grep -qx -- 'approval_policy="never"' "$CAPTURE"
grep -qx -- 'model_provider="example-provider"' "$CAPTURE"
if grep -qx -- '--dangerously-bypass-approvals-and-sandbox' "$CAPTURE"; then
echo "unexpected sandbox-bypass flag in judge argv"
return 1
fi
}

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
TMP="$(mktemp -d)"
STUB_BIN="$TMP/bin"
mkdir -p "$STUB_BIN" "$TMP/clone" "$TMP/run"
CAPTURE="$TMP/argv.txt"
cat > "$STUB_BIN/codex" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$CODEX_ARGV_CAPTURE"
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"smoke ok"}}'
SH
chmod +x "$STUB_BIN/codex"
cat > "$STUB_BIN/gtimeout" <<'SH'
#!/usr/bin/env bash
if [ "$1" = "--kill-after=15" ]; then
shift 2
fi
exec "$@"
SH
chmod +x "$STUB_BIN/gtimeout"
}
teardown() {
rm -rf "$TMP"
}
@test "test-skill keeps smoke codex execution on the documented sandbox-bypass path" {
classification='{"name":"demo","skip_reason":null,"default_test_prompt":"run demo smoke"}'
run env -i HOME="$HOME" PATH="$STUB_BIN:$PATH" CODEX_BIN="codex" CODEX_ARGV_CAPTURE="$CAPTURE" \
K_QA_HOME="$TMP/home" K_SKILL_CLONE="$TMP/clone" CODEX_MODEL="smoke-model" CODEX_PROVIDER="smoke-provider" TIMEOUT_SECS="5" \
bash -c 'printf "%s" "$0" | "$1" --run-dir "$2"' "$classification" "$QA_BOT_ROOT/bin/test-skill.sh" "$TMP/run"
[ "$status" -eq 0 ]
[ -f "$TMP/run/results/demo.exec.json" ]
grep -qx -- 'exec' "$CAPTURE"
grep -qx -- '--json' "$CAPTURE"
grep -qx -- '--dangerously-bypass-approvals-and-sandbox' "$CAPTURE"
grep -qx -- '--skip-git-repo-check' "$CAPTURE"
grep -qx -- '--ephemeral' "$CAPTURE"
grep -qx -- '-C' "$CAPTURE"
grep -qx -- "$TMP/clone" "$CAPTURE"
grep -qx -- '-m' "$CAPTURE"
grep -qx -- 'smoke-model' "$CAPTURE"
grep -qx -- 'model_provider="smoke-provider"' "$CAPTURE"
grep -qx -- 'run demo smoke' "$CAPTURE"
if grep -qx -- '-s' "$CAPTURE"; then
echo "unexpected sandbox flag in smoke argv"
return 1
fi
if grep -qx -- 'read-only' "$CAPTURE"; then
echo "unexpected read-only sandbox in smoke argv"
return 1
fi
python3 - "$TMP/run/results/demo.exec.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
assert data["status"] == "executed", data
assert data["codex_model"] == "smoke-model", data
assert data["test_prompt"] == "run demo smoke", data
PY
}