From de634c10e00d9f3f4a16345ce5fbcb0962167db5 Mon Sep 17 00:00:00 2001 From: ArcSolver Date: Thu, 4 Jun 2026 01:29:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(wikipedia):=20=EC=9C=84=ED=82=A4=EB=B0=B1?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=BD=EA=B8=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?(=EA=B2=80=EC=83=89=C2=B7=EC=9A=94=EC=95=BD=C2=B7=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=C2=B7=EB=A7=81=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 위키백과(Wikipedia) 읽기 래퍼 — 무인증으로 전체 읽기가 동작하되 Wikimedia가 요구하는 식별용 User-Agent 헤더를 항상 전송하고(WIKIPEDIA_USER_AGENT로 덮어씀), (선택) WIKIPEDIA_API_TOKEN Bearer로 레이트리밋을 완화한다. 4개 GET 도구: - wikipedia_search: per-wiki 클린 REST /w/rest.php/v1/search/page (구식 list=search 아님) - wikipedia_summary: rest_v1 /api/rest_v1/page/summary/{title} — lead extract + Wikidata Q-id(wikibase_item)·좌표·동음이의 안내 (path segment 인코딩, 리다이렉트 자동 추적) - wikipedia_extract: Action API TextExtracts(prop=extracts·explaintext·exintro·exchars) - wikipedia_links: Action API prop=links|categories(plnamespace=0) 언어판마다 호스트가 다르므로 lang으로 base(https://{lang}.wikipedia.org)를 만들고 형식 검증(소문자+하이픈 변형)으로 호스트 오염을 막는다. Action API는 formatversion=2로 query.pages를 깨끗한 배열로 받고 redirects=1로 리다이렉트를 추적한다. ⚠️ Action API는 잘못된 파라미터에 HTTP 200 + {"error":{code,info}}를 줄 수 있어 본문을 보고 error.info로 매핑한다(403 User-Agent·404 not found·429 스로틀도 매핑). ⚠️ deprecating(2026-07) api.wikimedia.org/core/v1/* 회피 — Action API + per-wiki REST + rest_v1 앵커. 4개 엔드포인트·응답 필드(REST 검색 total 부재·formatversion=2 배열·missing·요약 wikibase_item/coordinates·Action 200+error 봉투)를 라이브(en.wikipedia.org)에서 직접 확인. 테스트 2종(계약 20 + 도구 25 = mock, 네트워크 없음) 전체 통과, ruff 통과. Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 + CHANGELOG.md | 1 + arcsolve/services/wikipedia/README.md | 74 +++++ arcsolve/services/wikipedia/__init__.py | 12 + arcsolve/services/wikipedia/contract.py | 386 ++++++++++++++++++++++++ arcsolve/services/wikipedia/tools.py | 294 ++++++++++++++++++ changelog.d/wikipedia.md | 1 + docs/providers.md | 22 ++ docs/services.md | 12 +- tests/test_wikipedia_contract.py | 298 ++++++++++++++++++ tests/test_wikipedia_tools.py | 320 ++++++++++++++++++++ 11 files changed, 1427 insertions(+), 1 deletion(-) create mode 100644 arcsolve/services/wikipedia/README.md create mode 100644 arcsolve/services/wikipedia/__init__.py create mode 100644 arcsolve/services/wikipedia/contract.py create mode 100644 arcsolve/services/wikipedia/tools.py create mode 100644 changelog.d/wikipedia.md create mode 100644 tests/test_wikipedia_contract.py create mode 100644 tests/test_wikipedia_tools.py diff --git a/.env.example b/.env.example index 4610668..aaabf84 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,14 @@ CROSSREF_MAILTO= # 키 없이도 동작(공유 풀, 혼잡 시 429↑). 키 있으면 전용 풀(1 RPS). SEMANTICSCHOLAR_API_KEY= +# ─── Wikipedia ─────────────────────────────────────────── +# (선택) 식별/연락용 User-Agent — 미설정 시 기본 식별 문자열. Wikimedia는 User-Agent를 요구한다 +# (없거나 약하면 403/스로틀). 공식 권장은 연락처 포함(예: "(myapp.com, you@example.com)"). +WIKIPEDIA_USER_AGENT= +# (선택) Bearer 토큰 — 설정 시 Authorization: Bearer 헤더로 전송해 레이트리밋이 완화된다. +# 토큰 없이도 전체 읽기가 동작한다(무인증). +WIKIPEDIA_API_TOKEN= + # ─── AirKorea(에어코리아) ──────────────────────────────── # data.go.kr '대기오염정보' OpenAPI(15073861) 활용신청 후 발급되는 서비스키 (필수) # ⚠️ Encoding/Decoding 2종 중 **Decoding 키(원문)**를 넣을 것 — httpx 자동 인코딩으로 인한 이중 인코딩 방지 diff --git a/CHANGELOG.md b/CHANGELOG.md index a100780..bbd5c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,5 +39,6 @@ - **telegram**: 코어 도구 확장 — getMe(헬스체크)/sendPhoto/sendDocument/editMessageText/deleteMessage 추가 - **telegram**: sendPhoto/sendDocument 로컬 파일 multipart 업로드 지원(사진≤10MB·파일≤50MB), editMessageText inline_message_id 경로 추가 - **usgs_quake**: USGS 지진 정보 읽기 서비스 추가 — FDSN Event API 검색·건수 2개 GET 도구(`usgs_search_earthquakes`/`usgs_count_earthquakes`), 무인증·`format=geojson` 고정, 시간 ISO8601·`orderby`(time/magnitude)·원형 위치(`latitude`+`longitude`+`maxradiuskm`)·`limit` 1–20000(기본 20), GeoJSON FeatureCollection 파싱(time ms→UTC, coordinates[lon,lat,depth])·`{count,maxAllowed}` 건수 +- **wikipedia**: 위키백과 읽기 서비스 추가 — 검색·요약·본문·링크 4개 GET 도구(`wikipedia_search`/`wikipedia_summary`/`wikipedia_extract`/`wikipedia_links`), 무인증이나 **User-Agent 헤더 요구**(기본값 상수, `WIKIPEDIA_USER_AGENT`로 덮어씀) + (선택) `WIKIPEDIA_API_TOKEN` Bearer로 레이트리밋 완화, 세 엔드포인트 혼합(per-wiki REST 검색 `/w/rest.php/v1/search/page` · rest_v1 요약 `/api/rest_v1/page/summary/{title}`(Wikidata Q-id·좌표 노출) · Action API TextExtracts/links|categories `formatversion=2` 배열), 언어판별 호스트(`{lang}.wikipedia.org`)·**Action API HTTP 200+error 봉투** 매핑, ⚠️ deprecating `api.wikimedia.org/core/v1/*` 회피 - **zotero**: Zotero 라이브러리 읽기 서비스 추가(Web API v3 + 로컬 데스크톱 API 단일 서비스·백엔드 전환) — 검색/아이템/자식/컬렉션/컬렉션 아이템/태그/전문/헬스 8개 GET 도구, 응답 헤더 기반 페이지네이션 안내 diff --git a/arcsolve/services/wikipedia/README.md b/arcsolve/services/wikipedia/README.md new file mode 100644 index 0000000..bcd9c3d --- /dev/null +++ b/arcsolve/services/wikipedia/README.md @@ -0,0 +1,74 @@ +# Wikipedia 서비스 + +위키백과(Wikipedia) **읽기** 래퍼 — 검색·요약·본문·링크. 전부 GET·읽기. **무인증**으로 전체 +읽기가 동작하지만, Wikimedia는 식별용 **`User-Agent` 헤더를 요구**한다(없거나 약하면 403/스로틀). +(선택) Bearer 토큰을 주면 레이트리밋이 완화된다. + +## 계약 출처 (공식 문서) +- per-wiki REST(검색) 레퍼런스: https://www.mediawiki.org/wiki/API:REST_API/Reference +- Wikimedia REST API(rest_v1 summary): https://www.mediawiki.org/wiki/Wikimedia_REST_API (per-wiki 명세: https://en.wikipedia.org/api/rest_v1/) +- TextExtracts(`prop=extracts`·`exintro`·`explaintext`·`exchars`): https://www.mediawiki.org/wiki/Extension:TextExtracts +- Action API Query(`prop=links|categories`·`formatversion=2`·`redirects`): https://www.mediawiki.org/wiki/API:Query +- 라이브 응답 확인: `/w/rest.php/v1/search/page` · `/api/rest_v1/page/summary/{title}` · `/w/api.php?action=query&prop=extracts` · `/w/api.php?action=query&prop=links|categories` + +> 계약 본체는 [`contract.py`](contract.py)에 코드로 박제되어 있다(호스트/경로 빌더·언어·limit 검증·제목 인코더·HTML 스트립·부분 응답 모델). + +## 인증 (없음 · User-Agent 필수 · 토큰 선택) +무인증으로 전체 읽기가 동작한다. 다만 Wikimedia는 식별용 **`User-Agent` 헤더를 요구**하므로 기본 +식별 문자열(`contract.DEFAULT_USER_AGENT`)을 항상 보내며, 연락처를 넣고 싶으면 +`WIKIPEDIA_USER_AGENT`로 덮어쓴다. (선택) `WIKIPEDIA_API_TOKEN`을 주면 `Authorization: Bearer`를 +UA와 함께 보내 레이트리밋이 완화된다(토큰 없이도 읽기는 전부 동작). + +| env | 쓰임 | 비고 | +|---|---|---| +| `WIKIPEDIA_USER_AGENT` | `User-Agent: <값>` | 선택. 미설정 시 기본 식별 문자열. 공식 권장은 연락처 포함(예: `(myapp.com, you@example.com)`) | +| `WIKIPEDIA_API_TOKEN` | `Authorization: Bearer <값>` | 선택. 있으면 레이트리밋 완화. 없어도 전체 읽기 동작 | + +- 헤더는 코어 `get_json(headers=...)`로 주입한다(서비스 폴더에서 httpx 직접 생성 금지 — AGENTS 규칙). +- 언어판마다 호스트가 다르다: base `https://{lang}.wikipedia.org`. + +## 엔드포인트 (전부 GET · `https://{lang}.wikipedia.org`) +| 종류 | METHOD · PATH | +|------|------| +| 검색(클린 REST) | `GET /w/rest.php/v1/search/page?q=&limit=` | +| 요약(rest_v1) | `GET /api/rest_v1/page/summary/{title}` | +| 본문(TextExtracts) | `GET /w/api.php?action=query&prop=extracts&explaintext=1&formatversion=2` | +| 링크·분류 | `GET /w/api.php?action=query&prop=links\|categories&formatversion=2` | + +Base: `https://{lang}.wikipedia.org` · 인증: 없음(User-Agent 필수, Bearer 선택) · 스코프: 읽기 전용 + +> 세 종류 엔드포인트를 섞어 쓴다: ① per-wiki REST 검색, ② rest_v1 요약, ③ Action API 본문/링크. +> ⚠️ `api.wikimedia.org/core/v1/*`(통합 REST)는 2026-07 deprecation 예정·후속 없음 → 사용하지 않는다. +> ⚠️ Action API는 잘못된 파라미터에 **HTTP 200 + `{"error":{"code","info"}}`**를 줄 수 있다(4xx가 아님) → 본문을 보고 매핑한다. +> Action API는 `formatversion=2`로 `query.pages`를 **깨끗한 배열**로 받는다(pageid-keyed 객체 아님). `redirects=1`로 리다이렉트를 추적한다. + +## 셋업 +1. 키 발급 단계 없음(무인증). +2. `.env`(선택): `WIKIPEDIA_USER_AGENT="(myapp.com, you@example.com)"` — 식별/연락용 User-Agent. +3. `.env`(선택): `WIKIPEDIA_API_TOKEN=...` — Bearer 토큰(레이트리밋 완화). + +> 무인증·필수 User-Agent 방식 — 인터랙티브 OAuth가 아니므로 `arcsolve-mcp auth wikipedia` 단계는 없다. + +## 도구 +| 도구 | 설명 | +|------|------| +| `wikipedia_search(query, lang="en", limit=10)` | 클린 REST 검색. 제목·요약·스니펫(HTML 태그 제거). `limit` 1–100 | +| `wikipedia_summary(title, lang="en")` | rest_v1 lead 요약. extract·문서 URL·**Wikidata Q-id**(있으면)·좌표(지리)·동음이의 안내 | +| `wikipedia_extract(title, lang="en", intro_only=True, max_chars=None)` | TextExtracts 평문 본문. 도입부/전체 선택, `max_chars` 1–1200 | +| `wikipedia_links(title, lang="en", limit=50)` | 나가는 문서 링크(ns 0) + 분류. `limit` 1–500 | + +## 범위 / 제약 (공식) +- **읽기만.** 검색·요약·본문·링크/분류(MVP). +- `lang`은 소문자 언어 코드(`[a-z]`+하이픈 변형, 예: `en`·`ko`·`de`·`zh`·`simple`·`zh-yue`) — 형식 위반은 HTTP 전에 차단(호스트 오염 방지). +- 검색 `limit` **1–100**(기본 10), 링크/분류 `limit` **1–500**(기본 50), `max_chars`(exchars) **1–1200**. +- 요약 404 → "문서를 찾을 수 없습니다". 본문/링크는 `missing:true` 또는 빈 `pages` → 동일 안내. +- 무 User-Agent → 403, 스로틀 → 429(Retry-After 권장). Action API 잘못된 파라미터 → HTTP 200 + `{error}` → `error.info` 노출. +- 제외: 편집/쓰기, 미디어 업로드, 위키데이터 직접 조회(요약의 `wikibase_item`만 브리지로 노출), `api.wikimedia.org/core/v1/*`(deprecating), CirrusSearch 고급 구문, parse/렌더 HTML, 카테고리 멤버 역방향(`list=categorymembers`). + +## UNVERIFIED / provenance 노트 +- 모든 엔드포인트·응답 필드는 라이브(en.wikipedia.org)에서 확인했다: REST 검색(`pages[]`, total 없음), rest_v1 요약(`type`·`extract`·`content_urls.desktop.page`·`thumbnail.source`·`wikibase_item`·`coordinates`), TextExtracts(`formatversion=2` → `query.pages[]` 배열·`missing:true`), links/categories(`links[]`·`categories[]`·`redirects[]`). +- Action API의 **HTTP 200 + `{error}`** 봉투는 라이브에서 확인(`action=nonsense` → `badvalue`, `exchars=abc` → `badinteger`). REST 검색 응답에는 **total 필드가 없다**(라이브 확인). +- `WIKIPEDIA_API_TOKEN`(Bearer) 자체는 라이브에서 토큰 없이 검증할 수 없어 **헤더 조립만 단위 테스트로 확인**했다(토큰 유효성·완화 효과는 미검증). + +## 확장 포인트 +- 미디어(`/api/rest_v1/page/media-list/{title}`), 관련 문서(`/api/rest_v1/page/related/{title}`), 역링크(`list=backlinks`), 카테고리 멤버(`list=categorymembers`), 좌표 기반 근접 검색(`list=geosearch`)은 동일 패턴으로 경로 상수·도구 추가. 위키데이터 엔티티 상세는 별도 서비스(요약의 `wikibase_item`이 브리지). diff --git a/arcsolve/services/wikipedia/__init__.py b/arcsolve/services/wikipedia/__init__.py new file mode 100644 index 0000000..3e42fa3 --- /dev/null +++ b/arcsolve/services/wikipedia/__init__.py @@ -0,0 +1,12 @@ +from arcsolve.service import Service +from arcsolve.services.wikipedia.tools import register + +SERVICE = Service( + name="wikipedia", + register=register, + docs_url="https://www.mediawiki.org/wiki/API:REST_API/Reference", + summary="위키백과 읽기(검색·요약·본문·링크)", + # 무인증으로 전체 읽기 동작 — 단 식별용 User-Agent 헤더 요구(기본값 contract.DEFAULT_USER_AGENT, + # WIKIPEDIA_USER_AGENT로 덮어씀). (선택) WIKIPEDIA_API_TOKEN으로 레이트리밋 완화. + # 인터랙티브 OAuth 아님 → make_auth_client 없음. +) diff --git a/arcsolve/services/wikipedia/contract.py b/arcsolve/services/wikipedia/contract.py new file mode 100644 index 0000000..3608e34 --- /dev/null +++ b/arcsolve/services/wikipedia/contract.py @@ -0,0 +1,386 @@ +"""Wikipedia(위키백과) 읽기 계약(contract). + +상류 API의 '진실'만 담는다 — 호스트/경로 상수, 언어·limit 검증, 제목 인코더, HTML 스트립 +헬퍼, 부분 응답 모델. MCP/네트워크 무의존(순수 상수 + 헬퍼 + pydantic 모델). + +전부 GET·읽기. **무인증**(키 없음)으로 전체 읽기가 동작하지만, Wikimedia는 NWS처럼 식별용 +**`User-Agent` 헤더를 요구**한다(없거나 약하면 403/스로틀). 기본 식별 문자열을 +`DEFAULT_USER_AGENT` 상수로 두고 tools에서 env(`WIKIPEDIA_USER_AGENT`)로 덮어쓴다. (선택) +Bearer 토큰을 주면 레이트리밋이 완화되지만 토큰 없이도 읽기는 전부 동작한다. + +언어판마다 호스트가 다르다: `https://{lang}.wikipedia.org`. 세 종류의 엔드포인트를 섞어 쓴다. + - 검색: per-wiki REST `/w/rest.php/v1/search/page` (구식 Action API `list=search`가 아님) + - 요약: rest_v1 `/api/rest_v1/page/summary/{title}` (lead extract; 2026년 현재 살아있음) + - 본문: Action API `/w/api.php?action=query&prop=extracts` (TextExtracts 확장) + - 링크: Action API `/w/api.php?action=query&prop=links|categories` +⚠️ `api.wikimedia.org/core/v1/*`(통합 REST)는 2026-07 deprecation 예정·후속 없음 → 사용하지 않는다. + +출처(공식 문서 + 라이브 확인): + - per-wiki REST(검색) 레퍼런스: https://www.mediawiki.org/wiki/API:REST_API/Reference + - rest_v1(summary) — Wikimedia REST API: https://www.mediawiki.org/wiki/Wikimedia_REST_API + (per-wiki rest_v1 명세: https://en.wikipedia.org/api/rest_v1/) + - TextExtracts(prop=extracts·exintro·explaintext·exchars): https://www.mediawiki.org/wiki/Extension:TextExtracts + - Action API(query·prop=links|categories·formatversion=2·redirects): https://www.mediawiki.org/wiki/API:Query + - 라이브 응답 확인: /w/rest.php/v1/search/page · /api/rest_v1/page/summary/{title} · + /w/api.php?action=query&prop=extracts · /w/api.php?action=query&prop=links|categories +""" + +from __future__ import annotations + +import re +from urllib.parse import quote + +from pydantic import BaseModel + +# ─── 호스트 / 경로 상수 ───────────────────────────────────── +# 언어판마다 호스트가 다르다(예: en·ko·de·zh·simple). base는 wiki_host(lang)로 만든다. +# 출처(per-wiki REST·Action API base): API:REST_API/Reference, API:Main_page(`/w/api.php`) + 라이브. +SCHEME = "https" +WIKI_HOST_SUFFIX = "wikipedia.org" + +# per-wiki REST 검색(클린 REST — 폐기 대상 아님). 출처: API:REST_API/Reference(Search pages) + 라이브. +REST_SEARCH_PATH = "/w/rest.php/v1/search/page" +# rest_v1 요약(lead extract; 리다이렉트 자동 추적). 출처: Wikimedia_REST_API + 라이브(/page/summary/{title}). +REST_V1_SUMMARY_PREFIX = "/api/rest_v1/page/summary/" +# Action API 엔드포인트(TextExtracts·links/categories). 출처: API:Query + 라이브(/w/api.php). +ACTION_API_PATH = "/w/api.php" + + +# ─── User-Agent (필수) ────────────────────────────────────── +# Wikimedia는 식별용 User-Agent를 요구한다(약하거나 없으면 403/스로틀). NWS와 동일 패턴으로 기본 +# 식별 문자열을 상수로 두고 env로 덮어쓴다. 출처: Wikimedia User-Agent policy + NWS 동형. +DEFAULT_USER_AGENT = "ArcSolve-MCP (github.com/ArcSolver/ArcSolve-MCP)" + + +def wiki_host(lang: str) -> str: + """언어판 base URL `https://{lang}.wikipedia.org`를 만든다(검증된 lang 가정). + + 출처: per-wiki 호스트 규칙(예: en.wikipedia.org·ko.wikipedia.org) + 라이브. + """ + return f"{SCHEME}://{lang}.{WIKI_HOST_SUFFIX}" + + +def summary_path(title: str) -> str: + """rest_v1 요약 경로 `/api/rest_v1/page/summary/{title}`. + + title은 path segment이므로 `quote(safe="")`로 슬래시·공백까지 전부 인코딩한다(`%20`·`%2F`). + rest_v1은 리다이렉트를 자동 추적한다(라이브: NYC → New York City). + 출처: Wikimedia_REST_API(/page/summary/{title}) + 라이브. + """ + return f"{REST_V1_SUMMARY_PREFIX}{encode_title(title)}" + + +# ─── 제목 인코더 ──────────────────────────────────────────── + + +def encode_title(title: str) -> str: + """rest_v1 path segment용 제목 인코딩(공백·슬래시 포함 전부). + + `urllib.parse.quote(title, safe="")` — path segment라 `/`도 인코딩해야 한다(`%2F`). + Action API의 `titles=`는 쿼리 파라미터라 별도 인코딩 불필요(httpx가 처리) → 거기엔 쓰지 않는다. + """ + return quote(title.strip(), safe="") + + +# ─── 언어 / limit 검증 ────────────────────────────────────── +# lang은 위키 서브도메인(언어 코드)이다. 가볍게 검증한다: 소문자 + [a-z-] (예: en·ko·de·zh·simple· +# zh-yue). 숫자/대문자/언더스코어는 거른다(SSRF·오타 방지). 모든 코드를 enum으로 박지는 않는다 +# (위키 언어판이 300+이고 추가/변경됨) — 형식 검증만. +# 출처: per-wiki 언어 서브도메인 규칙(소문자 ISO 639 코드 + 하이픈 변형). +LANG_RE = re.compile(r"^[a-z]+(-[a-z]+)*$") +DEFAULT_LANG = "en" + +# REST 검색 limit: 1..100(기본 10). 출처: API:REST_API/Reference(Search pages — limit 1–100, 기본 50). +MIN_LIMIT = 1 +DEFAULT_SEARCH_LIMIT = 10 +MAX_SEARCH_LIMIT = 100 +# Action API links/categories pllimit·cllimit 상한(비-bot은 500). 출처: API:Links/API:Categories. +DEFAULT_LINKS_LIMIT = 50 +MAX_LINKS_LIMIT = 500 +# TextExtracts exchars 상한 1200(확장 한도). 출처: Extension:TextExtracts(exchars max 1200). +MIN_EXCHARS = 1 +MAX_EXCHARS = 1200 + + +def validate_lang(lang: str) -> str: + """lang을 소문자 언어 코드(`[a-z]`+하이픈 변형)로 정규화·검증한다. + + 소문자로 맞추고 형식만 본다(예: `en`·`ko`·`de`·`zh`·`simple`·`zh-yue`). 형식 위반은 + HTTP 전에 막는다(호스트 오염·오타 방지). 출처: per-wiki 언어 서브도메인 규칙. + """ + code = lang.strip().lower() + if not code or not LANG_RE.match(code): + raise ValueError( + f"lang은 소문자 언어 코드여야 합니다(현재 {lang!r}). 예: en, ko, de, zh, simple." + ) + return code + + +def validate_limit(limit: int, *, maximum: int) -> int: + """limit을 1..maximum 범위로 검증한다(검색=100·링크=500). + + 위반 시 ValueError(상류 호출 전에 막는다). + 출처: API:REST_API/Reference(search limit 1–100), API:Links/API:Categories(pllimit/cllimit ≤500). + """ + if limit < MIN_LIMIT or limit > maximum: + raise ValueError(f"limit은 {MIN_LIMIT}..{maximum} 범위여야 합니다(현재 {limit}).") + return limit + + +def validate_exchars(max_chars: int) -> int: + """exchars(요약 글자 수)를 1..1200 범위로 검증한다. + + 위반 시 ValueError. 출처: Extension:TextExtracts(exchars 상한 1200). + """ + if max_chars < MIN_EXCHARS or max_chars > MAX_EXCHARS: + raise ValueError( + f"max_chars는 {MIN_EXCHARS}..{MAX_EXCHARS} 범위여야 합니다(현재 {max_chars})." + ) + return max_chars + + +# ─── HTML 태그 스트립 헬퍼 ────────────────────────────────── +# REST 검색의 `excerpt`는 `` 같은 HTML 스니펫이다(라이브 확인). +# 평문 한 줄로 보여주기 위해 태그를 지우고 HTML 엔티티 일부를 푼다(표준 html.unescape 사용). +_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"\s+") + + +def strip_html(snippet: str | None) -> str: + """HTML 스니펫에서 태그를 제거하고 공백을 정리한 평문을 돌려준다. + + `` 등의 태그를 지우고, 엔티티(`'`·`&` 등)를 + `html.unescape`로 푼다. None/빈값이면 빈 문자열. 출처: 라이브(REST 검색 excerpt = HTML 스니펫). + """ + if not snippet: + return "" + import html + + text = _TAG_RE.sub("", snippet) + text = html.unescape(text) + return _WS_RE.sub(" ", text).strip() + + +# ─── 쿼리 파라미터 빌더 (Action API) ─────────────────────── +# Action API는 항상 format=json·formatversion=2를 쓴다. formatversion=2면 query.pages가 +# pageid-keyed 객체가 아니라 **깨끗한 배열**로 온다(라이브 확인). redirects=1로 리다이렉트를 따른다. +# 출처: API:JSON_version_2(formatversion=2), API:Query(redirects), Extension:TextExtracts. + + +def extracts_params( + title: str, *, intro_only: bool = True, max_chars: int | None = None +) -> dict[str, str | int]: + """TextExtracts 평문 본문 쿼리스트링을 만든다(prop=extracts). + + explaintext=1(평문)·formatversion=2(배열)·redirects=1(리다이렉트 추적). intro_only면 + exintro=1(도입부만), max_chars면 exchars={n}(글자 수 제한). titles는 쿼리 파라미터라 별도 + 인코딩하지 않는다(httpx가 처리). 출처: Extension:TextExtracts + 라이브. + """ + params: dict[str, str | int] = { + "action": "query", + "prop": "extracts", + "explaintext": 1, + "format": "json", + "formatversion": 2, + "redirects": 1, + "titles": title, + } + if intro_only: + params["exintro"] = 1 + if max_chars is not None: + params["exchars"] = max_chars + return params + + +def links_params(title: str, *, limit: int = DEFAULT_LINKS_LIMIT) -> dict[str, str | int]: + """나가는 링크 + 분류 쿼리스트링을 만든다(prop=links|categories). + + plnamespace=0(문서 네임스페이스 링크만)·pllimit/cllimit={limit}·formatversion=2·redirects=1. + 출처: API:Links(plnamespace·pllimit), API:Categories(cllimit) + 라이브. + """ + return { + "action": "query", + "prop": "links|categories", + "titles": title, + "plnamespace": 0, + "pllimit": limit, + "cllimit": limit, + "format": "json", + "formatversion": 2, + "redirects": 1, + } + + +# ─── 응답 모델 (부분 모델 · extra="ignore") ──────────────── +# fields가 빠질 수 있어 핵심 외 전부 Optional. snake_case와 다른 상류 키만 alias. + + +class SearchThumbnail(BaseModel): + """REST 검색 결과의 `thumbnail`(부분). url은 `//upload...`처럼 scheme-relative일 수 있다. + + 출처: 라이브(/w/rest.php/v1/search/page → pages[].thumbnail{url,width,height,mimetype}). + """ + + model_config = {"extra": "ignore"} + + url: str | None = None + width: int | None = None + height: int | None = None + + +class SearchPage(BaseModel): + """REST 검색 결과 `pages[]` 한 항목(부분). + + id·key(URL 슬러그)·title·excerpt(HTML 스니펫)·matched_title·description·thumbnail. + REST 검색 응답에는 total 필드가 **없다**(라이브 확인). 출처: API:REST_API/Reference + 라이브. + """ + + model_config = {"extra": "ignore"} + + id: int | None = None + key: str | None = None + title: str | None = None + excerpt: str | None = None + matched_title: str | None = None + description: str | None = None + thumbnail: SearchThumbnail | None = None + + +class SearchResponse(BaseModel): + """REST 검색 응답 봉투 `{"pages":[...]}`(total 없음). + + 출처: 라이브(/w/rest.php/v1/search/page → {"pages":[...]}). + """ + + model_config = {"extra": "ignore"} + + pages: list[SearchPage] = [] + + +class SummaryContentUrls(BaseModel): + """rest_v1 요약의 `content_urls.desktop.page`만 뽑기 위한 중첩 모델(부분). + + 출처: 라이브(/api/rest_v1/page/summary → content_urls.desktop.page). + """ + + model_config = {"extra": "ignore"} + + class _Desktop(BaseModel): + model_config = {"extra": "ignore"} + + page: str | None = None + + desktop: _Desktop | None = None + + +class SummaryThumbnail(BaseModel): + """rest_v1 요약의 `thumbnail`(부분) — 대표 이미지 `source` URL. + + 출처: 라이브(/api/rest_v1/page/summary → thumbnail.source). + """ + + model_config = {"extra": "ignore"} + + source: str | None = None + + +class SummaryCoordinates(BaseModel): + """rest_v1 요약의 `coordinates`(부분) — 지리 문서일 때만 존재. + + 출처: 라이브(/api/rest_v1/page/summary/Paris → coordinates{lat,lon}). + """ + + model_config = {"extra": "ignore"} + + lat: float | None = None + lon: float | None = None + + +class SummaryResponse(BaseModel): + """rest_v1 요약 응답 최상위(부분). + + type(`standard`·`disambiguation` 등)·title·description·extract·content_urls.desktop.page· + thumbnail.source·lang·**wikibase_item**(Wikidata Q-id 브리지)·coordinates(지리일 때). + 404면 상류가 4xx → tools에서 "문서를 찾을 수 없습니다"로 매핑(여기선 모델만). + 출처: 라이브(/api/rest_v1/page/summary/{title}) + Wikimedia_REST_API. + """ + + model_config = {"extra": "ignore"} + + type: str | None = None + title: str | None = None + description: str | None = None + extract: str | None = None + lang: str | None = None + wikibase_item: str | None = None + content_urls: SummaryContentUrls | None = None + thumbnail: SummaryThumbnail | None = None + coordinates: SummaryCoordinates | None = None + + +class ExtractPage(BaseModel): + """TextExtracts `query.pages[]` 한 항목(formatversion=2 배열, 부분). + + 존재하면 {pageid,title,extract}, 없으면 {title,missing:true}. missing이 True면 문서 없음. + 출처: 라이브(action=query&prop=extracts&formatversion=2 → query.pages[]). + """ + + model_config = {"extra": "ignore"} + + pageid: int | None = None + title: str | None = None + extract: str | None = None + missing: bool | None = None + + +class LinkItem(BaseModel): + """`query.pages[0].links[]` 한 항목 {ns,title}(부분). + + 출처: 라이브(prop=links&plnamespace=0 → links[]{ns,title}). + """ + + model_config = {"extra": "ignore"} + + ns: int | None = None + title: str | None = None + + +class CategoryItem(BaseModel): + """`query.pages[0].categories[]` 한 항목 {ns,title}(부분). title은 `Category:`/`분류:` 접두 포함. + + 출처: 라이브(prop=categories → categories[]{ns,title}). + """ + + model_config = {"extra": "ignore"} + + ns: int | None = None + title: str | None = None + + +class LinksPage(BaseModel): + """links/categories 조회의 `query.pages[0]`(부분). links·categories는 둘 다 없을 수 있다. + + missing=true면 문서 없음(extracts와 동일). 출처: 라이브(prop=links|categories → query.pages[0]). + """ + + model_config = {"extra": "ignore"} + + pageid: int | None = None + title: str | None = None + missing: bool | None = None + links: list[LinkItem] = [] + categories: list[CategoryItem] = [] + + +class ActionError(BaseModel): + """Action API 에러 봉투 `{"error":{"code","info"}}`(부분). + + ⚠️ Action API는 잘못된 파라미터에 **HTTP 200 + 본문 error**를 줄 수 있다(4xx가 아님, 라이브 + 확인: action=nonsense·exchars=abc). tools에서 본문을 보고 매핑한다. + 출처: 라이브(/w/api.php?action=nonsense → {"error":{"code":"badvalue","info":...}}). + """ + + model_config = {"extra": "ignore"} + + code: str | None = None + info: str | None = None diff --git a/arcsolve/services/wikipedia/tools.py b/arcsolve/services/wikipedia/tools.py new file mode 100644 index 0000000..545a0c4 --- /dev/null +++ b/arcsolve/services/wikipedia/tools.py @@ -0,0 +1,294 @@ +"""Wikipedia(위키백과) 읽기 MCP 도구 + 런타임 배선. + +contract.py의 계약을 실제 MCP 도구로 노출하는 얇은 층. 전부 GET·읽기다. + +**무인증**으로 전체 읽기가 동작하지만 Wikimedia는 NWS처럼 식별용 **`User-Agent` 헤더를 요구**한다 +(없거나 약하면 403/스로틀). 기본 식별 문자열(contract.DEFAULT_USER_AGENT)을 항상 보내고 +`WIKIPEDIA_USER_AGENT`로 덮어쓴다. (선택) `WIKIPEDIA_API_TOKEN`을 주면 `Authorization: Bearer`를 +UA와 함께 보내 레이트리밋이 완화된다(토큰 없이도 읽기는 전부 동작). + +세 종류 엔드포인트를 섞어 쓴다: ① per-wiki REST 검색(`/w/rest.php/v1/search/page`), +② rest_v1 요약(`/api/rest_v1/page/summary/{title}`), ③ Action API 본문/링크 +(`/w/api.php?action=query&prop=extracts` · `prop=links|categories`). 언어판마다 호스트가 달라 +`lang`으로 base를 만든다. ⚠️ Action API는 잘못된 파라미터에 **HTTP 200 + `{"error":...}`**를 줄 수 +있어 본문을 보고 매핑한다. 헤더는 코어 `get_json(headers=...)`로 주입한다(서비스 폴더에서 httpx +직접 생성 금지 — AGENTS 규칙). 인터랙티브 OAuth가 아니므로 make_auth_client 없음(nws와 동형). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic_settings import BaseSettings, SettingsConfigDict + +from arcsolve.http import UpstreamError, bearer, get_json +from arcsolve.services.wikipedia import contract as c + +if TYPE_CHECKING: + from fastmcp import FastMCP # 타입힌트 전용 — 런타임 fastmcp import 회피 + + +class WikipediaSettings(BaseSettings): + """WIKIPEDIA_* 환경변수에서 (선택) User-Agent / API 토큰을 로드한다. + + - user_agent: 식별용 User-Agent(선택). Wikimedia는 헤더를 요구하므로 기본값 + (contract.DEFAULT_USER_AGENT)을 항상 보내며, 연락처를 넣고 싶으면 `WIKIPEDIA_USER_AGENT`로 + 덮어쓴다. + - api_token: (선택) Bearer 토큰. 있으면 `Authorization: Bearer {token}`을 UA와 함께 보내 + 레이트리밋이 완화된다. 없어도 전체 읽기가 동작한다(무인증). + """ + + model_config = SettingsConfigDict(env_prefix="WIKIPEDIA_", env_file=".env", extra="ignore") + user_agent: str | None = None + api_token: str | None = None + + +def _headers(user_agent: str | None, api_token: str | None) -> dict[str, str]: + """필수 User-Agent 헤더를 만들고, 토큰이 있으면 Bearer를 덧붙인다. + + Wikimedia는 식별용 User-Agent를 요구하므로 항상 채운다(env 비면 기본 식별 문자열). + 토큰은 선택 — 있으면 레이트리밋 완화. 출처: Wikimedia User-Agent policy + REST API Bearer 인증. + """ + h = {"User-Agent": user_agent or c.DEFAULT_USER_AGENT} + if api_token: + h.update(bearer(api_token)) + return h + + +def _explain(e: UpstreamError) -> str: + """문서화/관측된 상태코드를 사람이 읽을 메시지로 매핑한다. + + rest_v1/REST 검색은 4xx/5xx로 에러를 준다(요약 404 = 문서 없음). Action API는 잘못된 파라미터에 + HTTP 200 + 본문 error를 주므로(별도 처리), 여기선 전송 계층 상태코드만 매핑한다. + 출처: 라이브(요약 404, 무 UA → 403, 429 스로틀). + """ + payload = e.payload if isinstance(e.payload, dict) else None + detail = "" + if payload: + # rest_v1 에러 봉투: {"type","title","detail","method","uri"}; Action 비-200은 드묾. + d = payload.get("detail") or payload.get("title") or payload.get("message") + if isinstance(d, str) and d.strip(): + detail = f" {d.strip()}" + if e.status == 400: + return f"요청 오류(400): 입력 값을 확인하세요.{detail}" + if e.status == 403: + return ( + "접근 거부(403): User-Agent 헤더가 필요합니다(WIKIPEDIA_USER_AGENT 설정을 확인하세요)." + ) + if e.status == 404: + return "문서를 찾을 수 없습니다(404): 제목/언어를 확인하세요." + if e.status == 429: + return ( + "요청 한도 초과(429): 잠시 후 재시도하세요" + "(Retry-After 권장). WIKIPEDIA_API_TOKEN을 쓰면 레이트리밋이 완화됩니다." + ) + if e.status in (500, 502, 503, 504): + return f"Wikipedia 서버 오류({e.status}): 잠시 후 재시도하세요.{detail}" + # 미매핑 상태코드: dict에서 뽑은 detail만 노출하고, 비-dict 본문(HTML 등)은 원문을 흘리지 않는다. + return f"Wikipedia API 오류 {e.status}.{detail}" + + +def _action_error(body: dict) -> str | None: + """Action API의 HTTP 200 + `{"error":{code,info}}` 봉투를 검사해 메시지를 만든다(없으면 None). + + 출처: 라이브(/w/api.php?action=nonsense → 200 {"error":{"code":"badvalue","info":...}}). + """ + if isinstance(body, dict) and body.get("error"): + err = c.ActionError.model_validate(body["error"]) + info = (err.info or err.code or "").strip() + return f"요청 오류: {info}" if info else "요청 오류(Action API)." + return None + + +def register(mcp: FastMCP) -> None: + """이 서비스의 도구를 서버에 등록한다.""" + + @mcp.tool + async def wikipedia_search( + query: str, lang: str = c.DEFAULT_LANG, limit: int = c.DEFAULT_SEARCH_LIMIT + ) -> str: + """위키백과에서 문서를 검색한다(클린 REST: GET /w/rest.php/v1/search/page). + + 제목·요약·스니펫을 평문으로 돌려준다(구식 Action `list=search`가 아닌 per-wiki REST). + + Args: + query: 검색어. 필수. + lang: 언어판 코드(소문자). 기본 `en`. 예: `en`·`ko`·`de`·`zh`·`simple`. + limit: 결과 개수. 기본 10, 1..100. + """ + s = WikipediaSettings() + try: + code = c.validate_lang(lang) + c.validate_limit(limit, maximum=c.MAX_SEARCH_LIMIT) + except ValueError as e: # 형식/범위 위반은 HTTP 전에 막힌다 + return str(e) + + url = f"{c.wiki_host(code)}{c.REST_SEARCH_PATH}" + try: + body = await get_json( + url, + params={"q": query, "limit": limit}, + headers=_headers(s.user_agent, s.api_token), + ) + except UpstreamError as e: + return _explain(e) + + if not isinstance(body, dict): + return f"응답: {body}" + result = c.SearchResponse.model_validate(body) + if not result.pages: + return "검색 결과 없음" + lines = [] + for p in result.pages: + head = f"- [{p.key or '?'}] {p.title or '(제목 없음)'}" + if p.description: + head += f" — {p.description}" + lines.append(head) + snippet = c.strip_html(p.excerpt) + if snippet: + lines.append(f" {snippet}") + return "\n".join(lines) + + @mcp.tool + async def wikipedia_summary(title: str, lang: str = c.DEFAULT_LANG) -> str: + """문서의 lead 요약(extract)을 조회한다(rest_v1: GET /api/rest_v1/page/summary/{title}). + + 리다이렉트를 자동 추적한다. 요약·문서 URL·Wikidata Q-id(있으면)·좌표(지리 문서)를 돌려준다. + 동음이의(disambiguation) 문서면 안내를 덧붙인다. + + Args: + title: 문서 제목(공백/슬래시 그대로 — 경로 인코딩은 내부 처리). 필수. + lang: 언어판 코드(소문자). 기본 `en`. + """ + s = WikipediaSettings() + try: + code = c.validate_lang(lang) + except ValueError as e: + return str(e) + + url = f"{c.wiki_host(code)}{c.summary_path(title)}" + try: + body = await get_json(url, headers=_headers(s.user_agent, s.api_token)) + except UpstreamError as e: + return _explain(e) + + if not isinstance(body, dict): + return f"응답: {body}" + sm = c.SummaryResponse.model_validate(body) + head = sm.title or title + if sm.description: + head += f" — {sm.description}" + lines = [head] + if sm.extract: + lines.append(sm.extract) + page_url = ( + sm.content_urls.desktop.page if (sm.content_urls and sm.content_urls.desktop) else None + ) + if page_url: + lines.append(f"URL: {page_url}") + if sm.wikibase_item: + lines.append(f"Wikidata: {sm.wikibase_item}") + if sm.coordinates and sm.coordinates.lat is not None and sm.coordinates.lon is not None: + lines.append(f"좌표: {sm.coordinates.lat}, {sm.coordinates.lon}") + if sm.type == "disambiguation": + lines.append("(동음이의 문서입니다 — 더 구체적인 제목으로 다시 조회하세요.)") + return "\n".join(lines) + + @mcp.tool + async def wikipedia_extract( + title: str, + lang: str = c.DEFAULT_LANG, + intro_only: bool = True, + max_chars: int | None = None, + ) -> str: + """문서 평문 본문을 조회한다(TextExtracts: GET /w/api.php?action=query&prop=extracts). + + 리다이렉트를 추적하고, 기본은 도입부(intro)만 평문으로 돌려준다. + + Args: + title: 문서 제목. 필수. + lang: 언어판 코드(소문자). 기본 `en`. + intro_only: True면 도입부만(exintro). False면 전체 본문. 기본 True. + max_chars: 글자 수 제한(exchars, 1..1200). 미지정 시 제한 없음. + """ + s = WikipediaSettings() + try: + code = c.validate_lang(lang) + if max_chars is not None: + c.validate_exchars(max_chars) + except ValueError as e: + return str(e) + + url = f"{c.wiki_host(code)}{c.ACTION_API_PATH}" + params = c.extracts_params(title, intro_only=intro_only, max_chars=max_chars) + try: + body = await get_json(url, params=params, headers=_headers(s.user_agent, s.api_token)) + except UpstreamError as e: + return _explain(e) + + if not isinstance(body, dict): + return f"응답: {body}" + action_err = _action_error(body) + if action_err: + return action_err + pages = (body.get("query") or {}).get("pages") or [] + if not pages: + return "문서를 찾을 수 없습니다" + page = c.ExtractPage.model_validate(pages[0]) + if page.missing: + return "문서를 찾을 수 없습니다" + if not page.extract: + return f"{page.title or title}: (본문 없음)" + return f"{page.title or title}\n{page.extract}" + + @mcp.tool + async def wikipedia_links( + title: str, lang: str = c.DEFAULT_LANG, limit: int = c.DEFAULT_LINKS_LIMIT + ) -> str: + """문서의 나가는 링크와 분류를 조회한다(Action API: prop=links|categories). + + 문서(ns 0) 링크 제목과 분류(category)를 나열한다. + + Args: + title: 문서 제목. 필수. + lang: 언어판 코드(소문자). 기본 `en`. + limit: 링크/분류 각 최대 개수. 기본 50, 1..500. + """ + s = WikipediaSettings() + try: + code = c.validate_lang(lang) + c.validate_limit(limit, maximum=c.MAX_LINKS_LIMIT) + except ValueError as e: + return str(e) + + url = f"{c.wiki_host(code)}{c.ACTION_API_PATH}" + params = c.links_params(title, limit=limit) + try: + body = await get_json(url, params=params, headers=_headers(s.user_agent, s.api_token)) + except UpstreamError as e: + return _explain(e) + + if not isinstance(body, dict): + return f"응답: {body}" + action_err = _action_error(body) + if action_err: + return action_err + pages = (body.get("query") or {}).get("pages") or [] + if not pages: + return "문서를 찾을 수 없습니다" + page = c.LinksPage.model_validate(pages[0]) + if page.missing: + return "문서를 찾을 수 없습니다" + + lines = [page.title or title] + if page.links: + lines.append(f"연결 문서 {len(page.links)}개:") + lines += [f"- {ln.title}" for ln in page.links if ln.title] + else: + lines.append("연결 문서: 없음") + if page.categories: + lines.append(f"분류 {len(page.categories)}개:") + lines += [f"- {cat.title}" for cat in page.categories if cat.title] + else: + lines.append("분류: 없음") + return "\n".join(lines) diff --git a/changelog.d/wikipedia.md b/changelog.d/wikipedia.md new file mode 100644 index 0000000..6738cf3 --- /dev/null +++ b/changelog.d/wikipedia.md @@ -0,0 +1 @@ +- **wikipedia**: 위키백과 읽기 서비스 추가 — 검색·요약·본문·링크 4개 GET 도구(`wikipedia_search`/`wikipedia_summary`/`wikipedia_extract`/`wikipedia_links`), 무인증이나 **User-Agent 헤더 요구**(기본값 상수, `WIKIPEDIA_USER_AGENT`로 덮어씀) + (선택) `WIKIPEDIA_API_TOKEN` Bearer로 레이트리밋 완화, 세 엔드포인트 혼합(per-wiki REST 검색 `/w/rest.php/v1/search/page` · rest_v1 요약 `/api/rest_v1/page/summary/{title}`(Wikidata Q-id·좌표 노출) · Action API TextExtracts/links|categories `formatversion=2` 배열), 언어판별 호스트(`{lang}.wikipedia.org`)·**Action API HTTP 200+error 봉투** 매핑, ⚠️ deprecating `api.wikimedia.org/core/v1/*` 회피 diff --git a/docs/providers.md b/docs/providers.md index 501c60b..c9dc6a3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -357,6 +357,28 @@ --- +## wikipedia — 위키백과 읽기 (검색·요약·본문·링크) +- 상태: `done` +- 인증: **무인증**(전체 읽기 동작). 단 **`User-Agent` 헤더 요구**(없거나 약하면 403/스로틀) → 기본값 상수(`contract.DEFAULT_USER_AGENT`)를 항상 전송하고 `WIKIPEDIA_USER_AGENT`로 덮어쓴다(연락처 권장). (선택) `WIKIPEDIA_API_TOKEN` → `Authorization: Bearer`(UA와 함께) 전송 시 레이트리밋 완화. base **언어판별** `https://{lang}.wikipedia.org`. +- 공식 문서: + - per-wiki REST(검색) 레퍼런스: https://www.mediawiki.org/wiki/API:REST_API/Reference + - Wikimedia REST API(rest_v1 summary): https://www.mediawiki.org/wiki/Wikimedia_REST_API (per-wiki 명세: https://en.wikipedia.org/api/rest_v1/) + - TextExtracts(`prop=extracts`·`exintro`·`explaintext`·`exchars`): https://www.mediawiki.org/wiki/Extension:TextExtracts + - Action API Query(`prop=links|categories`·`formatversion=2`·`redirects`): https://www.mediawiki.org/wiki/API:Query +- 도구(MVP 4개, 전부 GET·읽기 · `https://{lang}.wikipedia.org`): + - `wikipedia_search(query, lang="en", limit=10)` — 클린 REST `/w/rest.php/v1/search/page?q=&limit=`(구식 Action `list=search` 아님). 제목·요약·스니펫(HTML 태그 제거) + - `wikipedia_summary(title, lang="en")` — rest_v1 `/api/rest_v1/page/summary/{title}`(path segment 인코딩). lead extract·문서 URL·**Wikidata Q-id**(`wikibase_item`)·좌표(지리)·동음이의 안내 + - `wikipedia_extract(title, lang="en", intro_only=True, max_chars=None)` — TextExtracts `/w/api.php?action=query&prop=extracts&explaintext=1&formatversion=2`. 도입부/전체·`exchars` + - `wikipedia_links(title, lang="en", limit=50)` — Action API `prop=links|categories&plnamespace=0&formatversion=2`. 나가는 문서 링크 + 분류 +- 특수성: 세 종류 엔드포인트 혼합(per-wiki REST 검색 · rest_v1 요약 · Action API 본문/링크). ⚠️ `api.wikimedia.org/core/v1/*`(통합 REST)는 2026-07 deprecation 예정·후속 없음 → 사용하지 않는다. Action API는 `formatversion=2`로 `query.pages`를 **깨끗한 배열**(pageid-keyed 객체 아님)로 받고 `redirects=1`로 리다이렉트를 추적한다. rest_v1 요약은 리다이렉트를 자동 추적한다. +- 응답: REST 검색 `{"pages":[{id,key,title,excerpt(HTML),description,thumbnail?}]}`(**total 없음**, 라이브 확인). rest_v1 요약 최상위 `{type,title,description,extract,content_urls.desktop.page,thumbnail.source,lang,wikibase_item,coordinates?}`. TextExtracts `{"query":{"pages":[{pageid,title,extract}|{title,missing:true}],"redirects":?}}`. links/categories `query.pages[0].{links[]{ns,title},categories[]{ns,title}}`(둘 다 없을 수 있음). ⚠️ Action API는 잘못된 파라미터에 **HTTP 200 + `{"error":{"code","info"}}`**(4xx 아님) → 본문 보고 `error.info` 매핑. +- 제약(라이브 확인): 검색 `limit` **1–100**(기본 10), 링크/분류 `limit` **1–500**(기본 50), `max_chars`(exchars) **1–1200**. `lang`은 소문자 언어 코드(`[a-z]`+하이픈 변형, 예 `en`·`ko`·`de`·`zh`·`simple`·`zh-yue`) — 형식 위반은 HTTP 전에 차단(호스트 오염 방지). 요약 404 / 본문·링크 `missing:true` → "문서를 찾을 수 없습니다". 무 User-Agent → 403, 스로틀 → 429. +- 스코프(MVP): 포함 = 검색·요약·본문·링크/분류 / 제외 = 편집·쓰기, 미디어 업로드, 위키데이터 엔티티 상세(요약의 `wikibase_item`만 브리지), `api.wikimedia.org/core/v1/*`(deprecating), CirrusSearch 고급 구문, parse/HTML 렌더, 카테고리 멤버 역방향(`list=categorymembers`) +- 코어 의존: `get_json`만으로 충분(User-Agent/Bearer는 `headers=`로 주입, 콘텐츠는 본문). 새 코어 동사 불필요. +- provenance 노트: 4개 엔드포인트·모든 응답 필드를 라이브(en.wikipedia.org)에서 직접 확인. Action API **HTTP 200+`{error}`** 봉투(`action=nonsense` → `badvalue`, `exchars=abc` → `badinteger`)·REST 검색 **total 부재**·`formatversion=2` 배열 형태·`missing:true`·요약 `wikibase_item`/`coordinates`·rest_v1 리다이렉트 추적 전부 라이브 확인. `WIKIPEDIA_API_TOKEN`(Bearer)은 토큰 없이 라이브 검증 불가 → **헤더 조립만 단위 테스트로 확인**(유효성·완화 효과 미검증). + +--- + ## 블록 템플릿 (복사해서 새 대상 추가) ```markdown diff --git a/docs/services.md b/docs/services.md index 4ba516c..3c8b521 100644 --- a/docs/services.md +++ b/docs/services.md @@ -2,7 +2,7 @@ > ⚙️ 자동 생성 — 직접 수정하지 마세요. `arcsolve-mcp catalog`로 재생성됩니다. -현재 **21개 서비스 · 총 79개 도구**. +현재 **22개 서비스 · 총 83개 도구**. ## airkorea — 에어코리아 대기오염정보 읽기(시도·측정소 실시간 측정 + 예보) 공식 문서: https://www.data.go.kr/data/15073861/openapi.do @@ -195,6 +195,16 @@ | `usgs_count_earthquakes` | 조건에 매칭되는 지진 건수만 센다(GET /count?format=geojson). | | `usgs_search_earthquakes` | USGS에서 지진 이벤트를 검색/나열한다(GET /query?format=geojson). | +## wikipedia — 위키백과 읽기(검색·요약·본문·링크) +공식 문서: https://www.mediawiki.org/wiki/API:REST_API/Reference + +| 도구 | 설명 | +|------|------| +| `wikipedia_extract` | 문서 평문 본문을 조회한다(TextExtracts: GET /w/api.php?action=query&prop=extracts). | +| `wikipedia_links` | 문서의 나가는 링크와 분류를 조회한다(Action API: prop=links|categories). | +| `wikipedia_search` | 위키백과에서 문서를 검색한다(클린 REST: GET /w/rest.php/v1/search/page). | +| `wikipedia_summary` | 문서의 lead 요약(extract)을 조회한다(rest_v1: GET /api/rest_v1/page/summary/{title}). | + ## zotero — Zotero 라이브러리 읽기(Web API v3 + 로컬 데스크톱 API) 공식 문서: https://www.zotero.org/support/dev/web_api/v3/basics diff --git a/tests/test_wikipedia_contract.py b/tests/test_wikipedia_contract.py new file mode 100644 index 0000000..91209dc --- /dev/null +++ b/tests/test_wikipedia_contract.py @@ -0,0 +1,298 @@ +"""Wikipedia 계약 검증 — 네트워크 없이 contract.py만 테스트. + +검증 범위: 상수(호스트·경로·기본 User-Agent)·언어/limit/exchars 검증·제목 인코더·HTML 스트립· +쿼리 빌더·부분 응답 모델 파싱(REST 검색 pages[]·요약 최상위(wikibase_item·coordinates)· +TextExtracts formatversion=2 배열·missing 페이지·links/categories·Action 에러 봉투). +HTTP 호출은 일절 하지 않는다. +""" + +import pytest + +from arcsolve.services.wikipedia.contract import ( + ACTION_API_PATH, + DEFAULT_USER_AGENT, + MAX_EXCHARS, + MAX_LINKS_LIMIT, + MAX_SEARCH_LIMIT, + REST_SEARCH_PATH, + REST_V1_SUMMARY_PREFIX, + ActionError, + ExtractPage, + LinksPage, + SearchResponse, + SummaryResponse, + encode_title, + extracts_params, + links_params, + strip_html, + summary_path, + validate_exchars, + validate_lang, + validate_limit, + wiki_host, +) + + +# ─── 상수 ─────────────────────────────────────────────────── + + +def test_constants_match_official(): + assert REST_SEARCH_PATH == "/w/rest.php/v1/search/page" + assert REST_V1_SUMMARY_PREFIX == "/api/rest_v1/page/summary/" + assert ACTION_API_PATH == "/w/api.php" + assert "ArcSolve-MCP" in DEFAULT_USER_AGENT + assert "ArcSolver/ArcSolve-MCP" in DEFAULT_USER_AGENT + + +def test_wiki_host_per_language(): + assert wiki_host("en") == "https://en.wikipedia.org" + assert wiki_host("ko") == "https://ko.wikipedia.org" + assert wiki_host("simple") == "https://simple.wikipedia.org" + + +# ─── 제목 인코더 / 경로 빌더 ─────────────────────────────── + + +def test_encode_title_encodes_space_and_slash(): + # path segment라 슬래시도 인코딩(%2F), 공백은 %20. + assert encode_title("Quantum computing") == "Quantum%20computing" + assert encode_title("AC/DC") == "AC%2FDC" + assert encode_title(" Paris ") == "Paris" # 트림 + + +def test_summary_path_builds_encoded_segment(): + assert summary_path("Quantum computing") == ("/api/rest_v1/page/summary/Quantum%20computing") + + +# ─── 언어 / limit / exchars 검증 ─────────────────────────── + + +def test_validate_lang_normalizes_and_accepts_variants(): + assert validate_lang("EN") == "en" # 소문자화 + assert validate_lang(" ko ") == "ko" # 트림 + assert validate_lang("simple") == "simple" + assert validate_lang("zh-yue") == "zh-yue" # 하이픈 변형 허용 + + +def test_validate_lang_rejects_bad_format(): + for bad in ("en_US", "en1", "EN US", "", "..", "zh/yue"): + with pytest.raises(ValueError): + validate_lang(bad) + + +def test_validate_limit_bounds(): + assert validate_limit(1, maximum=MAX_SEARCH_LIMIT) == 1 + assert validate_limit(100, maximum=MAX_SEARCH_LIMIT) == 100 + assert validate_limit(500, maximum=MAX_LINKS_LIMIT) == 500 + with pytest.raises(ValueError): + validate_limit(0, maximum=MAX_SEARCH_LIMIT) + with pytest.raises(ValueError): + validate_limit(101, maximum=MAX_SEARCH_LIMIT) # 검색 상한 100 + with pytest.raises(ValueError): + validate_limit(501, maximum=MAX_LINKS_LIMIT) # 링크 상한 500 + + +def test_validate_exchars_bounds(): + assert validate_exchars(1) == 1 + assert validate_exchars(MAX_EXCHARS) == 1200 + with pytest.raises(ValueError): + validate_exchars(0) + with pytest.raises(ValueError): + validate_exchars(1201) + + +# ─── HTML 스트립 ──────────────────────────────────────────── + + +def test_strip_html_removes_tags_and_unescapes(): + snippet = ( + 'quantum computing (abbreviated 'n.') & more' + ) + out = strip_html(snippet) + assert "quantum computing', + "matched_title": None, + "description": "Computer hardware technology that uses quantum mechanics", + "thumbnail": { + "mimetype": "image/jpeg", + "width": 60, + "height": 80, + "url": "//upload.wikimedia.org/x.jpg", + }, + "extra": "ignored", + } + ] + } + r = SearchResponse.model_validate(body) + assert len(r.pages) == 1 + p = r.pages[0] + assert p.key == "Quantum_computing" + assert p.title == "Quantum computing" + assert p.description.startswith("Computer hardware") + assert p.thumbnail.url == "//upload.wikimedia.org/x.jpg" + # excerpt는 HTML — 스트립 헬퍼로 평문화. + assert strip_html(p.excerpt) == "quantum computing" + + +def test_summary_response_fields_including_wikibase_and_coordinates(): + body = { + "type": "standard", + "title": "Paris", + "description": "Capital of France", + "extract": "Paris is the capital and largest city of France.", + "lang": "en", + "wikibase_item": "Q90", + "content_urls": { + "desktop": { + "page": "https://en.wikipedia.org/wiki/Paris", + "edit": "https://en.wikipedia.org/wiki/Paris?action=edit", + }, + "mobile": {"page": "https://en.m.wikipedia.org/wiki/Paris"}, + }, + "thumbnail": {"source": "https://upload.wikimedia.org/paris.jpg", "width": 320}, + "coordinates": {"lat": 48.8567, "lon": 2.3522}, + "extra": "ignored", + } + sm = SummaryResponse.model_validate(body) + assert sm.type == "standard" + assert sm.title == "Paris" + assert sm.wikibase_item == "Q90" # Wikidata Q-id 브리지 + assert sm.content_urls.desktop.page == "https://en.wikipedia.org/wiki/Paris" + assert sm.thumbnail.source.endswith("paris.jpg") + assert sm.coordinates.lat == 48.8567 and sm.coordinates.lon == 2.3522 + + +def test_summary_response_disambiguation_without_coordinates(): + body = {"type": "disambiguation", "title": "Mercury", "extract": "Mercury may refer to ..."} + sm = SummaryResponse.model_validate(body) + assert sm.type == "disambiguation" + assert sm.coordinates is None # 비-지리 문서엔 좌표 없음 + assert sm.wikibase_item is None + + +def test_extract_page_formatversion2_array_shape(): + # formatversion=2 → query.pages가 깨끗한 배열(pageid-keyed 객체 아님). + body = { + "batchcomplete": True, + "query": { + "pages": [ + { + "pageid": 23862, + "ns": 0, + "title": "Python (programming language)", + "extract": "Python is a high-level language.", + } + ] + }, + } + pages = body["query"]["pages"] + assert isinstance(pages, list) # 배열 + page = ExtractPage.model_validate(pages[0]) + assert page.pageid == 23862 + assert page.title.startswith("Python") + assert page.extract.startswith("Python is") + assert page.missing is None + + +def test_extract_page_missing(): + page = ExtractPage.model_validate({"ns": 0, "title": "ZZZNope", "missing": True}) + assert page.missing is True + assert page.extract is None + + +def test_links_page_with_links_and_categories(): + page = LinksPage.model_validate( + { + "pageid": 23862, + "ns": 0, + "title": "Python (programming language)", + "links": [ + {"ns": 0, "title": '"Hello, World!" program'}, + {"ns": 0, "title": "ALGOL 68"}, + ], + "categories": [ + {"ns": 14, "title": "Category:Programming languages"}, + ], + } + ) + assert page.title.startswith("Python") + assert len(page.links) == 2 + assert page.links[0].title == '"Hello, World!" program' + assert page.categories[0].title == "Category:Programming languages" + assert page.missing is None + + +def test_links_page_absent_links_default_empty(): + # links/categories는 둘 다 없을 수 있다 → 기본 빈 리스트. + page = LinksPage.model_validate({"pageid": 1, "title": "Stub"}) + assert page.links == [] + assert page.categories == [] + + +def test_links_page_missing(): + page = LinksPage.model_validate({"title": "ZZZNope", "missing": True}) + assert page.missing is True + + +def test_action_error_envelope(): + # 라이브: action=nonsense → HTTP 200 + {"error":{"code":"badvalue","info":...}}. + err = ActionError.model_validate( + { + "code": "badvalue", + "info": 'Unrecognized value for parameter "action": nonsense.', + "*": "docref...", + } + ) + assert err.code == "badvalue" + assert "Unrecognized value" in err.info diff --git a/tests/test_wikipedia_tools.py b/tests/test_wikipedia_tools.py new file mode 100644 index 0000000..b31dedd --- /dev/null +++ b/tests/test_wikipedia_tools.py @@ -0,0 +1,320 @@ +"""Wikipedia 도구 런타임 검증 — 네트워크 없이 요청 조립·응답 파싱·에러 매핑·UA/토큰 확인. + +get_json은 본문 dict를 돌려주므로 RecordingHTTP의 ret도 dict로 준다. User-Agent 헤더가 항상 +실리는지(필수), Bearer는 WIKIPEDIA_API_TOKEN이 있을 때만 붙는지, lang/limit 검증이 HTTP 전에 +막는지, Action API의 **HTTP 200 + error 봉투**와 403/404/429 매핑을 확인한다. +""" + +import pytest + +from arcsolve.http import UpstreamError +from arcsolve.services.wikipedia.tools import register + +MOD = "arcsolve.services.wikipedia.tools" + + +@pytest.fixture +def tools(monkeypatch, load_tools): + """기본 환경(무인증·기본 User-Agent·토큰 없음).""" + monkeypatch.delenv("WIKIPEDIA_USER_AGENT", raising=False) + monkeypatch.delenv("WIKIPEDIA_API_TOKEN", raising=False) + return load_tools(register) + + +# ─── 검색 (per-wiki REST) ────────────────────────────────── + + +async def test_search_request_and_output(tools, monkeypatch, recording_http): + body = { + "pages": [ + { + "id": 25220, + "key": "Quantum_computing", + "title": "Quantum computing", + "excerpt": 'quantum computing', + "description": "Computer hardware technology", + } + ] + } + http = recording_http(ret=body) + monkeypatch.setattr(f"{MOD}.get_json", http) + + out = await tools["wikipedia_search"](query="quantum computing", limit=5) + assert http.last["url"] == "https://en.wikipedia.org/w/rest.php/v1/search/page" + assert http.last["params"]["q"] == "quantum computing" + assert http.last["params"]["limit"] == 5 + # User-Agent는 항상 실린다(필수). 토큰 없으면 Bearer 없음. + assert "User-Agent" in http.last["headers"] + assert "Authorization" not in http.last["headers"] + assert "Quantum_computing" in out and "Quantum computing" in out + assert "Computer hardware technology" in out + # excerpt는 HTML 태그가 제거된 평문으로. + assert "