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 @@
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()