This commit is contained in:
ajkh624 2026-06-22 17:33:55 +09:00 committed by GitHub
commit af4deda976
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 423 additions and 114 deletions

View file

@ -1,101 +1,121 @@
---
name: daangn-realty-search
description: 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인을 수행한다. 문의/예약/계약 자동화는 제외한다.
description: 당근부동산(realty.daangn.com) 공개 웹 데이터로 지역 기반 부동산 매물 검색과 상세 확인을 수행한다. 문의/예약/계약 자동화는 제외한다.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
phase: v1.5
---
# Daangn Realty Search
## What this skill does
당근부동산 목록의 공개 Remix `_data` JSON과 상세 페이지의 JSON-LD/HTML 메타를 읽어 매물 후보를 정리한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
당근부동산 지도 페이지(`realty.daangn.com/map/{name1}/{name2}/{name3}`)의 SSR `window.RELAY_STORE`(Relay 정규화 스토어)를 파싱해 매물 후보를 정리한다. 제목·주소·층수는 상세 페이지의 JSON-LD에서 보강한다. 외부 패키지 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근부동산 합정동 전세 찾아봐"
- "마포구 월세 매물 봐줘"
- "당근부동산 매교동 월세 찾아봐"
- "수원 팔달구 상가 매물 봐줘" (`--expand`로 인접 동까지)
- "이 당근부동산 URL 상세 요약해줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- 채팅, 찜, 거래 제안, 문의, 예약, 계약, 구매처럼 상대방/계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
- 인터넷 연결, Python 3.9+
## Data surfaces
## Data surfaces (2026-06 도메인 이전 대응)
- 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>`
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>``{"locations":[{id,name1,name2,name3,name*Id,depth}]}`
- 매물 목록: `https://realty.daangn.com/map/{name1}/{name2}/{name3}``window.RELAY_STORE`
- 상세: `https://realty.daangn.com/articles/<id>` `application/ld+json`
## Workflow
> ⚠️ **구버전(`www.daangn.com/kr/realty/?_data=routes/kr.realty._index`)은 2026-06부터 HTTP 204(빈 응답)로 폐기됨.** 절대 사용 금지.
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## RELAY_STORE 파싱 경로 (검증됨)
```
ArticleFeedConnection.edges → ArticleFeedEdge.node → ArticleFeedCard.article → Article
```
- 스토어에 `ArticleFeedCard`가 직접 다 들어있어, **Card를 순회 → article ref 디레퍼런스**가 가장 견고하다.
- `Article`: `originalId`, `area`(㎡, **문자열일 수 있어 float 변환 필수**), `salesTypeV3`(→`*SalesTypeV2.type`), `trades`(→ Month/Buy/BorrowTrade)
- 가격 단위 = **만원**: `deposit`(보증금), `monthlyPay`(월세), `price`(매매가). 예: deposit 2000 = 2천만원, price 28700 = 2억8,700만.
- `window.RELAY_STORE`는 **JS 문자열로 이스케이프**돼 있다 → `json.loads(json.loads('"'+raw+'"'))` 2단 디코드.
## Trade 유형 & 평당 단가
| 거래유형 | typename | 가격 필드 | 평당 단가 |
|---|---|---|---|
| 월세(MONTH) | MonthTrade | deposit + monthlyPay | monthlyPay / 평 |
| 매매(BUY) | BuyTrade | price | price / 평 |
| 전세(BORROW) | BorrowTrade | deposit | deposit / 평 |
평 = ㎡ / 3.305785.
## salesType enum
`APART`, `OFFICETEL`, `STORE`, `OPEN_ONE_ROOM`, `SPLIT_ONE_ROOM`, `TWO_ROOM`, `HOUSE` 등.
## 층수 (상세 JSON-LD)
목록엔 층수가 없다. 상세 페이지 `Product.additionalProperty` 배열에서:
- `floor`(예 "8.0") / `topFloor`(예 "10") → `floor_label` = "8층/10층"
- `nearbySubwayStation` 도 함께 추출.
제목은 `Product.name`, 주소는 `Place.name`.
## 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/..."
# 기본 검색 (상위 5개 제목·층수 보강)
python3 daangn-realty-search/scripts/daangn_realty.py search --region "매교동" --limit 20
# 거래/용도 필터
python3 ... search --region "매교동" --trade-type BUY # 매매만
python3 ... search --region "매교동" --sales-type STORE,OFFICETEL # 용도 콤마구분
# 인접 동까지 확장 (같은 구/시)
python3 ... search --region "매교동" --expand --expand-max 6
# 상세
python3 ... search --region "매교동" --titles 0 # 제목 보강 끄기(빠름)
python3 ... detail "https://realty.daangn.com/articles/2947028"
```
옵션: `--limit`(기본 20), `--titles N`(상세로 제목·층수 보강할 상위 N, 기본 5, 0=끔), `--expand`/`--expand-max`(기본 6).
## Output fields
- title, salesType, trade, area, areaPyeong, totalManageCost, url
- detail: JSON-LD, page title
매물: `article_id, salesType, area_sqm, area_pyeong, trades[{type,label,deposit_manwon,monthly_manwon,price_manwon,per_pyeong_manwon}], url, region, title, address, floor_label, nearby_subway`
## 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/쿠키 기본 위치에 의존하지 않는다.
지역명 → region API로 내부 id 해석. 동명이 여럿이면 정확일치 → 서울 depth=3 → 첫 후보 순. 응답에 `effective_region` 포함. `--expand`는 같은 `name2Id`(구/시) 인접 동을 모은다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
- 읽기 전용 검색/상세만. 로그인·채팅·거래·예약·계약 자동화 없음.
- 공개 표면이 바뀌거나 빈 응답/봇 차단이면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 호가라 실거래와 다를 수 있으므로 source URL을 함께 제시.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
- `RELAY_STORE 없음` (sources note) → 페이지 구조 재변경 또는 봇 차단. HTML 구조 재확인 필요.
- 시군구(name2) 랜딩 URL은 매물이 없다 — **반드시 동(name3)까지** 있어야 articleFeed가 SSR로 채워진다.
- 동명이 넓거나 중복되면 다른 행정동 선택 가능.
- 상세는 삭제/비공개 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 지역 id 해석 → map URL의 RELAY_STORE에서 매물 추출 → source URL·effective_region 포함.
- 인증/거래성 액션은 수행하지 않았다.
## Notes
- Windows stdout 한글 깨짐 방지: `sys.stdout.reconfigure(encoding='utf-8')` 적용됨.
- `area`·가격 필드가 문자열로 오는 케이스가 있어 모든 수치 연산 전 float 변환 가드.

View file

@ -1,85 +1,374 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
# -*- coding: utf-8 -*-
"""당근부동산(realty.daangn.com) 읽기 전용 매물 검색/상세.
2026-06 당근부동산 도메인 이전 대응:
- 기존 www.daangn.com/kr/realty/?_data=routes/kr.realty._index HTTP 204 (폐기)
- 신규 realty.daangn.com/map/{name1}/{name2}/{name3} 페이지의
window.RELAY_STORE (Relay 정규화 스토어) 파싱한다.
RELAY_STORE 경로:
ArticleFeedConnection.edges ArticleFeedEdge.node ArticleFeedCard.article Article
Article: originalId, area(), salesTypeV3(*SalesTypeV2.type), trades(Month/Buy/BorrowTrade)
가격 단위: 만원 (deposit 2000 = 2천만원, monthlyPay 100 = 100만원, price 28700 = 2억8700만).
층수: 상세 페이지 JSON-LD additionalProperty floor/topFloor.
"""
import argparse, json, re, sys, urllib.parse, urllib.request
# Windows 등에서 stdout 한글 깨짐 방지
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "text/html,application/json;q=0.9,*/*;q=0.8"}
REGION_API = "https://www.daangn.com/kr/api/v1/regions/keyword?keyword="
MAP_BASE = "https://realty.daangn.com/map/"
DETAIL_BASE = "https://realty.daangn.com/articles/"
PY_PER_SQM = 3.305785 # 1평 = 3.305785㎡
TRADE_LABEL = {"MONTH": "월세", "BUY": "매매", "BORROW": "전세"}
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"})
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
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
# ------------------------- region 해석 -------------------------
def resolve_region(region):
"""지역명 → 당근 내부 region 객체 (id, name1/2/3, name*Id)."""
if not region:
return None
data = fetch_json(REGION_API + urllib.parse.quote(region))
locs = data.get("locations") or []
if not locs:
raise SystemExit(f"지역 후보 없음: {region}")
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]
return (exact or seoul or locs)[0]
def find_sibling_regions(sel, max_siblings=6):
"""같은 name2(구/시) 내 인접 동들을 조회 (--expand 용).
name2 키워드로 다시 region API를 때려 같은 name2Id 가진 depth=3 동들을 모은다.
"""
name2 = sel.get("name2") or ""
if not name2:
return []
try:
data = fetch_json(REGION_API + urllib.parse.quote(name2.split()[-1]))
except Exception:
return []
sibs = []
seen = {sel.get("name3Id")}
for x in (data.get("locations") or []):
if x.get("depth") != 3:
continue
if x.get("name2Id") != sel.get("name2Id"):
continue
if x.get("name3Id") in seen:
continue
seen.add(x.get("name3Id"))
sibs.append(x)
if len(sibs) >= max_siblings:
break
return sibs
def map_url(sel):
parts = [sel.get("name1") or "", sel.get("name2") or "", sel.get("name3") or ""]
path = "/".join(urllib.parse.quote(p) for p in parts)
return MAP_BASE + path
# ------------------------- RELAY_STORE 파싱 -------------------------
def extract_relay_store(html):
"""window.RELAY_STORE = "<json-string>"; 를 dict 로 디코드."""
m = re.search(r'window\.RELAY_STORE\s*=\s*"((?:[^"\\]|\\.)*)"', html)
if m:
try:
return json.loads(json.loads('"' + m.group(1) + '"'))
except Exception:
pass
# 혹시 객체 리터럴로 박힌 경우 (balanced scan)
i = html.find("window.RELAY_STORE")
if i >= 0:
eq = html.find("=", i)
s = html[eq + 1:]
depth = 0; instr = False; esc = False; q = ""; end = 0
for idx, ch in enumerate(s):
if instr:
if esc: esc = False
elif ch == "\\": esc = True
elif ch == q: instr = False
else:
if ch in "\"'": instr = True; q = ch
elif ch == "{": depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = idx + 1; break
if end:
try:
return json.loads(s[:end])
except Exception:
pass
return None
def _deref(store, ref):
if isinstance(ref, dict) and "__ref" in ref:
return store.get(ref["__ref"])
return ref
def _refs(store, node, key):
"""edges 처럼 __refs 배열 / __ref 단일 / list 모두 대응."""
v = node.get(key)
out = []
if isinstance(v, dict):
if "__refs" in v:
out = [store.get(r) for r in v["__refs"]]
elif "__ref" in v:
out = [store.get(v["__ref"])]
elif isinstance(v, list):
for x in v:
if isinstance(x, dict) and "__ref" in x:
out.append(store.get(x["__ref"]))
return [o for o in out if o]
def sales_type(store, article):
st = _deref(store, article.get("salesTypeV3"))
if isinstance(st, dict):
return st.get("type") or st.get("name")
return None
def parse_trade(store, trade):
"""Month/Buy/BorrowTrade → (label, deposit, monthly, price)."""
tn = trade.get("__typename")
if tn == "MonthTrade":
return ("MONTH", trade.get("deposit"), trade.get("monthlyPay"), None)
if tn == "BuyTrade":
return ("BUY", None, None, trade.get("price"))
if tn == "BorrowTrade":
return ("BORROW", trade.get("deposit"), None, None)
# fallback: type 필드
t = trade.get("type")
return (t, trade.get("deposit"), trade.get("monthlyPay"), trade.get("price"))
def per_pyeong(kind, deposit, monthly, price, pyeong):
"""거래유형별 평당 단가(만원/평). 월세=월세/평, 매매=매매가/평, 전세=보증금/평."""
if not pyeong or pyeong <= 0:
return None
base = None
if kind == "MONTH":
base = monthly
elif kind == "BUY":
base = price
elif kind == "BORROW":
base = deposit
if base is None:
return None
try:
base = float(base)
except (TypeError, ValueError):
return None
return round(base / pyeong, 2)
def extract_articles(store, max_items):
"""RELAY_STORE → 매물 리스트.
ArticleFeedConnection.edges ArticleFeedEdge.node(ArticleFeedCard).article(Article)
스토어에 ArticleFeedCard 직접 있으므로, Card 순회하는 가장 견고하다.
"""
items = []
cards = [v for v in store.values()
if isinstance(v, dict) and v.get("__typename") == "ArticleFeedCard"]
for card in cards:
art = _deref(store, card.get("article"))
if not art or art.get("__typename") != "Article":
continue
area = art.get("area")
try:
area = float(area) if area is not None and area != "" else None
except (TypeError, ValueError):
area = None
pyeong = round(area / PY_PER_SQM, 2) if area else None
trades_out = []
for tr in _refs(store, art, "trades"):
kind, dep, mon, prc = parse_trade(store, tr)
trades_out.append({
"type": kind,
"label": TRADE_LABEL.get(kind, kind),
"deposit_manwon": dep,
"monthly_manwon": mon,
"price_manwon": prc,
"per_pyeong_manwon": per_pyeong(kind, dep, mon, prc, pyeong),
})
items.append({
"article_id": art.get("originalId"),
"salesType": sales_type(store, art),
"area_sqm": area,
"area_pyeong": pyeong,
"trades": trades_out,
"url": DETAIL_BASE + str(art.get("originalId")) if art.get("originalId") else None,
})
if len(items) >= max_items:
break
return items
# ------------------------- 상세(JSON-LD) -------------------------
def parse_detail(url):
html = fetch_text(url)
out = {"source": url, "title": None, "address": None, "floor": None, "top_floor": None,
"floor_label": None, "nearby_subway": None, "json_ld": []}
lds = re.findall(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', html, re.S)
for ld in lds:
try:
d = json.loads(ld)
except Exception:
continue
out["json_ld"].append(d)
items = d.get("@graph") if isinstance(d, dict) and "@graph" in d else (d if isinstance(d, list) else [d])
for o in items:
if not isinstance(o, dict):
continue
if o.get("@type") == "Product" and not out["title"]:
out["title"] = o.get("name")
if o.get("@type") == "Place" and not out["address"]:
out["address"] = o.get("name")
for prop in (o.get("additionalProperty") or []):
nm = prop.get("name"); val = prop.get("value")
if nm == "floor":
out["floor"] = val
elif nm == "topFloor":
out["top_floor"] = val
elif nm == "nearbySubwayStation":
out["nearby_subway"] = val
if out["floor"] is not None:
fl = str(out["floor"]).replace(".0", "")
tf = str(out["top_floor"]).replace(".0", "") if out["top_floor"] is not None else "?"
out["floor_label"] = f"{fl}층/{tf}"
out["json_ld"] = out["json_ld"][:3]
return out
# ------------------------- 커맨드 -------------------------
def collect_for_region(sel, sales_type_filter, trade_type_filter, limit):
url = map_url(sel)
html = fetch_text(url)
store = extract_relay_store(html)
if not store:
return url, [], "RELAY_STORE 없음 (페이지 구조 변경 또는 차단)"
items = extract_articles(store, max_items=10_000)
# 필터
if sales_type_filter:
sset = {s.strip().upper() for s in sales_type_filter.split(",")}
items = [it for it in items if (it.get("salesType") or "").upper() in sset]
if trade_type_filter:
tset = {t.strip().upper() for t in trade_type_filter.split(",")}
items = [it for it in items if any((tr["type"] or "").upper() in tset for tr in it["trades"])]
return url, items, None
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})
if not sel:
raise SystemExit("--region 이 필요합니다")
regions = [sel]
if args.expand:
regions += find_sibling_regions(sel, max_siblings=args.expand_max)
all_items, sources, errors = [], [], []
seen = set()
for rg in regions:
try:
url, items, err = collect_for_region(rg, args.sales_type, args.trade_type, args.limit)
except Exception as e:
errors.append({"region": rg.get("name3"), "error": str(e)})
continue
sources.append({"region": f"{rg.get('name1')} {rg.get('name2')} {rg.get('name3')}", "url": url,
"count": len(items), "note": err})
for it in items:
if it["article_id"] in seen:
continue
seen.add(it["article_id"])
it["region"] = f"{rg.get('name2')} {rg.get('name3')}"
all_items.append(it)
all_items = all_items[:args.limit]
# 제목 보강(상세 JSON-LD) — 상위 N개만 (네트워크 비용 절약)
if args.titles > 0:
for it in all_items[:args.titles]:
if not it.get("url"):
continue
try:
d = parse_detail(it["url"])
it["title"] = d.get("title")
it["address"] = d.get("address")
it["floor_label"] = d.get("floor_label")
it["nearby_subway"] = d.get("nearby_subway")
except Exception:
pass
print_json({
"effective_region": f"{sel.get('name1')} {sel.get('name2')} {sel.get('name3')}",
"expand": bool(args.expand),
"regions_searched": len(regions),
"sources": sources,
"count": len(all_items),
"errors": errors,
"items": all_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]})
print_json(parse_detail(args.url))
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)
def build_parser():
p = argparse.ArgumentParser(description="당근부동산 읽기전용 검색/상세 (realty.daangn.com)")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("search", help="지역 매물 검색")
s.add_argument("--region", required=True, help="동 이름 (예: 매교동, 합정동)")
s.add_argument("--sales-type", help="용도 필터(콤마구분): APART,OFFICETEL,STORE,OPEN_ONE_ROOM,SPLIT_ONE_ROOM,TWO_ROOM,HOUSE")
s.add_argument("--trade-type", help="거래 필터(콤마구분): MONTH(월세),BUY(매매),BORROW(전세)")
s.add_argument("--expand", action="store_true", help="같은 구/시 인접 동까지 확장 검색")
s.add_argument("--expand-max", type=int, default=6, help="확장 시 인접 동 최대 개수 (기본 6)")
s.add_argument("--titles", type=int, default=5, help="상세 JSON-LD로 제목·층수 보강할 상위 N개 (기본 5, 0=비활성)")
s.add_argument("--limit", type=int, default=20, help="최대 매물 수 (기본 20)")
s.set_defaults(func=cmd_search)
d = sub.add_parser("detail", help="매물 상세 (제목·주소·층수)")
d.add_argument("url", help="https://realty.daangn.com/articles/<id>")
d.set_defaults(func=cmd_detail)
return p
if __name__ == "__main__":
args = build_parser().parse_args()
args.func(args)