wikicitation-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.
annotate_utils.py ADDED
@@ -0,0 +1,219 @@
1
+ """
2
+ annotate_utils.py
3
+ DOI and ISBN annotation via external REST APIs.
4
+ Pure Python / httpx, no R required.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+
10
+ _HEADERS = {
11
+ "User-Agent": (
12
+ "wikicitation-mcp/0.3 "
13
+ "(https://github.com/jsobel1/wikicitation-mcp)"
14
+ )
15
+ }
16
+
17
+
18
+ def _get(url: str, **kwargs) -> httpx.Response:
19
+ return httpx.get(url, headers=_HEADERS, timeout=20, **kwargs)
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # EuropePMC
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _EPMC_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
27
+
28
+ def annotate_dois_europmc(doi_list: list[str]) -> list[dict]:
29
+ """Annotate DOIs via EuropePMC (batches of 10)."""
30
+ if not doi_list:
31
+ return []
32
+ results: list[dict] = []
33
+ for i in range(0, len(doi_list), 10):
34
+ chunk = doi_list[i : i + 10]
35
+ query = " OR ".join(f'DOI:"{doi}"' for doi in chunk)
36
+ r = _get(_EPMC_URL, params={
37
+ "query": query,
38
+ "resultType": "core",
39
+ "pageSize": len(chunk) * 2,
40
+ "format": "json",
41
+ })
42
+ if r.status_code == 200:
43
+ results.extend(
44
+ r.json().get("resultList", {}).get("result", [])
45
+ )
46
+ return results
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # CrossRef
51
+ # ---------------------------------------------------------------------------
52
+
53
+ _CROSSREF_BASE = "https://api.crossref.org/works"
54
+
55
+ def annotate_dois_crossref(doi_list: list[str]) -> list[dict]:
56
+ """Annotate DOIs via CrossRef REST API (one request per DOI)."""
57
+ results: list[dict] = []
58
+ for doi in doi_list:
59
+ r = _get(f"{_CROSSREF_BASE}/{doi}")
60
+ if r.status_code != 200:
61
+ results.append({"doi": doi, "error": f"HTTP {r.status_code}"})
62
+ continue
63
+ msg = r.json().get("message", {})
64
+ pub = msg.get("published-print", msg.get("published-online", {}))
65
+ year = (pub.get("date-parts", [[None]])[0] or [None])[0]
66
+ results.append({
67
+ "doi": doi,
68
+ "title": " ".join(msg.get("title", [])),
69
+ "authors": [
70
+ f"{a.get('given','')} {a.get('family','')}".strip()
71
+ for a in msg.get("author", [])
72
+ ],
73
+ "journal": " ".join(msg.get("container-title", [])),
74
+ "year": year,
75
+ "publisher": msg.get("publisher", ""),
76
+ "type": msg.get("type", ""),
77
+ "is_referenced_by_count": msg.get("is-referenced-by-count", 0),
78
+ "url": msg.get("URL", ""),
79
+ })
80
+ return results
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Altmetric
85
+ # ---------------------------------------------------------------------------
86
+
87
+ _ALTMETRIC_BASE = "https://api.altmetric.com/v1"
88
+
89
+ def annotate_dois_altmetric(doi_list: list[str]) -> list[dict]:
90
+ """Altmetric attention scores for a list of DOIs."""
91
+ results: list[dict] = []
92
+ for doi in doi_list:
93
+ r = _get(f"{_ALTMETRIC_BASE}/doi/{doi}")
94
+ if r.status_code == 404:
95
+ results.append({"doi": doi, "altmetric_score": None})
96
+ continue
97
+ if r.status_code != 200:
98
+ results.append({"doi": doi, "error": f"HTTP {r.status_code}"})
99
+ continue
100
+ d = r.json()
101
+ results.append({
102
+ "doi": doi,
103
+ "altmetric_score": d.get("score"),
104
+ "altmetric_id": d.get("altmetric_id"),
105
+ "cited_by_tweeters_count": d.get("cited_by_tweeters_count", 0),
106
+ "cited_by_accounts_count": d.get("cited_by_accounts_count", 0),
107
+ "cited_by_msm_count": d.get("cited_by_msm_count", 0),
108
+ "cited_by_fbwalls_count": d.get("cited_by_fbwalls_count", 0),
109
+ "readers_count": d.get("readers_count", 0),
110
+ "url": d.get("details_url", ""),
111
+ })
112
+ return results
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # BibTeX via CrossRef content negotiation
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def annotate_dois_bibtex(doi_list: list[str]) -> dict:
120
+ """Retrieve BibTeX entries for DOIs via CrossRef content negotiation."""
121
+ entries: list[str] = []
122
+ for doi in doi_list:
123
+ r = httpx.get(
124
+ f"https://doi.org/{doi}",
125
+ headers={**_HEADERS, "Accept": "application/x-bibtex"},
126
+ follow_redirects=True,
127
+ timeout=20,
128
+ )
129
+ entries.append(
130
+ r.text.strip() if r.status_code == 200
131
+ else f"% DOI {doi}: HTTP {r.status_code}"
132
+ )
133
+ return {"bibtex_entries": entries}
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # ISBN — Google Books
138
+ # ---------------------------------------------------------------------------
139
+
140
+ _GOOGLE_BOOKS = "https://www.googleapis.com/books/v1/volumes"
141
+
142
+ def annotate_isbn_google(isbn: str) -> dict:
143
+ """Book metadata from Google Books for one ISBN."""
144
+ clean = isbn.replace("-", "").replace(" ", "")
145
+ r = _get(_GOOGLE_BOOKS, params={"q": f"isbn:{clean}"})
146
+ if r.status_code != 200:
147
+ return {"isbn": isbn, "error": f"HTTP {r.status_code}"}
148
+ items = r.json().get("items", [])
149
+ if not items:
150
+ return {"isbn": isbn, "error": "not found"}
151
+ info = items[0].get("volumeInfo", {})
152
+ return {
153
+ "isbn": isbn,
154
+ "title": info.get("title", ""),
155
+ "authors": info.get("authors", []),
156
+ "publisher": info.get("publisher", ""),
157
+ "publishedDate": info.get("publishedDate", ""),
158
+ "description": info.get("description", "")[:500],
159
+ "categories": info.get("categories", []),
160
+ }
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # ISBN — Open Library
165
+ # ---------------------------------------------------------------------------
166
+
167
+ _OPENLIB = "https://openlibrary.org/api/books"
168
+
169
+ def annotate_isbn_openlib(isbn: str) -> dict:
170
+ """Book metadata from Open Library for one ISBN."""
171
+ clean = isbn.replace("-", "").replace(" ", "")
172
+ r = _get(_OPENLIB, params={
173
+ "bibkeys": f"ISBN:{clean}",
174
+ "format": "json",
175
+ "jscmd": "data",
176
+ })
177
+ if r.status_code != 200:
178
+ return {"isbn": isbn, "error": f"HTTP {r.status_code}"}
179
+ data = r.json()
180
+ key = f"ISBN:{clean}"
181
+ if key not in data:
182
+ return {"isbn": isbn, "error": "not found"}
183
+ info = data[key]
184
+ return {
185
+ "isbn": isbn,
186
+ "title": info.get("title", ""),
187
+ "authors": [a.get("name", "") for a in info.get("authors", [])],
188
+ "publishers": [p.get("name", "") for p in info.get("publishers", [])],
189
+ "publish_date": info.get("publish_date", ""),
190
+ "number_of_pages": info.get("number_of_pages"),
191
+ "subjects": [s.get("name", "") for s in info.get("subjects", [])[:10]],
192
+ }
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # ISBN — Altmetric
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def annotate_isbns_altmetric(isbn_list: list[str]) -> list[dict]:
200
+ """Altmetric attention scores for a list of ISBNs."""
201
+ results: list[dict] = []
202
+ for isbn in isbn_list:
203
+ clean = isbn.replace("-", "").replace(" ", "")
204
+ r = _get(f"{_ALTMETRIC_BASE}/isbn/{clean}")
205
+ if r.status_code == 404:
206
+ results.append({"isbn": isbn, "altmetric_score": None})
207
+ continue
208
+ if r.status_code != 200:
209
+ results.append({"isbn": isbn, "error": f"HTTP {r.status_code}"})
210
+ continue
211
+ d = r.json()
212
+ results.append({
213
+ "isbn": isbn,
214
+ "altmetric_score": d.get("score"),
215
+ "cited_by_tweeters_count": d.get("cited_by_tweeters_count", 0),
216
+ "cited_by_accounts_count": d.get("cited_by_accounts_count", 0),
217
+ "url": d.get("details_url", ""),
218
+ })
219
+ return results
citation_utils.py ADDED
@@ -0,0 +1,286 @@
1
+ """
2
+ citation_utils.py
3
+ Citation counting, extraction, parsing, and quality metrics.
4
+ Pure Python (re + mwparserfromhell). Text operations need no network;
5
+ article-level functions fetch wikitext via wiki_api.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import Counter
11
+ from typing import Any
12
+
13
+ import mwparserfromhell
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Compiled regex patterns (mirrors wikilite pkg.env regexps)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ DOI_RE = re.compile(r'10\.\d{4,9}/[-._;()/:a-zA-Z0-9]+')
20
+ URL_RE = re.compile(r'https?://[^\s\]<>"|{}\\^`]+')
21
+ REF_RE = re.compile(
22
+ r'<ref(?:\s[^>]*)?(?<!/)>.*?</ref>|<ref(?:\s[^>]*)?/>',
23
+ re.DOTALL | re.IGNORECASE,
24
+ )
25
+ ISBN_RE = re.compile(r'(?i)\bisbn\s*[=:]?\s*([-0-9X ]{9,18})')
26
+ WIKILINK_RE = re.compile(r'\[\[.*?\]\]', re.DOTALL)
27
+ WIKILINK_DISPLAY_RE = re.compile(r'\[\[(?:[^|\]]*\|)?([^\]]+)\]\]')
28
+ PMID_RE = re.compile(r'(?:pmid|PMID)\s*[=:]\s*(\d{5,9})')
29
+ CITE_RE = re.compile(r'\{\{[Cc]ite\b[^{}]*(?:\{\{[^{}]*\}\}[^{}]*)?\}\}',
30
+ re.DOTALL)
31
+
32
+ ALL_PATTERNS: dict[str, re.Pattern] = {
33
+ "doi": DOI_RE,
34
+ "url": URL_RE,
35
+ "ref": REF_RE,
36
+ "isbn": ISBN_RE,
37
+ "wikilink": WIKILINK_RE,
38
+ "pmid": PMID_RE,
39
+ "cite_all": CITE_RE,
40
+ }
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Template helpers
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _is_cs1(name: str) -> bool:
48
+ n = name.strip().lower()
49
+ return n.startswith("cite ") or n == "citation"
50
+
51
+
52
+ def _cite_type(template_name: str) -> str:
53
+ n = template_name.strip().lower()
54
+ return n.replace("cite ", "", 1) if n.startswith("cite ") else n
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Group 2 — counting (text-based, no network)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def get_doi_count(text: str) -> int:
62
+ return len(DOI_RE.findall(text))
63
+
64
+
65
+ def get_ref_count(text: str) -> int:
66
+ return len(REF_RE.findall(text))
67
+
68
+
69
+ def get_url_count(text: str) -> int:
70
+ return len(URL_RE.findall(text))
71
+
72
+
73
+ def get_isbn_count(text: str) -> int:
74
+ return len(ISBN_RE.findall(text))
75
+
76
+
77
+ def get_hyperlink_count(text: str) -> int:
78
+ return len(WIKILINK_RE.findall(text))
79
+
80
+
81
+ def get_any_count(text: str, regexp: str) -> int:
82
+ return len(re.findall(regexp, text))
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Group 2 — extraction (text-based, no network)
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def extract_citations(text: str) -> list[str]:
90
+ """Return all CS1 citation templates found in wikitext."""
91
+ wikicode = mwparserfromhell.parse(text)
92
+ return [str(t) for t in wikicode.filter_templates() if _is_cs1(str(t.name))]
93
+
94
+
95
+ def extract_wikihyperlinks(text: str) -> list[str]:
96
+ """Return all [[...]] wikilinks."""
97
+ return WIKILINK_RE.findall(text)
98
+
99
+
100
+ def replace_wikihyperlinks(text: str) -> str:
101
+ """Replace [[Link|Display]] → Display, [[Link]] → Link."""
102
+ return WIKILINK_DISPLAY_RE.sub(r'\1', text)
103
+
104
+
105
+ def parse_cite_type(text: str) -> dict:
106
+ """
107
+ Parse a single CS1 template string.
108
+ Returns {"cite_type": str, "fields": {param: value, …}}.
109
+ """
110
+ wikicode = mwparserfromhell.parse(text)
111
+ templates = [t for t in wikicode.filter_templates() if _is_cs1(str(t.name))]
112
+ if not templates:
113
+ return {}
114
+ t = templates[0]
115
+ return {
116
+ "cite_type": _cite_type(str(t.name)),
117
+ "fields": {
118
+ str(p.name).strip(): str(p.value).strip()
119
+ for p in t.params
120
+ if str(p.value).strip()
121
+ },
122
+ }
123
+
124
+
125
+ def get_source_type_counts(text: str) -> list[dict]:
126
+ """Count CS1 citation types in raw wikitext."""
127
+ wikicode = mwparserfromhell.parse(text)
128
+ counts: Counter[str] = Counter()
129
+ for t in wikicode.filter_templates():
130
+ if _is_cs1(str(t.name)):
131
+ counts[_cite_type(str(t.name))] += 1
132
+ return [{"cite_type": ct, "count": c} for ct, c in counts.most_common()]
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Internal: fetch wikitext
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def _get_wikitext(article_name: str, date_limit: str) -> tuple[int, str]:
140
+ """Return (revid, wikitext) for the most recent revision up to date_limit."""
141
+ from wiki_api import get_article_recent
142
+ result = get_article_recent(article_name, date_limit)
143
+ return result["metadata"].get("revid", 0), result["wikitext"]
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Group 2 — article-level functions (require network)
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def extract_with_regex(
151
+ article_name: str,
152
+ regexp: str,
153
+ date_limit: str = "2024-01-01T00:00:00Z",
154
+ ) -> list[dict]:
155
+ """Apply a regex to the most recent article wikitext."""
156
+ revid, wikitext = _get_wikitext(article_name, date_limit)
157
+ return [
158
+ {"art": article_name, "revid": revid, "match": m}
159
+ for m in re.findall(regexp, wikitext)
160
+ ]
161
+
162
+
163
+ def extract_all_regex(
164
+ article_name: str,
165
+ date_limit: str = "2024-01-01T00:00:00Z",
166
+ ) -> list[dict]:
167
+ """Apply all built-in patterns to the most recent article wikitext."""
168
+ revid, wikitext = _get_wikitext(article_name, date_limit)
169
+ rows: list[dict] = []
170
+ for pattern_name, compiled in ALL_PATTERNS.items():
171
+ for m in compiled.findall(wikitext):
172
+ match_str = m if isinstance(m, str) else (m[0] if m else "")
173
+ rows.append({
174
+ "art": article_name,
175
+ "revid": revid,
176
+ "pattern_name": pattern_name,
177
+ "match": match_str,
178
+ })
179
+ return rows
180
+
181
+
182
+ def parse_citations(
183
+ article_name: str,
184
+ date_limit: str = "2024-01-01T00:00:00Z",
185
+ ) -> list[dict]:
186
+ """Structured citation table (one row per citation template)."""
187
+ revid, wikitext = _get_wikitext(article_name, date_limit)
188
+ wikicode = mwparserfromhell.parse(wikitext)
189
+ rows: list[dict] = []
190
+ for i, t in enumerate(
191
+ (t for t in wikicode.filter_templates() if _is_cs1(str(t.name))), start=1
192
+ ):
193
+ params = {str(p.name).strip(): str(p.value).strip() for p in t.params}
194
+ rows.append({
195
+ "art": article_name,
196
+ "revid": revid,
197
+ "cite_type": _cite_type(str(t.name)),
198
+ "id_cite": i,
199
+ "doi": params.get("doi", ""),
200
+ "author": params.get("author", params.get("author1",
201
+ params.get("last", params.get("last1", "")))),
202
+ "year": params.get("year", ""),
203
+ "title": params.get("title", ""),
204
+ })
205
+ return rows
206
+
207
+
208
+ def parse_all_citations(
209
+ article_name: str,
210
+ date_limit: str = "2024-01-01T00:00:00Z",
211
+ ) -> list[dict]:
212
+ """Full long-form table: one row per citation *field*."""
213
+ revid, wikitext = _get_wikitext(article_name, date_limit)
214
+ wikicode = mwparserfromhell.parse(wikitext)
215
+ rows: list[dict] = []
216
+ cite_idx = 0
217
+ for t in wikicode.filter_templates():
218
+ if not _is_cs1(str(t.name)):
219
+ continue
220
+ cite_idx += 1
221
+ ct = _cite_type(str(t.name))
222
+ for p in t.params:
223
+ rows.append({
224
+ "art": article_name,
225
+ "revid": revid,
226
+ "cite_type": ct,
227
+ "id_cite": cite_idx,
228
+ "variable": str(p.name).strip(),
229
+ "value": str(p.value).strip(),
230
+ })
231
+ return rows
232
+
233
+
234
+ def get_citation_types(
235
+ article_name: str,
236
+ date_limit: str = "2024-01-01T00:00:00Z",
237
+ ) -> list[dict]:
238
+ """Count CS1 citation types for an article."""
239
+ _, wikitext = _get_wikitext(article_name, date_limit)
240
+ return get_source_type_counts(wikitext)
241
+
242
+
243
+ def get_sci_score(
244
+ article_name: str,
245
+ date_limit: str = "2024-01-01T00:00:00Z",
246
+ ) -> dict:
247
+ """
248
+ sci_score: fraction of CS1 templates that are cite-journal.
249
+ sci_score2: ratio of DOI count to <ref> tag count.
250
+ """
251
+ _, wikitext = _get_wikitext(article_name, date_limit)
252
+
253
+ type_counts = get_source_type_counts(wikitext)
254
+ total_cs1 = sum(r["count"] for r in type_counts)
255
+ journal_count = next(
256
+ (r["count"] for r in type_counts if r["cite_type"] == "journal"), 0
257
+ )
258
+ sci_score = journal_count / total_cs1 if total_cs1 else 0.0
259
+ doi_count = get_doi_count(wikitext)
260
+ ref_count = get_ref_count(wikitext)
261
+ sci_score2 = doi_count / ref_count if ref_count else 0.0
262
+
263
+ return {
264
+ "sci_score": round(sci_score, 4),
265
+ "sci_score2": round(sci_score2, 4),
266
+ "article": article_name,
267
+ }
268
+
269
+
270
+ def get_top_cited_papers(
271
+ article_name: str,
272
+ date_limit: str = "2024-01-01T00:00:00Z",
273
+ ) -> list[dict]:
274
+ """Top 40 most-cited DOIs in an article, annotated via EuropePMC."""
275
+ from annotate_utils import annotate_dois_europmc
276
+
277
+ _, wikitext = _get_wikitext(article_name, date_limit)
278
+ counter = Counter(DOI_RE.findall(wikitext))
279
+ top_dois = [doi for doi, _ in counter.most_common(40)]
280
+
281
+ annotated = annotate_dois_europmc(top_dois)
282
+ for row in annotated:
283
+ doi = row.get("doi", "")
284
+ row["wiki_count"] = counter.get(doi, counter.get(doi.lower(), 0))
285
+
286
+ return sorted(annotated, key=lambda r: r.get("wiki_count", 0), reverse=True)