linkmend
linkmend finds external links that have rotted (gone dead) inside a
static site's Markdown content, and mends them by swapping in the
closest-in-time archive capture it can find — from the Wayback Machine,
WebCite, or archive.today. It ships as both a linkmend CLI for
walking a content tree and a Python library for callers who want to
resolve individual URLs themselves.
Install
The core package has zero runtime dependencies:
pip install linkmend
Two optional extras unlock more:
# Needed by `linkmend check`: reads a post's frontmatter `date` (for
# picking a near-in-time capture) and walks comment sidecar files.
pip install linkmend[hugo]
# The bundled requests-backed HTTP client, used for live link probes
# and archive lookups. Skip this if you'll supply your own HTTPClient
# (see "How it picks a replacement" below) or only ever run --offline.
pip install linkmend[requests]
# Both, which is what `linkmend check` needs for a normal (online) run:
pip install "linkmend[hugo,requests]"
Quick start
CLI
Point check at a directory of Markdown content. By default it reports
what it finds without touching any files:
linkmend check --content path/to/site/content
Add --rewrite to swap dead links for archive replacements in place,
and --report to write a JSON summary you can keep or hand to
import-report later:
linkmend check --content path/to/site/content \
--rewrite \
--report rotted.json
Sample output:
files with external links: 2
external links checked: 4
rotted (with replacement): 2
rotted (no replacement): 1
Run --offline to resolve only from what's already cached (no network
calls at all — useful in CI, or to preview a rewrite before spending
API calls):
linkmend check --content path/to/site/content --offline --rewrite
To proactively archive a URL before it goes dead:
linkmend save-url https://example.com/some/page
On success this prints the archived URL and exits 0; if the Wayback
Machine declines or the request fails, it prints an error to stderr
and exits 1.
Run linkmend <subcommand> --help for the full flag list (check,
save-url, and import-report each have one).
Library
The same resolution check uses is available directly, for callers
who want to check individual URLs rather than walk a directory:
from datetime import UTC, datetime
from linkmend import ArchiveCapture, LinkCheckDB, LinkChecker
db_path = "linkmend.db"
# In practice this cache is already populated by a prior `check` run;
# seeded here so the example is self-contained.
db = LinkCheckDB(db_path)
now = datetime.now(UTC)
db.record_seen("http://dead.example/some/page", context_date=now)
db.record_archive_captures(
"http://dead.example/some/page",
[ArchiveCapture(
provider="wayback",
archive_url="https://web.archive.org/web/20120602000000/http://dead.example/some/page",
captured_at=datetime(2012, 6, 2, tzinfo=UTC),
discovered_at=now,
)],
)
db.close()
checker = LinkChecker(db_path, offline=True)
result = checker.check(
"http://dead.example/some/page",
context_date=datetime(2012, 6, 1, tzinfo=UTC),
)
print(result.is_alive, result.replacement_url, result.replacement_source)
# False https://web.archive.org/web/20120602000000/http://dead.example/some/page wayback
checker.close()
Drop offline=True and pass an http client (see
requests_http_client, or supply your
own — anything matching the HTTPClient protocol works) to probe
links live and query the archive providers.
How it picks a replacement
For each URL, check tries, in order:
- A cached probe result that's still fresh (alive and within the TTL).
- A live HTTP probe (unless
--offline). - Caller-supplied fallback URLs, if any were given.
- Archive providers, in
provider_priorityorder —wayback,webcitation, thenarchive.isby default.
Once a provider returns any captures for a dead URL, linkmend picks
the one closest in time to the post's publication date (its
frontmatter date, or the sidecar's parent post's date for comment
links) — not necessarily the newest or oldest capture. Ties (same
distance) are broken by provider_priority, so an equally-close
Wayback capture wins over an equally-close archive.today one. This
logic lives in LinkCheckDB.best_capture (src/linkmend/db.py) and is
shared by both the online and --offline resolution paths.
Cache location
linkmend keeps a SQLite cache of every URL it has seen, its latest
probe result, and any archive captures it has discovered, so repeat
runs don't re-probe or re-query archives needlessly. Resolved, in
order:
$LINKMEND_DB, if set — an explicit path to the database file.$XDG_DATA_HOME/linkmend/linkmend.db, ifXDG_DATA_HOMEis set.~/.local/share/linkmend/linkmend.db, otherwise.
Every subcommand also accepts --db PATH to override this directly
for a single invocation.
Reusing a previous report
import-report reseeds the archive_captures cache table from a
report check --report previously wrote:
linkmend import-report rotted.json
This exists as a recovery path: if the cache database is lost (a new
machine, a cleared CI cache, a deleted linkmend.db) but a report from
an earlier run survives, import-report restores the replacement URLs
it already found without re-querying rate-limited archive APIs from
scratch. It only imports entries that name an actual archive capture —
entries with no replacement, or where the replacement came from a
caller-supplied fallback rather than an archive, are skipped.
Limitations
- Inline Markdown links only. The extractor handles
[text](url "title")and the image-wrapped form[](target). Reference-style links ([text][ref]with a separate[ref]: urldefinition) and bare images are not detected and will be left untouched. - Comment sidecars are opt-in and schema-specific. The bundled
HUGO_COMMENT_SIDECARpreset expectscomments.jsonbeside a post'sindex.md. Other layouts need a customSidecarSpec. - Archive availability is not guaranteed. A dead link with no
capture in any provider is reported, not fixed. Do not put
linkmend checkin a deploy gate — a third-party archive outage would then break your deploy.
The CLI wires up only the HUGO_COMMENT_SIDECAR preset in this
release (0.1.0); a different sidecar shape means using the library
directly with your own SidecarSpec, not a CLI flag.
Project structure
src/linkmend/
__init__.py Public API surface (__all__)
__main__.py `python -m linkmend` entry point
cli.py argparse CLI: check / save-url / import-report
checker.py LinkChecker — resolution policy (cache → probe → fallback → archive)
db.py SQLite persistence (LinkCheckDB, best_capture)
markdown.py Inline-Markdown-link extraction and rewriting
comments.py JSON comment-sidecar walking (SidecarSpec, HUGO_COMMENT_SIDECAR)
providers.py Archive provider integrations + HTTPClient protocol
report.py Report writer and import-report reader
tests/
test_*.py One test module per area above
Licence
Apache License 2.0 — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file linkmend-0.1.0.tar.gz.
File metadata
- Download URL: linkmend-0.1.0.tar.gz
- Upload date:
- Size: 65.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2b6214bfd7ecd12076237cd1f00d9573b2988aa6cbb1d259c60aab4be259b25b
|
|
| MD5 |
8320910a1427c57fd92957825938f4e6
|
|
| BLAKE2b-256 |
e26055165dd0fae76152b8b6af79a50d93eb1faf49afdc53d99f0534a6962b7d
|
Provenance
The following attestation bundles were made for linkmend-0.1.0.tar.gz:
Publisher:
publish.yml on killett/linkmend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
linkmend-0.1.0.tar.gz -
Subject digest:
2b6214bfd7ecd12076237cd1f00d9573b2988aa6cbb1d259c60aab4be259b25b - Sigstore transparency entry: 2317783885
- Sigstore integration time:
-
Permalink:
killett/linkmend@d59a9eb7e0be8d72c80bb031921477453cdaa8fd -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/killett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d59a9eb7e0be8d72c80bb031921477453cdaa8fd -
Trigger Event:
push
-
Statement type:
File details
Details for the file linkmend-0.1.0-py3-none-any.whl.
File metadata
- Download URL: linkmend-0.1.0-py3-none-any.whl
- Upload date:
- Size: 34.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3d69d8f476c9b0726849fc1ccefdf87cb40fa7f86f39d59300a160b900cc88e
|
|
| MD5 |
63ad76e9095d2cbbfffaf05d5ea3f464
|
|
| BLAKE2b-256 |
b6bc741b8fb8bb98f7313d77a34cdfd3282bdc7ce8e3a61c75bd3f934db1f99e
|
Provenance
The following attestation bundles were made for linkmend-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on killett/linkmend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
linkmend-0.1.0-py3-none-any.whl -
Subject digest:
a3d69d8f476c9b0726849fc1ccefdf87cb40fa7f86f39d59300a160b900cc88e - Sigstore transparency entry: 2317783904
- Sigstore integration time:
-
Permalink:
killett/linkmend@d59a9eb7e0be8d72c80bb031921477453cdaa8fd -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/killett
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d59a9eb7e0be8d72c80bb031921477453cdaa8fd -
Trigger Event:
push
-
Statement type: