"""论文标题质量检测与全文标题提取工具。 本模块不依赖 Django,管理命令、抓取任务和单元测试都可以复用。全文来源的 可信度按 XML(标题节点且 DOI 匹配) > PDF 元数据(首页身份匹配) > PDF 首页版式 推断排序;调用方默认只应自动采用 high 置信度候选。 """ from __future__ import annotations from dataclasses import dataclass from html import unescape import logging import os import re from typing import Optional _TAG_RE = re.compile(r"<[^>]+>") _SPACE_RE = re.compile(r"\s+") _WORD_RE = re.compile(r"[\w'-]+", re.UNICODE) _DOI_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I) _WEB_MARKERS = ( "journal help", "user username", "remember me login", "current issue", "paper submission", "guidelines for authors", "view subscribe", ) _DOCUMENT_MARKERS = ("author:", "authors:", "abstract:", "document type:") _GENERIC_PDF_TITLES = { "untitled", "microsoft word", "document", "article", "full text", "pdf", "no job name", } _FILE_TITLE_RE = re.compile(r"(?:\.eps|\.indd|\.docx?|\.pdf|\.tex)$", re.I) @dataclass(frozen=True) class TitleAssessment: title: str plain_length: int word_count: int sentence_count: int suspect: bool reasons: tuple[str, ...] @dataclass(frozen=True) class FulltextTitleCandidate: title: str source: str confidence: str evidence: str def clean_title(value: Optional[str]) -> str: """去标签、解实体并合并空白,保留公式中的可见文本。""" if not value: return "" text = unescape(_TAG_RE.sub(" ", str(value))) text = _SPACE_RE.sub(" ", text).strip(" \t\r\n\ufeff") if text.lower().startswith("title:"): text = text[6:].strip() return text def normalize_doi(value: Optional[str]) -> str: if not value: return "" value = unescape(str(value)).strip().lower() value = re.sub(r"^(?:doi:\s*|https?://(?:dx\.)?doi\.org/)", "", value) match = _DOI_RE.search(value) return match.group(0).rstrip(".,;)").lower() if match else value.rstrip(".,;)") def assess_title(value: Optional[str]) -> TitleAssessment: plain = clean_title(value) lower = plain.lower() words = len(_WORD_RE.findall(plain)) sentences = len(re.findall(r"[.!?](?:\s|$)", plain)) reasons = [] # 去标签后的长度才用于判断,避免 MathML 本身把正常标题撑到几千字符。 if len(plain) >= 300 and words >= 50 and sentences >= 3: reasons.append("abstract_like_prose") web_hits = sum(marker in lower for marker in _WEB_MARKERS) if len(plain) >= 200 and web_hits >= 2: reasons.append("webpage_text") doc_hits = sum(marker in lower for marker in _DOCUMENT_MARKERS) if len(plain) >= 300 and doc_hits >= 2: reasons.append("document_metadata") if len(plain) >= 800 and words >= 80: reasons.append("extreme_length") return TitleAssessment( title=plain, plain_length=len(plain), word_count=words, sentence_count=sentences, suspect=bool(reasons), reasons=tuple(reasons), ) def is_usable_title(value: Optional[str]) -> bool: assessment = assess_title(value) if not assessment.title or assessment.suspect: return False if assessment.plain_length < 5 or assessment.plain_length > 500: return False if assessment.word_count > 70 or assessment.sentence_count > 2: return False return True def _is_usable_pdf_metadata_title(value: Optional[str]) -> bool: """PDF Title 属性经常是排版文件名,使用比普通标题更严格的门槛。""" title = clean_title(value) assessment = assess_title(title) if not is_usable_title(title): return False if assessment.plain_length < 15 or assessment.word_count < 3: return False if title.casefold() in _GENERIC_PDF_TITLES or _FILE_TITLE_RE.search(title): return False if re.fullmatch(r"(?:issn\s*)?[\dXx-]{7,}", title): return False return True def is_better_title(current: Optional[str], candidate: Optional[str]) -> bool: old = assess_title(current) new = assess_title(candidate) if not old.suspect or not is_usable_title(new.title): return False normalized_old = re.sub(r"\W+", "", old.title).casefold() normalized_new = re.sub(r"\W+", "", new.title).casefold() if not normalized_new or normalized_new == normalized_old: return False return new.plain_length < old.plain_length * 0.75 def _element_text(element) -> str: return clean_title(" ".join(element.itertext())) def extract_title_from_xml( path: str, expected_doi: Optional[str] = None, ) -> Optional[FulltextTitleCandidate]: """从 Elsevier/JATS/常见全文 XML 中提取文章标题。 XML 内存在 DOI 时必须与目标论文一致;不一致直接拒绝,防止历史文件错配。 """ if not os.path.exists(path): return None try: from lxml import etree parser = etree.XMLParser(recover=True, huge_tree=True, resolve_entities=False) root = etree.parse(path, parser).getroot() except Exception: return None expected = normalize_doi(expected_doi) doi_values = [] for node in root.xpath( "//*[local-name()='doi' or local-name()='identifier' " "or (local-name()='article-id' and " "translate(@pub-id-type, 'DOI', 'doi')='doi')]" ): value = normalize_doi(_element_text(node)) if value.startswith("10."): doi_values.append(value) if expected and doi_values and expected not in doi_values: return None title_xpaths = ( "//*[local-name()='coredata']/*[local-name()='title']", "//*[local-name()='article-meta']/*[local-name()='title-group']/*[local-name()='article-title']", "//*[local-name()='article-title']", "/*/*[local-name()='coredata']/*[local-name()='title']", ) for xpath in title_xpaths: for node in root.xpath(xpath): title = _element_text(node) if not is_usable_title(title): continue doi_matched = bool(expected and expected in doi_values) return FulltextTitleCandidate( title=title, source="fulltext_xml", confidence="high" if doi_matched else "medium", evidence="xml_title_node+doi" if doi_matched else "xml_title_node", ) return None def _identity_evidence(text: str, expected_doi: str, first_author: str) -> str: if expected_doi: found = {normalize_doi(v) for v in _DOI_RE.findall(text)} if normalize_doi(expected_doi) in found: return "doi" if first_author: surname = clean_title(first_author).split()[-1].casefold() if len(surname) >= 3 and surname in text.casefold(): return "author" return "" def _title_from_first_page(text: str, first_author: str) -> str: """保守地从首页文本猜标题,仅返回低/中置信候选。""" lines = [clean_title(line) for line in text.splitlines()] lines = [line for line in lines if line] if not lines: return "" surname = clean_title(first_author).split()[-1].casefold() if first_author else "" author_index = next( (i for i, line in enumerate(lines[:30]) if len(surname) >= 3 and surname in line.casefold()), None, ) if author_index is None or author_index == 0: return "" candidates = [] for line in lines[max(0, author_index - 4):author_index]: lower = line.casefold() if _DOI_RE.search(line) or "journal" in lower or "issn" in lower: continue if re.fullmatch(r"[\d\s|:/.-]+", line): continue candidates.append(line) title = clean_title(" ".join(candidates)) return title if is_usable_title(title) else "" def extract_title_from_pdf( path: str, expected_doi: Optional[str] = None, first_author: Optional[str] = None, ) -> Optional[FulltextTitleCandidate]: """从 PDF metadata 或首页文本提取标题候选。 PDF metadata 只有在首页 DOI/作者能确认文件身份时才达到 medium;首页版式推断 即使 DOI 匹配也保持 medium,默认不会被管理命令自动写库。 """ if not os.path.exists(path): return None try: logging.getLogger("pypdf").setLevel(logging.CRITICAL) from pypdf import PdfReader reader = PdfReader(path, strict=False) page_text = reader.pages[0].extract_text() or "" if reader.pages else "" metadata = reader.metadata except Exception: return None identity = _identity_evidence(page_text, expected_doi or "", first_author or "") meta_title = clean_title(getattr(metadata, "title", "") if metadata else "") if _is_usable_pdf_metadata_title(meta_title): return FulltextTitleCandidate( title=meta_title, source="pdf_metadata", confidence="medium" if identity else "low", evidence=f"pdf_metadata+{identity}" if identity else "pdf_metadata_only", ) page_title = _title_from_first_page(page_text, first_author or "") if page_title: return FulltextTitleCandidate( title=page_title, source="pdf_first_page", # 首页文本顺序受版式影响很大,只输出人工复核候选,绝不批量自动采用。 confidence="low", evidence=f"pdf_first_page+{identity}" if identity else "pdf_first_page_only", ) return None