# Broken Links Console script that lists internal links pointing to content which no longer exists or has been moved to the trash. Editors keep links to pages that are later renamed, deleted or trashed — the stored link stays behind and only fails when a visitor clicks it. The script walks all content of every Plone site in the instance and reports those links. Key characteristics: - Operates on **all Plone sites** within the Zope instance - Reads only — no content is modified, nothing is committed - Checks rich text, link widget fields and JSON schema link fields - Reports missing **and** trashed targets - Logs a per-site summary ## Usage ```bash ./bin/broken-links [--host HOSTNAME ...] ``` **Arguments:** | Argument | Description | |---|---| | `zopeconf` | Absolute path to the Zope configuration file (`zope.conf`) | | `--host` | Hostname whose absolute URLs are treated as internal. Repeatable. | Links written as absolute URLs (`https://www.example.ch/de/page`) are only checked when their host is known. Outside of a running server the instance does not know its public hostnames, so pass every one of them with `--host` — absolute URLs on any other host count as external and are skipped. **Examples:** ```bash # All sites of the instance ./bin/broken-links instance/etc/zope.conf # Also check absolute URLs written against the live site ./bin/broken-links instance/etc/zope.conf --host www.example.ch --host example.ch ``` In a container the config file depends on the storage — `relstorage.conf`, `zeo.conf` or `zope.conf` in `/app/etc/`: ```bash docker compose exec -u plone backend /app/bin/broken-links /app/etc/relstorage.conf ``` ## Output Findings are grouped per object. The first line is the path and portal type of the object holding the link, followed by one line per broken link with the field name, the stored link and the reason: ```text /Plone/gerichte/rechtsprechung/uebersicht (Block) text: resolveuid/6f6d6a0834914414bce433f0a962689f -> target does not exist text: /themen/wohnen/schlichtungsstelle -> target does not exist /Plone/verwaltung/kontakt (ContentPage) cta_href: resolveuid/1d8d8958263942d297f520d5cc18252c -> target is in the trash Plone: 3 broken internal link(s). othersite: 0 broken internal link(s). ``` Redirect to a file for large sites — a grown site easily produces thousands of lines: ```bash ./bin/broken-links instance/etc/zope.conf > broken-links.txt ``` ## Reasons | Reason | Meaning | |---|---| | `target does not exist` | The UID is unknown or the path cannot be traversed. The link is dead. | | `target is in the trash` | The target was deleted by an editor and sits in the trash container. | Trashed content is worth its own reason: it stays in the catalog, so the link still resolves internally, but the public site answers with a 404. Such links are fixed either by restoring the target or by repointing the link. ## What is checked | Field type | Where links come from | |---|---| | Rich text | `href` of links and `src` of images in the stored HTML | | Text line with the link widget | The stored value, e.g. a call to action link | | JSON field | Every value whose JSON schema marks it with `"format": "link"` | ## How links are resolved - `resolveuid/` is looked up by UID. Everything after the UID is a view on the target (`/@@images/image/preview`) and is not checked separately. - `${portal_url}` and `${navigation_root_url}` are replaced by the path they stand for, relative to the object holding the link. - Absolute paths are traversed from the Zope root first (`/Plone/de/page`) and then from the portal, so public frontend paths without the portal id (`/de/page`) resolve as well. - Relative paths are traversed from the container of the object. A link stored in a block is resolved against the container of its page, because that is the URL the block is rendered at. - The view part of a path (everything from `/@@` or `/++`) is cut off before traversal. Whether `/@@download/file/report.pdf` renders says nothing about the existence of the linked content. Skipped, because they are not internal links: other URL schemes (`mailto:`, `tel:`, …), absolute URLs on unknown hosts, and links that carry only a fragment or a query string. ## Not checked - **External links** — no HTTP request is made, the script never leaves the ZODB. - **Relations** — reference fields (`RelationValue`) are not link fields and are not inspected. - **Content in the trash** — trashed content is skipped as a *source* of links; fixing links inside deleted content has no value. ## Scheduling with Cron The script is safe to run unattended, for example as a weekly report: ```bash # Every Monday at 3:00 AM 0 3 * * 1 /path/to/bin/broken-links /path/to/instance/etc/zope.conf > /var/log/broken-links.txt ``` ## Storage note The script opens the database like any other Zope process. With **RelStorage** or **ZEO** it runs next to the live instance without interfering. With a direct **FileStorage** only one process can hold the storage lock, so the instance has to be stopped, or the script has to be pointed at a copy of `Data.fs`. ## Logging The script logs to the `wcs.backend.linkcheck` logger at INFO level. Output is written to stdout with the format `%(message)s`.