pdfget 0.1.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.
pdfget/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ PDFGet - 智能文献搜索与批量下载工具
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "gqy"
7
+ __email__ = "qingyu_ge@foxmail.com"
8
+ __description__ = "智能文献搜索与批量下载工具,支持高级检索和并发下载"
9
+
10
+ from .fetcher import PaperFetcher
11
+
12
+ __all__ = ["PaperFetcher"]
pdfget/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """PDF下载器主程序入口"""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
pdfget/config.py ADDED
@@ -0,0 +1,30 @@
1
+ """PDF下载器配置"""
2
+
3
+ from pathlib import Path
4
+
5
+ # 项目根目录
6
+ ROOT_DIR = Path(__file__).parent.parent.parent
7
+ DATA_DIR = ROOT_DIR / "data"
8
+ OUTPUT_DIR = DATA_DIR / "pdfs"
9
+ CACHE_DIR = DATA_DIR / ".cache"
10
+
11
+ # 创建目录
12
+ for d in [DATA_DIR, OUTPUT_DIR, CACHE_DIR]:
13
+ d.mkdir(exist_ok=True, parents=True)
14
+
15
+ # 下载设置
16
+ TIMEOUT = 30
17
+ MAX_RETRIES = 3
18
+ DELAY = 1.0
19
+ MAX_CONCURRENT = 5
20
+ MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
21
+
22
+ # API设置
23
+ HEADERS = {
24
+ "User-Agent": "Mozilla/5.0 (compatible; PDFGet/1.0)",
25
+ "Accept": "application/pdf,*/*",
26
+ }
27
+
28
+ # 日志设置
29
+ LOG_LEVEL = "INFO"
30
+ LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
pdfget/downloader.py ADDED
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 并发下载器 - 提升PDF下载效率
4
+ 使用线程池实现并发下载,同时保持API调用限制
5
+ """
6
+
7
+ import logging
8
+ import random
9
+ import threading
10
+ import time
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ from typing import List, Dict, Any, Callable, Optional
13
+
14
+ from .fetcher import PaperFetcher
15
+
16
+
17
+ class ConcurrentDownloader:
18
+ """并发下载管理器"""
19
+
20
+ def __init__(
21
+ self,
22
+ max_workers: int = 3,
23
+ base_delay: float = 1.0,
24
+ random_delay_range: float = 0.5,
25
+ fetcher: Optional[PaperFetcher] = None,
26
+ ):
27
+ """
28
+ 初始化并发下载器
29
+
30
+ Args:
31
+ max_workers: 最大并发线程数(默认3)
32
+ base_delay: 基础延迟时间(秒)
33
+ random_delay_range: 随机延迟范围(秒)
34
+ fetcher: PaperFetcher实例(可选)
35
+ """
36
+ self.logger = logging.getLogger("ConcurrentDownloader")
37
+ self.max_workers = max_workers
38
+ self.base_delay = base_delay
39
+ self.random_delay_range = random_delay_range
40
+
41
+ # 为每个线程创建独立的fetcher实例(避免session冲突)
42
+ if fetcher:
43
+ self.base_fetcher = fetcher
44
+ else:
45
+ self.base_fetcher = PaperFetcher()
46
+
47
+ # 线程安全的进度跟踪
48
+ self._lock = threading.Lock()
49
+ self._completed = 0
50
+ self._successful = 0
51
+ self._failed = 0
52
+ self._pdf_count = 0
53
+
54
+ def _get_delay(self) -> float:
55
+ """获取随机延迟时间,避免同步请求"""
56
+ random_delay = random.uniform(0, self.random_delay_range)
57
+ return self.base_delay + random_delay
58
+
59
+ def _create_thread_fetcher(self) -> PaperFetcher:
60
+ """为线程创建独立的fetcher实例"""
61
+ # 复制基础配置,但创建新的session
62
+ fetcher = PaperFetcher(
63
+ cache_dir=str(self.base_fetcher.cache_dir),
64
+ output_dir=str(self.base_fetcher.output_dir),
65
+ )
66
+ return fetcher
67
+
68
+ def _update_progress(
69
+ self, success: bool = False, pdf_downloaded: bool = False
70
+ ) -> None:
71
+ """线程安全的进度更新"""
72
+ with self._lock:
73
+ self._completed += 1
74
+ if success:
75
+ self._successful += 1
76
+ if pdf_downloaded:
77
+ self._pdf_count += 1
78
+ else:
79
+ self._failed += 1
80
+
81
+ # 简单的进度显示
82
+ progress = (self._completed / self._total) * 100
83
+ self.logger.info(
84
+ f" 进度: {self._completed}/{self._total} ({progress:.1f}%) "
85
+ f"成功: {self._successful} PDF: {self._pdf_count} 失败: {self._failed}"
86
+ )
87
+
88
+ def _download_single(
89
+ self, doi: str, fetcher: PaperFetcher, timeout: int = 30
90
+ ) -> Dict[str, Any]:
91
+ """单个文献的下载任务"""
92
+ try:
93
+ # 添加随机延迟
94
+ time.sleep(self._get_delay())
95
+
96
+ result = fetcher.fetch_by_doi(doi, timeout=timeout)
97
+
98
+ # 更新进度
99
+ success = result.get("success", False)
100
+ pdf_downloaded = bool(result.get("pdf_path"))
101
+ self._update_progress(success, pdf_downloaded)
102
+
103
+ return result
104
+
105
+ except Exception as e:
106
+ self.logger.debug(f"下载失败 ({doi}): {str(e)}")
107
+ self._update_progress(False)
108
+ return {"doi": doi, "success": False, "error": str(e)}
109
+
110
+ def download_batch(
111
+ self, dois: List[str], timeout: int = 30
112
+ ) -> List[Dict[str, Any]]:
113
+ """
114
+ 并发批量下载文献
115
+
116
+ Args:
117
+ dois: DOI列表
118
+ timeout: 单个请求超时时间
119
+
120
+ Returns:
121
+ 下载结果列表
122
+ """
123
+ if not dois:
124
+ return []
125
+
126
+ self.logger.info(
127
+ f"🚀 启动并发下载:{len(dois)} 篇文献,{self.max_workers} 个并发线程"
128
+ )
129
+
130
+ # 初始化进度跟踪
131
+ self._total = len(dois)
132
+ self._completed = 0
133
+ self._successful = 0
134
+ self._failed = 0
135
+ self._pdf_count = 0
136
+
137
+ results = []
138
+
139
+ # 使用线程池执行并发下载
140
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
141
+ # 提交所有下载任务
142
+ future_to_doi = {}
143
+
144
+ for doi in dois:
145
+ # 为每个线程创建独立的fetcher
146
+ thread_fetcher = self._create_thread_fetcher()
147
+ future = executor.submit(
148
+ self._download_single, doi, thread_fetcher, timeout
149
+ )
150
+ future_to_doi[future] = doi
151
+
152
+ # 收集结果(保持原始顺序)
153
+ for future in as_completed(future_to_doi):
154
+ doi = future_to_doi[future]
155
+ try:
156
+ result = future.result()
157
+ results.append(result)
158
+ except Exception as e:
159
+ self.logger.error(f"并发下载异常 ({doi}): {str(e)}")
160
+ results.append({"doi": doi, "success": False, "error": str(e)})
161
+
162
+ # 按原始DOI顺序重新排列结果
163
+ doi_to_result = {r["doi"]: r for r in results}
164
+ ordered_results = [
165
+ doi_to_result.get(doi, {"doi": doi, "success": False, "error": "Not found"})
166
+ for doi in dois
167
+ ]
168
+
169
+ # 最终统计
170
+ self.logger.info("\n📊 并发下载完成:")
171
+ self.logger.info(f" 总计: {len(ordered_results)}")
172
+ self.logger.info(f" 成功: {self._successful}")
173
+ self.logger.info(f" PDF: {self._pdf_count}")
174
+ self.logger.info(f" 失败: {self._failed}")
175
+ self.logger.info(
176
+ f" 成功率: {(self._successful / len(ordered_results)) * 100:.1f}%"
177
+ )
178
+
179
+ return ordered_results
180
+
181
+ def download_with_progress_callback(
182
+ self,
183
+ dois: List[str],
184
+ timeout: int = 30,
185
+ progress_callback: Optional[Callable[[int, int, int, int], None]] = None,
186
+ ) -> List[Dict[str, Any]]:
187
+ """
188
+ 带进度回调的并发下载
189
+
190
+ Args:
191
+ dois: DOI列表
192
+ timeout: 超时时间
193
+ progress_callback: 进度回调函数 (completed, successful, pdf_count, total)
194
+
195
+ Returns:
196
+ 下载结果列表
197
+ """
198
+ if not dois:
199
+ return []
200
+
201
+ self.logger.info(
202
+ f"🚀 启动并发下载:{len(dois)} 篇文献,{self.max_workers} 个并发线程"
203
+ )
204
+
205
+ # 初始化进度跟踪
206
+ self._total = len(dois)
207
+ self._completed = 0
208
+ self._successful = 0
209
+ self._failed = 0
210
+ self._pdf_count = 0
211
+
212
+ results = []
213
+
214
+ def update_progress_with_callback(
215
+ success: bool = False, pdf_downloaded: bool = False
216
+ ) -> None:
217
+ """带回调的进度更新"""
218
+ self._update_progress(success, pdf_downloaded)
219
+ if progress_callback:
220
+ progress_callback(
221
+ self._completed, self._successful, self._pdf_count, self._total
222
+ )
223
+
224
+ # 使用线程池执行并发下载,避免方法赋值
225
+ try:
226
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
227
+ future_to_doi = {}
228
+
229
+ for doi in dois:
230
+ thread_fetcher = self._create_thread_fetcher()
231
+ # 直接使用线程中的update_with_progress方法
232
+ future = executor.submit(
233
+ self._download_single_with_callback,
234
+ doi,
235
+ thread_fetcher,
236
+ timeout,
237
+ update_progress_with_callback,
238
+ )
239
+ future_to_doi[future] = doi
240
+
241
+ for future in as_completed(future_to_doi):
242
+ doi = future_to_doi[future]
243
+ try:
244
+ result = future.result()
245
+ results.append(result)
246
+ except Exception as e:
247
+ self.logger.error(f"并发下载异常 ({doi}): {str(e)}")
248
+ results.append({"doi": doi, "success": False, "error": str(e)})
249
+
250
+ # 按原始顺序排列结果
251
+ doi_to_result = {r["doi"]: r for r in results}
252
+ ordered_results = [
253
+ doi_to_result.get(
254
+ doi, {"doi": doi, "success": False, "error": "Not found"}
255
+ )
256
+ for doi in dois
257
+ ]
258
+
259
+ # 最终统计和最后一次回调
260
+ self.logger.info("\n📊 并发下载完成:")
261
+ self.logger.info(f" 总计: {len(ordered_results)}")
262
+ self.logger.info(f" 成功: {self._successful}")
263
+ self.logger.info(f" PDF: {self._pdf_count}")
264
+ self.logger.info(f" 失败: {self._failed}")
265
+ self.logger.info(
266
+ f" 成功率: {(self._successful / len(ordered_results)) * 100:.1f}%"
267
+ )
268
+
269
+ if progress_callback:
270
+ progress_callback(
271
+ self._completed, self._successful, self._pdf_count, self._total
272
+ )
273
+
274
+ return ordered_results
275
+
276
+ finally:
277
+ pass
278
+
279
+ def _download_single_with_callback(
280
+ self,
281
+ doi: str,
282
+ thread_fetcher: PaperFetcher,
283
+ timeout: int,
284
+ progress_callback: Callable[[], None],
285
+ ) -> Dict[str, Any]:
286
+ """带回调的单个文献下载(用于并发下载)"""
287
+ try:
288
+ # 添加随机延迟避免API限制
289
+ delay = self._get_delay()
290
+ time.sleep(delay)
291
+
292
+ # 获取文献信息
293
+ paper_info = thread_fetcher.fetch_by_doi(doi, timeout)
294
+ if not paper_info:
295
+ progress_callback()
296
+ return {"doi": doi, "success": False, "error": "文献信息获取失败"}
297
+
298
+ result = {"doi": doi, "success": True, "paper_info": paper_info}
299
+
300
+ # 更新进度
301
+ progress_callback()
302
+
303
+ return result
304
+
305
+ except Exception as e:
306
+ progress_callback()
307
+ self.logger.error(f"下载异常 ({doi}): {str(e)}")
308
+ return {"doi": doi, "success": False, "error": str(e)}
pdfget/fetcher.py ADDED
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 简化版文献获取器 - Linus风格
4
+ 只做一件事:下载开放获取文献
5
+ 遵循KISS原则:Keep It Simple, Stupid
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import re
11
+ import time
12
+ from pathlib import Path
13
+ from urllib.parse import quote
14
+
15
+ import requests
16
+
17
+ import logging
18
+
19
+
20
+ class PaperFetcher:
21
+ """简单文献获取器"""
22
+
23
+ def __init__(self, cache_dir: str = "data/cache", output_dir: str = "data/pdfs"):
24
+ """
25
+ 初始化获取器
26
+
27
+ Args:
28
+ cache_dir: 缓存目录
29
+ output_dir: PDF输出目录
30
+ """
31
+ self.logger = logging.getLogger("PaperFetcher")
32
+ self.cache_dir = Path(cache_dir)
33
+ self.output_dir = Path(output_dir)
34
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
35
+ self.output_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ # 简单的HTTP会话
38
+ self.session = requests.Session()
39
+ self.session.headers.update(
40
+ {"User-Agent": "Mozilla/5.0 (compatible; PaperFetcher/1.0)"}
41
+ )
42
+
43
+ def parse_query(self, query: str) -> str:
44
+ """
45
+ 解析高级检索词为Europe PMC格式
46
+
47
+ 支持的语法:
48
+ - 布尔运算符:AND, OR, NOT
49
+ - 字段检索:title:, author:, journal:
50
+ - 短语检索:"exact phrase"
51
+
52
+ Args:
53
+ query: 用户输入的检索词
54
+
55
+ Returns:
56
+ Europe PMC格式的检索词
57
+ """
58
+ # 处理短语检索(引号包围的内容)
59
+ phrase_pattern = r'"([^"]+)"'
60
+ phrases = re.findall(phrase_pattern, query)
61
+
62
+ # 临时替换短语为占位符
63
+ for i, phrase in enumerate(phrases):
64
+ query = query.replace(f'"{phrase}"', f"__PHRASE_{i}__")
65
+
66
+ # 处理字段检索
67
+ field_mappings = {
68
+ "title:": "TITLE:",
69
+ "author:": "AUTHOR:",
70
+ "journal:": "JOURNAL:",
71
+ "abstract:": "ABSTRACT:",
72
+ }
73
+
74
+ for user_field, pmc_field in field_mappings.items():
75
+ query = query.replace(user_field, pmc_field)
76
+
77
+ # 恢复短语,并添加必要的引号
78
+ for i, phrase in enumerate(phrases):
79
+ query = query.replace(f"__PHRASE_{i}__", f'"{phrase}"')
80
+
81
+ # 处理布尔运算符(确保大写)
82
+ query = (
83
+ query.replace(" and ", " AND ")
84
+ .replace(" or ", " OR ")
85
+ .replace(" not ", " NOT ")
86
+ )
87
+
88
+ return query.strip()
89
+
90
+ def search_papers(self, query: str, limit: int = 50) -> list[dict]:
91
+ """
92
+ 通过Europe PMC搜索文献
93
+
94
+ Args:
95
+ query: 检索词(支持高级语法)
96
+ limit: 返回结果数量限制
97
+
98
+ Returns:
99
+ 文献列表,包含DOI、标题、作者等信息
100
+ """
101
+ self.logger.info(f"🔍 搜索文献: {query}")
102
+
103
+ # 解析检索词
104
+ parsed_query = self.parse_query(query)
105
+ self.logger.debug(f" 解析后: {parsed_query}")
106
+
107
+ # 构建搜索URL
108
+ search_url = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
109
+ params = {
110
+ "query": parsed_query,
111
+ "resulttype": "core",
112
+ "format": "json",
113
+ "pageSize": min(limit, 1000), # Europe PMC限制最多1000条
114
+ "cursorMark": "*",
115
+ }
116
+
117
+ try:
118
+ response = self.session.get(search_url, params=params, timeout=30) # type: ignore[arg-type]
119
+ response.raise_for_status()
120
+
121
+ data = response.json()
122
+
123
+ if data.get("hitCount", 0) == 0:
124
+ self.logger.info(" ❌ 未找到匹配的文献")
125
+ return []
126
+
127
+ # 处理结果
128
+ papers = []
129
+ results = data.get("resultList", {}).get("result", [])
130
+
131
+ for i, record in enumerate(results[:limit]):
132
+ # 获取期刊信息
133
+ journal_info = record.get("journalInfo", {})
134
+
135
+ paper = {
136
+ "title": record.get("title", ""),
137
+ "authors": [
138
+ a.strip() for a in record.get("authorString", "").split(",")
139
+ ]
140
+ if record.get("authorString")
141
+ else [],
142
+ "journal": journal_info.get("journal", {}).get("title", ""),
143
+ "year": record.get("pubYear", ""),
144
+ "doi": record.get("doi", ""),
145
+ "pmcid": record.get("pmcid", ""),
146
+ "pmid": record.get("pmid", ""),
147
+ "abstract": record.get("abstractText", ""),
148
+ "isOpenAccess": bool(
149
+ record.get("pmcid")
150
+ ), # 有PMCID通常表示开放获取
151
+ "source": "Europe PMC",
152
+ # 新增的10个字段
153
+ "affiliation": record.get("affiliation", ""),
154
+ "volume": journal_info.get("volume", ""),
155
+ "issue": journal_info.get("issue", ""),
156
+ "pages": record.get("pageInfo", ""),
157
+ "license": record.get("license", ""),
158
+ "citedBy": record.get("citedByCount", 0),
159
+ "keywords": record.get("keywordList", []),
160
+ "meshTerms": record.get("meshHeadingList", []),
161
+ "grants": record.get("grantsList", []),
162
+ "hasData": record.get("hasData") == "Y",
163
+ "hasSuppl": record.get("hasSuppl") == "Y",
164
+ }
165
+ papers.append(paper)
166
+
167
+ self.logger.info(
168
+ f" 📄 {i + 1}/{min(len(results), limit)}: {paper['title'][:60]}..."
169
+ )
170
+
171
+ self.logger.info(f" ✅ 找到 {len(papers)} 篇文献")
172
+ return papers
173
+
174
+ except requests.exceptions.Timeout:
175
+ self.logger.error(" ❌ 搜索超时")
176
+ return []
177
+ except requests.exceptions.ConnectionError:
178
+ self.logger.error(" ❌ 连接失败")
179
+ return []
180
+ except Exception as e:
181
+ self.logger.error(f" ❌ 搜索失败: {str(e)}")
182
+ return []
183
+
184
+ def fetch_by_doi(self, doi: str, timeout: int = 30) -> dict:
185
+ """
186
+ 通过DOI获取文献(简化版)
187
+
188
+ 策略:
189
+ 1. 只处理开放获取文献(有PMCID)
190
+ 2. 快速失败,不重试
191
+ 3. 简单缓存
192
+ 4. 不搞复杂的网络监控和自适应重试
193
+
194
+ Args:
195
+ doi: 文献DOI
196
+ timeout: 超时时间
197
+
198
+ Returns:
199
+ 获取结果字典
200
+ """
201
+ self.logger.info(f"🔍 获取文献: {doi}")
202
+
203
+ # 检查缓存
204
+ cached_result = self._get_cache(doi)
205
+ if cached_result:
206
+ self.logger.info(" 📦 从缓存加载")
207
+ return cached_result
208
+
209
+ # 只使用Europe PMC(主要的开放获取源)
210
+ result = self._fetch_from_pmc(doi, timeout)
211
+
212
+ # 缓存结果
213
+ self._save_cache(doi, result)
214
+
215
+ if result.get("success"):
216
+ self.logger.info(" ✅ 获取成功")
217
+ else:
218
+ self.logger.info(f" ❌ 获取失败: {result.get('error', 'Unknown error')}")
219
+
220
+ return result
221
+
222
+ def _fetch_from_pmc(self, doi: str, timeout: int) -> dict:
223
+ """从Europe PMC获取文献"""
224
+ try:
225
+ # 搜索PMCID
226
+ search_url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=DOI:{quote(doi)}&resulttype=core&format=json"
227
+ self.logger.debug(f" 🔍 Europe PMC URL: {search_url}")
228
+
229
+ response = self.session.get(search_url, timeout=timeout)
230
+ response.raise_for_status()
231
+
232
+ data = response.json()
233
+ if data.get("hitCount", 0) == 0:
234
+ return {
235
+ "success": False,
236
+ "error": "Not found in Europe PMC",
237
+ "doi": doi,
238
+ }
239
+
240
+ record = data["resultList"]["result"][0]
241
+ pmcid = record.get("pmcid")
242
+
243
+ if not pmcid:
244
+ self.logger.info(" ⏭️ 无PMCID,非开放获取文献")
245
+ return {
246
+ "success": False,
247
+ "error": "Not open access (no PMCID)",
248
+ "doi": doi,
249
+ }
250
+
251
+ self.logger.info(f" 📄 找到PMCID: {pmcid}")
252
+
253
+ # 尝试下载PDF
254
+ pdf_result = self._download_pdf(pmcid, doi)
255
+
256
+ if pdf_result["success"]:
257
+ return {
258
+ "success": True,
259
+ "doi": doi,
260
+ "pmcid": pmcid,
261
+ "pdf_path": pdf_result["path"],
262
+ "content_type": "pdf",
263
+ "title": record.get("title"),
264
+ "journal": record.get("journalInfo", {})
265
+ .get("journal", {})
266
+ .get("title"),
267
+ "authors": [
268
+ a.strip() for a in record.get("authorString", "").split(",")
269
+ ]
270
+ if record.get("authorString")
271
+ else [],
272
+ "year": record.get("pubYear"),
273
+ "abstract": record.get("abstractText"),
274
+ }
275
+
276
+ # PDF下载失败,返回全文HTML链接
277
+ return {
278
+ "success": True,
279
+ "doi": doi,
280
+ "pmcid": pmcid,
281
+ "full_text_url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/",
282
+ "content_type": "html",
283
+ "title": record.get("title"),
284
+ "authors": [
285
+ a.strip() for a in record.get("authorString", "").split(",")
286
+ ]
287
+ if record.get("authorString")
288
+ else [],
289
+ "year": record.get("pubYear"),
290
+ "abstract": record.get("abstractText"),
291
+ }
292
+
293
+ except requests.exceptions.Timeout:
294
+ return {"success": False, "error": "Request timeout", "doi": doi}
295
+ except requests.exceptions.ConnectionError:
296
+ return {"success": False, "error": "Connection error", "doi": doi}
297
+ except Exception as e:
298
+ return {"success": False, "error": str(e), "doi": doi}
299
+
300
+ def _download_pdf(self, pmcid: str, doi: str) -> dict:
301
+ """下载PDF文件"""
302
+ # 尝试几个常见的PDF URL
303
+ pdf_urls = [
304
+ f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/pdf/",
305
+ f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/pdf/{pmcid}.pdf",
306
+ f"https://europepmc.org/articles/{pmcid}?pdf=render",
307
+ ]
308
+
309
+ for i, pdf_url in enumerate(pdf_urls):
310
+ try:
311
+ self.logger.debug(f" 📥 尝试PDF源 {i + 1}: {pdf_url}")
312
+ response = self.session.get(pdf_url, timeout=30, stream=True)
313
+ response.raise_for_status()
314
+
315
+ content_type = response.headers.get("content-type", "").lower()
316
+ if "application/pdf" not in content_type:
317
+ continue
318
+
319
+ # 保存文件
320
+ safe_doi = "".join(c for c in doi if c.isalnum() or c in "-._")
321
+ filename = f"{pmcid}_{safe_doi}.pdf"
322
+ file_path = self.output_dir / filename
323
+
324
+ with open(file_path, "wb") as f:
325
+ for chunk in response.iter_content(chunk_size=8192):
326
+ f.write(chunk)
327
+
328
+ self.logger.info(f" 💾 PDF保存成功: {file_path}")
329
+ return {"success": True, "path": str(file_path)}
330
+
331
+ except Exception as e:
332
+ self.logger.debug(f" ⚠️ PDF源 {i + 1} 失败: {str(e)}")
333
+ continue
334
+
335
+ return {"success": False, "error": "All PDF sources failed"}
336
+
337
+ def _get_cache(self, doi: str) -> dict | None:
338
+ """简单缓存检查"""
339
+ cache_file = (
340
+ self.cache_dir / f"cache_{hashlib.md5(doi.encode()).hexdigest()}.json"
341
+ )
342
+
343
+ if cache_file.exists():
344
+ try:
345
+ with open(cache_file, "r") as f:
346
+ data = json.load(f)
347
+
348
+ # 检查PDF文件是否还存在
349
+ if data.get("pdf_path") and not Path(data["pdf_path"]).exists():
350
+ self.logger.debug("缓存的PDF文件不存在,清除缓存")
351
+ cache_file.unlink()
352
+ return None
353
+
354
+ # 检查缓存是否过期(24小时)
355
+ if time.time() - data.get("timestamp", 0) > 86400:
356
+ self.logger.debug("缓存已过期")
357
+ cache_file.unlink()
358
+ return None
359
+
360
+ return data # type: ignore
361
+
362
+ except Exception as e:
363
+ self.logger.debug(f"缓存读取失败: {str(e)}")
364
+ cache_file.unlink()
365
+ return None
366
+
367
+ return None
368
+
369
+ def _save_cache(self, doi: str, result: dict) -> None:
370
+ """保存缓存"""
371
+ try:
372
+ cache_file = (
373
+ self.cache_dir / f"cache_{hashlib.md5(doi.encode()).hexdigest()}.json"
374
+ )
375
+ result["timestamp"] = time.time()
376
+
377
+ with open(cache_file, "w") as f:
378
+ json.dump(result, f, indent=2)
379
+
380
+ except Exception as e:
381
+ self.logger.debug(f"缓存保存失败: {str(e)}")
382
+
383
+ def fetch_batch(self, dois: list[str], delay: float = 1.0) -> list[dict]:
384
+ """
385
+ 批量获取文献(简化版)
386
+
387
+ Args:
388
+ dois: DOI列表
389
+ delay: 请求间延迟(秒)
390
+
391
+ Returns:
392
+ 结果列表
393
+ """
394
+ self.logger.info(f"🚀 批量获取 {len(dois)} 篇文献")
395
+ results = []
396
+
397
+ for i, doi in enumerate(dois, 1):
398
+ self.logger.info(f"\n📄 进度: {i}/{len(dois)}")
399
+
400
+ try:
401
+ result = self.fetch_by_doi(doi)
402
+ results.append(result)
403
+ except Exception as e:
404
+ self.logger.error(f"获取文献失败 ({doi}): {e}")
405
+ results.append({"doi": doi, "success": False, "error": str(e)})
406
+
407
+ # 简单延迟,避免被限制
408
+ if i < len(dois):
409
+ time.sleep(delay)
410
+
411
+ # 统计结果
412
+ success_count = sum(1 for r in results if r.get("success"))
413
+ self.logger.info(f"\n📊 批量获取完成: {success_count}/{len(dois)} 成功")
414
+
415
+ return results
pdfget/main.py ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PDF下载器主程序
4
+ 独立的文献PDF下载工具
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import logging
13
+
14
+ from .fetcher import PaperFetcher
15
+ from .downloader import ConcurrentDownloader
16
+ from .config import TIMEOUT, DELAY, LOG_LEVEL, LOG_FORMAT
17
+
18
+
19
+ def main() -> None:
20
+ """主函数"""
21
+ parser = argparse.ArgumentParser(
22
+ description="PDF文献下载器",
23
+ formatter_class=argparse.RawDescriptionHelpFormatter,
24
+ epilog="""
25
+ 使用示例:
26
+ # 搜索文献
27
+ python -m pdfget -s "machine learning cancer"
28
+ python -m pdfget -s "deep learning" -l 20 -d
29
+
30
+ # 并发下载(多线程)
31
+ python -m pdfget -s "cancer immunotherapy" -l 20 -d -t 5
32
+ python -m pdfget -i dois.csv -t 3
33
+
34
+ # 下载单个文献
35
+ python -m pdfget --doi 10.1016/j.cell.2020.01.021
36
+ """,
37
+ )
38
+
39
+ # 输入选项
40
+ group = parser.add_mutually_exclusive_group(required=True)
41
+ group.add_argument("--doi", help="单个DOI")
42
+ group.add_argument("-i", help="输入文件(CSV或TXT)")
43
+ group.add_argument("-s", help="搜索文献")
44
+
45
+ # 可选参数
46
+ parser.add_argument("-c", default="doi", help="CSV列名(默认: doi)")
47
+ parser.add_argument("-o", default="data/pdfs", help="输出目录")
48
+ parser.add_argument("--delay", type=float, default=DELAY, help="请求延迟秒数")
49
+ parser.add_argument("-l", type=int, default=50, help="搜索结果数量")
50
+ parser.add_argument("-d", action="store_true", help="下载PDF")
51
+ parser.add_argument("-t", type=int, default=3, help="并发线程数(默认3)")
52
+ parser.add_argument("-v", action="store_true", help="详细输出")
53
+
54
+ args = parser.parse_args()
55
+
56
+ # 设置日志
57
+ logging.basicConfig(level=logging.DEBUG if args.v else LOG_LEVEL, format=LOG_FORMAT)
58
+ logger = logging.getLogger("PDFDownloader")
59
+
60
+ # 初始化下载器
61
+ fetcher = PaperFetcher(cache_dir="data/cache", output_dir="data/pdfs")
62
+
63
+ logger.info("🚀 PDF下载器启动")
64
+ logger.info(f" 输出目录: {args.o}")
65
+
66
+ try:
67
+ if args.doi:
68
+ # 单个DOI下载
69
+ logger.info(f"\n📄 下载单个文献: {args.doi}")
70
+ result = fetcher.fetch_by_doi(args.doi, timeout=TIMEOUT)
71
+
72
+ if result.get("success"):
73
+ logger.info("✅ 下载成功!")
74
+ if result.get("pdf_path"):
75
+ logger.info(f" PDF路径: {result['pdf_path']}")
76
+ else:
77
+ logger.info(f" HTML链接: {result.get('full_text_url')}")
78
+ else:
79
+ logger.error(f"❌ 下载失败: {result.get('error', 'Unknown error')}")
80
+
81
+ elif args.s:
82
+ # 搜索文献
83
+ logger.info(f"\n🔍 搜索文献: {args.s}")
84
+ papers = fetcher.search_papers(args.s, limit=args.l)
85
+
86
+ if not papers:
87
+ logger.error("❌ 未找到匹配的文献")
88
+ exit(1)
89
+
90
+ # 显示搜索结果
91
+ logger.info(f"\n📊 搜索结果 ({len(papers)} 篇):")
92
+ for i, paper in enumerate(papers, 1):
93
+ logger.info(f"\n{i}. {paper['title']}")
94
+ logger.info(
95
+ f" 作者: {', '.join(paper['authors'][:3])}{'...' if len(paper['authors']) > 3 else ''}"
96
+ )
97
+ logger.info(f" 期刊: {paper['journal']} ({paper['year']})")
98
+ if paper["doi"]:
99
+ logger.info(f" DOI: {paper['doi']}")
100
+ logger.info(f" 开放获取: {'是' if paper['isOpenAccess'] else '否'}")
101
+
102
+ # 保存搜索结果
103
+ search_results_file = (
104
+ Path(args.o) / f"search_results_{int(time.time())}.json"
105
+ )
106
+ search_results_file.parent.mkdir(parents=True, exist_ok=True)
107
+
108
+ with open(search_results_file, "w", encoding="utf-8") as f:
109
+ json.dump(
110
+ {
111
+ "query": args.s,
112
+ "timestamp": time.time(),
113
+ "total": len(papers),
114
+ "results": papers,
115
+ },
116
+ f,
117
+ indent=2,
118
+ ensure_ascii=False,
119
+ )
120
+
121
+ logger.info(f"\n💾 搜索结果已保存到: {search_results_file}")
122
+
123
+ # 如果需要下载PDF
124
+ if args.d:
125
+ logger.info("\n📥 开始下载PDF...")
126
+
127
+ # 只下载有PMCID的开放获取文献
128
+ oa_papers = [p for p in papers if p["pmcid"]]
129
+ logger.info(f" 找到 {len(oa_papers)} 篇开放获取文献")
130
+
131
+ if oa_papers:
132
+ # 构造DOI列表
133
+ dois = [p["doi"] for p in oa_papers if p["doi"]]
134
+
135
+ if dois:
136
+ # 根据线程数决定是否使用并发下载
137
+ if len(dois) > 1 and args.t > 1:
138
+ logger.info(
139
+ f"\n🚀 使用 {args.t} 个线程并发下载 {len(dois)} 篇文献"
140
+ )
141
+ concurrent_downloader = ConcurrentDownloader(
142
+ max_workers=args.t,
143
+ base_delay=args.delay,
144
+ fetcher=fetcher,
145
+ )
146
+ results = concurrent_downloader.download_batch(
147
+ dois, timeout=TIMEOUT
148
+ )
149
+ else:
150
+ # 单线程下载(保持原有逻辑)
151
+ results = fetcher.fetch_batch(dois, delay=args.delay)
152
+
153
+ # 统计结果
154
+ success_count = sum(1 for r in results if r.get("success"))
155
+ pdf_count = sum(1 for r in results if r.get("pdf_path"))
156
+ html_count = sum(1 for r in results if r.get("full_text_url"))
157
+
158
+ logger.info("\n📊 下载统计:")
159
+ logger.info(f" 总计: {len(results)}")
160
+ logger.info(f" 成功: {success_count}")
161
+ logger.info(f" PDF: {pdf_count}")
162
+ logger.info(f" HTML: {html_count}")
163
+ logger.info(f" 失败: {len(results) - success_count}")
164
+
165
+ # 保存下载结果
166
+ if success_count > 0:
167
+ download_results_file = (
168
+ Path(args.o) / "download_results.json"
169
+ )
170
+ with open(
171
+ download_results_file, "w", encoding="utf-8"
172
+ ) as f:
173
+ json.dump(
174
+ {
175
+ "timestamp": time.time(),
176
+ "total": len(results),
177
+ "success": success_count,
178
+ "results": results,
179
+ },
180
+ f,
181
+ indent=2,
182
+ ensure_ascii=False,
183
+ )
184
+
185
+ logger.info(
186
+ f"\n💾 下载结果已保存到: {download_results_file}"
187
+ )
188
+
189
+ else:
190
+ # 批量下载
191
+ logger.info(f"\n📚 批量下载: {args.i}")
192
+
193
+ # 读取DOI列表
194
+ input_path = Path(args.i)
195
+ if not input_path.exists():
196
+ logger.error(f"❌ 输入文件不存在: {args.i}")
197
+ exit(1)
198
+
199
+ if input_path.suffix.lower() == ".csv":
200
+ # 读取CSV文件
201
+ import pandas as pd
202
+
203
+ try:
204
+ df = pd.read_csv(input_path)
205
+ if args.c not in df.columns:
206
+ logger.error(f"❌ CSV文件中找不到列: {args.c}")
207
+ exit(1)
208
+
209
+ dois = df[args.c].dropna().unique().tolist()
210
+ logger.info(f" 找到 {len(dois)} 个唯一DOI")
211
+
212
+ except Exception as e:
213
+ logger.error(f"❌ 读取CSV文件失败: {e}")
214
+ exit(1)
215
+
216
+ else:
217
+ # 读取文本文件(每行一个DOI)
218
+ try:
219
+ with open(input_path, "r") as f:
220
+ dois = [line.strip() for line in f if line.strip()]
221
+ logger.info(f" 找到 {len(dois)} 个DOI")
222
+
223
+ except Exception as e:
224
+ logger.error(f"❌ 读取文件失败: {e}")
225
+ exit(1)
226
+
227
+ # 根据线程数决定是否使用并发下载
228
+ if len(dois) > 1 and args.t > 1:
229
+ logger.info(f"\n🚀 使用 {args.t} 个线程并发下载 {len(dois)} 篇文献")
230
+ concurrent_downloader = ConcurrentDownloader(
231
+ max_workers=args.t, base_delay=args.delay, fetcher=fetcher
232
+ )
233
+ results = concurrent_downloader.download_batch(dois, timeout=TIMEOUT)
234
+ else:
235
+ # 单线程下载(保持原有逻辑)
236
+ results = fetcher.fetch_batch(dois, delay=args.delay)
237
+
238
+ # 统计结果
239
+ success_count = sum(1 for r in results if r.get("success"))
240
+ pdf_count = sum(1 for r in results if r.get("pdf_path"))
241
+ html_count = sum(1 for r in results if r.get("full_text_url"))
242
+
243
+ logger.info("\n📊 下载统计:")
244
+ logger.info(f" 总计: {len(results)}")
245
+ logger.info(f" 成功: {success_count}")
246
+ logger.info(f" PDF: {pdf_count}")
247
+ logger.info(f" HTML: {html_count}")
248
+ logger.info(f" 失败: {len(results) - success_count}")
249
+
250
+ # 保存结果
251
+ if success_count > 0:
252
+ output_file = Path(args.o) / "download_results.json"
253
+ output_file.parent.mkdir(parents=True, exist_ok=True)
254
+
255
+ with open(output_file, "w", encoding="utf-8") as f:
256
+ json.dump(
257
+ {
258
+ "timestamp": time.time(),
259
+ "total": len(results),
260
+ "success": success_count,
261
+ "results": results,
262
+ },
263
+ f,
264
+ indent=2,
265
+ ensure_ascii=False,
266
+ )
267
+
268
+ logger.info(f"\n💾 结果已保存到: {output_file}")
269
+
270
+ except KeyboardInterrupt:
271
+ logger.info("\n⏹️ 用户中断下载")
272
+ exit(1)
273
+ except Exception as e:
274
+ logger.error(f"\n💥 发生错误: {e}", exc_info=True)
275
+ exit(1)
276
+
277
+ logger.info("\n✨ 下载完成")
278
+ exit(0)
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdfget
3
+ Version: 0.1.0
4
+ Summary: 智能文献搜索与批量下载工具,支持高级检索和并发下载
5
+ Author-email: gqy <qingyu_ge@foxmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: pandas>=2.0.0
9
+ Requires-Dist: requests>=2.31.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: black>=24.0.0; extra == 'dev'
12
+ Requires-Dist: isort>=5.13.0; extra == 'dev'
13
+ Requires-Dist: mypy>=1.9.0; extra == 'dev'
14
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.5.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # PDFGet - 高效文献下载工具
20
+
21
+ 智能文献搜索与批量下载工具,支持高级检索和并发下载。
22
+
23
+ ## 1. 项目概述
24
+
25
+ PDFGet是一个专为科研工作者设计的智能文献搜索与批量下载工具,集成了Europe PMC等权威学术数据库,提供高效的文献获取和管理功能。
26
+
27
+ ### 1.1 主要特性
28
+
29
+ - 🔍 **高级搜索**:支持布尔运算符、字段检索、短语检索
30
+ - 🚀 **并发下载**:多线程并行下载,3-5倍速度提升
31
+ - 📊 **丰富元数据**:包含作者、单位、期刊、摘要、引用等完整信息
32
+ - 💾 **智能缓存**:24小时缓存,避免重复下载
33
+ - 📄 **批量处理**:支持CSV/TXT文件批量下载
34
+
35
+ ## 2. 安装与配置
36
+
37
+ ### 2.1 系统要求
38
+
39
+ 详细的系统要求和依赖信息请查看 [pyproject.toml](pyproject.toml) 文件。
40
+
41
+ ### 2.2 安装方法
42
+
43
+ ```bash
44
+ # 使用pip安装
45
+ pip install pdfget
46
+
47
+ # 使用uv安装
48
+ uv add pdfget
49
+
50
+ # 或从源码安装
51
+ git clone https://github.com/gqy20/pdfget.git
52
+ cd pdfget
53
+ pip install -e .
54
+ ```
55
+
56
+ ### 2.3 快速开始
57
+
58
+ 安装完成后,您可以直接使用 `pdfget` 命令:
59
+
60
+ ```bash
61
+ # 搜索文献
62
+ pdfget -s "machine learning" -l 20
63
+
64
+ # 搜索并下载
65
+ pdfget -s "cancer immunotherapy" -d
66
+
67
+ # 并发下载(5线程)
68
+ pdfget -s "deep learning" -l 50 -d -t 5
69
+
70
+ # 单篇文献下载
71
+ pdfget --doi 10.1016/j.cell.2020.01.021
72
+
73
+ # 批量下载
74
+ pdfget -i dois.csv -d -t 3
75
+ ```
76
+
77
+ 如果您使用 uv 作为包管理器,也可以:
78
+ ```bash
79
+ # 使用uv运行
80
+ uv run pdfget -s "machine learning" -l 20
81
+ ```
82
+
83
+ ## 3. 高级检索语法
84
+
85
+ ### 3.1 布尔运算符
86
+ ```bash
87
+ # AND: 同时包含多个关键词
88
+ pdfget -s "cancer AND immunotherapy" -l 30
89
+
90
+ # OR: 包含任意关键词
91
+ pdfget -s "machine OR deep learning" -l 20
92
+
93
+ # NOT: 排除特定词汇
94
+ pdfget -s "cancer AND immunotherapy NOT review" -l 30
95
+
96
+ # 复杂组合
97
+ pdfget -s "(cancer OR tumor) AND immunotherapy NOT mice" -l 25
98
+ ```
99
+
100
+ ### 3.2 字段检索
101
+ ```bash
102
+ # 标题检索
103
+ pdfget -s 'title:"deep learning"' -l 15
104
+
105
+ # 作者检索
106
+ pdfget -s 'author:hinton AND title:"neural networks"' -l 10
107
+
108
+ # 期刊检索
109
+ pdfget -s 'journal:nature AND cancer' -l 20
110
+
111
+ # 年份检索
112
+ pdfget -s 'cancer AND year:2023' -l 15
113
+ ```
114
+
115
+ ### 3.3 短语和精确匹配
116
+ ```bash
117
+ # 短语检索(用双引号)
118
+ pdfget -s '"quantum computing"' -l 10
119
+
120
+ # 混合使用
121
+ pdfget -s '"gene expression" AND (cancer OR tumor) NOT review' -l 20
122
+ ```
123
+
124
+ ### 3.4 实用检索技巧
125
+ - 使用括号分组复杂的布尔逻辑
126
+ - 短语用双引号确保精确匹配
127
+ - 可以组合多个字段进行精确检索
128
+ - 使用 NOT 过滤掉不相关的结果(如综述、评论等)
129
+
130
+ ## 4. 性能优势
131
+
132
+ ### 4.1 并发下载效率对比
133
+
134
+ | 文献数量 | 单线程耗时 | 并发耗时 | 性能提升 |
135
+ |---------|-----------|----------|----------|
136
+ | 5篇 | ~25秒 | ~8秒 | 3x |
137
+ | 20篇 | ~100秒 | ~25秒 | 4x |
138
+ | 50篇 | ~250秒 | ~60秒 | 4x |
139
+
140
+ ## 5. 命令行参数详解
141
+
142
+ ### 5.1 核心参数
143
+ - `-s QUERY` : 搜索文献
144
+ - `--doi DOI` : 下载单个文献
145
+ - `-i FILE` : 批量输入文件
146
+ - `-d` : 下载PDF
147
+
148
+ ### 5.2 优化参数
149
+ - `-l NUM` : 搜索结果数量(默认50)
150
+ - `-t NUM` : 并发线程数(默认3)
151
+ - `-v` : 详细输出
152
+
153
+ ## 6. 输出格式与文件结构
154
+
155
+ ### 6.1 搜索结果格式
156
+ ```json
157
+ {
158
+ "query": "关键词",
159
+ "total": 10,
160
+ "results": [
161
+ {
162
+ "title": "文献标题",
163
+ "authors": ["作者1", "作者2"],
164
+ "journal": "期刊名称",
165
+ "year": "2025",
166
+ "doi": "10.1016/xxx",
167
+ "affiliation": "作者单位",
168
+ "citedBy": 0,
169
+ "keywords": ["关键词1", "关键词2"]
170
+ }
171
+ ]
172
+ }
173
+ ```
174
+
175
+ ### 6.2 文件目录结构
176
+ ```
177
+ data/
178
+ ├── pdfs/ # 下载的PDF文件
179
+ ├── cache/ # 缓存文件
180
+ └── download_results.json # 下载结果记录
181
+ ```
182
+
183
+ ## 7. 许可证
184
+
185
+ 本项目采用 MIT License,允许自由使用和修改。
186
+
187
+ ## 📚 更新日志
188
+
189
+ <details>
190
+ <summary><strong>📋 查看版本更新历史</strong></summary>
191
+
192
+ - 🔗 **完整更新日志**: [CHANGELOG.md](CHANGELOG.md)
193
+ - ✨ **最新版本 (v0.1.0)**: 高级文献搜索 + 并发下载 + 智能缓存
194
+
195
+ </details>
196
+
197
+ ## 🔗 相关链接
198
+
199
+ - **项目源码**: [GitHub Repository](https://github.com/gqy20/pdfget)
200
+ - **问题反馈**: [GitHub Issues](https://github.com/gqy20/pdfget/issues)
@@ -0,0 +1,10 @@
1
+ pdfget/__init__.py,sha256=-qap676xaNk4jiFkzqU7LjCuzrHFIEcZgVzBqpZ2FmE,294
2
+ pdfget/__main__.py,sha256=SAGyFJO_1WAqCYhJtr2QNl7fVI5Gws_nKHYw7SHTjiM,97
3
+ pdfget/config.py,sha256=jyjJr6PwYC5o96wAWD_6Qx-WhwHJ2pJQ634wOf0fcFo,643
4
+ pdfget/downloader.py,sha256=AuM93j95DmPYz5Dma3o5Cz_bG1oF5AUF8kXrcblgl60,10342
5
+ pdfget/fetcher.py,sha256=iHfg72zjbURvndC49B4WMTs3cFCsLh9c1pa6Wb67hyI,14446
6
+ pdfget/main.py,sha256=kzs5AavcSkpjaCWdWM3CtedKkm78qiDpcp8ylaVxqRs,11104
7
+ pdfget-0.1.0.dist-info/METADATA,sha256=WnlOMj622_A6UI93WBnXnUSIRfEs8qnXHqagTZF2r_A,4920
8
+ pdfget-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ pdfget-0.1.0.dist-info/entry_points.txt,sha256=htkdzIZIePSAe0VCp5_0EnZAqcWxOwm3OJ-BcvhXaag,48
10
+ pdfget-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdfget = pdfget.__main__:main