code.pablomurad.com

patchwork-turbo

Feeds patchwork with web pages, sites, other feeds, data files, documents, Go APIs and dependencies, written in Go

Import history from the Internet Archive, add the README

- import-wayback lists a url track's captures (one per day at most, only
  when the bytes changed, the newest ones first), converts each with the
  track's selectors and feeds them to `patchwork snapshot` oldest first.
  The newest copy stays in the output folder, so the first live run's
  change becomes a revision with a note.
- Tracks that follow one thing write out/<name>.md, so their slug is
  the track name.
- README: what each track reads, how notes are built, politeness rules,
  limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
author
Pablo Murad
date
commit
c39474323da09448d3bf7dec53dedfb00fa4f8b2
parent
9e470ae984a919b681a4fd68461e115fc5a07193
changes
11 files +451 −22
@@ -0,0 +1,153 @@+# patchwork-turbo
+
+[Patchwork](https://code.pablomurad.com/patchwork.git) publishes revision feeds for Markdown pages. patchwork-turbo brings everything else to it: web pages, whole sites, other people's feeds, JSON and CSV files, Word and LibreOffice documents, the API of a Go module and a project's dependencies.
+
+It turns each thing it tracks into a Markdown file in a folder, and a normal `patchwork serve` watches that folder. The two programs never import each other. You can open any file turbo wrote and read exactly what Patchwork will compare.
+
+```
+turbo.yaml ──> patchwork-turbo run ──> out/*.md ──> patchwork serve ──> Atom, JSON Feed, API, text
+```
+
+> Status: early development. The JSON contract and the file layout may change before 1.0.
+
+## What it tracks
+
+| Track | Source | One page per | What a revision note says |
+| --- | --- | --- | --- |
+| `url` | a web page | the page | `Changed on 2026-10-24 14:05 -03: 1 block added.` |
+| `sitemap` | a sitemap.xml or sitemap index | listed page | the same, per page |
+| `feed` | an RSS or Atom feed | item | the same, per item |
+| `data` | JSON, YAML or CSV, local or remote | file | `plans[Pro].price 49 → 59; added plans[Max].price` |
+| `doc` | DOCX or ODT, local or remote | document | `2 blocks changed.` |
+| `go` | a Go module on disk | package | `Breaking: changed func New, removed func Old. Added: type Option` |
+| `deps` | go.mod or package.json | file | `Updated golang.org/x/net v0.60.0 → v0.61.0; added golang.org/x/mod v0.42.0` |
+
+Patchwork publishes a revision when a page's `revision` note changes. Turbo writes a file only when the converted content changed, and gives it a new note naming the time and the change, so every real change becomes one revision and a page that didn't change costs nothing. The first version of a page is Patchwork's silent baseline, as always.
+
+## Install
+
+```sh
+git clone https://code.pablomurad.com/patchwork-turbo.git
+cd patchwork-turbo
+go build -o patchwork-turbo ./cmd/patchwork-turbo
+```
+
+One binary, no cgo, about 10 MB stripped.
+
+## Quick start
+
+```sh
+patchwork-turbo init        # writes a commented turbo.yaml
+patchwork-turbo check       # runs every track, writes nothing, shows what it found
+patchwork-turbo once        # runs every track now
+patchwork-turbo run         # keeps running each track when it is due
+```
+
+Then point Patchwork at turbo's output:
+
+```yaml
+# patchwork.yaml
+source:
+  mode: folder
+  path: /data/turbo        # turbo.yaml's `out`
+  include: ["**/*.md"]
+  slug: path
+```
+
+A track that follows one thing (`url`, `data`, `doc`, `deps`) writes `out/<name>.md`, so its slug is the track name. The others write into `out/<name>/`.
+
+## Configuration
+
+```yaml
+out: out                   # the folder patchwork watches; keep it on a local disk
+timezone: America/Sao_Paulo
+user_agent: "patchwork-turbo (+https://example.com/contact)"
+host_gap: 5s               # minimum time between two requests to one host
+
+tracks:
+  - name: home
+    url: https://example.com/
+    select: main           # CSS selector for the content (default: main, article, body)
+    ignore: ["footer", ".visit-counter", "time"]
+    every: 6h
+
+  - name: docs
+    sitemap: https://example.com/sitemap.xml
+    select: article
+    max_pages: 200
+    every: 24h
+
+  - name: blog
+    feed: https://example.com/feed.xml
+    every: 3h
+
+  - name: prices
+    data: https://example.com/prices.json
+
+  - name: handbook
+    doc: ./handbook.docx
+
+  - name: api
+    go: ../my-module
+
+  - name: deps
+    deps: ../my-module/go.mod
+```
+
+Every track takes `every` (default `6h`, at least `1m`). `TURBO_CONFIG`, `TURBO_OUT` and `TURBO_STATE` override the config path, the output folder and the state file.
+
+## How each converter reads its source
+
+### Web pages
+
+Web pages keep headings, paragraphs, lists, quotes, code blocks, tables, links and images with alt text. Scripts, styles, navigation, forms, sidebars and hidden elements are dropped, plus whatever `ignore` lists. Whitespace is normalized, so the same text always gives the same bytes. Use `ignore` for parts that change on their own, like dates, visit counters or "related posts".
+
+### Sitemaps
+
+Sitemaps are fetched in full each run, but a page is fetched again only when its `<lastmod>` moved. Pages that leave the sitemap leave the folder, and an empty sitemap is treated as an error rather than as a site with no pages.
+
+### Feeds
+
+Feeds become one page per item, keyed by the item's id or link. An item that falls off the feed keeps its last version, since feeds only carry the latest items.
+
+### Data files
+
+Data files become one line per field, sorted: `- plans[Pro].price: 49`. Lists of objects are keyed by `id`, `slug`, `key`, `name` or `title` when one of them is unique, so inserting an element doesn't renumber everything after it. CSV rows are keyed by their first column.
+
+### Documents
+
+Documents keep headings (from the style's outline level, so localized style names work), paragraphs, lists and tables. Formatting, images, comments and footnotes are left out. Deleted text in tracked changes is ignored.
+
+### Go modules
+
+Go modules become one page per package with every exported declaration, its doc comment and no function bodies; git already keeps the bodies. Files are chosen for linux/amd64 so every machine writes the same pages. Main packages, tests, `testdata`, `vendor` and nested modules are skipped. Removing a declaration or changing a function's signature is reported as breaking.
+
+### Dependencies
+
+Dependencies come from `go.mod` (requires, replaces, the Go version and toolchain) or `package.json` (runtime, dev, peer and optional dependencies).
+
+## Politeness
+
+Turbo identifies itself with `user_agent`, follows robots.txt (a group naming `patchwork-turbo`, else `*`), waits `host_gap` between requests to the same host, sends `If-None-Match` and `If-Modified-Since` so unchanged pages cost a 304, honors `Retry-After` on 429 and 503, and refuses bodies over 2 MB. When robots.txt can't be read because of a server error, the host is skipped for an hour rather than crawled blind.
+
+## Importing history from the Internet Archive
+
+A `url` track can start with the page's past instead of today:
+
+```sh
+patchwork-turbo import-wayback -track home -limit 20 \
+  -patchwork /usr/local/bin/patchwork -patchwork-config patchwork.yaml
+```
+
+Turbo lists the archive's captures (at most one per day, only when the content changed, the newest `-limit` of them), converts each with the track's selectors and hands them to `patchwork snapshot`, oldest first. Patchwork can only add history before a page's first version, so run this before the track's first `once` or `run`. Add `-dry-run` to see which copies would go in.
+
+## Limits
+
+- Pages that build their content with JavaScript come out nearly empty. Turbo has no headless browser on purpose; `check` warns when a page has fewer than 30 words.
+- PDF isn't read yet: there's no pure-Go text extractor good enough.
+- Code tracking reads Go only.
+- No AI anywhere. Notes are counts, field names and declaration names.
+
+## License
+
+MIT. See [LICENSE](LICENSE).
@@ -36,6 +36,8 @@ init      write a commented turbo.yaml   once      run every track (or one, with -track) now
   run       keep running each track when it is due
   check     run tracks without writing anything, and report what they find
+  import-wayback
+            build a url track's history from the Internet Archive
   version   print the version
 
 Commands read turbo.yaml from the current directory, or the file named by
@@ -63,6 +65,8 @@ case "check": 		err = cmdOnce(ctx, args, true)
 	case "run":
 		err = cmdRun(ctx, args)
+	case "import-wayback":
+		err = cmdImportWayback(ctx, args)
 	case "version", "-version", "--version":
 		fmt.Fprintln(stdout, "patchwork-turbo", version)
 	case "help", "-h", "-help", "--help":
@@ -199,11 +203,19 @@ paths = append(paths, p) 	}
 	sort.Strings(paths)
 	for _, p := range paths {
-		fmt.Fprintf(stdout, "  %s: %s\n", p, rep.Notes[p])
+		name := p
+		if name == out.Single {
+			name = t.Name
+		}
+		fmt.Fprintf(stdout, "  %s: %s\n", name, rep.Notes[p])
 	}
 	if dryRun {
 		for _, p := range res.Pages {
-			fmt.Fprintf(stdout, "  page %s  %q  %d words\n", p.Path, p.Title, len(strings.Fields(p.Body)))
+			name := p.Path
+			if name == out.Single {
+				name = t.Name
+			}
+			fmt.Fprintf(stdout, "  page %s  %q  %d words\n", name, p.Title, len(strings.Fields(p.Body)))
 		}
 	}
 	for _, w := range res.Warnings {
@@ -0,0 +1,143 @@+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/config"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/fetch"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/htmlmd"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/out"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/state"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/wayback"
+)
+
+// cmdImportWayback builds a url track's history from the Internet
+// Archive. Patchwork extends a page's history only at its start, so this
+// runs before the track's first run: every archived copy goes in through
+// `patchwork snapshot`, oldest first, and the newest one is left in the
+// output folder for the first live run to compare against.
+func cmdImportWayback(ctx context.Context, args []string) error {
+	fs := newFlags("import-wayback", "-track NAME [-patchwork PATH] [-patchwork-config FILE] [-from YEAR] [-limit N] [-dry-run]")
+	cfgPath := configFlag(fs)
+	name := fs.String("track", "", "the url track to import")
+	bin := fs.String("patchwork", "patchwork", "the patchwork binary")
+	pcfg := fs.String("patchwork-config", "patchwork.yaml", "the patchwork.yaml whose source folder is turbo's out")
+	from := fs.Int("from", 0, "skip captures before this year")
+	limit := fs.Int("limit", 50, "import at most this many captures, the newest ones")
+	dryRun := fs.Bool("dry-run", false, "list what would be imported without importing")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	cfg, err := config.Load(*cfgPath)
+	if err != nil {
+		return err
+	}
+	t, ok := cfg.Track(*name)
+	if !ok {
+		return fmt.Errorf("no track named %q", *name)
+	}
+	if t.Kind() != config.KindURL {
+		return fmt.Errorf("track %q: only url tracks can be imported from the archive", t.Name)
+	}
+	live := &out.Writer{Dir: cfg.Out}
+	if live.HasAny(t.Name) {
+		return fmt.Errorf("track %q already has pages: patchwork can only add history before the first version, so import before the track's first run", t.Name)
+	}
+
+	st, _ := state.Load("") // archive validators are not worth keeping
+	client := fetch.New(cfg.UserAgent, cfg.HostGap.Duration, st)
+	caps, err := wayback.Captures(ctx, client, t.URL, *from, *limit)
+	if err != nil {
+		return err
+	}
+	if len(caps) == 0 {
+		fmt.Fprintln(stdout, "the archive has no captures of", t.URL)
+		return nil
+	}
+	base, err := url.Parse(t.URL)
+	if err != nil {
+		return err
+	}
+	tmp, err := os.MkdirTemp("", "turbo-wayback-")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(tmp)
+
+	rel := t.Name + ".md"
+	last := ""
+	imported := 0
+	for _, c := range caps {
+		resp, err := client.Get(ctx, c.URL, false)
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+		if err != nil {
+			fmt.Fprintf(stdout, "%s  skipped: %v\n", c.At.Format(time.DateOnly), err)
+			continue
+		}
+		hp, err := htmlmd.Convert(resp.Body, resp.ContentType, htmlmd.Options{Base: base, Select: t.Select, Ignore: t.Ignore})
+		if err != nil {
+			fmt.Fprintf(stdout, "%s  skipped: %v\n", c.At.Format(time.DateOnly), err)
+			continue
+		}
+		if strings.TrimSpace(hp.Body) == "" {
+			fmt.Fprintf(stdout, "%s  skipped: no text (a redirect or an error page)\n", c.At.Format(time.DateOnly))
+			continue
+		}
+		title := hp.Title
+		if title == "" {
+			title = t.Name
+		}
+		at := c.At
+		w := &out.Writer{Dir: tmp, Loc: cfg.Location(), Now: func() time.Time { return at }}
+		rep, err := w.Write(t.Name, []out.Page{{Path: out.Single, Title: title, URL: t.URL, Body: hp.Body}}, nil, false)
+		if err != nil {
+			return err
+		}
+		if rep.New+rep.Changed == 0 {
+			continue // the same text as the copy before it
+		}
+		file := w.File(t.Name, out.Single)
+		fmt.Fprintf(stdout, "%s  %d words\n", c.At.Format(time.DateOnly), len(strings.Fields(hp.Body)))
+		if !*dryRun {
+			cmd := exec.CommandContext(ctx, *bin, "snapshot", "-config", *pcfg, "-path", rel, "-file", file, "-at", c.At.Format(time.RFC3339))
+			cmd.Stdout, cmd.Stderr = stdout, os.Stderr
+			if err := cmd.Run(); err != nil {
+				return fmt.Errorf("patchwork snapshot: %w", err)
+			}
+		}
+		last = file
+		imported++
+	}
+	if *dryRun || last == "" {
+		fmt.Fprintf(stdout, "%d copies to import\n", imported)
+		return nil
+	}
+	// Leave the newest copy where the first live run will find it, so the
+	// change since then becomes a revision with a note.
+	raw, err := os.ReadFile(last)
+	if err != nil {
+		return err
+	}
+	dst := live.File(t.Name, out.Single)
+	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+		return err
+	}
+	if err := os.WriteFile(dst+".tmp", raw, 0o644); err != nil {
+		return err
+	}
+	if err := os.Rename(dst+".tmp", dst); err != nil {
+		return errors.Join(err, os.Remove(dst+".tmp"))
+	}
+	fmt.Fprintf(stdout, "imported %d copies; run the track now to add today's version\n", imported)
+	return nil
+}
@@ -34,6 +34,11 @@ // Compare. 	Summary func(before, after string) (summary, kind string)
 }
 
