"""PDF 解析/分类工具(纯 stdlib + pypdf, 不依赖 Django)。 独立成模块, 以便 ProcessPoolExecutor 的子进程能安全导入(fork/spawn 均可), 不会牵连 Django 模型与配置。tasks.py 从这里复用这些函数。 """ import os import re def _pdf_page_count(content: bytes): """返回 PDF 页数; 无法确定时返回 None。 优先用 pypdf 精确解析; 未安装或解析异常时退化为字节扫描 (对未压缩对象树有效, Elsevier 的摘要预览页正属此类)。""" try: from io import BytesIO import logging # 坏 PDF 会让 pypdf 刷大量恢复日志, 这里只关心页数, 静音其 logger logging.getLogger("pypdf").setLevel(logging.CRITICAL) from pypdf import PdfReader return len(PdfReader(BytesIO(content), strict=False).pages) except ImportError: pass except Exception: return None try: counts = [int(m) for m in re.findall(rb"/Count\s+(\d+)", content)] if counts: return max(counts) n = len(re.findall(rb"/Type\s*/Page(?![sR])", content)) if n: return n except Exception: pass return None def _is_elsevier_preview_pdf(content: bytes) -> bool: """判断 Elsevier 返回的 PDF 是否为"摘要预览页"。 Elsevier Article API 对未授权 / in-press 文章, application/pdf 端点会返回 仅含摘要的 1 页预览 PDF(魔数仍是 %PDF、体积也不小), 全文 XML 却可能正常。 判据: 能确定页数且 <= 1 页。无法确定页数时返回 False(从宽, 不误杀真全文)。""" pages = _pdf_page_count(content) return pages is not None and pages <= 1 def _inspect_pdf(content: bytes): """对历史落库的 PDF 文件分类, 返回 (kind, pages)。 kind: 'broken' - 非 PDF(魔数不符)或 pypdf 解析直接失败 -> 可安全删除重抓 'preview' - 1 页摘要预览页 'ok' - 多页, 视为真全文, 不处理 'unknown' - 魔数正常但页数判不出(通常因未装 pypdf) -> 不处理, 绝不当坏文件 pages: 页数; 无法确定为 None。""" if not content or b"%PDF" not in content[:1024]: return "broken", 0 try: from io import BytesIO import logging logging.getLogger("pypdf").setLevel(logging.CRITICAL) from pypdf import PdfReader except ImportError: # 没装 pypdf: 只能靠字节扫描, 判不出就 unknown(从宽, 不误判为坏) pages = _pdf_page_count(content) if pages is None: return "unknown", None return ("preview" if pages <= 1 else "ok"), pages try: pages = len(PdfReader(BytesIO(content), strict=False).pages) except Exception: return "broken", None if pages <= 0: return "broken", pages return ("preview" if pages == 1 else "ok"), pages def classify_pdf_file(path: str): """并发 worker 入口: 读取并分类单个 PDF 文件路径。 返回 (path, kind, pages)。除 _inspect_pdf 的四种 kind 外, 另有 IO 结果: 'missing' - 文件不存在 'unreadable' - 打开失败(权限等) 设计为纯函数(仅 stdlib + pypdf), 可被进程池安全 pickle / 导入。""" try: if not os.path.exists(path): return path, "missing", None with open(path, "rb") as f: content = f.read() except OSError: return path, "unreadable", None kind, pages = _inspect_pdf(content) return path, kind, pages