@clawhub-twbeatles-dc2f192c5d
Search for products on Naver Shopping. Use when the user wants to find product prices, links, or compare items in the Korean market.
---
name: naver-shopping
description: Search for products on Naver Shopping. Use when the user wants to find product prices, links, or compare items in the Korean market.
---
# Naver Shopping Search
네이버 쇼핑 Search API로 한국 상품 검색을 수행한다.
## Usage
검색어를 넣어 스크립트를 실행한다.
```bash
python skills/naver-shopping/scripts/search_shopping.py "상품명"
```
### Options
- `--display <number>`: Number of results to show (default: 5, max: 100)
- `--sort <sim|date|asc|dsc>`: Sort order (sim: similarity, date: date, asc: price ascending, dsc: price descending)
### Example
```bash
python skills/naver-shopping/scripts/search_shopping.py "아이폰 16" --display 3 --sort asc
```
## Environment Variables
다음 중 하나의 이름으로 자격증명을 읽는다.
- `NAVER_Client_ID` / `NAVER_Client_Secret`
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`
- `NAVER_SHOPPING_CLIENT_ID` / `NAVER_SHOPPING_CLIENT_SECRET`
우선 순위:
1. 현재 환경 변수
2. `skills/naver-shopping/.env`
3. `~/.openclaw/credentials/naver-shopping.env`
FILE:scripts/search_shopping.py
#!/usr/bin/env python3
import os
import sys
import json
import urllib.parse
import urllib.request
import argparse
def load_env_file(path):
if not os.path.exists(path):
return
with open(path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and not os.getenv(key):
os.environ[key] = value
def _resolve_credentials():
skill_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_env_file(os.path.join(skill_dir, '.env'))
load_env_file(os.path.join(os.path.expanduser('~'), '.openclaw', 'credentials', 'naver-shopping.env'))
client_id = (
os.getenv('NAVER_Client_ID')
or os.getenv('NAVER_CLIENT_ID')
or os.getenv('NAVER_SHOPPING_CLIENT_ID')
)
client_secret = (
os.getenv('NAVER_Client_Secret')
or os.getenv('NAVER_CLIENT_SECRET')
or os.getenv('NAVER_SHOPPING_CLIENT_SECRET')
)
return client_id, client_secret
def search_shopping(query, display=5, sort='sim'):
client_id, client_secret = _resolve_credentials()
if not client_id or not client_secret:
return {
"error": (
"네이버 쇼핑 API 자격증명을 찾지 못했습니다. "
"NAVER_Client_ID / NAVER_Client_Secret 또는 NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 을 설정해 주세요."
)
}
display = max(1, min(int(display), 100))
encText = urllib.parse.quote(query)
url = f"https://openapi.naver.com/v1/search/shop.json?query={encText}&display={display}&sort={sort}"
request = urllib.request.Request(url)
request.add_header("X-Naver-Client-Id", client_id.strip())
request.add_header("X-Naver-Client-Secret", client_secret.strip())
# Debug: print headers (excluding secret)
# print(f"ID: {client_id.strip()}", file=sys.stderr)
try:
response = urllib.request.urlopen(request)
rescode = response.getcode()
if rescode == 200:
response_body = response.read()
return json.loads(response_body.decode('utf-8'))
else:
return {"error": f"Error Code: {rescode}"}
except Exception as e:
return {"error": str(e)}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Naver Shopping Search')
parser.add_argument('query', help='Search query')
parser.add_argument('--display', type=int, default=5, help='Number of results (1-100)')
parser.add_argument('--sort', default='sim', choices=['sim', 'date', 'asc', 'dsc'], help='Sort order')
args = parser.parse_args()
results = search_shopping(args.query, args.display, args.sort)
if "error" in results:
print(json.dumps(results, indent=2, ensure_ascii=False))
sys.exit(1)
print(json.dumps(results, indent=2, ensure_ascii=False))
FILE:_meta.json
{
"ownerId": "kn7cckmvhax99kg7tzktf3dwps80t23r",
"slug": "naver-shopping",
"version": "1.0.0",
"publishedAt": 1770629738906
}대한민국 대기질(미세먼지, 초미세먼지, 오존, 통합대기) 조회·브리핑·알림을 위한 OpenClaw 스킬. 사용자가 서울/성동구/분당 같은 한국 지역의 공기질을 묻거나, 오늘/지금 대기질 요약, 현재 위치 기반 조회, 기본 지역 저장, 나쁨 이상 알림, 매일 아침 대기질 브리핑, 여...
---
name: korea-air-quality
description: 대한민국 대기질(미세먼지, 초미세먼지, 오존, 통합대기) 조회·브리핑·알림을 위한 OpenClaw 스킬. 사용자가 서울/성동구/분당 같은 한국 지역의 공기질을 묻거나, 오늘/지금 대기질 요약, 현재 위치 기반 조회, 기본 지역 저장, 나쁨 이상 알림, 매일 아침 대기질 브리핑, 여러 지역 비교를 원할 때 사용한다. 특히 한국 지역명 입력을 받아 지역/측정소 후보를 해석하고, 위치 공유 다음 저장된 기본 지역, 그다음 수동 지역 입력 순서로 기본 지역을 결정하는 워크플로에 적합하다.
---
# korea-air-quality
대한민국 지역의 대기질을 OpenClaw에서 자연어로 조회하고, 브리핑/감시 흐름으로 연결한다.
## Quick start
우선 `references/api-and-product-plan.md` 를 읽고 데이터 소스/범위를 확인한다.
그 다음 아래 흐름으로 처리한다.
1. 사용자가 원하는 것이 무엇인지 분류한다.
- 지금/오늘 대기질 조회
- 현재 위치 기반 조회
- 기본 지역 저장/변경
- 나쁨 이상 알림
- 아침 브리핑
- 여러 지역 비교
2. 지역 결정을 시도한다.
- 1순위: 사용자가 방금 공유한 위치 좌표
- 2순위: 저장된 기본 지역
- 3순위: 메시지에 포함된 지역명
3. 지역이 확정되면 측정소/행정구역 후보를 해석한다.
4. 실시간 대기질과 예보를 조합해 짧고 실용적으로 요약한다.
5. 반복 요청이면 alert/cron 흐름으로 바꾼다.
## Core capabilities
### 1. 현재 대기질 조회
다음 같은 요청을 처리한다.
- `지금 우리 동네 미세먼지 어때?`
- `오늘 서울 공기질 알려줘`
- `성동구 초미세먼지 어때?`
- `분당 공기 어때?`
반드시 포함한다.
- 기준 지역 또는 측정소
- 측정 시각
- PM10 / PM2.5 / O3 가능 범위
- 등급(좋음/보통/나쁨/매우나쁨)
- 한 줄 행동 가이드
### 2. 현재 위치 기반 조회
사용자가 위치를 직접 보내거나 좌표가 있으면 가장 우선한다.
좌표가 있으면 가장 가까운 측정소를 찾는 방식으로 해석한다.
좌표가 없으면 저장된 기본 지역을 사용하고, 그것도 없으면 지역명을 다시 물어본다.
### 3. 기본 지역 저장
다음 요청을 처리한다.
- `내 기본 지역을 성동구로 저장해줘`
- `앞으로는 분당 기준으로 알려줘`
- `기본 지역 삭제해줘`
- `내 현재 위치를 기본 위치로 저장해줘`
저장 위치는 `data/user-preferences.json` 같은 로컬 파일을 우선 고려한다.
사용자별 식별자가 필요하면 채널 사용자 id를 키로 쓴다.
현재 버전은 지역명 저장과 좌표 저장(`save-location`) 모두 지원한다.
### 4. 알림 / 감시
다음 요청을 처리한다.
- `초미세먼지 나쁨 이상이면 알려줘`
- `서울 오존 나쁘면 알려줘`
- `출근 시간에 공기 안 좋으면 알려줘`
현재 버전은 `alert-add`, `alert-list`, `alert-check` 명령으로 로컬 규칙 저장/점검이 가능하다.
`alert-check` 는 기본적으로 중복 hit를 다시 알리지 않도록 상태를 저장한다.
반복 감시는 watch 성격의 로컬 규칙 + OpenClaw cron 조합으로 설계한다.
### 5. 아침 브리핑
다음 요청을 처리한다.
- `매일 아침 7시에 오늘 공기질 알려줘`
- `날씨랑 같이 대기질 브리핑해줘`
현재 버전은 `morning-brief` 명령으로 날씨 + 대기질 결합 브리핑을 생성할 수 있다.
브리핑은 모바일에서 읽기 좋게 짧게 쓰고, 아래 순서를 권장한다.
- 지역
- 현재/예상 등급
- 핵심 수치
- 외출/환기/마스크 한 줄 팁
## Location resolution policy
항상 아래 우선순위를 사용한다.
1. 사용자가 방금 보낸 위치 좌표
2. 저장된 기본 지역
3. 메시지에 적힌 지역명
4. 아무 정보가 없으면 지역을 짧게 재질문
`우리 동네`, `내 위치`, `지금 있는 곳` 같은 표현은 좌표 또는 기본 지역 해석을 먼저 시도한다.
## Output style
출력은 한국어로, 짧고 실용적으로 쓴다.
권장 형식:
- `성동구 인근 측정소 기준`
- `초미세먼지: 38㎍/㎥ · 나쁨`
- `미세먼지: 54㎍/㎥ · 보통`
- `한줄 요약: 오늘은 초미세먼지가 다소 높은 편이라 장시간 야외활동은 줄이는 게 좋겠어.`
- `행동 팁: 창문 환기는 짧게, 외출 시 마스크 권장`
여러 지역 비교일 때는 지역별 1~2줄 요약으로 정리한다.
## Resources
### references/api-and-product-plan.md
데이터 소스 후보, 권장 MVP 범위, 위치 반영 전략, 알림 설계를 정리한 문서다. 이 스킬을 확장하거나 실제 API를 연결할 때 먼저 읽는다.
### scripts/air_quality.py
실행 가능한 CLI다. 현재 버전은 Open-Meteo 기반으로 한국 지역 대기질 조회가 가능하며, 지역명 해석, 기본 지역/기본 좌표 저장, 여러 지역 비교, 알림 점검, 중복 알림 방지, 아침 브리핑, OpenClaw cron 초안 생성을 지원한다. `--provider airkorea` 레이어와 `setup-provider` 설정 흐름도 준비돼 있어 국내 API 연결 시 이 파일을 중심으로 확장한다.
FILE:README.md
# OpenClaw Skill: Korea Air Quality
`openclaw-korea-air-quality`는 대한민국 지역의 **미세먼지 / 초미세먼지 / 오존 / 대기질 요약 / 위치 기반 조회 / 알림 / 아침 브리핑**을 다루기 위한 **OpenClaw AgentSkill 저장소**입니다.
이 저장소는 일반 파이썬 앱이 아니라, **OpenClaw에서 설치·호출·자동화하는 스킬 저장소**입니다.
이 스킬이 다루는 대표 요청:
- `답십리동 미세먼지 알려줘`
- `내 위치 기준으로 공기질 알려줘`
- `초미세먼지 나쁨 이상이면 알려줘`
- `매일 아침 7시 30분에 답십리동 브리핑해줘`
- `서울, 수원, 인천 공기질 비교해줘`
## 이 저장소가 OpenClaw 스킬인 이유
- 권장 저장소명: `openclaw-korea-air-quality`
- 스킬 엔트리: `SKILL.md`
- 로컬 실행용 CLI: `scripts/air_quality.py`
- 배포 결과물: `korea-air-quality.skill`
- OpenClaw cron / 알림 / 브리핑 자동화 흐름과 연결되도록 설계됨
## 현재 상태 한눈에 보기
### 지금 바로 동작하는 것
- **AirKorea 기반 대한민국 대기질 조회**
- Open-Meteo 기반 fallback 조회
- 지역 alias / 동·구 fallback (`답십리동`, `동대문구`, `성동구`, `분당`, `판교`, `영통`, `잠실` 등)
- 사용자 기본 지역 저장/조회
- 사용자 기본 위치 좌표 저장/조회
- 저장된 위치 기반 조회 우선 적용
- 여러 지역 비교 CLI
- 대기질 알림 규칙 추가/목록/점검
- 중복 알림 방지 상태 저장
- 날씨 + 대기질 결합 아침 브리핑
- OpenClaw cron 초안 생성 (`cron-plan`)
- provider 설정 저장/조회 (`setup-provider`, `show-config`)
- AirKorea 시도 단위 실시간 측정 JSON/XML 파싱 fallback
### 참고
- 실제 국내 실측값을 쓰려면 **`AIRKOREA_API_KEY` 또는 `data/config.json`의 `airkorea_api_key`** 가 필요함
- 키가 없는 환경에서도 fallback으로 `openmeteo` provider 사용 가능
## 빠르게 써보기
### 1) 현재 대기질 조회
```bash
python scripts/air_quality.py now 답십리동
python scripts/air_quality.py now 답십리동 --provider airkorea
python scripts/air_quality.py now 서울 --json
```
### 2) 기본 지역 / 위치 저장
```bash
python scripts/air_quality.py save-default telegram:8209218742 답십리동
python scripts/air_quality.py save-location telegram:8209218742 37.5666 127.0569 --label 답십리동
python scripts/air_quality.py show-default telegram:8209218742 --json
python scripts/air_quality.py now --user telegram:8209218742
```
### 3) 여러 지역 비교
```bash
python scripts/air_quality.py compare 서울 수원 인천
```
### 4) 알림 규칙 추가 / 점검
```bash
python scripts/air_quality.py alert-add telegram:8209218742 답십리동 pm2_5 나쁨
python scripts/air_quality.py alert-list --user telegram:8209218742
python scripts/air_quality.py alert-check --user telegram:8209218742
```
### 5) 아침 브리핑
```bash
python scripts/air_quality.py morning-brief 답십리동
python scripts/air_quality.py morning-brief --user telegram:8209218742
```
### 6) OpenClaw cron 초안 생성
```bash
python scripts/air_quality.py cron-plan morning-brief telegram:8209218742 --region 답십리동 --hour 7 --minute 30 --json
python scripts/air_quality.py cron-plan alert-check telegram:8209218742 --json
```
이 출력은 OpenClaw `cron add`에 넣기 쉬운 job 초안으로 쓰는 걸 전제로 한다.
### 7) provider 설정
```bash
python scripts/air_quality.py setup-provider airkorea --airkorea-api-key "YOUR_KEY" --json
python scripts/air_quality.py show-config --json
python scripts/air_quality.py now 답십리동 --provider airkorea
```
## AirKorea OpenAPI 키 발급 받기
AirKorea 실측값을 쓰려면 공공데이터포털에서 OpenAPI 활용신청 후 서비스키를 발급받아야 한다.
권장 순서:
1. 공공데이터포털(data.go.kr)에 로그인
2. AirKorea/대기질 관련 API 페이지로 이동
- 실시간 대기질 조회 계열: `ArpltnInforInqireSvc`
- 측정소 정보 계열: `MsrstnInfoInqireSvc`
3. 원하는 API에 대해 **활용신청** 진행
4. 승인 후 **일반 인증키**를 확인
- Encoding / Decoding 중 하나를 사용할 수 있지만, 구현/환경에 따라 차이가 날 수 있으니 둘 다 확인해 두는 것을 권장
5. 아래처럼 스킬에 저장
```bash
python scripts/air_quality.py setup-provider airkorea --airkorea-api-key "발급받은서비스키" --json
```
확인:
```bash
python scripts/air_quality.py show-config --json
python scripts/air_quality.py now 답십리동 --provider airkorea
```
참고:
- 활용신청 직후에는 권한 반영이 바로 안 될 수 있다.
- `ArpltnInforInqireSvc` 와 `MsrstnInfoInqireSvc` 는 같은 에어코리아 계열이지만 서비스군이 다를 수 있어, 필요한 API별로 신청 상태를 확인하는 것이 안전하다.
## 지역 해석 예시
```bash
python scripts/air_quality.py resolve-region 답십리동 --json
python scripts/air_quality.py resolve-region 판교 --json
```
## OpenClaw 자동화 흐름
이 저장소는 단발 조회보다도 **OpenClaw 자동화**에 잘 맞는다.
예:
- `초미세먼지 나쁨 이상이면 알려줘`
- `alert-add`로 규칙 저장
- `cron-plan alert-check ...` 로 cron 초안 생성
- OpenClaw cron에 연결해 주기 점검
- `매일 아침 7시 30분에 답십리동 브리핑`
- `morning-brief`
- `cron-plan morning-brief ...`
- OpenClaw cron으로 매일 전달
## 로컬 상태 파일 정책
이 저장소는 로컬 상태를 `data/` 아래에 저장한다.
예:
- `data/user-preferences.json`
- `data/alert-rules.json`
- `data/alert-state.json`
- `data/station-cache.json`
이 파일들은 **사용자별 상태 / 캐시 / 알림 이력**이라서 기본적으로 `.gitignore` 대상이다.
즉, 저장소에는 스킬 코드와 문서 중심으로 남기고, 개인 상태는 로컬에 남기는 방식을 권장한다.
## 지금 남아 있는 큰 작업
1. AirKorea 실 API 키 기준 실측 응답 검증/미세 조정
2. 텔레그램 위치 공유 메시지에서 위경도 자동 흡수
3. OpenClaw cron 실제 등록을 상위 래퍼로 더 자동화
4. weather 스킬과 더 자연스럽게 결합한 생활형 브리핑 고도화
FILE:references/alert-and-briefing.md
# Alert and Morning Briefing
## Alert rule concept
대기질 감시는 지역 + 항목 + 임계 등급 조합으로 저장한다.
예시:
- 성동구 / pm2_5 / 나쁨 이상
- 서울 / pm10 / 매우나쁨 이상
- 분당 / overall / 나쁨 이상
## Rule storage
`data/alert-rules.json` 에 단순 JSON 배열로 저장한다.
권장 필드:
- id
- user
- region
- metric (`pm2_5`, `pm10`, `overall`)
- threshold (`좋음`, `보통`, `나쁨`, `매우나쁨`)
- created_at
## Morning briefing
아침 브리핑은 날씨 + 대기질을 합쳐 짧게 정리한다.
권장 순서:
1. 지역
2. 현재/오늘 기온 흐름
3. 하늘/강수 요약
4. PM2.5 / PM10 / 종합 판단
5. 외출 팁 한 줄
## OpenClaw usage
- 단발 조회: `now`, `morning-brief`
- 반복 알림: `alert-add` 후 OpenClaw cron으로 `alert-check`
- 알림 메시지는 새 rule hit만 전달하거나, 조건 충족 시마다 전달하는 정책을 나중에 선택할 수 있다.
FILE:references/api-and-product-plan.md
# Korea Air Quality Skill Plan
## 1. 목표
이 스킬은 대한민국 대기질 정보를 OpenClaw에서 자연어로 조회·브리핑·알림할 수 있게 만드는 것을 목표로 한다.
핵심 사용자 가치:
- 한국 지역명으로 바로 조회 가능
- 현재 위치 또는 기본 지역 반영
- 미세먼지/초미세먼지/오존 같은 핵심 지표를 이해하기 쉽게 요약
- 나쁨 이상 알림과 아침 브리핑 자동화
## 2. 추천 MVP 범위
### 포함
- 지역명 기반 실시간 대기질 조회
- PM10 / PM2.5 / O3 / 통합 등급 요약
- 오늘 예보 또는 주의 문구
- 기본 지역 저장/삭제
- 나쁨 이상 alert rule 저장
- 아침 브리핑용 요약 포맷
### 제외 또는 후순위
- 고급 지도 시각화
- 정밀한 실시간 좌표 역지오코딩
- 다중 소스 결합 분석
- 장기 통계 리포트
## 3. 데이터 소스 방향
현재 구현은 **Open-Meteo 대기질 API + Open-Meteo geocoding** 기반으로 동작한다. 장점은 API 키 없이 빠르게 실사용 프로토타입을 만들 수 있다는 점이다.
중장기적으로는 에어코리아/공공데이터포털 계열 API를 추가 검토하는 것이 좋다.
필요한 데이터 유형:
- 측정소별 실시간 대기질
- 시도/권역 예보
- 측정소 메타데이터(이름, 좌표, 지역)
실제 구현 시 확인할 것:
- 인증 방식 (service key 등)
- 지역명/시군구/측정소 검색 가능 여부
- 응답 포맷(JSON/XML)
- 호출 제한
- Open-Meteo와 국내 소스의 지표/등급 기준 차이
## 4. 지역 해석 전략
우선순위:
1. 위치 공유 좌표
2. 저장된 기본 지역
3. 메시지 안의 지역명
4. 모르면 재질문
실제 로직:
- 좌표가 있으면 가장 가까운 측정소를 찾는다.
- 지역명만 있으면 지역 alias 사전을 먼저 확인하고 측정소 후보를 찾는다.
- `분당`, `판교`, `잠실`, `영통` 같은 생활권 표현을 보조 alias로 관리한다.
## 5. 저장 파일 제안
- `data/user-preferences.json`
- 사용자별 기본 지역
- `data/alert-rules.json`
- 지역, 항목, 임계치, 스케줄
- `data/station-cache.json`
- 측정소/지역 캐시
## 6. OpenClaw 연동 포인트
### 직접 조회
- `지금 서울 공기질 어때?`
- `성동구 초미세먼지 알려줘`
### 위치 기반
- `내 위치 기준으로 알려줘`
- Telegram/채널에서 좌표가 있으면 그걸 우선
### 감시
- `초미세먼지 나쁨 이상이면 알려줘`
- watch 규칙 저장 후 cron 점검
### 브리핑
- `매일 아침 7시 오늘 대기질 요약해줘`
- 필요하면 weather 스킬과 조합 가능
## 7. 구현 순서 제안
1. 지역명 입력 -> 내부 지역 표현 정규화
2. 실시간 조회 command 완성
3. 저장된 기본 지역 적용
4. 예보/행동 문구 추가
5. alert rule + cron 연결
6. 좌표 기반 nearest station 추가
## 8. 예시 사용자 요청
- `오늘 서울 미세먼지 어때?`
- `지금 우리 동네 공기 어떤지 알려줘`
- `내 기본 지역을 성동구로 저장해줘`
- `초미세먼지 나쁨 이상이면 알려줘`
- `서울, 수원, 인천 비교해줘`
## 9. 예시 응답 톤
- 간결하고 실용적으로
- 수치 + 등급 + 행동 팁 중심
- 공포 조장 없이 일상형 브리핑 느낌 유지
FILE:references/domestic-api-notes.md
# Domestic API Notes
## 목표
Open-Meteo 기반 프로토타입 이후, 국내 대기질 API를 붙일 때 필요한 연결 레이어를 정리한다.
## 우선 후보
- 에어코리아 / 공공데이터포털 계열 API
- 국내 측정소/권역 예보 데이터
## 구현 방향
현재 스킬은 `provider` 개념을 도입해 `openmeteo` 와 `airkorea` 같은 공급자를 구분할 수 있게 확장되어 있다.
현재 상태:
1. `AIRKOREA_API_KEY` 또는 `data/config.json`의 `airkorea_api_key` 를 읽는다.
2. AirKorea 시도 단위 실시간 측정 API를 호출한다.
3. JSON 응답을 우선 시도하고, 필요하면 XML도 파싱한다.
4. 지역명과 비슷한 측정소가 있으면 우선 선택하고, 없으면 값이 있는 첫 항목을 사용한다.
5. OpenClaw summary 스키마로 매핑한다.
남은 과제:
- 측정소 좌표 기반 nearest station 정밀 매칭
- 권역 예보 API 연결
- 시도 단위 fallback보다 더 촘촘한 지역별 정확도 개선
## 필요한 설정 예시
- `AIRKOREA_API_KEY`
- 또는 `data/config.json` 내 provider 설정
## 주의점
- 국내 API는 응답 포맷이 XML 기반일 수 있어 파서 추가가 필요하다.
- 측정소 중심 데이터와 행정구역 중심 질의 사이의 매핑 레이어가 중요하다.
- 동일 지표라도 등급 기준/표현이 Open-Meteo와 다를 수 있으니 요약 전에 정규화해야 한다.
FILE:references/integration-roadmap.md
# Integration Roadmap Status
## 이미 반영된 항목
- OpenClaw 스타일 README/SKILL 정리
- Open-Meteo 기반 실사용 프로토타입
- 지역명 alias 및 동/구 fallback 강화
- 기본 지역 저장
- 위치 좌표 인자(`--lat`, `--lon`) 반영
- 대기질 alert rule 저장/점검
- 중복 알림 방지 상태 저장
- 날씨 + 대기질 아침 브리핑
- OpenClaw cron 초안 생성
- provider 레이어(`openmeteo`, `airkorea`) 준비
## 이번 단계 목표
1. 사용자 기본 위치 좌표 저장/조회
2. 기본 provider 설정 저장
3. 기본 좌표를 지역 fallback보다 우선 사용하는 흐름 강화
4. 문서에 OpenClaw 자동화 흐름을 더 명확히 반영
## 다음 실제 확장 포인트
- AIRKOREA_API_KEY 설정 후 국내 API 매핑 구현
- 채팅 플랫폼 위치 공유 메시지를 OpenClaw 상위 레이어에서 `--lat/--lon` 으로 넘기기
- `cron-plan` 결과를 실제 `cron add` 호출로 자동 연결하는 상위 래퍼 추가
FILE:references/location-resolution.md
# Location Resolution Strategy
## 목표
OpenClaw에서 사용자의 현재 위치 또는 습관적 지역을 반영해 대한민국 대기질을 조회한다.
## 우선순위
1. 사용자가 방금 보낸 위치 좌표
2. 저장된 기본 지역
3. 메시지 안의 지역명
4. 아무 정보도 없으면 짧게 재질문
## 이유
- 위치 좌표가 가장 정확하다.
- 위치 공유가 없는 채널도 많아서 기본 지역 저장이 실용적이다.
- 사용자가 직접 지역명을 말하면 저장 정보보다 우선해야 한다.
## 구현 메모
- Telegram 등에서 좌표를 받을 수 있으면 `--lat`, `--lon` 경로로 연결한다.
- 좌표 기반 nearest station은 측정소 캐시가 쌓일수록 정확도가 올라간다.
- `우리 동네`, `내 위치`, `지금 있는 곳` 같은 표현은 좌표/기본 지역 fallback으로 처리한다.
FILE:scripts/air_quality.py
from __future__ import annotations
import argparse
import json
import math
import os
import urllib.parse
import urllib.request
from urllib.error import HTTPError
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple
BASE_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = BASE_DIR / "data"
PREFERENCES_PATH = DATA_DIR / "user-preferences.json"
STATION_CACHE_PATH = DATA_DIR / "station-cache.json"
ALERT_RULES_PATH = DATA_DIR / "alert-rules.json"
ALERT_STATE_PATH = DATA_DIR / "alert-state.json"
CONFIG_PATH = DATA_DIR / "config.json"
OPENMETEO_GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
OPENMETEO_AIR_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
OPENMETEO_WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
REGION_ALIASES = {
"서울": "서울특별시",
"부산": "부산광역시",
"대구": "대구광역시",
"인천": "인천광역시",
"광주": "광주광역시",
"대전": "대전광역시",
"울산": "울산광역시",
"세종": "세종특별자치시",
"제주": "제주특별자치도",
"성동구": "서울특별시 성동구",
"강남구": "서울특별시 강남구",
"동대문구": "서울특별시 동대문구",
"답십리": "서울특별시 동대문구 답십리동",
"답십리동": "서울특별시 동대문구 답십리동",
"장안동": "서울특별시 동대문구 장안동",
"전농동": "서울특별시 동대문구 전농동",
"청량리": "서울특별시 동대문구 청량리동",
"왕십리": "서울특별시 성동구 왕십리동",
"왕십리역": "서울특별시 성동구 행당동",
"행당동": "서울특별시 성동구 행당동",
"영통": "수원시 영통구",
"수원 영통": "수원시 영통구",
"분당": "성남시 분당구",
"판교": "성남시 분당구 판교동",
"잠실": "서울특별시 송파구 잠실동",
}
STATIC_REGIONS = {
"서울특별시 성동구": {"resolved_name": "성동구", "admin1": "서울특별시", "admin2": "성동구", "admin3": None, "country": "대한민국", "latitude": 37.5636, "longitude": 127.0365, "timezone": "Asia/Seoul"},
"서울특별시 강남구": {"resolved_name": "강남구", "admin1": "서울특별시", "admin2": "강남구", "admin3": None, "country": "대한민국", "latitude": 37.5172, "longitude": 127.0473, "timezone": "Asia/Seoul"},
"서울특별시 동대문구": {"resolved_name": "동대문구", "admin1": "서울특별시", "admin2": "동대문구", "admin3": None, "country": "대한민국", "latitude": 37.5744, "longitude": 127.0396, "timezone": "Asia/Seoul"},
"서울특별시 동대문구 답십리동": {"resolved_name": "답십리동", "admin1": "서울특별시", "admin2": "동대문구", "admin3": "답십리동", "country": "대한민국", "latitude": 37.5666, "longitude": 127.0569, "timezone": "Asia/Seoul"},
"서울특별시 동대문구 장안동": {"resolved_name": "장안동", "admin1": "서울특별시", "admin2": "동대문구", "admin3": "장안동", "country": "대한민국", "latitude": 37.5707, "longitude": 127.0682, "timezone": "Asia/Seoul"},
"서울특별시 동대문구 전농동": {"resolved_name": "전농동", "admin1": "서울특별시", "admin2": "동대문구", "admin3": "전농동", "country": "대한민국", "latitude": 37.5787, "longitude": 127.0471, "timezone": "Asia/Seoul"},
"서울특별시 동대문구 청량리동": {"resolved_name": "청량리동", "admin1": "서울특별시", "admin2": "동대문구", "admin3": "청량리동", "country": "대한민국", "latitude": 37.5863, "longitude": 127.0446, "timezone": "Asia/Seoul"},
"서울특별시 성동구 왕십리동": {"resolved_name": "왕십리동", "admin1": "서울특별시", "admin2": "성동구", "admin3": "왕십리동", "country": "대한민국", "latitude": 37.5618, "longitude": 127.0372, "timezone": "Asia/Seoul"},
"서울특별시 성동구 행당동": {"resolved_name": "행당동", "admin1": "서울특별시", "admin2": "성동구", "admin3": "행당동", "country": "대한민국", "latitude": 37.5587, "longitude": 127.0351, "timezone": "Asia/Seoul"},
"수원시 영통구": {"resolved_name": "영통구", "admin1": "경기도", "admin2": "수원시 영통구", "admin3": None, "country": "대한민국", "latitude": 37.2595, "longitude": 127.0464, "timezone": "Asia/Seoul"},
"성남시 분당구": {"resolved_name": "분당구", "admin1": "경기도", "admin2": "성남시 분당구", "admin3": None, "country": "대한민국", "latitude": 37.3826, "longitude": 127.1187, "timezone": "Asia/Seoul"},
"성남시 분당구 판교동": {"resolved_name": "판교동", "admin1": "경기도", "admin2": "성남시 분당구", "admin3": "판교동", "country": "대한민국", "latitude": 37.3943, "longitude": 127.1112, "timezone": "Asia/Seoul"},
"서울특별시 송파구 잠실동": {"resolved_name": "잠실동", "admin1": "서울특별시", "admin2": "송파구", "admin3": "잠실동", "country": "대한민국", "latitude": 37.5110, "longitude": 127.0811, "timezone": "Asia/Seoul"},
}
DISTRICT_FALLBACKS = {
"답십리동": "서울특별시 동대문구 답십리동",
"장안동": "서울특별시 동대문구 장안동",
"전농동": "서울특별시 동대문구 전농동",
"청량리동": "서울특별시 동대문구 청량리동",
"왕십리동": "서울특별시 성동구 왕십리동",
"행당동": "서울특별시 성동구 행당동",
"동대문구": "서울특별시 동대문구",
}
GRADE_ORDER = {"좋음": 0, "보통": 1, "나쁨": 2, "매우나쁨": 3, "정보 없음": -1}
WEATHER_CODES = {0: "맑음", 1: "대체로 맑음", 2: "부분적으로 흐림", 3: "흐림", 45: "안개", 48: "서리 안개", 51: "이슬비", 53: "이슬비", 55: "강한 이슬비", 61: "비", 63: "비", 65: "강한 비", 71: "눈", 73: "눈", 75: "강한 눈", 80: "소나기", 81: "소나기", 82: "강한 소나기", 95: "뇌우"}
def ensure_data_dir() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
def _read_json(path: Path, default: Dict[str, Any]) -> Dict[str, Any]:
ensure_data_dir()
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
ensure_data_dir()
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def load_preferences() -> Dict[str, Any]:
return _read_json(PREFERENCES_PATH, {"users": {}})
def save_preferences(payload: Dict[str, Any]) -> None:
_write_json(PREFERENCES_PATH, payload)
def load_station_cache() -> Dict[str, Any]:
return _read_json(STATION_CACHE_PATH, {"regions": {}})
def save_station_cache(payload: Dict[str, Any]) -> None:
_write_json(STATION_CACHE_PATH, payload)
def load_alert_rules() -> Dict[str, Any]:
return _read_json(ALERT_RULES_PATH, {"rules": []})
def save_alert_rules(payload: Dict[str, Any]) -> None:
_write_json(ALERT_RULES_PATH, payload)
def load_alert_state() -> Dict[str, Any]:
return _read_json(ALERT_STATE_PATH, {"lastHits": {}})
def save_alert_state(payload: Dict[str, Any]) -> None:
_write_json(ALERT_STATE_PATH, payload)
def load_config() -> Dict[str, Any]:
return _read_json(CONFIG_PATH, {"provider": "openmeteo", "airkorea_api_key": None})
def save_config(payload: Dict[str, Any]) -> None:
_write_json(CONFIG_PATH, payload)
def fetch_text(url: str, params: Dict[str, Any]) -> str:
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
req = urllib.request.Request(f"{url}?{query}", headers={"User-Agent": "OpenClaw-Korea-Air-Quality/0.5"})
with urllib.request.urlopen(req, timeout=20) as response:
return response.read().decode("utf-8")
def fetch_json(url: str, params: Dict[str, Any]) -> Dict[str, Any]:
return json.loads(fetch_text(url, params))
def parse_xml_items(xml_text: str) -> List[Dict[str, Any]]:
root = ET.fromstring(xml_text)
items: List[Dict[str, Any]] = []
for item in root.findall('.//item'):
row: Dict[str, Any] = {}
for child in item:
row[child.tag] = (child.text or '').strip()
items.append(row)
return items
def _to_float(value: Any) -> float | None:
try:
if value in (None, "", "-", "null"):
return None
return float(value)
except Exception:
return None
def normalize_region_name(region: str) -> str:
cleaned = " ".join((region or "").strip().split())
return REGION_ALIASES.get(cleaned, cleaned)
def resolve_provider(explicit_provider: str | None) -> str:
if explicit_provider:
return explicit_provider
return load_config().get("provider", "openmeteo")
def _airkorea_sido_name(region: Dict[str, Any] | None) -> str:
admin1 = (region or {}).get("admin1") or "서울특별시"
mapping = {
"서울특별시": "서울",
"부산광역시": "부산",
"대구광역시": "대구",
"인천광역시": "인천",
"광주광역시": "광주",
"대전광역시": "대전",
"울산광역시": "울산",
"세종특별자치시": "세종",
"제주특별자치도": "제주",
"경기도": "경기",
"강원특별자치도": "강원",
"충청북도": "충북",
"충청남도": "충남",
"전북특별자치도": "전북",
"전라남도": "전남",
"경상북도": "경북",
"경상남도": "경남",
}
return mapping.get(admin1, admin1.replace("특별시", "").replace("광역시", "").replace("특별자치도", "").replace("특별자치시", "").replace("도", ""))
def fetch_airkorea_air_quality(lat: float, lon: float, timezone: str = "Asia/Seoul", region: Dict[str, Any] | None = None) -> Dict[str, Any]:
cfg = load_config()
api_key = os.getenv("AIRKOREA_API_KEY") or cfg.get("airkorea_api_key")
if not api_key:
raise ValueError("AIRKOREA_API_KEY 또는 config.json의 airkorea_api_key 가 없어 airkorea provider를 사용할 수 없습니다. 현재는 openmeteo provider를 사용하세요.")
service_key = urllib.parse.unquote(str(api_key))
sido_name = _airkorea_sido_name(region)
url = "https://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getCtprvnRltmMesureDnsty"
params = {
"serviceKey": service_key,
"returnType": "json",
"numOfRows": 100,
"pageNo": 1,
"sidoName": sido_name,
"ver": "1.0",
}
payload: Dict[str, Any] | None = None
items: List[Dict[str, Any]] = []
try:
payload = fetch_json(url, params)
items = (((payload or {}).get("response") or {}).get("body") or {}).get("items") or []
except HTTPError as exc:
if exc.code == 401:
raise ValueError(
"AirKorea 인증은 됐지만 현재 호출한 서비스(ArpltnInforInqireSvc)에 대한 권한이 없을 수 있습니다. "
"현재 발급 화면상 승인된 엔드포인트는 MsrstnInfoInqireSvc 계열로 보입니다. "
"공공데이터포털에서 ArpltnInforInqireSvc 사용 신청/승인 여부를 확인하거나, 승인된 서비스군에 맞춰 측정소 정보 API부터 연결하세요."
) from exc
try:
xml_text = fetch_text(url, {k: v for k, v in params.items() if k != "returnType"})
items = parse_xml_items(xml_text)
except HTTPError as xml_exc:
if xml_exc.code == 401:
raise ValueError(
"AirKorea API 호출이 401 Unauthorized로 거부되었습니다. 현재 서비스키가 MsrstnInfoInqireSvc 전용이거나, "
"ArpltnInforInqireSvc에 대한 활용신청이 아직 반영되지 않았을 가능성이 큽니다."
) from xml_exc
raise
except Exception:
xml_text = fetch_text(url, {k: v for k, v in params.items() if k != "returnType"})
items = parse_xml_items(xml_text)
if not items:
raise ValueError(f"AirKorea 응답에서 측정 항목을 찾지 못했습니다: sido={sido_name}")
preferred_station = ((region or {}).get("resolved_name") or "").replace("동", "").replace("구", "")
chosen = None
if preferred_station:
for item in items:
station_name = str(item.get("stationName") or "")
if preferred_station and preferred_station in station_name:
chosen = item
break
if chosen is None:
for item in items:
if item.get("pm25Value") not in (None, "", "-") or item.get("pm10Value") not in (None, "", "-"):
chosen = item
break
if chosen is None:
chosen = items[0]
return {
"time": chosen.get("dataTime") or chosen.get("dataTm"),
"pm10": _to_float(chosen.get("pm10Value")),
"pm2_5": _to_float(chosen.get("pm25Value")),
"ozone": _to_float(chosen.get("o3Value")),
"us_aqi": None,
"european_aqi": None,
"provider": "airkorea",
"station_name": chosen.get("stationName"),
"sido_name": sido_name,
}
def geocode_region(region: str) -> Dict[str, Any]:
normalized = normalize_region_name(region)
cache = load_station_cache()
cached = cache.setdefault("regions", {}).get(normalized)
if cached:
return cached
if normalized in STATIC_REGIONS:
resolved = {"query": region, **STATIC_REGIONS[normalized]}
cache.setdefault("regions", {})[normalized] = resolved
save_station_cache(cache)
return resolved
fallback = DISTRICT_FALLBACKS.get(normalized)
if fallback and fallback in STATIC_REGIONS:
resolved = {"query": region, **STATIC_REGIONS[fallback]}
cache.setdefault("regions", {})[normalized] = resolved
save_station_cache(cache)
return resolved
payload = fetch_json(OPENMETEO_GEOCODING_URL, {"name": normalized, "count": 5, "language": "ko", "format": "json", "countryCode": "KR"})
results = payload.get("results") or []
if not results and " " in normalized:
payload = fetch_json(OPENMETEO_GEOCODING_URL, {"name": normalized.split()[-1], "count": 5, "language": "ko", "format": "json", "countryCode": "KR"})
results = payload.get("results") or []
if not results and normalized.endswith(("동", "읍", "면", "구")):
trimmed = normalized[:-1]
payload = fetch_json(OPENMETEO_GEOCODING_URL, {"name": trimmed, "count": 5, "language": "ko", "format": "json", "countryCode": "KR"})
results = payload.get("results") or []
if not results:
raise ValueError(f"대한민국 지역 후보를 찾지 못했습니다: {region}")
best = results[0]
resolved = {"query": region, "resolved_name": best.get("name") or normalized, "admin1": best.get("admin1"), "admin2": best.get("admin2"), "admin3": best.get("admin3"), "country": best.get("country"), "latitude": best["latitude"], "longitude": best["longitude"], "timezone": best.get("timezone", "Asia/Seoul")}
cache.setdefault("regions", {})[normalized] = resolved
save_station_cache(cache)
return resolved
def nearest_known_region(lat: float, lon: float) -> Dict[str, Any]:
cache = load_station_cache().get("regions", {})
if not cache:
raise ValueError("좌표 기반 추정에 사용할 지역 캐시가 없습니다. 먼저 지역명 조회를 한 번 수행하세요.")
best_item = None
best_distance = float("inf")
for item in cache.values():
distance = math.hypot(float(item["latitude"]) - lat, float(item["longitude"]) - lon)
if distance < best_distance:
best_distance = distance
best_item = item
if not best_item:
raise ValueError("좌표 기반 지역 추정에 실패했습니다.")
return dict(best_item, matched_by="cached-nearest")
def fetch_openmeteo_air_quality(lat: float, lon: float, timezone: str = "Asia/Seoul") -> Dict[str, Any]:
payload = fetch_json(OPENMETEO_AIR_URL, {"latitude": lat, "longitude": lon, "timezone": timezone, "current": "pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone,us_aqi,european_aqi", "forecast_days": 1})
current = payload.get("current") or {}
return {"time": current.get("time"), "pm10": current.get("pm10"), "pm2_5": current.get("pm2_5"), "ozone": current.get("ozone"), "us_aqi": current.get("us_aqi"), "european_aqi": current.get("european_aqi"), "provider": "openmeteo"}
def fetch_air_quality(lat: float, lon: float, timezone: str = "Asia/Seoul", provider: str | None = None, region: Dict[str, Any] | None = None) -> Dict[str, Any]:
resolved_provider = resolve_provider(provider)
if resolved_provider == "airkorea":
return fetch_airkorea_air_quality(lat, lon, timezone, region=region)
return fetch_openmeteo_air_quality(lat, lon, timezone)
def fetch_weather(lat: float, lon: float, timezone: str = "Asia/Seoul") -> Dict[str, Any]:
payload = fetch_json(OPENMETEO_WEATHER_URL, {"latitude": lat, "longitude": lon, "timezone": timezone, "current": "temperature_2m,apparent_temperature,weather_code", "daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code", "forecast_days": 1})
current = payload.get("current") or {}
daily = payload.get("daily") or {}
return {"current_temp": current.get("temperature_2m"), "apparent_temp": current.get("apparent_temperature"), "current_weather_code": current.get("weather_code"), "today_max": (daily.get("temperature_2m_max") or [None])[0], "today_min": (daily.get("temperature_2m_min") or [None])[0], "precipitation_probability_max": (daily.get("precipitation_probability_max") or [None])[0], "today_weather_code": (daily.get("weather_code") or [None])[0]}
def grade_pm25(value: float | None) -> str:
if value is None:
return "정보 없음"
if value <= 15:
return "좋음"
if value <= 35:
return "보통"
if value <= 75:
return "나쁨"
return "매우나쁨"
def grade_pm10(value: float | None) -> str:
if value is None:
return "정보 없음"
if value <= 30:
return "좋음"
if value <= 80:
return "보통"
if value <= 150:
return "나쁨"
return "매우나쁨"
def overall_grade(pm10: float | None, pm25: float | None) -> str:
grades = [grade_pm10(pm10), grade_pm25(pm25)]
ranked = [g for g in grades if g in ("좋음", "보통", "나쁨", "매우나쁨")]
return max(ranked, key=lambda g: GRADE_ORDER[g]) if ranked else "정보 없음"
def action_tip(overall: str) -> str:
if overall == "좋음":
return "야외 활동과 환기를 무난하게 해도 괜찮은 편이에요."
if overall == "보통":
return "일반 활동은 무난하지만 민감군은 장시간 야외활동을 조금 조심하는 게 좋아요."
if overall == "나쁨":
return "장시간 야외활동은 줄이고, 외출 시 마스크를 챙기는 편이 좋아요."
if overall == "매우나쁨":
return "가급적 실외활동을 줄이고, 환기는 짧게 하며 마스크 착용을 권장해요."
return "추가 데이터 확인이 필요해요."
def weather_text(code: int | None) -> str:
return WEATHER_CODES.get(code or -1, "날씨 정보 없음")
def resolve_region(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]:
if getattr(args, "lat", None) is not None and getattr(args, "lon", None) is not None:
region = nearest_known_region(args.lat, args.lon)
return region, "location"
if getattr(args, "region", None):
return geocode_region(args.region), "query"
if getattr(args, "user", None):
prefs = load_preferences()
user_info = prefs.get("users", {}).get(args.user, {})
if user_info.get("default_location"):
lat = float(user_info["default_location"]["lat"])
lon = float(user_info["default_location"]["lon"])
region = nearest_known_region(lat, lon)
return region, "saved-location"
if user_info.get("default_region"):
return geocode_region(user_info["default_region"]), "saved-default"
raise ValueError("지역을 확인할 수 없습니다. 지역명을 입력하거나 저장된 기본 지역/좌표를 사용하세요.")
def build_summary(region: Dict[str, Any], air: Dict[str, Any], source: str) -> Dict[str, Any]:
pm10 = air.get("pm10")
pm25 = air.get("pm2_5")
overall = overall_grade(pm10, pm25)
return {"resolved_region": region.get("resolved_name"), "admin1": region.get("admin1"), "admin2": region.get("admin2"), "latitude": region.get("latitude"), "longitude": region.get("longitude"), "resolved_by": source, "measured_at": air.get("time"), "provider": air.get("provider", "unknown"), "pm10": {"value": pm10, "grade": grade_pm10(pm10)}, "pm2_5": {"value": pm25, "grade": grade_pm25(pm25)}, "ozone": {"value": air.get("ozone")}, "overall_grade": overall, "action_tip": action_tip(overall)}
def render_text(summary: Dict[str, Any]) -> str:
region_line = summary["resolved_region"]
if summary.get("admin1") and summary["admin1"] != summary["resolved_region"]:
region_line = f"{summary['admin1']} {summary['resolved_region']}"
return "\n".join([f"{region_line} 기준 대기질이야.", f"- 측정 시각: {summary.get('measured_at') or '정보 없음'}", f"- 초미세먼지(PM2.5): {summary['pm2_5']['value']} μg/m³ · {summary['pm2_5']['grade']}", f"- 미세먼지(PM10): {summary['pm10']['value']} μg/m³ · {summary['pm10']['grade']}", f"- 오존: {summary['ozone']['value']}", f"- 종합 판단: {summary['overall_grade']}", f"- 한줄 팁: {summary['action_tip']}", f"- 지역 결정 방식: {summary['resolved_by']}", f"- 공급자: {summary['provider']}"])
def build_morning_brief(summary: Dict[str, Any], weather: Dict[str, Any]) -> Dict[str, Any]:
return {"region": f"{summary.get('admin1') or ''} {summary['resolved_region']}".strip(), "measured_at": summary.get("measured_at"), "weather": {"current_temp": weather.get("current_temp"), "apparent_temp": weather.get("apparent_temp"), "today_max": weather.get("today_max"), "today_min": weather.get("today_min"), "summary": weather_text(weather.get("today_weather_code")), "precipitation_probability_max": weather.get("precipitation_probability_max")}, "air": summary, "brief": f"오늘 {summary['resolved_region']}은 {weather_text(weather.get('today_weather_code'))}, 기온 {weather.get('today_min')}~{weather.get('today_max')}°C 정도고 초미세먼지는 {summary['pm2_5']['grade']}, 미세먼지는 {summary['pm10']['grade']} 수준이야. {summary['action_tip']}"}
def grade_value_for_metric(summary: Dict[str, Any], metric: str) -> str:
if metric == "pm2_5":
return summary["pm2_5"]["grade"]
if metric == "pm10":
return summary["pm10"]["grade"]
return summary["overall_grade"]
def alert_matches(summary: Dict[str, Any], metric: str, threshold: str) -> bool:
current_grade = grade_value_for_metric(summary, metric)
return GRADE_ORDER.get(current_grade, -1) >= GRADE_ORDER.get(threshold, 99)
def build_hit_signature(rule: Dict[str, Any], summary: Dict[str, Any]) -> str:
metric = rule["metric"]
grade = grade_value_for_metric(summary, metric)
measured_at = summary.get("measured_at") or "unknown"
return f"{rule['region']}|{metric}|{grade}|{measured_at}"
def build_cron_plan(kind: str, user: str, region: str | None, metric: str | None, threshold: str | None, hour: int | None, minute: int | None) -> Dict[str, Any]:
if kind == "morning-brief":
schedule_expr = f"{minute or 0} {hour or 7} * * *"
command = f"python scripts/air_quality.py morning-brief {region or ''} --user {user} --provider airkorea".strip()
message = f"C:\\Users\\김태완\\.openclaw\\workspace\\skills\\korea-air-quality 에서 `{command}` 를 실행해. 결과를 한국어로 그대로 전달해."
name = f"대기질 아침 브리핑 ({user})"
else:
schedule_expr = f"{minute or 0} * * * *"
command = f"python scripts/air_quality.py alert-check --user {user} --json --provider airkorea"
message = f"C:\\Users\\김태완\\.openclaw\\workspace\\skills\\korea-air-quality 에서 `{command}` 를 실행해. 신규 hit만 한국어로 알려줘. 신규 hit가 없으면 정확히 NO_REPLY 만 출력해."
name = f"대기질 알림 점검 ({user})"
return {"name": name, "schedule": {"kind": "cron", "expr": schedule_expr, "tz": "Asia/Seoul"}, "payload": {"kind": "agentTurn", "message": message, "timeoutSeconds": 120}, "sessionTarget": "current", "delivery": {"mode": "announce"}, "notes": {"kind": kind, "region": region, "metric": metric, "threshold": threshold}}
def cmd_now(args: argparse.Namespace) -> int:
region, source = resolve_region(args)
air = fetch_air_quality(float(region["latitude"]), float(region["longitude"]), region.get("timezone", "Asia/Seoul"), provider=args.provider, region=region)
summary = build_summary(region, air, source)
print(json.dumps(summary, ensure_ascii=False, indent=2) if args.json else render_text(summary))
return 0
def cmd_morning_brief(args: argparse.Namespace) -> int:
region, source = resolve_region(args)
air = fetch_air_quality(float(region["latitude"]), float(region["longitude"]), region.get("timezone", "Asia/Seoul"), provider=args.provider, region=region)
summary = build_summary(region, air, source)
weather = fetch_weather(float(region["latitude"]), float(region["longitude"]), region.get("timezone", "Asia/Seoul"))
brief = build_morning_brief(summary, weather)
if args.json:
print(json.dumps(brief, ensure_ascii=False, indent=2))
else:
print(f"{brief['region']} 아침 브리핑")
print(f"- 날씨: {brief['weather']['summary']} / {brief['weather']['today_min']}~{brief['weather']['today_max']}°C")
print(f"- 강수 확률: {brief['weather']['precipitation_probability_max']}%")
print(f"- 초미세먼지: {summary['pm2_5']['value']} μg/m³ · {summary['pm2_5']['grade']}")
print(f"- 미세먼지: {summary['pm10']['value']} μg/m³ · {summary['pm10']['grade']}")
print(f"- 종합 판단: {summary['overall_grade']}")
print(f"- 한줄 요약: {brief['brief']}")
return 0
def cmd_alert_add(args: argparse.Namespace) -> int:
rules_payload = load_alert_rules()
rules = rules_payload.setdefault("rules", [])
rule = {"id": len(rules) + 1, "user": args.user, "region": args.region, "metric": args.metric, "threshold": args.threshold, "created_at": datetime.now().isoformat(timespec="seconds")}
rules.append(rule)
save_alert_rules(rules_payload)
print(json.dumps(rule, ensure_ascii=False, indent=2) if args.json else f"알림 규칙 저장 완료: {args.user} / {args.region} / {args.metric} / {args.threshold} 이상")
return 0
def cmd_alert_list(args: argparse.Namespace) -> int:
rules = load_alert_rules().get("rules", [])
if args.user:
rules = [rule for rule in rules if rule.get("user") == args.user]
if args.json:
print(json.dumps(rules, ensure_ascii=False, indent=2))
return 0
if not rules:
print("등록된 알림 규칙이 없습니다.")
return 0
for rule in rules:
print(f"- #{rule['id']} {rule['user']} / {rule['region']} / {rule['metric']} / {rule['threshold']} 이상")
return 0
def cmd_alert_check(args: argparse.Namespace) -> int:
rules = load_alert_rules().get("rules", [])
state = load_alert_state()
last_hits = state.setdefault("lastHits", {})
if args.user:
rules = [rule for rule in rules if rule.get("user") == args.user]
hits = []
for rule in rules:
region = geocode_region(rule["region"])
air = fetch_air_quality(float(region["latitude"]), float(region["longitude"]), region.get("timezone", "Asia/Seoul"), provider=args.provider, region=region)
summary = build_summary(region, air, "alert-check")
if not alert_matches(summary, rule["metric"], rule["threshold"]):
continue
signature = build_hit_signature(rule, summary)
last_signature = last_hits.get(str(rule["id"]))
if not args.emit_all and signature == last_signature:
continue
last_hits[str(rule["id"])] = signature
hits.append({"rule": rule, "summary": summary, "current_grade": grade_value_for_metric(summary, rule["metric"]), "signature": signature})
save_alert_state(state)
if args.json:
print(json.dumps(hits, ensure_ascii=False, indent=2))
return 0
if getattr(args, "announce_text", False):
if not hits:
print("NO_REPLY")
return 0
hit = hits[0]
summary = hit["summary"]
rule = hit["rule"]
measured_at = summary.get("measured_at") or "측정 시각 없음"
print(
f"{rule['region']} 초미세먼지 알림: 현재 등급 {hit['current_grade']}(기준: {rule['threshold']} 이상), "
f"초미세먼지 {summary['pm2_5']['value']}㎍/㎥ / 미세먼지 {summary['pm10']['value']}㎍/㎥, 측정 시각 {measured_at}."
)
return 0
if not hits:
print("현재 조건을 만족하는 신규 알림 항목이 없습니다.")
return 0
for hit in hits:
print(f"- {hit['rule']['region']} / {hit['rule']['metric']} / 현재 {hit['current_grade']} / 기준 {hit['rule']['threshold']} 이상")
print(f" 초미세먼지 {hit['summary']['pm2_5']['value']}({hit['summary']['pm2_5']['grade']}), 미세먼지 {hit['summary']['pm10']['value']}({hit['summary']['pm10']['grade']})")
return 0
def cmd_cron_plan(args: argparse.Namespace) -> int:
plan = build_cron_plan(args.kind, args.user, args.region, args.metric, args.threshold, args.hour, args.minute)
print(json.dumps(plan, ensure_ascii=False, indent=2) if args.json else f"권장 cron expr: {plan['schedule']['expr']}\njob name: {plan['name']}")
return 0
def cmd_save_default(args: argparse.Namespace) -> int:
prefs = load_preferences()
users = prefs.setdefault("users", {})
info = users.setdefault(args.user, {})
info["default_region"] = args.region
save_preferences(prefs)
print(f"기본 지역 저장 완료: {args.user} -> {args.region}")
return 0
def cmd_save_location(args: argparse.Namespace) -> int:
prefs = load_preferences()
users = prefs.setdefault("users", {})
info = users.setdefault(args.user, {})
info["default_location"] = {"lat": args.lat, "lon": args.lon, "label": args.label}
save_preferences(prefs)
print(f"기본 위치 저장 완료: {args.user} -> ({args.lat}, {args.lon})" + (f" / {args.label}" if args.label else ""))
return 0
def cmd_show_default(args: argparse.Namespace) -> int:
prefs = load_preferences()
info = prefs.get("users", {}).get(args.user, {})
payload = {"user": args.user, "default_region": info.get("default_region"), "default_location": info.get("default_location")}
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else (json.dumps(payload, ensure_ascii=False) if info else "저장된 기본 설정이 없습니다."))
return 0
def cmd_setup_provider(args: argparse.Namespace) -> int:
cfg = load_config()
cfg["provider"] = args.provider
if args.airkorea_api_key is not None:
cfg["airkorea_api_key"] = args.airkorea_api_key
save_config(cfg)
print(json.dumps(cfg, ensure_ascii=False, indent=2) if args.json else f"기본 provider 저장 완료: {args.provider}")
return 0
def cmd_show_config(args: argparse.Namespace) -> int:
cfg = load_config()
redacted = dict(cfg)
if redacted.get("airkorea_api_key"):
redacted["airkorea_api_key"] = "***configured***"
print(json.dumps(redacted, ensure_ascii=False, indent=2) if args.json else f"provider={redacted.get('provider')} airkorea_api_key={redacted.get('airkorea_api_key')}")
return 0
def cmd_resolve_region(args: argparse.Namespace) -> int:
region = geocode_region(args.region)
print(json.dumps(region, ensure_ascii=False, indent=2) if args.json else f"{args.region} -> {region['resolved_name']} ({region['latitude']}, {region['longitude']})")
return 0
def cmd_compare(args: argparse.Namespace) -> int:
results = []
for region_name in args.regions:
region = geocode_region(region_name)
air = fetch_air_quality(float(region["latitude"]), float(region["longitude"]), region.get("timezone", "Asia/Seoul"), provider=args.provider, region=region)
results.append(build_summary(region, air, "query"))
if args.json:
print(json.dumps(results, ensure_ascii=False, indent=2))
return 0
for item in results:
print(f"- {item['resolved_region']}: PM2.5 {item['pm2_5']['value']}({item['pm2_5']['grade']}), PM10 {item['pm10']['value']}({item['pm10']['grade']}), 종합 {item['overall_grade']}")
return 0
def add_provider_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--provider", choices=["openmeteo", "airkorea"])
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Korea air quality CLI")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("now", help="현재 대기질 조회")
p.add_argument("region", nargs="?")
p.add_argument("--user")
p.add_argument("--lat", type=float)
p.add_argument("--lon", type=float)
p.add_argument("--json", action="store_true")
add_provider_args(p)
p.set_defaults(func=cmd_now)
p = sub.add_parser("morning-brief", help="날씨+대기질 아침 브리핑")
p.add_argument("region", nargs="?")
p.add_argument("--user")
p.add_argument("--lat", type=float)
p.add_argument("--lon", type=float)
p.add_argument("--json", action="store_true")
add_provider_args(p)
p.set_defaults(func=cmd_morning_brief)
p = sub.add_parser("resolve-region", help="지역명 해석/좌표 확인")
p.add_argument("region")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_resolve_region)
p = sub.add_parser("compare", help="여러 지역 대기질 비교")
p.add_argument("regions", nargs="+")
p.add_argument("--json", action="store_true")
add_provider_args(p)
p.set_defaults(func=cmd_compare)
p = sub.add_parser("save-default", help="사용자 기본 지역 저장")
p.add_argument("user")
p.add_argument("region")
p.set_defaults(func=cmd_save_default)
p = sub.add_parser("save-location", help="사용자 기본 위치 좌표 저장")
p.add_argument("user")
p.add_argument("lat", type=float)
p.add_argument("lon", type=float)
p.add_argument("--label")
p.set_defaults(func=cmd_save_location)
p = sub.add_parser("show-default", help="사용자 기본 지역/위치 조회")
p.add_argument("user")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_show_default)
p = sub.add_parser("setup-provider", help="기본 provider 및 국내 API 키 저장")
p.add_argument("provider", choices=["openmeteo", "airkorea"])
p.add_argument("--airkorea-api-key")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_setup_provider)
p = sub.add_parser("show-config", help="provider 설정 조회")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_show_config)
p = sub.add_parser("alert-add", help="대기질 알림 규칙 추가")
p.add_argument("user")
p.add_argument("region")
p.add_argument("metric", choices=["pm2_5", "pm10", "overall"])
p.add_argument("threshold", choices=["좋음", "보통", "나쁨", "매우나쁨"])
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_alert_add)
p = sub.add_parser("alert-list", help="알림 규칙 목록")
p.add_argument("--user")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_alert_list)
p = sub.add_parser("alert-check", help="알림 규칙 점검")
p.add_argument("--user")
p.add_argument("--json", action="store_true")
p.add_argument("--emit-all", action="store_true")
p.add_argument("--announce-text", action="store_true", help="cron/announce friendly plain text output")
add_provider_args(p)
p.set_defaults(func=cmd_alert_check)
p = sub.add_parser("cron-plan", help="OpenClaw cron job 초안 생성")
p.add_argument("kind", choices=["morning-brief", "alert-check"])
p.add_argument("user")
p.add_argument("--region")
p.add_argument("--metric", choices=["pm2_5", "pm10", "overall"])
p.add_argument("--threshold", choices=["좋음", "보통", "나쁨", "매우나쁨"])
p.add_argument("--hour", type=int)
p.add_argument("--minute", type=int)
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_cron_plan)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
Search, brief, and monitor Korean used-market listings across 당근마켓, 번개장터, and 중고나라. Use when the user wants 중고 매물 찾아줘, 당근/번장/중고나라 동시 검색, 아이폰/맥북 같은 물건의 신규 매물...
---
name: used-market-watch
description: Search, brief, and monitor Korean used-market listings across 당근마켓, 번개장터, and 중고나라. Use when the user wants 중고 매물 찾아줘, 당근/번장/중고나라 동시 검색, 아이폰/맥북 같은 물건의 신규 매물 감시, 가격하락 체크, 자연어 기반 한국 중고거래 브리핑, 자연어 watch rule 추가/수정, 최근 watch 이벤트 확인, 1시간마다/매일 아침 8시 같은 주기 표현이 포함된 감시 요청, or cron-friendly stdout/JSON monitoring output.
---
# Used Market Watch
한국 중고거래 매물을 **자연어 검색 / 채팅형 브리핑 / persistent watch rule / 신규·가격하락 체크 / 주기 해석 기반 운영 계획** 형태로 다루는 OpenClaw 스킬이다.
핵심 원칙:
- **chat-first**: 사람이 읽는 한국어 브리핑을 먼저 만든다.
- **json-ready**: cron/상위 레이어 연결용 JSON도 바로 뽑는다.
- **watch-state 단순화**: GUI/DB 대신 `data/watch-rules.json` 하나로 상태를 유지한다.
- **natural-language ops**: 검색과 감시 등록을 분리하지 않고 한 줄 한국어 요청에서 watch intent를 최대한 바로 해석한다.
- **plan-aware**: `1시간마다`, `30분마다`, `매일 아침 8시`, `브리핑해줘` 같은 운영 문장을 rule 메타와 실행 힌트로 변환한다.
- **upstream 계승**: `used-market-notifier`의 마켓 범위, 가격 파싱, Playwright 검색 감각, 신규/가격하락 개념을 OpenClaw용 CLI로 재구성했다.
## Source dependency / analysis
분석 기준 upstream:
- `tmp/used-market-notifier-upstream`
- public repo: `twbeatles/used-market-notifier`
핵심 참고 내용은 `references/upstream-notes.md`에 정리돼 있다.
## Scripts
### 1) 자연어 파싱 확인
```bash
python skills/used-market-watch/scripts/used_market_watch.py parse "잠실에서 아이폰 15 프로 120만원 이하 당근 번장만 -깨짐"
```
### 2) 원샷 검색 / 브리핑
```bash
python skills/used-market-watch/scripts/used_market_watch.py search "잠실에서 아이폰 15 프로 120만원 이하 당근 번장만 -깨짐"
python skills/used-market-watch/scripts/used_market_watch.py search "맥북 에어 m2 중고나라 포함" --json
```
출력 특징:
- 마켓별 개수 요약
- 대표 매물 리스트
- 링크 포함
- text / JSON 선택 가능
### 3) 자연어 watch 미리보기 / 저장
```bash
python skills/used-market-watch/scripts/used_market_watch.py watch-plan "아이폰 15 프로 1시간마다 신규만 감시해줘"
python skills/used-market-watch/scripts/used_market_watch.py watch-plan "맥북 에어 가격 내려가면 알려줘" --json
python skills/used-market-watch/scripts/used_market_watch.py watch-plan "플스5 매일 아침 8시에 브리핑해줘"
python skills/used-market-watch/scripts/used_market_watch.py integration-plan "아이폰 15 프로 신규 매물만 1시간마다 감시해줘"
python skills/used-market-watch/scripts/used_market_watch.py integration-plan "플스5 매일 아침 8시에 브리핑해줘" --json
python skills/used-market-watch/scripts/used_market_watch.py integration-plan "맥북 에어 가격 내려가면 알려줘" --persist --json
python skills/used-market-watch/scripts/used_market_watch.py watch-upsert "아이폰 15 프로 1시간마다 신규만 감시해줘"
python skills/used-market-watch/scripts/used_market_watch.py watch-upsert '"잠실 맥북 하락" 맥북 에어 m2 잠실 가격하락만 감시'
```
동작 특징:
- `신규만`, `가격하락만`, `가격 내려가면` 같은 표현을 해석한다.
- `1시간마다`, `30분마다`, `매일 아침 8시` 같은 주기 표현을 해석한다.
- `브리핑해줘`, `요약해줘` 같은 표현을 브리핑 모드로 해석한다.
- `5개`, `10건` 같은 limit 힌트를 반영한다.
- 이름을 따로 안 주면 keyword 기반 규칙 이름을 만든다.
- 같은 이름이 있으면 `watch-upsert`가 갱신한다.
- `integration-plan`은 저장 명령, 실행 명령, cron payload, systemEvent 힌트, 사용자 확인 문구까지 한 번에 묶어 준다.
- 해석 결과에 권장 실행 명령과 cron 예시를 포함한다.
### 4) watch rule 목록 / 상태 관리
```bash
python skills/used-market-watch/scripts/used_market_watch.py watch-list
python skills/used-market-watch/scripts/used_market_watch.py watch-enable "잠실 맥북 하락"
python skills/used-market-watch/scripts/used_market_watch.py watch-disable "잠실 맥북 하락"
python skills/used-market-watch/scripts/used_market_watch.py watch-remove "잠실 맥북 하락"
```
### 5) watch 점검 / 이벤트 피드
```bash
python skills/used-market-watch/scripts/used_market_watch.py watch-check
python skills/used-market-watch/scripts/used_market_watch.py watch-check --alerts-only --json
python skills/used-market-watch/scripts/used_market_watch.py watch-events --limit 20
python skills/used-market-watch/scripts/used_market_watch.py watch-events "잠실 맥북 하락" --json
```
점검 결과:
- 신규 매물(`new_listing`)
- 가격하락(`price_drop`)
- 각 rule별 snapshot 요약
- `summary.event_counts` 포함 JSON
- 최근 이벤트 조회용 `watch-events`
## Runtime notes
필수 준비:
```bash
pip install playwright
python -m playwright install chromium
```
테스트:
```bash
python -m pytest skills/used-market-watch/tests -q
```
## Stored files
- `data/watch-rules.json`: watch rule + last_seen + dedupe event state
- `references/upstream-notes.md`: upstream 분석 메모
- `dist/used-market-watch.skill`: 배포용 패키지 아티팩트
## Recommended workflow
1. 사용자가 한 줄로 원하는 물건/가격/마켓을 말하면 `search`로 먼저 브리핑한다.
2. 반복 추적이 필요하면 `watch-plan`으로 해석을 확인하거나 바로 `watch-upsert`로 규칙을 저장한다.
3. 채팅 자동화/cron 연결이 목적이면 `integration-plan`으로 저장 명령, 실행 명령, cron payload, systemEvent 힌트를 한 번에 만든다.
4. heartbeat/cron에서는 `watch-check --alerts-only --json` 또는 특정 rule 대상 `watch-check "이름" --json`을 사용한다.
5. 운영 중에는 `watch-list`, `watch-events`, `watch-enable`, `watch-disable`로 상태를 관리한다.
6. 상위 레이어(OpenClaw)가 텔레그램/디스코드 전달을 담당한다.
## Current limitations
- 각 마켓 DOM 구조 변경에 민감하다.
- 로그인 필요/봇 차단이 강한 상황에서는 결과가 줄 수 있다.
- 중고나라는 네이버 검색 결과 기반이라 가격 정보가 제한적일 수 있다.
- 현재는 Playwright 단일 경로이며 Selenium fallback은 넣지 않았다.
FILE:README.md
# used-market-watch
당근마켓, 번개장터, 중고나라를 대상으로 **중고 매물 검색 / 채팅형 브리핑 / 저장형 감시 규칙 / 신규 매물 / 가격하락 체크**를 수행하는 OpenClaw 스킬입니다.
이 스킬은 `used-market-notifier`의 핵심 아이디어를 OpenClaw 운영 흐름에 맞게 다시 묶은 버전입니다.
- 자연어로 검색하고
- 결과를 바로 브리핑하고
- 감시 규칙을 저장하고
- `watch-check`를 cron/heartbeat/메시징에 연결해 반복 운영하는 데 초점을 맞췄습니다.
## 지원 마켓
- 당근마켓
- 번개장터
- 중고나라
## 이번 버전에서 강해진 점
- `1시간마다`, `30분마다`, `매일 아침 8시` 같은 **주기 표현 해석**
- `신규만 감시`, `가격 내려가면 알려줘`, `브리핑해줘` 같은 **채팅형 의도 파싱 강화**
- `watch-plan` 출력에 **실행 주기 / 권장 명령 / cron 예시** 포함
- 저장된 rule에 **delivery_mode / schedule / plan_hints** 메타 저장
- 운영자가 `watch-list`만 봐도 **주기와 브리핑/알림 성격**을 바로 확인 가능
## 어떤 요청을 잘 받나
### 신규 매물 감시형
```text
아이폰 15 프로 1시간마다 신규만 감시해줘
```
해석 포인트:
- 감시 대상: 아이폰 15 프로
- 주기: 1시간마다
- 알림 조건: 신규만
- 출력 성격: 알림(alert)
### 가격하락 알림형
```text
맥북 에어 가격 내려가면 알려줘
```
해석 포인트:
- 감시 대상: 맥북 에어
- 주기: 수동 또는 상위 스케줄러 연결
- 알림 조건: 가격하락만
- 출력 성격: 알림(alert)
### 정기 브리핑형
```text
플스5 매일 아침 8시에 브리핑해줘
```
해석 포인트:
- 감시 대상: 플스5
- 주기: 매일 08:00
- 알림 조건: 신규 + 가격하락 기본
- 출력 성격: 브리핑(briefing)
- cron 예시: `0 8 * * * ... watch-check "플스5 감시" --json`
## 설치
```bash
clawhub install used-market-watch
```
## 준비
```bash
pip install playwright
python -m playwright install chromium
```
## 빠른 시작
### 1) 자연어 파싱 확인
```bash
python scripts/used_market_watch.py parse "잠실에서 아이폰 15 프로 120만원 이하 당근 번장만 -깨짐"
```
### 2) 원샷 검색 / 브리핑
```bash
python scripts/used_market_watch.py search "잠실에서 아이폰 15 프로 120만원 이하 당근 번장만 -깨짐"
python scripts/used_market_watch.py search "맥북 에어 m2 중고나라 포함" --json
```
### 3) 감시 규칙 해석 미리보기
```bash
python scripts/used_market_watch.py watch-plan "아이폰 15 프로 1시간마다 신규만 감시해줘"
python scripts/used_market_watch.py watch-plan "맥북 에어 가격 내려가면 알려줘" --json
python scripts/used_market_watch.py watch-plan "플스5 매일 아침 8시에 브리핑해줘"
```
### 4) OpenClaw용 자동화 연동 번들 생성
```bash
python scripts/used_market_watch.py integration-plan "아이폰 15 프로 신규 매물만 1시간마다 감시해줘"
python scripts/used_market_watch.py integration-plan "플스5 매일 아침 8시에 브리핑해줘" --json
python scripts/used_market_watch.py integration-plan "맥북 에어 가격 내려가면 알려줘" --persist --json
```
출력에 포함되는 것:
- 파싱된 감시 계획
- 실제 저장 명령(`watch-upsert`)
- 실제 실행 명령(`watch-check`)
- cron payload 제안
- systemEvent 힌트
- 사용자 확인용 짧은 한국어 문구
### 5) 감시 규칙 저장 / 업데이트
```bash
python scripts/used_market_watch.py watch-upsert "아이폰 15 프로 1시간마다 신규만 감시해줘"
python scripts/used_market_watch.py watch-upsert "맥북 에어 가격 내려가면 알려줘"
python scripts/used_market_watch.py watch-upsert "플스5 매일 아침 8시에 브리핑해줘"
```
같은 이름의 규칙이 이미 있으면 새로 만들지 않고 업데이트합니다.
이름을 고정하고 싶으면 큰따옴표로 먼저 지정하면 됩니다.
```bash
python scripts/used_market_watch.py watch-upsert '"잠실 맥북 하락" 맥북 에어 m2 잠실 가격하락만 감시'
```
### 6) 저장된 규칙 목록 확인
```bash
python scripts/used_market_watch.py watch-list
python scripts/used_market_watch.py watch-list --json
```
### 7) 실제 점검 실행
```bash
python scripts/used_market_watch.py watch-check
python scripts/used_market_watch.py watch-check --alerts-only --json
python scripts/used_market_watch.py watch-check "잠실 맥북 하락" --json
```
### 8) 최근 이벤트 피드 보기
```bash
python scripts/used_market_watch.py watch-events --limit 20
python scripts/used_market_watch.py watch-events "잠실 맥북 하락" --json
```
## 운영 패턴 추천
### 패턴 A. 검색 후 감시 등록
1. `search`로 검색 품질과 키워드를 먼저 확인
2. 원하는 조건이 맞으면 `watch-upsert`로 저장
3. 이후는 scheduler가 `watch-check`만 주기적으로 실행
### 패턴 B. 신규 매물만 짧은 주기로 추적
추천 예시:
```text
아이폰 15 프로 1시간마다 신규만 감시해줘
```
권장 연결:
- 실행: `watch-check "아이폰 15 프로 감시" --alerts-only --json`
- 용도: 텔레그램/디스코드 신규 매물 알림
### 패턴 C. 가격하락만 저소음 감시
추천 예시:
```text
맥북 에어 가격 내려가면 알려줘
```
권장 연결:
- 실행: `watch-check "맥북 에어 감시" --alerts-only --json`
- 용도: 노이즈를 줄이고 할인 신호만 받고 싶을 때
### 패턴 D. 하루 1회 아침 브리핑
추천 예시:
```text
플스5 매일 아침 8시에 브리핑해줘
```
권장 연결:
- 실행: `watch-check "플스5 감시" --json`
- 용도: 아침 요약 브리핑, 데일리 리포트, 채널 게시
## OpenClaw 채팅→자동화 연결 패턴
메인 어시스턴트가 자연어 요청을 받으면 보통 아래 순서로 쓰면 됩니다.
1. `integration-plan "사용자 요청" --json` 실행
2. `user_confirmation` 문구로 사용자에게 최종 확인
3. 확인되면 `persist.command` 또는 `integration-plan ... --persist --json`으로 규칙 저장
4. `execution.cron_payload`를 기준으로 cron/systemEvent 초안 생성
5. 실제 주기 실행에서는 `execution.recommended_command`를 호출
예시 JSON 필드:
- `parsed_plan`: 저장될 rule 원본
- `persist.command`: 실제 watch-upsert 명령
- `execution.recommended_command`: 실제 watch-check 명령
- `execution.cron_payload.expr`: cron 식
- `execution.system_event`: 상위 자동화 레이어에 넘길 힌트 객체
- `user_confirmation`: 사용자에게 보여줄 짧은 한국어 확인 문구
## cron 연결 힌트
`watch-plan`과 `integration-plan`은 해석뿐 아니라 운영 힌트를 함께 보여줍니다.
예를 들어 `플스5 매일 아침 8시에 브리핑해줘`를 넣으면 다음 정보를 얻을 수 있습니다.
- 실행 주기: `매일 08:00`
- 권장 실행: `python ... watch-check "플스5 감시" --json`
- cron 예시: `0 8 * * * python ... watch-check "플스5 감시" --json`
자주 쓰는 패턴:
```bash
python scripts/used_market_watch.py watch-check --alerts-only --json
```
- 여러 규칙의 신규/가격하락 이벤트만 모아 채팅 알림으로 보낼 때 적합
```bash
python scripts/used_market_watch.py watch-check "플스5 감시" --json
```
- 특정 규칙 브리핑을 정해진 시각에 보내고 싶을 때 적합
## JSON 출력 포인트
### `search`
- `kind=used-market-search`
- `intent`
- `summary.total`, `summary.by_market`
- `items[]`
### `watch-plan`
- `kind=used-market-watch-plan`
- `rule.delivery_mode`
- `rule.schedule`
- `rule.plan_hints.recommended_command`
- `rule.plan_hints.cron_example`
### `watch-check`
- `kind=used-market-watch-check`
- `alert_count`
- `summary.rule_count`
- `summary.rules_with_matches`
- `summary.event_counts`
- `alerts[]`
### `watch-events`
- `kind=used-market-watch-events`
- `count`
- `events[]`
## 운영 팁
- 규칙 이름을 고정하려면 큰따옴표로 먼저 이름을 주는 편이 안전합니다.
- `신규만`, `가격하락만`, `브리핑해줘` 같은 표현으로 감시 성격을 자연어로 제어할 수 있습니다.
- `5개`, `10건` 같은 limit 힌트도 자연어로 줄 수 있습니다.
- 비활성화는 삭제보다 `watch-disable`이 안전합니다.
- 상위 레이어에서는 `summary.event_counts`, `alerts`, `events`를 바로 재가공하면 됩니다.
## 테스트
```bash
python -m pytest tests -q
```
## 한계
- 실검색은 Playwright와 각 마켓 DOM 구조에 의존합니다.
- 로그인/봇 차단이 강한 경우 결과가 줄 수 있습니다.
- 중고나라는 메타데이터가 제한적일 수 있습니다.
- 현재는 Playwright 단일 경로입니다.
## 설치 / 링크
- GitHub: <https://github.com/twbeatles/openclaw-used-market-watch>
- ClawHub 홈: <https://clawhub.com>
- 설치 명령: `clawhub install used-market-watch`
## 대표 예시 요청 모음
- `잠실에서 아이폰 15 프로 120만원 이하 당근 번장만 찾아줘`
- `아이폰 15 프로 신규 매물만 1시간마다 감시해줘`
- `맥북 에어 가격 내려가면 알려줘`
- `플스5 매일 아침 8시에 브리핑해줘`
- `후지 x100 시리즈 번장 포함 -고장 -파손 조건으로 계속 체크해줘`
FILE:references/upstream-notes.md
# Upstream notes: `used-market-notifier`
이 스킬은 `tmp/used-market-notifier-upstream` 및 public repo `twbeatles/used-market-notifier`를 분석해, 다음 개념을 OpenClaw용으로 재구성했다.
## 유지한 핵심 개념
- **지원 마켓 범위**: 당근마켓 / 번개장터 / 중고나라
- **가격 정규화**: `10만`, `2만5천`, `무료나눔` 같은 한국형 가격 파싱
- **검색 후 필터링**: 가격 범위, 지역, 제외 키워드
- **신규/가격하락 감지**: article key + 이전 가격 상태를 로컬 저장소에 유지
- **Playwright 우선 수집**: 동적 마켓 검색 페이지에 맞춘 one-shot browser 세션
- **채팅/알림 친화 출력**: GUI 대신 text + JSON stdout
## 의도적으로 제거/축소한 것
- PyQt GUI 전체
- Telegram/Discord/Slack 직접 전송
- DB 기반 대시보드/백업/즐겨찾기/메시지 템플릿 UI
- 복잡한 dual-engine orchestration
## 이 스킬에서의 재해석
- OpenClaw가 상위 레이어에서 메시징/cron을 담당하므로, 이 스킬은 **검색·브리핑·watch state 계산**에 집중한다.
- persistent state는 `data/watch-rules.json` 하나로 단순화했다.
- upstream scrapers의 title/link/article-id 파싱 감각을 가져오되, CLI용으로 최소한의 구조만 남겼다.
## upstream에서 특히 참고한 파일
- `README.md`
- `monitor_engine.py`
- `models.py`
- `price_utils.py`
- `scrapers/playwright_danggeun.py`
- `scrapers/playwright_bunjang.py`
- `scrapers/playwright_joonggonara.py`
FILE:scripts/build_skill.py
from __future__ import annotations
import json
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
from _paths import DIST_DIR, SKILL_DIR
EXCLUDE_DIRS = {"__pycache__", ".pytest_cache", "dist", "data"}
EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
def should_include(path: Path) -> bool:
rel = path.relative_to(SKILL_DIR)
if any(part in EXCLUDE_DIRS for part in rel.parts):
return False
if path.suffix.lower() in EXCLUDE_SUFFIXES:
return False
return path.is_file()
def main() -> int:
DIST_DIR.mkdir(parents=True, exist_ok=True)
out_path = DIST_DIR / "used-market-watch.skill"
manifest = {
"name": "used-market-watch",
"version": "0.4.0",
"entry": "SKILL.md",
}
with ZipFile(out_path, "w", compression=ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
for path in sorted(SKILL_DIR.rglob("*")):
if should_include(path):
zf.write(path, arcname=str(path.relative_to(SKILL_DIR)).replace('\\', '/'))
print(str(out_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/market_client.py
from __future__ import annotations
import asyncio
import hashlib
import json
import re
from urllib.parse import quote
from models import ListingItem, SearchIntent
from price_utils import parse_price_kr
def _run_async(coro_factory):
try:
return asyncio.run(coro_factory())
except RuntimeError:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro_factory())
finally:
loop.close()
def _is_valid_title(title: str) -> bool:
if not title or len(title.strip()) < 2:
return False
bad = ("판매완료", "예약중", "거래완료", "광고", "No Title")
return not any(token.lower() in title.lower() for token in bad)
def _extract_article_id(link: str) -> str:
for pattern in (r"/products/(\d+)", r"-(\d+)(?:/|\?|$)", r"/articles/(\d+)"):
m = re.search(pattern, link)
if m:
return m.group(1)
return "hash_" + hashlib.sha1(link.encode("utf-8")).hexdigest()[:12]
def _location_from_text(text: str) -> str | None:
m = re.search(r"(서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)[^\n|,/]{0,20}", text or "")
return m.group(0).strip() if m else None
def _passes_filters(item: ListingItem, intent: SearchIntent) -> bool:
title = (item.title or "").lower()
if intent.exclude_terms and any(term.lower() in title for term in intent.exclude_terms):
return False
price = item.parse_price()
if intent.min_price and price and price < intent.min_price:
return False
if intent.max_price and price and price > intent.max_price:
return False
if intent.location and item.market == "danggeun":
if not item.location or intent.location.lower() not in item.location.lower():
return False
return True
async def _search_danggeun(page, intent: SearchIntent) -> list[ListingItem]:
url = f"https://www.daangn.com/kr/buy-sell/?search={quote(intent.keyword)}&sort=recent"
await page.goto(url, wait_until="domcontentloaded")
await page.wait_for_timeout(1200)
items: list[ListingItem] = []
try:
scripts = await page.locator("script[type='application/ld+json']").all_text_contents()
for script_text in scripts:
try:
data = json.loads(script_text)
except Exception:
continue
nodes = data if isinstance(data, list) else [data]
for node in nodes:
if not isinstance(node, dict) or node.get("@type") != "ItemList":
continue
for entry in node.get("itemListElement", [])[: max(20, intent.limit * 3)]:
product = entry.get("item", {}) if isinstance(entry, dict) else {}
if not isinstance(product, dict):
continue
link = product.get("url") or ""
if link.startswith("/"):
link = "https://www.daangn.com" + link
title = str(product.get("name") or "").strip()
if not _is_valid_title(title):
continue
offers = product.get("offers") or {}
raw_price = offers.get("price") if isinstance(offers, dict) else None
price_text = f"{int(float(raw_price)):,}원" if raw_price else "가격문의"
description = str(product.get("description") or "")
items.append(ListingItem(
market="danggeun",
article_id=_extract_article_id(link),
title=title,
price_text=price_text,
link=link,
query=intent.raw_query,
thumbnail=product.get("image"),
location=_location_from_text(description),
))
if len(items) >= intent.limit * 2:
return items
if items:
return items
except Exception:
pass
cards = page.locator("a[data-gtm='search_article'][href^='/kr/buy-sell/']")
count = min(await cards.count(), max(20, intent.limit * 3))
for i in range(count):
card = cards.nth(i)
href = (await card.get_attribute("href") or "").strip()
if not href:
continue
link = "https://www.daangn.com" + href if href.startswith("/") else href
text = (await card.inner_text() or "").strip()
lines = [line.strip() for line in text.splitlines() if line.strip()]
if not lines:
continue
title = lines[0]
if not _is_valid_title(title):
continue
price_text = next((line for line in lines[1:] if any(ch.isdigit() for ch in line)), "가격문의")
items.append(ListingItem("danggeun", _extract_article_id(link), title, price_text, link, intent.raw_query, location=_location_from_text(text)))
return items
async def _search_bunjang(page, intent: SearchIntent) -> list[ListingItem]:
url = f"https://m.bunjang.co.kr/search/products?q={quote(intent.keyword)}&order=date"
await page.goto(url, wait_until="domcontentloaded")
await page.wait_for_timeout(1200)
cards = page.locator("a[data-pid]")
items: list[ListingItem] = []
count = min(await cards.count(), max(20, intent.limit * 3))
for i in range(count):
card = cards.nth(i)
pid = (await card.get_attribute("data-pid") or "").strip()
if not pid:
continue
text = (await card.inner_text() or "").strip()
lines = [line.strip() for line in text.splitlines() if line.strip() and line.strip() not in {"배송비포함", "검수가능", "·"}]
if not lines:
continue
title = lines[0]
if not _is_valid_title(title):
continue
price_text = next((line for line in lines[1:] if any(ch.isdigit() for ch in line)), "가격문의")
location = None
for line in reversed(lines):
compact = line.replace(",", "").replace(" ", "")
if line == title or compact.isdigit() or compact.endswith("원"):
continue
location = None if "지역정보없음" in compact else line
break
items.append(ListingItem("bunjang", pid, title, price_text, f"https://m.bunjang.co.kr/products/{pid}", intent.raw_query, location=location))
return items
async def _search_joonggonara(page, intent: SearchIntent) -> list[ListingItem]:
url = "https://search.naver.com/search.naver?where=article&query=" + quote(f"{intent.keyword} site:cafe.naver.com/joonggonara")
await page.goto(url, wait_until="domcontentloaded")
await page.wait_for_timeout(1200)
selectors = ["a.title_link", "a.api_txt_lines.total_tit", "a[href*='cafe.naver.com/joonggonara']"]
items: list[ListingItem] = []
seen: set[str] = set()
for selector in selectors:
loc = page.locator(selector)
count = min(await loc.count(), max(20, intent.limit * 3))
for i in range(count):
el = loc.nth(i)
link = (await el.get_attribute("href") or "").strip()
title = " ".join((await el.inner_text() or "").split())
if not link or "joonggonara" not in link and "cafe.naver.com" not in link:
continue
if not _is_valid_title(title):
continue
article_id = _extract_article_id(link)
if article_id in seen:
continue
seen.add(article_id)
items.append(ListingItem("joonggonara", article_id, title, "가격문의", link, intent.raw_query))
if items:
return items
return items
async def _search_async(intent: SearchIntent) -> list[ListingItem]:
from playwright.async_api import async_playwright
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
out: list[ListingItem] = []
try:
for market in intent.markets:
if market == "danggeun":
out.extend(await _search_danggeun(page, intent))
elif market == "bunjang":
out.extend(await _search_bunjang(page, intent))
elif market == "joonggonara":
out.extend(await _search_joonggonara(page, intent))
finally:
await context.close()
await browser.close()
deduped: list[ListingItem] = []
seen_keys: set[str] = set()
for item in out:
key = item.article_key()
if key in seen_keys:
continue
seen_keys.add(key)
if _passes_filters(item, intent):
deduped.append(item)
deduped.sort(key=lambda row: (row.market, row.parse_price() or 0))
return deduped[: intent.limit]
def search_markets(intent: SearchIntent) -> list[ListingItem]:
return _run_async(lambda: _search_async(intent))
FILE:scripts/models.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from price_utils import parse_price_kr
SUPPORTED_MARKETS = ("danggeun", "bunjang", "joonggonara")
MARKET_LABELS = {
"danggeun": "당근마켓",
"bunjang": "번개장터",
"joonggonara": "중고나라",
}
@dataclass
class ListingItem:
market: str
article_id: str
title: str
price_text: str
link: str
query: str
thumbnail: str | None = None
seller: str | None = None
location: str | None = None
price_numeric: int | None = None
meta: dict[str, Any] = field(default_factory=dict)
def parse_price(self) -> int:
if self.price_numeric is None:
self.price_numeric = parse_price_kr(self.price_text)
return self.price_numeric
def article_key(self) -> str:
return f"{self.market}:{self.article_id}"
def to_dict(self) -> dict[str, Any]:
return {
"market": self.market,
"market_label": MARKET_LABELS.get(self.market, self.market),
"article_id": self.article_id,
"article_key": self.article_key(),
"title": self.title,
"price_text": self.price_text,
"price_numeric": self.parse_price(),
"link": self.link,
"query": self.query,
"thumbnail": self.thumbnail,
"seller": self.seller,
"location": self.location,
"meta": self.meta,
}
@dataclass
class SearchIntent:
raw_query: str
keyword: str
include_terms: list[str]
exclude_terms: list[str]
markets: list[str]
min_price: int | None = None
max_price: int | None = None
location: str | None = None
limit: int = 12
def to_dict(self) -> dict[str, Any]:
return {
"raw_query": self.raw_query,
"keyword": self.keyword,
"include_terms": self.include_terms,
"exclude_terms": self.exclude_terms,
"markets": self.markets,
"min_price": self.min_price,
"max_price": self.max_price,
"location": self.location,
"limit": self.limit,
}
FILE:scripts/output_utils.py
from __future__ import annotations
from typing import Any
from models import MARKET_LABELS
from price_utils import format_price_kr
EVENT_LABELS = {
"new_listing": "신규 매물",
"price_drop": "가격하락",
}
def render_search_text(payload: dict[str, Any]) -> str:
intent = payload.get("intent") or {}
items = payload.get("items") or []
lines = [f"중고 매물 브리핑: {intent.get('keyword') or intent.get('raw_query')}"]
filters = []
if intent.get("markets"):
filters.append("마켓=" + ", ".join(MARKET_LABELS.get(m, m) for m in intent["markets"]))
if intent.get("location"):
filters.append(f"지역={intent['location']}")
if intent.get("min_price"):
filters.append(f"최소={format_price_kr(intent['min_price'])}")
if intent.get("max_price"):
filters.append(f"최대={format_price_kr(intent['max_price'])}")
if intent.get("exclude_terms"):
filters.append("제외=" + ", ".join(intent["exclude_terms"]))
if filters:
lines.append("- " + " / ".join(filters))
summary = payload.get("summary") or {}
lines.append(f"- 총 {summary.get('total', 0)}건, 표시 {len(items)}건")
for market, row in (summary.get("by_market") or {}).items():
lines.append(f"- {MARKET_LABELS.get(market, market)}: {row.get('count', 0)}건, 최저 {format_price_kr(row.get('min_price'))}, 최고 {format_price_kr(row.get('max_price'))}")
if not items:
lines.append("- 조건에 맞는 매물이 없습니다.")
return "\n".join(lines)
for idx, item in enumerate(items, start=1):
label = MARKET_LABELS.get(item.get("market"), item.get("market"))
price = item.get("price_text") or format_price_kr(item.get("price_numeric"))
extra = []
if item.get("location"):
extra.append(item["location"])
if item.get("seller"):
extra.append(f"판매자 {item['seller']}")
suffix = f" ({' / '.join(extra)})" if extra else ""
lines.append(f"{idx}. [{label}] {item.get('title')} - {price}{suffix}")
if item.get("link"):
lines.append(f" - {item['link']}")
return "\n".join(lines)
def render_watch_preview(payload: dict[str, Any]) -> str:
lines = [f"중고 매물 watch 점검: {payload.get('alert_count', 0)}건 알림"]
summary = payload.get("summary") or {}
if summary.get("event_counts"):
counts = ", ".join(f"{EVENT_LABELS.get(k, k)}={v}" for k, v in summary["event_counts"].items())
lines.append(f"- 이벤트 요약: {counts}")
for row in payload.get("alerts") or []:
rule = row.get("rule") or {}
schedule = ((rule.get("schedule") or {}).get("label") or "수동")
mode_label = "브리핑" if rule.get("delivery_mode") == "briefing" else "알림"
lines.append(f"- {rule.get('name')}: {row.get('matched_count', 0)}건 / {mode_label} / {schedule}")
for match in (row.get("matched") or [])[:5]:
badges = [EVENT_LABELS.get(match.get("event_type"), match.get("event_type"))]
if match.get("previous_price_text"):
badges.append(f"이전 {match['previous_price_text']}")
lines.append(f" · [{MARKET_LABELS.get(match.get('market'), match.get('market'))}] {match.get('title')} / {match.get('price_text')} ({', '.join([b for b in badges if b])})")
if match.get("link"):
lines.append(f" - {match['link']}")
return "\n".join(lines)
def render_watch_plan(payload: dict[str, Any]) -> str:
rule = payload.get("rule") or {}
intent = payload.get("intent") or {}
modes = []
if rule.get("notify_on_new"):
modes.append("신규")
if rule.get("notify_on_price_drop"):
modes.append("가격하락")
delivery_mode = "브리핑" if rule.get("delivery_mode") == "briefing" else "알림"
schedule = rule.get("schedule") or {}
plan_hints = rule.get("plan_hints") or payload.get("plan_hints") or {}
lines = [f"watch 규칙 해석: {rule.get('name')}"]
lines.append(f"- 쿼리: {rule.get('query')}")
lines.append(f"- 동작: {delivery_mode}")
lines.append(f"- 알림 조건: {', '.join(modes) if modes else '없음'}")
lines.append(f"- 상태: {'활성' if rule.get('enabled', True) else '비활성'}")
lines.append(f"- limit: {rule.get('limit')}")
lines.append(f"- 실행 주기: {schedule.get('label') or '수동'}")
if plan_hints.get("recommended_command"):
lines.append(f"- 권장 실행: {plan_hints['recommended_command']}")
if plan_hints.get("cron_example"):
lines.append(f"- cron 예시: {plan_hints['cron_example']}")
elif schedule.get("cron"):
lines.append(f"- cron: {schedule['cron']}")
if intent.get("markets"):
lines.append("- 마켓: " + ", ".join(MARKET_LABELS.get(m, m) for m in intent["markets"]))
if intent.get("location"):
lines.append(f"- 지역: {intent['location']}")
if intent.get("min_price"):
lines.append(f"- 최소가: {format_price_kr(intent['min_price'])}")
if intent.get("max_price"):
lines.append(f"- 최대가: {format_price_kr(intent['max_price'])}")
if intent.get("exclude_terms"):
lines.append("- 제외어: " + ", ".join(intent["exclude_terms"]))
return "\n".join(lines)
def render_watch_list(state: dict[str, Any]) -> str:
rules = state.get("rules") or []
if not rules:
return "등록된 watch rule이 없습니다."
event_counts: dict[str, int] = {}
for event in state.get("events") or []:
event_counts[event.get("rule_id")] = event_counts.get(event.get("rule_id"), 0) + 1
lines = [f"등록된 watch rule {len(rules)}개"]
for rule in rules:
modes = []
if rule.get("notify_on_new"):
modes.append("신규")
if rule.get("notify_on_price_drop"):
modes.append("가격하락")
schedule_label = ((rule.get("schedule") or {}).get("label") or "수동")
delivery_mode = "브리핑" if rule.get("delivery_mode") == "briefing" else "알림"
lines.append(f"- {rule['name']} ({rule['id']})")
lines.append(f" · 상태: {'활성' if rule.get('enabled', True) else '비활성'} / 동작: {delivery_mode} / 주기: {schedule_label}")
lines.append(f" · 알림 조건: {', '.join(modes) if modes else '없음'}")
lines.append(f" · 쿼리: {rule['query']}")
if rule.get("min_price"):
lines.append(f" · 최소가: {rule['min_price']:,}원")
if rule.get("max_price"):
lines.append(f" · 최대가: {rule['max_price']:,}원")
if (rule.get("plan_hints") or {}).get("cron_example"):
lines.append(f" · cron 예시: {rule['plan_hints']['cron_example']}")
lines.append(f" · 누적 이벤트: {event_counts.get(rule['id'], 0)}건")
return "\n".join(lines)
def render_watch_events(payload: dict[str, Any]) -> str:
events = payload.get("events") or []
if not events:
return "최근 watch 이벤트가 없습니다."
lines = [f"최근 watch 이벤트 {len(events)}건"]
for event in events:
badges = [EVENT_LABELS.get(event.get("event_type"), event.get("event_type"))]
if event.get("previous_price_text"):
badges.append(f"이전 {event['previous_price_text']}")
lines.append(
f"- {event.get('rule_name')} / [{MARKET_LABELS.get(event.get('market'), event.get('market'))}] {event.get('title')} / {event.get('price_text')} ({', '.join([b for b in badges if b])})"
)
if event.get("link"):
lines.append(f" · {event['link']}")
return "\n".join(lines)
def render_integration_plan(payload: dict[str, Any]) -> str:
plan = payload.get("parsed_plan") or {}
rule = {k: v for k, v in plan.items() if k != "intent"}
schedule = rule.get("schedule") or {}
execution = payload.get("execution") or {}
cron_payload = execution.get("cron_payload") or {}
lines = [f"자동화 연동 계획: {rule.get('name')}"]
lines.append(f"- 요청: {payload.get('request')}")
lines.append(f"- 확인 문구: {payload.get('user_confirmation')}")
lines.append(f"- 저장 명령: {((payload.get('persist') or {}).get('command'))}")
if execution.get("recommended_command"):
lines.append(f"- 실행 명령: {execution['recommended_command']}")
lines.append(f"- 주기: {schedule.get('label') or '수동'}")
if cron_payload.get("expr"):
lines.append(f"- cron 제안: {cron_payload['expr']}")
system_event = execution.get("system_event") or {}
if system_event:
lines.append(f"- systemEvent 힌트: {system_event.get('type')} / rule={system_event.get('rule_name')} / mode={system_event.get('delivery_mode')}")
lines.append(f"- 운영 요약: {payload.get('operator_summary')}")
return "\n".join(lines)
FILE:scripts/price_utils.py
from __future__ import annotations
import re
_FREE_KEYWORDS = ("무료나눔", "무료", "나눔", "무나")
def parse_price_kr(text: str | None) -> int:
if text is None:
return 0
s = str(text).strip()
if not s:
return 0
s = s.replace(" ", "")
s_norm = s.lower().replace(",", "").replace("krw", "").replace("₩", "").replace("원", "")
if not re.search(r"\d", s_norm):
return 0 if any(k in s_norm for k in _FREE_KEYWORDS) else 0
total = 0
m_man = re.search(r"(\d+(?:\.\d+)?)만", s_norm)
if m_man:
total += int(float(m_man.group(1)) * 10000)
rest = s_norm[m_man.end():]
m_thousand = re.search(r"(\d+(?:\.\d+)?)천", rest)
if m_thousand:
total += int(float(m_thousand.group(1)) * 1000)
return total
m_tail = re.search(r"(\d+)", rest)
if m_tail:
tail = int(m_tail.group(1))
total += tail * 1000 if tail < 1000 else tail
return total
m_thousand = re.search(r"(\d+(?:\.\d+)?)천", s_norm)
if m_thousand:
return int(float(m_thousand.group(1)) * 1000)
digits = re.findall(r"\d+", s_norm)
return int("".join(digits)) if digits else 0
def format_price_kr(amount: int | None) -> str:
if not amount:
return "가격문의"
return f"{int(amount):,}원"
FILE:scripts/query_parser.py
from __future__ import annotations
import re
from typing import Iterable
from models import SUPPORTED_MARKETS, SearchIntent
from price_utils import parse_price_kr
MARKET_SYNONYMS = {
"danggeun": ("당근", "당근마켓", "당근마켓만", "당근만"),
"bunjang": ("번장", "번장만", "번개", "번개장터", "번개장터만"),
"joonggonara": ("중고나라", "중고나라만", "중나"),
}
LOCATION_HINTS = (
"서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종",
"경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주",
"성남", "수원", "용인", "고양", "부천", "안양", "분당", "잠실", "강남", "송파",
)
STOPWORDS = {
"찾아줘", "찾아", "브리핑", "브리핑해줘", "알려줘", "모니터링", "감시", "감시해줘", "매물", "중고", "중고매물",
"신규", "신규만", "새매물", "새매물만", "가격하락", "가격하락만", "가격", "제품", "거래", "검색",
"해줘", "켜줘", "추가", "저장", "등록", "설정", "만", "위주", "만요", "내려가면", "떨어지면", "매일", "아침", "오전", "오후", "저녁", "밤",
"포함", "포함해", "포함해서", "이하", "이상", "미만", "초과", "까지", "부터",
"매시간", "시간마다", "분마다", "매주", "매월", "계속", "계속해줘",
}
def _extract_markets(text: str) -> list[str]:
found: list[str] = []
for market, words in MARKET_SYNONYMS.items():
if any(word in text for word in words):
found.append(market)
return found or list(SUPPORTED_MARKETS)
def _extract_excludes(text: str) -> list[str]:
found = re.findall(r"-(\S+)", text)
extra = re.findall(r"제외\s*[::]?\s*([^,]+)", text)
for chunk in extra:
found.extend([token.strip() for token in re.split(r"[\s,/]+", chunk) if token.strip()])
return list(dict.fromkeys(found))
def _normalize_location(value: str) -> str:
text = str(value or "").strip()
text = re.sub(r"(에서|근처|인근|부근|쪽|쪽에서|에서만)$", "", text)
return text.strip()
def _extract_location(text: str) -> str | None:
for hint in LOCATION_HINTS:
if hint in text:
m = re.search(rf"({re.escape(hint)}[^\s,/]{{0,12}})", text)
if m:
return _normalize_location(m.group(1))
return _normalize_location(hint)
return None
def _extract_price_bounds(text: str) -> tuple[int | None, int | None]:
min_price = None
max_price = None
m = re.search(r"(\S+)\s*(?:이하|까지|미만)", text)
if m:
max_price = parse_price_kr(m.group(1))
m = re.search(r"(\S+)\s*(?:이상|부터|초과)", text)
if m:
min_price = parse_price_kr(m.group(1))
m = re.search(r"(\S+)\s*[~-]\s*(\S+)", text)
if m:
a = parse_price_kr(m.group(1))
b = parse_price_kr(m.group(2))
if a and b:
min_price, max_price = min(a, b), max(a, b)
return min_price or None, max_price or None
def _clean_tokens(tokens: Iterable[str], *, excludes: list[str], markets: list[str], location: str | None) -> list[str]:
block = set(excludes) | STOPWORDS
for market in markets:
block.add(market)
block.update(MARKET_SYNONYMS.get(market, ()))
if location:
block.add(location)
block.add(_normalize_location(location))
out: list[str] = []
for token in tokens:
token = token.strip().strip('"\'“”‘’')
token = _normalize_location(token)
if not token or token in block:
continue
if token.startswith("-"):
continue
if re.fullmatch(r"\d+(?:개|건)?", token):
numeric_only = re.sub(r"[^0-9]", "", token)
if not numeric_only or len(numeric_only) >= 5:
continue
if re.fullmatch(r"\d+(?:만|천|원)+", token):
continue
if re.fullmatch(r"\d{1,2}시(?:에)?|\d{1,2}:\d{2}", token):
continue
if re.fullmatch(r"\d+(?:시간|분|일|주|개월|달)마다", token):
continue
out.append(token)
return list(dict.fromkeys(out))
def parse_search_intent(raw_query: str, *, limit: int = 12) -> SearchIntent:
text = " ".join(str(raw_query or "").split())
markets = _extract_markets(text)
excludes = _extract_excludes(text)
location = _extract_location(text)
min_price, max_price = _extract_price_bounds(text)
normalized = re.sub(r"[-~]", " ", text)
normalized = re.sub(r"[,:/()]", " ", normalized)
tokens = _clean_tokens(normalized.split(), excludes=excludes, markets=markets, location=location)
keyword = " ".join(tokens[:4]).strip() or text
include_terms = tokens[:8] if tokens else [keyword]
return SearchIntent(
raw_query=text,
keyword=keyword,
include_terms=include_terms,
exclude_terms=excludes,
markets=markets,
min_price=min_price,
max_price=max_price,
location=location,
limit=max(1, limit),
)
FILE:scripts/used_market_watch.py
from __future__ import annotations
import argparse
import json
import sys
import time
from typing import Any
from market_client import search_markets
from models import MARKET_LABELS
from output_utils import (
render_integration_plan,
render_search_text,
render_watch_events,
render_watch_list,
render_watch_plan,
render_watch_preview,
)
from query_parser import parse_search_intent
from watch_intent import build_integration_bundle, parse_watch_request
from watch_store import (
find_rule,
load_state,
make_rule,
remove_rule,
save_state,
set_rule_enabled,
upsert_rule,
)
def _summarize(items: list[dict[str, Any]]) -> dict[str, Any]:
by_market: dict[str, dict[str, Any]] = {}
for item in items:
row = by_market.setdefault(item["market"], {"count": 0, "prices": []})
row["count"] += 1
if item.get("price_numeric"):
row["prices"].append(item["price_numeric"])
out: dict[str, Any] = {"total": len(items), "by_market": {}}
for market, row in by_market.items():
prices = row.pop("prices")
out["by_market"][market] = {
**row,
"min_price": min(prices) if prices else None,
"max_price": max(prices) if prices else None,
}
return out
def _event_counts(rows: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for row in rows:
kind = row.get("event_type")
if kind:
counts[kind] = counts.get(kind, 0) + 1
return counts
def run_search(query: str, *, limit: int, as_json: bool) -> int:
intent = parse_search_intent(query, limit=limit)
items = [item.to_dict() for item in search_markets(intent)]
payload = {"kind": "used-market-search", "intent": intent.to_dict(), "summary": _summarize(items), "items": items}
print(json.dumps(payload, ensure_ascii=False, indent=2) if as_json else render_search_text(payload))
return 0
def cmd_parse(args: argparse.Namespace) -> int:
intent = parse_search_intent(args.query, limit=args.limit)
print(json.dumps(intent.to_dict(), ensure_ascii=False, indent=2))
return 0
def cmd_search(args: argparse.Namespace) -> int:
return run_search(args.query, limit=args.limit, as_json=args.json)
def cmd_watch_plan(args: argparse.Namespace) -> int:
plan = parse_watch_request(args.request, default_limit=args.limit)
payload = {"kind": "used-market-watch-plan", "rule": {k: v for k, v in plan.items() if k != "intent"}, "intent": plan["intent"]}
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else render_watch_plan(payload))
return 0
def cmd_watch_add(args: argparse.Namespace) -> int:
state = load_state()
rule = make_rule(
name=args.name,
query=args.query,
limit=args.limit,
min_price=args.min_price,
max_price=args.max_price,
notify_on_new=args.notify_on_new,
notify_on_price_drop=args.notify_on_price_drop,
)
state["rules"].append(rule)
save_state(state)
print(json.dumps({"saved": True, "rule": rule}, ensure_ascii=False, indent=2) if args.json else f"등록 완료: {rule['name']} -> {rule['query']}")
return 0
def cmd_watch_upsert(args: argparse.Namespace) -> int:
state = load_state()
plan = parse_watch_request(args.request, default_limit=args.limit)
rule, created = upsert_rule(state, plan)
save_state(state)
payload = {"saved": True, "created": created, "rule": rule, "intent": plan["intent"]}
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
status = "등록" if created else "업데이트"
print(render_watch_plan({"rule": rule, "intent": plan["intent"]}))
print(f"\n{status} 완료")
return 0
def cmd_integration_plan(args: argparse.Namespace) -> int:
bundle = build_integration_bundle(args.request, default_limit=args.limit)
if args.persist:
state = load_state()
rule, created = upsert_rule(state, bundle["parsed_plan"])
save_state(state)
bundle["persist"]["saved"] = True
bundle["persist"]["created"] = created
bundle["persist"]["rule"] = rule
print(json.dumps(bundle, ensure_ascii=False, indent=2) if args.json else render_integration_plan(bundle))
return 0
def cmd_watch_list(args: argparse.Namespace) -> int:
state = load_state()
if args.json:
print(json.dumps(state, ensure_ascii=False, indent=2))
return 0
print(render_watch_list(state))
return 0
def _make_alert(rule: dict[str, Any], item: dict[str, Any], event_type: str, previous: dict[str, Any] | None) -> dict[str, Any]:
return {
"event_type": event_type,
"rule_id": rule["id"],
"rule_name": rule["name"],
"market": item["market"],
"market_label": MARKET_LABELS.get(item["market"], item["market"]),
"article_key": item["article_key"],
"title": item["title"],
"price_text": item.get("price_text"),
"price_numeric": item.get("price_numeric"),
"previous_price_text": previous.get("price_text") if previous else None,
"previous_price_numeric": previous.get("price_numeric") if previous else None,
"link": item.get("link"),
"location": item.get("location"),
"seller": item.get("seller"),
"detected_at": int(time.time()),
}
def cmd_watch_check(args: argparse.Namespace) -> int:
state = load_state()
rules = state.get("rules") or []
if args.name_or_id:
rules = [r for r in rules if r["id"] == args.name_or_id or r["name"] == args.name_or_id]
alerts: list[dict[str, Any]] = []
last_seen = state.setdefault("last_seen", {})
new_events = []
checked_at = int(time.time())
for rule in rules:
if not rule.get("enabled", True):
alerts.append({"rule": rule, "matched_count": 0, "matched": [], "snapshot": {"count": 0, "items": [], "summary": {"total": 0, "by_market": {}}}, "skipped": True})
continue
intent = parse_search_intent(rule["query"], limit=int(rule.get("limit") or 12))
if rule.get("min_price"):
intent.min_price = rule["min_price"]
if rule.get("max_price"):
intent.max_price = rule["max_price"]
items = [item.to_dict() for item in search_markets(intent)]
matched = []
known = {row.get('dedupe_key') for row in state.get('events', [])[-500:]}
for item in items:
prev = last_seen.get(item["article_key"])
is_new = prev is None
is_price_drop = bool(prev and prev.get("price_numeric") and item.get("price_numeric") and item["price_numeric"] < prev["price_numeric"])
event_type = None
if is_new and rule.get("notify_on_new"):
event_type = "new_listing"
if is_price_drop and rule.get("notify_on_price_drop"):
event_type = "price_drop"
if event_type:
alert = _make_alert(rule, item, event_type, prev)
matched.append(alert)
dedupe_key = f"{rule['id']}::{event_type}::{item['article_key']}::{item.get('price_numeric')}"
if dedupe_key not in known:
known.add(dedupe_key)
new_events.append({"dedupe_key": dedupe_key, **alert})
last_seen[item["article_key"]] = {
"rule_id": rule["id"],
"price_text": item.get("price_text"),
"price_numeric": item.get("price_numeric"),
"title": item.get("title"),
"link": item.get("link"),
"last_seen_at": checked_at,
}
alerts.append({
"rule": rule,
"matched_count": len(matched),
"matched": matched,
"snapshot": {"count": len(items), "items": items[:5], "summary": _summarize(items)},
})
state["last_checked_at"] = checked_at
state["events"] = (state.get("events") or []) + new_events
state["events"] = state["events"][-1000:]
save_state(state)
visible_alerts = [row for row in alerts if row["matched_count"] > 0] if args.alerts_only else alerts
payload = {
"kind": "used-market-watch-check",
"checked_at": checked_at,
"alert_count": sum(row["matched_count"] for row in alerts),
"alerts": visible_alerts,
"summary": {
"rule_count": len(alerts),
"rules_with_matches": sum(1 for row in alerts if row["matched_count"] > 0),
"event_counts": _event_counts(new_events),
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else render_watch_preview(payload))
return 0
def cmd_watch_events(args: argparse.Namespace) -> int:
state = load_state()
events = list(state.get("events") or [])
if args.name_or_id:
rule = find_rule(state, args.name_or_id)
target_rule_id = rule.get("id") if rule else args.name_or_id
events = [row for row in events if row.get("rule_id") == target_rule_id or row.get("rule_name") == args.name_or_id]
events = events[-args.limit:]
payload = {"kind": "used-market-watch-events", "count": len(events), "events": events}
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else render_watch_events(payload))
return 0
def cmd_watch_enable(args: argparse.Namespace) -> int:
state = load_state()
rule = set_rule_enabled(state, args.name_or_id, True)
if not rule:
raise ValueError("해당 watch rule을 찾을 수 없습니다.")
save_state(state)
print(json.dumps({"updated": True, "rule": rule}, ensure_ascii=False, indent=2) if args.json else f"활성화 완료: {rule['name']}")
return 0
def cmd_watch_disable(args: argparse.Namespace) -> int:
state = load_state()
rule = set_rule_enabled(state, args.name_or_id, False)
if not rule:
raise ValueError("해당 watch rule을 찾을 수 없습니다.")
save_state(state)
print(json.dumps({"updated": True, "rule": rule}, ensure_ascii=False, indent=2) if args.json else f"비활성화 완료: {rule['name']}")
return 0
def cmd_watch_remove(args: argparse.Namespace) -> int:
state = load_state()
rule = remove_rule(state, args.name_or_id)
if not rule:
raise ValueError("해당 watch rule을 찾을 수 없습니다.")
save_state(state)
print(json.dumps({"removed": True, "rule": rule}, ensure_ascii=False, indent=2) if args.json else f"삭제 완료: {rule['name']}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="한국 중고거래 검색/브리핑/watch 스킬")
sub = p.add_subparsers(dest="cmd", required=True)
x = sub.add_parser("parse")
x.add_argument("query")
x.add_argument("--limit", type=int, default=12)
x.set_defaults(func=cmd_parse)
x = sub.add_parser("search")
x.add_argument("query")
x.add_argument("--limit", type=int, default=12)
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_search)
x = sub.add_parser("watch-plan")
x.add_argument("request")
x.add_argument("--limit", type=int, default=12)
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_plan)
x = sub.add_parser("watch-add")
x.add_argument("name")
x.add_argument("query")
x.add_argument("--limit", type=int, default=12)
x.add_argument("--min-price", type=int)
x.add_argument("--max-price", type=int)
x.add_argument("--notify-on-new", action="store_true")
x.add_argument("--notify-on-price-drop", action="store_true")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_add)
x = sub.add_parser("watch-upsert")
x.add_argument("request")
x.add_argument("--limit", type=int, default=12)
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_upsert)
x = sub.add_parser("integration-plan")
x.add_argument("request")
x.add_argument("--limit", type=int, default=12)
x.add_argument("--persist", action="store_true")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_integration_plan)
x = sub.add_parser("watch-list")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_list)
x = sub.add_parser("watch-check")
x.add_argument("name_or_id", nargs="?")
x.add_argument("--alerts-only", action="store_true")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_check)
x = sub.add_parser("watch-events")
x.add_argument("name_or_id", nargs="?")
x.add_argument("--limit", type=int, default=10)
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_events)
x = sub.add_parser("watch-enable")
x.add_argument("name_or_id")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_enable)
x = sub.add_parser("watch-disable")
x.add_argument("name_or_id")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_disable)
x = sub.add_parser("watch-remove")
x.add_argument("name_or_id")
x.add_argument("--json", action="store_true")
x.set_defaults(func=cmd_watch_remove)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/watch_intent.py
from __future__ import annotations
import re
from typing import Any
from query_parser import parse_search_intent
SCRIPT_PATH = "skills/used-market-watch/scripts/used_market_watch.py"
def _derive_name(text: str, keyword: str) -> str:
quoted = re.search(r'"([^"]{2,40})"', text)
if quoted:
return quoted.group(1).strip()
clean_keyword = " ".join(str(keyword or "").split())
clean_keyword = re.sub(r"\b\d+시간마다\b|\b\d+분마다\b", " ", clean_keyword)
clean_keyword = re.sub(r"매일\s*(?:아침|저녁|밤|오전|오후)?\s*\d{1,2}시(?:\s*\d{1,2}분)?(?:에)?", " ", clean_keyword)
clean_keyword = re.sub(r"(신규\s*매물만|신규만|새매물만|새매물|매물만|감시해줘|브리핑해줘|알려줘|체크해줘|감시|브리핑|알림|요약)", " ", clean_keyword)
clean_keyword = " ".join(clean_keyword.split())[:32].strip()
return f"{clean_keyword or '중고 매물'} 감시"
def _detect_notifications(text: str) -> tuple[bool, bool]:
normalized = text.replace(" ", "")
has_new = any(token in normalized for token in ("신규만", "새매물만", "신규", "새매물", "새로올라오면", "새로올라온"))
has_drop = any(token in normalized for token in ("가격하락만", "가격내려가면", "가격떨어지면", "가격하락", "내려가면", "떨어지면"))
if "신규만" in normalized or "새매물만" in normalized:
return True, False
if "가격하락만" in normalized:
return False, True
if has_new or has_drop:
return has_new, has_drop
return True, True
def _detect_limit(text: str, default_limit: int) -> int:
m = re.search(r"(\d+)\s*(?:개|건)\s*(?:만|까지|정도)?", text)
if m:
return max(1, int(m.group(1)))
return default_limit
def _detect_delivery_mode(text: str) -> str:
normalized = text.replace(" ", "")
if any(token in normalized for token in ("브리핑", "요약", "정리해줘", "정리", "리포트", "보고")):
return "briefing"
return "alert"
def _parse_hour_minute(text: str) -> tuple[int, int] | None:
m = re.search(r"(?:(오전|오후|아침|저녁|밤)\s*)?(\d{1,2})\s*(?:시|:)\s*(?:(\d{1,2})\s*분?)?", text)
if not m:
return None
meridiem, hour_raw, minute_raw = m.groups()
hour = int(hour_raw)
minute = int(minute_raw or 0)
if meridiem in ("오후", "저녁", "밤") and hour < 12:
hour += 12
if meridiem in ("오전", "아침") and hour == 12:
hour = 0
hour = max(0, min(hour, 23))
minute = max(0, min(minute, 59))
return hour, minute
def _detect_schedule(text: str) -> dict[str, Any]:
normalized = text.replace(" ", "")
every_minutes = re.search(r"(\d+)분마다", normalized)
if every_minutes:
minutes = max(1, int(every_minutes.group(1)))
return {
"kind": "interval",
"every_minutes": minutes,
"label": f"{minutes}분마다",
"cron": f"*/{minutes} * * * *" if minutes < 60 and 60 % minutes == 0 else None,
}
every_hours = re.search(r"(\d+)시간마다", normalized)
if every_hours:
hours = max(1, int(every_hours.group(1)))
return {
"kind": "interval",
"every_hours": hours,
"label": f"{hours}시간마다",
"cron": f"0 */{hours} * * *" if hours < 24 and 24 % hours == 0 else None,
}
if any(token in normalized for token in ("매일", "매일아침", "매일저녁", "매일밤", "아침", "저녁", "밤")):
parsed = _parse_hour_minute(text)
if parsed:
hour, minute = parsed
return {
"kind": "daily",
"hour": hour,
"minute": minute,
"label": f"매일 {hour:02d}:{minute:02d}",
"cron": f"{minute} {hour} * * *",
}
return {"kind": "manual", "label": "수동 또는 상위 스케줄러 연결 필요", "cron": None}
def _detect_action(text: str, delivery_mode: str) -> str:
normalized = text.replace(" ", "")
if delivery_mode == "briefing" or any(token in normalized for token in ("브리핑", "요약", "정리")):
return "brief"
if any(token in normalized for token in ("감시", "모니터링", "알려줘", "체크")):
return "watch"
return "watch"
def _build_plan_hints(name: str, delivery_mode: str, schedule: dict[str, Any]) -> dict[str, Any]:
command = f'python {SCRIPT_PATH} watch-check "{name}" --json'
if delivery_mode == "alert":
command = f'python {SCRIPT_PATH} watch-check "{name}" --alerts-only --json'
persist_command = f'python {SCRIPT_PATH} watch-upsert {{request_json}}'
return {
"recommended_command": command,
"persist_command_template": persist_command,
"cron": schedule.get("cron"),
"cron_example": f'{schedule["cron"]} {command}' if schedule.get("cron") else None,
}
def parse_watch_request(text: str, *, default_limit: int = 12) -> dict[str, Any]:
intent = parse_search_intent(text, limit=_detect_limit(text, default_limit))
notify_on_new, notify_on_price_drop = _detect_notifications(text)
name = _derive_name(text, intent.keyword)
delivery_mode = _detect_delivery_mode(text)
schedule = _detect_schedule(text)
action = _detect_action(text, delivery_mode)
plan_hints = _build_plan_hints(name, delivery_mode, schedule)
return {
"name": name,
"query": intent.raw_query,
"limit": intent.limit,
"min_price": intent.min_price,
"max_price": intent.max_price,
"notify_on_new": notify_on_new,
"notify_on_price_drop": notify_on_price_drop,
"enabled": "비활성" not in text and "끄기" not in text,
"delivery_mode": delivery_mode,
"action": action,
"schedule": schedule,
"plan_hints": plan_hints,
"intent": intent.to_dict(),
}
def build_integration_bundle(text: str, *, default_limit: int = 12) -> dict[str, Any]:
plan = parse_watch_request(text, default_limit=default_limit)
rule = {k: v for k, v in plan.items() if k != "intent"}
delivery_label = "브리핑" if rule.get("delivery_mode") == "briefing" else "알림"
notification_bits = []
if rule.get("notify_on_new"):
notification_bits.append("신규")
if rule.get("notify_on_price_drop"):
notification_bits.append("가격하락")
schedule = rule.get("schedule") or {}
recommended_command = (rule.get("plan_hints") or {}).get("recommended_command")
persist_command = f'python {SCRIPT_PATH} watch-upsert {text!r}'
cron_payload = None
if schedule.get("cron"):
cron_payload = {
"expr": schedule["cron"],
"command": recommended_command,
"description": f'{rule["name"]} / {schedule.get("label") or schedule["cron"]}',
}
system_event = {
"type": "used-market-watch-check",
"schedule_label": schedule.get("label"),
"rule_name": rule["name"],
"delivery_mode": rule.get("delivery_mode"),
"command": recommended_command,
}
operator_summary = (
f'"{rule["name"]}" 규칙으로 {plan["intent"].get("keyword") or rule.get("query")} 를 '
f'{schedule.get("label") or "수동"} 기준 {delivery_label} 형태로 운영합니다. '
f'알림 조건은 {", ".join(notification_bits) if notification_bits else "없음"}입니다.'
)
user_confirmation = (
f'{rule["name"]}: {schedule.get("label") or "수동 실행"} / {delivery_label} / '
f'{", ".join(notification_bits) if notification_bits else "조건 없음"}으로 설정하면 됩니다.'
)
return {
"kind": "used-market-integration-plan",
"request": text,
"parsed_plan": plan,
"persist": {
"command": persist_command,
"rule_name": rule["name"],
},
"execution": {
"recommended_command": recommended_command,
"cron_payload": cron_payload,
"system_event": system_event,
},
"operator_summary": operator_summary,
"user_confirmation": user_confirmation,
}
FILE:scripts/watch_store.py
from __future__ import annotations
import json
import time
import uuid
from typing import Any
from _paths import WATCH_STATE_FILE
SCHEMA_VERSION = 2
def _normalize_rule(rule: dict[str, Any]) -> dict[str, Any]:
rule.setdefault("delivery_mode", "alert")
rule.setdefault("action", "watch")
rule.setdefault("schedule", {"kind": "manual", "label": "수동 또는 상위 스케줄러 연결 필요", "cron": None})
rule.setdefault("plan_hints", {})
return rule
def load_state() -> dict[str, Any]:
if WATCH_STATE_FILE.exists():
data = json.loads(WATCH_STATE_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
data.setdefault("schema_version", SCHEMA_VERSION)
data.setdefault("rules", [])
data.setdefault("events", [])
data.setdefault("last_seen", {})
data.setdefault("last_checked_at", None)
data["rules"] = [_normalize_rule(rule) for rule in (data.get("rules") or [])]
return data
return {"schema_version": SCHEMA_VERSION, "rules": [], "events": [], "last_seen": {}, "last_checked_at": None}
def save_state(data: dict[str, Any]) -> None:
WATCH_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
WATCH_STATE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def make_rule(*, name: str, query: str, limit: int, min_price: int | None, max_price: int | None, notify_on_new: bool, notify_on_price_drop: bool, enabled: bool = True, delivery_mode: str = "alert", action: str = "watch", schedule: dict[str, Any] | None = None, plan_hints: dict[str, Any] | None = None) -> dict[str, Any]:
now = int(time.time())
return {
"id": f"rule-{uuid.uuid4().hex[:10]}",
"name": name,
"query": query,
"limit": limit,
"min_price": min_price,
"max_price": max_price,
"notify_on_new": bool(notify_on_new),
"notify_on_price_drop": bool(notify_on_price_drop),
"enabled": bool(enabled),
"delivery_mode": delivery_mode,
"action": action,
"schedule": schedule or {"kind": "manual", "label": "수동 또는 상위 스케줄러 연결 필요", "cron": None},
"plan_hints": plan_hints or {},
"created_at": now,
"updated_at": now,
}
def find_rule(state: dict[str, Any], name_or_id: str) -> dict[str, Any] | None:
for rule in state.get("rules") or []:
if rule.get("id") == name_or_id or rule.get("name") == name_or_id:
return rule
return None
def upsert_rule(state: dict[str, Any], rule_data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
now = int(time.time())
existing = find_rule(state, rule_data["name"])
if existing:
existing.update(
{
"query": rule_data["query"],
"limit": int(rule_data.get("limit") or existing.get("limit") or 12),
"min_price": rule_data.get("min_price"),
"max_price": rule_data.get("max_price"),
"notify_on_new": bool(rule_data.get("notify_on_new")),
"notify_on_price_drop": bool(rule_data.get("notify_on_price_drop")),
"enabled": bool(rule_data.get("enabled", True)),
"delivery_mode": rule_data.get("delivery_mode") or existing.get("delivery_mode") or "alert",
"action": rule_data.get("action") or existing.get("action") or "watch",
"schedule": rule_data.get("schedule") or existing.get("schedule") or {"kind": "manual", "label": "수동 또는 상위 스케줄러 연결 필요", "cron": None},
"plan_hints": rule_data.get("plan_hints") or existing.get("plan_hints") or {},
"updated_at": now,
}
)
return _normalize_rule(existing), False
rule = make_rule(
name=rule_data["name"],
query=rule_data["query"],
limit=int(rule_data.get("limit") or 12),
min_price=rule_data.get("min_price"),
max_price=rule_data.get("max_price"),
notify_on_new=bool(rule_data.get("notify_on_new")),
notify_on_price_drop=bool(rule_data.get("notify_on_price_drop")),
enabled=bool(rule_data.get("enabled", True)),
delivery_mode=rule_data.get("delivery_mode") or "alert",
action=rule_data.get("action") or "watch",
schedule=rule_data.get("schedule"),
plan_hints=rule_data.get("plan_hints"),
)
state.setdefault("rules", []).append(rule)
return _normalize_rule(rule), True
def set_rule_enabled(state: dict[str, Any], name_or_id: str, enabled: bool) -> dict[str, Any] | None:
rule = find_rule(state, name_or_id)
if not rule:
return None
rule["enabled"] = bool(enabled)
rule["updated_at"] = int(time.time())
return rule
def remove_rule(state: dict[str, Any], name_or_id: str) -> dict[str, Any] | None:
rules = state.get("rules") or []
for idx, rule in enumerate(rules):
if rule.get("id") == name_or_id or rule.get("name") == name_or_id:
return rules.pop(idx)
return None
FILE:scripts/_paths.py
from __future__ import annotations
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = SKILL_DIR / "data"
REFERENCES_DIR = SKILL_DIR / "references"
DIST_DIR = SKILL_DIR / "dist"
TESTS_DIR = SKILL_DIR / "tests"
WATCH_STATE_FILE = DATA_DIR / "watch-rules.json"
for _path in (DATA_DIR, REFERENCES_DIR, DIST_DIR, TESTS_DIR):
_path.mkdir(parents=True, exist_ok=True)
FILE:tests/test_price_utils.py
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from price_utils import parse_price_kr
def test_parse_price_formats():
assert parse_price_kr("10,000원") == 10000
assert parse_price_kr("10만") == 100000
assert parse_price_kr("2만5천") == 25000
assert parse_price_kr("1.2만") == 12000
assert parse_price_kr("무료나눔") == 0
FILE:tests/test_query_parser.py
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from query_parser import parse_search_intent
def test_parse_markets_and_excludes():
intent = parse_search_intent("당근 번장에서 아이폰 15 프로 max 120만원 이하 -깨짐 제외: 고장")
assert "danggeun" in intent.markets
assert "bunjang" in intent.markets
assert "깨짐" in intent.exclude_terms
assert "고장" in intent.exclude_terms
assert intent.max_price == 1200000
def test_parse_location_and_keyword():
intent = parse_search_intent("잠실 당근마켓에서 맥북 에어 m2 찾아줘")
assert intent.location and "잠실" in intent.location
assert "맥북" in intent.keyword
FILE:tests/test_watch_intent.py
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from watch_intent import build_integration_bundle, parse_watch_request
def test_parse_watch_request_for_new_only():
data = parse_watch_request('"아이폰 신규" 아이폰 15 프로 120만원 이하 당근 번장 신규만 감시 추가')
assert data["name"] == "아이폰 신규"
assert data["notify_on_new"] is True
assert data["notify_on_price_drop"] is False
assert data["max_price"] == 1200000
assert data["delivery_mode"] == "alert"
assert data["schedule"]["kind"] == "manual"
assert "danggeun" in data["intent"]["markets"]
assert "bunjang" in data["intent"]["markets"]
def test_parse_watch_request_for_price_drop_and_limit():
data = parse_watch_request("맥북 에어 m2 가격하락만 감시 5개 잠실")
assert data["notify_on_new"] is False
assert data["notify_on_price_drop"] is True
assert data["limit"] == 5
assert data["intent"]["location"] and "잠실" in data["intent"]["location"]
def test_parse_watch_request_for_hourly_new_watch():
data = parse_watch_request("아이폰 15 프로 1시간마다 신규만 감시해줘")
assert data["notify_on_new"] is True
assert data["notify_on_price_drop"] is False
assert data["schedule"] == {
"kind": "interval",
"every_hours": 1,
"label": "1시간마다",
"cron": "0 */1 * * *",
}
assert data["plan_hints"]["recommended_command"].endswith('--alerts-only --json')
def test_parse_watch_request_for_price_drop_alert():
data = parse_watch_request("맥북 에어 가격 내려가면 알려줘")
assert data["notify_on_new"] is False
assert data["notify_on_price_drop"] is True
assert data["delivery_mode"] == "alert"
def test_parse_watch_request_for_daily_briefing():
data = parse_watch_request("플스5 매일 아침 8시에 브리핑해줘")
assert data["delivery_mode"] == "briefing"
assert data["action"] == "brief"
assert data["schedule"] == {
"kind": "daily",
"hour": 8,
"minute": 0,
"label": "매일 08:00",
"cron": "0 8 * * *",
}
assert data["plan_hints"]["recommended_command"].endswith('--json')
assert '--alerts-only' not in data["plan_hints"]["recommended_command"]
def test_build_integration_bundle_for_hourly_new_listing_watch():
bundle = build_integration_bundle("아이폰 15 프로 신규 매물만 1시간마다 감시해줘")
assert bundle["kind"] == "used-market-integration-plan"
assert bundle["parsed_plan"]["schedule"]["cron"] == "0 */1 * * *"
assert bundle["persist"]["command"].startswith("python skills/used-market-watch/scripts/used_market_watch.py watch-upsert")
assert bundle["execution"]["recommended_command"].endswith('--alerts-only --json')
assert bundle["execution"]["cron_payload"]["expr"] == "0 */1 * * *"
assert bundle["execution"]["system_event"]["type"] == "used-market-watch-check"
assert "설정하면 됩니다" in bundle["user_confirmation"]
FILE:tests/test_watch_logic.py
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from output_utils import render_integration_plan, render_watch_plan
from used_market_watch import _event_counts, _make_alert, _summarize
def test_summarize_by_market():
items = [
{"market": "danggeun", "price_numeric": 10000},
{"market": "danggeun", "price_numeric": 5000},
{"market": "bunjang", "price_numeric": 30000},
]
data = _summarize(items)
assert data["total"] == 3
assert data["by_market"]["danggeun"]["count"] == 2
assert data["by_market"]["danggeun"]["min_price"] == 5000
def test_make_alert_has_previous_fields():
rule = {"id": "r1", "name": "테스트"}
item = {
"market": "danggeun",
"article_key": "danggeun:1",
"title": "아이폰",
"price_text": "100,000원",
"price_numeric": 100000,
"link": "https://example.com",
}
prev = {"price_text": "120,000원", "price_numeric": 120000}
alert = _make_alert(rule, item, "price_drop", prev)
assert alert["event_type"] == "price_drop"
assert alert["previous_price_numeric"] == 120000
def test_event_counts_groups_by_type():
counts = _event_counts([
{"event_type": "new_listing"},
{"event_type": "price_drop"},
{"event_type": "new_listing"},
])
assert counts == {"new_listing": 2, "price_drop": 1}
def test_render_watch_plan_includes_schedule_and_cron_hint():
text = render_watch_plan(
{
"rule": {
"name": "플스5 감시",
"query": "플스5 매일 아침 8시에 브리핑해줘",
"notify_on_new": True,
"notify_on_price_drop": True,
"enabled": True,
"limit": 12,
"delivery_mode": "briefing",
"schedule": {"kind": "daily", "hour": 8, "minute": 0, "label": "매일 08:00", "cron": "0 8 * * *"},
"plan_hints": {"recommended_command": 'python skills/used-market-watch/scripts/used_market_watch.py watch-check "플스5 감시" --json', "cron_example": '0 8 * * * python skills/used-market-watch/scripts/used_market_watch.py watch-check "플스5 감시" --json'},
},
"intent": {"markets": ["danggeun", "bunjang"]},
}
)
assert "실행 주기: 매일 08:00" in text
assert "cron 예시:" in text
assert "권장 실행:" in text
assert "동작: 브리핑" in text
def test_render_integration_plan_includes_save_and_cron_details():
text = render_integration_plan(
{
"request": "아이폰 15 프로 신규 매물만 1시간마다 감시해줘",
"parsed_plan": {
"name": "아이폰 15 프로 감시",
"query": "아이폰 15 프로 신규 매물만 1시간마다 감시해줘",
"notify_on_new": True,
"notify_on_price_drop": False,
"delivery_mode": "alert",
"schedule": {"kind": "interval", "every_hours": 1, "label": "1시간마다", "cron": "0 */1 * * *"},
},
"persist": {"command": 'python skills/used-market-watch/scripts/used_market_watch.py watch-upsert "아이폰 15 프로 신규 매물만 1시간마다 감시해줘"'},
"execution": {
"recommended_command": 'python skills/used-market-watch/scripts/used_market_watch.py watch-check "아이폰 15 프로 감시" --alerts-only --json',
"cron_payload": {"expr": "0 */1 * * *"},
"system_event": {"type": "used-market-watch-check", "rule_name": "아이폰 15 프로 감시", "delivery_mode": "alert"},
},
"operator_summary": "운영 요약",
"user_confirmation": "확인 문구",
}
)
assert "자동화 연동 계획" in text
assert "저장 명령:" in text
assert "cron 제안: 0 */1 * * *" in text
assert "systemEvent 힌트:" in text
FILE:tests/test_watch_store.py
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from watch_store import remove_rule, set_rule_enabled, upsert_rule
def test_upsert_rule_creates_then_updates_existing_name():
state = {"rules": []}
created, is_created = upsert_rule(
state,
{
"name": "아이폰 감시",
"query": "아이폰 15 프로",
"limit": 12,
"min_price": None,
"max_price": 1200000,
"notify_on_new": True,
"notify_on_price_drop": False,
"enabled": True,
"delivery_mode": "alert",
"action": "watch",
"schedule": {"kind": "interval", "every_hours": 1, "label": "1시간마다", "cron": "0 */1 * * *"},
"plan_hints": {"cron_example": '0 */1 * * * python skills/used-market-watch/scripts/used_market_watch.py watch-check "아이폰 감시" --alerts-only --json'},
},
)
assert is_created is True
assert created["schedule"]["kind"] == "interval"
updated, is_created = upsert_rule(
state,
{
"name": "아이폰 감시",
"query": "아이폰 15 프로 max",
"limit": 5,
"min_price": None,
"max_price": 1100000,
"notify_on_new": True,
"notify_on_price_drop": True,
"enabled": False,
"delivery_mode": "briefing",
"action": "brief",
"schedule": {"kind": "daily", "hour": 8, "minute": 0, "label": "매일 08:00", "cron": "0 8 * * *"},
"plan_hints": {"cron_example": '0 8 * * * python skills/used-market-watch/scripts/used_market_watch.py watch-check "아이폰 감시" --json'},
},
)
assert is_created is False
assert created["id"] == updated["id"]
assert updated["query"] == "아이폰 15 프로 max"
assert updated["limit"] == 5
assert updated["enabled"] is False
assert updated["delivery_mode"] == "briefing"
assert updated["schedule"]["kind"] == "daily"
def test_enable_disable_and_remove_rule():
state = {
"rules": [
{
"id": "rule-1",
"name": "맥북 감시",
"query": "맥북",
"limit": 12,
"notify_on_new": True,
"notify_on_price_drop": True,
"enabled": True,
}
]
}
rule = set_rule_enabled(state, "맥북 감시", False)
assert rule and rule["enabled"] is False
removed = remove_rule(state, "rule-1")
assert removed and removed["name"] == "맥북 감시"
assert state["rules"] == []
Search, brief, and monitor 대한민국 Naver News via the Naver Search API using natural-language Korean queries. Use when the user wants 네이버 뉴스 브리핑, 최근 N일 뉴스 요약, 제...
---
name: naver-news-briefing
description: Search, brief, and monitor 대한민국 Naver News via the Naver Search API using natural-language Korean queries. Use when the user wants 네이버 뉴스 브리핑, 최근 N일 뉴스 요약, 제외어 포함 뉴스 검색, 여러 질의를 한 번에 묶은 멀티 브리핑, 키워드 그룹 저장/관리, 특정 키워드의 지속 감시 규칙 추가/목록/삭제, 채팅형 자동화 요청을 watch/group 설정으로 바꾸기, or cron-friendly operator guidance for periodic news checks. Prefer this skill for Korean Naver-news workflows backed by local persistent state.
---
# naver-news-briefing
Use the CLI script at `scripts/naver_news_briefing.py`.
## Onboarding
- Treat Naver Search API credentials as mandatory before the first real use.
- Tell the user early that search/briefing/watch flows will fail until `client_id` and `client_secret` are stored.
- If the user has not completed setup yet, direct them to run:
- `python scripts/naver_news_briefing.py setup --client-id ... --client-secret ...`
- `python scripts/naver_news_briefing.py check-credentials --json`
- Present setup as the first-run path, not an optional advanced step.
- When helping with installation or first use, mention that credentials are stored in `data/config.json` and use DPAPI-backed secret storage on Windows when possible.
## Workflow
1. Store credentials once before any search/brief/watch command.
- `python scripts/naver_news_briefing.py setup --client-id ... --client-secret ...`
- Verify with `python scripts/naver_news_briefing.py check-credentials --json`
2. Run a one-shot briefing.
- `python scripts/naver_news_briefing.py search "최근 3일 반도체 뉴스 브리핑 -광고"`
- Add `--json` for machine-readable output.
3. Manage persistent watch rules with optional operator metadata.
- Add: `python scripts/naver_news_briefing.py watch-add semiconductor "최근 7일 반도체 -광고" --label "반도체 감시" --tag watch --template watch-alert`
- List: `python scripts/naver_news_briefing.py watch-list`
- Remove: `python scripts/naver_news_briefing.py watch-remove semiconductor`
- Check: `python scripts/naver_news_briefing.py watch-check semiconductor --json`
4. Manage persistent keyword groups for recurring briefings.
- Add: `python scripts/naver_news_briefing.py group-add market-watch "최근 3일 반도체 -광고" "오늘 AI 데이터센터 -주가" --label "아침 시장" --tag 테크 --context "오전 보고용" --template morning-briefing`
- List: `python scripts/naver_news_briefing.py group-list`
- Inspect one group: `python scripts/naver_news_briefing.py group-list market-watch --json`
- Update: `python scripts/naver_news_briefing.py group-update market-watch --add-query "배터리 공급망 -광고" --tag 공급망 --template analyst`
- Remove: `python scripts/naver_news_briefing.py group-remove market-watch`
5. Run combined briefings.
- `python scripts/naver_news_briefing.py brief-multi --group market-watch`
- `python scripts/naver_news_briefing.py brief-multi --group market-watch --query "환율 뉴스" --template morning-briefing --json`
- If `--template` is omitted, prefer the saved group template when present.
6. Convert chat-style automation requests into structured plans.
- Inspect plan: `python scripts/naver_news_briefing.py plan "반도체 뉴스 1시간마다 모니터링해줘" --json`
- Build an OpenClaw-friendly integration bundle: `python scripts/naver_news_briefing.py integration-plan "반도체 뉴스 1시간마다 모니터링해줘" --json`
- Support practical Korean patterns such as `매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘` and `증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘`.
7. Materialize plans into persistent configs.
- Watch: `python scripts/naver_news_briefing.py plan-save "반도체 뉴스 1시간마다 모니터링해줘" --as watch --name semiconductor-hourly`
- Group: `python scripts/naver_news_briefing.py plan-save "매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘" --as group --name morning-tech --label "아침 브리핑"`
## Behavior
- Parse positive keywords and `-제외어` using the upstream tab-search policy.
- Interpret recent-news phrases such as `오늘`, `최근 3일`, `최근 2주`, `한달`, `이번주`, `지난주` as a date window and remove that phrase from the API search query.
- Normalize more natural Korean sentence inputs by stripping request phrases, common 조사, duplicate tokens, and Korean exclusion phrases such as `A 말고`, `B 빼고`, `C 제외` into `-제외어` tokens.
- Parse practical schedule intent from Korean chat requests into `interval`, `daily`, `weekly`, or `manual` plans.
- Preserve operator-facing metadata on saved watch/group entries: `label`, `tags`, `template`, `schedule`, `operator_hints`, and original request context.
- Use DPAPI-backed secret storage on Windows when possible.
- Deduplicate watch notifications by `(watch_id, link)` so repeated cron runs emit only newly seen items.
- `plan` returns cron-friendly operator hints, recommended commands, and a storage-target recommendation (`watch` vs `group`).
- `integration-plan` returns a more practical operator bundle: save command, run command, schedule object, cron line, OpenClaw-friendly systemEvent text, and a Korean confirmation summary.
- `plan-save` materializes a parsed plan into a saved `watch` or `group` configuration without owning cron wiring itself.
- `brief-multi` returns chat-friendly combined text by default and structured JSON with `--json`.
## Notes
- Read `references/upstream-notes.md` before major edits.
- The skill uses headline/summary metadata from the Naver Search API. It does not fetch or summarize full article bodies.
- Keep additions additive: preserve existing `search`, `watch-add`, `watch-list`, `watch-remove`, and `watch-check` flows.
- Public user-facing documentation lives in `README.md`; keep it Korean-first and concrete.
FILE:README.md
# naver-news-briefing
네이버 Search API 기반으로 **뉴스 검색 / 브리핑 / 지속 감시 / 키워드 그룹 / 자동화 계획 생성**을 수행하는 OpenClaw 스킬입니다.
> [!IMPORTANT]
> 이 스킬은 **네이버 개발자센터에서 발급받은 Search API 자격증명(client_id, client_secret)이 있어야만 정상 동작**합니다.
> 설치만으로 바로 검색되지는 않으며, **최초 1회 자격증명 입력(onboarding/setup)** 을 반드시 거쳐야 합니다.
이 스킬의 핵심은 단순 검색이 아니라 **채팅형 한국어 요청을 실제 운영 가능한 로컬 설정으로 연결**하는 데 있습니다.
즉, 사람이 이렇게 말하면:
- `반도체 뉴스 1시간마다 모니터링해줘`
- `매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘`
- `증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘`
이 스킬은 이를 **정규화된 질의 + 저장 가능한 watch/group 설정 + cron 연결 힌트 + 추천 실행 명령**으로 바꿉니다.
## 설치 / 링크
- GitHub: <https://github.com/twbeatles/openclaw-naver-news-briefing>
- ClawHub 홈: <https://clawhub.com>
- 설치 명령: `clawhub install naver-news-briefing`
## 대표 예시 요청 모음
- `최근 3일 반도체 뉴스 브리핑해줘`
- `반도체 뉴스 1시간마다 모니터링해줘`
- `매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘`
- `증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘`
- `환율이랑 2차전지 뉴스 묶어서 아침 브리핑용 그룹 만들어줘`
---
## 핵심 기능
- 한국어 자연어 뉴스 검색
- `-제외어` 필터링
- 최근 기간 해석
- `오늘`, `최근 3일`, `최근 2주`, `한달`, `이번주`, `지난주`
- 문장형 한국어 요청 정규화
- 원샷 브리핑
- watch rule 저장 / 목록 / 삭제 / 신규 기사 체크
- 키워드 그룹 저장 / 수정 / 삭제
- 멀티 브리핑 템플릿
- `concise`
- `analyst`
- `morning-briefing`
- `watch-alert`
- 자연어 자동화 계획 파싱
- `interval / daily / weekly / manual`
- 일정 설명 + cron 힌트 + 추천 템플릿 + 추천 저장 대상
- `plan-save`로 watch/group 저장
- **저장 시 운영 메타데이터까지 같이 보존**
- label
- tags
- template
- schedule
- operator_hints
- 기본 텍스트 출력 + `--json` 구조화 출력
---
## 빠른 시작
### 0) 먼저 준비할 것
이 스킬을 처음 쓰는 사용자는 먼저 아래를 준비해야 합니다.
- 네이버 개발자센터에서 발급받은 `client_id`
- 네이버 개발자센터에서 발급받은 `client_secret`
자격증명이 없으면 `search`, `watch-add`, `watch-check`, `brief-multi`, `plan-save` 같은 실제 운영 명령은 정상적으로 끝까지 진행되지 않습니다.
### 1) 최초 온보딩: 자격증명 저장
명령줄 인자를 한 번에 넣는 방식:
```bash
python scripts/naver_news_briefing.py setup --client-id YOUR_ID --client-secret YOUR_SECRET
python scripts/naver_news_briefing.py check-credentials --json
```
더 사용자 친화적인 대화형 방식:
```bash
python scripts/naver_news_briefing.py setup
```
위처럼 `setup`만 실행하면 CLI가 `client_id`, `client_secret`를 순서대로 물어봅니다.
특히 `client_secret`는 화면에 그대로 보이지 않도록 입력됩니다.
저장 직후 실제 API까지 바로 확인하고 싶다면:
```bash
python scripts/naver_news_briefing.py setup --live-check
python scripts/naver_news_briefing.py setup --test-search "최근 1일 반도체 뉴스"
```
설명:
- `setup`은 최초 1회 실행하는 온보딩 명령입니다.
- 입력값에 공백/줄바꿈이 섞였거나 지나치게 짧으면 저장 전에 먼저 알려줍니다.
- 자격증명은 `data/config.json`에 저장됩니다.
- Windows에서는 가능하면 DPAPI 기반으로 `client_secret`를 보호합니다.
- `check-credentials --json`으로 첫 입력이 제대로 끝났는지 바로 검증하세요.
- 일반 텍스트 확인용으로는 `python scripts/naver_news_briefing.py check-credentials` 도 사용할 수 있습니다.
- 실제 API까지 확인하려면 `python scripts/naver_news_briefing.py check-credentials --live-check` 를 사용할 수 있습니다.
### 2) 원샷 브리핑
```bash
python scripts/naver_news_briefing.py search "최근 3일 반도체 뉴스 브리핑 -광고"
```
### 3) 자연어 자동화 계획 확인
```bash
python scripts/naver_news_briefing.py plan "반도체 뉴스 1시간마다 모니터링해줘"
python scripts/naver_news_briefing.py plan "매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘" --json
python scripts/naver_news_briefing.py plan "증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘"
```
### 4) OpenClaw/cron 연동 번들 생성
```bash
python scripts/naver_news_briefing.py integration-plan "반도체 뉴스 1시간마다 모니터링해줘"
python scripts/naver_news_briefing.py integration-plan "매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘" --json
python scripts/naver_news_briefing.py integration-plan "반도체 뉴스 1시간마다 모니터링해줘" --output data/semiconductor-hourly-bundle.json --json
```
이 명령은 한 번에 아래를 만듭니다.
- 해석된 자동화 계획
- 실제 저장 명령 (`plan-save`)
- 반복 실행 명령 (`watch-check` 또는 `brief-multi`)
- cron 한 줄 예시
- OpenClaw cron/systemEvent에 붙이기 좋은 텍스트
- 사용자 확인용 짧은 한국어 문구
### 5) 계획을 실제 설정으로 저장
watch로 저장:
```bash
python scripts/naver_news_briefing.py plan-save "반도체 뉴스 1시간마다 모니터링해줘" --as watch --name semiconductor-hourly
```
group으로 저장:
```bash
python scripts/naver_news_briefing.py plan-save \
"매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘" \
--as group \
--name morning-tech \
--label "아침 브리핑" \
--tag 테크 \
--tag 시장
```
---
## 한국어 요청이 어떻게 해석되는가
### 예시 1) `반도체 뉴스 1시간마다 모니터링해줘`
해석 포인트:
- 작업 유형: `monitor`
- 저장 대상 추천: `watch`
- 주제 질의: `반도체`
- 일정: `1시간마다`
- 템플릿: `watch-alert`
- 후속 실행 추천: `watch-check <name> --json`
권장 운영:
1. `plan`으로 해석 확인
2. `plan-save --as watch`
3. 외부 스케줄러에서 `watch-check`를 1시간마다 실행
4. 신규 기사만 메신저로 전달
### 예시 2) `매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘`
해석 포인트:
- 작업 유형: `briefing`
- 저장 대상 추천: `group`
- 주제 질의: `반도체`, `AI 데이터센터`
- 일정: `매일 07:00`
- 템플릿: `morning-briefing`
- 후속 실행 추천: `brief-multi --group <name> --template morning-briefing`
권장 운영:
1. `plan`으로 schedule / grouping 확인
2. `plan-save --as group`
3. 외부 스케줄러에서 `brief-multi`를 07:00에 실행
4. stdout 텍스트를 텔레그램/디스코드/노션 등으로 전달
### 예시 3) `증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘`
해석 포인트:
- 작업 유형: `monitor`
- 주제 질의: `삼성전자 -증권사 -리포트`
- 일정: `15분마다`로 보수적으로 해석
- 템플릿: `watch-alert`
- 제외어 유지: `증권사`, `리포트`
이 패턴은 실무에서 꽤 중요합니다. “말고 / 빼고 / 제외”가 자동으로 `-제외어`로 정규화되므로, 채팅형 요청을 곧바로 watch로 저장하기 쉽습니다.
---
## CLI 개요
### search
```bash
python scripts/naver_news_briefing.py search "최근 3일 반도체 뉴스 브리핑 -광고"
python scripts/naver_news_briefing.py search "AI 데이터센터 뉴스" --json
```
### watch
```bash
python scripts/naver_news_briefing.py watch-add semiconductor "최근 7일 반도체 -광고"
python scripts/naver_news_briefing.py watch-add samsung-watch "삼성전자 -증권사 -리포트" --label "삼성전자 감시" --tag watch --tag 삼성 --template watch-alert
python scripts/naver_news_briefing.py watch-list
python scripts/naver_news_briefing.py watch-check semiconductor --json
python scripts/naver_news_briefing.py watch-remove semiconductor
```
### group
```bash
python scripts/naver_news_briefing.py group-add market-watch "최근 3일 반도체 -광고" "오늘 AI 데이터센터 -주가" --label "시장 체크" --tag 테크 --template morning-briefing
python scripts/naver_news_briefing.py group-list
python scripts/naver_news_briefing.py group-update market-watch --add-query "배터리 공급망 -광고" --tag 테크 --tag 공급망 --template analyst
python scripts/naver_news_briefing.py group-remove market-watch
```
### brief-multi
```bash
python scripts/naver_news_briefing.py brief-multi --group market-watch --template concise
python scripts/naver_news_briefing.py brief-multi --group market-watch --query "환율 뉴스" --template morning-briefing --json
```
참고:
- `--template`를 주지 않으면 group에 저장된 template를 우선 사용합니다.
- 운영자가 template를 group 단위로 고정해 두면 cron 명령이 짧아집니다.
### plan / integration-plan / plan-save
```bash
python scripts/naver_news_briefing.py plan "반도체 뉴스 1시간마다 모니터링해줘"
python scripts/naver_news_briefing.py integration-plan "매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘" --json
python scripts/naver_news_briefing.py plan-save "증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘" --as watch --name samsung-watch
```
`integration-plan`은 `plan`보다 한 단계 더 나가서 **실행 가능한 연동 번들**을 만듭니다.
대표 필드:
- `storage.save_command`: 상태 저장용 `plan-save` 명령
- `runner.command`: 반복 실행용 핵심 명령
- `automation.schedule`: 스케줄 객체
- `automation.cron_line`: 바로 복사 가능한 cron 한 줄
- `automation.system_event_text`: OpenClaw systemEvent 초안
- `automation.openclaw_prompt`: 작업 생성기에 넘기기 좋은 요약 프롬프트
- `assistant_summary.confirmation`: 사용자에게 확인받기 좋은 짧은 문장
`plan` 출력에는 보통 다음이 들어갑니다.
- 작업 유형: `monitor / briefing / monitor+briefing`
- 해석된 질의 목록
- 일정 종류: `interval / daily / weekly / manual`
- cron 힌트
- 추천 저장 대상: `watch / group`
- 추천 템플릿
- operator hints
- 추천 실행 명령
- 추천 전달 포맷
- cron 예시
- 추천 후속 명령
`plan-save`는 여기에 더해 **실제 DB에 저장되는 watch/group 객체에도 template / schedule / operator_hints / tags / context를 같이 남깁니다.**
---
## 추천 운영 패턴
### 패턴 1) 새 기사 감시형
요청:
- `반도체 뉴스 1시간마다 모니터링해줘`
추천 흐름:
```bash
python scripts/naver_news_briefing.py plan "반도체 뉴스 1시간마다 모니터링해줘"
python scripts/naver_news_briefing.py plan-save "반도체 뉴스 1시간마다 모니터링해줘" --as watch --name semiconductor-hourly
python scripts/naver_news_briefing.py watch-check semiconductor-hourly --json
```
이 방식은 **신규 기사 JSON을 메신저/웹훅 레이어로 넘기기 좋다**는 장점이 있습니다.
### 패턴 2) 아침 브리핑형
요청:
- `매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘`
추천 흐름:
```bash
python scripts/naver_news_briefing.py plan-save \
"매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘" \
--as group --name morning-tech --label "아침 브리핑"
python scripts/naver_news_briefing.py brief-multi --group morning-tech
```
이 패턴은 **group에 template를 저장해 두고 cron에서는 group 이름만 호출**하는 방식이 편합니다.
### 패턴 3) 필터 강한 실무형 감시
요청:
- `증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘`
추천 흐름:
```bash
python scripts/naver_news_briefing.py plan "증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘"
python scripts/naver_news_briefing.py plan-save "증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘" --as watch --name samsung-watch
python scripts/naver_news_briefing.py watch-check samsung-watch --json
```
---
## 온보딩 / 최초 사용 가이드
처음 설치한 사용자는 아래 순서로 시작하면 됩니다.
1. 네이버 Search API 자격증명(`client_id`, `client_secret`)을 준비합니다.
2. `setup` 명령으로 최초 입력을 완료합니다.
3. `check-credentials --json`으로 유효성을 확인합니다.
4. 그 다음에만 `search` 또는 `plan`/`plan-save` 흐름으로 넘어갑니다.
가장 안전한 첫 실행 순서:
```bash
python scripts/naver_news_briefing.py setup --live-check
python scripts/naver_news_briefing.py check-credentials --live-check
python scripts/naver_news_briefing.py search "최근 3일 반도체 뉴스 브리핑"
```
자동화 스크립트나 CI에서는 아래처럼 인자를 직접 넣는 방식도 계속 지원합니다.
```bash
python scripts/naver_news_briefing.py setup --client-id YOUR_ID --client-secret YOUR_SECRET
python scripts/naver_news_briefing.py check-credentials --json
```
만약 자격증명이 설정되지 않았다면, 다른 명령을 먼저 시도하기보다 **다시 온보딩(setup)부터 안내하는 쪽이 맞습니다.**
### 자주 보는 초기 오류
자격증명 없이 바로 `search`나 `watch-check`를 실행하면, 이제 CLI는 단순한 실패 문구 대신 아래 방향으로 안내합니다.
- 네이버 Search API 자격증명이 아직 설정되지 않았다는 점
- 먼저 `setup`을 실행해야 한다는 점
- 이어서 `check-credentials --json`으로 검증하라는 점
- 그 다음에 실제 검색/브리핑 명령으로 넘어가라는 점
즉, **초기 사용자에게는 에러보다 온보딩 안내가 먼저 보이도록** 맞춰 두었습니다.
### 추천 health check 흐름
실제 API가 정상 응답하는지까지 보고 싶다면 아래 흐름이 가장 직관적입니다.
```bash
python scripts/naver_news_briefing.py setup --live-check
python scripts/naver_news_briefing.py check-credentials --live-check
```
원하는 테스트 질의가 있다면:
```bash
python scripts/naver_news_briefing.py setup --test-search "삼성전자 뉴스"
python scripts/naver_news_briefing.py check-credentials --live-check --query "삼성전자 뉴스"
```
## 운영자 가이드
### 1) watch와 group을 구분해서 쓰기
- **watch**: 단일 관심 주제를 새 기사 기준으로 계속 체크할 때
- **group**: 여러 주제를 묶어 반복 브리핑할 때
이 둘을 섞지 말고 역할을 분리하면 운영이 단순해집니다.
### 2) 스케줄은 외부에서, 상태는 이 스킬에서
이 스킬은 다음을 담당합니다.
- 질의 정규화
- 상태 저장
- dedupe
- 브리핑 렌더링
- 일정 의도 해석
- operator-friendly plan 출력
정확한 실행 시각은 아래 같은 외부 레이어에서 붙이세요.
- OpenClaw cron
- Windows 작업 스케줄러
- GitHub Actions
- 별도 Python worker
- PM2 / systemd timer / crontab
### 3) cron 연결 힌트
예를 들어 `plan` 출력이 아래를 주면:
- cron 힌트: `0 7 * * *`
- 추천 실행 명령: `python scripts/naver_news_briefing.py brief-multi --group morning-tech --template morning-briefing`
운영자는 이를 거의 그대로 옮길 수 있습니다.
예시:
```cron
0 7 * * * cd /path/to/workspace/skills/naver-news-briefing && python scripts/naver_news_briefing.py brief-multi --group morning-tech --template morning-briefing
```
watch 예시:
```cron
0 * * * * cd /path/to/workspace/skills/naver-news-briefing && python scripts/naver_news_briefing.py watch-check semiconductor-hourly --json
```
### 4) 저장 메타데이터를 같이 남기는 이유
`plan-save`는 단순히 검색어만 저장하지 않습니다.
아래 메타데이터를 같이 남겨 두면 운영자가 나중에 훨씬 덜 헷갈립니다.
- `label`: 사람이 읽는 용도
- `tags`: 분류 / 운영 필터링
- `template`: 출력 형식 기본값
- `schedule`: 어떤 cadence로 설계됐는지
- `operator_hints`: 추천 실행 명령 / 전달 방식 / cron 예시
- `context`: 원래 사용자의 요청 문장
### 5) 가장 안정적인 질의 형식
자연어도 되지만 아래 구조가 가장 예측 가능하게 동작합니다.
- 기간 표현 + 핵심 키워드 + 제외어
예:
- `최근 7일 반도체 공급망 -광고 -주가`
- `오늘 AI 데이터센터 -리포트`
### 6) dedupe 동작
`watch-check`는 `(watch_id, link)` 기준으로 신규 여부를 판정합니다.
같은 기사 링크는 반복 실행해도 다시 알리지 않습니다.
---
## 저장 파일
- `data/config.json`: API 자격증명 및 기본 설정
- `data/watch_state.db`: watch / group / seen-link 상태
- `references/upstream-notes.md`: upstream 설계 메모
---
## 테스트
```bash
python -m pytest scripts/tests -q
```
---
## 한계
- 기사 본문 크롤링/본문 요약은 하지 않습니다.
- 네이버 Search API의 제목 / 요약 / 링크 / 발행시각 메타데이터 기반으로 동작합니다.
- 자연어 일정 파서는 실무형 요청 위주입니다.
- 주제 없이 `매일 아침 7시에 브리핑해줘`처럼 일정만 있는 요청은 계획은 일부 만들 수 있어도 저장 가능한 watch/group으로는 제한될 수 있습니다.
FILE:references/upstream-notes.md
# Upstream notes: `navernews-tabsearch`
Use these notes when extending or debugging the skill.
## Reused patterns
- `core.query_parser.py`
- Preserve the upstream split between:
- API query = all positive keywords joined by spaces
- tab/db keyword = first positive keyword only
- exclude words = `-단어` tokens
- Preserve `build_fetch_key(search_query, exclude_words)` normalization.
- `core.config_store.py`
- Reuse Windows-first credential handling with DPAPI-encrypted `client_secret_enc` and atomic config writes.
- `core.workers.ApiWorker`
- Reuse request shape: `https://openapi.naver.com/v1/search/news.json`
- Use headers `X-Naver-Client-Id`, `X-Naver-Client-Secret`
- Use `sort=date`, clean `<b>` tags, prefer `news.naver.com` links when present, filter exclude words on title/description.
- Repo persistence style
- Keep state on local disk inside the skill (`data/`), use atomic JSON for config and SQLite for watch state / dedupe.
## Intentional simplifications vs upstream GUI app
- No PyQt UI, tabs, bookmarks, tray, or backup rotation UI.
- No full article database; only persistent watch rules, keyword groups, and seen-link dedupe for cron-style monitoring.
- Adapt the upstream tab-search/bookmark mindset into CLI-friendly persistent keyword groups (`group-*`) plus combined briefing templates (`brief-multi`) instead of recreating GUI tabs.
- Focus on CLI/stdout output that OpenClaw can relay into chat or scheduled jobs.
## Relevant upstream files
- `README.md`
- `core/query_parser.py`
- `core/config_store.py`
- `core/workers.py`
- `core/database.py`
- `tests/test_query_parser_search_policy.py`
FILE:scripts/automation_plans.py
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List
from query_utils import build_intent, clean_natural_query
_MONITOR_WORDS = ["모니터링", "감시", "체크", "알림", "추적", "watch"]
_BRIEF_WORDS = ["브리핑", "요약", "정리", "보고"]
_GROUP_HINTS = ["묶", "그룹", "여러", "같이", "함께", "이랑", "하고", "랑"]
_TEMPLATE_HINTS = {
"concise": ["간단", "짧게", "핵심만", "한눈에"],
"analyst": ["분석", "인사이트", "시사점", "깊게"],
"morning-briefing": ["아침", "출근", "오전 보고", "morning"],
"watch-alert": ["알림", "alert", "실시간", "바로"],
}
_TIME_OF_DAY_HINTS = {
"아침": "08:00",
"오전": "09:00",
"점심": "12:00",
"오후": "15:00",
"저녁": "18:00",
"밤": "21:00",
"새벽": "06:00",
}
_DAYS_OF_WEEK = {"월": "mon", "화": "tue", "수": "wed", "목": "thu", "금": "fri", "토": "sat", "일": "sun"}
_DOW_CRON = {"mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, "sun": 0}
@dataclass(frozen=True)
class SchedulePlan:
kind: str
label: str
cron: str | None = None
interval_minutes: int | None = None
time: str | None = None
days_of_week: List[str] = field(default_factory=list)
@dataclass(frozen=True)
class OperatorHints:
recommended_runner: str
recommended_command: str
delivery_format: str
storage_target: str
cadence_summary: str
cron_examples: List[str] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass(frozen=True)
class AutomationPlan:
raw_request: str
action: str
query_mode: str
queries: List[str]
primary_query: str | None
intent: Dict[str, Any] | None
schedule: SchedulePlan
name_hint: str
template: str
briefing_focus: str
watch_intent: str
group_reason: str | None
rationale: List[str]
suggested_commands: List[str]
operator_hints: OperatorHints
def _normalize_request(raw: str) -> str:
return re.sub(r"\s+", " ", str(raw or "").strip())
def _slugify_korean(text: str) -> str:
cleaned = re.sub(r"[^0-9A-Za-z가-힣]+", "-", str(text or "").strip().lower())
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
return cleaned or "news-plan"
def _detect_action(raw: str) -> str:
has_monitor = any(word in raw for word in _MONITOR_WORDS)
has_brief = any(word in raw for word in _BRIEF_WORDS)
if has_monitor and not has_brief:
return "monitor"
if has_brief and not has_monitor:
return "briefing"
if has_monitor and has_brief:
return "monitor+briefing"
return "briefing"
def _strip_schedule_and_action_phrases(text: str) -> str:
patterns = [
r"\d+\s*시간(?:마다|간격)",
r"\d+\s*분(?:마다|간격)",
r"매일",
r"매주\s*[월화수목금토일]요일?",
r"(?:아침|오전|점심|오후|저녁|밤|새벽)\s*\d{1,2}(?::|시)?\s*\d{0,2}분?(?:\s*에)?",
r"\d{1,2}(?::|시)\s*\d{0,2}분?(?:\s*에)?",
r"실시간",
r"수시로",
r"계속",
r"지속적으로",
r"주기적으로",
r"모니터링해줘|모니터링 해줘|모니터링",
r"감시해줘|감시 해줘|감시",
r"체크해줘|체크 해줘|체크",
r"알림해줘|알림 해줘",
r"브리핑해줘|브리핑 해줘",
r"정리해줘|정리 해줘",
r"요약해줘|요약 해줘",
]
stripped = text
for pattern in patterns:
stripped = re.sub(pattern, " ", stripped)
return _normalize_request(stripped)
def _normalize_query_order(query: str) -> str:
parts = [part for part in str(query or "").split() if part]
positives = [part for part in parts if not part.startswith("-")]
negatives = [part for part in parts if part.startswith("-")]
return " ".join(positives + negatives)
def _extract_queries(raw: str) -> List[str]:
text = _strip_schedule_and_action_phrases(_normalize_request(raw))
split_pattern = r"\s*(?:,|/|\+|그리고|및|이랑|와|과|랑|하고)\s*"
candidates = [part.strip() for part in re.split(split_pattern, text) if part.strip()]
queries: List[str] = []
for candidate in candidates:
cleaned = _normalize_query_order(clean_natural_query(candidate))
if cleaned:
queries.append(cleaned)
deduped: List[str] = []
seen = set()
for query in queries:
key = query.lower()
if key in seen:
continue
seen.add(key)
deduped.append(query)
return deduped
def _detect_explicit_time(text: str) -> str | None:
prefixed = re.search(r"(아침|오전|오후|저녁|밤|새벽)\s*(\d{1,2})(?:[:시]\s*(\d{1,2}))?분?", text)
if prefixed:
prefix = prefixed.group(1)
hour = int(prefixed.group(2))
minute = int(prefixed.group(3) or 0)
if prefix in {"오후", "저녁", "밤"} and hour < 12:
hour += 12
if prefix == "새벽" and hour == 12:
hour = 0
if prefix == "오전" and hour == 12:
hour = 0
return f"{hour:02d}:{minute:02d}"
plain = re.search(r"(\d{1,2})(?:[:시]\s*(\d{1,2}))?분?", text)
if plain:
return f"{int(plain.group(1)):02d}:{int(plain.group(2) or 0):02d}"
return None
def _detect_schedule(raw: str) -> SchedulePlan:
text = _normalize_request(raw)
if re.search(r"실시간|수시로|계속|지속적으로", text):
return SchedulePlan(kind="interval", label="15분마다", cron="*/15 * * * *", interval_minutes=15)
every_n_hours = re.search(r"(\d+)\s*시간(?:마다|간격)", text)
if every_n_hours:
hours = max(1, int(every_n_hours.group(1)))
minutes = hours * 60
cron = f"0 */{hours} * * *" if minutes >= 60 and minutes % 60 == 0 else f"*/{minutes} * * * *"
return SchedulePlan(kind="interval", label=f"{hours}시간마다", cron=cron, interval_minutes=minutes)
every_n_minutes = re.search(r"(\d+)\s*분(?:마다|간격)", text)
if every_n_minutes:
minutes = max(1, int(every_n_minutes.group(1)))
return SchedulePlan(kind="interval", label=f"{minutes}분마다", cron=f"*/{minutes} * * * *", interval_minutes=minutes)
weekly = re.search(r"매주\s*([월화수목금토일])요일?", text)
if weekly:
day = _DAYS_OF_WEEK[weekly.group(1)]
time = _detect_explicit_time(text) or next((v for k, v in _TIME_OF_DAY_HINTS.items() if k in text), "08:00")
hh, mm = time.split(":")
return SchedulePlan(kind="weekly", label=f"매주 {weekly.group(1)}요일 {time}", cron=f"{int(mm)} {int(hh)} * * {_DOW_CRON[day]}", time=time, days_of_week=[day])
if "매일" in text:
time = _detect_explicit_time(text) or next((v for k, v in _TIME_OF_DAY_HINTS.items() if k in text), "08:00")
hh, mm = time.split(":")
return SchedulePlan(kind="daily", label=f"매일 {time}", cron=f"{int(mm)} {int(hh)} * * *", time=time)
return SchedulePlan(kind="manual", label="수동 실행")
def _detect_template(raw: str, action: str, schedule: SchedulePlan) -> str:
for template, words in _TEMPLATE_HINTS.items():
if any(word in raw for word in words):
return template
if action == "monitor":
return "watch-alert"
if schedule.kind == "daily":
return "morning-briefing"
return "concise"
def _detect_briefing_focus(raw: str, template: str) -> str:
if template == "morning-briefing":
return "아침 브리핑"
if any(word in raw for word in ["핵심만", "간단", "짧게"]):
return "핵심 요약"
if any(word in raw for word in ["분석", "시사점", "인사이트"]):
return "분석형 정리"
return "일반 브리핑"
def _detect_watch_intent(raw: str, action: str) -> str:
if "monitor" not in action:
return "none"
if any(word in raw for word in ["계속", "실시간", "수시로", "바로"]):
return "continuous"
if any(word in raw for word in ["모니터링", "감시", "체크", "알림"]):
return "scheduled"
return "manual"
def _suggest_name(queries: List[str], action: str) -> str:
base = queries[0] if queries else action
suffix = "watch" if "monitor" in action else "brief"
return _slugify_korean(f"{base}-{suffix}")
def _build_commands(plan: AutomationPlan) -> List[str]:
commands: List[str] = []
if plan.query_mode == "group":
quoted_queries = " ".join(f'"{query}"' for query in plan.queries)
commands.append(
f'python scripts/naver_news_briefing.py group-add {plan.name_hint} {quoted_queries} --label "자동 생성 그룹" --context "{plan.raw_request}" --template {plan.template}'
)
commands.append(f"python scripts/naver_news_briefing.py brief-multi --group {plan.name_hint} --template {plan.template}")
commands.append(f'python scripts/naver_news_briefing.py plan-save "{plan.raw_request}" --as group --name {plan.name_hint}')
else:
query = plan.primary_query or ""
if "monitor" in plan.action:
commands.append(f'python scripts/naver_news_briefing.py watch-add {plan.name_hint} "{query}" --template {plan.template}')
commands.append(f"python scripts/naver_news_briefing.py watch-check {plan.name_hint} --json")
if "briefing" in plan.action:
commands.append(f'python scripts/naver_news_briefing.py search "{query}"')
commands.append(f'python scripts/naver_news_briefing.py plan-save "{plan.raw_request}" --as watch --name {plan.name_hint}')
return commands
def _build_operator_hints(plan: AutomationPlan) -> OperatorHints:
storage_target = "group" if plan.query_mode == "group" else "watch"
recommended_runner = "cron" if plan.schedule.kind != "manual" else "manual"
if storage_target == "group":
recommended_command = f"python scripts/naver_news_briefing.py brief-multi --group {plan.name_hint} --template {plan.template}"
delivery_format = "chat-briefing"
else:
recommended_command = f"python scripts/naver_news_briefing.py watch-check {plan.name_hint} --json" if "monitor" in plan.action else f'python scripts/naver_news_briefing.py search "{plan.primary_query or ""}"'
delivery_format = "watch-json" if "monitor" in plan.action else "chat-briefing"
notes = [
"스케줄 실행은 이 스킬이 아니라 외부 cron/작업 스케줄러/OpenClaw cron에서 연결하는 방식이 가장 안정적입니다.",
"watch는 새 기사 감지용, group은 반복 브리핑용으로 두면 운영이 단순합니다.",
]
cron_examples: List[str] = []
if plan.schedule.cron:
cron_examples.append(f'{plan.schedule.cron} {recommended_command}')
cron_examples.append(f'{plan.schedule.cron} # 위 명령 실행 후 stdout/json을 텔레그램·디스코드 전송')
return OperatorHints(
recommended_runner=recommended_runner,
recommended_command=recommended_command,
delivery_format=delivery_format,
storage_target=storage_target,
cadence_summary=plan.schedule.label,
cron_examples=cron_examples,
notes=notes,
)
def parse_automation_request(raw: str) -> AutomationPlan:
request = _normalize_request(raw)
action = _detect_action(request)
schedule = _detect_schedule(request)
queries = _extract_queries(request)
query_mode = "group" if len(queries) > 1 or any(hint in request for hint in _GROUP_HINTS) else "single"
primary_query = queries[0] if queries else None
intent = None
rationale: List[str] = []
if primary_query:
built = build_intent(primary_query)
intent = asdict(built)
rationale.append(f"핵심 검색어를 '{built.search_query}'로 정규화했습니다.")
if built.exclude_words:
rationale.append("제외어를 유지해 watch/search 명령으로 바로 연결할 수 있게 했습니다.")
else:
rationale.append("주제 키워드가 없어 저장 가능한 watch/search 명령은 만들지 않았습니다.")
if schedule.kind != "manual":
rationale.append(f"일정 표현을 '{schedule.label}' 구조로 해석했습니다.")
group_reason = None
if query_mode == "group":
group_reason = "여러 주제를 한 번에 반복 브리핑하기 좋은 요청입니다."
rationale.append("여러 주제를 감지해 그룹 기반 브리핑/자동화로 분류했습니다.")
template = _detect_template(request, action, schedule)
plan = AutomationPlan(
raw_request=request,
action=action,
query_mode=query_mode,
queries=queries,
primary_query=primary_query,
intent=intent,
schedule=schedule,
name_hint=_suggest_name(queries, action),
template=template,
briefing_focus=_detect_briefing_focus(request, template),
watch_intent=_detect_watch_intent(request, action),
group_reason=group_reason,
rationale=rationale,
suggested_commands=[],
operator_hints=OperatorHints(
recommended_runner="manual",
recommended_command="",
delivery_format="text",
storage_target="watch" if query_mode == "single" else "group",
cadence_summary=schedule.label,
),
)
commands = _build_commands(plan)
hints = _build_operator_hints(plan)
return AutomationPlan(**{**asdict(plan), "suggested_commands": commands, "operator_hints": hints, "schedule": plan.schedule})
def render_plan_text(plan: AutomationPlan) -> str:
lines = ["## 뉴스 자동화 계획", f"- 요청: {plan.raw_request}", f"- 작업 유형: {plan.action}", f"- 일정: {plan.schedule.label}"]
if plan.queries:
lines.append("- 해석된 질의: " + ", ".join(plan.queries))
lines.append(f"- 저장 대상 추천: {plan.operator_hints.storage_target}")
if plan.name_hint:
lines.append(f"- 저장 이름 제안: {plan.name_hint}")
lines.append(f"- 추천 템플릿: {plan.template} ({plan.briefing_focus})")
lines.append(f"- watch 의도: {plan.watch_intent}")
if plan.schedule.cron:
lines.append(f"- cron 힌트: {plan.schedule.cron}")
if plan.group_reason:
lines.append(f"- 그룹 판단: {plan.group_reason}")
if plan.rationale:
lines.append("- 해석 근거:")
lines.extend(f" - {item}" for item in plan.rationale)
lines.append("- 운영 힌트:")
lines.append(f" - 실행 방식: {plan.operator_hints.recommended_runner}")
lines.append(f" - 추천 실행 명령: {plan.operator_hints.recommended_command}")
for note in plan.operator_hints.notes:
lines.append(f" - {note}")
if plan.operator_hints.cron_examples:
lines.append("- cron 예시:")
lines.extend(f" - {item}" for item in plan.operator_hints.cron_examples)
if plan.suggested_commands:
lines.append("- 추천 명령:")
lines.extend(f" - {cmd}" for cmd in plan.suggested_commands)
return "\n".join(lines)
def plan_to_dict(plan: AutomationPlan) -> Dict[str, Any]:
payload = asdict(plan)
payload["schedule"] = asdict(plan.schedule)
payload["operator_hints"] = asdict(plan.operator_hints)
return payload
def build_integration_bundle(raw: str, *, skill_dir: str | Path | None = None, assistant_channel: str = "telegram") -> Dict[str, Any]:
plan = parse_automation_request(raw)
skill_root = Path(skill_dir).resolve() if skill_dir else Path(__file__).resolve().parents[1]
cli_rel = str(skill_root / "scripts" / "naver_news_briefing.py")
save_as = "group" if plan.query_mode == "group" else "watch"
save_command = f'python scripts/naver_news_briefing.py plan-save "{plan.raw_request}" --as {save_as} --name {plan.name_hint}'
run_command = plan.operator_hints.recommended_command
shell_run = f'cd "{skill_root}" && {run_command}'
shell_save = f'cd "{skill_root}" && {save_command}'
schedule_payload = {
"kind": plan.schedule.kind,
"label": plan.schedule.label,
"cron": plan.schedule.cron,
"time": plan.schedule.time,
"interval_minutes": plan.schedule.interval_minutes,
"days_of_week": plan.schedule.days_of_week,
}
confirmation = f"'{plan.raw_request}' 요청을 {plan.schedule.label} {plan.operator_hints.storage_target} 자동화로 해석했습니다. 저장 이름은 '{plan.name_hint}'를 추천하고, 저장 뒤에는 '{run_command}'를 스케줄러에 연결하면 됩니다."
cadence_phrase = plan.schedule.label if plan.schedule.label.endswith("마다") else f"{plan.schedule.label}마다"
system_event_text = (
f"{cadence_phrase} {assistant_channel} 채널에 네이버 뉴스 자동화를 실행하세요. "
f"먼저 {save_command} 로 상태를 저장하고, 이후 {run_command} 결과를 전달합니다."
if plan.schedule.kind != "manual"
else f"필요할 때 {run_command} 를 수동 실행하는 네이버 뉴스 브리핑 요청입니다."
)
openclaw_prompt = (
"다음 뉴스 자동화 계획을 기준으로 cron/작업을 생성하세요.\n"
f"- 사용자 요청: {plan.raw_request}\n"
f"- 저장 명령: {save_command}\n"
f"- 실행 명령: {run_command}\n"
f"- 일정: {json.dumps(schedule_payload, ensure_ascii=False)}\n"
f"- 전달 포맷: {plan.operator_hints.delivery_format}\n"
f"- 확인 문구: {confirmation}"
)
bundle = {
"plan": plan_to_dict(plan),
"storage": {
"target": save_as,
"name": plan.name_hint,
"save_command": save_command,
"shell_save_command": shell_save,
},
"runner": {
"command": run_command,
"shell_command": shell_run,
"delivery_format": plan.operator_hints.delivery_format,
"assistant_channel": assistant_channel,
},
"automation": {
"schedule": schedule_payload,
"cron_line": f"{plan.schedule.cron} {shell_run}" if plan.schedule.cron else None,
"system_event_text": system_event_text,
"openclaw_prompt": openclaw_prompt,
},
"assistant_summary": {
"confirmation": confirmation,
"user_summary": confirmation,
"next_step": "저장 명령을 한 번 실행한 뒤 cron/OpenClaw 작업에서 실행 명령을 연결하세요.",
},
"artifacts": {
"skill_dir": str(skill_root),
"cli_path": cli_rel,
},
}
return bundle
def render_integration_bundle_text(bundle: Dict[str, Any]) -> str:
plan = bundle["plan"]
storage = bundle["storage"]
runner = bundle["runner"]
automation = bundle["automation"]
assistant_summary = bundle["assistant_summary"]
lines = [
"## OpenClaw 연동 번들",
f"- 요청: {plan['raw_request']}",
f"- 저장 대상: {storage['target']} ({storage['name']})",
f"- 일정: {automation['schedule']['label']}",
f"- 저장 명령: {storage['save_command']}",
f"- 실행 명령: {runner['command']}",
]
if automation.get("cron_line"):
lines.append(f"- cron 한 줄 예시: {automation['cron_line']}")
lines.append("- OpenClaw systemEvent 제안:")
lines.append(f" {automation['system_event_text']}")
lines.append("- OpenClaw 작업 생성용 프롬프트:")
for line in str(automation["openclaw_prompt"]).splitlines():
lines.append(f" {line}")
lines.append("- 사용자 확인 문구:")
lines.append(f" {assistant_summary['confirmation']}")
return "\n".join(lines)
FILE:scripts/briefing_templates.py
from __future__ import annotations
import json
from collections import Counter
from typing import Any, Dict, List
TEMPLATES = {
"concise": "핵심만 빠르게 묶는 짧은 브리핑",
"analyst": "질문별 핵심 흐름과 시사점을 조금 더 분석적으로 정리",
"morning-briefing": "아침 보고용으로 전체 동향과 체크포인트를 정리",
"watch-alert": "감시/알림 용도로 신규 기사 중심으로 짧게 정리",
}
def supported_templates() -> List[str]:
return list(TEMPLATES.keys())
def build_combined_payload(entries: List[Dict[str, Any]], *, template: str, source_groups: List[Dict[str, Any]] | None = None) -> Dict[str, Any]:
publishers = Counter()
total_items = 0
total_filtered = 0
total_too_old = 0
for entry in entries:
result = entry["result"]
total_items += len(result.get("items", []))
total_filtered += int(result.get("filtered_out", 0) or 0)
total_too_old += int(result.get("too_old", 0) or 0)
for item in result.get("items", []):
publisher = str(item.get("publisher", "") or "").strip()
if publisher:
publishers[publisher] += 1
top_publishers = [{"publisher": name, "count": count} for name, count in publishers.most_common(5)]
return {
"template": template,
"entry_count": len(entries),
"item_count": total_items,
"filtered_out": total_filtered,
"too_old": total_too_old,
"top_publishers": top_publishers,
"groups": source_groups or [],
"entries": entries,
}
def _header(payload: Dict[str, Any]) -> List[str]:
lines = [f"네이버 뉴스 멀티 브리핑 · {payload['template']}"]
lines.append(f"- 쿼리 수: {payload['entry_count']} / 기사 수: {payload['item_count']}")
if payload.get("filtered_out"):
lines.append(f"- 제외어 필터링: {payload['filtered_out']}건")
if payload.get("too_old"):
lines.append(f"- 기간 제외: {payload['too_old']}건")
if payload.get("groups"):
group_names = ", ".join(group["name"] for group in payload["groups"])
lines.append(f"- 그룹: {group_names}")
top_publishers = payload.get("top_publishers") or []
if top_publishers:
lines.append("- 많이 보인 출처: " + ", ".join(f"{item['publisher']} {item['count']}건" for item in top_publishers[:3]))
return lines
def _entry_title(entry: Dict[str, Any]) -> str:
title_bits = []
if entry.get("group_name"):
title_bits.append(f"[{entry['group_name']}]")
title_bits.append(entry["query"])
if entry.get("label"):
title_bits.append(f"({entry['label']})")
return " ".join(title_bits)
def _entry_items(entry: Dict[str, Any], *, limit: int) -> List[str]:
result = entry["result"]
items = result.get("items", [])[:limit]
if not items:
return ["- 표시할 기사가 없습니다."]
lines: List[str] = []
for item in items:
title = str(item.get("title", "") or "").strip()
publisher = str(item.get("publisher", "정보 없음") or "정보 없음")
lines.append(f"- [{publisher}] {title}")
if item.get("link"):
lines.append(f" 링크: {item['link']}")
return lines
def render_combined_text(payload: Dict[str, Any]) -> str:
template = payload["template"]
lines = _header(payload)
lines.append("")
if template == "concise":
for entry in payload["entries"]:
lines.append(f"## {_entry_title(entry)}")
lines.extend(_entry_items(entry, limit=2))
lines.append("")
elif template == "analyst":
lines.append("관찰 포인트")
for entry in payload["entries"]:
result = entry["result"]
lines.append(f"## {_entry_title(entry)}")
lines.append(f"- 노출 기사: {result.get('displayed', 0)} / 전체 결과: {result.get('total', 0)}")
if entry.get("context"):
lines.append(f"- 맥락: {entry['context']}")
lines.extend(_entry_items(entry, limit=3))
lines.append("")
elif template == "morning-briefing":
lines.append("오늘 체크할 흐름")
for entry in payload["entries"]:
result = entry["result"]
headline = result.get("items", [{}])[0].get("title") if result.get("items") else "기사 없음"
lines.append(f"- {_entry_title(entry)}: {headline}")
lines.append("")
lines.append("세부 기사")
for entry in payload["entries"]:
lines.append(f"## {_entry_title(entry)}")
lines.extend(_entry_items(entry, limit=2))
lines.append("")
elif template == "watch-alert":
alert_entries = [entry for entry in payload["entries"] if entry["result"].get("displayed", 0)]
if not alert_entries:
lines.append("- 신규/표시 가능한 기사가 없습니다.")
for entry in alert_entries:
lines.append(f"## ALERT · {_entry_title(entry)}")
lines.extend(_entry_items(entry, limit=2))
lines.append("")
else:
raise ValueError(f"지원하지 않는 템플릿입니다: {template}")
return "\n".join(line for line in lines).strip()
def render_combined_json(payload: Dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2)
FILE:scripts/config_store.py
from __future__ import annotations
import base64
import ctypes
import json
import os
import sys
import tempfile
from typing import Any, Dict, Mapping, Tuple
from _paths import CONFIG_PATH, ensure_data_dir
DEFAULT_CONFIG: Dict[str, Any] = {
"naver_api": {
"client_id": "",
"client_secret": "",
"client_secret_enc": "",
"client_secret_storage": "plain",
"timeout": 15,
}
}
def _is_windows_platform() -> bool:
return sys.platform == "win32"
def _normalize_secret_storage(value: Any) -> str:
return "dpapi" if str(value or "").strip().lower() == "dpapi" else "plain"
def _dpapi_encrypt_text(text: str) -> str:
if not _is_windows_platform() or not text:
return ""
from ctypes import wintypes
class DATA_BLOB(ctypes.Structure):
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
source = text.encode("utf-8")
source_buffer = (ctypes.c_byte * len(source)).from_buffer_copy(source)
in_blob = DATA_BLOB(len(source), ctypes.cast(source_buffer, ctypes.POINTER(ctypes.c_byte)))
out_blob = DATA_BLOB()
crypt32 = ctypes.windll.crypt32
kernel32 = ctypes.windll.kernel32
ok = crypt32.CryptProtectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob))
if not ok:
return ""
try:
encrypted = ctypes.string_at(out_blob.pbData, out_blob.cbData)
return base64.b64encode(encrypted).decode("ascii")
finally:
if out_blob.pbData:
kernel32.LocalFree(out_blob.pbData)
def _dpapi_decrypt_text(payload: str) -> str:
if not _is_windows_platform() or not payload:
return ""
from ctypes import wintypes
raw = base64.b64decode(payload)
class DATA_BLOB(ctypes.Structure):
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
source_buffer = (ctypes.c_byte * len(raw)).from_buffer_copy(raw)
in_blob = DATA_BLOB(len(raw), ctypes.cast(source_buffer, ctypes.POINTER(ctypes.c_byte)))
out_blob = DATA_BLOB()
crypt32 = ctypes.windll.crypt32
kernel32 = ctypes.windll.kernel32
ok = crypt32.CryptUnprotectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob))
if not ok:
return ""
try:
return ctypes.string_at(out_blob.pbData, out_blob.cbData).decode("utf-8")
finally:
if out_blob.pbData:
kernel32.LocalFree(out_blob.pbData)
def encode_client_secret_for_storage(client_secret: str) -> Dict[str, str]:
plain = str(client_secret or "").strip()
if not plain:
return {"client_secret": "", "client_secret_enc": "", "client_secret_storage": "plain"}
if _is_windows_platform():
encrypted = _dpapi_encrypt_text(plain)
if encrypted:
return {"client_secret": "", "client_secret_enc": encrypted, "client_secret_storage": "dpapi"}
return {"client_secret": plain, "client_secret_enc": "", "client_secret_storage": "plain"}
def resolve_client_secret_for_runtime(settings: Mapping[str, Any]) -> Tuple[str, bool]:
plain = str(settings.get("client_secret", "") or "")
encrypted = str(settings.get("client_secret_enc", "") or "")
storage = _normalize_secret_storage(settings.get("client_secret_storage", "plain"))
if _is_windows_platform() and encrypted and storage == "dpapi":
decrypted = _dpapi_decrypt_text(encrypted)
if decrypted:
return decrypted, bool(plain)
return plain, bool(_is_windows_platform() and plain)
def _write_text_atomic(path: str, text: str) -> None:
directory = os.path.dirname(os.path.abspath(path)) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".config_", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
if not text.endswith("\n"):
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def load_config() -> Dict[str, Any]:
ensure_data_dir()
if not CONFIG_PATH.exists():
return json.loads(json.dumps(DEFAULT_CONFIG))
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
merged = json.loads(json.dumps(DEFAULT_CONFIG))
if isinstance(raw, dict):
merged.update(raw)
if isinstance(raw.get("naver_api"), dict):
merged["naver_api"].update(raw["naver_api"])
return merged
def save_config(config: Dict[str, Any]) -> None:
ensure_data_dir()
_write_text_atomic(str(CONFIG_PATH), json.dumps(config, indent=2, ensure_ascii=False))
def set_credentials(client_id: str, client_secret: str, timeout: int = 15) -> Dict[str, Any]:
config = load_config()
encoded = encode_client_secret_for_storage(client_secret)
config["naver_api"].update(
{
"client_id": str(client_id or "").strip(),
"client_secret": encoded["client_secret"],
"client_secret_enc": encoded["client_secret_enc"],
"client_secret_storage": encoded["client_secret_storage"],
"timeout": max(5, min(60, int(timeout))),
}
)
save_config(config)
return config
def get_runtime_credentials() -> Tuple[str, str, int, Dict[str, Any]]:
config = load_config()
settings = config["naver_api"]
client_secret, needs_migration = resolve_client_secret_for_runtime(settings)
if needs_migration and client_secret:
updated = set_credentials(str(settings.get("client_id", "")), client_secret, int(settings.get("timeout", 15)))
settings = updated["naver_api"]
client_secret, _ = resolve_client_secret_for_runtime(settings)
return str(settings.get("client_id", "")).strip(), client_secret.strip(), int(settings.get("timeout", 15)), config
FILE:scripts/group_store.py
from __future__ import annotations
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime
from typing import Any, Dict, Iterable, List
from _paths import DB_PATH, ensure_data_dir
SCHEMA = """
CREATE TABLE IF NOT EXISTS keyword_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
label TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
context TEXT,
template TEXT,
schedule_json TEXT NOT NULL DEFAULT '{}',
operator_hint_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS group_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
position INTEGER NOT NULL,
raw_query TEXT NOT NULL,
FOREIGN KEY (group_id) REFERENCES keyword_groups(id) ON DELETE CASCADE,
UNIQUE(group_id, position)
);
"""
@contextmanager
def connect():
ensure_data_dir()
conn = sqlite3.connect(DB_PATH)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
columns = {row[1] for row in conn.execute("PRAGMA table_info(keyword_groups)").fetchall()}
migrations = {
"template": "ALTER TABLE keyword_groups ADD COLUMN template TEXT",
"schedule_json": "ALTER TABLE keyword_groups ADD COLUMN schedule_json TEXT NOT NULL DEFAULT '{}'",
"operator_hint_json": "ALTER TABLE keyword_groups ADD COLUMN operator_hint_json TEXT NOT NULL DEFAULT '{}'",
}
for column, sql in migrations.items():
if column not in columns:
conn.execute(sql)
yield conn
conn.commit()
finally:
conn.close()
def _normalize_tags(tags: Iterable[str] | None) -> List[str]:
seen = set()
normalized: List[str] = []
for tag in tags or []:
value = str(tag or "").strip()
if not value or value in seen:
continue
seen.add(value)
normalized.append(value)
return normalized
def _serialize_group_row(row: sqlite3.Row | tuple[Any, ...], queries: List[str]) -> Dict[str, Any]:
return {
"id": row[0],
"name": row[1],
"label": row[2],
"tags": json.loads(row[3] or "[]"),
"context": row[4],
"template": row[5],
"schedule": json.loads(row[6] or "{}"),
"operator_hints": json.loads(row[7] or "{}"),
"created_at": row[8],
"updated_at": row[9],
"queries": queries,
"query_count": len(queries),
}
def _fetch_queries(conn: sqlite3.Connection, group_id: int) -> List[str]:
rows = conn.execute("SELECT raw_query FROM group_queries WHERE group_id = ? ORDER BY position, id", (group_id,)).fetchall()
return [row[0] for row in rows]
def list_groups() -> List[Dict[str, Any]]:
with connect() as conn:
rows = conn.execute("SELECT id, name, label, tags_json, context, template, schedule_json, operator_hint_json, created_at, updated_at FROM keyword_groups ORDER BY name").fetchall()
return [_serialize_group_row(row, _fetch_queries(conn, row[0])) for row in rows]
def get_group(name_or_id: str | int) -> Dict[str, Any]:
field = "id" if isinstance(name_or_id, int) or str(name_or_id).isdigit() else "name"
with connect() as conn:
row = conn.execute(
f"SELECT id, name, label, tags_json, context, template, schedule_json, operator_hint_json, created_at, updated_at FROM keyword_groups WHERE {field} = ?",
(int(name_or_id) if field == "id" else name_or_id,),
).fetchone()
if not row:
raise KeyError(f"keyword group not found: {name_or_id}")
return _serialize_group_row(row, _fetch_queries(conn, row[0]))
def create_group(*, name: str, queries: List[str], label: str | None = None, tags: Iterable[str] | None = None, context: str | None = None, template: str | None = None, schedule: Dict[str, Any] | None = None, operator_hints: Dict[str, Any] | None = None) -> Dict[str, Any]:
clean_queries = [str(query or "").strip() for query in queries if str(query or "").strip()]
if not clean_queries:
raise ValueError("키워드 그룹에는 최소 1개 이상의 쿼리가 필요합니다.")
now = datetime.now().isoformat(timespec="seconds")
tag_values = _normalize_tags(tags)
with connect() as conn:
cur = conn.execute(
"INSERT INTO keyword_groups(name, label, tags_json, context, template, schedule_json, operator_hint_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(name, label, json.dumps(tag_values, ensure_ascii=False), context, template, json.dumps(schedule or {}, ensure_ascii=False), json.dumps(operator_hints or {}, ensure_ascii=False), now, now),
)
group_id = int(cur.lastrowid)
for idx, query in enumerate(clean_queries, start=1):
conn.execute("INSERT INTO group_queries(group_id, position, raw_query) VALUES (?, ?, ?)", (group_id, idx, query))
return get_group(name)
def update_group(name_or_id: str | int, *, label: str | None = None, context: str | None = None, tags: Iterable[str] | None | object = None, template: str | None = None, schedule: Dict[str, Any] | None = None, operator_hints: Dict[str, Any] | None = None, replace_queries: List[str] | None = None, add_queries: List[str] | None = None, remove_queries: List[str] | None = None) -> Dict[str, Any]:
group = get_group(name_or_id)
group_id = group["id"]
new_label = group["label"] if label is None else label
new_context = group["context"] if context is None else context
new_template = group["template"] if template is None else template
new_schedule = group["schedule"] if schedule is None else schedule
new_operator_hints = group["operator_hints"] if operator_hints is None else operator_hints
new_tags = group["tags"] if tags is None else _normalize_tags(tags) # type: ignore[arg-type]
queries = list(group["queries"])
if replace_queries is not None:
queries = [str(query or "").strip() for query in replace_queries if str(query or "").strip()]
else:
for query in add_queries or []:
cleaned = str(query or "").strip()
if cleaned and cleaned not in queries:
queries.append(cleaned)
for query in remove_queries or []:
cleaned = str(query or "").strip()
queries = [item for item in queries if item != cleaned]
if not queries:
raise ValueError("키워드 그룹에는 최소 1개 이상의 쿼리가 남아 있어야 합니다.")
now = datetime.now().isoformat(timespec="seconds")
with connect() as conn:
conn.execute(
"UPDATE keyword_groups SET label = ?, tags_json = ?, context = ?, template = ?, schedule_json = ?, operator_hint_json = ?, updated_at = ? WHERE id = ?",
(new_label, json.dumps(new_tags, ensure_ascii=False), new_context, new_template, json.dumps(new_schedule, ensure_ascii=False), json.dumps(new_operator_hints, ensure_ascii=False), now, group_id),
)
conn.execute("DELETE FROM group_queries WHERE group_id = ?", (group_id,))
for idx, query in enumerate(queries, start=1):
conn.execute("INSERT INTO group_queries(group_id, position, raw_query) VALUES (?, ?, ?)", (group_id, idx, query))
return get_group(group_id)
def remove_group(name_or_id: str | int) -> int:
field = "id" if isinstance(name_or_id, int) or str(name_or_id).isdigit() else "name"
with connect() as conn:
cur = conn.execute(f"DELETE FROM keyword_groups WHERE {field} = ?", (int(name_or_id) if field == "id" else name_or_id,))
return int(cur.rowcount or 0)
FILE:scripts/naver_api.py
from __future__ import annotations
import html
import json
import re
import urllib.parse
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Dict, List, Protocol
import requests
RE_BOLD_TAGS = re.compile(r"</?b>")
API_URL = "https://openapi.naver.com/v1/search/news.json"
class ResponseLike(Protocol):
status_code: int
text: str
def json(self) -> Dict[str, Any]: ...
class SessionLike(Protocol):
def get(self, url: str, *, headers: Dict[str, str], params: Dict[str, Any], timeout: int) -> ResponseLike: ...
@dataclass
class NewsItem:
title: str
description: str
link: str
original_link: str
publisher: str
pub_date: str
pub_date_iso: str | None
def to_dict(self) -> Dict[str, Any]:
return self.__dict__.copy()
def parse_pub_date(value: str) -> str | None:
text = str(value or "").strip()
if not text:
return None
try:
dt = parsedate_to_datetime(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone().isoformat(timespec="seconds")
except Exception:
return None
def clean_item(item: Dict[str, Any]) -> NewsItem:
title = html.unescape(RE_BOLD_TAGS.sub("", str(item.get("title", ""))))
desc = html.unescape(RE_BOLD_TAGS.sub("", str(item.get("description", ""))))
naver_link = str(item.get("link", "") or "")
original_link = str(item.get("originallink", "") or "")
if "news.naver.com" in naver_link:
final_link = naver_link
elif "news.naver.com" in original_link:
final_link = original_link
else:
final_link = naver_link or original_link
publisher = "정보 없음"
if original_link:
publisher = urllib.parse.urlparse(original_link).netloc.replace("www.", "") or publisher
elif final_link:
publisher = "네이버뉴스" if "news.naver.com" in final_link else urllib.parse.urlparse(final_link).netloc.replace("www.", "")
return NewsItem(
title=title,
description=desc,
link=final_link,
original_link=original_link,
publisher=publisher or "정보 없음",
pub_date=str(item.get("pubDate", "") or ""),
pub_date_iso=parse_pub_date(str(item.get("pubDate", "") or "")),
)
def fetch_news(
*,
client_id: str,
client_secret: str,
search_query: str,
exclude_words: List[str],
limit: int = 10,
days: int | None = None,
timeout: int = 15,
session: SessionLike | None = None,
) -> Dict[str, Any]:
if not search_query.strip():
raise ValueError("검색어가 비어 있습니다.")
if not client_id.strip() or not client_secret.strip():
raise ValueError("네이버 API 자격증명이 설정되지 않았습니다.")
active_session = session or requests.Session()
resp = active_session.get(
API_URL,
headers={
"X-Naver-Client-Id": client_id.strip(),
"X-Naver-Client-Secret": client_secret.strip(),
},
params={"query": search_query, "display": min(max(limit, 10), 100), "start": 1, "sort": "date"},
timeout=timeout,
)
if resp.status_code != 200:
try:
payload = resp.json()
msg = f"API 오류 {resp.status_code} ({payload.get('errorCode', '')}): {payload.get('errorMessage', '알 수 없는 오류')}"
except Exception:
msg = f"API 오류 {resp.status_code}: {getattr(resp, 'text', '')[:200]}"
raise RuntimeError(msg)
data = resp.json()
exclude_words_lc = [w.lower() for w in exclude_words if w]
cutoff = None
if days:
cutoff = datetime.now().astimezone().timestamp() - (days * 86400)
items: List[NewsItem] = []
filtered_out = 0
too_old = 0
for raw in data.get("items", []):
item = clean_item(raw)
text_blob = f"{item.title}\n{item.description}".lower()
if exclude_words_lc and any(ex in text_blob for ex in exclude_words_lc):
filtered_out += 1
continue
if cutoff is not None and item.pub_date_iso:
try:
if datetime.fromisoformat(item.pub_date_iso).timestamp() < cutoff:
too_old += 1
continue
except Exception:
pass
items.append(item)
if len(items) >= limit:
break
return {
"query": search_query,
"exclude_words": exclude_words,
"days": days,
"total": int(data.get("total", 0) or 0),
"displayed": len(items),
"filtered_out": filtered_out,
"too_old": too_old,
"items": [item.to_dict() for item in items],
"raw_meta": {"lastBuildDate": data.get("lastBuildDate", "")},
}
FILE:scripts/naver_news_briefing.py
from __future__ import annotations
import argparse
import getpass
import html
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from automation_plans import (
build_integration_bundle,
parse_automation_request,
plan_to_dict,
render_integration_bundle_text,
render_plan_text,
)
from briefing_templates import build_combined_payload, render_combined_json, render_combined_text, supported_templates
from config_store import get_runtime_credentials, set_credentials
from group_store import create_group, get_group, list_groups, remove_group, update_group
from naver_api import fetch_news
from query_utils import build_intent
from watch_store import add_rule, get_rule, list_rules, mark_seen, remove_rule
MISSING_CREDENTIALS_ERROR = "네이버 API 자격증명이 설정되지 않았습니다."
DEFAULT_TEST_QUERY = "최근 1일 반도체 뉴스"
ANNOUNCE_PREVIEW_LIMIT = 10
def _strip_html(value: str) -> str:
text = html.unescape(str(value or ""))
text = re.sub(r"<[^>]+>", "", text)
return " ".join(text.split()).strip()
def _brief_lines(result: Dict[str, Any], *, title: str | None = None) -> List[str]:
lines: List[str] = []
heading = title or f"네이버 뉴스 브리핑: {result['query']}"
suffix = []
if result.get("exclude_words"):
suffix.append("제외=" + ", ".join(result["exclude_words"]))
if result.get("days"):
suffix.append(f"최근 {result['days']}일")
meta = f" ({'; '.join(suffix)})" if suffix else ""
lines.append(heading + meta)
lines.append(f"- 총 검색 결과: {result.get('total', 0)}")
lines.append(f"- 출력 기사 수: {result.get('displayed', 0)}")
if result.get("filtered_out"):
lines.append(f"- 제외어로 걸러진 수: {result['filtered_out']}")
if result.get("too_old"):
lines.append(f"- 기간 조건으로 제외된 수: {result['too_old']}")
items = result.get("items", [])
if not items:
lines.append("- 새로 보여줄 기사가 없습니다.")
return lines
for idx, item in enumerate(items, start=1):
publisher = item.get("publisher", "정보 없음")
pub_date = item.get("pub_date", "")
lines.append(f"{idx}. [{publisher}] {item.get('title', '').strip()}")
if item.get("description"):
lines.append(f" - {item['description'].strip()[:180]}")
if pub_date:
lines.append(f" - 발행: {pub_date}")
if item.get("link"):
lines.append(f" - 링크: {item['link']}")
return lines
def _print_payload(payload: Any, *, as_json: bool, render_text=None) -> None:
if as_json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(render_text(payload) if render_text else payload)
def _format_watch_text(rule: Dict[str, Any]) -> str:
extra = []
if rule.get("days"):
extra.append(f"최근 {rule['days']}일")
if rule.get("exclude_words"):
extra.append("제외=" + ", ".join(rule["exclude_words"]))
if rule.get("template"):
extra.append(f"template={rule['template']}")
if rule.get("schedule", {}).get("label"):
extra.append(f"schedule={rule['schedule']['label']}")
lines = [f"- {rule['name']}: {rule['raw_query']}" + (f" ({'; '.join(extra)})" if extra else "")]
if rule.get("label"):
lines.append(f" label: {rule['label']}")
if rule.get("tags"):
lines.append(" tags: " + ", ".join(rule["tags"]))
if rule.get("context"):
lines.append(f" context: {rule['context']}")
return "\n".join(lines)
def _prompt_required(prompt: str, *, secret: bool = False) -> str:
while True:
value = (getpass.getpass(prompt) if secret else input(prompt)).strip()
if value:
return value
print("값이 비어 있습니다. 다시 입력해 주세요.")
def _validate_credential_value(name: str, value: str) -> str:
cleaned = str(value or "").strip()
if not cleaned:
raise ValueError(f"{name} 값이 비어 있습니다.")
if any(ch.isspace() for ch in cleaned):
raise ValueError(f"{name} 값에 공백이 포함되어 있습니다. 복사 과정에서 줄바꿈/공백이 들어갔는지 확인해 주세요.")
if len(cleaned) < 5:
raise ValueError(f"{name} 값이 너무 짧습니다. 네이버 개발자센터에서 발급받은 값을 다시 확인해 주세요.")
return cleaned
def _perform_live_check(query: str, *, limit: int = 1) -> Dict[str, Any]:
client_id, client_secret, timeout, _ = get_runtime_credentials()
return fetch_news(
client_id=client_id,
client_secret=client_secret,
search_query=query,
exclude_words=[],
limit=limit,
days=None,
timeout=timeout,
)
def _render_setup_success(timeout: int, *, live_checked: bool = False, live_query: str | None = None) -> str:
lines = [
"네이버 Search API 자격증명을 저장했습니다.",
"- 저장 위치: data/config.json",
f"- 요청 타임아웃: {timeout}초",
]
if live_checked:
lines.append(f"- 라이브 API 확인: 성공 ({live_query or DEFAULT_TEST_QUERY})")
else:
lines.append("- 라이브 API 확인: 생략")
lines.extend(
[
"- 다음 단계: python scripts/naver_news_briefing.py check-credentials --json",
"- 그 다음: python scripts/naver_news_briefing.py search \"최근 3일 반도체 뉴스 브리핑\"",
]
)
return "\n".join(lines)
def cmd_setup(args: argparse.Namespace) -> int:
client_id = (args.client_id or "").strip()
client_secret = (args.client_secret or "").strip()
if not client_id:
print("client_id가 없어 대화형 입력으로 전환합니다.")
client_id = _prompt_required("네이버 client_id: ")
if not client_secret:
print("client_secret이 없어 대화형 입력으로 전환합니다.")
client_secret = _prompt_required("네이버 client_secret: ", secret=True)
client_id = _validate_credential_value("client_id", client_id)
client_secret = _validate_credential_value("client_secret", client_secret)
set_credentials(client_id, client_secret, args.timeout)
live_checked = False
live_query = args.test_search or (DEFAULT_TEST_QUERY if args.live_check else None)
if live_query:
result = _perform_live_check(live_query, limit=1)
live_checked = True
if args.json:
print(json.dumps({
"saved": True,
"timeout": args.timeout,
"live_check": {
"query": live_query,
"ok": True,
"displayed": result.get("displayed", 0),
"total": result.get("total", 0),
},
}, ensure_ascii=False, indent=2))
return 0
print(_render_setup_success(args.timeout, live_checked=live_checked, live_query=live_query))
return 0
def _render_check_credentials_text(payload: Dict[str, Any]) -> str:
lines = [
"네이버 Search API 자격증명이 설정되어 있습니다." if payload["configured"] else "아직 네이버 Search API 자격증명이 설정되지 않았습니다.",
f"- client_id 저장 여부: {'예' if payload['client_id_present'] else '아니오'}",
f"- client_secret 저장 여부: {'예' if payload['client_secret_present'] else '아니오'}",
f"- 요청 타임아웃: {payload['timeout']}초",
]
live_check = payload.get("live_check")
if isinstance(live_check, dict):
if live_check.get("ok"):
lines.append(f"- 라이브 API 확인: 성공 ({live_check.get('query')})")
lines.append(f"- 테스트 결과: total={live_check.get('total', 0)}, displayed={live_check.get('displayed', 0)}")
else:
lines.append(f"- 라이브 API 확인: 실패 ({live_check.get('query')})")
if live_check.get("error"):
lines.append(f"- 오류: {live_check['error']}")
if payload["configured"]:
lines.append("- 다음 단계: search / watch / brief-multi / plan-save 를 실행하면 됩니다.")
else:
lines.append("- 먼저 실행: python scripts/naver_news_briefing.py setup")
lines.append("- 확인: python scripts/naver_news_briefing.py check-credentials --json")
return "\n".join(lines)
def cmd_check_credentials(args: argparse.Namespace) -> int:
client_id, client_secret, timeout, _ = get_runtime_credentials()
ok = bool(client_id and client_secret)
payload: Dict[str, Any] = {"configured": ok, "client_id_present": bool(client_id), "client_secret_present": bool(client_secret), "timeout": timeout}
if ok and args.live_check:
query = args.query or DEFAULT_TEST_QUERY
try:
result = _perform_live_check(query, limit=1)
payload["live_check"] = {
"query": query,
"ok": True,
"displayed": result.get("displayed", 0),
"total": result.get("total", 0),
}
except Exception as exc:
payload["live_check"] = {"query": query, "ok": False, "error": str(exc)}
ok = False
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else _render_check_credentials_text(payload))
return 0 if ok else 1
def _render_missing_credentials_guidance() -> str:
return "\n".join(
[
"네이버 Search API 자격증명이 아직 설정되지 않았습니다.",
"먼저 최초 온보딩(setup)을 완료해 주세요.",
"",
"실행 순서:",
"1) python scripts/naver_news_briefing.py setup --client-id YOUR_ID --client-secret YOUR_SECRET",
"2) python scripts/naver_news_briefing.py check-credentials --json",
"3) 그 다음 search / watch / brief-multi / plan-save 명령을 실행",
"",
"참고: 자격증명은 data/config.json 에 저장되며 Windows에서는 가능하면 DPAPI로 보호합니다.",
]
)
def _dedupe_preserve_order(values: List[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
ordered.append(value)
return ordered
def _format_missing_named_resource(resource_label: str, missing_name: str, available_names: List[str]) -> str:
lines = [f"등록된 {resource_label}을(를) 찾지 못했습니다: {missing_name}"]
if available_names:
lines.append("현재 등록된 항목: " + ", ".join(available_names))
else:
lines.append("현재 등록된 항목이 없습니다.")
return "\n".join(lines)
def _format_exception_message(exc: Exception) -> str:
message = str(exc).strip() or exc.__class__.__name__
if MISSING_CREDENTIALS_ERROR in message:
return _render_missing_credentials_guidance()
if "keyword group not found:" in message:
missing_name = message.split("keyword group not found:", 1)[1].strip().strip("'")
available = [group.get("name") for group in list_groups() if group.get("name")]
return _format_missing_named_resource("키워드 그룹", missing_name, available)
if "watch rule not found:" in message:
missing_name = message.split("watch rule not found:", 1)[1].strip().strip("'")
available = [rule.get("name") for rule in list_rules() if rule.get("name")]
return _format_missing_named_resource("watch rule", missing_name, available)
return f"ERROR: {message}"
def run_search(query: str, *, limit: int, days: int | None, as_json: bool) -> int:
intent = build_intent(query, limit=limit, days=days)
client_id, client_secret, timeout, _ = get_runtime_credentials()
result = fetch_news(client_id=client_id, client_secret=client_secret, search_query=intent.search_query, exclude_words=intent.exclude_words, limit=intent.limit, days=intent.days, timeout=timeout)
result["intent"] = {"db_keyword": intent.db_keyword, "fetch_key": intent.fetch_key, "raw_query": intent.raw_query}
if as_json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("\n".join(_brief_lines(result)))
return 0
def cmd_search(args: argparse.Namespace) -> int:
return run_search(args.query, limit=args.limit, days=args.days, as_json=args.json)
def cmd_watch_add(args: argparse.Namespace) -> int:
intent = build_intent(args.query, limit=args.limit, days=args.days)
rule = add_rule(
name=args.name,
raw_query=intent.raw_query,
search_query=intent.search_query,
db_keyword=intent.db_keyword,
exclude_words=intent.exclude_words,
fetch_key=intent.fetch_key,
days=intent.days,
limit=intent.limit,
label=args.label,
tags=args.tag,
context=args.context,
template=args.template,
)
_print_payload(rule, as_json=args.json, render_text=_format_watch_text)
return 0
def cmd_watch_list(args: argparse.Namespace) -> int:
rules = list_rules()
if args.json:
print(json.dumps(rules, ensure_ascii=False, indent=2))
return 0
if not rules:
print("등록된 watch rule이 없습니다.")
return 0
print("\n\n".join(_format_watch_text(rule) for rule in rules))
return 0
def cmd_watch_remove(args: argparse.Namespace) -> int:
deleted = remove_rule(args.name_or_id)
if deleted:
print(f"삭제 완료: {args.name_or_id}")
return 0
print(f"삭제 대상 없음: {args.name_or_id}")
return 1
def _run_rule(rule: Dict[str, Any]) -> Dict[str, Any]:
client_id, client_secret, timeout, _ = get_runtime_credentials()
result = fetch_news(client_id=client_id, client_secret=client_secret, search_query=rule["search_query"], exclude_words=rule["exclude_words"], limit=rule["limit"], days=rule.get("days"), timeout=timeout)
new_items = mark_seen(rule["id"], result["items"])
return {
"rule": rule,
"summary": {
"query": result["query"],
"total": result["total"],
"displayed": result["displayed"],
"new_count": len(new_items),
"filtered_out": result["filtered_out"],
"too_old": result["too_old"],
},
"new_items": new_items,
"all_items": result["items"],
}
def _format_watch_status_lines(entry: Dict[str, Any]) -> List[str]:
rule = entry["rule"]
summary = entry["summary"]
items = entry.get("all_items", [])
now = datetime.now().strftime("%m월 %d일 %H:%M")
query = summary.get("query") or rule.get("search_query") or rule.get("name")
current_top_count = len(items)
new_count = summary["new_count"]
lines = [
f"## {rule['name']}",
f"- {now} 기준, '{query}' 관련 상위 {current_top_count}건을 확인했고 이번 체크에서 신규 기사 {new_count}건이 추가됐습니다.",
f"- 전체 검색 결과: {summary.get('total', 0)}건",
f"- 이번 확인 신규 기사: {new_count}건",
f"- 현재 검색 상위 기사 수: {current_top_count}건",
]
if items:
latest_pub = next((item.get("pub_date_iso") or item.get("pub_date") for item in items if item.get("pub_date_iso") or item.get("pub_date")), None)
if latest_pub:
lines.append(f"- 현재 기준 최신 기사 시각: {latest_pub}")
return lines
def cmd_watch_check(args: argparse.Namespace) -> int:
targets = [get_rule(args.name_or_id)] if args.name_or_id else list_rules()
payload = [_run_rule(rule) for rule in targets]
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
if not payload:
print("체크할 watch rule이 없습니다.")
return 0
if getattr(args, "announce_text", False):
if len(payload) != 1:
print("NO_REPLY")
return 0
entry = payload[0]
new_items = entry.get("new_items", [])
if not new_items:
print("NO_REPLY")
return 0
summary = entry.get("summary", {})
new_count = summary.get("new_count", len(new_items))
latest_iso = new_items[0].get("pub_date_iso")
latest_text = latest_iso.replace("T", " ")[:16] if latest_iso else "최신 없음"
preview_items = new_items[:ANNOUNCE_PREVIEW_LIMIT]
lines = [
f"확인 요약: 새 뉴스 {new_count}건, 현재 검색 상위 {summary.get('displayed', 0)}건 / 전체 {summary.get('total', 0)}건, 최신 {latest_text}"
]
for item in preview_items:
title = _strip_html(item.get("title", "제목 없음"))
publisher = item.get("publisher") or "정보 없음"
pub_iso = item.get("pub_date_iso")
pub_text = pub_iso.replace("T", " ")[:16] if pub_iso else item.get("pub_date", "시간 없음")
link = item.get("link") or item.get("original_link") or ""
lines.append(f"- {title} / {publisher} / {pub_text} / {link}")
extra_count = max(0, new_count - len(preview_items))
if extra_count > 0:
lines.append(f"- 외 추가 새 뉴스 {extra_count}건")
print("\n".join(lines))
return 0
lines: List[str] = []
for entry in payload:
rule = entry["rule"]
lines.extend(_format_watch_status_lines(entry))
lines.extend(_brief_lines({
"query": rule["search_query"],
"exclude_words": rule["exclude_words"],
"days": rule.get("days"),
"total": entry["summary"]["total"],
"displayed": len(entry["new_items"]),
"filtered_out": entry["summary"]["filtered_out"],
"too_old": entry["summary"]["too_old"],
"items": entry["new_items"],
}, title=f"watch: {rule['name']} 신규 기사 요약"))
lines.append("")
print("\n".join(lines).strip())
return 0
def _format_group_text(group: Dict[str, Any]) -> str:
lines = [f"- {group['name']} ({group['query_count']}개 쿼리)"]
if group.get("label"):
lines.append(f" label: {group['label']}")
if group.get("tags"):
lines.append(" tags: " + ", ".join(group["tags"]))
if group.get("template"):
lines.append(f" template: {group['template']}")
if group.get("schedule", {}).get("label"):
lines.append(f" schedule: {group['schedule']['label']}")
if group.get("context"):
lines.append(f" context: {group['context']}")
for idx, query in enumerate(group.get("queries", []), start=1):
lines.append(f" {idx}. {query}")
return "\n".join(lines)
def cmd_group_add(args: argparse.Namespace) -> int:
group = create_group(name=args.name, queries=args.query, label=args.label, tags=args.tag, context=args.context, template=args.template)
_print_payload(group, as_json=args.json, render_text=_format_group_text)
return 0
def cmd_group_list(args: argparse.Namespace) -> int:
groups = [get_group(args.name_or_id)] if args.name_or_id else list_groups()
if args.json:
print(json.dumps(groups if not args.name_or_id else groups[0], ensure_ascii=False, indent=2))
return 0
if not groups:
print("등록된 키워드 그룹이 없습니다.")
return 0
print("\n\n".join(_format_group_text(group) for group in groups))
return 0
def cmd_group_remove(args: argparse.Namespace) -> int:
deleted = remove_group(args.name_or_id)
if deleted:
print(f"삭제 완료: {args.name_or_id}")
return 0
print(f"삭제 대상 없음: {args.name_or_id}")
return 1
def cmd_group_update(args: argparse.Namespace) -> int:
tags = None
if args.tag is not None or args.clear_tags:
tags = [] if args.clear_tags else args.tag
group = update_group(args.name_or_id, label=args.label, context=args.context, tags=tags, template=args.template, replace_queries=args.set_query, add_queries=args.add_query, remove_queries=args.remove_query)
_print_payload(group, as_json=args.json, render_text=_format_group_text)
return 0
def _run_query_entry(query: str, *, limit: int, days: int | None, group: Dict[str, Any] | None = None, label: str | None = None, context: str | None = None) -> Dict[str, Any]:
intent = build_intent(query, limit=limit, days=days)
client_id, client_secret, timeout, _ = get_runtime_credentials()
result = fetch_news(client_id=client_id, client_secret=client_secret, search_query=intent.search_query, exclude_words=intent.exclude_words, limit=intent.limit, days=intent.days, timeout=timeout)
result["intent"] = {"db_keyword": intent.db_keyword, "fetch_key": intent.fetch_key, "raw_query": intent.raw_query}
return {
"query": query,
"search_query": intent.search_query,
"exclude_words": intent.exclude_words,
"days": intent.days,
"group_name": group["name"] if group else None,
"label": label or (group.get("label") if group else None),
"tags": group.get("tags", []) if group else [],
"context": context or (group.get("context") if group else None),
"result": result,
}
def cmd_brief_multi(args: argparse.Namespace) -> int:
entries: List[Dict[str, Any]] = []
groups: List[Dict[str, Any]] = []
template = args.template
for group_name in args.group or []:
group = get_group(group_name)
groups.append(group)
if not args.template and group.get("template"):
template = group["template"]
for query in group["queries"]:
entries.append(_run_query_entry(query, limit=args.limit, days=args.days, group=group))
for query in args.query or []:
entries.append(_run_query_entry(query, limit=args.limit, days=args.days))
if not entries:
raise ValueError("brief-multi에는 --query 또는 --group이 최소 1개 이상 필요합니다.")
payload = build_combined_payload(entries, template=template, source_groups=groups)
if args.json:
print(render_combined_json(payload))
else:
print(render_combined_text(payload))
return 0
def cmd_plan(args: argparse.Namespace) -> int:
plan = parse_automation_request(args.request)
_print_payload(plan_to_dict(plan), as_json=args.json, render_text=lambda _: render_plan_text(plan))
return 0
def cmd_integration_plan(args: argparse.Namespace) -> int:
bundle = build_integration_bundle(
args.request,
skill_dir=args.skill_dir or str(Path(__file__).resolve().parents[1]),
assistant_channel=args.channel,
)
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as fp:
json.dump(bundle, fp, ensure_ascii=False, indent=2)
_print_payload(bundle, as_json=args.json, render_text=render_integration_bundle_text)
return 0
def cmd_plan_save(args: argparse.Namespace) -> int:
plan = parse_automation_request(args.request)
created: Dict[str, Any] = {"plan": plan_to_dict(plan), "created": []}
name = args.name or plan.name_hint
label = args.label or ("아침 브리핑" if plan.template == "morning-briefing" else None)
tags = list(args.tag or [])
if plan.schedule.kind != "manual":
tags.append(plan.schedule.kind)
if plan.watch_intent != "none":
tags.append("watch")
if plan.query_mode == "group":
tags.append("group")
tags = _dedupe_preserve_order(tags)
if not plan.queries:
raise ValueError("저장 가능한 주제 키워드를 찾지 못했습니다. 요청에 관심 주제를 포함해 주세요.")
if args.as_type == "watch" and plan.query_mode == "group":
raise ValueError("여러 주제를 포함한 그룹형 브리핑 요청입니다. --as group 으로 저장해 주세요.")
if args.as_type == "group" or plan.query_mode == "group":
group = create_group(
name=name,
queries=plan.queries,
label=label,
tags=tags,
context=plan.raw_request,
template=plan.template,
schedule=plan_to_dict(plan)["schedule"],
operator_hints=plan_to_dict(plan)["operator_hints"],
)
created["created"].append({"type": "group", "value": group})
else:
intent = build_intent(plan.primary_query or plan.queries[0])
rule = add_rule(
name=name,
raw_query=intent.raw_query,
search_query=intent.search_query,
db_keyword=intent.db_keyword,
exclude_words=intent.exclude_words,
fetch_key=intent.fetch_key,
days=intent.days,
limit=intent.limit,
label=label,
tags=tags,
context=plan.raw_request,
template=plan.template,
schedule=plan_to_dict(plan)["schedule"],
operator_hints=plan_to_dict(plan)["operator_hints"],
)
created["created"].append({"type": "watch", "value": rule})
_print_payload(created, as_json=args.json, render_text=lambda payload: render_plan_text(plan) + "\n- 저장 결과:\n" + "\n".join(f" - {item['type']}: {item['value']['name']}" for item in payload['created']))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Naver news briefing skill CLI")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("setup", help="네이버 API 자격증명 저장")
p.add_argument("--client-id")
p.add_argument("--client-secret")
p.add_argument("--timeout", type=int, default=15)
p.add_argument("--live-check", action="store_true", help="저장 직후 실제 API 호출로 자격증명을 확인")
p.add_argument("--test-search", help="저장 직후 테스트할 검색어 (설정 시 라이브 체크 수행)")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_setup)
p = sub.add_parser("check-credentials", help="자격증명 상태 확인")
p.add_argument("--json", action="store_true")
p.add_argument("--live-check", action="store_true", help="실제 API 호출까지 포함해 확인")
p.add_argument("--query", help="라이브 체크용 검색어")
p.set_defaults(func=cmd_check_credentials)
p = sub.add_parser("search", help="원샷 뉴스 검색/브리핑")
p.add_argument("query")
p.add_argument("--limit", type=int, default=10)
p.add_argument("--days", type=int)
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_search)
p = sub.add_parser("watch-add", help="watch rule 추가")
p.add_argument("name")
p.add_argument("query")
p.add_argument("--limit", type=int, default=10)
p.add_argument("--days", type=int)
p.add_argument("--label")
p.add_argument("--tag", action="append")
p.add_argument("--context")
p.add_argument("--template", choices=supported_templates())
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_watch_add)
p = sub.add_parser("watch-list", help="watch rule 목록")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_watch_list)
p = sub.add_parser("watch-remove", help="watch rule 삭제")
p.add_argument("name_or_id")
p.set_defaults(func=cmd_watch_remove)
p = sub.add_parser("watch-check", help="watch rule 신규 기사 체크")
p.add_argument("name_or_id", nargs="?")
p.add_argument("--json", action="store_true")
p.add_argument("--announce-text", action="store_true", help="cron/announce friendly plain text output")
p.set_defaults(func=cmd_watch_check)
p = sub.add_parser("group-add", help="키워드 그룹 추가")
p.add_argument("name")
p.add_argument("query", nargs="+")
p.add_argument("--label")
p.add_argument("--tag", action="append")
p.add_argument("--context")
p.add_argument("--template", choices=supported_templates())
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_group_add)
p = sub.add_parser("group-list", help="키워드 그룹 조회/목록")
p.add_argument("name_or_id", nargs="?")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_group_list)
p = sub.add_parser("group-remove", help="키워드 그룹 삭제")
p.add_argument("name_or_id")
p.set_defaults(func=cmd_group_remove)
p = sub.add_parser("group-update", help="키워드 그룹 수정")
p.add_argument("name_or_id")
p.add_argument("--label")
p.add_argument("--context")
p.add_argument("--template", choices=supported_templates())
p.add_argument("--tag", action="append")
p.add_argument("--clear-tags", action="store_true")
p.add_argument("--set-query", action="append")
p.add_argument("--add-query", action="append")
p.add_argument("--remove-query", action="append")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_group_update)
p = sub.add_parser("brief-multi", help="여러 쿼리/그룹을 묶어 한 번에 브리핑")
p.add_argument("--query", action="append")
p.add_argument("--group", action="append")
p.add_argument("--limit", type=int, default=5)
p.add_argument("--days", type=int)
p.add_argument("--template", choices=supported_templates())
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_brief_multi)
p = sub.add_parser("plan", help="채팅형 자연어 요청을 자동화 계획으로 변환")
p.add_argument("request")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_plan)
p = sub.add_parser("integration-plan", help="자연어 요청을 OpenClaw/cron 연동 번들로 변환")
p.add_argument("request")
p.add_argument("--channel", default="telegram")
p.add_argument("--skill-dir")
p.add_argument("--output")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_integration_plan)
p = sub.add_parser("plan-save", help="자연어 요청을 해석해 watch/group 설정으로 저장")
p.add_argument("request")
p.add_argument("--name")
p.add_argument("--as", dest="as_type", choices=["watch", "group"])
p.add_argument("--label")
p.add_argument("--tag", action="append")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_plan_save)
return parser
def main(argv: List[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except Exception as exc:
print(_format_exception_message(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/query_utils.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import List, Tuple
# Adapted from upstream core.query_parser.py
_REQUEST_PHRASES = [
"뉴스",
"브리핑",
"브리핑해줘",
"브리핑 해줘",
"요약",
"요약해줘",
"요약해 줘",
"검색",
"검색해줘",
"검색해 줘",
"찾아줘",
"찾아 줘",
"알려줘",
"알려 줘",
"정리해줘",
"정리해 줘",
"보여줘",
"보여 줘",
"뽑아줘",
"뽑아 줘",
"체크해줘",
"체크해 줘",
"모아줘",
"모아 줘",
"보고싶어",
"보고 싶어",
"궁금해",
"궁금한",
"관련해서",
"관련 기사",
"관련 뉴스",
"관련",
"대해서",
"대해",
"관한",
"중심으로",
"위주로",
"위주",
"핵심만",
"핵심",
"동향",
]
_RECENT_TOKEN_DEFAULTS = {
"오늘": 1,
"금일": 1,
"어제": 2,
"최근": 7,
"최신": 7,
"요즘": 7,
"이번주": 7,
"이번 주": 7,
"지난주": 14,
"지난 주": 14,
"일주일": 7,
"한주": 7,
"한 주": 7,
"보름": 15,
"한달": 30,
"한 달": 30,
"한달간": 30,
"한 달간": 30,
}
_TOKEN_STOPWORDS = {
"관련",
"관련해서",
"관련된",
"대해",
"대해서",
"대한",
"기사",
"뉴스",
"브리핑",
"검색",
"요약",
"정리",
"핵심",
"동향",
"위주",
"중심",
"중심으로",
"위주로",
"알려줘",
"찾아줘",
"보여줘",
"정리해줘",
"요약해줘",
"브리핑해줘",
"검색해줘",
"해줘",
"만",
"최근",
"최신",
"오늘",
"금일",
"어제",
"이번주",
"지난주",
"일주일",
"한주",
"한달",
"한달간",
"보름",
}
_KOREAN_PARTICLES = [
"에서는",
"에선",
"으로는",
"로는",
"에게는",
"한테는",
"과는",
"와는",
"으로",
"에서",
"에게",
"한테",
"까지",
"부터",
"처럼",
"보다",
"마저",
"조차",
"이라도",
"라도",
"이나",
"나",
"은",
"는",
"이",
"가",
"을",
"를",
"에",
"의",
"과",
"와",
"도",
"만",
]
def _split_query_tokens(raw: str) -> Tuple[List[str], List[str]]:
parts = str(raw or "").split()
if not parts:
return [], []
positive_words: List[str] = []
exclude_words: List[str] = []
for token in parts:
if token.startswith("-"):
if len(token) > 1:
exclude_words.append(token[1:])
continue
positive_words.append(token)
return positive_words, exclude_words
def parse_tab_query(raw: str) -> Tuple[str, List[str]]:
positive_words, exclude_words = _split_query_tokens(raw)
db_keyword = positive_words[0] if positive_words else ""
return db_keyword, exclude_words
def parse_search_query(raw: str) -> Tuple[str, List[str]]:
positive_words, exclude_words = _split_query_tokens(raw)
search_query = " ".join(positive_words)
return search_query, exclude_words
def build_fetch_key(search_keyword: str, exclude_words: List[str]) -> str:
normalized_keyword = (search_keyword or "").strip().lower()
normalized_excludes = sorted(
{
word.strip().lower()
for word in (exclude_words or [])
if isinstance(word, str) and word.strip()
}
)
return f"{normalized_keyword}|{'|'.join(normalized_excludes)}"
@dataclass(frozen=True)
class QueryIntent:
raw_query: str
search_query: str
db_keyword: str
exclude_words: List[str]
fetch_key: str
days: int | None
limit: int
def detect_recent_days(raw: str) -> int | None:
lowered = str(raw or "").lower()
import re
patterns = [
r"최근\s*(\d+)\s*일",
r"(\d+)\s*일\s*(내|이내)",
r"최근\s*(\d+)\s*주",
r"(\d+)\s*주\s*(내|이내)",
r"최근\s*(\d+)\s*(?:개월|달)",
r"(\d+)\s*(?:개월|달)\s*(내|이내)",
]
for pattern in patterns:
m = re.search(pattern, raw)
if not m:
continue
value = int(m.group(1))
if "주" in pattern:
value *= 7
elif "개월" in pattern or "달" in pattern:
value *= 30
return max(1, min(365, value))
for token, days in _RECENT_TOKEN_DEFAULTS.items():
if token in raw:
return days
if "today" in lowered:
return 1
if "this week" in lowered or "recent" in lowered or "latest" in lowered:
return 7
if "last week" in lowered:
return 14
return None
def _normalize_spacing(raw: str) -> str:
import re
text = str(raw or "").strip()
text = re.sub(r"[\[\]\(\){}<>\"'“”‘’,:;!?/~`]+", " ", text)
text = text.replace("·", " ").replace("…", " ")
text = re.sub(r"\s+", " ", text)
return text.strip()
def _apply_exclude_phrases(raw: str) -> str:
import re
text = _normalize_spacing(raw)
patterns = [
r"([가-힣A-Za-z0-9+#./_-]+(?:\s+[가-힣A-Za-z0-9+#./_-]+){0,2})\s*(?:말고|빼고|제외|제외하고|제외한|없이)",
r"([가-힣A-Za-z0-9+#./_-]+(?:\s+[가-힣A-Za-z0-9+#./_-]+){0,2})\s*(?:은|는|을|를)?\s*빼면",
]
def repl(match: re.Match[str]) -> str:
words = [w for w in match.group(1).split() if w]
if not words:
return " "
return " " + " ".join(f"-{word}" for word in words) + " "
for pattern in patterns:
text = re.sub(pattern, repl, text)
return _normalize_spacing(text)
def _strip_particle(token: str) -> str:
stripped = token.strip()
if stripped.startswith("-"):
head = "-"
stripped = stripped[1:]
else:
head = ""
if not stripped:
return token
for particle in _KOREAN_PARTICLES:
if stripped.endswith(particle) and len(stripped) > len(particle) + 1:
stripped = stripped[: -len(particle)]
break
return head + stripped
def _normalize_token(token: str) -> str:
import re
token = _strip_particle(token)
token = token.strip("- ") if token == "-" else token.strip()
token = re.sub(r"^[^\w가-힣+#]+|[^\w가-힣+#-]+$", "", token)
token = token.strip()
if token.startswith("-"):
body = token[1:].strip()
body = re.sub(r"^[^\w가-힣+#]+|[^\w가-힣+#-]+$", "", body)
return f"-{body}" if body else ""
return token
def clean_natural_query(raw: str) -> str:
import re
stripped = _apply_exclude_phrases(raw)
for token in _REQUEST_PHRASES:
stripped = stripped.replace(token, " ")
stripped = re.sub(r"\b(today|latest|recent|this week|last week)\b", " ", stripped, flags=re.IGNORECASE)
stripped = re.sub(r"최근\s*\d+\s*(?:일|주|개월|달)", " ", stripped)
stripped = re.sub(r"\d+\s*(?:일|주|개월|달)\s*(?:내|이내)", " ", stripped)
stripped = re.sub(r"(?:일주일|한\s*주|한\s*달|한달|보름|이번\s*주|지난\s*주)", " ", stripped)
stripped = re.sub(r"\b\d+\b", " ", stripped)
stripped = _normalize_spacing(stripped)
normalized_tokens: List[str] = []
for raw_token in stripped.split():
negative = raw_token.startswith("-")
body = raw_token[1:] if negative else raw_token
body = re.sub(r"^[^\w가-힣+#]+|[^\w가-힣+#]+$", "", body)
body = _strip_particle(body).strip()
body = body.strip("- ")
if not body or body in _TOKEN_STOPWORDS:
continue
token = f"-{body}" if negative else body
normalized_tokens.append(token)
deduped: List[str] = []
seen = set()
for token in normalized_tokens:
key = token.lower()
if key in seen:
continue
seen.add(key)
deduped.append(token)
return " ".join(deduped)
def build_intent(raw_query: str, *, limit: int = 10, days: int | None = None) -> QueryIntent:
cleaned = clean_natural_query(raw_query)
detected_days = days if days is not None else detect_recent_days(raw_query)
search_query, exclude_words = parse_search_query(cleaned)
db_keyword, _ = parse_tab_query(cleaned)
if not search_query:
raise ValueError("최소 1개 이상의 일반 키워드가 필요합니다.")
return QueryIntent(
raw_query=raw_query,
search_query=search_query,
db_keyword=db_keyword or search_query,
exclude_words=exclude_words,
fetch_key=build_fetch_key(search_query, exclude_words),
days=detected_days,
limit=max(1, min(100, int(limit))),
)
def cutoff_iso(days: int | None, now: datetime | None = None) -> str | None:
if not days:
return None
base = now or datetime.now()
return (base - timedelta(days=days)).isoformat(timespec="seconds")
FILE:scripts/tests/conftest.py
from __future__ import annotations
import sys
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
FILE:scripts/tests/test_automation_plans.py
import json
import naver_news_briefing as cli
from automation_plans import build_integration_bundle, parse_automation_request
class DummyArgs:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def test_parse_monitoring_interval_plan():
plan = parse_automation_request("반도체 뉴스 1시간마다 모니터링해줘")
assert plan.action == "monitor"
assert plan.schedule.kind == "interval"
assert plan.schedule.interval_minutes == 60
assert plan.primary_query == "반도체"
assert plan.template == "watch-alert"
assert plan.operator_hints.storage_target == "watch"
assert any("watch-add" in cmd for cmd in plan.suggested_commands)
def test_parse_daily_briefing_group_plan():
plan = parse_automation_request("매일 아침 7시에 반도체, AI 데이터센터 뉴스 브리핑해줘")
assert plan.action == "briefing"
assert plan.query_mode == "group"
assert plan.schedule.kind == "daily"
assert plan.schedule.time == "07:00"
assert len(plan.queries) == 2
assert plan.template == "morning-briefing"
assert any("group-add" in cmd for cmd in plan.suggested_commands)
def test_parse_exclude_and_continuous_watch_plan():
plan = parse_automation_request("증권사 리포트 빼고 삼성전자 뉴스 계속 체크해줘")
assert plan.action == "monitor"
assert plan.primary_query == "삼성전자 -증권사 -리포트"
assert plan.schedule.kind == "interval"
assert plan.schedule.interval_minutes == 15
assert plan.watch_intent == "continuous"
def test_cmd_plan_json(capsys):
args = DummyArgs(request="반도체 뉴스 1시간마다 모니터링해줘", json=True)
assert cli.cmd_plan(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["schedule"]["interval_minutes"] == 60
assert payload["primary_query"] == "반도체"
assert payload["operator_hints"]["storage_target"] == "watch"
def test_cmd_plan_save_creates_watch(monkeypatch, capsys):
monkeypatch.setattr(cli, "add_rule", lambda **kwargs: {"name": kwargs["name"], "search_query": kwargs["search_query"], "template": kwargs["template"], "tags": kwargs["tags"], "schedule": kwargs["schedule"]})
args = DummyArgs(request="반도체 뉴스 1시간마다 모니터링해줘", name="semi-hourly", as_type="watch", label=None, tag=None, json=True)
assert cli.cmd_plan_save(args) == 0
payload = json.loads(capsys.readouterr().out)
value = payload["created"][0]["value"]
assert payload["created"][0]["type"] == "watch"
assert value["name"] == "semi-hourly"
assert value["template"] == "watch-alert"
assert "interval" in value["tags"]
assert value["schedule"]["interval_minutes"] == 60
def test_build_integration_bundle_for_watch(tmp_path):
skill_dir = tmp_path / "skills" / "naver-news-briefing"
skill_dir.mkdir(parents=True)
bundle = build_integration_bundle(
"반도체 뉴스 1시간마다 모니터링해줘",
skill_dir=skill_dir,
assistant_channel="telegram",
)
assert bundle["storage"]["target"] == "watch"
assert bundle["storage"]["name"] == "반도체-watch"
assert "plan-save" in bundle["storage"]["save_command"]
assert "watch-check 반도체-watch --json" in bundle["runner"]["command"]
assert bundle["automation"]["schedule"]["cron"] == "0 */1 * * *"
assert bundle["automation"]["cron_line"].startswith("0 */1 * * * cd ")
assert "telegram 채널" in bundle["automation"]["system_event_text"]
def test_cmd_integration_plan_json(tmp_path, capsys):
skill_dir = tmp_path / "skills" / "naver-news-briefing"
skill_dir.mkdir(parents=True)
args = DummyArgs(
request="매일 아침 7시에 반도체랑 AI 데이터센터 뉴스 브리핑해줘",
channel="telegram",
skill_dir=str(skill_dir),
output=None,
json=True,
)
assert cli.cmd_integration_plan(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["storage"]["target"] == "group"
assert payload["plan"]["schedule"]["time"] == "07:00"
assert "brief-multi --group" in payload["runner"]["command"]
assert payload["automation"]["schedule"]["cron"] == "0 7 * * *"
def test_cmd_integration_plan_creates_output_parent_dirs(tmp_path, capsys):
skill_dir = tmp_path / "skills" / "naver-news-briefing"
skill_dir.mkdir(parents=True)
output_path = tmp_path / "nested" / "plans" / "bundle.json"
args = DummyArgs(
request="반도체 뉴스 1시간마다 모니터링해줘",
channel="telegram",
skill_dir=str(skill_dir),
output=str(output_path),
json=True,
)
assert cli.cmd_integration_plan(args) == 0
payload = json.loads(capsys.readouterr().out)
assert output_path.exists()
assert json.loads(output_path.read_text(encoding="utf-8"))["storage"]["target"] == payload["storage"]["target"]
def test_cmd_plan_save_creates_group(monkeypatch, capsys):
monkeypatch.setattr(cli, "create_group", lambda **kwargs: {"name": kwargs["name"], "queries": kwargs["queries"], "template": kwargs["template"], "schedule": kwargs["schedule"], "operator_hints": kwargs["operator_hints"]})
args = DummyArgs(request="반도체, AI 데이터센터 뉴스 매일 아침 7시에 브리핑해줘", name="morning-tech", as_type="group", label="아침 브리핑", tag=["테크"], json=True)
assert cli.cmd_plan_save(args) == 0
payload = json.loads(capsys.readouterr().out)
value = payload["created"][0]["value"]
assert payload["created"][0]["type"] == "group"
assert value["name"] == "morning-tech"
assert len(value["queries"]) == 2
assert value["template"] == "morning-briefing"
assert value["schedule"]["time"] == "07:00"
assert value["operator_hints"]["storage_target"] == "group"
def test_cmd_plan_save_dedupes_tags(monkeypatch, capsys):
monkeypatch.setattr(cli, "add_rule", lambda **kwargs: {"name": kwargs["name"], "tags": kwargs["tags"]})
args = DummyArgs(
request="반도체 뉴스 1시간마다 모니터링해줘",
name="semi-hourly",
as_type="watch",
label=None,
tag=["watch", "interval", "watch", "사용자태그"],
json=True,
)
assert cli.cmd_plan_save(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["created"][0]["value"]["tags"] == ["watch", "interval", "사용자태그"]
def test_cmd_plan_save_rejects_group_plan_as_watch():
args = DummyArgs(
request="반도체, AI 데이터센터 뉴스 매일 아침 7시에 브리핑해줘",
name="morning-tech",
as_type="watch",
label=None,
tag=None,
json=True,
)
try:
cli.cmd_plan_save(args)
except ValueError as exc:
assert "--as group" in str(exc)
else:
raise AssertionError("ValueError expected")
def test_main_reports_missing_group_with_available_names(monkeypatch, capsys):
monkeypatch.setattr(cli, "get_group", lambda name: (_ for _ in ()).throw(KeyError(f"keyword group not found: {name}")))
monkeypatch.setattr(cli, "list_groups", lambda: [{"name": "market-watch"}, {"name": "morning-tech"}])
exit_code = cli.main(["group-list", "missing-group"])
captured = capsys.readouterr()
assert exit_code == 1
assert "등록된 키워드 그룹을(를) 찾지 못했습니다: missing-group" in captured.err
assert "market-watch, morning-tech" in captured.err
def test_main_reports_missing_watch_rule_with_hint(monkeypatch, capsys):
monkeypatch.setattr(cli, "get_rule", lambda name: (_ for _ in ()).throw(KeyError(f"watch rule not found: {name}")))
monkeypatch.setattr(cli, "list_rules", lambda: [{"name": "semi-hourly"}])
exit_code = cli.main(["watch-check", "unknown-watch"])
captured = capsys.readouterr()
assert exit_code == 1
assert "등록된 watch rule을(를) 찾지 못했습니다: unknown-watch" in captured.err
assert "semi-hourly" in captured.err
FILE:scripts/tests/test_brief_multi.py
import json
import naver_news_briefing as cli
from briefing_templates import build_combined_payload, render_combined_text
class DummyArgs:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def test_build_combined_payload_and_render_text():
payload = build_combined_payload(
[
{
"query": "반도체 뉴스",
"group_name": "market-watch",
"label": "시장",
"context": "아침 브리핑",
"result": {
"displayed": 1,
"total": 10,
"filtered_out": 0,
"too_old": 0,
"items": [{"title": "반도체 상승", "publisher": "연합", "link": "https://example.com/a"}],
},
}
],
template="analyst",
source_groups=[{"name": "market-watch"}],
)
text = render_combined_text(payload)
assert "네이버 뉴스 멀티 브리핑 · analyst" in text
assert "market-watch" in text
assert "반도체 상승" in text
def test_cmd_brief_multi_supports_group_and_query_json(monkeypatch, capsys):
monkeypatch.setattr(cli, "get_group", lambda name: {
"name": name,
"label": "관심주제",
"tags": ["태그"],
"context": "체크 포인트",
"queries": ["반도체 -광고"],
})
def fake_run_query_entry(query, *, limit, days, group=None, label=None, context=None):
return {
"query": query,
"group_name": group["name"] if group else None,
"label": label or (group.get("label") if group else None),
"context": context or (group.get("context") if group else None),
"result": {
"displayed": 1,
"total": 3,
"filtered_out": 0,
"too_old": 0,
"items": [{"title": f"{query} 기사", "publisher": "연합", "link": "https://example.com/x"}],
},
}
monkeypatch.setattr(cli, "_run_query_entry", fake_run_query_entry)
args = DummyArgs(group=["market-watch"], query=["환율 뉴스"], limit=5, days=None, template="morning-briefing", json=True)
assert cli.cmd_brief_multi(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["template"] == "morning-briefing"
assert payload["entry_count"] == 2
assert payload["groups"][0]["name"] == "market-watch"
def test_main_brief_multi_missing_group_is_operator_friendly(monkeypatch, capsys):
monkeypatch.setattr(cli, "get_group", lambda name: (_ for _ in ()).throw(KeyError(f"keyword group not found: {name}")))
monkeypatch.setattr(cli, "list_groups", lambda: [{"name": "market-watch"}])
exit_code = cli.main(["brief-multi", "--group", "missing-group"])
captured = capsys.readouterr()
assert exit_code == 1
assert "등록된 키워드 그룹을(를) 찾지 못했습니다: missing-group" in captured.err
assert "market-watch" in captured.err
FILE:scripts/tests/test_group_store.py
import group_store
from group_store import create_group, get_group, list_groups, remove_group, update_group
def test_group_store_create_update_remove(tmp_path, monkeypatch):
monkeypatch.setattr(group_store, "DB_PATH", tmp_path / "watch.db")
group = create_group(
name="market-watch",
queries=["반도체 -광고", "AI 데이터센터 -주가"],
label="시장 모니터링",
tags=["시장", "테크", "시장"],
context="아침 브리핑용",
template="morning-briefing",
schedule={"kind": "daily", "label": "매일 07:00", "time": "07:00"},
operator_hints={"storage_target": "group"},
)
assert group["query_count"] == 2
assert group["tags"] == ["시장", "테크"]
assert group["template"] == "morning-briefing"
assert group["schedule"]["time"] == "07:00"
assert len(list_groups()) == 1
updated = update_group(
"market-watch",
add_queries=["배터리 공급망 -광고"],
remove_queries=["AI 데이터센터 -주가"],
tags=["시장", "모니터링"],
template="analyst",
)
assert updated["queries"] == ["반도체 -광고", "배터리 공급망 -광고"]
assert updated["tags"] == ["시장", "모니터링"]
assert updated["template"] == "analyst"
assert get_group("market-watch")["query_count"] == 2
assert remove_group("market-watch") == 1
FILE:scripts/tests/test_naver_api.py
from naver_api import clean_item, fetch_news
class DummyResponse:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
self.text = ""
def json(self):
return self._payload
class DummySession:
def __init__(self, payload):
self.payload = payload
def get(self, url, *, headers, params, timeout):
return DummyResponse(200, self.payload)
SAMPLE = {
"total": 3,
"items": [
{
"title": "<b>반도체</b> 시장 확대",
"description": "좋은 뉴스",
"link": "https://news.naver.com/a",
"originallink": "https://example.com/a",
"pubDate": "Wed, 25 Mar 2026 10:00:00 +0900",
},
{
"title": "광고성 기사",
"description": "광고 포함",
"link": "https://example.com/b",
"originallink": "https://example.com/b",
"pubDate": "Wed, 25 Mar 2026 10:00:00 +0900",
},
],
}
def test_clean_item_prefers_naver_link_and_strips_html():
item = clean_item(SAMPLE["items"][0])
assert item.title == "반도체 시장 확대"
assert item.link == "https://news.naver.com/a"
assert item.publisher == "example.com"
def test_fetch_news_applies_exclude_words_and_limit():
result = fetch_news(
client_id="id",
client_secret="secret",
search_query="반도체",
exclude_words=["광고"],
limit=5,
timeout=5,
session=DummySession(SAMPLE),
)
assert result["displayed"] == 1
assert result["filtered_out"] == 1
assert result["items"][0]["title"] == "반도체 시장 확대"
FILE:scripts/tests/test_query_utils.py
from query_utils import build_intent, clean_natural_query, parse_search_query, parse_tab_query
def test_parse_queries_match_upstream_policy():
assert parse_search_query("인공지능 AI -광고 -코인") == ("인공지능 AI", ["광고", "코인"])
assert parse_tab_query("인공지능 AI -광고 -코인") == ("인공지능", ["광고", "코인"])
def test_build_intent_detects_recent_days_and_strips_briefing_words():
intent = build_intent("최근 3일 반도체 뉴스 브리핑 -광고", limit=7)
assert intent.search_query == "반도체"
assert intent.exclude_words == ["광고"]
assert intent.days == 3
assert intent.limit == 7
def test_clean_natural_query_handles_sentence_style_korean():
cleaned = clean_natural_query("삼성전자 관련해서 최근 일주일 핵심 뉴스만 브리핑해줘")
assert cleaned == "삼성전자"
def test_build_intent_converts_korean_exclude_phrases():
intent = build_intent("삼성전자 관련해서 증권사 리포트 말고 최근 일주일 핵심만 알려줘")
assert intent.search_query == "삼성전자"
assert intent.exclude_words == ["증권사", "리포트"]
assert intent.days == 7
def test_build_intent_strips_particles_and_dedupes_tokens():
intent = build_intent("반도체를 반도체 관련 뉴스 찾아줘")
assert intent.search_query == "반도체"
assert intent.exclude_words == []
FILE:scripts/tests/test_watch_check_output.py
import naver_news_briefing as cli
class DummyArgs:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def test_cmd_watch_check_renders_friendlier_status(monkeypatch, capsys):
monkeypatch.setattr(cli, "get_rule", lambda name: {
"id": 1,
"name": name,
"search_query": "방미심위",
"exclude_words": [],
"days": None,
})
monkeypatch.setattr(cli, "_run_rule", lambda rule: {
"rule": rule,
"summary": {
"query": "방미심위",
"total": 1068,
"displayed": 10,
"new_count": 2,
"filtered_out": 0,
"too_old": 0,
},
"new_items": [
{
"title": "새 기사 A",
"publisher": "연합",
"link": "https://example.com/a",
"pub_date": "Fri, 27 Mar 2026 17:05:00 +0900",
},
{
"title": "새 기사 B",
"publisher": "뉴스1",
"link": "https://example.com/b",
"pub_date": "Fri, 27 Mar 2026 16:50:00 +0900",
},
],
"all_items": [
{
"title": "새 기사 A",
"publisher": "연합",
"link": "https://example.com/a",
"pub_date_iso": "2026-03-27T17:05:00+09:00",
}
],
})
args = DummyArgs(name_or_id="bangmisimwi", json=False)
assert cli.cmd_watch_check(args) == 0
out = capsys.readouterr().out
assert "관련 상위 1건을 확인했고 이번 체크에서 신규 기사 2건이 추가됐습니다." in out
assert "전체 검색 결과: 1068건" in out
assert "이번 확인 신규 기사: 2건" in out
assert "현재 검색 상위 기사 수: 1건" in out
assert "현재 기준 최신 기사 시각: 2026-03-27T17:05:00+09:00" in out
assert "watch: bangmisimwi 신규 기사 요약" in out
FILE:scripts/tests/test_watch_store.py
import watch_store
from watch_store import add_rule, get_rule, list_rules, mark_seen, remove_rule
def test_watch_store_add_list_seen_remove(tmp_path, monkeypatch):
monkeypatch.setattr(watch_store, "DB_PATH", tmp_path / "watch.db")
rule = add_rule(
name="semiconductor",
raw_query="반도체 -광고",
search_query="반도체",
db_keyword="반도체",
exclude_words=["광고"],
fetch_key="반도체|광고",
days=7,
limit=10,
label="반도체 감시",
tags=["watch", "interval"],
context="1시간마다 모니터링",
template="watch-alert",
schedule={"kind": "interval", "interval_minutes": 60, "label": "1시간마다"},
operator_hints={"storage_target": "watch", "delivery_format": "watch-json"},
)
assert rule["name"] == "semiconductor"
assert rule["template"] == "watch-alert"
assert rule["schedule"]["interval_minutes"] == 60
assert get_rule("semiconductor")["label"] == "반도체 감시"
assert len(list_rules()) == 1
first = mark_seen(rule["id"], [{"link": "https://example.com/a", "pub_date_iso": "2026-03-25T10:00:00+09:00"}])
second = mark_seen(rule["id"], [{"link": "https://example.com/a", "pub_date_iso": "2026-03-25T10:00:00+09:00"}])
assert len(first) == 1
assert len(second) == 0
assert remove_rule("semiconductor") == 1
FILE:scripts/watch_store.py
from __future__ import annotations
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime
from typing import Any, Dict, Iterable, List
from _paths import DB_PATH, ensure_data_dir
SCHEMA = """
CREATE TABLE IF NOT EXISTS watch_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
raw_query TEXT NOT NULL,
search_query TEXT NOT NULL,
db_keyword TEXT NOT NULL,
exclude_json TEXT NOT NULL,
fetch_key TEXT NOT NULL,
days INTEGER,
limit_count INTEGER NOT NULL,
label TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
context TEXT,
template TEXT,
schedule_json TEXT NOT NULL DEFAULT '{}',
operator_hint_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_checked_at TEXT,
last_new_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS seen_items (
watch_id INTEGER NOT NULL,
link TEXT NOT NULL,
published_at TEXT,
first_seen_at TEXT NOT NULL,
PRIMARY KEY (watch_id, link),
FOREIGN KEY (watch_id) REFERENCES watch_rules(id) ON DELETE CASCADE
);
"""
@contextmanager
def connect():
ensure_data_dir()
conn = sqlite3.connect(DB_PATH)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
columns = {row[1] for row in conn.execute("PRAGMA table_info(watch_rules)").fetchall()}
migrations = {
"label": "ALTER TABLE watch_rules ADD COLUMN label TEXT",
"tags_json": "ALTER TABLE watch_rules ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'",
"context": "ALTER TABLE watch_rules ADD COLUMN context TEXT",
"template": "ALTER TABLE watch_rules ADD COLUMN template TEXT",
"schedule_json": "ALTER TABLE watch_rules ADD COLUMN schedule_json TEXT NOT NULL DEFAULT '{}'",
"operator_hint_json": "ALTER TABLE watch_rules ADD COLUMN operator_hint_json TEXT NOT NULL DEFAULT '{}'",
}
for column, sql in migrations.items():
if column not in columns:
conn.execute(sql)
yield conn
conn.commit()
finally:
conn.close()
def _normalize_tags(tags: Iterable[str] | None) -> List[str]:
seen = set()
result: List[str] = []
for tag in tags or []:
value = str(tag or "").strip()
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result
def list_rules() -> List[Dict[str, Any]]:
with connect() as conn:
rows = conn.execute(
"SELECT id, name, raw_query, search_query, db_keyword, exclude_json, fetch_key, days, limit_count, label, tags_json, context, template, schedule_json, operator_hint_json, created_at, updated_at, last_checked_at, last_new_count FROM watch_rules ORDER BY name"
).fetchall()
result = []
for row in rows:
result.append(
{
"id": row[0],
"name": row[1],
"raw_query": row[2],
"search_query": row[3],
"db_keyword": row[4],
"exclude_words": json.loads(row[5]),
"fetch_key": row[6],
"days": row[7],
"limit": row[8],
"label": row[9],
"tags": json.loads(row[10] or "[]"),
"context": row[11],
"template": row[12],
"schedule": json.loads(row[13] or "{}"),
"operator_hints": json.loads(row[14] or "{}"),
"created_at": row[15],
"updated_at": row[16],
"last_checked_at": row[17],
"last_new_count": row[18],
}
)
return result
def add_rule(*, name: str, raw_query: str, search_query: str, db_keyword: str, exclude_words: List[str], fetch_key: str, days: int | None, limit: int, label: str | None = None, tags: Iterable[str] | None = None, context: str | None = None, template: str | None = None, schedule: Dict[str, Any] | None = None, operator_hints: Dict[str, Any] | None = None) -> Dict[str, Any]:
now = datetime.now().isoformat(timespec="seconds")
with connect() as conn:
conn.execute(
"""
INSERT INTO watch_rules(name, raw_query, search_query, db_keyword, exclude_json, fetch_key, days, limit_count, label, tags_json, context, template, schedule_json, operator_hint_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
raw_query,
search_query,
db_keyword,
json.dumps(exclude_words, ensure_ascii=False),
fetch_key,
days,
limit,
label,
json.dumps(_normalize_tags(tags), ensure_ascii=False),
context,
template,
json.dumps(schedule or {}, ensure_ascii=False),
json.dumps(operator_hints or {}, ensure_ascii=False),
now,
now,
),
)
return get_rule(name)
def get_rule(name_or_id: str | int) -> Dict[str, Any]:
query = "SELECT id, name, raw_query, search_query, db_keyword, exclude_json, fetch_key, days, limit_count, label, tags_json, context, template, schedule_json, operator_hint_json, created_at, updated_at, last_checked_at, last_new_count FROM watch_rules WHERE {} = ?"
field = "id" if isinstance(name_or_id, int) or str(name_or_id).isdigit() else "name"
with connect() as conn:
row = conn.execute(query.format(field), (int(name_or_id) if field == "id" else name_or_id,)).fetchone()
if not row:
raise KeyError(f"watch rule not found: {name_or_id}")
return {
"id": row[0], "name": row[1], "raw_query": row[2], "search_query": row[3], "db_keyword": row[4],
"exclude_words": json.loads(row[5]), "fetch_key": row[6], "days": row[7], "limit": row[8],
"label": row[9], "tags": json.loads(row[10] or "[]"), "context": row[11], "template": row[12],
"schedule": json.loads(row[13] or "{}"), "operator_hints": json.loads(row[14] or "{}"),
"created_at": row[15], "updated_at": row[16], "last_checked_at": row[17], "last_new_count": row[18],
}
def remove_rule(name_or_id: str | int) -> int:
field = "id" if isinstance(name_or_id, int) or str(name_or_id).isdigit() else "name"
with connect() as conn:
cur = conn.execute(f"DELETE FROM watch_rules WHERE {field} = ?", (int(name_or_id) if field == 'id' else name_or_id,))
return int(cur.rowcount or 0)
def mark_seen(watch_id: int, items: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
now = datetime.now().isoformat(timespec="seconds")
new_items: List[Dict[str, Any]] = []
with connect() as conn:
for item in items:
link = str(item.get("link", "") or "").strip()
if not link:
continue
exists = conn.execute("SELECT 1 FROM seen_items WHERE watch_id = ? AND link = ?", (watch_id, link)).fetchone()
if exists:
continue
conn.execute(
"INSERT INTO seen_items(watch_id, link, published_at, first_seen_at) VALUES (?, ?, ?, ?)",
(watch_id, link, item.get("pub_date_iso"), now),
)
new_items.append(item)
conn.execute(
"UPDATE watch_rules SET last_checked_at = ?, last_new_count = ? WHERE id = ?",
(now, len(new_items), watch_id),
)
return new_items
FILE:scripts/_paths.py
from __future__ import annotations
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = SKILL_DIR / "data"
CONFIG_PATH = DATA_DIR / "config.json"
DB_PATH = DATA_DIR / "watch_state.db"
TEST_CACHE_DIR = DATA_DIR / ".pytest-cache"
UPSTREAM_REPO = Path(r"C:\Users\김태완\.openclaw\workspace\navernews-tabsearch")
def ensure_data_dir() -> Path:
DATA_DIR.mkdir(parents=True, exist_ok=True)
return DATA_DIR
Batch-convert 한컴 한글 문서(HWP/HWPX) to PDF, HWPX, DOCX, ODT, HTML, RTF, TXT, and image formats on Windows using HWP COM automation. Use when the user asks for 이...
---
name: hwp-batch-convert
description: Batch-convert 한컴 한글 문서(HWP/HWPX) to PDF, HWPX, DOCX, ODT, HTML, RTF, TXT, and image formats on Windows using HWP COM automation. Use when the user asks for 이 폴더 hwp 전부 pdf로 바꿔줘, hwp/hwpx/doc/pdf 일괄 변환, 한글문서 일괄 처리, 폴더 단위 변환, 여러 한글 파일을 다른 형식으로 내보내기, or wants a Korean-friendly batch conversion/report flow. Prefer this skill for Windows environments with Hancom HWP installed; do not use it for non-HWP document families unless the task is explicitly about HWP/HWPX conversion.
---
# Hwp Batch Convert
Use this skill for **Windows 기반 한글(HWP/HWPX) 문서 일괄 변환**.
Current scope:
- 폴더 단위 일괄 변환
- 파일 여러 개 일괄 변환
- HWP/HWPX → PDF 기본 변환
- HWP/HWPX → HWPX/DOCX/ODT/HTML/RTF/TXT/PNG/JPG/BMP/GIF 변환
- 동일 형식 자동 건너뜀
- 출력 파일명 충돌 시 자동 번호 부여
- 작업 계획만 확인하는 `--plan-only`
- OpenClaw 상위 레이어 연동용 `--json`, `--report-json`
- 한글 보안 확인 팝업 자동 허용용 `--auto-allow-dialogs`
- 로컬 UI 검증용 `--self-test-dialog-handler`
- 테스트용 `--mode mock`
## Source basis
This skill reuses the design of the local/source repo:
- `tmp/HwpMate`
- upstream: `https://github.com/twbeatles/HwpMate`
Main logic origin:
- `hwpmate/services/hwp_converter.py`
- `hwpmate/services/task_planner.py`
- `hwpmate/constants.py`
- `hwpmate/path_utils.py`
If you need the mapping details or reuse rationale, read:
- `references/hwpmate-reuse-notes.md`
If you need the popup whitelist / safety details, read:
- `references/auto-allow-dialogs.md`
## Quick start
같은 폴더에 PDF 출력:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\contracts" --format PDF --same-location
```
별도 출력 폴더로 변환:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\hwp" --format PDF --output-dir "C:\docs\pdf" --auto-allow-dialogs
```
여러 파일 한 번에 변환:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\a.hwp" "C:\docs\b.hwpx" --format DOCX --output-dir "C:\docs\docx"
```
실제 변환 없이 계획만 확인:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\hwp" --format PDF --output-dir "C:\docs\pdf" --plan-only --json
```
테스트용 모의 변환:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\sample" --format PDF --output-dir "C:\docs\out" --mode mock --json
```
## Main script
### `scripts/hwp_batch_convert.py`
Parameters:
- `sources...`: 입력 파일/폴더 경로 하나 이상
- `--format`: 출력 형식 (`PDF`, `HWPX`, `DOCX`, `ODT`, `HTML`, `RTF`, `TXT`, `PNG`, `JPG`, `BMP`, `GIF`, `HWP`)
- `--same-location`: 원본과 같은 폴더에 출력
- `--output-dir`: 출력 루트 폴더
- `--include-sub`: 하위 폴더 포함(기본값)
- `--no-include-sub`: 하위 폴더 제외
- `--overwrite`: 같은 이름 출력 허용
- `--plan-only`: 실제 변환 없이 작업 계획만 생성
- `--mode real|mock`: 실변환 또는 모의 변환
- `--auto-allow-dialogs`: 제목 `한글`, 본문에 `접근하려는 시도`, 버튼 `모두 허용`/`허용` 조건을 모두 만족하는 보안 팝업만 자동 클릭
- `--json`: stdout JSON 출력
- `--report-json`: 결과 JSON 파일 저장
- `--self-test-dialog-handler`: 로컬 테스트용 샘플 `한글` 창을 띄워 자동 클릭 로직만 검증
## Recommended workflow
1. 사용자 요청이 폴더/여러 파일 기반 HWP/HWPX 변환인지 확인한다.
2. 출력 형식이 명시되지 않았으면 보통 `PDF`를 기본 제안으로 사용한다.
3. 먼저 `--plan-only --json` 으로 대상/건너뜀/출력 경로를 확인한다.
4. 환경이 Windows + 한글 설치 + pywin32 가능하면 `--mode real` 로 실행한다.
5. 환경 검증이 먼저 필요하면 `--mode mock` 으로 경로/출력 구조만 검증한다.
6. 필요하면 `--report-json` 으로 결과 파일을 남긴다.
## Operational notes
- 이 스킬은 사실상 **Windows 전용**이다.
- 실변환(`--mode real`)은 **한컴 한글 설치**와 **pywin32**가 필요하다.
- `--auto-allow-dialogs` 는 화이트리스트 방식이다. 제목이 `한글` 이고, 본문에 `접근하려는 시도` 가 있으며, 버튼이 `모두 허용` 또는 `허용` 인 경우에만 클릭한다.
- 위 조건에 맞지 않는 다른 팝업은 자동으로 건드리지 않는다. 감지되더라도 클릭 없이 이벤트만 남기거나 무시한다.
- 자동 허용 기록은 stdout JSON/`--report-json` 의 `auto_dialog_*` 필드와 `auto_dialog_events` 배열에서 확인한다.
- 한글 COM 자동화가 실패하면 `--mode mock` 으로 스킬/경로 로직만 먼저 검증하고, 이후 환경 문제를 분리 진단한다.
- 여러 폴더를 동시에 입력하면서 `--output-dir` 를 쓰면, 파일명 충돌 가능성이 있으니 결과 경로를 확인한다.
- 기본 입력 확장자는 `.hwp`, `.hwpx` 만 대상으로 한다.
- 사용자 요청이 DOCX/PDF 일반 문서 처리 중심이고 HWP가 핵심이 아니면 다른 문서 스킬/도구를 우선 고려한다.
FILE:README.md
# hwp-batch-convert
Windows에서 한컴 한글(HWP/HWPX) 문서를 COM 자동화로 일괄 변환하는 OpenClaw 스킬입니다.
## 새 기능: 보안 확인 팝업 자동 허용
한글 COM 자동화 중 아래 조건을 **모두 만족하는 보안 팝업만** 자동으로 처리할 수 있습니다.
- 창 제목: `한글`
- 본문 텍스트에 `접근하려는 시도` 포함
- 버튼 텍스트: `모두 허용` 우선, 없으면 `허용`
예상치 못한 다른 팝업은 건드리지 않도록 화이트리스트 방식으로 제한했습니다.
## 사용 예시
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\hwp" --format PDF --output-dir "C:\docs\pdf" --auto-allow-dialogs --json --report-json "C:\docs\pdf\report.json"
```
## 테스트
### 1) UI 자동 클릭 로컬 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py --self-test-dialog-handler
```
### 2) 모의 변환 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\sample" --format PDF --output-dir "C:\docs\out" --mode mock --auto-allow-dialogs --json
```
mock 모드에서는 실제 한글 창이 없으므로 자동 허용이 동작하지 않고 경고만 남깁니다.
## 리포트 확인 포인트
JSON summary에 다음 필드가 추가됩니다.
- `auto_dialog_enabled`
- `auto_dialog_detected_count`
- `auto_dialog_clicked_count`
- `auto_dialog_events[]`
FILE:references/auto-allow-dialogs.md
# 한글 보안 팝업 자동 허용 메모
## 목적
한글(HWP) COM 자동화 중 뜨는 보안 확인 팝업 때문에 `Open`/`SaveAs` 흐름이 멈추는 경우를 줄이기 위해, 매우 제한된 조건의 팝업만 자동 클릭한다.
## 화이트리스트 조건
다음 조건을 모두 만족할 때만 클릭한다.
1. 최상위 창 제목이 정확히 `한글`
2. 자식 컨트롤 텍스트를 합친 본문에 `접근하려는 시도` 포함
3. 버튼 텍스트가 `모두 허용` 우선, 없으면 `허용`
## 안전장치
- 제목만 `한글` 인 다른 창은 본문/버튼 조건까지 함께 확인하기 전에는 클릭하지 않는다.
- 버튼 텍스트가 없거나 다른 경우 클릭하지 않는다.
- 화이트리스트와 맞지 않는 창은 `text-mismatch`, `button-mismatch` 등 이유를 이벤트에 남길 수 있다.
- 탐지된 창 핸들은 중복 처리하지 않는다.
- 외부 UI 자동화 패키지 없이 Win32 API(`EnumWindows`, `EnumChildWindows`, `SendMessageW(BM_CLICK)`)만 사용한다.
## 테스트 방법
### UI 클릭 자체 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py --self-test-dialog-handler
```
테스트는 PowerShell WinForms로 아래와 유사한 샘플 창을 띄우고, 자동 클릭 로직이 `모두 허용` 버튼을 실제로 누르는지 확인한다.
- 제목: `한글`
- 본문: `한글 문서에 접근하려는 시도를 허용하시겠습니까?`
- 버튼: `모두 허용`
### 변환 경로 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py <입력> --format PDF --output-dir <출력> --mode mock --auto-allow-dialogs --json
```
mock 모드에서는 실제 한글 창이 없으므로 `auto_dialog_detected_count = 0` 이 정상이다.
FILE:references/hwpmate-reuse-notes.md
# HwpMate 재사용 메모
이 스킬의 최소 CLI는 `twbeatles/HwpMate`의 다음 구조를 직접 참고해 경량 재구성했다.
- `hwpmate/services/hwp_converter.py`
- pywin32 + HWP COM 초기화
- `Open` + `SaveAs` + `Clear` 기반 변환 흐름
- 여러 ProgID 폴백 시도
- `hwpmate/services/task_planner.py`
- 폴더/파일 입력을 변환 작업 목록으로 펼치기
- 동일 형식 자동 건너뜀
- 출력 경로 충돌 시 ` (1)`, ` (2)` 번호 부여
- `hwpmate/constants.py`
- 지원 입력 확장자, 출력 포맷 매핑, ProgID 목록
- `hwpmate/path_utils.py`
- 지원 파일 순회 로직
- `hwpmate/models.py`
- 작업/요약 데이터 구조
스킬 쪽 구현은 OpenClaw에서 바로 쓰기 쉽게 GUI 의존성(PyQt6)을 제거하고 CLI 중심으로 축소했다.
## 차이점
- GUI, 백업, 트레이, 토스트, CSV/TXT 실패 리포트는 제외
- OpenClaw에서 쓰기 좋은 `--plan-only`, `--json`, `--report-json`, `--mode mock` 추가
- `sources` 인자에 파일/폴더 여러 개를 동시에 받을 수 있게 단순화
- 기본 목적은 `한글 문서 일괄 처리` 요청에서 빠르게 실행 가능한 최소 기능 제공
FILE:scripts/hwp_batch_convert.py
from __future__ import annotations
import argparse
import ctypes
import json
import os
import subprocess
import sys
import threading
import time
from ctypes import wintypes
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
SUPPORTED_EXTENSIONS = ('.hwp', '.hwpx')
DOCUMENT_LOAD_DELAY = 1.0
MAX_FILENAME_COUNTER = 1000
HWP_PROGIDS = [
'HWPControl.HwpCtrl.1',
'HwpObject.HwpObject',
'HWPFrame.HwpObject',
]
FORMAT_TYPES: dict[str, dict[str, str]] = {
'HWP': {'ext': '.hwp', 'save_format': 'HWP'},
'HWPX': {'ext': '.hwpx', 'save_format': 'HWPX'},
'PDF': {'ext': '.pdf', 'save_format': 'PDF'},
'DOCX': {'ext': '.docx', 'save_format': 'OOXML'},
'ODT': {'ext': '.odt', 'save_format': 'ODT'},
'HTML': {'ext': '.html', 'save_format': 'HTML'},
'RTF': {'ext': '.rtf', 'save_format': 'RTF'},
'TXT': {'ext': '.txt', 'save_format': 'TEXT'},
'PNG': {'ext': '.png', 'save_format': 'PNG'},
'JPG': {'ext': '.jpg', 'save_format': 'JPG'},
'BMP': {'ext': '.bmp', 'save_format': 'BMP'},
'GIF': {'ext': '.gif', 'save_format': 'GIF'},
}
HWP_PROCESS_NAMES = {'hwp.exe', 'hwpctrl.exe'}
DIALOG_TITLE_WHITELIST = {'한글'}
DIALOG_TEXT_KEYWORDS = ('접근하려는 시도',)
DIALOG_ALLOW_BUTTONS = ('모두 허용', '허용')
POLL_INTERVAL_SECONDS = 0.35
WINDOW_SCAN_TIMEOUT_SECONDS = 3.0
BM_CLICK = 0x00F5
WM_GETTEXT = 0x000D
WM_GETTEXTLENGTH = 0x000E
USER32 = ctypes.WinDLL('user32', use_last_error=True)
@dataclass
class AutoDialogEvent:
window_title: str
window_text: str
button_text: str
clicked: bool
reason: str
timestamp: float = field(default_factory=time.time)
def to_record(self) -> dict[str, Any]:
return {
'timestamp': round(self.timestamp, 3),
'window_title': self.window_title,
'window_text': self.window_text,
'button_text': self.button_text,
'clicked': self.clicked,
'reason': self.reason,
}
def canonicalize_path(path: str | Path) -> str:
return os.path.abspath(os.path.normpath(str(path)))
def iter_supported_files(root_path: Path, include_sub: bool = True, allowed_exts: Optional[Iterable[str]] = None) -> Iterable[Path]:
allowed = {ext.lower() for ext in (allowed_exts or SUPPORTED_EXTENSIONS)}
if root_path.is_file():
if root_path.suffix.lower() in allowed:
yield root_path
return
if not root_path.is_dir():
return
if include_sub:
for dirpath, _, filenames in os.walk(root_path):
for filename in filenames:
_, ext = os.path.splitext(filename)
if ext.lower() in allowed:
yield Path(dirpath) / filename
return
with os.scandir(root_path) as entries:
for entry in entries:
if not entry.is_file():
continue
_, ext = os.path.splitext(entry.name)
if ext.lower() in allowed:
yield Path(entry.path)
@dataclass
class ConversionTask:
input_file: Path
output_file: Path
status: str = '대기'
error: str | None = None
def to_record(self) -> dict[str, str]:
return {
'input_file': str(self.input_file),
'output_file': str(self.output_file),
'status': self.status,
'detail': self.error or '',
}
@dataclass
class PlannedConversion:
format_type: str
same_location: bool
output_path: str
tasks: list[ConversionTask] = field(default_factory=list)
skipped_tasks: list[ConversionTask] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
conflict_renamed_count: int = 0
@dataclass
class ConversionSummary:
format_type: str
tasks: list[ConversionTask] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
elapsed_seconds: float | None = None
progid_used: str | None = None
mode: str = 'real'
auto_dialog_enabled: bool = False
auto_dialog_events: list[AutoDialogEvent] = field(default_factory=list)
def to_json_dict(self) -> dict[str, Any]:
success = len([task for task in self.tasks if task.status == '성공'])
failed = len([task for task in self.tasks if task.status == '실패'])
skipped = len([task for task in self.tasks if task.status == '건너뜀'])
clicked = len([event for event in self.auto_dialog_events if event.clicked])
detected = len(self.auto_dialog_events)
return {
'summary': {
'format_type': self.format_type,
'mode': self.mode,
'total_requested': len(self.tasks),
'success_count': success,
'failed_count': failed,
'skipped_count': skipped,
'elapsed_seconds': self.elapsed_seconds,
'progid_used': self.progid_used,
'warnings': self.warnings,
'auto_dialog_enabled': self.auto_dialog_enabled,
'auto_dialog_detected_count': detected,
'auto_dialog_clicked_count': clicked,
},
'tasks': [task.to_record() for task in sorted(self.tasks, key=lambda t: str(t.input_file).lower())],
'auto_dialog_events': [event.to_record() for event in self.auto_dialog_events],
}
class TaskPlanner:
def build_tasks(
self,
*,
sources: list[str],
format_type: str,
include_sub: bool,
same_location: bool,
output_path: str,
) -> PlannedConversion:
tasks: list[ConversionTask] = []
skipped: list[ConversionTask] = []
warnings: list[str] = []
out_ext = FORMAT_TYPES[format_type]['ext']
if not sources:
raise ValueError('파일 또는 폴더를 하나 이상 지정하세요.')
normalized_sources = [Path(canonicalize_path(src)) for src in sources]
multiple_sources = len(normalized_sources) > 1
explicit_output_root = Path(canonicalize_path(output_path)) if output_path else None
for source in normalized_sources:
if not source.exists():
raise ValueError(f'입력 경로가 존재하지 않습니다: {source}')
source_files = sorted(iter_supported_files(source, include_sub=include_sub), key=lambda p: str(p).lower())
if not source_files and source.is_dir():
warnings.append(f'지원 파일이 없는 폴더를 건너뜀: {source}')
for input_file in source_files:
if input_file.suffix.lower() == out_ext.lower():
skipped.append(ConversionTask(input_file, input_file, status='건너뜀', error=f'이미 {format_type} 형식입니다.'))
continue
if same_location:
output_file = input_file.parent / (input_file.stem + out_ext)
else:
if explicit_output_root is None:
raise ValueError('--output-dir 또는 --same-location 중 하나가 필요합니다.')
if source.is_file() or multiple_sources:
base_dir = explicit_output_root
output_file = base_dir / (input_file.stem + out_ext)
else:
rel_path = input_file.relative_to(source)
output_file = explicit_output_root / rel_path.parent / (input_file.stem + out_ext)
tasks.append(ConversionTask(input_file=input_file, output_file=output_file))
if skipped:
warnings.append(f'동일 형식 {len(skipped)}개는 자동으로 건너뜁니다.')
return PlannedConversion(format_type=format_type, same_location=same_location, output_path=output_path, tasks=tasks, skipped_tasks=skipped, warnings=warnings)
def resolve_output_conflicts(self, tasks: list[ConversionTask], overwrite: bool) -> int:
if overwrite:
return 0
used_paths: set[Path] = set()
renamed_count = 0
for task in tasks:
original_path = task.output_file
if task.output_file.exists() or task.output_file in used_paths:
counter = 1
stem = original_path.stem
ext = original_path.suffix
parent = original_path.parent
while counter <= MAX_FILENAME_COUNTER:
candidate = parent / f'{stem} ({counter}){ext}'
if (not candidate.exists()) and (candidate not in used_paths):
task.output_file = candidate
break
counter += 1
if task.output_file == original_path:
task.output_file = parent / f'{stem}_{int(time.time())}{ext}'
if task.output_file != original_path:
renamed_count += 1
used_paths.add(task.output_file)
return renamed_count
class MockConverter:
def __init__(self) -> None:
self.progid_used = 'mock'
def initialize(self) -> bool:
return True
def convert_file(self, input_path: Path, output_path: Path, format_type: str = 'PDF'):
output_path.parent.mkdir(parents=True, exist_ok=True)
source_name = Path(input_path).name
payload = f'mock-converted:{source_name}->{format_type}\n'
output_path.write_text(payload, encoding='utf-8')
return True, None
def cleanup(self) -> None:
return None
def _snapshot_hwp_pids() -> set[int]:
try:
result = subprocess.run(['tasklist', '/FO', 'CSV', '/NH'], capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.returncode != 0:
return set()
import csv
import io
reader = csv.reader(io.StringIO(result.stdout))
pids: set[int] = set()
for row in reader:
if len(row) < 2:
continue
image_name = row[0].strip().lower()
if image_name not in HWP_PROCESS_NAMES:
continue
try:
pids.add(int(row[1]))
except ValueError:
pass
return pids
except Exception:
return set()
def _get_window_text(hwnd: int) -> str:
length = USER32.SendMessageW(hwnd, WM_GETTEXTLENGTH, 0, 0)
if length <= 0:
return ''
buffer = ctypes.create_unicode_buffer(length + 1)
USER32.SendMessageW(hwnd, WM_GETTEXT, length + 1, ctypes.byref(buffer))
return buffer.value.strip()
def _get_class_name(hwnd: int) -> str:
buffer = ctypes.create_unicode_buffer(256)
USER32.GetClassNameW(hwnd, buffer, 256)
return buffer.value
class AutoAllowDialogWatcher:
def __init__(self, *, enabled: bool = False, poll_interval: float = POLL_INTERVAL_SECONDS) -> None:
self.enabled = enabled
self.poll_interval = poll_interval
self.events: list[AutoDialogEvent] = []
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._handled_hwnds: set[int] = set()
def start(self) -> None:
if not self.enabled:
return
self._thread = threading.Thread(target=self._run, name='hwp-auto-allow-dialogs', daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
def snapshot_events(self) -> list[AutoDialogEvent]:
with self._lock:
return list(self.events)
def click_once_for_test(self, timeout_seconds: float = WINDOW_SCAN_TIMEOUT_SECONDS) -> bool:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if self._scan_once():
return True
time.sleep(self.poll_interval)
return False
def _run(self) -> None:
while not self._stop_event.wait(self.poll_interval):
self._scan_once()
def _scan_once(self) -> bool:
matched = False
hwnds: list[int] = []
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)(lambda hwnd, lparam: hwnds.append(hwnd) or True)
USER32.EnumWindows(enum_proc, 0)
for hwnd in hwnds:
if hwnd in self._handled_hwnds or not USER32.IsWindowVisible(hwnd):
continue
title = USER32.GetWindowTextLengthW(hwnd)
if title <= 0:
continue
window_title = ctypes.create_unicode_buffer(title + 1)
USER32.GetWindowTextW(hwnd, window_title, title + 1)
if window_title.value.strip() not in DIALOG_TITLE_WHITELIST:
continue
text_parts, allow_button_hwnd, allow_button_text = self._inspect_dialog(hwnd)
window_text = ' '.join(part for part in text_parts if part).strip()
reason = self._classify_candidate(window_title.value.strip(), window_text, allow_button_text)
if reason != 'match':
if window_text:
self._record_event(AutoDialogEvent(window_title=window_title.value.strip(), window_text=window_text, button_text=allow_button_text, clicked=False, reason=reason))
self._handled_hwnds.add(hwnd)
continue
clicked = False
if allow_button_hwnd:
USER32.SendMessageW(allow_button_hwnd, BM_CLICK, 0, 0)
clicked = True
self._record_event(AutoDialogEvent(window_title=window_title.value.strip(), window_text=window_text, button_text=allow_button_text, clicked=clicked, reason='clicked' if clicked else 'allow-button-not-found'))
self._handled_hwnds.add(hwnd)
matched = matched or clicked
return matched
def _inspect_dialog(self, hwnd: int) -> tuple[list[str], int | None, str]:
parts: list[str] = []
allow_button_hwnd: int | None = None
allow_button_text = ''
child_hwnds: list[int] = []
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)(lambda child, lparam: child_hwnds.append(child) or True)
USER32.EnumChildWindows(hwnd, enum_proc, 0)
for child in child_hwnds:
text = _get_window_text(child)
class_name = _get_class_name(child)
if text:
parts.append(text)
if 'BUTTON' in class_name.upper() and text in DIALOG_ALLOW_BUTTONS and allow_button_hwnd is None:
allow_button_hwnd = child
allow_button_text = text
return parts, allow_button_hwnd, allow_button_text
def _classify_candidate(self, title: str, window_text: str, allow_button_text: str) -> str:
if title not in DIALOG_TITLE_WHITELIST:
return 'title-mismatch'
if not all(keyword in window_text for keyword in DIALOG_TEXT_KEYWORDS):
return 'text-mismatch'
if not allow_button_text:
return 'button-mismatch'
return 'match'
def _record_event(self, event: AutoDialogEvent) -> None:
with self._lock:
self.events.append(event)
class RealHwpConverter:
def __init__(self) -> None:
self.hwp = None
self.progid_used: str | None = None
self.is_initialized = False
self.owned_pids: set[int] = set()
def initialize(self) -> bool:
if self.is_initialized:
return True
try:
import pythoncom
from win32com import client as win32_client
except ImportError as exc:
raise RuntimeError('pywin32가 필요합니다. `pip install pywin32` 후 다시 실행하세요.') from exc
try:
pythoncom.CoInitialize()
except Exception:
pass
errors: list[str] = []
for progid in HWP_PROGIDS:
before_pids = _snapshot_hwp_pids()
try:
self.hwp = win32_client.Dispatch(progid)
self.progid_used = progid
try:
self.hwp.RegisterModule('FilePathCheckDLL', 'FilePathCheckerModuleExample')
except Exception:
pass
self.hwp.SetMessageBoxMode(0x00000001)
time.sleep(0.2)
self.owned_pids = _snapshot_hwp_pids() - before_pids
self.is_initialized = True
return True
except Exception as exc:
errors.append(f'{progid}: {exc}')
raise RuntimeError('한글 COM 객체 생성 실패\n' + '\n'.join(errors))
def convert_file(self, input_path: Path, output_path: Path, format_type: str = 'PDF'):
if not self.is_initialized or self.hwp is None:
return False, '한글 객체가 초기화되지 않았습니다.'
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
self.hwp.Open(str(input_path), '', 'forceopen:true')
time.sleep(DOCUMENT_LOAD_DELAY)
save_format = FORMAT_TYPES[format_type]['save_format']
try:
self.hwp.SaveAs(str(output_path), save_format)
except Exception:
self.hwp.SaveAs(str(output_path), save_format, '')
self.hwp.Clear(option=1)
return True, None
except Exception as exc:
try:
self.hwp.Clear(option=1)
except Exception:
pass
return False, str(exc)
def cleanup(self) -> None:
if self.hwp is not None and self.is_initialized:
try:
self.hwp.Clear(3)
except Exception:
pass
try:
self.hwp.Quit()
except Exception:
pass
self.hwp = None
self.is_initialized = False
def choose_converter(mode: str):
if mode == 'mock':
return MockConverter()
return RealHwpConverter()
def render_human(summary: ConversionSummary) -> str:
data = summary.to_json_dict()['summary']
lines = [
f"형식: {data['format_type']} ({data['mode']})",
f"총 {data['total_requested']}건 | 성공 {data['success_count']} | 실패 {data['failed_count']} | 건너뜀 {data['skipped_count']}",
]
if data['warnings']:
lines.append('경고: ' + ' / '.join(data['warnings']))
if summary.auto_dialog_enabled:
lines.append(f"보안 팝업 자동 허용: 감지 {data['auto_dialog_detected_count']} | 클릭 {data['auto_dialog_clicked_count']}")
failed = [task for task in summary.tasks if task.status == '실패']
if failed:
lines.append('실패 목록:')
for task in failed[:10]:
lines.append(f'- {task.input_file.name}: {task.error}')
return '\n'.join(lines)
def run_conversion(args: argparse.Namespace) -> ConversionSummary:
planner = TaskPlanner()
plan = planner.build_tasks(
sources=args.sources,
format_type=args.format,
include_sub=args.include_sub,
same_location=args.same_location,
output_path=args.output_dir or '',
)
plan.conflict_renamed_count = planner.resolve_output_conflicts(plan.tasks, overwrite=args.overwrite)
if plan.conflict_renamed_count:
plan.warnings.append(f'출력 파일 충돌 {plan.conflict_renamed_count}건은 자동으로 새 이름을 부여했습니다.')
all_tasks = list(plan.skipped_tasks)
start = time.time()
if args.plan_only:
for task in plan.tasks:
task.status = '계획됨'
all_tasks.extend(plan.tasks)
return ConversionSummary(format_type=args.format, tasks=all_tasks, warnings=plan.warnings, elapsed_seconds=round(time.time() - start, 3), mode='plan', auto_dialog_enabled=args.auto_allow_dialogs)
converter = choose_converter(args.mode)
dialog_watcher = AutoAllowDialogWatcher(enabled=args.auto_allow_dialogs and args.mode == 'real')
converter.initialize()
dialog_watcher.start()
try:
for task in plan.tasks:
ok, error = converter.convert_file(task.input_file, task.output_file, args.format)
task.status = '성공' if ok else '실패'
task.error = error
all_tasks.append(task)
finally:
dialog_watcher.stop()
converter.cleanup()
dialog_events = dialog_watcher.snapshot_events()
if args.auto_allow_dialogs and args.mode != 'real':
plan.warnings.append('--auto-allow-dialogs 는 real 모드에서만 실제 동작합니다.')
if dialog_events:
clicked = len([event for event in dialog_events if event.clicked])
plan.warnings.append(f'보안 팝업 자동 허용 기록: 감지 {len(dialog_events)}건, 클릭 {clicked}건')
return ConversionSummary(
format_type=args.format,
tasks=all_tasks,
warnings=plan.warnings,
elapsed_seconds=round(time.time() - start, 3),
progid_used=getattr(converter, 'progid_used', None),
mode=args.mode,
auto_dialog_enabled=args.auto_allow_dialogs,
auto_dialog_events=dialog_events,
)
def run_dialog_self_test(timeout_seconds: float = 8.0) -> dict[str, Any]:
dialog_script = r"""
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.Text = '한글'
$form.Width = 420
$form.Height = 170
$form.StartPosition = 'CenterScreen'
$label = New-Object System.Windows.Forms.Label
$label.AutoSize = $true
$label.Left = 20
$label.Top = 20
$label.Text = '한글 문서에 접근하려는 시도를 허용하시겠습니까?'
$form.Controls.Add($label)
$button = New-Object System.Windows.Forms.Button
$button.Text = '모두 허용'
$button.Left = 20
$button.Top = 70
$button.Width = 100
$button.DialogResult = [System.Windows.Forms.DialogResult]::OK
$form.AcceptButton = $button
$form.Controls.Add($button)
[void]$form.ShowDialog()
""".strip()
watcher = AutoAllowDialogWatcher(enabled=True, poll_interval=0.2)
proc = subprocess.Popen(['powershell', '-NoProfile', '-STA', '-Command', dialog_script])
try:
clicked = watcher.click_once_for_test(timeout_seconds=timeout_seconds)
proc.wait(timeout=timeout_seconds)
events = watcher.snapshot_events()
return {
'clicked': clicked,
'returncode': proc.returncode,
'events': [event.to_record() for event in events],
}
finally:
if proc.poll() is None:
proc.kill()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='한글(HWP/HWPX) 문서를 PDF/DOCX/HWPX 등으로 일괄 변환합니다.')
parser.add_argument('sources', nargs='*', help='입력 파일 또는 폴더 경로(여러 개 가능)')
parser.add_argument('--format', default='PDF', choices=sorted(FORMAT_TYPES.keys()), help='출력 형식')
parser.add_argument('--include-sub', action='store_true', default=True, help='하위 폴더 포함(기본값: 켜짐)')
parser.add_argument('--no-include-sub', dest='include_sub', action='store_false', help='하위 폴더 미포함')
parser.add_argument('--same-location', action='store_true', default=False, help='원본과 같은 폴더에 출력')
parser.add_argument('--output-dir', help='출력 루트 폴더')
parser.add_argument('--overwrite', action='store_true', help='같은 이름 출력 파일 덮어쓰기 허용')
parser.add_argument('--plan-only', action='store_true', help='실제 변환 없이 작업 계획만 출력')
parser.add_argument('--mode', choices=['real', 'mock'], default='real', help='real=한글 COM 실변환, mock=테스트용 가짜 변환')
parser.add_argument('--auto-allow-dialogs', action='store_true', help='한글 보안 확인 팝업(제목=한글, 본문에 접근하려는 시도, 버튼=모두 허용)만 자동 클릭')
parser.add_argument('--json', action='store_true', help='JSON 출력')
parser.add_argument('--report-json', help='결과 JSON 파일 저장 경로')
parser.add_argument('--self-test-dialog-handler', action='store_true', help='보안 팝업 자동 클릭 로직의 로컬 UI 테스트를 실행')
args = parser.parse_args()
if args.self_test_dialog_handler:
return args
if not args.sources:
parser.error('입력 파일 또는 폴더를 하나 이상 지정하세요.')
if not args.same_location and not args.output_dir:
parser.error('--same-location 또는 --output-dir 중 하나를 지정하세요.')
return args
def main() -> int:
args = parse_args()
try:
if args.self_test_dialog_handler:
payload = run_dialog_self_test()
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload['clicked'] and payload['returncode'] == 0 else 1
summary = run_conversion(args)
except Exception as exc:
error_payload = {'error': str(exc)}
if getattr(args, 'json', False):
print(json.dumps(error_payload, ensure_ascii=False, indent=2))
else:
print(f'오류: {exc}', file=sys.stderr)
return 1
payload = summary.to_json_dict()
if args.report_json:
report_path = Path(args.report_json)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(render_human(summary))
return 0
if __name__ == '__main__':
raise SystemExit(main())
Batch-convert 한컴 한글 문서(HWP/HWPX) on Windows into PDF and other export formats with a Korean-friendly automation workflow. Use when the user asks for 이 폴더 hwp...
---
name: hwp-batch-convert
description: Batch-convert 한컴 한글 문서(HWP/HWPX) on Windows into PDF and other export formats with a Korean-friendly automation workflow. Use when the user asks for 이 폴더 hwp 전부 pdf로 바꿔줘, hwp/hwpx/doc/pdf 일괄 변환, 한글문서 일괄 처리, 폴더 단위 변환, 여러 한글 파일을 다른 형식으로 내보내기, or wants plan-only / mock / real conversion runs with machine-readable reports. Supports HWP/HWPX to PDF, HWPX, DOCX, ODT, HTML, RTF, TXT, PNG, JPG, BMP, and GIF, plus optional automatic approval of known 한글 보안 확인 팝업. Prefer this skill for Windows environments with Hancom HWP installed; do not use it for non-HWP document families unless the task is explicitly about HWP/HWPX conversion.
---
# Hwp Batch Convert
Use this skill for **Windows 기반 한글(HWP/HWPX) 문서 일괄 변환**.
Current scope:
- 폴더 단위 일괄 변환
- 파일 여러 개 일괄 변환
- HWP/HWPX → PDF 기본 변환
- HWP/HWPX → HWPX/DOCX/ODT/HTML/RTF/TXT/PNG/JPG/BMP/GIF 변환
- 동일 형식 자동 건너뜀
- 출력 파일명 충돌 시 자동 번호 부여
- 작업 계획만 확인하는 `--plan-only`
- OpenClaw 상위 레이어 연동용 `--json`, `--report-json`
- 한글 보안 확인 팝업 자동 허용용 `--auto-allow-dialogs`
- 로컬 UI 검증용 `--self-test-dialog-handler`
- 테스트용 `--mode mock`
## Source basis
This skill reuses the design of the local/source repo:
- `tmp/HwpMate`
- upstream: `https://github.com/twbeatles/HwpMate`
Main logic origin:
- `hwpmate/services/hwp_converter.py`
- `hwpmate/services/task_planner.py`
- `hwpmate/constants.py`
- `hwpmate/path_utils.py`
If you need the mapping details or reuse rationale, read:
- `references/hwpmate-reuse-notes.md`
If you need the popup whitelist / safety details, read:
- `references/auto-allow-dialogs.md`
## Quick start
같은 폴더에 PDF 출력:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\contracts" --format PDF --same-location
```
별도 출력 폴더로 변환:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\hwp" --format PDF --output-dir "C:\docs\pdf" --auto-allow-dialogs
```
여러 파일 한 번에 변환:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\a.hwp" "C:\docs\b.hwpx" --format DOCX --output-dir "C:\docs\docx"
```
실제 변환 없이 계획만 확인:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\hwp" --format PDF --output-dir "C:\docs\pdf" --plan-only --json
```
테스트용 모의 변환:
```bash
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\sample" --format PDF --output-dir "C:\docs\out" --mode mock --json
```
## Main script
### `scripts/hwp_batch_convert.py`
Parameters:
- `sources...`: 입력 파일/폴더 경로 하나 이상
- `--format`: 출력 형식 (`PDF`, `HWPX`, `DOCX`, `ODT`, `HTML`, `RTF`, `TXT`, `PNG`, `JPG`, `BMP`, `GIF`, `HWP`)
- `--same-location`: 원본과 같은 폴더에 출력
- `--output-dir`: 출력 루트 폴더
- `--include-sub`: 하위 폴더 포함(기본값)
- `--no-include-sub`: 하위 폴더 제외
- `--overwrite`: 같은 이름 출력 허용
- `--plan-only`: 실제 변환 없이 작업 계획만 생성
- `--mode real|mock`: 실변환 또는 모의 변환
- `--auto-allow-dialogs`: 제목 `한글`, 본문에 `접근하려는 시도`, 버튼 `모두 허용`/`허용` 조건을 모두 만족하는 보안 팝업만 자동 클릭
- `--json`: stdout JSON 출력
- `--report-json`: 결과 JSON 파일 저장
- `--self-test-dialog-handler`: 로컬 테스트용 샘플 `한글` 창을 띄워 자동 클릭 로직만 검증
## Recommended workflow
1. 사용자 요청이 폴더/여러 파일 기반 HWP/HWPX 변환인지 확인한다.
2. 출력 형식이 명시되지 않았으면 보통 `PDF`를 기본 제안으로 사용한다.
3. 먼저 `--plan-only --json` 으로 대상/건너뜀/출력 경로를 확인한다.
4. 환경이 Windows + 한글 설치 + pywin32 가능하면 `--mode real` 로 실행한다.
5. 환경 검증이 먼저 필요하면 `--mode mock` 으로 경로/출력 구조만 검증한다.
6. 필요하면 `--report-json` 으로 결과 파일을 남긴다.
## Operational notes
- 이 스킬은 사실상 **Windows 전용**이다.
- 실변환(`--mode real`)은 **한컴 한글 설치**와 **pywin32**가 필요하다.
- `--auto-allow-dialogs` 는 화이트리스트 방식이다. 제목이 `한글` 이고, 본문에 `접근하려는 시도` 가 있으며, 버튼이 `모두 허용` 또는 `허용` 인 경우에만 클릭한다.
- 위 조건에 맞지 않는 다른 팝업은 자동으로 건드리지 않는다. 감지되더라도 클릭 없이 이벤트만 남기거나 무시한다.
- 자동 허용 기록은 stdout JSON/`--report-json` 의 `auto_dialog_*` 필드와 `auto_dialog_events` 배열에서 확인한다.
- 한글 COM 자동화가 실패하면 `--mode mock` 으로 스킬/경로 로직만 먼저 검증하고, 이후 환경 문제를 분리 진단한다.
- 여러 폴더를 동시에 입력하면서 `--output-dir` 를 쓰면, 파일명 충돌 가능성이 있으니 결과 경로를 확인한다.
- 기본 입력 확장자는 `.hwp`, `.hwpx` 만 대상으로 한다.
- 사용자 요청이 DOCX/PDF 일반 문서 처리 중심이고 HWP가 핵심이 아니면 다른 문서 스킬/도구를 우선 고려한다.
FILE:README.md
# hwp-batch-convert
Windows에서 한컴 한글(HWP/HWPX) 문서를 COM 자동화로 일괄 변환하는 OpenClaw 스킬입니다.
## 새 기능: 보안 확인 팝업 자동 허용
한글 COM 자동화 중 아래 조건을 **모두 만족하는 보안 팝업만** 자동으로 처리할 수 있습니다.
- 창 제목: `한글`
- 본문 텍스트에 `접근하려는 시도` 포함
- 버튼 텍스트: `모두 허용` 우선, 없으면 `허용`
예상치 못한 다른 팝업은 건드리지 않도록 화이트리스트 방식으로 제한했습니다.
## 사용 예시
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\hwp" --format PDF --output-dir "C:\docs\pdf" --auto-allow-dialogs --json --report-json "C:\docs\pdf\report.json"
```
## 테스트
### 1) UI 자동 클릭 로컬 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py --self-test-dialog-handler
```
### 2) 모의 변환 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py "C:\docs\sample" --format PDF --output-dir "C:\docs\out" --mode mock --auto-allow-dialogs --json
```
mock 모드에서는 실제 한글 창이 없으므로 자동 허용이 동작하지 않고 경고만 남깁니다.
## 리포트 확인 포인트
JSON summary에 다음 필드가 추가됩니다.
- `auto_dialog_enabled`
- `auto_dialog_detected_count`
- `auto_dialog_clicked_count`
- `auto_dialog_events[]`
FILE:references/auto-allow-dialogs.md
# 한글 보안 팝업 자동 허용 메모
## 목적
한글(HWP) COM 자동화 중 뜨는 보안 확인 팝업 때문에 `Open`/`SaveAs` 흐름이 멈추는 경우를 줄이기 위해, 매우 제한된 조건의 팝업만 자동 클릭한다.
## 화이트리스트 조건
다음 조건을 모두 만족할 때만 클릭한다.
1. 최상위 창 제목이 정확히 `한글`
2. 자식 컨트롤 텍스트를 합친 본문에 `접근하려는 시도` 포함
3. 버튼 텍스트가 `모두 허용` 우선, 없으면 `허용`
## 안전장치
- 제목만 `한글` 인 다른 창은 본문/버튼 조건까지 함께 확인하기 전에는 클릭하지 않는다.
- 버튼 텍스트가 없거나 다른 경우 클릭하지 않는다.
- 화이트리스트와 맞지 않는 창은 `text-mismatch`, `button-mismatch` 등 이유를 이벤트에 남길 수 있다.
- 탐지된 창 핸들은 중복 처리하지 않는다.
- 외부 UI 자동화 패키지 없이 Win32 API(`EnumWindows`, `EnumChildWindows`, `SendMessageW(BM_CLICK)`)만 사용한다.
## 테스트 방법
### UI 클릭 자체 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py --self-test-dialog-handler
```
테스트는 PowerShell WinForms로 아래와 유사한 샘플 창을 띄우고, 자동 클릭 로직이 `모두 허용` 버튼을 실제로 누르는지 확인한다.
- 제목: `한글`
- 본문: `한글 문서에 접근하려는 시도를 허용하시겠습니까?`
- 버튼: `모두 허용`
### 변환 경로 테스트
```powershell
python skills/hwp-batch-convert/scripts/hwp_batch_convert.py <입력> --format PDF --output-dir <출력> --mode mock --auto-allow-dialogs --json
```
mock 모드에서는 실제 한글 창이 없으므로 `auto_dialog_detected_count = 0` 이 정상이다.
FILE:references/hwpmate-reuse-notes.md
# HwpMate 재사용 메모
이 스킬의 최소 CLI는 `twbeatles/HwpMate`의 다음 구조를 직접 참고해 경량 재구성했다.
- `hwpmate/services/hwp_converter.py`
- pywin32 + HWP COM 초기화
- `Open` + `SaveAs` + `Clear` 기반 변환 흐름
- 여러 ProgID 폴백 시도
- `hwpmate/services/task_planner.py`
- 폴더/파일 입력을 변환 작업 목록으로 펼치기
- 동일 형식 자동 건너뜀
- 출력 경로 충돌 시 ` (1)`, ` (2)` 번호 부여
- `hwpmate/constants.py`
- 지원 입력 확장자, 출력 포맷 매핑, ProgID 목록
- `hwpmate/path_utils.py`
- 지원 파일 순회 로직
- `hwpmate/models.py`
- 작업/요약 데이터 구조
스킬 쪽 구현은 OpenClaw에서 바로 쓰기 쉽게 GUI 의존성(PyQt6)을 제거하고 CLI 중심으로 축소했다.
## 차이점
- GUI, 백업, 트레이, 토스트, CSV/TXT 실패 리포트는 제외
- OpenClaw에서 쓰기 좋은 `--plan-only`, `--json`, `--report-json`, `--mode mock` 추가
- `sources` 인자에 파일/폴더 여러 개를 동시에 받을 수 있게 단순화
- 기본 목적은 `한글 문서 일괄 처리` 요청에서 빠르게 실행 가능한 최소 기능 제공
FILE:scripts/hwp_batch_convert.py
from __future__ import annotations
import argparse
import ctypes
import json
import os
import subprocess
import sys
import threading
import time
from ctypes import wintypes
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
SUPPORTED_EXTENSIONS = ('.hwp', '.hwpx')
DOCUMENT_LOAD_DELAY = 1.0
MAX_FILENAME_COUNTER = 1000
HWP_PROGIDS = [
'HWPControl.HwpCtrl.1',
'HwpObject.HwpObject',
'HWPFrame.HwpObject',
]
FORMAT_TYPES: dict[str, dict[str, str]] = {
'HWP': {'ext': '.hwp', 'save_format': 'HWP'},
'HWPX': {'ext': '.hwpx', 'save_format': 'HWPX'},
'PDF': {'ext': '.pdf', 'save_format': 'PDF'},
'DOCX': {'ext': '.docx', 'save_format': 'OOXML'},
'ODT': {'ext': '.odt', 'save_format': 'ODT'},
'HTML': {'ext': '.html', 'save_format': 'HTML'},
'RTF': {'ext': '.rtf', 'save_format': 'RTF'},
'TXT': {'ext': '.txt', 'save_format': 'TEXT'},
'PNG': {'ext': '.png', 'save_format': 'PNG'},
'JPG': {'ext': '.jpg', 'save_format': 'JPG'},
'BMP': {'ext': '.bmp', 'save_format': 'BMP'},
'GIF': {'ext': '.gif', 'save_format': 'GIF'},
}
HWP_PROCESS_NAMES = {'hwp.exe', 'hwpctrl.exe'}
DIALOG_TITLE_WHITELIST = {'한글'}
DIALOG_TEXT_KEYWORDS = ('접근하려는 시도',)
DIALOG_ALLOW_BUTTONS = ('모두 허용', '허용')
POLL_INTERVAL_SECONDS = 0.35
WINDOW_SCAN_TIMEOUT_SECONDS = 3.0
BM_CLICK = 0x00F5
WM_GETTEXT = 0x000D
WM_GETTEXTLENGTH = 0x000E
USER32 = ctypes.WinDLL('user32', use_last_error=True)
@dataclass
class AutoDialogEvent:
window_title: str
window_text: str
button_text: str
clicked: bool
reason: str
timestamp: float = field(default_factory=time.time)
def to_record(self) -> dict[str, Any]:
return {
'timestamp': round(self.timestamp, 3),
'window_title': self.window_title,
'window_text': self.window_text,
'button_text': self.button_text,
'clicked': self.clicked,
'reason': self.reason,
}
def canonicalize_path(path: str | Path) -> str:
return os.path.abspath(os.path.normpath(str(path)))
def iter_supported_files(root_path: Path, include_sub: bool = True, allowed_exts: Optional[Iterable[str]] = None) -> Iterable[Path]:
allowed = {ext.lower() for ext in (allowed_exts or SUPPORTED_EXTENSIONS)}
if root_path.is_file():
if root_path.suffix.lower() in allowed:
yield root_path
return
if not root_path.is_dir():
return
if include_sub:
for dirpath, _, filenames in os.walk(root_path):
for filename in filenames:
_, ext = os.path.splitext(filename)
if ext.lower() in allowed:
yield Path(dirpath) / filename
return
with os.scandir(root_path) as entries:
for entry in entries:
if not entry.is_file():
continue
_, ext = os.path.splitext(entry.name)
if ext.lower() in allowed:
yield Path(entry.path)
@dataclass
class ConversionTask:
input_file: Path
output_file: Path
status: str = '대기'
error: str | None = None
def to_record(self) -> dict[str, str]:
return {
'input_file': str(self.input_file),
'output_file': str(self.output_file),
'status': self.status,
'detail': self.error or '',
}
@dataclass
class PlannedConversion:
format_type: str
same_location: bool
output_path: str
tasks: list[ConversionTask] = field(default_factory=list)
skipped_tasks: list[ConversionTask] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
conflict_renamed_count: int = 0
@dataclass
class ConversionSummary:
format_type: str
tasks: list[ConversionTask] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
elapsed_seconds: float | None = None
progid_used: str | None = None
mode: str = 'real'
auto_dialog_enabled: bool = False
auto_dialog_events: list[AutoDialogEvent] = field(default_factory=list)
def to_json_dict(self) -> dict[str, Any]:
success = len([task for task in self.tasks if task.status == '성공'])
failed = len([task for task in self.tasks if task.status == '실패'])
skipped = len([task for task in self.tasks if task.status == '건너뜀'])
clicked = len([event for event in self.auto_dialog_events if event.clicked])
detected = len(self.auto_dialog_events)
return {
'summary': {
'format_type': self.format_type,
'mode': self.mode,
'total_requested': len(self.tasks),
'success_count': success,
'failed_count': failed,
'skipped_count': skipped,
'elapsed_seconds': self.elapsed_seconds,
'progid_used': self.progid_used,
'warnings': self.warnings,
'auto_dialog_enabled': self.auto_dialog_enabled,
'auto_dialog_detected_count': detected,
'auto_dialog_clicked_count': clicked,
},
'tasks': [task.to_record() for task in sorted(self.tasks, key=lambda t: str(t.input_file).lower())],
'auto_dialog_events': [event.to_record() for event in self.auto_dialog_events],
}
class TaskPlanner:
def build_tasks(
self,
*,
sources: list[str],
format_type: str,
include_sub: bool,
same_location: bool,
output_path: str,
) -> PlannedConversion:
tasks: list[ConversionTask] = []
skipped: list[ConversionTask] = []
warnings: list[str] = []
out_ext = FORMAT_TYPES[format_type]['ext']
if not sources:
raise ValueError('파일 또는 폴더를 하나 이상 지정하세요.')
normalized_sources = [Path(canonicalize_path(src)) for src in sources]
multiple_sources = len(normalized_sources) > 1
explicit_output_root = Path(canonicalize_path(output_path)) if output_path else None
for source in normalized_sources:
if not source.exists():
raise ValueError(f'입력 경로가 존재하지 않습니다: {source}')
source_files = sorted(iter_supported_files(source, include_sub=include_sub), key=lambda p: str(p).lower())
if not source_files and source.is_dir():
warnings.append(f'지원 파일이 없는 폴더를 건너뜀: {source}')
for input_file in source_files:
if input_file.suffix.lower() == out_ext.lower():
skipped.append(ConversionTask(input_file, input_file, status='건너뜀', error=f'이미 {format_type} 형식입니다.'))
continue
if same_location:
output_file = input_file.parent / (input_file.stem + out_ext)
else:
if explicit_output_root is None:
raise ValueError('--output-dir 또는 --same-location 중 하나가 필요합니다.')
if source.is_file() or multiple_sources:
base_dir = explicit_output_root
output_file = base_dir / (input_file.stem + out_ext)
else:
rel_path = input_file.relative_to(source)
output_file = explicit_output_root / rel_path.parent / (input_file.stem + out_ext)
tasks.append(ConversionTask(input_file=input_file, output_file=output_file))
if skipped:
warnings.append(f'동일 형식 {len(skipped)}개는 자동으로 건너뜁니다.')
return PlannedConversion(format_type=format_type, same_location=same_location, output_path=output_path, tasks=tasks, skipped_tasks=skipped, warnings=warnings)
def resolve_output_conflicts(self, tasks: list[ConversionTask], overwrite: bool) -> int:
if overwrite:
return 0
used_paths: set[Path] = set()
renamed_count = 0
for task in tasks:
original_path = task.output_file
if task.output_file.exists() or task.output_file in used_paths:
counter = 1
stem = original_path.stem
ext = original_path.suffix
parent = original_path.parent
while counter <= MAX_FILENAME_COUNTER:
candidate = parent / f'{stem} ({counter}){ext}'
if (not candidate.exists()) and (candidate not in used_paths):
task.output_file = candidate
break
counter += 1
if task.output_file == original_path:
task.output_file = parent / f'{stem}_{int(time.time())}{ext}'
if task.output_file != original_path:
renamed_count += 1
used_paths.add(task.output_file)
return renamed_count
class MockConverter:
def __init__(self) -> None:
self.progid_used = 'mock'
def initialize(self) -> bool:
return True
def convert_file(self, input_path: Path, output_path: Path, format_type: str = 'PDF'):
output_path.parent.mkdir(parents=True, exist_ok=True)
source_name = Path(input_path).name
payload = f'mock-converted:{source_name}->{format_type}\n'
output_path.write_text(payload, encoding='utf-8')
return True, None
def cleanup(self) -> None:
return None
def _snapshot_hwp_pids() -> set[int]:
try:
result = subprocess.run(['tasklist', '/FO', 'CSV', '/NH'], capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.returncode != 0:
return set()
import csv
import io
reader = csv.reader(io.StringIO(result.stdout))
pids: set[int] = set()
for row in reader:
if len(row) < 2:
continue
image_name = row[0].strip().lower()
if image_name not in HWP_PROCESS_NAMES:
continue
try:
pids.add(int(row[1]))
except ValueError:
pass
return pids
except Exception:
return set()
def _get_window_text(hwnd: int) -> str:
length = USER32.SendMessageW(hwnd, WM_GETTEXTLENGTH, 0, 0)
if length <= 0:
return ''
buffer = ctypes.create_unicode_buffer(length + 1)
USER32.SendMessageW(hwnd, WM_GETTEXT, length + 1, ctypes.byref(buffer))
return buffer.value.strip()
def _get_class_name(hwnd: int) -> str:
buffer = ctypes.create_unicode_buffer(256)
USER32.GetClassNameW(hwnd, buffer, 256)
return buffer.value
class AutoAllowDialogWatcher:
def __init__(self, *, enabled: bool = False, poll_interval: float = POLL_INTERVAL_SECONDS) -> None:
self.enabled = enabled
self.poll_interval = poll_interval
self.events: list[AutoDialogEvent] = []
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._handled_hwnds: set[int] = set()
def start(self) -> None:
if not self.enabled:
return
self._thread = threading.Thread(target=self._run, name='hwp-auto-allow-dialogs', daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
def snapshot_events(self) -> list[AutoDialogEvent]:
with self._lock:
return list(self.events)
def click_once_for_test(self, timeout_seconds: float = WINDOW_SCAN_TIMEOUT_SECONDS) -> bool:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if self._scan_once():
return True
time.sleep(self.poll_interval)
return False
def _run(self) -> None:
while not self._stop_event.wait(self.poll_interval):
self._scan_once()
def _scan_once(self) -> bool:
matched = False
hwnds: list[int] = []
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)(lambda hwnd, lparam: hwnds.append(hwnd) or True)
USER32.EnumWindows(enum_proc, 0)
for hwnd in hwnds:
if hwnd in self._handled_hwnds or not USER32.IsWindowVisible(hwnd):
continue
title = USER32.GetWindowTextLengthW(hwnd)
if title <= 0:
continue
window_title = ctypes.create_unicode_buffer(title + 1)
USER32.GetWindowTextW(hwnd, window_title, title + 1)
if window_title.value.strip() not in DIALOG_TITLE_WHITELIST:
continue
text_parts, allow_button_hwnd, allow_button_text = self._inspect_dialog(hwnd)
window_text = ' '.join(part for part in text_parts if part).strip()
reason = self._classify_candidate(window_title.value.strip(), window_text, allow_button_text)
if reason != 'match':
if window_text:
self._record_event(AutoDialogEvent(window_title=window_title.value.strip(), window_text=window_text, button_text=allow_button_text, clicked=False, reason=reason))
self._handled_hwnds.add(hwnd)
continue
clicked = False
if allow_button_hwnd:
USER32.SendMessageW(allow_button_hwnd, BM_CLICK, 0, 0)
clicked = True
self._record_event(AutoDialogEvent(window_title=window_title.value.strip(), window_text=window_text, button_text=allow_button_text, clicked=clicked, reason='clicked' if clicked else 'allow-button-not-found'))
self._handled_hwnds.add(hwnd)
matched = matched or clicked
return matched
def _inspect_dialog(self, hwnd: int) -> tuple[list[str], int | None, str]:
parts: list[str] = []
allow_button_hwnd: int | None = None
allow_button_text = ''
child_hwnds: list[int] = []
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)(lambda child, lparam: child_hwnds.append(child) or True)
USER32.EnumChildWindows(hwnd, enum_proc, 0)
for child in child_hwnds:
text = _get_window_text(child)
class_name = _get_class_name(child)
if text:
parts.append(text)
if 'BUTTON' in class_name.upper() and text in DIALOG_ALLOW_BUTTONS and allow_button_hwnd is None:
allow_button_hwnd = child
allow_button_text = text
return parts, allow_button_hwnd, allow_button_text
def _classify_candidate(self, title: str, window_text: str, allow_button_text: str) -> str:
if title not in DIALOG_TITLE_WHITELIST:
return 'title-mismatch'
if not all(keyword in window_text for keyword in DIALOG_TEXT_KEYWORDS):
return 'text-mismatch'
if not allow_button_text:
return 'button-mismatch'
return 'match'
def _record_event(self, event: AutoDialogEvent) -> None:
with self._lock:
self.events.append(event)
class RealHwpConverter:
def __init__(self) -> None:
self.hwp = None
self.progid_used: str | None = None
self.is_initialized = False
self.owned_pids: set[int] = set()
def initialize(self) -> bool:
if self.is_initialized:
return True
try:
import pythoncom
from win32com import client as win32_client
except ImportError as exc:
raise RuntimeError('pywin32가 필요합니다. `pip install pywin32` 후 다시 실행하세요.') from exc
try:
pythoncom.CoInitialize()
except Exception:
pass
errors: list[str] = []
for progid in HWP_PROGIDS:
before_pids = _snapshot_hwp_pids()
try:
self.hwp = win32_client.Dispatch(progid)
self.progid_used = progid
try:
self.hwp.RegisterModule('FilePathCheckDLL', 'FilePathCheckerModuleExample')
except Exception:
pass
self.hwp.SetMessageBoxMode(0x00000001)
time.sleep(0.2)
self.owned_pids = _snapshot_hwp_pids() - before_pids
self.is_initialized = True
return True
except Exception as exc:
errors.append(f'{progid}: {exc}')
raise RuntimeError('한글 COM 객체 생성 실패\n' + '\n'.join(errors))
def convert_file(self, input_path: Path, output_path: Path, format_type: str = 'PDF'):
if not self.is_initialized or self.hwp is None:
return False, '한글 객체가 초기화되지 않았습니다.'
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
self.hwp.Open(str(input_path), '', 'forceopen:true')
time.sleep(DOCUMENT_LOAD_DELAY)
save_format = FORMAT_TYPES[format_type]['save_format']
try:
self.hwp.SaveAs(str(output_path), save_format)
except Exception:
self.hwp.SaveAs(str(output_path), save_format, '')
self.hwp.Clear(option=1)
return True, None
except Exception as exc:
try:
self.hwp.Clear(option=1)
except Exception:
pass
return False, str(exc)
def cleanup(self) -> None:
if self.hwp is not None and self.is_initialized:
try:
self.hwp.Clear(3)
except Exception:
pass
try:
self.hwp.Quit()
except Exception:
pass
self.hwp = None
self.is_initialized = False
def choose_converter(mode: str):
if mode == 'mock':
return MockConverter()
return RealHwpConverter()
def render_human(summary: ConversionSummary) -> str:
data = summary.to_json_dict()['summary']
lines = [
f"형식: {data['format_type']} ({data['mode']})",
f"총 {data['total_requested']}건 | 성공 {data['success_count']} | 실패 {data['failed_count']} | 건너뜀 {data['skipped_count']}",
]
if data['warnings']:
lines.append('경고: ' + ' / '.join(data['warnings']))
if summary.auto_dialog_enabled:
lines.append(f"보안 팝업 자동 허용: 감지 {data['auto_dialog_detected_count']} | 클릭 {data['auto_dialog_clicked_count']}")
failed = [task for task in summary.tasks if task.status == '실패']
if failed:
lines.append('실패 목록:')
for task in failed[:10]:
lines.append(f'- {task.input_file.name}: {task.error}')
return '\n'.join(lines)
def run_conversion(args: argparse.Namespace) -> ConversionSummary:
planner = TaskPlanner()
plan = planner.build_tasks(
sources=args.sources,
format_type=args.format,
include_sub=args.include_sub,
same_location=args.same_location,
output_path=args.output_dir or '',
)
plan.conflict_renamed_count = planner.resolve_output_conflicts(plan.tasks, overwrite=args.overwrite)
if plan.conflict_renamed_count:
plan.warnings.append(f'출력 파일 충돌 {plan.conflict_renamed_count}건은 자동으로 새 이름을 부여했습니다.')
all_tasks = list(plan.skipped_tasks)
start = time.time()
if args.plan_only:
for task in plan.tasks:
task.status = '계획됨'
all_tasks.extend(plan.tasks)
return ConversionSummary(format_type=args.format, tasks=all_tasks, warnings=plan.warnings, elapsed_seconds=round(time.time() - start, 3), mode='plan', auto_dialog_enabled=args.auto_allow_dialogs)
converter = choose_converter(args.mode)
dialog_watcher = AutoAllowDialogWatcher(enabled=args.auto_allow_dialogs and args.mode == 'real')
converter.initialize()
dialog_watcher.start()
try:
for task in plan.tasks:
ok, error = converter.convert_file(task.input_file, task.output_file, args.format)
task.status = '성공' if ok else '실패'
task.error = error
all_tasks.append(task)
finally:
dialog_watcher.stop()
converter.cleanup()
dialog_events = dialog_watcher.snapshot_events()
if args.auto_allow_dialogs and args.mode != 'real':
plan.warnings.append('--auto-allow-dialogs 는 real 모드에서만 실제 동작합니다.')
if dialog_events:
clicked = len([event for event in dialog_events if event.clicked])
plan.warnings.append(f'보안 팝업 자동 허용 기록: 감지 {len(dialog_events)}건, 클릭 {clicked}건')
return ConversionSummary(
format_type=args.format,
tasks=all_tasks,
warnings=plan.warnings,
elapsed_seconds=round(time.time() - start, 3),
progid_used=getattr(converter, 'progid_used', None),
mode=args.mode,
auto_dialog_enabled=args.auto_allow_dialogs,
auto_dialog_events=dialog_events,
)
def run_dialog_self_test(timeout_seconds: float = 8.0) -> dict[str, Any]:
dialog_script = r"""
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.Text = '한글'
$form.Width = 420
$form.Height = 170
$form.StartPosition = 'CenterScreen'
$label = New-Object System.Windows.Forms.Label
$label.AutoSize = $true
$label.Left = 20
$label.Top = 20
$label.Text = '한글 문서에 접근하려는 시도를 허용하시겠습니까?'
$form.Controls.Add($label)
$button = New-Object System.Windows.Forms.Button
$button.Text = '모두 허용'
$button.Left = 20
$button.Top = 70
$button.Width = 100
$button.DialogResult = [System.Windows.Forms.DialogResult]::OK
$form.AcceptButton = $button
$form.Controls.Add($button)
[void]$form.ShowDialog()
""".strip()
watcher = AutoAllowDialogWatcher(enabled=True, poll_interval=0.2)
proc = subprocess.Popen(['powershell', '-NoProfile', '-STA', '-Command', dialog_script])
try:
clicked = watcher.click_once_for_test(timeout_seconds=timeout_seconds)
proc.wait(timeout=timeout_seconds)
events = watcher.snapshot_events()
return {
'clicked': clicked,
'returncode': proc.returncode,
'events': [event.to_record() for event in events],
}
finally:
if proc.poll() is None:
proc.kill()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='한글(HWP/HWPX) 문서를 PDF/DOCX/HWPX 등으로 일괄 변환합니다.')
parser.add_argument('sources', nargs='*', help='입력 파일 또는 폴더 경로(여러 개 가능)')
parser.add_argument('--format', default='PDF', choices=sorted(FORMAT_TYPES.keys()), help='출력 형식')
parser.add_argument('--include-sub', action='store_true', default=True, help='하위 폴더 포함(기본값: 켜짐)')
parser.add_argument('--no-include-sub', dest='include_sub', action='store_false', help='하위 폴더 미포함')
parser.add_argument('--same-location', action='store_true', default=False, help='원본과 같은 폴더에 출력')
parser.add_argument('--output-dir', help='출력 루트 폴더')
parser.add_argument('--overwrite', action='store_true', help='같은 이름 출력 파일 덮어쓰기 허용')
parser.add_argument('--plan-only', action='store_true', help='실제 변환 없이 작업 계획만 출력')
parser.add_argument('--mode', choices=['real', 'mock'], default='real', help='real=한글 COM 실변환, mock=테스트용 가짜 변환')
parser.add_argument('--auto-allow-dialogs', action='store_true', help='한글 보안 확인 팝업(제목=한글, 본문에 접근하려는 시도, 버튼=모두 허용)만 자동 클릭')
parser.add_argument('--json', action='store_true', help='JSON 출력')
parser.add_argument('--report-json', help='결과 JSON 파일 저장 경로')
parser.add_argument('--self-test-dialog-handler', action='store_true', help='보안 팝업 자동 클릭 로직의 로컬 UI 테스트를 실행')
args = parser.parse_args()
if args.self_test_dialog_handler:
return args
if not args.sources:
parser.error('입력 파일 또는 폴더를 하나 이상 지정하세요.')
if not args.same_location and not args.output_dir:
parser.error('--same-location 또는 --output-dir 중 하나를 지정하세요.')
return args
def main() -> int:
args = parse_args()
try:
if args.self_test_dialog_handler:
payload = run_dialog_self_test()
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload['clicked'] and payload['returncode'] == 0 else 1
summary = run_conversion(args)
except Exception as exc:
error_payload = {'error': str(exc)}
if getattr(args, 'json', False):
print(json.dumps(error_payload, ensure_ascii=False, indent=2))
else:
print(f'오류: {exc}', file=sys.stderr)
return 1
payload = summary.to_json_dict()
if args.report_json:
report_path = Path(args.report_json)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(render_human(summary))
return 0
if __name__ == '__main__':
raise SystemExit(main())
Search, compare, and monitor 대한민국 property listings from 네이버 부동산 with natural-language queries. Use when the user wants 강남 아파트 전세 시세 찾기, 특정 지역 매매/전세/월세 비교, 조...
---
name: naver-real-estate-search
description: Search, compare, and monitor 대한민국 property listings from 네이버 부동산 with natural-language queries. Use when the user wants 강남 아파트 전세 시세 찾기, 특정 지역 매매/전세/월세 비교, 조건에 맞는 매물 리스트 정리, 단지 후보 찾기, 여러 단지 비교 리포트, 자연어 채팅형 부동산 브리핑, or 목표가/새 매물/가격하락 감시 초안. Supports Korean property tasks such as apartment/빌라 listing summaries, 지역명/단지명 기반 단지 후보 탐색, same-area comparison, candidate seed/candidate cache workflows, and stdout/JSON results that can be connected to Telegram or higher-level briefings. Prefer direct 단지 URL or complex ID first when rate-limited; otherwise use the natural-language wrapper and narrow to 1~3 candidate complexes before broad scans.
---
# Naver Real Estate Search
네이버 부동산 기반의 **대한민국 부동산 매물 검색 / 단지 후보 탐색 / 단지 비교 / 채팅형 브리핑 / 가격 감시** 스킬이다.
핵심 원칙:
- **단일 단지 우선**: URL/complex ID가 있으면 그걸 먼저 쓴다.
- **후보는 좁게**: 지역 전체를 넓게 긁기보다 후보 단지를 1~3개 먼저 좁힌다.
- **자연어 우선**: “잠실 리센츠 전세 30평대”, “은마와 래미안대치팰리스 비교”처럼 바로 넣는다.
- **한국어 브리핑 우선**: 채팅 표면에는 짧은 한국어 해석을 먼저 주고, 구조화 데이터가 필요할 때 JSON을 붙인다.
- **429 완화**: 짧은 백오프 후에도 막히면 direct URL/ID 기반 재조회 쪽으로 유도한다.
## Source dependency
이 스킬은 로컬 upstream clone을 래핑한다.
- `tmp/naverland-scrapper`
재사용하는 주요 로직:
- `src.core.parser.NaverURLParser`
- `src.core.services.response_capture.normalize_article_payload`
- `src.utils.helpers.PriceConverter`
- `src.utils.helpers.get_article_url`
## Scripts
### 1) 핵심 검색 엔진
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --self-test
```
역할:
- 자연어 파싱
- 후보 단지 탐색
- 단일 단지 조회
- 다중 단지 비교
- 동일 평형 요약
- JSON / 텍스트 출력
- candidate cache seed import/export 연결
### 1-1) candidate seed 자동 생성
```bash
python skills/naver-real-estate-search/scripts/build_candidate_seeds.py --print-summary
python skills/naver-real-estate-search/scripts/build_candidate_seeds.py --input skills/naver-real-estate-search/references/seoul-major-complexes.seed-input.json --output skills/naver-real-estate-search/references/candidate-seeds.generated.json --pause 0.1 --print-summary
```
역할:
- 서울 주요 단지 seed 입력을 받아 자동 alias 확장
- 기존 `candidate-seeds.json` / `candidate-cache.json` / 네이버 검색 HTML 링크 추출을 조합해 candidate seed 초안 생성
- 가능한 경우 단지 상세 API로 후보 검증 후 `confidence` / `verification_status` 부여
- 403/429 발생 시 `blocked_reasons`, `candidate_pool`, `evidence`에 흔적을 남겨 운영자가 후속 검증 가능하게 함
### 1-2) generated seed preview / apply
```bash
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --json
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --only-names "리센츠,은마" --json
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --apply-target --apply-cache --json
```
역할:
- `candidate-seeds.generated.json`의 `results[]`를 읽어 **운영 승격 가능 항목**과 **수동 검수 큐**를 분리
- 기본값은 preview이며, `--apply-target`일 때만 `references/candidate-seeds.json`을 갱신
- `--apply-cache`를 함께 쓰면 accepted 항목만 `data/candidate-cache.json`에 warm-cache
- `verified/weak-verified + complex_id + confidence 기준`으로 승격 후보를 고르고, 나머지는 `manual_review_queue`에 정리
- `--only-names` / `--exclude-names`로 일부 단지만 골라 검수 가능
### 2) 채팅형 브리핑 래퍼
```bash
python skills/naver-real-estate-search/scripts/chat_real_estate.py --query "잠실 리센츠 전세 30평대"
```
역할:
- 단일 단지 결과를 더 자연스러운 한국어 브리핑으로 요약
- 여러 단지 비교 결과를 “어디가 더 낮은지 / 동일 평형이면 어디가 더 싼지 / 차이가 어느 정도인지” 중심으로 설명
- 대표 매물/링크를 채팅에 붙이기 쉽게 정리
### 3) 가격 감시 / 새 매물 감지
```bash
python skills/naver-real-estate-search/scripts/watch_real_estate.py add --name "리센츠 전세 30평대" --query "잠실 리센츠 전세 30평대" --target-max-price 950000000 --notify-on-new --notify-on-price-drop
python skills/naver-real-estate-search/scripts/watch_real_estate.py check --preview
python skills/naver-real-estate-search/scripts/watch_real_estate.py check --json
```
역할:
- 로컬 JSON 파일에 watch rule 저장
- 목표가 이하 / 새 매물 / 가격하락 감지
- `last_seen` / `events` / dedupe 기반의 실사용형 감시 초안 제공
- 텔레그램/브리핑 등 상위 레이어에 바로 전달하기 쉬운 stdout JSON 구조 제공
저장 파일:
- `skills/naver-real-estate-search/data/watch-rules.json`
- `skills/naver-real-estate-search/data/candidate-cache.json`
- `skills/naver-real-estate-search/references/candidate-seeds.json`
## Quick start
### 1) self-test
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --self-test
```
### 2) 자연어 파싱만 확인
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --query "잠실 리센츠랑 엘스 전세 비교 30평대" --parse-only
```
### 3) 후보 단지만 찾기
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --query "서울 양천구 신월동 신월시영아파트 전세" --list-candidates --json
```
### 4) 채팅형 후보 리스트
```bash
python skills/naver-real-estate-search/scripts/chat_real_estate.py --query "대치 은마 전세" --list-candidates
```
### 4-1) candidate-cache seed / alias 학습
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --seed-candidate-file
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --apply-target --apply-cache --json
python skills/naver-real-estate-search/scripts/search_real_estate.py --seed-candidate --complex-id 1147 --candidate-name "리센츠" --candidate-address "서울특별시 송파구 잠실동" --candidate-aliases "잠실 리센츠,잠실리센츠"
python skills/naver-real-estate-search/scripts/search_real_estate.py --show-cache --query "리센츠"
```
권장 순서:
1. `build_candidate_seeds.py`로 generated 초안을 만든다.
2. `apply_generated_seeds.py`로 preview해서 accepted / manual review를 확인한다.
3. 문제가 없을 때만 `--apply-target --apply-cache`를 실행한다.
4. direct URL/ID를 확보한 단지는 `--seed-candidate`로 수동 보강한다.
반복 조회가 필요한 실존 단지나 alias가 자주 흔들리는 단지(예: `신월시영아파트`, `답십리두산위브`)는 먼저 cache에 학습시켜 둔다. 아직 complex ID를 확보하지 못한 경우에도 `manual_review_queue`와 `seed-input`에 남겨 두면 후보 힌트 fallback에 활용된다.
### 5) 단일 단지 조회
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --complex-id 1147 --trade-types 전세 --limit 10
```
### 6) direct URL로 조회
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --url "https://new.land.naver.com/complexes/1147" --trade-types 매매,전세 --json
```
### 7) 자연어 한 줄 브리핑
```bash
python skills/naver-real-estate-search/scripts/chat_real_estate.py --query "잠실 리센츠 전세 30평대" --limit 8
```
### 8) 여러 단지 비교 브리핑
```bash
python skills/naver-real-estate-search/scripts/chat_real_estate.py --query "잠실 리센츠와 잠실 엘스 전세 비교 30평대" --compare --candidate-limit 2
```
## Recommended workflow
1. 사용자가 **특정 단지 URL/ID**를 주면 그 값을 우선 사용한다.
2. URL/ID가 없으면 `chat_real_estate.py --query ...` 로 먼저 자연어 브리핑을 시도한다.
3. 질의가 넓거나 모호하면 `--list-candidates` 로 후보를 1~3개만 보여준다.
4. 후보가 좁혀지면 단일 단지 조회 또는 `--compare` 로 비교한다.
5. 구조화 데이터가 필요하면 같은 조건으로 `search_real_estate.py --json` 을 다시 호출한다.
6. 반복 확인이 필요하면 `watch_real_estate.py add/check` 흐름으로 가격 감시를 연결한다.
## Candidate-search guidance
현재 후보 탐색은 다음 순서로 동작한다.
1. direct complex ID / URL 우선 추출
2. 자연어에서 비교 대상 / 위치 힌트 / 거래유형 / 평형대 분리
3. 로컬 candidate cache(alias → complex_id) exact/contains 매칭
4. 캐시에 없으면 네이버 검색 결과 HTML에서 후보 complex ID 수집
5. 가능하면 단지 상세 API로 이름/주소/세대수 보강 후 캐시에 적재
6. 그래도 후보가 비면 `candidate-seeds.json`의 `manual_review_queue` + `seoul-major-complexes.seed-input.json`를 fallback reference로 조회해 **힌트 후보**를 반환
7. 이름 정규화 / alias 일치 / 지역 힌트 / 질의 토큰 / 세대수 신뢰도 기준으로 점수화
8. 점수 상위 후보만 반환
`신월시영아파트 ↔ 신월시영` 같은 축약/별칭 차이를 줄이기 위해 공백 제거 + suffix 제거 + alias 확장을 같이 사용한다.
## Output guidance
### 단일 단지
- 단지명 / 위치
- 필터 요약(거래유형, 평형대)
- 건수 / 최저가 / 평균가 / 중앙값 / 최고가
- 대표 동일 평형 요약
- 대표 매물 2~3개
- 사람이 읽기 쉬운 짧은 해석
### 여러 단지 비교
- 단지별 핵심 수치
- 전체 평균 기준 어느 단지가 더 낮은지
- 가능한 경우 동일 평형 기준 어느 단지가 더 낮은지
- 대표 매물 또는 링크
### 가격 감시
- rule 이름 / id
- 목표가 이하 매물 수
- 신규 매물 / 가격하락 감지
- stdout JSON의 `alerts`, `snapshot`, `events`, `message_preview`, `summary` 계층 활용
- 텔레그램/브리핑 레이어에는 `watch_real_estate.py check --preview` 결과를 바로 붙이고, 구조화 후처리가 필요하면 `--json`을 사용
## References
세부 설계/429 운영 지침/감시 schema/seed 예시는 다음 파일을 참고한다.
- `references/design.md`
- `references/candidate-seeds.json`
- `references/seoul-major-complexes.seed-input.json`
- `references/candidate-seeds.generated.json`
- `references/candidate-seed-builder.md`
FILE:data/candidate-cache.json
{
"version": 3,
"updated_at": 1773964931,
"entries": [
{
"complex_id": "1147",
"name": "리센츠",
"address": "서울특별시 송파구 잠실동",
"household_count": 5563,
"aliases": [
"리센츠",
"리센츠아파트",
"리센츠단지",
"잠실 리센츠",
"잠실리센츠",
"잠실리센츠아파트",
"잠실리센츠단지"
],
"updated_at": 1773919558,
"source": "seed-file:candidate-seeds.json",
"learned_from": "seed-file:candidate-seeds.json",
"note": "verified warm-cache seed example"
}
]
}
FILE:data/watch-rules.json
{
"schema_version": 2,
"last_checked_at": 1773919559,
"rules": [],
"events": [],
"last_seen": {}
}
FILE:README.md
# naver-real-estate-search
네이버 부동산 기반으로 대한민국 아파트/빌라/오피스텔 매물 조회, 단지 후보 탐색, 비교 브리핑, 가격 감시를 수행하는 OpenClaw 스킬입니다.
## 주요 개선점
- alias/후보 캐시 구조 강화 (`candidate-cache.json` v3 스타일)
- candidate-cache seed/수동 학습 CLI 추가 (`--seed-candidate`, `--seed-candidate-file`, `--show-cache`)
- 서울 주요 단지용 candidate seed 자동 생성기 추가 (`scripts/build_candidate_seeds.py`)
- 지역명/단지명 파서 보강, cold-start 후보 탐색 품질 개선
- `신월시영아파트`, `답십리두산위브` 같은 실사용 질의에 대해 reference-seed/manual-review fallback 보강
- 429 감지 시 direct URL/complex ID 우선 흐름을 유지하는 fallback 메타 추가
- 동일 평형 기준 비교 요약 추가
- 한국어 비교 브리핑과 대표 매물 요약 개선
- watch schema 확장: `last_seen`, `events`, dedupe, 새 매물/가격하락 감지
- 상위 레이어(텔레그램/브리핑) 연동용 stdout JSON 구조 개선
## candidate seed 운영 흐름
### 1) generated 초안 만들기
```bash
python skills/naver-real-estate-search/scripts/build_candidate_seeds.py --print-summary
python skills/naver-real-estate-search/scripts/build_candidate_seeds.py --input skills/naver-real-estate-search/references/seoul-major-complexes.seed-input.json --output skills/naver-real-estate-search/references/candidate-seeds.generated.json --pause 0.1 --print-summary
```
- 입력: `references/seoul-major-complexes.seed-input.json`
- 출력: `references/candidate-seeds.generated.json`
- 각 결과에는 `confidence`, `verification_status`, 자동 생성 `aliases`, `candidate_pool`, `evidence`, `blocked_reasons`가 포함됩니다.
- 자동 생성 결과는 **운영 seed가 아니라 generated 초안**입니다.
### 2) preview로 승격 후보 / 검수 큐 확인
```bash
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --json
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --only-names "리센츠,은마" --json
```
- 기본 동작은 preview입니다. 파일은 바꾸지 않습니다.
- `verified/weak-verified + complex_id + confidence` 기준으로 accepted 후보를 고릅니다.
- 나머지는 `manual_review_queue`로 정리할 후보로 봅니다.
- 일부 단지만 볼 때는 `--only-names`, 빼고 싶을 때는 `--exclude-names`를 씁니다.
### 3) 실제 반영
```bash
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --apply-target --apply-cache --json
```
- `--apply-target`: `references/candidate-seeds.json` 갱신
- `--apply-cache`: accepted 항목만 `data/candidate-cache.json` warm-cache
- preview 없이 곧바로 apply하지 말고, 최소 한 번은 summary를 확인하는 편이 안전합니다.
현재 2026-03-20 기준 운영 검수 결과:
- 운영 유지: `리센츠`
- 오검출 제외: `엘스`, `트리지움`
- manual review: `은마`, `래미안대치팰리스`, `아크로리버파크`, `래미안원베일리`, `목동신시가지7단지`, `신월시영아파트`, `답십리두산위브`
> 실사용 안정화 메모: production complex ID를 아직 못 확보한 단지도 `manual_review_queue` + `seoul-major-complexes.seed-input.json`에 남겨 두면, `--list-candidates`에서 완전 빈 응답 대신 **reference-seed 후보 힌트**를 돌려줄 수 있습니다.
## 스크립트
### 1) 후보 탐색 / 단일 조회 / 비교
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --query "잠실 리센츠 전세 30평대"
python skills/naver-real-estate-search/scripts/search_real_estate.py --query "잠실 리센츠와 엘스 전세 비교 30평대" --compare --json
python skills/naver-real-estate-search/scripts/search_real_estate.py --query "서울 양천구 신월동 신월시영아파트 전세" --list-candidates --json
```
### 2) 채팅형 브리핑
```bash
python skills/naver-real-estate-search/scripts/chat_real_estate.py --query "잠실 리센츠 전세 30평대"
python skills/naver-real-estate-search/scripts/chat_real_estate.py --query "잠실 리센츠와 엘스 전세 비교 30평대" --compare
```
### 3) 가격 감시 / 새 매물 감지
```bash
python skills/naver-real-estate-search/scripts/watch_real_estate.py add --name "리센츠 전세 30평대" --query "잠실 리센츠 전세 30평대" --target-max-price 950000000 --notify-on-new --notify-on-price-drop
python skills/naver-real-estate-search/scripts/watch_real_estate.py check --preview
python skills/naver-real-estate-search/scripts/watch_real_estate.py check --json
```
## 저장 파일
- `data/candidate-cache.json`: 후보 단지 alias/주소/ID 캐시
- `data/watch-rules.json`: 감시 규칙 + 최근 관측 상태 + 이벤트 히스토리
- `references/candidate-seeds.json`: 초기 alias seed / warm-cache 예시
## candidate-cache 관리
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --seed-candidate-file
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --apply-target --apply-cache --json
python skills/naver-real-estate-search/scripts/search_real_estate.py --seed-candidate --complex-id 1147 --candidate-name "리센츠" --candidate-address "서울특별시 송파구 잠실동" --candidate-aliases "잠실 리센츠,잠실리센츠"
python skills/naver-real-estate-search/scripts/search_real_estate.py --show-cache --query "리센츠"
```
- 운영 seed 전체를 cache에 한 번에 반영할 때는 `search_real_estate.py --seed-candidate-file`를 씁니다.
- generated 초안에서 **accepted만 선별 반영**하려면 `apply_generated_seeds.py --apply-target --apply-cache`가 더 안전합니다.
- 반복 조회가 필요한 실존 단지는 먼저 cache에 seed/학습시켜 두면 alias mismatch와 cold-start 실패를 줄일 수 있습니다.
- `references/candidate-seeds.json`은 **운영 검수 통과 seed만 넣는 파일**입니다.
- generated 초안 검토 결과, 오검출/미검증 항목은 `manual_review_queue`로 분리하고 `entries[]`에는 넣지 않습니다.
- 특히 `신월시영아파트`, `은마`, `목동신시가지7단지`처럼 alias나 숫자 단지명이 흔들리는 케이스는 direct complex URL/ID 확보 전까지 운영 seed로 올리지 않는 편이 안전합니다.
## seed 검수 기준 요약
- `verified`: 이름/주소/ID 정합성이 맞아 운영 seed에 바로 반영 가능
- `weak-verified`: 자동 근거는 있으나 사람이 한 번 더 보고 승격
- `unresolved`: complex_id 미확보 또는 근거 부족, 운영 seed 제외
- `blocked`: 403/429 등 외부 차단으로 검증 중단, direct URL/ID 수동 확보 우선
서울 주요 단지 보강 우선순위:
1. 신월시영아파트
2. 은마 / 래미안대치팰리스
3. 래미안원베일리 / 아크로리버파크
4. 목동신시가지7단지
5. 엘스 / 트리지움
## 배포 체크리스트
1. self-test 실행
2. 대표 자연어 질의/후보 탐색/감시 check 실제 실행
3. `references/candidate-seeds.json`의 entries/manual_review_queue가 검수 결과와 일치하는지 확인
4. skill 패키징
5. GitHub tag/release 및 ClawHub publish
FILE:references/candidate-seed-builder.md
# candidate seed 자동 생성 메모
## 목적
- 서울 주요 단지 seed 리스트를 입력받아 `candidate-seeds.generated.json` 초안을 만든다.
- 기존 `candidate-seeds.json`, `candidate-cache.json`, `search_real_estate.py`와 연결 가능한 구조를 유지한다.
- 운영용 승격 기준은 `verification_status in {verified, weak-verified}` 로 두되, **실무에서는 이름/주소/ID 정합성 검수까지 통과한 항목만 `entries[]`에 반영**한다.
## 입력/출력
### 입력
- `references/seoul-major-complexes.seed-input.json`
- shape:
```json
{
"seeds": [
{
"name": "리센츠",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": ["잠실 리센츠", "잠실리센츠"]
}
]
}
```
### 출력
- `references/candidate-seeds.generated.json`
- 핵심 필드:
- `entries[]`: 자동 검증상 승격 후보
- `results[]`: 전체 생성 결과 + evidence
- `unresolved[]`: 후속 수동 검증 대상
## 상태값 운영 기준
### `verified`
다음을 모두 만족하는 항목으로 본다.
- `complex_id`가 있음
- 상세 조회된 단지명 정규화 결과가 seed 이름과 정확히 맞거나 매우 강하게 일치함
- 주소에 구/동 힌트가 자연스럽게 맞음
- 오검출 정황이 없음
운영 액션:
- `candidate-seeds.json`의 `entries[]`에 바로 반영 가능
- warm-cache baseline으로 사용 가능
### `weak-verified`
다음을 만족하는 항목으로 본다.
- `complex_id`가 있음
- 이름/주소가 대체로 맞지만 일부 힌트만 확보됐거나 429 직전 등으로 근거가 약함
- 사람이 빠르게 다시 봤을 때 오검출 가능성이 낮음
운영 액션:
- 자동 반영 후보는 될 수 있지만, **서울 주요 단지 운영 seed는 사람이 한 번 더 보고 `entries[]`에 승격**하는 편이 안전
- 저장 시 note에 검수 근거를 남긴다
### `unresolved`
다음을 의미한다.
- `complex_id`를 못 찾았거나
- 후보는 있었지만 이름/주소 검증까지 못 갔거나
- broad query만으로는 신뢰할 수 있는 후보를 고르지 못함
운영 액션:
- `entries[]`에는 넣지 않음
- `manual_review_queue`로 넘겨 direct URL/ID를 확보한 뒤 재검증
### `blocked`
다음을 의미한다.
- 403/429/검색 HTML 차단 등 외부 제약 때문에 자동 검증 흐름이 끊김
- 결과 부재가 곧 단지 부재를 뜻하지는 않음
운영 액션:
- `entries[]`에는 넣지 않음
- 차단이 풀릴 때까지 broad query 재시도보다 direct URL/ID 수동 확보를 우선
- 운영 가치가 높은 단지(예: 신월시영아파트, 은마)는 우선 보강 큐로 올린다
## 생성 순서
1. 입력 seed의 이름/지역을 바탕으로 alias를 자동 확장한다.
2. 기존 `references/candidate-seeds.json`의 verified baseline과 이름/alias exact 매칭을 먼저 본다.
3. `candidate-cache.json` exact/contains 매칭으로 warm-cache 힌트를 찾는다.
4. 네이버 검색 HTML에서 complex link/ID를 추출한다.
5. 가능하면 단지 상세 API로 이름/주소/세대수를 조회해 검증한다.
6. `confidence`, `verification_status`, `candidate_pool`, `evidence`, `blocked_reasons`를 남긴다.
## 운영 권장
- 생성 파일의 `entries[]`는 그대로 신뢰하지 말고, **오검출 여부를 반드시 다시 본다.**
- 특히 잠실 대단지처럼 비슷한 문맥이 자주 섞이는 곳에서는 다른 complex ID로 잘못 수렴할 수 있다.
- `results[]`에 complex_id가 있어도 `verification_status`가 `unverified`면 자동 투입하지 않는다.
- `verification_status`가 `weak-verified`여도 서울 주요 단지는 수동 검수 후 승격한다.
- 403/429가 반복되면 broad query 대신 direct complex URL/ID로 수동 보강한다.
## preview / apply 운영 절차
### 1. generated 초안 생성
```bash
python skills/naver-real-estate-search/scripts/build_candidate_seeds.py --print-summary
```
### 2. preview 확인
```bash
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --json
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --only-names "리센츠,은마" --json
```
여기서 확인할 것:
- `accepted[]`에 들어간 단지가 정말 운영 승격 대상인지
- `rejected[]`의 `complex_id`가 오검출인지
- `manual_review_queue_count`가 기대와 맞는지
### 3. 운영 반영
```bash
python skills/naver-real-estate-search/scripts/apply_generated_seeds.py --apply-target --apply-cache --json
```
반영 결과:
- `references/candidate-seeds.json`의 `entries` / `manual_review_queue` 갱신
- accepted 항목만 `data/candidate-cache.json`에 warm-cache
### 4. 수동 보강
자동 승격이 어려운 단지는 direct complex URL/ID 확보 후 다음처럼 수동 보강한다.
```bash
python skills/naver-real-estate-search/scripts/search_real_estate.py --seed-candidate --complex-id <ID> --candidate-name "단지명" --candidate-address "주소" --candidate-aliases "별칭1,별칭2"
```
## 실제 검수 요약 (2026-03-20)
### 운영 반영
- `리센츠 -> 1147`: 유지 (`verified`)
### generated에서 제외
- `엘스`: generated가 `1147`(리센츠)로 잘못 수렴해서 제외
- `트리지움`: generated가 `1147`(리센츠)로 잘못 수렴해서 제외
### 수동 검증 대기
- `은마`
- `래미안대치팰리스`
- `아크로리버파크`
- `래미안원베일리`
- `목동신시가지7단지`
- `신월시영아파트`
- `답십리두산위브`
### 우선 보강 추천 순서
1. `신월시영아파트` — alias 난이도가 높고 403 영향이 커서 baseline 가치가 큼
2. `답십리두산위브` — 실사용 질의 빈도가 높고 현재 cold-start에서 빈 응답이 나기 쉬움
3. `은마`, `래미안대치팰리스` — 대치권 대표 비교 질의 대응용
4. `래미안원베일리`, `아크로리버파크` — 반포권 대표 비교 질의 대응용
5. `목동신시가지7단지` — 숫자 단지형 alias 안정화용
6. `엘스`, `트리지움` — 잠실권 분리 baseline 확정용
complex ID를 아직 못 찾았더라도 아예 버리지 말고 `manual_review_queue`와 `seed-input`에 남겨 둔다. 그러면 후보 탐색 시 reference-seed fallback으로 노출할 수 있어, 실사용에서 `0건` 대신 검수 대기 후보 힌트를 줄 수 있다.
## 한계
- 검색 HTML 구조 변화에 취약하다.
- 네이버 상세 API 429 발생 시 검증 성공률이 크게 떨어진다.
- warm-cache가 부족하면 broad query만으로는 서울 주요 단지 전체를 안정적으로 맞추기 어렵다.
- 따라서 자동 생성은 초안/보조 도구로 보고, 운영 seed는 검증 승격 단계를 두는 것이 안전하다.
FILE:references/candidate-seeds.generated.json
{
"kind": "naver-real-estate-candidate-seed-generation",
"schema_version": 1,
"generated_at": 1773965554,
"source_input": "C:\\Users\\김태완\\.openclaw\\workspace\\skills\\naver-real-estate-search\\references\\seoul-major-complexes.seed-input.json",
"entry_count": 1,
"unresolved_count": 6,
"entries": [
{
"complex_id": "1147",
"name": "리센츠",
"address": "서울특별시 송파구 잠실동",
"household_count": 5563,
"aliases": [
"리센츠",
"리센츠아파트",
"리센츠단지",
"잠실 리센츠",
"잠실리센츠",
"잠실리센츠아파트",
"잠실리센츠단지",
"서울특별시 리센츠",
"서울특별시리센츠",
"서울특별시리센츠아파트",
"서울특별시리센츠단지",
"송파구 리센츠",
"송파구리센츠",
"송파구리센츠아파트",
"송파구리센츠단지",
"잠실동 리센츠",
"잠실동리센츠",
"잠실동리센츠아파트",
"잠실동리센츠단지",
"서울 리센츠",
"서울리센츠",
"서울리센츠아파트",
"서울리센츠단지",
"송파구 잠실동 리센츠",
"송파구잠실동리센츠",
"송파구잠실동리센츠아파트",
"송파구잠실동리센츠단지"
],
"note": "verified baseline; 생성기 동작 검증용",
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"confidence": 1.0,
"verification_status": "verified"
}
],
"unresolved": [
{
"name": "은마",
"district": "강남구",
"neighborhood": "대치동",
"aliases": [
"은마",
"은마아파트",
"은마단지",
"대치 은마",
"대치은마",
"대치은마아파트",
"대치은마단지",
"서울특별시 은마",
"서울특별시은마",
"서울특별시은마아파트",
"서울특별시은마단지",
"강남구 은마",
"강남구은마",
"강남구은마아파트",
"강남구은마단지",
"대치동 은마",
"대치동은마",
"대치동은마아파트",
"대치동은마단지",
"서울 은마",
"서울은마",
"서울은마아파트",
"서울은마단지",
"강남구 대치동 은마",
"강남구대치동은마",
"강남구대치동은마아파트",
"강남구대치동은마단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"blocked_reasons": []
},
{
"name": "래미안대치팰리스",
"district": "강남구",
"neighborhood": "대치동",
"aliases": [
"래미안대치팰리스",
"래미안대치팰리스아파트",
"래미안대치팰리스단지",
"대치 팰리스",
"대치팰리스",
"대치팰리스아파트",
"대치팰리스단지",
"대치래미안팰리스",
"대치래미안팰리스아파트",
"대치래미안팰리스단지",
"래미안 대치팰리스",
"서울특별시 래미안대치팰리스",
"서울특별시래미안대치팰리스",
"서울특별시래미안대치팰리스아파트",
"서울특별시래미안대치팰리스단지",
"강남구 래미안대치팰리스",
"강남구래미안대치팰리스",
"강남구래미안대치팰리스아파트",
"강남구래미안대치팰리스단지",
"대치동 래미안대치팰리스",
"대치동래미안대치팰리스",
"대치동래미안대치팰리스아파트",
"대치동래미안대치팰리스단지",
"서울 래미안대치팰리스",
"서울래미안대치팰리스",
"서울래미안대치팰리스아파트",
"서울래미안대치팰리스단지",
"강남구 대치동 래미안대치팰리스",
"강남구대치동래미안대치팰리스",
"강남구대치동래미안대치팰리스아파트"
],
"verification_status": "unresolved",
"confidence": 0.0,
"blocked_reasons": []
},
{
"name": "아크로리버파크",
"district": "서초구",
"neighborhood": "반포동",
"aliases": [
"아크로리버파크",
"아크로리버파크아파트",
"아크로리버파크단지",
"반포 아크로리버파크",
"반포아크로리버파크",
"반포아크로리버파크아파트",
"반포아크로리버파크단지",
"서울특별시 아크로리버파크",
"서울특별시아크로리버파크",
"서울특별시아크로리버파크아파트",
"서울특별시아크로리버파크단지",
"서초구 아크로리버파크",
"서초구아크로리버파크",
"서초구아크로리버파크아파트",
"서초구아크로리버파크단지",
"반포동 아크로리버파크",
"반포동아크로리버파크",
"반포동아크로리버파크아파트",
"반포동아크로리버파크단지",
"서울 아크로리버파크",
"서울아크로리버파크",
"서울아크로리버파크아파트",
"서울아크로리버파크단지",
"서초구 반포동 아크로리버파크",
"서초구반포동아크로리버파크",
"서초구반포동아크로리버파크아파트",
"서초구반포동아크로리버파크단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"blocked_reasons": []
},
{
"name": "래미안원베일리",
"district": "서초구",
"neighborhood": "반포동",
"aliases": [
"래미안원베일리",
"래미안원베일리아파트",
"래미안원베일리단지",
"반포 원베일리",
"반포원베일리",
"반포원베일리아파트",
"반포원베일리단지",
"원베일리",
"원베일리아파트",
"원베일리단지",
"서울특별시 래미안원베일리",
"서울특별시래미안원베일리",
"서울특별시래미안원베일리아파트",
"서울특별시래미안원베일리단지",
"서초구 래미안원베일리",
"서초구래미안원베일리",
"서초구래미안원베일리아파트",
"서초구래미안원베일리단지",
"반포동 래미안원베일리",
"반포동래미안원베일리",
"반포동래미안원베일리아파트",
"반포동래미안원베일리단지",
"서울 래미안원베일리",
"서울래미안원베일리",
"서울래미안원베일리아파트",
"서울래미안원베일리단지",
"서초구 반포동 래미안원베일리",
"서초구반포동래미안원베일리",
"서초구반포동래미안원베일리아파트",
"서초구반포동래미안원베일리단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"blocked_reasons": []
},
{
"name": "목동신시가지7단지",
"district": "양천구",
"neighborhood": "목동",
"aliases": [
"목동신시가지7단지",
"목동신시가지7",
"목동신시가지7아파트",
"목동 7단지",
"목동 7",
"목동7",
"목동7아파트",
"목동7단지",
"목동신시가지 7단지",
"목동신시가지 7",
"서울특별시 목동신시가지7단지",
"서울특별시 목동신시가지7",
"서울특별시목동신시가지7",
"서울특별시목동신시가지7아파트",
"서울특별시목동신시가지7단지",
"양천구 목동신시가지7단지",
"양천구 목동신시가지7",
"양천구목동신시가지7",
"양천구목동신시가지7아파트",
"양천구목동신시가지7단지",
"목동 목동신시가지7단지",
"목동 목동신시가지7",
"목동목동신시가지7",
"목동목동신시가지7아파트",
"목동목동신시가지7단지",
"서울 목동신시가지7단지",
"서울 목동신시가지7",
"서울목동신시가지7",
"서울목동신시가지7아파트",
"서울목동신시가지7단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"blocked_reasons": []
},
{
"name": "신월시영아파트",
"district": "양천구",
"neighborhood": "신월동",
"aliases": [
"신월시영아파트",
"신월시영",
"신월시영단지",
"양천 신월시영",
"양천신월시영",
"양천신월시영아파트",
"양천신월시영단지",
"서울특별시 신월시영아파트",
"서울특별시신월시영",
"서울특별시신월시영아파트",
"서울특별시신월시영단지",
"서울특별시 신월시영",
"양천구 신월시영아파트",
"양천구신월시영",
"양천구신월시영아파트",
"양천구신월시영단지",
"양천구 신월시영",
"신월동 신월시영아파트",
"신월동신월시영",
"신월동신월시영아파트",
"신월동신월시영단지",
"신월동 신월시영",
"서울 신월시영아파트",
"서울신월시영",
"서울신월시영아파트",
"서울신월시영단지",
"서울 신월시영",
"양천구 신월동 신월시영아파트",
"양천구신월동신월시영",
"양천구신월동신월시영아파트"
],
"verification_status": "blocked",
"confidence": 0.0,
"blocked_reasons": [
"네이버 검색 HTML 후보 탐색이 차단되었습니다: HTTP 403"
]
}
],
"results": [
{
"name": "리센츠",
"district": "송파구",
"neighborhood": "잠실동",
"seed_input": {
"name": "리센츠",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": [
"잠실 리센츠",
"잠실리센츠"
],
"note": "verified baseline; 생성기 동작 검증용"
},
"complex_id": "1147",
"address": "서울특별시 송파구 잠실동",
"household_count": 5563,
"aliases": [
"리센츠",
"리센츠아파트",
"리센츠단지",
"잠실 리센츠",
"잠실리센츠",
"잠실리센츠아파트",
"잠실리센츠단지",
"서울특별시 리센츠",
"서울특별시리센츠",
"서울특별시리센츠아파트",
"서울특별시리센츠단지",
"송파구 리센츠",
"송파구리센츠",
"송파구리센츠아파트",
"송파구리센츠단지",
"잠실동 리센츠",
"잠실동리센츠",
"잠실동리센츠아파트",
"잠실동리센츠단지",
"서울 리센츠",
"서울리센츠",
"서울리센츠아파트",
"서울리센츠단지",
"송파구 잠실동 리센츠",
"송파구잠실동리센츠",
"송파구잠실동리센츠아파트",
"송파구잠실동리센츠단지"
],
"verification_status": "verified",
"confidence": 1.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "verified baseline; 생성기 동작 검증용",
"candidate_pool": [
{
"complex_id": "1147",
"query": "리센츠",
"discovery_source": "baseline-seed",
"status": "verified",
"confidence": 1.0,
"verification_reasons": [
"baseline-seed-match"
],
"complex": {
"complex_id": "1147",
"name": "리센츠",
"address": "서울특별시 송파구 잠실동",
"household_count": 5563
}
}
],
"evidence": [
{
"query": "리센츠",
"status": "baseline-seed-hit",
"ids": [
"1147"
]
},
{
"query": "서울특별시 송파구 잠실동 리센츠 네이버 부동산",
"status": "cache-hit",
"source": "candidate-cache",
"ids": [
"1147"
]
}
],
"blocked_reasons": []
},
{
"name": "엘스",
"district": "송파구",
"neighborhood": "잠실동",
"seed_input": {
"name": "엘스",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": [
"잠실 엘스",
"잠실엘스"
]
},
"complex_id": "1147",
"address": "",
"household_count": null,
"aliases": [
"엘스",
"엘스아파트",
"엘스단지",
"잠실 엘스",
"잠실엘스",
"잠실엘스아파트",
"잠실엘스단지",
"서울특별시 엘스",
"서울특별시엘스",
"서울특별시엘스아파트",
"서울특별시엘스단지",
"송파구 엘스",
"송파구엘스",
"송파구엘스아파트",
"송파구엘스단지",
"잠실동 엘스",
"잠실동엘스",
"잠실동엘스아파트",
"잠실동엘스단지",
"서울 엘스",
"서울엘스",
"서울엘스아파트",
"서울엘스단지",
"송파구 잠실동 엘스",
"송파구잠실동엘스",
"송파구잠실동엘스아파트",
"송파구잠실동엘스단지"
],
"verification_status": "unverified",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [
{
"complex_id": "1147",
"query": "서울특별시 송파구 잠실동 잠실 엘스 네이버 부동산",
"discovery_source": "candidate-cache",
"status": "unverified",
"confidence": 0.0,
"verification_reasons": [
"detail-fetch-failed:SearchError"
],
"error": "네이버 부동산 API가 429(요청 제한)를 반환했습니다. 단일 단지 URL/ID를 우선 사용하거나, 후보 단지부터 1~3개로 좁혀 다시 시도해 주세요.",
"complex": {
"complex_id": "1147"
}
}
],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 송파구 잠실동 엘스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "송파구 잠실동 엘스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "송파구 엘스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "엘스 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 송파구 엘스"
},
{
"query": "서울특별시 송파구 잠실동 잠실 엘스 네이버 부동산",
"status": "cache-hit",
"source": "candidate-cache",
"ids": [
"1147"
]
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 송파구 잠실동 잠실 엘스 네이버 부동산"
},
{
"query": "송파구 잠실동 잠실 엘스 네이버 부동산",
"status": "cache-hit",
"source": "candidate-cache",
"ids": [
"1147"
]
}
],
"blocked_reasons": []
},
{
"name": "트리지움",
"district": "송파구",
"neighborhood": "잠실동",
"seed_input": {
"name": "트리지움",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": [
"잠실 트리지움",
"잠실트리지움"
]
},
"complex_id": "1147",
"address": "",
"household_count": null,
"aliases": [
"트리지움",
"트리지움아파트",
"트리지움단지",
"잠실 트리지움",
"잠실트리지움",
"잠실트리지움아파트",
"잠실트리지움단지",
"서울특별시 트리지움",
"서울특별시트리지움",
"서울특별시트리지움아파트",
"서울특별시트리지움단지",
"송파구 트리지움",
"송파구트리지움",
"송파구트리지움아파트",
"송파구트리지움단지",
"잠실동 트리지움",
"잠실동트리지움",
"잠실동트리지움아파트",
"잠실동트리지움단지",
"서울 트리지움",
"서울트리지움",
"서울트리지움아파트",
"서울트리지움단지",
"송파구 잠실동 트리지움",
"송파구잠실동트리지움",
"송파구잠실동트리지움아파트",
"송파구잠실동트리지움단지"
],
"verification_status": "unverified",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [
{
"complex_id": "1147",
"query": "서울특별시 송파구 잠실동 잠실 트리지움 네이버 부동산",
"discovery_source": "candidate-cache",
"status": "unverified",
"confidence": 0.0,
"verification_reasons": [
"detail-fetch-failed:SearchError"
],
"error": "네이버 부동산 API가 429(요청 제한)를 반환했습니다. 단일 단지 URL/ID를 우선 사용하거나, 후보 단지부터 1~3개로 좁혀 다시 시도해 주세요.",
"complex": {
"complex_id": "1147"
}
}
],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 송파구 잠실동 트리지움 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "송파구 잠실동 트리지움 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "송파구 트리지움 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "트리지움 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 송파구 트리지움"
},
{
"query": "서울특별시 송파구 잠실동 잠실 트리지움 네이버 부동산",
"status": "cache-hit",
"source": "candidate-cache",
"ids": [
"1147"
]
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 송파구 잠실동 잠실 트리지움 네이버 부동산"
},
{
"query": "송파구 잠실동 잠실 트리지움 네이버 부동산",
"status": "cache-hit",
"source": "candidate-cache",
"ids": [
"1147"
]
}
],
"blocked_reasons": []
},
{
"name": "은마",
"district": "강남구",
"neighborhood": "대치동",
"seed_input": {
"name": "은마",
"city": "서울특별시",
"district": "강남구",
"neighborhood": "대치동",
"aliases": [
"은마아파트",
"대치 은마"
]
},
"complex_id": null,
"address": "",
"household_count": null,
"aliases": [
"은마",
"은마아파트",
"은마단지",
"대치 은마",
"대치은마",
"대치은마아파트",
"대치은마단지",
"서울특별시 은마",
"서울특별시은마",
"서울특별시은마아파트",
"서울특별시은마단지",
"강남구 은마",
"강남구은마",
"강남구은마아파트",
"강남구은마단지",
"대치동 은마",
"대치동은마",
"대치동은마아파트",
"대치동은마단지",
"서울 은마",
"서울은마",
"서울은마아파트",
"서울은마단지",
"강남구 대치동 은마",
"강남구대치동은마",
"강남구대치동은마아파트",
"강남구대치동은마단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 강남구 대치동 은마 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 대치동 은마 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 은마 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "은마 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 강남구 은마"
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 강남구 대치동 은마아파트 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 대치동 은마아파트 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 은마아파트 네이버 부동산"
}
],
"blocked_reasons": []
},
{
"name": "래미안대치팰리스",
"district": "강남구",
"neighborhood": "대치동",
"seed_input": {
"name": "래미안대치팰리스",
"city": "서울특별시",
"district": "강남구",
"neighborhood": "대치동",
"aliases": [
"대치 팰리스",
"대치래미안팰리스",
"래미안 대치팰리스"
]
},
"complex_id": null,
"address": "",
"household_count": null,
"aliases": [
"래미안대치팰리스",
"래미안대치팰리스아파트",
"래미안대치팰리스단지",
"대치 팰리스",
"대치팰리스",
"대치팰리스아파트",
"대치팰리스단지",
"대치래미안팰리스",
"대치래미안팰리스아파트",
"대치래미안팰리스단지",
"래미안 대치팰리스",
"서울특별시 래미안대치팰리스",
"서울특별시래미안대치팰리스",
"서울특별시래미안대치팰리스아파트",
"서울특별시래미안대치팰리스단지",
"강남구 래미안대치팰리스",
"강남구래미안대치팰리스",
"강남구래미안대치팰리스아파트",
"강남구래미안대치팰리스단지",
"대치동 래미안대치팰리스",
"대치동래미안대치팰리스",
"대치동래미안대치팰리스아파트",
"대치동래미안대치팰리스단지",
"서울 래미안대치팰리스",
"서울래미안대치팰리스",
"서울래미안대치팰리스아파트",
"서울래미안대치팰리스단지",
"강남구 대치동 래미안대치팰리스",
"강남구대치동래미안대치팰리스",
"강남구대치동래미안대치팰리스아파트"
],
"verification_status": "unresolved",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 강남구 대치동 래미안대치팰리스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 대치동 래미안대치팰리스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 래미안대치팰리스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "래미안대치팰리스 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 강남구 래미안대치팰리스"
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 강남구 대치동 대치 팰리스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 대치동 대치 팰리스 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "강남구 대치 팰리스 네이버 부동산"
}
],
"blocked_reasons": []
},
{
"name": "아크로리버파크",
"district": "서초구",
"neighborhood": "반포동",
"seed_input": {
"name": "아크로리버파크",
"city": "서울특별시",
"district": "서초구",
"neighborhood": "반포동",
"aliases": [
"반포 아크로리버파크",
"반포아크로리버파크"
]
},
"complex_id": null,
"address": "",
"household_count": null,
"aliases": [
"아크로리버파크",
"아크로리버파크아파트",
"아크로리버파크단지",
"반포 아크로리버파크",
"반포아크로리버파크",
"반포아크로리버파크아파트",
"반포아크로리버파크단지",
"서울특별시 아크로리버파크",
"서울특별시아크로리버파크",
"서울특별시아크로리버파크아파트",
"서울특별시아크로리버파크단지",
"서초구 아크로리버파크",
"서초구아크로리버파크",
"서초구아크로리버파크아파트",
"서초구아크로리버파크단지",
"반포동 아크로리버파크",
"반포동아크로리버파크",
"반포동아크로리버파크아파트",
"반포동아크로리버파크단지",
"서울 아크로리버파크",
"서울아크로리버파크",
"서울아크로리버파크아파트",
"서울아크로리버파크단지",
"서초구 반포동 아크로리버파크",
"서초구반포동아크로리버파크",
"서초구반포동아크로리버파크아파트",
"서초구반포동아크로리버파크단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 서초구 반포동 아크로리버파크 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 반포동 아크로리버파크 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 아크로리버파크 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "아크로리버파크 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 서초구 아크로리버파크"
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 서초구 반포동 반포 아크로리버파크 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 반포동 반포 아크로리버파크 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 반포 아크로리버파크 네이버 부동산"
}
],
"blocked_reasons": []
},
{
"name": "래미안원베일리",
"district": "서초구",
"neighborhood": "반포동",
"seed_input": {
"name": "래미안원베일리",
"city": "서울특별시",
"district": "서초구",
"neighborhood": "반포동",
"aliases": [
"반포 원베일리",
"원베일리"
]
},
"complex_id": null,
"address": "",
"household_count": null,
"aliases": [
"래미안원베일리",
"래미안원베일리아파트",
"래미안원베일리단지",
"반포 원베일리",
"반포원베일리",
"반포원베일리아파트",
"반포원베일리단지",
"원베일리",
"원베일리아파트",
"원베일리단지",
"서울특별시 래미안원베일리",
"서울특별시래미안원베일리",
"서울특별시래미안원베일리아파트",
"서울특별시래미안원베일리단지",
"서초구 래미안원베일리",
"서초구래미안원베일리",
"서초구래미안원베일리아파트",
"서초구래미안원베일리단지",
"반포동 래미안원베일리",
"반포동래미안원베일리",
"반포동래미안원베일리아파트",
"반포동래미안원베일리단지",
"서울 래미안원베일리",
"서울래미안원베일리",
"서울래미안원베일리아파트",
"서울래미안원베일리단지",
"서초구 반포동 래미안원베일리",
"서초구반포동래미안원베일리",
"서초구반포동래미안원베일리아파트",
"서초구반포동래미안원베일리단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 서초구 반포동 래미안원베일리 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 반포동 래미안원베일리 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 래미안원베일리 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "래미안원베일리 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 서초구 래미안원베일리"
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 서초구 반포동 반포 원베일리 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 반포동 반포 원베일리 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "서초구 반포 원베일리 네이버 부동산"
}
],
"blocked_reasons": []
},
{
"name": "목동신시가지7단지",
"district": "양천구",
"neighborhood": "목동",
"seed_input": {
"name": "목동신시가지7단지",
"city": "서울특별시",
"district": "양천구",
"neighborhood": "목동",
"aliases": [
"목동 7단지",
"목동신시가지7",
"목동신시가지 7단지"
]
},
"complex_id": null,
"address": "",
"household_count": null,
"aliases": [
"목동신시가지7단지",
"목동신시가지7",
"목동신시가지7아파트",
"목동 7단지",
"목동 7",
"목동7",
"목동7아파트",
"목동7단지",
"목동신시가지 7단지",
"목동신시가지 7",
"서울특별시 목동신시가지7단지",
"서울특별시 목동신시가지7",
"서울특별시목동신시가지7",
"서울특별시목동신시가지7아파트",
"서울특별시목동신시가지7단지",
"양천구 목동신시가지7단지",
"양천구 목동신시가지7",
"양천구목동신시가지7",
"양천구목동신시가지7아파트",
"양천구목동신시가지7단지",
"목동 목동신시가지7단지",
"목동 목동신시가지7",
"목동목동신시가지7",
"목동목동신시가지7아파트",
"목동목동신시가지7단지",
"서울 목동신시가지7단지",
"서울 목동신시가지7",
"서울목동신시가지7",
"서울목동신시가지7아파트",
"서울목동신시가지7단지"
],
"verification_status": "unresolved",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "generated candidate seed",
"candidate_pool": [],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 양천구 목동 목동신시가지7단지 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 목동 목동신시가지7단지 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 목동신시가지7단지 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "목동신시가지7단지 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 양천구 목동신시가지7단지"
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 양천구 목동 목동 7단지 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 목동 목동 7단지 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 목동 7단지 네이버 부동산"
}
],
"blocked_reasons": []
},
{
"name": "신월시영아파트",
"district": "양천구",
"neighborhood": "신월동",
"seed_input": {
"name": "신월시영아파트",
"city": "서울특별시",
"district": "양천구",
"neighborhood": "신월동",
"aliases": [
"신월시영",
"양천 신월시영"
],
"note": "tricky alias 케이스"
},
"complex_id": null,
"address": "",
"household_count": null,
"aliases": [
"신월시영아파트",
"신월시영",
"신월시영단지",
"양천 신월시영",
"양천신월시영",
"양천신월시영아파트",
"양천신월시영단지",
"서울특별시 신월시영아파트",
"서울특별시신월시영",
"서울특별시신월시영아파트",
"서울특별시신월시영단지",
"서울특별시 신월시영",
"양천구 신월시영아파트",
"양천구신월시영",
"양천구신월시영아파트",
"양천구신월시영단지",
"양천구 신월시영",
"신월동 신월시영아파트",
"신월동신월시영",
"신월동신월시영아파트",
"신월동신월시영단지",
"신월동 신월시영",
"서울 신월시영아파트",
"서울신월시영",
"서울신월시영아파트",
"서울신월시영단지",
"서울 신월시영",
"양천구 신월동 신월시영아파트",
"양천구신월동신월시영",
"양천구신월동신월시영아파트"
],
"verification_status": "blocked",
"confidence": 0.0,
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": "tricky alias 케이스",
"candidate_pool": [],
"evidence": [
{
"ids": [],
"status": "ok",
"query": "서울특별시 양천구 신월동 신월시영아파트 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 신월동 신월시영아파트 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 신월시영아파트 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "신월시영아파트 네이버 부동산 아파트"
},
{
"ids": [],
"status": "ok",
"query": "site:new.land.naver.com/complexes 양천구 신월시영아파트"
},
{
"ids": [],
"status": "ok",
"query": "서울특별시 양천구 신월동 신월시영 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 신월동 신월시영 네이버 부동산"
},
{
"ids": [],
"status": "ok",
"query": "양천구 신월시영 네이버 부동산"
}
],
"blocked_reasons": [
"네이버 검색 HTML 후보 탐색이 차단되었습니다: HTTP 403"
]
}
]
}
FILE:references/candidate-seeds.json
{
"schema_version": 2,
"updated_at": 1773970000,
"policy": {
"production_entry_rule": "verification_status가 verified 또는 weak-verified 이고, complex_id/name/address 정합성이 확인된 항목만 entries에 넣는다.",
"excluded_rule": "generated 결과에서 complex_id가 있더라도 이름/주소 검증이 실패했거나 429/오염된 cache에 기대는 항목은 entries에 넣지 않는다."
},
"entries": [
{
"complex_id": "1147",
"name": "리센츠",
"address": "서울특별시 송파구 잠실동",
"household_count": 5563,
"aliases": [
"리센츠",
"잠실 리센츠",
"잠실리센츠",
"리센츠아파트"
],
"verification_status": "verified",
"note": "운영용 verified warm-cache seed"
}
],
"manual_review_queue": [
{
"name": "엘스",
"district": "송파구",
"neighborhood": "잠실동",
"review_status": "excluded-for-now",
"reason": "generated가 complex_id 1147(리센츠)로 잘못 수렴했다. 429 환경에서 오염된 cache candidate가 끼어든 케이스로 보고 운영 seed에서 제외한다.",
"next_action": "direct complex URL/ID를 확보한 뒤 verified 또는 weak-verified로 재검수한다."
},
{
"name": "트리지움",
"district": "송파구",
"neighborhood": "잠실동",
"review_status": "excluded-for-now",
"reason": "generated가 complex_id 1147(리센츠)로 잘못 수렴했다. 잠실 대단지 군집에서 alias 충돌 위험이 있다.",
"next_action": "잠실권 단지는 URL/ID 기반 baseline을 별도로 확보해 분리 검증한다."
},
{
"name": "은마",
"district": "강남구",
"neighborhood": "대치동",
"review_status": "manual-verification-needed",
"reason": "generated evidence상 complex_id를 확보하지 못했다. 서울 핵심 단지라 운영 가치가 높지만 현재 자동 생성만으로는 불충분하다.",
"next_action": "네이버 complex URL/ID를 직접 확보해 대치동 주소 일치 여부를 확인한 후 seed 추가를 검토한다."
},
{
"name": "래미안대치팰리스",
"district": "강남구",
"neighborhood": "대치동",
"review_status": "manual-verification-needed",
"reason": "자동 생성에서 complex_id 미확보. 대치권 비교 질의에 중요하지만 현재는 운영 seed로 승격 불가.",
"next_action": "브랜드명/띄어쓰기 변형을 줄인 direct URL/ID baseline을 먼저 수집한다."
},
{
"name": "아크로리버파크",
"district": "서초구",
"neighborhood": "반포동",
"review_status": "manual-verification-needed",
"reason": "generated unresolved. 반포권 대표 단지지만 HTML 후보 추출만으로는 안정적으로 못 잡는다.",
"next_action": "반포동 direct complex URL/ID로 seed를 만든 뒤 weak-verified 이상일 때만 entries로 승격한다."
},
{
"name": "래미안원베일리",
"district": "서초구",
"neighborhood": "반포동",
"review_status": "manual-verification-needed",
"reason": "generated unresolved. 신축/브랜드 단지라 검색량은 높지만 자동 검색 안정성은 낮다.",
"next_action": "반포 원베일리/래미안원베일리 alias를 분리한 수동 검증 seed를 마련한다."
},
{
"name": "목동신시가지7단지",
"district": "양천구",
"neighborhood": "목동",
"review_status": "manual-verification-needed",
"reason": "숫자 단지형 이름이라 parser와 검색 결과가 흔들린다.",
"next_action": "목동 7단지/목동신시가지7단지/목동신시가지 7단지 alias를 유지하되 complex_id 확정 전까지 entries에 넣지 않는다."
},
{
"name": "신월시영아파트",
"district": "양천구",
"neighborhood": "신월동",
"review_status": "blocked-but-high-priority",
"reason": "403 차단으로 generated가 막혔다. 다만 alias 난이도가 높아서 운영상 seed 가치가 크다.",
"next_action": "신월시영 complex URL/ID를 수동 확보해 최우선으로 baseline seed에 추가한다."
},
{
"name": "답십리두산위브",
"district": "동대문구",
"neighborhood": "답십리동",
"review_status": "manual-verification-needed",
"reason": "실사용 질의 빈도가 높은데 현재 production seed/candidate-cache에 없음. 네이버 complex ID를 아직 수동 확정하지 못했다.",
"next_action": "direct complex URL/ID를 확보하면 production seed로 승격하고, 그 전까지는 reference seed fallback으로 후보 탐색 응답을 보강한다."
}
],
"review_summary": {
"accepted_for_production": [
"리센츠"
],
"excluded_due_to_wrong_match": [
"엘스",
"트리지움"
],
"pending_manual_verification": [
"은마",
"래미안대치팰리스",
"아크로리버파크",
"래미안원베일리",
"목동신시가지7단지",
"신월시영아파트",
"답십리두산위브"
],
"priority_backfill_targets": [
"신월시영아파트",
"답십리두산위브",
"은마",
"래미안대치팰리스",
"래미안원베일리",
"아크로리버파크",
"목동신시가지7단지",
"엘스",
"트리지움"
]
}
}
FILE:references/design.md
# naver-real-estate-search 설계 메모
## 목표
- 대한민국 / 네이버 부동산 맥락에 맞는 OpenClaw 스킬 제공
- 자연어 요청을 최소 실행 가능한 검색 파라미터로 빠르게 축약
- `twbeatles/naverland-scrapper` 내부 로직을 가능한 범위에서 재사용
- 429 환경에서도 **direct URL/ID 우선 → 후보 좁히기 → 비교 → 감시** 흐름 유지
## 이번 고도화 핵심
### 1) 식별/안정성
- candidate cache를 단순 리스트에서 `version/entries/updated_at` 구조로 확장하고, seed/import/list 관리용 CLI를 추가
- alias 변형 생성 강화: 원문 / 공백 정리 / suffix 제거 / `아파트` 재부착 / 일부 지역 alias 확장
- 자연어 파서 강화: raw subject, location hint, direct complex ID 추출 보존
- cold-start 후보 탐색 시 `지역 + 단지명`, raw subject, cleaned query를 조합한 다중 검색어 전략 사용
- 점수화 기준 강화: 이름 정규화, alias, 질의 토큰, 주소 내 지역 힌트, 세대수 신뢰도 반영
- `신월시영아파트` 같은 케이스에서 alias cache warm-up 이후 재질문 성공률이 높아지도록 설계
- `references/candidate-seeds.json`을 통해 자주 쓰는 단지 후보를 운영 전 warm-cache 할 수 있게 설계
- 429 발생 시 메타에 상태를 남기고 direct URL/ID 우선 재시도 흐름을 유지
### 2) 비교/출력
- 거래유형별 `min/avg/median/max` 요약 추가
- `area_summary`를 도입해 동일 평형 버킷(예: `33평`) 기준 비교 가능하게 확장
- compare 결과에 `compare_insights.trade`, `compare_insights.same_area` 계층 추가
- 한국어 브리핑에서:
- 전체 평균 비교 문장
- 동일 평형 기준 비교 문장
- 가격 분산 해석 문장
- 대표 매물 bullet 정리
를 더 자연스럽게 생성
### 3) 감시/연동
- watch schema를 `schema_version`, `rules`, `events`, `last_seen`, `last_checked_at` 구조로 확장
- rule 단위 옵션 추가:
- `target_max_price`
- `notify_on_new`
- `notify_on_price_drop`
- check 결과 stdout JSON을 상위 레이어 친화적으로 정리:
- `kind`
- `schema_version`
- `checked_at`
- `alert_count`
- `alerts[]`
- `message_preview`
- `summary`
- `last_seen` + `dedupe_key` 기반 중복 알림 억제
- snapshot에 `complex_info`, `market_summary`, `meta`를 넣어 텔레그램/브리핑 레이어가 재가공하기 쉽게 만듦
## 후보 탐색 전략 상세
1. direct complex ID / URL / 텍스트 내 complex ID를 먼저 본다.
2. cache에서 alias exact/contains 매칭을 먼저 시도한다.
3. 실패 시 web search를 쓰되 단일 키워드 하나만 쓰지 않는다.
- raw subject
- cleaned query
- candidate keyword
- location hint
- `지역 + 단지명` 조합
4. 수집한 complex ID에 대해 상세 API를 조회해 이름/주소/세대수 보강
5. 점수 상위 후보만 반환
## 429 운영 원칙
- 먼저 짧은 backoff로 재시도한다.
- 여전히 실패하면 더 넓은 탐색으로 밀어붙이지 않는다.
- 사용자나 상위 레이어에 다음을 유도한다.
- direct complex ID
- direct URL
- 후보 1~3개만 좁혀 재시도
## watch JSON 예시
```json
{
"kind": "naver-real-estate-watch-check",
"schema_version": 2,
"checked_at": 1760000000,
"alert_count": 2,
"alerts": [
{
"rule": {"id": "rule-abcd1234", "name": "리센츠 전세 30평대"},
"matched_count": 1,
"matched": [
{
"event_type": "target_hit",
"article_key": "1147:123456789",
"price": 950000000,
"price_text": "9억 5,000",
"article_url": "https://new.land.naver.com/..."
}
],
"snapshot": {
"complex_info": {"name": "리센츠"},
"market_summary": {"전세": {"count": 8}},
"meta": {"rate_limited": false}
}
}
]
}
```
## 테스트 권장 시나리오
- `--self-test`
- `--parse-only` 로 자연어 파서 확인
- `--list-candidates` 로 tricky alias 확인
- direct complex ID 조회
- 비교 브리핑 확인
- watch add/check 및 두 번째 check에서 dedupe 동작 확인
## 추가 개선 후보
- Playwright 세션 재사용 기반 429 회피력 향상
- 실거래가/전세가율 같은 파생 지표 추가
- 특정 지역 사전(alias seed) 확장
- 텔레그램 markdown-safe formatter 별도 스크립트 추가
FILE:references/seoul-major-complexes.seed-input.json
{
"kind": "naver-real-estate-seed-input",
"schema_version": 1,
"description": "서울 주요 단지 seed 생성용 입력 예시",
"seeds": [
{
"name": "리센츠",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": ["잠실 리센츠", "잠실리센츠"],
"note": "verified baseline; 생성기 동작 검증용"
},
{
"name": "엘스",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": ["잠실 엘스", "잠실엘스"]
},
{
"name": "트리지움",
"city": "서울특별시",
"district": "송파구",
"neighborhood": "잠실동",
"aliases": ["잠실 트리지움", "잠실트리지움"]
},
{
"name": "은마",
"city": "서울특별시",
"district": "강남구",
"neighborhood": "대치동",
"aliases": ["은마아파트", "대치 은마"]
},
{
"name": "래미안대치팰리스",
"city": "서울특별시",
"district": "강남구",
"neighborhood": "대치동",
"aliases": ["대치 팰리스", "대치래미안팰리스", "래미안 대치팰리스"]
},
{
"name": "아크로리버파크",
"city": "서울특별시",
"district": "서초구",
"neighborhood": "반포동",
"aliases": ["반포 아크로리버파크", "반포아크로리버파크"]
},
{
"name": "래미안원베일리",
"city": "서울특별시",
"district": "서초구",
"neighborhood": "반포동",
"aliases": ["반포 원베일리", "원베일리"]
},
{
"name": "목동신시가지7단지",
"city": "서울특별시",
"district": "양천구",
"neighborhood": "목동",
"aliases": ["목동 7단지", "목동신시가지7", "목동신시가지 7단지"]
},
{
"name": "신월시영아파트",
"city": "서울특별시",
"district": "양천구",
"neighborhood": "신월동",
"aliases": ["신월시영", "양천 신월시영"],
"note": "tricky alias 케이스"
},
{
"name": "답십리두산위브",
"city": "서울특별시",
"district": "동대문구",
"neighborhood": "답십리동",
"aliases": ["두산위브", "답십리 두산위브", "답십리두산위브아파트"],
"note": "실사용 자연어 질의 fallback 강화용. complex ID 수동 확보 전까지 reference seed로 운영"
}
]
}
FILE:scripts/apply_generated_seeds.py
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import search_real_estate as sre
DEFAULT_GENERATED = SCRIPT_DIR.parent / "references" / "candidate-seeds.generated.json"
DEFAULT_TARGET = SCRIPT_DIR.parent / "references" / "candidate-seeds.json"
DEFAULT_CACHE = SCRIPT_DIR.parent / "data" / "candidate-cache.json"
DEFAULT_INCLUDE_STATUSES = ["verified", "weak-verified"]
def _load_json(path: Path, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _dedupe_keep_order(values: list[str]) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for value in values:
item = str(value or "").strip()
if not item or item in seen:
continue
out.append(item)
seen.add(item)
return out
def _normalize_names(values: list[str] | None) -> set[str]:
return {sre.normalize_complex_alias(v) for v in (values or []) if sre.normalize_complex_alias(v)}
def _merge_aliases(*parts: list[str]) -> list[str]:
flat: list[str] = []
for group in parts:
flat.extend([str(x) for x in group if str(x).strip()])
expanded: list[str] = []
for item in flat:
expanded.extend(sre.expand_alias_variants(item))
return _dedupe_keep_order(expanded)
def _build_review_row(result: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
status = str(result.get("verification_status") or "unresolved")
seed = result.get("seed_input") or {}
complex_id = str(result.get("complex_id") or "").strip()
reason_bits = []
if result.get("blocked_reasons"):
reason_bits.append("external-blocked:" + ", ".join(str(x) for x in result.get("blocked_reasons") or []))
if not complex_id:
reason_bits.append("complex_id 미확보")
pool = result.get("candidate_pool") or []
if complex_id and pool:
top = pool[0]
top_name = ((top.get("complex") or {}).get("name") or top.get("name") or "")
if top_name:
reason_bits.append(f"top-candidate={top_name} ({complex_id})")
if not reason_bits:
reason_bits.append(f"verification_status={status}")
review_status = {
"blocked": "blocked-but-high-priority",
"unresolved": "manual-verification-needed",
"unverified": "manual-verification-needed",
"weak-verified": "review-before-promote",
}.get(status, "manual-verification-needed")
if existing and existing.get("review_status") == "excluded-for-now":
review_status = "excluded-for-now"
next_action = (
"direct complex URL/ID를 확보해 name/address 정합성을 다시 확인한 뒤 승격한다."
if not complex_id
else "preview 결과를 보고 name/address/aliases가 맞으면 --apply-target으로 승격하고, 필요 시 --apply-cache로 warm-cache 한다."
)
return {
"name": result.get("name") or seed.get("name"),
"district": result.get("district") or seed.get("district"),
"neighborhood": result.get("neighborhood") or seed.get("neighborhood"),
"review_status": review_status,
"reason": "; ".join(reason_bits),
"next_action": next_action,
"verification_status": status,
"confidence": result.get("confidence"),
"complex_id": complex_id or None,
}
def _build_entry(result: dict[str, Any], previous: dict[str, Any] | None = None) -> dict[str, Any]:
seed = result.get("seed_input") or {}
previous_aliases = list((previous or {}).get("aliases") or [])
aliases = _merge_aliases(
[str(result.get("name") or "")],
list(result.get("aliases") or []),
list(seed.get("aliases") or []),
previous_aliases,
)
note_bits = [str((previous or {}).get("note") or "").strip(), str(result.get("note") or "").strip()]
note = " | ".join([x for x in note_bits if x])
return {
"complex_id": str(result.get("complex_id") or ""),
"name": result.get("name") or seed.get("name"),
"address": result.get("address") or seed.get("address") or (previous or {}).get("address"),
"household_count": result.get("household_count") or (previous or {}).get("household_count"),
"aliases": aliases,
"verification_status": result.get("verification_status"),
"confidence": result.get("confidence"),
"source": result.get("source") or "generated:web-search+detail",
"learned_from": result.get("learned_from") or "apply_generated_seeds.py",
"note": note or "generated seed promoted to production",
}
def build_plan(
generated_payload: dict[str, Any],
current_payload: dict[str, Any],
*,
include_statuses: set[str],
min_confidence: float,
only_names: set[str],
exclude_names: set[str],
) -> dict[str, Any]:
results = list(generated_payload.get("results") or [])
existing_entries = list(current_payload.get("entries") or [])
existing_reviews = list(current_payload.get("manual_review_queue") or [])
existing_by_id = {str(row.get("complex_id") or ""): row for row in existing_entries if str(row.get("complex_id") or "").strip()}
existing_reviews_by_name = {sre.normalize_complex_alias(row.get("name") or ""): row for row in existing_reviews}
accepted: list[dict[str, Any]] = []
rejected: list[dict[str, Any]] = []
review_rows: list[dict[str, Any]] = []
for result in results:
name = str(result.get("name") or "").strip()
name_key = sre.normalize_complex_alias(name)
complex_id = str(result.get("complex_id") or "").strip()
status = str(result.get("verification_status") or "")
confidence = float(result.get("confidence") or 0.0)
if only_names and name_key not in only_names:
continue
if exclude_names and name_key in exclude_names:
continue
eligible = bool(complex_id) and status in include_statuses and confidence >= min_confidence
if eligible:
accepted.append(_build_entry(result, previous=existing_by_id.get(complex_id)))
else:
rejected.append({
"name": name,
"complex_id": complex_id or None,
"verification_status": status,
"confidence": confidence,
})
review_rows.append(_build_review_row(result, existing_reviews_by_name.get(name_key)))
preserved_entries: list[dict[str, Any]] = []
accepted_ids = {str(row.get("complex_id") or "") for row in accepted}
for row in existing_entries:
row_id = str(row.get("complex_id") or "")
if row_id and row_id not in accepted_ids:
preserved_entries.append(row)
merged_entries = sorted(
[*preserved_entries, *accepted],
key=lambda row: (str(row.get("name") or ""), str(row.get("complex_id") or "")),
)
merged_reviews_by_name = {sre.normalize_complex_alias(row.get("name") or ""): row for row in existing_reviews}
for row in review_rows:
merged_reviews_by_name[sre.normalize_complex_alias(row.get("name") or "")] = row
for row in accepted:
merged_reviews_by_name.pop(sre.normalize_complex_alias(row.get("name") or ""), None)
merged_reviews = sorted(merged_reviews_by_name.values(), key=lambda row: str(row.get("name") or ""))
next_payload = {
"schema_version": max(2, int(current_payload.get("schema_version") or 2)),
"updated_at": int(time.time()),
"policy": current_payload.get("policy") or {
"production_entry_rule": "verification_status가 verified 또는 weak-verified 이고, complex_id/name/address 정합성이 확인된 항목만 entries에 넣는다.",
"excluded_rule": "generated 결과에서 complex_id가 있더라도 이름/주소 검증이 실패했거나 차단/오염 가능성이 있으면 entries에 넣지 않는다.",
},
"entries": merged_entries,
"manual_review_queue": merged_reviews,
"review_summary": {
"accepted_for_production": [row.get("name") for row in accepted],
"pending_manual_verification": [row.get("name") for row in merged_reviews],
"generated_input_count": len(results),
"accepted_count": len(accepted),
"rejected_count": len(rejected),
},
}
return {
"accepted": accepted,
"rejected": rejected,
"next_payload": next_payload,
}
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="generated candidate seed preview/apply 도우미")
p.add_argument("--input", default=str(DEFAULT_GENERATED), help="candidate-seeds.generated.json 경로")
p.add_argument("--target", default=str(DEFAULT_TARGET), help="운영 candidate-seeds.json 경로")
p.add_argument("--cache-file", default=str(DEFAULT_CACHE), help="candidate-cache.json 경로")
p.add_argument("--include-statuses", default=",".join(DEFAULT_INCLUDE_STATUSES), help="승격 허용 상태 목록")
p.add_argument("--min-confidence", type=float, default=0.65, help="승격 최소 confidence")
p.add_argument("--only-names", default="", help="쉼표 구분 이름 필터")
p.add_argument("--exclude-names", default="", help="쉼표 구분 제외 이름")
p.add_argument("--apply-target", action="store_true", help="운영 candidate-seeds.json에 반영")
p.add_argument("--apply-cache", action="store_true", help="accepted 항목을 candidate-cache에도 warm-cache")
p.add_argument("--json", action="store_true", help="JSON으로 출력")
p.add_argument("--self-test", action="store_true", help="내장 self-test 실행")
return p
def run_self_test() -> int:
sample_generated = {
"results": [
{
"name": "리센츠",
"district": "송파구",
"neighborhood": "잠실동",
"complex_id": "1147",
"address": "서울특별시 송파구 잠실동",
"household_count": 5563,
"aliases": ["리센츠", "잠실 리센츠"],
"seed_input": {"aliases": ["잠실리센츠"]},
"verification_status": "verified",
"confidence": 1.0,
"source": "generated:test",
"learned_from": "self-test",
"note": "ok",
},
{
"name": "은마",
"district": "강남구",
"neighborhood": "대치동",
"complex_id": "",
"aliases": ["은마", "대치 은마"],
"verification_status": "blocked",
"confidence": 0.0,
"blocked_reasons": ["HTTP 403"],
},
]
}
sample_current = {
"schema_version": 2,
"entries": [],
"manual_review_queue": [],
}
plan = build_plan(
sample_generated,
sample_current,
include_statuses={"verified", "weak-verified"},
min_confidence=0.65,
only_names=set(),
exclude_names=set(),
)
assert len(plan["accepted"]) == 1
assert plan["accepted"][0]["name"] == "리센츠"
assert len(plan["next_payload"]["entries"]) == 1
assert plan["next_payload"]["entries"][0]["complex_id"] == "1147"
assert len(plan["next_payload"]["manual_review_queue"]) == 1
assert plan["next_payload"]["manual_review_queue"][0]["name"] == "은마"
print("SELF_TEST_OK")
print(json.dumps({
"accepted": plan["accepted"],
"review_queue": plan["next_payload"]["manual_review_queue"],
}, ensure_ascii=False, indent=2))
return 0
def main() -> int:
args = build_parser().parse_args()
if args.self_test:
return run_self_test()
input_path = Path(args.input)
target_path = Path(args.target)
cache_path = Path(args.cache_file)
include_statuses = {x.strip() for x in str(args.include_statuses or "").split(",") if x.strip()}
only_names = _normalize_names([x.strip() for x in str(args.only_names or "").split(",") if x.strip()])
exclude_names = _normalize_names([x.strip() for x in str(args.exclude_names or "").split(",") if x.strip()])
generated_payload = _load_json(input_path, {"results": []})
current_payload = _load_json(target_path, {"schema_version": 2, "entries": [], "manual_review_queue": []})
plan = build_plan(
generated_payload,
current_payload,
include_statuses=include_statuses,
min_confidence=max(0.0, float(args.min_confidence)),
only_names=only_names,
exclude_names=exclude_names,
)
next_payload = plan["next_payload"]
cache_result: dict[str, Any] | None = None
if args.apply_target:
_write_json(target_path, next_payload)
if args.apply_cache:
previous_cache = sre.CANDIDATE_CACHE_FILE
sre.CANDIDATE_CACHE_FILE = cache_path
try:
cache_saved = sre.seed_candidate_cache(next_payload.get("entries") or [], source=f"generated-seed:{input_path.name}")
cache_result = {"saved_count": len(cache_saved), "saved": cache_saved}
finally:
sre.CANDIDATE_CACHE_FILE = previous_cache
summary = {
"input": str(input_path),
"target": str(target_path),
"cache_file": str(cache_path),
"applied_target": bool(args.apply_target),
"applied_cache": bool(args.apply_cache),
"accepted_count": len(plan["accepted"]),
"rejected_count": len(plan["rejected"]),
"accepted": [
{
"name": row.get("name"),
"complex_id": row.get("complex_id"),
"verification_status": row.get("verification_status"),
"confidence": row.get("confidence"),
}
for row in plan["accepted"]
],
"rejected": plan["rejected"],
"manual_review_queue_count": len(next_payload.get("manual_review_queue") or []),
"cache_result": cache_result,
}
if args.json or not (args.apply_target or args.apply_cache):
print(json.dumps(summary, ensure_ascii=False, indent=2))
else:
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/build_candidate_seeds.py
from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.parse
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import search_real_estate as sre
DEFAULT_INPUT = SCRIPT_DIR.parent / "references" / "seoul-major-complexes.seed-input.json"
DEFAULT_OUTPUT = SCRIPT_DIR.parent / "references" / "candidate-seeds.generated.json"
SEARCH_URL = "https://search.naver.com/search.naver?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query={query}"
COMPLEX_PATTERNS = [
r"https://new\.land\.naver\.com/complexes/(\d+)",
r"https://new\.land\.naver\.com/houses/(\d+)",
r"complexNo=(\d+)",
]
def _load_json(path: Path, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _dedupe_keep_order(values: list[str]) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for value in values:
key = str(value or "").strip()
if not key or key in seen:
continue
out.append(key)
seen.add(key)
return out
def build_aliases(name: str, district: str = "", neighborhood: str = "", city: str = "서울특별시", extra: list[str] | None = None) -> list[str]:
base = str(name or "").strip()
variants: list[str] = []
for seed in [base, *(extra or [])]:
for value in sre.expand_alias_variants(seed):
variants.append(value)
place_bits = [x.strip() for x in [city, district, neighborhood] if str(x or "").strip()]
short_city = city.replace("특별시", "").replace("광역시", "").strip() if city else ""
if short_city:
place_bits.append(short_city)
normalized = sre.normalize_complex_alias(base)
for prefix in _dedupe_keep_order(place_bits):
if base:
variants.extend([f"{prefix} {base}", f"{prefix}{base}"])
if normalized:
variants.extend([f"{prefix} {normalized}", f"{prefix}{normalized}"])
if district and neighborhood:
variants.extend([f"{district} {neighborhood} {base}", f"{district}{neighborhood}{base}"])
return _dedupe_keep_order([v for item in variants for v in sre.expand_alias_variants(item)])[:30]
def extract_ids_from_html(query: str, *, limit: int = 8) -> dict[str, Any]:
url = SEARCH_URL.format(query=urllib.parse.quote(query))
try:
html = sre._request_text(url) # type: ignore[attr-defined]
except Exception as exc:
return {"ids": [], "status": "blocked", "error": str(exc), "query": query}
ids: list[str] = []
seen: set[str] = set()
for pattern in COMPLEX_PATTERNS:
for match in re.finditer(pattern, html):
cid = match.group(1)
if cid not in seen:
ids.append(cid)
seen.add(cid)
if len(ids) >= limit:
break
if len(ids) >= limit:
break
return {"ids": ids, "status": "ok", "query": query}
def verify_candidate(complex_id: str, seed: dict[str, Any]) -> dict[str, Any]:
try:
info = sre.fetch_complex_info(complex_id)
name = str(info.get("name") or "")
address = str(info.get("address") or "")
score = 0.0
reasons: list[str] = []
seed_name_norm = sre.normalize_complex_alias(seed.get("name") or "")
found_name_norm = sre.normalize_complex_alias(name)
district = str(seed.get("district") or "")
neighborhood = str(seed.get("neighborhood") or "")
if seed_name_norm and found_name_norm == seed_name_norm:
score += 0.55
reasons.append("name-exact")
elif seed_name_norm and seed_name_norm in found_name_norm:
score += 0.35
reasons.append("name-partial")
if district and district in address:
score += 0.15
reasons.append("district-match")
if neighborhood and neighborhood in address:
score += 0.15
reasons.append("neighborhood-match")
household_count = info.get("household_count")
try:
household_count = int(household_count or 0)
except Exception:
household_count = 0
if household_count >= 500:
score += 0.05
reasons.append("household-signal")
status = "verified" if score >= 0.65 else "weak-verified"
return {
"status": status,
"confidence": round(min(1.0, score), 3),
"verification_reasons": reasons,
"complex": info,
}
except Exception as exc:
return {
"status": "unverified",
"confidence": 0.0,
"verification_reasons": [f"detail-fetch-failed:{type(exc).__name__}"],
"error": str(exc),
"complex": {"complex_id": complex_id},
}
def build_search_queries(seed: dict[str, Any]) -> list[str]:
city = str(seed.get("city") or "서울특별시")
district = str(seed.get("district") or "")
neighborhood = str(seed.get("neighborhood") or "")
name = str(seed.get("name") or "")
aliases = [str(x) for x in (seed.get("aliases") or []) if str(x).strip()]
queries: list[str] = []
for label in [name, *aliases]:
label = label.strip()
if not label:
continue
queries.extend([
f"{city} {district} {neighborhood} {label} 네이버 부동산",
f"{district} {neighborhood} {label} 네이버 부동산",
f"{district} {label} 네이버 부동산",
f"{label} 네이버 부동산 아파트",
f"site:new.land.naver.com/complexes {district} {label}",
])
return _dedupe_keep_order([q.strip() for q in queries if q.strip()])[:12]
def build_entry(seed: dict[str, Any], *, pause: float = 0.6) -> dict[str, Any]:
aliases = build_aliases(
seed.get("name") or "",
district=seed.get("district") or "",
neighborhood=seed.get("neighborhood") or "",
city=seed.get("city") or "서울특별시",
extra=[str(x) for x in (seed.get("aliases") or []) if str(x).strip()],
)
evidence: list[dict[str, Any]] = []
candidates: list[dict[str, Any]] = []
seen_ids: set[str] = set()
blocked_reasons: list[str] = []
baseline_payload = _load_json(SCRIPT_DIR.parent / "references" / "candidate-seeds.json", {"entries": []})
baseline_entries = baseline_payload.get("entries") if isinstance(baseline_payload, dict) else []
seed_name_norm = sre.normalize_complex_alias(seed.get("name") or "")
for row in baseline_entries or []:
row_name_norm = sre.normalize_complex_alias(row.get("name") or "")
row_aliases = [sre.normalize_complex_alias(x) for x in (row.get("aliases") or [])]
if seed_name_norm and (seed_name_norm == row_name_norm or seed_name_norm in row_aliases):
candidates.append({
"complex_id": str(row.get("complex_id") or ""),
"query": seed.get("name") or "",
"discovery_source": "baseline-seed",
"status": "verified",
"confidence": 1.0,
"verification_reasons": ["baseline-seed-match"],
"complex": {
"complex_id": str(row.get("complex_id") or ""),
"name": row.get("name"),
"address": row.get("address"),
"household_count": row.get("household_count"),
},
})
seen_ids.add(str(row.get("complex_id") or ""))
evidence.append({"query": seed.get("name") or "", "status": "baseline-seed-hit", "ids": [row.get("complex_id")]})
break
def _append_candidate(cid: str, query: str, source: str) -> None:
if not cid or cid in seen_ids:
return
seen_ids.add(cid)
verified = verify_candidate(cid, seed)
candidates.append({
"complex_id": cid,
"query": query,
"discovery_source": source,
**verified,
})
for query in build_search_queries(seed):
try:
cached = sre.search_cached_candidates(query, candidate_limit=3)
except Exception as exc:
cached = []
evidence.append({"query": query, "status": "cache-error", "error": str(exc)})
else:
if cached:
evidence.append({
"query": query,
"status": "cache-hit",
"source": "candidate-cache",
"ids": [row.get("complex_id") for row in cached],
})
for row in cached:
_append_candidate(str(row.get("complex_id") or ""), query, "candidate-cache")
if candidates and float(candidates[0].get("confidence") or 0.0) >= 0.8:
break
snapshot = extract_ids_from_html(query, limit=6)
evidence.append(snapshot)
if snapshot.get("status") != "ok":
blocked_reasons.append(str(snapshot.get("error") or snapshot.get("status") or "blocked"))
time.sleep(pause)
continue
for cid in snapshot.get("ids") or []:
_append_candidate(cid, query, "html-extract")
time.sleep(pause)
time.sleep(pause)
candidates.sort(key=lambda row: (-float(row.get("confidence") or 0.0), str(row.get("complex", {}).get("name") or ""), str(row.get("complex_id") or "")))
chosen = candidates[0] if candidates else None
verification_status = "blocked" if (not chosen and blocked_reasons) else (chosen.get("status") if chosen else "unresolved")
confidence = float(chosen.get("confidence") or 0.0) if chosen else 0.0
complex_info = (chosen or {}).get("complex") or {}
return {
"name": seed.get("name"),
"district": seed.get("district"),
"neighborhood": seed.get("neighborhood"),
"seed_input": seed,
"complex_id": complex_info.get("complex_id") or (chosen or {}).get("complex_id"),
"address": complex_info.get("address") or seed.get("address") or "",
"household_count": complex_info.get("household_count"),
"aliases": aliases,
"verification_status": verification_status,
"confidence": round(confidence, 3),
"source": "generated:web-search+detail",
"learned_from": "build_candidate_seeds.py",
"note": seed.get("note") or "generated candidate seed",
"candidate_pool": candidates[:5],
"evidence": evidence[:8],
"blocked_reasons": _dedupe_keep_order(blocked_reasons)[:5],
}
def build_generated_payload(seed_items: list[dict[str, Any]], *, pause: float = 0.6) -> dict[str, Any]:
generated = [build_entry(seed, pause=pause) for seed in seed_items]
entries = [
{
"complex_id": row.get("complex_id"),
"name": row.get("name"),
"address": row.get("address"),
"household_count": row.get("household_count"),
"aliases": row.get("aliases") or [],
"note": row.get("note"),
"source": row.get("source"),
"learned_from": row.get("learned_from"),
"confidence": row.get("confidence"),
"verification_status": row.get("verification_status"),
}
for row in generated
if row.get("complex_id") and row.get("verification_status") in {"verified", "weak-verified"}
]
unresolved = [
{
"name": row.get("name"),
"district": row.get("district"),
"neighborhood": row.get("neighborhood"),
"aliases": row.get("aliases") or [],
"verification_status": row.get("verification_status"),
"confidence": row.get("confidence"),
"blocked_reasons": row.get("blocked_reasons") or [],
}
for row in generated if not row.get("complex_id")
]
return {
"kind": "naver-real-estate-candidate-seed-generation",
"schema_version": 1,
"generated_at": int(time.time()),
"source_input": str(DEFAULT_INPUT),
"entry_count": len(entries),
"unresolved_count": len(unresolved),
"entries": entries,
"unresolved": unresolved,
"results": generated,
}
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="네이버 부동산 candidate seed 자동 생성기")
p.add_argument("--input", default=str(DEFAULT_INPUT), help="seed 입력 JSON 파일")
p.add_argument("--output", default=str(DEFAULT_OUTPUT), help="생성 결과 JSON 파일")
p.add_argument("--pause", type=float, default=0.7, help="요청 사이 pause seconds")
p.add_argument("--print-summary", action="store_true", help="간단 요약 출력")
return p
def main() -> int:
args = build_parser().parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
payload = _load_json(input_path, {"seeds": []})
seed_items = payload.get("seeds") if isinstance(payload, dict) else payload
if not seed_items:
raise SystemExit(f"seed 입력이 비어 있습니다: {input_path}")
generated = build_generated_payload(list(seed_items), pause=max(0.0, float(args.pause)))
generated["source_input"] = str(input_path)
_write_json(output_path, generated)
if args.print_summary:
print(json.dumps({
"output": str(output_path),
"entry_count": generated.get("entry_count"),
"unresolved_count": generated.get("unresolved_count"),
"resolved": [
{
"name": row.get("name"),
"complex_id": row.get("complex_id"),
"confidence": row.get("confidence"),
"verification_status": row.get("verification_status"),
}
for row in generated.get("results", [])
],
}, ensure_ascii=False, indent=2))
else:
print(str(output_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/chat_real_estate.py
from __future__ import annotations
import argparse
import json
from typing import Any
from search_real_estate import PriceConverter, run_query, search_complex_candidates
def _fmt_price(value: int | None) -> str:
return PriceConverter.to_string(value) if value else "-"
def _pick_headline(meta: dict[str, Any], trade_type: str) -> str:
count = int(meta.get("count") or 0)
if count == 0:
return f"{trade_type} 매물이 거의 안 잡힙니다."
if count <= 3:
return f"{trade_type} 매물이 많지는 않지만 확인 가능한 건은 있습니다."
if count <= 10:
return f"{trade_type} 매물이 어느 정도 보입니다."
return f"{trade_type} 매물이 비교적 넉넉하게 잡힙니다."
def _trend_line(meta: dict[str, Any]) -> str:
min_price = meta.get("min_price")
avg_price = meta.get("avg_price")
max_price = meta.get("max_price")
if not (min_price and avg_price and max_price):
return "가격대 해석용 표본은 아직 제한적입니다."
spread = max_price - min_price
if spread <= max(1, avg_price * 0.05):
return "가격 분산이 크지 않아 호가대가 비교적 촘촘합니다."
if spread >= max(1, avg_price * 0.15):
return "호가 폭이 꽤 넓어서 같은 단지 안에서도 조건 차이가 크게 보입니다."
return "호가 차이는 있으나 극단적으로 벌어지지는 않습니다."
def _representative_lines(items: list[dict[str, Any]], max_items: int = 3) -> list[str]:
lines: list[str] = []
for row in items[:max_items]:
price = row.get("매매가") or row.get("보증금") or "-"
if row.get("거래유형") == "월세" and row.get("월세"):
price = f"{price}/{row.get('월세')}"
parts = [f"{price}", f"{row.get('면적(평)', 0)}평", row.get("층/방향", "-") or "-"]
lines.append(f" · 대표 매물: {', '.join(parts)}")
if row.get("특징"):
lines.append(f" - 포인트: {row['특징']}")
if row.get("매물URL"):
lines.append(f" - 링크: {row['매물URL']}")
return lines
def brief_single(payload: dict[str, Any]) -> str:
info = payload.get("complex_info") or {}
parsed = payload.get("parsed") or {}
lines = [f"{info.get('name', payload.get('selected_complex_id'))} 브리핑"]
if info.get("address"):
lines.append(f"- 위치: {info['address']}")
filters = []
if payload.get("trade_types"):
filters.append("거래유형 " + ", ".join(payload["trade_types"]))
if parsed.get("min_pyeong") or parsed.get("max_pyeong"):
filters.append(f"평형 {parsed.get('min_pyeong', '-')}~{parsed.get('max_pyeong', '-')}평")
if filters:
lines.append(f"- 필터: {' / '.join(filters)}")
summary = payload.get("market_summary") or {}
if not summary:
lines.append("- 조건에 맞는 매물이 아직 잡히지 않았습니다.")
return "\n".join(lines)
for trade_type, meta in summary.items():
lines.append(f"- {trade_type}: {meta.get('count', 0)}건")
lines.append(f" · {_pick_headline(meta, trade_type)}")
lines.append(f" · 가격대: 최저 {_fmt_price(meta.get('min_price'))} / 평균 {_fmt_price(meta.get('avg_price'))} / 중앙값 {_fmt_price(meta.get('median_price'))} / 최고 {_fmt_price(meta.get('max_price'))}")
lines.append(f" · 해석: {_trend_line(meta)}")
area_rows = meta.get("area_summary") or []
if area_rows:
head = area_rows[0]
lines.append(f" · 동일 평형 대표: {head.get('area_key')} {head.get('count')}건, 평균 {_fmt_price(head.get('avg_price'))}")
lines.extend(_representative_lines(payload.get("items", [])))
return "\n".join(lines)
def brief_compare(payload: dict[str, Any]) -> str:
results = payload.get("results") or []
insights = payload.get("compare_insights") or {}
lines = ["단지 비교 브리핑"]
for result in results:
info = result.get("complex_info") or {}
name = info.get("name", result.get("complex_id"))
address = info.get("address") or "-"
lines.append(f"- {name} ({address})")
summary = result.get("market_summary") or {}
if not summary:
lines.append(" · 조건에 맞는 매물이 거의 안 보입니다.")
continue
for trade_type, meta in summary.items():
lines.append(f" · {trade_type}: {meta.get('count', 0)}건 / 최저 {_fmt_price(meta.get('min_price'))} / 평균 {_fmt_price(meta.get('avg_price'))} / 중앙값 {_fmt_price(meta.get('median_price'))} / 최고 {_fmt_price(meta.get('max_price'))}")
area_rows = meta.get("area_summary") or []
if area_rows:
head = area_rows[0]
lines.append(f" - 대표 동일 평형: {head.get('area_key')} {head.get('count')}건 / 평균 {_fmt_price(head.get('avg_price'))}")
for trade_type, meta in (insights.get("trade") or {}).items():
lines.append(f"- 종합 해석 ({trade_type}): {meta['cheapest']['name']} 쪽 평균이 가장 낮고 {meta['most_expensive']['name']} 쪽이 가장 높습니다. 전체 평균 차이는 {_fmt_price(meta['gap'])} 정도입니다.")
for trade_type, area_rows in (insights.get("same_area") or {}).items():
if not area_rows:
continue
head = area_rows[0]
lines.append(f"- 동일 평형 해석 ({trade_type} {head['area_key']}): {head['cheapest']['name']} 쪽이 더 낮고 {head['most_expensive']['name']} 쪽이 더 높습니다. 같은 평형 기준 차이는 {_fmt_price(head['gap'])} 정도입니다.")
if payload.get("meta", {}).get("rate_limited"):
lines.append("- 참고: 현재 네이버 요청 제한이 감지돼 direct URL/complex ID 기반 조회가 더 안정적일 수 있습니다.")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="네이버 부동산 채팅형 브리핑 래퍼")
p.add_argument("--query", help="자연어 질의")
p.add_argument("--complex-id")
p.add_argument("--url")
p.add_argument("--trade-types", default="")
p.add_argument("--pages", type=int, default=1)
p.add_argument("--limit", type=int, default=10)
p.add_argument("--candidate-limit", type=int, default=3)
p.add_argument("--min-pyeong", type=float)
p.add_argument("--max-pyeong", type=float)
p.add_argument("--list-candidates", action="store_true")
p.add_argument("--compare", action="store_true")
p.add_argument("--json", action="store_true")
return p
def main() -> int:
args = build_parser().parse_args()
if args.list_candidates:
if not args.query:
raise SystemExit("--list-candidates 는 --query 와 함께 사용하세요.")
candidates = search_complex_candidates(args.query, candidate_limit=max(1, args.candidate_limit))
if args.json:
print(json.dumps(candidates, ensure_ascii=False, indent=2))
else:
lines = ["후보 단지"]
for idx, row in enumerate(candidates, start=1):
lines.append(f"- {idx}. {row.get('name')} | {row.get('address') or '-'} | ID {row.get('complex_id')} | score {row.get('match_score', '-')} | source {row.get('source', '-')}")
print("\n".join(lines))
return 0
trade_types = [token.strip() for token in str(args.trade_types).split(",") if token.strip()]
payload = run_query(
query=args.query,
complex_id=args.complex_id,
url=args.url,
trade_types=trade_types,
pages=max(1, args.pages),
limit=max(1, args.limit),
candidate_limit=max(1, args.candidate_limit),
min_pyeong=args.min_pyeong,
max_pyeong=args.max_pyeong,
compare=bool(args.compare),
)
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(brief_compare(payload) if payload.get("compare_mode") else brief_single(payload))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/search_real_estate.py
from __future__ import annotations
import argparse
import json
import math
import re
import statistics
import sys
import time
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
WORKSPACE = Path(__file__).resolve().parents[3]
UPSTREAM = WORKSPACE / "tmp" / "naverland-scrapper"
SRC_ROOT = UPSTREAM / "src"
if str(UPSTREAM) not in sys.path:
sys.path.insert(0, str(UPSTREAM))
if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT))
UPSTREAM_IMPORT_ERROR: Exception | None = None
try:
from src.core.parser import NaverURLParser
from src.core.services.response_capture import normalize_article_payload
from src.utils.helpers import PriceConverter, get_article_url
except Exception as exc:
UPSTREAM_IMPORT_ERROR = exc
class NaverURLParser:
@staticmethod
def extract_from_text(text: str) -> list[tuple[str, str]]:
pairs: list[tuple[str, str]] = []
for cid in RAW_COMPLEX_ID_RE.findall(text or ""):
pairs.append(("", cid))
return pairs
@staticmethod
def extract_complex_id(text: str | None) -> str | None:
match = RAW_COMPLEX_ID_RE.search(text or "")
return match.group(1) if match else None
@staticmethod
def fetch_complex_name(complex_id: str) -> str:
_raise_missing_upstream()
class PriceConverter:
@staticmethod
def to_int(value: Any) -> int:
raw = str(value or "").strip()
if not raw:
return 0
digits = re.sub(r"[^0-9]", "", raw)
return int(digits) if digits else 0
@staticmethod
def to_string(value: Any) -> str:
amount = PriceConverter.to_int(value)
if amount <= 0:
return "0"
eok = amount // 100000000
rem = amount % 100000000
man = rem // 10000
if eok and man:
return f"{eok}억 {man:,}만"
if eok:
return f"{eok}억"
return f"{man:,}만"
def get_article_url(complex_id: str, article_id: str, real_estate_type: str = "APT") -> str:
article = str(article_id or "").strip()
if not article:
return ""
return f"https://new.land.naver.com/articles/{article}?complexNo={complex_id}&realEstateType={real_estate_type}"
def normalize_article_payload(*args: Any, **kwargs: Any) -> dict[str, Any]:
_raise_missing_upstream()
def _raise_missing_upstream() -> None:
detail = f" ({UPSTREAM_IMPORT_ERROR})" if UPSTREAM_IMPORT_ERROR else ""
raise RuntimeError(
"필수 upstream clone(tmp/naverland-scrapper)이 없거나 불완전합니다. "
"이 스킬은 해당 로컬 저장소의 src 패키지에 의존합니다. "
f"경로를 확인한 뒤 다시 시도해 주세요{detail}"
)
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
SEARCH_URL = "https://search.naver.com/search.naver?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query={query}"
COMPLEX_DETAIL_URL = "https://new.land.naver.com/api/complexes/{complex_id}?sameAddressGroup=false"
COMPLEX_ARTICLE_URL = (
"https://new.land.naver.com/api/articles/complex/{complex_id}?"
"realEstateType=APT%3AVL&tradeType={trade_codes}&tag=%3A%3A%3A%3A%3A%3A%3A%3A"
"&rentPriceMin=0&rentPriceMax=900000000&priceMin=0&priceMax=900000000"
"&areaMin=0&areaMax=900000000&oldBuildYears=&recentlyBuildYears=&minHouseHoldCount="
"&maxHouseHoldCount=&showArticle=false&sameAddressGroup=false&minMaintenanceCost=&maxMaintenanceCost="
"&priceType=RETAIL&directions=&page={page}&complexNo={complex_id}&buildingNos=&areaNos=&type=list&order=rank"
)
TRADE_CODE_MAP = {"매매": "A1", "전세": "B1", "월세": "B2"}
DEFAULT_QUERY_SUFFIX = " 네이버 부동산 아파트"
DEFAULT_BACKOFFS = [1.2, 2.5, 4.0]
STOPWORDS = [
"네이버 부동산", "부동산", "시세", "매물", "가격", "가격대", "얼마", "비교", "정리", "요약", "알려줘", "찾아줘",
"보여줘", "조회", "검색", "추천", "빌라", "오피스텔", "실거래가", "단지", "찾기", "브리핑", "아파트좀",
"해줘", "해주세요", "알림", "감시", "체크", "체크해줘", "요청", "보고", "리포트", "채팅", "래퍼", "근처",
"살펴봐", "알아봐", "부탁", "정도", "위주", "기준", "기반", "정도면", "좀", "그리고", "중에서", "해석",
]
TRADE_STOPWORDS = ["매매", "전세", "월세"]
COMPARE_TOKENS = ["비교", "대비", "vs", "VS"]
LOCATION_SUFFIXES = ("특별시", "광역시", "시", "도", "군", "구", "동", "읍", "면", "리", "가")
SIMPLE_KOREAN_TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]{2,}")
RAW_COMPLEX_ID_RE = re.compile(r"(?:complex(?:\s*id|no)?|단지(?:\s*id)?|id)\s*[:=#-]?\s*(\d{3,10})", re.I)
WATCH_STATE_FILE = WORKSPACE / "skills" / "naver-real-estate-search" / "data" / "watch-rules.json"
CANDIDATE_CACHE_FILE = WORKSPACE / "skills" / "naver-real-estate-search" / "data" / "candidate-cache.json"
DEFAULT_CANDIDATE_SEED_FILE = WORKSPACE / "skills" / "naver-real-estate-search" / "references" / "candidate-seeds.json"
DEFAULT_SEED_INPUT_FILE = WORKSPACE / "skills" / "naver-real-estate-search" / "references" / "seoul-major-complexes.seed-input.json"
APT_SUFFIX_RE = re.compile(r"(?:아파트|맨션|타운하우스|주상복합|오피스텔|빌라)$")
AREA_RANGE_RE = re.compile(r"(\d{1,2})\s*평\s*[~-]\s*(\d{1,2})\s*평")
AREA_BAND_RE = re.compile(r"(\d{1,2})\s*평대")
AREA_SINGLE_RE = re.compile(r"(\d{1,2})\s*평(?:형)?")
NOISE_TOKENS = set(STOPWORDS + TRADE_STOPWORDS + [
"서울", "경기", "인천", "부산", "대구", "대전", "광주", "울산", "세종", "대한민국", "한국", "수도권"
])
REGION_ALIASES = {
"서울": ["서울", "서울시", "서울특별시"],
"부산": ["부산", "부산시", "부산광역시"],
"인천": ["인천", "인천시", "인천광역시"],
"대구": ["대구", "대구시", "대구광역시"],
"광주": ["광주", "광주시", "광주광역시"],
"대전": ["대전", "대전시", "대전광역시"],
"울산": ["울산", "울산시", "울산광역시"],
"세종": ["세종", "세종시", "세종특별자치시"],
"경기": ["경기", "경기도"],
"강원": ["강원", "강원도", "강원특별자치도"],
"충북": ["충북", "충청북도"],
"충남": ["충남", "충청남도"],
"전북": ["전북", "전라북도", "전북특별자치도"],
"전남": ["전남", "전라남도"],
"경북": ["경북", "경상북도"],
"경남": ["경남", "경상남도"],
"제주": ["제주", "제주도", "제주특별자치도"],
}
class SearchError(RuntimeError):
pass
@dataclass
class ParsedQuery:
raw_query: str
cleaned_query: str
trade_types: list[str]
min_pyeong: float | None
max_pyeong: float | None
compare_mode: bool
candidate_keywords: list[str]
location_hints: list[str]
raw_subjects: list[str]
direct_complex_ids: list[str]
RATE_LIMIT_STATE = {"active": False, "last_error": None}
def _record_rate_limit(message: str) -> None:
RATE_LIMIT_STATE["active"] = True
RATE_LIMIT_STATE["last_error"] = message
def _request_json(url: str, *, referer: str = "https://new.land.naver.com/", backoffs: list[float] | None = None) -> Any:
backoffs = DEFAULT_BACKOFFS if backoffs is None else backoffs
req = urllib.request.Request(url)
req.add_header("User-Agent", USER_AGENT)
req.add_header("Referer", referer)
req.add_header("Accept", "application/json, text/plain, */*")
attempts = len(backoffs) + 1
for attempt in range(attempts):
try:
with urllib.request.urlopen(req, timeout=20) as response:
RATE_LIMIT_STATE["active"] = False
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="ignore")
if exc.code == 429 and attempt < len(backoffs):
_record_rate_limit("429")
time.sleep(backoffs[attempt])
continue
if exc.code == 429:
_record_rate_limit("429")
raise SearchError(
"네이버 부동산 API가 429(요청 제한)를 반환했습니다. 단일 단지 URL/ID를 우선 사용하거나, 후보 단지부터 1~3개로 좁혀 다시 시도해 주세요."
)
raise SearchError(f"네이버 부동산 API 호출 실패: HTTP {exc.code} {body[:200]}")
except Exception as exc:
raise SearchError(f"네이버 부동산 API 호출 실패: {exc}") from exc
raise SearchError("네이버 부동산 API 호출 실패")
def _request_text(url: str) -> str:
req = urllib.request.Request(url)
req.add_header("User-Agent", USER_AGENT)
req.add_header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
try:
with urllib.request.urlopen(req, timeout=20) as response:
return response.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
if exc.code in {403, 429}:
raise SearchError(f"네이버 검색 HTML 후보 탐색이 차단되었습니다: HTTP {exc.code}")
raise
def _read_json_file(path: Path, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _write_json_file(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _read_candidate_cache() -> dict[str, Any]:
data = _read_json_file(CANDIDATE_CACHE_FILE, {"version": 3, "updated_at": 0, "entries": []})
if isinstance(data, list):
data = {"version": 3, "updated_at": 0, "entries": data}
data.setdefault("version", 3)
data.setdefault("updated_at", 0)
data.setdefault("entries", [])
return data
def _write_candidate_cache(data: dict[str, Any]) -> None:
data = dict(data)
data["updated_at"] = int(time.time())
_write_json_file(CANDIDATE_CACHE_FILE, data)
def normalize_keyword(text: str) -> str:
value = str(text or "").strip()
for token in STOPWORDS + TRADE_STOPWORDS:
value = value.replace(token, " ")
value = re.sub(r"\([^)]*\)", " ", value)
value = AREA_RANGE_RE.sub(" ", value)
value = AREA_BAND_RE.sub(" ", value)
value = AREA_SINGLE_RE.sub(" ", value)
value = re.sub(r"[?!.]", " ", value)
value = re.sub(r"\s+", " ", value).strip(" ,/")
return value
def normalize_complex_alias(text: str) -> str:
value = normalize_keyword(text)
value = APT_SUFFIX_RE.sub("", value.strip())
value = re.sub(r"\s+", "", value)
return value.strip()
def expand_region_aliases(value: str) -> list[str]:
out: list[str] = []
for canonical, aliases in REGION_ALIASES.items():
if value in aliases or canonical == value:
for alias in aliases + [canonical]:
if alias not in out:
out.append(alias)
return out
def expand_alias_variants(text: str) -> list[str]:
raw = str(text or "").strip()
normalized = normalize_complex_alias(raw)
spaced = normalize_keyword(raw)
variants: list[str] = []
for value in [raw, spaced, normalized]:
value = str(value or "").strip()
if value and value not in variants:
variants.append(value)
if normalized:
for suffix in ["아파트", "", "단지"]:
value = f"{normalized}{suffix}" if suffix else normalized
if value and value not in variants:
variants.append(value)
for token in list(variants):
for alias in expand_region_aliases(token):
if alias not in variants:
variants.append(alias)
return variants[:12]
def parse_trade_types(query: str) -> list[str]:
hits = [trade for trade in ["매매", "전세", "월세"] if trade in query]
return hits or ["전세"]
def parse_pyeong_range(query: str) -> tuple[float | None, float | None]:
m = AREA_BAND_RE.search(query)
if m:
base = float(m.group(1))
return max(0.0, base - 3), base + 3
m = AREA_RANGE_RE.search(query)
if m:
return float(m.group(1)), float(m.group(2))
single = AREA_SINGLE_RE.search(query)
if single:
base = float(single.group(1))
return max(0.0, base - 1), base + 1
return None, None
def _looks_like_location(token: str) -> bool:
return any(token.endswith(suffix) for suffix in LOCATION_SUFFIXES) or token in REGION_ALIASES
def extract_location_hints(query: str) -> list[str]:
seen: set[str] = set()
results: list[str] = []
spaced = normalize_keyword(query)
for token in re.split(r"\s+", spaced):
token = token.strip()
if len(token) < 2 or token in NOISE_TOKENS:
continue
if _looks_like_location(token):
for alias in [token, *expand_region_aliases(token)]:
if alias not in seen:
results.append(alias)
seen.add(alias)
for token in SIMPLE_KOREAN_TOKEN_RE.findall(spaced):
if token in NOISE_TOKENS or len(token) < 2:
continue
if token.endswith("동") or token.endswith("구") or token.endswith("시") or token in {"잠실", "대치", "반포", "신월", "목동", "상계", "중계"}:
if token not in seen:
results.append(token)
seen.add(token)
return results[:8]
def split_query_subjects(query: str) -> list[str]:
cleaned = normalize_keyword(query)
if not cleaned:
return []
parts = [part.strip() for part in re.split(r"\s*(?:,|/|\||vs\.?|대비|와|과|랑|및)\s*", cleaned) if part.strip()]
return parts[:6]
def split_candidate_keywords(query: str) -> list[str]:
uniq: list[str] = []
seen: set[str] = set()
global_locations = extract_location_hints(query)
removable_locations = list(global_locations)
for location in list(global_locations):
removable_locations.extend(expand_region_aliases(location))
removable_locations.extend(["서울", "서울시", "서울특별시", "경기", "경기도"])
for part in split_query_subjects(query):
value = part.strip()
for location in removable_locations:
value = value.replace(location, " ")
value = normalize_keyword(value)
for variant in expand_alias_variants(value):
norm = normalize_complex_alias(variant)
if norm and norm not in seen:
uniq.append(variant)
seen.add(norm)
return uniq[:10]
def extract_direct_complex_ids(text: str) -> list[str]:
direct_complex_ids: list[str] = []
seen: set[str] = set()
for _, cid in NaverURLParser.extract_from_text(text or ""):
if cid and cid not in seen:
direct_complex_ids.append(cid)
seen.add(cid)
for match in RAW_COMPLEX_ID_RE.finditer(text or ""):
cid = match.group(1)
if cid and cid not in seen:
direct_complex_ids.append(cid)
seen.add(cid)
text_clean = str(text or "").strip()
if re.fullmatch(r"\d{3,10}", text_clean) and text_clean not in seen:
direct_complex_ids.append(text_clean)
return direct_complex_ids
def build_direct_lookup_payload(query: str | None, complex_id: str | None, url: str | None) -> dict[str, Any]:
parts = [str(x or "") for x in [query, complex_id, url] if str(x or "").strip()]
direct_ids = extract_direct_complex_ids("\n".join(parts))
chosen = str(complex_id or "").strip() or (NaverURLParser.extract_complex_id(url) if url else None) or (direct_ids[0] if direct_ids else None)
return {
"query": query,
"explicit_complex_id": complex_id,
"explicit_url": url,
"detected_complex_ids": direct_ids,
"selected_complex_id": chosen,
"canonical_complex_url": f"https://new.land.naver.com/complexes/{chosen}" if chosen else None,
}
def parse_natural_query(query: str) -> ParsedQuery:
min_pyeong, max_pyeong = parse_pyeong_range(query)
subject_parts = split_query_subjects(query)
location_hints = extract_location_hints(query)
candidate_keywords = split_candidate_keywords(query)
compare_mode = any(token in query for token in COMPARE_TOKENS) or len(subject_parts) >= 2
cleaned_query = normalize_keyword(query)
direct_complex_ids = extract_direct_complex_ids(query)
return ParsedQuery(
raw_query=query,
cleaned_query=cleaned_query,
trade_types=parse_trade_types(query),
min_pyeong=min_pyeong,
max_pyeong=max_pyeong,
compare_mode=compare_mode,
candidate_keywords=candidate_keywords or ([cleaned_query] if cleaned_query else []),
location_hints=location_hints,
raw_subjects=subject_parts,
direct_complex_ids=direct_complex_ids,
)
def extract_complex_candidates_from_web(query: str, limit: int = 5) -> list[dict[str, str]]:
html = _request_text(SEARCH_URL.format(query=urllib.parse.quote(query + DEFAULT_QUERY_SUFFIX)))
ids: list[str] = []
seen: set[str] = set()
patterns = [
r"https://new\.land\.naver\.com/complexes/(\d+)",
r"https://new\.land\.naver\.com/houses/(\d+)",
r"complexNo=(\d+)",
]
for pattern in patterns:
for match in re.finditer(pattern, html):
cid = match.group(1)
if cid not in seen:
ids.append(cid)
seen.add(cid)
if len(ids) >= limit:
break
if len(ids) >= limit:
break
return [{"complex_id": cid, "source": "web-search", "query": query} for cid in ids]
def fetch_complex_info(complex_id: str) -> dict[str, Any]:
payload = _request_json(COMPLEX_DETAIL_URL.format(complex_id=complex_id))
info = payload.get("complexDetail") or payload
address = " ".join(filter(None, [str(info.get("cortarAddress") or "").strip(), str(info.get("roadAddressPrefix") or "").strip()])).strip()
result = {
"complex_id": complex_id,
"name": str(info.get("complexName") or info.get("complexNm") or f"단지_{complex_id}"),
"address": address,
"household_count": info.get("totalHouseHoldCount") or info.get("houseHoldCount"),
"complex_url": f"https://new.land.naver.com/complexes/{complex_id}",
}
remember_candidate(result)
return result
def _tokenize_for_match(text: str) -> list[str]:
normalized = normalize_keyword(text)
return [token for token in re.split(r"\s+", normalized) if token and len(token) >= 2]
def _score_candidate(info: dict[str, Any], keyword: str, parsed: ParsedQuery | None) -> int:
score = 0
name = str(info.get("name") or "")
address = str(info.get("address") or "")
haystack = f"{name} {address}"
normalized_name = normalize_complex_alias(name)
normalized_keyword = normalize_complex_alias(keyword)
query_alias = normalize_complex_alias(parsed.cleaned_query if parsed else keyword)
keyword_tokens = _tokenize_for_match(keyword)
query_tokens = _tokenize_for_match(parsed.cleaned_query if parsed else keyword)
if normalized_keyword and normalized_keyword == normalized_name:
score += 260
elif normalized_keyword and normalized_keyword in normalized_name:
score += 130
if query_alias and query_alias == normalized_name:
score += 180
elif query_alias and query_alias in normalized_name:
score += 85
for token in keyword_tokens:
if token == name:
score += 120
elif token in name:
score += 55
elif token in address:
score += 22
elif token in haystack:
score += 10
for token in query_tokens:
if token in name:
score += 14
elif token in address:
score += 8
for alias in info.get("aliases") or []:
alias_norm = normalize_complex_alias(alias)
if query_alias and alias_norm == query_alias:
score += 170
elif query_alias and query_alias in alias_norm:
score += 70
if normalized_keyword and alias_norm == normalized_keyword:
score += 140
if parsed:
for location in parsed.location_hints:
if location and location in address:
score += 32
elif location and location in name:
score += 16
if parsed.raw_subjects:
own = normalize_complex_alias(parsed.raw_subjects[0])
if own and own == normalized_name:
score += 50
try:
household_count = int(info.get("household_count") or 0)
except Exception:
household_count = 0
if household_count >= 300:
score += 4
if household_count >= 800:
score += 4
if household_count >= 2000:
score += 3
return score
def remember_candidate(info: dict[str, Any], aliases: list[str] | None = None) -> dict[str, Any]:
complex_id = str(info.get("complex_id") or "").strip()
if not complex_id:
return {}
cache = _read_candidate_cache()
entries = cache.get("entries") or []
by_id = {str(row.get("complex_id") or ""): dict(row) for row in entries if row.get("complex_id")}
row = by_id.get(complex_id, {})
merged_aliases: list[str] = []
for value in [row.get("name"), *(row.get("aliases") or []), info.get("name"), *(aliases or [])]:
for variant in expand_alias_variants(str(value or "")):
if variant and variant not in merged_aliases:
merged_aliases.append(variant)
note_items = [str(x).strip() for x in [row.get("note"), info.get("note")] if str(x or "").strip()]
row.update(
{
"complex_id": complex_id,
"name": str(info.get("name") or row.get("name") or f"단지_{complex_id}"),
"address": str(info.get("address") or row.get("address") or ""),
"household_count": info.get("household_count") or row.get("household_count"),
"aliases": merged_aliases[:30],
"updated_at": int(time.time()),
"source": info.get("source") or row.get("source") or "runtime",
"learned_from": info.get("learned_from") or row.get("learned_from"),
"note": note_items[-1] if note_items else None,
}
)
by_id[complex_id] = row
rows = sorted(by_id.values(), key=lambda x: (-int(x.get("updated_at") or 0), str(x.get("name") or "")))
cache["entries"] = rows[:500]
_write_candidate_cache(cache)
return row
def list_candidate_cache(*, limit: int = 50, keyword: str | None = None) -> list[dict[str, Any]]:
rows = _read_candidate_cache().get("entries") or []
if keyword:
term = normalize_complex_alias(keyword)
rows = [
row for row in rows
if term in normalize_complex_alias(row.get("name") or "")
or any(term in normalize_complex_alias(alias) for alias in (row.get("aliases") or []))
or term in normalize_complex_alias(row.get("address") or "")
]
rows = sorted(rows, key=lambda row: (-int(row.get("updated_at") or 0), str(row.get("name") or "")))
return rows[: max(1, limit)]
def seed_candidate_cache(entries: list[dict[str, Any]], *, source: str = "seed-file") -> list[dict[str, Any]]:
saved: list[dict[str, Any]] = []
for item in entries:
complex_id = str(item.get("complex_id") or "").strip()
if not complex_id:
continue
info = {
"complex_id": complex_id,
"name": item.get("name"),
"address": item.get("address"),
"household_count": item.get("household_count"),
"source": item.get("source") or source,
"learned_from": item.get("learned_from") or source,
"note": item.get("note"),
}
saved.append(remember_candidate(info, aliases=[str(x) for x in (item.get("aliases") or []) if str(x).strip()]))
return [row for row in saved if row]
def seed_candidate_from_file(path: str | Path | None = None) -> dict[str, Any]:
target = Path(path) if path else DEFAULT_CANDIDATE_SEED_FILE
payload = _read_json_file(target, {"entries": []})
entries = payload.get("entries") if isinstance(payload, dict) else payload
saved = seed_candidate_cache(entries or [], source=f"seed-file:{target.name}")
return {"path": str(target), "saved_count": len(saved), "saved": saved}
def _load_reference_seed_rows() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
target = _read_json_file(DEFAULT_CANDIDATE_SEED_FILE, {"entries": [], "manual_review_queue": []})
if isinstance(target, dict):
for row in target.get("entries") or []:
rows.append({**row, "reference_kind": "production-entry"})
for row in target.get("manual_review_queue") or []:
rows.append({**row, "reference_kind": "manual-review"})
seed_input = _read_json_file(DEFAULT_SEED_INPUT_FILE, {"seeds": []})
if isinstance(seed_input, dict):
for row in seed_input.get("seeds") or []:
rows.append({**row, "reference_kind": "seed-input"})
return rows
def search_reference_candidates(query: str, *, candidate_limit: int = 5, parsed: ParsedQuery | None = None) -> list[dict[str, Any]]:
parsed = parsed or parse_natural_query(query)
terms: list[str] = []
for value in [query, parsed.cleaned_query, *parsed.candidate_keywords, *parsed.raw_subjects]:
for variant in expand_alias_variants(value):
norm = normalize_complex_alias(variant)
if norm and norm not in terms:
terms.append(norm)
if not terms:
return []
scored: list[dict[str, Any]] = []
seen_keys: set[str] = set()
for row in _load_reference_seed_rows():
name = str(row.get("name") or "").strip()
if not name:
continue
aliases = [str(x) for x in (row.get("aliases") or []) if str(x).strip()]
bag = [name, *aliases, row.get("district") or "", row.get("neighborhood") or "", row.get("address") or ""]
bag_norms = [normalize_complex_alias(x) for x in bag if str(x or "").strip()]
local_score = 0
for term in terms:
if term in bag_norms:
local_score = max(local_score, 240)
elif any(term and term in norm for norm in bag_norms):
local_score = max(local_score, 170)
if local_score <= 0:
continue
key = str(row.get("complex_id") or f"hint:{normalize_complex_alias(name)}:{row.get('reference_kind')}")
if key in seen_keys:
continue
seen_keys.add(key)
reference_score = local_score + _score_candidate(
{
"name": name,
"address": row.get("address") or " ".join(filter(None, [row.get("district"), row.get("neighborhood")])),
"household_count": row.get("household_count"),
"aliases": aliases,
},
query,
parsed,
)
cid = str(row.get("complex_id") or "")
scored.append(
{
"complex_id": cid,
"name": name,
"address": row.get("address") or " ".join(filter(None, [row.get("district"), row.get("neighborhood")])),
"household_count": row.get("household_count"),
"aliases": aliases[:20],
"match_score": reference_score,
"source_term": row.get("reference_kind") or "reference-seed",
"source": "reference-seed",
"reference_kind": row.get("reference_kind"),
"review_status": row.get("review_status"),
"verification_status": row.get("verification_status"),
"next_action": row.get("next_action"),
"note": row.get("note") or row.get("reason"),
"complex_url": f"https://new.land.naver.com/complexes/{cid}" if cid else None,
}
)
scored.sort(key=lambda row: (-int(row.get("match_score") or 0), str(row.get("name") or ""), str(row.get("complex_id") or "")))
return scored[:candidate_limit]
def search_cached_candidates(query: str, *, candidate_limit: int = 5, parsed: ParsedQuery | None = None) -> list[dict[str, Any]]:
parsed = parsed or parse_natural_query(query)
cache = _read_candidate_cache().get("entries") or []
if not cache:
return []
terms: list[str] = []
for value in [query, parsed.cleaned_query, *parsed.candidate_keywords, *parsed.location_hints]:
for variant in expand_alias_variants(value):
norm = normalize_complex_alias(variant)
if norm and norm not in terms:
terms.append(norm)
scored: list[dict[str, Any]] = []
for row in cache:
aliases = row.get("aliases") or []
alias_norms = [normalize_complex_alias(x) for x in aliases if x]
alias_norms = [x for x in alias_norms if x]
local_score = 0
for term in terms:
if term in alias_norms:
local_score = max(local_score, 300)
elif any(term in alias for alias in alias_norms):
local_score = max(local_score, 210)
if local_score <= 0:
continue
score = local_score + _score_candidate({**row, "aliases": aliases}, query, parsed)
cid = str(row.get("complex_id") or "")
scored.append({**row, "aliases": aliases, "match_score": score, "source_term": "candidate-cache", "source": "candidate-cache", "complex_url": f"https://new.land.naver.com/complexes/{cid}" if cid else None})
scored.sort(key=lambda row: (-int(row.get("match_score") or 0), str(row.get("name") or ""), str(row.get("complex_id") or "")))
return scored[:candidate_limit]
def build_search_terms(parsed: ParsedQuery) -> list[str]:
terms: list[str] = []
raw_values = [*parsed.raw_subjects, parsed.cleaned_query, *parsed.candidate_keywords, *parsed.location_hints]
for value in raw_values:
for variant in expand_alias_variants(value):
variant = str(variant or "").strip()
if variant and variant not in terms:
terms.append(variant)
if parsed.location_hints and parsed.candidate_keywords:
for loc in parsed.location_hints[:3]:
for kw in parsed.candidate_keywords[:3]:
combo = f"{loc} {kw}".strip()
if combo and combo not in terms:
terms.append(combo)
return terms[:16]
def resolve_complex_ids(query: str | None, complex_id: str | None, url: str | None, *, candidate_limit: int = 5) -> list[str]:
results: list[str] = []
seen: set[str] = set()
def _push(value: str | None):
if value and value not in seen:
results.append(value)
seen.add(value)
_push(complex_id)
if url:
_push(NaverURLParser.extract_complex_id(url))
if query:
parsed = parse_natural_query(query)
for cid in parsed.direct_complex_ids:
_push(cid)
if not results:
for item in search_cached_candidates(query, candidate_limit=max(candidate_limit * 2, 6), parsed=parsed):
_push(item.get("complex_id"))
if len(results) >= candidate_limit:
break
if not results:
for item in search_complex_candidates(query, candidate_limit=max(candidate_limit * 2, 6)):
_push(item.get("complex_id"))
if len(results) >= candidate_limit:
break
return results[:candidate_limit]
def search_complex_candidates(query: str, *, candidate_limit: int = 5) -> list[dict[str, Any]]:
parsed = parse_natural_query(query)
merged: dict[str, dict[str, Any]] = {}
def _merge_item(item: dict[str, Any]) -> None:
key = str(item.get("complex_id") or "").strip() or f"hint:{normalize_complex_alias(item.get('name') or '')}:{item.get('source') or item.get('reference_kind') or 'unknown'}"
prev = merged.get(key)
if not prev or int(item.get("match_score") or 0) >= int(prev.get("match_score") or 0):
merged[key] = dict(item)
for item in search_cached_candidates(query, candidate_limit=max(candidate_limit * 2, 8), parsed=parsed):
_merge_item(item)
for item in search_reference_candidates(query, candidate_limit=max(candidate_limit * 2, 8), parsed=parsed):
_merge_item(item)
raw_ids: list[tuple[str, str]] = []
seen_ids: set[str] = {str(row.get("complex_id") or "") for row in merged.values() if str(row.get("complex_id") or "").strip()}
for term in build_search_terms(parsed):
try:
web_items = extract_complex_candidates_from_web(term, limit=max(candidate_limit * 2, 8))
except SearchError:
web_items = []
for item in web_items:
cid = str(item.get("complex_id") or "").strip()
if cid and cid not in seen_ids:
raw_ids.append((cid, term))
seen_ids.add(cid)
for cid, source_term in raw_ids:
try:
info = fetch_complex_info(cid)
except Exception as exc:
info = {"complex_id": cid, "name": f"단지_{cid}", "address": "", "error": str(exc), "source": "web-search-fallback"}
score = _score_candidate(info, source_term, parsed)
merged[cid] = {**info, "match_score": score, "source_term": source_term, "source": info.get("source") or "web-search", "complex_url": f"https://new.land.naver.com/complexes/{cid}"}
scored = list(merged.values())
scored.sort(key=lambda row: (-int(row.get("match_score") or 0), str(row.get("name") or ""), str(row.get("complex_id") or "")))
return scored[:candidate_limit]
def fetch_articles(complex_id: str, trade_types: list[str], pages: int = 1) -> list[dict[str, Any]]:
trade_codes = ":".join(TRADE_CODE_MAP[t] for t in trade_types if t in TRADE_CODE_MAP)
if not trade_codes:
trade_codes = "A1:B1:B2"
complex_name = NaverURLParser.fetch_complex_name(complex_id)
items: list[dict[str, Any]] = []
for page in range(1, pages + 1):
payload = _request_json(COMPLEX_ARTICLE_URL.format(complex_id=complex_id, trade_codes=trade_codes, page=page))
article_list = payload.get("articleList") or payload.get("list") or []
if not article_list:
break
for article in article_list:
trade_type = str(article.get("tradeTypeName") or article.get("tradTpNm") or "").strip()
normalized = normalize_article_payload(article, complex_name, complex_id, requested_trade_type=trade_type)
normalized["매물URL"] = get_article_url(complex_id, normalized.get("매물ID", ""), normalized.get("자산유형", "APT"))
normalized["complex_id"] = complex_id
normalized["article_key"] = f"{complex_id}:{normalized.get('매물ID', '')}"
items.append(normalized)
return items
def filter_items(items: list[dict[str, Any]], min_pyeong: float | None, max_pyeong: float | None, limit: int) -> list[dict[str, Any]]:
filtered = []
for item in items:
area = float(item.get("면적(평)") or 0)
if min_pyeong is not None and area < min_pyeong:
continue
if max_pyeong is not None and area > max_pyeong:
continue
filtered.append(item)
filtered.sort(key=lambda row: (row.get("거래유형", ""), PriceConverter.to_int(row.get("매매가") or row.get("보증금") or "0"), row.get("면적(평)", 0)))
return filtered[:limit]
def _bucket_area(value: float | int | None) -> str:
try:
area = float(value or 0)
except Exception:
area = 0.0
if area <= 0:
return "미상"
base = int(round(area))
return f"{base}평"
def _extract_price_int(row: dict[str, Any]) -> int:
return PriceConverter.to_int(row.get("매매가") or row.get("보증금") or "0")
def build_market_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
grouped: dict[str, list[dict[str, Any]]] = {}
for item in items:
grouped.setdefault(item.get("거래유형", "기타"), []).append(item)
summary: dict[str, Any] = {}
for trade_type, rows in grouped.items():
prices = [_extract_price_int(r) for r in rows]
prices = [p for p in prices if p > 0]
area_groups: dict[str, list[dict[str, Any]]] = {}
for row in rows:
area_groups.setdefault(_bucket_area(row.get("면적(평)")), []).append(row)
comparable_area_rows = []
for area_key, area_items in area_groups.items():
area_prices = [_extract_price_int(r) for r in area_items]
area_prices = [p for p in area_prices if p > 0]
comparable_area_rows.append({
"area_key": area_key,
"count": len(area_items),
"min_price": min(area_prices) if area_prices else None,
"avg_price": int(sum(area_prices) / len(area_prices)) if area_prices else None,
"max_price": max(area_prices) if area_prices else None,
"sample_items": area_items[:2],
})
comparable_area_rows.sort(key=lambda row: (-int(row.get("count") or 0), str(row.get("area_key") or "")))
summary[trade_type] = {
"count": len(rows),
"min_price": min(prices) if prices else None,
"max_price": max(prices) if prices else None,
"avg_price": int(sum(prices) / len(prices)) if prices else None,
"median_price": int(statistics.median(prices)) if prices else None,
"sample_items": rows[:3],
"area_summary": comparable_area_rows[:5],
}
return summary
def summarize(items: list[dict[str, Any]]) -> str:
if not items:
return "조건에 맞는 매물을 찾지 못했습니다."
lines = []
grouped: dict[str, list[dict[str, Any]]] = {}
for item in items:
grouped.setdefault(item.get("거래유형", "기타"), []).append(item)
for trade_type, rows in grouped.items():
lines.append(f"[{trade_type}] {len(rows)}건")
prices = [_extract_price_int(r) for r in rows]
prices = [p for p in prices if p > 0]
if prices:
lines.append(f"- 가격 범위: {PriceConverter.to_string(min(prices))} ~ {PriceConverter.to_string(max(prices))}")
lines.append(f"- 평균/중앙값: {PriceConverter.to_string(int(sum(prices)/len(prices)))} / {PriceConverter.to_string(int(statistics.median(prices)))}")
area_summary = build_market_summary(rows).get(trade_type, {}).get("area_summary", [])
if area_summary:
head = area_summary[0]
lines.append(f"- 대표 동일 평형: {head.get('area_key')} {head.get('count')}건 | 평균 {PriceConverter.to_string(head.get('avg_price')) if head.get('avg_price') else '-'}")
for row in rows[:5]:
price = row.get("매매가") or row.get("보증금") or "-"
if trade_type == "월세" and row.get("월세"):
price = f"{price}/{row.get('월세')}"
lines.append(f"- {row.get('단지명')} | {price} | {row.get('면적(평)', 0)}평 | {row.get('층/방향', '-') or '-'} | {row.get('매물URL', '')}")
return "\n".join(lines)
def _brief_price(value: int | None) -> str:
return PriceConverter.to_string(value) if value else "-"
def build_compare_insights(results: list[dict[str, Any]]) -> dict[str, Any]:
trade_groups: dict[str, list[dict[str, Any]]] = {}
area_match_groups: dict[str, dict[str, list[dict[str, Any]]]] = {}
for result in results:
info = result.get("complex_info", {})
name = info.get("name", result.get("complex_id"))
for trade_type, meta in (result.get("market_summary") or {}).items():
trade_groups.setdefault(trade_type, []).append({"name": name, "avg_price": meta.get("avg_price"), "min_price": meta.get("min_price"), "count": meta.get("count", 0)})
for area_row in meta.get("area_summary") or []:
area_match_groups.setdefault(trade_type, {}).setdefault(area_row.get("area_key") or "미상", []).append({"name": name, **area_row})
insights: dict[str, Any] = {"trade": {}, "same_area": {}}
for trade_type, rows in trade_groups.items():
valid = [row for row in rows if row.get("avg_price")]
if len(valid) >= 2:
valid.sort(key=lambda x: x["avg_price"])
insights["trade"][trade_type] = {
"cheapest": valid[0],
"most_expensive": valid[-1],
"gap": valid[-1]["avg_price"] - valid[0]["avg_price"],
}
for trade_type, area_rows in area_match_groups.items():
for area_key, rows in area_rows.items():
valid = [row for row in rows if row.get("avg_price")]
if len(valid) >= 2:
valid.sort(key=lambda x: x["avg_price"])
insights["same_area"].setdefault(trade_type, []).append({
"area_key": area_key,
"cheapest": valid[0],
"most_expensive": valid[-1],
"gap": valid[-1]["avg_price"] - valid[0]["avg_price"],
"participant_count": len(valid),
})
for trade_type in list(insights["same_area"].keys()):
insights["same_area"][trade_type].sort(key=lambda row: (-row.get("participant_count", 0), row.get("gap", 0)))
return insights
def summarize_comparison(results: list[dict[str, Any]]) -> str:
if not results:
return "비교할 단지 결과가 없습니다."
lines = ["[단지 비교 브리핑]"]
insights = build_compare_insights(results)
for result in results:
info = result.get("complex_info", {})
name = info.get("name", result.get("complex_id"))
address = info.get("address") or "-"
lines.append(f"- {name}")
lines.append(f" · 주소: {address}")
for trade_type, meta in result.get("market_summary", {}).items():
lines.append(f" · {trade_type}: {meta.get('count', 0)}건 | 최저 {_brief_price(meta.get('min_price'))} | 평균 {_brief_price(meta.get('avg_price'))} | 중앙값 {_brief_price(meta.get('median_price'))} | 최고 {_brief_price(meta.get('max_price'))}")
area_rows = meta.get("area_summary") or []
if area_rows:
head = area_rows[0]
lines.append(f" - 대표 동일 평형: {head.get('area_key')} {head.get('count')}건 | 평균 {_brief_price(head.get('avg_price'))}")
for trade_type, meta in (insights.get("trade") or {}).items():
lines.append(f"- 한줄 해석 ({trade_type}): {meta['cheapest']['name']} 쪽 평균이 가장 낮고 {meta['most_expensive']['name']} 쪽이 가장 높습니다. 전체 평균 격차는 {_brief_price(meta['gap'])} 정도입니다.")
for trade_type, area_rows in (insights.get("same_area") or {}).items():
if area_rows:
head = area_rows[0]
lines.append(f"- 동일 평형 비교 ({trade_type} {head['area_key']}): {head['cheapest']['name']} 쪽이 더 낮고 {head['most_expensive']['name']} 쪽이 더 높습니다. 같은 평형대 기준 격차는 {_brief_price(head['gap'])} 정도입니다.")
return "\n".join(lines)
def run_query(*, query: str | None, complex_id: str | None, url: str | None, trade_types: list[str] | None, pages: int, limit: int, candidate_limit: int, min_pyeong: float | None, max_pyeong: float | None, compare: bool) -> dict[str, Any]:
parsed = parse_natural_query(query or "") if query else None
trade_types = list(trade_types or [])
if not trade_types:
trade_types = parsed.trade_types if parsed else ["전세"]
min_pyeong = min_pyeong if min_pyeong is not None else (parsed.min_pyeong if parsed else None)
max_pyeong = max_pyeong if max_pyeong is not None else (parsed.max_pyeong if parsed else None)
complex_ids = resolve_complex_ids(query, complex_id, url, candidate_limit=max(1, candidate_limit))
if not complex_ids:
raise SearchError("단지 ID를 찾지 못했습니다. 더 구체적인 단지명/지역명을 주거나 단지 URL/ID를 직접 넣어 주세요.")
compare_mode = compare or bool(parsed and parsed.compare_mode and len(complex_ids) >= 2)
target_ids = complex_ids[: max(1, candidate_limit if compare_mode else 1)]
meta = {"rate_limited": bool(RATE_LIMIT_STATE.get("active")), "rate_limit_message": RATE_LIMIT_STATE.get("last_error")}
if compare_mode:
results = []
for cid in target_ids:
items = fetch_articles(cid, trade_types, pages=max(1, pages))
items = filter_items(items, min_pyeong, max_pyeong, max(1, limit))
results.append({
"complex_id": cid,
"complex_info": fetch_complex_info(cid),
"trade_types": trade_types,
"count": len(items),
"market_summary": build_market_summary(items),
"items": items[:5],
})
compare_insights = build_compare_insights(results)
return {"query": query, "parsed": asdict(parsed) if parsed else None, "compare_mode": True, "results": results, "compare_insights": compare_insights, "meta": meta}
selected_complex_id = target_ids[0]
items = fetch_articles(selected_complex_id, trade_types, pages=max(1, pages))
items = filter_items(items, min_pyeong, max_pyeong, max(1, limit))
return {
"query": query,
"parsed": asdict(parsed) if parsed else None,
"selected_complex_id": selected_complex_id,
"complex_info": fetch_complex_info(selected_complex_id),
"trade_types": trade_types,
"count": len(items),
"market_summary": build_market_summary(items),
"items": items,
"meta": meta,
}
def run_self_test() -> int:
if not UPSTREAM.exists() or UPSTREAM_IMPORT_ERROR is not None:
print(
"SKIP: upstream clone(tmp/naverland-scrapper) 미구성 상태라 upstream 의존 self-test는 건너뜁니다.",
file=sys.stderr,
)
return 0
sample = {
"articleNo": "123456789",
"tradeTypeName": "전세",
"dealOrWarrantPrc": "12억 5,000",
"area1": 84.98,
"floorInfo": "12/25",
"direction": "남향",
"articleFeatureDesc": "역세권, 학군우수",
"realEstateTypeCode": "APT",
}
row = normalize_article_payload(sample, "테스트아파트", "99999", requested_trade_type="전세")
assert row["단지명"] == "테스트아파트"
assert row["거래유형"] == "전세"
assert row["보증금"] == "12억 5,000"
assert row["면적(평)"] > 0
parsed = parse_natural_query("잠실 리센츠랑 엘스 전세 비교 30평대")
assert parsed.compare_mode is True
assert "전세" in parsed.trade_types
assert parsed.min_pyeong is not None and parsed.max_pyeong is not None
assert len(parsed.candidate_keywords) >= 2
assert "잠실" in parsed.location_hints
tricky = parse_natural_query("서울 양천구 신월동 신월시영아파트 전세")
assert tricky.cleaned_query.startswith("서울 양천구 신월동 신월시영")
assert any(normalize_complex_alias(x) == "신월시영" for x in tricky.candidate_keywords)
assert normalize_complex_alias("신월시영아파트") == "신월시영"
direct = build_direct_lookup_payload("complex 1147 리센츠", None, None)
assert direct["selected_complex_id"] == "1147"
assert direct["canonical_complex_url"].endswith("/1147")
backup_cache = _read_candidate_cache()
remember_candidate({"complex_id": "777", "name": "신월시영아파트", "address": "서울시 양천구 신월동", "household_count": 2256}, aliases=["신월시영", "양천 신월시영"])
saved = seed_candidate_cache([
{"complex_id": "778", "name": "리센츠", "address": "서울시 송파구 잠실동", "aliases": ["잠실 리센츠", "잠실리센츠"], "household_count": 5563}
], source="self-test")
cached = search_cached_candidates("신월시영아파트", candidate_limit=3)
assert cached and cached[0]["complex_id"] == "777"
assert saved and any(row["complex_id"] == "778" for row in saved)
assert list_candidate_cache(limit=5, keyword="리센츠")
ref_candidates = search_reference_candidates("서울 양천구 신월동 신월시영아파트 전세", candidate_limit=3)
assert ref_candidates and normalize_complex_alias(ref_candidates[0]["name"]) == "신월시영"
_write_candidate_cache(backup_cache)
score = _score_candidate({"name": "잠실리센츠", "address": "서울시 송파구 잠실동", "household_count": 5563}, "리센츠", parsed)
assert score > 0
summary = build_market_summary([
{"거래유형": "전세", "보증금": "10억", "면적(평)": 33.0},
{"거래유형": "전세", "보증금": "11억", "면적(평)": 33.2},
{"거래유형": "전세", "보증금": "13억", "면적(평)": 44.0},
])
assert summary["전세"]["area_summary"][0]["area_key"] in {"33평", "44평"}
compare_insights = build_compare_insights([
{"complex_info": {"name": "A"}, "market_summary": {"전세": {"avg_price": 100, "count": 2, "area_summary": [{"area_key": "33평", "avg_price": 100, "count": 2}]}}},
{"complex_info": {"name": "B"}, "market_summary": {"전세": {"avg_price": 130, "count": 2, "area_summary": [{"area_key": "33평", "avg_price": 130, "count": 2}]}}},
])
assert compare_insights["trade"]["전세"]["gap"] == 30
print("SELF_TEST_OK")
print(json.dumps({"parsed_query": asdict(parsed), "tricky_query": asdict(tricky), "cached_top": cached[:1], "sample_score": score, "compare_insights": compare_insights}, ensure_ascii=False, indent=2)[:3000])
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="네이버 부동산 매물 검색/비교 래퍼")
p.add_argument("--query", help="자연어 또는 지역/단지 키워드. 예: 잠실 리센츠 전세 30평대, 대치 은마와 래미안대치팰리스 비교")
p.add_argument("--complex-id", help="네이버 부동산 단지 ID")
p.add_argument("--url", help="네이버 부동산 단지/매물 URL")
p.add_argument("--trade-types", default="", help="쉼표 구분 거래 유형. 비우면 query에서 추론하고, 없으면 전세")
p.add_argument("--pages", type=int, default=1)
p.add_argument("--limit", type=int, default=15)
p.add_argument("--candidate-limit", type=int, default=3)
p.add_argument("--min-pyeong", type=float)
p.add_argument("--max-pyeong", type=float)
p.add_argument("--list-candidates", action="store_true", help="매물 조회 대신 단지 후보만 출력")
p.add_argument("--compare", action="store_true", help="후보 상위 단지들을 비교 모드로 조회")
p.add_argument("--parse-only", action="store_true", help="자연어 파싱 결과만 출력")
p.add_argument("--show-cache", action="store_true", help="candidate cache를 조회")
p.add_argument("--resolve-direct", action="store_true", help="query/url/complex-id에서 direct ID와 canonical URL을 정리")
p.add_argument("--lookup-complex", action="store_true", help="매물 조회 대신 단지 기본 정보만 direct lookup")
p.add_argument("--seed-candidate-file", nargs="?", const="", help="candidate-cache에 seed할 JSON 파일 경로. 비우면 references/candidate-seeds.json")
p.add_argument("--seed-candidate", action="store_true", help="단일 후보를 candidate-cache에 직접 저장")
p.add_argument("--candidate-name", help="seed candidate 이름")
p.add_argument("--candidate-address", help="seed candidate 주소")
p.add_argument("--candidate-households", type=int, help="seed candidate 세대수")
p.add_argument("--candidate-aliases", default="", help="쉼표 구분 alias 목록")
p.add_argument("--candidate-note", help="seed candidate 메모")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
return p
def main() -> int:
args = build_parser().parse_args()
if args.self_test:
return run_self_test()
parsed = parse_natural_query(args.query or "") if args.query else None
if args.parse_only:
print(json.dumps(asdict(parsed) if parsed else {}, ensure_ascii=False, indent=2))
return 0
if args.seed_candidate_file is not None:
result = seed_candidate_from_file(args.seed_candidate_file or None)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if args.resolve_direct:
print(json.dumps(build_direct_lookup_payload(args.query, args.complex_id, args.url), ensure_ascii=False, indent=2))
return 0
if args.seed_candidate:
if not args.complex_id:
raise SystemExit("--seed-candidate 는 --complex-id 와 함께 사용하세요.")
aliases = [token.strip() for token in str(args.candidate_aliases or "").split(",") if token.strip()]
saved = remember_candidate(
{
"complex_id": args.complex_id,
"name": args.candidate_name,
"address": args.candidate_address,
"household_count": args.candidate_households,
"source": "manual-seed",
"learned_from": args.query or args.url or "manual-seed",
"note": args.candidate_note,
},
aliases=aliases,
)
print(json.dumps({"saved": True, "candidate": saved}, ensure_ascii=False, indent=2))
return 0
if args.show_cache:
rows = list_candidate_cache(limit=max(1, args.limit), keyword=args.query)
print(json.dumps({"count": len(rows), "entries": rows}, ensure_ascii=False, indent=2))
return 0
if args.lookup_complex:
payload = build_direct_lookup_payload(args.query, args.complex_id, args.url)
selected = payload.get("selected_complex_id")
if not selected:
raise SystemExit("direct lookup용 complex ID를 찾지 못했습니다. --complex-id / --url / --query 중 하나에 direct 단서를 넣어 주세요.")
payload["complex_info"] = fetch_complex_info(str(selected))
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
trade_types = [token.strip() for token in str(args.trade_types).split(",") if token.strip()]
if args.list_candidates:
if not args.query:
raise SystemExit("--list-candidates 는 --query 와 함께 사용하세요.")
candidates = search_complex_candidates(args.query, candidate_limit=max(1, args.candidate_limit))
print(json.dumps({"query": args.query, "parsed": asdict(parsed), "candidates": candidates, "meta": {"rate_limited": RATE_LIMIT_STATE.get('active'), "rate_limit_message": RATE_LIMIT_STATE.get('last_error')}}, ensure_ascii=False, indent=2))
return 0
output = run_query(
query=args.query,
complex_id=args.complex_id,
url=args.url,
trade_types=trade_types,
pages=max(1, args.pages),
limit=max(1, args.limit),
candidate_limit=max(1, args.candidate_limit),
min_pyeong=args.min_pyeong,
max_pyeong=args.max_pyeong,
compare=bool(args.compare),
)
if args.json:
print(json.dumps(output, ensure_ascii=False, indent=2))
else:
print(summarize_comparison(output.get("results", [])) if output.get("compare_mode") else summarize(output.get("items", [])))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/watch_real_estate.py
from __future__ import annotations
import argparse
import json
import time
import uuid
from pathlib import Path
from typing import Any
from search_real_estate import WATCH_STATE_FILE, PriceConverter, run_query
SCHEMA_VERSION = 2
def _load_rules() -> dict[str, Any]:
if WATCH_STATE_FILE.exists():
data = json.loads(WATCH_STATE_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict) and "rules" in data:
data.setdefault("schema_version", SCHEMA_VERSION)
data.setdefault("last_checked_at", None)
data.setdefault("events", [])
data.setdefault("last_seen", {})
return data
return {"schema_version": SCHEMA_VERSION, "last_checked_at": None, "rules": [], "events": [], "last_seen": {}}
def _save_rules(data: dict[str, Any]) -> None:
WATCH_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
WATCH_STATE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _fmt_price(value: int | None) -> str:
return PriceConverter.to_string(value) if value else "-"
def _article_key(item: dict[str, Any]) -> str:
return str(item.get("article_key") or f"{item.get('complex_id', '')}:{item.get('매물ID', '')}")
def _normalize_rule(args: argparse.Namespace) -> dict[str, Any]:
return {
"id": getattr(args, "rule_id", None) or f"rule-{uuid.uuid4().hex[:10]}",
"name": args.name,
"query": args.query,
"complex_id": args.complex_id,
"url": args.url,
"trade_types": [token.strip() for token in str(args.trade_types or "").split(",") if token.strip()],
"target_max_price": args.target_max_price,
"pages": args.pages,
"limit": args.limit,
"candidate_limit": args.candidate_limit,
"min_pyeong": args.min_pyeong,
"max_pyeong": args.max_pyeong,
"notify_on_new": bool(args.notify_on_new),
"notify_on_price_drop": bool(args.notify_on_price_drop),
"enabled": True,
"created_at": int(time.time()),
"updated_at": int(time.time()),
"notes": getattr(args, "notes", None),
}
def add_rule(args: argparse.Namespace) -> int:
data = _load_rules()
rule = _normalize_rule(args)
data.setdefault("rules", []).append(rule)
_save_rules(data)
print(json.dumps({"saved": True, "rule": rule}, ensure_ascii=False, indent=2))
return 0
def list_rules() -> int:
print(json.dumps(_load_rules(), ensure_ascii=False, indent=2))
return 0
def _make_match(rule: dict[str, Any], item: dict[str, Any], *, event_type: str, previous: dict[str, Any] | None = None) -> dict[str, Any]:
price = PriceConverter.to_int(item.get("매매가") or item.get("보증금") or "0")
return {
"event_type": event_type,
"article_key": _article_key(item),
"price": price,
"price_text": _fmt_price(price),
"complex_id": item.get("complex_id"),
"complex_name": item.get("단지명"),
"article_url": item.get("매물URL"),
"area_pyeong": item.get("면적(평)"),
"trade_type": item.get("거래유형"),
"floor_direction": item.get("층/방향"),
"feature": item.get("특징"),
"previous_price": previous.get("price") if previous else None,
"previous_price_text": _fmt_price(previous.get("price")) if previous and previous.get("price") else None,
"target_max_price": rule.get("target_max_price"),
"target_max_price_text": _fmt_price(rule.get("target_max_price")),
"detected_at": int(time.time()),
}
def _build_alert_lines(alerts: list[dict[str, Any]], *, max_matches_per_rule: int = 3) -> list[str]:
lines = []
for row in alerts:
rule = row.get("rule") or {}
matched = row.get("matched") or []
snapshot = row.get("snapshot") or {}
complex_info = snapshot.get("complex_info") or {}
label = rule.get("name") or rule.get("id") or "이름없는 규칙"
header = f"- {label}: {row.get('matched_count', 0)}건"
if complex_info.get("name"):
header += f" | {complex_info.get('name')}"
if row.get("error"):
header += f" | 오류: {row['error']}"
lines.append(header)
for item in matched[:max_matches_per_rule]:
badges = []
if item.get("event_type") == "new_listing":
badges.append("신규")
elif item.get("event_type") == "price_drop":
badges.append("가격하락")
elif item.get("event_type") == "target_hit":
badges.append("목표가도달")
if item.get("previous_price_text"):
badges.append(f"이전 {item['previous_price_text']}")
badge_text = f" ({', '.join(badges)})" if badges else ""
lines.append(f" · {item.get('complex_name')} {item.get('trade_type')} {item.get('price_text')} / {item.get('area_pyeong')}평{badge_text}")
if item.get("article_url"):
lines.append(f" - {item['article_url']}")
return lines
def _build_message_preview(alerts: list[dict[str, Any]], *, checked_at: int) -> str:
total = sum(int(row.get("matched_count") or 0) for row in alerts)
lines = [f"부동산 감시 점검 결과: {total}건 알림", f"checked_at={checked_at}"]
lines.extend(_build_alert_lines(alerts))
return "\n".join(lines)
def _stdout_payload(alerts: list[dict[str, Any]], *, checked_at: int) -> dict[str, Any]:
preview = _build_message_preview(alerts, checked_at=checked_at)
return {
"kind": "naver-real-estate-watch-check",
"schema_version": SCHEMA_VERSION,
"checked_at": checked_at,
"alert_count": sum(int(row.get("matched_count") or 0) for row in alerts),
"alerts": alerts,
"message_preview": preview,
"summary": {
"rule_count": len(alerts),
"rules_with_matches": sum(1 for row in alerts if int(row.get("matched_count") or 0) > 0),
"rules_with_errors": sum(1 for row in alerts if row.get("error")),
},
}
def check_rules(args: argparse.Namespace) -> int:
data = _load_rules()
last_seen = data.setdefault("last_seen", {})
checked_at = int(time.time())
alerts = []
new_events = []
for rule in data.get("rules", []):
if rule.get("enabled") is False:
continue
try:
payload = run_query(
query=rule.get("query"),
complex_id=rule.get("complex_id"),
url=rule.get("url"),
trade_types=rule.get("trade_types") or [],
pages=max(1, int(rule.get("pages") or 1)),
limit=max(1, int(rule.get("limit") or 10)),
candidate_limit=max(1, int(rule.get("candidate_limit") or 1)),
min_pyeong=rule.get("min_pyeong"),
max_pyeong=rule.get("max_pyeong"),
compare=False,
)
except Exception as exc:
alerts.append({
"rule": rule,
"matched": [],
"matched_count": 0,
"error": str(exc),
"snapshot": None,
})
continue
threshold = rule.get("target_max_price")
matches = []
for item in payload.get("items", []):
price = PriceConverter.to_int(item.get("매매가") or item.get("보증금") or "0")
article_key = _article_key(item)
prev = last_seen.get(article_key)
is_new = prev is None
is_price_drop = bool(prev and prev.get("price") and price and price < prev.get("price"))
threshold_hit = bool(threshold and price and price <= threshold)
emit = False
event_type = None
if threshold_hit:
emit = True
event_type = "target_hit"
if is_new and rule.get("notify_on_new"):
emit = True
event_type = event_type or "new_listing"
if is_price_drop and rule.get("notify_on_price_drop"):
emit = True
event_type = event_type or "price_drop"
if emit:
match = _make_match(rule, item, event_type=event_type or "matched", previous=prev)
matches.append(match)
dedupe_key = f"{rule.get('id')}::{event_type}::{article_key}::{price}"
known_event_keys = {e.get('dedupe_key') for e in data.get('events', [])[-500:]}
if dedupe_key not in known_event_keys:
event = {"rule_id": rule.get("id"), "rule_name": rule.get("name"), "dedupe_key": dedupe_key, **match}
new_events.append(event)
last_seen[article_key] = {
"rule_id": rule.get("id"),
"price": price,
"last_seen_at": checked_at,
"trade_type": item.get("거래유형"),
"complex_name": item.get("단지명"),
"article_url": item.get("매물URL"),
}
alerts.append({
"rule": rule,
"matched": matches,
"matched_count": len(matches),
"snapshot": {
"selected_complex_id": payload.get("selected_complex_id"),
"complex_info": payload.get("complex_info"),
"market_summary": payload.get("market_summary"),
"meta": payload.get("meta"),
},
})
data["last_checked_at"] = checked_at
data.setdefault("events", []).extend(new_events)
data["events"] = data["events"][-1000:]
data["last_seen"] = last_seen
_save_rules(data)
payload = _stdout_payload(alerts, checked_at=checked_at)
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
elif args.preview:
print(payload["message_preview"])
else:
print(payload["message_preview"])
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="네이버 부동산 가격 감시/새 매물 감지")
sub = p.add_subparsers(dest="cmd", required=True)
add_p = sub.add_parser("add")
add_p.add_argument("--name", required=True)
add_p.add_argument("--query")
add_p.add_argument("--complex-id")
add_p.add_argument("--url")
add_p.add_argument("--trade-types", default="")
add_p.add_argument("--target-max-price", type=int, help="정수 가격 기준. 예: 950000000")
add_p.add_argument("--pages", type=int, default=1)
add_p.add_argument("--limit", type=int, default=10)
add_p.add_argument("--candidate-limit", type=int, default=1)
add_p.add_argument("--min-pyeong", type=float)
add_p.add_argument("--max-pyeong", type=float)
add_p.add_argument("--notify-on-new", action="store_true")
add_p.add_argument("--notify-on-price-drop", action="store_true")
add_p.add_argument("--notes")
sub.add_parser("list")
check_p = sub.add_parser("check")
check_p.add_argument("--json", action="store_true")
check_p.add_argument("--preview", action="store_true", help="사람이 읽기 쉬운 preview만 출력")
return p
def main() -> int:
args = build_parser().parse_args()
if args.cmd == "add":
return add_rule(args)
if args.cmd == "list":
return list_rules()
if args.cmd == "check":
return check_rules(args)
raise SystemExit(2)
if __name__ == "__main__":
raise SystemExit(main())
Search, compare, and monitor 대한민국 domestic flights using a Playwright-backed airfare workflow. Use when the user wants 한국 국내선 항공권 최저가 조회, 김포-제주/부산-제주 같은 노선 비...
---
name: korea-domestic-flights
description: Search, compare, and monitor 대한민국 domestic flights using a Playwright-backed airfare workflow. Use when the user wants 한국 국내선 항공권 최저가 조회, 김포-제주/부산-제주 같은 노선 비교, 편도/왕복 검색, 날짜 범위 최저가 탐색, 여러 국내 목적지 비교, 시간대 선호(늦은 출발/늦은 복귀) 반영, 국내선 운임 브리핑, 목적지+날짜 범위 최적 조합 탐색, or 목표가 이하 가격 감시/가격 알림 저장·점검. Accept common Korean airport names like 김포, 제주, 부산, 청주 and natural-language dates like 오늘/내일/모레/이번주말/내일부터 3일. Prefer this skill for Korean domestic airfare tasks; do not use it for international flights.
---
# Korea Domestic Flights
Use this skill for **대한민국 국내선 전용 항공권 검색**.
Current scope:
- 국내선 편도 검색
- 국내선 왕복 검색
- 날짜 범위 최저가 탐색
- 날짜별 가격 캘린더/히트맵 요약
- 다중 목적지 비교
- 다중 목적지 + 날짜 범위 최적 조합 탐색
- 다중 목적지 + 날짜 범위 비교에서도 목적지별 가격 캘린더 요약 제공
- JSON 출력
- 더 자연스러운 한국어 브리핑 출력
- 한글 공항명 입력 지원
- `오늘/내일/모레/이번주말/내일부터 3일` 같은 간단한 자연어 날짜 지원
- `2026-03-25~2026-03-30`, `20260325~20260330`, `2026-03-25부터 2026-03-30` 같은 명시 날짜 범위 지원
- 채팅 친화 래퍼 제공
- 시간대 필터(오전/오후/저녁, 출발 N시 이후, 복귀 N시 이후, 너무 이른 비행 제외)
- 최저가 외 시간 선호 기반 추천(예: 늦은 시간대 기준 추천)
- 왕복/날짜범위 검색에서 귀환편 시간 조건 반영
- 왕복 범위/조합 검색에서 가격만이 아닌 왕복 시간 균형 추천 제공
- 추천 결과에 가격 차이·시간 조건·왕복 균형 기반 추천 사유 설명 제공
- 목표가 기반 가격 감시 규칙 저장/목록/삭제/점검
- 가격 감시 규칙에 시간대 조건 저장/점검 지원
- 가격 알림 중복 방지(dedupe)
- 단일/다중 목적지 가격 감시
- 알림 메시지 포맷 커스터마이즈
- cron/브리핑 연동을 염두에 둔 JSON 저장 포맷
- Windows 작업 스케줄러 등록 초안 스크립트
Do not use it for 국제선.
## Source dependency
This skill wraps the local project clone at:
- `tmp/Scraping-flight-information`
Main reused entry points:
- `scraping.searcher.FlightSearcher`
- `scraping.parallel.ParallelSearcher`
If the clone or its dependencies are missing, searches will fail.
## Scripts
### 1) Single-route domestic search
```bash
python skills/korea-domestic-flights/scripts/search_domestic.py --origin 김포 --destination 제주 --departure 내일 --human
```
시간 조건 포함:
```bash
python skills/korea-domestic-flights/scripts/search_domestic.py --origin 김포 --destination 제주 --departure 내일 --time-pref "출발 10시 이후" --prefer late --human
```
Round trip:
```bash
python skills/korea-domestic-flights/scripts/search_domestic.py --origin GMP --destination CJU --departure 2026-03-25 --return-date 2026-03-28 --human
```
### 2) Date-range cheapest-day search
```bash
python skills/korea-domestic-flights/scripts/search_date_range.py --origin 김포 --destination 제주 --date-range "내일부터 3일" --human
```
Explicit range:
```bash
python skills/korea-domestic-flights/scripts/search_date_range.py --origin 김포 --destination 제주 --start-date 내일 --end-date 2026-03-30 --human
```
Round-trip-style date scan with fixed return offset:
```bash
python skills/korea-domestic-flights/scripts/search_date_range.py --origin 김포 --destination 제주 --date-range "다음주말" --return-offset 2 --time-pref "복귀 18시 이후" --human
```
### 3) Multi-destination comparison
```bash
python skills/korea-domestic-flights/scripts/search_multi_destination.py --origin 김포 --destinations 제주,부산,여수 --departure 내일 --human
```
Round trip comparison:
```bash
python skills/korea-domestic-flights/scripts/search_multi_destination.py --origin GMP --destinations CJU,PUS,RSU --departure 2026-03-25 --return-date 2026-03-28 --human
```
### 4) Multi-destination + date-range best-combo search
```bash
python skills/korea-domestic-flights/scripts/search_destination_date_matrix.py --origin 김포 --destinations 제주,부산 --date-range "내일부터 2일" --human
```
Round-trip offset scan across destinations:
```bash
python skills/korea-domestic-flights/scripts/search_destination_date_matrix.py --origin 김포 --destinations 제주,부산 --date-range "다음주말" --return-offset 2 --human
```
### 5) Chat-friendly wrapper
Use this first when the user is chatting naturally and you want the easiest invocation.
Single route:
```bash
python skills/korea-domestic-flights/scripts/chat_search.py --origin 김포 --destination 제주 --when 내일
```
Date range:
```bash
python skills/korea-domestic-flights/scripts/chat_search.py --origin 김포 --destination 제주 --when "내일부터 3일"
```
Multi-destination + range:
```bash
python skills/korea-domestic-flights/scripts/chat_search.py --origin 김포 --destinations 제주,부산 --when "내일부터 2일" --return-offset 1 --time-pref "출발 10시 이후, 늦은 시간 선호"
```
Add `--json` when structured output is needed; otherwise it defaults to a human-readable Korean briefing.
### 0) Hybrid diagnostics smoke/live checks
Fixture-based regression/smoke check:
```bash
python skills/korea-domestic-flights/scripts/hybrid_smoke_check.py
```
Shallow environment-only check:
```bash
python skills/korea-domestic-flights/scripts/hybrid_live_dry_run.py
```
Optional shallow live probe (non-brittle, no fare assertion):
```bash
python skills/korea-domestic-flights/scripts/hybrid_live_dry_run.py --probe
```
### 6) Price alert / watch rules
Store a single-date alert:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destination 제주 --departure 내일 --target-price 70000 --label "김포-제주 내일 특가"
```
Store a date-range alert:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destination 제주 --date-range "내일부터 3일" --target-price 80000 --label "김포-제주 3일 범위 감시"
```
Store a time-filtered alert:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destination 제주 --date-range "다음주말" --return-offset 2 --target-price 150000 --time-pref "복귀 18시 이후, 늦은 시간 선호" --label "주말 늦복 왕복 감시"
```
Store a multi-destination watch:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destinations 제주,부산,여수 --departure 내일 --target-price 90000 --label "김포 출발 내일 다중 목적지 감시"
```
Store a round-trip range alert with fixed return offset:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destination 제주 --date-range "다음주말" --return-offset 2 --target-price 150000 --label "주말 왕복 특가"
```
Store a custom message format:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destinations 제주,부산 --date-range "내일부터 3일" --target-price 85000 --message-template "[특가감시] {best_destination_label} {departure_date} {observed_price} / 기준 {target_price}"
```
Check all active rules:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py check
```
List saved rules:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py list
```
Remove a rule:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py remove --rule-id kdf-1234abcd
```
When a rule matches, `check` prints a human-readable Korean alert message to stdout so an upper-layer cron/briefing flow can forward it directly.
## Parameters
`search_domestic.py`
- `--origin`: 출발 공항 코드 또는 한글 공항명
- `--destination`: 도착 공항 코드 또는 한글 공항명
- `--departure`: 출발일 (`YYYY-MM-DD`, `YYYYMMDD`, `내일`, `모레`, `이번주말`, `다음주 금요일` 등)
- `--return-date`: 귀국일 (선택)
- `--adults`: 성인 수, 기본값 `1`
- `--cabin`: `ECONOMY|BUSINESS|FIRST`
- `--max-results`: 최대 결과 수
- `--time-pref`: 자연어 시간 조건 (`오전`, `오후`, `저녁`, `출발 10시 이후`, `복귀 18시 이후`, `너무 이른 비행 제외 8시`, `늦은 시간 선호` 등)
- `--depart-after`, `--return-after`, `--exclude-early-before`: 옵션 기반 시간 필터
- `--prefer`: `late|morning|afternoon|evening` 시간 선호 추천
- `--human`: 자연스러운 한국어 브리핑 출력 (`최저가` / `시간대 추천` / `왕복 균형 추천` 중심 구획형 요약)
`search_date_range.py`
- `--origin`: 출발 공항 코드 또는 한글 공항명
- `--destination`: 도착 공항 코드 또는 한글 공항명
- `--start-date`: 범위 시작일
- `--end-date`: 범위 종료일
- `--date-range`: 자연어 범위 (`내일부터 3일`, `이번주말`, `2026-03-25~2026-03-30`)
- `--return-offset`: 왕복 탐색용 귀국 오프셋 일수
- `--adults`: 성인 수
- `--cabin`: `ECONOMY|BUSINESS|FIRST`
- `--time-pref`, `--depart-after`, `--return-after`, `--exclude-early-before`, `--prefer`: 시간 필터/시간 선호 추천
- 시간 조건이 있으면 전체 날짜를 병렬로 빠르게 훑은 뒤 저가 후보·인접 날짜·범위 커버리지 앵커를 함께 상세 재검증하는 하이브리드 모드로 전환됨
- 상세 재검증 후 시간 조건 일치 결과가 너무 적거나, 시간조건 탈락/빈결과 유사 패턴이 강하면 fallback 후보 확장을 한 번 더 수행할 수 있음
- `summary.search_metadata` / 최상위 `search_metadata` / `logs` 에 하이브리드 여부, 전체 스캔 수, 초기/추가 재검증 수, fallback 여부, 시간 조건 요약과 `refine_diagnostics`(시간조건 탈락 / usable match 없음 / 빠른스캔-상세 불일치 빈결과 / 출발·복귀 시간정보 부족 / 가격정보 부족 분류, 샘플, user/developer 힌트, `ranked_reasons`, `dominant_reason_code`, `primary_interpretation`)가 기록됨
- fallback 판단 요약은 `fallback_decision` / `fallback_reason_codes` 에 구조화되어 남음
- 결과 요약에는 날짜별 가격 캘린더/히트맵(`summary.price_calendar`)이 포함됨
- `--human`: 자연스러운 한국어 브리핑 출력
`search_multi_destination.py`
- `--origin`: 출발 공항 코드 또는 한글 공항명
- `--destinations`: 쉼표로 구분한 여러 목적지 (코드 또는 한글)
- `--departure`: 출발일
- `--return-date`: 귀국일 (선택)
- `--adults`: 성인 수
- `--cabin`: `ECONOMY|BUSINESS|FIRST`
- `--time-pref`, `--depart-after`, `--return-after`, `--exclude-early-before`, `--prefer`: 시간 필터/시간 선호 추천
- `--human`: 목적지 간 가격 차이와 추천이 포함된 한국어 브리핑 출력
`search_destination_date_matrix.py`
- `--origin`: 출발 공항 코드 또는 한글 공항명
- `--destinations`: 쉼표로 구분한 여러 목적지
- `--start-date`, `--end-date`: 날짜 범위 지정
- `--date-range`: 자연어 날짜 범위 지정
- `--return-offset`: 출발일 기준 귀국 오프셋 일수
- `--adults`: 성인 수
- `--cabin`: `ECONOMY|BUSINESS|FIRST`
- `--time-pref`, `--depart-after`, `--return-after`, `--exclude-early-before`, `--prefer`: 시간 필터/시간 선호 추천
- 시간 조건이 있으면 전체 조합을 목적지별 병렬 스캔으로 먼저 좁힌 뒤 저가 조합·목적지별 후보·인접 날짜 조합·커버리지 앵커를 함께 상세 재검증하는 하이브리드 모드로 전환됨
- 상세 재검증 후 시간 조건 일치 조합이 너무 적거나, 시간조건 탈락/빈결과 유사 패턴이 강하면 fallback 후보 확장을 한 번 더 수행할 수 있음
- `summary.search_metadata` / 최상위 `search_metadata` / `logs` 에 하이브리드 여부, 전체 스캔 수, 초기/추가 재검증 수, fallback 여부, 시간 조건 요약과 `refine_diagnostics`(시간조건 탈락 / usable match 없음 / scraper-empty 유사 분류 / 시간·가격 정보 완전누락·부분누락, 샘플, `human_hint`/`developer_hint`, `extraction_summary`, `ranked_reasons`, `dominant_reason_code`, `primary_interpretation`)가 기록됨
- fallback 판단 요약은 `fallback_decision` / `fallback_reason_codes` 에 구조화되어 남음
- `--human`: 전체 최적 조합 + 목적지별 베스트 브리핑 출력
`chat_search.py`
- `--origin`: 출발 공항명/코드
- `--destination`: 단일 목적지
- `--destinations`: 다중 목적지
- `--when`: 자연어 날짜/날짜범위
- `--departure`: 명시적 출발일
- `--return-date`: 명시적 귀국일
- `--return-offset`: 날짜범위 왕복 오프셋. `chat_search.py`에서는 다중 목적지 + 명시적 `--departure` 와 함께 써도 단일일 매트릭스 왕복 탐색으로 라우팅됨
- `--time-pref`: 자연어 시간 선호/필터 입력
- `--depart-after`, `--return-after`, `--exclude-early-before`, `--prefer`: 옵션 기반 시간 필터
- `--json`: JSON 출력, 생략 시 사람이 읽기 쉬운 브리핑 출력
`price_alerts.py`
- `add`: 감시 규칙 저장
- `--origin`: 출발 공항
- `--destination` 또는 `--destinations`: 단일/다중 목적지
- `--departure`: 단일 날짜 감시
- `--return-date`: 왕복 귀국일
- `--date-range`: 날짜 범위 감시
- `--return-offset`: 날짜 범위 왕복 감시 시 귀국 오프셋
- `--adults`: 성인 수
- `--cabin`: `ECONOMY|BUSINESS|FIRST`
- `--target-price`: 목표가(원)
- `--label`: 사람이 읽을 이름
- `--time-pref`, `--depart-after`, `--return-after`, `--exclude-early-before`, `--prefer`: 시간대 조건/선호 저장
- `--message-template`: 커스텀 알림 포맷
- `--store`: 저장 파일 경로 오버라이드
- 동일 조건+목표가 규칙은 fingerprint 기준으로 중복 저장되지 않음
- `list`: 저장된 규칙 목록 출력
- `check`: 활성 규칙 점검, 목표가 충족 시 한국어 알림 출력
- 동일한 결과는 `notify.dedupe_key` 기준으로 재알림 억제
- `--no-dedupe` 로 강제 재출력 가능
- `render`: 마지막 `last_result` 를 현재 템플릿으로 미리보기
- `remove`: 규칙 삭제
## References
Read these only when needed:
- `references/domestic-airports.md`: 국내 공항 코드/이름 매핑
- `references/price-alerts-schema.md`: 가격 감시 JSON 저장 포맷, dedupe, 다중 목적지 감시, 메시지 템플릿, cron/스케줄러 연결 힌트
## Operational notes
- This skill depends on a working Playwright browser environment.
- If browser init fails, install or repair Chromium/Chrome/Edge in the source repo environment.
- The provider site DOM may change; if results suddenly disappear, the upstream scraper may need maintenance.
- For stable chat use, prefer `chat_search.py` or `--human` summaries unless structured JSON is explicitly needed.
- Prefer domestic routes only; if the user asks for ICN-NRT or any overseas route, do not use this skill.
FILE:README.md
# korea-domestic-flights
대한민국 **국내선 항공권 검색·비교·날짜 범위 탐색·가격 감시**를 위한 OpenClaw 스킬입니다.
이 스킬은 Playwright 기반 항공권 검색 흐름을 감싸서 다음 같은 작업을 처리합니다.
- 김포-제주, 부산-제주 같은 **국내선 단일 노선 검색**
- **왕복 검색** 및 시간대 조건 반영
- **날짜 범위 최저가 탐색**
- **다중 목적지 비교**
- **다중 목적지 + 날짜 범위 최적 조합 탐색**
- **가격 캘린더/히트맵 요약**
- **목표가 기반 가격 감시 규칙 저장/점검**
> 국제선에는 사용하지 않습니다.
---
## 핵심 기능
### 1. 단일 노선 검색
- 편도/왕복 검색
- 한글 공항명 입력 지원 (`김포`, `제주`, `부산` 등)
- 시간대 조건 반영 (`출발 10시 이후`, `복귀 18시 이후` 등)
- 추천 사유 설명 출력
### 2. 날짜 범위 최저가 탐색
- `내일부터 3일`, `이번주말`, `다음주말`, `2026-03-25~2026-03-30` 같은 자연어/명시 범위 입력 지원
- 날짜별 가격 캘린더 제공
- 왕복 검색 시 균형 추천 제공
- 시간 조건이 있을 때는 빠른 전체 스캔 후 저가 후보 + 인접 날짜 + 범위 커버리지 앵커를 함께 상세 재검증하는 하이브리드 최적화 적용
- 시간 조건 하이브리드에서 상세 재검증 결과가 너무 적거나 시간조건 탈락/빈결과 유사 패턴이 강하면 fallback 후보 확장을 한 번 더 수행해 false no-result / false-best 위험을 줄임
### 3. 다중 목적지 비교
- 예: 김포 출발로 제주/부산/여수 중 어디가 가장 유리한지 비교
- 목적지별 최저가/추천/가격 차이 설명 제공
### 4. 목적지 + 날짜 범위 매트릭스 탐색
- 여러 목적지와 여러 날짜 조합을 한 번에 비교
- 최적 조합 + 목적지별 가격 캘린더 제공
### 5. 가격 감시 / 알림
- 목표가 이하로 내려오면 알림 메시지 생성
- 단일 목적지 / 다중 목적지 / 날짜 범위 감시 지원
- 시간대 조건 포함 규칙 저장 가능
- dedupe(중복 억제) 지원
---
## 빠른 예시
### 단일 검색
```bash
python skills/korea-domestic-flights/scripts/search_domestic.py --origin 김포 --destination 제주 --departure 내일 --human
```
### 날짜 범위 검색
```bash
python skills/korea-domestic-flights/scripts/search_date_range.py --origin 김포 --destination 제주 --date-range "내일부터 3일" --human
```
### 다중 목적지 + 날짜 범위 검색
```bash
python skills/korea-domestic-flights/scripts/search_destination_date_matrix.py --origin 김포 --destinations 제주,부산 --date-range "다음주말" --return-offset 2 --human
```
### 시간 조건 포함 가격 감시 규칙 저장
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add --origin 김포 --destination 제주 --date-range "다음주말" --return-offset 2 --target-price 150000 --time-pref "복귀 18시 이후, 늦은 시간 선호" --label "주말 늦복 왕복 감시"
```
---
## 출력 특징
이 스킬은 단순 최저가만 보여주지 않고, 가능하면 아래 정보도 같이 제공합니다.
- 추천 사유
- 시간대 추천
- 왕복 균형 추천
- 날짜별 가격 캘린더
- 목적지별 가격 캘린더
- 사람용 출력에서는 `최저가`, `시간대 추천`, `왕복 균형 추천` 같은 구획을 나눠 더 읽기 쉽게 표시
- 사람용 출력에서는 너무 길어지지 않도록 캘린더를 일부만 미리 보여주고 나머지 일수는 축약 표시
예를 들어:
- **추천:** 이번 조건에서는 부산(PUS) 조합이 가장 유리합니다.
- **추천 사유:** 2위보다 더 저렴하고, 시간 조건에도 맞습니다.
- **왕복 균형 추천:** 아주 약간 더 비싸더라도 시간대가 더 무난한 조합을 별도로 제안할 수 있습니다.
---
## 의존성
이 스킬은 다음 로컬 소스 저장소를 감쌉니다.
- `tmp/Scraping-flight-information`
필요 조건:
- Playwright/브라우저 실행 가능 환경
- upstream 검색 로직이 정상 동작할 것
환경이 깨졌거나 사이트 DOM이 바뀌면 결과가 없거나 오류가 날 수 있습니다.
---
## 현재 확인된 동작 상태
최근 점검 기준:
- 모든 주요 스크립트 `py_compile` 통과
- `price_alerts.py add/list/remove` 동작 확인
- `chat_search.py`를 통한 다중 목적지+날짜 범위 JSON 검색 동작 확인
- `chat_search.py`에서 다중 목적지 + 명시적 출발일 + `--return-offset` 조합이 날짜 매트릭스로 올바르게 라우팅되도록 보정
- 다중 목적지+날짜 범위 검색에서 목적지별 `price_calendar` 출력 확인
- `references/hybrid-smoke-fixtures.json` 기반 회귀/스모크 진단 케이스 확인
- `hybrid_live_dry_run.py`로 환경 전용 또는 얕은 live probe 점검 가능
---
## 한계 / 주의점
- 실제 검색은 외부 사이트 상태와 브라우저 환경에 영향을 받습니다.
- 날짜 범위/다중 목적지/왕복 검색은 실행 시간이 길 수 있지만, 시간 조건이 있는 범위/매트릭스 검색은 하이브리드 최적화로 전체 조합을 먼저 빠르게 훑은 뒤 저가 후보·인접 날짜·커버리지 앵커를 함께 상세 검색합니다.
- 상세 재검증 후 시간 조건 일치 결과가 너무 적으면 fallback 후보 확장을 추가로 수행할 수 있습니다.
- JSON `summary.search_metadata` / 최상위 `search_metadata` 와 `logs` 에 하이브리드 사용 여부, 초기/추가 재검증 수, fallback 여부, 시간 조건 요약과 `refine_diagnostics`(시간조건 탈락 / usable match 없음 / 빠른스캔-상세 불일치 빈결과 / 시간·가격 정보 완전누락/부분누락 분류, 샘플, `human_hint`/`developer_hint`, `extraction_summary`, `ranked_reasons`, `dominant_reason_code`, `primary_interpretation`)가 기록됩니다.
- fallback 판단은 `fallback_decision` / `fallback_reason_codes` 로 구조화되어 남아 extraction incompleteness 우세인지 genuine time-filter rejection 우세인지 구분하기 쉽게 했습니다.
- 사람용 출력에서는 필요할 때만 한 줄짜리 `참고:` 진단 힌트를 덧붙이고, 더 자세한 디버그성 힌트와 커버리지 수치는 JSON 메타데이터에만 남겨서 사람용 출력이 시끄러워지지 않게 유지합니다.
- 국제선은 범위 밖입니다.
자세한 사용법은 `SKILL.md`를 참고하세요.
FILE:references/domestic-airports.md
# 대한민국 국내선 주요 공항 코드
- `GMP` — 김포
- `CJU` — 제주
- `PUS` — 부산(김해)
- `TAE` — 대구
- `CJJ` — 청주
- `KWJ` — 광주
- `RSU` — 여수
- `USN` — 울산
- `HIN` — 사천
- `KPO` — 포항경주
- `YNY` — 양양
- `MWX` — 무안
- `SEL` — 서울(검색 컨텍스트용 묶음 코드로 쓰일 수 있음)
## 한글 입력 별칭 예시
- `김포` → `GMP`
- `제주`, `제주도` → `CJU`
- `부산`, `김해` → `PUS`
- `청주` → `CJJ`
- `광주` → `KWJ`
- `여수` → `RSU`
- `울산` → `USN`
- `사천`, `진주` → `HIN`
- `포항`, `포항경주` → `KPO`
- `양양` → `YNY`
- `무안` → `MWX`
- `서울` → `SEL`
## 날짜 입력 예시
- `오늘`
- `내일`
- `모레`
- `2026-03-25`
- `20260325`
- `2026/03/25`
## 추천 자연어 예시
- 김포에서 제주 가는 내일 최저가 찾아줘
- 부산 제주 왕복 항공권 요약해줘
- 청주 제주 3일 범위로 최저가 비교해줘
- 김포 제주 왕복 2박 기준 날짜별 최저가 찾아줘
- 김포 출발로 제주, 부산, 여수 중 어디가 제일 싼지 비교해줘
FILE:references/hybrid-smoke-fixtures.json
{
"date_range_cases": [
{
"name": "broad-hit-empty-detail-vs-time-rejection",
"broad_rows": [
{"departure_date": "2026-03-28", "price": 90000},
{"departure_date": "2026-03-29", "price": 95000},
{"departure_date": "2026-03-30", "price": 98000},
{"departure_date": "2026-03-31", "price": 101000}
],
"detailed_rows": [
{"departure_date": "2026-03-28", "broad_price": 90000, "raw_option_count": 4, "priced_option_count": 4, "departure_time_count": 4, "return_time_count": 0, "time_pref_valid_count": 0, "search_stage": "refine", "diagnostic_reason": "broad_candidate_time_rejected", "diagnostic_detail": {"hint": "시간 조건 미충족"}},
{"departure_date": "2026-03-29", "broad_price": 95000, "raw_option_count": 0, "priced_option_count": 0, "departure_time_count": 0, "return_time_count": 0, "time_pref_valid_count": 0, "search_stage": "refine", "diagnostic_reason": "detail_empty_after_broad_hit", "diagnostic_detail": {"hint": "빠른 스캔 대비 상세 빈결과"}}
],
"expect": {
"dominant_reason": "detail_empty_after_broad_hit",
"primary_interpretation": "extraction_incomplete",
"fallback_triggered": true,
"required_reasons": ["broad_candidate_time_rejected", "detail_empty_after_broad_hit"],
"required_fallback_reason_prefixes": ["coverage.time_pref_shortfall", "signal.high_extraction_incomplete_ratio"]
}
},
{
"name": "pure-time-filter-rejection",
"broad_rows": [
{"departure_date": "2026-04-04", "price": 87000},
{"departure_date": "2026-04-05", "price": 91000},
{"departure_date": "2026-04-06", "price": 94000}
],
"detailed_rows": [
{"departure_date": "2026-04-04", "broad_price": 87000, "raw_option_count": 5, "priced_option_count": 5, "departure_time_count": 5, "return_time_count": 0, "time_pref_valid_count": 0, "search_stage": "refine", "diagnostic_reason": "broad_candidate_time_rejected", "diagnostic_detail": {"hint": "시간 조건 미충족"}},
{"departure_date": "2026-04-05", "broad_price": 91000, "raw_option_count": 4, "priced_option_count": 4, "departure_time_count": 4, "return_time_count": 0, "time_pref_valid_count": 0, "search_stage": "refine", "diagnostic_reason": "detailed_no_usable_time_filter_match", "diagnostic_detail": {"hint": "usable match 없음"}}
],
"expect": {
"dominant_reason": "broad_candidate_time_rejected",
"primary_interpretation": "time_filter_rejection",
"fallback_triggered": true,
"required_reasons": ["broad_candidate_time_rejected", "detailed_no_usable_time_filter_match"],
"required_fallback_reason_prefixes": ["signal.high_time_filter_rejection_ratio", "signal.time_filter_dominant:"]
}
}
],
"matrix_cases": [
{
"name": "mixed-matrix-return-time-and-price-gaps",
"broad_rows": [
{"destination": "CJU", "departure_date": "2026-03-28", "price": 90000},
{"destination": "CJU", "departure_date": "2026-03-29", "price": 97000},
{"destination": "PUS", "departure_date": "2026-03-28", "price": 88000},
{"destination": "PUS", "departure_date": "2026-03-29", "price": 92000}
],
"detailed_rows": [
{"destination": "CJU", "departure_date": "2026-03-28", "broad_price": 90000, "raw_option_count": 3, "priced_option_count": 3, "departure_time_count": 3, "return_time_count": 1, "has_return_time_constraint": true, "time_pref_valid_count": 0, "search_stage": "refine", "diagnostic_reason": "detail_partial_return_times", "diagnostic_detail": {"hint": "복귀 시간 정보 부분 누락"}},
{"destination": "PUS", "departure_date": "2026-03-28", "broad_price": 88000, "raw_option_count": 2, "priced_option_count": 2, "departure_time_count": 2, "return_time_count": 2, "has_return_time_constraint": true, "time_pref_valid_count": 1, "search_stage": "refine", "time_pref_match": true, "diagnostic_reason": "detailed_match_with_time_pref", "diagnostic_detail": {"hint": "시간 조건 일치"}},
{"destination": "PUS", "departure_date": "2026-03-29", "broad_price": 92000, "raw_option_count": 4, "priced_option_count": 2, "departure_time_count": 4, "return_time_count": 4, "has_return_time_constraint": false, "time_pref_valid_count": 0, "search_stage": "refine", "diagnostic_reason": "detail_sparse_price_data", "diagnostic_detail": {"hint": "가격 정보 일부 누락"}}
],
"expect": {
"required_reasons": ["detailed_match_with_time_pref", "detail_partial_return_times", "detail_sparse_price_data"],
"fallback_triggered": true,
"required_fallback_reason_prefixes": ["coverage.time_pref_shortfall", "signal.high_extraction_incomplete_ratio"],
"extraction_summary": {
"partial_return_time_rows": 1,
"sparse_price_rows": 1
}
}
}
]
}
FILE:references/price-alerts-schema.md
# 가격 감시 JSON 포맷
이 스킬의 가격 감시 기능은 기본적으로 `skills/korea-domestic-flights/price-alert-rules.json` 파일을 사용한다.
OpenClaw cron/브리핑과 연결하기 쉽게 다음 원칙으로 설계한다.
- 파일 1개에 여러 감시 규칙을 저장한다.
- 각 규칙은 사람이 읽기 쉬운 `label` 과 안정적인 `id` 를 가진다.
- **동일 조건 중복 저장 방지**를 위해 `fingerprint` 를 저장한다.
- 마지막 점검 시각(`last_checked_at`)과 마지막 결과(`last_result`)를 함께 저장한다.
- **동일 최저가/동일 항공편 조건의 재알림 방지**를 위해 `notify.dedupe_key` 를 저장한다.
- **알림 문구 커스터마이즈**를 위해 `notify.message_template` 를 저장한다.
- 점검 스크립트는 목표가 충족 시 한국어 알림 메시지를 stdout 으로 출력하므로 cron/briefing 파이프에 바로 연결하기 쉽다.
- 향후 알림 채널 확장이 가능하도록 `notify` 필드를 남겨 둔다.
## 스키마 개요
```json
{
"version": 2,
"timezone": "Asia/Seoul",
"updated_at": "2026-03-19T18:40:00+09:00",
"rules": [
{
"id": "kdf-a1b2c3d4",
"enabled": true,
"label": "김포→제주,부산 주말 특가 감시",
"fingerprint": "{...canonical json...}",
"query": {
"origin": "GMP",
"destination": null,
"destinations": ["CJU", "PUS"],
"departure": null,
"return_date": null,
"date_range": {
"start_date": "2026-03-21",
"end_date": "2026-03-23"
},
"return_offset": 0,
"adults": 1,
"cabin": "ECONOMY",
"trip_type": "one_way"
},
"target_price_krw": 70000,
"created_at": "2026-03-19T18:35:00+09:00",
"last_checked_at": "2026-03-19T18:39:12+09:00",
"last_result": {
"matched": false,
"observed_price_krw": 81200,
"best_option": {
"destination": "CJU",
"destination_label": "제주(CJU)",
"departure_date": "2026-03-22",
"price": 81200,
"airline": "제주항공"
},
"search_type": "destination_date_matrix"
},
"notify": {
"channel": "stdout",
"dedupe_key": "{...canonical json...}",
"last_sent_at": "2026-03-19T18:39:13+09:00",
"message_template": "[특가] {label} {best_destination_label} {observed_price}"
},
"meta": {
"source": "price_alerts.py",
"notes": "아침 비행 우선"
}
}
]
}
```
## 필드 설명
- `version`: 저장 포맷 버전
- `timezone`: 날짜/시간 기준 타임존
- `updated_at`: 파일 마지막 갱신 시각
- `rules[]`: 감시 규칙 배열
- `rules[].id`: 안정적인 규칙 식별자
- `rules[].enabled`: 감시 활성 여부
- `rules[].label`: 사람이 읽는 규칙 이름
- `rules[].fingerprint`: 조건 중복 저장 방지용 canonical signature
- `rules[].query.origin`: IATA 코드 기준 저장
- `rules[].query.destination`: 단일 목적지용
- `rules[].query.destinations`: 다중 목적지용 배열
- `rules[].query.departure`, `return_date`: 단일 날짜 감시용
- `rules[].query.date_range`: 날짜 범위 감시용
- `rules[].query.return_offset`: 날짜 범위 왕복 감시에서 귀국일 오프셋
- `rules[].query.adults`, `cabin`: 검색 조건
- `rules[].target_price_krw`: 목표가
- `rules[].last_result`: 마지막 점검 결과 캐시
- `rules[].notify.channel`: 현재는 `stdout` 고정, 향후 확장 대비
- `rules[].notify.dedupe_key`: 최근 발송된 알림 fingerprint
- `rules[].notify.last_sent_at`: 마지막 알림 발송 시각
- `rules[].notify.message_template`: 사용자 정의 메시지 템플릿
- `rules[].meta.notes`: 사용자 메모
## 다중 목적지 감시 동작
- `destinations` 가 2개 이상이면 `price_alerts.py check` 가 자동으로 다중 목적지 검색 스크립트를 사용한다.
- 단일 날짜 감시는 `search_multi_destination.py`
- 날짜 범위 감시는 `search_destination_date_matrix.py`
- 알림은 **가장 저렴한 목적지/날짜 조합 1건** 기준으로 발생한다.
## 중복 방지 동작
### 1) 규칙 저장 단계 dedupe
동일한 검색 조건 + 목표가 조합으로 `add` 하면 새 규칙을 저장하지 않고 기존 규칙 ID 를 안내한다.
### 2) 알림 발송 단계 dedupe
`check` 중 목표가를 만족하더라도 다음 값이 같으면 같은 알림으로 간주하고 재출력하지 않는다.
- 규칙 ID
- 검색 타입
- 관측 최저가
- 최적 목적지
- 출발일/귀국일
- 항공사
- 출발/도착 시각
강제로 다시 출력하려면:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py check --no-dedupe
```
## 메시지 템플릿 변수
`add --message-template` 로 커스텀 알림 포맷을 저장할 수 있다.
자주 쓸 만한 변수:
- `{label}`
- `{route}`
- `{origin_label}`
- `{destinations_label}`
- `{best_destination_label}`
- `{target_price}` / `{target_price_krw}`
- `{observed_price}` / `{observed_price_krw}`
- `{difference_krw}`
- `{departure_date}` / `{return_date}`
- `{date_text}`
- `{airline}`
- `{departure_time}` / `{arrival_time}`
- `{cabin_label}`
- `{status_line}`
예시:
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py add \
--origin 김포 \
--destinations 제주,부산 \
--date-range "내일부터 3일" \
--target-price 80000 \
--message-template "[특가감시] {best_destination_label} {departure_date} {observed_price} / 기준 {target_price}"
```
## 추천 cron 연결 방식
정기 점검에서 다음처럼 실행하면 된다.
```bash
python skills/korea-domestic-flights/scripts/price_alerts.py check
```
동작 방식:
- 목표가 미충족이면 `점검 완료: N개 규칙 확인, 목표가 충족 알림 없음` 출력
- 목표가 충족이면 규칙별 한국어 알림 메시지를 stdout 으로 출력
- 동일 알림은 `notify.dedupe_key` 기준으로 억제
- 일부 규칙 점검 오류가 있으면 stderr 에 오류를 남기고 JSON 저장 파일에는 `last_result.error` 를 기록
즉, 상위 OpenClaw 브리핑/cron 레이어에서는 stdout 을 메시지 본문으로 사용하고, stderr 또는 종료 코드를 장애 감지에 활용하면 된다.
## Windows 작업 스케줄러 초안
Windows 환경에서는 다음 스크립트 초안을 사용할 수 있다.
```powershell
powershell -ExecutionPolicy Bypass -File skills/korea-domestic-flights/scripts/register_price_alerts_task.ps1 -IntervalMinutes 30
```
주의:
- 이 스크립트는 **작업 등록만** 돕는다.
- 실제 알림 전송은 상위 OpenClaw cron/브리핑 파이프가 stdout 을 받아 전달하는 방식으로 연결해야 한다.
FILE:scripts/chat_search.py
#!/usr/bin/env python3
import argparse
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import parse_date_range_text, time_preference_cli_args
def run_script(script_name: str, extra_args: list[str]) -> int:
command = [sys.executable, str(SCRIPT_DIR / script_name), *extra_args]
return subprocess.call(command)
def time_args(args) -> list[str]:
return time_preference_cli_args({
"time_pref": args.time_pref,
"depart_after": args.depart_after,
"return_after": args.return_after,
"exclude_early_before": args.exclude_early_before,
"prefer": args.prefer,
})
def main():
parser = argparse.ArgumentParser(description="Chat-friendly wrapper for Korea domestic flight search")
parser.add_argument("--origin", required=True, help="예: 김포")
parser.add_argument("--destination", help="단일 목적지")
parser.add_argument("--destinations", help="여러 목적지, 예: 제주,부산,여수")
parser.add_argument("--when", help="단일 날짜 또는 날짜 범위. 예: 내일, 이번주말, 내일부터 3일")
parser.add_argument("--departure")
parser.add_argument("--return-date")
parser.add_argument("--return-offset", type=int, default=0)
parser.add_argument("--adults", type=int, default=1)
parser.add_argument("--cabin", default="ECONOMY", choices=["ECONOMY", "BUSINESS", "FIRST"])
parser.add_argument("--time-pref", help="예: 오전, 저녁, 출발 10시 이후, 복귀 18시 이후, 너무 이른 비행 제외 8시, 늦은 시간 선호")
parser.add_argument("--depart-after")
parser.add_argument("--return-after")
parser.add_argument("--exclude-early-before")
parser.add_argument("--prefer", choices=["late", "morning", "afternoon", "evening"])
parser.add_argument("--json", action="store_true", help="JSON 출력")
args = parser.parse_args()
human = [] if args.json else ["--human"]
extra_time_args = time_args(args)
has_multi_dest = bool(args.destinations and "," in args.destinations or (args.destinations and args.destination))
destinations_value = args.destinations or args.destination
if not destinations_value:
raise SystemExit("--destination 또는 --destinations 가 필요합니다.")
if args.when and not args.departure:
start_dt, end_dt = parse_date_range_text(args.when)
inferred_single_day = start_dt == end_dt
if has_multi_dest and not inferred_single_day:
return run_script(
"search_destination_date_matrix.py",
[
"--origin", args.origin,
"--destinations", destinations_value,
"--start-date", start_dt.strftime("%Y-%m-%d"),
"--end-date", end_dt.strftime("%Y-%m-%d"),
"--return-offset", str(args.return_offset),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
if has_multi_dest:
return run_script(
"search_multi_destination.py",
[
"--origin", args.origin,
"--destinations", destinations_value,
"--departure", start_dt.strftime("%Y-%m-%d"),
*(["--return-date", args.return_date] if args.return_date else []),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
if not inferred_single_day:
return run_script(
"search_date_range.py",
[
"--origin", args.origin,
"--destination", destinations_value,
"--start-date", start_dt.strftime("%Y-%m-%d"),
"--end-date", end_dt.strftime("%Y-%m-%d"),
"--return-offset", str(args.return_offset),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
return run_script(
"search_domestic.py",
[
"--origin", args.origin,
"--destination", destinations_value,
"--departure", start_dt.strftime("%Y-%m-%d"),
*(["--return-date", args.return_date] if args.return_date else []),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
if has_multi_dest and args.departure and args.return_offset > 0 and not args.return_date:
return run_script(
"search_destination_date_matrix.py",
[
"--origin", args.origin,
"--destinations", destinations_value,
"--start-date", args.departure,
"--end-date", args.departure,
"--return-offset", str(args.return_offset),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
if has_multi_dest and args.departure:
return run_script(
"search_multi_destination.py",
[
"--origin", args.origin,
"--destinations", destinations_value,
"--departure", args.departure,
*( ["--return-date", args.return_date] if args.return_date else []),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
if args.departure:
return run_script(
"search_domestic.py",
[
"--origin", args.origin,
"--destination", destinations_value,
"--departure", args.departure,
*(["--return-date", args.return_date] if args.return_date else []),
"--adults", str(args.adults),
"--cabin", args.cabin,
*extra_time_args,
*human,
],
)
raise SystemExit("날짜 정보가 없습니다. --when 또는 --departure 를 제공하세요.")
if __name__ == "__main__":
main()
FILE:scripts/common_cli.py
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Iterable, List, Sequence
AIRPORT_NAMES = {
"GMP": "김포",
"CJU": "제주",
"PUS": "부산",
"TAE": "대구",
"CJJ": "청주",
"KWJ": "광주",
"RSU": "여수",
"USN": "울산",
"HIN": "사천",
"KPO": "포항경주",
"YNY": "양양",
"MWX": "무안",
"SEL": "서울",
}
AIRPORT_ALIASES = {
"김포": "GMP",
"제주": "CJU",
"제주도": "CJU",
"부산": "PUS",
"김해": "PUS",
"대구": "TAE",
"청주": "CJJ",
"광주": "KWJ",
"여수": "RSU",
"울산": "USN",
"사천": "HIN",
"진주": "HIN",
"포항": "KPO",
"포항경주": "KPO",
"양양": "YNY",
"무안": "MWX",
"서울": "SEL",
"gimpo": "GMP",
"jeju": "CJU",
"busan": "PUS",
"daegu": "TAE",
"cheongju": "CJJ",
"gwangju": "KWJ",
"yeosu": "RSU",
"ulsan": "USN",
"sacheon": "HIN",
"pohang": "KPO",
"yangyang": "YNY",
"muan": "MWX",
"seoul": "SEL",
}
WEEKDAY_ALIASES = {
"월": 0,
"월요일": 0,
"화": 1,
"화요일": 1,
"수": 2,
"수요일": 2,
"목": 3,
"목요일": 3,
"금": 4,
"금요일": 4,
"토": 5,
"토요일": 5,
"일": 6,
"일요일": 6,
}
TIME_BUCKETS = {
"새벽": (0, 5),
"아침": (6, 10),
"오전": (6, 11),
"점심": (11, 13),
"오후": (12, 17),
"저녁": (18, 21),
"밤": (20, 23),
"야간": (20, 23),
"늦은": (18, 23),
}
def airport_label(code: str) -> str:
code = (code or "").upper()
return f"{AIRPORT_NAMES.get(code, code)}({code})" if code else ""
def normalize_airport(value: str) -> str:
if not value:
raise ValueError("공항 값이 비어 있습니다.")
raw = value.strip()
upper = raw.upper()
if upper in AIRPORT_NAMES:
return upper
lowered = raw.lower()
if lowered in AIRPORT_ALIASES:
return AIRPORT_ALIASES[lowered]
if raw in AIRPORT_ALIASES:
return AIRPORT_ALIASES[raw]
raise ValueError(f"지원하지 않는 공항 입력입니다: {value}")
def _base_today() -> datetime:
return datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
def _parse_month_day(raw: str, today: datetime) -> datetime | None:
match = re.fullmatch(r"(\d{1,2})[./\-월\s]+(\d{1,2})(?:일)?", raw)
if not match:
return None
month = int(match.group(1))
day = int(match.group(2))
year = today.year
candidate = datetime(year, month, day)
if candidate < today:
candidate = datetime(year + 1, month, day)
return candidate
def _parse_relative_days(raw: str, today: datetime) -> datetime | None:
match = re.fullmatch(r"(\d+)\s*(?:일 뒤|일후|days? later)", raw)
if match:
return today + timedelta(days=int(match.group(1)))
match = re.fullmatch(r"(\d+)\s*(?:주 뒤|주후)", raw)
if match:
return today + timedelta(days=7 * int(match.group(1)))
return None
def _next_weekday(today: datetime, weekday: int, week_offset: int = 0) -> datetime:
days_ahead = (weekday - today.weekday()) % 7
candidate = today + timedelta(days=days_ahead + week_offset * 7)
if week_offset == 0 and candidate < today:
candidate += timedelta(days=7)
return candidate
def _parse_weekday(raw: str, today: datetime) -> datetime | None:
raw = raw.strip()
for prefix, offset in (("이번주 ", 0), ("이번 주 ", 0), ("다음주 ", 1), ("다음 주 ", 1), ("오는 ", 0)):
if raw.startswith(prefix):
tail = raw[len(prefix):].strip()
if tail in WEEKDAY_ALIASES:
return _next_weekday(today, WEEKDAY_ALIASES[tail], offset)
if raw in WEEKDAY_ALIASES:
return _next_weekday(today, WEEKDAY_ALIASES[raw], 0)
if raw in ("주말", "이번주말", "이번 주말"):
return _next_weekday(today, 5, 0)
if raw in ("다음주말", "다음 주말"):
return _next_weekday(today, 5, 1)
return None
def parse_flexible_date(value: str) -> datetime:
raw = value.strip().lower()
today = _base_today()
mapping = {
"today": 0,
"오늘": 0,
"tomorrow": 1,
"내일": 1,
"day after tomorrow": 2,
"모레": 2,
"글피": 3,
}
if raw in mapping:
return today + timedelta(days=mapping[raw])
relative = _parse_relative_days(raw, today)
if relative:
return relative
weekday = _parse_weekday(raw, today)
if weekday:
return weekday
month_day = _parse_month_day(raw.replace(" ", " "), today)
if month_day:
return month_day
for fmt in ("%Y-%m-%d", "%Y%m%d", "%Y.%m.%d", "%Y/%m/%d"):
try:
return datetime.strptime(value, fmt)
except ValueError:
pass
raise ValueError(f"지원하지 않는 날짜 형식입니다: {value}")
def parse_date_range_text(value: str) -> tuple[datetime, datetime]:
raw = value.strip().lower()
today = _base_today()
m = re.fullmatch(r"(.+?)부터\s*(\d+)일", raw)
if m:
start = parse_flexible_date(m.group(1))
days = int(m.group(2))
return start, start + timedelta(days=max(days - 1, 0))
if raw in ("이번주말", "이번 주말", "주말"):
start = _next_weekday(today, 5, 0)
return start, start + timedelta(days=1)
if raw in ("다음주말", "다음 주말"):
start = _next_weekday(today, 5, 1)
return start, start + timedelta(days=1)
explicit_patterns = [
r"^\s*(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{8})\s*~\s*(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{8})\s*$",
r"^\s*(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{8})\s*to\s*(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{8})\s*$",
r"^\s*(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{8})\s*부터\s*(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{8})\s*(?:까지)?\s*$",
]
for pattern in explicit_patterns:
match = re.fullmatch(pattern, value, re.IGNORECASE)
if match:
start = parse_flexible_date(match.group(1).strip())
end = parse_flexible_date(match.group(2).strip())
return start, end
parts = re.split(r"\s*(?:~|부터|to)\s*", value, maxsplit=1)
if len(parts) == 2 and all(part.strip() for part in parts):
start = parse_flexible_date(parts[0].strip())
end = parse_flexible_date(parts[1].strip())
return start, end
single = parse_flexible_date(value)
return single, single
def pretty_date(value: datetime) -> str:
return value.strftime("%Y-%m-%d")
def compact_date(value: datetime) -> str:
return value.strftime("%Y%m%d")
def cabin_label(code: str) -> str:
return {
"ECONOMY": "이코노미",
"BUSINESS": "비즈니스",
"FIRST": "일등석",
}.get((code or "").upper(), code or "")
def format_price(value: int | float | None) -> str:
return f"{int(value or 0):,}원"
def format_time_or_fallback(value: str | None, fallback: str = "시간 정보 없음") -> str:
text = str(value or "").strip()
return text or fallback
def join_nonempty(parts: Sequence[str | None], sep: str = " · ") -> str:
return sep.join([str(part) for part in parts if part])
def add_section(lines: list[str], title: str, body: Sequence[str | None]) -> None:
items = [str(item) for item in body if item]
if not items:
return
if lines:
lines.append("")
lines.append(f"[{title}]")
lines.extend(items)
def summarize_price_gap(best_price: int, next_price: int | None) -> str | None:
if not best_price or not next_price or next_price <= best_price:
return None
gap = next_price - best_price
ratio = round((gap / best_price) * 100)
return f"2위보다 {gap:,}원 저렴해 가성비가 좋습니다{f' (약 {ratio}% 차이)' if ratio >= 5 else ''}."
def recommendation_line(subject: str, best_price: int, next_price: int | None = None) -> str:
gap_text = summarize_price_gap(best_price, next_price)
if gap_text:
return f"추천: 이번 조건에서는 {subject}이(가) 가장 유리합니다. {gap_text}"
return f"추천: 이번 조건에서는 {subject}이(가) 가장 무난한 최저가 선택입니다."
def explain_recommendation(subject: str, best_price: int, next_price: int | None = None, reasons: Sequence[str] | None = None) -> str:
parts: list[str] = [f"추천 사유: {subject} 기준"]
if best_price > 0:
parts.append(f"최저가 {format_price(best_price)}")
gap_text = summarize_price_gap(best_price, next_price)
if gap_text:
parts.append(gap_text)
if reasons:
parts.extend([reason for reason in reasons if reason])
return " · ".join(parts) + "."
def bullet_rank_lines(items: Sequence[dict], label_key: str, price_key: str, detail_builder=None, limit: int = 5) -> List[str]:
lines: List[str] = []
for idx, item in enumerate(items[:limit], start=1):
label = item.get(label_key, "옵션")
price = item.get(price_key, 0)
if price and price > 0:
detail = detail_builder(item) if detail_builder else ""
suffix = f" · {detail}" if detail else ""
lines.append(f"{idx}. {label} · {format_price(price)}{suffix}")
else:
lines.append(f"{idx}. {label} · 결과 없음")
return lines
def build_price_calendar(rows: Sequence[dict], date_key: str = "departure_date", price_key: str = "price") -> list[dict]:
available_prices = [int(item.get(price_key, 0) or 0) for item in rows if int(item.get(price_key, 0) or 0) > 0]
if not rows:
return []
cheapest = min(available_prices) if available_prices else 0
median = sorted(available_prices)[len(available_prices) // 2] if available_prices else 0
calendar = []
for item in rows:
price = int(item.get(price_key, 0) or 0)
if price <= 0:
band = "unavailable"
badge = "⚪"
note = "결과 없음"
elif cheapest and price == cheapest:
band = "best"
badge = "🟢"
note = "최저가"
elif median and price <= median:
band = "good"
badge = "🟡"
note = "양호"
else:
band = "high"
badge = "🔴"
note = "상대적으로 높음"
calendar.append({
"date": item.get(date_key),
"price": price,
"band": band,
"badge": badge,
"label": f"{item.get(date_key)} {badge} {format_price(price) if price > 0 else '결과 없음'} · {note}",
})
return calendar
def unique_codes(values: Iterable[str]) -> list[str]:
seen = set()
output = []
for value in values:
if value not in seen:
seen.add(value)
output.append(value)
return output
def parse_time_to_minutes(value: str | None) -> int | None:
if not value:
return None
raw = str(value).strip()
match = re.fullmatch(r"(\d{1,2})(?::(\d{2}))?", raw)
if not match:
return None
hour = int(match.group(1))
minute = int(match.group(2) or 0)
if 0 <= hour <= 23 and 0 <= minute <= 59:
return hour * 60 + minute
return None
@dataclass
class TimePreference:
depart_min: int | None = None
depart_max: int | None = None
return_min: int | None = None
return_max: int | None = None
exclude_before_depart: int | None = None
prefer: str | None = None
raw: str = ""
def active(self) -> bool:
return any(
value is not None for value in [self.depart_min, self.depart_max, self.return_min, self.return_max, self.exclude_before_depart]
) or bool(self.prefer)
def describe(self) -> str | None:
parts: list[str] = []
if self.depart_min is not None:
parts.append(f"출발 {format_minutes(self.depart_min)} 이후")
if self.depart_max is not None:
parts.append(f"출발 {format_minutes(self.depart_max)} 이전")
if self.return_min is not None:
parts.append(f"복귀 {format_minutes(self.return_min)} 이후")
if self.return_max is not None:
parts.append(f"복귀 {format_minutes(self.return_max)} 이전")
if self.exclude_before_depart is not None:
parts.append(f"너무 이른 비행 제외({format_minutes(self.exclude_before_depart)} 이전 제외)")
if self.prefer:
prefer_map = {
"late": "늦은 시간대 선호",
"morning": "오전 선호",
"afternoon": "오후 선호",
"evening": "저녁 선호",
}
parts.append(prefer_map.get(self.prefer, self.prefer))
return " · ".join(parts) if parts else None
def _set_min(current: int | None, value: int) -> int:
return value if current is None else max(current, value)
def _set_max(current: int | None, value: int) -> int:
return value if current is None else min(current, value)
def _split_time_segments(text: str) -> list[str]:
if not text:
return []
parts = [part.strip() for part in re.split(r"\s*(?:,|/|\||;| 그리고 | and )\s*", text) if part.strip()]
return parts or [text.strip()]
def _segment_scope(segment: str) -> str:
if any(keyword in segment for keyword in ["복귀", "귀환", "오는편", "오는 편", "리턴"]):
return "return"
return "depart"
def _apply_bucket(pref: TimePreference, scope: str, start_hour: int, end_hour: int) -> None:
start_minutes = start_hour * 60
end_minutes = end_hour * 60 + 59
if scope == "return":
pref.return_min = _set_min(pref.return_min, start_minutes)
pref.return_max = _set_max(pref.return_max, end_minutes)
else:
pref.depart_min = _set_min(pref.depart_min, start_minutes)
pref.depart_max = _set_max(pref.depart_max, end_minutes)
def _normalize_time_ranges(pref: TimePreference, raw: str) -> None:
if pref.depart_min is not None and pref.depart_max is not None and pref.depart_min > pref.depart_max:
if "출발" in raw and "이후" in raw:
pref.depart_max = None
elif "출발" in raw and "이전" in raw:
pref.depart_min = None
if pref.return_min is not None and pref.return_max is not None and pref.return_min > pref.return_max:
if any(token in raw for token in ["복귀", "귀환", "오는편", "오는 편"]) and "이후" in raw:
pref.return_max = None
elif any(token in raw for token in ["복귀", "귀환", "오는편", "오는 편"]) and "이전" in raw:
pref.return_min = None
def apply_time_overrides(pref: TimePreference, *, depart_after: str | None = None, return_after: str | None = None, exclude_early_before: str | None = None, prefer: str | None = None) -> TimePreference:
if depart_after:
minutes = parse_time_to_minutes(str(depart_after))
if minutes is None:
raise ValueError(f"지원하지 않는 출발 시간 형식입니다: {depart_after}")
pref.depart_min = _set_min(pref.depart_min, minutes)
if return_after:
minutes = parse_time_to_minutes(str(return_after))
if minutes is None:
raise ValueError(f"지원하지 않는 복귀 시간 형식입니다: {return_after}")
pref.return_min = _set_min(pref.return_min, minutes)
if exclude_early_before:
minutes = parse_time_to_minutes(str(exclude_early_before))
if minutes is None:
raise ValueError(f"지원하지 않는 제외 시간 형식입니다: {exclude_early_before}")
pref.exclude_before_depart = _set_min(pref.exclude_before_depart, minutes)
if prefer:
pref.prefer = prefer
return pref
def parse_time_preference_args(args) -> TimePreference:
return apply_time_overrides(
parse_time_preference_text(getattr(args, "time_pref", None)),
depart_after=getattr(args, "depart_after", None),
return_after=getattr(args, "return_after", None),
exclude_early_before=getattr(args, "exclude_early_before", None),
prefer=getattr(args, "prefer", None),
)
def time_preference_cli_args(time_pref: dict | None) -> list[str]:
tp = time_pref or {}
args: list[str] = []
if tp.get("time_pref"):
args.extend(["--time-pref", str(tp["time_pref"])])
if tp.get("depart_after"):
args.extend(["--depart-after", str(tp["depart_after"])])
if tp.get("return_after"):
args.extend(["--return-after", str(tp["return_after"])])
if tp.get("exclude_early_before"):
args.extend(["--exclude-early-before", str(tp["exclude_early_before"])])
if tp.get("prefer"):
args.extend(["--prefer", str(tp["prefer"])])
return args
def describe_time_preference_payload(time_pref: dict | None) -> str | None:
tp = time_pref or {}
pref = apply_time_overrides(
parse_time_preference_text(tp.get("time_pref")),
depart_after=tp.get("depart_after"),
return_after=tp.get("return_after"),
exclude_early_before=tp.get("exclude_early_before"),
prefer=tp.get("prefer"),
)
return pref.describe()
def format_minutes(value: int | None) -> str:
if value is None:
return ""
hour = value // 60
minute = value % 60
return f"{hour:02d}:{minute:02d}"
def parse_time_preference_text(text: str | None) -> TimePreference:
pref = TimePreference(raw=text or "")
if not text:
return pref
normalized = str(text).strip().lower()
normalized = normalized.replace("시 이후", "시이후").replace("시 이전", "시이전").replace("시 전", "시이전")
for segment in _split_time_segments(normalized):
scope = _segment_scope(segment)
for key, (start_hour, end_hour) in TIME_BUCKETS.items():
if key in segment:
prefer_only = f"{key} 선호" in segment or f"{key}시간 선호" in segment or f"{key} 시간 선호" in segment
if not prefer_only:
_apply_bucket(pref, scope, start_hour, end_hour)
if "늦은 시간" in segment or "늦게" in segment:
pref.prefer = pref.prefer or "late"
elif "오전 선호" in segment:
pref.prefer = pref.prefer or "morning"
elif "오후 선호" in segment:
pref.prefer = pref.prefer or "afternoon"
elif "저녁 선호" in segment:
pref.prefer = pref.prefer or "evening"
for pattern, target in [
(r"출발\s*(\d{1,2})(?::(\d{2}))?\s*시?이후", "depart_min"),
(r"출발\s*(\d{1,2})(?::(\d{2}))?\s*시?이전", "depart_max"),
(r"복귀\s*(\d{1,2})(?::(\d{2}))?\s*시?이후", "return_min"),
(r"귀환\s*(\d{1,2})(?::(\d{2}))?\s*시?이후", "return_min"),
(r"오는편\s*(\d{1,2})(?::(\d{2}))?\s*시?이후", "return_min"),
(r"오는 편\s*(\d{1,2})(?::(\d{2}))?\s*시?이후", "return_min"),
(r"복귀\s*(\d{1,2})(?::(\d{2}))?\s*시?이전", "return_max"),
(r"귀환\s*(\d{1,2})(?::(\d{2}))?\s*시?이전", "return_max"),
(r"오는편\s*(\d{1,2})(?::(\d{2}))?\s*시?이전", "return_max"),
(r"오는 편\s*(\d{1,2})(?::(\d{2}))?\s*시?이전", "return_max"),
(r"너무\s*이른\s*비행\s*제외.*?(\d{1,2})(?::(\d{2}))?\s*시", "exclude_before_depart"),
(r"(\d{1,2})(?::(\d{2}))?\s*시\s*이전\s*비행\s*제외", "exclude_before_depart"),
]:
match = re.search(pattern, segment)
if match:
minutes = int(match.group(1)) * 60 + int(match.group(2) or 0)
current = getattr(pref, target)
if target.endswith("_max"):
setattr(pref, target, _set_max(current, minutes) if current is not None else minutes)
elif target == "exclude_before_depart":
setattr(pref, target, _set_min(current, minutes) if current is not None else minutes)
else:
setattr(pref, target, _set_min(current, minutes) if current is not None else minutes)
_normalize_time_ranges(pref, segment)
return pref
def _within_range(value_minutes: int | None, min_minutes: int | None, max_minutes: int | None) -> bool:
if value_minutes is None:
return False
if min_minutes is not None and value_minutes < min_minutes:
return False
if max_minutes is not None and value_minutes > max_minutes:
return False
return True
def _score_time_preference(item: dict, pref: TimePreference) -> int:
depart = parse_time_to_minutes(item.get("departure_time"))
ret = parse_time_to_minutes(item.get("return_departure_time"))
score = 0
if pref.prefer == "late":
score += depart or 0
score += (ret or 0) // 2
elif pref.prefer == "morning":
score -= abs((depart or 12 * 60) - 9 * 60)
elif pref.prefer == "afternoon":
score -= abs((depart or 12 * 60) - 15 * 60)
elif pref.prefer == "evening":
score -= abs((depart or 12 * 60) - 19 * 60)
return score
def filter_and_rank_by_time_preference(items: Sequence[dict], pref: TimePreference) -> tuple[list[dict], list[dict]]:
if not pref.active():
return list(items), sorted(list(items), key=lambda x: x.get("price", 0) if x.get("price", 0) > 0 else 10**12)
filtered = []
for item in items:
depart = parse_time_to_minutes(item.get("departure_time"))
ret = parse_time_to_minutes(item.get("return_departure_time"))
if pref.exclude_before_depart is not None and (depart is None or depart < pref.exclude_before_depart):
continue
if (pref.depart_min is not None or pref.depart_max is not None) and not _within_range(depart, pref.depart_min, pref.depart_max):
continue
if item.get("is_round_trip") and (pref.return_min is not None or pref.return_max is not None) and not _within_range(ret, pref.return_min, pref.return_max):
continue
filtered.append(item)
ranked = sorted(
filtered,
key=lambda x: (
-_score_time_preference(x, pref),
x.get("price", 0) if x.get("price", 0) > 0 else 10**12,
),
)
filtered.sort(key=lambda x: x.get("price", 0) if x.get("price", 0) > 0 else 10**12)
return filtered, ranked
def choose_preferred_option(items: Sequence[dict], pref: TimePreference) -> dict | None:
_, ranked = filter_and_rank_by_time_preference(items, pref)
return ranked[0] if ranked else None
def choose_balanced_round_trip_option(items: Sequence[dict], pref: TimePreference | None = None) -> dict | None:
candidates = [item for item in items if (item.get("price", 0) or 0) > 0]
if not candidates:
return None
candidates = sorted(candidates, key=lambda x: x.get("price", 0))
cheapest_price = candidates[0].get("price", 0) or 0
price_cap = int(cheapest_price * 1.15) if cheapest_price else 0
pool = [item for item in candidates if (item.get("price", 0) or 0) <= price_cap][:5] or candidates[:3]
def score(item: dict) -> tuple:
price = int(item.get("price", 0) or 0)
depart = parse_time_to_minutes(item.get("departure_time")) or 0
ret = parse_time_to_minutes(item.get("return_departure_time")) or 0
time_score = _score_time_preference(item, pref or TimePreference()) if pref else (depart // 2 + ret)
return (
-time_score,
price,
)
return sorted(pool, key=score)[0] if pool else candidates[0]
def round_trip_balance_recommendation(balanced: dict | None, cheapest: dict | None, pref: TimePreference | None = None) -> str | None:
if not balanced:
return None
label = balanced.get("airline") or "옵션"
depart = format_time_or_fallback(balanced.get("departure_time"))
ret = format_time_or_fallback(balanced.get("return_departure_time"))
if cheapest and cheapest.get("price", 0) and balanced.get("price", 0):
gap = int(balanced.get("price", 0)) - int(cheapest.get("price", 0))
gap_text = "최저가와 동일 가격" if gap == 0 else (f"최저가 대비 {gap:,}원 추가" if gap > 0 else f"최저가보다 {-gap:,}원 저렴")
else:
gap_text = "가격 비교 정보 없음"
reason = pref.describe() if pref and pref.describe() else "왕복 시간 균형"
return f"왕복 균형 추천: {reason} 기준으로는 {label} 가는편 {depart}, 오는편 {ret} 조합이 무난합니다 ({gap_text})."
def build_best_option_reasons(best: dict | None, next_price: int | None = None, pref: TimePreference | None = None) -> list[str]:
if not best:
return []
reasons: list[str] = []
airline = best.get("airline")
depart = best.get("departure_time")
arrival = best.get("arrival_time")
if airline:
if depart and arrival:
reasons.append(f"항공사 {airline}, 가는편 {depart}→{arrival}")
else:
reasons.append(f"항공사 {airline}")
if best.get("return_departure_time") or best.get("return_arrival_time"):
ret_depart = format_time_or_fallback(best.get("return_departure_time"))
ret_arrive = best.get("return_arrival_time")
if ret_arrive:
reasons.append(f"오는편 {ret_depart}→{ret_arrive}")
else:
reasons.append(f"오는편 {ret_depart} 출발")
if pref and pref.describe():
reasons.append(f"시간 조건 '{pref.describe()}' 반영")
price = int(best.get("price", 0) or best.get("cheapest_price", 0) or 0)
if next_price and price and next_price > price:
reasons.append(f"차상위 대비 {next_price - price:,}원 저렴")
return reasons
def build_balanced_option_reasons(balanced: dict | None, cheapest: dict | None = None, pref: TimePreference | None = None) -> list[str]:
if not balanced:
return []
reasons: list[str] = []
depart = format_time_or_fallback(balanced.get("departure_time"))
ret = format_time_or_fallback(balanced.get("return_departure_time"))
reasons.append(f"왕복 시간대 {depart} / {ret} 조합")
if pref and pref.describe():
reasons.append(f"시간 선호 '{pref.describe()}'에 더 잘 맞음")
price = int(balanced.get("price", 0) or 0)
cheapest_price = int((cheapest or {}).get("price", 0) or 0)
if price and cheapest_price:
gap = price - cheapest_price
if gap == 0:
reasons.append("최저가와 동일한 가격")
elif gap > 0:
reasons.append(f"최저가 대비 추가 비용 {gap:,}원 수준")
return reasons
def time_preference_recommendation(preferred: dict | None, cheapest: dict | None, pref: TimePreference) -> str | None:
if not pref.active() or not preferred:
return None
subject = preferred.get("airline", "옵션")
depart = format_time_or_fallback(preferred.get("departure_time"))
price = preferred.get("price", 0)
if cheapest and cheapest is not preferred and cheapest.get("price", 0) > 0 and price > 0:
gap = price - cheapest.get("price", 0)
gap_text = f"최저가 대비 {gap:,}원 추가" if gap > 0 else "최저가와 동일 가격"
else:
gap_text = "최저가와 같은 옵션"
detail = pref.describe() or "시간 선호"
if preferred.get("is_round_trip") and preferred.get("return_departure_time"):
return f"시간대 추천: {detail} 기준으로는 {subject} 가는편 {depart}, 오는편 {format_time_or_fallback(preferred.get('return_departure_time'))} 옵션이 적합합니다 ({gap_text})."
return f"시간대 추천: {detail} 기준으로는 {subject} {depart} 출발 옵션이 적합합니다 ({gap_text})."
FILE:scripts/hybrid_live_dry_run.py
#!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import normalize_airport
def main():
parser = argparse.ArgumentParser(description="Shallow live dry-run / DOM drift sanity check for korea-domestic-flights")
parser.add_argument("--origin", default="김포")
parser.add_argument("--destination", default="제주")
parser.add_argument("--departure", default="내일", help="Currently informational only; live probe uses broad date-range scanner.")
parser.add_argument("--probe", action="store_true", help="Actually attempt a 1-day broad scan. Without this flag, only environment/import checks run.")
args = parser.parse_args()
workspace = Path(__file__).resolve().parents[3]
repo_path = workspace / "tmp" / "Scraping-flight-information"
report = {
"status": "ok",
"mode": "live_probe" if args.probe else "env_only",
"checks": [],
"repo_path": str(repo_path),
"probe": None,
}
report["checks"].append({"name": "repo_exists", "ok": repo_path.exists()})
if not repo_path.exists():
report["status"] = "degraded"
print(json.dumps(report, ensure_ascii=False, indent=2))
sys.exit(0)
sys.path.insert(0, str(repo_path))
try:
from scraping.parallel import ParallelSearcher
except Exception as exc:
report["status"] = "degraded"
report["checks"].append({"name": "import_parallel_searcher", "ok": False, "error": str(exc)})
print(json.dumps(report, ensure_ascii=False, indent=2))
sys.exit(0)
report["checks"].append({"name": "import_parallel_searcher", "ok": True})
if not args.probe:
print(json.dumps(report, ensure_ascii=False, indent=2))
return
try:
origin = normalize_airport(args.origin)
destination = normalize_airport(args.destination)
except Exception as exc:
report["status"] = "degraded"
report["probe"] = {"ok": False, "error": f"invalid route input: {exc}"}
print(json.dumps(report, ensure_ascii=False, indent=2))
sys.exit(0)
searcher = ParallelSearcher()
probe_logs = []
try:
raw = searcher.search_date_range(
origin=origin,
destination=destination,
dates=[],
return_offset=0,
adults=1,
cabin_class="ECONOMY",
progress_callback=lambda msg: probe_logs.append(str(msg)),
)
report["probe"] = {
"ok": isinstance(raw, dict),
"route": f"{origin}-{destination}",
"raw_type": type(raw).__name__,
"keys": list(raw.keys())[:5],
"log_preview": probe_logs[:10],
"note": "Empty-date broad scan probe completed. This validates import + shallow execution path without asserting fare availability.",
}
except TypeError as exc:
report["status"] = "degraded"
report["probe"] = {
"ok": False,
"route": f"{origin}-{destination}",
"error": str(exc),
"note": "ParallelSearcher.search_date_range signature or call contract may have drifted.",
}
except Exception as exc:
report["status"] = "degraded"
report["probe"] = {
"ok": False,
"route": f"{origin}-{destination}",
"error": str(exc),
"log_preview": probe_logs[:10],
"note": "Live probe failed; inspect upstream scraper/browser environment or DOM drift.",
}
finally:
close_fn = getattr(searcher, "close", None)
if callable(close_fn):
try:
close_fn()
except Exception:
pass
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/hybrid_observability.py
from __future__ import annotations
from typing import Callable, Iterable
EMPTY_LIKE_REASONS = {
"not_attempted",
"broad_only",
"detailed_match",
"detailed_match_with_time_pref",
}
REASON_LABELS = {
"not_attempted": "미시도",
"broad_only": "빠른 스캔 전용",
"detailed_match": "상세 검색 일치",
"detailed_match_with_time_pref": "시간 조건 일치",
"broad_candidate_time_rejected": "시간 조건 탈락",
"detailed_no_usable_time_filter_match": "usable match 없음",
"detail_empty_after_broad_hit": "빠른 스캔 가격 있었지만 상세 결과 비어 있음",
"detail_empty_no_broad_signal": "상세 결과 비어 있음",
"detail_missing_departure_times": "출발 시간 정보 부족",
"detail_partial_departure_times": "출발 시간 정보 부분 누락",
"detail_missing_return_times": "복귀 시간 정보 부족",
"detail_partial_return_times": "복귀 시간 정보 부분 누락",
"detail_missing_price_data": "가격 정보 부족",
"detail_sparse_price_data": "가격 정보 일부 누락",
}
REASON_CODES = {
"not_attempted": "diagnostic.not_attempted",
"broad_only": "diagnostic.broad_only",
"detailed_match": "success.detail_match",
"detailed_match_with_time_pref": "success.time_pref_match",
"broad_candidate_time_rejected": "filter.time_pref_rejected",
"detailed_no_usable_time_filter_match": "filter.no_usable_match",
"detail_empty_after_broad_hit": "extraction.detail_empty_after_broad_hit",
"detail_empty_no_broad_signal": "extraction.detail_empty_no_broad_signal",
"detail_missing_departure_times": "extraction.missing_departure_times",
"detail_partial_departure_times": "extraction.partial_departure_times",
"detail_missing_return_times": "extraction.missing_return_times",
"detail_partial_return_times": "extraction.partial_return_times",
"detail_missing_price_data": "extraction.missing_price_data",
"detail_sparse_price_data": "extraction.sparse_price_data",
}
REASON_CATEGORIES = {
"not_attempted": "diagnostic",
"broad_only": "diagnostic",
"detailed_match": "success",
"detailed_match_with_time_pref": "success",
"broad_candidate_time_rejected": "time_filter",
"detailed_no_usable_time_filter_match": "time_filter",
"detail_empty_after_broad_hit": "extraction",
"detail_empty_no_broad_signal": "extraction",
"detail_missing_departure_times": "extraction",
"detail_partial_departure_times": "extraction",
"detail_missing_return_times": "extraction",
"detail_partial_return_times": "extraction",
"detail_missing_price_data": "extraction",
"detail_sparse_price_data": "extraction",
}
REASON_PRIORITY = {
"detail_empty_after_broad_hit": 100,
"detail_missing_return_times": 95,
"detail_missing_departure_times": 94,
"detail_missing_price_data": 93,
"detail_partial_return_times": 92,
"detail_partial_departure_times": 91,
"detail_sparse_price_data": 90,
"broad_candidate_time_rejected": 70,
"detailed_no_usable_time_filter_match": 69,
"detailed_match_with_time_pref": 10,
"detailed_match": 9,
"broad_only": 2,
"not_attempted": 1,
}
def short_reason_label(reason: str) -> str:
return REASON_LABELS.get(reason, reason)
def reason_code(reason: str | None) -> str | None:
if not reason:
return None
return REASON_CODES.get(reason, f"unknown.{reason}")
def reason_category(reason: str | None) -> str | None:
if not reason:
return None
return REASON_CATEGORIES.get(reason, "unknown")
def classify_refine_row(row: dict | None) -> str:
if not row:
return "not_attempted"
explicit = str(row.get("diagnostic_reason") or "").strip()
if explicit:
return explicit
if row.get("search_stage") == "broad_only":
return "broad_only"
raw_count = int(row.get("raw_option_count") or 0)
valid_count = int(row.get("time_pref_valid_count") or 0)
broad_price = int(row.get("broad_price") or 0)
if valid_count > 0:
return "detailed_match_with_time_pref" if row.get("time_pref_match") else "detailed_match"
if raw_count <= 0:
return "detail_empty_after_broad_hit" if broad_price > 0 else "detail_empty_no_broad_signal"
if broad_price > 0:
return "broad_candidate_time_rejected"
return "detailed_no_usable_time_filter_match"
def _detail_hint(row: dict) -> str | None:
detail = row.get("diagnostic_detail") or {}
hint = str(detail.get("hint") or "").strip()
return hint or None
def _rank_reasons(counts: dict[str, int]) -> list[dict]:
ranked = []
for reason, count in counts.items():
ranked.append({
"reason": reason,
"reason_code": reason_code(reason),
"label": short_reason_label(reason),
"category": reason_category(reason),
"count": count,
"priority": REASON_PRIORITY.get(reason, 0),
})
ranked.sort(key=lambda item: (-item["priority"], -item["count"], item["reason"]))
return ranked
def build_refine_diagnostics(
broad_rows: Iterable[dict],
detailed_rows: Iterable[dict],
*,
key_fn: Callable[[dict], object],
label_fn: Callable[[dict], str],
sample_limit: int = 5,
) -> dict:
broad_rows = list(broad_rows)
detailed_rows = list(detailed_rows)
broad_map = {key_fn(row): row for row in broad_rows}
detailed_map = {key_fn(row): row for row in detailed_rows}
counts: dict[str, int] = {}
samples: dict[str, list[str]] = {}
hint_counts: dict[str, int] = {}
hint_samples: list[str] = []
extraction_totals = {
"rows": 0,
"raw_options": 0,
"priced_options": 0,
"departure_time_options": 0,
"return_time_options": 0,
"missing_price_rows": 0,
"sparse_price_rows": 0,
"missing_departure_time_rows": 0,
"partial_departure_time_rows": 0,
"missing_return_time_rows": 0,
"partial_return_time_rows": 0,
}
for key, broad in broad_map.items():
detailed = detailed_map.get(key)
if detailed is None:
merged = dict(broad)
merged.setdefault("search_stage", "broad_only")
else:
merged = dict(broad)
merged.update(detailed)
reason = classify_refine_row(merged)
counts[reason] = counts.get(reason, 0) + 1
samples.setdefault(reason, [])
sample_label = label_fn(merged)
hint = _detail_hint(merged)
if hint:
sample_label = f"{sample_label} ({hint})"
hint_counts[hint] = hint_counts.get(hint, 0) + 1
if len(hint_samples) < sample_limit and hint not in hint_samples:
hint_samples.append(hint)
if len(samples[reason]) < sample_limit:
samples[reason].append(sample_label)
if detailed is not None:
raw_option_count = int(merged.get("raw_option_count") or 0)
priced_option_count = int(merged.get("priced_option_count") or 0)
departure_time_count = int(merged.get("departure_time_count") or 0)
return_time_count = int(merged.get("return_time_count") or 0)
extraction_totals["rows"] += 1
extraction_totals["raw_options"] += raw_option_count
extraction_totals["priced_options"] += priced_option_count
extraction_totals["departure_time_options"] += departure_time_count
extraction_totals["return_time_options"] += return_time_count
if raw_option_count > 0:
if priced_option_count <= 0:
extraction_totals["missing_price_rows"] += 1
elif priced_option_count < raw_option_count:
extraction_totals["sparse_price_rows"] += 1
if departure_time_count <= 0:
extraction_totals["missing_departure_time_rows"] += 1
elif departure_time_count < raw_option_count:
extraction_totals["partial_departure_time_rows"] += 1
if bool(merged.get("has_return_time_constraint")):
if return_time_count <= 0:
extraction_totals["missing_return_time_rows"] += 1
elif return_time_count < raw_option_count:
extraction_totals["partial_return_time_rows"] += 1
attempted = len(detailed_map)
broad_available = sum(1 for row in broad_rows if (row.get("price") or 0) > 0)
success = counts.get("detailed_match_with_time_pref", 0)
rejected = counts.get("broad_candidate_time_rejected", 0) + counts.get("detailed_no_usable_time_filter_match", 0)
extraction_incomplete = sum(
counts.get(reason, 0)
for reason in (
"detail_empty_after_broad_hit",
"detail_empty_no_broad_signal",
"detail_missing_departure_times",
"detail_partial_departure_times",
"detail_missing_return_times",
"detail_partial_return_times",
"detail_missing_price_data",
"detail_sparse_price_data",
)
)
empty_like = (
counts.get("detail_empty_after_broad_hit", 0)
+ counts.get("detail_empty_no_broad_signal", 0)
)
remaining_available = max(0, broad_available - attempted)
attempted_with_broad = max(1, sum(1 for row in detailed_rows if (row.get("broad_price") or 0) > 0))
extraction_summary = {
**extraction_totals,
"price_coverage_ratio": extraction_totals["priced_options"] / max(1, extraction_totals["raw_options"]),
"departure_time_coverage_ratio": extraction_totals["departure_time_options"] / max(1, extraction_totals["raw_options"]),
"return_time_coverage_ratio": extraction_totals["return_time_options"] / max(1, extraction_totals["raw_options"]),
}
summary_bits = []
if success:
summary_bits.append(f"시간 조건 일치 {success}건")
if counts.get("broad_candidate_time_rejected"):
summary_bits.append(f"시간 조건 탈락 {counts['broad_candidate_time_rejected']}건")
if counts.get("detailed_no_usable_time_filter_match"):
summary_bits.append(f"usable match 없음 {counts['detailed_no_usable_time_filter_match']}건")
if counts.get("detail_empty_after_broad_hit"):
summary_bits.append(f"빠른 스캔가 있었지만 상세 비어 있음 {counts['detail_empty_after_broad_hit']}건")
if counts.get("detail_empty_no_broad_signal"):
summary_bits.append(f"상세 빈결과 {counts['detail_empty_no_broad_signal']}건")
if counts.get("detail_missing_departure_times"):
summary_bits.append(f"출발 시간 정보 부족 {counts['detail_missing_departure_times']}건")
if counts.get("detail_partial_departure_times"):
summary_bits.append(f"출발 시간 부분 누락 {counts['detail_partial_departure_times']}건")
if counts.get("detail_missing_return_times"):
summary_bits.append(f"복귀 시간 정보 부족 {counts['detail_missing_return_times']}건")
if counts.get("detail_partial_return_times"):
summary_bits.append(f"복귀 시간 부분 누락 {counts['detail_partial_return_times']}건")
if counts.get("detail_missing_price_data"):
summary_bits.append(f"가격 정보 부족 {counts['detail_missing_price_data']}건")
if counts.get("detail_sparse_price_data"):
summary_bits.append(f"가격 정보 일부 누락 {counts['detail_sparse_price_data']}건")
if extraction_totals["partial_departure_time_rows"] and not counts.get("detail_partial_departure_times"):
summary_bits.append(f"출발 시간 일부 누락 후보 {extraction_totals['partial_departure_time_rows']}건")
if extraction_totals["partial_return_time_rows"] and not counts.get("detail_partial_return_times"):
summary_bits.append(f"복귀 시간 일부 누락 후보 {extraction_totals['partial_return_time_rows']}건")
if extraction_totals["sparse_price_rows"] and not counts.get("detail_sparse_price_data"):
summary_bits.append(f"가격 일부 누락 후보 {extraction_totals['sparse_price_rows']}건")
if not summary_bits:
summary_bits.append("상세 진단 데이터 없음")
ranked_reasons = _rank_reasons(counts)
dominant_reason = ranked_reasons[0]["reason"] if ranked_reasons else None
user_hint = None
developer_hint = None
if counts.get("detail_empty_after_broad_hit"):
user_hint = "일부 날짜/조합은 빠른 스캔 가격이 있었지만 상세 단계에서 빈결과가 나와 추가 후보를 다시 확인했습니다."
developer_hint = "broad/detail 불일치 빈도가 있어 upstream DOM 변화나 상세 페이지 추출 불안정 가능성을 점검하세요."
elif counts.get("detail_missing_return_times") or counts.get("detail_partial_return_times"):
if counts.get("detail_missing_return_times"):
user_hint = "일부 왕복 후보는 복귀 시간 정보가 부족해 시간 조건 판단에서 제외됐습니다."
else:
user_hint = "일부 왕복 후보는 복귀 시간 정보가 들쭉날쭉해 시간 조건 판단 신뢰도가 낮았습니다."
developer_hint = "return_departure_time 추출 커버리지와 왕복 결과 shape 변화를 점검하세요."
elif counts.get("detail_missing_departure_times") or counts.get("detail_partial_departure_times"):
if counts.get("detail_missing_departure_times"):
user_hint = "일부 후보는 출발 시간 정보가 부족해 시간 조건 판단에서 제외됐습니다."
else:
user_hint = "일부 후보는 출발 시간 정보가 부분 누락되어 시간 조건 판단 신뢰도가 낮았습니다."
developer_hint = "departure_time 추출 커버리지와 시간 셀렉터 안정성을 점검하세요."
elif counts.get("detail_missing_price_data") or counts.get("detail_sparse_price_data"):
user_hint = "일부 상세 후보는 가격 정보가 불완전해 제외됐습니다."
developer_hint = "price 추출 커버리지와 결과 카드별 가격 필드 누락 비율을 점검하세요."
elif counts.get("broad_candidate_time_rejected"):
user_hint = "빠른 스캔 최저가 중 일부는 요청한 시간 조건과 맞지 않아 제외됐습니다."
human_hint = user_hint
return {
"counts": counts,
"reason_codes": {reason: reason_code(reason) for reason in counts},
"reason_categories": {reason: reason_category(reason) for reason in counts},
"ranked_reasons": ranked_reasons,
"samples": samples,
"summary_text": ", ".join(summary_bits),
"broad_available": broad_available,
"attempted": attempted,
"success": success,
"rejected": rejected,
"extraction_incomplete": extraction_incomplete,
"empty_like": empty_like,
"remaining_available": remaining_available,
"rejection_ratio": rejected / attempted_with_broad,
"empty_like_ratio": empty_like / max(1, attempted),
"extraction_incomplete_ratio": extraction_incomplete / max(1, attempted),
"dominant_reason": dominant_reason,
"dominant_reason_code": reason_code(dominant_reason),
"dominant_reason_category": reason_category(dominant_reason),
"dominant_reason_label": short_reason_label(dominant_reason) if dominant_reason else None,
"primary_interpretation": "extraction_incomplete" if reason_category(dominant_reason) == "extraction" else ("time_filter_rejection" if reason_category(dominant_reason) == "time_filter" else reason_category(dominant_reason)),
"hint_counts": hint_counts,
"hint_samples": hint_samples,
"human_hint": human_hint,
"user_hint": user_hint,
"developer_hint": developer_hint,
"extraction_summary": extraction_summary,
}
def choose_fallback_plan(diag: dict, *, minimum_target: int, hard_cap: int, pad: int) -> dict:
broad_available = int(diag.get("broad_available") or 0)
success = int(diag.get("success") or 0)
remaining_available = int(diag.get("remaining_available") or 0)
rejected = int(diag.get("rejected") or 0)
extraction_incomplete = int(diag.get("extraction_incomplete") or 0)
empty_like = int(diag.get("empty_like") or 0)
rejection_ratio = float(diag.get("rejection_ratio") or 0.0)
empty_like_ratio = float(diag.get("empty_like_ratio") or 0.0)
extraction_incomplete_ratio = float(diag.get("extraction_incomplete_ratio") or 0.0)
dominant_reason = str(diag.get("dominant_reason") or "")
dominant_category = str(diag.get("dominant_reason_category") or "")
target = min(max(1, minimum_target), broad_available) if broad_available else 0
shortfall = max(0, target - success)
reasons = []
if shortfall > 0:
reasons.append("coverage.time_pref_shortfall")
if success == 0 and rejected > 0:
reasons.append("coverage.zero_success_with_rejections")
if remaining_available > 0 and rejection_ratio >= 0.5:
reasons.append("signal.high_time_filter_rejection_ratio")
if remaining_available > 0 and extraction_incomplete > 0 and extraction_incomplete_ratio >= 0.35:
reasons.append("signal.high_extraction_incomplete_ratio")
if remaining_available > 0 and empty_like > 0 and empty_like_ratio >= 0.35:
reasons.append("signal.high_empty_like_ratio")
if remaining_available > 0 and dominant_reason == "detail_empty_after_broad_hit":
reasons.append("signal.broad_detail_disagreement")
if remaining_available > 0 and dominant_category == "extraction":
reasons.append(f"signal.extraction_dominant:{reason_code(dominant_reason)}")
if remaining_available > 0 and dominant_category == "time_filter":
reasons.append(f"signal.time_filter_dominant:{reason_code(dominant_reason)}")
triggered = bool(reasons) and remaining_available > 0
limit = 0
if triggered:
extra_pad = pad
if dominant_reason == "detail_empty_after_broad_hit":
extra_pad += 1
if dominant_category == "extraction":
extra_pad += 1
limit = min(remaining_available, max(shortfall + extra_pad, min(hard_cap, remaining_available)))
return {
"triggered": triggered,
"target": target,
"shortfall": shortfall,
"limit": limit,
"reasons": reasons,
"primary_reason": reasons[0] if reasons else None,
"dominant_reason": dominant_reason or None,
"dominant_reason_code": reason_code(dominant_reason),
"dominant_reason_category": dominant_category or None,
}
FILE:scripts/hybrid_smoke_check.py
#!/usr/bin/env python3
import json
from pathlib import Path
from hybrid_observability import build_refine_diagnostics, choose_fallback_plan
FIXTURE_PATH = Path(__file__).resolve().parents[1] / "references" / "hybrid-smoke-fixtures.json"
def _load_fixtures():
return json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
def _assert_reason_prefixes(actual_reasons, expected_prefixes):
for prefix in expected_prefixes:
assert any(reason.startswith(prefix) for reason in actual_reasons), f"missing fallback reason prefix: {prefix}"
def _run_case(case, *, key_fn, label_fn, minimum_target=2, hard_cap=4, pad=2):
diag = build_refine_diagnostics(
case["broad_rows"],
case["detailed_rows"],
key_fn=key_fn,
label_fn=label_fn,
)
plan = choose_fallback_plan(diag, minimum_target=minimum_target, hard_cap=hard_cap, pad=pad)
expect = case.get("expect", {})
for reason in expect.get("required_reasons", []):
assert diag["counts"].get(reason), f"missing expected reason: {reason}"
if "dominant_reason" in expect:
assert diag["dominant_reason"] == expect["dominant_reason"], f"unexpected dominant_reason: {diag['dominant_reason']}"
if "primary_interpretation" in expect:
assert diag["primary_interpretation"] == expect["primary_interpretation"], f"unexpected interpretation: {diag['primary_interpretation']}"
if "fallback_triggered" in expect:
assert plan["triggered"] is expect["fallback_triggered"], f"unexpected fallback trigger state: {plan['triggered']}"
_assert_reason_prefixes(plan["reasons"], expect.get("required_fallback_reason_prefixes", []))
for key, value in expect.get("extraction_summary", {}).items():
assert diag["extraction_summary"].get(key) == value, f"unexpected extraction_summary[{key}]: {diag['extraction_summary'].get(key)}"
assert diag["ranked_reasons"], "ranked reasons should not be empty"
if diag.get("dominant_reason"):
assert diag.get("dominant_reason_code"), "dominant reason code missing"
assert diag.get("dominant_reason_category"), "dominant reason category missing"
return {"diagnostics": diag, "fallback_plan": plan}
def run_date_range_cases():
fixtures = _load_fixtures()
outputs = {}
for case in fixtures.get("date_range_cases", []):
outputs[case["name"]] = _run_case(
case,
key_fn=lambda row: row["departure_date"],
label_fn=lambda row: row["departure_date"],
)
return outputs
def run_matrix_cases():
fixtures = _load_fixtures()
outputs = {}
for case in fixtures.get("matrix_cases", []):
outputs[case["name"]] = _run_case(
case,
key_fn=lambda row: (row["destination"], row["departure_date"]),
label_fn=lambda row: f"{row['destination']} {row['departure_date']}",
)
return outputs
if __name__ == "__main__":
print(json.dumps({
"fixture_path": str(FIXTURE_PATH),
"date_range_cases": run_date_range_cases(),
"matrix_cases": run_matrix_cases(),
}, ensure_ascii=False, indent=2))
FILE:scripts/price_alerts.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
DEFAULT_STORE = SKILL_DIR / "price-alert-rules.json"
KST_LABEL = "Asia/Seoul"
DEFAULT_MESSAGE_TEMPLATE = """[국내선 가격 알림] {label}
- 노선: {route}
- 조건: 성인 {adults}명 · {cabin_label}
- 목표가: {target_price}
- 확인된 최저가: {observed_price}
- 일정: {date_text}{best_destination_line}{airline_line}{time_line}
- 상태: {status_line}"""
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import airport_label, cabin_label, describe_time_preference_payload, format_price, normalize_airport, parse_date_range_text, parse_flexible_date, pretty_date, time_preference_cli_args, unique_codes
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def load_store(path: Path) -> dict[str, Any]:
if not path.exists():
return {
"version": 2,
"timezone": KST_LABEL,
"updated_at": now_iso(),
"rules": [],
}
data = json.loads(path.read_text(encoding="utf-8"))
data.setdefault("version", 2)
data.setdefault("timezone", KST_LABEL)
data.setdefault("rules", [])
for rule in data["rules"]:
rule.setdefault("notify", {})
rule["notify"].setdefault("channel", "stdout")
rule["notify"].setdefault("dedupe_key", None)
rule["notify"].setdefault("last_sent_at", None)
rule["notify"].setdefault("message_template", None)
return data
def save_store(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data["updated_at"] = now_iso()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _parse_destinations(args) -> list[str]:
if args.destinations:
return unique_codes([normalize_airport(x.strip()) for x in args.destinations.split(",") if x.strip()])
if args.destination:
return [normalize_airport(args.destination)]
raise ValueError("--destination 또는 --destinations 가 필요합니다.")
def canonical_signature(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def make_rule(args) -> dict[str, Any]:
origin = normalize_airport(args.origin)
destinations = _parse_destinations(args)
if args.date_range:
start_dt, end_dt = parse_date_range_text(args.date_range)
departure = None
return_date = None
date_range = {
"start_date": pretty_date(start_dt),
"end_date": pretty_date(end_dt),
}
else:
departure = pretty_date(parse_flexible_date(args.departure))
return_date = pretty_date(parse_flexible_date(args.return_date)) if args.return_date else None
date_range = None
trip_type = "round_trip" if return_date or args.return_offset > 0 else "one_way"
destination_label = airport_label(destinations[0]) if len(destinations) == 1 else ", ".join(airport_label(code) for code in destinations)
date_label = f"{date_range['start_date']}~{date_range['end_date']}" if date_range else departure
label = args.label or f"{airport_label(origin)}→{destination_label} {date_label}"
time_preference = {
"time_pref": args.time_pref,
"depart_after": args.depart_after,
"return_after": args.return_after,
"exclude_early_before": args.exclude_early_before,
"prefer": args.prefer,
}
fingerprint_payload = {
"origin": origin,
"destinations": destinations,
"departure": departure,
"return_date": return_date,
"date_range": date_range,
"return_offset": args.return_offset,
"adults": args.adults,
"cabin": args.cabin,
"trip_type": trip_type,
"target_price_krw": args.target_price,
"time_preference": time_preference,
}
return {
"id": args.rule_id or f"kdf-{uuid.uuid4().hex[:8]}",
"enabled": True,
"label": label,
"fingerprint": canonical_signature(fingerprint_payload),
"query": {
"origin": origin,
"destination": destinations[0] if len(destinations) == 1 else None,
"destinations": destinations,
"departure": departure,
"return_date": return_date,
"date_range": date_range,
"return_offset": args.return_offset,
"adults": args.adults,
"cabin": args.cabin,
"trip_type": trip_type,
"time_preference": time_preference,
},
"target_price_krw": args.target_price,
"created_at": now_iso(),
"last_checked_at": None,
"last_result": None,
"notify": {
"channel": "stdout",
"dedupe_key": None,
"last_sent_at": None,
"message_template": args.message_template or None,
},
"meta": {
"source": "price_alerts.py",
"notes": args.notes or "",
},
}
def describe_rule(rule: dict[str, Any]) -> str:
q = rule["query"]
destinations = q.get("destinations") or ([q["destination"]] if q.get("destination") else [])
destination_text = ", ".join(airport_label(code) for code in destinations)
if q.get("date_range"):
date_text = f"{q['date_range']['start_date']}~{q['date_range']['end_date']}"
if q.get("return_offset"):
date_text += f" (귀국 +{q['return_offset']}일)"
else:
date_text = q["departure"]
if q.get("return_date"):
date_text += f" ~ {q['return_date']}"
state = "ON" if rule.get("enabled", True) else "OFF"
template_flag = "사용자 템플릿" if rule.get("notify", {}).get("message_template") else "기본 템플릿"
time_pref = q.get("time_preference") or {}
time_pref_text = describe_time_preference_payload(time_pref)
time_pref_line = f"\n- 시간 조건: {time_pref_text}" if time_pref_text else ""
return (
f"[{state}] {rule['id']} | {rule['label']}\n"
f"- 노선: {airport_label(q['origin'])} → {destination_text}\n"
f"- 일정: {date_text}\n"
f"- 조건: 성인 {q['adults']}명 · {cabin_label(q['cabin'])} · 목표가 {format_price(rule['target_price_krw'])}{time_pref_line}\n"
f"- 알림: {template_flag} · 마지막 dedupe_key={rule.get('notify', {}).get('dedupe_key') or '없음'}"
)
def run_search(script_name: str, params: list[str]) -> dict[str, Any]:
command = [sys.executable, str(SCRIPT_DIR / script_name), *params]
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8")
if result.returncode != 0:
raise RuntimeError(result.stdout or result.stderr or f"검색 스크립트 실패: {script_name}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"검색 결과를 JSON으로 해석하지 못했습니다: {exc}\n{result.stdout[:800]}") from exc
def check_rule(rule: dict[str, Any]) -> dict[str, Any]:
q = rule["query"]
destinations = q.get("destinations") or ([q["destination"]] if q.get("destination") else [])
common = [
"--origin", q["origin"],
"--adults", str(q["adults"]),
"--cabin", q["cabin"],
]
tp = q.get("time_preference") or {}
common.extend(time_preference_cli_args(tp))
if len(destinations) > 1 and q.get("date_range"):
payload = run_search(
"search_destination_date_matrix.py",
[
*common,
"--destinations", ",".join(destinations),
"--start-date", q["date_range"]["start_date"],
"--end-date", q["date_range"]["end_date"],
"--return-offset", str(q.get("return_offset", 0)),
],
)
best = payload.get("summary", {}).get("best_combo")
matched = bool(best and best.get("price", 0) and best["price"] <= rule["target_price_krw"])
return {
"matched": matched,
"observed_price_krw": best.get("price", 0) if best else 0,
"best_option": best,
"search_type": "destination_date_matrix",
"raw_summary": payload.get("summary"),
}
if len(destinations) > 1:
payload = run_search(
"search_multi_destination.py",
[
*common,
"--destinations", ",".join(destinations),
"--departure", q["departure"],
*(["--return-date", q["return_date"]] if q.get("return_date") else []),
],
)
best = payload.get("summary", {}).get("best_option")
matched = bool(best and best.get("cheapest_price", 0) and best["cheapest_price"] <= rule["target_price_krw"])
return {
"matched": matched,
"observed_price_krw": best.get("cheapest_price", 0) if best else 0,
"best_option": {
"destination": best.get("destination") if best else None,
"destination_label": best.get("destination_label") if best else None,
"price": best.get("cheapest_price", 0) if best else 0,
"airline": best.get("airline") if best else None,
"departure_time": best.get("departure_time") if best else None,
"arrival_time": best.get("arrival_time") if best else None,
"departure_date": q.get("departure"),
"return_date": q.get("return_date"),
},
"search_type": "multi_destination",
"raw_summary": payload.get("summary"),
}
destination = destinations[0]
single_common = [*common, "--destination", destination]
if q.get("date_range"):
payload = run_search(
"search_date_range.py",
[
*single_common,
"--start-date", q["date_range"]["start_date"],
"--end-date", q["date_range"]["end_date"],
"--return-offset", str(q.get("return_offset", 0)),
],
)
best = payload.get("summary", {}).get("best_date")
matched = bool(best and best.get("price", 0) and best["price"] <= rule["target_price_krw"])
return {
"matched": matched,
"observed_price_krw": best.get("price", 0) if best else 0,
"best_option": best,
"search_type": "date_range",
"raw_summary": payload.get("summary"),
}
payload = run_search(
"search_domestic.py",
[
*single_common,
"--departure", q["departure"],
*(["--return-date", q["return_date"]] if q.get("return_date") else []),
],
)
best = payload.get("cheapest")
matched = bool(best and best.get("price", 0) and best["price"] <= rule["target_price_krw"])
return {
"matched": matched,
"observed_price_krw": best.get("price", 0) if best else 0,
"best_option": best,
"search_type": "single_date",
"raw_summary": payload.get("summary"),
}
def _safe_format(template: str, context: dict[str, Any]) -> str:
class SafeDict(dict):
def __missing__(self, key):
return ""
return template.format_map(SafeDict({k: "" if v is None else v for k, v in context.items()})).strip()
def build_notification_context(rule: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
q = rule["query"]
best = result.get("best_option") or {}
destinations = q.get("destinations") or ([q["destination"]] if q.get("destination") else [])
route = f"{airport_label(q['origin'])} → {', '.join(airport_label(code) for code in destinations)}"
observed = result.get("observed_price_krw", 0)
target = rule["target_price_krw"]
diff = target - observed
departure_date = best.get("departure_date") or q.get("departure") or (q.get("date_range") or {}).get("start_date")
return_date = best.get("return_date") or q.get("return_date")
if q.get("date_range"):
date_text = f"최저가 날짜 {departure_date}"
if return_date:
date_text += f" ~ {return_date}"
else:
date_text += f" (탐색 범위 {q['date_range']['start_date']}~{q['date_range']['end_date']})"
else:
date_text = departure_date or ""
if return_date:
date_text += f" ~ {return_date}"
best_destination_label = best.get("destination_label") or airport_label(best.get("destination")) if best.get("destination") else ""
status_line = f"목표가 충족 ({diff:,}원 여유)" if diff >= 0 else f"목표가 초과 ({abs(diff):,}원 초과)"
best_destination_line = f"\n- 최적 목적지: {best_destination_label}" if best_destination_label else ""
airline_line = f"\n- 항공사: {best.get('airline')}" if best.get("airline") else ""
time_line = ""
if best.get("departure_time") and best.get("arrival_time"):
time_line = f"\n- 시간: {best['departure_time']} → {best['arrival_time']}"
return {
"rule_id": rule["id"],
"label": rule["label"],
"route": route,
"origin": q["origin"],
"origin_label": airport_label(q["origin"]),
"destinations": ",".join(destinations),
"destinations_label": ", ".join(airport_label(code) for code in destinations),
"best_destination": best.get("destination") or "",
"best_destination_label": best_destination_label,
"adults": q["adults"],
"cabin": q["cabin"],
"cabin_label": cabin_label(q["cabin"]),
"target_price": format_price(target),
"target_price_krw": target,
"observed_price": format_price(observed),
"observed_price_krw": observed,
"difference_krw": diff,
"departure_date": departure_date or "",
"return_date": return_date or "",
"date_text": date_text,
"airline": best.get("airline") or "",
"departure_time": best.get("departure_time") or "",
"arrival_time": best.get("arrival_time") or "",
"search_type": result.get("search_type") or "",
"status_line": status_line,
"best_destination_line": best_destination_line,
"airline_line": airline_line,
"time_line": time_line,
}
def compute_dedupe_key(rule: dict[str, Any], result: dict[str, Any]) -> str:
best = result.get("best_option") or {}
payload = {
"rule_id": rule["id"],
"search_type": result.get("search_type"),
"observed_price_krw": result.get("observed_price_krw", 0),
"destination": best.get("destination"),
"departure_date": best.get("departure_date"),
"return_date": best.get("return_date"),
"airline": best.get("airline"),
"departure_time": best.get("departure_time"),
"arrival_time": best.get("arrival_time"),
}
return canonical_signature(payload)
def build_notification(rule: dict[str, Any], result: dict[str, Any]) -> str:
template = rule.get("notify", {}).get("message_template") or DEFAULT_MESSAGE_TEMPLATE
context = build_notification_context(rule, result)
return _safe_format(template, context)
def command_add(args) -> int:
store_path = Path(args.store)
data = load_store(store_path)
rule = make_rule(args)
if any(item["id"] == rule["id"] for item in data["rules"]):
raise SystemExit(f"이미 같은 id 규칙이 있습니다: {rule['id']}")
duplicate = next((item for item in data["rules"] if item.get("fingerprint") == rule["fingerprint"]), None)
if duplicate:
raise SystemExit(f"중복 규칙입니다. 기존 규칙 id={duplicate['id']} label={duplicate['label']}")
data["rules"].append(rule)
save_store(store_path, data)
print("규칙 저장 완료")
print(describe_rule(rule))
print(f"저장 파일: {store_path}")
return 0
def command_list(args) -> int:
data = load_store(Path(args.store))
rules = data.get("rules", [])
if not rules:
print("저장된 가격 감시 규칙이 없습니다.")
return 0
for idx, rule in enumerate(rules, start=1):
print(f"{idx}. {describe_rule(rule)}")
return 0
def command_check(args) -> int:
store_path = Path(args.store)
data = load_store(store_path)
matched_messages: list[str] = []
checked = 0
failures: list[str] = []
suppressed = 0
for rule in data.get("rules", []):
if not rule.get("enabled", True):
continue
if args.rule_id and rule["id"] != args.rule_id:
continue
checked += 1
try:
result = check_rule(rule)
rule["last_checked_at"] = now_iso()
rule["last_result"] = result
if result["matched"]:
dedupe_key = compute_dedupe_key(rule, result)
if args.no_dedupe or rule.get("notify", {}).get("dedupe_key") != dedupe_key:
matched_messages.append(build_notification(rule, result))
rule.setdefault("notify", {})["dedupe_key"] = dedupe_key
rule["notify"]["last_sent_at"] = now_iso()
else:
suppressed += 1
except Exception as exc:
failures.append(f"{rule['id']}: {exc}")
rule["last_checked_at"] = now_iso()
rule["last_result"] = {"matched": False, "error": str(exc)}
save_store(store_path, data)
if failures:
print("[점검 오류]", file=sys.stderr)
for item in failures:
print(f"- {item}", file=sys.stderr)
if matched_messages:
print("\n\n".join(matched_messages))
if suppressed:
print(f"\n[dedupe] 동일 알림 {suppressed}건은 재전송하지 않았습니다.", file=sys.stderr)
return 0
if checked == 0:
print("점검할 활성 규칙이 없습니다.")
else:
msg = f"점검 완료: {checked}개 규칙 확인, 목표가 충족 알림 없음"
if suppressed:
msg += f" (중복 억제 {suppressed}건)"
print(msg)
return 0 if not failures else 1
def command_remove(args) -> int:
store_path = Path(args.store)
data = load_store(store_path)
before = len(data.get("rules", []))
data["rules"] = [rule for rule in data.get("rules", []) if rule["id"] != args.rule_id]
after = len(data["rules"])
if before == after:
raise SystemExit(f"삭제할 규칙을 찾지 못했습니다: {args.rule_id}")
save_store(store_path, data)
print(f"규칙 삭제 완료: {args.rule_id}")
return 0
def command_render(args) -> int:
data = load_store(Path(args.store))
rule = next((item for item in data.get("rules", []) if item["id"] == args.rule_id), None)
if not rule:
raise SystemExit(f"규칙을 찾지 못했습니다: {args.rule_id}")
if not rule.get("last_result"):
raise SystemExit("아직 last_result 가 없습니다. 먼저 check 를 실행하세요.")
print(build_notification(rule, rule["last_result"]))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="대한민국 국내선 가격 감시 규칙 저장/점검 도구")
parser.add_argument("--store", default=str(DEFAULT_STORE), help="규칙 JSON 저장 파일 경로")
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="감시 규칙 추가")
add.add_argument("--rule-id", help="직접 지정할 규칙 ID")
add.add_argument("--label", help="사람이 보기 쉬운 규칙 이름")
add.add_argument("--origin", required=True)
add_dest = add.add_mutually_exclusive_group(required=True)
add_dest.add_argument("--destination", help="단일 목적지")
add_dest.add_argument("--destinations", help="쉼표 구분 다중 목적지")
add.add_argument("--departure", help="단일 출발일")
add.add_argument("--return-date", help="왕복 귀국일")
add.add_argument("--date-range", help="날짜 범위. 예: 내일부터 3일, 2026-03-20~2026-03-22")
add.add_argument("--return-offset", type=int, default=0, help="날짜 범위 감시에서 출발일 기준 귀국일 오프셋")
add.add_argument("--adults", type=int, default=1)
add.add_argument("--cabin", default="ECONOMY", choices=["ECONOMY", "BUSINESS", "FIRST"])
add.add_argument("--target-price", type=int, required=True, help="목표가(원)")
add.add_argument("--time-pref", help="자연어 시간 조건. 예: 저녁, 출발 10시 이후, 복귀 18시 이후, 늦은 시간 선호")
add.add_argument("--depart-after", help="출발 N시 이후. 예: 10, 10:30")
add.add_argument("--return-after", help="복귀 N시 이후. 예: 18, 18:30")
add.add_argument("--exclude-early-before", help="이 시간 이전 출발 제외. 예: 8, 08:30")
add.add_argument("--prefer", choices=["late", "morning", "afternoon", "evening"], help="시간대 선호 추천")
add.add_argument("--notes", help="메모")
add.add_argument("--message-template", help="알림 메시지 포맷. 예: '[특가] {label} {observed_price} / {date_text}'")
sub.add_parser("list", help="저장 규칙 목록")
check = sub.add_parser("check", help="저장 규칙 점검")
check.add_argument("--rule-id", help="특정 규칙만 점검")
check.add_argument("--no-dedupe", action="store_true", help="중복 알림 억제를 끄고 이번 점검에서는 항상 출력")
remove = sub.add_parser("remove", help="규칙 삭제")
remove.add_argument("--rule-id", required=True)
render = sub.add_parser("render", help="마지막 결과를 현재 템플릿으로 미리보기")
render.add_argument("--rule-id", required=True)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.command == "add":
if not args.date_range and not args.departure:
raise SystemExit("add 에서는 --departure 또는 --date-range 가 필요합니다.")
return command_add(args)
if args.command == "list":
return command_list(args)
if args.command == "check":
return command_check(args)
if args.command == "remove":
return command_remove(args)
if args.command == "render":
return command_render(args)
raise SystemExit("알 수 없는 명령입니다.")
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/search_date_range.py
#!/usr/bin/env python3
import argparse
import json
import math
import sys
from dataclasses import asdict, is_dataclass
from datetime import timedelta
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import (
add_section,
airport_label,
build_balanced_option_reasons,
build_best_option_reasons,
build_price_calendar,
cabin_label,
choose_balanced_round_trip_option,
explain_recommendation,
filter_and_rank_by_time_preference,
format_price,
format_time_or_fallback,
join_nonempty,
normalize_airport,
parse_date_range_text,
parse_flexible_date,
parse_time_preference_args,
pretty_date,
recommendation_line,
round_trip_balance_recommendation,
time_preference_recommendation,
)
from hybrid_observability import build_refine_diagnostics, choose_fallback_plan
def build_dates(start_date, end_date):
days = []
current = start_date
while current <= end_date:
days.append(current)
current += timedelta(days=1)
return days
def _normalize(item):
if is_dataclass(item):
return asdict(item)
if hasattr(item, "__dict__"):
return dict(item.__dict__)
return {"value": str(item)}
def _empty_row(dep, ret, price=0, airline=""):
return {
"departure_date": dep,
"return_date": ret,
"price": price,
"airline": airline,
"departure_time": "",
"return_departure_time": "",
"preferred_option": None,
"time_recommendation": None,
"search_stage": "broad_only",
"time_pref_match": None,
"raw_option_count": 0,
"time_pref_valid_count": 0,
"broad_price": price,
"diagnostic_reason": "broad_only",
"diagnostic_detail": {},
}
def _candidate_detail(row):
detail = {
"departure_date": row["departure_date"],
"price": row.get("price", 0),
"reason": row.get("candidate_reason", "broad_rank"),
}
if row.get("return_date"):
detail["return_date"] = row["return_date"]
return detail
def _choose_refine_dates(dates, broad_rows):
available = [row for row in broad_rows if row.get("price", 0) and row.get("price", 0) > 0]
if not available:
return [], []
index_by_date = {pretty_date(d): idx for idx, d in enumerate(dates)}
chosen_indexes = set()
selected = []
seen_dates = set()
def add_date_label(date_label, reason):
if date_label in seen_dates:
return
idx = index_by_date.get(date_label)
if idx is None:
return
seen_dates.add(date_label)
chosen_indexes.add(idx)
selected.append({
"departure_date": date_label,
"price": next((row["price"] for row in available if row["departure_date"] == date_label), 0),
"candidate_reason": reason,
})
available_by_price = sorted(available, key=lambda x: (x["price"], x["departure_date"]))
base_count = min(len(available_by_price), max(8, min(len(dates), math.ceil(len(dates) * 0.6))))
for row in available_by_price[:base_count]:
add_date_label(row["departure_date"], "cheap_broad_rank")
for row in available_by_price[: min(4, len(available_by_price))]:
idx = index_by_date.get(row["departure_date"])
if idx is None:
continue
for neighbor in (idx - 1, idx + 1):
if 0 <= neighbor < len(dates):
add_date_label(pretty_date(dates[neighbor]), "neighbor_of_cheap")
spread_positions = sorted({0, len(dates) - 1, len(dates) // 2, len(dates) // 3, (len(dates) * 2) // 3})
for pos in spread_positions:
if 0 <= pos < len(dates):
add_date_label(pretty_date(dates[pos]), "coverage_anchor")
for idx in sorted(chosen_indexes):
row = next((item for item in selected if item["departure_date"] == pretty_date(dates[idx])), None)
if row is None:
selected.append({
"departure_date": pretty_date(dates[idx]),
"price": next((item["price"] for item in available if item["departure_date"] == pretty_date(dates[idx])), 0),
"candidate_reason": "selected",
})
return [selected, [dates[idx] for idx in sorted(chosen_indexes)]]
def _build_fallback_dates(dates, broad_rows, attempted_dates, limit, diagnostics=None):
if limit <= 0:
return []
diagnostics = diagnostics or {}
attempted_labels = {pretty_date(d) for d in attempted_dates}
index_by_date = {pretty_date(d): idx for idx, d in enumerate(dates)}
broad_by_date = {row["departure_date"]: row for row in broad_rows}
available = sorted(
[row for row in broad_rows if row.get("price", 0) and row.get("price", 0) > 0 and row["departure_date"] not in attempted_labels],
key=lambda x: (x["price"], x["departure_date"]),
)
fallback = []
seen = set()
dominant_reason = diagnostics.get("dominant_reason")
def push(date_label, reason):
if date_label in attempted_labels or date_label in seen or len(fallback) >= limit:
return
idx = index_by_date.get(date_label)
if idx is None:
return
seen.add(date_label)
fallback.append({
"date": dates[idx],
"detail": {
"departure_date": date_label,
"price": next((row["price"] for row in broad_rows if row["departure_date"] == date_label), 0),
"reason": reason,
},
})
if dominant_reason == "detail_empty_after_broad_hit":
empty_labels = []
for sample in diagnostics.get("samples", {}).get("detail_empty_after_broad_hit", []):
label = str(sample).split(" (", 1)[0]
if label in index_by_date:
empty_labels.append(label)
for label in empty_labels:
idx = index_by_date.get(label)
if idx is None:
continue
for neighbor in (idx - 2, idx - 1, idx + 1, idx + 2):
if 0 <= neighbor < len(dates):
push(pretty_date(dates[neighbor]), "fallback_neighbor_of_empty_detail")
if dominant_reason in {"detail_missing_departure_times", "detail_missing_return_times"}:
for row in available[: min(limit, 4)]:
idx = index_by_date.get(row["departure_date"])
if idx is None:
continue
for neighbor in (idx - 1, idx + 1):
if 0 <= neighbor < len(dates):
push(pretty_date(dates[neighbor]), "fallback_neighbor_of_missing_time")
for row in available:
push(row["departure_date"], "fallback_broad_rank")
idx = index_by_date.get(row["departure_date"])
if idx is None:
continue
for neighbor in (idx - 1, idx + 1):
if 0 <= neighbor < len(dates):
candidate_label = pretty_date(dates[neighbor])
if candidate_label in broad_by_date and (broad_by_date[candidate_label].get("price") or 0) > 0:
push(candidate_label, "fallback_neighbor")
if len(fallback) >= limit:
break
return fallback
def _diagnose_refine_failure(raw_results, filtered, broad_price, has_return_time_constraint):
raw_count = len(raw_results)
priced_count = sum(1 for item in raw_results if int(item.get("price", 0) or 0) > 0)
depart_time_count = sum(1 for item in raw_results if str(item.get("departure_time") or "").strip())
return_time_count = sum(1 for item in raw_results if str(item.get("return_departure_time") or "").strip())
detail = {
"raw_option_count": raw_count,
"priced_option_count": priced_count,
"departure_time_count": depart_time_count,
"return_time_count": return_time_count,
"has_return_time_constraint": bool(has_return_time_constraint),
}
if raw_count > 0:
detail["price_coverage_ratio"] = round(priced_count / raw_count, 3)
detail["departure_time_coverage_ratio"] = round(depart_time_count / raw_count, 3)
detail["return_time_coverage_ratio"] = round(return_time_count / raw_count, 3)
if filtered:
return "detailed_match_with_time_pref", {**detail, "hint": "시간 조건 일치"}
if not raw_results:
if broad_price > 0:
return "detail_empty_after_broad_hit", {**detail, "hint": "빠른 스캔 대비 상세 빈결과"}
return "detail_empty_no_broad_signal", {**detail, "hint": "상세 빈결과"}
if priced_count <= 0:
return "detail_missing_price_data", {**detail, "hint": "가격 정보 없음"}
if 0 < priced_count < raw_count:
return "detail_sparse_price_data", {**detail, "hint": "가격 정보 일부 누락"}
if depart_time_count <= 0:
return "detail_missing_departure_times", {**detail, "hint": "출발 시간 정보 부족"}
if 0 < depart_time_count < raw_count:
return "detail_partial_departure_times", {**detail, "hint": "출발 시간 정보 부분 누락"}
if has_return_time_constraint and return_time_count <= 0:
return "detail_missing_return_times", {**detail, "hint": "복귀 시간 정보 부족"}
if has_return_time_constraint and 0 < return_time_count < raw_count:
return "detail_partial_return_times", {**detail, "hint": "복귀 시간 정보 부분 누락"}
if broad_price > 0:
return "broad_candidate_time_rejected", {**detail, "hint": "시간 조건 미충족"}
return "detailed_no_usable_time_filter_match", {**detail, "hint": "usable match 없음"}
def _refine_single_date(searcher, origin, destination, dep, ret, args, time_pref, logs, stage, broad_price=0):
results = searcher.search(
origin=origin,
destination=destination,
departure_date=dep,
return_date=ret,
adults=args.adults,
cabin_class=args.cabin,
max_results=20,
progress_callback=lambda msg, dep=dep, stage=stage: logs.append(f"[{stage} {dep}] {msg}"),
background_mode=False,
)
raw_results = [_normalize(item) for item in results]
filtered, preferred_ranked = filter_and_rank_by_time_preference(raw_results, time_pref)
cheapest = filtered[0] if filtered else None
preferred = preferred_ranked[0] if preferred_ranked else None
diagnostic_reason, diagnostic_detail = _diagnose_refine_failure(
raw_results,
filtered,
broad_price,
has_return_time_constraint=bool(ret and (time_pref.return_min is not None or time_pref.return_max is not None)),
)
return {
"departure_date": dep,
"return_date": ret,
"price": cheapest.get("price", 0) if cheapest else 0,
"airline": cheapest.get("airline", "") if cheapest else "",
"departure_time": cheapest.get("departure_time", "") if cheapest else "",
"return_departure_time": cheapest.get("return_departure_time", "") if cheapest else "",
"preferred_option": preferred,
"time_recommendation": time_preference_recommendation(preferred, cheapest, time_pref),
"search_stage": stage,
"time_pref_match": bool(cheapest),
"raw_option_count": len(raw_results),
"priced_option_count": diagnostic_detail.get("priced_option_count", 0),
"departure_time_count": diagnostic_detail.get("departure_time_count", 0),
"return_time_count": diagnostic_detail.get("return_time_count", 0),
"has_return_time_constraint": diagnostic_detail.get("has_return_time_constraint", False),
"time_pref_valid_count": len(filtered),
"broad_price": broad_price,
"diagnostic_reason": diagnostic_reason,
"diagnostic_detail": diagnostic_detail,
}
def main():
parser = argparse.ArgumentParser(description="Search Korean domestic flights across date ranges")
parser.add_argument("--origin", required=True, help="예: GMP 또는 김포")
parser.add_argument("--destination", required=True, help="예: CJU 또는 제주")
parser.add_argument("--start-date", help="예: 2026-03-25, 내일")
parser.add_argument("--end-date", help="예: 2026-03-30")
parser.add_argument("--date-range", help="예: 내일부터 3일, 이번주말, 2026-03-25~2026-03-30")
parser.add_argument("--return-offset", type=int, default=0)
parser.add_argument("--adults", type=int, default=1)
parser.add_argument("--cabin", default="ECONOMY", choices=["ECONOMY", "BUSINESS", "FIRST"])
parser.add_argument("--time-pref")
parser.add_argument("--depart-after")
parser.add_argument("--return-after")
parser.add_argument("--exclude-early-before")
parser.add_argument("--prefer", choices=["late", "morning", "afternoon", "evening"])
parser.add_argument("--human", action="store_true")
args = parser.parse_args()
try:
origin = normalize_airport(args.origin)
destination = normalize_airport(args.destination)
if args.date_range:
start_dt, end_dt = parse_date_range_text(args.date_range)
elif args.start_date and args.end_date:
start_dt = parse_flexible_date(args.start_date)
end_dt = parse_flexible_date(args.end_date)
else:
raise ValueError("start/end-date 또는 --date-range 중 하나를 제공해야 합니다.")
except ValueError as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
if end_dt < start_dt:
print(json.dumps({"status": "error", "message": "end-date must be after or equal to start-date"}, ensure_ascii=False, indent=2))
sys.exit(1)
dates = build_dates(start_dt, end_dt)
if len(dates) > 30:
print(json.dumps({"status": "error", "message": "date range must be 30 days or less"}, ensure_ascii=False, indent=2))
sys.exit(1)
workspace = Path(__file__).resolve().parents[3]
repo_path = workspace / "tmp" / "Scraping-flight-information"
if not repo_path.exists():
print(json.dumps({"status": "error", "message": "Source repository clone not found.", "expected": str(repo_path)}, ensure_ascii=False, indent=2))
sys.exit(1)
sys.path.insert(0, str(repo_path))
time_pref = parse_time_preference_args(args)
logs = []
normalized = []
search_metadata = {
"strategy": "parallel",
"time_preference_active": time_pref.active(),
"time_preference_summary": time_pref.describe(),
"broad_scan_dates": len(dates),
"refined_dates": 0,
"refined_candidates": [],
"broad_scan_available": 0,
"fallback_refined_dates": 0,
"fallback_candidates": [],
"refine_attempted_dates": 0,
"refine_success_dates": 0,
"fallback_triggered": False,
"fallback_reason": None,
"fallback_trigger_reasons": [],
"fallback_reason_codes": [],
"fallback_decision": None,
"refine_diagnostics": None,
"diagnostic_hint": None,
"developer_diagnostic_hint": None,
}
if time_pref.active():
try:
from scraping.parallel import ParallelSearcher
from scraping.searcher import FlightSearcher
except Exception as exc:
print(json.dumps({"status": "error", "message": "Failed to import hybrid search components.", "details": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
search_metadata["strategy"] = "hybrid_parallel_then_detailed"
parallel_searcher = ParallelSearcher()
logs.append("hybrid mode: broad parallel scan started")
broad_raw = parallel_searcher.search_date_range(
origin=origin,
destination=destination,
dates=[d.strftime("%Y%m%d") for d in dates],
return_offset=args.return_offset,
adults=args.adults,
cabin_class=args.cabin,
progress_callback=lambda msg: logs.append(f"[broad] {msg}"),
)
broad_rows = []
for d in dates:
dep = pretty_date(d)
ret = pretty_date(d + timedelta(days=args.return_offset)) if args.return_offset > 0 else None
price, airline = broad_raw.get(d.strftime("%Y%m%d"), (0, "N/A"))
broad_rows.append(_empty_row(dep, ret, price=price, airline=airline))
refine_candidate_details, refine_dates = _choose_refine_dates(dates, broad_rows)
search_metadata["broad_scan_available"] = len([row for row in broad_rows if row["price"] and row["price"] > 0])
search_metadata["refined_dates"] = len(refine_dates)
search_metadata["refined_candidates"] = refine_candidate_details
logs.append(
f"hybrid mode: broad scan complete, available={search_metadata['broad_scan_available']}, refining={search_metadata['refined_dates']}"
)
detailed_by_date = {}
attempted_dates = list(refine_dates)
searcher = FlightSearcher()
try:
broad_price_by_dep = {row["departure_date"]: row.get("price", 0) for row in broad_rows}
for d in refine_dates:
dep = pretty_date(d)
ret = pretty_date(d + timedelta(days=args.return_offset)) if args.return_offset > 0 else None
detailed_by_date[dep] = _refine_single_date(
searcher,
origin,
destination,
dep,
ret,
args,
time_pref,
logs,
"refine",
broad_price=broad_price_by_dep.get(dep, 0),
)
successful_details = [row for row in detailed_by_date.values() if row.get("time_pref_match")]
search_metadata["refine_attempted_dates"] = len(detailed_by_date)
search_metadata["refine_success_dates"] = len(successful_details)
diagnostics = build_refine_diagnostics(
broad_rows,
detailed_by_date.values(),
key_fn=lambda row: row["departure_date"],
label_fn=lambda row: row["departure_date"],
)
search_metadata["refine_diagnostics"] = diagnostics
search_metadata["diagnostic_hint"] = diagnostics.get("human_hint")
search_metadata["developer_diagnostic_hint"] = diagnostics.get("developer_hint")
logs.append(f"hybrid refine diagnostics: {diagnostics['summary_text']}")
fallback_plan = choose_fallback_plan(
diagnostics,
minimum_target=max(2, math.ceil(len(dates) * 0.35)),
hard_cap=min(6, len(dates)),
pad=2,
)
search_metadata["fallback_decision"] = fallback_plan
search_metadata["fallback_reason_codes"] = list(fallback_plan.get("reasons", []))
if fallback_plan["triggered"]:
fallback_dates = _build_fallback_dates(dates, broad_rows, attempted_dates, fallback_plan["limit"], diagnostics)
if fallback_dates:
search_metadata["fallback_triggered"] = True
search_metadata["fallback_trigger_reasons"] = fallback_plan["reasons"]
search_metadata["fallback_reason"] = (
f"후보 확장: {diagnostics['summary_text']}"
)
search_metadata["fallback_refined_dates"] = len(fallback_dates)
search_metadata["fallback_candidates"] = [item["detail"] for item in fallback_dates]
logs.append(
f"hybrid mode: fallback refine triggered, reason={fallback_plan['primary_reason']}, extra={len(fallback_dates)}"
)
for item in fallback_dates:
dep = pretty_date(item["date"])
ret = pretty_date(item["date"] + timedelta(days=args.return_offset)) if args.return_offset > 0 else None
detailed_by_date[dep] = _refine_single_date(
searcher,
origin,
destination,
dep,
ret,
args,
time_pref,
logs,
"fallback",
broad_price=broad_price_by_dep.get(dep, 0),
)
search_metadata["refine_attempted_dates"] = len(detailed_by_date)
search_metadata["refine_success_dates"] = len([row for row in detailed_by_date.values() if row.get("time_pref_match")])
diagnostics = build_refine_diagnostics(
broad_rows,
detailed_by_date.values(),
key_fn=lambda row: row["departure_date"],
label_fn=lambda row: row["departure_date"],
)
search_metadata["refine_diagnostics"] = diagnostics
search_metadata["diagnostic_hint"] = diagnostics.get("human_hint")
search_metadata["developer_diagnostic_hint"] = diagnostics.get("developer_hint")
logs.append(f"hybrid refine diagnostics (after fallback): {diagnostics['summary_text']}")
finally:
try:
searcher.close()
except Exception:
pass
for broad in broad_rows:
normalized.append(detailed_by_date.get(broad["departure_date"], broad))
else:
try:
from scraping.parallel import ParallelSearcher
except Exception as exc:
print(json.dumps({"status": "error", "message": "Failed to import parallel searcher.", "details": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
searcher = ParallelSearcher()
raw = searcher.search_date_range(
origin=origin,
destination=destination,
dates=[d.strftime("%Y%m%d") for d in dates],
return_offset=args.return_offset,
adults=args.adults,
cabin_class=args.cabin,
progress_callback=lambda msg: logs.append(str(msg)),
)
for d in dates:
key = d.strftime("%Y%m%d")
price, airline = raw.get(key, (0, "N/A"))
normalized.append({
"departure_date": pretty_date(d),
"return_date": pretty_date(d + timedelta(days=args.return_offset)) if args.return_offset > 0 else None,
"price": price,
"airline": airline,
"departure_time": "",
"return_departure_time": "",
"preferred_option": None,
"time_recommendation": None,
"search_stage": "parallel",
"time_pref_match": None,
"raw_option_count": 0,
"time_pref_valid_count": 0,
"diagnostic_detail": {},
})
search_metadata["broad_scan_available"] = len([item for item in normalized if item["price"] and item["price"] > 0])
available = [item for item in normalized if item["price"] and item["price"] > 0]
available.sort(key=lambda x: x["price"])
cheapest = available[0] if available else None
second_price = available[1]["price"] if len(available) > 1 else None
price_calendar = build_price_calendar(normalized, date_key="departure_date", price_key="price")
balanced_option = choose_balanced_round_trip_option(available, time_pref) if args.return_offset > 0 else None
summary = {
"headline": (
f"{airport_label(origin)} → {airport_label(destination)} 날짜범위 최저가 {format_price(cheapest['price'])}"
if cheapest else
f"{airport_label(origin)} → {airport_label(destination)} 날짜범위 검색 결과가 없습니다."
),
"range": f"{pretty_date(start_dt)} ~ {pretty_date(end_dt)}",
"trip_type": "왕복 범위검색" if args.return_offset > 0 else "편도 범위검색",
"search_strategy": search_metadata["strategy"],
"search_metadata": search_metadata,
"best_date": cheapest,
"top_dates": available[:5],
"price_calendar": price_calendar,
"balanced_round_trip": balanced_option,
"diagnostic_hint": search_metadata.get("diagnostic_hint"),
"recommendation": recommendation_line(
f"{cheapest['departure_date']}{f'~{cheapest['return_date']}' if cheapest and cheapest['return_date'] else ''}",
cheapest["price"],
second_price,
) if cheapest else None,
"recommendation_explained": explain_recommendation(
f"{cheapest['departure_date']}{f'~{cheapest['return_date']}' if cheapest and cheapest['return_date'] else ''}",
int(cheapest["price"] or 0),
second_price,
build_best_option_reasons(cheapest, second_price, time_pref),
) if cheapest else None,
"time_recommendation": next((item.get("time_recommendation") for item in available if item.get("time_recommendation")), None),
"balanced_recommendation": round_trip_balance_recommendation(balanced_option, cheapest, time_pref) if balanced_option else None,
"balanced_recommendation_explained": explain_recommendation(
f"{balanced_option['departure_date']}~{balanced_option['return_date']}",
int(balanced_option["price"] or 0),
int(cheapest["price"] or 0) if cheapest else None,
build_balanced_option_reasons(balanced_option, cheapest, time_pref),
) if balanced_option else None,
}
if args.human:
def date_row_text(item):
date_text = f"{item['departure_date']} ~ {item['return_date']}" if item.get('return_date') else item['departure_date']
time_bits = []
if item.get('return_date'):
if item.get('departure_time'):
time_bits.append(f"가는편 {format_time_or_fallback(item.get('departure_time'))}")
if item.get('return_departure_time'):
time_bits.append(f"오는편 {format_time_or_fallback(item.get('return_departure_time'))}")
elif item.get('departure_time'):
time_bits.append(format_time_or_fallback(item.get('departure_time')))
return join_nonempty([
date_text,
format_price(item['price']),
item.get('airline') or None,
join_nonempty(time_bits),
])
lines = [summary["headline"]]
lines.append(f"범위: {summary['range']}")
lines.append(f"조건: 성인 {args.adults}명 · {cabin_label(args.cabin)}")
if args.return_offset > 0:
lines.append(f"왕복 기준: 출발일 + {args.return_offset}일 귀국")
if time_pref.describe():
lines.append(f"시간 조건: {time_pref.describe()}")
if search_metadata["strategy"].startswith("hybrid"):
hybrid_text = f"검색 방식: 하이브리드(빠른 전체 스캔 {search_metadata['broad_scan_dates']}일 → 상세 재검증 {search_metadata['refined_dates']}일"
if search_metadata.get("fallback_refined_dates"):
hybrid_text += f" + fallback {search_metadata['fallback_refined_dates']}일"
hybrid_text += ")"
lines.append(hybrid_text)
if search_metadata.get("diagnostic_hint"):
lines.append(f"참고: {search_metadata['diagnostic_hint']}")
add_section(lines, "최저가", [
f"최저가 날짜: {date_row_text(cheapest)}" if cheapest else None,
summary.get("recommendation"),
summary.get("recommendation_explained"),
])
add_section(lines, "시간대 추천", [summary.get("time_recommendation")])
add_section(lines, "왕복 균형 추천", [summary.get("balanced_recommendation"), summary.get("balanced_recommendation_explained")])
add_section(lines, "상위 날짜", [f"{idx}. {date_row_text(item)}" for idx, item in enumerate(summary.get("top_dates", []), start=1)])
if summary["price_calendar"]:
calendar_rows = summary["price_calendar"]
preview = calendar_rows[:10]
add_section(lines, "가격 캘린더", [*(item["label"] for item in preview), f"… 외 {len(calendar_rows) - len(preview)}일" if len(calendar_rows) > len(preview) else None])
print("\n".join(lines))
return
print(json.dumps({
"status": "success",
"query": {
"origin": origin,
"destination": destination,
"start_date": pretty_date(start_dt),
"end_date": pretty_date(end_dt),
"return_offset": args.return_offset,
"adults": args.adults,
"cabin": args.cabin,
"time_preference": time_pref.describe(),
},
"summary": summary,
"results": normalized,
"logs": logs,
"search_metadata": search_metadata,
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/search_destination_date_matrix.py
#!/usr/bin/env python3
import argparse
import json
import math
import sys
from dataclasses import asdict, is_dataclass
from datetime import timedelta
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import (
add_section,
airport_label,
build_balanced_option_reasons,
build_best_option_reasons,
build_price_calendar,
cabin_label,
choose_balanced_round_trip_option,
explain_recommendation,
filter_and_rank_by_time_preference,
format_price,
format_time_or_fallback,
join_nonempty,
normalize_airport,
parse_date_range_text,
parse_flexible_date,
parse_time_preference_args,
pretty_date,
recommendation_line,
round_trip_balance_recommendation,
unique_codes,
)
from hybrid_observability import build_refine_diagnostics, choose_fallback_plan
def build_dates(start_date, end_date):
days = []
current = start_date
while current <= end_date:
days.append(current)
current += timedelta(days=1)
return days
def _normalize(item):
if is_dataclass(item):
return asdict(item)
if hasattr(item, "__dict__"):
return dict(item.__dict__)
return {"value": str(item)}
def _broad_row(destination, dep, ret, price=0, airline=""):
return {
"destination": destination,
"destination_label": airport_label(destination),
"departure_date": dep,
"return_date": ret,
"price": price,
"airline": airline,
"departure_time": "",
"return_departure_time": "",
"preferred_option": None,
"search_stage": "broad_only",
"time_pref_match": None,
"raw_option_count": 0,
"time_pref_valid_count": 0,
"broad_price": price,
"diagnostic_reason": "broad_only",
"diagnostic_detail": {},
}
def _candidate_detail(row):
detail = {
"destination": row["destination"],
"departure_date": row["departure_date"],
"price": row.get("price", 0),
"reason": row.get("candidate_reason", "broad_rank"),
}
if row.get("return_date"):
detail["return_date"] = row["return_date"]
return detail
def _choose_refine_combos(destinations, dates, broad_rows):
available = [row for row in broad_rows if row.get("price", 0) and row.get("price", 0) > 0]
if not available:
return []
rows_by_destination = {
destination: sorted(
[row for row in available if row["destination"] == destination],
key=lambda x: (x["price"], x["departure_date"]),
)
for destination in destinations
}
date_index = {pretty_date(d): idx for idx, d in enumerate(dates)}
chosen = []
seen = set()
def add_row(row, reason):
key = (row["destination"], row["departure_date"])
if key in seen:
return
seen.add(key)
enriched = dict(row)
enriched["candidate_reason"] = reason
chosen.append(enriched)
per_destination_budget = max(3, min(6, math.ceil(len(dates) * 0.5)))
for destination in destinations:
rows = rows_by_destination.get(destination, [])
for row in rows[:per_destination_budget]:
add_row(row, "cheap_within_destination")
for row in rows[: min(3, len(rows))]:
idx = date_index.get(row["departure_date"])
if idx is None:
continue
for neighbor in (idx - 1, idx + 1):
if 0 <= neighbor < len(dates):
neighbor_label = pretty_date(dates[neighbor])
neighbor_row = next((item for item in rows if item["departure_date"] == neighbor_label), None)
if neighbor_row:
add_row(neighbor_row, "neighbor_of_destination_candidate")
globally_sorted = sorted(available, key=lambda x: (x["price"], x["destination"], x["departure_date"]))
global_budget = min(len(globally_sorted), max(len(destinations) * 4, math.ceil(len(available) * 0.35)))
for row in globally_sorted[:global_budget]:
add_row(row, "cheap_global_rank")
anchor_positions = sorted({0, len(dates) - 1, len(dates) // 2})
for destination in destinations:
rows = {row["departure_date"]: row for row in rows_by_destination.get(destination, [])}
for pos in anchor_positions:
if 0 <= pos < len(dates):
anchor_label = pretty_date(dates[pos])
if anchor_label in rows:
add_row(rows[anchor_label], "coverage_anchor")
return chosen
def _build_fallback_combos(destinations, dates, broad_rows, attempted_keys, limit, diagnostics=None):
if limit <= 0:
return []
diagnostics = diagnostics or {}
date_index = {pretty_date(d): idx for idx, d in enumerate(dates)}
broad_index = {(row["destination"], row["departure_date"]): row for row in broad_rows}
available = sorted(
[row for row in broad_rows if row.get("price", 0) and row.get("price", 0) > 0 and (row["destination"], row["departure_date"]) not in attempted_keys],
key=lambda x: (x["price"], x["destination"], x["departure_date"]),
)
fallback = []
seen = set()
dominant_reason = diagnostics.get("dominant_reason")
def push(row, reason):
key = (row["destination"], row["departure_date"])
if key in attempted_keys or key in seen or len(fallback) >= limit:
return
seen.add(key)
enriched = dict(row)
enriched["candidate_reason"] = reason
fallback.append(enriched)
if dominant_reason == "detail_empty_after_broad_hit":
for sample in diagnostics.get("samples", {}).get("detail_empty_after_broad_hit", []):
label = str(sample).split(" (", 1)[0]
parts = label.split()
if len(parts) != 2:
continue
destination, dep = parts
idx = date_index.get(dep)
if idx is None:
continue
for neighbor in (idx - 1, idx + 1):
if 0 <= neighbor < len(dates):
key = (destination, pretty_date(dates[neighbor]))
row = broad_index.get(key)
if row and (row.get("price") or 0) > 0:
push(row, "fallback_neighbor_of_empty_detail")
for destination in destinations:
destination_rows = [row for row in available if row["destination"] == destination]
if destination_rows:
preferred_reason = "fallback_best_per_destination"
if dominant_reason in {"detail_missing_departure_times", "detail_missing_return_times"}:
preferred_reason = "fallback_best_per_destination_after_missing_time"
push(destination_rows[0], preferred_reason)
for row in available:
push(row, "fallback_global_rank")
idx = date_index.get(row["departure_date"])
if idx is not None:
for neighbor in (idx - 1, idx + 1):
if 0 <= neighbor < len(dates):
neighbor_key = (row["destination"], pretty_date(dates[neighbor]))
neighbor_row = broad_index.get(neighbor_key)
if neighbor_row and (neighbor_row.get("price") or 0) > 0:
push(neighbor_row, "fallback_neighbor")
if len(fallback) >= limit:
break
return fallback
def _diagnose_refine_failure(raw_results, filtered, broad_price, has_return_time_constraint):
raw_count = len(raw_results)
priced_count = sum(1 for item in raw_results if int(item.get("price", 0) or 0) > 0)
depart_time_count = sum(1 for item in raw_results if str(item.get("departure_time") or "").strip())
return_time_count = sum(1 for item in raw_results if str(item.get("return_departure_time") or "").strip())
detail = {
"raw_option_count": raw_count,
"priced_option_count": priced_count,
"departure_time_count": depart_time_count,
"return_time_count": return_time_count,
"has_return_time_constraint": bool(has_return_time_constraint),
}
if raw_count > 0:
detail["price_coverage_ratio"] = round(priced_count / raw_count, 3)
detail["departure_time_coverage_ratio"] = round(depart_time_count / raw_count, 3)
detail["return_time_coverage_ratio"] = round(return_time_count / raw_count, 3)
if filtered:
return "detailed_match_with_time_pref", {**detail, "hint": "시간 조건 일치"}
if not raw_results:
if broad_price > 0:
return "detail_empty_after_broad_hit", {**detail, "hint": "빠른 스캔 대비 상세 빈결과"}
return "detail_empty_no_broad_signal", {**detail, "hint": "상세 빈결과"}
if priced_count <= 0:
return "detail_missing_price_data", {**detail, "hint": "가격 정보 없음"}
if 0 < priced_count < raw_count:
return "detail_sparse_price_data", {**detail, "hint": "가격 정보 일부 누락"}
if depart_time_count <= 0:
return "detail_missing_departure_times", {**detail, "hint": "출발 시간 정보 부족"}
if 0 < depart_time_count < raw_count:
return "detail_partial_departure_times", {**detail, "hint": "출발 시간 정보 부분 누락"}
if has_return_time_constraint and return_time_count <= 0:
return "detail_missing_return_times", {**detail, "hint": "복귀 시간 정보 부족"}
if has_return_time_constraint and 0 < return_time_count < raw_count:
return "detail_partial_return_times", {**detail, "hint": "복귀 시간 정보 부분 누락"}
if broad_price > 0:
return "broad_candidate_time_rejected", {**detail, "hint": "시간 조건 미충족"}
return "detailed_no_usable_time_filter_match", {**detail, "hint": "usable match 없음"}
def _refine_combo(searcher, origin, row, args, time_pref, logs, stage, broad_price=0):
destination = row["destination"]
dep = row["departure_date"]
ret = row["return_date"]
results = searcher.search(
origin=origin,
destination=destination,
departure_date=dep,
return_date=ret,
adults=args.adults,
cabin_class=args.cabin,
max_results=20,
progress_callback=lambda msg, dest=destination, dep=dep, stage=stage: logs.append(f"[{stage} {dest} {dep}] {msg}"),
background_mode=False,
)
raw_results = [_normalize(item) for item in results]
filtered, preferred_ranked = filter_and_rank_by_time_preference(raw_results, time_pref)
cheapest = filtered[0] if filtered else None
diagnostic_reason, diagnostic_detail = _diagnose_refine_failure(
raw_results,
filtered,
broad_price,
has_return_time_constraint=bool(ret and (time_pref.return_min is not None or time_pref.return_max is not None)),
)
return {
"destination": destination,
"destination_label": airport_label(destination),
"departure_date": dep,
"return_date": ret,
"price": cheapest.get("price", 0) if cheapest else 0,
"airline": cheapest.get("airline", "") if cheapest else "",
"departure_time": cheapest.get("departure_time", "") if cheapest else "",
"return_departure_time": cheapest.get("return_departure_time", "") if cheapest else "",
"preferred_option": preferred_ranked[0] if preferred_ranked else None,
"search_stage": stage,
"time_pref_match": bool(cheapest),
"raw_option_count": len(raw_results),
"priced_option_count": diagnostic_detail.get("priced_option_count", 0),
"departure_time_count": diagnostic_detail.get("departure_time_count", 0),
"return_time_count": diagnostic_detail.get("return_time_count", 0),
"has_return_time_constraint": diagnostic_detail.get("has_return_time_constraint", False),
"time_pref_valid_count": len(filtered),
"broad_price": broad_price,
"diagnostic_reason": diagnostic_reason,
"diagnostic_detail": diagnostic_detail,
}
def main():
parser = argparse.ArgumentParser(description="Search combined destination/date ranges for Korean domestic flights")
parser.add_argument("--origin", required=True)
parser.add_argument("--destinations", required=True, help="쉼표 구분 목적지 목록")
parser.add_argument("--start-date")
parser.add_argument("--end-date")
parser.add_argument("--date-range", help="예: 내일부터 5일, 2026-03-25~2026-03-30")
parser.add_argument("--return-offset", type=int, default=0)
parser.add_argument("--adults", type=int, default=1)
parser.add_argument("--cabin", default="ECONOMY", choices=["ECONOMY", "BUSINESS", "FIRST"])
parser.add_argument("--time-pref")
parser.add_argument("--depart-after")
parser.add_argument("--return-after")
parser.add_argument("--exclude-early-before")
parser.add_argument("--prefer", choices=["late", "morning", "afternoon", "evening"])
parser.add_argument("--human", action="store_true")
args = parser.parse_args()
try:
origin = normalize_airport(args.origin)
destinations = unique_codes([normalize_airport(x.strip()) for x in args.destinations.split(",") if x.strip()])
if args.date_range:
start_dt, end_dt = parse_date_range_text(args.date_range)
elif args.start_date and args.end_date:
start_dt = parse_flexible_date(args.start_date)
end_dt = parse_flexible_date(args.end_date)
else:
raise ValueError("start/end-date 또는 --date-range 중 하나를 제공해야 합니다.")
except ValueError as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
if end_dt < start_dt:
print(json.dumps({"status": "error", "message": "end-date must be after or equal to start-date"}, ensure_ascii=False, indent=2))
sys.exit(1)
dates = build_dates(start_dt, end_dt)
if len(dates) * len(destinations) > 90:
print(json.dumps({"status": "error", "message": "검색 조합 수는 90개 이하로 제한됩니다."}, ensure_ascii=False, indent=2))
sys.exit(1)
workspace = Path(__file__).resolve().parents[3]
repo_path = workspace / "tmp" / "Scraping-flight-information"
if not repo_path.exists():
print(json.dumps({"status": "error", "message": "Source repository clone not found.", "expected": str(repo_path)}, ensure_ascii=False, indent=2))
sys.exit(1)
sys.path.insert(0, str(repo_path))
time_pref = parse_time_preference_args(args)
logs = []
destination_rows = []
combos = []
search_metadata = {
"strategy": "parallel",
"time_preference_active": time_pref.active(),
"time_preference_summary": time_pref.describe(),
"broad_scan_destinations": len(destinations),
"broad_scan_dates": len(dates),
"broad_scan_combos": len(destinations) * len(dates),
"broad_scan_available": 0,
"refined_combos": 0,
"refined_candidates": [],
"fallback_refined_combos": 0,
"fallback_candidates": [],
"refine_attempted_combos": 0,
"refine_success_combos": 0,
"fallback_triggered": False,
"fallback_reason": None,
"fallback_trigger_reasons": [],
"fallback_reason_codes": [],
"fallback_decision": None,
"refine_diagnostics": None,
"diagnostic_hint": None,
"developer_diagnostic_hint": None,
}
if time_pref.active():
try:
from scraping.parallel import ParallelSearcher
from scraping.searcher import FlightSearcher
except Exception as exc:
print(json.dumps({"status": "error", "message": "Failed to import hybrid search components.", "details": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
search_metadata["strategy"] = "hybrid_parallel_then_detailed"
broad_rows = []
parallel_searcher = ParallelSearcher()
for destination in destinations:
logs.append(f"[broad] matrix search start: {destination}")
raw = parallel_searcher.search_date_range(
origin=origin,
destination=destination,
dates=[d.strftime("%Y%m%d") for d in dates],
return_offset=args.return_offset,
adults=args.adults,
cabin_class=args.cabin,
progress_callback=lambda msg, dest=destination: logs.append(f"[broad {dest}] {msg}"),
)
for d in dates:
key = d.strftime("%Y%m%d")
price, airline = raw.get(key, (0, "N/A"))
broad_rows.append(_broad_row(
destination=destination,
dep=pretty_date(d),
ret=pretty_date(d + timedelta(days=args.return_offset)) if args.return_offset > 0 else None,
price=price,
airline=airline,
))
search_metadata["broad_scan_available"] = len([row for row in broad_rows if row["price"] and row["price"] > 0])
refine_rows = _choose_refine_combos(destinations, dates, broad_rows)
search_metadata["refined_combos"] = len(refine_rows)
search_metadata["refined_candidates"] = [_candidate_detail(row) for row in refine_rows]
logs.append(
f"hybrid mode: broad scan complete, available={search_metadata['broad_scan_available']}, refining={search_metadata['refined_combos']}"
)
detailed_map = {}
attempted_keys = {(row["destination"], row["departure_date"]) for row in refine_rows}
searcher = FlightSearcher()
try:
broad_price_by_key = {(row["destination"], row["departure_date"]): row.get("price", 0) for row in broad_rows}
for row in refine_rows:
key = (row["destination"], row["departure_date"])
detailed_map[key] = _refine_combo(searcher, origin, row, args, time_pref, logs, "refine", broad_price=broad_price_by_key.get(key, 0))
successful = [row for row in detailed_map.values() if row.get("time_pref_match")]
search_metadata["refine_attempted_combos"] = len(detailed_map)
search_metadata["refine_success_combos"] = len(successful)
diagnostics = build_refine_diagnostics(
broad_rows,
detailed_map.values(),
key_fn=lambda row: (row["destination"], row["departure_date"]),
label_fn=lambda row: f"{row['destination']} {row['departure_date']}",
)
search_metadata["refine_diagnostics"] = diagnostics
search_metadata["diagnostic_hint"] = diagnostics.get("human_hint")
search_metadata["developer_diagnostic_hint"] = diagnostics.get("developer_hint")
logs.append(f"hybrid refine diagnostics: {diagnostics['summary_text']}")
fallback_plan = choose_fallback_plan(
diagnostics,
minimum_target=max(len(destinations), math.ceil(search_metadata["broad_scan_combos"] * 0.18)),
hard_cap=min(len(destinations) * 3, max(6, len(destinations) + len(dates))),
pad=len(destinations),
)
search_metadata["fallback_decision"] = fallback_plan
search_metadata["fallback_reason_codes"] = list(fallback_plan.get("reasons", []))
if fallback_plan["triggered"]:
fallback_rows = _build_fallback_combos(destinations, dates, broad_rows, attempted_keys, fallback_plan["limit"], diagnostics)
if fallback_rows:
search_metadata["fallback_triggered"] = True
search_metadata["fallback_trigger_reasons"] = fallback_plan["reasons"]
search_metadata["fallback_reason"] = (
f"후보 확장: {diagnostics['summary_text']}"
)
search_metadata["fallback_refined_combos"] = len(fallback_rows)
search_metadata["fallback_candidates"] = [_candidate_detail(row) for row in fallback_rows]
logs.append(
f"hybrid mode: fallback refine triggered, reason={fallback_plan['primary_reason']}, extra={len(fallback_rows)}"
)
for row in fallback_rows:
key = (row["destination"], row["departure_date"])
detailed_map[key] = _refine_combo(searcher, origin, row, args, time_pref, logs, "fallback", broad_price=broad_price_by_key.get(key, 0))
search_metadata["refine_attempted_combos"] = len(detailed_map)
search_metadata["refine_success_combos"] = len([row for row in detailed_map.values() if row.get("time_pref_match")])
diagnostics = build_refine_diagnostics(
broad_rows,
detailed_map.values(),
key_fn=lambda row: (row["destination"], row["departure_date"]),
label_fn=lambda row: f"{row['destination']} {row['departure_date']}",
)
search_metadata["refine_diagnostics"] = diagnostics
search_metadata["diagnostic_hint"] = diagnostics.get("human_hint")
search_metadata["developer_diagnostic_hint"] = diagnostics.get("developer_hint")
logs.append(f"hybrid refine diagnostics (after fallback): {diagnostics['summary_text']}")
finally:
try:
searcher.close()
except Exception:
pass
all_rows = []
for row in broad_rows:
key = (row["destination"], row["departure_date"])
all_rows.append(detailed_map.get(key, row))
for destination in destinations:
rows = [row for row in all_rows if row["destination"] == destination]
combos.extend(rows)
available_rows = [row for row in rows if row["price"] and row["price"] > 0]
available_rows.sort(key=lambda x: x["price"])
destination_rows.append({
"destination": destination,
"destination_label": airport_label(destination),
"search_strategy": search_metadata["strategy"],
"best_option": available_rows[0] if available_rows else None,
"top_dates": available_rows[:3],
"price_calendar": build_price_calendar(rows, date_key="departure_date", price_key="price"),
})
else:
try:
from scraping.parallel import ParallelSearcher
except Exception as exc:
print(json.dumps({"status": "error", "message": "Failed to import parallel searcher.", "details": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
searcher = ParallelSearcher()
for destination in destinations:
logs.append(f"matrix search start: {destination}")
raw = searcher.search_date_range(
origin=origin,
destination=destination,
dates=[d.strftime("%Y%m%d") for d in dates],
return_offset=args.return_offset,
adults=args.adults,
cabin_class=args.cabin,
progress_callback=lambda msg, dest=destination: logs.append(f"[{dest}] {msg}"),
)
rows = []
for d in dates:
key = d.strftime("%Y%m%d")
price, airline = raw.get(key, (0, "N/A"))
row = {
"destination": destination,
"destination_label": airport_label(destination),
"departure_date": pretty_date(d),
"return_date": pretty_date(d + timedelta(days=args.return_offset)) if args.return_offset > 0 else None,
"price": price,
"airline": airline,
"departure_time": "",
"return_departure_time": "",
"preferred_option": None,
"search_stage": "parallel",
"time_pref_match": None,
"raw_option_count": 0,
"time_pref_valid_count": 0,
"diagnostic_detail": {},
}
rows.append(row)
combos.append(row)
available_rows = [row for row in rows if row["price"] and row["price"] > 0]
available_rows.sort(key=lambda x: x["price"])
destination_rows.append({
"destination": destination,
"destination_label": airport_label(destination),
"search_strategy": search_metadata["strategy"],
"best_option": available_rows[0] if available_rows else None,
"top_dates": available_rows[:3],
"price_calendar": build_price_calendar(rows, date_key="departure_date", price_key="price"),
})
search_metadata["broad_scan_available"] = len([row for row in combos if row["price"] and row["price"] > 0])
ranked_combos = sorted([row for row in combos if row["price"] and row["price"] > 0], key=lambda x: x["price"])
best = ranked_combos[0] if ranked_combos else None
balanced_combo = choose_balanced_round_trip_option(ranked_combos, time_pref) if args.return_offset > 0 else None
second_price = ranked_combos[1]["price"] if len(ranked_combos) > 1 else None
destination_rows.sort(key=lambda x: x["best_option"]["price"] if x["best_option"] else 10**12)
summary = {
"headline": (
f"{airport_label(origin)} 출발 최적 조합은 {best['destination_label']} {best['departure_date']} {format_price(best['price'])}"
if best else
f"{airport_label(origin)} 출발 목적지+날짜 범위 검색 결과가 없습니다."
),
"range": f"{pretty_date(start_dt)} ~ {pretty_date(end_dt)}",
"search_strategy": search_metadata["strategy"],
"search_metadata": search_metadata,
"best_combo": best,
"balanced_round_trip": balanced_combo,
"top_combos": ranked_combos[:7],
"by_destination": destination_rows,
"diagnostic_hint": search_metadata.get("diagnostic_hint"),
"recommendation": recommendation_line(
f"{best['destination_label']} / {best['departure_date']}{f'~{best['return_date']}' if best and best['return_date'] else ''}",
best["price"],
second_price,
) if best else None,
"recommendation_explained": explain_recommendation(
f"{best['destination_label']} / {best['departure_date']}{f'~{best['return_date']}' if best and best['return_date'] else ''}",
int(best["price"] or 0),
second_price,
build_best_option_reasons(best, second_price, time_pref),
) if best else None,
"balanced_recommendation": round_trip_balance_recommendation(balanced_combo, best, time_pref) if balanced_combo else None,
"balanced_recommendation_explained": explain_recommendation(
f"{balanced_combo['destination_label']} / {balanced_combo['departure_date']}{f'~{balanced_combo['return_date']}' if balanced_combo and balanced_combo['return_date'] else ''}",
int(balanced_combo["price"] or 0),
int(best["price"] or 0) if best else None,
build_balanced_option_reasons(balanced_combo, best, time_pref),
) if balanced_combo else None,
}
if args.human:
def combo_text(item):
date_text = f"{item['departure_date']} ~ {item['return_date']}" if item.get('return_date') else item['departure_date']
time_bits = []
if item.get('departure_time'):
time_bits.append(f"가는편 {format_time_or_fallback(item.get('departure_time'))}")
if item.get('return_departure_time'):
time_bits.append(f"오는편 {format_time_or_fallback(item.get('return_departure_time'))}")
return join_nonempty([
item.get('destination_label') or None,
date_text,
format_price(item['price']),
item.get('airline') or None,
join_nonempty(time_bits),
])
lines = [summary["headline"]]
lines.append(f"범위: {summary['range']}")
lines.append(f"조건: 출발 {airport_label(origin)} · 목적지 {len(destinations)}곳 · 성인 {args.adults}명 · {cabin_label(args.cabin)}")
if args.return_offset > 0:
lines.append(f"왕복 기준: 출발일 + {args.return_offset}일 귀국")
if time_pref.describe():
lines.append(f"시간 조건: {time_pref.describe()}")
if search_metadata["strategy"].startswith("hybrid"):
hybrid_text = f"검색 방식: 하이브리드(빠른 전체 스캔 {search_metadata['broad_scan_combos']}조합 → 상세 재검증 {search_metadata['refined_combos']}조합"
if search_metadata.get("fallback_refined_combos"):
hybrid_text += f" + fallback {search_metadata['fallback_refined_combos']}조합"
hybrid_text += ")"
lines.append(hybrid_text)
if search_metadata.get("diagnostic_hint"):
lines.append(f"참고: {search_metadata['diagnostic_hint']}")
add_section(lines, "최저가", [
f"최적 조합: {combo_text(best)}" if best else None,
summary.get("recommendation"),
summary.get("recommendation_explained"),
])
add_section(lines, "왕복 균형 추천", [summary.get("balanced_recommendation"), summary.get("balanced_recommendation_explained")])
add_section(lines, "목적지별 베스트", [
(f"{idx}. {item['destination_label']} · {combo_text(best_option).replace(item['destination_label'] + ' · ', '', 1)}" if best_option else f"{idx}. {item['destination_label']} · 결과 없음")
for idx, item in enumerate(destination_rows[:5], start=1)
for best_option in [item.get('best_option')]
])
if destination_rows:
calendar_lines = []
for item in destination_rows[:5]:
calendar_lines.append(f"- {item['destination_label']}")
calendar_rows = item.get("price_calendar", [])
preview = calendar_rows[:7]
calendar_lines.extend(f" {entry['label']}" for entry in preview)
if len(calendar_rows) > len(preview):
calendar_lines.append(f" … 외 {len(calendar_rows) - len(preview)}일")
add_section(lines, "목적지별 가격 캘린더", calendar_lines)
add_section(lines, "전체 상위 조합", [f"{idx}. {combo_text(item)}" for idx, item in enumerate(ranked_combos[:7], start=1)])
print("\n".join(lines))
return
print(json.dumps({
"status": "success",
"query": {
"origin": origin,
"destinations": destinations,
"start_date": pretty_date(start_dt),
"end_date": pretty_date(end_dt),
"return_offset": args.return_offset,
"adults": args.adults,
"cabin": args.cabin,
"time_preference": time_pref.describe(),
},
"summary": summary,
"results": ranked_combos,
"matrix": destination_rows,
"logs": logs,
"search_metadata": search_metadata,
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/search_domestic.py
#!/usr/bin/env python3
import argparse
import json
import sys
from dataclasses import asdict, is_dataclass
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import (
add_section,
airport_label,
build_best_option_reasons,
cabin_label,
explain_recommendation,
filter_and_rank_by_time_preference,
format_price,
format_time_or_fallback,
join_nonempty,
normalize_airport,
parse_flexible_date,
parse_time_preference_args,
pretty_date,
recommendation_line,
time_preference_recommendation,
)
def normalize_result(item):
if is_dataclass(item):
data = asdict(item)
elif hasattr(item, "__dict__"):
data = dict(item.__dict__)
else:
data = {"value": str(item)}
return {
"airline": data.get("airline", ""),
"price": data.get("price", 0),
"departure_time": data.get("departure_time", ""),
"arrival_time": data.get("arrival_time", ""),
"stops": data.get("stops", 0),
"source": data.get("source", ""),
"return_departure_time": data.get("return_departure_time", ""),
"return_arrival_time": data.get("return_arrival_time", ""),
"return_stops": data.get("return_stops", 0),
"is_round_trip": data.get("is_round_trip", False),
"outbound_price": data.get("outbound_price", 0),
"return_price": data.get("return_price", 0),
"return_airline": data.get("return_airline", ""),
"confidence": data.get("confidence", 0),
"extraction_source": data.get("extraction_source", ""),
}
def option_text(item):
airline = item.get('airline', '')
if item.get("is_round_trip"):
return join_nonempty([
f"{airline}/{item.get('return_airline') or airline}" if airline else None,
f"총 {format_price(item.get('price', 0))}",
f"가는편 {join_nonempty([format_time_or_fallback(item.get('departure_time')), item.get('arrival_time')], '→')}",
f"오는편 {join_nonempty([format_time_or_fallback(item.get('return_departure_time')), item.get('return_arrival_time')], '→')}",
])
return join_nonempty([
airline or None,
format_price(item.get('price', 0)),
join_nonempty([format_time_or_fallback(item.get('departure_time')), item.get('arrival_time')], '→'),
])
def build_summary(query, normalized, preferred, time_pref):
route = f"{airport_label(query['origin'])} → {airport_label(query['destination'])}"
trip_type = "왕복" if query.get("return_date") else "편도"
if not normalized:
return {
"headline": f"{route} {trip_type} 검색 결과가 없습니다.",
"route": route,
"trip_type": trip_type,
"cheapest_text": None,
"top_options": [],
"recommendation": None,
"time_preference_recommendation": None,
"preferred_option": preferred,
}
cheapest = normalized[0]
second_price = normalized[1].get("price", 0) if len(normalized) > 1 else None
return {
"headline": f"{route} {trip_type} 최저가 {format_price(cheapest.get('price', 0))}",
"route": route,
"trip_type": trip_type,
"cheapest_text": option_text(cheapest),
"top_options": [option_text(item) for item in normalized[:3]],
"recommendation": recommendation_line(option_text(cheapest), cheapest.get("price", 0), second_price),
"recommendation_explained": explain_recommendation(
option_text(cheapest),
int(cheapest.get("price", 0) or 0),
second_price,
build_best_option_reasons(cheapest, second_price, time_pref),
),
"time_preference_recommendation": time_preference_recommendation(preferred, cheapest, time_pref),
"preferred_option": preferred,
}
def format_human(summary, query, count, time_pref):
lines = [summary["headline"]]
meta = [f"조건: 성인 {query['adults']}명 · {cabin_label(query['cabin'])} · 결과 {count}건"]
if query.get("return_date"):
meta.append(f"일정: {query['departure']} ~ {query['return_date']}")
else:
meta.append(f"일정: {query['departure']}")
if time_pref.describe():
meta.append(f"시간 조건: {time_pref.describe()}")
lines.extend(meta)
add_section(lines, "최저가", [summary.get("cheapest_text"), summary.get("recommendation"), summary.get("recommendation_explained")])
add_section(lines, "시간대 추천", [summary.get("time_preference_recommendation")])
add_section(lines, "상위 옵션", [f"{idx}. {item}" for idx, item in enumerate(summary.get("top_options", []), start=1)])
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Search Korean domestic flights")
parser.add_argument("--origin", required=True, help="예: GMP 또는 김포")
parser.add_argument("--destination", required=True, help="예: CJU 또는 제주")
parser.add_argument("--departure", required=True, help="예: 2026-03-25, 20260325, 내일")
parser.add_argument("--return-date", dest="return_date", help="예: 2026-03-28, 모레")
parser.add_argument("--adults", type=int, default=1)
parser.add_argument("--cabin", default="ECONOMY", choices=["ECONOMY", "BUSINESS", "FIRST"])
parser.add_argument("--max-results", type=int, default=20)
parser.add_argument("--time-pref", help="예: 오전, 저녁, 출발 10시 이후, 복귀 18시 이후, 너무 이른 비행 제외 8시")
parser.add_argument("--depart-after", help="출발 N시 이후. 예: 10, 10:30")
parser.add_argument("--return-after", help="복귀 N시 이후. 예: 18, 18:30")
parser.add_argument("--exclude-early-before", help="이 시간 이전 출발 제외. 예: 8, 08:30")
parser.add_argument("--prefer", choices=["late", "morning", "afternoon", "evening"], help="시간대 선호 추천")
parser.add_argument("--human", action="store_true")
args = parser.parse_args()
workspace = Path(__file__).resolve().parents[3]
repo_path = workspace / "tmp" / "Scraping-flight-information"
if not repo_path.exists():
print(json.dumps({"status": "error", "message": "Source repository clone not found.", "expected": str(repo_path)}, ensure_ascii=False, indent=2))
sys.exit(1)
sys.path.insert(0, str(repo_path))
try:
from scraping.searcher import FlightSearcher
except Exception as exc:
print(json.dumps({"status": "error", "message": "Failed to import flight searcher.", "details": str(exc), "repo": str(repo_path)}, ensure_ascii=False, indent=2))
sys.exit(1)
try:
origin = normalize_airport(args.origin)
destination = normalize_airport(args.destination)
departure = pretty_date(parse_flexible_date(args.departure))
return_date = pretty_date(parse_flexible_date(args.return_date)) if args.return_date else None
except ValueError as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
time_pref = parse_time_preference_args(args)
logs = []
def progress(msg):
logs.append(str(msg))
query = {
"origin": origin,
"destination": destination,
"departure": departure,
"return_date": return_date,
"adults": args.adults,
"cabin": args.cabin,
"max_results": args.max_results,
"time_preference": time_pref.describe(),
}
searcher = FlightSearcher()
try:
results = searcher.search(
origin=query["origin"],
destination=query["destination"],
departure_date=query["departure"],
return_date=query["return_date"],
adults=query["adults"],
cabin_class=query["cabin"],
max_results=query["max_results"],
progress_callback=progress,
background_mode=False,
)
all_results = [normalize_result(item) for item in results]
normalized, preferred_ranked = filter_and_rank_by_time_preference(all_results, time_pref)
preferred = preferred_ranked[0] if preferred_ranked else None
cheapest = normalized[0] if normalized else None
summary = build_summary(query, normalized, preferred, time_pref)
if args.human:
print(format_human(summary, query, len(normalized), time_pref))
return
print(json.dumps({
"status": "success",
"query": query,
"count": len(normalized),
"summary": summary,
"cheapest": cheapest,
"preferred_option": preferred,
"results": normalized,
"all_results_before_time_filter": all_results,
"logs": logs,
}, ensure_ascii=False, indent=2))
finally:
try:
searcher.close()
except Exception:
pass
if __name__ == "__main__":
main()
FILE:scripts/search_multi_destination.py
#!/usr/bin/env python3
import argparse
import json
import sys
from dataclasses import asdict, is_dataclass
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from common_cli import (
add_section,
airport_label,
build_best_option_reasons,
cabin_label,
bullet_rank_lines,
explain_recommendation,
filter_and_rank_by_time_preference,
format_price,
format_time_or_fallback,
join_nonempty,
normalize_airport,
parse_flexible_date,
parse_time_preference_args,
pretty_date,
recommendation_line,
time_preference_recommendation,
unique_codes,
)
def normalize_result(item):
if is_dataclass(item):
return asdict(item)
if hasattr(item, "__dict__"):
return dict(item.__dict__)
return {"value": str(item)}
def main():
parser = argparse.ArgumentParser(description="Compare Korean domestic flight fares across multiple destinations")
parser.add_argument("--origin", required=True)
parser.add_argument("--destinations", required=True, help="Comma-separated destinations, e.g. CJU,PUS,RSU or 제주,부산,여수")
parser.add_argument("--departure", required=True)
parser.add_argument("--return-date")
parser.add_argument("--adults", type=int, default=1)
parser.add_argument("--cabin", default="ECONOMY", choices=["ECONOMY", "BUSINESS", "FIRST"])
parser.add_argument("--time-pref")
parser.add_argument("--depart-after")
parser.add_argument("--return-after")
parser.add_argument("--exclude-early-before")
parser.add_argument("--prefer", choices=["late", "morning", "afternoon", "evening"])
parser.add_argument("--human", action="store_true")
args = parser.parse_args()
workspace = Path(__file__).resolve().parents[3]
repo_path = workspace / "tmp" / "Scraping-flight-information"
if not repo_path.exists():
print(json.dumps({"status": "error", "message": "Source repository clone not found.", "expected": str(repo_path)}, ensure_ascii=False, indent=2))
sys.exit(1)
sys.path.insert(0, str(repo_path))
try:
from scraping.parallel import ParallelSearcher
except Exception as exc:
print(json.dumps({"status": "error", "message": "Failed to import parallel searcher.", "details": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
try:
origin = normalize_airport(args.origin)
destinations = unique_codes([normalize_airport(x.strip()) for x in args.destinations.split(",") if x.strip()])
departure = pretty_date(parse_flexible_date(args.departure))
return_date = pretty_date(parse_flexible_date(args.return_date)) if args.return_date else None
except ValueError as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False, indent=2))
sys.exit(1)
time_pref = parse_time_preference_args(args)
logs = []
def progress(msg):
logs.append(str(msg))
searcher = ParallelSearcher()
raw = searcher.search_multiple_destinations(
origin=origin,
destinations=destinations,
departure_date=departure.replace("-", ""),
return_date=return_date.replace("-", "") if return_date else None,
adults=args.adults,
cabin_class=args.cabin,
progress_callback=progress,
)
normalized = []
for dest in destinations:
raw_results = [normalize_result(item) for item in (raw.get(dest, []) or [])]
filtered, preferred_ranked = filter_and_rank_by_time_preference(raw_results, time_pref)
cheapest = filtered[0] if filtered else None
preferred = preferred_ranked[0] if preferred_ranked else None
normalized.append({
"destination": dest,
"destination_label": airport_label(dest),
"count": len(filtered),
"raw_count": len(raw_results),
"cheapest_price": cheapest.get("price", 0) if cheapest else 0,
"airline": cheapest.get("airline", "") if cheapest else "",
"departure_time": cheapest.get("departure_time", "") if cheapest else "",
"arrival_time": cheapest.get("arrival_time", "") if cheapest else "",
"preferred_price": preferred.get("price", 0) if preferred else 0,
"preferred_airline": preferred.get("airline", "") if preferred else "",
"preferred_departure_time": preferred.get("departure_time", "") if preferred else "",
"preferred_return_departure_time": preferred.get("return_departure_time", "") if preferred else "",
"preferred_option": preferred,
"time_recommendation": time_preference_recommendation(preferred, cheapest, time_pref),
})
ranked = sorted(normalized, key=lambda x: x["cheapest_price"] if x["cheapest_price"] > 0 else 10**12)
best = ranked[0] if ranked and ranked[0]["cheapest_price"] > 0 else None
second_price = ranked[1]["cheapest_price"] if len(ranked) > 1 and ranked[1]["cheapest_price"] > 0 else None
summary = {
"headline": (
f"{airport_label(origin)} 출발 다중 목적지 최저가 {format_price(best['cheapest_price'])}"
if best else
f"{airport_label(origin)} 출발 다중 목적지 검색 결과가 없습니다."
),
"best_option": best,
"ranked_destinations": ranked,
"recommendation": recommendation_line(best["destination_label"], best["cheapest_price"], second_price) if best else None,
"recommendation_explained": explain_recommendation(
best["destination_label"],
int(best["cheapest_price"] or 0),
second_price,
build_best_option_reasons({
"airline": best.get("airline"),
"departure_time": best.get("departure_time"),
"arrival_time": best.get("arrival_time"),
"cheapest_price": best.get("cheapest_price"),
"price": best.get("cheapest_price"),
}, second_price, time_pref),
) if best else None,
}
if args.human:
def destination_detail(item):
return join_nonempty([
item.get('airline') or None,
join_nonempty([
format_time_or_fallback(item.get('departure_time')) if item.get('departure_time') else None,
item.get('arrival_time') or None,
], '→'),
])
lines = [summary["headline"]]
lines.append(f"조건: 출발 {airport_label(origin)} · 출발일 {departure} · 성인 {args.adults}명 · {cabin_label(args.cabin)}")
if return_date:
lines.append(f"왕복 일정: {departure} ~ {return_date}")
if time_pref.describe():
lines.append(f"시간 조건: {time_pref.describe()}")
add_section(lines, "최저가", [
join_nonempty([
f"최적 목적지: {best['destination_label']}" if best else None,
format_price(best['cheapest_price']) if best else None,
destination_detail(best) if best else None,
]),
summary.get("recommendation"),
summary.get("recommendation_explained"),
])
late_pref = next((item["time_recommendation"] for item in ranked if item.get("time_recommendation")), None)
add_section(lines, "시간대 추천", [late_pref])
add_section(lines, "목적지 비교", bullet_rank_lines(ranked, "destination_label", "cheapest_price", destination_detail, limit=5))
print("\n".join(lines))
return
print(json.dumps({
"status": "success",
"query": {
"origin": origin,
"destinations": destinations,
"departure": departure,
"return_date": return_date,
"adults": args.adults,
"cabin": args.cabin,
"time_preference": time_pref.describe(),
},
"summary": summary,
"results": ranked,
"logs": logs,
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()