paper_server/apps/resm/title_utils.py

275 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""论文标题质量检测与全文标题提取工具。
本模块不依赖 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