mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Add a repeatable GeekNews lookup path without unofficial APIs
Issue #108 needs a dedicated read-only skill that can browse, search, and inspect GeekNews posts using the public feed alone. The implementation adds a fixture-backed Atom helper, skill/docs surfaces, and install/README wiring, then verifies the helper against the live GeekNews feed. Constraint: Must stay RSS-first and avoid new dependencies or unofficial APIs Constraint: Skill development requires syncing the skill into ~/.claude/skills and ~/.agents/skills during verification Rejected: Fetch article pages directly for v1 | expands scope beyond the approved RSS-driven workflow Rejected: Use XML parser modules | current python3 environment has expat issues, so regex + HTML parsing is safer here Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the root helper and geeknews-search/scripts copy behaviorally identical because the installed skill must remain self-contained Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_geeknews_search; node --test scripts/skill-docs.test.js; python3 scripts/geeknews_search.py list --limit 3; python3 scripts/geeknews_search.py search --query Claude --limit 3; python3 scripts/geeknews_search.py detail --id 28439; npm run ci Not-tested: Non-default feed mirrors or future Atom schema changes beyond the current public GeekNews feed shape Related: Issue #108
This commit is contained in:
parent
d3d048e822
commit
43e8625986
12 changed files with 950 additions and 2 deletions
|
|
@ -25,6 +25,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 카카오톡 Mac CLI | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
|
||||
| 서울 지하철 도착정보 조회 | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 지하철 분실물 조회 | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
|
||||
| 긱뉴스 조회 | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
|
||||
| 한국 날씨 조회 | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
|
||||
| 사용자 위치 미세먼지 조회 | 현재 위치 또는 지역 기준 PM10/PM2.5 미세먼지 조회 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
|
||||
| 한강 수위 정보 조회 | 한강 관측소 기준 현재 수위·유량·기준수위 확인 | 불필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
|
||||
|
|
@ -93,6 +94,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
|
||||
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
|
||||
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
|
||||
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
|
||||
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
|
||||
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
|
||||
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
|
||||
|
|
|
|||
68
docs/features/geeknews-search.md
Normal file
68
docs/features/geeknews-search.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# 긱뉴스 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- GeekNews 공개 RSS/Atom 피드에서 최신 글 목록 조회
|
||||
- 제목/요약/작성자 기준 키워드 검색
|
||||
- 특정 항목의 RSS 기반 요약/링크/작성자/게시 시각 확인
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 완료
|
||||
- `python3` 사용 가능 환경
|
||||
- 인터넷 연결
|
||||
|
||||
## v1 범위
|
||||
|
||||
이 기능은 **RSS-first / 읽기 전용** 범위로 제공된다.
|
||||
|
||||
- 공개 피드(`https://feeds.feedburner.com/geeknews-feed`)만 사용한다.
|
||||
- 최신 글/검색/상세 조회까지만 다룬다.
|
||||
- 댓글, 투표, 로그인, 개인화 상태는 다루지 않는다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 최신 글을 훑을 때는 목록 조회부터 실행한다.
|
||||
2. 원하는 주제가 있으면 제목/요약/작성자 기준 검색으로 좁힌다.
|
||||
3. 특정 글을 확인할 때는 링크/id/토픽 번호 일부로 상세 조회한다.
|
||||
|
||||
## 예시
|
||||
|
||||
최신 글 목록:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py list --limit 5
|
||||
```
|
||||
|
||||
검색:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py search --query Claude --limit 5
|
||||
```
|
||||
|
||||
상세:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py detail --id 28439
|
||||
```
|
||||
|
||||
오프라인 fixture 또는 저장된 feed로 검증할 때:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py list \
|
||||
--feed-file scripts/fixtures/geeknews-feed.xml \
|
||||
--limit 3
|
||||
```
|
||||
|
||||
## 출력에서 확인할 점
|
||||
|
||||
- `source.feed_url` 이 GeekNews RSS feed를 가리키는지
|
||||
- `items[].title`, `items[].link`, `items[].author_name`, `items[].summary` 가 함께 내려오는지
|
||||
- 상세 조회에서 `item.content_html` 과 `item.summary` 가 모두 포함되는지
|
||||
- 검색 결과가 제목/요약/작성자 기준으로 보수적으로 매칭되는지
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- RSS 피드 기반이라 원문 전체/댓글/메타데이터는 제한적일 수 있다.
|
||||
- FeedBurner 응답이 느리거나 실패하면 재시도하거나 직접 링크를 여는 fallback이 필요하다.
|
||||
- 상세 조회는 feed에 포함된 `content` 범위까지만 보장한다.
|
||||
|
|
@ -65,6 +65,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill fine-dust-location \
|
||||
--skill han-river-water-level \
|
||||
--skill subway-lost-property \
|
||||
--skill geeknews-search \
|
||||
--skill daiso-product-search \
|
||||
--skill market-kurly-search \
|
||||
--skill olive-young-search \
|
||||
|
|
@ -97,6 +98,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill hipass-receipt \
|
||||
--skill seoul-subway-arrival \
|
||||
--skill subway-lost-property \
|
||||
--skill geeknews-search \
|
||||
--skill korea-weather \
|
||||
--skill fine-dust-location
|
||||
```
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
- 중고차 가격 조회 스킬 출시
|
||||
- 한국어 맞춤법 검사 스킬 출시
|
||||
- 한국어 글자 수 세기 스킬 출시
|
||||
- 긱뉴스 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@
|
|||
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
|
||||
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
|
||||
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
|
||||
- GeekNews public RSS/Atom feed: https://feeds.feedburner.com/geeknews-feed
|
||||
- GeekNews home: https://news.hada.io
|
||||
- 기상청 단기예보 조회서비스: https://www.data.go.kr/data/15084084/openapi.do
|
||||
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
|
||||
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do
|
||||
|
|
|
|||
79
geeknews-search/SKILL.md
Normal file
79
geeknews-search/SKILL.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
name: geeknews-search
|
||||
description: GeekNews public RSS/Atom feed로 긱뉴스 게시물을 조회, 검색, 상세 확인하는 읽기 전용 스킬.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: news
|
||||
locale: ko-KR
|
||||
source: geeknews-rss
|
||||
---
|
||||
|
||||
# GeekNews Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
GeekNews 공개 RSS/Atom 피드(`https://feeds.feedburner.com/geeknews-feed`)를 사용해 최신 글을 읽기 전용으로 조회한다.
|
||||
|
||||
- 최신 글 목록 조회
|
||||
- 제목/요약/작성자 기준 검색
|
||||
- 항목 id/link 기준 상세 확인
|
||||
|
||||
## When to use
|
||||
|
||||
- "긱뉴스 오늘 뭐 올라왔어?"
|
||||
- "긱뉴스에서 Claude 관련 글 찾아줘"
|
||||
- "이 GeekNews 글 요약/링크 확인해줘"
|
||||
|
||||
## Inputs
|
||||
|
||||
- 기본: 별도 인증 없이 public feed만 사용
|
||||
- 목록 조회: `limit`
|
||||
- 검색: `query`, 선택 `limit`
|
||||
- 상세 조회: `id` 또는 링크/토픽 번호 일부
|
||||
|
||||
## Official surface
|
||||
|
||||
- GeekNews RSS/Atom feed: `https://feeds.feedburner.com/geeknews-feed`
|
||||
- GeekNews home: `https://news.hada.io`
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1) List recent entries
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py list --limit 10
|
||||
```
|
||||
|
||||
### 2) Search the feed conservatively
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py search --query Claude --limit 5
|
||||
```
|
||||
|
||||
검색은 제목, 요약, 작성자, 링크/id 기준으로만 동작한다.
|
||||
|
||||
### 3) Inspect a specific item
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py detail --id 28439
|
||||
```
|
||||
|
||||
상세 조회는 RSS 피드에 포함된 `content`/요약과 원문 링크를 함께 돌려준다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 최신 GeekNews 글 목록을 바로 보여줄 수 있다.
|
||||
- 키워드 검색 결과에서 제목/링크/작성자/요약을 정리할 수 있다.
|
||||
- 특정 항목의 RSS 기반 내용을 보수적으로 확인하고 원문 링크를 함께 제시할 수 있다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- FeedBurner/GeekNews feed가 일시적으로 응답하지 않을 수 있다.
|
||||
- RSS 피드가 제공하는 범위를 넘는 전체 본문/댓글/투표 정보는 포함되지 않는다.
|
||||
- HTML 요약은 feed 원문 기준이라 일부가 잘릴 수 있다.
|
||||
|
||||
## Notes
|
||||
|
||||
- v1은 RSS-first, read-only 범위다.
|
||||
- 비공식 API나 로그인 세션에 의존하지 않는다.
|
||||
- 테스트/오프라인 검증 시 `--feed-file` 로 저장된 Atom XML을 넣을 수 있다.
|
||||
296
geeknews-search/scripts/geeknews_search.py
Executable file
296
geeknews-search/scripts/geeknews_search.py
Executable file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
GEEKNEWS_FEED_URL = "https://feeds.feedburner.com/geeknews-feed"
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(part.strip() for part in self.parts if part.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
link: str
|
||||
published: str | None
|
||||
updated: str | None
|
||||
author_name: str | None
|
||||
author_url: str | None
|
||||
summary: str
|
||||
content_html: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsFeed:
|
||||
title: str
|
||||
source_id: str | None
|
||||
updated: str | None
|
||||
home_url: str | None
|
||||
feed_url: str | None
|
||||
category: str | None
|
||||
items: list[GeekNewsItem]
|
||||
|
||||
def source_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"title": self.title,
|
||||
"id": self.source_id,
|
||||
"updated": self.updated,
|
||||
"home_url": self.home_url,
|
||||
"feed_url": self.feed_url,
|
||||
"category": self.category,
|
||||
}
|
||||
|
||||
|
||||
def _strip_cdata(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("<![CDATA[") and stripped.endswith("]]>"):
|
||||
return stripped[9:-3]
|
||||
return stripped
|
||||
|
||||
|
||||
def _collapse_whitespace(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _clean_xml_text(value: str | None) -> str:
|
||||
return _collapse_whitespace(unescape(_strip_cdata(value)))
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return _collapse_whitespace(unescape(parser.text()))
|
||||
|
||||
|
||||
def _first_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _clean_xml_text(match.group(1))
|
||||
|
||||
|
||||
def _first_raw_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _strip_cdata(match.group(1)).strip()
|
||||
|
||||
|
||||
def _first_link_href(block: str) -> str | None:
|
||||
patterns = (
|
||||
r"<link\b[^>]*rel=['\"]alternate['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
r"<link\b[^>]*href=['\"]([^'\"]+)['\"]",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, block)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return None
|
||||
|
||||
|
||||
def _link_href(block: str, *, rel: str | None = None) -> str | None:
|
||||
if rel:
|
||||
match = re.search(
|
||||
rf"<link\b[^>]*(?:rel|ref)=['\"]{re.escape(rel)}['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
block,
|
||||
)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return _first_link_href(block)
|
||||
|
||||
|
||||
def _feed_prefix(xml_text: str) -> str:
|
||||
if "<entry" not in xml_text:
|
||||
return xml_text
|
||||
return xml_text.split("<entry", 1)[0]
|
||||
|
||||
|
||||
def _entry_blocks(xml_text: str) -> list[str]:
|
||||
return re.findall(r"<entry\b[^>]*>(.*?)</entry>", xml_text, re.DOTALL)
|
||||
|
||||
|
||||
def _validate_limit(limit: int) -> int:
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be positive")
|
||||
return limit
|
||||
|
||||
|
||||
def load_feed(xml_text: str) -> GeekNewsFeed:
|
||||
prefix = _feed_prefix(xml_text)
|
||||
items = []
|
||||
for entry in _entry_blocks(xml_text):
|
||||
author_block_match = re.search(r"<author\b[^>]*>(.*?)</author>", entry, re.DOTALL)
|
||||
author_block = author_block_match.group(1) if author_block_match else ""
|
||||
content_html = (_first_raw_tag(entry, "content") or "").strip()
|
||||
items.append(
|
||||
GeekNewsItem(
|
||||
id=_first_tag(entry, "id") or "",
|
||||
title=_first_tag(entry, "title") or "",
|
||||
link=_first_link_href(entry) or (_first_tag(entry, "id") or ""),
|
||||
published=_first_tag(entry, "published") or _first_tag(entry, "updated"),
|
||||
updated=_first_tag(entry, "updated"),
|
||||
author_name=_first_tag(author_block, "name"),
|
||||
author_url=_first_tag(author_block, "uri"),
|
||||
summary=_html_to_text(content_html),
|
||||
content_html=content_html,
|
||||
)
|
||||
)
|
||||
|
||||
category_match = re.search(r"<category\b[^>]*term=['\"]([^'\"]+)['\"]", prefix)
|
||||
return GeekNewsFeed(
|
||||
title=_first_tag(prefix, "title") or "GeekNews",
|
||||
source_id=_first_tag(prefix, "id"),
|
||||
updated=_first_tag(prefix, "updated"),
|
||||
home_url=_link_href(prefix, rel="alternate"),
|
||||
feed_url=_link_href(prefix, rel="self") or _first_tag(prefix, "id"),
|
||||
category=category_match.group(1) if category_match else None,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
def list_items(feed: GeekNewsFeed, limit: int = 10) -> list[GeekNewsItem]:
|
||||
return feed.items[:_validate_limit(limit)]
|
||||
|
||||
|
||||
def search_items(feed: GeekNewsFeed, query: str, limit: int = 10) -> list[GeekNewsItem]:
|
||||
if not query.strip():
|
||||
raise ValueError("query is required")
|
||||
limit = _validate_limit(limit)
|
||||
needle = query.casefold()
|
||||
matches = []
|
||||
for item in feed.items:
|
||||
haystack = "\n".join(
|
||||
part
|
||||
for part in (
|
||||
item.title,
|
||||
item.summary,
|
||||
item.author_name or "",
|
||||
item.author_url or "",
|
||||
item.id,
|
||||
item.link,
|
||||
)
|
||||
if part
|
||||
).casefold()
|
||||
if needle in haystack:
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
return matches
|
||||
|
||||
|
||||
def get_item_detail(feed: GeekNewsFeed, lookup: str) -> GeekNewsItem:
|
||||
normalized_lookup = lookup.strip().casefold()
|
||||
if not normalized_lookup:
|
||||
raise ValueError("lookup is required")
|
||||
for item in feed.items:
|
||||
candidates = [item.id, item.link, item.title]
|
||||
lowered = [candidate.casefold() for candidate in candidates if candidate]
|
||||
if normalized_lookup in lowered or any(normalized_lookup in candidate for candidate in lowered):
|
||||
return item
|
||||
raise LookupError(f"No GeekNews entry matched: {lookup}")
|
||||
|
||||
|
||||
def _serialize_items(items: list[GeekNewsItem]) -> list[dict[str, object]]:
|
||||
return [item.to_dict() for item in items]
|
||||
|
||||
|
||||
def build_list_payload(feed: GeekNewsFeed, limit: int = 10) -> dict[str, object]:
|
||||
items = list_items(feed, limit=limit)
|
||||
return {"source": feed.source_dict(), "count": len(items), "items": _serialize_items(items)}
|
||||
|
||||
|
||||
def build_search_payload(feed: GeekNewsFeed, query: str, limit: int = 10) -> dict[str, object]:
|
||||
items = search_items(feed, query=query, limit=limit)
|
||||
return {
|
||||
"source": feed.source_dict(),
|
||||
"query": query,
|
||||
"count": len(items),
|
||||
"items": _serialize_items(items),
|
||||
}
|
||||
|
||||
|
||||
def build_detail_payload(feed: GeekNewsFeed, lookup: str) -> dict[str, object]:
|
||||
item = get_item_detail(feed, lookup)
|
||||
return {"source": feed.source_dict(), "item": item.to_dict()}
|
||||
|
||||
|
||||
def fetch_feed(url: str = GEEKNEWS_FEED_URL, timeout: int = 20) -> str:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "k-skill-geeknews/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
charset = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(charset, errors="replace")
|
||||
|
||||
|
||||
def _add_feed_source_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--feed-url", default=GEEKNEWS_FEED_URL, help="기본값: GeekNews public feed URL")
|
||||
parser.add_argument("--feed-file", help="테스트/오프라인 검증용 로컬 Atom XML 파일")
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Read GeekNews entries from the public RSS/Atom feed.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="최신 GeekNews 항목 목록")
|
||||
_add_feed_source_args(list_parser)
|
||||
list_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="제목/요약/작성자 기준 검색")
|
||||
_add_feed_source_args(search_parser)
|
||||
search_parser.add_argument("--query", required=True)
|
||||
search_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
detail_parser = subparsers.add_parser("detail", help="항목 상세 확인")
|
||||
_add_feed_source_args(detail_parser)
|
||||
detail_parser.add_argument("--id", required=True, help="entry id/link/topic id 일부")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _load_feed_text(args: argparse.Namespace) -> str:
|
||||
if args.feed_file:
|
||||
return Path(args.feed_file).read_text(encoding="utf-8")
|
||||
return fetch_feed(url=args.feed_url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = parse_args(argv)
|
||||
feed = load_feed(_load_feed_text(args))
|
||||
|
||||
if args.command == "list":
|
||||
payload = build_list_payload(feed, limit=args.limit)
|
||||
elif args.command == "search":
|
||||
payload = build_search_payload(feed, query=args.query, limit=args.limit)
|
||||
else:
|
||||
payload = build_detail_payload(feed, lookup=args.id)
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
45
scripts/fixtures/geeknews-feed.xml
Normal file
45
scripts/fixtures/geeknews-feed.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<feed xmlns='http://www.w3.org/2005/Atom'>
|
||||
<title>GeekNews - 개발/기술/스타트업 뉴스 서비스</title>
|
||||
<subtitle>개발 뉴스, 기술 관련 새소식, 스타트업 정보와 노하우</subtitle>
|
||||
<link ref='alternate' type='text/html' href='https://news.hada.io' />
|
||||
<link ref='self' type='application/atom+xml' href='https://news.hada.io/rss/news' />
|
||||
<id>https://news.hada.io/rss/news</id>
|
||||
<updated>2026-04-12T22:53:56+09:00</updated>
|
||||
<entry>
|
||||
<title><![CDATA[Ask GN: 기억이 안나는 웹사이트를 찾고 있습니다.]]></title>
|
||||
<link rel='alternate' type='text/html' href='https://news.hada.io/topic?id=28441' />
|
||||
<id>https://news.hada.io/topic?id=28441</id>
|
||||
<updated>2026-04-12T22:53:56+09:00</updated>
|
||||
<published>2026-04-12T22:53:56+09:00</published>
|
||||
<author>
|
||||
<name>princox</name>
|
||||
<uri>https://news.hada.io/user/princox</uri>
|
||||
</author>
|
||||
<content type='html' xml:lang='ko'><![CDATA[<p>예전에 아내와 함께 인상깊게 봤던 웹 사이트가 있었는데 기억이 안납니다.</p><p>교육용 시각화 사이트였습니다.</p>]]></content>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[AI 에이전트 벤치마크를 무너뜨린 방법과 그 다음 단계]]></title>
|
||||
<link rel='alternate' type='text/html' href='https://news.hada.io/topic?id=28440' />
|
||||
<id>https://news.hada.io/topic?id=28440</id>
|
||||
<updated>2026-04-12T21:32:54+09:00</updated>
|
||||
<published>2026-04-12T21:32:54+09:00</published>
|
||||
<author>
|
||||
<name>neo</name>
|
||||
<uri>https://news.hada.io/user/neo</uri>
|
||||
</author>
|
||||
<content type='html' xml:lang='ko'><![CDATA[<ul><li>주요 AI agent benchmark 8종이 실제 문제 해결 없이도 최고 점수를 얻을 수 있는 구조적 취약점을 가진 것으로 드러남</li></ul>]]></content>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Show GN: [GN] 비개발자 + Claude로 프로덕션 운영 238일 — 무엇이 됐고 무엇이 안 됐나?]]></title>
|
||||
<link rel='alternate' type='text/html' href='https://news.hada.io/topic?id=28439' />
|
||||
<id>https://news.hada.io/topic?id=28439</id>
|
||||
<updated>2026-04-12T20:53:05+09:00</updated>
|
||||
<published>2026-04-12T20:53:05+09:00</published>
|
||||
<author>
|
||||
<name>workdriver</name>
|
||||
<uri>https://news.hada.io/user/workdriver</uri>
|
||||
</author>
|
||||
<content type='html' xml:lang='ko'><![CDATA[<p>코드를 한 줄도 칠 줄 모르는 상태에서 2025년 8월 16일 Claude와 함께 첫 커밋을 찍었습니다.</p>]]></content>
|
||||
</entry>
|
||||
</feed>
|
||||
296
scripts/geeknews_search.py
Executable file
296
scripts/geeknews_search.py
Executable file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
GEEKNEWS_FEED_URL = "https://feeds.feedburner.com/geeknews-feed"
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(part.strip() for part in self.parts if part.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
link: str
|
||||
published: str | None
|
||||
updated: str | None
|
||||
author_name: str | None
|
||||
author_url: str | None
|
||||
summary: str
|
||||
content_html: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsFeed:
|
||||
title: str
|
||||
source_id: str | None
|
||||
updated: str | None
|
||||
home_url: str | None
|
||||
feed_url: str | None
|
||||
category: str | None
|
||||
items: list[GeekNewsItem]
|
||||
|
||||
def source_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"title": self.title,
|
||||
"id": self.source_id,
|
||||
"updated": self.updated,
|
||||
"home_url": self.home_url,
|
||||
"feed_url": self.feed_url,
|
||||
"category": self.category,
|
||||
}
|
||||
|
||||
|
||||
def _strip_cdata(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("<![CDATA[") and stripped.endswith("]]>"):
|
||||
return stripped[9:-3]
|
||||
return stripped
|
||||
|
||||
|
||||
def _collapse_whitespace(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _clean_xml_text(value: str | None) -> str:
|
||||
return _collapse_whitespace(unescape(_strip_cdata(value)))
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return _collapse_whitespace(unescape(parser.text()))
|
||||
|
||||
|
||||
def _first_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _clean_xml_text(match.group(1))
|
||||
|
||||
|
||||
def _first_raw_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _strip_cdata(match.group(1)).strip()
|
||||
|
||||
|
||||
def _first_link_href(block: str) -> str | None:
|
||||
patterns = (
|
||||
r"<link\b[^>]*rel=['\"]alternate['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
r"<link\b[^>]*href=['\"]([^'\"]+)['\"]",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, block)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return None
|
||||
|
||||
|
||||
def _link_href(block: str, *, rel: str | None = None) -> str | None:
|
||||
if rel:
|
||||
match = re.search(
|
||||
rf"<link\b[^>]*(?:rel|ref)=['\"]{re.escape(rel)}['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
block,
|
||||
)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return _first_link_href(block)
|
||||
|
||||
|
||||
def _feed_prefix(xml_text: str) -> str:
|
||||
if "<entry" not in xml_text:
|
||||
return xml_text
|
||||
return xml_text.split("<entry", 1)[0]
|
||||
|
||||
|
||||
def _entry_blocks(xml_text: str) -> list[str]:
|
||||
return re.findall(r"<entry\b[^>]*>(.*?)</entry>", xml_text, re.DOTALL)
|
||||
|
||||
|
||||
def _validate_limit(limit: int) -> int:
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be positive")
|
||||
return limit
|
||||
|
||||
|
||||
def load_feed(xml_text: str) -> GeekNewsFeed:
|
||||
prefix = _feed_prefix(xml_text)
|
||||
items = []
|
||||
for entry in _entry_blocks(xml_text):
|
||||
author_block_match = re.search(r"<author\b[^>]*>(.*?)</author>", entry, re.DOTALL)
|
||||
author_block = author_block_match.group(1) if author_block_match else ""
|
||||
content_html = (_first_raw_tag(entry, "content") or "").strip()
|
||||
items.append(
|
||||
GeekNewsItem(
|
||||
id=_first_tag(entry, "id") or "",
|
||||
title=_first_tag(entry, "title") or "",
|
||||
link=_first_link_href(entry) or (_first_tag(entry, "id") or ""),
|
||||
published=_first_tag(entry, "published") or _first_tag(entry, "updated"),
|
||||
updated=_first_tag(entry, "updated"),
|
||||
author_name=_first_tag(author_block, "name"),
|
||||
author_url=_first_tag(author_block, "uri"),
|
||||
summary=_html_to_text(content_html),
|
||||
content_html=content_html,
|
||||
)
|
||||
)
|
||||
|
||||
category_match = re.search(r"<category\b[^>]*term=['\"]([^'\"]+)['\"]", prefix)
|
||||
return GeekNewsFeed(
|
||||
title=_first_tag(prefix, "title") or "GeekNews",
|
||||
source_id=_first_tag(prefix, "id"),
|
||||
updated=_first_tag(prefix, "updated"),
|
||||
home_url=_link_href(prefix, rel="alternate"),
|
||||
feed_url=_link_href(prefix, rel="self") or _first_tag(prefix, "id"),
|
||||
category=category_match.group(1) if category_match else None,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
def list_items(feed: GeekNewsFeed, limit: int = 10) -> list[GeekNewsItem]:
|
||||
return feed.items[:_validate_limit(limit)]
|
||||
|
||||
|
||||
def search_items(feed: GeekNewsFeed, query: str, limit: int = 10) -> list[GeekNewsItem]:
|
||||
if not query.strip():
|
||||
raise ValueError("query is required")
|
||||
limit = _validate_limit(limit)
|
||||
needle = query.casefold()
|
||||
matches = []
|
||||
for item in feed.items:
|
||||
haystack = "\n".join(
|
||||
part
|
||||
for part in (
|
||||
item.title,
|
||||
item.summary,
|
||||
item.author_name or "",
|
||||
item.author_url or "",
|
||||
item.id,
|
||||
item.link,
|
||||
)
|
||||
if part
|
||||
).casefold()
|
||||
if needle in haystack:
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
return matches
|
||||
|
||||
|
||||
def get_item_detail(feed: GeekNewsFeed, lookup: str) -> GeekNewsItem:
|
||||
normalized_lookup = lookup.strip().casefold()
|
||||
if not normalized_lookup:
|
||||
raise ValueError("lookup is required")
|
||||
for item in feed.items:
|
||||
candidates = [item.id, item.link, item.title]
|
||||
lowered = [candidate.casefold() for candidate in candidates if candidate]
|
||||
if normalized_lookup in lowered or any(normalized_lookup in candidate for candidate in lowered):
|
||||
return item
|
||||
raise LookupError(f"No GeekNews entry matched: {lookup}")
|
||||
|
||||
|
||||
def _serialize_items(items: list[GeekNewsItem]) -> list[dict[str, object]]:
|
||||
return [item.to_dict() for item in items]
|
||||
|
||||
|
||||
def build_list_payload(feed: GeekNewsFeed, limit: int = 10) -> dict[str, object]:
|
||||
items = list_items(feed, limit=limit)
|
||||
return {"source": feed.source_dict(), "count": len(items), "items": _serialize_items(items)}
|
||||
|
||||
|
||||
def build_search_payload(feed: GeekNewsFeed, query: str, limit: int = 10) -> dict[str, object]:
|
||||
items = search_items(feed, query=query, limit=limit)
|
||||
return {
|
||||
"source": feed.source_dict(),
|
||||
"query": query,
|
||||
"count": len(items),
|
||||
"items": _serialize_items(items),
|
||||
}
|
||||
|
||||
|
||||
def build_detail_payload(feed: GeekNewsFeed, lookup: str) -> dict[str, object]:
|
||||
item = get_item_detail(feed, lookup)
|
||||
return {"source": feed.source_dict(), "item": item.to_dict()}
|
||||
|
||||
|
||||
def fetch_feed(url: str = GEEKNEWS_FEED_URL, timeout: int = 20) -> str:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "k-skill-geeknews/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
charset = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(charset, errors="replace")
|
||||
|
||||
|
||||
def _add_feed_source_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--feed-url", default=GEEKNEWS_FEED_URL, help="기본값: GeekNews public feed URL")
|
||||
parser.add_argument("--feed-file", help="테스트/오프라인 검증용 로컬 Atom XML 파일")
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Read GeekNews entries from the public RSS/Atom feed.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="최신 GeekNews 항목 목록")
|
||||
_add_feed_source_args(list_parser)
|
||||
list_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="제목/요약/작성자 기준 검색")
|
||||
_add_feed_source_args(search_parser)
|
||||
search_parser.add_argument("--query", required=True)
|
||||
search_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
detail_parser = subparsers.add_parser("detail", help="항목 상세 확인")
|
||||
_add_feed_source_args(detail_parser)
|
||||
detail_parser.add_argument("--id", required=True, help="entry id/link/topic id 일부")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _load_feed_text(args: argparse.Namespace) -> str:
|
||||
if args.feed_file:
|
||||
return Path(args.feed_file).read_text(encoding="utf-8")
|
||||
return fetch_feed(url=args.feed_url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = parse_args(argv)
|
||||
feed = load_feed(_load_feed_text(args))
|
||||
|
||||
if args.command == "list":
|
||||
payload = build_list_payload(feed, limit=args.limit)
|
||||
elif args.command == "search":
|
||||
payload = build_search_payload(feed, query=args.query, limit=args.limit)
|
||||
else:
|
||||
payload = build_detail_payload(feed, lookup=args.id)
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -513,6 +513,35 @@ test("ktx-booking helper python regression tests pass", () => {
|
|||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("repository docs advertise the geeknews-search skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "geeknews-search.md");
|
||||
const skillPath = path.join(repoRoot, "geeknews-search", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/geeknews-search.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected geeknews-search/SKILL.md to exist");
|
||||
assert.match(readme, /\| 긱뉴스 조회 \|/);
|
||||
assert.match(readme, /\[긱뉴스 조회 가이드\]\(docs\/features\/geeknews-search\.md\)/);
|
||||
assert.match(install, /--skill geeknews-search/);
|
||||
});
|
||||
|
||||
test("geeknews-search docs lock the RSS-first list-search-detail workflow", () => {
|
||||
const skill = read(path.join("geeknews-search", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "geeknews-search.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /feeds\.feedburner\.com\/geeknews-feed/);
|
||||
assert.match(doc, /python3 scripts\/geeknews_search\.py list/);
|
||||
assert.match(doc, /python3 scripts\/geeknews_search\.py search/);
|
||||
assert.match(doc, /python3 scripts\/geeknews_search\.py detail/);
|
||||
assert.match(doc, /RSS-first|RSS first|RSS 피드/);
|
||||
assert.match(doc, /read-only|읽기 전용/);
|
||||
}
|
||||
});
|
||||
|
||||
test("repository docs advertise the subway-lost-property skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
|
|||
128
scripts/test_geeknews_search.py
Normal file
128
scripts/test_geeknews_search.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from scripts.geeknews_search import (
|
||||
GeekNewsFeed,
|
||||
build_detail_payload,
|
||||
build_list_payload,
|
||||
build_search_payload,
|
||||
get_item_detail,
|
||||
list_items,
|
||||
load_feed,
|
||||
main,
|
||||
search_items,
|
||||
)
|
||||
|
||||
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "geeknews-feed.xml"
|
||||
|
||||
|
||||
class GeekNewsFeedParseTest(unittest.TestCase):
|
||||
def test_load_feed_parses_atom_entries_into_normalized_items(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertIsInstance(feed, GeekNewsFeed)
|
||||
self.assertEqual(feed.title, "GeekNews - 개발/기술/스타트업 뉴스 서비스")
|
||||
self.assertEqual(feed.updated, "2026-04-12T22:53:56+09:00")
|
||||
self.assertEqual(feed.home_url, "https://news.hada.io")
|
||||
self.assertEqual(feed.feed_url, "https://news.hada.io/rss/news")
|
||||
self.assertEqual(len(feed.items), 3)
|
||||
|
||||
first = feed.items[0]
|
||||
self.assertEqual(first.title, "Ask GN: 기억이 안나는 웹사이트를 찾고 있습니다.")
|
||||
self.assertEqual(first.link, "https://news.hada.io/topic?id=28441")
|
||||
self.assertEqual(first.id, "https://news.hada.io/topic?id=28441")
|
||||
self.assertEqual(first.author_name, "princox")
|
||||
self.assertEqual(first.author_url, "https://news.hada.io/user/princox")
|
||||
self.assertEqual(first.published, "2026-04-12T22:53:56+09:00")
|
||||
self.assertIn("시각화 사이트", first.summary)
|
||||
self.assertNotIn("<p>", first.summary)
|
||||
self.assertIn("<p>", first.content_html)
|
||||
|
||||
def test_list_items_keeps_feed_order_and_applies_limit(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
items = list_items(feed, limit=2)
|
||||
|
||||
self.assertEqual([item.id for item in items], [
|
||||
"https://news.hada.io/topic?id=28441",
|
||||
"https://news.hada.io/topic?id=28440",
|
||||
])
|
||||
|
||||
def test_search_items_matches_title_summary_and_author_case_insensitively(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
ai_matches = search_items(feed, query="agent", limit=5)
|
||||
author_matches = search_items(feed, query="WORKDRIVER", limit=5)
|
||||
|
||||
self.assertEqual([item.id for item in ai_matches], ["https://news.hada.io/topic?id=28440"])
|
||||
self.assertEqual([item.id for item in author_matches], ["https://news.hada.io/topic?id=28439"])
|
||||
|
||||
def test_get_item_detail_resolves_by_id_or_link_and_errors_cleanly(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
item = get_item_detail(feed, "https://news.hada.io/topic?id=28439")
|
||||
same_item = get_item_detail(feed, "28439")
|
||||
|
||||
self.assertEqual(item.title, "Show GN: [GN] 비개발자 + Claude로 프로덕션 운영 238일 — 무엇이 됐고 무엇이 안 됐나?")
|
||||
self.assertEqual(same_item.id, item.id)
|
||||
with self.assertRaisesRegex(LookupError, "No GeekNews entry matched"):
|
||||
get_item_detail(feed, "missing-topic")
|
||||
|
||||
|
||||
class GeekNewsPayloadShapeTest(unittest.TestCase):
|
||||
def test_list_search_and_detail_payloads_have_stable_json_shape(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
list_payload = build_list_payload(feed, limit=2)
|
||||
search_payload = build_search_payload(feed, query="claude", limit=5)
|
||||
detail_payload = build_detail_payload(feed, lookup="28439")
|
||||
|
||||
self.assertEqual(list_payload["source"]["title"], feed.title)
|
||||
self.assertEqual(list_payload["count"], 2)
|
||||
self.assertEqual(search_payload["query"], "claude")
|
||||
self.assertEqual(search_payload["count"], 1)
|
||||
self.assertEqual(detail_payload["item"]["id"], "https://news.hada.io/topic?id=28439")
|
||||
self.assertIn("summary", detail_payload["item"])
|
||||
self.assertIn("content_html", detail_payload["item"])
|
||||
|
||||
|
||||
class GeekNewsCliShapeTest(unittest.TestCase):
|
||||
def test_cli_prints_json_for_each_subcommand(self):
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
main(["list", "--feed-file", str(FIXTURE_PATH), "--limit", "2"])
|
||||
listed = json.loads(stdout.getvalue())
|
||||
self.assertEqual(listed["count"], 2)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
main(["search", "--feed-file", str(FIXTURE_PATH), "--query", "claude"])
|
||||
searched = json.loads(stdout.getvalue())
|
||||
self.assertEqual(searched["count"], 1)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
main(["detail", "--feed-file", str(FIXTURE_PATH), "--id", "28439"])
|
||||
detail = json.loads(stdout.getvalue())
|
||||
self.assertEqual(detail["item"]["author_name"], "workdriver")
|
||||
|
||||
def test_helper_scripts_are_executable_python_entrypoints(self):
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
for helper in (
|
||||
repo_root / "scripts" / "geeknews_search.py",
|
||||
repo_root / "geeknews-search" / "scripts" / "geeknews_search.py",
|
||||
):
|
||||
with self.subTest(helper=helper):
|
||||
self.assertTrue(os.access(helper, os.X_OK), f"{helper} should be executable")
|
||||
self.assertTrue(
|
||||
helper.read_text(encoding="utf-8").startswith("#!/usr/bin/env python3\n"),
|
||||
f"{helper} should start with a Python shebang",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue