Detects silent edits to news articles: ingest RSS/Atom feeds, fetch the full text, version every revision, and diff what changed.
An article that is edited after publication usually says nothing about it. This service polls the feeds, re-fetches known articles on a decaying schedule, stores every observed revision append-only, and lets you diff any two of them.
Note
Quiet Edit is the artefact of an experiment in agent-driven development. One Claude Code session per ticket, tracked in beads, every decision written back into the ticket graph as a close reason. Planning started with 23 tickets, the build ended at 63, and 33 of the new ones the agent filed itself against the ticket they surfaced from. The known limitations at the bottom of this file come out of those close reasons.
Write-up (German): Ein Gedächtnis aus Tickets
One ingest run does the whole path:
- Catalogue sync —
feeds.yamlis reconciled into thefeedtable (additively). - Feed poll — conditional GET per feed (
If-None-Match/If-Modified-Since), per-host rate limiting, retries on 5xx and transport errors only. - Feed parse — one jsoup XML pass; RSS 2.0, Atom 1.0 and older dialects come out as one uniform entry shape. Dates stay verbatim text until normalised.
- Re-check policy — decides which known articles are due. Pure function: state in, one decision per candidate out.
- Article fetch — robots.txt honoured per redirect hop, size-capped, raw HTML stored gzipped and content-addressed on disk.
- Encoding resolution — BOM > HTTP charset > document declaration > UTF-8, with every losing declaration recorded.
- Extraction — jsoup-only boilerplate removal down to a title plus a flat paragraph list. Deterministic, no readability port.
- Identity — canonical URL (tracking-parameter denylist, AMP unwrapping,
rel=canonicalwith guards). - Versioning — SHA-256 over folded text; a revision is appended only when the hash differs from the newest one. Append-only is enforced by database triggers.
| Term | Meaning |
|---|---|
| Feed | a subscribed RSS or Atom source |
| Document | one article, identified by its canonical URL |
| DocumentVersion | one observed revision, immutable and append-only |
| Change | a detected difference between two versions (not yet populated) |
- Java 25
- Maven 3.9.12
- Docker (local database and Testcontainers-backed tests)
docker compose up -d # PostgreSQL 18.6 on localhost:55432
mvn spring-boot:run # http://localhost:8080, Flyway migrates on startupData lives in the named volume quietedit-pgdata and survives docker compose down.
docker compose down -v discards it.
The scheduler is on by default and calls the same runOnce() the REST endpoint
does, with a fixed delay of 5 minutes measured from the end of the previous run.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/ingest/run |
Run the full ingest once. 409 if a run is already in flight. |
POST |
/api/ingest/feeds/run |
Poll the feeds only, no article fetching. |
GET |
/api/documents/changed?limit=50 |
Documents with more than one revision, most recently changed first. limit clamped to 200. |
GET |
/api/documents/{id}/revisions |
The document's full history, oldest first. No text. |
GET |
/api/documents/{id}/diff?from=&to= |
Diff of two revisions. Defaults to the newest pair; to alone means "against its predecessor". |
Diffs are computed per request, never stored: a diff is a pure function of two immutable rows, so recomputing can never disagree with the store.
Errors are RFC 7807 Problem Details. Stack traces never leave the process.
feeds.yaml at the repository root lists the sources:
feeds:
- url: "https://www.srf.ch/news/bnf/rss/1646"
name: "SRF News"Environment variables (all have local defaults):
| Variable | Default |
|---|---|
QUIETEDIT_DB_URL |
jdbc:postgresql://localhost:55432/quietedit |
QUIETEDIT_DB_USER / QUIETEDIT_DB_PASSWORD |
quietedit |
QUIETEDIT_PORT |
8080 |
QUIETEDIT_FEEDS_FILE |
file:./feeds.yaml |
QUIETEDIT_SCHEDULE_ENABLED |
true |
QUIETEDIT_SCHEDULE_INTERVAL |
5m |
QUIETEDIT_MAX_ARTICLES_PER_RUN |
100 |
QUIETEDIT_MAX_HOST_REQUESTS_PER_HOUR |
120 |
QUIETEDIT_RAW_HTML_DIR |
./data/raw-html |
The re-check curve, the observation window and the fetch timeouts live under
quietedit.ingest.* in application.yml; the reasoning behind each number is in
the Javadoc of the class that reads it (RecheckPolicy above all).
An article is re-checked after age-factor (0.25) of its age, floored at 10
minutes and capped at 12 hours. Self-similar rather than tiered, because the rate
at which an article is still being edited falls off roughly like 1/age. An observed
edit restarts the curve at its steep end. A candidate is retired once nothing has
happened to it for observation-window × (1 + observed changes), capped at 4×.
A feed's updated field makes a candidate due immediately — but a feed that
stamps updated on every render loses that power after 20 consecutive claims
that produced no revision, and earns it back with the first that does.
mvn verifyTests run under the test profile and start their own throwaway PostgreSQL
container via Testcontainers. They never touch the compose database, so only a
running Docker daemon is required. Outbound HTTP is stubbed with WireMock.
Test policy: core logic only — extraction, hashing, canonicalisation, diffing, date normalisation, re-check policy. Plus integration tests for the full ingest run and the append-only guarantee. No tests for wiring, mappers or getters.
- Change classification does not run. The
changetable and theClassificationenum exist, but nothing writes to them. Two classification rules are implemented and tested —EncodingRepair(mojibake fixed between revisions) andIndexLineRewrite(a ticker restructuring its own index line) — but no classifier calls them yet. Today the system reports that something changed and what the text difference is, never what kind of change it was. - No title tracking, no change store, no change REST API, no web interface, no cross-source clustering.
- No authentication on any endpoint.
- Single process only. The "one run at a time" guard is an in-process flag. A second instance would need a database lock.
- The per-host hourly ceiling is approximate. Spend is reconstructed from the
candidates'
lastCheckedAt, which undercounts by a bounded amount. There is no exact request ledger. - Nothing is ever deleted. The raw HTML store has no retention rule, and neither does the attempt log.
- An article that fails three times is abandoned permanently, with no path back.
- Article age is measured from discovery, not publication. An archive article entering a feed today gets a fresh article's attention.
- A feed removed from
feeds.yamlis not deactivated — the catalogue sync is additive on purpose, because documents reference the feed row. - A distrusted feed whose articles are all retired can never be tested again,
so it cannot earn its
updatedclaims back. - The
changedlisting is capped at 200 rows with no total and no offset.
- The furniture vocabulary is a curated DE/EN list and will miss differently-named boilerplate. There is no per-host comparison across many articles, which is what would tell a page's own furniture from its prose.
<figure>is removed wholesale, so figure-marked pull quotes are lost.- A rail caption a publisher styles exactly like its body subheadings survives.
- Ticker entry bodies wrapped in
divrather thanpare not extracted at all. - Promo boxes that read as prose (an app-push box, for instance) can survive as paragraphs.
- Text in no prose block is dropped; JS-injected text is invisible by design.
- An edit confined to a masked span is invisible. The hash deliberately folds ad and session identifiers, relative timestamps and typographic variants, so a change inside one of them produces no revision. The alternative was reporting a change on every single fetch.
- The diff engine cannot tell a heading from a paragraph — extraction yields a flat list — so one similarity threshold (0.5, relaxed to 1/3 for a replacement in a balanced run) has to serve both. The relaxed value is calibrated against observed data with a thin margin: 0.36 for a real correction against 0.25 for the closest observed non-pair.
- Paragraph split/merge is not recognised as such; repeated identical paragraphs are interchangeable to the move pass; pairing is greedy in reading order, not globally optimal.
- The
IndexLineRewriterule can claim a prose paragraph that imitates the shape of a ticker index line, and is all-or-nothing per paragraph. - An encoding repair shipped together with a real edit hides that edit; a clean-charset-to-clean-charset switch is deliberately not claimed as a repair.
- URL canonicalisation:
index.htmland the bare directory are not folded; path-based mobile variants beyond a leading/m/are left alone; hashbang routes lose their identity with the fragment; a cross-publisherrel=canonicalis trusted, which folds syndicated copies together; the tracking-parameter denylist will lag new campaign parameters. - Encoding: a BOM-less UTF-16 body with no header charset is undiagnosable without content detection, which is out of scope. Bytes invalid in their declared charset are decoded with replacement characters — deterministically, so the hash stays stable, but the text is wrong.
- Dates: a missing timezone becomes UTC and is flagged inexact; a two-digit year windows into 1970–2069; a date more than an hour ahead of the fetch is replaced by the fetch time.
- Feed parsing detects a truncated body from jsoup's source-range behaviour
rather than a documented guarantee. Bodies a strict XML parser would reject (a
stray
&, say) are repaired and parsed. robots.txtpaths are compared as written: a rule that percent-encodes a character the URL spells literally will not match.
src/main/java/org/korhan/quietedit/
ingest/ feeds, fetching, extraction, re-check policy, orchestration
versioning/ canonical URLs, hashing, version store, diff engine, diff API
analysis/ change entity, classification rules (not yet wired)
Layering is Controller → Service → Repository; business logic lives in the service
layer only. Scheduled jobs contain no logic — they call a synchronous runOnce()
that is also reachable over REST and testable without a scheduler.