Skip to content

Commit e930d71

Browse files
ArcSolverclaude
andcommitted
feat(openalex): 학술 그래프 읽기 서비스 (works/authors 검색·조회)
학술 빈틈 두 번째 — OpenAlex MCP. 무인증(키 선택)·JSON·페이지네이션은 본문 meta라 get_json만 사용. 도구 4종(전부 GET·읽기): search_works / get_work / search_authors / get_author. 인증: api_key·mailto는 헤더가 아니라 쿼리 파라미터(선택). per-page는 하이픈·1..200 검증. 응답 {meta,results} 봉투 모델. 라이브 API + 공식 문서로 계약 검증. 머지 전 독립·적대적 검증(라이브 curl 대조)에서 발견·교정: - bare DOI/ORCID는 OpenAlex가 404 → doi:/orcid: 네임스페이스로 자동 정규화(라이브 확인). - 무효 키는 401(403 아님) → _explain에 401 분기 추가. - 404 본문이 HTML → 비-JSON payload는 메시지에 노출 안 함. - 미검증 "100 req/s" 수치 제거(현 문서는 크레딧 모델). 통합: providers done, .env.example, 배선 스모크 6서비스·31도구, 카탈로그/체인지로그 재생성. 검증: pytest 204 passed, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ec55255 commit e930d71

12 files changed

