@nomadamas/k-skill 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/k-skill.js +60 -0
- package/package.json +37 -0
- package/skills/assembly-bill-vote-search/instruction.md +111 -0
- package/skills/assembly-bill-vote-search/skill.json +10 -0
- package/skills/biz-health-check/instruction.md +69 -0
- package/skills/biz-health-check/scripts/biz_health_check.py +163 -0
- package/skills/biz-health-check/skill.json +10 -0
- package/skills/bok-ecos-stats/instruction.md +95 -0
- package/skills/bok-ecos-stats/scripts/bok_ecos.py +302 -0
- package/skills/bok-ecos-stats/skill.json +10 -0
- package/skills/building-register-search/instruction.md +69 -0
- package/skills/building-register-search/scripts/building_register.py +309 -0
- package/skills/building-register-search/scripts/building_register_xml.py +32 -0
- package/skills/building-register-search/skill.json +9 -0
- package/skills/bunjang-search/instruction.md +154 -0
- package/skills/bunjang-search/skill.json +10 -0
- package/skills/catchtable-sniper/instruction.md +270 -0
- package/skills/catchtable-sniper/skill.json +9 -0
- package/skills/cheap-gas-nearby/instruction.md +107 -0
- package/skills/cheap-gas-nearby/skill.json +10 -0
- package/skills/corporate-registration-consulting/instruction.md +117 -0
- package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +106 -0
- package/skills/corporate-registration-consulting/skill.json +8 -0
- package/skills/coupang-product-search/instruction.md +219 -0
- package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +146 -0
- package/skills/coupang-product-search/skill.json +10 -0
- package/skills/court-auction-notice-search/instruction.md +201 -0
- package/skills/court-auction-notice-search/skill.json +9 -0
- package/skills/court-payment-order-assistant/instruction.md +120 -0
- package/skills/court-payment-order-assistant/skill.json +10 -0
- package/skills/d2b-notice-search/instruction.md +117 -0
- package/skills/d2b-notice-search/skill.json +10 -0
- package/skills/daangn-cars-search/instruction.md +91 -0
- package/skills/daangn-cars-search/scripts/daangn_cars.py +72 -0
- package/skills/daangn-cars-search/skill.json +9 -0
- package/skills/daangn-jobs-search/instruction.md +90 -0
- package/skills/daangn-jobs-search/scripts/daangn_jobs.py +98 -0
- package/skills/daangn-jobs-search/skill.json +9 -0
- package/skills/daangn-realty-search/instruction.md +114 -0
- package/skills/daangn-realty-search/scripts/daangn_detail_ld.py +76 -0
- package/skills/daangn-realty-search/scripts/daangn_realty.py +216 -0
- package/skills/daangn-realty-search/scripts/daangn_relay_store.py +174 -0
- package/skills/daangn-realty-search/skill.json +8 -0
- package/skills/daangn-used-goods-search/instruction.md +90 -0
- package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +80 -0
- package/skills/daangn-used-goods-search/skill.json +9 -0
- package/skills/daishin-report-search/instruction.md +138 -0
- package/skills/daishin-report-search/skill.json +10 -0
- package/skills/daiso-product-search/instruction.md +172 -0
- package/skills/daiso-product-search/skill.json +8 -0
- package/skills/danawa-price-search/instruction.md +183 -0
- package/skills/danawa-price-search/scripts/danawa_search.py +354 -0
- package/skills/danawa-price-search/skill.json +8 -0
- package/skills/delivery-tracking/instruction.md +358 -0
- package/skills/delivery-tracking/skill.json +8 -0
- package/skills/donation-place-search/instruction.md +129 -0
- package/skills/donation-place-search/skill.json +9 -0
- package/skills/emergency-room-beds/instruction.md +82 -0
- package/skills/emergency-room-beds/skill.json +8 -0
- package/skills/ev-charger-nearby/instruction.md +80 -0
- package/skills/ev-charger-nearby/scripts/ev_charger.py +222 -0
- package/skills/ev-charger-nearby/skill.json +10 -0
- package/skills/ev-subsidy-status/instruction.md +165 -0
- package/skills/ev-subsidy-status/skill.json +10 -0
- package/skills/express-bus-booking/instruction.md +207 -0
- package/skills/express-bus-booking/references/kobus-http-flow.md +159 -0
- package/skills/express-bus-booking/scripts/kobus_express_booking.py +243 -0
- package/skills/express-bus-booking/skill.json +8 -0
- package/skills/fine-dust-location/instruction.md +89 -0
- package/skills/fine-dust-location/skill.json +9 -0
- package/skills/flight-ticket-search/instruction.md +237 -0
- package/skills/flight-ticket-search/scripts/flight_ticket_search.py +501 -0
- package/skills/flight-ticket-search/skill.json +9 -0
- package/skills/foresttrip-vacancy/instruction.md +167 -0
- package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +549 -0
- package/skills/foresttrip-vacancy/skill.json +10 -0
- package/skills/fsc-corporate-info/instruction.md +57 -0
- package/skills/fsc-corporate-info/scripts/fsc_corporate_info.py +113 -0
- package/skills/fsc-corporate-info/skill.json +10 -0
- package/skills/g2b-order-plan-search/instruction.md +131 -0
- package/skills/g2b-order-plan-search/scripts/g2b_order_plan.py +158 -0
- package/skills/g2b-order-plan-search/skill.json +10 -0
- package/skills/g2b-sanctioned-supplier/instruction.md +61 -0
- package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +114 -0
- package/skills/g2b-sanctioned-supplier/skill.json +10 -0
- package/skills/gangnamunni-clinic-search/instruction.md +113 -0
- package/skills/gangnamunni-clinic-search/skill.json +9 -0
- package/skills/geeknews-search/instruction.md +69 -0
- package/skills/geeknews-search/scripts/geeknews_search.py +296 -0
- package/skills/geeknews-search/skill.json +8 -0
- package/skills/gongsijiga-search/instruction.md +128 -0
- package/skills/gongsijiga-search/skill.json +9 -0
- package/skills/gov-overseas-trip-report/instruction.md +488 -0
- package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +1206 -0
- package/skills/gov-overseas-trip-report/skill.json +9 -0
- package/skills/han-river-water-level/instruction.md +83 -0
- package/skills/han-river-water-level/skill.json +10 -0
- package/skills/highway-traffic-status/instruction.md +88 -0
- package/skills/highway-traffic-status/scripts/highway_traffic.py +315 -0
- package/skills/highway-traffic-status/skill.json +11 -0
- package/skills/hipass-receipt/instruction.md +97 -0
- package/skills/hipass-receipt/skill.json +10 -0
- package/skills/hola-poke-yeoksam/instruction.md +247 -0
- package/skills/hola-poke-yeoksam/skill.json +8 -0
- package/skills/household-waste-info/instruction.md +117 -0
- package/skills/household-waste-info/skill.json +10 -0
- package/skills/housing-official-price/instruction.md +177 -0
- package/skills/housing-official-price/skill.json +10 -0
- package/skills/hwp/instruction.md +206 -0
- package/skills/hwp/skill.json +8 -0
- package/skills/intercity-bus-booking/instruction.md +189 -0
- package/skills/intercity-bus-booking/references/tmoney-intercity-http-flow.md +126 -0
- package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +381 -0
- package/skills/intercity-bus-booking/skill.json +8 -0
- package/skills/iros-registry-automation/instruction.md +229 -0
- package/skills/iros-registry-automation/scripts/iros_pdf_summary.py +249 -0
- package/skills/iros-registry-automation/scripts/upstream.pin +1 -0
- package/skills/iros-registry-automation/skill.json +9 -0
- package/skills/job-posting-match/instruction.md +130 -0
- package/skills/job-posting-match/scripts/job_posting_match.py +396 -0
- package/skills/job-posting-match/scripts/test_job_posting_match.py +54 -0
- package/skills/job-posting-match/skill.json +8 -0
- package/skills/jobkorea-talent-search/instruction.md +118 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_models.py +27 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_parse.py +186 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +94 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search_condition.py +136 -0
- package/skills/jobkorea-talent-search/scripts/test_jobkorea_talent_search.py +76 -0
- package/skills/jobkorea-talent-search/skill.json +9 -0
- package/skills/joseon-sillok-search/instruction.md +76 -0
- package/skills/joseon-sillok-search/scripts/sillok_search.py +552 -0
- package/skills/joseon-sillok-search/skill.json +8 -0
- package/skills/k-dart/instruction.md +406 -0
- package/skills/k-dart/skill.json +8 -0
- package/skills/k-schoollunch-menu/instruction.md +109 -0
- package/skills/k-schoollunch-menu/skill.json +9 -0
- package/skills/k-skill-cleaner/instruction.md +80 -0
- package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +410 -0
- package/skills/k-skill-cleaner/skill.json +8 -0
- package/skills/k-skill-setup/instruction.md +253 -0
- package/skills/k-skill-setup/skill.json +11 -0
- package/skills/kakao-bar-nearby/instruction.md +76 -0
- package/skills/kakao-bar-nearby/skill.json +8 -0
- package/skills/kakao-map/instruction.md +176 -0
- package/skills/kakao-map/skill.json +10 -0
- package/skills/kakaotalk-mac/instruction.md +189 -0
- package/skills/kakaotalk-mac/skill.json +8 -0
- package/skills/kbl-results/instruction.md +89 -0
- package/skills/kbl-results/skill.json +9 -0
- package/skills/kbo-results/instruction.md +82 -0
- package/skills/kbo-results/skill.json +8 -0
- package/skills/keris-academic-search/instruction.md +81 -0
- package/skills/keris-academic-search/scripts/keris_academic.py +210 -0
- package/skills/keris-academic-search/skill.json +10 -0
- package/skills/kleague-results/instruction.md +92 -0
- package/skills/kleague-results/skill.json +8 -0
- package/skills/kopis-performance-search/instruction.md +109 -0
- package/skills/kopis-performance-search/skill.json +10 -0
- package/skills/korea-weather/instruction.md +93 -0
- package/skills/korea-weather/skill.json +10 -0
- package/skills/korean-character-count/instruction.md +87 -0
- package/skills/korean-character-count/scripts/korean_character_count.js +268 -0
- package/skills/korean-character-count/skill.json +8 -0
- package/skills/korean-cinema-search/instruction.md +177 -0
- package/skills/korean-cinema-search/skill.json +8 -0
- package/skills/korean-heritage-search/instruction.md +106 -0
- package/skills/korean-heritage-search/scripts/korean_heritage_search.py +321 -0
- package/skills/korean-heritage-search/skill.json +8 -0
- package/skills/korean-holiday-calendar/instruction.md +97 -0
- package/skills/korean-holiday-calendar/skill.json +9 -0
- package/skills/korean-humanizer/instruction.md +389 -0
- package/skills/korean-humanizer/references/ai-tell-taxonomy.md +147 -0
- package/skills/korean-humanizer/skill.json +8 -0
- package/skills/korean-jangbu-for/instruction.md +133 -0
- package/skills/korean-jangbu-for/scripts/install.sh +237 -0
- package/skills/korean-jangbu-for/scripts/upstream.pin +1 -0
- package/skills/korean-jangbu-for/skill.json +8 -0
- package/skills/korean-law-search/instruction.md +126 -0
- package/skills/korean-law-search/skill.json +10 -0
- package/skills/korean-marathon-schedule/instruction.md +111 -0
- package/skills/korean-marathon-schedule/skill.json +9 -0
- package/skills/korean-middle-korean/instruction.md +79 -0
- package/skills/korean-middle-korean/scripts/korean_middle_korean.js +214 -0
- package/skills/korean-middle-korean/skill.json +8 -0
- package/skills/korean-patent-search/instruction.md +79 -0
- package/skills/korean-patent-search/scripts/patent_search.py +409 -0
- package/skills/korean-patent-search/skill.json +9 -0
- package/skills/korean-privacy-terms/instruction.md +128 -0
- package/skills/korean-privacy-terms/scripts/install.sh +108 -0
- package/skills/korean-privacy-terms/scripts/upstream.pin +1 -0
- package/skills/korean-privacy-terms/skill.json +8 -0
- package/skills/korean-scholarship-search/instruction.md +317 -0
- package/skills/korean-scholarship-search/references/report-format.md +40 -0
- package/skills/korean-scholarship-search/references/school-discovery.md +61 -0
- package/skills/korean-scholarship-search/references/search-clues.md +58 -0
- package/skills/korean-scholarship-search/references/source-patterns.md +67 -0
- package/skills/korean-scholarship-search/scripts/scholarship_filter.py +811 -0
- package/skills/korean-scholarship-search/scripts/test_scholarship_filter.py +224 -0
- package/skills/korean-scholarship-search/scripts/university_search_plan.py +148 -0
- package/skills/korean-scholarship-search/skill.json +8 -0
- package/skills/korean-slang-writing/instruction.md +181 -0
- package/skills/korean-slang-writing/scripts/_slang_http.py +91 -0
- package/skills/korean-slang-writing/scripts/slang_lookup.py +291 -0
- package/skills/korean-slang-writing/scripts/slang_search.py +284 -0
- package/skills/korean-slang-writing/skill.json +8 -0
- package/skills/korean-spell-check/instruction.md +105 -0
- package/skills/korean-spell-check/scripts/korean_spell_check.py +523 -0
- package/skills/korean-spell-check/skill.json +9 -0
- package/skills/korean-stock-search/instruction.md +194 -0
- package/skills/korean-stock-search/skill.json +10 -0
- package/skills/korean-transit-route/instruction.md +113 -0
- package/skills/korean-transit-route/skill.json +10 -0
- package/skills/kosis-stats/instruction.md +232 -0
- package/skills/kosis-stats/references/kosis-openapi-guide.md +171 -0
- package/skills/kosis-stats/scripts/run_kosis_stats.py +896 -0
- package/skills/kosis-stats/skill.json +9 -0
- package/skills/kr-whois-lookup/instruction.md +107 -0
- package/skills/kr-whois-lookup/skill.json +10 -0
- package/skills/kstartup-search/instruction.md +186 -0
- package/skills/kstartup-search/scripts/run_kstartup.py +424 -0
- package/skills/kstartup-search/skill.json +11 -0
- package/skills/ktx-booking/instruction.md +244 -0
- package/skills/ktx-booking/skill.json +10 -0
- package/skills/lck-analytics/instruction.md +192 -0
- package/skills/lck-analytics/scripts/_lib.js +103 -0
- package/skills/lck-analytics/scripts/analyze-live-game.js +52 -0
- package/skills/lck-analytics/scripts/build-match-report.js +44 -0
- package/skills/lck-analytics/scripts/sync-oracle.js +50 -0
- package/skills/lck-analytics/skill.json +9 -0
- package/skills/lh-notice-search/instruction.md +206 -0
- package/skills/lh-notice-search/skill.json +10 -0
- package/skills/library-book-search/instruction.md +139 -0
- package/skills/library-book-search/skill.json +9 -0
- package/skills/local-election-candidate-search/instruction.md +77 -0
- package/skills/local-election-candidate-search/skill.json +8 -0
- package/skills/localdata-business-status/instruction.md +64 -0
- package/skills/localdata-business-status/scripts/localdata_business_status.py +206 -0
- package/skills/localdata-business-status/skill.json +8 -0
- package/skills/lotto-results/instruction.md +80 -0
- package/skills/lotto-results/skill.json +8 -0
- package/skills/lovebug-report/instruction.md +185 -0
- package/skills/lovebug-report/skill.json +9 -0
- package/skills/market-kurly-search/instruction.md +125 -0
- package/skills/market-kurly-search/skill.json +8 -0
- package/skills/mfds-drug-safety/instruction.md +86 -0
- package/skills/mfds-drug-safety/scripts/mfds_drug_safety.py +184 -0
- package/skills/mfds-drug-safety/skill.json +10 -0
- package/skills/mfds-food-safety/instruction.md +126 -0
- package/skills/mfds-food-safety/scripts/mfds_food_safety.py +281 -0
- package/skills/mfds-food-safety/skill.json +10 -0
- package/skills/myrealtrip-search/instruction.md +239 -0
- package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +194 -0
- package/skills/myrealtrip-search/scripts/test_myrealtrip_mcp.py +99 -0
- package/skills/myrealtrip-search/skill.json +9 -0
- package/skills/naming-house/instruction.md +146 -0
- package/skills/naming-house/skill.json +8 -0
- package/skills/national-pension-workplace/instruction.md +64 -0
- package/skills/national-pension-workplace/scripts/national_pension_workplace.py +113 -0
- package/skills/national-pension-workplace/skill.json +10 -0
- package/skills/naver-ad-performance/instruction.md +108 -0
- package/skills/naver-ad-performance/scripts/naver_ad_performance.py +240 -0
- package/skills/naver-ad-performance/skill.json +9 -0
- package/skills/naver-blog-research/instruction.md +128 -0
- package/skills/naver-blog-research/scripts/_naver_http.py +58 -0
- package/skills/naver-blog-research/scripts/naver_download_images.py +233 -0
- package/skills/naver-blog-research/scripts/naver_read.py +256 -0
- package/skills/naver-blog-research/scripts/naver_search.py +192 -0
- package/skills/naver-blog-research/skill.json +8 -0
- package/skills/naver-news-search/instruction.md +103 -0
- package/skills/naver-news-search/skill.json +10 -0
- package/skills/naver-shopping-search/instruction.md +94 -0
- package/skills/naver-shopping-search/skill.json +11 -0
- package/skills/nhis-care-checkup-search/instruction.md +116 -0
- package/skills/nhis-care-checkup-search/skill.json +10 -0
- package/skills/nts-business-registration/instruction.md +115 -0
- package/skills/nts-business-registration/scripts/nts_business_registration.py +215 -0
- package/skills/nts-business-registration/skill.json +10 -0
- package/skills/nts-tax-delinquency/instruction.md +54 -0
- package/skills/nts-tax-delinquency/scripts/nts_tax_delinquency.py +150 -0
- package/skills/nts-tax-delinquency/skill.json +8 -0
- package/skills/ohou-today-deal/instruction.md +182 -0
- package/skills/ohou-today-deal/scripts/ohou_today_deal.py +369 -0
- package/skills/ohou-today-deal/skill.json +9 -0
- package/skills/olive-young-search/instruction.md +154 -0
- package/skills/olive-young-search/skill.json +8 -0
- package/skills/parking-lot-search/instruction.md +96 -0
- package/skills/parking-lot-search/skill.json +9 -0
- package/skills/popbill/instruction.md +146 -0
- package/skills/popbill/scripts/popbill_cli.py +284 -0
- package/skills/popbill/scripts/popbill_registry.py +54 -0
- package/skills/popbill/scripts/popbill_safety.py +37 -0
- package/skills/popbill/scripts/popbill_templates.py +71 -0
- package/skills/popbill/skill.json +9 -0
- package/skills/public-restroom-nearby/instruction.md +89 -0
- package/skills/public-restroom-nearby/skill.json +9 -0
- package/skills/real-estate-search/instruction.md +172 -0
- package/skills/real-estate-search/skill.json +10 -0
- package/skills/rhwp-advanced/instruction.md +145 -0
- package/skills/rhwp-advanced/skill.json +8 -0
- package/skills/rhwp-edit/instruction.md +153 -0
- package/skills/rhwp-edit/skill.json +8 -0
- package/skills/s2b-notice-search/instruction.md +66 -0
- package/skills/s2b-notice-search/skill.json +9 -0
- package/skills/saju-fortune/instruction.md +177 -0
- package/skills/saju-fortune/skill.json +8 -0
- package/skills/saramin-talent-search/instruction.md +119 -0
- package/skills/saramin-talent-search/skill.json +9 -0
- package/skills/seoul-bike/instruction.md +83 -0
- package/skills/seoul-bike/scripts/seoul_bike.py +247 -0
- package/skills/seoul-bike/skill.json +10 -0
- package/skills/seoul-density/instruction.md +109 -0
- package/skills/seoul-density/scripts/seoul_density.py +271 -0
- package/skills/seoul-density/skill.json +10 -0
- package/skills/seoul-subway-arrival/instruction.md +85 -0
- package/skills/seoul-subway-arrival/skill.json +9 -0
- package/skills/sh-notice-search/instruction.md +150 -0
- package/skills/sh-notice-search/skill.json +9 -0
- package/skills/srt-booking/instruction.md +181 -0
- package/skills/srt-booking/scripts/srt_booking.py +272 -0
- package/skills/srt-booking/scripts/srt_seats.py +156 -0
- package/skills/srt-booking/skill.json +20 -0
- package/skills/subway-lost-property/instruction.md +93 -0
- package/skills/subway-lost-property/scripts/subway_lost_property.py +244 -0
- package/skills/subway-lost-property/skill.json +8 -0
- package/skills/ticket-availability/instruction.md +175 -0
- package/skills/ticket-availability/scripts/ticket_availability.py +430 -0
- package/skills/ticket-availability/skill.json +9 -0
- package/skills/toss-securities/instruction.md +116 -0
- package/skills/toss-securities/skill.json +9 -0
- package/skills/used-car-price-search/instruction.md +109 -0
- package/skills/used-car-price-search/skill.json +8 -0
- package/skills/yebigun-training/instruction.md +177 -0
- package/skills/yebigun-training/skill.json +9 -0
- package/skills/zipcode-search/instruction.md +159 -0
- package/skills/zipcode-search/scripts/zipcode_search.py +150 -0
- package/skills/zipcode-search/skill.json +8 -0
- package/src/assemble.js +134 -0
- package/src/detect.js +18 -0
- package/templates/action-account.md +4 -0
- package/templates/action-booking.md +5 -0
- package/templates/action-commerce.md +5 -0
- package/templates/action-communication.md +5 -0
- package/templates/action-submission.md +5 -0
- package/templates/browser.md +4 -0
- package/templates/core.md +5 -0
- package/templates/hard-boundary.md +3 -0
- package/templates/local.md +2 -0
- package/templates/lookup.md +2 -0
- package/templates/proxy.md +2 -0
- package/templates/vault.md +4 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Daangn Realty Search
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
당근부동산 지도 페이지(`realty.daangn.com/map/{name1}/{name2}/{name3}`)의 SSR `window.RELAY_STORE`(Relay 정규화 스토어)를 파싱해 매물 후보를 정리한다. 제목·주소·층수는 상세 페이지의 JSON-LD에서 보강한다. 외부 패키지 없이 Python 표준 라이브러리만 사용한다.
|
|
6
|
+
|
|
7
|
+
## When to use
|
|
8
|
+
|
|
9
|
+
- "당근부동산 매교동 월세 찾아봐"
|
|
10
|
+
- "수원 팔달구 상가 매물 봐줘" (`--expand`로 인접 동까지)
|
|
11
|
+
- "이 당근부동산 URL 상세 요약해줘"
|
|
12
|
+
|
|
13
|
+
## When not to use
|
|
14
|
+
|
|
15
|
+
- 당근 계정 로그인이 필요한 작업
|
|
16
|
+
- 채팅, 찜, 거래 제안, 문의, 예약, 계약, 구매처럼 상대방/계정에 영향을 주는 작업
|
|
17
|
+
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- 인터넷 연결, Python 3.9+
|
|
22
|
+
|
|
23
|
+
## Data surfaces (2026-06 도메인 이전 대응)
|
|
24
|
+
|
|
25
|
+
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>` → `{"locations":[{id,name1,name2,name3,name*Id,depth}]}`
|
|
26
|
+
- 매물 목록: `https://realty.daangn.com/map/{name1}/{name2}/{name3}` 의 `window.RELAY_STORE`
|
|
27
|
+
- 상세: `https://realty.daangn.com/articles/<id>` 의 `application/ld+json`
|
|
28
|
+
|
|
29
|
+
> ⚠️ **구버전(`www.daangn.com/kr/realty/?_data=routes/kr.realty._index`)은 2026-06부터 HTTP 204(빈 응답)로 폐기됨.** 절대 사용 금지.
|
|
30
|
+
|
|
31
|
+
## RELAY_STORE 파싱 경로 (검증됨)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
ArticleFeedConnection.edges → ArticleFeedEdge.node → ArticleFeedCard.article → Article
|
|
35
|
+
```
|
|
36
|
+
- 스토어에 `ArticleFeedCard`가 직접 다 들어있어, **Card를 순회 → article ref 디레퍼런스**가 가장 견고하다.
|
|
37
|
+
- `Article`: `originalId`, `area`(㎡, **문자열일 수 있어 float 변환 필수**), `salesTypeV3`(→`*SalesTypeV2.type`), `trades`(→ Month/Buy/BorrowTrade)
|
|
38
|
+
- 가격 단위 = **만원**: `deposit`(보증금), `monthlyPay`(월세), `price`(매매가). 예: deposit 2000 = 2천만원, price 28700 = 2억8,700만.
|
|
39
|
+
- `window.RELAY_STORE`는 **JS 문자열로 이스케이프**돼 있다 → `json.loads(json.loads('"'+raw+'"'))` 2단 디코드.
|
|
40
|
+
|
|
41
|
+
## Trade 유형 & 평당 단가
|
|
42
|
+
|
|
43
|
+
| 거래유형 | typename | 가격 필드 | 평당 단가 |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| 월세(MONTH) | MonthTrade | deposit + monthlyPay | monthlyPay / 평 |
|
|
46
|
+
| 매매(BUY) | BuyTrade | price | price / 평 |
|
|
47
|
+
| 전세(BORROW) | BorrowTrade | deposit | deposit / 평 |
|
|
48
|
+
|
|
49
|
+
평 = ㎡ / 3.305785.
|
|
50
|
+
|
|
51
|
+
## salesType enum
|
|
52
|
+
|
|
53
|
+
`APART`, `OFFICETEL`, `STORE`, `OPEN_ONE_ROOM`, `SPLIT_ONE_ROOM`, `TWO_ROOM`, `HOUSE` 등.
|
|
54
|
+
|
|
55
|
+
## 층수 (상세 JSON-LD)
|
|
56
|
+
|
|
57
|
+
목록엔 층수가 없다. 상세 페이지 `Product.additionalProperty` 배열에서:
|
|
58
|
+
- `floor`(예 "8.0") / `topFloor`(예 "10") → `floor_label` = "8층/10층"
|
|
59
|
+
- `nearbySubwayStation` 도 함께 추출.
|
|
60
|
+
제목은 `Product.name`, 주소는 `Place.name`.
|
|
61
|
+
|
|
62
|
+
## Commands
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 기본 검색 (상위 5개 제목·층수 보강)
|
|
66
|
+
python3 daangn-realty-search/scripts/daangn_realty.py search --region "매교동" --limit 20
|
|
67
|
+
|
|
68
|
+
# 거래/용도 필터
|
|
69
|
+
python3 ... search --region "매교동" --trade-type BUY # 매매만
|
|
70
|
+
python3 ... search --region "매교동" --sales-type STORE,OFFICETEL # 용도 콤마구분
|
|
71
|
+
|
|
72
|
+
# 인접 동까지 확장 (같은 구/시)
|
|
73
|
+
python3 ... search --region "매교동" --expand --expand-max 6
|
|
74
|
+
|
|
75
|
+
# 상세
|
|
76
|
+
python3 ... search --region "매교동" --titles 0 # 제목 보강 끄기(빠름)
|
|
77
|
+
python3 ... detail "https://realty.daangn.com/articles/2947028"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
옵션: `--limit`(기본 20), `--titles N`(상세로 제목·층수 보강할 상위 N, 기본 5, 0=끔), `--expand`/`--expand-max`(기본 6).
|
|
81
|
+
기존 CLI 호환 옵션인 `--keyword`는 반환 매물의 공개 필드 텍스트를 필터링한다. `--only-verified`는 공개 피드에서 별도 인증 필드를 제공하지 않아 호환 목적으로만 허용한다.
|
|
82
|
+
|
|
83
|
+
## Output fields
|
|
84
|
+
|
|
85
|
+
매물: `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`
|
|
86
|
+
|
|
87
|
+
`release_date`는 상세 페이지 JSON-LD의 `Product.releaseDate`(ISO 8601, UTC)이며 `--titles` 보강 대상 매물에만 채워진다. 최초 등록일인지 최근 끌어올리기/수정일인지는 당근 쪽 명세에 없어 확정할 수 없다.
|
|
88
|
+
|
|
89
|
+
## Region handling
|
|
90
|
+
|
|
91
|
+
지역명 → region API로 내부 id 해석. 동명이 여럿이면 정확일치 → 서울 depth=3 → 첫 후보 순. 응답에 `effective_region` 포함. `--expand`는 같은 `name2Id`(구/시) 인접 동을 모은다.
|
|
92
|
+
|
|
93
|
+
## Safety and scope
|
|
94
|
+
|
|
95
|
+
- 읽기 전용 검색/상세만. 로그인·채팅·거래·예약·계약 자동화 없음.
|
|
96
|
+
- 공개 표면이 바뀌거나 빈 응답/봇 차단이면 실패 모드로 보고하고 우회하지 않는다.
|
|
97
|
+
- 결과는 실시간 호가라 실거래와 다를 수 있으므로 source URL을 함께 제시.
|
|
98
|
+
|
|
99
|
+
## Failure modes
|
|
100
|
+
|
|
101
|
+
- `RELAY_STORE 없음` (sources note) → 페이지 구조 재변경 또는 봇 차단. HTML 구조 재확인 필요.
|
|
102
|
+
- 시군구(name2) 랜딩 URL은 매물이 없다 — **반드시 동(name3)까지** 있어야 articleFeed가 SSR로 채워진다.
|
|
103
|
+
- 동명이 넓거나 중복되면 다른 행정동 선택 가능.
|
|
104
|
+
- 상세는 삭제/비공개 글에서 실패할 수 있다.
|
|
105
|
+
|
|
106
|
+
## Done when
|
|
107
|
+
|
|
108
|
+
- 지역 id 해석 → map URL의 RELAY_STORE에서 매물 추출 → source URL·effective_region 포함.
|
|
109
|
+
- 인증/거래성 액션은 수행하지 않았다.
|
|
110
|
+
|
|
111
|
+
## Notes
|
|
112
|
+
|
|
113
|
+
- Windows stdout 한글 깨짐 방지: 지원 런타임에서는 `sys.stdout.reconfigure(encoding='utf-8')`를 적용한다.
|
|
114
|
+
- `area`·가격 필드가 문자열로 오는 케이스가 있어 모든 수치 연산 전 float 변환 가드.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def parse_detail(url, fetch_text):
|
|
6
|
+
html = fetch_text(url)
|
|
7
|
+
out = {
|
|
8
|
+
"source": url,
|
|
9
|
+
"title": None,
|
|
10
|
+
"address": None,
|
|
11
|
+
"floor": None,
|
|
12
|
+
"top_floor": None,
|
|
13
|
+
"floor_label": None,
|
|
14
|
+
"nearby_subway": None,
|
|
15
|
+
"release_date": None,
|
|
16
|
+
"json_ld": [],
|
|
17
|
+
}
|
|
18
|
+
scripts = re.findall(
|
|
19
|
+
r"<script[^>]*application/ld\+json[^>]*>(.*?)</script>", html, re.S
|
|
20
|
+
)
|
|
21
|
+
for script in scripts:
|
|
22
|
+
try:
|
|
23
|
+
document = json.loads(script)
|
|
24
|
+
except json.JSONDecodeError:
|
|
25
|
+
continue
|
|
26
|
+
out["json_ld"].append(document)
|
|
27
|
+
for node in _json_ld_nodes(document):
|
|
28
|
+
_apply_node(out, node)
|
|
29
|
+
if out["floor"] is not None:
|
|
30
|
+
floor = str(out["floor"]).replace(".0", "")
|
|
31
|
+
top = str(out["top_floor"]).replace(".0", "") if out["top_floor"] else "?"
|
|
32
|
+
out["floor_label"] = f"{floor}층/{top}층"
|
|
33
|
+
out["json_ld"] = out["json_ld"][:3]
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _json_ld_nodes(document):
|
|
38
|
+
if isinstance(document, list):
|
|
39
|
+
return document
|
|
40
|
+
if not isinstance(document, dict):
|
|
41
|
+
return []
|
|
42
|
+
graph = document.get("@graph")
|
|
43
|
+
if isinstance(graph, list):
|
|
44
|
+
return graph
|
|
45
|
+
if isinstance(graph, dict):
|
|
46
|
+
return [graph]
|
|
47
|
+
return [document]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_node(out, node):
|
|
51
|
+
if not isinstance(node, dict):
|
|
52
|
+
return
|
|
53
|
+
if node.get("@type") == "Product":
|
|
54
|
+
if not out["title"]:
|
|
55
|
+
out["title"] = node.get("name")
|
|
56
|
+
if not out["release_date"]:
|
|
57
|
+
out["release_date"] = node.get("releaseDate")
|
|
58
|
+
if node.get("@type") == "Place" and not out["address"]:
|
|
59
|
+
out["address"] = node.get("name")
|
|
60
|
+
for prop in _properties(node.get("additionalProperty")):
|
|
61
|
+
name = prop.get("name")
|
|
62
|
+
value = prop.get("value")
|
|
63
|
+
if name == "floor":
|
|
64
|
+
out["floor"] = value
|
|
65
|
+
elif name == "topFloor":
|
|
66
|
+
out["top_floor"] = value
|
|
67
|
+
elif name == "nearbySubwayStation":
|
|
68
|
+
out["nearby_subway"] = value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _properties(value):
|
|
72
|
+
if isinstance(value, list):
|
|
73
|
+
return [item for item in value if isinstance(item, dict)]
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
return [value]
|
|
76
|
+
return []
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""당근부동산(realty.daangn.com) 읽기 전용 매물 검색/상세.
|
|
4
|
+
|
|
5
|
+
2026-06 당근부동산 도메인 이전 대응:
|
|
6
|
+
- 기존 www.daangn.com/kr/realty/?_data=routes/kr.realty._index → HTTP 204 (폐기)
|
|
7
|
+
- 신규 realty.daangn.com/map/{name1}/{name2}/{name3} 페이지의
|
|
8
|
+
window.RELAY_STORE (Relay 정규화 스토어)를 파싱한다.
|
|
9
|
+
|
|
10
|
+
RELAY_STORE 경로:
|
|
11
|
+
ArticleFeedConnection.edges → ArticleFeedEdge.node → ArticleFeedCard.article → Article
|
|
12
|
+
Article: originalId, area(㎡), salesTypeV3(→*SalesTypeV2.type), trades(→Month/Buy/BorrowTrade)
|
|
13
|
+
가격 단위: 만원 (deposit 2000 = 2천만원, monthlyPay 100 = 100만원, price 28700 = 2억8700만).
|
|
14
|
+
층수: 상세 페이지 JSON-LD additionalProperty 의 floor/topFloor.
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import pathlib
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
|
|
24
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
25
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
27
|
+
|
|
28
|
+
from daangn_detail_ld import parse_detail as _parse_detail
|
|
29
|
+
from daangn_relay_store import extract_articles, extract_relay_store
|
|
30
|
+
|
|
31
|
+
reconfigure_stdout = getattr(sys.stdout, "reconfigure", None)
|
|
32
|
+
if callable(reconfigure_stdout):
|
|
33
|
+
try:
|
|
34
|
+
reconfigure_stdout(encoding="utf-8")
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "text/html,application/json;q=0.9,*/*;q=0.8"}
|
|
39
|
+
REGION_API = "https://www.daangn.com/kr/api/v1/regions/keyword?keyword="
|
|
40
|
+
MAP_BASE = "https://realty.daangn.com/map/"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fetch_json(url):
|
|
44
|
+
req = urllib.request.Request(url, headers=HEADERS)
|
|
45
|
+
with urllib.request.urlopen(req, timeout=25) as r:
|
|
46
|
+
return json.load(r)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_text(url):
|
|
50
|
+
req = urllib.request.Request(url, headers=HEADERS)
|
|
51
|
+
with urllib.request.urlopen(req, timeout=25) as r:
|
|
52
|
+
return r.read().decode("utf-8", "ignore")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def print_json(obj):
|
|
56
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ------------------------- region 해석 -------------------------
|
|
60
|
+
|
|
61
|
+
def resolve_region(region):
|
|
62
|
+
"""지역명 → 당근 내부 region 객체 (id, name1/2/3, name*Id)."""
|
|
63
|
+
if not region:
|
|
64
|
+
return None
|
|
65
|
+
data = fetch_json(REGION_API + urllib.parse.quote(region))
|
|
66
|
+
locs = data.get("locations") or []
|
|
67
|
+
if not locs:
|
|
68
|
+
raise SystemExit(f"지역 후보 없음: {region}")
|
|
69
|
+
exact = [x for x in locs if region in (x.get("name"), x.get("name1"), x.get("name2"), x.get("name3"))]
|
|
70
|
+
seoul = [x for x in locs if x.get("name1") == "서울특별시" and x.get("depth") == 3]
|
|
71
|
+
return (exact or seoul or locs)[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_sibling_regions(sel, max_siblings=6):
|
|
75
|
+
"""같은 name2(구/시) 내 인접 동들을 조회 (--expand 용).
|
|
76
|
+
|
|
77
|
+
name2 키워드로 다시 region API를 때려 같은 name2Id 를 가진 depth=3 동들을 모은다.
|
|
78
|
+
"""
|
|
79
|
+
name2 = sel.get("name2") or ""
|
|
80
|
+
if not name2:
|
|
81
|
+
return []
|
|
82
|
+
try:
|
|
83
|
+
data = fetch_json(REGION_API + urllib.parse.quote(name2.split()[-1]))
|
|
84
|
+
except (json.JSONDecodeError, OSError, TimeoutError, urllib.error.URLError):
|
|
85
|
+
return []
|
|
86
|
+
sibs = []
|
|
87
|
+
seen = {sel.get("name3Id")}
|
|
88
|
+
for x in (data.get("locations") or []):
|
|
89
|
+
if x.get("depth") != 3:
|
|
90
|
+
continue
|
|
91
|
+
if x.get("name2Id") != sel.get("name2Id"):
|
|
92
|
+
continue
|
|
93
|
+
if x.get("name3Id") in seen:
|
|
94
|
+
continue
|
|
95
|
+
seen.add(x.get("name3Id"))
|
|
96
|
+
sibs.append(x)
|
|
97
|
+
if len(sibs) >= max_siblings:
|
|
98
|
+
break
|
|
99
|
+
return sibs
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def map_url(sel):
|
|
103
|
+
parts = [sel.get("name1") or "", sel.get("name2") or "", sel.get("name3") or ""]
|
|
104
|
+
path = "/".join(urllib.parse.quote(p) for p in parts)
|
|
105
|
+
return MAP_BASE + path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------- 상세(JSON-LD) -------------------------
|
|
109
|
+
|
|
110
|
+
def parse_detail(url):
|
|
111
|
+
return _parse_detail(url, fetch_text)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ------------------------- 커맨드 -------------------------
|
|
115
|
+
|
|
116
|
+
def collect_for_region(sel, sales_type_filter, trade_type_filter, limit, keyword=None):
|
|
117
|
+
url = map_url(sel)
|
|
118
|
+
html = fetch_text(url)
|
|
119
|
+
store = extract_relay_store(html)
|
|
120
|
+
if not store:
|
|
121
|
+
return url, [], "RELAY_STORE 없음 (페이지 구조 변경 또는 차단)"
|
|
122
|
+
items = extract_articles(store, max_items=10_000)
|
|
123
|
+
# 필터
|
|
124
|
+
if sales_type_filter:
|
|
125
|
+
sset = {s.strip().upper() for s in sales_type_filter.split(",")}
|
|
126
|
+
items = [it for it in items if (it.get("salesType") or "").upper() in sset]
|
|
127
|
+
if trade_type_filter:
|
|
128
|
+
tset = {t.strip().upper() for t in trade_type_filter.split(",")}
|
|
129
|
+
items = [it for it in items if any((tr["type"] or "").upper() in tset for tr in it["trades"])]
|
|
130
|
+
if keyword:
|
|
131
|
+
needle = keyword.casefold()
|
|
132
|
+
items = [it for it in items if needle in json.dumps(it, ensure_ascii=False).casefold()]
|
|
133
|
+
return url, items, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_search(args):
|
|
137
|
+
sel = resolve_region(args.region) if args.region else None
|
|
138
|
+
if not sel:
|
|
139
|
+
raise SystemExit("--region 이 필요합니다")
|
|
140
|
+
regions = [sel]
|
|
141
|
+
if args.expand:
|
|
142
|
+
regions += find_sibling_regions(sel, max_siblings=args.expand_max)
|
|
143
|
+
|
|
144
|
+
all_items, sources, errors = [], [], []
|
|
145
|
+
seen = set()
|
|
146
|
+
for rg in regions:
|
|
147
|
+
try:
|
|
148
|
+
url, items, err = collect_for_region(rg, args.sales_type, args.trade_type, args.limit, args.keyword)
|
|
149
|
+
except (json.JSONDecodeError, OSError, TimeoutError, urllib.error.URLError) as e:
|
|
150
|
+
errors.append({"region": rg.get("name3"), "error": str(e)})
|
|
151
|
+
continue
|
|
152
|
+
sources.append({"region": f"{rg.get('name1')} {rg.get('name2')} {rg.get('name3')}", "url": url,
|
|
153
|
+
"count": len(items), "note": err})
|
|
154
|
+
for it in items:
|
|
155
|
+
if it["article_id"] in seen:
|
|
156
|
+
continue
|
|
157
|
+
seen.add(it["article_id"])
|
|
158
|
+
it["region"] = f"{rg.get('name2')} {rg.get('name3')}"
|
|
159
|
+
all_items.append(it)
|
|
160
|
+
|
|
161
|
+
all_items = all_items[:args.limit]
|
|
162
|
+
# 제목 보강(상세 JSON-LD) — 상위 N개만 (네트워크 비용 절약)
|
|
163
|
+
if args.titles > 0:
|
|
164
|
+
for it in all_items[:args.titles]:
|
|
165
|
+
if not it.get("url"):
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
d = parse_detail(it["url"])
|
|
169
|
+
it["title"] = d.get("title")
|
|
170
|
+
it["address"] = d.get("address")
|
|
171
|
+
it["floor_label"] = d.get("floor_label")
|
|
172
|
+
it["nearby_subway"] = d.get("nearby_subway")
|
|
173
|
+
it["release_date"] = d.get("release_date")
|
|
174
|
+
except (json.JSONDecodeError, OSError, TimeoutError, urllib.error.URLError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
print_json({
|
|
178
|
+
"effective_region": f"{sel.get('name1')} {sel.get('name2')} {sel.get('name3')}",
|
|
179
|
+
"expand": bool(args.expand),
|
|
180
|
+
"regions_searched": len(regions),
|
|
181
|
+
"sources": sources,
|
|
182
|
+
"count": len(all_items),
|
|
183
|
+
"errors": errors,
|
|
184
|
+
"items": all_items,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_detail(args):
|
|
189
|
+
print_json(parse_detail(args.url))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_parser():
|
|
193
|
+
p = argparse.ArgumentParser(description="당근부동산 읽기전용 검색/상세 (realty.daangn.com)")
|
|
194
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
195
|
+
|
|
196
|
+
s = sub.add_parser("search", help="지역 매물 검색")
|
|
197
|
+
s.add_argument("--region", required=True, help="동 이름 (예: 매교동, 합정동)")
|
|
198
|
+
s.add_argument("--sales-type", help="용도 필터(콤마구분): APART,OFFICETEL,STORE,OPEN_ONE_ROOM,SPLIT_ONE_ROOM,TWO_ROOM,HOUSE")
|
|
199
|
+
s.add_argument("--trade-type", help="거래 필터(콤마구분): MONTH(월세),BUY(매매),BORROW(전세)")
|
|
200
|
+
s.add_argument("--keyword", help="기존 CLI 호환용 키워드 필터(현재 공개 피드에서는 미사용)")
|
|
201
|
+
s.add_argument("--only-verified", action="store_true", help="기존 CLI 호환용 인증 매물 플래그")
|
|
202
|
+
s.add_argument("--expand", action="store_true", help="같은 구/시 인접 동까지 확장 검색")
|
|
203
|
+
s.add_argument("--expand-max", type=int, default=6, help="확장 시 인접 동 최대 개수 (기본 6)")
|
|
204
|
+
s.add_argument("--titles", type=int, default=5, help="상세 JSON-LD로 제목·층수 보강할 상위 N개 (기본 5, 0=비활성)")
|
|
205
|
+
s.add_argument("--limit", type=int, default=20, help="최대 매물 수 (기본 20)")
|
|
206
|
+
s.set_defaults(func=cmd_search)
|
|
207
|
+
|
|
208
|
+
d = sub.add_parser("detail", help="매물 상세 (제목·주소·층수)")
|
|
209
|
+
d.add_argument("url", help="https://realty.daangn.com/articles/<id>")
|
|
210
|
+
d.set_defaults(func=cmd_detail)
|
|
211
|
+
return p
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
args = build_parser().parse_args()
|
|
216
|
+
args.func(args)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
DETAIL_BASE = "https://realty.daangn.com/articles/"
|
|
5
|
+
PY_PER_SQM = 3.305785
|
|
6
|
+
TRADE_LABEL = {"MONTH": "월세", "BUY": "매매", "BORROW": "전세"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_relay_store(html):
|
|
10
|
+
match = re.search(r'window\.RELAY_STORE\s*=\s*"((?:[^"\\]|\\.)*)"', html)
|
|
11
|
+
if match:
|
|
12
|
+
try:
|
|
13
|
+
return json.loads(json.loads('"' + match.group(1) + '"'))
|
|
14
|
+
except json.JSONDecodeError:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
start = html.find("window.RELAY_STORE")
|
|
18
|
+
if start < 0:
|
|
19
|
+
return None
|
|
20
|
+
eq = html.find("=", start)
|
|
21
|
+
if eq < 0:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
literal = _scan_object_literal(html[eq + 1 :])
|
|
25
|
+
if literal is None:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(literal)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _scan_object_literal(source):
|
|
34
|
+
depth = 0
|
|
35
|
+
in_string = False
|
|
36
|
+
escaped = False
|
|
37
|
+
quote = ""
|
|
38
|
+
end = 0
|
|
39
|
+
for idx, ch in enumerate(source):
|
|
40
|
+
if in_string:
|
|
41
|
+
if escaped:
|
|
42
|
+
escaped = False
|
|
43
|
+
elif ch == "\\":
|
|
44
|
+
escaped = True
|
|
45
|
+
elif ch == quote:
|
|
46
|
+
in_string = False
|
|
47
|
+
continue
|
|
48
|
+
if ch in "\"'":
|
|
49
|
+
in_string = True
|
|
50
|
+
quote = ch
|
|
51
|
+
elif ch == "{":
|
|
52
|
+
depth += 1
|
|
53
|
+
elif ch == "}":
|
|
54
|
+
depth -= 1
|
|
55
|
+
if depth == 0:
|
|
56
|
+
end = idx + 1
|
|
57
|
+
break
|
|
58
|
+
if end == 0:
|
|
59
|
+
return None
|
|
60
|
+
return source[:end]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _deref(store, ref):
|
|
64
|
+
if isinstance(ref, dict) and "__ref" in ref:
|
|
65
|
+
return store.get(ref["__ref"])
|
|
66
|
+
return ref
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _refs(store, node, key):
|
|
70
|
+
value = node.get(key)
|
|
71
|
+
out = []
|
|
72
|
+
if isinstance(value, dict):
|
|
73
|
+
if "__refs" in value:
|
|
74
|
+
out = [store.get(ref) for ref in value["__refs"]]
|
|
75
|
+
elif "__ref" in value:
|
|
76
|
+
out = [store.get(value["__ref"])]
|
|
77
|
+
elif isinstance(value, list):
|
|
78
|
+
for item in value:
|
|
79
|
+
if isinstance(item, dict) and "__ref" in item:
|
|
80
|
+
out.append(store.get(item["__ref"]))
|
|
81
|
+
return [item for item in out if item]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sales_type(store, article):
|
|
85
|
+
sales = _deref(store, article.get("salesTypeV3"))
|
|
86
|
+
if isinstance(sales, dict):
|
|
87
|
+
return sales.get("type") or sales.get("name")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_trade(trade):
|
|
92
|
+
typename = trade.get("__typename")
|
|
93
|
+
if typename == "MonthTrade":
|
|
94
|
+
return ("MONTH", trade.get("deposit"), trade.get("monthlyPay"), None)
|
|
95
|
+
if typename == "BuyTrade":
|
|
96
|
+
return ("BUY", None, None, trade.get("price"))
|
|
97
|
+
if typename == "BorrowTrade":
|
|
98
|
+
return ("BORROW", trade.get("deposit"), None, None)
|
|
99
|
+
return (
|
|
100
|
+
trade.get("type"),
|
|
101
|
+
trade.get("deposit"),
|
|
102
|
+
trade.get("monthlyPay"),
|
|
103
|
+
trade.get("price"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def per_pyeong(kind, deposit, monthly, price, pyeong):
|
|
108
|
+
if not pyeong or pyeong <= 0:
|
|
109
|
+
return None
|
|
110
|
+
base = None
|
|
111
|
+
if kind == "MONTH":
|
|
112
|
+
base = monthly
|
|
113
|
+
elif kind == "BUY":
|
|
114
|
+
base = price
|
|
115
|
+
elif kind == "BORROW":
|
|
116
|
+
base = deposit
|
|
117
|
+
if base is None:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
numeric_base = float(base)
|
|
121
|
+
except (TypeError, ValueError):
|
|
122
|
+
return None
|
|
123
|
+
return round(numeric_base / pyeong, 2)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_articles(store, max_items):
|
|
127
|
+
items = []
|
|
128
|
+
cards = [
|
|
129
|
+
value
|
|
130
|
+
for value in store.values()
|
|
131
|
+
if isinstance(value, dict) and value.get("__typename") == "ArticleFeedCard"
|
|
132
|
+
]
|
|
133
|
+
for card in cards:
|
|
134
|
+
article = _deref(store, card.get("article"))
|
|
135
|
+
if not article or article.get("__typename") != "Article":
|
|
136
|
+
continue
|
|
137
|
+
area = _parse_area(article.get("area"))
|
|
138
|
+
pyeong = round(area / PY_PER_SQM, 2) if area else None
|
|
139
|
+
trades = []
|
|
140
|
+
for trade in _refs(store, article, "trades"):
|
|
141
|
+
kind, deposit, monthly, price = parse_trade(trade)
|
|
142
|
+
trades.append(
|
|
143
|
+
{
|
|
144
|
+
"type": kind,
|
|
145
|
+
"label": TRADE_LABEL.get(kind, kind),
|
|
146
|
+
"deposit_manwon": deposit,
|
|
147
|
+
"monthly_manwon": monthly,
|
|
148
|
+
"price_manwon": price,
|
|
149
|
+
"per_pyeong_manwon": per_pyeong(
|
|
150
|
+
kind, deposit, monthly, price, pyeong
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
article_id = article.get("originalId")
|
|
155
|
+
items.append(
|
|
156
|
+
{
|
|
157
|
+
"article_id": article_id,
|
|
158
|
+
"salesType": sales_type(store, article),
|
|
159
|
+
"area_sqm": area,
|
|
160
|
+
"area_pyeong": pyeong,
|
|
161
|
+
"trades": trades,
|
|
162
|
+
"url": DETAIL_BASE + str(article_id) if article_id else None,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
if len(items) >= max_items:
|
|
166
|
+
break
|
|
167
|
+
return items
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_area(value):
|
|
171
|
+
try:
|
|
172
|
+
return float(value) if value is not None and value != "" else None
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
return None
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "daangn-realty-search",
|
|
3
|
+
"description": "당근부동산 공개 웹 데이터로 매물을 검색하고, 돌쇠에서는 공식 계정 표면에서 문의·방문예약·계약 준비까지 진행한다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"action:commerce"
|
|
6
|
+
],
|
|
7
|
+
"frontmatter": "name: daangn-realty-search\ndescription: 당근부동산 공개 웹 데이터로 매물을 검색하고, 돌쇠에서는 공식 계정 표면에서 문의·방문예약·계약 준비까지 진행한다.\nlicense: MIT\nmetadata:\n category: real-estate\n locale: ko-KR\n phase: v1.5"
|
|
8
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Daangn Used-Goods Search
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
당근 중고거래 공개 Remix `_data` JSON route를 사용해 매물 목록과 상세 정보를 읽기 전용으로 조회한다.
|
|
6
|
+
|
|
7
|
+
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
|
|
8
|
+
|
|
9
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
- "당근에서 맥북 찾아봐"
|
|
12
|
+
- "합정동 아이폰 매물 검색"
|
|
13
|
+
- "이 당근 중고거래 URL 상세 봐줘"
|
|
14
|
+
|
|
15
|
+
## When not to use
|
|
16
|
+
|
|
17
|
+
- 당근 계정 로그인이 필요한 작업
|
|
18
|
+
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
|
|
19
|
+
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
- 인터넷 연결
|
|
24
|
+
- Python 3.9+
|
|
25
|
+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
|
|
26
|
+
|
|
27
|
+
## Data surfaces
|
|
28
|
+
|
|
29
|
+
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
|
|
30
|
+
- Search `_data`: `/kr/buy-sell/all/?in=<지역명>-<id>&search=<keyword>&only_on_sale=true&_data=routes/kr.buy-sell._index`
|
|
31
|
+
- Detail `_data`: `<listing-url>?_data=routes%2Fkr.buy-sell.%24buy_sell_id`
|
|
32
|
+
|
|
33
|
+
## Workflow
|
|
34
|
+
|
|
35
|
+
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
|
|
36
|
+
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
|
|
37
|
+
3. 목록 검색은 category별 `_data` route를 호출한다.
|
|
38
|
+
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
|
|
39
|
+
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python3 daangn-used-goods-search/scripts/daangn_used_goods.py search "맥북" --region "합정동" --limit 5
|
|
45
|
+
python3 daangn-used-goods-search/scripts/daangn_used_goods.py detail "https://www.daangn.com/kr/buy-sell/.../"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Output fields
|
|
49
|
+
|
|
50
|
+
- title, price, price_text, status, region, url
|
|
51
|
+
- detail: product 원문, view/chat/count류 필드가 있으면 함께 확인
|
|
52
|
+
|
|
53
|
+
## Region handling
|
|
54
|
+
|
|
55
|
+
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
|
|
59
|
+
→ 서울특별시 마포구 합정동, id=231
|
|
60
|
+
→ in=합정동-231
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
|
|
64
|
+
|
|
65
|
+
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
|
|
66
|
+
2. 서울 `depth=3` 동 단위 후보
|
|
67
|
+
3. 첫 번째 후보
|
|
68
|
+
|
|
69
|
+
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
|
|
70
|
+
|
|
71
|
+
## Safety and scope
|
|
72
|
+
|
|
73
|
+
- 읽기 전용 검색/상세 조회만 수행한다.
|
|
74
|
+
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
|
|
75
|
+
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
|
|
76
|
+
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
|
|
77
|
+
|
|
78
|
+
## Failure modes
|
|
79
|
+
|
|
80
|
+
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
|
|
81
|
+
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
|
|
82
|
+
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
|
|
83
|
+
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
|
|
84
|
+
|
|
85
|
+
## Done when
|
|
86
|
+
|
|
87
|
+
- 지역명이 있으면 지역 id를 해석하고 적용했다.
|
|
88
|
+
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
|
|
89
|
+
- 결과에 source URL과 effective region을 포함했다.
|
|
90
|
+
- 인증/거래성 액션은 수행하지 않았다.
|