Files
nomadamas__k-skill/naver-blog-research/scripts/naver_search.py
owen 4f015c5680 feat: 네이버 블로그 리서치 스킬 추가 (#107)
* feat: 네이버 블로그 리서치 스킬 추가

API 키 없이 python3 표준 라이브러리만으로 네이버 블로그 검색, 원문 읽기, 이미지 다운로드를 수행하는 스킬.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Restore Naver blog search paging and newest-sort behavior

Naver's current blog search surface does not honor the older
where=blog + sort query pattern used by this skill. The request
now targets the blog tab surface, uses the observed NSO sort
controls, and trims each parsed page to the visible 15-result
window so count-based pagination returns distinct results.

Constraint: Must keep using stdlib-only HTTP scraping without adding dependencies
Constraint: Current Naver blog tab behavior requires ssc/tab parameters plus nso sort controls
Rejected: Keep where=blog and tune start values only | still returned repeated first-page results
Rejected: Leave sort=date as-is | current endpoint ignored it and returned relevance ordering
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Re-verify request params against live Naver markup before changing paging or sort semantics again
Tested: python3 -m py_compile on naver-blog-research scripts and new regression test; PYTHONPATH=.:scripts python3 -m unittest scripts.test_naver_blog_search; npm run lint; live naver_search.py --count 20/30 --sort sim; live naver_search.py --count 10/20 --sort date
Not-tested: Full npm run test remains blocked by unrelated local pyexpat/libexpat environment failures in patent-search tests

* Surface the new Naver blog skill in the main README

PR 107 adds the skill and feature guide, but the repository landing page
still omitted it from the user-facing capability list. This commit keeps the
README aligned with the actual shipped skill set so users can discover the
new entry point from the main docs.

Constraint: README capability tables and feature lists should stay aligned with docs/features entries
Rejected: Leave README unchanged until merge | hides the new skill from the main index during PR review
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When adding a new skill guide, update both the summary table and the included-features list together
Tested: README diff review; verified docs/features/naver-blog-research.md link target exists
Not-tested: Full npm run ci (docs-only change)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-04-13 00:06:18 +09:00

193 lines
5.9 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request
from html import unescape
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _naver_http import TAG_RE, urlopen
SEARCH_URL = "https://search.naver.com/search.naver"
DEFAULT_COUNT = 10
MAX_COUNT = 30
FIRST_PAGE_START = 1
RESULTS_PER_PAGE = 15
DEFAULT_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
),
}
BLOG_ANCHOR_PATTERN = re.compile(
r'<a[^>]*href="(https?://blog\.naver\.com/([a-zA-Z0-9_]+)/(\d+))"[^>]*>(.*?)</a>',
re.DOTALL,
)
def strip_html(text: str) -> str:
return unescape(TAG_RE.sub("", text)).strip()
def build_search_params(query: str, start: int = FIRST_PAGE_START, sort: str = "sim") -> dict[str, str]:
return {
"query": query,
"ssc": "tab.blog.all",
"sm": "tab_jum" if start <= FIRST_PAGE_START else "tab_pge",
"start": str(start),
"nso": {"sim": "so:r,p:all,a:all", "date": "so:dd,p:all,a:all"}.get(sort, "so:r,p:all,a:all"),
}
def fetch_search_page(query: str, start: int = 1, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> str:
params = build_search_params(query, start=start, sort=sort)
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(url, headers=DEFAULT_HEADERS)
try:
with urlopen(request, timeout, insecure=insecure) as response:
return response.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as error:
raise RuntimeError(
f"Naver search returned HTTP {error.code}. "
"The request may have been blocked. Retry later or reduce request volume."
) from error
def parse_search_results(html: str) -> list[dict]:
results: list[dict] = []
anchors = BLOG_ANCHOR_PATTERN.findall(html)
pending: dict[str, dict] = {}
for full_url, user_id, post_id, inner_html in anchors:
if full_url not in pending:
pending[full_url] = {
"url": full_url,
"mobile_url": f"https://m.blog.naver.com/{user_id}/{post_id}",
"author": user_id,
"title": "",
"snippet": "",
}
text = strip_html(inner_html)
if not text:
continue
entry = pending[full_url]
if "headline1" in inner_html or "text-type-headline" in inner_html:
if not entry["title"]:
entry["title"] = text
elif "body1" in inner_html or "text-type-body" in inner_html:
if not entry["snippet"]:
entry["snippet"] = text
else:
if not entry["title"]:
entry["title"] = text
for entry in pending.values():
results.append(entry)
return results
def search(query: str, count: int = DEFAULT_COUNT, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> dict:
count = max(1, min(count, MAX_COUNT))
all_results: list[dict] = []
seen_urls: set[str] = set()
start = FIRST_PAGE_START
# 네이버 검색이 페이지당 정확히 RESULTS_PER_PAGE개를 반환하지 않을 수 있으므로 여유 페이지 확보
max_pages = (count // RESULTS_PER_PAGE) + 3
for page_num in range(max_pages):
if len(all_results) >= count:
break
if page_num > 0:
time.sleep(0.5)
html = fetch_search_page(query, start=start, sort=sort, timeout=timeout, insecure=insecure)
page_results = parse_search_results(html)[:RESULTS_PER_PAGE]
if not page_results:
if start == 1:
print("[warn] 검색 결과 파싱 실패. 네이버 HTML 구조가 변경되었을 수 있습니다.", file=sys.stderr)
break
new_count = 0
for result in page_results:
if result["url"] not in seen_urls:
seen_urls.add(result["url"])
all_results.append(result)
new_count += 1
if len(all_results) >= count:
break
if new_count == 0:
break
start += RESULTS_PER_PAGE
return {
"query": query,
"total_results": len(all_results),
"results": all_results,
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Search Naver blogs and return structured JSON results."
)
parser.add_argument("query", help="Search query string.")
parser.add_argument(
"--count", type=int, default=DEFAULT_COUNT,
help=f"Number of results to return (max {MAX_COUNT}, default {DEFAULT_COUNT}).",
)
parser.add_argument(
"--sort", choices=["sim", "date"], default="sim",
help="Sort order: sim (relevance) or date (newest first). Default: sim.",
)
parser.add_argument(
"--timeout", type=int, default=15,
help="HTTP request timeout in seconds. Default: 15.",
)
parser.add_argument(
"--insecure", action="store_true",
help="Skip SSL certificate verification (use only when certificate errors occur).",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
try:
result = search(
args.query,
count=args.count,
sort=args.sort,
timeout=args.timeout,
insecure=args.insecure,
)
except RuntimeError as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())