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,958 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Paper Merger Module for pyPaperFlow
|
|
3
|
+
|
|
4
|
+
This module provides functionality to merge PubMed paper metadata and content
|
|
5
|
+
into unified formats optimized for AI analysis.
|
|
6
|
+
|
|
7
|
+
⚠️ For Pubmed papers ONLY
|
|
8
|
+
|
|
9
|
+
This module provides a minimal, well-documented implementation that
|
|
10
|
+
performs exactly two tasks required by the user:
|
|
11
|
+
|
|
12
|
+
- Merge: scan a PubMed-style folder (pubmed/<year>/<pmid>/...) or a list
|
|
13
|
+
of PMIDs and produce per-paper `{PMID}.json` sidecars plus a single
|
|
14
|
+
merged JSON (or JSONL) file named `<source>_<timestamp>.json`.
|
|
15
|
+
|
|
16
|
+
- Export: read the merged JSON/JSONL and write a single Markdown file.
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import json
|
|
22
|
+
import csv
|
|
23
|
+
import re
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import *
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
# typical paper sections that we want to export
|
|
30
|
+
SECTION_CANONICAL_ORDER = [
|
|
31
|
+
# "title"
|
|
32
|
+
# "year"
|
|
33
|
+
# "authors"
|
|
34
|
+
'abstract',
|
|
35
|
+
'introduction',
|
|
36
|
+
'results',
|
|
37
|
+
'discussion',
|
|
38
|
+
'methods',
|
|
39
|
+
'conclusion',
|
|
40
|
+
'supplementary',
|
|
41
|
+
'availability',
|
|
42
|
+
'funding',
|
|
43
|
+
'acknowledgements',
|
|
44
|
+
'author_contributions',
|
|
45
|
+
'other',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# lower case aliases for section titles.
|
|
49
|
+
# Each canonical section uses two tiers:
|
|
50
|
+
# - strong: exact phrases that should map immediately
|
|
51
|
+
# - weak: regex patterns, most are "in" matches
|
|
52
|
+
# The two tiers are OR'ed together during normalization.
|
|
53
|
+
SECTION_TITLE_ALIASES = {
|
|
54
|
+
'abstract': {
|
|
55
|
+
'strong': {'abstract'},
|
|
56
|
+
'weak': {r'abstract', r'summary'},
|
|
57
|
+
},
|
|
58
|
+
'introduction': {
|
|
59
|
+
'strong': {'introduction', 'intro', 'background'},
|
|
60
|
+
'weak': {r'introduction', r'intro', r'background'},
|
|
61
|
+
},
|
|
62
|
+
'results': {
|
|
63
|
+
'strong': {'results', 'result', 'findings', 'finding'},
|
|
64
|
+
'weak': {r'result', r'finding'},
|
|
65
|
+
},
|
|
66
|
+
'discussion': {
|
|
67
|
+
'strong': {'discussion', 'discussions'},
|
|
68
|
+
'weak': {r'discussion'},
|
|
69
|
+
},
|
|
70
|
+
'methods': {
|
|
71
|
+
'strong': {
|
|
72
|
+
'methods',
|
|
73
|
+
'method',
|
|
74
|
+
'materials and methods',
|
|
75
|
+
'material and methods',
|
|
76
|
+
'materials & methods',
|
|
77
|
+
'methodology',
|
|
78
|
+
},
|
|
79
|
+
'weak': {
|
|
80
|
+
r'method',
|
|
81
|
+
r'material',
|
|
82
|
+
r'methodology',
|
|
83
|
+
r'materials?\s*(?:and|&)\s*methods?',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
'conclusion': {
|
|
87
|
+
'strong': {'conclusion', 'conclusions', 'concluding remarks', 'summary'},
|
|
88
|
+
'weak': {r'conclusion', r'remark', r'summary'},
|
|
89
|
+
},
|
|
90
|
+
'supplementary': {
|
|
91
|
+
'strong': {
|
|
92
|
+
'supplementary material',
|
|
93
|
+
'supplementary information',
|
|
94
|
+
'supplementary data',
|
|
95
|
+
'supporting information',
|
|
96
|
+
'supplementary',
|
|
97
|
+
},
|
|
98
|
+
'weak': {r'supplementary', r'supporting', r'supplement'},
|
|
99
|
+
},
|
|
100
|
+
'availability': {
|
|
101
|
+
'strong': {
|
|
102
|
+
'data availability',
|
|
103
|
+
'software and data availability',
|
|
104
|
+
'data and code availability',
|
|
105
|
+
'data availability statement',
|
|
106
|
+
'data and code availability.',
|
|
107
|
+
'availability and implementation',
|
|
108
|
+
'availability',
|
|
109
|
+
},
|
|
110
|
+
'weak': {
|
|
111
|
+
r'data',
|
|
112
|
+
r'software',
|
|
113
|
+
r'code',
|
|
114
|
+
r'availability'
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
'funding': {
|
|
118
|
+
'strong': {'funding'},
|
|
119
|
+
'weak': {r'funding', r'financial'},
|
|
120
|
+
},
|
|
121
|
+
'acknowledgements': {
|
|
122
|
+
'strong': {'acknowledgements', 'acknowledgments', 'acknowledgment'},
|
|
123
|
+
'weak': {r'acknowledgement', r'thank', r'expression'},
|
|
124
|
+
},
|
|
125
|
+
'author_contributions': {
|
|
126
|
+
'strong': {
|
|
127
|
+
'author contributions',
|
|
128
|
+
'authors contributions',
|
|
129
|
+
'author contribution',
|
|
130
|
+
'authors contribution',
|
|
131
|
+
},
|
|
132
|
+
'weak': {
|
|
133
|
+
r'author',
|
|
134
|
+
r'contribution',
|
|
135
|
+
r'statement',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# display names for section titles (for better readability in the Markdown output)
|
|
141
|
+
# only shown in the Markdown output(final output)
|
|
142
|
+
SECTION_DISPLAY_NAMES = {
|
|
143
|
+
'abstract': 'Abstract',
|
|
144
|
+
'introduction': 'Introduction',
|
|
145
|
+
'results': 'Results',
|
|
146
|
+
'discussion': 'Discussion',
|
|
147
|
+
'methods': 'Methods',
|
|
148
|
+
'conclusion': 'Conclusion',
|
|
149
|
+
'supplementary': 'Supplementary Material',
|
|
150
|
+
'availability': 'Data Availability',
|
|
151
|
+
'funding': 'Funding',
|
|
152
|
+
'acknowledgements': 'Acknowledgements',
|
|
153
|
+
'author_contributions': 'Author Contributions',
|
|
154
|
+
'other': 'Other',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def _timestamp() -> str:
|
|
158
|
+
return datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PubmedMerger:
|
|
162
|
+
"""
|
|
163
|
+
Description
|
|
164
|
+
-----------
|
|
165
|
+
Minimal merger with two public methods: merge_json and export_md.
|
|
166
|
+
JSON/JSONL is for structured data storage and potential downstream use,
|
|
167
|
+
while Markdown is for human-readable output and LLM input(the most important part).
|
|
168
|
+
|
|
169
|
+
Usage:
|
|
170
|
+
merger = PubmedMerger()
|
|
171
|
+
merger.merge_json_from_directory(paper_dir, output_path, pmid_file=None)
|
|
172
|
+
merger.export_md_from_merged_json(merged_json, output_md, yaml_cfg=None)
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def merge_json_from_directory(
|
|
176
|
+
self,
|
|
177
|
+
paper_dir: str,
|
|
178
|
+
output_json: str,
|
|
179
|
+
pmid_file: Optional[str] = None,
|
|
180
|
+
jsonl: bool = False,
|
|
181
|
+
) -> Dict[str, Any]:
|
|
182
|
+
"""
|
|
183
|
+
Description
|
|
184
|
+
-----------
|
|
185
|
+
Merge per-paper JSONs.
|
|
186
|
+
|
|
187
|
+
- If `pmid_file` is provided, only PMIDs in that file are merged.
|
|
188
|
+
But the premise is that the pmid_file's PMIDs must be present in the directory structure.
|
|
189
|
+
If `pmid_file` is not provided, all discovered PMIDs in `paper_dir` are merged.
|
|
190
|
+
- For each discovered paper we write `{PMID}.json` next to the
|
|
191
|
+
paper's files (sidecar).
|
|
192
|
+
- A single merged JSON (array) or JSONL is written to `output_json`.
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
Args
|
|
196
|
+
----
|
|
197
|
+
paper_dir: Directory containing per-paper subdirectories (e.g. pubmed/2023/12345678/, withput pubmed/ containing)
|
|
198
|
+
output_json: Path to the output merged JSON/JSONL file, or a directory to auto-name the output, default is current directory with auto-naming
|
|
199
|
+
pmid_file: Optional path to a file containing a list of PMIDs to merge
|
|
200
|
+
jsonl: If True, write output as JSONL instead of JSON
|
|
201
|
+
|
|
202
|
+
Returns
|
|
203
|
+
-------
|
|
204
|
+
A small stats dict.
|
|
205
|
+
|
|
206
|
+
Example return value:
|
|
207
|
+
{
|
|
208
|
+
'total': 0, of PMIDs found in directory (after optional filtering by pmid_file)
|
|
209
|
+
'meta_missing': ['12345678'], of papers that failed to merge in theory (missing meta JSON), we do not count the files failing at merge part
|
|
210
|
+
'content_missing': ['12345678'], of papers with missing content JSON
|
|
211
|
+
}
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
pmids: Optional[List[str]] = None
|
|
215
|
+
if pmid_file:
|
|
216
|
+
# read PMIDs from file, if provided (filter), list like ['12345678', '23456789', ...]
|
|
217
|
+
pmids = self._read_pmids_from_file(pmid_file)
|
|
218
|
+
|
|
219
|
+
# discover PMIDs and their directories, list like [('12345678', '/path/to/paper_dir/pubmed/2023/12345678'), ...]
|
|
220
|
+
pairs = self._iter_pmid_directories(paper_dir)
|
|
221
|
+
|
|
222
|
+
# define stats
|
|
223
|
+
# list for pmid records
|
|
224
|
+
meta_missing = []
|
|
225
|
+
content_missing = []
|
|
226
|
+
# number of papers to process in total
|
|
227
|
+
total_papers = len(pmids) if pmids else len(pairs)
|
|
228
|
+
|
|
229
|
+
# build selected list filtered by pmid_file (if provided), and load paper data
|
|
230
|
+
papers: List[Dict[str, Any]] = []
|
|
231
|
+
for pmid, pmid_dir in pairs:
|
|
232
|
+
if pmids is not None and pmid not in pmids:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# load paper data from meta JSON and content JSON (if exists)
|
|
236
|
+
# paper is like {
|
|
237
|
+
# 'pmid': '12345678',
|
|
238
|
+
# 'meta': {...}, # loaded from 12345678_meta.json
|
|
239
|
+
# 'content': {...} # loaded from 12345678_content.json
|
|
240
|
+
# }
|
|
241
|
+
paper = self._load_paper_data(pmid_dir, pmid)
|
|
242
|
+
# meta and content are both empty, skip (no useful info to merge)
|
|
243
|
+
if not paper['meta']:
|
|
244
|
+
meta_missing.append(pmid)
|
|
245
|
+
continue
|
|
246
|
+
elif not paper['content']:
|
|
247
|
+
content_missing.append(pmid)
|
|
248
|
+
|
|
249
|
+
# write sidecar {PMID}.json
|
|
250
|
+
try:
|
|
251
|
+
with open(os.path.join(pmid_dir, f"{pmid}.json"), 'w') as fh:
|
|
252
|
+
json.dump(paper, fh, ensure_ascii=False, indent=2)
|
|
253
|
+
except Exception:
|
|
254
|
+
# best-effort: do not fail merge for a single write error
|
|
255
|
+
pass
|
|
256
|
+
papers.append(paper)
|
|
257
|
+
|
|
258
|
+
# resolve output path (if directory or no ext -> auto name)
|
|
259
|
+
# output_json can be a file path or a directory
|
|
260
|
+
out = output_json
|
|
261
|
+
# directory existed or non-existent path without extension (treat as directory)
|
|
262
|
+
# /data2/pyPaperFlow/test/full_paper_test or /data2/pyPaperFlow/test/Not-Exist-Folder/
|
|
263
|
+
if os.path.isdir(output_json) or not os.path.splitext(output_json)[1]:
|
|
264
|
+
# paper_dir is not pubmed/ containing
|
|
265
|
+
source_name = os.path.basename(os.path.normpath(paper_dir))
|
|
266
|
+
suffix = '.jsonl' if jsonl else '.json'
|
|
267
|
+
out = os.path.join(output_json, f"{source_name}_{_timestamp()}{suffix}")
|
|
268
|
+
|
|
269
|
+
# write merged output
|
|
270
|
+
# note that papers are list of dict
|
|
271
|
+
try:
|
|
272
|
+
# ensure output directory exists, default is current directory
|
|
273
|
+
os.makedirs(os.path.dirname(out) or '.', exist_ok=True)
|
|
274
|
+
if jsonl:
|
|
275
|
+
# list of dict -> JSONL with one JSON object per line, one paper per line
|
|
276
|
+
with open(out, 'w') as fh:
|
|
277
|
+
for p in papers:
|
|
278
|
+
fh.write(json.dumps(p, ensure_ascii=False) + '\n')
|
|
279
|
+
else:
|
|
280
|
+
# json
|
|
281
|
+
# here we create pmid: i in papers
|
|
282
|
+
with open(out, 'w') as fh:
|
|
283
|
+
papers_json_dict = {p.get('pmid'): p for p in papers}
|
|
284
|
+
json.dump(papers_json_dict, fh, ensure_ascii=False, indent=2)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
raise RuntimeError(f"Failed to write merged JSON: {e}")
|
|
287
|
+
|
|
288
|
+
# write the stats
|
|
289
|
+
# actually we care more about the content missing than meta missing, cause the later is less likely to happen
|
|
290
|
+
# ⚠️ and we can use the pmids missing content to fetch the article again by DOI module
|
|
291
|
+
stats = {
|
|
292
|
+
'total': total_papers,
|
|
293
|
+
'meta_missing': meta_missing,
|
|
294
|
+
'content_missing': content_missing
|
|
295
|
+
}
|
|
296
|
+
return stats
|
|
297
|
+
|
|
298
|
+
def export_md_from_merged_json(
|
|
299
|
+
self,
|
|
300
|
+
merged_json: str,
|
|
301
|
+
output_md: str,
|
|
302
|
+
yaml_cfg: Optional[str] = None,
|
|
303
|
+
pmid_file: Optional[str] = None,
|
|
304
|
+
) -> Dict[str, Any]:
|
|
305
|
+
"""
|
|
306
|
+
Description
|
|
307
|
+
-----------
|
|
308
|
+
Export a single Markdown file from merged JSON/JSONL.
|
|
309
|
+
|
|
310
|
+
Args
|
|
311
|
+
----
|
|
312
|
+
merged_json: Path to the merged JSON or JSONL file produced by merge_json_from_directory
|
|
313
|
+
output_md: Path to the output Markdown file
|
|
314
|
+
yaml_cfg: Optional path to a YAML config file specifying which metadata fields and content sections to include in the Markdown output. If not provided, defaults to including 'identity.title' and 'identity.pmid' for metadata, and 'abstract' for content.
|
|
315
|
+
|
|
316
|
+
`yaml_cfg` may specify:
|
|
317
|
+
metadata_fields: ["identity.title", "identity.pmid"]
|
|
318
|
+
content_sections: ["abstract", "introduction"]
|
|
319
|
+
|
|
320
|
+
pmid_file: Optional path to a file containing a list of PMIDs to filter the papers by. If not provided, defaults to including all papers. Note that the PMIDs in this file must be present in the merged JSON for them to be included in the output.
|
|
321
|
+
|
|
322
|
+
Notes
|
|
323
|
+
-----
|
|
324
|
+
- ⚠️ 1. export_md_from_merged_json must be called after merge_json_from_directory, we design it like this to ensure the merged JSON/JSONL file is up-to-date.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
# papers now is list of dict loaded from merged_json,
|
|
328
|
+
# each dict is like {'pmid': '12345678', 'meta': {...}, 'content': {...} }
|
|
329
|
+
papers = self._load_merged(merged_json)
|
|
330
|
+
|
|
331
|
+
if pmid_file:
|
|
332
|
+
filter_pmids = set(self._read_pmids_from_file(pmid_file))
|
|
333
|
+
# filter papers by pmid_file
|
|
334
|
+
papers = [p for p in papers if str(p.get('pmid', '')) in filter_pmids]
|
|
335
|
+
|
|
336
|
+
cfg = {}
|
|
337
|
+
if yaml_cfg:
|
|
338
|
+
with open(yaml_cfg, 'r') as yf:
|
|
339
|
+
cfg = yaml.safe_load(yf) or {}
|
|
340
|
+
|
|
341
|
+
use_yaml_layout = bool(yaml_cfg)
|
|
342
|
+
# call config yaml first, if not exist, use default values for metadata only
|
|
343
|
+
if use_yaml_layout:
|
|
344
|
+
metadata_fields = cfg.get('metadata_fields', ['identity.title', 'identity.pmid', 'identity.doi', 'content.keywords', 'content.mesh_terms', 'content.pub_types', 'content.abstract'])
|
|
345
|
+
content_sections = cfg.get('content_sections', ['abstract', 'introduction', 'methods', 'discussion', 'conclusion', 'availability'])
|
|
346
|
+
else:
|
|
347
|
+
# No YAML means raw meta + raw content tree. Keep metadata lean to avoid
|
|
348
|
+
# duplicating abstract/content sections that will be rendered below.
|
|
349
|
+
metadata_fields = cfg.get('metadata_fields', ['identity.title', 'identity.pmid', 'identity.doi', 'content.keywords', 'content.mesh_terms', 'content.pub_types'])
|
|
350
|
+
content_sections = []
|
|
351
|
+
|
|
352
|
+
# write markdown
|
|
353
|
+
os.makedirs(os.path.dirname(output_md) or '.', exist_ok=True)
|
|
354
|
+
section_summary = Counter()
|
|
355
|
+
raw_title_summary = Counter()
|
|
356
|
+
|
|
357
|
+
def slugify(text: str) -> str:
|
|
358
|
+
"""
|
|
359
|
+
Description
|
|
360
|
+
-----------
|
|
361
|
+
create a slug for markdown anchor from the heading text,
|
|
362
|
+
like 'PMID 12345678 - Title of the Paper' -> 'pmid-12345678-title-of-the-paper'
|
|
363
|
+
"""
|
|
364
|
+
value = re.sub(r'[^a-z0-9\s-]', '', text.lower())
|
|
365
|
+
value = re.sub(r'\s+', '-', value.strip())
|
|
366
|
+
value = re.sub(r'-+', '-', value)
|
|
367
|
+
return value or 'paper'
|
|
368
|
+
|
|
369
|
+
def resolve_field(paper: Dict[str, Any], path: str) -> Any:
|
|
370
|
+
value = self._get_by_path(paper.get('meta', {}), path)
|
|
371
|
+
if value is not None:
|
|
372
|
+
return value
|
|
373
|
+
|
|
374
|
+
if path.startswith('content.'):
|
|
375
|
+
tail = path.split('.', 1)[1]
|
|
376
|
+
content = paper.get('content')
|
|
377
|
+
if isinstance(content, dict):
|
|
378
|
+
value = content.get(tail)
|
|
379
|
+
if value is not None:
|
|
380
|
+
return value
|
|
381
|
+
|
|
382
|
+
if tail == 'abstract':
|
|
383
|
+
body_nodes = content.get('body')
|
|
384
|
+
if isinstance(body_nodes, list):
|
|
385
|
+
for node in body_nodes:
|
|
386
|
+
if not isinstance(node, dict):
|
|
387
|
+
continue
|
|
388
|
+
if self._normalize_section_title(node.get('title')) != 'abstract':
|
|
389
|
+
continue
|
|
390
|
+
paragraphs = node.get('content') or []
|
|
391
|
+
if isinstance(paragraphs, str):
|
|
392
|
+
return paragraphs.strip() or None
|
|
393
|
+
if isinstance(paragraphs, list):
|
|
394
|
+
joined = '\n\n'.join(str(item).strip() for item in paragraphs if str(item).strip())
|
|
395
|
+
if joined:
|
|
396
|
+
return joined
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
index_entries: List[tuple[str, str, str]] = []
|
|
402
|
+
for p in papers:
|
|
403
|
+
pmid = str(self._get_by_path(p['meta'], 'identity.pmid') or p.get('pmid', 'N/A'))
|
|
404
|
+
title = str(self._get_by_path(p['meta'], 'identity.title') or 'N/A')
|
|
405
|
+
heading_text = f'PMID {pmid} - {title}' if title != 'N/A' else f'PMID {pmid}'
|
|
406
|
+
# anchor for markdown link
|
|
407
|
+
index_entries.append((pmid, title, slugify(heading_text)))
|
|
408
|
+
|
|
409
|
+
with open(output_md, 'w') as f:
|
|
410
|
+
for pmid, title, anchor in index_entries:
|
|
411
|
+
display = f'PMID {pmid} - {title}' if title != 'N/A' else f'PMID {pmid}'
|
|
412
|
+
f.write(f'- [{display}](#{anchor})\n')
|
|
413
|
+
|
|
414
|
+
if index_entries:
|
|
415
|
+
f.write('\n---\n\n')
|
|
416
|
+
|
|
417
|
+
for i, p in enumerate(papers, 1):
|
|
418
|
+
# i for paper index, p for paper dict
|
|
419
|
+
pmid = str(self._get_by_path(p['meta'], 'identity.pmid') or p.get('pmid', 'N/A'))
|
|
420
|
+
title = str(self._get_by_path(p['meta'], 'identity.title') or 'N/A')
|
|
421
|
+
heading_text = f'PMID {pmid} - {title}' if title != 'N/A' else f'PMID {pmid}'
|
|
422
|
+
anchor = slugify(heading_text)
|
|
423
|
+
content = p.get('content') if isinstance(p.get('content'), dict) else {}
|
|
424
|
+
body_nodes = content.get('body') if isinstance(content, dict) else []
|
|
425
|
+
metadata_fields_to_write = list(metadata_fields)
|
|
426
|
+
if not use_yaml_layout and not body_nodes:
|
|
427
|
+
metadata_fields_to_write.append('content.abstract')
|
|
428
|
+
|
|
429
|
+
f.write(f'<a id="{anchor}"></a>\n\n')
|
|
430
|
+
f.write(f'# PMID {pmid} - {title}\n\n')
|
|
431
|
+
|
|
432
|
+
# flat metadata block
|
|
433
|
+
for mf in metadata_fields_to_write:
|
|
434
|
+
label = mf.split('.')[-1].replace('_', ' ').title() # default label is the last part
|
|
435
|
+
val = resolve_field(p, mf)
|
|
436
|
+
if val is None:
|
|
437
|
+
continue
|
|
438
|
+
if isinstance(val, list):
|
|
439
|
+
val = ', '.join(str(x) for x in val)
|
|
440
|
+
f.write(f'## {label}\n\n{val}\n\n')
|
|
441
|
+
|
|
442
|
+
if use_yaml_layout:
|
|
443
|
+
# YAML provided: canonicalize only first-level body nodes.
|
|
444
|
+
section_records = self._extract_section_records(p)
|
|
445
|
+
for record in section_records:
|
|
446
|
+
section_summary[record['canonical_type']] += 1
|
|
447
|
+
raw_title_summary[record['raw_title'] or 'N/A'] += 1
|
|
448
|
+
for record in section_records:
|
|
449
|
+
# skip canonical abstract (handled via metadata/resolve_field)
|
|
450
|
+
if record.get('canonical_type') == 'abstract':
|
|
451
|
+
continue
|
|
452
|
+
# only render sections requested in the YAML config
|
|
453
|
+
if record.get('canonical_type') not in content_sections:
|
|
454
|
+
continue
|
|
455
|
+
self._render_section_records(f, [record], base_level=2)
|
|
456
|
+
else:
|
|
457
|
+
# No YAML provided: render raw meta + raw content tree with no canonical mapping.
|
|
458
|
+
section_records = self._extract_section_records(p)
|
|
459
|
+
for record in section_records:
|
|
460
|
+
section_summary[record['canonical_type']] += 1
|
|
461
|
+
raw_title_summary[record['raw_title'] or 'N/A'] += 1
|
|
462
|
+
self._render_raw_section_nodes(f, body_nodes, level=2)
|
|
463
|
+
|
|
464
|
+
if i < len(papers):
|
|
465
|
+
f.write('\n<!-- PAPER_BREAK -->\n\n---\n\n')
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
'total': len(papers),
|
|
469
|
+
'output': output_md,
|
|
470
|
+
'section_summary': dict(section_summary),
|
|
471
|
+
'raw_title_summary': dict(raw_title_summary),
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# ---- helpers ----
|
|
475
|
+
def _load_merged(self, path: str) -> List[Dict[str, Any]]:
|
|
476
|
+
"""
|
|
477
|
+
Description
|
|
478
|
+
-----------
|
|
479
|
+
Load merged JSON/JSONL file.
|
|
480
|
+
|
|
481
|
+
Args
|
|
482
|
+
----
|
|
483
|
+
path: Path to the merged JSON or JSONL file produced by merge_json_from_directory.
|
|
484
|
+
|
|
485
|
+
Returns
|
|
486
|
+
-------
|
|
487
|
+
A list of paper dicts loaded from the merged file. If the file does not exist or cannot be parsed, returns an empty list.
|
|
488
|
+
Like [ {'pmid': '12345678','meta': {...},'content': {...}},... ]
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
if not os.path.exists(path):
|
|
492
|
+
return []
|
|
493
|
+
try:
|
|
494
|
+
if path.endswith('.json'):
|
|
495
|
+
with open(path, 'r') as fh:
|
|
496
|
+
# data now is a dict of {pmid: paper}
|
|
497
|
+
data = json.load(fh)
|
|
498
|
+
if isinstance(data, dict):
|
|
499
|
+
return list(data.values())
|
|
500
|
+
elif path.endswith('.jsonl'):
|
|
501
|
+
out = []
|
|
502
|
+
with open(path, 'r') as fh:
|
|
503
|
+
for line in fh:
|
|
504
|
+
line = line.strip()
|
|
505
|
+
if not line:
|
|
506
|
+
continue
|
|
507
|
+
try:
|
|
508
|
+
out.append(json.loads(line))
|
|
509
|
+
except Exception:
|
|
510
|
+
continue
|
|
511
|
+
return out
|
|
512
|
+
else:
|
|
513
|
+
raise ValueError("Unsupported file format for merged_json. Only .json and .jsonl are supported.")
|
|
514
|
+
except Exception:
|
|
515
|
+
return []
|
|
516
|
+
|
|
517
|
+
def _get_by_path(self, data: Dict[str, Any], path: str) -> Any:
|
|
518
|
+
"""
|
|
519
|
+
Description
|
|
520
|
+
-----------
|
|
521
|
+
Safely get a nested value from a dict using a dot-separated path.
|
|
522
|
+
|
|
523
|
+
Args
|
|
524
|
+
----
|
|
525
|
+
data: The dict to search.
|
|
526
|
+
path: The dot-separated path to the value to get.
|
|
527
|
+
|
|
528
|
+
Returns
|
|
529
|
+
-------
|
|
530
|
+
The value at the specified path, or None if any part of the path is not found or if the path leads to a non-dict.
|
|
531
|
+
"""
|
|
532
|
+
parts = path.split('.')
|
|
533
|
+
cur = data
|
|
534
|
+
for p in parts:
|
|
535
|
+
if not isinstance(cur, dict):
|
|
536
|
+
return None
|
|
537
|
+
cur = cur.get(p)
|
|
538
|
+
if cur is None:
|
|
539
|
+
return None
|
|
540
|
+
return cur
|
|
541
|
+
|
|
542
|
+
def _normalize_section_title(self, title: Any) -> str:
|
|
543
|
+
"""
|
|
544
|
+
Description
|
|
545
|
+
-----------
|
|
546
|
+
Normalize section title to a canonical form.
|
|
547
|
+
|
|
548
|
+
Args
|
|
549
|
+
----
|
|
550
|
+
title: The raw section title to normalize. (key of the node in the tree: title)
|
|
551
|
+
|
|
552
|
+
Returns
|
|
553
|
+
-------
|
|
554
|
+
A canonical section type string, like 'abstract', 'introduction', 'methods', etc. If the title cannot be matched to any known section, returns 'other'.
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
canonical_type, _ = self._match_section_title_from_cursor(title, 0)
|
|
558
|
+
return canonical_type or 'other'
|
|
559
|
+
|
|
560
|
+
def _match_section_title_from_cursor(self, title: Any, start_index: int = 0) -> tuple[Optional[str], int]:
|
|
561
|
+
"""
|
|
562
|
+
Description
|
|
563
|
+
-----------
|
|
564
|
+
Match a title against canonical sections starting from a given cursor.
|
|
565
|
+
|
|
566
|
+
Returns
|
|
567
|
+
-------
|
|
568
|
+
A pair of (canonical_type, next_cursor). If no match is found, returns
|
|
569
|
+
(None, start_index) so the caller can keep the cursor in place.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
# normalize the title
|
|
573
|
+
text = re.sub(r'\s+', ' ', str(title or '').strip())
|
|
574
|
+
text = text.strip(' .:-—–\t\n\r')
|
|
575
|
+
lower = text.lower()
|
|
576
|
+
|
|
577
|
+
section_names = [name for name in SECTION_CANONICAL_ORDER if name != 'other']
|
|
578
|
+
if start_index < 0:
|
|
579
|
+
start_index = 0
|
|
580
|
+
|
|
581
|
+
# 1) exact alias table first: cheapest and safest.
|
|
582
|
+
for index in range(start_index, len(section_names)):
|
|
583
|
+
canonical = section_names[index]
|
|
584
|
+
aliases = SECTION_TITLE_ALIASES.get(canonical, {})
|
|
585
|
+
strong_aliases = aliases.get('strong', set())
|
|
586
|
+
weak_aliases = aliases.get('weak', set())
|
|
587
|
+
if lower == canonical or lower in strong_aliases:
|
|
588
|
+
return canonical, index + 1
|
|
589
|
+
|
|
590
|
+
for pattern in weak_aliases:
|
|
591
|
+
if re.search(pattern, lower, flags=re.IGNORECASE):
|
|
592
|
+
return canonical, index + 1
|
|
593
|
+
|
|
594
|
+
return None, start_index
|
|
595
|
+
|
|
596
|
+
def _display_section_title(self, canonical_type: str) -> str:
|
|
597
|
+
return SECTION_DISPLAY_NAMES.get(canonical_type, canonical_type.replace('_', ' ').title())
|
|
598
|
+
|
|
599
|
+
def _escape_md_table(self, text: Any) -> str:
|
|
600
|
+
value = str(text or '')
|
|
601
|
+
return value.replace('|', '\\|').replace('\n', ' ').strip()
|
|
602
|
+
|
|
603
|
+
def _candidate_body_nodes(self, paper: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
604
|
+
"""
|
|
605
|
+
Description
|
|
606
|
+
-----------
|
|
607
|
+
Find the body nodes in the paper dictionary.
|
|
608
|
+
|
|
609
|
+
Args
|
|
610
|
+
----
|
|
611
|
+
paper: The paper dictionary.
|
|
612
|
+
|
|
613
|
+
Returns
|
|
614
|
+
-------
|
|
615
|
+
A list of body nodes.
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
# papers now is list of dict loaded from merged_json,
|
|
619
|
+
# each dict is like {'pmid': '12345678', 'meta': {...}, 'content': {...} }
|
|
620
|
+
candidates = [
|
|
621
|
+
paper.get('content'),
|
|
622
|
+
paper.get('meta'),
|
|
623
|
+
(paper.get('meta') or {}).get('content') if isinstance(paper.get('meta'), dict) else None,
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
for candidate in candidates:
|
|
627
|
+
if isinstance(candidate, list) and candidate:
|
|
628
|
+
return candidate
|
|
629
|
+
if isinstance(candidate, dict):
|
|
630
|
+
for key in ('body', 'sections', 'content'):
|
|
631
|
+
nested = candidate.get(key)
|
|
632
|
+
if isinstance(nested, list) and nested:
|
|
633
|
+
return nested
|
|
634
|
+
|
|
635
|
+
return []
|
|
636
|
+
|
|
637
|
+
def _extract_section_records(
|
|
638
|
+
self,
|
|
639
|
+
paper: Dict[str, Any],
|
|
640
|
+
) -> List[Dict[str, Any]]:
|
|
641
|
+
body_nodes = self._candidate_body_nodes(paper)
|
|
642
|
+
|
|
643
|
+
def copy_raw_tree(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
644
|
+
copied: List[Dict[str, Any]] = []
|
|
645
|
+
for node in nodes or []:
|
|
646
|
+
if not isinstance(node, dict):
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
copied.append({
|
|
650
|
+
'title': node.get('title'),
|
|
651
|
+
'content': node.get('content'),
|
|
652
|
+
'subsections': copy_raw_tree(node.get('subsections') or []),
|
|
653
|
+
})
|
|
654
|
+
return copied
|
|
655
|
+
|
|
656
|
+
records: List[Dict[str, Any]] = []
|
|
657
|
+
cursor = 0
|
|
658
|
+
for node in body_nodes or []:
|
|
659
|
+
if not isinstance(node, dict):
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
raw_title = str(node.get('title') or 'N/A').strip()
|
|
663
|
+
canonical_type, next_cursor = self._match_section_title_from_cursor(raw_title, cursor)
|
|
664
|
+
if canonical_type is None:
|
|
665
|
+
canonical_type = 'other'
|
|
666
|
+
else:
|
|
667
|
+
cursor = next_cursor
|
|
668
|
+
|
|
669
|
+
paragraphs = node.get('content') or []
|
|
670
|
+
if isinstance(paragraphs, str):
|
|
671
|
+
paragraphs = [paragraphs]
|
|
672
|
+
paragraphs = [str(item).strip() for item in paragraphs if str(item).strip()]
|
|
673
|
+
|
|
674
|
+
records.append({
|
|
675
|
+
'raw_title': raw_title,
|
|
676
|
+
'canonical_type': canonical_type,
|
|
677
|
+
'display_title': self._display_section_title(canonical_type),
|
|
678
|
+
'path': raw_title,
|
|
679
|
+
'depth': 0,
|
|
680
|
+
'paragraphs': paragraphs,
|
|
681
|
+
'paragraph_count': len(paragraphs),
|
|
682
|
+
'children': copy_raw_tree(node.get('subsections') or []),
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
return records
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _flatten_section_records(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
689
|
+
flattened: List[Dict[str, Any]] = []
|
|
690
|
+
|
|
691
|
+
def recurse(items: List[Dict[str, Any]]) -> None:
|
|
692
|
+
for item in items or []:
|
|
693
|
+
flattened.append(item)
|
|
694
|
+
recurse(item.get('children', []))
|
|
695
|
+
|
|
696
|
+
recurse(records)
|
|
697
|
+
return flattened
|
|
698
|
+
|
|
699
|
+
def _render_raw_section_nodes(
|
|
700
|
+
self,
|
|
701
|
+
handle: Any,
|
|
702
|
+
sections: List[Dict[str, Any]],
|
|
703
|
+
level: int = 2,
|
|
704
|
+
) -> None:
|
|
705
|
+
for sec in sections or []:
|
|
706
|
+
if not isinstance(sec, dict):
|
|
707
|
+
continue
|
|
708
|
+
|
|
709
|
+
title = str(sec.get('title') or 'No Title').strip() or 'No Title'
|
|
710
|
+
heading_level = '#' * max(2, min(level, 6))
|
|
711
|
+
handle.write(f'\n{heading_level} {title}\n\n')
|
|
712
|
+
|
|
713
|
+
content = sec.get('content') or []
|
|
714
|
+
if isinstance(content, str):
|
|
715
|
+
content = [content]
|
|
716
|
+
for paragraph in content:
|
|
717
|
+
clean_para = str(paragraph).replace('\n', ' ').strip()
|
|
718
|
+
if clean_para:
|
|
719
|
+
handle.write(f' {clean_para}\n\n')
|
|
720
|
+
|
|
721
|
+
self._render_raw_section_nodes(handle, sec.get('subsections', []), level=level + 1)
|
|
722
|
+
|
|
723
|
+
def _order_section_records(
|
|
724
|
+
self,
|
|
725
|
+
records: List[Dict[str, Any]],
|
|
726
|
+
section_order: List[str],
|
|
727
|
+
) -> List[Dict[str, Any]]:
|
|
728
|
+
order_index = {name: idx for idx, name in enumerate(section_order or SECTION_CANONICAL_ORDER)}
|
|
729
|
+
|
|
730
|
+
def sort_key(item: Dict[str, Any]) -> tuple[int, int]:
|
|
731
|
+
return (order_index.get(item.get('canonical_type', 'other'), len(order_index)), item.get('depth', 0))
|
|
732
|
+
|
|
733
|
+
# Preserve intra-paper order as much as possible while keeping common sections first.
|
|
734
|
+
return sorted(records, key=sort_key)
|
|
735
|
+
|
|
736
|
+
def _aggregate_section_records(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
737
|
+
"""
|
|
738
|
+
Aggregate multiple section records that share the same canonical_type into
|
|
739
|
+
a single record. This preserves the first-seen ordering from `records`
|
|
740
|
+
and concatenates paragraphs and children for non-'other' canonical types.
|
|
741
|
+
'other' sections are kept separate to preserve their distinct titles.
|
|
742
|
+
"""
|
|
743
|
+
aggregated: List[Dict[str, Any]] = []
|
|
744
|
+
seen: Dict[str, Dict[str, Any]] = {}
|
|
745
|
+
|
|
746
|
+
for rec in records or []:
|
|
747
|
+
ctype = rec.get('canonical_type', 'other') or 'other'
|
|
748
|
+
if ctype != 'other' and ctype in seen:
|
|
749
|
+
target = seen[ctype]
|
|
750
|
+
target_pars = target.setdefault('paragraphs', [])
|
|
751
|
+
target_children = target.setdefault('children', [])
|
|
752
|
+
target_pars.extend(rec.get('paragraphs', []) or [])
|
|
753
|
+
target_children.extend(rec.get('children', []) or [])
|
|
754
|
+
target['paragraph_count'] = target.get('paragraph_count', 0) + rec.get('paragraph_count', 0)
|
|
755
|
+
else:
|
|
756
|
+
# shallow copy to avoid mutating the original record list
|
|
757
|
+
new_rec = {
|
|
758
|
+
'raw_title': rec.get('raw_title'),
|
|
759
|
+
'canonical_type': rec.get('canonical_type'),
|
|
760
|
+
'display_title': rec.get('display_title'),
|
|
761
|
+
'path': rec.get('path'),
|
|
762
|
+
'depth': rec.get('depth', 0),
|
|
763
|
+
'paragraphs': list(rec.get('paragraphs', []) or []),
|
|
764
|
+
'paragraph_count': rec.get('paragraph_count', 0),
|
|
765
|
+
'children': list(rec.get('children', []) or []),
|
|
766
|
+
}
|
|
767
|
+
aggregated.append(new_rec)
|
|
768
|
+
if ctype != 'other':
|
|
769
|
+
seen[ctype] = new_rec
|
|
770
|
+
|
|
771
|
+
return aggregated
|
|
772
|
+
|
|
773
|
+
def _render_section_records(
|
|
774
|
+
self,
|
|
775
|
+
handle: Any,
|
|
776
|
+
records: List[Dict[str, Any]],
|
|
777
|
+
base_level: int = 2,
|
|
778
|
+
prev_heading: Optional[str] = None
|
|
779
|
+
) -> None:
|
|
780
|
+
for record in records or []:
|
|
781
|
+
level = base_level + int(record.get('depth', 0))
|
|
782
|
+
heading_level = '#' * max(2, min(level, 6))
|
|
783
|
+
display_title = record.get('display_title') or self._display_section_title(record.get('canonical_type', 'other'))
|
|
784
|
+
raw_title = record.get('raw_title') or ''
|
|
785
|
+
if record.get('canonical_type') == 'other' and raw_title:
|
|
786
|
+
heading = raw_title
|
|
787
|
+
else:
|
|
788
|
+
heading = display_title
|
|
789
|
+
|
|
790
|
+
heading_line = f'{heading_level} {heading}'.strip()
|
|
791
|
+
# skip writing the heading if it's identical to the previous written heading
|
|
792
|
+
if heading_line != prev_heading:
|
|
793
|
+
handle.write(f'{heading_line}\n\n')
|
|
794
|
+
prev_heading = heading_line
|
|
795
|
+
|
|
796
|
+
for paragraph in record.get('paragraphs', []):
|
|
797
|
+
handle.write(f'{paragraph}\n\n')
|
|
798
|
+
if record.get('children'):
|
|
799
|
+
# children remain raw tree nodes and are appended under this top-level section.
|
|
800
|
+
self._render_raw_section_nodes(handle, record['children'], level=level + 1)
|
|
801
|
+
|
|
802
|
+
def _read_pmids_from_file(self, file_path: str) -> List[str]:
|
|
803
|
+
"""
|
|
804
|
+
Description
|
|
805
|
+
-----------
|
|
806
|
+
Read PMIDs from a file.
|
|
807
|
+
|
|
808
|
+
Args
|
|
809
|
+
----
|
|
810
|
+
file_path: Path to a text or csv file containing PMIDs.
|
|
811
|
+
|
|
812
|
+
Returns
|
|
813
|
+
-------
|
|
814
|
+
A list of PMIDs as strings. If the file does not exist, returns an empty list.
|
|
815
|
+
"""
|
|
816
|
+
|
|
817
|
+
pmids: List[str] = []
|
|
818
|
+
if not os.path.exists(file_path):
|
|
819
|
+
return pmids
|
|
820
|
+
# for csv file
|
|
821
|
+
if file_path.endswith('.csv'):
|
|
822
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
823
|
+
for row in csv.reader(f):
|
|
824
|
+
if row:
|
|
825
|
+
pmids.append(str(row[0]).strip())
|
|
826
|
+
return pmids
|
|
827
|
+
# for tsv or plain text file
|
|
828
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
829
|
+
for line in f:
|
|
830
|
+
v = line.strip()
|
|
831
|
+
if v:
|
|
832
|
+
pmids.append(v)
|
|
833
|
+
return pmids
|
|
834
|
+
|
|
835
|
+
def _iter_pmid_directories(self, paper_dir: str) -> List[tuple[str, str]]:
|
|
836
|
+
"""
|
|
837
|
+
Description
|
|
838
|
+
-----------
|
|
839
|
+
Yield (pmid, pmid_dir) pairs for two common layouts.
|
|
840
|
+
|
|
841
|
+
Args
|
|
842
|
+
----
|
|
843
|
+
paper_dir: Root directory to scan for PMIDs. Only accepts paper_dir(without pubmed)/pubmed/<year>/<pmid>/
|
|
844
|
+
|
|
845
|
+
Returns
|
|
846
|
+
-------
|
|
847
|
+
A list of (pmid, pmid_dir) tuples for discovered PMIDs, like ('12345678', '/path/to/paper_dir/pubmed/2023/12345678'). If no PMIDs are found, returns an empty list.
|
|
848
|
+
"""
|
|
849
|
+
|
|
850
|
+
pairs: List[tuple[str, str]] = []
|
|
851
|
+
if not os.path.isdir(paper_dir):
|
|
852
|
+
return pairs
|
|
853
|
+
|
|
854
|
+
# paper_dir(without pubmed)/pubmed/<year>/<pmid>/
|
|
855
|
+
# like /data2/pyPaperFlow/test/full_paper_test'
|
|
856
|
+
if 'pubmed' in os.listdir(paper_dir):
|
|
857
|
+
paper_dir = os.path.join(paper_dir, 'pubmed')
|
|
858
|
+
|
|
859
|
+
# year subfolders within pubmed/
|
|
860
|
+
entries = sorted(os.listdir(paper_dir))
|
|
861
|
+
# year subfolders
|
|
862
|
+
for e in entries:
|
|
863
|
+
year_dir = os.path.join(paper_dir, e)
|
|
864
|
+
if not os.path.isdir(year_dir):
|
|
865
|
+
continue
|
|
866
|
+
for pm in sorted(os.listdir(year_dir)):
|
|
867
|
+
pm_dir = os.path.join(year_dir, pm)
|
|
868
|
+
# pm is PMID
|
|
869
|
+
if os.path.isdir(pm_dir) and pm.isdigit():
|
|
870
|
+
pairs.append((pm, pm_dir))
|
|
871
|
+
return pairs
|
|
872
|
+
|
|
873
|
+
def _resolve_paper_files(self, pmid_dir: str, pmid: str) -> Dict[str, Optional[str]]:
|
|
874
|
+
"""
|
|
875
|
+
Description
|
|
876
|
+
-----------
|
|
877
|
+
Resolve file paths for a given PMID directory.
|
|
878
|
+
|
|
879
|
+
Args
|
|
880
|
+
----
|
|
881
|
+
pmid_dir: Directory containing the paper's files (e.g. pubmed/2023/12345678/)
|
|
882
|
+
pmid: The PMID of the paper (e.g. '12345678')
|
|
883
|
+
('12345678', '/path/to/paper_dir/pubmed/2023/12345678')
|
|
884
|
+
|
|
885
|
+
Returns
|
|
886
|
+
-------
|
|
887
|
+
A dict with keys 'meta_json', 'content_json', 'content_md' and values as the resolved file paths or None if not found.
|
|
888
|
+
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
# simple detection of common file names
|
|
892
|
+
candidates = {
|
|
893
|
+
'meta_json': f'{pmid}_meta.json',
|
|
894
|
+
'content_json': f'{pmid}_content.json',
|
|
895
|
+
'content_md': f'{pmid}_content.md',
|
|
896
|
+
}
|
|
897
|
+
resolved = {}
|
|
898
|
+
for k, name in candidates.items():
|
|
899
|
+
# in most cases, the meta JSON exits but content JSON/MD may be missing, so we do not fail if they are not found. We just set them to None in the resolved dict.
|
|
900
|
+
resolved[k] = None
|
|
901
|
+
# ('12345678', '/path/to/paper_dir/pubmed/2023/12345678') / '12345678_meta.json'
|
|
902
|
+
p = os.path.join(pmid_dir, name)
|
|
903
|
+
if os.path.exists(p):
|
|
904
|
+
# {'meta_json': '/path/to/paper_dir/pubmed/2023/12345678/12345678_meta.json', ...}
|
|
905
|
+
resolved[k] = p
|
|
906
|
+
return resolved
|
|
907
|
+
|
|
908
|
+
def _load_paper_data(self, pmid_dir: str, pmid: str) -> Optional[Dict[str, Any]]:
|
|
909
|
+
"""
|
|
910
|
+
Description
|
|
911
|
+
-----------
|
|
912
|
+
Load paper data from a PMID directory.
|
|
913
|
+
|
|
914
|
+
Args
|
|
915
|
+
----
|
|
916
|
+
pmid_dir: Directory containing the paper's files (e.g. pubmed/2023/12345678/)
|
|
917
|
+
pmid: The PMID of the paper (e.g. '12345678')
|
|
918
|
+
('12345678', '/path/to/paper_dir/pubmed/2023/12345678')
|
|
919
|
+
|
|
920
|
+
Returns
|
|
921
|
+
-------
|
|
922
|
+
A dict containing the paper's data, with keys 'meta' and 'content'.
|
|
923
|
+
'meta' is loaded from the meta JSON file if it exists, otherwise an empty dict.
|
|
924
|
+
'content' is loaded from the content JSON file if it exists, otherwise an empty dict.
|
|
925
|
+
"""
|
|
926
|
+
|
|
927
|
+
# paths is a dict like {'meta_json': '/path/to/paper_dir/pubmed/2023/12345678/12345678_meta.json', 'content_json': None, 'content_md': None}
|
|
928
|
+
paths = self._resolve_paper_files(pmid_dir, pmid)
|
|
929
|
+
|
|
930
|
+
# meta and meta_json
|
|
931
|
+
meta = paths.get('meta_json', None)
|
|
932
|
+
if meta:
|
|
933
|
+
try:
|
|
934
|
+
with open(meta, 'r') as fh:
|
|
935
|
+
meta_json = json.load(fh)
|
|
936
|
+
except Exception:
|
|
937
|
+
meta_json = {}
|
|
938
|
+
else:
|
|
939
|
+
meta_json = {}
|
|
940
|
+
|
|
941
|
+
# content and content_json
|
|
942
|
+
content = paths.get('content_json', None)
|
|
943
|
+
if content:
|
|
944
|
+
try:
|
|
945
|
+
with open(content, 'r') as fh:
|
|
946
|
+
content_json = json.load(fh)
|
|
947
|
+
except Exception:
|
|
948
|
+
content_json = {}
|
|
949
|
+
else:
|
|
950
|
+
content_json = {}
|
|
951
|
+
|
|
952
|
+
# Compose a small canonical dict
|
|
953
|
+
paper = {
|
|
954
|
+
'pmid': pmid,
|
|
955
|
+
'meta': meta_json,
|
|
956
|
+
'content': content_json
|
|
957
|
+
}
|
|
958
|
+
return paper
|