+// Single is the path of a track's only page, for tracks that follow one
+// thing: it is written as <track>.md next to the other tracks' folders, so
+// its patchwork slug is just the track name.
+const Single = ""
+
 // Writer writes the pages of every track under Dir.
 type Writer struct {
 	Dir string
@@ -63,11 +68,11 @@ rep := Report{Notes: map[string]string{}, Unchanged: len(keepPaths)} 	dir := filepath.Join(w.Dir, track)
 	keep := map[string]bool{}
 	for _, p := range keepPaths {
-		keep[w.file(track, p)] = true
+		keep[w.File(track, p)] = true
 	}
 	written := map[string]bool{}
 	for _, p := range pages {
-		file := w.file(track, p.Path)
+		file := w.File(track, p.Path)
 		if written[file] {
 			continue // two URLs that slugify alike: the first one wins
 		}
@@ -91,22 +96,38 @@ return rep, nil 	}
 	n, err := w.removeOthers(dir, keep)
 	rep.Removed = n
+	if err != nil {
+		return rep, err
+	}
+	if single := w.File(track, Single); !keep[single] && w.Has(track, Single) {
+		rep.Removed++
+		if !w.DryRun {
+			err = os.Remove(single)
+		}
+	}
 	return rep, err
 }
 
 // Has reports whether a page's file exists.
 func (w *Writer) Has(track, path string) bool {
-	_, err := os.Stat(w.file(track, path))
+	_, err := os.Stat(w.File(track, path))
 	return err == nil
 }
 
 // HasAny reports whether a track has written any page yet.
 func (w *Writer) HasAny(track string) bool {
+	if w.Has(track, Single) {
+		return true
+	}
 	entries, err := os.ReadDir(filepath.Join(w.Dir, track))
 	return err == nil && len(entries) > 0
 }
 
