mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge pull request #226 from taeyoung1005/feat/danawa-price-search
feat: 다나와 최저가 비교 스킬 추가
This commit is contained in:
commit
568a4453b9
5 changed files with 509 additions and 1 deletions
|
|
@ -82,6 +82,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 한국어 맞춤법 검사 | `korean-spell-check` | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
|
||||
| 네이버 블로그 리서치 | `naver-blog-research` | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
|
||||
| 네이버 쇼핑 가격비교 | `naver-shopping-search` | 네이버 검색 Open API 우선, 공개 BFF JSON fallback으로 상품 후보·현재 노출가·판매처 링크 비교 | 불필요 | [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md) |
|
||||
| 다나와 최저가 비교 | `danawa-price-search` | 다나와 공개 검색/가격비교 표면으로 상품 후보·쇼핑몰별 가격·배송비 포함 실구매가·카드 할인가·무이자 할부 비교 | 불필요 | [다나와 최저가 비교 가이드](docs/features/danawa-price-search.md) |
|
||||
| 네이버 뉴스 검색 | `naver-news-search` | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
|
||||
| 한국어 글자 수 세기 | `korean-character-count` | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
|
||||
| 한국어 유행어 글쓰기 | `korean-slang-writing` | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
|
||||
|
|
@ -179,6 +180,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
|
||||
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)
|
||||
- [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md)
|
||||
- [다나와 최저가 비교 가이드](docs/features/danawa-price-search.md)
|
||||
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
|
||||
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
|
||||
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
|
||||
|
|
|
|||
174
danawa-price-search/SKILL.md
Normal file
174
danawa-price-search/SKILL.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
name: danawa-price-search
|
||||
description: 다나와 공개 검색/가격비교 표면으로 상품 후보를 찾고, 쇼핑몰별 최저가·배송비 포함 실구매가·카드 할인가·무이자 할부 정보를 보수적으로 비교한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: retail
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Danawa Price Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
다나와의 로그인 없는 공개 검색/가격비교 표면을 읽기 전용으로 호출해 한국 쇼핑몰 가격을 비교한다.
|
||||
|
||||
- 상품명/검색어로 다나와 상품 후보와 `pcode`를 찾는다.
|
||||
- 선택한 상품의 쇼핑몰별 오퍼를 조회한다.
|
||||
- 상품가만이 아니라 배송비 포함 실구매가, 무료배송 여부, 카드 할인가, 무이자 할부 문구를 함께 정리한다.
|
||||
- 구매, 로그인, 장바구니, 찜, 주문 액션은 하지 않는다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "다나와에서 에어팟 최저가 찾아줘"
|
||||
- "다나와 가격비교로 쇼핑몰별 가격 비교해줘"
|
||||
- "무료배송인지, 카드 할인까지 보면 어디가 제일 싸?"
|
||||
- "무이자 할부 붙은 최저가도 같이 봐줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 실제 구매/주문/결제/로그인이 필요한 경우
|
||||
- 회원 전용 쿠폰, 개인화 포인트, 앱 전용 혜택을 확정해야 하는 경우
|
||||
- 대량 모니터링이나 고빈도 크롤링을 해야 하는 경우
|
||||
- CAPTCHA, 접근 차단, fingerprint 우회를 해야 하는 경우
|
||||
|
||||
## Required inputs
|
||||
|
||||
상품명 또는 검색어가 필요하다. 검색어가 넓으면 브랜드, 모델명, 용량, 색상, 자급제/통신사 여부 등을 추가로 물어본다.
|
||||
|
||||
권장 질문:
|
||||
|
||||
> 찾을 다나와 상품명이나 모델명을 알려주세요. 예: 갤럭시 S25 울트라 256GB 자급제, 에어팟 프로 2세대 USB-C
|
||||
|
||||
## Public surfaces
|
||||
|
||||
현재 구현은 인증 없는 공개 표면만 사용한다.
|
||||
|
||||
- 검색 페이지: `https://search.danawa.com/dsearch.php?query=...`
|
||||
- 상품 상세 페이지: `https://prod.danawa.com/info/?pcode=...`
|
||||
- 가격비교 AJAX: `https://prod.danawa.com/info/ajax/getAllPriceCompareMallList.ajax.php`
|
||||
|
||||
AJAX endpoint는 HTML fragment를 반환한다. helper는 `.diff_item`, 쇼핑몰 로고 `alt`, `em.prc_c`/`em.prc_t`, 배송 문구, 카드 할인 라인, 무이자 할부 레이어, 다나와 bridge link를 파싱한다.
|
||||
|
||||
## Commands
|
||||
|
||||
스킬 디렉터리에서 실행한다.
|
||||
|
||||
```bash
|
||||
python scripts/danawa_search.py search "에어팟 프로 2세대" --limit 8
|
||||
python scripts/danawa_search.py offers 28208783 --limit 10
|
||||
python scripts/danawa_search.py compare "에어팟 프로 2세대" --limit 5 --offers 5
|
||||
```
|
||||
|
||||
helper는 JSON만 출력한다. 결과를 확인한 뒤 사용자에게는 한국어 표와 짧은 결론으로 정리한다.
|
||||
|
||||
## Output shape
|
||||
|
||||
### `search`
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "...",
|
||||
"source_url": "...",
|
||||
"count": 0,
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
각 `items[]` 주요 필드:
|
||||
|
||||
- `pcode`
|
||||
- `title`
|
||||
- `price`, `price_text`
|
||||
- `mall_text`
|
||||
- `url`
|
||||
- `image_url`
|
||||
- `spec`
|
||||
|
||||
### `offers`
|
||||
|
||||
```json
|
||||
{
|
||||
"pcode": "...",
|
||||
"title": "...",
|
||||
"source_url": "...",
|
||||
"count": 0,
|
||||
"offers": []
|
||||
}
|
||||
```
|
||||
|
||||
각 `offers[]` 주요 필드:
|
||||
|
||||
- `mall`
|
||||
- `price`, `price_text`
|
||||
- `shipping`
|
||||
- `is_free_shipping`
|
||||
- `shipping_fee`
|
||||
- `total_price`, `total_price_text`
|
||||
- `card_price`, `card_price_text`
|
||||
- `card_name`
|
||||
- `card_discount`, `card_discount_text`
|
||||
- `installment`
|
||||
- `installment_detail`
|
||||
- `url`
|
||||
|
||||
항상 무료배송 여부, 배송비 포함 실구매가, 카드별 할인 가격, 무이자 할부 문구를 함께 확인한다.
|
||||
|
||||
### `compare`
|
||||
|
||||
`compare`는 검색 결과를 먼저 가져온 뒤 각 후보 상품에 대해 `offers[]`를 best-effort로 붙인다. 검색 결과가 애매하면 상위 후보의 제목과 `pcode`를 먼저 보여주고 선택을 요청한다.
|
||||
|
||||
## Response style
|
||||
|
||||
Discord/Telegram/chat 응답에서는 표 형식을 우선한다.
|
||||
|
||||
```md
|
||||
| 순위 | 판매처 | 상품가 | 배송 | 실구매가 | 카드할인가 | 무이자 | 링크 |
|
||||
|---:|---|---:|---|---:|---:|---|---|
|
||||
| 1 | G마켓 | 217,950원 | 무료배송 | 217,950원 | - | 최대 24개월 | 보기 |
|
||||
| 2 | 옥션 | 305,722원 | 무료배송 | 305,722원 | 우리카드 303,720원 | 최대 24개월 | 보기 |
|
||||
```
|
||||
|
||||
정렬 기준:
|
||||
|
||||
1. 기본 순위는 `total_price` 오름차순이다.
|
||||
2. `card_price`가 있고 카드 적용 시 승자가 바뀌면 표 아래에 "카드 기준 최저가"를 별도로 적는다.
|
||||
3. 무이자 할부는 결제 조건이 달라질 수 있으므로 Danawa 노출 문구 기준이라고 밝힌다.
|
||||
|
||||
요약 예시:
|
||||
|
||||
```md
|
||||
최저 실구매가: G마켓 217,950원 / 무료배송
|
||||
카드 기준 최저가: 옥션 우리카드 303,720원
|
||||
무이자: G마켓·옥션 최대 24개월 표기
|
||||
```
|
||||
|
||||
카드 할인 markup이 없으면 "카드 할인가 표기 없음"이라고 쓰고, 체크아웃 할인 자체가 없다고 단정하지 않는다.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. 검색어를 확인한다.
|
||||
2. `python scripts/danawa_search.py search "<검색어>" --limit 5`로 후보를 확인한다.
|
||||
3. 후보가 명확하면 해당 `pcode`로 `offers`를 실행한다.
|
||||
4. 후보가 애매하면 상위 3~5개 상품명/가격/`pcode`를 보여주고 선택을 요청한다.
|
||||
5. 오퍼는 배송비 포함 실구매가 기준으로 표 정렬한다.
|
||||
6. 카드 할인가가 있으면 카드 기준 최저가도 별도 요약한다.
|
||||
7. 조회 시점 기준이며 가격/배송/카드 혜택은 변동될 수 있음을 짧게 덧붙인다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- 검색 결과가 0개면 검색어를 더 구체화한다.
|
||||
- Danawa HTML/AJAX 구조가 바뀌면 selector가 깨져 `offers`가 비거나 필드가 누락될 수 있다.
|
||||
- 검색 결과 가격과 오퍼 AJAX 가격은 갱신 시점·카드가·제휴 링크 기준 차이로 다를 수 있다.
|
||||
- 카드 할인과 무이자 문구는 Danawa가 노출한 경우에만 확정적으로 보여준다.
|
||||
- 공개 표면 기반이므로 고빈도 요청에는 throttling/backoff를 추가해야 한다.
|
||||
- 접근 차단이나 CAPTCHA가 나오면 우회를 시도하지 말고 실패 모드로 보고한다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 검색어 또는 모델명을 확인했다.
|
||||
- 상품 후보를 최소 1개 이상 반환하거나, 반환 실패 이유를 설명했다.
|
||||
- 쇼핑몰별 상품가, 배송비, 실구매가, 카드 할인가, 무이자 문구를 조회 시점 기준으로 정리했다.
|
||||
- 사용자 응답은 표 형식으로 제공했다.
|
||||
- 로그인/구매/차단 우회 범위를 벗어나지 않았다.
|
||||
285
danawa-price-search/scripts/danawa_search.py
Executable file
285
danawa-price-search/scripts/danawa_search.py
Executable file
|
|
@ -0,0 +1,285 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only Danawa search/price comparison helper for Hermes.
|
||||
|
||||
Usage:
|
||||
python scripts/danawa_search.py search "에어팟 프로 2세대" --limit 8
|
||||
python scripts/danawa_search.py offers 28208783 --limit 10
|
||||
python scripts/danawa_search.py compare "에어팟 프로 2세대" --limit 5 --offers 5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from html import unescape
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError as exc: # pragma: no cover - environment guard
|
||||
raise SystemExit("beautifulsoup4 is required: python -m pip install beautifulsoup4") from exc
|
||||
|
||||
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/121 Safari/537.36"
|
||||
|
||||
|
||||
def fetch(url: str, *, method: str = "GET", data: Optional[dict] = None, referer: Optional[str] = None) -> str:
|
||||
headers = {
|
||||
"User-Agent": UA,
|
||||
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
|
||||
}
|
||||
body = None
|
||||
if data is not None:
|
||||
body = urllib.parse.urlencode(data).encode("utf-8")
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
|
||||
headers["X-Requested-With"] = "XMLHttpRequest"
|
||||
if referer:
|
||||
headers["Referer"] = referer
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
return resp.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def soup_for(html: str) -> BeautifulSoup:
|
||||
return BeautifulSoup(html, "html.parser")
|
||||
|
||||
|
||||
def clean_text(s: Optional[str]) -> Optional[str]:
|
||||
if s is None:
|
||||
return None
|
||||
return " ".join(unescape(s).split())
|
||||
|
||||
|
||||
def parse_int(s: Optional[str]) -> Optional[int]:
|
||||
if not s:
|
||||
return None
|
||||
digits = re.sub(r"\D", "", s)
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def abs_url(url: Optional[str]) -> Optional[str]:
|
||||
if not url:
|
||||
return None
|
||||
if url.startswith("//"):
|
||||
return "https:" + url
|
||||
if url.startswith("/"):
|
||||
return "https://prod.danawa.com" + url
|
||||
return url
|
||||
|
||||
|
||||
def search(query: str, limit: int = 10) -> Dict[str, Any]:
|
||||
url = "https://search.danawa.com/dsearch.php?query=" + urllib.parse.quote(query)
|
||||
html = fetch(url)
|
||||
soup = soup_for(html)
|
||||
items: List[Dict[str, Any]] = []
|
||||
for li in soup.select("li.prod_item"):
|
||||
pid = (li.get("id") or "").replace("productItem", "") or None
|
||||
name_el = li.select_one(".prod_name a") or li.select_one("p.prod_name a") or li.select_one('a[name="productName"]')
|
||||
if not name_el:
|
||||
continue
|
||||
name = clean_text(name_el.get_text(" ", strip=True))
|
||||
link = abs_url(name_el.get("href"))
|
||||
min_input = li.select_one(f"#min_price_{pid}") if pid else None
|
||||
price = parse_int(min_input.get("value") if min_input else None)
|
||||
if price is None:
|
||||
price_el = li.select_one(".price_sect strong") or li.select_one(".prod_pricelist strong")
|
||||
price = parse_int(price_el.get_text() if price_el else None)
|
||||
img = li.select_one(".thumb_image img")
|
||||
image = abs_url((img.get("data-original") or img.get("src")) if img else None)
|
||||
mall_el = li.select_one(".prod_pricelist .memory_sect") or li.select_one(".meta_item")
|
||||
spec = " / ".join(clean_text(e.get_text(" ", strip=True)) or "" for e in li.select(".spec_list a, .spec_list span")[:10])
|
||||
items.append(
|
||||
{
|
||||
"pcode": pid,
|
||||
"title": name,
|
||||
"price": price,
|
||||
"price_text": f"{price:,}원" if price else None,
|
||||
"mall_text": clean_text(mall_el.get_text(" ", strip=True)) if mall_el else None,
|
||||
"url": link,
|
||||
"image_url": image,
|
||||
"spec": spec[:300] if spec else None,
|
||||
}
|
||||
)
|
||||
if len(items) >= limit:
|
||||
break
|
||||
return {"query": query, "source_url": url, "count": len(items), "items": items, "meta": {"extraction": "danawa-search-html", "ts": int(time.time())}}
|
||||
|
||||
|
||||
def js_value(html: str, key: str) -> str:
|
||||
patterns = [
|
||||
rf"{re.escape(key)}\s*:\s*\"([^\"]*)\"",
|
||||
rf"{re.escape(key)}\s*:\s*'([^']*)'",
|
||||
rf"{re.escape(key)}\s*:\s*([0-9]+)",
|
||||
]
|
||||
for pat in patterns:
|
||||
m = re.search(pat, html)
|
||||
if m:
|
||||
raw = m.group(1)
|
||||
if "\\u" in raw or "\\/" in raw:
|
||||
try:
|
||||
return json.loads('"' + raw.replace('"', '\\"') + '"')
|
||||
except Exception:
|
||||
return raw.replace("\\/", "/")
|
||||
return raw
|
||||
return ""
|
||||
|
||||
|
||||
def product_meta(pcode: str) -> Dict[str, str]:
|
||||
url = f"https://prod.danawa.com/info/?pcode={urllib.parse.quote(str(pcode))}"
|
||||
html = fetch(url)
|
||||
meta = {
|
||||
"pcode": str(pcode),
|
||||
"source_url": url,
|
||||
"cate1": js_value(html, "nCategoryCode1"),
|
||||
"cate2": js_value(html, "nCategoryCode2"),
|
||||
"cate3": js_value(html, "nCategoryCode3"),
|
||||
"cate4": js_value(html, "nCategoryCode4") or "0",
|
||||
"UICategoryCode": js_value(html, "nCategoryCode"),
|
||||
"powerLinkKeyword": js_value(html, "powerLinkKeyword"),
|
||||
"minPrice": js_value(html, "nMinPrice"),
|
||||
"keyword": js_value(html, "sKeyword"),
|
||||
"NaPm": js_value(html, "sNaPm"),
|
||||
"sProductFullName": js_value(html, "sProductName"),
|
||||
"makerCode": js_value(html, "makerCode"),
|
||||
"makerName": js_value(html, "makerName"),
|
||||
}
|
||||
title = soup_for(html).select_one(".prod_tit .title")
|
||||
if title:
|
||||
meta["sProductFullName"] = clean_text(title.get_text(" ", strip=True)) or meta["sProductFullName"]
|
||||
return meta
|
||||
|
||||
|
||||
def offers(pcode: str, limit: int = 20, include_shipping: bool = False) -> Dict[str, Any]:
|
||||
meta = product_meta(pcode)
|
||||
post_price = "Y" if include_shipping else "N"
|
||||
data = {
|
||||
"pcode": meta["pcode"],
|
||||
"cate1": meta.get("cate1", ""),
|
||||
"cate2": meta.get("cate2", ""),
|
||||
"cate3": meta.get("cate3", ""),
|
||||
"cate4": meta.get("cate4", "0"),
|
||||
"UICategoryCode": meta.get("UICategoryCode", "0"),
|
||||
"powerLinkKeyword": meta.get("powerLinkKeyword", ""),
|
||||
"minPrice": meta.get("minPrice", ""),
|
||||
"keyword": meta.get("keyword", ""),
|
||||
"NaPm": meta.get("NaPm", ""),
|
||||
"bDeliveryLeftRightYN": "N",
|
||||
"bQuickPostSortYN": "N",
|
||||
"sSortType": "minPrice",
|
||||
"sProductFullName": meta.get("sProductFullName", ""),
|
||||
"bPostPriceYN": post_price,
|
||||
"bBadgeDefaultYN": "N",
|
||||
"bWarrantyDefaultYN": "N",
|
||||
"nOpenMarketMoreCount": "30",
|
||||
"nAffiliateMoreCount": "30",
|
||||
"nOverseasShoppingMoreCount": "30",
|
||||
"nGeneralAffiliateMoreCount": "3",
|
||||
"sRelationMenuType": "",
|
||||
"sRelationType": "",
|
||||
"bCoupangSortYN": "N",
|
||||
"makerCode": meta.get("makerCode", ""),
|
||||
"makerName": meta.get("makerName", ""),
|
||||
}
|
||||
html = fetch("https://prod.danawa.com/info/ajax/getAllPriceCompareMallList.ajax.php", method="POST", data=data, referer=meta["source_url"])
|
||||
soup = soup_for(html)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for div in soup.select(".diff_item"):
|
||||
mall_img = div.select_one(".d_mall img")
|
||||
mall = mall_img.get("alt") if mall_img else None
|
||||
price_el = div.select_one("em.prc_c") or div.select_one("em.prc_t")
|
||||
price = parse_int(price_el.get_text() if price_el else None)
|
||||
if not mall or price is None:
|
||||
continue
|
||||
ship_el = div.select_one(".ship") or div.select_one(".stxt")
|
||||
shipping = clean_text(ship_el.get_text(" ", strip=True)) if ship_el else None
|
||||
shipping_fee = 0 if shipping and "무료" in shipping else parse_int(shipping)
|
||||
card_line = div.select_one(".card_line")
|
||||
card_price_el = card_line.select_one(".card_prc") if card_line else None
|
||||
card_name_el = card_line.select_one(".txt") if card_line else None
|
||||
card_price = parse_int(card_price_el.get_text() if card_price_el else None)
|
||||
installment_el = div.select_one(".btn_foi .txt")
|
||||
installment_detail_el = div.select_one(".foi_layer .ly_cont")
|
||||
link = div.select_one("a.priceCompareBuyLink")
|
||||
rows.append(
|
||||
{
|
||||
"mall": clean_text(mall),
|
||||
"price": price,
|
||||
"price_text": f"{price:,}원",
|
||||
"shipping": shipping,
|
||||
"is_free_shipping": bool(shipping and "무료" in shipping),
|
||||
"shipping_fee": shipping_fee,
|
||||
"total_price": price + (shipping_fee or 0),
|
||||
"total_price_text": f"{price + (shipping_fee or 0):,}원",
|
||||
"card_price": card_price,
|
||||
"card_price_text": f"{card_price:,}원" if card_price else None,
|
||||
"card_name": clean_text(card_name_el.get_text(" ", strip=True)) if card_name_el else None,
|
||||
"card_discount": (price - card_price) if card_price else None,
|
||||
"card_discount_text": f"{price - card_price:,}원" if card_price else None,
|
||||
"installment": clean_text(installment_el.get_text(" ", strip=True)) if installment_el else None,
|
||||
"installment_detail": clean_text(installment_detail_el.get_text(" ", strip=True)) if installment_detail_el else None,
|
||||
"url": abs_url(link.get("href") if link else None),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["total_price"] is None, row["total_price"] or row["price"], row["price"], row["mall"] or ""))
|
||||
rows = rows[:limit]
|
||||
return {"pcode": str(pcode), "title": meta.get("sProductFullName"), "source_url": meta["source_url"], "count": len(rows), "offers": rows, "meta": {"extraction": "danawa-price-ajax", "include_shipping": include_shipping, "sort": "total_price", "ts": int(time.time())}}
|
||||
|
||||
|
||||
def compare(query: str, limit: int, offer_limit: int) -> Dict[str, Any]:
|
||||
result = search(query, limit=limit)
|
||||
enriched = []
|
||||
for item in result["items"]:
|
||||
row = dict(item)
|
||||
if item.get("pcode"):
|
||||
try:
|
||||
off = offers(item["pcode"], limit=offer_limit)
|
||||
row["offers"] = off.get("offers", [])
|
||||
except Exception as exc: # keep search result usable if a detail call fails
|
||||
row["offers_error"] = f"{type(exc).__name__}: {exc}"
|
||||
enriched.append(row)
|
||||
result["items"] = enriched
|
||||
result["meta"]["detail_extraction"] = "best-effort"
|
||||
return result
|
||||
|
||||
|
||||
def positive_int(raw: str) -> int:
|
||||
value = int(raw)
|
||||
if value < 1:
|
||||
raise argparse.ArgumentTypeError("must be >= 1")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
s = sub.add_parser("search")
|
||||
s.add_argument("query")
|
||||
s.add_argument("--limit", type=positive_int, default=10)
|
||||
o = sub.add_parser("offers")
|
||||
o.add_argument("pcode")
|
||||
o.add_argument("--limit", type=positive_int, default=20)
|
||||
o.add_argument("--include-shipping", action="store_true")
|
||||
c = sub.add_parser("compare")
|
||||
c.add_argument("query")
|
||||
c.add_argument("--limit", type=positive_int, default=5)
|
||||
c.add_argument("--offers", type=positive_int, default=5)
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
if args.cmd == "search":
|
||||
out = search(args.query, args.limit)
|
||||
elif args.cmd == "offers":
|
||||
out = offers(args.pcode, args.limit, args.include_shipping)
|
||||
else:
|
||||
out = compare(args.query, args.limit, args.offers)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(json.dumps({"error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
47
docs/features/danawa-price-search.md
Normal file
47
docs/features/danawa-price-search.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 다나와 최저가 비교 (`danawa-price-search`)
|
||||
|
||||
다나와 공개 검색/가격비교 표면을 사용해 상품 후보를 찾고, 쇼핑몰별 가격을 배송비 포함 실구매가 기준으로 비교하는 스킬입니다.
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
- "다나와에서 맥북 에어 M4 최저가 비교해줘"
|
||||
- "이 다나와 pcode 쇼핑몰별 가격 표로 보여줘"
|
||||
- "배송비랑 카드할인까지 포함해서 어디가 제일 싼지 봐줘"
|
||||
|
||||
## 구현 표면
|
||||
|
||||
브라우저 자동화나 로그인을 사용하지 않습니다.
|
||||
|
||||
1. 검색: `https://search.danawa.com/dsearch.php?query=...`
|
||||
2. 상품 상세 확인: `https://prod.danawa.com/info/?pcode=...`
|
||||
3. 쇼핑몰별 가격비교 AJAX: `https://prod.danawa.com/info/ajax/getAllPriceCompareMallList.ajax.php`
|
||||
|
||||
## 로컬 실행
|
||||
|
||||
```bash
|
||||
python3 danawa-price-search/scripts/danawa_search.py search "맥북 에어 M4" --limit 5
|
||||
python3 danawa-price-search/scripts/danawa_search.py offers 28208783 --limit 10
|
||||
python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --limit 3 --offers 5
|
||||
```
|
||||
|
||||
## 출력 해석
|
||||
|
||||
`offers`와 `compare` 결과에는 다음 필드가 포함됩니다.
|
||||
|
||||
- `mall`: 쇼핑몰명
|
||||
- `price`: 표시 가격
|
||||
- `shipping_fee`: 배송비 숫자. 무료배송이면 `0`, 파싱 불가면 `null`
|
||||
- `is_free_shipping`: 무료배송 여부
|
||||
- `total_price`: 가격 + 배송비 기준 실구매가 후보
|
||||
- `card_price`: 카드 적용 표시가
|
||||
- `card_discount`: 표시가와 카드가 차액
|
||||
- `installment`: 무이자 할부 문구
|
||||
- `url`: 다나와 경유 링크
|
||||
|
||||
사용자에게는 `total_price` 기준으로 정렬한 Markdown 표를 먼저 보여주고, 카드가는 별도 열에 표시합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
- 다나와의 공개 HTML/AJAX 구조가 바뀌면 selector와 파싱 규칙을 갱신해야 합니다.
|
||||
- 자동 구매, 로그인, CAPTCHA 우회, 결제 단계 자동화는 이 스킬의 범위가 아닙니다.
|
||||
- 동일 상품명이라도 옵션/용량/모델명이 섞일 수 있으므로 검색 후보를 먼저 확인한 뒤 가격비교를 진행합니다.
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue