goodlinks (https://goodlinks.app) remains my favorite read-it-later app. i store things in it like they're going out of style -- it's the final resting place of my flirtations with pocket, instapaper, and whatever else i tried before accepting that i was just going to hoard articles indefinitely instead of actually reading them.
when they dropped a local HTTP API in the 3.2 release, i could not look away. the collection had become a hot mess of untagged links, dead URLs, and duplicates that would make a librarian cry. this script is the shovel.
it's a single python script with inline PEP 723 metadata, so uv handles all the dependencies automatically. no virtualenv ceremony, no pip install, just run it.
chmod +x goodlinks-gardening.py
./goodlinks-gardening.py --helpor if you prefer being explicit about it:
uv run goodlinks-gardening.py --helpgoodlinks needs to be running with the local API enabled (settings -> API) before
any of this works. it talks to http://localhost:9428/api/v1 by default.
the script looks for an API bearer token in ascending precedence order:
~/.credentials/goodlinks-token.txt(lowest priority)GOODLINKS_APIenvironment variable--tokenflag on the CLI (wins)
if goodlinks doesn't require auth on your setup, you can skip this entirely.
shows every tag in the collection with a count of articles per tag, sorted by frequency. useful for getting a feel for what the taxonomy actually looks like.
./goodlinks-gardening.py tags
./goodlinks-gardening.py tags --jsondomain frequency stats -- which sites have you saved the most articles from? useful for identifying sources worth tagging in bulk, or for confronting your news diet.
./goodlinks-gardening.py urls
./goodlinks-gardening.py urls --min-count 5 # only domains with 5+ articles
./goodlinks-gardening.py urls --urls # raw URL dump, one per linebulk-tags every article from a given domain that doesn't already have the tag.
subdomains are handled automatically -- targeting nytimes.com also catches
www.nytimes.com. use --dry-run first if you value your sanity.
./goodlinks-gardening.py tag-domain --domain github.com --tag dev --dry-run
./goodlinks-gardening.py tag-domain --domain nytimes.com --tag newsfinds articles with identical URLs. by default just reports them. pass --delete
to remove all but the oldest saved copy of each duplicate. keeps the oldest because
at least that one you presumably meant to save.
./goodlinks-gardening.py dedupe
./goodlinks-gardening.py dedupe --delete
./goodlinks-gardening.py dedupe --jsonprobes your collection for articles that have gone dark. an article is flagged as
dead if goodlinks couldn't fetch the content (word count of zero) or if the URL
returns a 4xx/5xx, times out, or outright refuses to connect. dead articles get
tagged with the reason -- http-404, http-timeout, http-error, or
offline-unavailable -- so you can filter and clean them up later.
requires --tag TAG to scope to a specific tag, or --all to check everything.
combine with --unread or --untagged to narrow the blast radius.
./goodlinks-gardening.py dead-links --tag dev --dry-run
./goodlinks-gardening.py dead-links --all --unread
./goodlinks-gardening.py dead-links --tag news --workers 10 --timeout 15the lazy option. uses claude (via the anthropic API) to look at the content of
untagged articles and pick the best matching tag from what already exists in your
collection. it will not invent new tags. articles get a claude-auto tag in
addition to whatever it suggests, so you can audit the results later.
content comes from the goodlinks local API first; if that's not available it fetches
the URL directly. articles where content can't be retrieved get tagged
content-unavailable instead.
requires ANTHROPIC_API_KEY to be set. uses claude haiku to keep costs reasonable.
./goodlinks-gardening.py auto-tag --dry-run
./goodlinks-gardening.py auto-tag
./goodlinks-gardening.py auto-tag --jsonmost commands support --json for machine-readable output. commands that mutate
data support --dry-run to preview what would change without touching anything.
if gardening is the shovel, visuals is the graph you make after shoveling to feel like the hoarding was somehow intentional. it fetches the same collection via the local API and produces a JSON dataset plus an HTML stub you can open in a browser or drop into a hugo site.
chmod +x goodlinks-visuals.py
./goodlinks-visuals.pyor explicitly:
uv run goodlinks-visuals.pysame auth precedence as the gardening script. goodlinks must be running with the API enabled before this does anything useful.
running the script writes into goodlinks-stats/ by default:
goodlinks-stats/
data/goodlinks-data.json ← the full dataset
index.html ← HTML stub that loads the visualizations
the dataset has four keys:
articles-- every link as a table row, sorted by read date descendingheatmap--{date: count}of reads per calendar day (github-style grid)tag_series--{tag: {month: count}}for tracking tag volume over timedomain_series--{domain: {month: count}}for the same but by source
./goodlinks-visuals.py --output-dir ~/Sites/reading-stats
./goodlinks-visuals.py --pretty # human-readable JSON (larger file)
./goodlinks-visuals.py --base-url http://localhost:9428/api/v1
./goodlinks-visuals.py --token mytokenif you maintain a hugo site, --hugo-dir copies the dataset JSON into a page
bundle and installs the shortcode templates into layouts/shortcodes/. the
shortcodes let you embed the charts anywhere in your content without duplicating
the visualization code.
./goodlinks-visuals.py \
--hugo-dir ~/Sites/my-hugo-site \
--page-bundle content/posts/reading-statsthis writes goodlinks-data.json into the page bundle directory and drops four
shortcode templates into layouts/shortcodes/:
| shortcode | what it renders |
|---|---|
goodlinks-plotly |
stacked area chart of tags over time |
goodlinks-heatmap |
calendar heatmap of reading activity |
goodlinks-sunburst |
sunburst chart of domain/tag breakdown |
goodlinks-table |
sortable/filterable article table |
--page-bundle is required when --hugo-dir is set.
the templates/ directory is where the HTML lives. the top-level templates
(index.html, heatmap.html, sunburst.html, table.html) are rendered via
jinja2 to produce the standalone output. templates/shortcodes/ holds the four
hugo shortcode templates that get copied on --hugo-dir export. if you want to
customize the look, edit the templates -- the script just renders them, it doesn't
own the markup.
untag-- remove a tag from all articles matching a domain or search termretag-- rename a tag across the entire collectionreport-- markdown summary of reading stats (articles/week, top domains, etc.)export-- dump the collection to CSV / JSON / markdownbulk-tag-- apply tags from a YAML/JSON mapping file (domain -> tags)stale-- list articles saved more than N days ago that are still unread