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
pyPaperFlow/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
pyPaperFlow/cli.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import typer
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import *
|
|
7
|
+
from .pubmed.pubmed_fetcher import PubmedFetcher
|
|
8
|
+
from .preprint.arxiv_fetcher import ArxivFetcher
|
|
9
|
+
from .preprint.biorxiv_fetcher import BioRxivFetcher
|
|
10
|
+
from .pubmed.pubmed_merger import PubmedMerger
|
|
11
|
+
from .integrations import pdf_fetch
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="pyPaperFlow CLI", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
# Common options
|
|
17
|
+
opt_storage = typer.Option("./Papers", "--storage-dir", "-s", help="Directory in Repository-level to store paper data for Initialization.") # Note: this is a repository-level default path
|
|
18
|
+
opt_email = typer.Option(..., "--email", help="Entrez Email.")
|
|
19
|
+
opt_api_key = typer.Option(None, "--api-key", help="NCBI API Key (recommended).")
|
|
20
|
+
opt_max_retries = typer.Option(3, "--max-retries", help="Maximum number of retries for Entrez API calls.")
|
|
21
|
+
opt_batch_size = typer.Option(50, "--batch-size", "-b", help="Batch size for fetching.")
|
|
22
|
+
opt_arxiv_backend = typer.Option("native", "--backend", help="arXiv backend: 'native' or 'paperscraper'.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _save_id_list(output_dir: str, filename: str, values: List[str]) -> str:
|
|
26
|
+
if not os.path.exists(output_dir):
|
|
27
|
+
os.makedirs(output_dir)
|
|
28
|
+
output_file = os.path.join(output_dir, filename)
|
|
29
|
+
with open(output_file, "w", encoding="utf-8") as handle:
|
|
30
|
+
for value in values:
|
|
31
|
+
handle.write(f"{value}\n")
|
|
32
|
+
return output_file
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#############################################################
|
|
36
|
+
# 1, For Pubmed Parser
|
|
37
|
+
#############################################################
|
|
38
|
+
|
|
39
|
+
@app.command("pubmed-search")
|
|
40
|
+
def search_cmd(
|
|
41
|
+
query: str = typer.Argument(..., help="PubMed search query."), # Argument means that this parameter is different from option, it must be provided and does not need flag --query like option!
|
|
42
|
+
retmax: int = typer.Option(500, "--retmax", "-n", help="Max number of PMIDs to return every batch, must less than 10000."),
|
|
43
|
+
email: str = opt_email,
|
|
44
|
+
api_key: Optional[str] = opt_api_key,
|
|
45
|
+
storage_dir: str = opt_storage, # Repository-level default path, used for initialing fetcher
|
|
46
|
+
output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory in result-level to store output IDs."), # User-specified output path in result-level
|
|
47
|
+
max_retries: int = opt_max_retries
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Search PubMed using Your customized query and return PMIDs.
|
|
51
|
+
|
|
52
|
+
\b
|
|
53
|
+
Notes:
|
|
54
|
+
- 1, This command only searches and returns PMIDs, it does not fetch paper metadata.
|
|
55
|
+
- 2, This command will print the found PMIDs and also save them to 'pubmed_searched_ids.txt' in the specified output directory.
|
|
56
|
+
If --output-dir is not specified, it will default to the storage directory.
|
|
57
|
+
- 3, Note that storage_dir is used to initialize the fetcher for consistency, while output_dir is where the PMIDs are saved. They are different parameters!
|
|
58
|
+
|
|
59
|
+
\b
|
|
60
|
+
Example usage:
|
|
61
|
+
- 1. Search for papers related to "machine learning" and return up to 500 PMIDs/per batch:
|
|
62
|
+
paperflow pubmed-search "machine learning" --retmax 500 --output-dir ./MyPapers --email "YOUR_EMAIL@example.com" --api-key "YOUR_NCBI_API_KEY"
|
|
63
|
+
"""
|
|
64
|
+
# we initialize fetcher with storage_dir for consistency
|
|
65
|
+
fetcher = PubmedFetcher(root_dir=storage_dir, entrez_email=email, api_key=api_key or "", max_retries=max_retries)
|
|
66
|
+
|
|
67
|
+
# 1. Search
|
|
68
|
+
query_meta = fetcher.query_search(query)
|
|
69
|
+
|
|
70
|
+
# 2. Get PMIDs
|
|
71
|
+
pmids = fetcher.get_pubmedIDs_from_query(query_meta, retmax=retmax)
|
|
72
|
+
|
|
73
|
+
typer.echo(f"Found {len(pmids)} PMIDs.")
|
|
74
|
+
typer.echo(pmids)
|
|
75
|
+
|
|
76
|
+
# 3. Optionally, save PMIDs to a file
|
|
77
|
+
save_dir = output_dir if output_dir else storage_dir
|
|
78
|
+
if not os.path.exists(save_dir):
|
|
79
|
+
os.makedirs(save_dir)
|
|
80
|
+
|
|
81
|
+
# note: search log should be saved with timestamp
|
|
82
|
+
# pmid_file = os.path.join(save_dir, "pubmed_searched_ids.txt")
|
|
83
|
+
|
|
84
|
+
pmid_file = f"{save_dir}/pubmed_searched_ids_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.txt"
|
|
85
|
+
with open(pmid_file, 'w') as f:
|
|
86
|
+
for pmid in pmids:
|
|
87
|
+
f.write(f"{pmid}\n")
|
|
88
|
+
typer.echo(f"PMIDs saved to {pmid_file}.")
|
|
89
|
+
|
|
90
|
+
@app.command("pubmed-meta")
|
|
91
|
+
def fetch_cmd(
|
|
92
|
+
query: Optional[str] = typer.Option(None, "--query", "-q", help="PubMed search query."),
|
|
93
|
+
file: Optional[str] = typer.Option(None, "--file", "-f", help="Text file containing PMIDs (one per line), -q and -f are mutually exclusive."),
|
|
94
|
+
batch_size: int = opt_batch_size,
|
|
95
|
+
email: str = opt_email,
|
|
96
|
+
api_key: Optional[str] = opt_api_key,
|
|
97
|
+
storage_dir: str = opt_storage,
|
|
98
|
+
max_retries: int = opt_max_retries,
|
|
99
|
+
output_dir: Optional[str] = typer.Option(".", "--output-dir", "-o", help="Directory in result-level to store output papers, default is current directory. If not specified, will be set to root directory of the repository-level which is storage_dir. 🌟 We will create a '/pubmed' subfolder under the output directory to save all pubmed related data"),
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Fetch paper metadata from PubMed using Your customized query, pmid list file and save to storage.
|
|
103
|
+
|
|
104
|
+
\b
|
|
105
|
+
Notes:
|
|
106
|
+
- 1, You must provide one of --query, or --file to specify which papers to fetch. Note that they are mutually exclusive.
|
|
107
|
+
- 2, -f can be used to fetch one or more PMIDs listed in a text file (one PMID per line).
|
|
108
|
+
|
|
109
|
+
\b
|
|
110
|
+
Example usage:
|
|
111
|
+
- 1. Fetch papers for a query and save to storage:
|
|
112
|
+
paperflow pubmed-fetch --query "machine learning" --output-dir ./MyPapers --email "YOUR_EMAIL@example.com" --api-key "YOUR_NCBI_API_KEY"
|
|
113
|
+
- 2. Fetch papers from a list of PMIDs in a file:
|
|
114
|
+
paperflow pubmed-fetch --file ./pmid_list.txt --output-dir ./MyPapers --email "YOUR_EMAIL@example.com" --api-key "YOUR_NCBI_API_KEY"
|
|
115
|
+
"""
|
|
116
|
+
fetcher = PubmedFetcher(root_dir=storage_dir, entrez_email=email, api_key=api_key or "", batch_size=batch_size, max_retries=max_retries)
|
|
117
|
+
|
|
118
|
+
papers = []
|
|
119
|
+
|
|
120
|
+
# for meta data or full paper data from different sources, we will save them to corresponding paper source database folders
|
|
121
|
+
output_dir = f"{output_dir}/pubmed" if output_dir else f"{storage_dir}/pubmed"
|
|
122
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
if query:
|
|
125
|
+
typer.echo(f"Fetching papers for query: {query}")
|
|
126
|
+
query_meta = fetcher.query_search(query)
|
|
127
|
+
papers = fetcher.fetch_from_query(query_meta, output_dir=output_dir)
|
|
128
|
+
|
|
129
|
+
elif file:
|
|
130
|
+
if not os.path.exists(file):
|
|
131
|
+
typer.echo(f"Error: File {file} not found.")
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
with open(file, 'r') as f:
|
|
134
|
+
pmid_list = [line.strip() for line in f if line.strip()]
|
|
135
|
+
typer.echo(f"Fetching {len(pmid_list)} papers from file {os.path.abspath(file)}.")
|
|
136
|
+
papers = fetcher.fetch_from_pmid_list(pmid_list, output_dir=output_dir)
|
|
137
|
+
|
|
138
|
+
else:
|
|
139
|
+
typer.echo("Error: Must provide --query or --file.")
|
|
140
|
+
raise typer.Exit(code=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("pubmed-content")
|
|
144
|
+
def download_fulltext_cmd(
|
|
145
|
+
file: Optional[str] = typer.Option(None, "--file", "-f", help="File containing PMIDs (one per line)."),
|
|
146
|
+
email: str = opt_email,
|
|
147
|
+
api_key: Optional[str] = opt_api_key,
|
|
148
|
+
storage_dir: str = opt_storage,
|
|
149
|
+
max_retries: int = opt_max_retries,
|
|
150
|
+
output_dir: Optional[str] = typer.Option(".", "--output-dir", "-o", help="Directory in result-level to store output full texts, default is current directory. If not specified, will be set to root directory of the repository-level which is storage_dir. 🌟 We will create a '/pubmed' subfolder under the output directory to save all pubmed related data"),
|
|
151
|
+
pmid: Optional[List[str]] = typer.Option(None, "--pmid", "-p", help="Single PMID to download full text for, can be repeated."),
|
|
152
|
+
):
|
|
153
|
+
"""
|
|
154
|
+
Download full text (PMC) for given PMIDs if the paper has a PMC ID.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
\b
|
|
158
|
+
Notes:
|
|
159
|
+
- 1, This currently only supports PMC full text fetching if the paper has a PMC ID.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
\b
|
|
163
|
+
Example usage:
|
|
164
|
+
- 1. Download full text for PMIDs listed in a file:
|
|
165
|
+
paperflow download-fulltext --file ./pmid_list.txt --email "YOUR_EMAIL@example" --api-key "YOUR_NCBI_API_KEY" --output-dir ./MyPapers
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
fetcher = PubmedFetcher(root_dir=storage_dir, entrez_email=email, api_key=api_key or "", max_retries=max_retries)
|
|
169
|
+
|
|
170
|
+
# create pubmed subfolder in output directory to save full text data
|
|
171
|
+
output_dir = f"{output_dir}/pubmed" if output_dir else f"{storage_dir}/pubmed"
|
|
172
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
target_pmids = []
|
|
175
|
+
if file:
|
|
176
|
+
with open(file, 'r') as f:
|
|
177
|
+
target_pmids = [line.strip() for line in f if line.strip()]
|
|
178
|
+
elif pmid:
|
|
179
|
+
target_pmids = pmid
|
|
180
|
+
else:
|
|
181
|
+
typer.echo("Error: Must provide --file or --pmid.")
|
|
182
|
+
raise typer.Exit(code=1)
|
|
183
|
+
|
|
184
|
+
typer.echo(f"Downloading full texts for {len(target_pmids)} PMIDs from file {os.path.abspath(file) if file else 'provided PMIDs'}.")
|
|
185
|
+
fetcher.fetch_pmc_full_text(target_pmids, output_dir=output_dir)
|
|
186
|
+
|
|
187
|
+
@app.command("pubmed-all")
|
|
188
|
+
def fetch_full_cmd(
|
|
189
|
+
query: Optional[str] = typer.Option(None, "--query", "-q", help="PubMed search query."),
|
|
190
|
+
file: Optional[str] = typer.Option(None, "--file", "-f", help="Text file containing PMIDs (one per line), -q and -f are mutually exclusive."),
|
|
191
|
+
pmid: Optional[List[str]] = typer.Option(None, "--pmid", "-p", help="Single PMID to download full text for, can be repeated."),
|
|
192
|
+
batch_size: int = opt_batch_size,
|
|
193
|
+
max_retries: int = opt_max_retries,
|
|
194
|
+
email: str = opt_email,
|
|
195
|
+
api_key: Optional[str] = opt_api_key,
|
|
196
|
+
storage_dir: str = opt_storage,
|
|
197
|
+
output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory in result-level to store output papers. If not specified, defaults to storage-dir."),
|
|
198
|
+
):
|
|
199
|
+
"""
|
|
200
|
+
Fetch BOTH metadata and full text (if available) for papers.
|
|
201
|
+
Also extracts URLs from full text and updates metadata links.
|
|
202
|
+
|
|
203
|
+
\b
|
|
204
|
+
Example usage:
|
|
205
|
+
- 1. Fetch full papers for a query:
|
|
206
|
+
paperflow pubmed-all --query "machine learning" --output-dir ./MyPapers --email "YOUR_EMAIL"
|
|
207
|
+
"""
|
|
208
|
+
fetcher = PubmedFetcher(root_dir=storage_dir, entrez_email=email, api_key=api_key or "", batch_size=batch_size, max_retries=max_retries)
|
|
209
|
+
|
|
210
|
+
# create pubmed subfolder in output directory to save all pubmed related data (metadata + full text)
|
|
211
|
+
output_dir = f"{output_dir}/pubmed" if output_dir else f"{storage_dir}/pubmed"
|
|
212
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
pmid_list = []
|
|
215
|
+
if file:
|
|
216
|
+
if os.path.exists(file):
|
|
217
|
+
with open(file, 'r') as f:
|
|
218
|
+
pmid_list = [line.strip() for line in f if line.strip()]
|
|
219
|
+
if pmid:
|
|
220
|
+
pmid_list.extend(pmid)
|
|
221
|
+
|
|
222
|
+
if not query and not pmid_list:
|
|
223
|
+
typer.echo("Error: Must provide --query, --file, or --pmid.")
|
|
224
|
+
raise typer.Exit(code=1)
|
|
225
|
+
|
|
226
|
+
fetcher.fetch_and_save_full_papers(query=query, pmid_list=pmid_list if pmid_list else None, output_dir=output_dir)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command("pubmed-merge-json")
|
|
230
|
+
def merge_json_cmd(
|
|
231
|
+
paper_dir: str = typer.Option(..., "--input", "-i" , help="Directory containing paper data ({INPUT_PAPER_DIR_HERE}/pubmed/year/pmid/structure)."),
|
|
232
|
+
output: str = typer.Option(..., "--output", "-o" , help="Output directory or file path. If a directory or path without extension is given, the merged file is auto-named as <input-directory-base-name>_<datetime>.json/.jsonl."),
|
|
233
|
+
pmid_file: Optional[str] = typer.Option(None, "--pmid-file", "-p", help="File containing PMIDs to merge (one per line)."),
|
|
234
|
+
jsonl: bool = typer.Option(False, "--jsonl", help="Write output as JSONL, one JSON per line."),
|
|
235
|
+
stats_path: Optional[str] = typer.Option(".", "--stats-path", "-s", help="Optional path to save merge statistics file, defaults to current directory.")
|
|
236
|
+
):
|
|
237
|
+
"""
|
|
238
|
+
Create a merged JSON (or JSONL) file from PubMed paper directories.
|
|
239
|
+
|
|
240
|
+
This produces a canonical merged JSON representation per paper and is
|
|
241
|
+
intended as the first stage in a two-stage pipeline (merge-json -> export-md).
|
|
242
|
+
|
|
243
|
+
\b
|
|
244
|
+
Example usage:
|
|
245
|
+
- 1. Merge JSON files for all papers in a directory:
|
|
246
|
+
paperflow pubmed-merge-json --input ./MyPapers --output ./MyPapers
|
|
247
|
+
- 2. Merge JSON files for PMIDs listed in a file:
|
|
248
|
+
paperflow pubmed-merge-json --input ./MyPapers --output ./MyPapers --pmid-file ./pmid_list.txt --jsonl --stats-path ./MyPapers/stats
|
|
249
|
+
"""
|
|
250
|
+
merger = PubmedMerger()
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
stats = merger.merge_json_from_directory(paper_dir, output, pmid_file=pmid_file, jsonl=jsonl)
|
|
254
|
+
# Save stats if path provided
|
|
255
|
+
with open(f"{stats_path}/{os.path.basename(os.path.normpath(paper_dir))}_stats_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.json", "w") as f:
|
|
256
|
+
json.dump(stats, f, indent=2)
|
|
257
|
+
|
|
258
|
+
typer.echo(f"✅ Please check the merged pubmed JSON/JSONL file at {output} and the merge statistics file at {stats_path}. \
|
|
259
|
+
Also, a JSON file per paper is created within the PMID subfolders.")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
typer.echo(f"Error during merge-json: {e}")
|
|
262
|
+
raise typer.Exit(code=1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("pubmed-export-md")
|
|
266
|
+
def export_md_cmd(
|
|
267
|
+
merged_json: str = typer.Option(...,"--input", "-i", help="Path to merged JSON or JSONL produced by pubmed-merge-json."),
|
|
268
|
+
output_md: str = typer.Option(...,"--output", "-o", help="Output Markdown file path."),
|
|
269
|
+
yaml_cfg: Optional[str] = typer.Option(None, "--config", "-c", help="YAML config file specifying metadata_fields and content_sections. If not provided, defaults to basic metadata and FULL content."),
|
|
270
|
+
pmid_file: Optional[str] = typer.Option(None, "--pmid-file", "-p", help="Optional PMID file to filter exported papers."),
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
Export a single Markdown view from a merged JSON file using optional YAML config.
|
|
274
|
+
|
|
275
|
+
\b
|
|
276
|
+
Notes:
|
|
277
|
+
- 1, The input merged JSON/JSONL should be produced by the pubmed-merge-json command, which creates a canonical representation of paper metadata and content.
|
|
278
|
+
- 2, The optional YAML config can specify which metadata fields and content sections to include in the Markdown output. If not provided, it defaults to including basic metadata and the FULL content.
|
|
279
|
+
|
|
280
|
+
\b
|
|
281
|
+
Example usage:
|
|
282
|
+
- 1. Export Markdown for all papers in a merged JSON:
|
|
283
|
+
paperflow pubmed-export-md --input ./MyPapers/merged.jsonl --output ./MyPapers/exported.md --config ./config.yaml
|
|
284
|
+
- 2. Export Markdown for PMIDs listed in a file:
|
|
285
|
+
paperflow pubmed-export-md --input ./MyPapers/merged.jsonl --output ./MyPapers/exported.md --config ./config.yaml --pmid-file ./pmid_list.txt
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
"""
|
|
289
|
+
merger = PubmedMerger()
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
stats = merger.export_md_from_merged_json(merged_json, output_md, yaml_cfg=yaml_cfg, pmid_file=pmid_file)
|
|
293
|
+
typer.secho(f"Successfully exported {stats.get('total', 0)} papers to {stats.get('output')}", fg=typer.colors.GREEN)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
typer.echo(f"Error during export-md: {e}")
|
|
296
|
+
raise typer.Exit(code=1)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
#############################################################
|
|
301
|
+
# 2, For BioRxiv Parser
|
|
302
|
+
#############################################################
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command("arxiv-search")
|
|
306
|
+
def arxiv_search_cmd(
|
|
307
|
+
query: str = typer.Argument(..., help="arXiv search query."),
|
|
308
|
+
max_results: int = typer.Option(100, "--max-results", "-n", help="Maximum number of arXiv results to return."),
|
|
309
|
+
storage_dir: str = opt_storage,
|
|
310
|
+
output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory to save searched arXiv IDs."),
|
|
311
|
+
start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD."),
|
|
312
|
+
end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
|
|
313
|
+
backend: str = opt_arxiv_backend,
|
|
314
|
+
):
|
|
315
|
+
"""Search arXiv and write matching IDs to a text file."""
|
|
316
|
+
fetcher = ArxivFetcher(root_dir=storage_dir, backend=backend)
|
|
317
|
+
records = fetcher.search(query=query, max_results=max_results, start_date=start_date, end_date=end_date)
|
|
318
|
+
typer.echo(f"Found {len(records)} arXiv papers.")
|
|
319
|
+
for record in records:
|
|
320
|
+
typer.echo(record.source_id)
|
|
321
|
+
|
|
322
|
+
save_dir = output_dir if output_dir else storage_dir
|
|
323
|
+
output_file = _save_id_list(save_dir, "searched_arxiv_ids.txt", [record.source_id for record in records])
|
|
324
|
+
typer.echo(f"arXiv IDs saved to {output_file}.")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command("arxiv-fetch")
|
|
328
|
+
def arxiv_fetch_cmd(
|
|
329
|
+
query: str = typer.Argument(..., help="arXiv search query."),
|
|
330
|
+
max_results: int = typer.Option(100, "--max-results", "-n", help="Maximum number of arXiv records to fetch."),
|
|
331
|
+
storage_dir: str = opt_storage,
|
|
332
|
+
output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory to save fetched arXiv papers."),
|
|
333
|
+
start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD."),
|
|
334
|
+
end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
|
|
335
|
+
download_pdf: bool = typer.Option(True, "--download-pdf/--no-download-pdf", help="Download PDFs when available."),
|
|
336
|
+
backend: str = opt_arxiv_backend,
|
|
337
|
+
):
|
|
338
|
+
"""Fetch arXiv metadata and attempt to download PDFs."""
|
|
339
|
+
fetcher = ArxivFetcher(root_dir=storage_dir, backend=backend)
|
|
340
|
+
records = fetcher.fetch_from_query(
|
|
341
|
+
query=query,
|
|
342
|
+
output_dir=output_dir if output_dir else storage_dir,
|
|
343
|
+
max_results=max_results,
|
|
344
|
+
start_date=start_date,
|
|
345
|
+
end_date=end_date,
|
|
346
|
+
download_pdf=download_pdf,
|
|
347
|
+
)
|
|
348
|
+
typer.echo(f"Fetched {len(records)} arXiv papers.")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app.command("biorxiv-search")
|
|
352
|
+
def biorxiv_search_cmd(
|
|
353
|
+
query: str = typer.Argument(..., help="bioRxiv search query."),
|
|
354
|
+
max_results: int = typer.Option(100, "--max-results", "-n", help="Maximum number of bioRxiv results to return."),
|
|
355
|
+
storage_dir: str = opt_storage,
|
|
356
|
+
output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory to save searched bioRxiv IDs."),
|
|
357
|
+
start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD."),
|
|
358
|
+
end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
|
|
359
|
+
window_days: int = typer.Option(365, "--window-days", help="Compatibility-only option. Retained for older scripts; not used by current Crossref-backed direct query path."),
|
|
360
|
+
):
|
|
361
|
+
"""Search bioRxiv and write matching IDs to a text file.
|
|
362
|
+
|
|
363
|
+
The current implementation uses Crossref server-side query over openRxiv records
|
|
364
|
+
instead of date-window paging over the legacy bioRxiv details API.
|
|
365
|
+
"""
|
|
366
|
+
if window_days != 365:
|
|
367
|
+
typer.secho(
|
|
368
|
+
"Note: --window-days is a compatibility-only option and is ignored by the current Crossref-backed direct query path.",
|
|
369
|
+
fg=typer.colors.YELLOW,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
fetcher = BioRxivFetcher(root_dir=storage_dir, window_days=window_days)
|
|
373
|
+
records = fetcher.search(query=query, start_date=start_date, end_date=end_date, max_results=max_results)
|
|
374
|
+
typer.echo(f"Found {len(records)} bioRxiv papers.")
|
|
375
|
+
for record in records:
|
|
376
|
+
typer.echo(record.source_id)
|
|
377
|
+
|
|
378
|
+
save_dir = output_dir if output_dir else storage_dir
|
|
379
|
+
output_file = _save_id_list(save_dir, "searched_biorxiv_ids.txt", [record.source_id for record in records])
|
|
380
|
+
typer.echo(f"bioRxiv IDs saved to {output_file}.")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.command("biorxiv-fetch")
|
|
384
|
+
def biorxiv_fetch_cmd(
|
|
385
|
+
query: str = typer.Argument(..., help="bioRxiv search query."),
|
|
386
|
+
max_results: int = typer.Option(100, "--max-results", "-n", help="Maximum number of bioRxiv records to fetch."),
|
|
387
|
+
storage_dir: str = opt_storage,
|
|
388
|
+
output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory to save fetched bioRxiv papers."),
|
|
389
|
+
start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD."),
|
|
390
|
+
end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
|
|
391
|
+
window_days: int = typer.Option(365, "--window-days", help="Compatibility-only option. Retained for older scripts; not used by current Crossref-backed direct query path."),
|
|
392
|
+
download_pdf: bool = typer.Option(True, "--download-pdf/--no-download-pdf", help="Download PDFs when available."),
|
|
393
|
+
):
|
|
394
|
+
"""Fetch bioRxiv metadata and attempt to download PDFs.
|
|
395
|
+
|
|
396
|
+
Metadata retrieval uses Crossref server-side query over openRxiv records.
|
|
397
|
+
"""
|
|
398
|
+
if window_days != 365:
|
|
399
|
+
typer.secho(
|
|
400
|
+
"Note: --window-days is a compatibility-only option and is ignored by the current Crossref-backed direct query path.",
|
|
401
|
+
fg=typer.colors.YELLOW,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
fetcher = BioRxivFetcher(root_dir=storage_dir, window_days=window_days)
|
|
405
|
+
records = fetcher.fetch_from_query(
|
|
406
|
+
query=query,
|
|
407
|
+
output_dir=output_dir if output_dir else storage_dir,
|
|
408
|
+
start_date=start_date,
|
|
409
|
+
end_date=end_date,
|
|
410
|
+
max_results=max_results,
|
|
411
|
+
download_pdf=download_pdf,
|
|
412
|
+
)
|
|
413
|
+
typer.echo(f"Fetched {len(records)} bioRxiv papers.")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
#############################################################
|
|
418
|
+
# 3, For Third-Party integrations
|
|
419
|
+
#############################################################
|
|
420
|
+
|
|
421
|
+
# 3.1 paper fetch related commands
|
|
422
|
+
|
|
423
|
+
@app.command(
|
|
424
|
+
"paper-fetch",
|
|
425
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
426
|
+
add_help_option=False, # Disable typer's --help so argparse handles it
|
|
427
|
+
)
|
|
428
|
+
def paper_fetch_cmd(ctx: typer.Context):
|
|
429
|
+
"""
|
|
430
|
+
Fetch PDFs by DOI — passes through to the paper-fetch engine.
|
|
431
|
+
|
|
432
|
+
Use ``paperflow paper-fetch --help`` to see the full parameter list.
|
|
433
|
+
|
|
434
|
+
\b
|
|
435
|
+
Notes:
|
|
436
|
+
- 1, This command is a thin wrapper around the paper-fetch engine, which is a powerful tool for fetching papers by DOI, title, or batch lists.
|
|
437
|
+
- 2, ⚠️ Remember to set Unpaywall email in environment variables for best performance when fetching by DOI.
|
|
438
|
+
|
|
439
|
+
\b
|
|
440
|
+
Example usage:
|
|
441
|
+
paperflow paper-fetch 10.1038/s41586-020-2649-2 -o ./papers
|
|
442
|
+
paperflow paper-fetch --batch dois.txt -o ./papers --format text
|
|
443
|
+
paperflow paper-fetch --title "AlphaFold" -o ./papers
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
pdf_fetch.run(["paper-fetch"] + ctx.args)
|
|
448
|
+
except SystemExit as e:
|
|
449
|
+
if e.code != 0:
|
|
450
|
+
raise typer.Exit(code=e.code)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# 3.2 mineru related commands
|
|
454
|
+
# including pdf parse, post-parse structuring, and final markdown conversion
|
|
455
|
+
|
|
456
|
+
@app.command("pdf-parse")
|
|
457
|
+
def pdf_parse_cmd(
|
|
458
|
+
input_path: str = typer.Option(..., "--input", "-i", help="Input PDF file path."),
|
|
459
|
+
output_dir: str = typer.Option(..., "--output", "-o", help="Output directory for parsed output."),
|
|
460
|
+
clear: bool = typer.Option(False, "--clear", help="After conversion, keep only the .md files and necessary .json files(_content_list_v2.json/_content_list.json)."),
|
|
461
|
+
):
|
|
462
|
+
"""
|
|
463
|
+
Parse a PDF file using MinerU engine, and clean up the output directory.
|
|
464
|
+
|
|
465
|
+
\b
|
|
466
|
+
Notes:
|
|
467
|
+
- 1, MinerU generates a subfolder /auto under --output with .md, .json, .pdf, and images/. Use --clear to strip anything unnecessary,
|
|
468
|
+
note that we only use .md files and _content_list_v2.json/_content_list.json files for further processing like structuring.
|
|
469
|
+
- 2, ⚠️ Remember to switch to domestic mirror source when you can not access huggingface.
|
|
470
|
+
|
|
471
|
+
\b
|
|
472
|
+
Example usage:
|
|
473
|
+
paperflow pdf-parse -i paper.pdf -o ./output
|
|
474
|
+
"""
|
|
475
|
+
input_p = Path(input_path)
|
|
476
|
+
if not input_p.exists():
|
|
477
|
+
typer.echo(f"Error: Input path not found: {input_path}")
|
|
478
|
+
raise typer.Exit(code=1)
|
|
479
|
+
|
|
480
|
+
output_p = Path(output_dir)
|
|
481
|
+
output_p.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
|
|
483
|
+
# Snapshot existing top-level entries before mineru creates its subfolder
|
|
484
|
+
_before: set[Path] = {p for p in output_p.iterdir()} if output_p.exists() else set()
|
|
485
|
+
|
|
486
|
+
cmd = ["mineru", "-p", str(input_p.resolve()), "-o", str(output_p.resolve()), "-b", "pipeline"]
|
|
487
|
+
typer.echo(f"Running: {' '.join(cmd)}")
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
subprocess.run(cmd, check=True)
|
|
491
|
+
typer.secho("Done.", fg=typer.colors.GREEN)
|
|
492
|
+
except subprocess.CalledProcessError as e:
|
|
493
|
+
typer.secho(f"MinerU failed with exit code {e.returncode}", fg=typer.colors.RED)
|
|
494
|
+
raise typer.Exit(code=e.returncode)
|
|
495
|
+
except FileNotFoundError:
|
|
496
|
+
typer.secho("Error: mineru not found. Please install MinerU first.", fg=typer.colors.RED)
|
|
497
|
+
raise typer.Exit(code=1)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _clean_mineru_dirs(dirs: list[Path]) -> None:
|
|
501
|
+
"""
|
|
502
|
+
Clean up MinerU output: only keep .md files and necessary .json files(_content_list_v2.json/_content_list.json),
|
|
503
|
+
in case memory deficient when batch processing many PDFs with MinerU.
|
|
504
|
+
"""
|
|
505
|
+
import shutil
|
|
506
|
+
removed = 0
|
|
507
|
+
# subfolders under output_p are the ones created by mineru
|
|
508
|
+
for top_dir in dirs:
|
|
509
|
+
# generally, only one subfolder is created by mineru(only one top_dir)
|
|
510
|
+
for pdf_file in top_dir.rglob("*.pdf"):
|
|
511
|
+
pdf_file.unlink() # delete all the pdf files
|
|
512
|
+
removed += 1
|
|
513
|
+
for target_json in top_dir.rglob("*.json"):
|
|
514
|
+
if target_json.name.endswith(("_content_list_v2.json", "_content_list.json")):
|
|
515
|
+
continue # keep necessary json files for further processing
|
|
516
|
+
target_json.unlink() # delete all the unwanted json files
|
|
517
|
+
removed += 1
|
|
518
|
+
if removed:
|
|
519
|
+
typer.echo(f"✅ Removed {removed} source files. Only .md and necessary .json files are kept in the output directory {output_p}.")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if clear:
|
|
523
|
+
_new = [p for p in output_p.iterdir() if p not in _before and p.is_dir()]
|
|
524
|
+
_clean_mineru_dirs(_new)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@app.command("mineru-parse")
|
|
529
|
+
def mineru_parse_cmd(
|
|
530
|
+
input_json: str = typer.Option(..., "--input", "-i",
|
|
531
|
+
help="Path to mineru content_list_v2.json."),
|
|
532
|
+
output_json: str = typer.Option(..., "--output", "-o",
|
|
533
|
+
help="Output path for the structured JSON file."),
|
|
534
|
+
backend: str = typer.Option("regex", "--backend", "-b",
|
|
535
|
+
help="Section classification backend: 'regex' (default, no API needed) or 'ai'."),
|
|
536
|
+
config: Optional[str] = typer.Option(None, "--config", "-c",
|
|
537
|
+
help="Path to YAML config file for canonical types, aliases, and AI settings."),
|
|
538
|
+
api_key: Optional[str] = typer.Option(None, "--api-key",
|
|
539
|
+
help="API key for AI backend. Overrides config file and env var."),
|
|
540
|
+
model: Optional[str] = typer.Option(None, "--model",
|
|
541
|
+
help="Override AI model (e.g. 'deepseek-v4-pro', 'claude-haiku-4-5', 'gpt-4o-mini')."),
|
|
542
|
+
base_url: Optional[str] = typer.Option(None, "--base-url",
|
|
543
|
+
help="Custom API base URL for OpenAI-compatible endpoints (e.g. 'https://api.deepseek.com')."),
|
|
544
|
+
):
|
|
545
|
+
"""
|
|
546
|
+
Parse mineru output content_list_v2.json into canonical sectioned JSON.
|
|
547
|
+
|
|
548
|
+
Extracts metadata (title, authors, year, DOI, journal),
|
|
549
|
+
and sections normalised to canonical types (abstract, introduction, results,
|
|
550
|
+
discussion, methods, etc.). Tables are preserved as HTML.
|
|
551
|
+
|
|
552
|
+
\b
|
|
553
|
+
Notes:
|
|
554
|
+
- 1, Two backends: 'regex' (pattern + context, no API) and 'ai' (LLM batch classification).
|
|
555
|
+
- 2, AI backend supports Anthropic native, OpenAI native, and any OpenAI-compatible
|
|
556
|
+
endpoint via --base-url (DeepSeek, university proxies, self-hosted, etc.).
|
|
557
|
+
- 3, Set the appropriate API key env var (ANTHROPIC_API_KEY, OPENAI_API_KEY,
|
|
558
|
+
DEEPSEEK_API_KEY) or pass --api-key.
|
|
559
|
+
- 4, Configure provider/model via --model, --base-url, or a YAML config file.
|
|
560
|
+
|
|
561
|
+
\b
|
|
562
|
+
Examples:
|
|
563
|
+
paperflow mineru-parse -i content_list_v2.json -o paper.json
|
|
564
|
+
paperflow mineru-parse -i content_list_v2.json -o paper.json --backend ai
|
|
565
|
+
paperflow mineru-parse -i content_list_v2.json -o paper.json --backend ai \\
|
|
566
|
+
--base-url https://api.deepseek.com --model deepseek-v4-pro --api-key sk-xxx
|
|
567
|
+
paperflow mineru-parse -i content_list_v2.json -o paper.json --backend ai \\
|
|
568
|
+
--base-url https://models.sjtu.edu.cn/api/v1 --model deepseek-chat
|
|
569
|
+
paperflow mineru-parse -i content_list_v2.json -o paper.json --backend regex --config custom.yaml
|
|
570
|
+
"""
|
|
571
|
+
import json as _json
|
|
572
|
+
from .integrations.mineru_parser import (
|
|
573
|
+
MinerUContentParser,
|
|
574
|
+
RegexSectionClassifier,
|
|
575
|
+
AISectionClassifier,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if backend == "ai":
|
|
579
|
+
classifier = AISectionClassifier.from_config(config)
|
|
580
|
+
if api_key:
|
|
581
|
+
classifier.api_key = api_key
|
|
582
|
+
if model:
|
|
583
|
+
classifier.model = model
|
|
584
|
+
if base_url:
|
|
585
|
+
classifier.base_url = base_url
|
|
586
|
+
if base_url:
|
|
587
|
+
typer.echo(f"Using AI backend: {classifier.model} @ {classifier.base_url}")
|
|
588
|
+
else:
|
|
589
|
+
typer.echo(f"Using AI backend: {classifier.model}")
|
|
590
|
+
else:
|
|
591
|
+
classifier = RegexSectionClassifier.from_config(config)
|
|
592
|
+
typer.echo("Using regex backend with configurable aliases")
|
|
593
|
+
|
|
594
|
+
parser = MinerUContentParser(classifier)
|
|
595
|
+
result = parser.parse(input_json)
|
|
596
|
+
Path(output_json).parent.mkdir(parents=True, exist_ok=True)
|
|
597
|
+
with open(output_json, "w") as f:
|
|
598
|
+
_json.dump(result, f, ensure_ascii=False, indent=2)
|
|
599
|
+
|
|
600
|
+
section_summary = ", ".join(
|
|
601
|
+
f"{s['canonical_type']}({s.get('display_title', '?')})"
|
|
602
|
+
for s in result["sections"]
|
|
603
|
+
)
|
|
604
|
+
typer.echo(
|
|
605
|
+
f"Parsed {len(result['sections'])} sections -> {output_json}"
|
|
606
|
+
)
|
|
607
|
+
typer.echo(f" Sections: {section_summary}")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@app.command("mineru-export-md")
|
|
611
|
+
def mineru_export_md_cmd(
|
|
612
|
+
input_json: str = typer.Option(..., "--input", "-i",
|
|
613
|
+
help="Path to structured JSON file (from mineru-parse), or a directory of such files."),
|
|
614
|
+
output_md: str = typer.Option(..., "--output", "-o",
|
|
615
|
+
help="Output Markdown file path."),
|
|
616
|
+
yaml_cfg: Optional[str] = typer.Option(None, "--config", "-c",
|
|
617
|
+
help="YAML config specifying content_sections to include. If not provided, all sections are included."),
|
|
618
|
+
):
|
|
619
|
+
"""
|
|
620
|
+
Export structured mineru JSON to a clean Markdown file for LLM processing.
|
|
621
|
+
|
|
622
|
+
Reads one or more JSON files produced by ``mineru-parse`` and writes a
|
|
623
|
+
single Markdown file. Metadata (title, authors, year, DOI, journal) is
|
|
624
|
+
always included. Content sections are included based on the optional
|
|
625
|
+
YAML config.
|
|
626
|
+
|
|
627
|
+
\b
|
|
628
|
+
YAML config format:
|
|
629
|
+
content_sections:
|
|
630
|
+
- abstract
|
|
631
|
+
- introduction
|
|
632
|
+
- methods
|
|
633
|
+
- results
|
|
634
|
+
- discussion
|
|
635
|
+
- conclusion
|
|
636
|
+
|
|
637
|
+
\b
|
|
638
|
+
Examples:
|
|
639
|
+
paperflow mineru-export-md -i paper.json -o paper.md
|
|
640
|
+
paperflow mineru-export-md -i paper.json -o paper.md --config extract.yaml
|
|
641
|
+
paperflow mineru-export-md -i ./parsed_dir -o all_papers.md
|
|
642
|
+
"""
|
|
643
|
+
from .integrations.mineru_parser import export_mineru_json_to_md
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
stats = export_mineru_json_to_md(input_json, output_md, yaml_cfg)
|
|
647
|
+
typer.secho(
|
|
648
|
+
f"Exported {stats['total']} papers to {stats['output']}",
|
|
649
|
+
fg=typer.colors.GREEN,
|
|
650
|
+
)
|
|
651
|
+
if stats.get("sections_exported"):
|
|
652
|
+
sec_str = ", ".join(
|
|
653
|
+
f"{k}({v})" for k, v in stats["sections_exported"].items()
|
|
654
|
+
)
|
|
655
|
+
typer.echo(f" Sections exported: {sec_str}")
|
|
656
|
+
except Exception as e:
|
|
657
|
+
typer.echo(f"Error: {e}")
|
|
658
|
+
raise typer.Exit(code=1)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
if __name__ == "__main__":
|
|
662
|
+
app()
|