instruction.md
# Daangn Realty Search
## What this skill does
당근부동산 지도 페이지(`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+
## Data surfaces (2026-06 도메인 이전 대응)
- 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`
> ⚠️ **구버전(`www.daangn.com/kr/realty/?_data=routes/kr.realty._index`)은 2026-06부터 HTTP 204(빈 응답)로 폐기됨.** 절대 사용 금지.
## 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
# 기본 검색 (상위 5개 제목·층수 보강)
npx -y @nomadamas/k-skill@0 exec 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).
기존 CLI 호환 옵션인 `--keyword`는 반환 매물의 공개 필드 텍스트를 필터링한다. `--only-verified`는 공개 피드에서 별도 인증 필드를 제공하지 않아 호환 목적으로만 허용한다.
## Output fields
매물: `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, release_date`
`release_date`는 상세 페이지 JSON-LD의 `Product.releaseDate`(ISO 8601, UTC)이며 `--titles` 보강 대상 매물에만 채워진다. 최초 등록일인지 최근 끌어올리기/수정일인지는 당근 쪽 명세에 없어 확정할 수 없다.
## Region handling
지역명 → region API로 내부 id 해석. 동명이 여럿이면 정확일치 → 서울 depth=3 → 첫 후보 순. 응답에 `effective_region` 포함. `--expand`는 같은 `name2Id`(구/시) 인접 동을 모은다.
## Safety and scope
- 읽기 전용 검색/상세만. 로그인·채팅·거래·예약·계약 자동화 없음.
- 공개 표면이 바뀌거나 빈 응답/봇 차단이면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 호가라 실거래와 다를 수 있으므로 source URL을 함께 제시.
## Failure modes
- `RELAY_STORE 없음` (sources note) → 페이지 구조 재변경 또는 봇 차단. HTML 구조 재확인 필요.
- 시군구(name2) 랜딩 URL은 매물이 없다 — **반드시 동(name3)까지** 있어야 articleFeed가 SSR로 채워진다.
- 동명이 넓거나 중복되면 다른 행정동 선택 가능.
- 상세는 삭제/비공개 글에서 실패할 수 있다.
## Done when
- 지역 id 해석 → map URL의 RELAY_STORE에서 매물 추출 → source URL·effective_region 포함.
- 인증/거래성 액션은 수행하지 않았다.
## Notes
- Windows stdout 한글 깨짐 방지: 지원 런타임에서는 `sys.stdout.reconfigure(encoding='utf-8')`를 적용한다.
- `area`·가격 필드가 문자열로 오는 케이스가 있어 모든 수치 연산 전 float 변환 가드.
references/DISCLAIMER.md
# DISCLAIMER — `daangn-realty-search`
## 비제휴 및 상표 사용
이 스킬은 이 문서에서 언급하는 상표권자나 서비스 운영사의 공식 기능 또는 공식 지원 도구가 아닙니다. 해당 권리자·운영사와 공식 제휴, 후원, 승인, 인증 또는 협업한 사실이 전혀 없습니다. 제3자 상표와 서비스명은 스킬의 기능, 조회 대상 또는 호환 대상을 정확히 설명하기 위해 필요한 범위에서만 사용하며, 각 상표의 권리는 해당 권리자에게 있습니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 타인의 표장을 자기 상품의 출처표시가 아니라 상품의 기능 또는 적용 기종을 설명하기 위해 사용하고, 실제 표시 태양도 상표 사용으로 인식될 수 없는 경우에는 상표권 침해가 아니라고 판시했습니다. 다만 이는 제3자 상표 사용을 포괄적으로 허용한다는 뜻이 아니며, [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 실제 거래계의 인식에 따라 출처 혼동 여부를 개별적으로 판단해야 합니다.
## 공개정보에 대한 자동 접근
대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)(야놀자 정보 수집 사건)은 서비스 제공자가 접근권한을 제한했는지를 보호조치·이용약관 등 객관적으로 드러난 사정을 종합해 판단해야 한다고 판시했습니다. 그 사건에서는 별도 보호조치 없이 일반 이용자가 접근할 수 있었고 이미 공개된 정보를 수집한 사정 등에 비추어 정보통신망 침입을 인정하지 않았습니다. 따라서 공개되어 누구나 얻을 수 있는 정보를 기계적으로 수집했다는 사정만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아닙니다.
그러나 위 판결은 크롤링을 일반적으로 허용한 판결이 아닙니다. 접근통제나 명시적 제한을 우회하는 행위, 데이터베이스의 전부·상당한 부분을 복제하거나 반복적·체계적으로 수집하여 같은 결과를 내는 행위는 별도로 판단됩니다. 특히 [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)는 데이터베이스제작자의 권리를 보호하며, 정보처리에 현실적 장애를 일으켜 영업을 방해하면 [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)에 따른 책임이 문제될 수 있습니다.
## 필수 사용 제한
1. 공개정보 자동 수집 기능은 반드시 개인의 정보 조회용으로만 사용합니다.
2. 조직적·체계적·대량 크롤링, 별도 데이터베이스 구축, 재판매 또는 원본 콘텐츠 재배포에 사용하지 않습니다.
3. 특정 브랜드나 업체의 정상적인 영업·서비스를 방해하지 않도록 요청 횟수와 범위를 최소화하며, 공격적 polling이나 병렬 대량 호출을 하지 않습니다.
4. 로그인·유료벽·CAPTCHA·접근통제·rate limit·IP 차단을 우회하거나 차단 회피를 위해 계정·IP·헤더를 변경하지 않습니다. 접근 거부나 차단 신호가 확인되면 즉시 중단합니다.
5. 적용되는 이용약관, robots 지침, API 조건, 저작권·데이터베이스권·개인정보 보호 의무를 별도로 준수합니다.
이 문서는 일반적인 위험 경계를 알리기 위한 것이며, 개별 행위의 적법성을 보증하거나 법률 자문을 제공하지 않습니다.
references/TRADEMARK-LEGAL-STATEMENT.md
# 상표 사용 법적 고지 — `daangn-realty-search`
이 스킬에서 `당근` 및 `Daangn` 명칭은 당근부동산 매물 검색·문의·방문예약 흐름의 **대상 서비스**를 식별하기 위해 사용한다. k-skill의 출처를 당근 운영사로 표시하려는 사용이 아니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 저작권, 데이터베이스권, 부정경쟁방지법, 약관·계약, 개인정보 또는 접근 방식의 적법성을 판단하지 않는다.
전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)
scripts/daangn_detail_ld.py
import json
import re
def parse_detail(url, fetch_text):
html = fetch_text(url)
out = {
"source": url,
"title": None,
"address": None,
"floor": None,
"top_floor": None,
"floor_label": None,
"nearby_subway": None,
"release_date": None,
"json_ld": [],
}
scripts = re.findall(
r"<script[^>]*application/ld\+json[^>]*>(.*?)</script>", html, re.S
)
for script in scripts:
try:
document = json.loads(script)
except json.JSONDecodeError:
continue
out["json_ld"].append(document)
for node in _json_ld_nodes(document):
_apply_node(out, node)
if out["floor"] is not None:
floor = str(out["floor"]).replace(".0", "")
top = str(out["top_floor"]).replace(".0", "") if out["top_floor"] else "?"
out["floor_label"] = f"{floor}층/{top}층"
out["json_ld"] = out["json_ld"][:3]
return out
def _json_ld_nodes(document):
if isinstance(document, list):
return document
if not isinstance(document, dict):
return []
graph = document.get("@graph")
if isinstance(graph, list):
return graph
if isinstance(graph, dict):
return [graph]
return [document]
def _apply_node(out, node):
if not isinstance(node, dict):
return
if node.get("@type") == "Product":
if not out["title"]:
out["title"] = node.get("name")
if not out["release_date"]:
out["release_date"] = node.get("releaseDate")
if node.get("@type") == "Place" and not out["address"]:
out["address"] = node.get("name")
for prop in _properties(node.get("additionalProperty")):
name = prop.get("name")
value = prop.get("value")
if name == "floor":
out["floor"] = value
elif name == "topFloor":
out["top_floor"] = value
elif name == "nearbySubwayStation":
out["nearby_subway"] = value
def _properties(value):
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
return [value]
return []
scripts/daangn_realty.py
#!/usr/bin/env python3
# -*- 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
import json
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from daangn_detail_ld import parse_detail as _parse_detail
from daangn_relay_store import extract_articles, extract_relay_store
reconfigure_stdout = getattr(sys.stdout, "reconfigure", None)
if callable(reconfigure_stdout):
try:
reconfigure_stdout(encoding="utf-8")
except (OSError, ValueError):
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/"
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=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode("utf-8", "ignore")
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
# ------------------------- 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 (json.JSONDecodeError, OSError, TimeoutError, urllib.error.URLError):
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
# ------------------------- 상세(JSON-LD) -------------------------
def parse_detail(url):
return _parse_detail(url, fetch_text)
# ------------------------- 커맨드 -------------------------
def collect_for_region(sel, sales_type_filter, trade_type_filter, limit, keyword=None):
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"])]
if keyword:
needle = keyword.casefold()
items = [it for it in items if needle in json.dumps(it, ensure_ascii=False).casefold()]
return url, items, None
def cmd_search(args):
sel = resolve_region(args.region) if args.region else None
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, args.keyword)
except (json.JSONDecodeError, OSError, TimeoutError, urllib.error.URLError) 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")
it["release_date"] = d.get("release_date")
except (json.JSONDecodeError, OSError, TimeoutError, urllib.error.URLError):
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):
print_json(parse_detail(args.url))
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("--keyword", help="기존 CLI 호환용 키워드 필터(현재 공개 피드에서는 미사용)")
s.add_argument("--only-verified", action="store_true", help="기존 CLI 호환용 인증 매물 플래그")
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)
scripts/daangn_relay_store.py
import json
import re
DETAIL_BASE = "https://realty.daangn.com/articles/"
PY_PER_SQM = 3.305785
TRADE_LABEL = {"MONTH": "월세", "BUY": "매매", "BORROW": "전세"}
def extract_relay_store(html):
match = re.search(r'window\.RELAY_STORE\s*=\s*"((?:[^"\\]|\\.)*)"', html)
if match:
try:
return json.loads(json.loads('"' + match.group(1) + '"'))
except json.JSONDecodeError:
return None
start = html.find("window.RELAY_STORE")
if start < 0:
return None
eq = html.find("=", start)
if eq < 0:
return None
literal = _scan_object_literal(html[eq + 1 :])
if literal is None:
return None
try:
return json.loads(literal)
except json.JSONDecodeError:
return None
def _scan_object_literal(source):
depth = 0
in_string = False
escaped = False
quote = ""
end = 0
for idx, ch in enumerate(source):
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
in_string = False
continue
if ch in "\"'":
in_string = True
quote = ch
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = idx + 1
break
if end == 0:
return None
return source[:end]
def _deref(store, ref):
if isinstance(ref, dict) and "__ref" in ref:
return store.get(ref["__ref"])
return ref
def _refs(store, node, key):
value = node.get(key)
out = []
if isinstance(value, dict):
if "__refs" in value:
out = [store.get(ref) for ref in value["__refs"]]
elif "__ref" in value:
out = [store.get(value["__ref"])]
elif isinstance(value, list):
for item in value:
if isinstance(item, dict) and "__ref" in item:
out.append(store.get(item["__ref"]))
return [item for item in out if item]
def sales_type(store, article):
sales = _deref(store, article.get("salesTypeV3"))
if isinstance(sales, dict):
return sales.get("type") or sales.get("name")
return None
def parse_trade(trade):
typename = trade.get("__typename")
if typename == "MonthTrade":
return ("MONTH", trade.get("deposit"), trade.get("monthlyPay"), None)
if typename == "BuyTrade":
return ("BUY", None, None, trade.get("price"))
if typename == "BorrowTrade":
return ("BORROW", trade.get("deposit"), None, None)
return (
trade.get("type"),
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:
numeric_base = float(base)
except (TypeError, ValueError):
return None
return round(numeric_base / pyeong, 2)
def extract_articles(store, max_items):
items = []
cards = [
value
for value in store.values()
if isinstance(value, dict) and value.get("__typename") == "ArticleFeedCard"
]
for card in cards:
article = _deref(store, card.get("article"))
if not article or article.get("__typename") != "Article":
continue
area = _parse_area(article.get("area"))
pyeong = round(area / PY_PER_SQM, 2) if area else None
trades = []
for trade in _refs(store, article, "trades"):
kind, deposit, monthly, price = parse_trade(trade)
trades.append(
{
"type": kind,
"label": TRADE_LABEL.get(kind, kind),
"deposit_manwon": deposit,
"monthly_manwon": monthly,
"price_manwon": price,
"per_pyeong_manwon": per_pyeong(
kind, deposit, monthly, price, pyeong
),
}
)
article_id = article.get("originalId")
items.append(
{
"article_id": article_id,
"salesType": sales_type(store, article),
"area_sqm": area,
"area_pyeong": pyeong,
"trades": trades,
"url": DETAIL_BASE + str(article_id) if article_id else None,
}
)
if len(items) >= max_items:
break
return items
def _parse_area(value):
try:
return float(value) if value is not None and value != "" else None
except (TypeError, ValueError):
return None
skill.json
{
"name": "daangn-realty-search",
"description": "당근부동산 공개 웹 데이터로 매물을 검색하고, 돌쇠에서는 공식 계정 표면에서 문의·방문예약·계약 준비까지 진행한다.",
"profiles": [
"action:commerce"
],
"frontmatter": "name: daangn-realty-search\ndescription: 당근부동산 공개 웹 데이터로 매물을 검색하고, 돌쇠에서는 공식 계정 표면에서 문의·방문예약·계약 준비까지 진행한다.\nlicense: MIT\nmetadata:\n category: real-estate\n locale: ko-KR\n phase: v1.5"
}
SKILL.md
---
name: daangn-realty-search
description: 당근부동산 공개 웹 데이터로 매물을 검색하고, 돌쇠에서는 공식 계정 표면에서 문의·방문예약·계약 준비까지 진행한다.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1.5
---
# daangn-realty-search
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct daangn-realty-search
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files daangn-realty-search
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/daangn-realty-search/instruction.md.
## Legal disclaimer (required)
This skill is not an official feature of, officially supported by, affiliated with, sponsored by, approved by, or developed in collaboration with any third-party trademark owner or service operator it identifies. Third-party names are used only to describe the skill's function, lookup target, or compatibility.
Any automated collection of publicly accessible information must be limited to personal, non-organizational lookup. Do not use this skill for systematic or bulk crawling, database building, access-control or block circumvention, or conduct that interferes with a third party's business or service.
Read the full Korean legal disclaimer, including the cited Korean Supreme Court precedents and statutory limits, before use:
```bash
npx -y @nomadamas/k-skill@0 read daangn-realty-search references/DISCLAIMER.md
```
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.