@@ -104,6 +104,47 @@ def normalize_title(text: str) -> str:
104104 return re .sub (r"[^a-z0-9\s]" , "" , normalize_whitespace (text ).lower ()).strip ()
105105
106106
107+ LOCAL_PDF_PREFIX_PATTERN = re .compile (r"^(?:[^-]{1,120})\s+-\s+(?:19|20)\d{2}\s+-\s+" )
108+ LOCAL_PDF_SUFFIX_ID_PATTERN = re .compile (r"\s*-\s*\d{4,}\s*$" )
109+ PREPRINT_HINTS = ("medrxiv" , "biorxiv" , "preprint" , "arxiv" , "10.1101/" , "10.21203/rs." , "preprints.org" )
110+ PDF_LIGATURE_MAP = {
111+ "\u00df " : "ss" ,
112+ "\ufb00 " : "ff" ,
113+ "\ufb01 " : "fi" ,
114+ "\ufb02 " : "fl" ,
115+ "\ufb03 " : "ffi" ,
116+ "\ufb04 " : "ffl" ,
117+ }
118+
119+
120+ def clean_local_pdf_stem (stem : str ) -> str :
121+ raw = normalize_whitespace ((stem or "" ).replace ("_" , " " ))
122+ if not raw :
123+ return ""
124+ cleaned = LOCAL_PDF_PREFIX_PATTERN .sub ("" , raw )
125+ cleaned = LOCAL_PDF_SUFFIX_ID_PATTERN .sub ("" , cleaned )
126+ cleaned = normalize_whitespace (cleaned )
127+ return cleaned or raw
128+
129+
130+ def is_probable_local_pdf_artifact_title (title : str ) -> bool :
131+ normalized = normalize_whitespace (title )
132+ if not normalized :
133+ return False
134+ if LOCAL_PDF_PREFIX_PATTERN .match (normalized ):
135+ return True
136+ if LOCAL_PDF_SUFFIX_ID_PATTERN .search (normalized ):
137+ return True
138+ return bool (re .search (r"\b(?:et al\.?|等)\b" , normalized , flags = re .IGNORECASE ) and re .search (r"\b(?:19|20)\d{2}\b" , normalized ))
139+
140+
141+ def normalize_pdf_text_artifacts (text : str ) -> str :
142+ normalized = text or ""
143+ for original , replacement in PDF_LIGATURE_MAP .items ():
144+ normalized = normalized .replace (original , replacement )
145+ return normalized
146+
147+
107148def slugify_filename (text : str ) -> str :
108149 text = normalize_whitespace (text )
109150 text = re .sub (r"[^\w\s-]" , "" , text , flags = re .UNICODE )
@@ -167,8 +208,9 @@ def publication_quality_score(record: dict[str, Any]) -> int:
167208 venue = normalize_whitespace (str (record .get ("venue" , "" ))).lower ()
168209 source_url = normalize_whitespace (str (record .get ("source_url" , "" ))).lower ()
169210 source = normalize_whitespace (str (record .get ("source" , "" ))).lower ()
170- joined = " " .join ([venue , source_url , source ])
171- if any (token in joined for token in ["medrxiv" , "biorxiv" , "preprint" , "arxiv" ]):
211+ doi = normalize_whitespace (str (record .get ("doi" , "" ))).lower ()
212+ joined = " " .join ([venue , source_url , source , doi ])
213+ if any (token in joined for token in PREPRINT_HINTS ):
172214 return 0
173215 if venue or source == "crossref" :
174216 return 2
@@ -181,7 +223,7 @@ def candidate_priority_score(record: dict[str, Any]) -> int:
181223 doi = normalize_whitespace (str (record .get ("doi" , "" ))).lower ()
182224 joined = " " .join ([source , source_url , doi ])
183225
184- if "preprints.org " in joined or "10.20944/preprints" in joined :
226+ if "10.20944/preprints " in joined or any ( token in joined for token in PREPRINT_HINTS ) :
185227 return 0
186228
187229 if record .get ("doi" ) and publication_quality_score (record ) >= 2 :
@@ -356,8 +398,23 @@ def fetch_arxiv_entries(*, search_query: str = "", id_list: str = "", max_result
356398 "max_results" : max_results ,
357399 }
358400 )
359- xml_content = http_get_text (f"https://export.arxiv.org/api/query?{ params } " )
360- return parse_arxiv_xml (xml_content )
401+ try :
402+ xml_content = http_get_text (f"https://export.arxiv.org/api/query?{ params } " )
403+ except Exception :
404+ return []
405+ if not normalize_whitespace (xml_content ):
406+ return []
407+ try :
408+ return parse_arxiv_xml (xml_content )
409+ except Exception :
410+ return []
411+
412+
413+ def safe_fetch_arxiv_entries (* , search_query : str = "" , id_list : str = "" , max_results : int = 10 ) -> list [dict [str , Any ]]:
414+ try :
415+ return fetch_arxiv_entries (search_query = search_query , id_list = id_list , max_results = max_results )
416+ except Exception :
417+ return []
361418
362419
363420def normalize_crossref_work (item : dict [str , Any ]) -> dict [str , Any ]:
@@ -620,24 +677,32 @@ def resolve_reference(value: str) -> dict[str, Any]:
620677 stripped = (value or "" ).strip ()
621678 if source_type == "local_pdf" :
622679 path = Path (stripped ).expanduser ().resolve ()
623- return {
680+ hints = extract_local_pdf_hints (path )
681+ paper = {
624682 "status" : "ok" ,
625683 "source_type" : "local_pdf" ,
626684 "source_url" : str (path ),
627685 "local_pdf_path" : str (path ),
628- "title" : path .stem .replace ("_" , " " ),
686+ "title" : normalize_whitespace ( str ( hints . get ( "title" , "" ))) or clean_local_pdf_stem ( path . stem ) or path .stem .replace ("_" , " " ),
629687 "metadata_sources" : ["local_pdf" ],
630- "paper_id" : paper_id_for_record ({"title" : path .stem }),
631688 }
689+ doi = normalize_whitespace (str (hints .get ("doi" , "" )))
690+ arxiv_id = normalize_whitespace (str (hints .get ("arxiv_id" , "" )))
691+ if doi :
692+ paper ["doi" ] = doi
693+ if arxiv_id :
694+ paper ["arxiv_id" ] = arxiv_id
695+ paper ["paper_id" ] = paper_id_for_record (paper )
696+ return paper
632697 if source_type == "arxiv_id" :
633- papers = fetch_arxiv_entries (id_list = extract_arxiv_id (stripped ) or "" , max_results = 1 )
698+ papers = safe_fetch_arxiv_entries (id_list = extract_arxiv_id (stripped ) or "" , max_results = 1 )
634699 if papers :
635700 paper = papers [0 ]
636701 paper ["paper_id" ] = paper_id_for_record (paper )
637702 paper ["status" ] = "ok"
638703 return paper
639704 if source_type == "arxiv_url" :
640- papers = fetch_arxiv_entries (id_list = extract_arxiv_id (stripped ) or "" , max_results = 1 )
705+ papers = safe_fetch_arxiv_entries (id_list = extract_arxiv_id (stripped ) or "" , max_results = 1 )
641706 if papers :
642707 paper = papers [0 ]
643708 paper ["paper_id" ] = paper_id_for_record (paper )
@@ -691,7 +756,7 @@ def resolve_reference(value: str) -> dict[str, Any]:
691756 search_semantic_scholar (title , limit = 5 )
692757 + search_crossref_by_title (title , limit = 5 )
693758 + search_openalex_by_title (title , limit = 5 )
694- + fetch_arxiv_entries (search_query = f'ti:"{ title } "' , max_results = 5 )
759+ + safe_fetch_arxiv_entries (search_query = f'ti:"{ title } "' , max_results = 5 )
695760 )
696761 best = choose_best_title_match (title , candidates )
697762 if best :
@@ -728,7 +793,7 @@ def enrich_metadata(record: dict[str, Any]) -> dict[str, Any]:
728793 candidates .append (sem )
729794
730795 if arxiv_id :
731- arxiv = fetch_arxiv_entries (id_list = arxiv_id , max_results = 1 )
796+ arxiv = safe_fetch_arxiv_entries (id_list = arxiv_id , max_results = 1 )
732797 if arxiv :
733798 candidates .append (arxiv [0 ])
734799
@@ -742,7 +807,7 @@ def enrich_metadata(record: dict[str, Any]) -> dict[str, Any]:
742807 cross = choose_best_title_match (title , search_crossref_by_title (title , limit = 5 ))
743808 if cross :
744809 candidates .append (cross )
745- arxiv = choose_best_title_match (title , fetch_arxiv_entries (search_query = f'ti:"{ title } "' , max_results = 5 ))
810+ arxiv = choose_best_title_match (title , safe_fetch_arxiv_entries (search_query = f'ti:"{ title } "' , max_results = 5 ))
746811 if arxiv :
747812 candidates .append (arxiv )
748813
@@ -753,6 +818,12 @@ def enrich_metadata(record: dict[str, Any]) -> dict[str, Any]:
753818 merged ["source_url" ] = f"https://doi.org/{ merged ['doi' ]} "
754819 if merged .get ("arxiv_id" ) and not merged .get ("pdf_url" ):
755820 merged ["pdf_url" ] = f"https://arxiv.org/pdf/{ merged ['arxiv_id' ]} .pdf"
821+ if merged .get ("arxiv_id" ) and not merged .get ("doi" ):
822+ merged ["doi" ] = f"10.48550/arXiv.{ merged ['arxiv_id' ]} "
823+ if base .get ("source_type" ) == "local_pdf" :
824+ corrected_title = choose_local_pdf_corrected_title (base , candidates [1 :])
825+ if corrected_title :
826+ merged ["title" ] = corrected_title
756827 merged ["paper_id" ] = paper_id_for_record (merged )
757828 return merged
758829
@@ -943,7 +1014,7 @@ def split_sentences(text: str) -> list[str]:
9431014
9441015
9451016def clean_pdf_line (line : str ) -> str :
946- line = re .sub (r"\s+" , " " , line or "" ).strip ()
1017+ line = re .sub (r"\s+" , " " , normalize_pdf_text_artifacts ( line or "" ) ).strip ()
9471018 if not line :
9481019 return ""
9491020 if re .fullmatch (r"\d+" , line ):
@@ -1036,6 +1107,92 @@ def extract_pdf_text(pdf_path: Path, max_pages: int | None = None) -> str:
10361107 return "\n " .join (texts )
10371108
10381109
1110+ def is_plausible_pdf_title_line (line : str ) -> bool :
1111+ normalized = clean_pdf_line (line )
1112+ lower = normalized .lower ()
1113+ if len (normalized ) < 20 or len (normalized .split ()) < 4 :
1114+ return False
1115+ if normalized .count ("," ) >= 3 :
1116+ return False
1117+ if any (token in lower for token in ["doi.org/" , "http://" , "https://" , "www." , "check for updates" ]):
1118+ return False
1119+ if lower in {"abstract" , "article" , "preprint" }:
1120+ return False
1121+ if lower .startswith ("npj |" ) or lower .startswith ("arxiv:" ) or lower .startswith ("submitted to" ):
1122+ return False
1123+ if " doi:" in lower or lower .startswith ("doi:" ):
1124+ return False
1125+ return True
1126+
1127+
1128+ def first_page_title_candidate (first_page_text : str ) -> str :
1129+ for raw_line in (first_page_text or "" ).splitlines ():
1130+ if is_plausible_pdf_title_line (raw_line ):
1131+ return clean_pdf_line (raw_line )
1132+ return ""
1133+
1134+
1135+ def extract_local_pdf_hints (pdf_path : Path ) -> dict [str , Any ]:
1136+ raw_title = normalize_whitespace (pdf_path .stem .replace ("_" , " " ))
1137+ cleaned_title = clean_local_pdf_stem (pdf_path .stem )
1138+ hints : dict [str , Any ] = {"title" : cleaned_title or raw_title }
1139+ if fitz is None :
1140+ return hints
1141+
1142+ metadata_title = ""
1143+ metadata_subject = ""
1144+ first_page_text = ""
1145+ try :
1146+ doc = fitz .open (pdf_path )
1147+ except Exception :
1148+ return hints
1149+ try :
1150+ metadata = doc .metadata or {}
1151+ metadata_title = normalize_whitespace (str (metadata .get ("title" , "" )))
1152+ metadata_subject = normalize_whitespace (str (metadata .get ("subject" , "" )))
1153+ if len (doc ):
1154+ first_page_text = doc [0 ].get_text ("text" )
1155+ except Exception :
1156+ return hints
1157+ finally :
1158+ doc .close ()
1159+
1160+ if metadata_title :
1161+ hints ["title" ] = metadata_title
1162+ else :
1163+ page_title = first_page_title_candidate (first_page_text )
1164+ if page_title :
1165+ hints ["title" ] = page_title
1166+
1167+ searchable = "\n " .join (part for part in [metadata_subject , metadata_title , first_page_text ] if part )
1168+ doi = extract_doi (searchable )
1169+ if doi :
1170+ hints ["doi" ] = doi
1171+ arxiv_id = extract_arxiv_id (searchable )
1172+ if arxiv_id :
1173+ hints ["arxiv_id" ] = arxiv_id
1174+
1175+ return hints
1176+
1177+
1178+ def choose_local_pdf_corrected_title (base : dict [str , Any ], candidates : list [dict [str , Any ]]) -> str :
1179+ current_title = normalize_whitespace (str (base .get ("title" , "" )))
1180+ if not current_title or not is_probable_local_pdf_artifact_title (current_title ):
1181+ return ""
1182+ titled_candidates = [candidate for candidate in candidates if normalize_whitespace (str (candidate .get ("title" , "" )))]
1183+ best = choose_best_title_match (current_title , titled_candidates )
1184+ if not best :
1185+ return ""
1186+ candidate_title = normalize_whitespace (str (best .get ("title" , "" )))
1187+ if not candidate_title :
1188+ return ""
1189+ if title_similarity (current_title , candidate_title ) < 0.55 :
1190+ return ""
1191+ if not (best .get ("doi" ) or best .get ("arxiv_id" ) or publication_quality_score (best ) >= 2 ):
1192+ return ""
1193+ return candidate_title
1194+
1195+
10391196def extract_caption_lines (pdf_text : str , kind : str ) -> list [dict [str , str ]]:
10401197 results : list [dict [str , str ]] = []
10411198 seen = set ()
0 commit comments