@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,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
from _naver_http import is_naver_url, urlopen
|
|
13
|
+
|
|
14
|
+
DEFAULT_OUTPUT_DIR = "./naver-images"
|
|
15
|
+
DEFAULT_MAX = 10
|
|
16
|
+
DEFAULT_TIMEOUT = 15
|
|
17
|
+
|
|
18
|
+
DEFAULT_HEADERS = {
|
|
19
|
+
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
|
20
|
+
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
|
|
21
|
+
"Referer": "https://m.blog.naver.com/",
|
|
22
|
+
"User-Agent": (
|
|
23
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
24
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
|
|
25
|
+
),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
CONTENT_TYPE_TO_EXT = {
|
|
29
|
+
"image/jpeg": ".jpg",
|
|
30
|
+
"image/png": ".png",
|
|
31
|
+
"image/gif": ".gif",
|
|
32
|
+
"image/webp": ".webp",
|
|
33
|
+
"image/bmp": ".bmp",
|
|
34
|
+
"image/svg+xml": ".svg",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_MAGIC_BYTES = (
|
|
39
|
+
(b"\x89PNG\r\n\x1a\n", ".png"),
|
|
40
|
+
(b"GIF87a", ".gif"),
|
|
41
|
+
(b"GIF89a", ".gif"),
|
|
42
|
+
(b"RIFF", ".webp"), # WebP: RIFF....WEBP (check first 4 bytes)
|
|
43
|
+
(b"BM", ".bmp"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def guess_extension(url: str, content_type: str | None = None, data: bytes | None = None) -> str:
|
|
48
|
+
if content_type:
|
|
49
|
+
ct = content_type.split(";")[0].strip().lower()
|
|
50
|
+
if ct in CONTENT_TYPE_TO_EXT:
|
|
51
|
+
return CONTENT_TYPE_TO_EXT[ct]
|
|
52
|
+
|
|
53
|
+
lower_url = url.lower().split("?")[0]
|
|
54
|
+
for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"):
|
|
55
|
+
if lower_url.endswith(ext):
|
|
56
|
+
return ".jpg" if ext == ".jpeg" else ext
|
|
57
|
+
|
|
58
|
+
if data:
|
|
59
|
+
for magic, ext in _MAGIC_BYTES:
|
|
60
|
+
if data[:len(magic)] == magic:
|
|
61
|
+
if ext == ".webp" and data[8:12] != b"WEBP":
|
|
62
|
+
continue
|
|
63
|
+
return ext
|
|
64
|
+
if data[:2] in (b"\xff\xd8",):
|
|
65
|
+
return ".jpg"
|
|
66
|
+
|
|
67
|
+
return ".jpg"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def download_image(url: str, output_path: str, output_dir: str, timeout: int = DEFAULT_TIMEOUT, *, insecure: bool = False) -> dict:
|
|
71
|
+
"""Download a single image from a Naver CDN URL.
|
|
72
|
+
|
|
73
|
+
*output_dir* is used solely for path-traversal protection: the resolved
|
|
74
|
+
*output_path* must reside inside *output_dir*.
|
|
75
|
+
"""
|
|
76
|
+
if not is_naver_url(url):
|
|
77
|
+
return {"url": url, "error": "Not a Naver CDN URL. Skipped."}
|
|
78
|
+
|
|
79
|
+
real_dir = os.path.realpath(output_dir)
|
|
80
|
+
if not os.path.realpath(output_path).startswith(real_dir + os.sep):
|
|
81
|
+
return {"url": url, "error": "Output path escapes target directory. Skipped."}
|
|
82
|
+
|
|
83
|
+
request = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with urlopen(request, timeout, insecure=insecure) as response:
|
|
87
|
+
data = response.read()
|
|
88
|
+
content_type = response.headers.get("Content-Type", "")
|
|
89
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as error:
|
|
90
|
+
return {"url": url, "error": str(error)}
|
|
91
|
+
|
|
92
|
+
ext = guess_extension(url, content_type, data)
|
|
93
|
+
if not os.path.splitext(output_path)[1]:
|
|
94
|
+
output_path += ext
|
|
95
|
+
|
|
96
|
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
97
|
+
|
|
98
|
+
with open(output_path, "wb") as f:
|
|
99
|
+
f.write(data)
|
|
100
|
+
|
|
101
|
+
size_kb = round(len(data) / 1024, 1)
|
|
102
|
+
return {"url": url, "path": output_path, "size_kb": size_kb}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def download_images(
|
|
106
|
+
urls: list[str],
|
|
107
|
+
output_dir: str = DEFAULT_OUTPUT_DIR,
|
|
108
|
+
max_count: int = DEFAULT_MAX,
|
|
109
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
110
|
+
*,
|
|
111
|
+
insecure: bool = False,
|
|
112
|
+
) -> dict:
|
|
113
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
max_count = max(1, max_count)
|
|
116
|
+
targets = urls[:max_count]
|
|
117
|
+
downloaded: list[dict] = []
|
|
118
|
+
failed: list[dict] = []
|
|
119
|
+
|
|
120
|
+
# index → result 순서를 보장하기 위해 dict로 매핑
|
|
121
|
+
results_by_index: dict[int, dict] = {}
|
|
122
|
+
|
|
123
|
+
with ThreadPoolExecutor(max_workers=min(4, max(1, len(targets)))) as executor:
|
|
124
|
+
future_to_index = {}
|
|
125
|
+
for i, url in enumerate(targets, start=1):
|
|
126
|
+
filename = f"{i:03d}"
|
|
127
|
+
output_path = os.path.join(output_dir, filename)
|
|
128
|
+
future = executor.submit(download_image, url, output_path, output_dir, timeout, insecure=insecure)
|
|
129
|
+
future_to_index[future] = i
|
|
130
|
+
|
|
131
|
+
for future in as_completed(future_to_index):
|
|
132
|
+
idx = future_to_index[future]
|
|
133
|
+
try:
|
|
134
|
+
results_by_index[idx] = future.result()
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
results_by_index[idx] = {"url": targets[idx - 1], "error": str(exc)}
|
|
137
|
+
|
|
138
|
+
# 원래 순서대로 정렬
|
|
139
|
+
for idx in sorted(results_by_index):
|
|
140
|
+
result = results_by_index[idx]
|
|
141
|
+
if "error" in result:
|
|
142
|
+
failed.append(result)
|
|
143
|
+
else:
|
|
144
|
+
downloaded.append(result)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"downloaded": len(downloaded),
|
|
148
|
+
"files": downloaded,
|
|
149
|
+
"failed": failed,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
154
|
+
parser = argparse.ArgumentParser(
|
|
155
|
+
description="Download images from Naver blog CDN URLs."
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--urls", type=str, default="",
|
|
159
|
+
help="Comma-separated image URLs.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--output", type=str, default=DEFAULT_OUTPUT_DIR,
|
|
163
|
+
help=f"Output directory. Default: {DEFAULT_OUTPUT_DIR}",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--max", type=int, default=DEFAULT_MAX,
|
|
167
|
+
help=f"Maximum number of images to download. Default: {DEFAULT_MAX}",
|
|
168
|
+
)
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
"--timeout", type=int, default=DEFAULT_TIMEOUT,
|
|
171
|
+
help=f"HTTP request timeout in seconds. Default: {DEFAULT_TIMEOUT}",
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--insecure", action="store_true",
|
|
175
|
+
help="Skip SSL certificate verification (use only when certificate errors occur).",
|
|
176
|
+
)
|
|
177
|
+
return parser.parse_args(argv)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def read_urls_from_stdin() -> list[str]:
|
|
181
|
+
try:
|
|
182
|
+
data = json.load(sys.stdin)
|
|
183
|
+
if isinstance(data, dict) and "images" in data:
|
|
184
|
+
return [img["url"] for img in data["images"] if isinstance(img, dict) and img.get("url")]
|
|
185
|
+
if isinstance(data, list):
|
|
186
|
+
return [
|
|
187
|
+
u for item in data
|
|
188
|
+
if (u := (item if isinstance(item, str) else item.get("url", "")))
|
|
189
|
+
]
|
|
190
|
+
if isinstance(data, dict):
|
|
191
|
+
print(
|
|
192
|
+
"[warn] stdin JSON에 'images' 키가 없습니다. "
|
|
193
|
+
"naver_read.py 실행 시 --no-images 플래그를 사용하지 않았는지 확인하세요.",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
except (json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
197
|
+
print(f"[warn] stdin JSON 파싱 실패: {exc}", file=sys.stderr)
|
|
198
|
+
return []
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv: list[str] | None = None) -> int:
|
|
203
|
+
args = parse_args(argv or sys.argv[1:])
|
|
204
|
+
|
|
205
|
+
urls: list[str] = []
|
|
206
|
+
|
|
207
|
+
if args.urls:
|
|
208
|
+
urls = [u.strip() for u in args.urls.split(",") if u.strip()]
|
|
209
|
+
|
|
210
|
+
if not urls and not sys.stdin.isatty():
|
|
211
|
+
urls = read_urls_from_stdin()
|
|
212
|
+
|
|
213
|
+
if not urls:
|
|
214
|
+
print(
|
|
215
|
+
json.dumps({"error": "No image URLs provided. Use --urls or pipe naver_read.py output via stdin."}, ensure_ascii=False),
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
result = download_images(
|
|
221
|
+
urls,
|
|
222
|
+
output_dir=args.output,
|
|
223
|
+
max_count=args.max,
|
|
224
|
+
timeout=args.timeout,
|
|
225
|
+
insecure=args.insecure,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from html import unescape
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
from _naver_http import TAG_RE, is_naver_url, urlopen
|
|
14
|
+
|
|
15
|
+
MOBILE_UA = (
|
|
16
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
|
17
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
DEFAULT_HEADERS = {
|
|
21
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
22
|
+
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
|
|
23
|
+
"User-Agent": MOBILE_UA,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
|
27
|
+
BLOCK_END_RE = re.compile(r"</(p|div|li)>", re.IGNORECASE)
|
|
28
|
+
WHITESPACE_RE = re.compile(r"[ \t]+")
|
|
29
|
+
BLANK_LINES_RE = re.compile(r"\n{3,}")
|
|
30
|
+
|
|
31
|
+
_IMG_CDN_HOSTS = r"(?:blogfiles\.naver\.net|postfiles\.pstatic\.net|mblogthumb-phinf\.pstatic\.net)"
|
|
32
|
+
|
|
33
|
+
IMAGE_LAZY_PATTERN = re.compile(
|
|
34
|
+
rf'data-lazy-src="(https?://{_IMG_CDN_HOSTS}[^"]+)"'
|
|
35
|
+
)
|
|
36
|
+
IMAGE_SRC_PATTERN = re.compile(
|
|
37
|
+
rf'src="(https?://{_IMG_CDN_HOSTS}[^"]+)"'
|
|
38
|
+
)
|
|
39
|
+
IMAGE_ALT_PATTERN = re.compile(
|
|
40
|
+
r'alt="([^"]*)"'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
TITLE_PATTERN = re.compile(
|
|
44
|
+
r'<title[^>]*>(.*?)</title>', re.DOTALL | re.IGNORECASE
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
SCRIPT_STYLE_RE = re.compile(r"<(script|style|noscript)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
PC_BLOG_RE = re.compile(r"^https?://blog\.naver\.com/")
|
|
50
|
+
BLOG_ID_RE = re.compile(r"blog\.naver\.com/([a-zA-Z0-9_]+)/(\d+)")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_mobile_url(url: str) -> str:
|
|
54
|
+
url = url.strip()
|
|
55
|
+
url = PC_BLOG_RE.sub("https://m.blog.naver.com/", url)
|
|
56
|
+
if not url.startswith("https://m.blog.naver.com/"):
|
|
57
|
+
match = BLOG_ID_RE.search(url)
|
|
58
|
+
if match:
|
|
59
|
+
url = f"https://m.blog.naver.com/{match.group(1)}/{match.group(2)}"
|
|
60
|
+
return url
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_blog_page(url: str, timeout: int = 20, *, insecure: bool = False) -> str:
|
|
64
|
+
mobile_url = to_mobile_url(url)
|
|
65
|
+
if not is_naver_url(mobile_url):
|
|
66
|
+
raise ValueError(f"Not a Naver blog URL: {url}")
|
|
67
|
+
request = urllib.request.Request(mobile_url, headers=DEFAULT_HEADERS)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with urlopen(request, timeout, insecure=insecure) as response:
|
|
71
|
+
return response.read().decode("utf-8", "ignore")
|
|
72
|
+
except urllib.error.HTTPError as error:
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"Naver blog returned HTTP {error.code} for {mobile_url}. "
|
|
75
|
+
"The post may not exist or access may be restricted."
|
|
76
|
+
) from error
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_title(html: str) -> str:
|
|
80
|
+
match = TITLE_PATTERN.search(html)
|
|
81
|
+
if not match:
|
|
82
|
+
return ""
|
|
83
|
+
title = unescape(TAG_RE.sub("", match.group(1))).strip()
|
|
84
|
+
title = re.sub(r"\s*[-:|]?\s*네이버\s*블로그$", "", title).strip()
|
|
85
|
+
return title
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_div_block(html: str, start_pos: int) -> str:
|
|
89
|
+
tag_start = html.rfind("<div", 0, start_pos)
|
|
90
|
+
if tag_start < 0:
|
|
91
|
+
tag_start = start_pos
|
|
92
|
+
|
|
93
|
+
depth = 0
|
|
94
|
+
pos = tag_start
|
|
95
|
+
started = False
|
|
96
|
+
length = len(html)
|
|
97
|
+
while pos < length:
|
|
98
|
+
# HTML 주석 건너뛰기
|
|
99
|
+
if html[pos : pos + 4] == "<!--":
|
|
100
|
+
end = html.find("-->", pos + 4)
|
|
101
|
+
pos = end + 3 if end >= 0 else length
|
|
102
|
+
continue
|
|
103
|
+
if html[pos : pos + 4] == "<div" and (pos + 4 >= length or html[pos + 4] in (" ", ">", "\t", "\n", "/")):
|
|
104
|
+
depth += 1
|
|
105
|
+
started = True
|
|
106
|
+
elif html[pos : pos + 6] == "</div>":
|
|
107
|
+
depth -= 1
|
|
108
|
+
if started and depth == 0:
|
|
109
|
+
return html[tag_start : pos + 6]
|
|
110
|
+
pos += 1
|
|
111
|
+
|
|
112
|
+
return html[tag_start:]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_content_area(html: str) -> str:
|
|
116
|
+
cleaned = SCRIPT_STYLE_RE.sub("", html)
|
|
117
|
+
|
|
118
|
+
match = re.search(r'class="[^"]*\bse-main-container\b[^"]*"', cleaned)
|
|
119
|
+
if match:
|
|
120
|
+
return _extract_div_block(cleaned, match.start())
|
|
121
|
+
|
|
122
|
+
for class_name in ("post_ct", "postViewArea", "post-view"):
|
|
123
|
+
match = re.search(rf'class="[^"]*\b{re.escape(class_name)}\b[^"]*"', cleaned)
|
|
124
|
+
if match:
|
|
125
|
+
return _extract_div_block(cleaned, match.start())
|
|
126
|
+
|
|
127
|
+
marker = cleaned.find('id="viewTypeSelector"')
|
|
128
|
+
if marker >= 0:
|
|
129
|
+
return _extract_div_block(cleaned, marker)
|
|
130
|
+
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_text(html_fragment: str) -> str:
|
|
135
|
+
text = BR_RE.sub("\n", html_fragment)
|
|
136
|
+
text = BLOCK_END_RE.sub("\n", text)
|
|
137
|
+
text = TAG_RE.sub("", text)
|
|
138
|
+
text = unescape(text)
|
|
139
|
+
|
|
140
|
+
lines = []
|
|
141
|
+
for line in text.split("\n"):
|
|
142
|
+
stripped = WHITESPACE_RE.sub(" ", line).strip()
|
|
143
|
+
if stripped:
|
|
144
|
+
lines.append(stripped)
|
|
145
|
+
|
|
146
|
+
result = "\n".join(lines)
|
|
147
|
+
result = BLANK_LINES_RE.sub("\n\n", result)
|
|
148
|
+
return result.strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extract_images(html_fragment: str) -> list[dict]:
|
|
152
|
+
images: list[dict] = []
|
|
153
|
+
seen_base: set[str] = set()
|
|
154
|
+
|
|
155
|
+
img_tags = re.finditer(r"<img\s[^>]+>", html_fragment, re.IGNORECASE)
|
|
156
|
+
for img_match in img_tags:
|
|
157
|
+
img_tag = img_match.group(0)
|
|
158
|
+
|
|
159
|
+
lazy_match = IMAGE_LAZY_PATTERN.search(img_tag)
|
|
160
|
+
src_match = IMAGE_SRC_PATTERN.search(img_tag)
|
|
161
|
+
url_match = lazy_match or src_match
|
|
162
|
+
if not url_match:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
url = url_match.group(1)
|
|
166
|
+
|
|
167
|
+
base_url = re.sub(r"\?type=.*$", "", url)
|
|
168
|
+
if base_url in seen_base:
|
|
169
|
+
continue
|
|
170
|
+
seen_base.add(base_url)
|
|
171
|
+
|
|
172
|
+
if "?type=" not in url:
|
|
173
|
+
url = base_url
|
|
174
|
+
elif "_blur" in url:
|
|
175
|
+
url = re.sub(r"\?type=w\d+_blur", "?type=w800", url)
|
|
176
|
+
|
|
177
|
+
alt_match = IMAGE_ALT_PATTERN.search(img_tag)
|
|
178
|
+
alt = unescape(alt_match.group(1)).strip() if alt_match else ""
|
|
179
|
+
|
|
180
|
+
images.append({"url": url, "alt": alt})
|
|
181
|
+
|
|
182
|
+
return images
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_blog(url: str, include_images: bool = True, max_length: int = 0, timeout: int = 20, *, insecure: bool = False) -> dict:
|
|
186
|
+
html = fetch_blog_page(url, timeout=timeout, insecure=insecure)
|
|
187
|
+
mobile_url = to_mobile_url(url)
|
|
188
|
+
|
|
189
|
+
title = extract_title(html)
|
|
190
|
+
content_area = extract_content_area(html)
|
|
191
|
+
content = extract_text(content_area)
|
|
192
|
+
|
|
193
|
+
if max_length > 0 and len(content) > max_length:
|
|
194
|
+
content = content[:max_length] + "..."
|
|
195
|
+
|
|
196
|
+
result: dict = {
|
|
197
|
+
"url": mobile_url,
|
|
198
|
+
"title": title,
|
|
199
|
+
"content": content,
|
|
200
|
+
"char_count": len(content),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if not content:
|
|
204
|
+
result["warning"] = "본문 영역을 찾지 못했습니다. 네이버 HTML 구조가 변경되었을 수 있습니다."
|
|
205
|
+
|
|
206
|
+
if include_images:
|
|
207
|
+
result["images"] = extract_images(content_area)
|
|
208
|
+
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
213
|
+
parser = argparse.ArgumentParser(
|
|
214
|
+
description="Read a Naver blog post and extract text content and images."
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument("url", help="Naver blog post URL (PC or mobile).")
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--no-images", action="store_true",
|
|
219
|
+
help="Exclude image URLs from output.",
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--max-length", type=int, default=0,
|
|
223
|
+
help="Maximum content length in characters (0 = unlimited). Default: 0.",
|
|
224
|
+
)
|
|
225
|
+
parser.add_argument(
|
|
226
|
+
"--timeout", type=int, default=20,
|
|
227
|
+
help="HTTP request timeout in seconds. Default: 20.",
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument(
|
|
230
|
+
"--insecure", action="store_true",
|
|
231
|
+
help="Skip SSL certificate verification (use only when certificate errors occur).",
|
|
232
|
+
)
|
|
233
|
+
return parser.parse_args(argv)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main(argv: list[str] | None = None) -> int:
|
|
237
|
+
args = parse_args(argv or sys.argv[1:])
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
result = read_blog(
|
|
241
|
+
args.url,
|
|
242
|
+
include_images=not args.no_images,
|
|
243
|
+
max_length=args.max_length,
|
|
244
|
+
timeout=args.timeout,
|
|
245
|
+
insecure=args.insecure,
|
|
246
|
+
)
|
|
247
|
+
except (RuntimeError, ValueError) as error:
|
|
248
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
|
|
251
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from html import unescape
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
from _naver_http import TAG_RE, urlopen
|
|
15
|
+
|
|
16
|
+
SEARCH_URL = "https://search.naver.com/search.naver"
|
|
17
|
+
DEFAULT_COUNT = 10
|
|
18
|
+
MAX_COUNT = 30
|
|
19
|
+
FIRST_PAGE_START = 1
|
|
20
|
+
RESULTS_PER_PAGE = 15
|
|
21
|
+
|
|
22
|
+
DEFAULT_HEADERS = {
|
|
23
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
24
|
+
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
|
|
25
|
+
"User-Agent": (
|
|
26
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
27
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
BLOG_ANCHOR_PATTERN = re.compile(
|
|
32
|
+
r'<a[^>]*href="(https?://blog\.naver\.com/([a-zA-Z0-9_]+)/(\d+))"[^>]*>(.*?)</a>',
|
|
33
|
+
re.DOTALL,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_html(text: str) -> str:
|
|
38
|
+
return unescape(TAG_RE.sub("", text)).strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_search_params(query: str, start: int = FIRST_PAGE_START, sort: str = "sim") -> dict[str, str]:
|
|
42
|
+
return {
|
|
43
|
+
"query": query,
|
|
44
|
+
"ssc": "tab.blog.all",
|
|
45
|
+
"sm": "tab_jum" if start <= FIRST_PAGE_START else "tab_pge",
|
|
46
|
+
"start": str(start),
|
|
47
|
+
"nso": {"sim": "so:r,p:all,a:all", "date": "so:dd,p:all,a:all"}.get(sort, "so:r,p:all,a:all"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_search_page(query: str, start: int = 1, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> str:
|
|
52
|
+
params = build_search_params(query, start=start, sort=sort)
|
|
53
|
+
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
|
54
|
+
request = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with urlopen(request, timeout, insecure=insecure) as response:
|
|
58
|
+
return response.read().decode("utf-8", "ignore")
|
|
59
|
+
except urllib.error.HTTPError as error:
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"Naver search returned HTTP {error.code}. "
|
|
62
|
+
"The request may have been blocked. Retry later or reduce request volume."
|
|
63
|
+
) from error
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_search_results(html: str) -> list[dict]:
|
|
67
|
+
results: list[dict] = []
|
|
68
|
+
anchors = BLOG_ANCHOR_PATTERN.findall(html)
|
|
69
|
+
|
|
70
|
+
pending: dict[str, dict] = {}
|
|
71
|
+
|
|
72
|
+
for full_url, user_id, post_id, inner_html in anchors:
|
|
73
|
+
if full_url not in pending:
|
|
74
|
+
pending[full_url] = {
|
|
75
|
+
"url": full_url,
|
|
76
|
+
"mobile_url": f"https://m.blog.naver.com/{user_id}/{post_id}",
|
|
77
|
+
"author": user_id,
|
|
78
|
+
"title": "",
|
|
79
|
+
"snippet": "",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
text = strip_html(inner_html)
|
|
83
|
+
if not text:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
entry = pending[full_url]
|
|
87
|
+
|
|
88
|
+
if "headline1" in inner_html or "text-type-headline" in inner_html:
|
|
89
|
+
if not entry["title"]:
|
|
90
|
+
entry["title"] = text
|
|
91
|
+
elif "body1" in inner_html or "text-type-body" in inner_html:
|
|
92
|
+
if not entry["snippet"]:
|
|
93
|
+
entry["snippet"] = text
|
|
94
|
+
else:
|
|
95
|
+
if not entry["title"]:
|
|
96
|
+
entry["title"] = text
|
|
97
|
+
|
|
98
|
+
for entry in pending.values():
|
|
99
|
+
results.append(entry)
|
|
100
|
+
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def search(query: str, count: int = DEFAULT_COUNT, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> dict:
|
|
105
|
+
count = max(1, min(count, MAX_COUNT))
|
|
106
|
+
all_results: list[dict] = []
|
|
107
|
+
seen_urls: set[str] = set()
|
|
108
|
+
start = FIRST_PAGE_START
|
|
109
|
+
# 네이버 검색이 페이지당 정확히 RESULTS_PER_PAGE개를 반환하지 않을 수 있으므로 여유 페이지 확보
|
|
110
|
+
max_pages = (count // RESULTS_PER_PAGE) + 3
|
|
111
|
+
|
|
112
|
+
for page_num in range(max_pages):
|
|
113
|
+
if len(all_results) >= count:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
if page_num > 0:
|
|
117
|
+
time.sleep(0.5)
|
|
118
|
+
|
|
119
|
+
html = fetch_search_page(query, start=start, sort=sort, timeout=timeout, insecure=insecure)
|
|
120
|
+
page_results = parse_search_results(html)[:RESULTS_PER_PAGE]
|
|
121
|
+
|
|
122
|
+
if not page_results:
|
|
123
|
+
if start == 1:
|
|
124
|
+
print("[warn] 검색 결과 파싱 실패. 네이버 HTML 구조가 변경되었을 수 있습니다.", file=sys.stderr)
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
new_count = 0
|
|
128
|
+
for result in page_results:
|
|
129
|
+
if result["url"] not in seen_urls:
|
|
130
|
+
seen_urls.add(result["url"])
|
|
131
|
+
all_results.append(result)
|
|
132
|
+
new_count += 1
|
|
133
|
+
if len(all_results) >= count:
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
if new_count == 0:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
start += RESULTS_PER_PAGE
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"query": query,
|
|
143
|
+
"total_results": len(all_results),
|
|
144
|
+
"results": all_results,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
149
|
+
parser = argparse.ArgumentParser(
|
|
150
|
+
description="Search Naver blogs and return structured JSON results."
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument("query", help="Search query string.")
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"--count", type=int, default=DEFAULT_COUNT,
|
|
155
|
+
help=f"Number of results to return (max {MAX_COUNT}, default {DEFAULT_COUNT}).",
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--sort", choices=["sim", "date"], default="sim",
|
|
159
|
+
help="Sort order: sim (relevance) or date (newest first). Default: sim.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--timeout", type=int, default=15,
|
|
163
|
+
help="HTTP request timeout in seconds. Default: 15.",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--insecure", action="store_true",
|
|
167
|
+
help="Skip SSL certificate verification (use only when certificate errors occur).",
|
|
168
|
+
)
|
|
169
|
+
return parser.parse_args(argv)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
args = parse_args(argv or sys.argv[1:])
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
result = search(
|
|
177
|
+
args.query,
|
|
178
|
+
count=args.count,
|
|
179
|
+
sort=args.sort,
|
|
180
|
+
timeout=args.timeout,
|
|
181
|
+
insecure=args.insecure,
|
|
182
|
+
)
|
|
183
|
+
except RuntimeError as error:
|
|
184
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "naver-blog-research",
|
|
3
|
+
"description": "Search Naver blogs, read full post content, and download images using only python3 stdlib — no API key required.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"lookup"
|
|
6
|
+
],
|
|
7
|
+
"frontmatter": "name: naver-blog-research\ndescription: Search Naver blogs, read full post content, and download images using only python3 stdlib — no API key required.\nlicense: MIT\nmetadata:\n category: research\n locale: ko-KR\n phase: v1"
|
|
8
|
+
}
|