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.
@@ -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("&", "&amp;")
875
+ .replace("<", "&lt;")
876
+ .replace(">", "&gt;")
877
+ .replace('"', "&quot;")
878
+ .replace("'", "&#x27;")
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()