mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge 87afa37277 into 08533bd9eb
This commit is contained in:
commit
af4deda976
2 changed files with 423 additions and 114 deletions
|
|
@ -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 변환 가드.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue