Files
nomadamas__k-skill/naver-blog-research/scripts/naver_read.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

257 lines
7.7 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from html import unescape
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _naver_http import TAG_RE, is_naver_url, urlopen
MOBILE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
)
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": MOBILE_UA,
}
BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
BLOCK_END_RE = re.compile(r"</(p|div|li)>", re.IGNORECASE)
WHITESPACE_RE = re.compile(r"[ \t]+")
BLANK_LINES_RE = re.compile(r"\n{3,}")
_IMG_CDN_HOSTS = r"(?:blogfiles\.naver\.net|postfiles\.pstatic\.net|mblogthumb-phinf\.pstatic\.net)"
IMAGE_LAZY_PATTERN = re.compile(
rf'data-lazy-src="(https?://{_IMG_CDN_HOSTS}[^"]+)"'
)
IMAGE_SRC_PATTERN = re.compile(
rf'src="(https?://{_IMG_CDN_HOSTS}[^"]+)"'
)
IMAGE_ALT_PATTERN = re.compile(
r'alt="([^"]*)"'
)
TITLE_PATTERN = re.compile(
r'<title[^>]*>(.*?)</title>', re.DOTALL | re.IGNORECASE
)
SCRIPT_STYLE_RE = re.compile(r"<(script|style|noscript)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE)
PC_BLOG_RE = re.compile(r"^https?://blog\.naver\.com/")
BLOG_ID_RE = re.compile(r"blog\.naver\.com/([a-zA-Z0-9_]+)/(\d+)")
def to_mobile_url(url: str) -> str:
url = url.strip()
url = PC_BLOG_RE.sub("https://m.blog.naver.com/", url)
if not url.startswith("https://m.blog.naver.com/"):
match = BLOG_ID_RE.search(url)
if match:
url = f"https://m.blog.naver.com/{match.group(1)}/{match.group(2)}"
return url
def fetch_blog_page(url: str, timeout: int = 20, *, insecure: bool = False) -> str:
mobile_url = to_mobile_url(url)
if not is_naver_url(mobile_url):
raise ValueError(f"Not a Naver blog URL: {url}")
request = urllib.request.Request(mobile_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 blog returned HTTP {error.code} for {mobile_url}. "
"The post may not exist or access may be restricted."
) from error
def extract_title(html: str) -> str:
match = TITLE_PATTERN.search(html)
if not match:
return ""
title = unescape(TAG_RE.sub("", match.group(1))).strip()
title = re.sub(r"\s*[-:|]?\s*네이버\s*블로그$", "", title).strip()
return title
def _extract_div_block(html: str, start_pos: int) -> str:
tag_start = html.rfind("<div", 0, start_pos)
if tag_start < 0:
tag_start = start_pos
depth = 0
pos = tag_start
started = False
length = len(html)
while pos < length:
# HTML 주석 건너뛰기
if html[pos : pos + 4] == "<!--":
end = html.find("-->", pos + 4)
pos = end + 3 if end >= 0 else length
continue
if html[pos : pos + 4] == "<div" and (pos + 4 >= length or html[pos + 4] in (" ", ">", "\t", "\n", "/")):
depth += 1
started = True
elif html[pos : pos + 6] == "</div>":
depth -= 1
if started and depth == 0:
return html[tag_start : pos + 6]
pos += 1
return html[tag_start:]
def extract_content_area(html: str) -> str:
cleaned = SCRIPT_STYLE_RE.sub("", html)
match = re.search(r'class="[^"]*\bse-main-container\b[^"]*"', cleaned)
if match:
return _extract_div_block(cleaned, match.start())
for class_name in ("post_ct", "postViewArea", "post-view"):
match = re.search(rf'class="[^"]*\b{re.escape(class_name)}\b[^"]*"', cleaned)
if match:
return _extract_div_block(cleaned, match.start())
marker = cleaned.find('id="viewTypeSelector"')
if marker >= 0:
return _extract_div_block(cleaned, marker)
return ""
def extract_text(html_fragment: str) -> str:
text = BR_RE.sub("\n", html_fragment)
text = BLOCK_END_RE.sub("\n", text)
text = TAG_RE.sub("", text)
text = unescape(text)
lines = []
for line in text.split("\n"):
stripped = WHITESPACE_RE.sub(" ", line).strip()
if stripped:
lines.append(stripped)
result = "\n".join(lines)
result = BLANK_LINES_RE.sub("\n\n", result)
return result.strip()
def extract_images(html_fragment: str) -> list[dict]:
images: list[dict] = []
seen_base: set[str] = set()
img_tags = re.finditer(r"<img\s[^>]+>", html_fragment, re.IGNORECASE)
for img_match in img_tags:
img_tag = img_match.group(0)
lazy_match = IMAGE_LAZY_PATTERN.search(img_tag)
src_match = IMAGE_SRC_PATTERN.search(img_tag)
url_match = lazy_match or src_match
if not url_match:
continue
url = url_match.group(1)
base_url = re.sub(r"\?type=.*$", "", url)
if base_url in seen_base:
continue
seen_base.add(base_url)
if "?type=" not in url:
url = base_url
elif "_blur" in url:
url = re.sub(r"\?type=w\d+_blur", "?type=w800", url)
alt_match = IMAGE_ALT_PATTERN.search(img_tag)
alt = unescape(alt_match.group(1)).strip() if alt_match else ""
images.append({"url": url, "alt": alt})
return images
def read_blog(url: str, include_images: bool = True, max_length: int = 0, timeout: int = 20, *, insecure: bool = False) -> dict:
html = fetch_blog_page(url, timeout=timeout, insecure=insecure)
mobile_url = to_mobile_url(url)
title = extract_title(html)
content_area = extract_content_area(html)
content = extract_text(content_area)
if max_length > 0 and len(content) > max_length:
content = content[:max_length] + "..."
result: dict = {
"url": mobile_url,
"title": title,
"content": content,
"char_count": len(content),
}
if not content:
result["warning"] = "본문 영역을 찾지 못했습니다. 네이버 HTML 구조가 변경되었을 수 있습니다."
if include_images:
result["images"] = extract_images(content_area)
return result
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Read a Naver blog post and extract text content and images."
)
parser.add_argument("url", help="Naver blog post URL (PC or mobile).")
parser.add_argument(
"--no-images", action="store_true",
help="Exclude image URLs from output.",
)
parser.add_argument(
"--max-length", type=int, default=0,
help="Maximum content length in characters (0 = unlimited). Default: 0.",
)
parser.add_argument(
"--timeout", type=int, default=20,
help="HTTP request timeout in seconds. Default: 20.",
)
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 = read_blog(
args.url,
include_images=not args.no_images,
max_length=args.max_length,
timeout=args.timeout,
insecure=args.insecure,
)
except (RuntimeError, ValueError) 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())