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 +1 -0
- satchel/capture.py +44 -0
- satchel/cli.py +99 -0
- satchel/db.py +101 -0
- satchel/extract.py +30 -0
- satchel/fetch.py +115 -0
- satchel/serve.py +109 -0
- satchel_reader-0.1.0.dist-info/METADATA +185 -0
- satchel_reader-0.1.0.dist-info/RECORD +13 -0
- satchel_reader-0.1.0.dist-info/WHEEL +5 -0
- satchel_reader-0.1.0.dist-info/entry_points.txt +2 -0
- satchel_reader-0.1.0.dist-info/licenses/LICENSE +21 -0
- satchel_reader-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](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,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
|