Lines changed: 1009 additions & 3 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ ZOTERO_USER_ID=
3838
ZOTERO_GROUP_ID=
3939
# [local] 데스크톱 로컬 API base (기본값; Zotero 데스크톱에서 로컬 API 활성 필요)
4040
ZOTERO_LOCAL_BASE=http://localhost:23119/api
41+
42+
# ─── OpenAlex ────────────────────────────────────────────
43+
# (선택) openalex.org/settings/api 에서 발급한 무료 API 키 — 키 없이도 동작(무료 일일 크레딧)
44+
OPENALEX_API_KEY=
45+
# (선택) polite pool용 연락 이메일 — 응답 안정성↑
46+
OPENALEX_MAILTO=

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- **line**: LINE Messaging API push 텍스트 MCP 추가 — `line_send_text`(전송 메시지 id 반환)
1919
- **line**: push 응답 계약을 공식 스펙(`sentMessages[]`)에 맞게 수정, text 길이를 UTF-16 코드 유닛으로 검증
2020
- **line**: 코어 도구 확장 — `line_reply_text`(reply, sentMessages), `line_multicast_text`(userId 최대 500, 빈 응답), `line_broadcast_text`(빈 응답), `line_get_profile`(Profile 조회) 추가
21+
- **openalex**: OpenAlex 학술 그래프 읽기 서비스 추가 — works/authors 검색·단건 조회 4개 GET 도구(`openalex_search_works`/`openalex_get_work`/`openalex_search_authors`/`openalex_get_author`), API 키·mailto는 선택 쿼리 파라미터(키 없이도 동작), 본문 meta 기반 건수 안내
2122
- **repo**: README 상단 배지(CI · License · Python) 추가, 저장소 public 공개
2223
- **telegram**: sendMessage 기반 telegram_send_message 추가
2324
- **telegram**: 코어 도구 확장 — getMe(헬스체크)/sendPhoto/sendDocument/editMessageText/deleteMessage 추가
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# OpenAlex 서비스
2+
3+
OpenAlex 학술 그래프 **읽기** 래퍼 — 논문(works)·저자(authors) 검색·단건 조회. 전부 GET·JSON.
4+
**인증은 선택**(키 없이도 무료 일일 크레딧으로 동작), 키·polite 이메일은 **쿼리 파라미터**다.
5+
6+
## 계약 출처 (공식 문서)
7+
- API 개요(base URL): https://developers.openalex.org/how-to-use-the-api/api-overview
8+
- 리스트/검색(search·filter·sort·per-page·page·cursor·meta 봉투): https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
9+
- Work 오브젝트(필드): https://developers.openalex.org/api-entities/works/work-object
10+
- Author 오브젝트(필드): https://developers.openalex.org/api-entities/authors/author-object
11+
- 인증/요금(api_key·mailto·레이트리밋): https://developers.openalex.org/guides/authentication
12+
13+
> 계약 본체는 [`contract.py`](contract.py)에 코드로 박제되어 있다(엔드포인트 경로 빌더·쿼리 제약·응답 모델).
14+
15+
## 인증 (선택)
16+
`OpenAlexSettings`(`OPENALEX_*`)가 자격증명을 로드한다. **헤더가 아니라 쿼리 파라미터**다.
17+
18+
| env | 쿼리 파라미터 | 비고 |
19+
|---|---|---|
20+
| `OPENALEX_API_KEY` | `api_key=<키>` | 선택(권장). 없으면 무료 일일 크레딧으로 동작 |
21+
| `OPENALEX_MAILTO` | `mailto=<이메일>` | 선택. polite pool(안정적인 레이트리밋) |
22+
23+
- 키/이메일 둘 다 없어도 호출은 성공한다.
24+
- base `https://api.openalex.org`. 페이지네이션/건수는 **응답 본문 meta**(헤더 아님)이므로 코어 `get_json`만 쓴다.
25+
26+
## 엔드포인트 (전부 GET · `<base><path>`)
27+
| 도구 | METHOD · PATH |
28+
|------|------|
29+
| works 검색/나열 | `GET /works?search=&filter=&sort=&per-page=&page=` |
30+
| work 단건 | `GET /works/{id}` (OpenAlex ID `W…` 또는 DOI) |
31+
| authors 검색/나열 | `GET /authors?search=&per-page=&page=` |
32+
| author 단건 | `GET /authors/{id}` (OpenAlex ID `A…` 또는 ORCID URL) |
33+
34+
> **쿼리 파라미터명은 `per-page`(하이픈)**, 응답 본문 필드는 `per_page`(언더스코어). `per-page`는 1–200. filter는 `attr:value`(콤마=AND, `|`=OR, `!`=NOT). 정렬은 `sort`(예: `cited_by_count:desc`).
35+
> 리스트 응답 봉투: `{meta:{count,page,per_page,next_cursor?,cost_usd}, results:[...], group_by:[]}` — "총 N건 · page P"는 본문 `meta`에서 만든다.
36+
37+
## 셋업
38+
1. (선택) [OpenAlex 인증 가이드](https://developers.openalex.org/guides/authentication)에서 API 키 발급.
39+
2. `.env`(둘 다 선택):
40+
- `OPENALEX_API_KEY=<키>`
41+
- `OPENALEX_MAILTO=<이메일>`
42+
43+
> 키는 선택 쿼리 파라미터 방식 — 인터랙티브 OAuth가 아니므로 `arcsolve-mcp auth openalex` 단계는 없다.
44+
45+
## 도구
46+
| 도구 | 설명 |
47+
|------|------|
48+
| `openalex_search_works(query?, filter?, sort?, per_page?, page?)` | 논문 검색/나열. `filter`=`attr:value`(콤마=AND/`\|`=OR/`!`=NOT), `sort`=`cited_by_count:desc` 등. per_page 기본 25·1..200 |
49+
| `openalex_get_work(work_id)` | 단일 논문 조회(OpenAlex ID `W…` 또는 DOI). id/year/type/인용/제목/저자 |
50+
| `openalex_search_authors(query?, per_page?, page?)` | 저자 검색/나열. 논문 수·인용 수 |
51+
| `openalex_get_author(author_id)` | 단일 저자 조회(OpenAlex ID `A…` 또는 ORCID). 논문 수·인용 수·ORCID |
52+
53+
## 범위 / 제약 (공식)
54+
- **읽기만.** works/authors 검색·단건 조회만(MVP).
55+
- 제외: sources·institutions·topics·publishers·funders, `group_by` 집계, cursor 딥페이지네이션, ngrams/autocomplete.
56+
- `per-page` 1–200(기본 25). page 기반 페이지네이션은 최대 **10,000건**(이후 cursor 필요 — 범위 밖). 레이트리밋 100 req/s.
57+
58+
## UNVERIFIED / provenance 노트
59+
- `meta.cost_usd`는 라이브 응답에서 관측했으나 공식 산문의 표준화 명시는 약하다 → `float|None`로 느슨히(`extra="ignore"`).
60+
- Work의 `authorships`·`primary_location`·`open_access`는 중첩 스키마가 풍부해 `list[dict]`/`dict`로 느슨히 받는다(필요한 `author.display_name`/`author.id`만 출력에서 사용). `contract.py`의 출처 주석 참고.
61+
62+
## 확장 포인트
63+
- `select=`(필드 선택), cursor 페이지네이션(`cursor=*`), `group_by=`(집계), 다른 엔티티(`/sources`·`/institutions`·`/topics` 등)는 동일 패턴으로 경로 상수·도구 추가.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from arcsolve.service import Service
2+
from arcsolve.services.openalex.tools import register
3+
4+
SERVICE = Service(
5+
name="openalex",
6+
register=register,
7+
docs_url="https://developers.openalex.org/how-to-use-the-api/api-overview",
8+
summary="OpenAlex 학술 그래프 읽기(works/authors 검색·조회)",
9+
# API 키는 선택 쿼리 파라미터(키 없이도 동작) — 인터랙티브 OAuth 아님 → make_auth_client 없음.
10+
)
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""OpenAlex 학술 그래프 읽기 계약(contract).
2+
3+
상류 API의 '진실'만 담는다 — 엔드포인트 상수, 경로 빌더, 쿼리 제약/빌더, 응답 모델.
4+
MCP/네트워크 무의존(순수 상수 + pydantic 모델).
5+
6+
전부 GET·JSON·읽기. 인증은 **선택**(키 없이도 동작). 키(`api_key`)와 polite-pool 이메일
7+
(`mailto`)은 **쿼리 파라미터**다(헤더 아님). 페이지네이션/건수는 **응답 본문 meta**에 실리므로
8+
코어 `get_json`만으로 충분하다(헤더 동사 불필요).
9+
10+
출처(공식 문서 — developers.openalex.org):
11+
- API 개요(base URL): https://developers.openalex.org/how-to-use-the-api/api-overview
12+
- 리스트/검색(search·filter·sort·per-page·page·cursor·meta 봉투):
13+
https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
14+
- Work 오브젝트(필드): https://developers.openalex.org/api-entities/works/work-object
15+
- Author 오브젝트(필드): https://developers.openalex.org/api-entities/authors/author-object
16+
- 인증/요금(api_key·mailto polite pool·레이트리밋): https://developers.openalex.org/guides/authentication
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import re
22+
23+
from pydantic import BaseModel
24+
25+
# ─── base URL / 엔드포인트 상수 ─────────────────────────────
26+
# 출처(base): https://developers.openalex.org/how-to-use-the-api/api-overview
27+
# ("https://api.openalex.org")
28+
# 출처(엔드포인트 /works·/authors): get-lists-of-entities (entity 컬렉션 경로)
29+
BASE_URL = "https://api.openalex.org"
30+
WORKS = "/works"
31+
AUTHORS = "/authors"
32+
33+
34+
# bare DOI/ORCID는 OpenAlex가 거부(404)한다 — 네임스페이스 접두(doi:/orcid:)나 URL이라야 한다
35+
# ("a bare identifier without any prefix or URL wrapper is not supported"). 자동 정규화한다.
36+
# OpenAlex ID(W…/A…)·전체 URL·이미 접두가 붙은 값은 그대로 둔다.
37+
_BARE_DOI = re.compile(r"^10\.\d{4,9}/.+$", re.IGNORECASE)
38+
_BARE_ORCID = re.compile(r"^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$", re.IGNORECASE)
39+
40+
41+
def normalize_work_id(work_id: str) -> str:
42+
"""bare DOI(`10.x/...`)면 `doi:` 접두를 붙인다(OpenAlex ID·URL·접두값은 그대로)."""
43+
wid = work_id.strip()
44+
return f"doi:{wid}" if _BARE_DOI.match(wid) else wid
45+
46+
47+
def normalize_author_id(author_id: str) -> str:
48+
"""bare ORCID(`0000-0000-0000-0000`)면 `orcid:` 접두를 붙인다(OpenAlex ID·URL은 그대로)."""
49+
aid = author_id.strip()
50+
return f"orcid:{aid}" if _BARE_ORCID.match(aid) else aid
51+
52+
53+
def work_path(work_id: str) -> str:
54+
"""단건 work 경로 /works/{id}. id = OpenAlex ID(`W…`)·DOI(bare/doi:/URL)·기타 접두.
55+
56+
bare DOI는 `doi:`로 정규화한다(라이브 확인: bare DOI는 404, `doi:`는 200).
57+
출처: https://developers.openalex.org/api-entities/works/work-object
58+
"""
59+
return f"{WORKS}/{normalize_work_id(work_id)}"
60+
61+
62+
def author_path(author_id: str) -> str:
63+
"""단건 author 경로 /authors/{id}. id = OpenAlex ID(`A…`)·ORCID(bare/orcid:/URL).
64+
65+
bare ORCID는 `orcid:`로 정규화한다.
66+
출처: https://developers.openalex.org/api-entities/authors/author-object
67+
"""
68+
return f"{AUTHORS}/{normalize_author_id(author_id)}"
69+
70+
71+
# ─── 쿼리 파라미터 제약(공식) ───────────────────────────────
72+
# 출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
73+
# ("per-page" 1–200, page 기반 페이지네이션은 최대 10,000건까지 — 이후 cursor)
74+
# 주의: **쿼리 파라미터명은 `per-page`(하이픈)**, 응답 본문 필드명은 `per_page`(언더스코어).
75+
DEFAULT_PER_PAGE = 25
76+
MIN_PER_PAGE = 1
77+
MAX_PER_PAGE = 200
78+
MAX_PAGE_RESULTS = 10000
79+
80+
# 공식 쿼리 파라미터명(정확한 철자 — 하이픈/언더스코어 혼동 방지).
81+
# 출처: get-lists-of-entities(search·filter·sort·per-page·page) + authentication(api_key·mailto)
82+
PARAM_SEARCH = "search"
83+
PARAM_FILTER = "filter"
84+
PARAM_SORT = "sort"
85+
PARAM_PER_PAGE = "per-page" # 하이픈!
86+
PARAM_PAGE = "page"
87+
PARAM_API_KEY = "api_key"
88+
PARAM_MAILTO = "mailto"
89+
90+
91+
def validate_per_page(per_page: int) -> int:
92+
"""per-page를 1..200 범위로 검증한다(공식 제약).
93+
94+
위반 시 ValueError(상류가 `{"error":...,"message":"...must be between 1 and 200"}`로
95+
400을 주기 전에 미리 막는다).
96+
출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
97+
"""
98+
if per_page < MIN_PER_PAGE or per_page > MAX_PER_PAGE:
99+
raise ValueError(
100+
f"per_page는 {MIN_PER_PAGE}..{MAX_PER_PAGE} 범위여야 합니다(현재 {per_page})."
101+
)
102+
return per_page
103+
104+
105+
def build_params(
106+
*,
107+
query: str | None = None,
108+
filter: str | None = None, # noqa: A002 (공식 파라미터명 "filter")
109+
sort: str | None = None,
110+
per_page: int | None = None,
111+
page: int | None = None,
112+
api_key: str | None = None,
113+
mailto: str | None = None,
114+
) -> dict[str, str | int]:
115+
"""리스트/검색 쿼리스트링을 만든다. None/빈값은 생략한다.
116+
117+
- query → `search`(전문 검색)
118+
- filter → `filter`(attr:value, 콤마=AND / `|`=OR / `!`=NOT)
119+
- sort → `sort`
120+
- per_page → `per-page`(하이픈! 1..200 검증)
121+
- page → `page`
122+
- api_key → `api_key`(쿼리 파라미터, 선택)
123+
- mailto → `mailto`(polite pool, 선택)
124+
출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
125+
+ https://developers.openalex.org/guides/authentication
126+
"""
127+
params: dict[str, str | int] = {}
128+
if query:
129+
params[PARAM_SEARCH] = query
130+
if filter:
131+
params[PARAM_FILTER] = filter
132+
if sort:
133+
params[PARAM_SORT] = sort
134+
if per_page is not None:
135+
params[PARAM_PER_PAGE] = validate_per_page(per_page)
136+
if page is not None:
137+
params[PARAM_PAGE] = page
138+
if api_key:
139+
params[PARAM_API_KEY] = api_key
140+
if mailto:
141+
params[PARAM_MAILTO] = mailto
142+
return params
143+
144+
145+
# ─── 응답 모델 ──────────────────────────────────────────────
146+
# 리스트 응답 봉투: {"meta":{...}, "results":[...], "group_by":[]}.
147+
# 단건은 entity 오브젝트가 곧 최상위. extra="ignore"로 느슨히 받고(부분 모델),
148+
# 확신하는 필드만 모델링한다.
149+
# 출처(봉투/meta): https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
150+
151+
152+
class Meta(BaseModel):
153+
"""리스트 응답의 meta 봉투.
154+
155+
count(총 건수)·page·per_page(언더스코어!)·next_cursor(cursor 페이지네이션 시).
156+
cost_usd는 라이브 응답에서 관측됨 → float|None로 느슨히 둔다.
157+
출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
158+
"""
159+
160+
model_config = {"extra": "ignore"}
161+
162+
count: int
163+
page: int | None = None
164+
per_page: int | None = None # 응답 본문 필드는 per_page(언더스코어)
165+
next_cursor: str | None = None
166+
cost_usd: float | None = None # 라이브 관측 — 공식 산문에 표준화 명시는 약함
167+
168+
169+
class Work(BaseModel):
170+
"""단일 Work 오브젝트(부분).
171+
172+
공식 필드: id · doi · display_name(+ title 별칭) · publication_year ·
173+
publication_date · type · cited_by_count · authorships(각 author.display_name/id) ·
174+
primary_location · open_access.
175+
출처: https://developers.openalex.org/api-entities/works/work-object
176+
"""
177+
178+
model_config = {"extra": "ignore"}
179+
180+
id: str
181+
doi: str | None = None
182+
display_name: str | None = None
183+
title: str | None = None # 공식상 display_name의 별칭
184+
publication_year: int | None = None
185+
publication_date: str | None = None
186+
type: str | None = None
187+
cited_by_count: int | None = None
188+
authorships: list[dict] | None = None # 각 항목 author.display_name/author.id → dict로 느슨히
189+
primary_location: dict | None = None
190+
open_access: dict | None = None
191+
192+
193+
class Author(BaseModel):
194+
"""단일 Author 오브젝트(부분).
195+
196+
공식 필드: id · display_name · orcid · works_count · cited_by_count.
197+
출처: https://developers.openalex.org/api-entities/authors/author-object
198+
"""
199+
200+
model_config = {"extra": "ignore"}
201+
202+
id: str
203+
display_name: str | None = None
204+
orcid: str | None = None
205+
works_count: int | None = None
206+
cited_by_count: int | None = None
207+
208+
209+
class WorksList(BaseModel):
210+
"""`/works` 리스트 응답 봉투.
211+
212+
출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
213+
"""
214+
215+
model_config = {"extra": "ignore"}
216+
217+
meta: Meta
218+
results: list[Work] = []
219+
220+
221+
class AuthorsList(BaseModel):
222+
"""`/authors` 리스트 응답 봉투.
223+
224+
출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
225+
"""
226+
227+
model_config = {"extra": "ignore"}
228+
229+
meta: Meta
230+
results: list[Author] = []
231+
232+
233+
class ErrorResponse(BaseModel):
234+
"""OpenAlex 에러 봉투 `{error, message}`.
235+
236+
예: per-page 범위 위반 시 message="...must be between 1 and 200".
237+
출처: https://developers.openalex.org/how-to-use-the-api/get-lists-of-entities
238+
"""
239+
240+
model_config = {"extra": "ignore"}
241+
242+
error: str | None = None
243+
message: str | None = None

0 commit comments

Comments
 (0)