satchel-reader 0.1.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.
- satchel_reader-0.1.0/LICENSE +21 -0
- satchel_reader-0.1.0/PKG-INFO +185 -0
- satchel_reader-0.1.0/README.md +163 -0
- satchel_reader-0.1.0/pyproject.toml +45 -0
- satchel_reader-0.1.0/satchel/__init__.py +1 -0
- satchel_reader-0.1.0/satchel/capture.py +44 -0
- satchel_reader-0.1.0/satchel/cli.py +99 -0
- satchel_reader-0.1.0/satchel/db.py +101 -0
- satchel_reader-0.1.0/satchel/extract.py +30 -0
- satchel_reader-0.1.0/satchel/fetch.py +115 -0
- satchel_reader-0.1.0/satchel/serve.py +109 -0
- satchel_reader-0.1.0/satchel_reader.egg-info/PKG-INFO +185 -0
- satchel_reader-0.1.0/satchel_reader.egg-info/SOURCES.txt +17 -0
- satchel_reader-0.1.0/satchel_reader.egg-info/dependency_links.txt +1 -0
- satchel_reader-0.1.0/satchel_reader.egg-info/entry_points.txt +2 -0
- satchel_reader-0.1.0/satchel_reader.egg-info/requires.txt +1 -0
- satchel_reader-0.1.0/satchel_reader.egg-info/top_level.txt +1 -0
- satchel_reader-0.1.0/setup.cfg +4 -0
- satchel_reader-0.1.0/tests/test_satchel.py +404 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ritish Saini
|
|
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.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: satchel-reader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local-first reading queue: saves the real article text, not just the link, and makes it genuinely offline-searchable.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/MaXiMo000/satchel
|
|
7
|
+
Project-URL: Source, https://github.com/MaXiMo000/satchel
|
|
8
|
+
Project-URL: Issues, https://github.com/MaXiMo000/satchel/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/MaXiMo000/satchel/releases
|
|
10
|
+
Keywords: reading,offline,full-text-search,sqlite,local-first
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Text Processing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: trafilatura>=2.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# satchel
|
|
24
|
+
|
|
25
|
+
**A tiny local archive of the actual things you read.**
|
|
26
|
+
|
|
27
|
+
[](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml)
|
|
28
|
+
|
|
29
|
+
Most "read later" tools save a URL. The link rots, the site adds a
|
|
30
|
+
paywall, or you're offline — and the thing you saved is gone. `satchel`
|
|
31
|
+
fetches the page once, extracts the actual article text (not the nav, not
|
|
32
|
+
the related-links box, not the footer), and stores it in one local SQLite
|
|
33
|
+
file with real full-text search. No server, no account, no network needed
|
|
34
|
+
to search what you've already saved.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
$ satchel add https://example.com/some-article
|
|
38
|
+
saved #4: Why Local-First Software Is Worth the Extra Effort
|
|
39
|
+
|
|
40
|
+
$ satchel search "FTS5"
|
|
41
|
+
#4 Why Local-First Software Is Worth the Extra Effort (https://example.com/some-article)
|
|
42
|
+
|
|
43
|
+
$ satchel read 4
|
|
44
|
+
Why Local-First Software Is Worth the Extra Effort
|
|
45
|
+
by Jordan Rivers
|
|
46
|
+
|
|
47
|
+
Most reading tools save a link and call it done...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Save with one click instead of a terminal: `satchel serve` runs a local
|
|
51
|
+
listener and prints a bookmarklet — click it on any page and that page is
|
|
52
|
+
saved through the exact same pipeline `add` uses. See "Capture" below.
|
|
53
|
+
|
|
54
|
+
## How
|
|
55
|
+
|
|
56
|
+
- `fetch.py` — plain `urllib` GET. Stdlib, no dependency for the easy
|
|
57
|
+
part. Always rejects non-http(s) schemes; a URL that didn't come from
|
|
58
|
+
the person running satchel (see "Capture") is additionally checked
|
|
59
|
+
against loopback/private/link-local address space before and after
|
|
60
|
+
every redirect hop, not just the first one.
|
|
61
|
+
- `capture.py` — fetch → extract → store, in one place. Both `add` and the
|
|
62
|
+
capture listener call this; there is exactly one add pipeline.
|
|
63
|
+
- `extract.py` — wraps [trafilatura](https://github.com/adbar/trafilatura)
|
|
64
|
+
to pull title/author/main-text out of real HTML, correctly skipping
|
|
65
|
+
navigation, ads, and related-links boilerplate. Boilerplate-stripping is
|
|
66
|
+
exactly the kind of thing that looks fine on a hand-rolled test page and
|
|
67
|
+
breaks on the next real site's markup — not worth reimplementing.
|
|
68
|
+
- `db.py` — one SQLite file, an FTS5 virtual table kept in sync with the
|
|
69
|
+
real table via triggers (external-content FTS5: the searchable index
|
|
70
|
+
isn't a second copy of the truth that can drift from the first).
|
|
71
|
+
- `serve.py` — the local capture listener (below).
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install satchel-reader # the command it installs is `satchel`
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or from a checkout, for development:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install -e .
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Use
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
satchel add <url> # fetch, extract, save
|
|
89
|
+
satchel list # everything saved
|
|
90
|
+
satchel search <query> # full-text search
|
|
91
|
+
satchel read <id> # print an article's full text
|
|
92
|
+
satchel serve # one-click capture -- see below
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
All commands take `--db path/to/file.db`. The default, if you don't pass
|
|
96
|
+
one, is `~/.local/share/satchel/satchel.db` (respecting `XDG_DATA_HOME`) —
|
|
97
|
+
one stable location regardless of which directory you happen to run the
|
|
98
|
+
command from, not `./satchel.db` in the current directory. Run
|
|
99
|
+
`satchel --help` to see the exact resolved path on your machine.
|
|
100
|
+
|
|
101
|
+
Saving the same article twice — via a tracking link, a shortener, or a
|
|
102
|
+
plain `http://` URL the site itself upgrades to `https://` — is one
|
|
103
|
+
duplicate, not two. `add` normalizes the URL it's given, follows
|
|
104
|
+
redirects, and normalizes the *actual* address the server served before
|
|
105
|
+
checking for a duplicate: it defers to what the server says, rather than
|
|
106
|
+
guessing at a scheme policy.
|
|
107
|
+
|
|
108
|
+
## Capture
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
$ satchel serve
|
|
112
|
+
satchel capture listening on http://127.0.0.1:8765 (db: ~/.local/share/satchel/satchel.db)
|
|
113
|
+
|
|
114
|
+
Drag this to your bookmarks bar, then click it on any page to save it:
|
|
115
|
+
|
|
116
|
+
javascript:fetch('http://127.0.0.1:8765/add?token=...&url='+encodeURIComponent(location.href))...
|
|
117
|
+
|
|
118
|
+
^C to stop.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Drag the printed link to your bookmarks bar. Click it on any page while
|
|
122
|
+
`serve` is running, and that page goes through the same fetch/extract/save
|
|
123
|
+
pipeline as `satchel add` — no terminal required for the thing you do most
|
|
124
|
+
often.
|
|
125
|
+
|
|
126
|
+
**This changes the threat model, and it's handled, not ignored.** While
|
|
127
|
+
`serve` is running, any tab open in your browser can send it a request —
|
|
128
|
+
a browser's same-origin policy stops a page from *reading* a
|
|
129
|
+
cross-origin response it wasn't granted, but not from *sending* the
|
|
130
|
+
request in the first place. Without a shared secret, that would be an
|
|
131
|
+
open invitation for any open tab to make satchel fetch an arbitrary URL.
|
|
132
|
+
So: the listener only binds to `127.0.0.1`, a fresh token is generated
|
|
133
|
+
every time you run `serve` and only ever appears in the bookmarklet you
|
|
134
|
+
just dragged, and every captured URL is checked against loopback,
|
|
135
|
+
private (RFC1918), link-local (this is what closes off
|
|
136
|
+
`169.254.169.254`-style cloud metadata endpoints), reserved, and
|
|
137
|
+
multicast address space — before the first request, and again on every
|
|
138
|
+
redirect hop, since checking only the first hop would let a URL redirect
|
|
139
|
+
somewhere private after passing the initial check.
|
|
140
|
+
|
|
141
|
+
What that guard does *not* claim: it validates a hostname's DNS answer at
|
|
142
|
+
the moment it's checked, it doesn't pin the connection to that exact
|
|
143
|
+
resolved address. A DNS-rebinding attacker with a fast-expiring record
|
|
144
|
+
could in principle still slip a private address past the check and have
|
|
145
|
+
the actual connection re-resolve to it. Closing that fully means
|
|
146
|
+
connecting to a pinned IP rather than by hostname — real added complexity
|
|
147
|
+
for a personal tool's local listener. This is a deliberate, documented
|
|
148
|
+
partial mitigation, not a claim that it's unbreakable.
|
|
149
|
+
|
|
150
|
+
Direct `satchel add <url>` from your own terminal is **not** restricted
|
|
151
|
+
this way — typing your own local dev server's URL to save a draft you're
|
|
152
|
+
writing is a legitimate thing to do, and you are not a threat to
|
|
153
|
+
yourself.
|
|
154
|
+
|
|
155
|
+
## Test
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
python tests/test_satchel.py
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Extraction is tested against a fixture HTML file (`tests/fixtures/`), not
|
|
162
|
+
the network — a real bug was caught this way during development: an
|
|
163
|
+
ambiguous byline (`"By Jordan Rivers · September 2026"` in one text node)
|
|
164
|
+
made trafilatura fold part of the date into the author field. Fixed by
|
|
165
|
+
making the fixture look like well-structured real markup (byline and date
|
|
166
|
+
as separate elements) rather than loosening the assertion.
|
|
167
|
+
|
|
168
|
+
The capture listener is tested the same way it's actually used: a real
|
|
169
|
+
`http.server.HTTPServer` runs in a background thread and gets real HTTP
|
|
170
|
+
requests, including SSRF attempts against loopback and link-local
|
|
171
|
+
addresses — checked with the *correct* token, since the interesting
|
|
172
|
+
question is whether the guard holds once someone's past the door, not
|
|
173
|
+
whether the door itself works.
|
|
174
|
+
|
|
175
|
+
## What's deliberately not here yet
|
|
176
|
+
|
|
177
|
+
No tagging, no folders, no read/unread state — a flat list plus full-text
|
|
178
|
+
search covers the actual workflow (encounter → capture → search → read);
|
|
179
|
+
add these when a flat list genuinely stops being enough, not before. No
|
|
180
|
+
multi-device sync — one local file is the whole pitch, and sync is a
|
|
181
|
+
separate, harder problem this project isn't trying to solve. No AI
|
|
182
|
+
summarization, no embeddings, no recommendation engine: this is an
|
|
183
|
+
archive of what you actually read, not a platform.
|
|
184
|
+
|
|
185
|
+
MIT licensed.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# satchel
|
|
2
|
+
|
|
3
|
+
**A tiny local archive of the actual things you read.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
Most "read later" tools save a URL. The link rots, the site adds a
|
|
8
|
+
paywall, or you're offline — and the thing you saved is gone. `satchel`
|
|
9
|
+
fetches the page once, extracts the actual article text (not the nav, not
|
|
10
|
+
the related-links box, not the footer), and stores it in one local SQLite
|
|
11
|
+
file with real full-text search. No server, no account, no network needed
|
|
12
|
+
to search what you've already saved.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
$ satchel add https://example.com/some-article
|
|
16
|
+
saved #4: Why Local-First Software Is Worth the Extra Effort
|
|
17
|
+
|
|
18
|
+
$ satchel search "FTS5"
|
|
19
|
+
#4 Why Local-First Software Is Worth the Extra Effort (https://example.com/some-article)
|
|
20
|
+
|
|
21
|
+
$ satchel read 4
|
|
22
|
+
Why Local-First Software Is Worth the Extra Effort
|
|
23
|
+
by Jordan Rivers
|
|
24
|
+
|
|
25
|
+
Most reading tools save a link and call it done...
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Save with one click instead of a terminal: `satchel serve` runs a local
|
|
29
|
+
listener and prints a bookmarklet — click it on any page and that page is
|
|
30
|
+
saved through the exact same pipeline `add` uses. See "Capture" below.
|
|
31
|
+
|
|
32
|
+
## How
|
|
33
|
+
|
|
34
|
+
- `fetch.py` — plain `urllib` GET. Stdlib, no dependency for the easy
|
|
35
|
+
part. Always rejects non-http(s) schemes; a URL that didn't come from
|
|
36
|
+
the person running satchel (see "Capture") is additionally checked
|
|
37
|
+
against loopback/private/link-local address space before and after
|
|
38
|
+
every redirect hop, not just the first one.
|
|
39
|
+
- `capture.py` — fetch → extract → store, in one place. Both `add` and the
|
|
40
|
+
capture listener call this; there is exactly one add pipeline.
|
|
41
|
+
- `extract.py` — wraps [trafilatura](https://github.com/adbar/trafilatura)
|
|
42
|
+
to pull title/author/main-text out of real HTML, correctly skipping
|
|
43
|
+
navigation, ads, and related-links boilerplate. Boilerplate-stripping is
|
|
44
|
+
exactly the kind of thing that looks fine on a hand-rolled test page and
|
|
45
|
+
breaks on the next real site's markup — not worth reimplementing.
|
|
46
|
+
- `db.py` — one SQLite file, an FTS5 virtual table kept in sync with the
|
|
47
|
+
real table via triggers (external-content FTS5: the searchable index
|
|
48
|
+
isn't a second copy of the truth that can drift from the first).
|
|
49
|
+
- `serve.py` — the local capture listener (below).
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install satchel-reader # the command it installs is `satchel`
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or from a checkout, for development:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Use
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
satchel add <url> # fetch, extract, save
|
|
67
|
+
satchel list # everything saved
|
|
68
|
+
satchel search <query> # full-text search
|
|
69
|
+
satchel read <id> # print an article's full text
|
|
70
|
+
satchel serve # one-click capture -- see below
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
All commands take `--db path/to/file.db`. The default, if you don't pass
|
|
74
|
+
one, is `~/.local/share/satchel/satchel.db` (respecting `XDG_DATA_HOME`) —
|
|
75
|
+
one stable location regardless of which directory you happen to run the
|
|
76
|
+
command from, not `./satchel.db` in the current directory. Run
|
|
77
|
+
`satchel --help` to see the exact resolved path on your machine.
|
|
78
|
+
|
|
79
|
+
Saving the same article twice — via a tracking link, a shortener, or a
|
|
80
|
+
plain `http://` URL the site itself upgrades to `https://` — is one
|
|
81
|
+
duplicate, not two. `add` normalizes the URL it's given, follows
|
|
82
|
+
redirects, and normalizes the *actual* address the server served before
|
|
83
|
+
checking for a duplicate: it defers to what the server says, rather than
|
|
84
|
+
guessing at a scheme policy.
|
|
85
|
+
|
|
86
|
+
## Capture
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
$ satchel serve
|
|
90
|
+
satchel capture listening on http://127.0.0.1:8765 (db: ~/.local/share/satchel/satchel.db)
|
|
91
|
+
|
|
92
|
+
Drag this to your bookmarks bar, then click it on any page to save it:
|
|
93
|
+
|
|
94
|
+
javascript:fetch('http://127.0.0.1:8765/add?token=...&url='+encodeURIComponent(location.href))...
|
|
95
|
+
|
|
96
|
+
^C to stop.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Drag the printed link to your bookmarks bar. Click it on any page while
|
|
100
|
+
`serve` is running, and that page goes through the same fetch/extract/save
|
|
101
|
+
pipeline as `satchel add` — no terminal required for the thing you do most
|
|
102
|
+
often.
|
|
103
|
+
|
|
104
|
+
**This changes the threat model, and it's handled, not ignored.** While
|
|
105
|
+
`serve` is running, any tab open in your browser can send it a request —
|
|
106
|
+
a browser's same-origin policy stops a page from *reading* a
|
|
107
|
+
cross-origin response it wasn't granted, but not from *sending* the
|
|
108
|
+
request in the first place. Without a shared secret, that would be an
|
|
109
|
+
open invitation for any open tab to make satchel fetch an arbitrary URL.
|
|
110
|
+
So: the listener only binds to `127.0.0.1`, a fresh token is generated
|
|
111
|
+
every time you run `serve` and only ever appears in the bookmarklet you
|
|
112
|
+
just dragged, and every captured URL is checked against loopback,
|
|
113
|
+
private (RFC1918), link-local (this is what closes off
|
|
114
|
+
`169.254.169.254`-style cloud metadata endpoints), reserved, and
|
|
115
|
+
multicast address space — before the first request, and again on every
|
|
116
|
+
redirect hop, since checking only the first hop would let a URL redirect
|
|
117
|
+
somewhere private after passing the initial check.
|
|
118
|
+
|
|
119
|
+
What that guard does *not* claim: it validates a hostname's DNS answer at
|
|
120
|
+
the moment it's checked, it doesn't pin the connection to that exact
|
|
121
|
+
resolved address. A DNS-rebinding attacker with a fast-expiring record
|
|
122
|
+
could in principle still slip a private address past the check and have
|
|
123
|
+
the actual connection re-resolve to it. Closing that fully means
|
|
124
|
+
connecting to a pinned IP rather than by hostname — real added complexity
|
|
125
|
+
for a personal tool's local listener. This is a deliberate, documented
|
|
126
|
+
partial mitigation, not a claim that it's unbreakable.
|
|
127
|
+
|
|
128
|
+
Direct `satchel add <url>` from your own terminal is **not** restricted
|
|
129
|
+
this way — typing your own local dev server's URL to save a draft you're
|
|
130
|
+
writing is a legitimate thing to do, and you are not a threat to
|
|
131
|
+
yourself.
|
|
132
|
+
|
|
133
|
+
## Test
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python tests/test_satchel.py
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Extraction is tested against a fixture HTML file (`tests/fixtures/`), not
|
|
140
|
+
the network — a real bug was caught this way during development: an
|
|
141
|
+
ambiguous byline (`"By Jordan Rivers · September 2026"` in one text node)
|
|
142
|
+
made trafilatura fold part of the date into the author field. Fixed by
|
|
143
|
+
making the fixture look like well-structured real markup (byline and date
|
|
144
|
+
as separate elements) rather than loosening the assertion.
|
|
145
|
+
|
|
146
|
+
The capture listener is tested the same way it's actually used: a real
|
|
147
|
+
`http.server.HTTPServer` runs in a background thread and gets real HTTP
|
|
148
|
+
requests, including SSRF attempts against loopback and link-local
|
|
149
|
+
addresses — checked with the *correct* token, since the interesting
|
|
150
|
+
question is whether the guard holds once someone's past the door, not
|
|
151
|
+
whether the door itself works.
|
|
152
|
+
|
|
153
|
+
## What's deliberately not here yet
|
|
154
|
+
|
|
155
|
+
No tagging, no folders, no read/unread state — a flat list plus full-text
|
|
156
|
+
search covers the actual workflow (encounter → capture → search → read);
|
|
157
|
+
add these when a flat list genuinely stops being enough, not before. No
|
|
158
|
+
multi-device sync — one local file is the whole pitch, and sync is a
|
|
159
|
+
separate, harder problem this project isn't trying to solve. No AI
|
|
160
|
+
summarization, no embeddings, no recommendation engine: this is an
|
|
161
|
+
archive of what you actually read, not a platform.
|
|
162
|
+
|
|
163
|
+
MIT licensed.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# The command stays `satchel`; the distribution cannot -- that name is
|
|
3
|
+
# already taken on PyPI by an unrelated, abandoned package (a single empty
|
|
4
|
+
# "1.0" release from 2018, "Development Status :: 1 - Planning"). A
|
|
5
|
+
# distribution name differing from the command it installs is ordinary
|
|
6
|
+
# (python-dateutil installs `dateutil`).
|
|
7
|
+
name = "satchel-reader"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "A local-first reading queue: saves the real article text, not just the link, and makes it genuinely offline-searchable."
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
keywords = ["reading", "offline", "full-text-search", "sqlite", "local-first"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Text Processing",
|
|
22
|
+
]
|
|
23
|
+
# trafilatura: boilerplate-stripping (nav/ads/footers) is exactly the kind
|
|
24
|
+
# of thing that looks fine on one test page and breaks on the next real
|
|
25
|
+
# site's markup -- not worth reimplementing. Fetching itself stays stdlib
|
|
26
|
+
# (urllib) since a plain GET doesn't need a dependency.
|
|
27
|
+
dependencies = ["trafilatura>=2.0"]
|
|
28
|
+
|
|
29
|
+
urls.Homepage = "https://github.com/MaXiMo000/satchel"
|
|
30
|
+
urls.Source = "https://github.com/MaXiMo000/satchel"
|
|
31
|
+
urls.Issues = "https://github.com/MaXiMo000/satchel/issues"
|
|
32
|
+
urls.Changelog = "https://github.com/MaXiMo000/satchel/releases"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
satchel = "satchel.cli:main"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
# 77 is the floor for PEP 639 (`license = "MIT"` as an SPDX expression). On
|
|
39
|
+
# 68 a clean build environment can reject it -- learned the hard way in a
|
|
40
|
+
# sibling project's own release workflow, fixed here before it bites this one.
|
|
41
|
+
requires = ["setuptools>=77"]
|
|
42
|
+
build-backend = "setuptools.build_meta"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
include = ["satchel*"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The one place "fetch, extract, store" happens -- used by both the CLI's
|
|
2
|
+
`add` command and the local capture listener (`serve.py`), so there is
|
|
3
|
+
exactly one add pipeline, not two that can drift apart.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
|
|
9
|
+
from . import db
|
|
10
|
+
from .extract import extract
|
|
11
|
+
from .fetch import fetch, normalize_url
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_article(conn: sqlite3.Connection, raw_url: str, *, restrict_private_network: bool = False) -> dict:
|
|
15
|
+
"""Fetch, extract, and store one article.
|
|
16
|
+
|
|
17
|
+
Returns {"ok": bool, "message": str, "id": int | None}.
|
|
18
|
+
|
|
19
|
+
restrict_private_network is False for direct CLI use (a human typing a
|
|
20
|
+
URL into their own terminal isn't a threat to themselves) and True for
|
|
21
|
+
the capture listener (see serve.py) -- there, the URL comes from
|
|
22
|
+
whatever page happened to be open in the browser, not from the person
|
|
23
|
+
running satchel, and that's exactly the boundary an SSRF guard exists
|
|
24
|
+
for.
|
|
25
|
+
"""
|
|
26
|
+
url = normalize_url(raw_url)
|
|
27
|
+
try:
|
|
28
|
+
html, final_url = fetch(url, restrict_private_network=restrict_private_network)
|
|
29
|
+
except Exception as exc: # noqa: BLE001 - a bad/unsafe fetch is a clear result, not a crash
|
|
30
|
+
return {"ok": False, "message": f"could not fetch {url}: {exc}", "id": None}
|
|
31
|
+
# Normalize again after following redirects -- the URL actually served
|
|
32
|
+
# (past a shortener, or an http->https upgrade) is the real dedup key.
|
|
33
|
+
url = normalize_url(final_url)
|
|
34
|
+
|
|
35
|
+
article = extract(html, url=url)
|
|
36
|
+
if article is None:
|
|
37
|
+
return {"ok": False, "message": f"could not extract article text from {url}", "id": None}
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
article_id = db.add(conn, url, article["title"], article["author"], article["text"])
|
|
41
|
+
except sqlite3.IntegrityError:
|
|
42
|
+
return {"ok": False, "message": f"already saved: {url}", "id": None}
|
|
43
|
+
|
|
44
|
+
return {"ok": True, "message": f"saved #{article_id}: {article['title'] or url}", "id": article_id}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""satchel add <url> | search <query> | list | read <id> | serve"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import db
|
|
9
|
+
from .capture import add_article
|
|
10
|
+
from .serve import DEFAULT_PORT, serve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _do_add(args) -> int:
|
|
14
|
+
conn = db.connect(args.db)
|
|
15
|
+
result = add_article(conn, args.url)
|
|
16
|
+
print(result["message"], file=sys.stdout if result["ok"] else sys.stderr)
|
|
17
|
+
return 0 if result["ok"] else 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _do_search(args) -> int:
|
|
21
|
+
conn = db.connect(args.db)
|
|
22
|
+
try:
|
|
23
|
+
rows = db.search(conn, args.query)
|
|
24
|
+
except sqlite3.OperationalError:
|
|
25
|
+
# FTS5's MATCH syntax (quotes, AND/OR/NOT, prefix *, column filters)
|
|
26
|
+
# is real query syntax a user can get wrong -- an unbalanced quote or
|
|
27
|
+
# a bare operator shouldn't surface as a Python traceback.
|
|
28
|
+
print(f"error: couldn't parse that search query: {args.query!r}", file=sys.stderr)
|
|
29
|
+
print("tip: quotes must be balanced; AND/OR/NOT/* are reserved words in FTS5 syntax", file=sys.stderr)
|
|
30
|
+
return 1
|
|
31
|
+
if not rows:
|
|
32
|
+
print("no matches")
|
|
33
|
+
return 0
|
|
34
|
+
for row in rows:
|
|
35
|
+
print(f"#{row['id']:<4} {row['title'] or row['url']} ({row['url']})")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _do_list(args) -> int:
|
|
40
|
+
conn = db.connect(args.db)
|
|
41
|
+
rows = db.list_all(conn)
|
|
42
|
+
if not rows:
|
|
43
|
+
print(f"nothing saved yet — try: satchel add <url> (db: {args.db})")
|
|
44
|
+
return 0
|
|
45
|
+
for row in rows:
|
|
46
|
+
print(f"#{row['id']:<4} {row['title'] or row['url']} ({row['added_at']})")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _do_read(args) -> int:
|
|
51
|
+
conn = db.connect(args.db)
|
|
52
|
+
row = db.get(conn, args.id)
|
|
53
|
+
if row is None:
|
|
54
|
+
print(f"error: no article #{args.id}", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
print(row["title"] or row["url"])
|
|
57
|
+
if row["author"]:
|
|
58
|
+
print(f"by {row['author']}")
|
|
59
|
+
print()
|
|
60
|
+
print(row["text"])
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _do_serve(args) -> int:
|
|
65
|
+
return serve(args.db, port=args.port)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
parser = argparse.ArgumentParser(prog="satchel")
|
|
70
|
+
parser.add_argument("--db", default=db.default_db_path(),
|
|
71
|
+
help=f"path to the sqlite db (default: {db.default_db_path()})")
|
|
72
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
73
|
+
|
|
74
|
+
add_p = sub.add_parser("add", help="fetch a URL, extract the article, save it")
|
|
75
|
+
add_p.add_argument("url")
|
|
76
|
+
add_p.set_defaults(func=_do_add)
|
|
77
|
+
|
|
78
|
+
search_p = sub.add_parser("search", help="full-text search saved articles")
|
|
79
|
+
search_p.add_argument("query")
|
|
80
|
+
search_p.set_defaults(func=_do_search)
|
|
81
|
+
|
|
82
|
+
list_p = sub.add_parser("list", help="list everything saved")
|
|
83
|
+
list_p.set_defaults(func=_do_list)
|
|
84
|
+
|
|
85
|
+
read_p = sub.add_parser("read", help="print a saved article's full text")
|
|
86
|
+
read_p.add_argument("id", type=int)
|
|
87
|
+
read_p.set_defaults(func=_do_read)
|
|
88
|
+
|
|
89
|
+
serve_p = sub.add_parser("serve", help="run a local listener + bookmarklet for one-click capture")
|
|
90
|
+
serve_p.add_argument("--port", type=int, default=DEFAULT_PORT,
|
|
91
|
+
help=f"port to listen on (default: {DEFAULT_PORT})")
|
|
92
|
+
serve_p.set_defaults(func=_do_serve)
|
|
93
|
+
|
|
94
|
+
args = parser.parse_args(argv)
|
|
95
|
+
return args.func(args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""SQLite storage with full-text search (FTS5), local-first: one file, no
|
|
2
|
+
server, no network dependency to search what you've already saved.
|
|
3
|
+
|
|
4
|
+
The FTS5 table is external-content (`content='articles'`): the searchable
|
|
5
|
+
copy of the text isn't duplicated as the source of truth, and triggers keep
|
|
6
|
+
it in sync on insert/update/delete so the two can't drift apart.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_db_path() -> str:
|
|
17
|
+
"""Where satchel.db lives if --db isn't given: one stable, per-user
|
|
18
|
+
location instead of "whatever directory you happened to run the
|
|
19
|
+
command from" -- the latter means `satchel add` from ~/Downloads and
|
|
20
|
+
`satchel list` from ~ silently look at two different, disconnected
|
|
21
|
+
databases, which is indistinguishable from data loss to a new user.
|
|
22
|
+
Respects XDG_DATA_HOME; falls back to the XDG default location.
|
|
23
|
+
"""
|
|
24
|
+
data_home = os.environ.get("XDG_DATA_HOME") or str(pathlib.Path.home() / ".local" / "share")
|
|
25
|
+
return str(pathlib.Path(data_home) / "satchel" / "satchel.db")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SCHEMA = """
|
|
29
|
+
CREATE TABLE IF NOT EXISTS articles (
|
|
30
|
+
id INTEGER PRIMARY KEY,
|
|
31
|
+
url TEXT UNIQUE NOT NULL,
|
|
32
|
+
title TEXT,
|
|
33
|
+
author TEXT,
|
|
34
|
+
text TEXT NOT NULL,
|
|
35
|
+
added_at TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
|
39
|
+
title, author, text, content='articles', content_rowid='id'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
|
43
|
+
INSERT INTO articles_fts(rowid, title, author, text)
|
|
44
|
+
VALUES (new.id, new.title, new.author, new.text);
|
|
45
|
+
END;
|
|
46
|
+
|
|
47
|
+
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
|
48
|
+
INSERT INTO articles_fts(articles_fts, rowid, title, author, text)
|
|
49
|
+
VALUES ('delete', old.id, old.title, old.author, old.text);
|
|
50
|
+
END;
|
|
51
|
+
|
|
52
|
+
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
|
53
|
+
INSERT INTO articles_fts(articles_fts, rowid, title, author, text)
|
|
54
|
+
VALUES ('delete', old.id, old.title, old.author, old.text);
|
|
55
|
+
INSERT INTO articles_fts(rowid, title, author, text)
|
|
56
|
+
VALUES (new.id, new.title, new.author, new.text);
|
|
57
|
+
END;
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def connect(path: str) -> sqlite3.Connection:
|
|
62
|
+
parent = pathlib.Path(path).parent
|
|
63
|
+
if str(parent) not in ("", "."):
|
|
64
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
conn = sqlite3.connect(path)
|
|
66
|
+
conn.row_factory = sqlite3.Row
|
|
67
|
+
conn.executescript(SCHEMA)
|
|
68
|
+
return conn
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add(conn: sqlite3.Connection, url: str, title: str | None, author: str | None, text: str) -> int:
|
|
72
|
+
"""Returns the article's id. Raises sqlite3.IntegrityError if the url
|
|
73
|
+
is already saved -- the caller decides what "already have this" means
|
|
74
|
+
to them (skip, re-fetch, update), this layer doesn't guess."""
|
|
75
|
+
cur = conn.execute(
|
|
76
|
+
"INSERT INTO articles (url, title, author, text, added_at) VALUES (?, ?, ?, ?, ?)",
|
|
77
|
+
(url, title, author, text, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
|
|
78
|
+
)
|
|
79
|
+
conn.commit()
|
|
80
|
+
return cur.lastrowid
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get(conn: sqlite3.Connection, article_id: int) -> sqlite3.Row | None:
|
|
84
|
+
return conn.execute("SELECT * FROM articles WHERE id = ?", (article_id,)).fetchone()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_all(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
88
|
+
return conn.execute("SELECT id, url, title, author, added_at FROM articles ORDER BY added_at DESC").fetchall()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def search(conn: sqlite3.Connection, query: str) -> list[sqlite3.Row]:
|
|
92
|
+
return conn.execute(
|
|
93
|
+
"""
|
|
94
|
+
SELECT articles.id, articles.url, articles.title, articles.author, articles.added_at
|
|
95
|
+
FROM articles_fts
|
|
96
|
+
JOIN articles ON articles.id = articles_fts.rowid
|
|
97
|
+
WHERE articles_fts MATCH ?
|
|
98
|
+
ORDER BY rank
|
|
99
|
+
""",
|
|
100
|
+
(query,),
|
|
101
|
+
).fetchall()
|