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.
@@ -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