Add index-page link discovery and simplify licence/priority fields

Adds link_discovery.py: sitemap-first, same-domain-crawl-fallback
  discovery of sub-pages from a single index URL, so a source with many
  sub-pages no longer needs each page entered by hand. Never pulls page
  titles or body text into a ticket field, only URLs, to avoid a
  prompt-injection path into tickets Claude Code later reads. Adds a
  review-and-approve screen so nothing is queued without a human
  checking the actual discovered list first.

  Replaces the free-text licence field with a required typed permission
  question, since licence itself is better researched from the source
  during actual processing rather than typed in at ticket time. Replaces
  the priority dropdown with a single urgent checkbox.

  Fixes a few real bugs found along the way: colliding ticket filenames
  when writing many tickets in one batch, a couple of layout issues
  where long labels or URLs were getting clipped instead of wrapping or
  scrolling, and dead links slipping through discovery from malformed
  relative link resolution
This commit is contained in:
John A. Hoeven 2026-08-07 22:23:28 +02:00
commit 16c592c971
Signed by: giovannino
GPG key ID: 306E507219506D4E
3 changed files with 426 additions and 19 deletions

View file

@ -36,10 +36,13 @@ import tomllib
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll from textual.containers import Horizontal, VerticalScroll
from textual.screen import Screen
from textual.widgets import ( from textual.widgets import (
Button, Button,
Checkbox,
Footer, Footer,
Header, Header,
Input, Input,
@ -49,6 +52,8 @@ from textual.widgets import (
TextArea, TextArea,
) )
from link_discovery import DiscoveredPage, discover_links
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration # Configuration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -93,12 +98,9 @@ SOURCE_TIER_OPTIONS = [
("forum/unmoderated", "forum-unmoderated"), ("forum/unmoderated", "forum-unmoderated"),
] ]
PRIORITY_OPTIONS = [ PERMISSION_QUESTION = "Do you have permission to access this document and add it to the RAG?"
("Immediate need", "immediate"),
("Add to queue", "queue"),
]
REQUIRED_FIELDS = ("source_location", "source_type", "source_tier", "licence", "priority") REQUIRED_FIELDS = ("source_location", "source_type", "source_tier", "permission")
def slugify(text: str, fallback: str) -> str: def slugify(text: str, fallback: str) -> str:
@ -110,6 +112,81 @@ def slugify(text: str, fallback: str) -> str:
return text[:60] or fallback return text[:60] or fallback
# ---------------------------------------------------------------------------
# Discovery review screen
# ---------------------------------------------------------------------------
class LinkReviewScreen(Screen[list[DiscoveredPage]]):
"""
Shows discovered sub-pages for approval before any ticket is written.
Titles shown here are derived from the URL only (see link_discovery.py)
never from fetched page content, deliberately, to avoid a prompt-
injection vector into the ticket queue Claude Code later reads.
Dismisses with the list of approved DiscoveredPage entries (empty list
if cancelled).
"""
CSS = """
#review-scroll { width: 100%; padding: 1 2; overflow-x: auto; }
#review-header { width: 100%; padding-bottom: 1; color: $text-muted; }
.review-row { width: auto; min-width: 100%; }
.not-in-sitemap { color: $warning; }
#review-button-row { width: 100%; height: auto; padding-top: 1; align-horizontal: center; }
#review-button-row Button { margin: 0 1; }
"""
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(self, pages: list[DiscoveredPage], method: str) -> None:
super().__init__()
self.pages = pages
self.method = method
def compose(self) -> ComposeResult:
yield Header()
with VerticalScroll(id="review-scroll"):
confirmed_note = (
"sitemap-verified" if self.method == "sitemap-verified" else "HTML crawl (no sitemap found)"
)
yield Static(
f"Found {len(self.pages)} page(s) via {confirmed_note}. "
f"Review and deselect anything you don't want queued.",
id="review-header",
)
for index, page in enumerate(self.pages):
warn = page.sitemap_confirmed is False
label = f"{page.title} [dim]{page.url}[/dim]"
if warn:
label += " [warning](not in sitemap)[/warning]"
yield Checkbox(label, value=True, id=f"pick-{index}", classes="review-row")
with Horizontal(id="review-button-row"):
yield Button("Select all", id="select-all")
yield Button("Select none", id="select-none")
yield Button("Approve selected", id="approve", variant="success")
yield Button("Cancel", id="cancel", variant="error")
yield Footer()
def _checkboxes(self) -> list[Checkbox]:
return list(self.query(Checkbox))
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "select-all":
for box in self._checkboxes():
box.value = True
elif event.button.id == "select-none":
for box in self._checkboxes():
box.value = False
elif event.button.id == "approve":
approved = [page for page, box in zip(self.pages, self._checkboxes()) if box.value]
self.dismiss(approved)
elif event.button.id == "cancel":
self.dismiss([])
def action_cancel(self) -> None:
self.dismiss([])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# App # App
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -123,8 +200,7 @@ class AmbrosianaIntakeApp(App):
} }
#form-scroll { #form-scroll {
width: 90; width: 100%;
max-width: 100%;
padding: 1 2; padding: 1 2;
} }
@ -136,6 +212,7 @@ class AmbrosianaIntakeApp(App):
} }
.field-label { .field-label {
width: 100%;
padding-top: 1; padding-top: 1;
color: $text; color: $text;
text-style: bold; text-style: bold;
@ -203,17 +280,24 @@ class AmbrosianaIntakeApp(App):
yield Label("Source location [required]", classes="field-label") yield Label("Source location [required]", classes="field-label")
yield Input(placeholder="filepath or URL", id="source_location") yield Input(placeholder="filepath or URL", id="source_location")
yield Label(
"Discovery depth (only used by \"Discover sub-pages\", below)",
classes="field-label optional-marker",
)
yield Input(placeholder="e.g. 4", id="discovery_depth", value="1")
yield Label("Source type [required]", classes="field-label") yield Label("Source type [required]", classes="field-label")
yield Select(SOURCE_TYPE_OPTIONS, id="source_type", prompt="Choose source type") yield Select(SOURCE_TYPE_OPTIONS, id="source_type", prompt="Choose source type")
yield Label("Source tier [required]", classes="field-label") yield Label("Source tier [required]", classes="field-label")
yield Select(SOURCE_TIER_OPTIONS, id="source_tier", prompt="Choose source tier") yield Select(SOURCE_TIER_OPTIONS, id="source_tier", prompt="Choose source tier")
yield Label("Licence [required]", classes="field-label") yield Label(
yield Input(placeholder="e.g. The Unlicense, CC-BY-4.0, proprietary...", id="licence") f"{PERMISSION_QUESTION} [required — type yes/no]", classes="field-label"
)
yield Input(placeholder="yes / no", id="permission")
yield Label("Priority [required]", classes="field-label") yield Checkbox("Urgent (immediate need — otherwise added to queue)", value=False, id="urgent")
yield Select(PRIORITY_OPTIONS, id="priority", prompt="Choose priority")
yield Label("Title / description (optional)", classes="field-label optional-marker") yield Label("Title / description (optional)", classes="field-label optional-marker")
yield Input(placeholder="short human-readable title", id="title") yield Input(placeholder="short human-readable title", id="title")
@ -223,6 +307,7 @@ class AmbrosianaIntakeApp(App):
with Horizontal(id="button-row"): with Horizontal(id="button-row"):
yield Button("Save ticket", id="submit", variant="success") yield Button("Save ticket", id="submit", variant="success")
yield Button("Discover sub-pages", id="discover", variant="primary")
yield Button("Clear", id="reset", variant="warning") yield Button("Clear", id="reset", variant="warning")
yield Button("Quit", id="quit", variant="error") yield Button("Quit", id="quit", variant="error")
@ -236,8 +321,8 @@ class AmbrosianaIntakeApp(App):
"source_location": self.query_one("#source_location", Input).value.strip(), "source_location": self.query_one("#source_location", Input).value.strip(),
"source_type": self.query_one("#source_type", Select).value, "source_type": self.query_one("#source_type", Select).value,
"source_tier": self.query_one("#source_tier", Select).value, "source_tier": self.query_one("#source_tier", Select).value,
"licence": self.query_one("#licence", Input).value.strip(), "permission": self.query_one("#permission", Input).value.strip(),
"priority": self.query_one("#priority", Select).value, "priority": "immediate" if self.query_one("#urgent", Checkbox).value else "queue",
"title": self.query_one("#title", Input).value.strip(), "title": self.query_one("#title", Input).value.strip(),
"notes": self.query_one("#notes-area", TextArea).text.strip(), "notes": self.query_one("#notes-area", TextArea).text.strip(),
} }
@ -253,8 +338,8 @@ class AmbrosianaIntakeApp(App):
self.query_one("#source_location", Input).value = "" self.query_one("#source_location", Input).value = ""
self.query_one("#source_type", Select).clear() self.query_one("#source_type", Select).clear()
self.query_one("#source_tier", Select).clear() self.query_one("#source_tier", Select).clear()
self.query_one("#licence", Input).value = "" self.query_one("#permission", Input).value = ""
self.query_one("#priority", Select).clear() self.query_one("#urgent", Checkbox).value = False
self.query_one("#title", Input).value = "" self.query_one("#title", Input).value = ""
self.query_one("#notes-area", TextArea).text = "" self.query_one("#notes-area", TextArea).text = ""
self.query_one("#source_location", Input).focus() self.query_one("#source_location", Input).focus()
@ -264,14 +349,25 @@ class AmbrosianaIntakeApp(App):
if missing: if missing:
self._set_status(f"Missing required field(s): {', '.join(missing)}", error=True) self._set_status(f"Missing required field(s): {', '.join(missing)}", error=True)
return None return None
if data["permission"].strip().lower() not in ("yes", "y"):
self._set_status(
"Permission must be confirmed by typing 'yes' — ticket not saved.", error=True
)
return None
now = datetime.now() now = datetime.now()
fallback_slug = Path(data["source_location"]).stem or "untitled" fallback_slug = Path(data["source_location"]).stem or "untitled"
slug = slugify(data["title"] or fallback_slug, fallback_slug) slug = slugify(data["title"] or fallback_slug, fallback_slug)
priority_tag = "immediate_" if data["priority"] == "immediate" else "" priority_tag = "immediate_" if data["priority"] == "immediate" else ""
filename = f"{priority_tag}{now.strftime('%Y%m%d-%H%M%S')}_{slug}.md" base_filename = f"{priority_tag}{now.strftime('%Y%m%d-%H%M%S')}_{slug}"
ticket_path = TICKETS_ROOT / filename ticket_path = TICKETS_ROOT / f"{base_filename}.md"
# Second-precision timestamps collide when writing many tickets in a
# tight loop (batch discovery) — disambiguate rather than overwrite.
suffix = 2
while ticket_path.exists():
ticket_path = TICKETS_ROOT / f"{base_filename}-{suffix}.md"
suffix += 1
front_matter_lines = [ front_matter_lines = [
"---", "---",
@ -281,7 +377,7 @@ class AmbrosianaIntakeApp(App):
f"source_location: \"{data['source_location']}\"", f"source_location: \"{data['source_location']}\"",
f"source_type: {data['source_type']}", f"source_type: {data['source_type']}",
f"source_tier: {data['source_tier']}", f"source_tier: {data['source_tier']}",
f"licence: \"{data['licence']}\"", f"permission_confirmed: \"{data['permission']}\"",
f"priority: {data['priority']}", f"priority: {data['priority']}",
f"title: \"{data['title']}\"" if data["title"] else "title: \"\"", f"title: \"{data['title']}\"" if data["title"] else "title: \"\"",
"---", "---",
@ -295,9 +391,15 @@ class AmbrosianaIntakeApp(App):
body_lines.append(f"**Source:** {data['source_location']}") body_lines.append(f"**Source:** {data['source_location']}")
body_lines.append(f"**Source-type:** `{data['source_type']}`") body_lines.append(f"**Source-type:** `{data['source_type']}`")
body_lines.append(f"**Source-tier:** `{data['source_tier']}`") body_lines.append(f"**Source-tier:** `{data['source_tier']}`")
body_lines.append(f"**Licence:** {data['licence']}") body_lines.append(f"**Permission confirmed:** {data['permission']}")
body_lines.append(f"**Priority:** {data['priority']}") body_lines.append(f"**Priority:** {data['priority']}")
body_lines.append("") body_lines.append("")
body_lines.append(
"*Licence is not captured here — research it from the source itself "
"during drafting; default to \"restricted, in-house use only\" if it "
"can't be determined.*"
)
body_lines.append("")
if data["notes"]: if data["notes"]:
body_lines.append("## Notes / context") body_lines.append("## Notes / context")
body_lines.append("") body_lines.append("")
@ -328,12 +430,72 @@ class AmbrosianaIntakeApp(App):
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "submit": if event.button.id == "submit":
self._do_submit() self._do_submit()
elif event.button.id == "discover":
self._do_discover()
elif event.button.id == "reset": elif event.button.id == "reset":
self._clear_form() self._clear_form()
self._set_status("") self._set_status("")
elif event.button.id == "quit": elif event.button.id == "quit":
self.exit() self.exit()
# -- discovery flow ---------------------------------------------------
def _do_discover(self) -> None:
data = self._collect()
shared_missing = [f for f in REQUIRED_FIELDS if f != "source_location"
and (not data.get(f) or data.get(f) is Select.BLANK)]
if shared_missing:
self._set_status(
f"Fill in {', '.join(shared_missing)} first — every discovered "
f"page's ticket will reuse these.", error=True,
)
return
if data["permission"].strip().lower() not in ("yes", "y"):
self._set_status(
"Permission must be confirmed by typing 'yes' before discovering.", error=True
)
return
if not re.match(r"^https?://", data["source_location"]):
self._set_status("Discovery needs an http(s) URL in Source location.", error=True)
return
try:
depth = int(self.query_one("#discovery_depth", Input).value.strip() or "1")
except ValueError:
self._set_status("Discovery depth must be a whole number.", error=True)
return
self._set_status(f"Discovering (depth {depth})... this may take a moment.")
self._run_discovery(data["source_location"], depth, data)
@work(thread=True)
def _run_discovery(self, start_url: str, depth: int, shared_data: dict[str, str]) -> None:
pages, method = discover_links(start_url, max_depth=depth)
self.call_from_thread(self._on_discovery_done, pages, method, shared_data)
def _on_discovery_done(
self, pages: list[DiscoveredPage], method: str, shared_data: dict[str, str]
) -> None:
if not pages:
self._set_status(f"No pages discovered via {method} — nothing to review.", error=True)
return
def handle_approved(approved: list[DiscoveredPage]) -> None:
if not approved:
self._set_status("Discovery cancelled — no tickets written.")
return
written = 0
for page in approved:
page_data = dict(shared_data)
page_data["source_location"] = page.url
page_data["title"] = page.title
if self._write_ticket(page_data) is not None:
written += 1
self._set_status(f"Wrote {written} ticket(s) from discovery.")
self._clear_form()
self.push_screen(LinkReviewScreen(pages, method), handle_approved)
if __name__ == "__main__": if __name__ == "__main__":
AmbrosianaIntakeApp().run() AmbrosianaIntakeApp().run()

243
link_discovery.py Normal file
View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# link_discovery.py
# ~/projects/ambrosiana-document-intake/link_discovery.py
# Version: v0.2.0 | Status: BETA
# ---------------------------------------------------------------------------
# Purpose: URL discovery from an index page, for the "add index page,
# review discovered sub-pages" intake flow. Fetches pages to find
# URLs only — never writes a ticket itself; that stays a separate,
# human-approved step in the App.
# Target: Any Ambrosiana device.
# Entry: import as a module.
# Depends: requests, beautifulsoup4 (see requirements.txt)
# ---------------------------------------------------------------------------
"""
Security note (deliberate, not an oversight): this module never extracts
free-text content from a fetched page (titles, headings, link text) into
anything that ends up in a ticket. A ticket is later read by Claude Code
as a pipeline instruction, so pulling untrusted third-party prose into a
ticket field would be a real prompt-injection vector. Only URLs which
this tool itself discovered under a scope the human chose are ever
returned. Display titles shown in the review UI are derived from the URL
path alone, never from fetched page content.
Two discovery strategies, tried in this order:
1. Sitemap (preferred) sitemap.xml is pure structured data (<loc>,
<lastmod>), nothing to inject into. Checked at the domain root and via
robots.txt's Sitemap: directive.
2. HTML link crawl (fallback) same-domain breadth-first crawl for sites
without a sitemap. Extracts hrefs only, never page titles or body text.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from urllib.parse import urljoin, urlparse, urlunparse
import requests
from bs4 import BeautifulSoup
USER_AGENT = "ambrosiana-intake-discovery/0.2 (personal RAG intake tool)"
REQUEST_TIMEOUT = 10
DEFAULT_MAX_PAGES = 300
SKIP_EXTENSIONS = (
".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
".woff", ".woff2", ".ttf", ".xml", ".json", ".pdf", ".zip", ".md",
)
SITEMAP_XML_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
@dataclass
class DiscoveredPage:
url: str
title: str # derived from the URL path only — never from fetched content
depth: int
sitemap_confirmed: bool | None = None # None = no sitemap available to check against
def _display_title(url: str) -> str:
"""Human-readable label derived purely from the URL — no page content."""
parsed = urlparse(url)
path = parsed.path.strip("/")
if not path:
return parsed.netloc or url # site root — use the domain, not the raw URL
last_segment = path.rsplit("/", 1)[-1]
return last_segment.replace("-", " ").replace("_", " ").strip() or url
def _normalize(url: str) -> str:
"""Strip fragment/query for de-dup purposes; keep the path as-is."""
parsed = urlparse(url)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/") or "/", "", "", ""))
def _is_same_domain(url: str, domain: str) -> bool:
return urlparse(url).netloc == domain
def _is_content_link(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
return not parsed.path.lower().endswith(SKIP_EXTENSIONS)
def _fetch(url: str) -> requests.Response | None:
try:
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response
except requests.RequestException:
return None
# ── Strategy 1: sitemap ──────────────────────────────────────────────────
def find_sitemap_url(start_url: str) -> str | None:
"""Check robots.txt's Sitemap: directive first, then the conventional path."""
parsed = urlparse(start_url)
root = f"{parsed.scheme}://{parsed.netloc}"
robots = _fetch(f"{root}/robots.txt")
if robots is not None:
for line in robots.text.splitlines():
if line.strip().lower().startswith("sitemap:"):
return line.split(":", 1)[1].strip()
candidate = f"{root}/sitemap.xml"
response = _fetch(candidate)
if response is not None and "xml" in response.headers.get("content-type", ""):
return candidate
return None
def fetch_sitemap_urls(sitemap_url: str, _depth: int = 0) -> set[str]:
"""
Parse a sitemap.xml (or a sitemap index listing nested sitemaps, handled
up to 2 levels deep) and return the full set of normalized <loc> URLs
used only as a cross-reference trust signal, never as the primary
discovery source (see discover_links). ElementTree's default parser
does not resolve external entities/DTDs, so this is safe against
classic XXE without needing an extra dependency.
"""
if _depth > 2:
return set()
response = _fetch(sitemap_url)
if response is None:
return set()
try:
root = ET.fromstring(response.content)
except ET.ParseError:
return set()
locs = [el.text.strip() for el in root.iter(f"{SITEMAP_XML_NS}loc") if el.text]
if root.tag == f"{SITEMAP_XML_NS}sitemapindex":
urls: set[str] = set()
for nested_url in locs[:20]: # bounded — a sitemap index fan-out cap
urls |= fetch_sitemap_urls(nested_url, _depth=_depth + 1)
return urls
return {_normalize(loc) for loc in locs}
# ── Strategy 2: HTML crawl fallback ──────────────────────────────────────
def discover_from_crawl(
start_url: str,
max_depth: int,
max_pages: int = DEFAULT_MAX_PAGES,
sitemap_urls: set[str] | None = None,
) -> list[DiscoveredPage]:
"""
Breadth-first same-domain crawl from start_url, following the actual
links on each page this is what "N links deep from the index"
literally means, including for hub/TOC-style index pages whose real
sub-content lives at sibling paths rather than nested under the index's
own URL. Extracts hrefs only never page titles or body text (see
module docstring). The start page itself is not included in the
returned list, only pages found from it.
If sitemap_urls is given, each discovered page is annotated with
whether it's also present in the sitemap — a trust signal shown in the
review UI, not a filter (a sitemap can be incomplete or stale).
"""
domain = urlparse(start_url).netloc
visited = {_normalize(start_url)}
queue: list[tuple[str, int]] = [(start_url, 0)]
discovered: list[DiscoveredPage] = []
fetched_count = 0
while queue and fetched_count < max_pages:
url, depth = queue.pop(0)
if depth >= max_depth:
continue
response = _fetch(url)
fetched_count += 1
if response is None:
continue
soup = BeautifulSoup(response.text, "html.parser")
for anchor in soup.find_all("a", href=True):
link = urljoin(url, anchor["href"])
if not _is_content_link(link) or not _is_same_domain(link, domain):
continue
link_norm = _normalize(link)
if link_norm in visited:
continue
visited.add(link_norm)
confirmed = (link_norm in sitemap_urls) if sitemap_urls is not None else None
discovered.append(
DiscoveredPage(url=link, title=_display_title(link), depth=depth + 1, sitemap_confirmed=confirmed)
)
if len(discovered) + fetched_count >= max_pages:
break
queue.append((link, depth + 1))
return [p for p in discovered if p.sitemap_confirmed or _is_reachable(p.url)]
def _is_reachable(url: str) -> bool:
"""
Cheap liveness check for links not already confirmed via the sitemap.
Relative-link resolution against an already-nested page can produce
plausible-looking but nonexistent URLs (e.g. a same-name breadcrumb
href resolving into a doubled path segment) verified live 2026-08-07
against textual.textualize.io, where this caught real 404s a sitemap-
confirmed link would never need checking for.
"""
try:
response = requests.head(
url, headers={"User-Agent": USER_AGENT}, timeout=REQUEST_TIMEOUT, allow_redirects=True
)
return response.status_code < 400
except requests.RequestException:
return False
# ── Entry point ───────────────────────────────────────────────────────────
def discover_links(
start_url: str,
max_depth: int,
max_pages: int = DEFAULT_MAX_PAGES,
) -> tuple[list[DiscoveredPage], str]:
"""
Returns (discovered_pages, method) method is "sitemap-verified" or
"crawl" so the UI can tell the human whether a sitemap was available to
cross-check against.
"""
sitemap_url = find_sitemap_url(start_url)
sitemap_urls = fetch_sitemap_urls(sitemap_url) if sitemap_url else None
pages = discover_from_crawl(start_url, max_depth=max_depth, max_pages=max_pages, sitemap_urls=sitemap_urls)
method = "sitemap-verified" if sitemap_urls else "crawl"
return pages, method

View file

@ -1 +1,3 @@
textual>=8.2.0 textual>=8.2.0
requests>=2.34.0
beautifulsoup4>=4.15.0