perf(resm): scihub改用curl-cffi轻量解析, 移除oa_url内联Playwright回退

download_pdf 链两处都不再在 worker 里起浏览器(此前是并发扩容的内存瓶颈):

- Sci-Hub: 新增 d_scihub.download_by_doi_http, 用 curl-cffi 取页面解析内嵌
  PDF 地址直接下载并校验魔数, 覆盖各域名 embed#pdf/iframe#pdf/location.href
  结构; 缓存存活域名做秒级故障切换; 撞 Cloudflare/DDoS 挑战返回
  scihub_need_play 交独立脚本兜底。浏览器版 download_paper_by_doi 保留。
- oa_url: 移除策略3内联 Playwright(还会开有头窗口), curl-cffi 失败即打
  oa_url_need_play, 交 scripts/get_pdf_by_playwright.py 兜底。
  run_async 保留(该脚本从 tasks 导入)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-21 15:33:15 +08:00
parent adf34b4bde
commit 3ab18f3ed9
2 changed files with 152 additions and 14 deletions

View File

@ -1,8 +1,11 @@
import argparse
import asyncio
import logging
import os
import re
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin
from playwright.async_api import async_playwright, Page, Browser
# 尝试导入 playwright-stealth
@ -289,6 +292,146 @@ _SCIHUB_DOMAINS = [
"sci-hub.se",
]
# bot 挑战页标题/正文关键词: 命中则需浏览器兜底
_CHALLENGE_MARKERS = ("just a moment", "cloudflare", "checking your browser",
"ddos-guard", "ddos guard", "attention required")
# Sci-Hub 缓存当前存活域名, 避免每篇都从头探测已挂的域名
_ALIVE_DOMAIN_KEY = "scihub_alive_domain"
_ALIVE_DOMAIN_TTL = 600 # 秒
def _normalize_pdf_url(raw: str, base_url: str) -> str:
"""把 Sci-Hub 页面里抓到的 PDF 地址归一成可直接请求的绝对 URL。
处理: JSON 转义 \\/ #fragment、协议相对 // 、站内相对路径。"""
raw = raw.strip().replace("\\/", "/").split("#")[0]
if raw.startswith("//"):
return "https:" + raw
if raw.startswith("http"):
return raw
return urljoin(base_url, raw)
def _extract_scihub_pdf_url(html: str, base_url: str) -> Optional[str]:
"""从 Sci-Hub 页面 HTML 提取内嵌 PDF 地址, 覆盖各域名/版本的常见结构;
找不到返回 Nonelxml 优先(结构化), 正则兜底(应对畸形 HTML/JS 赋值)"""
try:
from lxml import etree
root = etree.HTML(html)
if root is not None:
for xp in ('//embed[@id="pdf"]/@src',
'//iframe[@id="pdf"]/@src',
'//div[@id="article"]//embed/@src',
'//div[@id="article"]//iframe/@src',
'//embed[@type="application/pdf"]/@src'):
hit = root.xpath(xp)
if hit and hit[0].strip():
return _normalize_pdf_url(hit[0], base_url)
except Exception:
pass
# 正则兜底: 按钮 location.href='...pdf...' / 任意 src|href 指向 .pdf
for pat in (r"location\.href\s*=\s*['\"]([^'\"]+?\.pdf[^'\"]*)['\"]",
r"(?:src|href)\s*=\s*['\"]([^'\"]*?/downloads/[^'\"]+?\.pdf[^'\"]*)['\"]",
r"(?:src|href)\s*=\s*['\"]([^'\"]*?\.pdf(?:\?[^'\"]*)?)['\"]"):
m = re.search(pat, html, re.I)
if m and m.group(1).strip():
return _normalize_pdf_url(m.group(1), base_url)
return None
def _save_pdf_bytes(content: bytes, output: str) -> bool:
"""校验 PDF 魔数 + 最小体积后落盘; 不合格返回 False。"""
if not content or content[:4] != b"%PDF" or len(content) < 10240:
return False
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
with open(output, "wb") as f:
f.write(content)
return True
def download_by_doi_http(doi: str, output: str, timeout: int = 30) -> tuple[bool, str]:
"""curl-cffi 轻量下载(worker 内首选, 不起浏览器): 逐域名取 Sci-Hub 页面,
解析内嵌 PDF 地址后直接下载并校验魔数绝大多数无挑战的情况几秒内完成
返回 (ok, err)err 前缀:
- scihub_error_empty_doi: DOI 为空
- scihub_curl_cffi_not_installed
- scihub_need_play: 命中 bot 挑战, 需浏览器兜底(交独立脚本)
- scihub_error_pdf_not_found: 页面正常但无 PDF / 所有域名 HTTP 均失败
"""
doi = (doi or "").strip()
if not doi:
return False, "scihub_error_empty_doi"
try:
import curl_cffi.requests as cf
except ImportError:
return False, "scihub_curl_cffi_not_installed"
try:
from django.core.cache import cache
except Exception:
cache = None
# 存活域名优先, 其余按默认顺序补上(去重)
domains = list(_SCIHUB_DOMAINS)
if cache is not None:
alive = cache.get(_ALIVE_DOMAIN_KEY)
if alive in domains:
domains = [alive] + [d for d in domains if d != alive]
challenged = False
for domain in domains:
page_url = f"https://{domain}/{doi}"
try:
resp = cf.get(page_url, impersonate="chrome131",
headers={"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"},
timeout=timeout, allow_redirects=True)
except Exception as e:
logger.info(f"{domain} 请求失败: {e}")
continue
ct = resp.headers.get("content-type", "")
# 有些域名直接把 PDF 返回在 DOI 页
if resp.content[:4] == b"%PDF" or "application/pdf" in ct:
if _save_pdf_bytes(resp.content, output):
if cache is not None:
cache.set(_ALIVE_DOMAIN_KEY, domain, timeout=_ALIVE_DOMAIN_TTL)
logger.info(f"✓ HTTP 直取 PDF 成功({domain}): {output}")
return True, ""
if resp.status_code != 200:
if resp.status_code in (403, 503): # 多为挑战/封禁
challenged = True
continue
try:
text = resp.text
except Exception:
continue
if any(k in text[:3000].lower() for k in _CHALLENGE_MARKERS):
challenged = True
continue
pdf_url = _extract_scihub_pdf_url(text, page_url)
if not pdf_url:
continue
try:
pr = cf.get(pdf_url, impersonate="chrome131",
headers={"Referer": page_url, "Accept": "application/pdf,*/*"},
timeout=timeout, allow_redirects=True)
except Exception as e:
logger.info(f"{domain} PDF 下载失败: {e}")
continue
if pr.status_code == 200 and _save_pdf_bytes(pr.content, output):
if cache is not None:
cache.set(_ALIVE_DOMAIN_KEY, domain, timeout=_ALIVE_DOMAIN_TTL)
logger.info(f"✓ HTTP 解析下载成功({domain}): {output}")
return True, ""
if challenged:
return False, "scihub_need_play: bot 挑战, 需浏览器兜底"
return False, "scihub_error_pdf_not_found: 所有域名 HTTP 均未获取 PDF"
def download_paper_by_doi(doi: str, output: Optional[str] = None, headless: bool = True) -> tuple[bool, str]:
"""

View File

@ -12,7 +12,6 @@ from celery import current_app
from datetime import datetime, timedelta
import random
from .pdf_utils import _is_elsevier_preview_pdf
from .d_oaurl import download_from_url_playwright
from uuid import uuid4
import asyncio
import sys
@ -833,7 +832,7 @@ def download_pdf(paper_id):
def save_pdf_from_oa_url(paper: Paper):
from .d_oaurl import download_pdf_with_curl_cffi, download_from_url_playwright
from .d_oaurl import download_pdf_with_curl_cffi
# 策略1: 直接请求
try:
@ -866,16 +865,10 @@ def save_pdf_from_oa_url(paper: Paper):
paper.save(update_fields=["has_fulltext", "has_fulltext_pdf", "update_time"])
return "success"
# 策略3: Playwright最终回退
is_ok, err_msg = run_async(download_from_url_playwright(paper.oa_url, paper_path))
if is_ok:
paper.has_fulltext = True
paper.has_fulltext_pdf = True
paper.save(update_fields=["has_fulltext", "has_fulltext_pdf", "update_time"])
return "success"
paper.save_fail_reason(f"oa_url_all_methods_failed: {err_msg}")
return f"oa_url_all_methods_failed: {err_msg}"
# 内联 Playwright 回退已移除(在 worker 里起浏览器太重, 且会开有头窗口)。
# 打 oa_url_need_play 标记, 交由独立脚本 scripts/get_pdf_by_playwright.py 兜底。
paper.save_fail_reason(f"oa_url_need_play: {err_msg}")
return f"oa_url_need_play: {err_msg}"
def save_pdf_from_openalex(paper:Paper):
if cache.get("openalex_api_exceed"):
@ -937,8 +930,10 @@ def save_pdf_from_elsevier(paper:Paper):
return f"elsevier_status_error: {res.status_code} {res.text}"
def save_pdf_from_scihub(paper:Paper):
from .d_scihub import download_paper_by_doi
is_ok, err_msg = download_paper_by_doi(paper.doi, paper.init_paper_path("pdf"))
# worker 内只走 curl-cffi 轻量解析, 不起浏览器; 撞挑战页返回 scihub_need_play,
# 交由独立脚本用浏览器兜底(同 oa_url need_play 的分工)。
from .d_scihub import download_by_doi_http
is_ok, err_msg = download_by_doi_http(paper.doi, paper.init_paper_path("pdf"))
if is_ok:
paper.has_fulltext = True
paper.has_fulltext_pdf = True