-func (w *Writer) file(track, path string) string {
+// File is where a page of a track is written.
+func (w *Writer) File(track, path string) string {
+	if path == Single {
+		return filepath.Join(w.Dir, track+".md")
+	}
 	return filepath.Join(w.Dir, track, filepath.FromSlash(path)+".md")
 }
 
@@ -17,7 +17,7 @@ } 
 func read(t *testing.T, w *Writer, track, path string) string {
 	t.Helper()
-	raw, err := os.ReadFile(w.file(track, path))
+	raw, err := os.ReadFile(w.File(track, path))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -43,7 +43,7 @@ func TestUnchangedIsNotRewritten(t *testing.T) { 	w := writer(t)
 	p := Page{Path: "index", Title: "Home", Body: "Hello.  \n\n\n"}
 	w.Write("site", []Page{p}, nil, true)
-	file := w.file("site", "index")
+	file := w.File("site", "index")
 	old := time.Now().Add(-time.Hour)
 	os.Chtimes(file, old, old)
 
@@ -55,13 +55,13 @@ if err != nil { 		return Result{}, err
 	}
 	if s == nil {
-		res.Keep = []string{"index"}
+		res.Keep = []string{out.Single}
 		return res, nil
 	}
 	body, err := deps.Convert(s.name, s.body)
 	if err != nil {
 		return Result{}, err
 	}
-	res.Pages = []out.Page{{Path: "index", Title: "Dependencies (" + s.name + ")", URL: s.url, Body: body, Summary: deps.Summary}}
+	res.Pages = []out.Page{{Path: out.Single, Title: "Dependencies (" + s.name + ")", URL: s.url, Body: body, Summary: deps.Summary}}
 	return res, nil
 }
@@ -33,7 +33,7 @@ return nil, err 		}
 		return &source{name: filepath.Base(src), body: body}, nil
 	}
