pdf_download.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """PDF 下载服务 —— 支持 arXiv/DBLP/出版商多源 PDF 获取与本地缓存."""
  2. from __future__ import annotations
  3. import contextlib
  4. import logging
  5. import os
  6. import re
  7. from urllib.parse import urlparse
  8. import requests
  9. from .conference_landing_pdf import fetch_pdf_url_from_html_page
  10. from .paper import Paper
  11. _log = logging.getLogger(__name__)
  12. def _is_pkp_ojs_article_download_url(url: str) -> bool:
  13. return bool(re.search(r"/article/download/\d+/\d+", (url or ""), re.I))
  14. def _is_direct_pdf_url(url: str) -> bool:
  15. u = (url or "").strip()
  16. if not u:
  17. return False
  18. ul = u.lower().split("?", 1)[0]
  19. return ul.endswith(".pdf") or _is_pkp_ojs_article_download_url(u)
  20. def _derive_proceedings_pdf_url(source_url: str) -> str | None:
  21. """Derive PDF URL from known proceedings abstract page URL patterns."""
  22. u = (source_url or "").strip()
  23. if not u:
  24. return None
  25. # NeurIPS: hash/XXX-Abstract-Conference.html → file/XXX-Paper-Conference.pdf
  26. m = re.match(r"(.*)/hash/([a-f0-9]+)-Abstract(-\w+)?\.html$", u, re.I)
  27. if m:
  28. suffix = (m.group(3) or "-Conference")
  29. return f"{m.group(1)}/file/{m.group(2)}-Paper{suffix}.pdf"
  30. # CVF (CVPR/ICCV/ECCV): .../html/PaperName-paper.html → .../papers/PaperName-paper.pdf
  31. m = re.match(r"(.*)/html/(.+?)\.html$", u, re.I)
  32. if m and ("/content/" in u.lower()):
  33. return f"{m.group(1)}/papers/{m.group(2)}.pdf"
  34. # OpenReview: forum?id=X → pdf?id=X
  35. if "openreview.net/forum" in u:
  36. return re.sub(r"/forum\?id=", "/pdf?id=", u)
  37. # Generic: .html → .pdf
  38. if u.lower().endswith(".html"):
  39. return re.sub(r"\.html$", ".pdf", u, flags=re.I)
  40. return None
  41. def _pdf_download_candidates(paper: Paper, email: str) -> list[str]:
  42. out: list[str] = []
  43. seen: set[str] = set()
  44. def _push(u: str | None) -> None:
  45. s = (u or "").strip()
  46. if s and s.lower().startswith(("http://", "https://")):
  47. low = s.lower()
  48. if low not in seen:
  49. seen.add(low)
  50. out.append(s)
  51. pu = (getattr(paper, "pdf_url", None) or "").strip()
  52. if pu and _is_direct_pdf_url(pu):
  53. _push(pu)
  54. su = (getattr(paper, "source_url", None) or "").strip()
  55. if su and _is_direct_pdf_url(su):
  56. _push(su)
  57. try:
  58. resolved = resolve_paper_pdf_url(paper, email=email)
  59. _push(resolved)
  60. except Exception as ex:
  61. _log.warning("resolve_paper_pdf_url 异常(已忽略): %s", ex, exc_info=True)
  62. # Last resort: derive PDF URL from abstract page URL pattern
  63. if not out and su:
  64. derived = _derive_proceedings_pdf_url(su)
  65. _push(derived)
  66. return out
  67. def _headers_for_pdf_get(url: str, paper: Paper, email: str) -> dict:
  68. mail = (email or "").strip()
  69. ref = (getattr(paper, "source_url", None) or "").strip()
  70. if not ref or not ref.lower().startswith("http"):
  71. try:
  72. pr = urlparse(url)
  73. if pr.scheme and pr.netloc:
  74. ref = f"{pr.scheme}://{pr.netloc}/"
  75. except ValueError:
  76. ref = ""
  77. ua = f"PaperGraph/0.3 (mailto:{mail})" if mail else "PaperGraph/0.3"
  78. h = {
  79. "User-Agent": ua,
  80. "Accept": "application/pdf,application/octet-stream,*/*;q=0.8",
  81. }
  82. if ref and ref.lower().startswith("http"):
  83. h["Referer"] = ref[:2048]
  84. return h
  85. def _file_looks_like_pdf(path: str) -> bool:
  86. try:
  87. with open(path, "rb") as f:
  88. return f.read(5) == b"%PDF-"
  89. except OSError:
  90. return False
  91. def _normalize_doi_url(doi: str) -> str | None:
  92. d = (doi or "").strip()
  93. if not d:
  94. return None
  95. d = re.sub(r"^https?://(dx\.)?doi\.org/", "", d, flags=re.I).strip().rstrip("/")
  96. return f"https://doi.org/{d}" if d else None
  97. def _should_probe_html_for_pdf(url: str) -> bool:
  98. u = (url or "").strip().lower()
  99. if not u.startswith(("http://", "https://")):
  100. return False
  101. if _is_pkp_ojs_article_download_url(u):
  102. return False
  103. return not re.search(r"openalex\.org/(?:w\d+|works/)", u)
  104. def resolve_paper_pdf_url(paper: Paper, email: str = "") -> str | None:
  105. u = (getattr(paper, "pdf_url", None) or "").strip()
  106. su = (getattr(paper, "source_url", None) or "").strip()
  107. doi_u = _normalize_doi_url(getattr(paper, "doi", None) or "")
  108. if u and _is_direct_pdf_url(u):
  109. return u
  110. from app.core.search import _arxiv_canonical_from_paper, _arxiv_pdf_url_from_id
  111. ax = _arxiv_pdf_url_from_id(_arxiv_canonical_from_paper(paper))
  112. if ax:
  113. return ax
  114. # Recover arXiv PDF URL from DOI/source URL.
  115. if not ax:
  116. for field_val in (getattr(paper, "doi", None) or "", getattr(paper, "source_url", None) or ""):
  117. m = re.search(r"arxiv/([\d.]+)", str(field_val), re.I)
  118. if m:
  119. ax = f"https://arxiv.org/pdf/{m.group(1)}"
  120. return ax
  121. probe_candidates: list[str] = []
  122. seen: set[str] = set()
  123. for cand in (u, su, doi_u):
  124. if cand and _should_probe_html_for_pdf(cand):
  125. norm = cand.rstrip("/").lower()
  126. if norm not in seen:
  127. seen.add(norm)
  128. probe_candidates.append(cand)
  129. for cand_url in probe_candidates:
  130. try:
  131. got = fetch_pdf_url_from_html_page(cand_url, email=email)
  132. if got:
  133. return got
  134. except Exception:
  135. continue
  136. # OpenReview forum pages expose PDFs at /pdf?id=...
  137. if su and "openreview.net/forum" in su:
  138. return re.sub(r"/forum\?id=", "/pdf?id=", su)
  139. # Some DOI URLs redirect directly to open-access PDFs.
  140. if doi_u:
  141. try:
  142. mail = (email or "").strip()
  143. ua = f"PaperGraph/0.3 (mailto:{mail})" if mail else "PaperGraph/0.3"
  144. headers = {"User-Agent": ua, "Accept": "application/pdf"}
  145. with requests.get(doi_u, timeout=30, headers=headers, allow_redirects=True, stream=True) as r:
  146. if r.status_code == 200 and r.headers.get("content-type", "").startswith("application/pdf"):
  147. return doi_u
  148. except Exception:
  149. pass
  150. return None
  151. def _cleanup_temp_file(tmp_path: str) -> None:
  152. with contextlib.suppress(OSError):
  153. if os.path.isfile(tmp_path):
  154. os.remove(tmp_path)
  155. def download_paper_pdf_to_path(paper: Paper, dest_abspath: str, email: str = "") -> bool:
  156. try:
  157. urls = _pdf_download_candidates(paper, email=email)
  158. if not urls:
  159. return False
  160. with contextlib.suppress(OSError):
  161. os.makedirs(os.path.dirname(dest_abspath) or ".", exist_ok=True)
  162. for url in urls:
  163. tmp = dest_abspath + ".part"
  164. try:
  165. headers = _headers_for_pdf_get(url, paper, email)
  166. with requests.get(url, timeout=90, stream=True, headers=headers, allow_redirects=True) as r:
  167. if r.status_code != 200:
  168. continue
  169. with open(tmp, "wb") as f:
  170. for chunk in r.iter_content(chunk_size=65536):
  171. if chunk:
  172. f.write(chunk)
  173. if os.path.getsize(tmp) < 256 or not _file_looks_like_pdf(tmp):
  174. _cleanup_temp_file(tmp)
  175. continue
  176. os.replace(tmp, dest_abspath)
  177. return True
  178. except (OSError, requests.RequestException):
  179. _cleanup_temp_file(tmp)
  180. continue
  181. return False
  182. except Exception as ex:
  183. _log.warning("download_paper_pdf_to_path 异常: %s", ex, exc_info=True)
  184. _cleanup_temp_file(dest_abspath + ".part")
  185. return False