satchel-reader 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.
satchel/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
satchel/capture.py ADDED
@@ -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}
satchel/cli.py ADDED
@@ -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())
satchel/db.py ADDED
@@ -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()
satchel/extract.py ADDED
@@ -0,0 +1,30 @@
1
+ """Turn raw HTML into (title, author, text). Wraps trafilatura rather than
2
+ reimplementing boilerplate-stripping -- a naive "grab the biggest <div>"
3
+ approach is exactly the kind of thing that looks fine on one test page and
4
+ breaks on the next real site's markup.
5
+
6
+ Deliberately takes HTML directly (bytes or str), not a URL -- fetching is a
7
+ separate, network-dependent step (see fetch.py), and keeping extraction pure
8
+ means it can be tested offline against fixture HTML with no network
9
+ involved. Bytes are preferred: trafilatura's own charset detection is
10
+ better than trusting the HTTP header.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+
16
+ import trafilatura
17
+
18
+
19
+ def extract(html: bytes | str, url: str | None = None) -> dict | None:
20
+ """Returns {"title", "author", "text"}, or None if trafilatura couldn't
21
+ find a main content block at all (a login wall, an empty page, a page
22
+ that's entirely JavaScript-rendered with nothing in the raw HTML)."""
23
+ raw = trafilatura.extract(html, url=url, output_format="json", with_metadata=True)
24
+ if not raw:
25
+ return None
26
+ data = json.loads(raw)
27
+ text = data.get("text") or ""
28
+ if not text.strip():
29
+ return None
30
+ return {"title": data.get("title"), "author": data.get("author"), "text": text}
satchel/fetch.py ADDED
@@ -0,0 +1,115 @@
1
+ """Fetch a URL's HTML. stdlib only -- a plain GET is not worth a dependency
2
+ when extraction (the actually hard part) already needs one.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import ipaddress
7
+ import socket
8
+ import urllib.request
9
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
+
11
+ _USER_AGENT = "satchel/0.1 (personal reading queue; +https://github.com/MaXiMo000/satchel)"
12
+
13
+ _ALLOWED_SCHEMES = {"http", "https"}
14
+
15
+
16
+ class UnsafeURLError(ValueError):
17
+ """A URL failed safety validation before satchel would fetch it: an
18
+ unsupported scheme, or -- when restrict_private_network=True -- an
19
+ address that isn't reachable on the public internet."""
20
+
21
+
22
+ def _check_scheme(url: str) -> None:
23
+ scheme = urlsplit(url).scheme.lower()
24
+ if scheme not in _ALLOWED_SCHEMES:
25
+ raise UnsafeURLError(f"unsupported URL scheme {scheme!r} (only http/https)")
26
+
27
+
28
+ def _check_not_private(url: str) -> None:
29
+ """Refuses a URL whose host resolves to loopback, private, link-local,
30
+ reserved, multicast, or unspecified address space -- the exact ranges
31
+ an SSRF payload targets (localhost services, 169.254.169.254-style
32
+ cloud metadata endpoints, other hosts on the local network).
33
+
34
+ This validates the hostname's *current* DNS answer, at the moment it's
35
+ checked -- it does not pin the connection to that exact resolved
36
+ address. A sufficiently motivated attacker who controls DNS for a
37
+ domain (a near-zero TTL, answering differently a moment later) could
38
+ still slip a private address past this check and have urllib re-
39
+ resolve to it at actual connect time -- classic DNS rebinding. Fully
40
+ closing that needs connecting to the pinned IP directly rather than by
41
+ hostname, which is real added complexity for a personal tool's local
42
+ capture endpoint; this is a deliberate, documented partial mitigation,
43
+ not a claim of a hard guarantee against a determined network attacker.
44
+ """
45
+ host = urlsplit(url).hostname
46
+ if not host:
47
+ raise UnsafeURLError("URL has no host")
48
+ try:
49
+ infos = socket.getaddrinfo(host, None)
50
+ except socket.gaierror as exc:
51
+ raise UnsafeURLError(f"could not resolve {host}: {exc}") from exc
52
+ for info in infos:
53
+ addr = ipaddress.ip_address(info[4][0])
54
+ if (addr.is_private or addr.is_loopback or addr.is_link_local
55
+ or addr.is_reserved or addr.is_multicast or addr.is_unspecified):
56
+ raise UnsafeURLError(f"{host} resolves to a non-public address ({addr}) -- refusing")
57
+
58
+
59
+ class _RestrictedRedirectHandler(urllib.request.HTTPRedirectHandler):
60
+ """Re-validates every redirect target before following it. Without
61
+ this, a URL that passes validation up front could still redirect
62
+ somewhere private, and urllib would follow it anyway -- the initial
63
+ check alone only guards the first hop."""
64
+
65
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
66
+ _check_scheme(newurl)
67
+ _check_not_private(newurl)
68
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
69
+
70
+ # The same article shared twice almost always differs only in tracking junk
71
+ # or a trailing slash -- strip that before it's used as the dedup key, or
72
+ # the one duplicate guard this tool has never actually fires in practice.
73
+ _TRACKING_PARAMS = {
74
+ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
75
+ "fbclid", "gclid", "ref", "ref_src", "source",
76
+ }
77
+
78
+
79
+ def normalize_url(url: str) -> str:
80
+ parts = urlsplit(url)
81
+ query = urlencode([(k, v) for k, v in parse_qsl(parts.query) if k not in _TRACKING_PARAMS])
82
+ path = parts.path.rstrip("/") or "/"
83
+ return urlunsplit((parts.scheme, parts.netloc, path, query, ""))
84
+
85
+
86
+ def fetch(url: str, timeout: float = 15.0, restrict_private_network: bool = False) -> tuple[bytes, str]:
87
+ """Returns (raw bytes, the final URL after any redirects).
88
+
89
+ Raw bytes, not a decoded string -- trafilatura does its own charset
90
+ detection (via cchardet) and does it better than trusting the HTTP
91
+ header alone, which many sites omit or get wrong.
92
+
93
+ The final URL matters for dedup: a shortened link (bit.ly, t.co) or a
94
+ plain http:// URL a site 301-redirects to https:// both resolve to the
95
+ same real address the server actually served, and *that* address --
96
+ not whatever the user happened to type or click -- is what two saves
97
+ of "the same" article should be compared against. This is also the
98
+ right way to answer "should http:// and https:// count as the same
99
+ URL": defer to what the server says by following the redirect, rather
100
+ than guessing.
101
+
102
+ Only http/https are ever accepted, always. restrict_private_network
103
+ additionally refuses a URL (or a redirect to one) that resolves to
104
+ loopback/private/link-local/reserved address space -- pass True when
105
+ the URL didn't come from the person running satchel (see serve.py).
106
+ """
107
+ _check_scheme(url)
108
+ if restrict_private_network:
109
+ _check_not_private(url)
110
+ opener = urllib.request.build_opener(_RestrictedRedirectHandler)
111
+ else:
112
+ opener = urllib.request.build_opener()
113
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
114
+ with opener.open(req, timeout=timeout) as resp:
115
+ return resp.read(), resp.geturl()
satchel/serve.py ADDED
@@ -0,0 +1,109 @@
1
+ """Local capture listener: click a bookmarklet on any page, and its URL
2
+ goes through the same fetch/extract/store pipeline the CLI uses -- no
3
+ terminal required for the one action that happens most often.
4
+
5
+ Loopback-only and token-gated. Why the token matters: while `serve` is
6
+ running, *any* tab open in the browser can send a request to
7
+ http://127.0.0.1:<port> -- a browser's same-origin policy stops a page
8
+ from reading a cross-origin response it didn't get permission for, but it
9
+ does not stop the page from sending the request in the first place. With
10
+ no shared secret, that's an open invitation for any site you happen to
11
+ have open to make this process fetch (and store) an arbitrary URL on your
12
+ behalf. The token is generated fresh each time `serve` starts and only
13
+ ever appears in the bookmarklet printed below -- it isn't the request
14
+ itself that's protected, it's that a request without the right token gets
15
+ nothing.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import http.server
20
+ import json
21
+ import secrets
22
+ import sys
23
+ import urllib.parse
24
+
25
+ from . import db
26
+ from .capture import add_article
27
+
28
+ DEFAULT_PORT = 8765
29
+
30
+
31
+ def _bookmarklet(port: int, token: str) -> str:
32
+ return (
33
+ "javascript:fetch('http://127.0.0.1:%d/add?token=%s&url='"
34
+ "+encodeURIComponent(location.href))"
35
+ ".then(r=>r.json()).then(j=>alert(j.message))"
36
+ ".catch(e=>alert('satchel: could not reach the local listener -- is `satchel serve` still running?'))"
37
+ ) % (port, urllib.parse.quote(token))
38
+
39
+
40
+ def _make_handler(db_path: str, token: str) -> type[http.server.BaseHTTPRequestHandler]:
41
+ class Handler(http.server.BaseHTTPRequestHandler):
42
+ def log_message(self, fmt: str, *args) -> None: # noqa: A003 - stdlib's name
43
+ pass # quiet by default; every response already carries the result
44
+
45
+ def _reply(self, status: int, payload: dict) -> None:
46
+ body = json.dumps(payload).encode("utf-8")
47
+ self.send_response(status)
48
+ self.send_header("Content-Type", "application/json")
49
+ # The token is the real access control here, not CORS -- once a
50
+ # request has the right token it's allowed to succeed, so there's
51
+ # nothing extra protected by hiding the response from whatever
52
+ # page's bookmarklet sent it.
53
+ self.send_header("Access-Control-Allow-Origin", "*")
54
+ self.send_header("Content-Length", str(len(body)))
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def do_GET(self) -> None:
59
+ parsed = urllib.parse.urlsplit(self.path)
60
+ if parsed.path != "/add":
61
+ self._reply(404, {"ok": False, "message": "not found"})
62
+ return
63
+
64
+ qs = urllib.parse.parse_qs(parsed.query)
65
+ given_token = qs.get("token", [""])[0]
66
+ if not secrets.compare_digest(given_token, token):
67
+ self._reply(403, {"ok": False, "message": "bad or missing token"})
68
+ return
69
+
70
+ url = qs.get("url", [""])[0]
71
+ if not url:
72
+ self._reply(400, {"ok": False, "message": "no url given"})
73
+ return
74
+
75
+ conn = db.connect(db_path)
76
+ try:
77
+ result = add_article(conn, url, restrict_private_network=True)
78
+ finally:
79
+ conn.close()
80
+ self._reply(200 if result["ok"] else 400, result)
81
+
82
+ return Handler
83
+
84
+
85
+ def serve(db_path: str, port: int = DEFAULT_PORT) -> int:
86
+ token = secrets.token_urlsafe(16)
87
+ try:
88
+ httpd = http.server.HTTPServer(("127.0.0.1", port), _make_handler(db_path, token))
89
+ except OSError as exc:
90
+ print(f"error: could not listen on 127.0.0.1:{port}: {exc}", file=sys.stderr)
91
+ return 1
92
+
93
+ print(f"satchel capture listening on http://127.0.0.1:{port} (db: {db_path})")
94
+ print()
95
+ print("Drag this to your bookmarks bar, then click it on any page to save it:")
96
+ print()
97
+ print(f" {_bookmarklet(port, token)}")
98
+ print()
99
+ print("Only this machine can reach it, and only with the token above -- restart")
100
+ print("`serve` and re-drag the bookmarklet if you ever want a fresh one.")
101
+ print()
102
+ print("^C to stop.")
103
+ try:
104
+ httpd.serve_forever()
105
+ except KeyboardInterrupt:
106
+ print("\nstopped.")
107
+ finally:
108
+ httpd.server_close()
109
+ return 0
@@ -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
+ [![ci](https://github.com/MaXiMo000/satchel/actions/workflows/ci.yml/badge.svg)](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,13 @@
1
+ satchel/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ satchel/capture.py,sha256=ZRxgqhjSeOqTTlPLN8qWj7FG_RUv9O9b8lir6Bq6fzI,1923
3
+ satchel/cli.py,sha256=AKX34cOC0fCy7zxBcT1UCsYLtOwnOr_oFeC66b0A_-I,3253
4
+ satchel/db.py,sha256=mSorGNBIFGVD7ZmklVRon_N6klXADzX9Y-ozBzu-Qkk,3729
5
+ satchel/extract.py,sha256=H11cT9e3gTPRNzpeES5nn3TRLGwSClNbktkKdCcPlQo,1274
6
+ satchel/fetch.py,sha256=72oYJKMJx677kjaEWfutkowQxyMPsNPlVlApDvrZWMM,5290
7
+ satchel/serve.py,sha256=RWbFMiotHI7OPGcb3szcBZFkHzCvcsljjQJojUTHvm4,4355
8
+ satchel_reader-0.1.0.dist-info/licenses/LICENSE,sha256=b3BQUid5-O27uBJjQzkF_jTy6vX5FMSPTuFoTlCZyI0,1069
9
+ satchel_reader-0.1.0.dist-info/METADATA,sha256=NF2-SGBAvbfUTvBECT7kJ2dqwZtYjk9KGEugb56PlhU,8103
10
+ satchel_reader-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ satchel_reader-0.1.0.dist-info/entry_points.txt,sha256=wUJy_vkKRfDDqfdLi1LLNAYuPmYb2mOc7GgCzSnPIXs,45
12
+ satchel_reader-0.1.0.dist-info/top_level.txt,sha256=LHfrnlGwK0nEpa7n3HPfFT55Dxfxzi9B1iKw2Qo7PzU,8
13
+ satchel_reader-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ satchel = satchel.cli:main
@@ -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 @@
1
+ satchel