-	resp, err := r.Fetch.Get(ctx, src, r.Writer.Has(t.Name, "index"))
+	resp, err := r.Fetch.Get(ctx, src, r.Writer.Has(t.Name, out.Single))
 	if err != nil || resp.NotModified {
 		return nil, err
 	}
@@ -56,14 +56,14 @@ if err != nil { 		return Result{}, err
 	}
 	if s == nil {
-		res.Keep = []string{"index"}
+		res.Keep = []string{out.Single}
 		return res, nil
 	}
 	body, err := datamd.Convert(s.body, datamd.Format(s.name, s.contentType))
 	if err != nil {
 		return Result{}, fmt.Errorf("%s: %w", t.Data, err)
 	}
-	res.Pages = []out.Page{{Path: "index", Title: s.name, URL: s.url, Body: body, Summary: datamd.Summary}}
+	res.Pages = []out.Page{{Path: out.Single, Title: s.name, URL: s.url, Body: body, Summary: datamd.Summary}}
 	return res, nil
 }
 
@@ -79,7 +79,7 @@ if err != nil { 		return Result{}, err
 	}
 	if s == nil {
-		res.Keep = []string{"index"}
+		res.Keep = []string{out.Single}
 		return res, nil
 	}
 	d, err := officemd.Convert(s.body)
