feat: add Daangn search skills

This commit is contained in:
TaeyoungPark 2026-05-13 15:48:49 +09:00
commit 12adac4eb8
9 changed files with 708 additions and 1 deletions

101
daangn-cars-search/SKILL.md Normal file
View file

@ -0,0 +1,101 @@
---
name: daangn-cars-search
description: 당근중고차 공개 웹 데이터 표면으로 지역·가격 조건 기반 차량 검색과 상세 조회를 수행한다. 문의/구매 자동화는 제외한다.
license: MIT
metadata:
category: automotive
locale: ko-KR
phase: v1
---
# Daangn Cars Search
## What this skill does
당근중고차 공개 Remix `_data` JSON route를 사용해 차량 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근중고차 합정동 레이 찾아봐"
- "당근에서 천만원 이하 중고차 검색"
- "이 당근 중고차 URL 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/cars/?in=<지역명>-<id>&onlyOnSale=1&_data=routes/kr.cars._index`
- Detail `_data`: `<car-url>?_data=routes%2Fkr.cars.%24car_post_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-cars-search/scripts/daangn_cars.py search "레이" --region "합정동" --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py search --region "합정동" --price-max 10000000 --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py detail "https://www.daangn.com/kr/cars/.../"
```
## Output fields
- title, price, price_text, region, status, driveDistance, carData, chatRoomCount, url
- detail: carPost 원문
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
sel=resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.only_on_sale: params.append(('onlyOnSale','1'))
if args.price_max: params.append(('priceMax', str(args.price_max)))
if args.price_min: params.append(('priceMin', str(args.price_min)))
params.append(('_data','routes/kr.cars._index'))
url='https://www.daangn.com/kr/cars/?'+urllib.parse.urlencode(params)
data=fetch_json(url); arr=((data.get('carAllPage') or {}).get('carPosts') or [])
if args.keyword:
arr=[a for a in arr if args.keyword.lower() in (a.get('title') or '').lower()]
arr=arr[:args.limit]
items=[{'title':a.get('title'),'price':a.get('price'),'price_text':won(a.get('price')),'region':(a.get('region') or {}).get('name'),
'status':a.get('status'),'driveDistance':a.get('driveDistance'),'carData':a.get('carData'),
'chatRoomCount':a.get('chatRoomCount'),'url':absolute(a.get('href'))} for a in arr]
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
u=args.url.rstrip('/')+'/?_data=routes%2Fkr.cars.%24car_post_id'
data=fetch_json(u); print_json({'source':u,'carPost':data.get('carPost') or data})
p=argparse.ArgumentParser(description='Daangn cars read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword', nargs='?'); s.add_argument('--region'); s.add_argument('--price-min',type=int); s.add_argument('--price-max',type=int); s.add_argument('--only-on-sale',action='store_true',default=True); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

100
daangn-jobs-search/SKILL.md Normal file
View file

