Added
-
Articles with nothing to read now offer the original page. Feeds that carry only a headline and a link (Hacker News is the clearest case) produce articles with no text at all, and opening one showed a blank pane and a line saying so. Such an article now shows an "Open original" button, plus "Retry extraction" when a previous attempt failed, so a source that was temporarily unreachable can be tried again without digging through the menu. Settings → Preferences adds "Open original for empty articles", off by default: with it on, clicking one of these articles opens the source in a new tab straight away, while the article itself still opens in the reader behind it, so it is marked read and stays starrable and labellable. Only articles that will never have text are affected, never one whose extraction is still running or could still be started. Time spent on the source counts towards reading time.
-
The interest profile used by AI scoring can now update itself. Settings → AI has an "Auto-generate" switch next to the Generate button with Off (the default), every 2 weeks and every 4 weeks. Until now the profile only changed when you opened settings and pressed a button, so scoring kept ranking articles by whatever you were interested in months ago. An automatic run saves straight away, keeps the version it replaced and offers a one-click revert, and the settings page always shows when the profile last changed and whether it was you or the schedule. Several things have to line up before it spends anything: the interval has to be up, at least 20 articles you read or starred have to be new since the last change, and there have to be enough reading signals that the profile is not padded with feed names. Output that does not look like a profile is thrown away rather than saved, and any attempt that reaches the model, successful or not, waits a full interval before the next one, so a dead API key cannot cost you a call a day. After three failures the schedule turns itself off and says why, in settings and on the admin dashboard.
-
Bot protection on the registration form, on by default and with nothing to configure. Open registration means the instance will email any address a visitor types, which bots abuse to flood scraped third-party inboxes using your domain as the sender. The form now carries a hidden honeypot field that only a script fills in, and a signed timestamp that rejects submissions arriving faster than a person could type. A caught submission creates no account and sends no email. Only the honeypot, which nothing legitimate can fill in, answers with the normal "check your email" page so the script cannot tell it was caught; the timing check is a heuristic, so it puts the form back in front of you instead, as does a form left open for hours. Correcting a mistyped password and submitting again does not restart the timer either, so a password manager refilling the form in one click cannot trip it. The README explains what else to put in front of a public instance.
-
LOG_OUTBOUND_REQUESTS(off by default): a diagnostic switch that logs one line per outbound HTTP request, covering feed fetches, scraping and readable extraction. Each line records the host, status, HTTP version, elapsed time and any rate-limit headers the server sent (Retry-After,X-RateLimit-*). Per-feed error records only show failures, and only per feed, so they cannot answer how often a host is really being hit when several feeds and the extraction pipeline share it. Turn this on when a site starts returning 403 or 429, read the real request rate and spacing from the log, then turn it off again. -
LOG_LEVEL(DEBUG/INFO/WARNING/ERROR, defaultWARNING): how much the app logs.WARNINGkeeps the log to things that need attention,INFOadds the running commentary from the scheduler and fetcher. Noisy libraries (httpx, APScheduler) stay atWARNINGeither way, so raising the level surfaces Readfine's own records rather than a wall of third-party chatter. -
FETCH_SCHEDULE_OFFSET_MIN(minutes, default0): shifts the four 15-minute feed-fetch ticks off the usual :00/:15/:30/:45. Useful when two instances share a host (for example a staging instance next to production) and you don't want both firing their fetch round at the same wall-clock moment. Set staging to7and it polls at :07/:22/:37/:52 instead. The value is folded into 0–14, and the predicted next-fetch times in the UI follow the shifted schedule.
Fixed
-
A filter regex could quietly fail to match on a busy instance. Filter patterns run under a time limit so that a pathological one cannot freeze the app, and a pattern that runs out of time counts as "no match", which means the filter silently does not fire. The limit was 0.1 seconds of wall-clock time, close enough to what an ordinary pattern costs on a long article that a busy fetch round was enough to trip it: a production log showed
\bAI\btiming out. The limit is now a full second, which still stops a runaway pattern but leaves normal ones far below it. -
A feed whose server dropped the connection mid-request is now retried once instead of counting as a failed fetch. Reusing a kept-alive connection races with the server closing it, and HTTP/2 servers close them routinely, so a request could die with nothing sent back through no fault of the request itself. Fetches are plain GETs, so a second attempt on a fresh connection is safe, and it recovers feeds that were losing a poll here and there for this reason.
-
Settings → AI no longer announces a nightly interest-profile generation that cannot happen. The status line checked that a quality model was picked, but not that there was a key to use it with, so an account whose key had been removed (or a restore that lost the encryption key) read "The next generation runs tonight" indefinitely while the job skipped every night without recording anything. It now says which provider is missing a key.
-
Three AI settings actions (saving a provider key, verifying a model, queueing summaries for all starred articles) were served with no rate limit, despite carrying one in the code. The limit was attached to the route in the wrong order, so it wrapped a copy of the handler that nothing ever called. Generating the interest profile by hand had no limit declared at all, which mattered more: it is the most expensive call the app can make, it builds its prompt from the whole reading history, and one stuck button could run it as fast as the provider would answer. It now allows 5 per hour, configurable through
RATE_LIMIT_AI_PREFERENCE. A test now checks every rate-limited route is really limited, since the failure left no trace anywhere. -
The general AI chat no longer scrolls the page behind it once you have sent a message. The message list was set to swallow the scroll at its own end, but only until the first reply arrived, because the server rebuilt the panel without that setting. Most visible on a phone, where reaching the bottom of a conversation started dragging the article list underneath.
-
The label next to the paperclip in an article's chat now updates when you detach or reattach the article, instead of only doing so after your first message. The panel was drawn without the hook the script needed to find it, and a later redraw quietly added it back.
-
Saving Settings → AI with scoring switched off no longer erases the interest profile. The profile field is disabled while scoring is off, and a disabled field sends nothing at all, so the save read it as "cleared" and wiped the text. Fields that were not part of the submit now keep their stored value, which also covers the new auto-generate schedule and the score-in-list toggle.
-
The scoring checkbox in Settings → AI now enables and disables the fields below it as you click it, instead of only after saving. The checkbox carried two id attributes and the script hooked onto the one the browser had thrown away.
-
Feeds on sites that block automated clients no longer get marked as failing and eventually switched off. Some sites (Reddit is the obvious one) refuse a share of requests with HTTP 403 or a bare 429 and let the rest through, in waves that last minutes to hours and hit every feed on the site at once. Readfine counted each refusal as a fetch error, so five of them in a row disabled a feed that was working fine. Refusals are now tracked separately from real errors: the feed keeps its normal state, backs off progressively instead of retrying on its usual schedule, and is only switched off after ten in a row. Settings → Feeds and the admin feed table label such a feed "throttled" once it has been refused three times running, in amber rather than red, and one stray refusal shows nothing at all. Any successful fetch clears the count.
-
Readfine could learn a fetch spacing that no site had actually asked for. A site that reports its rate limit as exhausted also reports how long until the limit resets, and that countdown was read as if it were the sustainable gap between requests. It is not: it depends on when the request happened to land in the site's current window, so the value swung between 0 and 60 seconds on identical traffic, and the "never loosen" rule kept the highest number seen. On Reddit this settled at 78 seconds, throttling every feed on the host for no reason. The countdown is now used for what it is, a deadline before the next request, and the learned spacing only comes from a limit with room left in it or from an explicit
Retry-After. The stored values are cleared once on upgrade so nothing carries the old numbers forward; sites that advertise a real limit are re-learned on the next fetch. -
Admin → Rate limits could list a host at a spacing of 0 seconds, which read as a learned limit but meant the opposite: a single rate-limit response had created the row before anything was learned, and it then stayed forever. Such rows are no longer written, and existing ones are removed.
-
Full-content extraction switched off because a site refused it now gets a second chance instead of staying off for good. Three refusals in a row disable extraction for a feed, for everyone subscribed to it, and only editing the feed by hand ever turned it back on. That made a passing block permanent: the move to HTTP/2 fixed a whole class of these refusals, yet the feeds it fixed stayed disabled, and the damage went past the article body, because labelled articles from such a feed reach AI scoring with the short feed text instead of the full one. A feed in this state is now retried twice, three days after it was disabled and a fortnight after that, and extraction comes back on if the site lets a page through. The retry only looks at whether the page downloads, so a video post or live blog at the top of the feed cannot condemn it. Feeds you switched off yourself are left alone, and so are feeds disabled for the other reason (they already carry full articles). A feed that gets refused again after coming back is not retried a third time. Existing feeds are queued for a retry when you upgrade, spread over a week. Nothing is re-extracted retroactively; articles already stored keep the text they have.
-
Settings → AI spells OpenAI's name the way OpenAI does. The API key list capitalised the stored identifier, so
openaicame out as "Openai", and the two provider dropdowns showed the identifier untouched, in lowercase. All three now use the providers' own names. -
Application log records never reached the log at all. Uvicorn configures only its own loggers and leaves the root logger without a handler, so every
logger.info()in Readfine was discarded and warnings fell through Python's fallback handler with no timestamp and no source, leaving no way to tell which part of the app wrote a line. Logging is now configured at startup with a proper format. This also meansLOG_OUTBOUND_REQUESTSproduces visible output; it lifts its own records aboveLOG_LEVEL, so switching it on is enough.
Changed
- Settings → AI is now grouped by feature: Scoring, Summaries & context, Chat, and Limits, each under its own heading. The interest profile used to sit in a section of its own at the bottom of the page, far from the switch that decides whether it is used at all; it is now indented under "Enable article scoring" together with "Show score in article list", so it is visible which settings depend on which.
- A feed that has permanently moved now gets its stored address updated, so each fetch is a single request again. Until now the redirect was followed but never remembered, and the feed walked the same chain on every poll, forever: one feed here cost three requests and 800 ms where one would do, and it counted against the request budget of sites that ration them. The new address is only taken when the site said the move is permanent, the fetch actually produced articles, and nothing changes along the way that should not (a query string, an HTTPS connection, or credentials in the URL, which are neither dropped nor picked up: a site that answers with credentials in its redirect does not get them stored on a feed everyone shares). Existing feeds fix themselves on their next fetch. Adding a feed resolves the address first as well, so a subscription, or an OPML import carrying a years-old URL, lands on the feed you already have instead of creating a second copy of it.
- Admin dashboard: a "Feed redirect conflicts" section, shown only when there is something to report. It lists feeds that permanently redirect onto a URL another feed already holds, the one case where the address above cannot be updated, so the feed keeps re-walking its redirect. The fix is to merge the pair by hand; the section names both feeds so it can be found.
- Admin → Users: hovering the "Joined" date now shows the exact date and time of sign-up. The column itself still shows the date alone, which is not enough to see that a burst of accounts arrived within the same minute.