@@ -90,7 +90,7 @@ title := d.Title 	if title == "" {
 		title = strings.TrimSuffix(s.name, path.Ext(s.name))
 	}
-	res.Pages = []out.Page{{Path: "index", Title: title, URL: s.url, Body: d.Body}}
+	res.Pages = []out.Page{{Path: out.Single, Title: title, URL: s.url, Body: d.Body}}
 	return res, nil
 }
 
@@ -79,7 +79,7 @@ } 
 func file(t *testing.T, r *Runner, track, path string) string {
 	t.Helper()
-	raw, err := os.ReadFile(filepath.Join(r.Cfg.Out, track, path+".md"))
+	raw, err := os.ReadFile(r.Writer.File(track, path))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -103,12 +103,12 @@ _, rep, err := r.Run(ctx, tr) 	if err != nil || rep.New != 1 {
 		t.Fatalf("first run: %+v %v", rep, err)
 	}
-	if got := file(t, r, "home", "index"); strings.Contains(got, "revision:") || !strings.Contains(got, "# Welcome") {
+	if got := file(t, r, "home", out.Single); strings.Contains(got, "revision:") || !strings.Contains(got, "# Welcome") {
 		t.Errorf("first version:\n%s", got)
 	}
 
 	_, rep, _ = r.Run(ctx, tr) // 304
-	if rep.Unchanged != 1 || rep.Removed != 0 || !r.Writer.Has("home", "index") {
+	if rep.Unchanged != 1 || rep.Removed != 0 || !r.Writer.Has("home", out.Single) {
 		t.Fatalf("a 304 must keep the page untouched: %+v", rep)
 	}
 
@@ -118,7 +118,7 @@ _, rep, _ = r.Run(ctx, tr) 	if rep.Changed != 1 {
 		t.Fatalf("change: %+v", rep)
 	}
-	if got := file(t, r, "home", "index"); !strings.Contains(got, "Changed on 2026-10-25 10:00 UTC: 1 block added.") {
+	if got := file(t, r, "home", out.Single); !strings.Contains(got, "Changed on 2026-10-25 10:00 UTC: 1 block added.") {
 		t.Errorf("changed version:\n%s", got)
 	}
 
@@ -222,7 +222,7 @@ os.WriteFile(src, []byte(`{"plans":[{"name":"Pro","price":59}]}`), 0o644) 	if _, rep, err := r.Run(ctx, tr); err != nil || rep.Changed != 1 {
 		t.Fatalf("second run: %+v %v", rep, err)
 	}
-	if got := file(t, r, "prices", "index"); !strings.Contains(got, "plans[Pro].price 49 → 59") {
+	if got := file(t, r, "prices", out.Single); !strings.Contains(got, "plans[Pro].price 49 → 59") {
 		t.Errorf("file:\n%s", got)
 	}
 }
@@ -19,9 +19,9 @@ // thinPage is the word count under which a page probably needs JavaScript // to show its content, or the selector missed it.
 const thinPage = 30
 
-// page tracks one URL. Its file is always named index.
+// page tracks one URL, as the track's single page.
 func (r *Runner) page(ctx context.Context, t config.Track) (Result, error) {
-	const path = "index"
+	const path = out.Single
 	var res Result
 	resp, err := r.Fetch.Get(ctx, t.URL, r.Writer.Has(t.Name, path))
 	switch {
@@ -0,0 +1,73 @@+// Package wayback lists the Internet Archive's captures of a page, so its
+// history from before turbo ran can be imported.
+package wayback
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"strconv"
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/fetch"
+)
+
+// Capture is one archived copy of a page.
+type Capture struct {
+	At       time.Time
+	Original string
+	// URL serves the copy exactly as the site sent it, without the
+	// archive's toolbar or rewritten links.
+	URL string
+}
+
+// CDX is the archive's capture index.
+const CDX = "https://web.archive.org/cdx/search/cdx"
+
+// Captures lists successful captures of u, oldest first, at most one per
+// day and only when the content changed since the previous one. With a
+// limit, the newest captures are the ones kept.
+func Captures(ctx context.Context, c *fetch.Client, u string, from int, limit int) ([]Capture, error) {
+	q := url.Values{}
+	q.Set("url", u)
+	q.Set("output", "json")
+	q.Set("fl", "timestamp,original")
+	q.Set("filter", "statuscode:200")
+	q.Add("collapse", "timestamp:8") // one per day
+	q.Add("collapse", "digest")      // and only when the bytes changed
+	if from > 0 {
+		q.Set("from", strconv.Itoa(from))
+	}
+	if limit > 0 {
+		q.Set("limit", strconv.Itoa(-limit)) // negative: the last ones
+	}
+	resp, err := c.Get(ctx, CDX+"?"+q.Encode(), false)
+	if err != nil {
+		return nil, err
+	}
+	return parse(resp.Body)
+}
+
+// parse reads the CDX JSON output: a header row, then one row per capture.
+func parse(body []byte) ([]Capture, error) {
+	var rows [][]string
+	if len(body) == 0 {
+		return nil, nil
+	}
+	if err := json.Unmarshal(body, &rows); err != nil {
+		return nil, fmt.Errorf("capture index: %w", err)
+	}
+	var caps []Capture
+	for i, row := range rows {
+		if i == 0 || len(row) < 2 {
+			continue // the header
+		}
+		at, err := time.Parse("20060102150405", row[0])
+		if err != nil {
+			return nil, fmt.Errorf("capture index: timestamp %q: %w", row[0], err)
+		}
+		caps = append(caps, Capture{At: at, Original: row[1], URL: "https://web.archive.org/web/" + row[0] + "id_/" + row[1]})
+	}
+	return caps, nil
+}
@@ -0,0 +1,27 @@+package wayback
+
+import (
+	"testing"
+	"time"
+)
+
+func TestParse(t *testing.T) {
+	caps, err := parse([]byte(`[["timestamp","original"],
+["20240115083000","https://example.com/"],
+["20250301000000","http://example.com:80/"]]`))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(caps) != 2 {
+		t.Fatalf("got %d captures", len(caps))
+	}
+	if !caps[0].At.Equal(time.Date(2024, 1, 15, 8, 30, 0, 0, time.UTC)) || caps[0].URL != "https://web.archive.org/web/20240115083000id_/https://example.com/" {
+		t.Errorf("first capture %+v", caps[0])
+	}
+	if caps, err := parse(nil); err != nil || caps != nil {
+		t.Errorf("no captures: %v %v", caps, err)
+	}
+	if _, err := parse([]byte(`[["timestamp","original"],["2024","x"]]`)); err == nil {
+		t.Error("a bad timestamp must be an error")
+	}
+}