pyPaperFlow 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyPaperFlow/__init__.py +1 -0
- pyPaperFlow/cli.py +662 -0
- pyPaperFlow/integrations/mineru_parser.py +1315 -0
- pyPaperFlow/integrations/pdf_fetch.py +2080 -0
- pyPaperFlow/preprint/arxiv_fetcher.py +549 -0
- pyPaperFlow/preprint/biorxiv_fetcher.py +404 -0
- pyPaperFlow/preprint/source_models.py +29 -0
- pyPaperFlow/preprint/source_utils.py +131 -0
- pyPaperFlow/pubmed/__init__.py +0 -0
- pyPaperFlow/pubmed/pubmed_fetcher.py +1910 -0
- pyPaperFlow/pubmed/pubmed_merger.py +958 -0
- pyPaperFlow/utils.py +70 -0
- pypaperflow-0.2.0.dist-info/METADATA +2025 -0
- pypaperflow-0.2.0.dist-info/RECORD +17 -0
- pypaperflow-0.2.0.dist-info/WHEEL +4 -0
- pypaperflow-0.2.0.dist-info/entry_points.txt +2 -0
- pypaperflow-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1910 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import *
|
|
5
|
+
from Bio import Entrez, Medline
|
|
6
|
+
import traceback
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from ..utils import extract_urls_from_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
#############################################################
|
|
13
|
+
# 1, Some pre-defined Data Classes for Paper Structure
|
|
14
|
+
#############################################################
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PaperIdentity:
|
|
18
|
+
"""
|
|
19
|
+
Description
|
|
20
|
+
----------
|
|
21
|
+
Identity of the paper, storing basic identification information
|
|
22
|
+
|
|
23
|
+
Args
|
|
24
|
+
----------
|
|
25
|
+
pmid (str): pubmed ID of the paper
|
|
26
|
+
doi (str): digital object identifier of the paper, default is an empty string
|
|
27
|
+
title (str): title of the paper, default is an empty string
|
|
28
|
+
"""
|
|
29
|
+
pmid: str
|
|
30
|
+
doi: str = ""
|
|
31
|
+
title: str = ""
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class PaperContent:
|
|
35
|
+
"""
|
|
36
|
+
Description
|
|
37
|
+
----------
|
|
38
|
+
Content of the paper, storing main textual information, important for NLP tasks like ontology construction.
|
|
39
|
+
|
|
40
|
+
Args
|
|
41
|
+
----------
|
|
42
|
+
abstract (str): abstract of the paper
|
|
43
|
+
keywords (List[str]): list of keywords associated with the paper
|
|
44
|
+
mesh_terms (List[str]): list of MeSH terms associated with the paper
|
|
45
|
+
pub_types (List[str]): list of publication types of the paper, e.g., Journal Article, Review, etc.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
abstract: str
|
|
49
|
+
keywords: List[str] = field(default_factory=list)
|
|
50
|
+
mesh_terms: List[str] = field(default_factory=list)
|
|
51
|
+
pub_types: List[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
# we do not use frozen here cause we need to update xml field once the instance is created for medline
|
|
54
|
+
@dataclass
|
|
55
|
+
class PaperContributors:
|
|
56
|
+
"""
|
|
57
|
+
Description
|
|
58
|
+
----------
|
|
59
|
+
Contributors of the paper, storing information about authors and affiliations.
|
|
60
|
+
Data is separated by source format (Medline vs XML) to preserve structure.
|
|
61
|
+
|
|
62
|
+
Args
|
|
63
|
+
----------
|
|
64
|
+
medline (Dict[str, List[str]]): Flat lists extracted from Medline format (or flattened from XML).
|
|
65
|
+
Keys: 'full_names', 'short_names', 'auids', 'affiliations'.
|
|
66
|
+
xml (List[Dict[str, Any]]): Structured author list with detailed fields (from XML).
|
|
67
|
+
"""
|
|
68
|
+
medline: Dict[str, List[str]] = field(default_factory=dict)
|
|
69
|
+
xml: List[Dict[str, Any]] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class PaperSource:
|
|
73
|
+
"""
|
|
74
|
+
Description
|
|
75
|
+
----------
|
|
76
|
+
Source information of the paper, storing journal and publication details.
|
|
77
|
+
|
|
78
|
+
Args
|
|
79
|
+
----------
|
|
80
|
+
journal_title (str): title of the journal
|
|
81
|
+
journal_abbrev (str): abbreviation of the journal
|
|
82
|
+
pub_date (str): publication date of the paper
|
|
83
|
+
pub_year (str): publication year of the paper
|
|
84
|
+
pub_types (List[str]): list of publication types of the paper, e.g., Journal Article, Review, etc. ⚠️ SAME as Pub_types in PaperContent
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
# journal: Dict[str, str] = field(default_factory=dict)
|
|
88
|
+
journal_title: str = ""
|
|
89
|
+
journal_abbrev: str = ""
|
|
90
|
+
pub_date: str = ""
|
|
91
|
+
pub_year: str = ""
|
|
92
|
+
pub_types: List[str] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class PaperLinks:
|
|
96
|
+
"""
|
|
97
|
+
Description
|
|
98
|
+
----------
|
|
99
|
+
Stores linked data from other NCBI databases (retrieved via ELink).
|
|
100
|
+
This is crucial for the "Deep Ontology" platform.
|
|
101
|
+
|
|
102
|
+
Args
|
|
103
|
+
----------
|
|
104
|
+
cites (List[str]): List of PMIDs that cite this paper (pubmed_pubmed_citedin).
|
|
105
|
+
refs (List[str]): List of PMIDs that this paper cites (pubmed_pubmed_refs).
|
|
106
|
+
similar (List[str]): List of related articles (pubmed_pubmed).Similar PubMed articles, obtained by matching text and MeSH terms
|
|
107
|
+
review (List[str]): List of review articles (pubmed_pubmed_reviews).
|
|
108
|
+
pmc (List[str]): List of PMC IDs linked to this paper (pubmed_pmc), we store free full-text links here.
|
|
109
|
+
entrez (Dict[str, List[str]]): Dictionary of other internal Entrez Cross-database links, keys are linkname, values are lists of linked UIDs.
|
|
110
|
+
external (List[Dict[str, str]]): List of external database links
|
|
111
|
+
text_mined (List[Dict[str, str]]): List of text-mined links, store urls mined from abstract or full text
|
|
112
|
+
|
|
113
|
+
Notes
|
|
114
|
+
----------
|
|
115
|
+
- 1, Since links may change over time, we store the fetch timestamp for reference.
|
|
116
|
+
That means we need to update link data(e.g. cites) periodically for each paper in our database, and update the fetched article
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
cites: List[str] = field(default_factory=list)
|
|
120
|
+
refs: List[str] = field(default_factory=list)
|
|
121
|
+
similar: List[str] = field(default_factory=list)
|
|
122
|
+
review: List[str] = field(default_factory=list)
|
|
123
|
+
pmc: List[str] = field(default_factory=list)
|
|
124
|
+
entrez: Dict[str, List[str]] = field(default_factory=dict)
|
|
125
|
+
external: List[Dict[str, str]] = field(default_factory=list)
|
|
126
|
+
text_mined: List[Dict[str, str]] = field(default_factory=list)
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class PaperMetadata:
|
|
130
|
+
"""
|
|
131
|
+
Description
|
|
132
|
+
----------
|
|
133
|
+
Metadata of the paper, storing additional information about the fetching process.
|
|
134
|
+
|
|
135
|
+
Args
|
|
136
|
+
----------
|
|
137
|
+
entrez_date (str): Entrez date of the paper, used for incremental updates
|
|
138
|
+
fetched_at (str): timestamp when the paper was fetched
|
|
139
|
+
|
|
140
|
+
Notes
|
|
141
|
+
----------
|
|
142
|
+
- 1, entrez_date is crucial for incremental updates, as it indicates when the paper was added to PubMed.
|
|
143
|
+
- 2, ⚠️ All information in this class is related to the fetching process, not the paper content itself.
|
|
144
|
+
"""
|
|
145
|
+
entrez_date: str = ""
|
|
146
|
+
fetched_at: str = ""
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Paper_MetaData:
|
|
150
|
+
identity: PaperIdentity
|
|
151
|
+
content: PaperContent
|
|
152
|
+
contributors: PaperContributors
|
|
153
|
+
source: PaperSource
|
|
154
|
+
metadata: PaperMetadata
|
|
155
|
+
links: PaperLinks = field(default_factory=PaperLinks)
|
|
156
|
+
|
|
157
|
+
def to_dict(self):
|
|
158
|
+
return asdict(self)
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class Paper_TextData:
|
|
162
|
+
"""
|
|
163
|
+
Description
|
|
164
|
+
----------
|
|
165
|
+
Text data of the paper, storing the full text content.
|
|
166
|
+
|
|
167
|
+
Args
|
|
168
|
+
----------
|
|
169
|
+
pub_year (str): publication year of the paper, parsed from XML <pub-date>
|
|
170
|
+
pmid (str): pubmed ID of the paper
|
|
171
|
+
pmcid (str): pmc ID of the paper
|
|
172
|
+
xml (str): raw XML content of the paper, can be exported to txt format or xml format (xml format is better here)
|
|
173
|
+
parsed_json (Dict[str, Any]): parsed JSON structure of the paper, exported to json format
|
|
174
|
+
parsed_text (str): parsed text content of the paper, can be exported to Markdown or plain text format (Md is better here, we choose Md format)
|
|
175
|
+
|
|
176
|
+
Notes
|
|
177
|
+
----------
|
|
178
|
+
- 1, There are mainly 3 ways to get full text:
|
|
179
|
+
- via PMC Open Access Subset (best quality, XML or PDF converted to text), we can fetch via EFetch API if we have PMC IDs
|
|
180
|
+
- via publisher website (if accessible, may require subscription), we can get urls via ELink such as llinks (test successfully in some cases, but it is hard to fetch automatically due to paywalls and different formats)
|
|
181
|
+
- via Other Mature Github Projects (e.g., SciHub, PaperScraper, etc.), we can integrate their APIs or methods to fetch full text given DOI or URL
|
|
182
|
+
We implement the first method here, and may consider the other two methods in future versions.
|
|
183
|
+
"""
|
|
184
|
+
pub_year: str = "" # Parsed from XML <pub-date>
|
|
185
|
+
pmid: str = ""
|
|
186
|
+
pmcid: str = ""
|
|
187
|
+
xml: str = ""
|
|
188
|
+
parsed_json: Dict[str, Any] = field(default_factory=dict)
|
|
189
|
+
parsed_text: str = ""
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class Paper:
|
|
193
|
+
Meta: Paper_MetaData
|
|
194
|
+
Text: Paper_TextData
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
#############################################################
|
|
198
|
+
# 2, Main Class: PubmedFetcher
|
|
199
|
+
#############################################################
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class PubmedFetcher:
|
|
203
|
+
def __init__(self, root_dir: str, entrez_email: str,api_key: str, batch_size: int = 50, max_retries: int = 3):
|
|
204
|
+
"""
|
|
205
|
+
Description
|
|
206
|
+
-----------
|
|
207
|
+
Initializes the PubmedFetcher with output directory, Entrez email, and batch size.
|
|
208
|
+
It automatically sets up the internal directory structure:
|
|
209
|
+
- root_dir/ (the main data repository, can be output directory)
|
|
210
|
+
- papers/ (or any other name you prefer)
|
|
211
|
+
- {pub_year}/
|
|
212
|
+
- {pmid}/
|
|
213
|
+
- metadata.json
|
|
214
|
+
- fulltext.txt
|
|
215
|
+
- others
|
|
216
|
+
- LookUP tables
|
|
217
|
+
- Others
|
|
218
|
+
|
|
219
|
+
Args
|
|
220
|
+
-----
|
|
221
|
+
root_dir (str): Directory to save the fetched JSON files.
|
|
222
|
+
entrez_email (str): Email address for NCBI Entrez. It is required by NCBI and should be set to a valid email.
|
|
223
|
+
api_key (str): NCBI API Key for higher rate limits (10 req/sec).
|
|
224
|
+
batch_size (int): Number of articles to fetch per batch, 50~100 is recommended.
|
|
225
|
+
max_retries (int): Maximum number of retries for Entrez API calls. Default is 3.
|
|
226
|
+
|
|
227
|
+
"""
|
|
228
|
+
self.root_dir = root_dir # This is the ROOT of our data repository
|
|
229
|
+
self.batch_size = batch_size
|
|
230
|
+
self.entrez_email = entrez_email
|
|
231
|
+
self.api_key = api_key
|
|
232
|
+
self.max_retries = max_retries
|
|
233
|
+
|
|
234
|
+
# Global setting for Entrez email (necessary for Biopython Entrez)
|
|
235
|
+
Entrez.email = entrez_email
|
|
236
|
+
|
|
237
|
+
if api_key:
|
|
238
|
+
Entrez.api_key = api_key
|
|
239
|
+
print(f"✅ NCBI API Key set successfully. Rate limit increased to 10 req/s.")
|
|
240
|
+
|
|
241
|
+
# designed for central library, but not used currently
|
|
242
|
+
# make sure that root directory exists
|
|
243
|
+
# if not os.path.exists(self.root_dir):
|
|
244
|
+
# os.makedirs(self.root_dir)
|
|
245
|
+
|
|
246
|
+
#############################################################
|
|
247
|
+
# 2.1, Query search and fetch PMIDs
|
|
248
|
+
#############################################################
|
|
249
|
+
|
|
250
|
+
def query_search(self, query: str) -> Dict[str, Any]:
|
|
251
|
+
"""
|
|
252
|
+
Description
|
|
253
|
+
-----------
|
|
254
|
+
Search for the first time in PubMed using the given query to get the count list of results and WebEnv info and QueryKey.
|
|
255
|
+
|
|
256
|
+
Args
|
|
257
|
+
-----
|
|
258
|
+
query (str): The search query string.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
Dict[str, Any]: A dictionary containing the count list of results, WebEnv, and QueryKey.
|
|
263
|
+
|
|
264
|
+
Notes
|
|
265
|
+
-----
|
|
266
|
+
- 1, why ues retmax=0? Because we only want the count and WebEnv, not the actual IDs ——> we do not need to retrieve the actual IDs at this first stage.
|
|
267
|
+
- 2, usehistory="y" is crucial as it tells the server to remember the search results for later retrieval using WebEnv.
|
|
268
|
+
"""
|
|
269
|
+
print(f"Now searching PubMed with query [{query}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
270
|
+
|
|
271
|
+
for attempt in range(self.max_retries):
|
|
272
|
+
try:
|
|
273
|
+
# set Entrez.email before making requests
|
|
274
|
+
# Entrez.email = self.entrez_email
|
|
275
|
+
|
|
276
|
+
# usehistory="y" to enable WebEnv
|
|
277
|
+
handle = Entrez.esearch(db="pubmed", term=query, retmax=0, usehistory="y")
|
|
278
|
+
results = Entrez.read(handle)
|
|
279
|
+
handle.close()
|
|
280
|
+
|
|
281
|
+
count = int(results["Count"])
|
|
282
|
+
print(f"found {count} related articles about [{query}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"count": count,
|
|
286
|
+
"query": query,
|
|
287
|
+
"webenv": results["WebEnv"],
|
|
288
|
+
"query_key": results["QueryKey"]
|
|
289
|
+
}
|
|
290
|
+
except Exception as e:
|
|
291
|
+
if attempt < self.max_retries - 1:
|
|
292
|
+
print(f"Search PubMed with query [{query}] failed (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 2s...")
|
|
293
|
+
time.sleep(2)
|
|
294
|
+
else:
|
|
295
|
+
print(f"Search PubMed with query [{query}] failed after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
296
|
+
traceback.print_exc()
|
|
297
|
+
return {"count": 0}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_pubmedIDs_from_query(self, query_meta: Dict[str, Any], retmax: int = 500) -> List[str]:
|
|
301
|
+
"""
|
|
302
|
+
Description
|
|
303
|
+
-----------
|
|
304
|
+
Get all PubMed IDs from a query search using the history.
|
|
305
|
+
|
|
306
|
+
Args
|
|
307
|
+
-----
|
|
308
|
+
query_meta (str): Metadata from the query search containing count, WebEnv, and QueryKey and query. Just the return
|
|
309
|
+
value of query_search function.
|
|
310
|
+
retmax (int): Batch size for fetching PubMed IDs to retrieve. Default is 500.
|
|
311
|
+
|
|
312
|
+
Returns
|
|
313
|
+
-------
|
|
314
|
+
List[str]: A list of PubMed IDs retrieved from the query.
|
|
315
|
+
|
|
316
|
+
Notes
|
|
317
|
+
-----
|
|
318
|
+
- 1, esearch function in Biopython's Entrez module forces us to use term parameter when we want to use history server,
|
|
319
|
+
so we need to pass query_meta["query"] here again even we do not really use it. Note that we actually do not need term parameter as long as we have
|
|
320
|
+
WebEnv and QueryKey. It is just a design quirk of Biopython Entrez.esearch function and we just need a placeholder(占位符).
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
count = query_meta.get("count", 0)
|
|
324
|
+
webenv = query_meta.get("webenv", "")
|
|
325
|
+
query_key = query_meta.get("query_key", "")
|
|
326
|
+
query = query_meta.get("query", "")
|
|
327
|
+
|
|
328
|
+
if count == 0 or not webenv or not query_key:
|
|
329
|
+
# the reason why we do not check query is that we just need a placeholder for Biopython Entrez.esearch function
|
|
330
|
+
print(f"No PMIDs to fetch due to failure in step query_search at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
print(f"Retrieving {count} PMIDs from history server at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
334
|
+
|
|
335
|
+
all_pmids = []
|
|
336
|
+
|
|
337
|
+
# Fetch in batches according to retmax
|
|
338
|
+
for start in range(0, count, retmax):
|
|
339
|
+
end = min(count, start + retmax)
|
|
340
|
+
print(f"Fetching PMIDs {start + 1} to {end} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
341
|
+
|
|
342
|
+
for attempt in range(self.max_retries):
|
|
343
|
+
try:
|
|
344
|
+
handle = Entrez.esearch(
|
|
345
|
+
db="pubmed",
|
|
346
|
+
term=query,
|
|
347
|
+
retstart=start,
|
|
348
|
+
retmax=retmax,
|
|
349
|
+
webenv=webenv,
|
|
350
|
+
query_key=query_key
|
|
351
|
+
)
|
|
352
|
+
results = Entrez.read(handle)
|
|
353
|
+
handle.close()
|
|
354
|
+
|
|
355
|
+
batch_pmids = results.get("IdList", [])
|
|
356
|
+
all_pmids.extend(batch_pmids)
|
|
357
|
+
|
|
358
|
+
print(f" -> Retrieved {len(batch_pmids)} PMIDs in this batch.")
|
|
359
|
+
|
|
360
|
+
# Polite delay to avoid overwhelming NCBI servers
|
|
361
|
+
time.sleep(1)
|
|
362
|
+
break # Success, exit retry loop
|
|
363
|
+
|
|
364
|
+
except Exception as e:
|
|
365
|
+
if attempt < self.max_retries - 1:
|
|
366
|
+
print(f"Fetching PMIDs {start + 1} to {end} failed (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 2s...")
|
|
367
|
+
time.sleep(2)
|
|
368
|
+
else:
|
|
369
|
+
print(f"Fetching PMIDs {start + 1} to {end} failed after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
370
|
+
traceback.print_exc()
|
|
371
|
+
# Continue to next batch even if this one failed
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# Final count check
|
|
375
|
+
print(f"Total PMIDs retrieved: {len(all_pmids)} out of {count} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
376
|
+
return all_pmids
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
#############################################################
|
|
380
|
+
# 2.2, Fetch articles and parse metadata: paper_metadata only
|
|
381
|
+
#############################################################
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def fetch_from_query(self, query_meta: Dict[str, Any], output_dir: str = None) -> List[Paper_MetaData]:
|
|
385
|
+
"""
|
|
386
|
+
Description
|
|
387
|
+
-----------
|
|
388
|
+
Use the query search metadata to fetch articles in batches and save them as JSON files.
|
|
389
|
+
|
|
390
|
+
Args
|
|
391
|
+
-----
|
|
392
|
+
query_meta (Dict[str, Any]): Metadata from the query search containing count, WebEnv, and QueryKey. Just the return \
|
|
393
|
+
value of query_search function.
|
|
394
|
+
|
|
395
|
+
Returns
|
|
396
|
+
-----
|
|
397
|
+
List[Paper_MetaData]: A list of Paper_MetaData objects
|
|
398
|
+
|
|
399
|
+
Notes
|
|
400
|
+
-----
|
|
401
|
+
- 1, The best strategy to collect large number of articles: medline parsing + xml parsing
|
|
402
|
+
|
|
403
|
+
"""
|
|
404
|
+
count = query_meta.get("count", 0)
|
|
405
|
+
webenv = query_meta.get("webenv", "")
|
|
406
|
+
query_key = query_meta.get("query_key", "")
|
|
407
|
+
|
|
408
|
+
if count == 0 or not webenv or not query_key:
|
|
409
|
+
print(f"No articles to fetch due to failure in step query_search at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
all_parsed_articles = []
|
|
413
|
+
|
|
414
|
+
# Fetch in batches according to batch_size
|
|
415
|
+
for start in range(0, count, self.batch_size):
|
|
416
|
+
end = min(count, start + self.batch_size)
|
|
417
|
+
print(f"Fetching articles {start + 1} to {end} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
418
|
+
|
|
419
|
+
for attempt in range(self.max_retries):
|
|
420
|
+
try:
|
|
421
|
+
# Use efetch to get detailed information in Medline format
|
|
422
|
+
# AS for rettype and retmode, please refer to:
|
|
423
|
+
# https://www.ncbi.nlm.nih.gov/books/NBK25499/table/chapter4.T._valid_values_of__retmode_and/?report=objectonly
|
|
424
|
+
fetch_handle_medline = Entrez.efetch(
|
|
425
|
+
db="pubmed",
|
|
426
|
+
rettype="medline",
|
|
427
|
+
retmode="text",
|
|
428
|
+
retstart=start,
|
|
429
|
+
retmax=self.batch_size,
|
|
430
|
+
webenv=webenv,
|
|
431
|
+
query_key=query_key
|
|
432
|
+
)
|
|
433
|
+
# and we also fetch xml format data
|
|
434
|
+
fetch_handle_xml = Entrez.efetch(
|
|
435
|
+
db="pubmed",
|
|
436
|
+
retmode="xml",
|
|
437
|
+
retstart=start,
|
|
438
|
+
retmax=self.batch_size,
|
|
439
|
+
webenv=webenv,
|
|
440
|
+
query_key=query_key
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Parse Medline format data and xml format data
|
|
444
|
+
data_medline = Medline.parse(fetch_handle_medline)
|
|
445
|
+
# data_Medline now is a generator, convert to list will get all data
|
|
446
|
+
# Now records_medline is a list of batch_size medline records
|
|
447
|
+
records_medline = list(data_medline)
|
|
448
|
+
|
|
449
|
+
# ⚠️ For xml, We read instead of parsing here, so we must be careful about the batch size and memory usage!
|
|
450
|
+
data_xml = Entrez.read(fetch_handle_xml)
|
|
451
|
+
# data_xml now is a huge dict, it only contains 2 keys:'PubmedBookArticle'(none) and 'PubmedArticle'(useful), we use latter
|
|
452
|
+
articles_list = data_xml['PubmedArticle']
|
|
453
|
+
|
|
454
|
+
fetch_handle_medline.close()
|
|
455
|
+
fetch_handle_xml.close()
|
|
456
|
+
|
|
457
|
+
print(f" -> Retrieved {len(records_medline)} Medline records and {len(articles_list)} Xml articles. Please check whether they equal and the efetch number here with esearch count.")
|
|
458
|
+
|
|
459
|
+
# Now we start to parse each record, note that each record is a PubMed article in Medline format
|
|
460
|
+
parsed_articles: List[Paper_MetaData] = []
|
|
461
|
+
|
|
462
|
+
# we parse medline and xml at the same time
|
|
463
|
+
for record,article in zip(records_medline, articles_list):
|
|
464
|
+
# one single record
|
|
465
|
+
parsed_medline = self.parse_medline_record(record)
|
|
466
|
+
parsed_xml = self.parse_article_xml(article)
|
|
467
|
+
|
|
468
|
+
if parsed_medline:
|
|
469
|
+
# merge medline and xml parsed results
|
|
470
|
+
# ⚠️ Note that parsed_medline is the main body, we only update contributors from parsed_xml
|
|
471
|
+
parsed_medline.contributors.xml = parsed_xml
|
|
472
|
+
parsed_articles.append(parsed_medline)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# --- Fetch Linked Data ---
|
|
476
|
+
if parsed_articles:
|
|
477
|
+
current_batch_pmids = [p.identity.pmid for p in parsed_articles if p.identity.pmid] # list of pmids
|
|
478
|
+
|
|
479
|
+
# Batch fetch all links for the current batch of PMIDs
|
|
480
|
+
links_map = self.fetch_linked_data_for_batch_pmid(current_batch_pmids) # Dict[str, PaperLinks]
|
|
481
|
+
|
|
482
|
+
# Map back to Paper objects
|
|
483
|
+
for p in parsed_articles:
|
|
484
|
+
if p.identity.pmid in links_map:
|
|
485
|
+
p.links = links_map[p.identity.pmid]
|
|
486
|
+
|
|
487
|
+
# Save this batch as individual JSON files per paper
|
|
488
|
+
# We do not save batch files anymore since it is hard to manage and update single paper
|
|
489
|
+
if parsed_articles:
|
|
490
|
+
# Change to save single paper to json for better indexing and retrieval
|
|
491
|
+
for paper_meta in parsed_articles:
|
|
492
|
+
self.save_single_paper_to_json(paper_meta, output_dir=output_dir)
|
|
493
|
+
all_parsed_articles.extend(parsed_articles)
|
|
494
|
+
|
|
495
|
+
# Polite delay to avoid overwhelming NCBI servers
|
|
496
|
+
time.sleep(1)
|
|
497
|
+
break # Success, exit retry loop
|
|
498
|
+
|
|
499
|
+
except Exception as e:
|
|
500
|
+
if attempt < self.max_retries - 1:
|
|
501
|
+
print(f"Fetching articles {start + 1} to {end} failed (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 1s...")
|
|
502
|
+
time.sleep(1)
|
|
503
|
+
else:
|
|
504
|
+
print(f"Fetching articles {start + 1} to {end} failed after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
505
|
+
traceback.print_exc()
|
|
506
|
+
# Continue to next batch
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
return all_parsed_articles
|
|
510
|
+
|
|
511
|
+
def fetch_from_pmid_list(self, pmid_list: List[str], output_dir: str = None) -> List[Paper_MetaData]:
|
|
512
|
+
"""
|
|
513
|
+
Description
|
|
514
|
+
-----------
|
|
515
|
+
Fetch articles by a specific list of PMIDs in batches and save them as JSON files.
|
|
516
|
+
|
|
517
|
+
Args
|
|
518
|
+
-----
|
|
519
|
+
pmid_list (List[str]): List of PubMed IDs to fetch.
|
|
520
|
+
|
|
521
|
+
Returns
|
|
522
|
+
-------
|
|
523
|
+
List[Paper_MetaData]: A list of Paper_MetaData objects retrieved from the given PMIDs.
|
|
524
|
+
|
|
525
|
+
Notes
|
|
526
|
+
-----
|
|
527
|
+
- 1, This function is similar to fetch_from_query, but it fetches articles based on a provided list of PMIDs.
|
|
528
|
+
- 2, It can be used to fetch batch articles or a single article by providing a list with one PMID.
|
|
529
|
+
"""
|
|
530
|
+
count = len([pmid_list] if isinstance(pmid_list, str) else pmid_list)
|
|
531
|
+
if count == 0:
|
|
532
|
+
print("No PMIDs to fetch.")
|
|
533
|
+
return []
|
|
534
|
+
|
|
535
|
+
if isinstance(pmid_list, str):
|
|
536
|
+
pmid_list = [pmid_list]
|
|
537
|
+
|
|
538
|
+
print(f"Total PMIDs to fetch: {count} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
539
|
+
all_parsed_articles = []
|
|
540
|
+
|
|
541
|
+
# Fetch in batches according to batch_size, same as fetch_from_query above
|
|
542
|
+
for start in range(0, count, self.batch_size):
|
|
543
|
+
end = min(count, start + self.batch_size)
|
|
544
|
+
batch_pmids = pmid_list[start:end]
|
|
545
|
+
print(f"Fetching articles {start + 1} to {end} (PMID: {batch_pmids}) at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
546
|
+
|
|
547
|
+
for attempt in range(self.max_retries):
|
|
548
|
+
try:
|
|
549
|
+
# same as fetch_from_query, we fetch both medline and xml formats
|
|
550
|
+
fetch_handle_medline = Entrez.efetch(
|
|
551
|
+
db="pubmed",
|
|
552
|
+
rettype="medline",
|
|
553
|
+
retmode="text",
|
|
554
|
+
id=batch_pmids
|
|
555
|
+
)
|
|
556
|
+
# and we also fetch xml format data
|
|
557
|
+
fetch_handle_xml = Entrez.efetch(
|
|
558
|
+
db="pubmed",
|
|
559
|
+
retmode="xml",
|
|
560
|
+
id=batch_pmids
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# Parse Medline format data and xml format data
|
|
564
|
+
data_medline = Medline.parse(fetch_handle_medline)
|
|
565
|
+
records_medline = list(data_medline)
|
|
566
|
+
|
|
567
|
+
data_xml = Entrez.read(fetch_handle_xml)
|
|
568
|
+
articles_list = data_xml['PubmedArticle']
|
|
569
|
+
|
|
570
|
+
fetch_handle_medline.close()
|
|
571
|
+
fetch_handle_xml.close()
|
|
572
|
+
|
|
573
|
+
print(f" -> Retrieved {len(records_medline)} Medline records and {len(articles_list)} Xml articles. Please check whether they equal and whether they match the number of this batch.")
|
|
574
|
+
|
|
575
|
+
# Now we start to parse each record, note that each record is a PubMed article in Medline format
|
|
576
|
+
parsed_articles: List[Paper_MetaData] = []
|
|
577
|
+
|
|
578
|
+
# we parse medline and xml at the same time
|
|
579
|
+
for record,article in zip(records_medline, articles_list):
|
|
580
|
+
# one single record
|
|
581
|
+
parsed_medline = self.parse_medline_record(record)
|
|
582
|
+
parsed_xml = self.parse_article_xml(article)
|
|
583
|
+
|
|
584
|
+
if parsed_medline:
|
|
585
|
+
# merge medline and xml parsed results
|
|
586
|
+
# ⚠️ Note that parsed_medline is the main body, we only update contributors from parsed_xml
|
|
587
|
+
parsed_medline.contributors.xml = parsed_xml
|
|
588
|
+
parsed_articles.append(parsed_medline)
|
|
589
|
+
|
|
590
|
+
# --- Fetch Linked Data ---
|
|
591
|
+
if parsed_articles:
|
|
592
|
+
|
|
593
|
+
links_map = self.fetch_linked_data_for_batch_pmid(batch_pmids)
|
|
594
|
+
|
|
595
|
+
for p in parsed_articles:
|
|
596
|
+
if p.identity.pmid in links_map:
|
|
597
|
+
p.links = links_map[p.identity.pmid]
|
|
598
|
+
|
|
599
|
+
# Save this batch as individual JSON files per paper
|
|
600
|
+
if parsed_articles:
|
|
601
|
+
# Change to save single paper to json for better indexing and retrieval
|
|
602
|
+
for paper_meta in parsed_articles:
|
|
603
|
+
self.save_single_paper_to_json(paper_meta, output_dir=output_dir)
|
|
604
|
+
all_parsed_articles.extend(parsed_articles)
|
|
605
|
+
# Polite delay to avoid overwhelming NCBI servers
|
|
606
|
+
time.sleep(1)
|
|
607
|
+
break # Success, exit retry loop
|
|
608
|
+
|
|
609
|
+
except Exception as e:
|
|
610
|
+
if attempt < self.max_retries - 1:
|
|
611
|
+
print(f"Fetching articles {start + 1} to {end} failed (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 1s...")
|
|
612
|
+
time.sleep(1)
|
|
613
|
+
else:
|
|
614
|
+
print(f"Fetching articles {start + 1} to {end} failed after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
615
|
+
traceback.print_exc()
|
|
616
|
+
# Continue to next batch
|
|
617
|
+
pass
|
|
618
|
+
|
|
619
|
+
return all_parsed_articles
|
|
620
|
+
|
|
621
|
+
def parse_medline_record(self, medline_record: Dict[str, Any]) -> Paper_MetaData:
|
|
622
|
+
"""
|
|
623
|
+
Description
|
|
624
|
+
-----------
|
|
625
|
+
Parse one single Medline record into a structured dictionary to get relevant fields for construction of Ontology of this article.
|
|
626
|
+
|
|
627
|
+
Args
|
|
628
|
+
----
|
|
629
|
+
medline_record: Dict[str, Any]
|
|
630
|
+
A single Medline record as parsed by Bio.Medline.
|
|
631
|
+
|
|
632
|
+
Returns
|
|
633
|
+
-------
|
|
634
|
+
Paper_MetaData
|
|
635
|
+
|
|
636
|
+
A Paper_MetaData object containing structured information extracted from the Medline record.
|
|
637
|
+
|
|
638
|
+
Notes
|
|
639
|
+
-----
|
|
640
|
+
- 1, For medline record field, refer to http://zatoka.icm.edu.pl/OVIDWEB/fldguide/medline.htm, https://medialab.github.io/sciencescape/medline_utils/
|
|
641
|
+
- 2, the INPUT medline_record should be a single record of medline parsing result:
|
|
642
|
+
handle = Entrez.efetch(db="pubmed",rettype="medline",retmode="text")
|
|
643
|
+
records = Medline.parse(handle)
|
|
644
|
+
medline_records = list(records)
|
|
645
|
+
for medline_record in medline_records: where each medline_record is the input here.
|
|
646
|
+
"""
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
# --- 1, Metadata extraction ---
|
|
650
|
+
pmid = medline_record.get("PMID", "")
|
|
651
|
+
title = medline_record.get("TI", "")
|
|
652
|
+
|
|
653
|
+
# ⚠️ get DOI from AID field if available (more reliable than LID)
|
|
654
|
+
# AID and LID fields could be str or list
|
|
655
|
+
doi = ""
|
|
656
|
+
aids = medline_record.get("AID", [])
|
|
657
|
+
if isinstance(aids, str):
|
|
658
|
+
aids = [aids]
|
|
659
|
+
for aid in aids:
|
|
660
|
+
if "[doi]" in aid:
|
|
661
|
+
doi = aid.replace("[doi]", "").strip()
|
|
662
|
+
break
|
|
663
|
+
|
|
664
|
+
# alternative DOI extraction from LID field if AID not available
|
|
665
|
+
if not doi and 'LID' in medline_record:
|
|
666
|
+
lids = medline_record.get("LID", [])
|
|
667
|
+
if isinstance(lids, str):
|
|
668
|
+
lids = [lids]
|
|
669
|
+
for lid in lids:
|
|
670
|
+
if '[doi]' in lid:
|
|
671
|
+
doi = lid.replace("[doi]", "").strip()
|
|
672
|
+
break
|
|
673
|
+
|
|
674
|
+
# --- 2, Content extraction ---
|
|
675
|
+
abstract = medline_record.get("AB", "")
|
|
676
|
+
|
|
677
|
+
# Mine URLs from abstract text
|
|
678
|
+
mined_urls = extract_urls_from_text(abstract, source_tag="abstract")
|
|
679
|
+
|
|
680
|
+
# keywords: OT(Other Terms) field, always converted to list
|
|
681
|
+
keywords = medline_record.get("OT", [])
|
|
682
|
+
if isinstance(keywords, str):
|
|
683
|
+
keywords = [keywords]
|
|
684
|
+
|
|
685
|
+
# MeSH terms: MH field, always converted to list
|
|
686
|
+
# ⚠️ A Question: what's the difference between OT and MH fields?
|
|
687
|
+
mesh_terms = medline_record.get("MH", [])
|
|
688
|
+
if isinstance(mesh_terms, str):
|
|
689
|
+
mesh_terms = [mesh_terms]
|
|
690
|
+
|
|
691
|
+
# Publication Type: PT field, e.g., Journal Article, Review, etc.
|
|
692
|
+
pub_types = medline_record.get("PT", [])
|
|
693
|
+
if isinstance(pub_types, str):
|
|
694
|
+
pub_types = [pub_types]
|
|
695
|
+
|
|
696
|
+
# 3, ---- Entity: Authors & Affiliation extraction ---
|
|
697
|
+
# Authors: FAU (Full Author Name) field
|
|
698
|
+
full_author_names = medline_record.get("FAU", [])
|
|
699
|
+
short_author_names = medline_record.get("AU", [])
|
|
700
|
+
|
|
701
|
+
# Author Identifiers (e.g., ORCID): AUID field
|
|
702
|
+
auids = medline_record.get("AUID", [])
|
|
703
|
+
# Affiliations: AD field
|
|
704
|
+
affiliations = medline_record.get("AD", [])
|
|
705
|
+
|
|
706
|
+
# Ensure all author-related fields are lists
|
|
707
|
+
for i in [full_author_names, short_author_names, auids, affiliations]:
|
|
708
|
+
if isinstance(i, str):
|
|
709
|
+
i = [i]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# Construct author entities/dictionaries
|
|
713
|
+
# ⚠️ A Question: How to correctly map authors to their affiliations and identifiers?
|
|
714
|
+
# we can not do this perfectly without more information
|
|
715
|
+
# let's try xml format output
|
|
716
|
+
|
|
717
|
+
# 4, ---- Entity: Journal & Publication extraction ---
|
|
718
|
+
journal_title = medline_record.get("JT", ""), # Journal Title
|
|
719
|
+
journal_abbrev = medline_record.get("TA", ""), # Journal Abbreviation
|
|
720
|
+
|
|
721
|
+
# Date of Publication: DP field
|
|
722
|
+
pub_date = medline_record.get("DP", "")
|
|
723
|
+
pub_year = pub_date[:4] if len(pub_date) >= 4 else ""
|
|
724
|
+
|
|
725
|
+
# Entrez Date: EDAT field, used for incremental updates (录入日期, 用于增量更新)
|
|
726
|
+
entrez_date = medline_record.get("EDAT", "")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ⚠️ Further fields can be extracted as needed
|
|
730
|
+
|
|
731
|
+
return Paper_MetaData(
|
|
732
|
+
identity=PaperIdentity(pmid=pmid, doi=doi, title=title),
|
|
733
|
+
content=PaperContent(abstract=abstract,
|
|
734
|
+
keywords=keywords,
|
|
735
|
+
mesh_terms=mesh_terms,
|
|
736
|
+
pub_types=pub_types),
|
|
737
|
+
contributors=PaperContributors(
|
|
738
|
+
medline={
|
|
739
|
+
"full_names": full_author_names,
|
|
740
|
+
"short_names": short_author_names,
|
|
741
|
+
"auids": auids,
|
|
742
|
+
"affiliations": affiliations,
|
|
743
|
+
}
|
|
744
|
+
),
|
|
745
|
+
source=PaperSource(
|
|
746
|
+
journal_title=journal_title,
|
|
747
|
+
journal_abbrev=journal_abbrev,
|
|
748
|
+
pub_date=pub_date,
|
|
749
|
+
pub_year=pub_year,
|
|
750
|
+
pub_types=pub_types,
|
|
751
|
+
),
|
|
752
|
+
metadata=PaperMetadata(
|
|
753
|
+
entrez_date=entrez_date,
|
|
754
|
+
fetched_at=time.strftime('%Y-%m-%d %H:%M:%S')
|
|
755
|
+
),
|
|
756
|
+
# Initialize links with mined URLs
|
|
757
|
+
links=PaperLinks(text_mined=mined_urls)
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
except Exception as e:
|
|
762
|
+
print(f"Error parsing Medline record PMID {medline_record.get('PMID', 'Unknown')}: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
763
|
+
traceback.print_exc()
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
def parse_article_xml(self, article: Any) -> Optional[List[Dict[str, Any]]]:
|
|
767
|
+
"""
|
|
768
|
+
Description
|
|
769
|
+
-----------
|
|
770
|
+
Parse one single article in XML format into a structured dictionary to get relevant fields for construction of Ontology of this article.
|
|
771
|
+
|
|
772
|
+
Args
|
|
773
|
+
----
|
|
774
|
+
article: Any
|
|
775
|
+
A single article as parsed by Entrez.read() from XML format.
|
|
776
|
+
|
|
777
|
+
Returns
|
|
778
|
+
-------
|
|
779
|
+
List[Dict[str, Any]]
|
|
780
|
+
A list of dictionaries containing structured author information extracted from the article XML.
|
|
781
|
+
|
|
782
|
+
Notes
|
|
783
|
+
-----
|
|
784
|
+
- 1, the INPUT article should be a single article of xml parsing result:
|
|
785
|
+
handle = Entrez.efetch(db="pubmed",retmode="xml")
|
|
786
|
+
data = Entrez.read(handle)
|
|
787
|
+
articles_list = data['PubmedArticle']
|
|
788
|
+
for article in articles_list: where each article is the input here.
|
|
789
|
+
- 2, Note that we only extract author information here, other fields can be added as needed but medline format already covers most.
|
|
790
|
+
"""
|
|
791
|
+
try:
|
|
792
|
+
medline = article.get('MedlineCitation', {})
|
|
793
|
+
article_data = medline.get('Article', {})
|
|
794
|
+
pmid = str(medline.get('PMID', 'Unknown')) # defensive coding: in case to trace back errors
|
|
795
|
+
|
|
796
|
+
# --- Authors & Affiliations ---
|
|
797
|
+
authors_structured = []
|
|
798
|
+
full_names_list = []
|
|
799
|
+
short_names_list = []
|
|
800
|
+
auids_list = []
|
|
801
|
+
affiliations_list = []
|
|
802
|
+
|
|
803
|
+
if 'AuthorList' in article_data:
|
|
804
|
+
for author in article_data['AuthorList']:
|
|
805
|
+
# 1. Name Extraction
|
|
806
|
+
last_name = author.get('LastName', '')
|
|
807
|
+
fore_name = author.get('ForeName', '')
|
|
808
|
+
initials = author.get('Initials', '')
|
|
809
|
+
|
|
810
|
+
# Construct Full Name (Medline FAU format: LastName, ForeName)
|
|
811
|
+
# else if only one of them exists, use that
|
|
812
|
+
full_name = f"{last_name}, {fore_name}" if last_name and fore_name else last_name or fore_name
|
|
813
|
+
if full_name: full_names_list.append(full_name)
|
|
814
|
+
|
|
815
|
+
# Construct Short Name (Medline AU format: LastName Initials)
|
|
816
|
+
short_name = f"{last_name} {initials}" if last_name and initials else last_name or initials
|
|
817
|
+
if short_name: short_names_list.append(short_name)
|
|
818
|
+
|
|
819
|
+
# 2. Identifier Extraction (e.g. ORCID)
|
|
820
|
+
current_auids = []
|
|
821
|
+
if 'Identifier' in author:
|
|
822
|
+
# Note that author['Identifier'] could be a empty list or a list of identifiers
|
|
823
|
+
identifiers = author['Identifier']
|
|
824
|
+
# Defensive coding: handle both list and single item
|
|
825
|
+
if len(identifiers) > 0:
|
|
826
|
+
# it means that this author has at least one identifier
|
|
827
|
+
current_auids.append(str(identifiers[0]))
|
|
828
|
+
|
|
829
|
+
# 3. Affiliation Extraction
|
|
830
|
+
current_affiliations = []
|
|
831
|
+
if 'AffiliationInfo' in author:
|
|
832
|
+
aff_info = author['AffiliationInfo']
|
|
833
|
+
for aff in aff_info:
|
|
834
|
+
aff_text = aff.get('Affiliation', '')
|
|
835
|
+
if aff_text:
|
|
836
|
+
current_affiliations.append(aff_text)
|
|
837
|
+
|
|
838
|
+
authors_structured.append({
|
|
839
|
+
"full_name": full_name,
|
|
840
|
+
"short_name": short_name,
|
|
841
|
+
"identifiers": current_auids,
|
|
842
|
+
"affiliations": current_affiliations,
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
return authors_structured
|
|
846
|
+
|
|
847
|
+
except Exception as e:
|
|
848
|
+
print(f"Error parsing article XML PMID {pmid}: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
849
|
+
traceback.print_exc()
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
'''
|
|
854
|
+
def fetch_linked_data_for_single_pmid(self, pmid: str) -> Dict[str, PaperLinks]:
|
|
855
|
+
"""
|
|
856
|
+
Description
|
|
857
|
+
-----------
|
|
858
|
+
Uses ELink to fetch all connected data for a single PMID.
|
|
859
|
+
|
|
860
|
+
Args
|
|
861
|
+
----
|
|
862
|
+
pmid: str
|
|
863
|
+
A single PubMed ID to fetch linked data for.
|
|
864
|
+
|
|
865
|
+
Returns
|
|
866
|
+
-------
|
|
867
|
+
Dict[str, PaperLinks]
|
|
868
|
+
A dictionary mapping the PMID to its corresponding PaperLinks object containing linked data.
|
|
869
|
+
|
|
870
|
+
Notes
|
|
871
|
+
-----
|
|
872
|
+
- 1, ELink Strategy:
|
|
873
|
+
1. 'acheck' to discover available internal Entrez links (Gene, Protein, etc.).
|
|
874
|
+
2. 'neighbor' to fetch the actual IDs for those internal links. (1 and 2 are combined here)
|
|
875
|
+
3. 'llinks' to fetch external URLs (LinkOuts) for datasets, full text, etc. (3 is separate)
|
|
876
|
+
"""
|
|
877
|
+
if not pmid:
|
|
878
|
+
return {}
|
|
879
|
+
|
|
880
|
+
# Map PMID -> PaperLinks
|
|
881
|
+
links_map = {pmid: PaperLinks()}
|
|
882
|
+
|
|
883
|
+
# --- Part A: Internal Entrez Links (Discovery & Fetching) ---
|
|
884
|
+
# 1. Discovery (acheck)
|
|
885
|
+
# Note: acheck returns LinkSetDbHistory
|
|
886
|
+
acheck_results = []
|
|
887
|
+
for attempt in range(self.max_retries):
|
|
888
|
+
try:
|
|
889
|
+
handle = Entrez.elink(dbfrom="pubmed", id=pmid, cmd="acheck")
|
|
890
|
+
acheck_results = Entrez.read(handle)
|
|
891
|
+
handle.close()
|
|
892
|
+
break
|
|
893
|
+
except Exception as e:
|
|
894
|
+
if attempt < self.max_retries - 1:
|
|
895
|
+
print(f"Error in ELink acheck for {pmid} (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 2s...")
|
|
896
|
+
time.sleep(2)
|
|
897
|
+
else:
|
|
898
|
+
print(f"Error in ELink acheck for {pmid} after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
899
|
+
# Continue even if acheck fails
|
|
900
|
+
acheck_results = []
|
|
901
|
+
|
|
902
|
+
# Collect all unique LinkNames to query
|
|
903
|
+
# tuple() like (db, linkname)
|
|
904
|
+
links_to_fetch : set[Tuple[str, str]] = set()
|
|
905
|
+
|
|
906
|
+
# ⚠️ The following links are always useful to fetch
|
|
907
|
+
# cited_by /被他引
|
|
908
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed_citedin"))
|
|
909
|
+
# references /参考文献
|
|
910
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed_refs"))
|
|
911
|
+
# similar articles /相似文章
|
|
912
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed"))
|
|
913
|
+
# related reviews / 相关综述文章
|
|
914
|
+
# ⚠️ But actually, We recommend you to unite all the articles about your PMID papers, and then efetch them using medline format,
|
|
915
|
+
# and finally filter them by PT field to get all reviews, which is more reliable.
|
|
916
|
+
# So we do not guarantee that this link is complete about all reviews.
|
|
917
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed_reviews"))
|
|
918
|
+
# pmc free full text articles / PMC 免费全文
|
|
919
|
+
links_to_fetch.add(("pmc", "pubmed_pmc"))
|
|
920
|
+
|
|
921
|
+
if acheck_results:
|
|
922
|
+
try:
|
|
923
|
+
# safe access to link info
|
|
924
|
+
id_check_list = acheck_results[0].get('IdCheckList', {})
|
|
925
|
+
if not id_check_list:
|
|
926
|
+
raise ValueError("IdCheckList missing in acheck results")
|
|
927
|
+
id_link_set = id_check_list.get('IdLinkSet', [])
|
|
928
|
+
if not id_link_set:
|
|
929
|
+
raise ValueError("IdLinkSet missing in acheck results IdCheckList")
|
|
930
|
+
link_list = id_link_set[0].get('LinkInfo', [])
|
|
931
|
+
if link_list:
|
|
932
|
+
for link in link_list:
|
|
933
|
+
db = link.get('DbTo', '')
|
|
934
|
+
linkname = link.get('LinkName', '')
|
|
935
|
+
|
|
936
|
+
# Filter out useless UI links
|
|
937
|
+
# ⚠️ You may customize this filtering based on your needs
|
|
938
|
+
# After testing, we find that some links are not useful for ontology construction, such as:
|
|
939
|
+
# pubmed_pubmed_five (part of pubmed_pubmed), pubmed_pubmed_reviews_five (part of pubmed_pubmed_reviews)
|
|
940
|
+
# ExternalLink (link to external resources BUT returns nothing ?))
|
|
941
|
+
filter_list = ["pubmed_pubmed_five", "pubmed_pubmed_reviews_five", "ExternalLink"]
|
|
942
|
+
if linkname in filter_list:
|
|
943
|
+
continue
|
|
944
|
+
|
|
945
|
+
if db and linkname:
|
|
946
|
+
links_to_fetch.add((db, linkname))
|
|
947
|
+
except Exception as e:
|
|
948
|
+
traceback.print_exc()
|
|
949
|
+
print(f" Warning: Failed to parse acheck results for {pmid}: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
950
|
+
|
|
951
|
+
print(f" -> Deep mining {len(links_to_fetch)} types of internal connections for {pmid} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
952
|
+
|
|
953
|
+
# 2. Fetching (neighbor)
|
|
954
|
+
for link in links_to_fetch:
|
|
955
|
+
if link[0] == "LinkOut":
|
|
956
|
+
# ("LinkOut", "ExternalLink") is handled again
|
|
957
|
+
continue
|
|
958
|
+
|
|
959
|
+
# for each link type, we initialize the db_link structure
|
|
960
|
+
db_link = {"id": pmid, "db": link[0], "linkname": link[1], "links": []}
|
|
961
|
+
|
|
962
|
+
for attempt in range(self.max_retries):
|
|
963
|
+
try:
|
|
964
|
+
print(f" Fetching {link[1]} from {link[0]} for {pmid} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
965
|
+
handle = Entrez.elink(dbfrom="pubmed", id=pmid, db=link[0], linkname=link[1])
|
|
966
|
+
results = Entrez.read(handle)
|
|
967
|
+
handle.close()
|
|
968
|
+
|
|
969
|
+
if not results:
|
|
970
|
+
break # Success (empty result is valid)
|
|
971
|
+
|
|
972
|
+
# Note that link[1] is the linkname, link[0] is the db
|
|
973
|
+
# ⚠️ For different db and linkname, THE retured results structure may vary slightly
|
|
974
|
+
|
|
975
|
+
# For db="pubmed" or "pmc", we can directly extract the linked PMIDs/PMCIDs, their uids are in results[0]['LinkSetDb'][0]['Link']
|
|
976
|
+
link_set_db = results[0].get('LinkSetDb', [])
|
|
977
|
+
if not link_set_db:
|
|
978
|
+
# raise ValueError("LinkSetDb missing in elink results")
|
|
979
|
+
# If missing, maybe just no links
|
|
980
|
+
break
|
|
981
|
+
|
|
982
|
+
link_data = link_set_db[0].get('Link', [])
|
|
983
|
+
if not link_data:
|
|
984
|
+
# raise ValueError("Link missing in LinkSetDb in elink results")
|
|
985
|
+
break
|
|
986
|
+
|
|
987
|
+
for uid in link_data:
|
|
988
|
+
if 'Id' in uid:
|
|
989
|
+
db_link['links'].append(uid['Id'])
|
|
990
|
+
|
|
991
|
+
# Now we have fetched all linked IDs for this link type
|
|
992
|
+
if db_link['linkname'] == "pubmed_pubmed_citedin":
|
|
993
|
+
links_map[pmid].cites = db_link['links']
|
|
994
|
+
elif db_link['linkname'] == "pubmed_pubmed_refs":
|
|
995
|
+
links_map[pmid].refs = db_link['links']
|
|
996
|
+
elif db_link['linkname'] == "pubmed_pubmed":
|
|
997
|
+
links_map[pmid].similar = db_link['links']
|
|
998
|
+
elif db_link['linkname'] == "pubmed_pubmed_reviews":
|
|
999
|
+
links_map[pmid].review = db_link['links']
|
|
1000
|
+
elif db_link['linkname'] == "pubmed_pmc":
|
|
1001
|
+
links_map[pmid].pmc = db_link['links']
|
|
1002
|
+
else:
|
|
1003
|
+
# other internal links will be stored in entrez dict
|
|
1004
|
+
links_map[pmid].entrez[db_link['linkname']] = db_link['links']
|
|
1005
|
+
|
|
1006
|
+
break # Success, exit retry loop
|
|
1007
|
+
|
|
1008
|
+
except Exception as e:
|
|
1009
|
+
if attempt < self.max_retries - 1:
|
|
1010
|
+
print(f" Warning: Failed to fetch {link[1]} from {link[0]} for {pmid} (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 2s...")
|
|
1011
|
+
time.sleep(2)
|
|
1012
|
+
else:
|
|
1013
|
+
# since we force fetch some useful links above, some of them may not be available for certain PMIDs
|
|
1014
|
+
# so we may fail to fetch them at db_link['links'] update, just warn and continue
|
|
1015
|
+
print(f" Warning: Failed to fetch {link[1]} from {link[0]} for {pmid} after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1016
|
+
# If fetching fails in db_link['links'] update, we just continue, cause links_map[pmid].this_link is still an available empty list, we still can access it later
|
|
1017
|
+
traceback.print_exc()
|
|
1018
|
+
pass
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# --- Part B: External LinkOuts (llinks) ---
|
|
1022
|
+
print(f" -> Fetching external LinkOuts (Datasets, Full Text, etc.) for {pmid} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1023
|
+
|
|
1024
|
+
for attempt in range(self.max_retries):
|
|
1025
|
+
try:
|
|
1026
|
+
handle = Entrez.elink(dbfrom="pubmed", id=pmid, cmd="llinks")
|
|
1027
|
+
llinks_results = Entrez.read(handle, validate=False)
|
|
1028
|
+
handle.close()
|
|
1029
|
+
|
|
1030
|
+
if llinks_results:
|
|
1031
|
+
# safe access to IdUrlList
|
|
1032
|
+
id_url_list = llinks_results[0].get('IdUrlList', {})
|
|
1033
|
+
if not id_url_list:
|
|
1034
|
+
# raise ValueError("IdUrlList missing in llinks results")
|
|
1035
|
+
break
|
|
1036
|
+
id_url_set = id_url_list.get('IdUrlSet', [])
|
|
1037
|
+
if not id_url_set:
|
|
1038
|
+
# raise ValueError("IdUrlSet missing in IdUrlList in llinks results")
|
|
1039
|
+
break
|
|
1040
|
+
urls_list = id_url_set[0].get('ObjUrl', [])
|
|
1041
|
+
if urls_list:
|
|
1042
|
+
links_map[pmid].external = urls_list
|
|
1043
|
+
|
|
1044
|
+
break # Success, exit retry loop
|
|
1045
|
+
|
|
1046
|
+
except Exception as e:
|
|
1047
|
+
if attempt < self.max_retries - 1:
|
|
1048
|
+
print(f" Warning: Failed to fetch external LinkOuts for {pmid} (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 2s...")
|
|
1049
|
+
time.sleep(2)
|
|
1050
|
+
else:
|
|
1051
|
+
print(f" Warning: Failed to fetch external LinkOuts for {pmid} after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1052
|
+
traceback.print_exc()
|
|
1053
|
+
|
|
1054
|
+
return links_map
|
|
1055
|
+
'''
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def fetch_linked_data_for_batch_pmid(self, pmids: List[str]) -> Dict[str, PaperLinks]:
|
|
1059
|
+
"""
|
|
1060
|
+
Description
|
|
1061
|
+
-----------
|
|
1062
|
+
Uses ELink to fetch all connected data for a batch of PMIDs.
|
|
1063
|
+
|
|
1064
|
+
Args
|
|
1065
|
+
----
|
|
1066
|
+
pmids: List[str]
|
|
1067
|
+
A list of PubMed IDs to fetch linked data for.
|
|
1068
|
+
|
|
1069
|
+
Returns
|
|
1070
|
+
-------
|
|
1071
|
+
Dict[str, PaperLinks]
|
|
1072
|
+
A dictionary mapping each PMID to its corresponding PaperLinks object containing linked data.
|
|
1073
|
+
|
|
1074
|
+
Notes
|
|
1075
|
+
-----
|
|
1076
|
+
- 1, This function internally calls fetch_linked_data_single for each PMID in the list.
|
|
1077
|
+
- 2, This batch processing helps to reduce the number of individual requests to NCBI servers.
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
if not pmids:
|
|
1081
|
+
return {}
|
|
1082
|
+
|
|
1083
|
+
# Map PMID -> PaperLinks
|
|
1084
|
+
links_map = {pmid: PaperLinks() for pmid in pmids}
|
|
1085
|
+
|
|
1086
|
+
# --- Part A: Internal Entrez Links (Discovery & Fetching) ---
|
|
1087
|
+
# 1. Discovery (acheck)
|
|
1088
|
+
|
|
1089
|
+
# ⚠️1️⃣ this part is deprecated, since we always find errors in batch acheck for large pmid lists
|
|
1090
|
+
# so we just directly use the default useful links below
|
|
1091
|
+
'''
|
|
1092
|
+
# Note: acheck returns LinkSetDbHistory
|
|
1093
|
+
acheck_results = []
|
|
1094
|
+
for attempt in range(self.max_retries):
|
|
1095
|
+
try:
|
|
1096
|
+
handle = Entrez.elink(dbfrom="pubmed", id=pmids, cmd="acheck")
|
|
1097
|
+
acheck_results = Entrez.read(handle)
|
|
1098
|
+
handle.close()
|
|
1099
|
+
break
|
|
1100
|
+
except Exception as e:
|
|
1101
|
+
if attempt < self.max_retries - 1:
|
|
1102
|
+
print(f"Error in batch Elink acheck for {len(pmids)} PMIDs (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 1s...")
|
|
1103
|
+
time.sleep(1)
|
|
1104
|
+
else:
|
|
1105
|
+
# we do not print traceback here to avoid flooding the logs
|
|
1106
|
+
print(f" [Warning] Batch Elink acheck failed for {len(pmids)} PMIDs after {self.max_retries} attempts at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ... Skipping discovery step (using default links). Error: {e}")
|
|
1107
|
+
# Continue even if acheck fails
|
|
1108
|
+
acheck_results = []
|
|
1109
|
+
'''
|
|
1110
|
+
|
|
1111
|
+
# Collect all unique LinkNames to query
|
|
1112
|
+
# tuple() like (db, linkname)
|
|
1113
|
+
links_to_fetch : set[Tuple[str, str]] = set()
|
|
1114
|
+
|
|
1115
|
+
# ⚠️ The following links are always useful to fetch
|
|
1116
|
+
# cited_by /被他引
|
|
1117
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed_citedin"))
|
|
1118
|
+
# references /参考文献
|
|
1119
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed_refs"))
|
|
1120
|
+
# similar articles /相似文章
|
|
1121
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed"))
|
|
1122
|
+
# related reviews / 相关综述文章
|
|
1123
|
+
links_to_fetch.add(("pubmed", "pubmed_pubmed_reviews"))
|
|
1124
|
+
# pmc free full text articles / PMC 免费全文
|
|
1125
|
+
links_to_fetch.add(("pmc", "pubmed_pmc"))
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
# ⚠️1️⃣ this part is deprecated, since we always find errors in batch acheck for large pmid lists
|
|
1129
|
+
# so we just directly use the default useful links above
|
|
1130
|
+
'''
|
|
1131
|
+
if acheck_results:
|
|
1132
|
+
for linkset in acheck_results:
|
|
1133
|
+
try:
|
|
1134
|
+
# safe access to LinkInfo
|
|
1135
|
+
id_check_list = linkset.get('IdCheckList', {})
|
|
1136
|
+
if not id_check_list:
|
|
1137
|
+
continue
|
|
1138
|
+
id_link_set = id_check_list.get('IdLinkSet', [])
|
|
1139
|
+
if not id_link_set:
|
|
1140
|
+
continue
|
|
1141
|
+
link_list = id_link_set[0].get('LinkInfo', [])
|
|
1142
|
+
if link_list:
|
|
1143
|
+
for link in link_list:
|
|
1144
|
+
db = link.get('DbTo', '')
|
|
1145
|
+
linkname = link.get('LinkName', '')
|
|
1146
|
+
|
|
1147
|
+
# Filter out useless UI links
|
|
1148
|
+
filter_list = ["pubmed_pubmed_five", "pubmed_pubmed_reviews_five", "ExternalLink"]
|
|
1149
|
+
if linkname in filter_list:
|
|
1150
|
+
continue
|
|
1151
|
+
if "combined" in linkname:
|
|
1152
|
+
continue
|
|
1153
|
+
|
|
1154
|
+
if db and linkname:
|
|
1155
|
+
links_to_fetch.add((db, linkname))
|
|
1156
|
+
|
|
1157
|
+
except Exception as e:
|
|
1158
|
+
print(f" Warning: Failed to parse batch acheck results for {len(pmids)} PMIDs: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1159
|
+
traceback.print_exc()
|
|
1160
|
+
'''
|
|
1161
|
+
|
|
1162
|
+
print(f" -> Deep mining {len(links_to_fetch)} types of internal connections for {len(pmids)} PMIDs at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1163
|
+
|
|
1164
|
+
# 2. Fetching (neighbor)
|
|
1165
|
+
for link in links_to_fetch:
|
|
1166
|
+
if link[0] == "LinkOut":
|
|
1167
|
+
# ("LinkOut", "ExternalLink") is handled again
|
|
1168
|
+
continue
|
|
1169
|
+
|
|
1170
|
+
# for each link type, we initialize the db_link structure
|
|
1171
|
+
for attempt in range(self.max_retries):
|
|
1172
|
+
try:
|
|
1173
|
+
print(f" Fetching {link[1]} from {link[0]} for {len(pmids)} PMIDs at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1174
|
+
handle = Entrez.elink(dbfrom="pubmed", id=pmids, db=link[0], linkname=link[1])
|
|
1175
|
+
results = Entrez.read(handle)
|
|
1176
|
+
handle.close()
|
|
1177
|
+
|
|
1178
|
+
if not results:
|
|
1179
|
+
break
|
|
1180
|
+
|
|
1181
|
+
# For each PMID in results
|
|
1182
|
+
for linkset in results:
|
|
1183
|
+
source_id = "Unknown"
|
|
1184
|
+
try:
|
|
1185
|
+
source_id = str(linkset['IdList'][0]) # the source PMID
|
|
1186
|
+
|
|
1187
|
+
if source_id not in links_map:
|
|
1188
|
+
continue
|
|
1189
|
+
|
|
1190
|
+
# for each link type, we initialize the db_link structure
|
|
1191
|
+
db_link = {"id": source_id, "db": link[0], "linkname": link[1], "links": []}
|
|
1192
|
+
|
|
1193
|
+
# For db="pubmed" or "pmc", we can directly extract the linked PMIDs/PMCIDs, their uids are in linkset['LinkSetDb'][0]['Link']
|
|
1194
|
+
link_set_db = linkset.get('LinkSetDb', [])
|
|
1195
|
+
# defensive coding
|
|
1196
|
+
if not link_set_db:
|
|
1197
|
+
continue
|
|
1198
|
+
if len(link_set_db) == 0:
|
|
1199
|
+
continue
|
|
1200
|
+
|
|
1201
|
+
links_list = link_set_db[0].get('Link', [])
|
|
1202
|
+
if not links_list:
|
|
1203
|
+
continue
|
|
1204
|
+
|
|
1205
|
+
for uid in links_list:
|
|
1206
|
+
if 'Id' in uid:
|
|
1207
|
+
db_link["links"].append(uid['Id'])
|
|
1208
|
+
|
|
1209
|
+
# Now we have fetched all linked IDs for this link type, for this PMID
|
|
1210
|
+
if db_link["linkname"] == "pubmed_pubmed_citedin":
|
|
1211
|
+
links_map[source_id].cites = db_link['links']
|
|
1212
|
+
elif db_link["linkname"] == "pubmed_pubmed_refs":
|
|
1213
|
+
links_map[source_id].refs = db_link['links']
|
|
1214
|
+
elif db_link["linkname"] == "pubmed_pubmed":
|
|
1215
|
+
links_map[source_id].similar = db_link['links']
|
|
1216
|
+
elif db_link["linkname"] == "pubmed_pubmed_reviews":
|
|
1217
|
+
links_map[source_id].review = db_link['links']
|
|
1218
|
+
elif db_link["linkname"] == "pubmed_pmc":
|
|
1219
|
+
links_map[source_id].pmc = db_link['links']
|
|
1220
|
+
else:
|
|
1221
|
+
# other internal links will be stored in entrez dict
|
|
1222
|
+
links_map[source_id].entrez[db_link['linkname']] = db_link['links']
|
|
1223
|
+
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
print(f" Warning: Failed to parse fetched links for {link[1]} from {link[0]} for {source_id}: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1226
|
+
traceback.print_exc()
|
|
1227
|
+
continue
|
|
1228
|
+
|
|
1229
|
+
break # Success, exit retry loop
|
|
1230
|
+
|
|
1231
|
+
except Exception as e:
|
|
1232
|
+
if attempt < self.max_retries - 1:
|
|
1233
|
+
print(f" Warning: Failed to fetch {link[1]} from {link[0]} for {len(pmids)} PMIDs (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 1s...")
|
|
1234
|
+
time.sleep(1)
|
|
1235
|
+
else:
|
|
1236
|
+
print(f" Warning: Failed to fetch {link[1]} from {link[0]} for {len(pmids)} PMIDs after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1237
|
+
traceback.print_exc()
|
|
1238
|
+
pass
|
|
1239
|
+
|
|
1240
|
+
# --- Part B: External LinkOuts (llinks) ---
|
|
1241
|
+
print(f" -> Fetching external LinkOuts (Datasets, Full Text, etc.) for {len(pmids)} PMIDs at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1242
|
+
|
|
1243
|
+
for attempt in range(self.max_retries):
|
|
1244
|
+
try:
|
|
1245
|
+
handle = Entrez.elink(dbfrom="pubmed", id=pmids, cmd="llinks")
|
|
1246
|
+
llinks_results = Entrez.read(handle, validate=False)
|
|
1247
|
+
handle.close()
|
|
1248
|
+
|
|
1249
|
+
if llinks_results:
|
|
1250
|
+
for linkset in llinks_results:
|
|
1251
|
+
source_id = "Unknown"
|
|
1252
|
+
try:
|
|
1253
|
+
# safe access to IdUrlList
|
|
1254
|
+
id_url_list = linkset.get('IdUrlList', {})
|
|
1255
|
+
if not id_url_list:
|
|
1256
|
+
continue
|
|
1257
|
+
id_url_set = id_url_list.get('IdUrlSet', [])
|
|
1258
|
+
if not id_url_set:
|
|
1259
|
+
continue
|
|
1260
|
+
source_id = str(id_url_set[0].get('Id', 'Unknown')) # the source PMID
|
|
1261
|
+
if source_id not in links_map:
|
|
1262
|
+
continue
|
|
1263
|
+
|
|
1264
|
+
# safe access to ObjUrl
|
|
1265
|
+
urls_list = id_url_set[0].get('ObjUrl', [])
|
|
1266
|
+
if urls_list:
|
|
1267
|
+
# we need clean urls here
|
|
1268
|
+
clean_urls = []
|
|
1269
|
+
|
|
1270
|
+
for item in urls_list:
|
|
1271
|
+
# 1. Handle Category (it's a list, take first item)
|
|
1272
|
+
cat_list = item.get('Category', [])
|
|
1273
|
+
category = str(cat_list[0]) if isinstance(cat_list, list) and cat_list else ""
|
|
1274
|
+
|
|
1275
|
+
# 2. Handle Attribute (it's a list, take first item)
|
|
1276
|
+
attr_list = item.get('Attribute', [])
|
|
1277
|
+
attribute = str(attr_list[0]) if isinstance(attr_list, list) and attr_list else ""
|
|
1278
|
+
|
|
1279
|
+
# 3. Handle LinkName (optional string, defensive)
|
|
1280
|
+
linkname = str(item.get('LinkName', ''))
|
|
1281
|
+
|
|
1282
|
+
# 4. Construct clean url entry
|
|
1283
|
+
url = str(item.get('Url', ''))
|
|
1284
|
+
|
|
1285
|
+
# 5. handle Provider Name
|
|
1286
|
+
provider_name = str(item.get('Provider', {}).get('Name', "Unknown"))
|
|
1287
|
+
|
|
1288
|
+
clean_urls.append({
|
|
1289
|
+
"url": url,
|
|
1290
|
+
"provider": provider_name,
|
|
1291
|
+
"category": category,
|
|
1292
|
+
"attribute": attribute,
|
|
1293
|
+
"linkname": linkname
|
|
1294
|
+
})
|
|
1295
|
+
links_map[source_id].external = clean_urls
|
|
1296
|
+
|
|
1297
|
+
except Exception as e:
|
|
1298
|
+
print(f" Warning: Failed to parse external LinkOuts for {source_id}: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1299
|
+
continue
|
|
1300
|
+
break # Success, exit retry loop
|
|
1301
|
+
|
|
1302
|
+
except Exception as e:
|
|
1303
|
+
if attempt < self.max_retries - 1:
|
|
1304
|
+
print(f" Warning: Failed to fetch external LinkOuts for {len(pmids)} PMIDs (Attempt {attempt+1}/{self.max_retries}): [{e}]. Retrying in 1s...")
|
|
1305
|
+
time.sleep(1)
|
|
1306
|
+
else:
|
|
1307
|
+
print(f" Warning: Failed to fetch external LinkOuts for {len(pmids)} PMIDs after {self.max_retries} attempts: [{e}] at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1308
|
+
traceback.print_exc()
|
|
1309
|
+
|
|
1310
|
+
return links_map
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
# ⚠️ For batch saving, we deprecate it for now, please use single paper saving instead.
|
|
1314
|
+
# def save_batch_to_json(self, articles: List[Paper], start_index: int)
|
|
1315
|
+
|
|
1316
|
+
def save_single_paper_to_json(self, paper_meta: Paper_MetaData, output_dir: Optional[str] = None):
|
|
1317
|
+
"""
|
|
1318
|
+
Description
|
|
1319
|
+
-----------
|
|
1320
|
+
Save a single paper (metadata only) to JSON.
|
|
1321
|
+
|
|
1322
|
+
Args
|
|
1323
|
+
----
|
|
1324
|
+
paper_meta: Paper_MetaData
|
|
1325
|
+
One single Paper_MetaData object to save.
|
|
1326
|
+
output_dir: Optional[str]
|
|
1327
|
+
Directory to save the JSON file. Defaults to self.root_dir (the library).
|
|
1328
|
+
"""
|
|
1329
|
+
pmid = paper_meta.identity.pmid if paper_meta.identity.pmid else "unknown"
|
|
1330
|
+
filename = f"{pmid}_meta.json"
|
|
1331
|
+
|
|
1332
|
+
# Determine base directory: specific output_dir or default repo root_dir
|
|
1333
|
+
base_dir = output_dir if output_dir else self.root_dir
|
|
1334
|
+
|
|
1335
|
+
# Structure: base_dir / Year / PMID / pmid.json
|
|
1336
|
+
pub_year = paper_meta.source.pub_year if paper_meta.source.pub_year else "Unknown_Year"
|
|
1337
|
+
|
|
1338
|
+
# Create year directory
|
|
1339
|
+
year_path = os.path.join(base_dir, pub_year)
|
|
1340
|
+
|
|
1341
|
+
# Create PMID directory inside year directory
|
|
1342
|
+
pmid_path = os.path.join(year_path, pmid)
|
|
1343
|
+
|
|
1344
|
+
if not os.path.exists(pmid_path):
|
|
1345
|
+
os.makedirs(pmid_path, exist_ok=True)
|
|
1346
|
+
|
|
1347
|
+
final_filepath = os.path.join(pmid_path, filename)
|
|
1348
|
+
|
|
1349
|
+
with open(final_filepath, 'w') as f:
|
|
1350
|
+
json.dump(paper_meta.to_dict(), f, ensure_ascii=False, sort_keys=True, indent=4)
|
|
1351
|
+
|
|
1352
|
+
print(f" -> Saved {pmid} metadata to {final_filepath}")
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
#############################################################
|
|
1356
|
+
# 2.3, Fetch Full Text from PMC: paper_text_data only
|
|
1357
|
+
#############################################################
|
|
1358
|
+
|
|
1359
|
+
def fetch_pmc_full_text(self, pmid_list: Union[str, List[str]], output_dir: str = None, pmid_year_map: Dict[str, str] = None) -> List[Paper_TextData]:
|
|
1360
|
+
"""
|
|
1361
|
+
Description
|
|
1362
|
+
-----------
|
|
1363
|
+
Fetch full text from PMC for given PMIDs using detailed error handling and batch processing.
|
|
1364
|
+
|
|
1365
|
+
Reasons for Try-Except Blocks:
|
|
1366
|
+
1. ELink/EFetch: Network calls are unstable. We use retry loops with backoff.
|
|
1367
|
+
2. Parsing: XML structure is unpredictable. We wrap individual article parsing in try-except to ensures
|
|
1368
|
+
that one malformed article doesn't crash the entire batch.
|
|
1369
|
+
|
|
1370
|
+
Args
|
|
1371
|
+
----
|
|
1372
|
+
pmid_list: Union[str, List[str]]
|
|
1373
|
+
A single PMID (e.g., "12345678") or a list of PMIDs.
|
|
1374
|
+
pmids will be mapped to PMC IDs (e.g., "PMC8328303" or "8328303")
|
|
1375
|
+
output_dir: str
|
|
1376
|
+
Directory to save the full text XML and parsed content (markdown and JSON). Defaults to self.root_dir.
|
|
1377
|
+
pmid_year_map: Dict[str, str]
|
|
1378
|
+
A dictionary mapping PMID to its publication year.
|
|
1379
|
+
If provided, this year will be used for folder organization instead of the one parsed from XML.
|
|
1380
|
+
This ensures consistency with metadata saving.
|
|
1381
|
+
|
|
1382
|
+
Returns
|
|
1383
|
+
-------
|
|
1384
|
+
List[Paper_TextData]
|
|
1385
|
+
A list of Paper_TextData objects containing the full text content for each PMID, including raw XML content, parsed json content, and parsed text content.
|
|
1386
|
+
"""
|
|
1387
|
+
|
|
1388
|
+
# 1. first check
|
|
1389
|
+
count = len([pmid_list] if isinstance(pmid_list, str) else pmid_list)
|
|
1390
|
+
if count == 0:
|
|
1391
|
+
print("No PMIDs provided for PMC full text fetching.")
|
|
1392
|
+
return []
|
|
1393
|
+
|
|
1394
|
+
if isinstance(pmid_list, str):
|
|
1395
|
+
pmid_list = [pmid_list]
|
|
1396
|
+
|
|
1397
|
+
all_paper_text_data = []
|
|
1398
|
+
|
|
1399
|
+
print(f"Fetching full text for {count} Pubmed articles at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1400
|
+
|
|
1401
|
+
# 2. Batch Processing Loop (Outer Loop)
|
|
1402
|
+
# We process PMIDs in batches to respect API limits and manage memory.
|
|
1403
|
+
for start in range(0, count, self.batch_size):
|
|
1404
|
+
end = min(count, start + self.batch_size)
|
|
1405
|
+
batch_pmids = pmid_list[start:end]
|
|
1406
|
+
print(f" -> Converting Pubmed articles {start+1} to {end} (PMID : {batch_pmids}) to PMC IDs at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1407
|
+
|
|
1408
|
+
# PMID -> PMCID Mapping (with Retry)
|
|
1409
|
+
pmid_to_pmcid = {}
|
|
1410
|
+
valid_pmcids = []
|
|
1411
|
+
|
|
1412
|
+
# --- Retry Block for ELink ---
|
|
1413
|
+
for attempt in range(self.max_retries):
|
|
1414
|
+
try:
|
|
1415
|
+
# Map PMIDs to PMC IDs using ELink
|
|
1416
|
+
handles = Entrez.elink(dbfrom="pubmed", id=batch_pmids, db="pmc", linkname="pubmed_pmc")
|
|
1417
|
+
pmc_links = Entrez.read(handles)
|
|
1418
|
+
handles.close()
|
|
1419
|
+
|
|
1420
|
+
for linkset in pmc_links:
|
|
1421
|
+
# Defensive coding: Check if IdList exists (input ID)
|
|
1422
|
+
if not linkset.get('IdList'):
|
|
1423
|
+
continue
|
|
1424
|
+
source_id = str(linkset['IdList'][0])
|
|
1425
|
+
|
|
1426
|
+
link_set_db = linkset.get('LinkSetDb', [])
|
|
1427
|
+
if link_set_db:
|
|
1428
|
+
pmc_links_list = link_set_db[0].get('Link', [])
|
|
1429
|
+
if pmc_links_list:
|
|
1430
|
+
pmc_id = str(pmc_links_list[0]['Id'])
|
|
1431
|
+
valid_pmcids.append(pmc_id)
|
|
1432
|
+
pmid_to_pmcid[pmc_id] = source_id # Map PMC ID back to PMID for later identification
|
|
1433
|
+
|
|
1434
|
+
break # Success: Break the retry loop immediately
|
|
1435
|
+
|
|
1436
|
+
except Exception as e:
|
|
1437
|
+
if attempt < self.max_retries - 1:
|
|
1438
|
+
print(f" [Warning] Map PMIDs to PMC IDs failed (Attempt {attempt+1}/{self.max_retries}): {e}, Retrying in 1s...")
|
|
1439
|
+
time.sleep(1) # Backoff strategies: wait before retry
|
|
1440
|
+
else:
|
|
1441
|
+
print(f" [Error] Map PMIDs to PMC IDs failed after {self.max_retries} attempts: {e} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1442
|
+
traceback.print_exc()
|
|
1443
|
+
# skip to next batch if mapping fails completely
|
|
1444
|
+
continue
|
|
1445
|
+
|
|
1446
|
+
# Reason for Continue: If mapping fails entirely, we cannot fetch anything for this batch.
|
|
1447
|
+
# We skip to the next batch of PMIDs.
|
|
1448
|
+
if not valid_pmcids:
|
|
1449
|
+
print(f" -> No valid PMC IDs found for current batch of PMIDs: {batch_pmids}. Skipping full text fetching for this batch at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1450
|
+
continue
|
|
1451
|
+
|
|
1452
|
+
# Fectch Full Text for valid PMC IDs only
|
|
1453
|
+
print(f" -> Mapped {len(valid_pmcids)} out of {len(batch_pmids)} PMIDs to valid PMC IDs. Downloading full text XML for these PMC IDs at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1454
|
+
|
|
1455
|
+
for attempt in range(self.max_retries):
|
|
1456
|
+
try:
|
|
1457
|
+
# db="pmc", retmode="xml" gets the full structured text
|
|
1458
|
+
handles = Entrez.efetch(db="pmc", id=valid_pmcids, retmode="xml")
|
|
1459
|
+
pmc_full_xml = handles.read() # Read raw string
|
|
1460
|
+
handles.close()
|
|
1461
|
+
|
|
1462
|
+
break # Success: Break the retry loop immediately
|
|
1463
|
+
|
|
1464
|
+
except Exception as e:
|
|
1465
|
+
if attempt < self.max_retries - 1:
|
|
1466
|
+
print(f" [Warning] EFetch full text XML failed (Attempt {attempt+1}/{self.max_retries}): {e}, Retrying in 1s...")
|
|
1467
|
+
time.sleep(1) # Backoff strategies: wait before retry
|
|
1468
|
+
else:
|
|
1469
|
+
print(f" [Error] EFetch full text XML failed after {self.max_retries} attempts: {e} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1470
|
+
traceback.print_exc()
|
|
1471
|
+
# skip to next batch if fetching fails completely
|
|
1472
|
+
continue
|
|
1473
|
+
|
|
1474
|
+
# Parse Big XML using BeautifulSoup
|
|
1475
|
+
# This part handles the "Big XML" containing multiple articles.
|
|
1476
|
+
try:
|
|
1477
|
+
soup = BeautifulSoup(pmc_full_xml, 'xml')
|
|
1478
|
+
articles = soup.find_all('article')
|
|
1479
|
+
|
|
1480
|
+
# Iterate over each article found in the batch XML
|
|
1481
|
+
for article in articles:
|
|
1482
|
+
# Reason for Try-Except inside loop:
|
|
1483
|
+
# Isolation. If one article has malformed XML or unexpected structure,
|
|
1484
|
+
# we log the error and CONTINUE to the next article, rather than crashing the whole batch.
|
|
1485
|
+
try:
|
|
1486
|
+
# 1. Identify the paper (Double Check)
|
|
1487
|
+
# We need to know which PMID this XML belongs to.
|
|
1488
|
+
current_pmid = None
|
|
1489
|
+
|
|
1490
|
+
# Strategy 1: Look for explicit PMID in metadata
|
|
1491
|
+
pmid_node = article.find("article-id", {"pub-id-type": "pmid"})
|
|
1492
|
+
if pmid_node:
|
|
1493
|
+
current_pmid = pmid_node.text.strip()
|
|
1494
|
+
|
|
1495
|
+
# Strategy 2: Look for PMC ID and map back using our pmid_to_pmcid map
|
|
1496
|
+
if not current_pmid:
|
|
1497
|
+
pmc_node = article.find("article-id", {"pub-id-type": "pmcid"})
|
|
1498
|
+
if pmc_node:
|
|
1499
|
+
found_pmc = pmc_node.text.strip()
|
|
1500
|
+
# The map keys are usually raw IDs, check compatibility
|
|
1501
|
+
if found_pmc in pmid_to_pmcid:
|
|
1502
|
+
current_pmid = pmid_to_pmcid[found_pmc]
|
|
1503
|
+
|
|
1504
|
+
if not current_pmid:
|
|
1505
|
+
# If we can't identify the paper, we can't safely store it.
|
|
1506
|
+
continue
|
|
1507
|
+
|
|
1508
|
+
# 2. Extract Full Text (Hierarchical JSON structure)
|
|
1509
|
+
# Utilizing the BeautifulSoup logic
|
|
1510
|
+
# Json file is hierarchical representation of the article structure, suitable for structured analysis
|
|
1511
|
+
# For example, we can extract the same section between different papers easily and compare them
|
|
1512
|
+
parsed_json = self._parse_soup_to_json(article)
|
|
1513
|
+
|
|
1514
|
+
# 3. Flatten for text view (for NLP or reading)
|
|
1515
|
+
# Plain text is a flattened version suitable for reading or NLP tasks, we use markdown-like formatting
|
|
1516
|
+
# Just markdown file
|
|
1517
|
+
# Markdown file is suitable for human reading and simple text-based NLP tasks, and easy to be used in AI models
|
|
1518
|
+
parsed_text = self._flatten_json_to_text(parsed_json)
|
|
1519
|
+
|
|
1520
|
+
# 4. Create Data Object
|
|
1521
|
+
current_pmc = ""
|
|
1522
|
+
pmc_node = article.find("article-id", {"pub-id-type": "pmcid"})
|
|
1523
|
+
if pmc_node:
|
|
1524
|
+
current_pmc = pmc_node.text.strip()
|
|
1525
|
+
|
|
1526
|
+
# 5. Extract Publication Year from <pub-date>
|
|
1527
|
+
# Priority:
|
|
1528
|
+
# 1. Use passed in pmid_year_map (ensure consistency with metadata)
|
|
1529
|
+
# 2. Parse from XML
|
|
1530
|
+
current_pub_year = "Unknown_Year"
|
|
1531
|
+
|
|
1532
|
+
if pmid_year_map and current_pmid in pmid_year_map:
|
|
1533
|
+
current_pub_year = pmid_year_map[current_pmid]
|
|
1534
|
+
else:
|
|
1535
|
+
pub_date = article.find("pub-date").find("year")
|
|
1536
|
+
if pub_date:
|
|
1537
|
+
current_pub_year = pub_date.text.strip()
|
|
1538
|
+
|
|
1539
|
+
paper_text_data = Paper_TextData(
|
|
1540
|
+
pmid=current_pmid,
|
|
1541
|
+
pmcid=current_pmc,
|
|
1542
|
+
xml=str(article), # Store just this article's XML
|
|
1543
|
+
parsed_json=parsed_json,
|
|
1544
|
+
parsed_text=parsed_text,
|
|
1545
|
+
pub_year=current_pub_year # ADDED from XML
|
|
1546
|
+
)
|
|
1547
|
+
|
|
1548
|
+
# save this paper's text data to output directory
|
|
1549
|
+
self.save_single_paper_text(paper_text_data, output_dir=output_dir)
|
|
1550
|
+
|
|
1551
|
+
all_paper_text_data.append(paper_text_data)
|
|
1552
|
+
|
|
1553
|
+
except Exception as inner_e:
|
|
1554
|
+
print(f" [Error] Failed to parse one article in batch: {inner_e} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1555
|
+
traceback.print_exc()
|
|
1556
|
+
continue
|
|
1557
|
+
|
|
1558
|
+
except Exception as e:
|
|
1559
|
+
# This catches errors if the entire XML batch is invalid (unlikely but possible)
|
|
1560
|
+
print(f" [Error] Failed to parse XML batch with BeautifulSoup: {e} at [{time.strftime('%Y-%m-%d %H:%M:%S')}] ...")
|
|
1561
|
+
traceback.print_exc()
|
|
1562
|
+
continue
|
|
1563
|
+
|
|
1564
|
+
return all_paper_text_data
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _parse_soup_to_json(self, article_soup: Any) -> Dict[str, Any]:
|
|
1568
|
+
"""
|
|
1569
|
+
Parses a single <article> soup object into hierarchical JSON.
|
|
1570
|
+
Robust strategy:
|
|
1571
|
+
1. Parse Title.
|
|
1572
|
+
2. Parse Abstract (Handle structured <sec> or flat text).
|
|
1573
|
+
3. Parse Body (Recursive <sec>).
|
|
1574
|
+
4. Parse Back (Recursive <sec> for Acknowledgements, etc.).
|
|
1575
|
+
"""
|
|
1576
|
+
paper_structure = {
|
|
1577
|
+
"title": "N/A",
|
|
1578
|
+
"body": []
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
# 1. Title
|
|
1582
|
+
title_node = article_soup.find('article-title')
|
|
1583
|
+
if title_node:
|
|
1584
|
+
paper_structure["title"] = title_node.get_text().strip()
|
|
1585
|
+
|
|
1586
|
+
# 2. Abstract
|
|
1587
|
+
abstract_node = article_soup.find('abstract')
|
|
1588
|
+
if abstract_node:
|
|
1589
|
+
# Check for structured sections in abstract
|
|
1590
|
+
# Note: recursive=False is safer to avoid finding sections inside other things erroneously
|
|
1591
|
+
abs_sections = abstract_node.find_all("sec", recursive=False)
|
|
1592
|
+
if abs_sections:
|
|
1593
|
+
for sec in abs_sections:
|
|
1594
|
+
parsed_sec = self._parse_section_recursive(sec)
|
|
1595
|
+
if parsed_sec["title"] == "N/A":
|
|
1596
|
+
parsed_sec["title"] = "Abstract Section"
|
|
1597
|
+
paper_structure["body"].append(parsed_sec)
|
|
1598
|
+
else:
|
|
1599
|
+
# If no top-level sections, it might be flat paragraphs or unstructured
|
|
1600
|
+
# Sometimes structured abstracts use <title> directly without <sec> (rare but possible)
|
|
1601
|
+
|
|
1602
|
+
# Check for title
|
|
1603
|
+
abs_title_node = abstract_node.find('title')
|
|
1604
|
+
abs_title = abs_title_node.get_text().strip() if abs_title_node else "Abstract"
|
|
1605
|
+
|
|
1606
|
+
# Collect paragraphs
|
|
1607
|
+
ps = abstract_node.find_all('p')
|
|
1608
|
+
if ps:
|
|
1609
|
+
content = [p.get_text().strip() for p in ps]
|
|
1610
|
+
paper_structure["body"].append({
|
|
1611
|
+
"title": abs_title,
|
|
1612
|
+
"content": content,
|
|
1613
|
+
"subsections": []
|
|
1614
|
+
})
|
|
1615
|
+
else:
|
|
1616
|
+
# Fallback to full text if no p tags
|
|
1617
|
+
text = abstract_node.get_text(separator=' ', strip=True)
|
|
1618
|
+
if text:
|
|
1619
|
+
paper_structure["body"].append({
|
|
1620
|
+
"title": abs_title,
|
|
1621
|
+
"content": [text],
|
|
1622
|
+
"subsections": []
|
|
1623
|
+
})
|
|
1624
|
+
|
|
1625
|
+
# 3. Body
|
|
1626
|
+
body = article_soup.find('body')
|
|
1627
|
+
if body:
|
|
1628
|
+
# Standard JATS: <body > <sec> ... </sec> </body>
|
|
1629
|
+
body_sections = body.find_all("sec", recursive=False)
|
|
1630
|
+
if body_sections:
|
|
1631
|
+
for sec in body_sections:
|
|
1632
|
+
# Prevent parsing sub-sections as top-level
|
|
1633
|
+
if sec.parent and sec.parent.name == 'sec':
|
|
1634
|
+
continue
|
|
1635
|
+
paper_structure["body"].append(self._parse_section_recursive(sec))
|
|
1636
|
+
else:
|
|
1637
|
+
# No sections? Look for direct paragraphs (e.g. Letters)
|
|
1638
|
+
direct_paragraphs = body.find_all("p", recursive=False)
|
|
1639
|
+
if direct_paragraphs:
|
|
1640
|
+
content = [p.get_text().strip() for p in direct_paragraphs]
|
|
1641
|
+
paper_structure["body"].append({
|
|
1642
|
+
"title": "Main Text",
|
|
1643
|
+
"content": content,
|
|
1644
|
+
"subsections": []
|
|
1645
|
+
})
|
|
1646
|
+
|
|
1647
|
+
# 4. Back Matter (Acknowledgements, etc.)
|
|
1648
|
+
back = article_soup.find('back')
|
|
1649
|
+
if back:
|
|
1650
|
+
# <ack> <sec> ...
|
|
1651
|
+
# Process standard sections in back
|
|
1652
|
+
back_sections = back.find_all("sec", recursive=False)
|
|
1653
|
+
for sec in back_sections:
|
|
1654
|
+
parsed = self._parse_section_recursive(sec)
|
|
1655
|
+
paper_structure["body"].append(parsed)
|
|
1656
|
+
|
|
1657
|
+
# Specifically check for <ack>
|
|
1658
|
+
ack = back.find('ack')
|
|
1659
|
+
if ack:
|
|
1660
|
+
# ack usually contains title and p
|
|
1661
|
+
ack_title_node = ack.find('title')
|
|
1662
|
+
ack_title = ack_title_node.get_text().strip() if ack_title_node else "Acknowledgements"
|
|
1663
|
+
ack_ps = ack.find_all('p')
|
|
1664
|
+
if ack_ps:
|
|
1665
|
+
content = [p.get_text().strip() for p in ack_ps]
|
|
1666
|
+
paper_structure["body"].append({
|
|
1667
|
+
"title": ack_title,
|
|
1668
|
+
"content": content,
|
|
1669
|
+
"subsections": []
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
return paper_structure
|
|
1673
|
+
|
|
1674
|
+
def _parse_section_recursive(self, sec_element: Any) -> Dict[str, Any]:
|
|
1675
|
+
"""
|
|
1676
|
+
Description
|
|
1677
|
+
-----------
|
|
1678
|
+
Recursive helper for section parsing.
|
|
1679
|
+
Traverses <sec> -> <title>/<p>/<sec> structure.
|
|
1680
|
+
Every section is represented as a dictionary with title, content, and its subsections.
|
|
1681
|
+
|
|
1682
|
+
Args
|
|
1683
|
+
----
|
|
1684
|
+
sec_element: Any
|
|
1685
|
+
A BeautifulSoup element representing a <sec> node.
|
|
1686
|
+
|
|
1687
|
+
Returns
|
|
1688
|
+
-------
|
|
1689
|
+
Dict[str, Any]
|
|
1690
|
+
A dictionary representing the section with title, content, and subsections.
|
|
1691
|
+
"""
|
|
1692
|
+
section_data = {
|
|
1693
|
+
"title": "N/A",
|
|
1694
|
+
"content": [],
|
|
1695
|
+
"subsections": []
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
# Title
|
|
1699
|
+
title_node = sec_element.find("title", recursive=False)
|
|
1700
|
+
if title_node:
|
|
1701
|
+
section_data["title"] = title_node.get_text().strip()
|
|
1702
|
+
|
|
1703
|
+
# Content (Paragraphs)
|
|
1704
|
+
# Only direct <p> children, not nested in subsections, so we use recursive=False here
|
|
1705
|
+
direct_paragraphs = sec_element.find_all("p", recursive=False)
|
|
1706
|
+
section_data["content"] = [p.get_text().strip() for p in direct_paragraphs]
|
|
1707
|
+
|
|
1708
|
+
# Subsections (Recursive)
|
|
1709
|
+
sub_sections = sec_element.find_all("sec", recursive=False)
|
|
1710
|
+
for sub in sub_sections:
|
|
1711
|
+
child_data = self._parse_section_recursive(sub)
|
|
1712
|
+
section_data["subsections"].append(child_data)
|
|
1713
|
+
|
|
1714
|
+
return section_data
|
|
1715
|
+
|
|
1716
|
+
def _flatten_json_to_text(self, json_data: Dict[str, Any]) -> str:
|
|
1717
|
+
"""
|
|
1718
|
+
Converts the hierarchical JSON into a flat text string for simple viewing (Markdown-like).
|
|
1719
|
+
"""
|
|
1720
|
+
lines = []
|
|
1721
|
+
|
|
1722
|
+
# Article Title (H1)
|
|
1723
|
+
# We explicitly mark it as Title
|
|
1724
|
+
article_title = json_data.get('title', 'N/A')
|
|
1725
|
+
lines.append(f"# {article_title}")
|
|
1726
|
+
lines.append("") # Blank line after title
|
|
1727
|
+
|
|
1728
|
+
def recurse(sections, level):
|
|
1729
|
+
for sec in sections:
|
|
1730
|
+
# Section Title (Markdown Header)
|
|
1731
|
+
header_prefix = "#" * level
|
|
1732
|
+
title = sec.get('title', 'No Title')
|
|
1733
|
+
|
|
1734
|
+
# Add a blank line before section title for better structure
|
|
1735
|
+
lines.append("")
|
|
1736
|
+
lines.append(f"{header_prefix} {title}")
|
|
1737
|
+
|
|
1738
|
+
# Content (Paragraphs)
|
|
1739
|
+
if sec['content']:
|
|
1740
|
+
for para in sec['content']:
|
|
1741
|
+
# Clean up newlines: replace newlines with spaces and strip extra spaces
|
|
1742
|
+
# This fixes issues where citations like [1] cause line breaks
|
|
1743
|
+
clean_para = str(para).replace('\n', ' ').strip()
|
|
1744
|
+
|
|
1745
|
+
# Indent paragraphs slightly (2 spaces) to visually distinguish from headers
|
|
1746
|
+
lines.append(f" {clean_para}")
|
|
1747
|
+
lines.append("") # Add blank line after paragraph for readability
|
|
1748
|
+
|
|
1749
|
+
# Subsections (Recursive)
|
|
1750
|
+
recurse(sec['subsections'], level + 1)
|
|
1751
|
+
|
|
1752
|
+
# Body sections start from Level 2 (##), assuming Paper Title is Level 1 (#)
|
|
1753
|
+
recurse(json_data.get('body', []), level=2)
|
|
1754
|
+
|
|
1755
|
+
return "\n".join(lines)
|
|
1756
|
+
|
|
1757
|
+
def save_single_paper_text(self, paper_text: Paper_TextData, output_dir: Optional[str] = None):
|
|
1758
|
+
"""
|
|
1759
|
+
Description
|
|
1760
|
+
-----------
|
|
1761
|
+
Save a single paper's text data (full text) to structured files (XML, Markdown, JSON).
|
|
1762
|
+
|
|
1763
|
+
Args
|
|
1764
|
+
----
|
|
1765
|
+
paper_text: Paper_TextData
|
|
1766
|
+
One single Paper_TextData object to save. It contains raw XML, parsed JSON, and parsed Markdown.
|
|
1767
|
+
output_dir: Optional[str]
|
|
1768
|
+
Directory to save the text files. Defaults to self.root_dir (the library).
|
|
1769
|
+
|
|
1770
|
+
"""
|
|
1771
|
+
|
|
1772
|
+
# Determine base directory: specific output_dir or default repo root_dir
|
|
1773
|
+
base_dir = output_dir if output_dir else self.root_dir
|
|
1774
|
+
|
|
1775
|
+
# structure: base_dir / Year / PMID / files
|
|
1776
|
+
pmid = paper_text.pmid if paper_text.pmid else "unknown"
|
|
1777
|
+
pub_year = paper_text.pub_year if paper_text.pub_year else "Unknown_Year"
|
|
1778
|
+
|
|
1779
|
+
# create year directory
|
|
1780
|
+
year_path = os.path.join(base_dir, pub_year)
|
|
1781
|
+
# create PMID directory inside year directory
|
|
1782
|
+
pmid_path = os.path.join(year_path, pmid)
|
|
1783
|
+
|
|
1784
|
+
if not os.path.exists(pmid_path):
|
|
1785
|
+
os.makedirs(pmid_path, exist_ok=True)
|
|
1786
|
+
|
|
1787
|
+
# 1. Save Raw XML
|
|
1788
|
+
if paper_text.xml:
|
|
1789
|
+
xml_filename = f"{pmid}_content.xml"
|
|
1790
|
+
xml_filepath = os.path.join(pmid_path, xml_filename)
|
|
1791
|
+
with open(xml_filepath, 'w') as f:
|
|
1792
|
+
f.write(paper_text.xml)
|
|
1793
|
+
print(f" -> Saved XML to {xml_filepath}")
|
|
1794
|
+
|
|
1795
|
+
# 2. Save Parsed JSON
|
|
1796
|
+
if paper_text.parsed_json:
|
|
1797
|
+
json_filename = f"{pmid}_content.json"
|
|
1798
|
+
json_filepath = os.path.join(pmid_path, json_filename)
|
|
1799
|
+
with open(json_filepath, 'w') as f:
|
|
1800
|
+
json.dump(paper_text.parsed_json, f, ensure_ascii=False, indent=2)
|
|
1801
|
+
print(f" -> Saved parsed JSON to {json_filepath}")
|
|
1802
|
+
|
|
1803
|
+
# 3. Save Parsed Text (Markdown)
|
|
1804
|
+
if paper_text.parsed_text:
|
|
1805
|
+
md_filename = f"{pmid}_content.md"
|
|
1806
|
+
md_filepath = os.path.join(pmid_path, md_filename)
|
|
1807
|
+
with open(md_filepath, 'w') as f:
|
|
1808
|
+
f.write(paper_text.parsed_text)
|
|
1809
|
+
print(f" -> Saved parsed text to {md_filepath}")
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
#############################################################
|
|
1813
|
+
# 2.4, Fetch articles and parse metadata + full text together
|
|
1814
|
+
#############################################################
|
|
1815
|
+
|
|
1816
|
+
def fetch_and_save_full_papers(self, query: str = None, pmid_list: List[str] = None, output_dir: str = None) -> List[Paper]:
|
|
1817
|
+
"""
|
|
1818
|
+
Description
|
|
1819
|
+
-----------
|
|
1820
|
+
Integrated fetching of full papers (metadata + full text) based on either a search query or a list of PMIDs.
|
|
1821
|
+
we first fetch metadata, then fetch full text from PMC, and finally save both metadata (as JSON) and text (TXT, MARKDOWN, JSON) to structured directories.
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
Args
|
|
1825
|
+
----
|
|
1826
|
+
query: str
|
|
1827
|
+
A PubMed search query string. If provided, PMIDs will be fetched based on this query.
|
|
1828
|
+
pmid_list: List[str]
|
|
1829
|
+
A list of PubMed IDs to fetch. If provided, these PMIDs will be used directly.
|
|
1830
|
+
output_dir: str
|
|
1831
|
+
Directory to save the fetched papers. If not provided, defaults to self.root_dir.
|
|
1832
|
+
|
|
1833
|
+
Returns
|
|
1834
|
+
-------
|
|
1835
|
+
List[Paper]: A list of Paper objects containing both metadata and text data.
|
|
1836
|
+
|
|
1837
|
+
Notes
|
|
1838
|
+
-----
|
|
1839
|
+
- 1, we store the results in following structure:
|
|
1840
|
+
output_dir / Year / PMID / pmid_meta.json (metadata)
|
|
1841
|
+
/ pmid_content.xml (raw xml)
|
|
1842
|
+
/ pmid_content.md (markdown text)
|
|
1843
|
+
/ pmid_content.json (parsed json)
|
|
1844
|
+
|
|
1845
|
+
"""
|
|
1846
|
+
if not query and not pmid_list:
|
|
1847
|
+
raise ValueError("Must provide either query or pmid_list")
|
|
1848
|
+
|
|
1849
|
+
# 1. Fetch Metadata
|
|
1850
|
+
print("=== Step 1: Fetching Metadata ===")
|
|
1851
|
+
meta_data_list: List[Paper_MetaData] = []
|
|
1852
|
+
if query:
|
|
1853
|
+
query_record = self.query_search(query)
|
|
1854
|
+
meta_data_list = self.fetch_from_query(query_record, output_dir)
|
|
1855
|
+
else:
|
|
1856
|
+
meta_data_list = self.fetch_from_pmid_list(pmid_list, output_dir)
|
|
1857
|
+
|
|
1858
|
+
if not meta_data_list:
|
|
1859
|
+
print("No papers found.")
|
|
1860
|
+
return []
|
|
1861
|
+
|
|
1862
|
+
# Collect PMIDs for text fetching
|
|
1863
|
+
found_pmids = [p.identity.pmid for p in meta_data_list if p.identity.pmid]
|
|
1864
|
+
|
|
1865
|
+
# Prepare Year Map for consistency (PMID -> Year)
|
|
1866
|
+
pmid_year_map = {
|
|
1867
|
+
p.identity.pmid: (p.source.pub_year if p.source.pub_year else "Unknown_Year")
|
|
1868
|
+
for p in meta_data_list
|
|
1869
|
+
if p.identity.pmid
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
# 2. Fetch Full Text (returns List[Paper_TextData])
|
|
1873
|
+
# This function fetches, parses (using robust soup parser), and saves text data to Year/PMID/ code.
|
|
1874
|
+
print("\n=== Step 2: Fetching Full Text ===")
|
|
1875
|
+
# Pass pmid_year_map to ensure text files are saved in the same year folder as metadata
|
|
1876
|
+
text_data_list = self.fetch_pmc_full_text(found_pmids, output_dir=output_dir, pmid_year_map=pmid_year_map)
|
|
1877
|
+
|
|
1878
|
+
# Convert list to map for easy lookup
|
|
1879
|
+
text_map = {td.pmid: td for td in text_data_list if td.pmid}
|
|
1880
|
+
|
|
1881
|
+
# 3. Process and Compile
|
|
1882
|
+
print("\n=== Step 3: Processing and Saving Metadata ===")
|
|
1883
|
+
complete_papers = []
|
|
1884
|
+
|
|
1885
|
+
for meta in meta_data_list:
|
|
1886
|
+
pmid = meta.identity.pmid
|
|
1887
|
+
pm_text = text_map.get(pmid, Paper_TextData())
|
|
1888
|
+
|
|
1889
|
+
# Combine
|
|
1890
|
+
paper = Paper(Meta=meta, Text=pm_text)
|
|
1891
|
+
complete_papers.append(paper)
|
|
1892
|
+
|
|
1893
|
+
# 3.1 Extract URLs from full text and update metadata
|
|
1894
|
+
if pm_text.parsed_text:
|
|
1895
|
+
extracted_urls = extract_urls_from_text(pm_text.parsed_text, "full_text")
|
|
1896
|
+
if extracted_urls:
|
|
1897
|
+
print(f" -> Extracted {len(extracted_urls)} URLs from full text for PMID {pmid}")
|
|
1898
|
+
# Update metadata links
|
|
1899
|
+
if not meta.links:
|
|
1900
|
+
meta.links = PaperLinks()
|
|
1901
|
+
|
|
1902
|
+
# Merge extracting URLs, avoiding duplicates if possible (though straightforward extend is okay here)
|
|
1903
|
+
meta.links.text_mined.extend(extracted_urls)
|
|
1904
|
+
|
|
1905
|
+
# 3.2 Save Metadata (JSON)
|
|
1906
|
+
# Text data (xml, parsed_json, parsed_md) is already saved by fetch_pmc_full_text
|
|
1907
|
+
# We save metadata NOW (save twice) because it might have been updated with mined links
|
|
1908
|
+
self.save_single_paper_to_json(meta, output_dir)
|
|
1909
|
+
|
|
1910
|
+
return complete_papers
|