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:
Jeffrey (Dongkyu) Kim 2026-04-13 00:16:35 +09:00
commit 43e8625986
12 changed files with 950 additions and 2 deletions

View file

@ -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)

View 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` 범위까지만 보장한다.

View file

@ -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
```

View file

@ -36,6 +36,7 @@
- 중고차 가격 조회 스킬 출시
- 한국어 맞춤법 검사 스킬 출시
- 한국어 글자 수 세기 스킬 출시
- 긱뉴스 조회 스킬 출시
## v1.5 candidates

View file

@ -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
View 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을 넣을 수 있다.

View 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()

View file

@ -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",

View 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
View 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()

View file

@ -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"));

View 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()