pdf2notes 1.0.0__tar.gz
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.
- pdf2notes-1.0.0/LICENSE +21 -0
- pdf2notes-1.0.0/PKG-INFO +139 -0
- pdf2notes-1.0.0/README.md +118 -0
- pdf2notes-1.0.0/pyproject.toml +34 -0
- pdf2notes-1.0.0/setup.cfg +4 -0
- pdf2notes-1.0.0/src/pdf2notes/__init__.py +3 -0
- pdf2notes-1.0.0/src/pdf2notes/cli.py +449 -0
- pdf2notes-1.0.0/src/pdf2notes.egg-info/PKG-INFO +139 -0
- pdf2notes-1.0.0/src/pdf2notes.egg-info/SOURCES.txt +12 -0
- pdf2notes-1.0.0/src/pdf2notes.egg-info/dependency_links.txt +1 -0
- pdf2notes-1.0.0/src/pdf2notes.egg-info/entry_points.txt +2 -0
- pdf2notes-1.0.0/src/pdf2notes.egg-info/requires.txt +2 -0
- pdf2notes-1.0.0/src/pdf2notes.egg-info/top_level.txt +1 -0
pdf2notes-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandeep Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pdf2notes-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf2notes
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Turn a PDF page range into AI-generated study notes (Markdown) for Obsidian/Notion
|
|
5
|
+
Author: Sandeep Singh
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SandeepSinghSethi/pdf2notes
|
|
8
|
+
Keywords: pdf,notes,obsidian,notion,study,ai,llm
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pdfplumber>=0.11.0
|
|
19
|
+
Requires-Dist: openai>=1.0.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# pdf2notes
|
|
23
|
+
|
|
24
|
+
Turn a page range of a PDF book into descriptive, review-ready study notes
|
|
25
|
+
(Markdown) — ready to drop into **Obsidian** or import into **Notion**.
|
|
26
|
+
|
|
27
|
+
Works with any **OpenAI-compatible** chat completion API: NVIDIA NIM
|
|
28
|
+
(`integrate.api.nvidia.com`), OpenAI, Groq, Together AI, OpenRouter, or a
|
|
29
|
+
local server (Ollama, vLLM, LM Studio). You bring your own API key.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -r requirements.txt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Get an API key (NVIDIA NIM example — free tier available)
|
|
38
|
+
|
|
39
|
+
1. Go to https://build.nvidia.com
|
|
40
|
+
2. Sign in, pick a model (e.g. `meta/llama-3.1-70b-instruct`), click "Get API Key"
|
|
41
|
+
3. Export it:
|
|
42
|
+
```bash
|
|
43
|
+
export NVIDIA_API_KEY="nvapi-xxxxxxxxxxxxxxxx"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Any other OpenAI-compatible provider works the same way — just pass
|
|
47
|
+
`--base-url` and `--api-key-env` for that provider.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python pdf2notes.py --pdf book.pdf --pages 120-180 --api-key-env NVIDIA_API_KEY
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This writes `book_notes_120-180.md` in the current folder. Requests run
|
|
56
|
+
concurrently and are throttled to a requests-per-minute cap, so a 100-page
|
|
57
|
+
range finishes in a couple of minutes instead of an hour.
|
|
58
|
+
|
|
59
|
+
If the run is interrupted, hits a persistent error, or you just Ctrl-C it,
|
|
60
|
+
**rerun the exact same command** — chunks already saved in the output file
|
|
61
|
+
are detected and skipped, so you only pay for/wait on what's missing.
|
|
62
|
+
|
|
63
|
+
### Common options
|
|
64
|
+
|
|
65
|
+
| Flag | Meaning | Default |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `--pdf` | Path to the source PDF | required |
|
|
68
|
+
| `--pages` | Page range, e.g. `120-180` (1-indexed, inclusive) | required |
|
|
69
|
+
| `--output` | Output `.md` path | `<pdf-name>_notes_<range>.md` |
|
|
70
|
+
| `--style` | `descriptive`, `cornell`, `qa`, or `outline` | `descriptive` |
|
|
71
|
+
| `--chunk-chars` | Max source characters sent per API call | `6000` |
|
|
72
|
+
| `--concurrency` | Max simultaneous API requests | `5` |
|
|
73
|
+
| `--rpm` | Max API requests per minute (across all workers) | `40` |
|
|
74
|
+
| `--model` | Model name as your provider expects it | `nvidia/llama-3.3-nemotron-super-49b-v1.5` |
|
|
75
|
+
| `--base-url` | OpenAI-compatible API base URL | NVIDIA NIM endpoint |
|
|
76
|
+
| `--api-key-env` | Env var holding your key | `NVIDIA_API_KEY` |
|
|
77
|
+
|
|
78
|
+
**Tune `--rpm` to your actual plan.** NVIDIA's free tier, OpenAI's free/low
|
|
79
|
+
tiers, etc. all cap requests per minute — check your provider's dashboard
|
|
80
|
+
and set `--rpm` a bit under that. Setting it too high just means the tool
|
|
81
|
+
eats a 429 and backs off; setting `--concurrency` too high does the same
|
|
82
|
+
without helping speed once you're rpm-bound.
|
|
83
|
+
|
|
84
|
+
**Model IDs get retired.** NVIDIA (and other providers) periodically pull
|
|
85
|
+
old model IDs from the catalog — you'll get an HTTP 404/410 if that
|
|
86
|
+
happens. The tool detects this as non-retryable and fails immediately with
|
|
87
|
+
the error instead of burning your rate-limit budget retrying a request
|
|
88
|
+
that will never succeed. Check https://build.nvidia.com/models for current
|
|
89
|
+
IDs and pass the right one via `--model`.
|
|
90
|
+
|
|
91
|
+
### Examples
|
|
92
|
+
|
|
93
|
+
Using OpenAI instead of NVIDIA:
|
|
94
|
+
```bash
|
|
95
|
+
export OPENAI_API_KEY="sk-..."
|
|
96
|
+
python pdf2notes.py --pdf book.pdf --pages 1-50 \
|
|
97
|
+
--base-url https://api.openai.com/v1 \
|
|
98
|
+
--model gpt-4o-mini \
|
|
99
|
+
--api-key-env OPENAI_API_KEY
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Q&A flashcard-style notes instead of descriptive prose:
|
|
103
|
+
```bash
|
|
104
|
+
python pdf2notes.py --pdf book.pdf --pages 200-260 --style qa
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Notes on how it works
|
|
108
|
+
|
|
109
|
+
1. Extracts text from the given page range with `pdfplumber`.
|
|
110
|
+
2. Groups pages into chunks (default ~6000 chars each) so each API call
|
|
111
|
+
covers a coherent span without blowing past context limits.
|
|
112
|
+
3. Sends each chunk to the model with a prompt tuned to produce **learnable
|
|
113
|
+
notes**, not a shortened summary — definitions, examples, and important
|
|
114
|
+
details are preserved and explained.
|
|
115
|
+
4. Stitches everything into one Markdown file with YAML frontmatter
|
|
116
|
+
(title, source, page range, style, timestamp) so it's ready to file
|
|
117
|
+
straight into Obsidian, or import into Notion via *Import → Markdown*.
|
|
118
|
+
|
|
119
|
+
## Limitations
|
|
120
|
+
|
|
121
|
+
- **Scanned/image-only PDFs** won't extract text — OCR the PDF first (e.g.
|
|
122
|
+
with `pytesseract` + `pdf2image`), then run this tool on the OCR'd version.
|
|
123
|
+
- Very dense chunking settings (`--chunk-chars` too high) can exceed your
|
|
124
|
+
model's context window — 6000–8000 is a safe default for most models.
|
|
125
|
+
- Quality depends on the model you point it at — bigger instruction-tuned
|
|
126
|
+
models produce noticeably better notes than small ones.
|
|
127
|
+
- **Memory**: page text is extracted one page at a time and each page's
|
|
128
|
+
internal pdfplumber cache (fonts, layout objects) is flushed immediately,
|
|
129
|
+
so memory stays roughly proportional to a chunk's text, not the whole
|
|
130
|
+
page range. For genuinely huge ranges (500+ pages in one run), consider
|
|
131
|
+
splitting into a couple of `--pages` calls instead of one giant one.
|
|
132
|
+
- **A crash/segfault message *after* "Done."**: if you see a SIGSEGV or
|
|
133
|
+
similar right as the process exits but *after* your notes file is
|
|
134
|
+
already complete and readable, it's happening during Python interpreter
|
|
135
|
+
teardown, not during note generation — a known quirk of some PDF C
|
|
136
|
+
extensions (e.g. pypdfium2, used internally by pdfplumber) not tearing
|
|
137
|
+
down cleanly at exit. It doesn't affect your output file. If you want to
|
|
138
|
+
suppress the noise, run with `python pdf2notes.py ... ; true` in a
|
|
139
|
+
script, or ignore the exit code.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# pdf2notes
|
|
2
|
+
|
|
3
|
+
Turn a page range of a PDF book into descriptive, review-ready study notes
|
|
4
|
+
(Markdown) — ready to drop into **Obsidian** or import into **Notion**.
|
|
5
|
+
|
|
6
|
+
Works with any **OpenAI-compatible** chat completion API: NVIDIA NIM
|
|
7
|
+
(`integrate.api.nvidia.com`), OpenAI, Groq, Together AI, OpenRouter, or a
|
|
8
|
+
local server (Ollama, vLLM, LM Studio). You bring your own API key.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install -r requirements.txt
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Get an API key (NVIDIA NIM example — free tier available)
|
|
17
|
+
|
|
18
|
+
1. Go to https://build.nvidia.com
|
|
19
|
+
2. Sign in, pick a model (e.g. `meta/llama-3.1-70b-instruct`), click "Get API Key"
|
|
20
|
+
3. Export it:
|
|
21
|
+
```bash
|
|
22
|
+
export NVIDIA_API_KEY="nvapi-xxxxxxxxxxxxxxxx"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Any other OpenAI-compatible provider works the same way — just pass
|
|
26
|
+
`--base-url` and `--api-key-env` for that provider.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python pdf2notes.py --pdf book.pdf --pages 120-180 --api-key-env NVIDIA_API_KEY
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This writes `book_notes_120-180.md` in the current folder. Requests run
|
|
35
|
+
concurrently and are throttled to a requests-per-minute cap, so a 100-page
|
|
36
|
+
range finishes in a couple of minutes instead of an hour.
|
|
37
|
+
|
|
38
|
+
If the run is interrupted, hits a persistent error, or you just Ctrl-C it,
|
|
39
|
+
**rerun the exact same command** — chunks already saved in the output file
|
|
40
|
+
are detected and skipped, so you only pay for/wait on what's missing.
|
|
41
|
+
|
|
42
|
+
### Common options
|
|
43
|
+
|
|
44
|
+
| Flag | Meaning | Default |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--pdf` | Path to the source PDF | required |
|
|
47
|
+
| `--pages` | Page range, e.g. `120-180` (1-indexed, inclusive) | required |
|
|
48
|
+
| `--output` | Output `.md` path | `<pdf-name>_notes_<range>.md` |
|
|
49
|
+
| `--style` | `descriptive`, `cornell`, `qa`, or `outline` | `descriptive` |
|
|
50
|
+
| `--chunk-chars` | Max source characters sent per API call | `6000` |
|
|
51
|
+
| `--concurrency` | Max simultaneous API requests | `5` |
|
|
52
|
+
| `--rpm` | Max API requests per minute (across all workers) | `40` |
|
|
53
|
+
| `--model` | Model name as your provider expects it | `nvidia/llama-3.3-nemotron-super-49b-v1.5` |
|
|
54
|
+
| `--base-url` | OpenAI-compatible API base URL | NVIDIA NIM endpoint |
|
|
55
|
+
| `--api-key-env` | Env var holding your key | `NVIDIA_API_KEY` |
|
|
56
|
+
|
|
57
|
+
**Tune `--rpm` to your actual plan.** NVIDIA's free tier, OpenAI's free/low
|
|
58
|
+
tiers, etc. all cap requests per minute — check your provider's dashboard
|
|
59
|
+
and set `--rpm` a bit under that. Setting it too high just means the tool
|
|
60
|
+
eats a 429 and backs off; setting `--concurrency` too high does the same
|
|
61
|
+
without helping speed once you're rpm-bound.
|
|
62
|
+
|
|
63
|
+
**Model IDs get retired.** NVIDIA (and other providers) periodically pull
|
|
64
|
+
old model IDs from the catalog — you'll get an HTTP 404/410 if that
|
|
65
|
+
happens. The tool detects this as non-retryable and fails immediately with
|
|
66
|
+
the error instead of burning your rate-limit budget retrying a request
|
|
67
|
+
that will never succeed. Check https://build.nvidia.com/models for current
|
|
68
|
+
IDs and pass the right one via `--model`.
|
|
69
|
+
|
|
70
|
+
### Examples
|
|
71
|
+
|
|
72
|
+
Using OpenAI instead of NVIDIA:
|
|
73
|
+
```bash
|
|
74
|
+
export OPENAI_API_KEY="sk-..."
|
|
75
|
+
python pdf2notes.py --pdf book.pdf --pages 1-50 \
|
|
76
|
+
--base-url https://api.openai.com/v1 \
|
|
77
|
+
--model gpt-4o-mini \
|
|
78
|
+
--api-key-env OPENAI_API_KEY
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Q&A flashcard-style notes instead of descriptive prose:
|
|
82
|
+
```bash
|
|
83
|
+
python pdf2notes.py --pdf book.pdf --pages 200-260 --style qa
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Notes on how it works
|
|
87
|
+
|
|
88
|
+
1. Extracts text from the given page range with `pdfplumber`.
|
|
89
|
+
2. Groups pages into chunks (default ~6000 chars each) so each API call
|
|
90
|
+
covers a coherent span without blowing past context limits.
|
|
91
|
+
3. Sends each chunk to the model with a prompt tuned to produce **learnable
|
|
92
|
+
notes**, not a shortened summary — definitions, examples, and important
|
|
93
|
+
details are preserved and explained.
|
|
94
|
+
4. Stitches everything into one Markdown file with YAML frontmatter
|
|
95
|
+
(title, source, page range, style, timestamp) so it's ready to file
|
|
96
|
+
straight into Obsidian, or import into Notion via *Import → Markdown*.
|
|
97
|
+
|
|
98
|
+
## Limitations
|
|
99
|
+
|
|
100
|
+
- **Scanned/image-only PDFs** won't extract text — OCR the PDF first (e.g.
|
|
101
|
+
with `pytesseract` + `pdf2image`), then run this tool on the OCR'd version.
|
|
102
|
+
- Very dense chunking settings (`--chunk-chars` too high) can exceed your
|
|
103
|
+
model's context window — 6000–8000 is a safe default for most models.
|
|
104
|
+
- Quality depends on the model you point it at — bigger instruction-tuned
|
|
105
|
+
models produce noticeably better notes than small ones.
|
|
106
|
+
- **Memory**: page text is extracted one page at a time and each page's
|
|
107
|
+
internal pdfplumber cache (fonts, layout objects) is flushed immediately,
|
|
108
|
+
so memory stays roughly proportional to a chunk's text, not the whole
|
|
109
|
+
page range. For genuinely huge ranges (500+ pages in one run), consider
|
|
110
|
+
splitting into a couple of `--pages` calls instead of one giant one.
|
|
111
|
+
- **A crash/segfault message *after* "Done."**: if you see a SIGSEGV or
|
|
112
|
+
similar right as the process exits but *after* your notes file is
|
|
113
|
+
already complete and readable, it's happening during Python interpreter
|
|
114
|
+
teardown, not during note generation — a known quirk of some PDF C
|
|
115
|
+
extensions (e.g. pypdfium2, used internally by pdfplumber) not tearing
|
|
116
|
+
down cleanly at exit. It doesn't affect your output file. If you want to
|
|
117
|
+
suppress the noise, run with `python pdf2notes.py ... ; true` in a
|
|
118
|
+
script, or ignore the exit code.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pdf2notes"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Turn a PDF page range into AI-generated study notes (Markdown) for Obsidian/Notion"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Sandeep Singh" }]
|
|
13
|
+
keywords = ["pdf", "notes", "obsidian", "notion", "study", "ai", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Education",
|
|
20
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"pdfplumber>=0.11.0",
|
|
24
|
+
"openai>=1.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
pdf2notes = "pdf2notes.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/SandeepSinghSethi/pdf2notes"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pdf2notes — Turn a page range of a PDF book into descriptive, review-ready
|
|
4
|
+
study notes (Markdown), ready to drop into Obsidian or Notion.
|
|
5
|
+
|
|
6
|
+
Works with ANY OpenAI-compatible chat completion API: NVIDIA NIM
|
|
7
|
+
(integrate.api.nvidia.com), OpenAI, Groq, Together AI, OpenRouter, a local
|
|
8
|
+
vLLM/Ollama server, etc. You bring your own API key and base URL.
|
|
9
|
+
|
|
10
|
+
Requests run concurrently (bounded by --concurrency) and are throttled to
|
|
11
|
+
stay under --rpm (requests/minute), so large page ranges finish in minutes
|
|
12
|
+
instead of hours without tripping your provider's rate limit.
|
|
13
|
+
|
|
14
|
+
USAGE
|
|
15
|
+
-----
|
|
16
|
+
python pdf2notes.py --pdf book.pdf --pages 120-180 \
|
|
17
|
+
--api-key-env NVIDIA_API_KEY \
|
|
18
|
+
--base-url https://integrate.api.nvidia.com/v1 \
|
|
19
|
+
--model nvidia/llama-3.3-nemotron-super-49b-v1.5 \
|
|
20
|
+
--style descriptive \
|
|
21
|
+
--concurrency 5 --rpm 40 \
|
|
22
|
+
--output notes.md
|
|
23
|
+
|
|
24
|
+
Set the API key as an environment variable first, e.g.:
|
|
25
|
+
export NVIDIA_API_KEY="nvapi-xxxxxxxx"
|
|
26
|
+
|
|
27
|
+
NOTE: NVIDIA (and other providers) periodically retire model IDs. If you
|
|
28
|
+
get an HTTP 404/410 error, check the current catalog at
|
|
29
|
+
https://build.nvidia.com/models and pass the correct --model.
|
|
30
|
+
|
|
31
|
+
If a run is interrupted or fails partway, just rerun the exact same
|
|
32
|
+
command — chunks already written to the output file are skipped.
|
|
33
|
+
|
|
34
|
+
Run `python pdf2notes.py --help` for all options.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import asyncio
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from collections import deque
|
|
44
|
+
from datetime import datetime
|
|
45
|
+
|
|
46
|
+
import pdfplumber
|
|
47
|
+
from openai import (
|
|
48
|
+
AsyncOpenAI,
|
|
49
|
+
APIStatusError,
|
|
50
|
+
APIConnectionError,
|
|
51
|
+
RateLimitError,
|
|
52
|
+
InternalServerError,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Prompt templates per note style
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
STYLE_PROMPTS = {
|
|
61
|
+
"descriptive": (
|
|
62
|
+
"You are an expert study-notes writer. Turn the given book excerpt into "
|
|
63
|
+
"detailed, descriptive notes a learner can review later WITHOUT rereading "
|
|
64
|
+
"the original text. Explain concepts in your own words, don't just "
|
|
65
|
+
"shorten sentences. Include definitions, examples, cause-effect "
|
|
66
|
+
"relationships, and any numbers, names, or terms that matter. "
|
|
67
|
+
"Use Markdown headers (##, ###) for structure and bullet points for "
|
|
68
|
+
"detail. Where useful, add a one-line 'Why it matters' or 'Key "
|
|
69
|
+
"takeaway' callout. Do not skip content just because it seems minor — "
|
|
70
|
+
"the goal is a faithful, learnable rewrite, not a compressed summary."
|
|
71
|
+
),
|
|
72
|
+
"cornell": (
|
|
73
|
+
"You are an expert study-notes writer using the Cornell Notes method. "
|
|
74
|
+
"For the given book excerpt, produce Markdown with three parts: "
|
|
75
|
+
"1) '## Cues' — a bullet list of key terms/questions, "
|
|
76
|
+
"2) '## Notes' — detailed explanatory notes matching each cue, "
|
|
77
|
+
"3) '## Summary' — a short paragraph summarizing the excerpt. "
|
|
78
|
+
"Be thorough in the Notes section — this is what the learner studies from."
|
|
79
|
+
),
|
|
80
|
+
"qa": (
|
|
81
|
+
"You are an expert study-notes writer. Convert the given book excerpt "
|
|
82
|
+
"into a thorough set of Markdown Q&A flashcard-style notes: "
|
|
83
|
+
"'### Q: <question>' followed by a detailed '**A:** <answer>'. Cover "
|
|
84
|
+
"every important concept, definition, and detail in the excerpt — "
|
|
85
|
+
"write enough questions to fully capture the material, not just the "
|
|
86
|
+
"obvious ones."
|
|
87
|
+
),
|
|
88
|
+
"outline": (
|
|
89
|
+
"You are an expert study-notes writer. Convert the given book excerpt "
|
|
90
|
+
"into a deeply nested Markdown outline (using #, ##, ###, and nested "
|
|
91
|
+
"bullet points) that mirrors the structure of the content and captures "
|
|
92
|
+
"every important detail, definition, and example in the source."
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
SYSTEM_SUFFIX = (
|
|
97
|
+
"\n\nFormatting rules:\n"
|
|
98
|
+
"- Output ONLY Markdown, no preamble like 'Here are the notes'.\n"
|
|
99
|
+
"- Start directly with a level-2 heading (##) that names the topic of "
|
|
100
|
+
"this excerpt.\n"
|
|
101
|
+
"- Reference page numbers in parentheses, e.g. (p. 42), when introducing "
|
|
102
|
+
"a major concept, so the notes stay traceable to the source.\n"
|
|
103
|
+
"- Never say things like 'the author discusses' — just present the "
|
|
104
|
+
"content directly, as notes.\n"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# PDF extraction (memory-conscious: flush pdfplumber's per-page cache
|
|
110
|
+
# immediately after pulling text, instead of letting fonts/layout objects
|
|
111
|
+
# for the whole range pile up in memory)
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def extract_pages(pdf_path, start_page, end_page):
|
|
115
|
+
"""Returns list of (page_number, text) for the given 1-indexed inclusive range."""
|
|
116
|
+
pages = []
|
|
117
|
+
with pdfplumber.open(pdf_path) as pdf:
|
|
118
|
+
total = len(pdf.pages)
|
|
119
|
+
if start_page < 1 or end_page > total or start_page > end_page:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Invalid page range {start_page}-{end_page}. "
|
|
122
|
+
f"This PDF has {total} pages."
|
|
123
|
+
)
|
|
124
|
+
for i in range(start_page - 1, end_page):
|
|
125
|
+
page = pdf.pages[i]
|
|
126
|
+
text = page.extract_text() or ""
|
|
127
|
+
pages.append((i + 1, text))
|
|
128
|
+
# Release this page's cached fonts/chars/layout objects now —
|
|
129
|
+
# otherwise pdfplumber keeps them alive for the whole range.
|
|
130
|
+
page.flush_cache()
|
|
131
|
+
return pages
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def chunk_pages(pages, max_chars=6000):
|
|
135
|
+
"""
|
|
136
|
+
Groups consecutive pages into chunks under max_chars, so each API call
|
|
137
|
+
covers a coherent span of pages. Returns list of (first_pg, last_pg, text).
|
|
138
|
+
"""
|
|
139
|
+
chunks = []
|
|
140
|
+
cur_text = []
|
|
141
|
+
cur_start = None
|
|
142
|
+
cur_len = 0
|
|
143
|
+
last_pg_seen = None
|
|
144
|
+
|
|
145
|
+
for pg_num, text in pages:
|
|
146
|
+
last_pg_seen = pg_num
|
|
147
|
+
text = text.strip()
|
|
148
|
+
if not text:
|
|
149
|
+
continue
|
|
150
|
+
if cur_start is None:
|
|
151
|
+
cur_start = pg_num
|
|
152
|
+
addition = f"\n\n[--- page {pg_num} ---]\n{text}"
|
|
153
|
+
if cur_len + len(addition) > max_chars and cur_text:
|
|
154
|
+
chunks.append((cur_start, pg_num - 1, "".join(cur_text)))
|
|
155
|
+
cur_text = [addition]
|
|
156
|
+
cur_start = pg_num
|
|
157
|
+
cur_len = len(addition)
|
|
158
|
+
else:
|
|
159
|
+
cur_text.append(addition)
|
|
160
|
+
cur_len += len(addition)
|
|
161
|
+
|
|
162
|
+
if cur_text:
|
|
163
|
+
chunks.append((cur_start, last_pg_seen, "".join(cur_text)))
|
|
164
|
+
|
|
165
|
+
return chunks
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Resume support: figure out which chunks are already in the output file
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def load_completed_ranges(output_path):
|
|
173
|
+
if not os.path.exists(output_path):
|
|
174
|
+
return set()
|
|
175
|
+
with open(output_path, "r", encoding="utf-8") as f:
|
|
176
|
+
content = f.read()
|
|
177
|
+
return {
|
|
178
|
+
(int(a), int(b))
|
|
179
|
+
for a, b in re.findall(r"<!-- pages (\d+)-(\d+) -->", content)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Rate limiter (sliding window, requests/minute) — shared across all
|
|
185
|
+
# concurrent workers so total request rate stays under your provider's limit
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
class RateLimiter:
|
|
189
|
+
def __init__(self, rpm):
|
|
190
|
+
self.rpm = max(rpm, 1)
|
|
191
|
+
self.lock = asyncio.Lock()
|
|
192
|
+
self.timestamps = deque()
|
|
193
|
+
|
|
194
|
+
async def acquire(self):
|
|
195
|
+
async with self.lock:
|
|
196
|
+
while True:
|
|
197
|
+
now = time.monotonic()
|
|
198
|
+
while self.timestamps and now - self.timestamps[0] > 60:
|
|
199
|
+
self.timestamps.popleft()
|
|
200
|
+
if len(self.timestamps) < self.rpm:
|
|
201
|
+
self.timestamps.append(now)
|
|
202
|
+
return
|
|
203
|
+
wait = 60 - (now - self.timestamps[0]) + 0.05
|
|
204
|
+
await asyncio.sleep(max(wait, 0.05))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# AI call (async, with retry only for transient errors)
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
NON_RETRYABLE_HINT = (
|
|
212
|
+
"This usually means the --model name is wrong or has been retired, or "
|
|
213
|
+
"the API key / --base-url is invalid. Check your provider's current "
|
|
214
|
+
"model list (e.g. https://build.nvidia.com/models) and retry with the "
|
|
215
|
+
"correct --model."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def generate_notes_for_chunk(client, limiter, semaphore, model, style,
|
|
220
|
+
chunk_text, first_pg, last_pg, stop_event,
|
|
221
|
+
max_retries=4):
|
|
222
|
+
system_prompt = STYLE_PROMPTS[style] + SYSTEM_SUFFIX
|
|
223
|
+
user_prompt = f"Book excerpt covering pages {first_pg}-{last_pg}:\n\n{chunk_text}"
|
|
224
|
+
|
|
225
|
+
async with semaphore:
|
|
226
|
+
delay = 2
|
|
227
|
+
for attempt in range(1, max_retries + 1):
|
|
228
|
+
if stop_event.is_set():
|
|
229
|
+
raise RuntimeError("cancelled: a fatal error occurred elsewhere")
|
|
230
|
+
|
|
231
|
+
await limiter.acquire()
|
|
232
|
+
try:
|
|
233
|
+
resp = await client.chat.completions.create(
|
|
234
|
+
model=model,
|
|
235
|
+
messages=[
|
|
236
|
+
{"role": "system", "content": system_prompt},
|
|
237
|
+
{"role": "user", "content": user_prompt},
|
|
238
|
+
],
|
|
239
|
+
temperature=0.3,
|
|
240
|
+
)
|
|
241
|
+
return resp.choices[0].message.content.strip()
|
|
242
|
+
|
|
243
|
+
except (RateLimitError, InternalServerError, APIConnectionError) as e:
|
|
244
|
+
if attempt == max_retries:
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f"gave up after {max_retries} attempts (pages "
|
|
247
|
+
f"{first_pg}-{last_pg}): {e}"
|
|
248
|
+
) from e
|
|
249
|
+
print(f" [pages {first_pg}-{last_pg}] transient error "
|
|
250
|
+
f"({type(e).__name__}), retrying in {delay}s...", file=sys.stderr)
|
|
251
|
+
await asyncio.sleep(delay)
|
|
252
|
+
delay *= 2
|
|
253
|
+
|
|
254
|
+
except APIStatusError as e:
|
|
255
|
+
# Non-retryable: bad model, auth, deprecated model (404/410), etc.
|
|
256
|
+
# Retrying this would just waste your rate-limit budget.
|
|
257
|
+
raise RuntimeError(
|
|
258
|
+
f"HTTP {e.status_code} on pages {first_pg}-{last_pg}: "
|
|
259
|
+
f"{e.message}. {NON_RETRYABLE_HINT}"
|
|
260
|
+
) from e
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Async pipeline
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async def run_pipeline(args, chunks, output_path, frontmatter, api_key):
|
|
268
|
+
completed = load_completed_ranges(output_path)
|
|
269
|
+
|
|
270
|
+
if not os.path.exists(output_path):
|
|
271
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
272
|
+
f.write(frontmatter)
|
|
273
|
+
|
|
274
|
+
pending = [
|
|
275
|
+
(i, f, l, t) for i, (f, l, t) in enumerate(chunks) if (f, l) not in completed
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
already_done = len(chunks) - len(pending)
|
|
279
|
+
if already_done:
|
|
280
|
+
print(f"{already_done} chunk(s) already in {output_path} — skipping those.")
|
|
281
|
+
if not pending:
|
|
282
|
+
print("Nothing left to generate.")
|
|
283
|
+
return 0, None
|
|
284
|
+
|
|
285
|
+
print(f"Generating {len(pending)} chunk(s) "
|
|
286
|
+
f"(concurrency={args.concurrency}, rate limit={args.rpm}/min)...")
|
|
287
|
+
|
|
288
|
+
client = AsyncOpenAI(api_key=api_key, base_url=args.base_url)
|
|
289
|
+
limiter = RateLimiter(args.rpm)
|
|
290
|
+
semaphore = asyncio.Semaphore(args.concurrency)
|
|
291
|
+
stop_event = asyncio.Event()
|
|
292
|
+
|
|
293
|
+
results = {}
|
|
294
|
+
errors = {}
|
|
295
|
+
|
|
296
|
+
async def worker(idx, first_pg, last_pg, text):
|
|
297
|
+
if stop_event.is_set():
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
notes = await generate_notes_for_chunk(
|
|
301
|
+
client, limiter, semaphore, args.model, args.style,
|
|
302
|
+
text, first_pg, last_pg, stop_event,
|
|
303
|
+
)
|
|
304
|
+
results[idx] = (first_pg, last_pg, notes)
|
|
305
|
+
print(f" done: pages {first_pg}-{last_pg} "
|
|
306
|
+
f"({len(results)}/{len(pending)})")
|
|
307
|
+
except RuntimeError as e:
|
|
308
|
+
errors[idx] = (first_pg, last_pg, e)
|
|
309
|
+
stop_event.set()
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
tasks = [asyncio.create_task(worker(i, f, l, t)) for i, f, l, t in pending]
|
|
313
|
+
await asyncio.gather(*tasks)
|
|
314
|
+
finally:
|
|
315
|
+
await client.close()
|
|
316
|
+
|
|
317
|
+
# Flush a *contiguous* prefix of newly-completed chunks in page order.
|
|
318
|
+
# Anything after the first gap (still running/failed) is left for a
|
|
319
|
+
# rerun — resume picks it up via the <!-- pages a-b --> markers.
|
|
320
|
+
written = 0
|
|
321
|
+
with open(output_path, "a", encoding="utf-8") as fh:
|
|
322
|
+
for i, (f, l, _t) in enumerate(chunks):
|
|
323
|
+
if (f, l) in completed:
|
|
324
|
+
continue
|
|
325
|
+
if i not in results:
|
|
326
|
+
break
|
|
327
|
+
first_pg, last_pg, notes = results[i]
|
|
328
|
+
fh.write(f"\n\n<!-- pages {first_pg}-{last_pg} -->\n{notes}\n")
|
|
329
|
+
written += 1
|
|
330
|
+
|
|
331
|
+
fatal = None
|
|
332
|
+
if errors:
|
|
333
|
+
first_idx = min(errors)
|
|
334
|
+
fatal = errors[first_idx]
|
|
335
|
+
|
|
336
|
+
return written, fatal
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Main
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def parse_page_range(s):
|
|
344
|
+
m = re.match(r"^\s*(\d+)\s*-\s*(\d+)\s*$", s)
|
|
345
|
+
if not m:
|
|
346
|
+
raise argparse.ArgumentTypeError("Pages must look like START-END, e.g. 120-180")
|
|
347
|
+
return int(m.group(1)), int(m.group(2))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def main():
|
|
351
|
+
parser = argparse.ArgumentParser(
|
|
352
|
+
prog="pdf2notes",
|
|
353
|
+
description="Convert a PDF page range into AI-generated study notes."
|
|
354
|
+
)
|
|
355
|
+
parser.add_argument("--pdf", required=True, help="Path to the source PDF")
|
|
356
|
+
parser.add_argument("--pages", required=True, type=parse_page_range,
|
|
357
|
+
help="Page range, e.g. 120-180 (inclusive, 1-indexed)")
|
|
358
|
+
parser.add_argument("--output", default=None,
|
|
359
|
+
help="Output .md file (default: <pdf-name>_notes_<range>.md)")
|
|
360
|
+
parser.add_argument("--style", choices=list(STYLE_PROMPTS.keys()),
|
|
361
|
+
default="descriptive", help="Note style (default: descriptive)")
|
|
362
|
+
parser.add_argument("--chunk-chars", type=int, default=6000,
|
|
363
|
+
help="Max characters of source text per API call (default 6000)")
|
|
364
|
+
parser.add_argument("--concurrency", type=int, default=30,
|
|
365
|
+
help="Max simultaneous API requests (default 5)")
|
|
366
|
+
parser.add_argument("--rpm", type=int, default=40,
|
|
367
|
+
help="Max API requests per minute, across all workers (default 40) "
|
|
368
|
+
"— set this to match your provider's actual rate limit")
|
|
369
|
+
parser.add_argument("--model", default="nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
|
370
|
+
help="Model name as your provider expects it. Providers retire "
|
|
371
|
+
"model IDs periodically — check your provider's current "
|
|
372
|
+
"catalog if this default 404s/410s.")
|
|
373
|
+
parser.add_argument("--base-url", default="https://integrate.api.nvidia.com/v1",
|
|
374
|
+
help="OpenAI-compatible API base URL "
|
|
375
|
+
"(default: NVIDIA NIM). Use https://api.openai.com/v1 "
|
|
376
|
+
"for OpenAI, http://localhost:11434/v1 for local, etc.")
|
|
377
|
+
parser.add_argument("--api-key", default=None,
|
|
378
|
+
help="API key (avoid this on shared machines — prefer --api-key-env)")
|
|
379
|
+
parser.add_argument("--api-key-env", default="NVIDIA_API_KEY",
|
|
380
|
+
help="Env var name holding the API key (default: NVIDIA_API_KEY)")
|
|
381
|
+
args = parser.parse_args()
|
|
382
|
+
|
|
383
|
+
api_key = args.api_key or os.environ.get(args.api_key_env)
|
|
384
|
+
if not api_key:
|
|
385
|
+
print(
|
|
386
|
+
f"No API key found. Set it with:\n"
|
|
387
|
+
f" export {args.api_key_env}=\"your-key-here\"\n"
|
|
388
|
+
f"or pass --api-key directly.",
|
|
389
|
+
file=sys.stderr,
|
|
390
|
+
)
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
|
|
393
|
+
start_page, end_page = args.pages
|
|
394
|
+
output_path = args.output or (
|
|
395
|
+
f"{os.path.splitext(os.path.basename(args.pdf))[0]}"
|
|
396
|
+
f"_notes_{start_page}-{end_page}.md"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
print(f"Extracting pages {start_page}-{end_page} from {args.pdf} ...")
|
|
400
|
+
pages = extract_pages(args.pdf, start_page, end_page)
|
|
401
|
+
|
|
402
|
+
non_empty = sum(1 for _, t in pages if t.strip())
|
|
403
|
+
if non_empty == 0:
|
|
404
|
+
print(
|
|
405
|
+
"No extractable text found in this range — the PDF might be "
|
|
406
|
+
"scanned/image-based. This tool needs OCR'd text first "
|
|
407
|
+
"(e.g. run it through an OCR tool, then retry).",
|
|
408
|
+
file=sys.stderr,
|
|
409
|
+
)
|
|
410
|
+
sys.exit(1)
|
|
411
|
+
|
|
412
|
+
chunks = chunk_pages(pages, max_chars=args.chunk_chars)
|
|
413
|
+
del pages # done with raw page text; chunks hold what we still need
|
|
414
|
+
print(f"Split into {len(chunks)} chunk(s) for note generation.")
|
|
415
|
+
|
|
416
|
+
book_title = os.path.splitext(os.path.basename(args.pdf))[0].replace("_", " ")
|
|
417
|
+
frontmatter = (
|
|
418
|
+
"---\n"
|
|
419
|
+
f"title: \"{book_title} — Notes (pp. {start_page}-{end_page})\"\n"
|
|
420
|
+
f"source: \"{os.path.basename(args.pdf)}\"\n"
|
|
421
|
+
f"pages: \"{start_page}-{end_page}\"\n"
|
|
422
|
+
f"style: {args.style}\n"
|
|
423
|
+
f"generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
|
|
424
|
+
"tags: [book-notes]\n"
|
|
425
|
+
"---\n\n"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
written, fatal = asyncio.run(
|
|
429
|
+
run_pipeline(args, chunks, output_path, frontmatter, api_key)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if fatal:
|
|
433
|
+
first_pg, last_pg, err = fatal
|
|
434
|
+
print(f"\nStopped due to an error on pages {first_pg}-{last_pg}:\n {err}",
|
|
435
|
+
file=sys.stderr)
|
|
436
|
+
if written:
|
|
437
|
+
print(f"{written} new chunk(s) were still saved to {output_path}.",
|
|
438
|
+
file=sys.stderr)
|
|
439
|
+
print("Fix the issue above, then rerun the exact same command — "
|
|
440
|
+
"completed chunks are skipped automatically.", file=sys.stderr)
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
|
|
443
|
+
print(f"\nDone. Notes saved to: {output_path}")
|
|
444
|
+
print("Drop this file straight into your Obsidian vault, or import into Notion "
|
|
445
|
+
"(Import > Markdown).")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
main()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf2notes
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Turn a PDF page range into AI-generated study notes (Markdown) for Obsidian/Notion
|
|
5
|
+
Author: Sandeep Singh
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SandeepSinghSethi/pdf2notes
|
|
8
|
+
Keywords: pdf,notes,obsidian,notion,study,ai,llm
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pdfplumber>=0.11.0
|
|
19
|
+
Requires-Dist: openai>=1.0.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# pdf2notes
|
|
23
|
+
|
|
24
|
+
Turn a page range of a PDF book into descriptive, review-ready study notes
|
|
25
|
+
(Markdown) — ready to drop into **Obsidian** or import into **Notion**.
|
|
26
|
+
|
|
27
|
+
Works with any **OpenAI-compatible** chat completion API: NVIDIA NIM
|
|
28
|
+
(`integrate.api.nvidia.com`), OpenAI, Groq, Together AI, OpenRouter, or a
|
|
29
|
+
local server (Ollama, vLLM, LM Studio). You bring your own API key.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -r requirements.txt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Get an API key (NVIDIA NIM example — free tier available)
|
|
38
|
+
|
|
39
|
+
1. Go to https://build.nvidia.com
|
|
40
|
+
2. Sign in, pick a model (e.g. `meta/llama-3.1-70b-instruct`), click "Get API Key"
|
|
41
|
+
3. Export it:
|
|
42
|
+
```bash
|
|
43
|
+
export NVIDIA_API_KEY="nvapi-xxxxxxxxxxxxxxxx"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Any other OpenAI-compatible provider works the same way — just pass
|
|
47
|
+
`--base-url` and `--api-key-env` for that provider.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python pdf2notes.py --pdf book.pdf --pages 120-180 --api-key-env NVIDIA_API_KEY
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This writes `book_notes_120-180.md` in the current folder. Requests run
|
|
56
|
+
concurrently and are throttled to a requests-per-minute cap, so a 100-page
|
|
57
|
+
range finishes in a couple of minutes instead of an hour.
|
|
58
|
+
|
|
59
|
+
If the run is interrupted, hits a persistent error, or you just Ctrl-C it,
|
|
60
|
+
**rerun the exact same command** — chunks already saved in the output file
|
|
61
|
+
are detected and skipped, so you only pay for/wait on what's missing.
|
|
62
|
+
|
|
63
|
+
### Common options
|
|
64
|
+
|
|
65
|
+
| Flag | Meaning | Default |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `--pdf` | Path to the source PDF | required |
|
|
68
|
+
| `--pages` | Page range, e.g. `120-180` (1-indexed, inclusive) | required |
|
|
69
|
+
| `--output` | Output `.md` path | `<pdf-name>_notes_<range>.md` |
|
|
70
|
+
| `--style` | `descriptive`, `cornell`, `qa`, or `outline` | `descriptive` |
|
|
71
|
+
| `--chunk-chars` | Max source characters sent per API call | `6000` |
|
|
72
|
+
| `--concurrency` | Max simultaneous API requests | `5` |
|
|
73
|
+
| `--rpm` | Max API requests per minute (across all workers) | `40` |
|
|
74
|
+
| `--model` | Model name as your provider expects it | `nvidia/llama-3.3-nemotron-super-49b-v1.5` |
|
|
75
|
+
| `--base-url` | OpenAI-compatible API base URL | NVIDIA NIM endpoint |
|
|
76
|
+
| `--api-key-env` | Env var holding your key | `NVIDIA_API_KEY` |
|
|
77
|
+
|
|
78
|
+
**Tune `--rpm` to your actual plan.** NVIDIA's free tier, OpenAI's free/low
|
|
79
|
+
tiers, etc. all cap requests per minute — check your provider's dashboard
|
|
80
|
+
and set `--rpm` a bit under that. Setting it too high just means the tool
|
|
81
|
+
eats a 429 and backs off; setting `--concurrency` too high does the same
|
|
82
|
+
without helping speed once you're rpm-bound.
|
|
83
|
+
|
|
84
|
+
**Model IDs get retired.** NVIDIA (and other providers) periodically pull
|
|
85
|
+
old model IDs from the catalog — you'll get an HTTP 404/410 if that
|
|
86
|
+
happens. The tool detects this as non-retryable and fails immediately with
|
|
87
|
+
the error instead of burning your rate-limit budget retrying a request
|
|
88
|
+
that will never succeed. Check https://build.nvidia.com/models for current
|
|
89
|
+
IDs and pass the right one via `--model`.
|
|
90
|
+
|
|
91
|
+
### Examples
|
|
92
|
+
|
|
93
|
+
Using OpenAI instead of NVIDIA:
|
|
94
|
+
```bash
|
|
95
|
+
export OPENAI_API_KEY="sk-..."
|
|
96
|
+
python pdf2notes.py --pdf book.pdf --pages 1-50 \
|
|
97
|
+
--base-url https://api.openai.com/v1 \
|
|
98
|
+
--model gpt-4o-mini \
|
|
99
|
+
--api-key-env OPENAI_API_KEY
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Q&A flashcard-style notes instead of descriptive prose:
|
|
103
|
+
```bash
|
|
104
|
+
python pdf2notes.py --pdf book.pdf --pages 200-260 --style qa
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Notes on how it works
|
|
108
|
+
|
|
109
|
+
1. Extracts text from the given page range with `pdfplumber`.
|
|
110
|
+
2. Groups pages into chunks (default ~6000 chars each) so each API call
|
|
111
|
+
covers a coherent span without blowing past context limits.
|
|
112
|
+
3. Sends each chunk to the model with a prompt tuned to produce **learnable
|
|
113
|
+
notes**, not a shortened summary — definitions, examples, and important
|
|
114
|
+
details are preserved and explained.
|
|
115
|
+
4. Stitches everything into one Markdown file with YAML frontmatter
|
|
116
|
+
(title, source, page range, style, timestamp) so it's ready to file
|
|
117
|
+
straight into Obsidian, or import into Notion via *Import → Markdown*.
|
|
118
|
+
|
|
119
|
+
## Limitations
|
|
120
|
+
|
|
121
|
+
- **Scanned/image-only PDFs** won't extract text — OCR the PDF first (e.g.
|
|
122
|
+
with `pytesseract` + `pdf2image`), then run this tool on the OCR'd version.
|
|
123
|
+
- Very dense chunking settings (`--chunk-chars` too high) can exceed your
|
|
124
|
+
model's context window — 6000–8000 is a safe default for most models.
|
|
125
|
+
- Quality depends on the model you point it at — bigger instruction-tuned
|
|
126
|
+
models produce noticeably better notes than small ones.
|
|
127
|
+
- **Memory**: page text is extracted one page at a time and each page's
|
|
128
|
+
internal pdfplumber cache (fonts, layout objects) is flushed immediately,
|
|
129
|
+
so memory stays roughly proportional to a chunk's text, not the whole
|
|
130
|
+
page range. For genuinely huge ranges (500+ pages in one run), consider
|
|
131
|
+
splitting into a couple of `--pages` calls instead of one giant one.
|
|
132
|
+
- **A crash/segfault message *after* "Done."**: if you see a SIGSEGV or
|
|
133
|
+
similar right as the process exits but *after* your notes file is
|
|
134
|
+
already complete and readable, it's happening during Python interpreter
|
|
135
|
+
teardown, not during note generation — a known quirk of some PDF C
|
|
136
|
+
extensions (e.g. pypdfium2, used internally by pdfplumber) not tearing
|
|
137
|
+
down cleanly at exit. It doesn't affect your output file. If you want to
|
|
138
|
+
suppress the noise, run with `python pdf2notes.py ... ; true` in a
|
|
139
|
+
script, or ignore the exit code.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.cfg
|
|
5
|
+
src/pdf2notes/__init__.py
|
|
6
|
+
src/pdf2notes/cli.py
|
|
7
|
+
src/pdf2notes.egg-info/PKG-INFO
|
|
8
|
+
src/pdf2notes.egg-info/SOURCES.txt
|
|
9
|
+
src/pdf2notes.egg-info/dependency_links.txt
|
|
10
|
+
src/pdf2notes.egg-info/entry_points.txt
|
|
11
|
+
src/pdf2notes.egg-info/requires.txt
|
|
12
|
+
src/pdf2notes.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pdf2notes
|