@ -0,0 +1,100 @@
---
name: daangn-jobs-search
description: 당근알바 공개 웹 데이터 표면으로 키워드·지역 기반 알바 공고 검색과 상세 조회를 수행한다. 지원/채팅 자동화는 제외한다.
license: MIT
metadata:
category: jobs
locale: ko-KR
phase: v1
---
# Daangn Jobs Search
## What this skill does
당근알바 공개 Remix `_data` JSON route로 채용/알바 공고 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근알바 합정동 카페 찾아봐"
- "홍대 근처 주말 알바 검색"
- "이 당근알바 공고 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/jobs/?in=<지역명>-<id>&search=<keyword>&_data=routes/kr.jobs._index`
- Detail `_data`: `<job-url>?_data=routes%2Fkr.jobs.%24job_post_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-jobs-search/scripts/daangn_jobs.py search "카페" --region "합정동" --limit 5
python3 daangn-jobs-search/scripts/daangn_jobs.py detail "https://www.daangn.com/kr/jobs/.../"
```
## Output fields
- title, company, region, address, salary, salaryType, workDays, workTimeStart, workTimeEnd, closed, url
- detail: jobPost 원문
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
sel=resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.keyword: params.append(('search', args.keyword))
params.append(('_data','routes/kr.jobs._index'))
url='https://www.daangn.com/kr/jobs/?'+urllib.parse.urlencode(params)
data=fetch_json(url); arr=((data.get('jobsAllPage') or {}).get('jobPosts') or [])[:args.limit]
items=[{'title':a.get('title'),'company':a.get('workplaceCompanyName'),'region':a.get('workplaceRegion'),
'address':a.get('workplaceRoadNameAddress'),'salary':a.get('salary'),'salaryType':a.get('salaryType'),
'workDays':a.get('workDays'),'workTimeStart':a.get('workTimeStart'),'workTimeEnd':a.get('workTimeEnd'),
'closed':a.get('closed'),'url':absolute(a.get('href') or a.get('jobsWebDetailUrl'))} for a in arr]
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
u=args.url.rstrip('/')+'/?_data=routes%2Fkr.jobs.%24job_post_id'
data=fetch_json(u); print_json({'source':u,'jobPost':data.get('jobPost') or data})
p=argparse.ArgumentParser(description='Daangn jobs read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword', nargs='?'); s.add_argument('--region'); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,101 @@
---
name: daangn-realty-search
description: 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인을 수행한다. 문의/예약/계약 자동화는 제외한다.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# Daangn Realty Search
## What this skill does
당근부동산 목록의 공개 Remix `_data` JSON과 상세 페이지의 JSON-LD/HTML 메타를 읽어 매물 후보를 정리한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근부동산 합정동 전세 찾아봐"
- "마포구 월세 매물 봐줘"
- "이 당근부동산 URL 상세 요약해줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/realty/?in=<지역명>-<id>&_data=routes/kr.realty._index`
- Detail: `https://realty.daangn.com/articles/<id>``application/ld+json``<title>`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --limit 5
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --sales-type "APARTMENT" --trade-type "MONTHLY_RENT"
python3 daangn-realty-search/scripts/daangn_realty.py detail "https://realty.daangn.com/articles/..."
```
## Output fields
- title, salesType, trade, area, areaPyeong, totalManageCost, url
- detail: JSON-LD, page title
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def norm_trade(t):
if not t: return None
return t
def cmd_search(args):
sel = resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.sales_type: params.append(('salesType', args.sales_type))
if args.trade_type: params.append(('tradeType', args.trade_type))
if args.only_verified: params.append(('onlyVerified','true'))
params.append(('_data','routes/kr.realty._index'))
url='https://www.daangn.com/kr/realty/?'+urllib.parse.urlencode(params)
data=fetch_json(url)
arr=((data.get('realtyPosts') or {}).get('realtyPosts') or [])
if args.keyword:
arr=[a for a in arr if args.keyword.lower() in json.dumps(a, ensure_ascii=False).lower()]
arr=arr[:args.limit]
items=[]
for a in arr:
tr=(a.get('trades') or [{}])[0]
items.append({'title':a.get('title'),'salesType':a.get('salesType') or a.get('salesTypeV2'),'trade':tr,
'area':a.get('area'),'areaPyeong':a.get('areaPyeong'),'totalManageCost':a.get('totalManageCost'),
'url':a.get('webUrl') or absolute(a.get('href'))})
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
html=fetch_text(args.url)
lds=[]
for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S):
try: lds.append(json.loads(unescape(m.group(1))))
except Exception: pass
title=re.search(r'<title>(.*?)</title>', html, re.S)
print_json({'source':args.url,'title':unescape(title.group(1)).strip() if title else None,'json_ld':lds[:3]})
p=argparse.ArgumentParser(description='Daangn realty read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('--region'); s.add_argument('--keyword'); s.add_argument('--sales-type'); s.add_argument('--trade-type'); s.add_argument('--only-verified',action='store_true'); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,100 @@
---
name: daangn-used-goods-search
description: 당근 중고거래 공개 웹 데이터 표면으로 키워드·지역 기반 매물 검색과 상세 조회를 수행한다. 로그인/채팅/찜/구매 자동화는 제외한다.
license: MIT
metadata:
category: marketplace
locale: ko-KR
phase: v1
---
# Daangn Used-Goods Search
## What this skill does
당근 중고거래 공개 Remix `_data` JSON route를 사용해 매물 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근에서 맥북 찾아봐"
- "합정동 아이폰 매물 검색"
- "이 당근 중고거래 URL 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/buy-sell/all/?in=<지역명>-<id>&search=<keyword>&only_on_sale=true&_data=routes/kr.buy-sell._index`
- Detail `_data`: `<listing-url>?_data=routes%2Fkr.buy-sell.%24buy_sell_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-used-goods-search/scripts/daangn_used_goods.py search "맥북" --region "합정동" --limit 5
python3 daangn-used-goods-search/scripts/daangn_used_goods.py detail "https://www.daangn.com/kr/buy-sell/.../"
```
## Output fields
- title, price, price_text, status, region, url
- detail: product 원문, view/chat/count류 필드가 있으면 함께 확인
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
params = []
effective = None
path = '/kr/buy-sell/'
if args.region:
effective = resolve_region(args.region)
path = '/kr/buy-sell/all/'
params.append(('in', f"{effective['name']}-{effective['id']}"))
params.append(('search', args.keyword))
if args.only_on_sale: params.append(('only_on_sale','true'))
params.append(('_data','routes/kr.buy-sell._index'))
url = 'https://www.daangn.com' + path + '?' + urllib.parse.urlencode(params)
data = fetch_json(url)
arr = (((data.get('allPage') or {}).get('fleamarketArticles')) or [])[:args.limit]
print_json({
'source': url,
'effective_region': effective or data.get('region'),
'count': len(arr),
'items': [{
'title': a.get('title'), 'price': a.get('price'), 'price_text': won(a.get('price')),
'region': (a.get('region') or {}).get('name'), 'status': a.get('status'),
'url': absolute(a.get('href') or a.get('webUrl')),
} for a in arr]
})
def cmd_detail(args):
u = args.url.rstrip('/') + '/?_data=routes%2Fkr.buy-sell.%24buy_sell_id'
data = fetch_json(u); p = data.get('product') or data.get('article') or data
print_json({'source': u, 'product': p})
p=argparse.ArgumentParser(description='Daangn used-goods read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword'); s.add_argument('--region'); s.add_argument('--limit',type=int,default=10); s.add_argument('--only-on-sale',action='store_true',default=True); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -10,7 +10,7 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/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 intercity-bus-booking/scripts/intercity_bus_search.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/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 intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_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",