mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge remote-tracking branch 'origin/dev' into feature/#256
# Conflicts: # package.json
This commit is contained in:
commit
ba4eadac37
20 changed files with 1216 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
77
docs/features/ohou-today-deal.md
Normal file
77
docs/features/ohou-today-deal.md
Normal 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__` 스키마가 바뀌면 파서 수정이 필요하다.
|
||||
- 구매, 장바구니, 결제는 사용자가 직접 진행해야 한다.
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
- 한국어 맞춤법 검사 스킬 출시
|
||||
- 한국어 글자 수 세기 스킬 출시
|
||||
- 긱뉴스 조회 스킬 출시
|
||||
- 오늘의집 오늘의딜 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
|
|||
|
|
@ -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
192
ohou-today-deal/SKILL.md
Normal 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 표준 라이브러리만 사용한다.
|
||||
369
ohou-today-deal/scripts/ohou_today_deal.py
Executable file
369
ohou-today-deal/scripts/ohou_today_deal.py
Executable 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
294
scripts/test_ohou_today_deal.py
Normal file
294
scripts/test_ohou_today_deal.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
|
||||
|
|
|
|||
|
|
@ -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' 우편번호와 공식 영문주소 찾아줘."
|
||||
|
|
|
|||
27
tools/k-skill-qa-bot/test/bats/docs_trust_boundary.bats
Normal file
27
tools/k-skill-qa-bot/test/bats/docs_trust_boundary.bats
Normal 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"
|
||||
}
|
||||
|
|
@ -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" {
|
||||
|
|
|
|||
54
tools/k-skill-qa-bot/test/bats/judge_command.bats
Normal file
54
tools/k-skill-qa-bot/test/bats/judge_command.bats
Normal 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
|
||||
}
|
||||
65
tools/k-skill-qa-bot/test/bats/smoke_command.bats
Normal file
65
tools/k-skill-qa-bot/test/bats/smoke_command.bats
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue