hf-papers-mcp 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/bin/daily.js +89 -0
- package/package.json +47 -0
- package/scripts/paper_manager.py +1252 -0
- package/server.js +62 -0
- package/src/papers.js +180 -0
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "huggingface_hub",
|
|
6
|
+
# "pyyaml",
|
|
7
|
+
# "requests",
|
|
8
|
+
# "markdown>=3.5.0",
|
|
9
|
+
# "python-dotenv",
|
|
10
|
+
# ]
|
|
11
|
+
# ///
|
|
12
|
+
"""
|
|
13
|
+
Paper Manager for Hugging Face Hub
|
|
14
|
+
Manages paper indexing, linking, authorship, search, daily papers, and article creation.
|
|
15
|
+
|
|
16
|
+
API Endpoints used:
|
|
17
|
+
GET /api/papers/{arxiv_id} - Single paper metadata
|
|
18
|
+
GET /api/papers/search?q= - Search papers
|
|
19
|
+
GET /api/daily_papers?date= - Daily curated papers
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import re
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional, List, Dict, Any
|
|
29
|
+
from datetime import datetime, date
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from huggingface_hub import HfApi, hf_hub_download, get_token
|
|
33
|
+
import yaml
|
|
34
|
+
import requests
|
|
35
|
+
import markdown as md_lib
|
|
36
|
+
from dotenv import load_dotenv
|
|
37
|
+
except ImportError as e:
|
|
38
|
+
print(f"Error: Missing required dependency: {e}", file=sys.stderr)
|
|
39
|
+
print("Tip: run this script with `uv run scripts/paper_manager.py ...`.", file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
# Load environment variables
|
|
43
|
+
load_dotenv()
|
|
44
|
+
|
|
45
|
+
HF_API_BASE = "https://huggingface.co/api"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PaperManager:
|
|
49
|
+
"""Manages paper publishing operations on Hugging Face Hub."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, hf_token: Optional[str] = None):
|
|
52
|
+
"""Initialize Paper Manager with HF token."""
|
|
53
|
+
self.token = hf_token or os.getenv("HF_TOKEN") or get_token()
|
|
54
|
+
if not self.token:
|
|
55
|
+
print("Warning: No HF_TOKEN found. Some operations will fail.", file=sys.stderr)
|
|
56
|
+
self.api = HfApi(token=self.token)
|
|
57
|
+
self.session = requests.Session()
|
|
58
|
+
if self.token:
|
|
59
|
+
self.session.headers["Authorization"] = f"Bearer {self.token}"
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Paper lookup
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def get_paper(self, arxiv_id: str) -> Dict[str, Any]:
|
|
66
|
+
"""
|
|
67
|
+
Fetch full paper metadata from Hugging Face.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
arxiv_id: arXiv identifier (e.g., "2301.12345")
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
dict: Paper metadata or error
|
|
74
|
+
"""
|
|
75
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
76
|
+
url = f"{HF_API_BASE}/papers/{arxiv_id}"
|
|
77
|
+
resp = self.session.get(url, timeout=15)
|
|
78
|
+
if resp.status_code == 404:
|
|
79
|
+
return {"error": "not_found", "message": f"Paper {arxiv_id} not indexed on HF"}
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
return resp.json()
|
|
82
|
+
|
|
83
|
+
def index_paper(self, arxiv_id: str) -> Dict[str, Any]:
|
|
84
|
+
"""
|
|
85
|
+
Index a paper on Hugging Face from arXiv.
|
|
86
|
+
Visiting the paper URL triggers indexing.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
arxiv_id: arXiv identifier
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
dict: Status information
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
96
|
+
except ValueError as e:
|
|
97
|
+
return {"status": "error", "message": str(e)}
|
|
98
|
+
|
|
99
|
+
paper_url = f"https://huggingface.co/papers/{arxiv_id}"
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# GET the paper page — HF indexes on first visit
|
|
103
|
+
response = self.session.get(paper_url, timeout=15)
|
|
104
|
+
if response.status_code == 200:
|
|
105
|
+
print(f"Paper indexed at {paper_url}", file=sys.stderr)
|
|
106
|
+
return {"status": "indexed", "url": paper_url, "arxiv_id": arxiv_id}
|
|
107
|
+
else:
|
|
108
|
+
print(f"Paper not found. Visit {paper_url} to trigger indexing.", file=sys.stderr)
|
|
109
|
+
return {"status": "not_indexed", "url": paper_url, "action": "visit_url"}
|
|
110
|
+
except requests.RequestException as e:
|
|
111
|
+
return {"status": "error", "message": str(e)}
|
|
112
|
+
|
|
113
|
+
def check_paper(self, arxiv_id: str) -> Dict[str, Any]:
|
|
114
|
+
"""
|
|
115
|
+
Check if a paper exists on Hugging Face and return its metadata.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
arxiv_id: arXiv identifier
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
dict: Paper status and metadata
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
return {"exists": False, "error": str(e)}
|
|
127
|
+
|
|
128
|
+
data = self.get_paper(arxiv_id)
|
|
129
|
+
if "error" in data:
|
|
130
|
+
return {
|
|
131
|
+
"exists": False,
|
|
132
|
+
"arxiv_id": arxiv_id,
|
|
133
|
+
"index_url": f"https://huggingface.co/papers/{arxiv_id}",
|
|
134
|
+
"message": f"Visit the URL to index this paper",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"exists": True,
|
|
139
|
+
"arxiv_id": arxiv_id,
|
|
140
|
+
"title": data.get("title"),
|
|
141
|
+
"upvotes": data.get("upvotes", 0),
|
|
142
|
+
"authors": [a.get("name") for a in data.get("authors", [])],
|
|
143
|
+
"url": f"https://huggingface.co/papers/{arxiv_id}",
|
|
144
|
+
"arxiv_url": f"https://arxiv.org/abs/{arxiv_id}",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Search & daily papers
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def search_papers(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
152
|
+
"""
|
|
153
|
+
Search papers on Hugging Face.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
query: Search query string
|
|
157
|
+
limit: Max results to return
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
list: Matching papers
|
|
161
|
+
"""
|
|
162
|
+
url = f"{HF_API_BASE}/papers/search"
|
|
163
|
+
resp = self.session.get(url, params={"q": query}, timeout=15)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
papers = resp.json()
|
|
166
|
+
|
|
167
|
+
results = []
|
|
168
|
+
for entry in papers[:limit]:
|
|
169
|
+
paper = entry.get("paper", entry)
|
|
170
|
+
results.append({
|
|
171
|
+
"arxiv_id": paper.get("id"),
|
|
172
|
+
"title": paper.get("title"),
|
|
173
|
+
"upvotes": paper.get("upvotes", 0),
|
|
174
|
+
"summary": (paper.get("ai_summary") or paper.get("summary", ""))[:200],
|
|
175
|
+
"authors": [a.get("name") for a in paper.get("authors", [])][:5],
|
|
176
|
+
"url": f"https://huggingface.co/papers/{paper.get('id')}",
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
def daily_papers(self, date_str: Optional[str] = None, limit: int = 30) -> List[Dict[str, Any]]:
|
|
182
|
+
"""
|
|
183
|
+
Fetch daily curated papers from Hugging Face.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
date_str: Date in YYYY-MM-DD format (defaults to today)
|
|
187
|
+
limit: Max results to return
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
list: Daily papers
|
|
191
|
+
"""
|
|
192
|
+
params = {}
|
|
193
|
+
if date_str:
|
|
194
|
+
# Validate date format
|
|
195
|
+
try:
|
|
196
|
+
datetime.strptime(date_str, "%Y-%m-%d")
|
|
197
|
+
except ValueError:
|
|
198
|
+
return [{"error": f"Invalid date format: {date_str}. Use YYYY-MM-DD"}]
|
|
199
|
+
params["date"] = date_str
|
|
200
|
+
|
|
201
|
+
url = f"{HF_API_BASE}/daily_papers"
|
|
202
|
+
resp = self.session.get(url, params=params, timeout=15)
|
|
203
|
+
resp.raise_for_status()
|
|
204
|
+
papers = resp.json()
|
|
205
|
+
|
|
206
|
+
results = []
|
|
207
|
+
for entry in papers[:limit]:
|
|
208
|
+
paper = entry.get("paper", entry)
|
|
209
|
+
results.append({
|
|
210
|
+
"arxiv_id": paper.get("id"),
|
|
211
|
+
"title": paper.get("title") or entry.get("title"),
|
|
212
|
+
"upvotes": paper.get("upvotes", 0),
|
|
213
|
+
"summary": (paper.get("ai_summary") or paper.get("summary", ""))[:200],
|
|
214
|
+
"num_comments": entry.get("numComments", 0),
|
|
215
|
+
"submitted_by": (entry.get("submittedBy") or {}).get("name"),
|
|
216
|
+
"url": f"https://huggingface.co/papers/{paper.get('id')}",
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return results
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Linking papers to repos
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def link_paper_to_repo(
|
|
226
|
+
self,
|
|
227
|
+
repo_id: str,
|
|
228
|
+
arxiv_id: str,
|
|
229
|
+
repo_type: str = "model",
|
|
230
|
+
citation: Optional[str] = None,
|
|
231
|
+
create_pr: bool = False,
|
|
232
|
+
) -> Dict[str, Any]:
|
|
233
|
+
"""
|
|
234
|
+
Link a paper to a model/dataset/space repository by updating its README.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
repo_id: Repository identifier (e.g., "username/repo-name")
|
|
238
|
+
arxiv_id: arXiv identifier
|
|
239
|
+
repo_type: Type of repository ("model", "dataset", or "space")
|
|
240
|
+
citation: Optional full citation text
|
|
241
|
+
create_pr: Create a PR instead of direct commit
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
dict: Operation status
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
248
|
+
except ValueError as e:
|
|
249
|
+
return {"status": "error", "message": str(e)}
|
|
250
|
+
|
|
251
|
+
print(f"Linking paper {arxiv_id} to {repo_type} {repo_id}...", file=sys.stderr)
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
readme_path = hf_hub_download(
|
|
255
|
+
repo_id=repo_id,
|
|
256
|
+
filename="README.md",
|
|
257
|
+
repo_type=repo_type,
|
|
258
|
+
token=self.token,
|
|
259
|
+
)
|
|
260
|
+
with open(readme_path, "r", encoding="utf-8") as f:
|
|
261
|
+
content = f.read()
|
|
262
|
+
|
|
263
|
+
updated_content = self._add_paper_to_readme(content, arxiv_id, citation)
|
|
264
|
+
commit_message = f"Add paper reference: arXiv:{arxiv_id}"
|
|
265
|
+
|
|
266
|
+
self.api.upload_file(
|
|
267
|
+
path_or_fileobj=updated_content.encode("utf-8"),
|
|
268
|
+
path_in_repo="README.md",
|
|
269
|
+
repo_id=repo_id,
|
|
270
|
+
repo_type=repo_type,
|
|
271
|
+
commit_message=commit_message,
|
|
272
|
+
create_pr=create_pr,
|
|
273
|
+
token=self.token,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
paper_url = f"https://huggingface.co/papers/{arxiv_id}"
|
|
277
|
+
repo_prefix = {"model": "", "dataset": "datasets/", "space": "spaces/"}
|
|
278
|
+
repo_url = f"https://huggingface.co/{repo_prefix.get(repo_type, '')}{repo_id}"
|
|
279
|
+
|
|
280
|
+
print(f"Linked paper to repository", file=sys.stderr)
|
|
281
|
+
print(f" Paper: {paper_url}", file=sys.stderr)
|
|
282
|
+
print(f" Repo: {repo_url}", file=sys.stderr)
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"status": "success",
|
|
286
|
+
"paper_url": paper_url,
|
|
287
|
+
"repo_url": repo_url,
|
|
288
|
+
"arxiv_id": arxiv_id,
|
|
289
|
+
"pr_created": create_pr,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return {"status": "error", "message": str(e)}
|
|
294
|
+
|
|
295
|
+
def _add_paper_to_readme(
|
|
296
|
+
self,
|
|
297
|
+
content: str,
|
|
298
|
+
arxiv_id: str,
|
|
299
|
+
citation: Optional[str] = None,
|
|
300
|
+
) -> str:
|
|
301
|
+
"""Add paper reference to README content."""
|
|
302
|
+
arxiv_url = f"https://arxiv.org/abs/{arxiv_id}"
|
|
303
|
+
hf_paper_url = f"https://huggingface.co/papers/{arxiv_id}"
|
|
304
|
+
|
|
305
|
+
yaml_pattern = r"^---\s*\n(.*?)\n---\s*\n"
|
|
306
|
+
match = re.match(yaml_pattern, content, re.DOTALL)
|
|
307
|
+
|
|
308
|
+
if match:
|
|
309
|
+
if arxiv_id in content:
|
|
310
|
+
print(f"Paper {arxiv_id} already referenced in README", file=sys.stderr)
|
|
311
|
+
return content
|
|
312
|
+
yaml_end = match.end()
|
|
313
|
+
before = content[:yaml_end]
|
|
314
|
+
after = content[yaml_end:]
|
|
315
|
+
else:
|
|
316
|
+
before = "---\n---\n\n"
|
|
317
|
+
after = content
|
|
318
|
+
|
|
319
|
+
paper_section = "\n<!-- paper-manager:start -->\n"
|
|
320
|
+
paper_section += "## Paper\n\n"
|
|
321
|
+
paper_section += "This work is based on research presented in:\n\n"
|
|
322
|
+
paper_section += f"**[View on arXiv]({arxiv_url})** | "
|
|
323
|
+
paper_section += f"**[View on Hugging Face]({hf_paper_url})**\n\n"
|
|
324
|
+
|
|
325
|
+
if citation:
|
|
326
|
+
safe_citation = self._sanitize_text(citation)
|
|
327
|
+
paper_section += f"### Citation\n\n```bibtex\n{safe_citation}\n```\n\n"
|
|
328
|
+
|
|
329
|
+
paper_section += "<!-- paper-manager:end -->\n"
|
|
330
|
+
|
|
331
|
+
return before + paper_section + after
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
# Authorship
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def claim_authorship(self, arxiv_id: str, email: Optional[str] = None) -> Dict[str, Any]:
|
|
338
|
+
"""
|
|
339
|
+
Guide the user through claiming authorship on a paper.
|
|
340
|
+
Authorship claims require manual verification via the HF web UI.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
arxiv_id: arXiv identifier
|
|
344
|
+
email: Author's institutional email
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
dict: Instructions for claiming authorship
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
351
|
+
except ValueError as e:
|
|
352
|
+
return {"status": "error", "message": str(e)}
|
|
353
|
+
|
|
354
|
+
paper_data = self.get_paper(arxiv_id)
|
|
355
|
+
if "error" in paper_data:
|
|
356
|
+
return {
|
|
357
|
+
"status": "error",
|
|
358
|
+
"message": f"Paper {arxiv_id} not found on HF. Index it first.",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
authors = [a.get("name") for a in paper_data.get("authors", [])]
|
|
362
|
+
claimed = [
|
|
363
|
+
a.get("name")
|
|
364
|
+
for a in paper_data.get("authors", [])
|
|
365
|
+
if a.get("status") == "claimed_verified"
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
paper_url = f"https://huggingface.co/papers/{arxiv_id}"
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
"status": "manual_action_required",
|
|
372
|
+
"paper_url": paper_url,
|
|
373
|
+
"authors": authors,
|
|
374
|
+
"already_claimed": claimed,
|
|
375
|
+
"instructions": [
|
|
376
|
+
f"1. Navigate to {paper_url}",
|
|
377
|
+
"2. Find your name in the author list",
|
|
378
|
+
"3. Click your name and select 'Claim authorship'",
|
|
379
|
+
"4. Verify with your institutional email" + (f" ({email})" if email else ""),
|
|
380
|
+
"5. Wait for admin team verification",
|
|
381
|
+
],
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
def check_authorship(self, arxiv_id: str) -> Dict[str, Any]:
|
|
385
|
+
"""
|
|
386
|
+
Check authorship claim status for a paper.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
arxiv_id: arXiv identifier
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
dict: Authorship status for all authors
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
396
|
+
except ValueError as e:
|
|
397
|
+
return {"error": str(e)}
|
|
398
|
+
|
|
399
|
+
paper_data = self.get_paper(arxiv_id)
|
|
400
|
+
if "error" in paper_data:
|
|
401
|
+
return {"error": f"Paper {arxiv_id} not found"}
|
|
402
|
+
|
|
403
|
+
authors_status = []
|
|
404
|
+
for author in paper_data.get("authors", []):
|
|
405
|
+
entry = {
|
|
406
|
+
"name": author.get("name"),
|
|
407
|
+
"status": author.get("status", "unclaimed"),
|
|
408
|
+
"hidden": author.get("hidden", False),
|
|
409
|
+
}
|
|
410
|
+
user = author.get("user")
|
|
411
|
+
if user:
|
|
412
|
+
entry["hf_username"] = user.get("user") or user.get("name")
|
|
413
|
+
authors_status.append(entry)
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"arxiv_id": arxiv_id,
|
|
417
|
+
"title": paper_data.get("title"),
|
|
418
|
+
"authors": authors_status,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------------
|
|
422
|
+
# Paper visibility (user profile)
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
def list_my_papers(self) -> Dict[str, Any]:
|
|
426
|
+
"""
|
|
427
|
+
List papers associated with the authenticated user.
|
|
428
|
+
Uses the HF API to find the user's profile and their papers.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
dict: User's papers or error
|
|
432
|
+
"""
|
|
433
|
+
if not self.token:
|
|
434
|
+
return {"error": "HF_TOKEN required to list your papers"}
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
user_info = self.api.whoami()
|
|
438
|
+
username = user_info.get("name")
|
|
439
|
+
if not username:
|
|
440
|
+
return {"error": "Could not determine username from token"}
|
|
441
|
+
|
|
442
|
+
# Search for papers by this user across daily papers
|
|
443
|
+
# The HF API doesn't have a direct "my papers" endpoint,
|
|
444
|
+
# so we search and filter by author
|
|
445
|
+
url = f"{HF_API_BASE}/papers/search"
|
|
446
|
+
resp = self.session.get(url, params={"q": username}, timeout=15)
|
|
447
|
+
resp.raise_for_status()
|
|
448
|
+
all_papers = resp.json()
|
|
449
|
+
|
|
450
|
+
my_papers = []
|
|
451
|
+
for entry in all_papers:
|
|
452
|
+
paper = entry.get("paper", entry)
|
|
453
|
+
authors = paper.get("authors", [])
|
|
454
|
+
# Check if user is an author (by HF username or name match)
|
|
455
|
+
is_author = any(
|
|
456
|
+
(a.get("user", {}) or {}).get("user") == username
|
|
457
|
+
or (a.get("user", {}) or {}).get("name") == username
|
|
458
|
+
for a in authors
|
|
459
|
+
)
|
|
460
|
+
if is_author:
|
|
461
|
+
my_papers.append({
|
|
462
|
+
"arxiv_id": paper.get("id"),
|
|
463
|
+
"title": paper.get("title"),
|
|
464
|
+
"upvotes": paper.get("upvotes", 0),
|
|
465
|
+
"published": paper.get("publishedAt"),
|
|
466
|
+
"url": f"https://huggingface.co/papers/{paper.get('id')}",
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
"username": username,
|
|
471
|
+
"papers_found": len(my_papers),
|
|
472
|
+
"papers": my_papers,
|
|
473
|
+
"note": "This searches by username. For complete list, visit your HF profile settings.",
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
except Exception as e:
|
|
477
|
+
return {"error": str(e)}
|
|
478
|
+
|
|
479
|
+
def toggle_visibility(self, arxiv_id: str, show: bool = True) -> Dict[str, Any]:
|
|
480
|
+
"""
|
|
481
|
+
Guide the user through toggling paper visibility on their profile.
|
|
482
|
+
This requires manual action via the HF web UI.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
arxiv_id: arXiv identifier
|
|
486
|
+
show: Whether to show (True) or hide (False) on profile
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
dict: Instructions
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
493
|
+
except ValueError as e:
|
|
494
|
+
return {"status": "error", "message": str(e)}
|
|
495
|
+
|
|
496
|
+
action = "show" if show else "hide"
|
|
497
|
+
return {
|
|
498
|
+
"status": "manual_action_required",
|
|
499
|
+
"arxiv_id": arxiv_id,
|
|
500
|
+
"action": action,
|
|
501
|
+
"instructions": [
|
|
502
|
+
"1. Navigate to https://huggingface.co/settings/papers",
|
|
503
|
+
f"2. Find paper {arxiv_id} in your claimed papers list",
|
|
504
|
+
f"3. Toggle 'Show on profile' to {'on' if show else 'off'}",
|
|
505
|
+
],
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------
|
|
509
|
+
# arXiv metadata
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def get_arxiv_info(self, arxiv_id: str) -> Dict[str, Any]:
|
|
513
|
+
"""
|
|
514
|
+
Fetch paper information from arXiv API.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
arxiv_id: arXiv identifier
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
dict: Paper metadata
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
524
|
+
except ValueError as e:
|
|
525
|
+
return {"error": str(e)}
|
|
526
|
+
|
|
527
|
+
api_url = f"https://export.arxiv.org/api/query?id_list={arxiv_id}"
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
response = requests.get(api_url, timeout=15)
|
|
531
|
+
response.raise_for_status()
|
|
532
|
+
content = response.text
|
|
533
|
+
|
|
534
|
+
title_match = re.search(r"<title>(.*?)</title>", content, re.DOTALL)
|
|
535
|
+
authors_matches = re.findall(r"<name>(.*?)</name>", content)
|
|
536
|
+
summary_match = re.search(r"<summary>(.*?)</summary>", content, re.DOTALL)
|
|
537
|
+
|
|
538
|
+
# First <title> is the feed title, skip it
|
|
539
|
+
all_titles = re.findall(r"<title>(.*?)</title>", content, re.DOTALL)
|
|
540
|
+
raw_title = all_titles[1].strip() if len(all_titles) > 1 else None
|
|
541
|
+
raw_authors = authors_matches if authors_matches else []
|
|
542
|
+
raw_abstract = summary_match.group(1).strip() if summary_match else None
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
"arxiv_id": arxiv_id,
|
|
546
|
+
"title": self._sanitize_text(raw_title) if raw_title else None,
|
|
547
|
+
"authors": [self._sanitize_text(a) for a in raw_authors],
|
|
548
|
+
"abstract": self._sanitize_text(raw_abstract) if raw_abstract else None,
|
|
549
|
+
"arxiv_url": f"https://arxiv.org/abs/{arxiv_id}",
|
|
550
|
+
"pdf_url": f"https://arxiv.org/pdf/{arxiv_id}.pdf",
|
|
551
|
+
}
|
|
552
|
+
except Exception as e:
|
|
553
|
+
return {"error": str(e)}
|
|
554
|
+
|
|
555
|
+
def generate_citation(self, arxiv_id: str, fmt: str = "bibtex") -> str:
|
|
556
|
+
"""
|
|
557
|
+
Generate citation for a paper.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
arxiv_id: arXiv identifier
|
|
561
|
+
fmt: Citation format ("bibtex", "apa", "mla")
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
str: Formatted citation
|
|
565
|
+
"""
|
|
566
|
+
try:
|
|
567
|
+
arxiv_id = self._clean_arxiv_id(arxiv_id)
|
|
568
|
+
except ValueError as e:
|
|
569
|
+
return f"Error: {e}"
|
|
570
|
+
|
|
571
|
+
info = self.get_arxiv_info(arxiv_id)
|
|
572
|
+
if "error" in info:
|
|
573
|
+
return f"Error fetching paper info: {info['error']}"
|
|
574
|
+
|
|
575
|
+
authors = info.get("authors", ["Unknown"])
|
|
576
|
+
title = info.get("title", "Untitled")
|
|
577
|
+
year_prefix = arxiv_id.split(".")[0][:2]
|
|
578
|
+
year = f"20{year_prefix}" if int(year_prefix) < 50 else f"19{year_prefix}"
|
|
579
|
+
|
|
580
|
+
if fmt == "bibtex":
|
|
581
|
+
key = f"arxiv{arxiv_id.replace('.', '_')}"
|
|
582
|
+
safe_title = title.replace("{", r"\{").replace("}", r"\}")
|
|
583
|
+
safe_authors = " and ".join(authors).replace("{", r"\{").replace("}", r"\}")
|
|
584
|
+
return (
|
|
585
|
+
f"@article{{{key},\n"
|
|
586
|
+
f" title={{{safe_title}}},\n"
|
|
587
|
+
f" author={{{safe_authors}}},\n"
|
|
588
|
+
f" journal={{arXiv preprint arXiv:{arxiv_id}}},\n"
|
|
589
|
+
f" year={{{year}}}\n"
|
|
590
|
+
f"}}"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if fmt == "apa":
|
|
594
|
+
if len(authors) > 7:
|
|
595
|
+
author_str = ", ".join(authors[:6]) + ", ... " + authors[-1]
|
|
596
|
+
else:
|
|
597
|
+
author_str = ", ".join(authors[:-1]) + ", & " + authors[-1] if len(authors) > 1 else authors[0]
|
|
598
|
+
return f"{author_str} ({year}). {title}. arXiv preprint arXiv:{arxiv_id}."
|
|
599
|
+
|
|
600
|
+
if fmt == "mla":
|
|
601
|
+
if len(authors) > 2:
|
|
602
|
+
author_str = authors[0] + ", et al."
|
|
603
|
+
elif len(authors) == 2:
|
|
604
|
+
author_str = authors[0] + ", and " + authors[1]
|
|
605
|
+
else:
|
|
606
|
+
author_str = authors[0]
|
|
607
|
+
return f'{author_str}. "{title}." arXiv preprint arXiv:{arxiv_id} ({year}).'
|
|
608
|
+
|
|
609
|
+
return f"Format '{fmt}' not supported. Use bibtex, apa, or mla."
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
# Article creation & conversion
|
|
613
|
+
# ------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
def create_research_article(
|
|
616
|
+
self,
|
|
617
|
+
template: str,
|
|
618
|
+
title: str,
|
|
619
|
+
output: str,
|
|
620
|
+
authors: Optional[str] = None,
|
|
621
|
+
abstract: Optional[str] = None,
|
|
622
|
+
) -> Dict[str, Any]:
|
|
623
|
+
"""
|
|
624
|
+
Create a research article from template.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
template: Template name ("standard", "modern", "arxiv", "ml-report")
|
|
628
|
+
title: Paper title
|
|
629
|
+
output: Output filename
|
|
630
|
+
authors: Comma-separated author names
|
|
631
|
+
abstract: Abstract text
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
dict: Creation status
|
|
635
|
+
"""
|
|
636
|
+
template_dir = Path(__file__).parent.parent / "templates"
|
|
637
|
+
template_file = template_dir / f"{template}.md"
|
|
638
|
+
|
|
639
|
+
if not template_file.exists():
|
|
640
|
+
available = [f.stem for f in template_dir.glob("*.md")]
|
|
641
|
+
return {
|
|
642
|
+
"status": "error",
|
|
643
|
+
"message": f"Template '{template}' not found. Available: {available}",
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
with open(template_file, "r", encoding="utf-8") as f:
|
|
647
|
+
template_content = f.read()
|
|
648
|
+
|
|
649
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
650
|
+
authors_val = authors if authors else "Your Name"
|
|
651
|
+
abstract_val = abstract if abstract else "Abstract to be written..."
|
|
652
|
+
|
|
653
|
+
safe_title_body = self._sanitize_text(title)
|
|
654
|
+
safe_authors_body = self._sanitize_text(authors_val)
|
|
655
|
+
safe_abstract_body = self._sanitize_text(abstract_val)
|
|
656
|
+
|
|
657
|
+
fm_pattern = r"^(---\s*\n)(.*?\n)(---\s*\n)"
|
|
658
|
+
fm_match = re.match(fm_pattern, template_content, re.DOTALL)
|
|
659
|
+
|
|
660
|
+
if fm_match:
|
|
661
|
+
fm_open = fm_match.group(1)
|
|
662
|
+
fm_body = fm_match.group(2)
|
|
663
|
+
fm_close = fm_match.group(3)
|
|
664
|
+
body = template_content[fm_match.end():]
|
|
665
|
+
|
|
666
|
+
fm_body = fm_body.replace("{{TITLE}}", self._escape_yaml_value(title))
|
|
667
|
+
fm_body = fm_body.replace("{{AUTHORS}}", self._escape_yaml_value(authors_val))
|
|
668
|
+
fm_body = fm_body.replace("{{DATE}}", date_str)
|
|
669
|
+
|
|
670
|
+
body = body.replace("{{TITLE}}", safe_title_body)
|
|
671
|
+
body = body.replace("{{AUTHORS}}", safe_authors_body)
|
|
672
|
+
body = body.replace("{{ABSTRACT}}", safe_abstract_body)
|
|
673
|
+
body = body.replace("{{DATE}}", date_str)
|
|
674
|
+
|
|
675
|
+
content = fm_open + fm_body + fm_close + body
|
|
676
|
+
else:
|
|
677
|
+
content = template_content
|
|
678
|
+
content = content.replace("{{TITLE}}", safe_title_body)
|
|
679
|
+
content = content.replace("{{DATE}}", date_str)
|
|
680
|
+
content = content.replace("{{AUTHORS}}", safe_authors_body)
|
|
681
|
+
content = content.replace("{{ABSTRACT}}", safe_abstract_body)
|
|
682
|
+
|
|
683
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
684
|
+
f.write(content)
|
|
685
|
+
|
|
686
|
+
print(f"Research article created at {output}", file=sys.stderr)
|
|
687
|
+
return {"status": "success", "output": output, "template": template}
|
|
688
|
+
|
|
689
|
+
def convert_to_html(
|
|
690
|
+
self,
|
|
691
|
+
input_path: str,
|
|
692
|
+
output_path: str,
|
|
693
|
+
style: str = "modern",
|
|
694
|
+
) -> Dict[str, Any]:
|
|
695
|
+
"""
|
|
696
|
+
Convert a markdown research article to styled HTML.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
input_path: Path to input markdown file
|
|
700
|
+
output_path: Path for output HTML file
|
|
701
|
+
style: Style theme ("modern" or "classic")
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
dict: Conversion status
|
|
705
|
+
"""
|
|
706
|
+
input_file = Path(input_path)
|
|
707
|
+
if not input_file.exists():
|
|
708
|
+
return {"status": "error", "message": f"Input file not found: {input_path}"}
|
|
709
|
+
|
|
710
|
+
with open(input_file, "r", encoding="utf-8") as f:
|
|
711
|
+
content = f.read()
|
|
712
|
+
|
|
713
|
+
# Strip YAML frontmatter but extract metadata
|
|
714
|
+
metadata = {}
|
|
715
|
+
fm_pattern = r"^---\s*\n(.*?)\n---\s*\n"
|
|
716
|
+
fm_match = re.match(fm_pattern, content, re.DOTALL)
|
|
717
|
+
if fm_match:
|
|
718
|
+
try:
|
|
719
|
+
metadata = yaml.safe_load(fm_match.group(1)) or {}
|
|
720
|
+
except yaml.YAMLError:
|
|
721
|
+
pass
|
|
722
|
+
content = content[fm_match.end():]
|
|
723
|
+
|
|
724
|
+
# Convert markdown to HTML
|
|
725
|
+
html_body = md_lib.markdown(
|
|
726
|
+
content,
|
|
727
|
+
extensions=["tables", "fenced_code", "toc", "attr_list"],
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
title = metadata.get("title", "Research Article")
|
|
731
|
+
authors = metadata.get("authors", "")
|
|
732
|
+
paper_date = metadata.get("date", "")
|
|
733
|
+
|
|
734
|
+
if style == "modern":
|
|
735
|
+
css = MODERN_CSS
|
|
736
|
+
else:
|
|
737
|
+
css = CLASSIC_CSS
|
|
738
|
+
|
|
739
|
+
html = f"""<!DOCTYPE html>
|
|
740
|
+
<html lang="en">
|
|
741
|
+
<head>
|
|
742
|
+
<meta charset="UTF-8">
|
|
743
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
744
|
+
<title>{self._escape_html(str(title))}</title>
|
|
745
|
+
<style>{css}</style>
|
|
746
|
+
</head>
|
|
747
|
+
<body>
|
|
748
|
+
<article class="paper">
|
|
749
|
+
<header class="paper-header">
|
|
750
|
+
<h1>{self._escape_html(str(title))}</h1>
|
|
751
|
+
<div class="authors">{self._escape_html(str(authors))}</div>
|
|
752
|
+
<div class="date">{self._escape_html(str(paper_date))}</div>
|
|
753
|
+
</header>
|
|
754
|
+
<div class="content">
|
|
755
|
+
{html_body}
|
|
756
|
+
</div>
|
|
757
|
+
</article>
|
|
758
|
+
</body>
|
|
759
|
+
</html>"""
|
|
760
|
+
|
|
761
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
762
|
+
f.write(html)
|
|
763
|
+
|
|
764
|
+
print(f"HTML article created at {output_path}", file=sys.stderr)
|
|
765
|
+
return {"status": "success", "output": output_path, "style": style}
|
|
766
|
+
|
|
767
|
+
# ------------------------------------------------------------------
|
|
768
|
+
# Validation
|
|
769
|
+
# ------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
def validate_repo_papers(self, repo_id: str, repo_type: str = "model") -> Dict[str, Any]:
|
|
772
|
+
"""
|
|
773
|
+
Validate all paper references in a repository's README.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
repo_id: Repository identifier
|
|
777
|
+
repo_type: Type of repository
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
dict: Validation results
|
|
781
|
+
"""
|
|
782
|
+
try:
|
|
783
|
+
readme_path = hf_hub_download(
|
|
784
|
+
repo_id=repo_id,
|
|
785
|
+
filename="README.md",
|
|
786
|
+
repo_type=repo_type,
|
|
787
|
+
token=self.token,
|
|
788
|
+
)
|
|
789
|
+
with open(readme_path, "r", encoding="utf-8") as f:
|
|
790
|
+
content = f.read()
|
|
791
|
+
except Exception as e:
|
|
792
|
+
return {"status": "error", "message": str(e)}
|
|
793
|
+
|
|
794
|
+
# Find all arXiv references
|
|
795
|
+
arxiv_ids = set()
|
|
796
|
+
# Match arxiv.org URLs
|
|
797
|
+
arxiv_ids.update(re.findall(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})", content))
|
|
798
|
+
# Match arxiv: tags
|
|
799
|
+
arxiv_ids.update(re.findall(r"arxiv:(\d{4}\.\d{4,5})", content, re.IGNORECASE))
|
|
800
|
+
|
|
801
|
+
if not arxiv_ids:
|
|
802
|
+
return {
|
|
803
|
+
"status": "ok",
|
|
804
|
+
"repo_id": repo_id,
|
|
805
|
+
"papers_found": 0,
|
|
806
|
+
"message": "No arXiv references found in README",
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
results = []
|
|
810
|
+
for aid in sorted(arxiv_ids):
|
|
811
|
+
paper_data = self.get_paper(aid)
|
|
812
|
+
indexed = "error" not in paper_data
|
|
813
|
+
results.append({
|
|
814
|
+
"arxiv_id": aid,
|
|
815
|
+
"indexed_on_hf": indexed,
|
|
816
|
+
"title": paper_data.get("title") if indexed else None,
|
|
817
|
+
"url": f"https://huggingface.co/papers/{aid}",
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
all_valid = all(r["indexed_on_hf"] for r in results)
|
|
821
|
+
return {
|
|
822
|
+
"status": "ok" if all_valid else "warnings",
|
|
823
|
+
"repo_id": repo_id,
|
|
824
|
+
"papers_found": len(results),
|
|
825
|
+
"all_indexed": all_valid,
|
|
826
|
+
"papers": results,
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
# ------------------------------------------------------------------
|
|
830
|
+
# Helpers
|
|
831
|
+
# ------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
_ARXIV_ID_MODERN = re.compile(r"^\d{4}\.\d{4,5}(v\d+)?$")
|
|
834
|
+
_ARXIV_ID_LEGACY = re.compile(r"^[a-zA-Z\-]+/\d{7}(v\d+)?$")
|
|
835
|
+
|
|
836
|
+
@staticmethod
|
|
837
|
+
def _clean_arxiv_id(arxiv_id: str) -> str:
|
|
838
|
+
"""Clean, normalize, and validate arXiv ID."""
|
|
839
|
+
arxiv_id = arxiv_id.strip()
|
|
840
|
+
arxiv_id = re.sub(r"^(arxiv:|arXiv:)", "", arxiv_id, flags=re.IGNORECASE)
|
|
841
|
+
arxiv_id = re.sub(r"https?://arxiv\.org/(abs|pdf)/", "", arxiv_id)
|
|
842
|
+
arxiv_id = arxiv_id.replace(".pdf", "")
|
|
843
|
+
|
|
844
|
+
if not (
|
|
845
|
+
PaperManager._ARXIV_ID_MODERN.match(arxiv_id)
|
|
846
|
+
or PaperManager._ARXIV_ID_LEGACY.match(arxiv_id)
|
|
847
|
+
):
|
|
848
|
+
raise ValueError(
|
|
849
|
+
f"Invalid arXiv ID: {arxiv_id!r}. "
|
|
850
|
+
"Expected format: YYMM.NNNNN[vN] or category/YYMMNNN[vN]"
|
|
851
|
+
)
|
|
852
|
+
return arxiv_id
|
|
853
|
+
|
|
854
|
+
@staticmethod
|
|
855
|
+
def _escape_yaml_value(value: str) -> str:
|
|
856
|
+
"""Escape a string for safe use as a YAML scalar value."""
|
|
857
|
+
value = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
858
|
+
return f'"{value}"'
|
|
859
|
+
|
|
860
|
+
@staticmethod
|
|
861
|
+
def _sanitize_text(text: str) -> str:
|
|
862
|
+
"""Sanitize untrusted text for safe inclusion in Markdown/YAML output."""
|
|
863
|
+
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
|
|
864
|
+
text = re.sub(r"[^\S\n]+", " ", text)
|
|
865
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
866
|
+
text = text.replace("```", r"\`\`\`")
|
|
867
|
+
text = re.sub(r"^---", r"\\---", text, flags=re.MULTILINE)
|
|
868
|
+
return text.strip()
|
|
869
|
+
|
|
870
|
+
@staticmethod
|
|
871
|
+
def _escape_html(text: str) -> str:
|
|
872
|
+
"""Escape text for safe HTML embedding."""
|
|
873
|
+
return (
|
|
874
|
+
text.replace("&", "&")
|
|
875
|
+
.replace("<", "<")
|
|
876
|
+
.replace(">", ">")
|
|
877
|
+
.replace('"', """)
|
|
878
|
+
.replace("'", "'")
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# ------------------------------------------------------------------
|
|
883
|
+
# CSS themes for HTML conversion
|
|
884
|
+
# ------------------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
MODERN_CSS = """
|
|
887
|
+
:root {
|
|
888
|
+
--primary: #2563eb;
|
|
889
|
+
--bg: #ffffff;
|
|
890
|
+
--text: #1e293b;
|
|
891
|
+
--text-secondary: #64748b;
|
|
892
|
+
--border: #e2e8f0;
|
|
893
|
+
--code-bg: #f1f5f9;
|
|
894
|
+
--accent-bg: #f8fafc;
|
|
895
|
+
}
|
|
896
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
897
|
+
body {
|
|
898
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
899
|
+
line-height: 1.8;
|
|
900
|
+
color: var(--text);
|
|
901
|
+
background: var(--bg);
|
|
902
|
+
max-width: 800px;
|
|
903
|
+
margin: 0 auto;
|
|
904
|
+
padding: 2rem 1.5rem;
|
|
905
|
+
}
|
|
906
|
+
.paper-header {
|
|
907
|
+
text-align: center;
|
|
908
|
+
margin-bottom: 3rem;
|
|
909
|
+
padding-bottom: 2rem;
|
|
910
|
+
border-bottom: 2px solid var(--border);
|
|
911
|
+
}
|
|
912
|
+
.paper-header h1 { font-size: 2rem; line-height: 1.3; margin-bottom: 1rem; }
|
|
913
|
+
.authors { font-size: 1.1rem; color: var(--text-secondary); margin-bottom: 0.5rem; }
|
|
914
|
+
.date { color: var(--text-secondary); font-size: 0.9rem; }
|
|
915
|
+
h2 { font-size: 1.5rem; margin: 2.5rem 0 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
|
|
916
|
+
h3 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
|
|
917
|
+
p { margin-bottom: 1rem; }
|
|
918
|
+
table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
|
|
919
|
+
th, td { padding: 0.75rem 1rem; border: 1px solid var(--border); text-align: left; }
|
|
920
|
+
th { background: var(--accent-bg); font-weight: 600; }
|
|
921
|
+
pre { background: var(--code-bg); padding: 1.25rem; border-radius: 8px; overflow-x: auto; margin: 1.5rem 0; }
|
|
922
|
+
code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.9em; }
|
|
923
|
+
blockquote { border-left: 4px solid var(--primary); padding: 0.75rem 1rem; margin: 1.5rem 0; background: var(--accent-bg); }
|
|
924
|
+
ul, ol { margin: 0.75rem 0 0.75rem 1.5rem; }
|
|
925
|
+
li { margin-bottom: 0.4rem; }
|
|
926
|
+
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
|
927
|
+
img { max-width: 100%; border-radius: 8px; }
|
|
928
|
+
a { color: var(--primary); text-decoration: none; }
|
|
929
|
+
a:hover { text-decoration: underline; }
|
|
930
|
+
.key-insight, .insight { background: #eff6ff; border-left: 4px solid var(--primary); padding: 1rem 1.25rem; margin: 1.5rem 0; border-radius: 0 8px 8px 0; }
|
|
931
|
+
.limitations { background: #fef2f2; border-left: 4px solid #ef4444; padding: 1rem 1.25rem; margin: 1.5rem 0; border-radius: 0 8px 8px 0; }
|
|
932
|
+
.conclusion { background: #f0fdf4; border-left: 4px solid #22c55e; padding: 1rem 1.25rem; margin: 1.5rem 0; border-radius: 0 8px 8px 0; }
|
|
933
|
+
.abstract { background: var(--accent-bg); padding: 1.5rem; border-radius: 8px; margin: 1.5rem 0; }
|
|
934
|
+
"""
|
|
935
|
+
|
|
936
|
+
CLASSIC_CSS = """
|
|
937
|
+
body {
|
|
938
|
+
font-family: 'Times New Roman', 'Computer Modern', Georgia, serif;
|
|
939
|
+
line-height: 1.6;
|
|
940
|
+
color: #333;
|
|
941
|
+
max-width: 700px;
|
|
942
|
+
margin: 0 auto;
|
|
943
|
+
padding: 2rem 1.5rem;
|
|
944
|
+
}
|
|
945
|
+
.paper-header { text-align: center; margin-bottom: 2rem; }
|
|
946
|
+
.paper-header h1 { font-size: 1.8rem; margin-bottom: 0.75rem; }
|
|
947
|
+
.authors { font-size: 1rem; margin-bottom: 0.5rem; }
|
|
948
|
+
.date { font-size: 0.9rem; color: #666; }
|
|
949
|
+
h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
|
950
|
+
h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
|
951
|
+
p { margin-bottom: 0.75rem; text-align: justify; }
|
|
952
|
+
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
|
953
|
+
th, td { padding: 0.5rem; border: 1px solid #ccc; text-align: left; }
|
|
954
|
+
th { background: #f5f5f5; }
|
|
955
|
+
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-size: 0.85rem; }
|
|
956
|
+
code { font-family: 'Courier New', monospace; font-size: 0.9em; }
|
|
957
|
+
blockquote { border-left: 3px solid #999; padding-left: 1rem; margin: 1rem 0; color: #555; }
|
|
958
|
+
ul, ol { margin: 0.5rem 0 0.5rem 1.5rem; }
|
|
959
|
+
hr { border: none; border-top: 1px solid #ccc; margin: 1.5rem 0; }
|
|
960
|
+
a { color: #1a0dab; }
|
|
961
|
+
"""
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
# ------------------------------------------------------------------
|
|
965
|
+
# CLI
|
|
966
|
+
# ------------------------------------------------------------------
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _print_json(data):
|
|
970
|
+
"""Pretty-print JSON to stdout."""
|
|
971
|
+
print(json.dumps(data, indent=2, default=str))
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _print_papers_table(papers: List[Dict], show_summary: bool = False):
|
|
975
|
+
"""Print papers in a human-readable table."""
|
|
976
|
+
if not papers:
|
|
977
|
+
print("No papers found.")
|
|
978
|
+
return
|
|
979
|
+
|
|
980
|
+
for i, p in enumerate(papers, 1):
|
|
981
|
+
print(f"\n{'='*70}")
|
|
982
|
+
print(f" [{i}] {p.get('title', 'Untitled')}")
|
|
983
|
+
print(f" arXiv: {p.get('arxiv_id', 'N/A')} | Upvotes: {p.get('upvotes', 0)}")
|
|
984
|
+
authors = p.get("authors")
|
|
985
|
+
if authors:
|
|
986
|
+
display = ", ".join(authors[:4])
|
|
987
|
+
if len(authors) > 4:
|
|
988
|
+
display += f" + {len(authors) - 4} more"
|
|
989
|
+
print(f" Authors: {display}")
|
|
990
|
+
submitted_by = p.get("submitted_by")
|
|
991
|
+
if submitted_by:
|
|
992
|
+
print(f" Submitted by: {submitted_by}")
|
|
993
|
+
if show_summary and p.get("summary"):
|
|
994
|
+
print(f" {p['summary']}")
|
|
995
|
+
print(f" {p.get('url', '')}")
|
|
996
|
+
print(f"\n{'='*70}")
|
|
997
|
+
print(f" Total: {len(papers)} papers")
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def main():
|
|
1001
|
+
"""Main CLI entry point."""
|
|
1002
|
+
parser = argparse.ArgumentParser(
|
|
1003
|
+
description="Paper Manager for Hugging Face Hub",
|
|
1004
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1005
|
+
epilog="""
|
|
1006
|
+
Examples:
|
|
1007
|
+
%(prog)s daily # Today's papers
|
|
1008
|
+
%(prog)s daily --date 2026-03-20 # Papers from a specific date
|
|
1009
|
+
%(prog)s search --query "attention" # Search papers
|
|
1010
|
+
%(prog)s check --arxiv-id 2301.12345 # Check if paper is indexed
|
|
1011
|
+
%(prog)s info --arxiv-id 2301.12345 # Get arXiv metadata
|
|
1012
|
+
%(prog)s citation --arxiv-id 2301.12345 # Generate BibTeX citation
|
|
1013
|
+
%(prog)s link --repo-id user/model --arxiv-id 2301.12345
|
|
1014
|
+
%(prog)s create --template modern --title "My Paper" --output paper.md
|
|
1015
|
+
%(prog)s convert --input paper.md --output paper.html
|
|
1016
|
+
%(prog)s validate --repo-id user/model
|
|
1017
|
+
""",
|
|
1018
|
+
)
|
|
1019
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON instead of formatted text")
|
|
1020
|
+
|
|
1021
|
+
sub = parser.add_subparsers(dest="command", help="Command to execute")
|
|
1022
|
+
|
|
1023
|
+
# --- daily ---
|
|
1024
|
+
p_daily = sub.add_parser("daily", help="Fetch daily curated papers")
|
|
1025
|
+
p_daily.add_argument("--date", help="Date in YYYY-MM-DD format (default: today)")
|
|
1026
|
+
p_daily.add_argument("--limit", type=int, default=30, help="Max papers to show")
|
|
1027
|
+
|
|
1028
|
+
# --- search ---
|
|
1029
|
+
p_search = sub.add_parser("search", help="Search papers on Hugging Face")
|
|
1030
|
+
p_search.add_argument("--query", "-q", required=True, help="Search query")
|
|
1031
|
+
p_search.add_argument("--limit", type=int, default=20, help="Max results")
|
|
1032
|
+
|
|
1033
|
+
# --- index ---
|
|
1034
|
+
p_index = sub.add_parser("index", help="Index a paper from arXiv on HF")
|
|
1035
|
+
p_index.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1036
|
+
|
|
1037
|
+
# --- check ---
|
|
1038
|
+
p_check = sub.add_parser("check", help="Check if paper exists on HF")
|
|
1039
|
+
p_check.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1040
|
+
|
|
1041
|
+
# --- info ---
|
|
1042
|
+
p_info = sub.add_parser("info", help="Get paper metadata from arXiv")
|
|
1043
|
+
p_info.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1044
|
+
p_info.add_argument("--format", default="text", choices=["json", "text"])
|
|
1045
|
+
|
|
1046
|
+
# --- citation ---
|
|
1047
|
+
p_cite = sub.add_parser("citation", help="Generate citation")
|
|
1048
|
+
p_cite.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1049
|
+
p_cite.add_argument("--format", default="bibtex", choices=["bibtex", "apa", "mla"])
|
|
1050
|
+
|
|
1051
|
+
# --- link ---
|
|
1052
|
+
p_link = sub.add_parser("link", help="Link paper to a HF repository")
|
|
1053
|
+
p_link.add_argument("--repo-id", required=True, help="Repository ID (user/repo)")
|
|
1054
|
+
p_link.add_argument("--repo-type", default="model", choices=["model", "dataset", "space"])
|
|
1055
|
+
p_link.add_argument("--arxiv-id", help="Single arXiv ID")
|
|
1056
|
+
p_link.add_argument("--arxiv-ids", help="Comma-separated arXiv IDs")
|
|
1057
|
+
p_link.add_argument("--citation", help="Full citation text")
|
|
1058
|
+
p_link.add_argument("--create-pr", action="store_true", help="Create PR instead of direct commit")
|
|
1059
|
+
|
|
1060
|
+
# --- claim ---
|
|
1061
|
+
p_claim = sub.add_parser("claim", help="Claim authorship on a paper")
|
|
1062
|
+
p_claim.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1063
|
+
p_claim.add_argument("--email", help="Your institutional email")
|
|
1064
|
+
|
|
1065
|
+
# --- check-authorship ---
|
|
1066
|
+
p_authorship = sub.add_parser("check-authorship", help="Check authorship status")
|
|
1067
|
+
p_authorship.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1068
|
+
|
|
1069
|
+
# --- list-my-papers ---
|
|
1070
|
+
sub.add_parser("list-my-papers", help="List your claimed papers")
|
|
1071
|
+
|
|
1072
|
+
# --- toggle-visibility ---
|
|
1073
|
+
p_vis = sub.add_parser("toggle-visibility", help="Toggle paper visibility on profile")
|
|
1074
|
+
p_vis.add_argument("--arxiv-id", required=True, help="arXiv paper ID")
|
|
1075
|
+
p_vis.add_argument("--show", required=True, choices=["true", "false"], help="Show on profile")
|
|
1076
|
+
|
|
1077
|
+
# --- create ---
|
|
1078
|
+
p_create = sub.add_parser("create", help="Create research article from template")
|
|
1079
|
+
p_create.add_argument("--template", required=True, help="Template: standard, modern, arxiv, ml-report")
|
|
1080
|
+
p_create.add_argument("--title", required=True, help="Paper title")
|
|
1081
|
+
p_create.add_argument("--output", required=True, help="Output filename")
|
|
1082
|
+
p_create.add_argument("--authors", help="Comma-separated author names")
|
|
1083
|
+
p_create.add_argument("--abstract", help="Abstract text")
|
|
1084
|
+
|
|
1085
|
+
# --- convert ---
|
|
1086
|
+
p_convert = sub.add_parser("convert", help="Convert markdown article to HTML")
|
|
1087
|
+
p_convert.add_argument("--input", required=True, help="Input markdown file")
|
|
1088
|
+
p_convert.add_argument("--output", required=True, help="Output HTML file")
|
|
1089
|
+
p_convert.add_argument("--style", default="modern", choices=["modern", "classic"])
|
|
1090
|
+
|
|
1091
|
+
# --- validate ---
|
|
1092
|
+
p_validate = sub.add_parser("validate", help="Validate paper links in a repository")
|
|
1093
|
+
p_validate.add_argument("--repo-id", required=True, help="Repository ID")
|
|
1094
|
+
p_validate.add_argument("--repo-type", default="model", choices=["model", "dataset", "space"])
|
|
1095
|
+
|
|
1096
|
+
args = parser.parse_args()
|
|
1097
|
+
|
|
1098
|
+
if not args.command:
|
|
1099
|
+
parser.print_help()
|
|
1100
|
+
sys.exit(1)
|
|
1101
|
+
|
|
1102
|
+
use_json = args.json
|
|
1103
|
+
manager = PaperManager()
|
|
1104
|
+
|
|
1105
|
+
# --- Execute commands ---
|
|
1106
|
+
|
|
1107
|
+
if args.command == "daily":
|
|
1108
|
+
papers = manager.daily_papers(date_str=args.date, limit=args.limit)
|
|
1109
|
+
if use_json:
|
|
1110
|
+
_print_json(papers)
|
|
1111
|
+
else:
|
|
1112
|
+
label = args.date or "today"
|
|
1113
|
+
print(f"\nDaily Papers ({label}):")
|
|
1114
|
+
_print_papers_table(papers, show_summary=True)
|
|
1115
|
+
|
|
1116
|
+
elif args.command == "search":
|
|
1117
|
+
papers = manager.search_papers(args.query, limit=args.limit)
|
|
1118
|
+
if use_json:
|
|
1119
|
+
_print_json(papers)
|
|
1120
|
+
else:
|
|
1121
|
+
print(f"\nSearch results for: \"{args.query}\"")
|
|
1122
|
+
_print_papers_table(papers, show_summary=True)
|
|
1123
|
+
|
|
1124
|
+
elif args.command == "index":
|
|
1125
|
+
result = manager.index_paper(args.arxiv_id)
|
|
1126
|
+
_print_json(result)
|
|
1127
|
+
|
|
1128
|
+
elif args.command == "check":
|
|
1129
|
+
result = manager.check_paper(args.arxiv_id)
|
|
1130
|
+
_print_json(result)
|
|
1131
|
+
|
|
1132
|
+
elif args.command == "info":
|
|
1133
|
+
result = manager.get_arxiv_info(args.arxiv_id)
|
|
1134
|
+
if args.format == "json" or use_json:
|
|
1135
|
+
_print_json(result)
|
|
1136
|
+
else:
|
|
1137
|
+
if "error" in result:
|
|
1138
|
+
print(f"Error: {result['error']}")
|
|
1139
|
+
else:
|
|
1140
|
+
print(f"\nTitle: {result.get('title')}")
|
|
1141
|
+
print(f"Authors: {', '.join(result.get('authors', []))}")
|
|
1142
|
+
print(f"arXiv: {result.get('arxiv_url')}")
|
|
1143
|
+
print(f"PDF: {result.get('pdf_url')}")
|
|
1144
|
+
abstract = result.get("abstract")
|
|
1145
|
+
if abstract:
|
|
1146
|
+
print(f"\nAbstract:\n{abstract}")
|
|
1147
|
+
|
|
1148
|
+
elif args.command == "citation":
|
|
1149
|
+
citation = manager.generate_citation(args.arxiv_id, args.format)
|
|
1150
|
+
print(citation)
|
|
1151
|
+
|
|
1152
|
+
elif args.command == "link":
|
|
1153
|
+
arxiv_ids = []
|
|
1154
|
+
if args.arxiv_id:
|
|
1155
|
+
arxiv_ids.append(args.arxiv_id)
|
|
1156
|
+
if args.arxiv_ids:
|
|
1157
|
+
arxiv_ids.extend([i.strip() for i in args.arxiv_ids.split(",")])
|
|
1158
|
+
if not arxiv_ids:
|
|
1159
|
+
print("Error: Must provide --arxiv-id or --arxiv-ids")
|
|
1160
|
+
sys.exit(1)
|
|
1161
|
+
for aid in arxiv_ids:
|
|
1162
|
+
result = manager.link_paper_to_repo(
|
|
1163
|
+
repo_id=args.repo_id,
|
|
1164
|
+
arxiv_id=aid,
|
|
1165
|
+
repo_type=args.repo_type,
|
|
1166
|
+
citation=args.citation,
|
|
1167
|
+
create_pr=args.create_pr,
|
|
1168
|
+
)
|
|
1169
|
+
_print_json(result)
|
|
1170
|
+
|
|
1171
|
+
elif args.command == "claim":
|
|
1172
|
+
result = manager.claim_authorship(args.arxiv_id, email=args.email)
|
|
1173
|
+
_print_json(result)
|
|
1174
|
+
|
|
1175
|
+
elif args.command == "check-authorship":
|
|
1176
|
+
result = manager.check_authorship(args.arxiv_id)
|
|
1177
|
+
if use_json:
|
|
1178
|
+
_print_json(result)
|
|
1179
|
+
else:
|
|
1180
|
+
if "error" in result:
|
|
1181
|
+
print(f"Error: {result['error']}")
|
|
1182
|
+
else:
|
|
1183
|
+
print(f"\nAuthorship Status: {result.get('title')}")
|
|
1184
|
+
print(f"arXiv: {result.get('arxiv_id')}\n")
|
|
1185
|
+
for a in result.get("authors", []):
|
|
1186
|
+
status_icon = {
|
|
1187
|
+
"claimed_verified": "[verified]",
|
|
1188
|
+
"claimed": "[pending]",
|
|
1189
|
+
"unclaimed": "[unclaimed]",
|
|
1190
|
+
}.get(a["status"], f"[{a['status']}]")
|
|
1191
|
+
username = a.get("hf_username", "")
|
|
1192
|
+
username_str = f" (@{username})" if username else ""
|
|
1193
|
+
print(f" {status_icon} {a['name']}{username_str}")
|
|
1194
|
+
|
|
1195
|
+
elif args.command == "list-my-papers":
|
|
1196
|
+
result = manager.list_my_papers()
|
|
1197
|
+
if use_json:
|
|
1198
|
+
_print_json(result)
|
|
1199
|
+
else:
|
|
1200
|
+
if "error" in result:
|
|
1201
|
+
print(f"Error: {result['error']}")
|
|
1202
|
+
else:
|
|
1203
|
+
print(f"\nPapers for @{result.get('username')} ({result.get('papers_found')} found):")
|
|
1204
|
+
for p in result.get("papers", []):
|
|
1205
|
+
print(f" - [{p['arxiv_id']}] {p['title']} (upvotes: {p['upvotes']})")
|
|
1206
|
+
print(f" {p['url']}")
|
|
1207
|
+
if result.get("note"):
|
|
1208
|
+
print(f"\nNote: {result['note']}")
|
|
1209
|
+
|
|
1210
|
+
elif args.command == "toggle-visibility":
|
|
1211
|
+
result = manager.toggle_visibility(args.arxiv_id, show=(args.show == "true"))
|
|
1212
|
+
_print_json(result)
|
|
1213
|
+
|
|
1214
|
+
elif args.command == "create":
|
|
1215
|
+
result = manager.create_research_article(
|
|
1216
|
+
template=args.template,
|
|
1217
|
+
title=args.title,
|
|
1218
|
+
output=args.output,
|
|
1219
|
+
authors=args.authors,
|
|
1220
|
+
abstract=args.abstract,
|
|
1221
|
+
)
|
|
1222
|
+
_print_json(result)
|
|
1223
|
+
|
|
1224
|
+
elif args.command == "convert":
|
|
1225
|
+
result = manager.convert_to_html(
|
|
1226
|
+
input_path=args.input,
|
|
1227
|
+
output_path=args.output,
|
|
1228
|
+
style=args.style,
|
|
1229
|
+
)
|
|
1230
|
+
_print_json(result)
|
|
1231
|
+
|
|
1232
|
+
elif args.command == "validate":
|
|
1233
|
+
result = manager.validate_repo_papers(args.repo_id, repo_type=args.repo_type)
|
|
1234
|
+
if use_json:
|
|
1235
|
+
_print_json(result)
|
|
1236
|
+
else:
|
|
1237
|
+
if "error" in result.get("status", ""):
|
|
1238
|
+
print(f"Error: {result.get('message')}")
|
|
1239
|
+
else:
|
|
1240
|
+
print(f"\nValidation: {result['repo_id']} ({result['papers_found']} papers)")
|
|
1241
|
+
for p in result.get("papers", []):
|
|
1242
|
+
icon = "[ok]" if p["indexed_on_hf"] else "[NOT INDEXED]"
|
|
1243
|
+
title = p.get("title") or ""
|
|
1244
|
+
print(f" {icon} {p['arxiv_id']} {title}")
|
|
1245
|
+
if result.get("all_indexed"):
|
|
1246
|
+
print("\nAll papers are indexed on Hugging Face.")
|
|
1247
|
+
else:
|
|
1248
|
+
print("\nSome papers are not indexed. Visit their URLs to trigger indexing.")
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
if __name__ == "__main__":
|
|
1252
|
+
main()
|