Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ code is worse than none, because it is still trusted. Two more doc rules:
### Where IO goes

When adding or swapping IO, work in `adapters/` and `bootstrap.build_deps()` — never touch
`service_layer/` or `domain/` (architecture.md §8). Only a genuine IO boundary gets a Protocol in
`service_layer/` or `domain/` (architecture.md §8), except to call an optional `Deps` callable from
`run_digest` whose IO lives in `adapters/` and whose None changes nothing. Only a genuine IO boundary gets a Protocol in
`ports.py`; a single-implementation component is injected directly. Vote similarity
suppresses by a relative outlier cutoff, `OUTLIER_MARGIN` in `src/cyris/domain/similarity.py`, not
a per-model cosine: a hold lasts `SUPPRESSION_CLOCK_DAYS` (ADR-0017), and ADR-0006 is the history of
Expand Down
23 changes: 20 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ test.
| Wiring | Targets | Swap difficulty |
|---|---|---|
| **Via Protocol** (`ports.py`) | `LLMClient`, `ArticleRepository`, `TopicRepository` (`D1TopicStore`, `JsonTopicStore`), `FetchSource`, `Embedder` | **Low** — swapping the implementation never touches the core |
| **Direct injection** (no Protocol) | `HtmlDigestWriter`, `publish`, `sync_promotions`, `append_usage`, `notify`, `send_email`, `send_email_alert`, `D1TagStore`, `D1StoryStore`, `D1DigestStore`, `D1DigestInputStore`, `D1AreaStore`, `D1SourceTitles`, `ask_clef` (`ClefClient`), `page_serves`, `FeedChecker` (the wizard's feed check, `bootstrap.build_feed_checker`) | **Medium** — the core calls them directly; a second backend needs a Protocol first |
| **Direct injection** (no Protocol) | `HtmlDigestWriter`, `publish`, `sync_promotions`, `append_usage`, `notify`, `send_email`, `send_email_alert`, `D1TagStore`, `D1StoryStore`, `D1DigestStore`, `D1DigestInputStore`, `D1AreaStore`, `D1SourceTitles`, `ask_clef` (`ClefClient`), `page_serves`, `FeedChecker` (the wizard's feed check, `bootstrap.build_feed_checker`), `lengthen_articles` (`adapters/fetch/article_page.py`; wired by `build_deps(fetch_pages=True)`, which only `cyris run` and a `regenerate` rerun ask for) | **Medium** — the core calls them directly; a second backend needs a Protocol first |

`ports.py`'s rule: *only genuine IO boundaries get a Protocol; single-implementation components are
injected directly.*
Expand Down Expand Up @@ -378,6 +378,21 @@ lives once, as the live-hold query in `adapters/store/suppressions.py`. On D1, e
candidate it judged also leaves a row in `vote_similarity_shadow` naming its nearest upvoted and
downvoted article and both cosines (§4), a record that changes nothing the run selects.

After the holds and before the pipeline, a run reads each **teaser's page**
(`Deps.lengthen_articles`, `adapters/fetch/article_page.py`): an RSS article whose text is shorter
than `digest.summarize_snippet_length` has its own page fetched and its body extracted with
trafilatura, and the longer of the two texts goes to the pipeline. Scoring has already read the
feed's text, and the page's text is never stored, so a later run or a rerun fetches it again. A page
that fails, times out, answers non-2xx or says less leaves the feed text. On the first address and
every redirect hop, only `http(s)` is asked, and a host written as a non-public IP address (in any
IPv4 spelling a resolver reads, such as `127.1` or `2130706433`) or as `localhost` or a name under
it is refused. Names are not resolved, so a hostname whose DNS points at a private address is still
asked: the owner accepted that risk on 2026-11-03, since the Cloudflare run container has nothing
listening locally and no private network, and resolving names here would break
`docs/spec/outbound-http-honours-environment.md`. An article linking to a video (`media_links` in
`adapters/fetch/keywords.json`) gets no page read: a video page's text is the site's navigation, not the video. Pages are read
eight at a time, ten seconds each, so a run's added time is at most ceil(teasers / 8) × 10s (§5).

**Areas** are the reader's closed set of topics (D1 `areas`, §4, §5). Scoring is offered the active
names and returns each article's pick; a name the model suggests because no active area fits is
counted on a proposed `areas` row for the reader to confirm, and never changes the scores. After the digest is built, each item gets the name of its first article's pick or else its
Expand Down Expand Up @@ -549,7 +564,7 @@ table `schema.sql` creates must appear in the rows below, so a new table cannot

| Datum | Today | Destination | Notes |
|---|---|---|---|
| Article store | **D1 `stored_articles`** | same | `content` is written as plain text for every source (newsletter bodies already were; an RSS entry's HTML is converted by `CloudflareRssSource`, or by `RssSource` on the direct-polling path, through `adapters/fetch/html_text.py`). Rows stored before RSS content was converted keep their HTML: `INSERT OR IGNORE` never rewrites them, and a pending row or a rerun turns them back into articles as they are. `url` PRIMARY KEY is the dedup key; a colliding newsletter issue is re-keyed to `newsletter:{id}`. Besides a run's save, the onboarding votes step (§6) stores each card the reader marked and then accepts or rejects it with `triaged_at`, as a `/labels` answer is written; a card left alone gets no row and stays in the buffer for the first run |
| Article store | **D1 `stored_articles`** | same | `content` is written as plain text for every source (newsletter bodies already were; an RSS entry's HTML is converted by `CloudflareRssSource`, or by `RssSource` on the direct-polling path, through `adapters/fetch/html_text.py`). Rows stored before RSS content was converted keep their HTML: `INSERT OR IGNORE` never rewrites them, and a pending row or a rerun turns them back into articles as they are. `url` PRIMARY KEY is the dedup key; a colliding newsletter issue is re-keyed to `newsletter:{id}`. Besides a run's save, the onboarding votes step (§6) stores each card the reader marked and then accepts or rejects it with `triaged_at`, as a `/labels` answer is written; a card left alone gets no row and stays in the buffer for the first run. A teaser's page text (§3.2) is the run's alone and is never written here |
| Tag vocabulary | **D1 `tags`** | same | Normalized tags emitted by clustering and scoring |
| Article tags | **D1 `article_tags`** | same | URL-keyed article membership in the tag vocabulary |
| Stories | **D1 `stories`** | same | Pre-truncation news clusters, keyed `{date}-{period}-{urlhash}` (content-derived from member URLs), replaced per window |
Expand Down Expand Up @@ -630,6 +645,7 @@ plan's ceilings, and the priced alternatives — is `docs/hosting-and-cost.md`.
| Propose title length | A | `TITLE_MAX_CHARS` = 80 in `adapters/store/source_titles.py` | unchanged — a headline fits in 80 characters; a longer title is mostly a dek the model does not need. `tests/test_area_source_titles.py` holds the value to this row |
| Propose title lookback | A | `TITLE_LOOKBACK_DAYS` = 30 in `adapters/store/source_titles.py` | unchanged — the window `feed_health.py`'s `QUIET_DAYS` uses: a source silent that long is quiet, not described. `tests/test_area_source_titles.py` holds the value to this row |
| RSS Worker read ceiling | A | `WORKER_ROW_CEILING` in `adapters/fetch/rss_worker_source.py`, mirroring the clamp on `GET /articles` in `workers/rss/src/index.js` | done 2026-10-02 — a memory bound, not the run cap: every row carries full feed content. It is sent by value because the Worker's default without one is lower; `tests/test_rss_worker_source.py` holds the two equal |
| Teaser page reads: 8 at once, 10s per page (redirects and body included), 5 MiB a page, and the User-Agent `cyris (+https://github.com/musingfox/cyris)` | A | `PAGE_CONCURRENCY`, `PAGE_TIMEOUT_SECONDS`, `MAX_PAGE_BYTES`, `USER_AGENT` in `adapters/fetch/article_page.py` | added 2026-11-03 — about a third of an issue's hundred articles are teasers, so eight at once reads them in about five waves and a run's added time stays under a minute, against a run container capped at 900s; a deployment has nothing to tune, and a site that refuses the honest User-Agent leaves the feed text. Which article is a teaser is the grade-D `digest.summarize_snippet_length`, not a constant of its own |
| Interest map limits: 60 nodes, layout iterations and margin, drawing box | A | `MAX_INTEREST_NODES` in `domain/interest_graph.py`; `ITERATIONS` and `MARGIN` in `domain/graph_layout.py`; `INTEREST_MAP_WIDTH` and `INTEREST_MAP_HEIGHT` in `adapters/output/html_digest.py` | done 2026-10-17 — display limits, each with its reason beside it in code; a reader sets none of them |
| Feed health thresholds: unhealthy at 3 consecutive failed polls, or no stored article for 30 days (never included) | A | `UNHEALTHY_FAILURE_STREAK`, `QUIET_DAYS` in `adapters/store/feed_health.py` | done 2026-10-08 — one Substack 429 is routine, three hourly polls failing in a row is not; feeds publish weekly or monthly, so a shorter silence is noise. `cyris doctor` and `/settings` both judge through `FeedHealth.problems`, so neither copies a number |
| Pages publish timing: the 180s budget and 120s run reserve, the 120s verify budget of a publishing rerun, the stage and alias poll counts and intervals, the 20s per-request timeout and a deploy attempt's worst case | A | `adapters/output/publish.py`, `adapters/output/pages_deploy.py` | unchanged — reasons in the comments beside each constant; `tests/test_publish.py` pins the budget against `RUN_SLEEP_AFTER` in `workers/app/src/index.js`, and what the budget does not cover is in *Publishing* (§6) |
Expand Down Expand Up @@ -1309,4 +1325,5 @@ on main after the change lands, citing the hash it landed as.
Across local, container, and cloud, `service_layer/` and `domain/` are untouched. Every difference
lives in `adapters/` and in `bootstrap.build_deps()`. That is the payoff of the Protocol +
composition-root design, and the reason the D1 store landed without a single line changing in the
pipeline.
pipeline. The one exception is an optional `Deps` callable that `run_digest` calls, its IO in
`adapters/` and None leaving the run unchanged: `lengthen_articles`.
226 changes: 226 additions & 0 deletions src/cyris/adapters/fetch/article_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""A teaser feed article's own page, read for its body during one run.

Some feeds carry one sentence per entry, and a summary of that sentence is all the
model can write. An RSS article whose text is shorter than the summarize window
has its page fetched and its body extracted through `html_text`, and the longer
of the two texts is kept. The text lives in the returned articles only: nothing
here writes it to a store, so the next run reads the feed's text again.
"""

from __future__ import annotations

import asyncio
import codecs
import ipaddress
import logging
import socket
from collections import Counter
from urllib.parse import urlsplit

import httpx

from cyris.adapters.fetch.html_text import page_text
from cyris.adapters.fetch.keywords import is_media_link
from cyris.domain.models import Article, SourceConfig

logger = logging.getLogger(__name__)

# An issue holds about a hundred articles and a third of them are teasers
# (measured 2026-11-03), so eight at once reads them in about five waves. Many
# teasers share a publisher, and eight keeps any one site to a reader's handful.
PAGE_CONCURRENCY = 8
# One page, its redirects and its body included. With the concurrency above, a
# run's added time is at most ceil(teasers / PAGE_CONCURRENCY) * this: about 50s
# for 33 teasers, against a run container capped at 900s whose slowest run took
# 196s (`RUN_INACTIVITY_TIMEOUT_MS` in workers/app/src/index.js).
PAGE_TIMEOUT_SECONDS = 10
# The most one page may hold, counted as it arrives: an article page is a few
# hundred kilobytes, and a link to a large file is not an article.
MAX_PAGE_BYTES = 5 * 1024 * 1024
# Names the fetcher and where to read about it, as the RSS Worker's does; a site
# that blocks it is answered by the feed text, which the run keeps.
USER_AGENT = "cyris (+https://github.com/musingfox/cyris)"

_HTML_TYPES = ("text/html", "application/xhtml+xml")


class _PageRefusedError(Exception):
"""A page this module does not read: a private address, the wrong type, too large."""


def _private_literal(host: str) -> bool:
"""Whether `host` names this machine or a non-public address without a lookup.

That is an IP address written out and not global, any IPv4 spelling a resolver
reads that `ipaddress` does not (`127.1`, `2130706433`, `0x7f000001`, octal),
and localhost or a name under it (RFC 6761 reserves them for loopback).
"""
# WHY no DNS: a name that resolves to a private address is not refused here; the owner
# accepted that risk (architecture.md §3.2), and through the proxy the proxy resolves it.
name = host.strip("[]").lower()
name = name.removesuffix(".")
if name == "localhost" or name.endswith(".localhost"):
return True
try:
return not ipaddress.ip_address(name).is_global
except ValueError:
pass
try:
socket.inet_aton(name)
except (OSError, ValueError):
return False
# A shorthand IPv4 address: refused whatever it names, as no page is linked so.
return True


def _web_page(url: str) -> bool:
try:
parts = urlsplit(url)
host = parts.hostname
except ValueError:
return False
return (
parts.scheme in ("http", "https")
and bool(host)
and not _private_literal(host)
# A video's page text is the site's navigation, not the video.
and not is_media_link(host, parts.path)
)


async def _refuse_private(request: httpx.Request) -> None:
# Every hop of a redirect passes here, not only the first address.
if _private_literal(request.url.host):
raise _PageRefusedError(f"private address {request.url.host}")


async def _last_hop(client: httpx.AsyncClient, url: str) -> httpx.Response:
"""The streamed response at the end of `url`'s redirects, its body not yet read.

Each redirect is closed unread. httpx's `follow_redirects` reads every hop's
body whole, decompressed, before the caller sees the final response, so no cap
here could count it.
"""
request = client.build_request("GET", url)
for _ in range(client.max_redirects + 1):
response = await client.send(request, stream=True)
if response.next_request is None:
return response
await response.aclose()
request = response.next_request
raise _PageRefusedError(f"more than {client.max_redirects} redirects")


async def _fetch(client: httpx.AsyncClient, url: str) -> str:
"""The body text of the page at `url`; raises when there is none to read."""
response = await _last_hop(client, url)
try:
if not response.is_success:
# No retry: a 4xx will not change, and a 5xx falls back to the feed text.
raise _PageRefusedError(f"HTTP {response.status_code}")
media_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type not in _HTML_TYPES:
raise _PageRefusedError(f"not a page ({media_type or 'no type'})")
# Asked for identity; an answer encoded anyway could inflate past any cap.
encoding = response.headers.get("content-encoding", "").strip().lower()
if encoding not in ("", "identity"):
raise _PageRefusedError(f"encoded ({encoding})")
body = bytearray()
async for chunk in response.aiter_raw():
body += chunk
if len(body) > MAX_PAGE_BYTES:
raise _PageRefusedError(f"larger than {MAX_PAGE_BYTES} bytes")
markup = _markup(bytes(body), response.charset_encoding)
finally:
await response.aclose()
# Off the event loop: a page can be megabytes of markup.
return await asyncio.to_thread(page_text, markup)


def _markup(body: bytes, charset: str | None) -> str | bytes:
"""A charset the server named is decoded here; without one, trafilatura reads
the page's own `<meta charset>` from the bytes."""
if not charset:
return body
try:
codecs.lookup(charset)
except LookupError:
# A name no codec knows (`x-bogus`, `none`): utf-8 is what most pages are.
charset = "utf-8"
return body.decode(charset, errors="replace")


async def _lengthen_one(
client: httpx.AsyncClient, gate: asyncio.Semaphore, article: Article, outcomes: Counter
) -> Article:
async with gate:
try:
async with asyncio.timeout(PAGE_TIMEOUT_SECONDS):
text = await _fetch(client, article.url)
except TimeoutError:
reason = f"no page within {PAGE_TIMEOUT_SECONDS}s"
except (httpx.HTTPError, httpx.InvalidURL, ValueError, _PageRefusedError) as e:
reason = str(e) or type(e).__name__
except Exception as e:
# Anything else, an extractor's own error included: the pages share one
# gather, and one page must not cost the others their body.
reason = f"unexpected {type(e).__name__}: {e}"
logger.warning("Reading %s failed unexpectedly", article.url, exc_info=True)
else:
if len(text) > len(article.content):
outcomes["lengthened"] += 1
return article.model_copy(update={"content": text})
reason = "page body no longer than the feed text"
outcomes["kept"] += 1
logger.info("Article page of %s (%s) not used: %s", article.url, article.source_name, reason)
return article


async def lengthen_articles(
articles: list[Article],
*,
summarize_snippet_length: int,
sources: dict[str, SourceConfig],
) -> list[Article]:
"""`articles` in order, each RSS teaser on its page's body where that is longer.

A teaser is an article of an RSS source whose text is shorter than
`summarize_snippet_length`, the most of it a summary prompt reads. Anything
else, or a page that fails, is returned as it came: one page never fails the run.
"""

def teaser(article: Article) -> bool:
source = sources.get(article.source_name)
return (
source is not None
and source.type == "rss"
and len(article.content) < summarize_snippet_length
and _web_page(article.url)
)

short = [i for i, a in enumerate(articles) if teaser(a)]
if not short:
return list(articles)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce response bounds before following redirects

When a teaser URL returns a redirect with a large or compressed body, follow_redirects=True lets HTTPX read and decompress that entire intermediate response before client.stream() yields the final response. Consequently, neither the encoding rejection nor MAX_PAGE_BYTES protects redirect bodies. I reproduced this with the installed HTTPX using a gzipped 302 response: its 6 MiB decoded body was already buffered in response.history before application code received the final response. A larger payload can exhaust the run container's memory instead of falling back to feed text. Follow redirects manually, closing intermediate responses without reading their bodies, or otherwise enforce the bounds before HTTPX buffers them.

Useful? React with 👍 / 👎.

gate = asyncio.Semaphore(PAGE_CONCURRENCY)
outcomes: Counter = Counter()
async with httpx.AsyncClient(
timeout=PAGE_TIMEOUT_SECONDS,
# Uncompressed, so `MAX_PAGE_BYTES` counts what arrives, not what one chunk
# inflates to.
headers={"User-Agent": USER_AGENT, "Accept-Encoding": "identity"},
event_hooks={"request": [_refuse_private]},
) as client:
lengthened = await asyncio.gather(
*(_lengthen_one(client, gate, articles[i], outcomes) for i in short)
)
out = list(articles)
for i, article in zip(short, lengthened, strict=True):
out[i] = article
logger.info(
"Article pages: %d teaser(s), %d read from their page, %d kept their feed text",
len(short),
outcomes["lengthened"],
outcomes["kept"],
)
return out
Loading
Loading