apaper-mcp 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apaper_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Python implementation of the apaper-mcp server."""
2
+
3
+ __version__ = "0.2.0"
apaper_mcp/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run the apaper-mcp server as a Python module."""
2
+
3
+ from .server import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,176 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+
5
+ def _year_filter(minimum: int | None, maximum: int | None) -> str:
6
+ if minimum is None and maximum is None:
7
+ return ""
8
+ return f" in year range ({minimum if minimum is not None else 'earliest'}-{maximum if maximum is not None else 'latest'})"
9
+
10
+
11
+ def format_iacr_search_response(
12
+ papers: list[dict[str, Any]],
13
+ query: str,
14
+ year_min: int | None = None,
15
+ year_max: int | None = None,
16
+ ) -> str:
17
+ if not papers:
18
+ return f"No papers found for query: {query}{_year_filter(year_min, year_max)}"
19
+ lines = [
20
+ f"Found {len(papers)} IACR papers for query '{query}'{_year_filter(year_min, year_max)}:",
21
+ "",
22
+ ]
23
+ for index, paper in enumerate(papers, 1):
24
+ lines += [
25
+ f"{index}. **{paper['title']}**",
26
+ f" - Paper ID: {paper['paper_id']}",
27
+ f" - Authors: {', '.join(paper['authors'])}",
28
+ f" - URL: {paper['url']}",
29
+ f" - PDF: {paper['pdf_url']}",
30
+ ]
31
+ if paper["categories"]:
32
+ lines.append(f" - Categories: {', '.join(paper['categories'])}")
33
+ if paper["keywords"]:
34
+ lines.append(f" - Keywords: {', '.join(paper['keywords'])}")
35
+ if paper["abstract"]:
36
+ lines.append(f" - Abstract: {paper['abstract']}")
37
+ lines.append("")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def format_dblp_search_response(
42
+ results: list[dict[str, Any]], query: str, options: dict[str, Any]
43
+ ) -> str:
44
+ filters = []
45
+ if options.get("year_from") is not None or options.get("year_to") is not None:
46
+ filters.append(
47
+ f"year range ({options.get('year_from', 'earliest')}-{options.get('year_to', 'latest')})"
48
+ )
49
+ if options.get("venue_filter"):
50
+ filters.append(f"venue '{options['venue_filter']}'")
51
+ suffix = f" with filters: {', '.join(filters)}" if filters else ""
52
+ if not results:
53
+ return f"No papers found for query: {query}{suffix}"
54
+ if options.get("include_bibtex"):
55
+ lines = [
56
+ f"Found {len(results)} DBLP BibTeX entries for query '{query}'{suffix}:",
57
+ "",
58
+ ]
59
+ for index, result in enumerate(results, 1):
60
+ lines += [
61
+ f"{index}. DBLP Key: {result['dblp_key']}",
62
+ "```bibtex",
63
+ result["bibtex"],
64
+ "```",
65
+ "",
66
+ ]
67
+ return "\n".join(lines)
68
+ lines = [f"Found {len(results)} DBLP papers for query '{query}'{suffix}:", ""]
69
+ for index, result in enumerate(results, 1):
70
+ lines += [
71
+ f"{index}. **{result['title'] or 'Untitled'}**",
72
+ f" - DBLP Key: {result['dblp_key']}",
73
+ f" - Authors: {', '.join(result['authors'])}",
74
+ ]
75
+ if result.get("venue"):
76
+ lines.append(f" - Venue: {result['venue']}")
77
+ if result.get("year"):
78
+ lines.append(f" - Year: {result['year']}")
79
+ if result.get("doi"):
80
+ lines.append(f" - DOI: {result['doi']}")
81
+ if result.get("url"):
82
+ lines.append(f" - URL: {result['url']}")
83
+ lines.append("")
84
+ return "\n".join(lines)
85
+
86
+
87
+ def format_google_scholar_search_response(
88
+ papers: list[dict[str, Any]],
89
+ query: str,
90
+ year_low: int | None = None,
91
+ year_high: int | None = None,
92
+ ) -> str:
93
+ if not papers:
94
+ return f"No papers found for query: {query}{_year_filter(year_low, year_high)}"
95
+ lines = [
96
+ f"Found {len(papers)} Google Scholar papers for query '{query}'{_year_filter(year_low, year_high)}:",
97
+ "",
98
+ ]
99
+ for index, paper in enumerate(papers, 1):
100
+ lines += [
101
+ f"{index}. **{paper['title']}**",
102
+ f" - Authors: {', '.join(paper['authors'])}",
103
+ ]
104
+ if paper["citations"] > 0:
105
+ lines.append(f" - Citations: {paper['citations']}")
106
+ if paper.get("published_date"):
107
+ try:
108
+ lines.append(
109
+ f" - Year: {datetime.fromisoformat(paper['published_date'].replace('Z', '+00:00')).year}"
110
+ )
111
+ except ValueError:
112
+ pass
113
+ if paper.get("url"):
114
+ lines.append(f" - URL: {paper['url']}")
115
+ if paper.get("abstract"):
116
+ lines.append(f" - Abstract: {paper['abstract']}")
117
+ lines.append("")
118
+ return "\n".join(lines)
119
+
120
+
121
+ def format_arxiv_search_response(
122
+ papers: list[dict[str, Any]], query: str, options: dict[str, Any] | None = None
123
+ ) -> str:
124
+ options = options or {}
125
+ filters = []
126
+ if options.get("date_from") or options.get("date_to"):
127
+ filters.append(
128
+ f"date range ({options.get('date_from', 'earliest')} to {options.get('date_to', 'latest')})"
129
+ )
130
+ if options.get("categories"):
131
+ filters.append(f"categories [{', '.join(options['categories'])}]")
132
+ suffix = f" with filters: {', '.join(filters)}" if filters else ""
133
+ if not papers:
134
+ return f"No arXiv papers found for query: {query}{suffix}"
135
+ lines = [f"Found {len(papers)} arXiv papers for query '{query}'{suffix}:", ""]
136
+ for index, p in enumerate(papers, 1):
137
+ lines += [
138
+ f"{index}. **{p['title'] or 'Untitled'}**",
139
+ f" - Paper ID: {p['paper_id']}",
140
+ ]
141
+ if p["authors"]:
142
+ lines.append(f" - Authors: {', '.join(p['authors'])}")
143
+ if p["primary_category"]:
144
+ lines.append(f" - Primary Category: {p['primary_category']}")
145
+ if p["categories"]:
146
+ lines.append(f" - Categories: {', '.join(p['categories'])}")
147
+ if p["published_date"]:
148
+ lines.append(f" - Published: {p['published_date']}")
149
+ if p["doi"]:
150
+ lines.append(f" - DOI: {p['doi']}")
151
+ if p["journal_ref"]:
152
+ lines.append(f" - Journal Ref: {p['journal_ref']}")
153
+ if p["url"]:
154
+ lines.append(f" - URL: {p['url']}")
155
+ if p["pdf_url"]:
156
+ lines.append(f" - PDF: {p['pdf_url']}")
157
+ if p["abstract"]:
158
+ lines.append(f" - Abstract: {p['abstract']}")
159
+ lines.append("")
160
+ return "\n".join(lines)
161
+
162
+
163
+ def format_cnki_search_response(papers: list[dict[str, Any]], query: str) -> str:
164
+ if not papers:
165
+ return f"No CNKI papers found for query: {query}"
166
+ lines = [f"Found {len(papers)} CNKI papers for query '{query}':", ""]
167
+ for index, p in enumerate(papers, 1):
168
+ lines.append(f"{index}. **{p['title'] or 'Untitled'}**")
169
+ if p["authors"]:
170
+ lines.append(f" - Authors: {', '.join(p['authors'])}")
171
+ if p["source"]:
172
+ lines.append(f" - Source: {p['source']}")
173
+ if p["href"]:
174
+ lines.append(f" - URL: {p['href']}")
175
+ lines.append("")
176
+ return "\n".join(lines)
apaper_mcp/models.py ADDED
@@ -0,0 +1,42 @@
1
+ from typing import Any, TypedDict
2
+
3
+
4
+ class Paper(TypedDict):
5
+ paper_id: str
6
+ title: str
7
+ authors: list[str]
8
+ abstract: str
9
+ doi: str
10
+ published_date: str | None
11
+ pdf_url: str
12
+ url: str
13
+ source: str
14
+ categories: list[str]
15
+ keywords: list[str]
16
+ citations: int
17
+ references: list[str]
18
+ extra: dict[str, Any]
19
+
20
+
21
+ class ArxivPaper(TypedDict):
22
+ paper_id: str
23
+ title: str
24
+ authors: list[str]
25
+ abstract: str
26
+ categories: list[str]
27
+ primary_category: str
28
+ published_date: str
29
+ updated_date: str
30
+ pdf_url: str
31
+ url: str
32
+ doi: str
33
+ journal_ref: str
34
+ comment: str
35
+
36
+
37
+ class CnkiPaper(TypedDict):
38
+ title: str
39
+ authors: list[str]
40
+ source: str
41
+ source_link: str
42
+ href: str
@@ -0,0 +1 @@
1
+ """Platform-specific academic paper clients and parsers."""
@@ -0,0 +1,257 @@
1
+ import os
2
+ import random
3
+ import re
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from urllib.parse import urlencode
7
+ from bs4 import BeautifulSoup
8
+
9
+ from ..models import ArxivPaper
10
+
11
+ _GROUPS = {
12
+ "cs": "computer_science",
13
+ "econ": "economics",
14
+ "eess": "eess",
15
+ "math": "mathematics",
16
+ "physics": "physics",
17
+ "astro-ph": "physics",
18
+ "cond-mat": "physics",
19
+ "gr-qc": "physics",
20
+ "hep-ex": "physics",
21
+ "hep-lat": "physics",
22
+ "hep-ph": "physics",
23
+ "hep-th": "physics",
24
+ "math-ph": "physics",
25
+ "nlin": "physics",
26
+ "nucl-ex": "physics",
27
+ "nucl-th": "physics",
28
+ "quant-ph": "physics",
29
+ "q-bio": "q_biology",
30
+ "q-fin": "q_finance",
31
+ "stat": "statistics",
32
+ }
33
+
34
+
35
+ def build_arxiv_search_params(
36
+ query: str, options: dict[str, object] | None = None
37
+ ) -> dict[str, str]:
38
+ options = options or {}
39
+ term = query.strip()
40
+ field = "all"
41
+ for prefix, name in (
42
+ ("ti:", "title"),
43
+ ("au:", "author"),
44
+ ("abs:", "abstract"),
45
+ ("cat:", "all"),
46
+ ("doi:", "doi"),
47
+ ):
48
+ if term.lower().startswith(prefix):
49
+ term, field = term[len(prefix) :].strip(), name
50
+ break
51
+ date_from, date_to = options.get("date_from"), options.get("date_to")
52
+ categories = options.get("categories") or []
53
+ if not term and not date_from and not date_to:
54
+ raise ValueError("No search criteria provided")
55
+ max_results = max(1, int(options.get("max_results", 10)))
56
+ size = (
57
+ 25
58
+ if max_results <= 25
59
+ else 50
60
+ if max_results <= 50
61
+ else 100
62
+ if max_results <= 100
63
+ else 200
64
+ )
65
+ if not categories and not date_from and not date_to and field == "all":
66
+ result = {"searchtype": "all", "query": term, "start": "0", "size": str(size)}
67
+ if options.get("sort_by") == "date":
68
+ result["order"] = "-announced_date_first"
69
+ return result
70
+ result = {
71
+ "advanced": "",
72
+ "terms-0-operator": "AND",
73
+ "terms-0-term": term,
74
+ "terms-0-field": field,
75
+ }
76
+ groups = {
77
+ _GROUPS.get(str(category).split(".")[0].lower()) for category in categories
78
+ }
79
+ groups.discard(None)
80
+ for group in groups:
81
+ result[f"classification-{group}"] = "y"
82
+ result.update(
83
+ {
84
+ "classification-physics_archives": "all",
85
+ "classification-include_cross_list": "include",
86
+ }
87
+ )
88
+ if date_from or date_to:
89
+ for label, value in (("date_from", date_from), ("date_to", date_to)):
90
+ if value and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(value)):
91
+ raise ValueError(f"Invalid {label} format. Use YYYY-MM-DD: {value}")
92
+ result.update(
93
+ {
94
+ "date-filter_by": "date_range",
95
+ "date-year": "",
96
+ "date-from_date": str(date_from or ""),
97
+ "date-to_date": str(date_to or ""),
98
+ "date-date_type": "submitted_date",
99
+ }
100
+ )
101
+ else:
102
+ result.update(
103
+ {
104
+ "date-filter_by": "all_dates",
105
+ "date-year": "",
106
+ "date-from_date": "",
107
+ "date-to_date": "",
108
+ "date-date_type": "submitted_date",
109
+ }
110
+ )
111
+ result.update(
112
+ {
113
+ "abstracts": "show",
114
+ "size": str(size),
115
+ "order": "-announced_date_first"
116
+ if options.get("sort_by") == "date"
117
+ else "",
118
+ "start": "0",
119
+ }
120
+ )
121
+ return result
122
+
123
+
124
+ def parse_arxiv_search_html(html: str) -> list[ArxivPaper]:
125
+ soup = BeautifulSoup(html, "html.parser")
126
+ papers = []
127
+ for item in soup.select("li.arxiv-result"):
128
+ link = item.select_one("p.list-title a[href*='/abs/']")
129
+ if not link:
130
+ continue
131
+ match = re.search(r"/abs/([^/?#]+)", link.get("href", ""))
132
+ if not match:
133
+ continue
134
+ paper_id = re.sub(r"v\d+$", "", match.group(1), flags=re.I)
135
+ clean = lambda value: re.sub(r"\s+", " ", value or "").strip()
136
+ tags = [
137
+ clean(x.get_text())
138
+ for x in item.select("div.tags span.tag")
139
+ if clean(x.get_text())
140
+ ]
141
+ pdf = next(
142
+ (
143
+ x.get("href", "")
144
+ for x in item.select("p.list-title a")
145
+ if "/pdf/" in x.get("href", "")
146
+ ),
147
+ f"https://arxiv.org/pdf/{paper_id}",
148
+ )
149
+ date_match = re.search(
150
+ r"Submitted\s+(\d{1,2}\s+\w+,?\s+\d{4})",
151
+ clean(
152
+ item.select_one("p.is-size-7").get_text()
153
+ if item.select_one("p.is-size-7")
154
+ else ""
155
+ ),
156
+ re.I,
157
+ )
158
+ published = ""
159
+ if date_match:
160
+ try:
161
+ published = (
162
+ datetime.strptime(date_match.group(1).replace(",", ""), "%d %B %Y")
163
+ .replace(tzinfo=timezone.utc)
164
+ .isoformat()
165
+ .replace("+00:00", "Z")
166
+ )
167
+ except ValueError:
168
+ pass
169
+ comments = {"comment": "", "journal_ref": "", "doi": ""}
170
+ for paragraph in item.select("p.comments"):
171
+ text = clean(paragraph.get_text())
172
+ for label, key in (
173
+ ("Comments", "comment"),
174
+ ("Journal ref", "journal_ref"),
175
+ ("doi", "doi"),
176
+ ):
177
+ if re.match(label + r"\s*:", text, re.I):
178
+ comments[key] = re.sub(label + r"\s*:\s*", "", text, flags=re.I)
179
+ papers.append(
180
+ {
181
+ "paper_id": paper_id,
182
+ "title": clean(
183
+ item.select_one("p.title").get_text()
184
+ if item.select_one("p.title")
185
+ else ""
186
+ ),
187
+ "authors": [
188
+ clean(x.get_text())
189
+ for x in item.select("p.authors a")
190
+ if clean(x.get_text())
191
+ ],
192
+ "abstract": clean(
193
+ (
194
+ item.select_one("span.abstract-full")
195
+ or item.select_one("span.abstract-short")
196
+ ).get_text()
197
+ if (
198
+ item.select_one("span.abstract-full")
199
+ or item.select_one("span.abstract-short")
200
+ )
201
+ else ""
202
+ )
203
+ .removesuffix("▽ More")
204
+ .removesuffix("△ Less")
205
+ .strip(),
206
+ "categories": tags,
207
+ "primary_category": tags[0] if tags else "",
208
+ "published_date": published,
209
+ "updated_date": published,
210
+ "pdf_url": pdf,
211
+ "url": f"https://arxiv.org/abs/{paper_id}",
212
+ **comments,
213
+ }
214
+ )
215
+ return papers
216
+
217
+
218
+ def parse_retry_after_ms(value: str | None, now: float | None = None) -> int | None:
219
+ if not value or not value.strip():
220
+ return None
221
+ if value.strip().isdigit():
222
+ return int(value.strip()) * 1000
223
+ if " " not in value:
224
+ return None
225
+ try:
226
+ return max(
227
+ 0,
228
+ int(
229
+ datetime.strptime(value.strip(), "%a, %d %b %Y %H:%M:%S GMT")
230
+ .replace(tzinfo=timezone.utc)
231
+ .timestamp()
232
+ * 1000
233
+ - (now if now is not None else time.time() * 1000)
234
+ ),
235
+ )
236
+ except ValueError:
237
+ return None
238
+
239
+
240
+ def compute_backoff_ms(
241
+ attempt: int,
242
+ retry_after_ms: int | None,
243
+ options: dict[str, int],
244
+ rand=random.random,
245
+ ) -> int:
246
+ delay = (
247
+ max(options["base_ms"] * 2**attempt, retry_after_ms or 0)
248
+ + rand() * options["jitter_ms"]
249
+ )
250
+ return int(min(delay, options["max_ms"]))
251
+
252
+
253
+ def looks_like_ip(text: str) -> bool:
254
+ return bool(
255
+ re.fullmatch(r"(\d{1,3}\.){3}\d{1,3}", text)
256
+ or (":" in text and re.fullmatch(r"[0-9a-fA-F:]{2,45}", text))
257
+ )
@@ -0,0 +1,103 @@
1
+ import re
2
+ from urllib.parse import unquote, urljoin
3
+ from bs4 import BeautifulSoup
4
+
5
+
6
+ def build_cnki_query_json(value: str) -> dict:
7
+ return {
8
+ "Platform": "",
9
+ "Resource": "CROSSDB",
10
+ "Classid": "WD0FTY92",
11
+ "Products": "CJFQ,CAPJ,WWJD,CJTL,CDFD,CMFD,CPFD,IPFD,CPVD,WWPD,CCND,SCSF,SCHF,SCSD,SOSD,SNAD,CCJD,WBFD,WWBD,CCVD,CJFN",
12
+ "QNode": {
13
+ "QGroup": [
14
+ {
15
+ "Key": "Subject",
16
+ "Title": "",
17
+ "Logic": 0,
18
+ "Items": [
19
+ {
20
+ "Field": "SU",
21
+ "Value": value,
22
+ "Operator": "TOPRANK",
23
+ "Logic": 0,
24
+ "Title": "主题",
25
+ }
26
+ ],
27
+ "ChildItems": [],
28
+ }
29
+ ]
30
+ },
31
+ "ExScope": 1,
32
+ "SearchType": 2,
33
+ "Rlang": "BOTH",
34
+ "KuaKuCode": "YSTT4HG0,LSTPFY1C,JUP3MUPD,MPMFIG1A,WQ0UVIAA,BLZOG7CK,PWFIRAGL,EMRPGLPA,NLBO1Z6R,NN3FJMUV",
35
+ "Expands": {},
36
+ "SearchFrom": 4,
37
+ }
38
+
39
+
40
+ def parse_cnki_search_html(
41
+ html: str, base_url: str = "https://kns.cnki.net/"
42
+ ) -> list[dict]:
43
+ soup = BeautifulSoup(html, "html.parser")
44
+ papers = []
45
+ for row in soup.select(
46
+ "table.result-table-list tbody tr, table.result-table-list tr"
47
+ ):
48
+ title = row.select_one("td.name a.fz14")
49
+ if (
50
+ not title
51
+ or not title.get("href")
52
+ or title["href"].startswith("javascript:")
53
+ or "/kcms2/article/abstract" not in title["href"]
54
+ ):
55
+ continue
56
+ source = row.select_one("td.source")
57
+ source_link = ""
58
+ source_name = ""
59
+ if source:
60
+ source_a = next(
61
+ (
62
+ x
63
+ for x in source.select("a")
64
+ if x.get("href")
65
+ and ("knavi/detail" in x["href"] or "thinker.cnki.net" in x["href"])
66
+ ),
67
+ None,
68
+ )
69
+ if source_a:
70
+ source_name, source_link = (
71
+ source_a.get_text(strip=True),
72
+ urljoin(base_url, source_a["href"]),
73
+ )
74
+ else:
75
+ source_name = next(
76
+ (
77
+ x.get_text(strip=True)
78
+ for x in source.select("span")
79
+ if x.get_text(strip=True) not in ("—",)
80
+ ),
81
+ source.get_text(" ", strip=True),
82
+ )
83
+ papers.append(
84
+ {
85
+ "title": title.get_text(strip=True),
86
+ "authors": [
87
+ x.get_text(strip=True)
88
+ for x in row.select("td.author a.KnowledgeNetLink")
89
+ ],
90
+ "source": source_name,
91
+ "source_link": source_link,
92
+ "href": urljoin(base_url, title["href"]),
93
+ }
94
+ )
95
+ return papers
96
+
97
+
98
+ def extract_filename_from_content_disposition(disposition: str) -> str:
99
+ match = re.search(r"filename\*\s*=\s*[^']*''([^;]+)", disposition, re.I)
100
+ if match:
101
+ return unquote(match.group(1).strip())
102
+ match = re.search(r"filename\s*=\s*\"?([^\";]+)", disposition, re.I)
103
+ return unquote(match.group(1).strip()) if match else ""
@@ -0,0 +1,57 @@
1
+ from typing import Any
2
+
3
+
4
+ def parse_dblp_api_response(payload: Any) -> list[dict]:
5
+ hits = (
6
+ payload.get("result", {}).get("hits", {}).get("hit", [])
7
+ if isinstance(payload, dict)
8
+ else []
9
+ )
10
+ if not isinstance(hits, list):
11
+ hits = [hits] if hits else []
12
+ result = []
13
+ for publication in hits:
14
+ info = publication.get("info", {})
15
+ authors = info.get("authors", {}).get("author", [])
16
+ if not isinstance(authors, list):
17
+ authors = [authors] if authors else []
18
+ authors = [a if isinstance(a, str) else a.get("text", "") for a in authors]
19
+ url = info.get("url", "")
20
+ result.append(
21
+ {
22
+ "title": info.get("title", ""),
23
+ "authors": [a for a in authors if a],
24
+ "venue": info.get("venue", ""),
25
+ "year": int(info["year"])
26
+ if str(info.get("year", "")).isdigit()
27
+ else None,
28
+ "type": info.get("type", ""),
29
+ "doi": info.get("doi", ""),
30
+ "ee": info.get("ee", ""),
31
+ "url": url,
32
+ "dblp_key": url.replace("https://dblp.org/rec/", "")
33
+ or publication.get("key", "").removeprefix("dblp:"),
34
+ }
35
+ )
36
+ return result
37
+
38
+
39
+ def filter_dblp_results(results: list[dict], options: dict[str, Any]) -> list[dict]:
40
+ return [
41
+ r
42
+ for r in results
43
+ if not (
44
+ options.get("year_from") is not None
45
+ and r.get("year") is not None
46
+ and r["year"] < options["year_from"]
47
+ )
48
+ and not (
49
+ options.get("year_to") is not None
50
+ and r.get("year") is not None
51
+ and r["year"] > options["year_to"]
52
+ )
53
+ and not (
54
+ options.get("venue_filter")
55
+ and options["venue_filter"].lower() not in r.get("venue", "").lower()
56
+ )
57
+ ]