Files
ttrss-plugin-af-fulltext/init.php
Michal 9bc854ec44 af_fulltext: rule-driven extraction with a pluggable renderer
Replaces subscribing feeds through a self-hosted Full-Text RSS proxy. Feed URLs
go back to being real feed URLs and extraction happens inside tt-rss, using the
same ftr-site-config rules the proxy used.

The engine in lib/ has no tt-rss dependencies, so rules can be developed and
audited from the command line; init.php is a thin adapter over it.

Two findings from measuring the real subscription first, both of which shaped
the design:

- Firecrawl's own onlyMainContent is far too coarse to extract with (73KB of
  chrome on a Cloudflare post), but it is an excellent renderer. So it is used
  for rawHtml only and the rule engine does the extraction.
- A body rule that stops matching after a redesign falls through to Readability
  and still produces a plausible article, so the breakage is invisible. Every
  extraction now records which rule matched and whether it fell back; auditing
  the 34 live feeds surfaced five community rules that match nothing.

Custom rules included for the sites that needed them, including three comics
where the article is an image and text-scoring extractors return the wrong thing
or nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sSJdftQx5bW5HZHgUKF3i
2026-08-25 00:16:16 +01:00

476 lines
16 KiB
PHP

<?php
/**
* af_fulltext -- rule-driven full-text extraction for Tiny Tiny RSS.
*
* Replaces the usual arrangement of subscribing to a separate Full-Text RSS proxy
* with extraction inside tt-rss, driven by the same fivefilters/ftr-site-config
* rules that proxy used. Two fetch backends: tt-rss's own HTTP stack, or a
* Firecrawl instance when a page only assembles itself under JavaScript.
*
* The extraction engine lives in lib/ and has no tt-rss dependencies, so rules can
* be developed and audited from the command line (bin/extract.php, bin/audit.php).
*/
require_once __DIR__ . '/autoload.php';
use AfFulltext\{Extractor, FirecrawlFetcher, RuleSet, TtrssFetcher};
use AfFulltext\Fetcher;
use AfFulltext\ExtractResult;
class Af_Fulltext extends Plugin {
private const BACKEND_DIRECT = 'direct';
private const BACKEND_FIRECRAWL = 'firecrawl';
/** Endpoint of a Firecrawl instance, e.g. http://firecrawl-api.firecrawl.svc.cluster.local:3002 */
private const CONF_FIRECRAWL_URL = 'AF_FULLTEXT_FIRECRAWL_URL';
private const CONF_FIRECRAWL_KEY = 'AF_FULLTEXT_FIRECRAWL_KEY';
private const CONF_TIMEOUT = 'AF_FULLTEXT_TIMEOUT';
/** Git repo the community rules are refreshed from. */
private const CONF_RULES_REPO = 'AF_FULLTEXT_RULES_REPO';
/** Hours between refreshes; 0 disables. */
private const CONF_RULES_REFRESH_HOURS = 'AF_FULLTEXT_RULES_REFRESH_HOURS';
/** @var PluginHost $host */
private $host;
function about() {
return array(null,
'Full-text extraction using ftr-site-config rules, with an optional Firecrawl renderer',
'michal');
}
function flags() {
return array('needs_curl' => true);
}
function api_version() {
return 2;
}
function init($host) {
$this->host = $host;
Config::add(self::CONF_FIRECRAWL_URL, '', Config::T_STRING);
Config::add(self::CONF_FIRECRAWL_KEY, '', Config::T_STRING);
Config::add(self::CONF_TIMEOUT, '20', Config::T_INT);
Config::add(self::CONF_RULES_REPO, 'https://github.com/fivefilters/ftr-site-config.git', Config::T_STRING);
Config::add(self::CONF_RULES_REFRESH_HOURS, '24', Config::T_INT);
$host->add_hook($host::HOOK_ARTICLE_FILTER, $this);
$host->add_hook($host::HOOK_PREFS_TAB, $this);
$host->add_hook($host::HOOK_PREFS_EDIT_FEED, $this);
$host->add_hook($host::HOOK_PREFS_SAVE_FEED, $this);
$host->add_hook($host::HOOK_ARTICLE_BUTTON, $this);
$host->add_hook($host::HOOK_HOUSE_KEEPING, $this);
// Installed unconditionally: init() runs before plugin storage is loaded,
// so the enable flag cannot be consulted here.
$host->add_hook($host::HOOK_GET_FULL_TEXT, $this);
$host->add_filter_action($this, 'action_inline', __('Extract full text'));
$host->add_filter_action($this, 'action_inline_append', __('Append full text'));
}
function get_js() {
return file_get_contents(__DIR__ . '/init.js');
}
// ---------------------------------------------------------------- extraction
private function rules_dirs(): array {
return [__DIR__ . '/site_config/custom', __DIR__ . '/site_config/standard'];
}
private function extractor(): Extractor {
return new Extractor(new RuleSet($this->rules_dirs()));
}
private function fetcher(string $backend): Fetcher {
$timeout = (int) Config::get(self::CONF_TIMEOUT);
if ($backend === self::BACKEND_FIRECRAWL) {
$endpoint = (string) Config::get(self::CONF_FIRECRAWL_URL);
if ($endpoint !== '')
return new FirecrawlFetcher($endpoint, max($timeout, 60),
((string) Config::get(self::CONF_FIRECRAWL_KEY)) ?: null);
// Configured per feed but unavailable globally: fall back rather than
// silently producing nothing.
user_error('af_fulltext: firecrawl requested but ' . self::CONF_FIRECRAWL_URL . ' is unset', E_USER_WARNING);
}
return new TtrssFetcher($timeout);
}
/** Backend selected for a feed, defaulting to direct. */
private function backend_for(int $feed_id): string {
$map = $this->host->get_array($this, 'backend_feeds');
return ($map[$feed_id] ?? self::BACKEND_DIRECT) === self::BACKEND_FIRECRAWL
? self::BACKEND_FIRECRAWL
: self::BACKEND_DIRECT;
}
private function extract(string $url, string $backend): ExtractResult {
return $this->extractor()->extract($url, $this->fetcher($backend));
}
/**
* Record what happened for a feed.
*
* A `body:` rule that stops matching after a site redesign still yields a
* plausible-looking article via the Readability fallback, so the degradation is
* invisible in the reader. Keeping the last outcome per feed is what lets the
* settings pane say so out loud.
*
* @return void
*/
private function record_health(int $feed_id, ExtractResult $result) {
$health = $this->host->get_array($this, 'health');
$health[$feed_id] = [
'when' => time(),
'summary' => $result->summary(),
'stale' => $result->rule_stale,
'fell_back' => $result->fell_back,
'bytes' => strlen($result->html),
'error' => $result->errors[0] ?? null,
];
$this->host->set($this, 'health', $health);
}
/**
* @param array<string,mixed> $article
* @return array<string,mixed>
*/
function process_article(array $article, bool $append_mode, ?string $backend = null): array {
$link = $article['link'] ?? '';
if (!$link) return $article;
$feed_id = (int) ($article['feed']['id'] ?? 0);
$backend ??= $this->backend_for($feed_id);
$result = $this->extract($link, $backend);
if ($feed_id) $this->record_health($feed_id, $result);
// Only replace the feed's own summary if we actually got something.
if (!$this->has_content($result->html)) return $article;
$content = $result->html . $this->provenance_comment($result);
if ($append_mode) $article['content'] .= '<hr/>' . $content;
else $article['content'] = $content;
return $article;
}
/**
* An HTML comment naming the rule that produced the article.
*
* Invisible while reading, and the first thing worth looking at when an
* article comes out wrong.
*/
private function provenance_comment(ExtractResult $result): string {
return "\n<!-- af_fulltext: " . htmlspecialchars($result->summary(), ENT_QUOTES) . " -->";
}
function hook_article_filter($article) {
$enabled = $this->host->get_array($this, 'enabled_feeds');
$append = $this->host->get_array($this, 'append_feeds');
$feed_id = $article['feed']['id'] ?? null;
if ($feed_id === null || !in_array($feed_id, $enabled)) return $article;
return $this->process_article($article, in_array($feed_id, $append));
}
function hook_article_filter_action($article, $action) {
return match ($action) {
'action_inline' => $this->process_article($article, false),
'action_inline_append' => $this->process_article($article, true),
default => $article,
};
}
function hook_get_full_text($link) {
if (!$this->host->get($this, 'enable_share_anything')) return false;
$result = $this->extract($link, self::BACKEND_DIRECT);
return $this->has_content($result->html) ? $result->html : false;
}
/**
* Does this survive sanitising as something worth showing?
*
* Sanitizer::sanitize returns false on failure, so its result cannot be passed
* straight to strip_tags. An image-only article has no text at all and must
* still count -- for a webcomic the picture IS the content.
*/
private function has_content(string $html): bool {
if ($html === '') return false;
$clean = Sanitizer::sanitize($html);
if (!is_string($clean)) return false;
if (trim(strip_tags($clean)) !== '') return true;
return (bool) preg_match('/<(img|video|audio|iframe|picture|source|svg|embed)\b/i', $clean);
}
// ------------------------------------------------------------- rule refresh
/**
* Keep the community rules current.
*
* Runs from the update daemon rather than a separate scheduled job, so the
* rules travel with the plugin instead of being someone else's cron entry to
* remember. Refreshing is a fast-forward pull only: a conflict means someone
* edited the checkout by hand, and overwriting it silently would be worse than
* leaving it stale and saying so.
*
* @return void
*/
function hook_house_keeping() {
$hours = (int) Config::get(self::CONF_RULES_REFRESH_HOURS);
if ($hours <= 0) return;
$last = (int) $this->host->get($this, 'rules_refreshed_at');
if ($last && time() - $last < $hours * 3600) return;
$dir = __DIR__ . '/site_config/standard';
$repo = (string) Config::get(self::CONF_RULES_REPO);
if (!is_dir("$dir/.git")) {
Debug::log("af_fulltext: $dir is not a git checkout, skipping rule refresh", Debug::LOG_VERBOSE);
return;
}
$cmd = sprintf('cd %s && git fetch --quiet --depth 1 origin 2>&1 && git reset --quiet --hard FETCH_HEAD 2>&1',
escapeshellarg($dir));
$output = [];
$rc = 0;
exec($cmd, $output, $rc);
// Stamp the attempt either way, so a persistently unreachable remote does
// not retry on every single housekeeping pass.
$this->host->set($this, 'rules_refreshed_at', time());
if ($rc !== 0) {
$this->host->set($this, 'rules_refresh_error', implode(' ', array_slice($output, 0, 3)));
Debug::log('af_fulltext: rule refresh failed: ' . implode(' ', $output), Debug::LOG_VERBOSE);
return;
}
$this->host->set($this, 'rules_refresh_error', '');
Debug::log("af_fulltext: refreshed site rules from $repo", Debug::LOG_VERBOSE);
}
// --------------------------------------------------------------------- UI
function hook_article_button($line) {
return "<i class='material-icons' onclick=\"Plugins.Af_Fulltext.embed(" . $line['id'] . ")\"
style='cursor : pointer' title=\"" . __('Extract full text') . "\">article</i>";
}
function embed(): void {
$article_id = (int) $_REQUEST['id'];
$sth = $this->pdo->prepare('SELECT link, feed_id FROM ttrss_entries e, ttrss_user_entries ue
WHERE e.id = ? AND ue.ref_id = e.id AND ue.owner_uid = ?');
$sth->execute([$article_id, $_SESSION['uid']]);
$ret = [];
if ($row = $sth->fetch()) {
$result = $this->extract($row['link'], $this->backend_for((int) $row['feed_id']));
$ret['content'] = (string) Sanitizer::sanitize($result->html);
$ret['summary'] = $result->summary();
$ret['errors'] = $result->errors;
}
print json_encode($ret);
}
/** @return void */
function save() {
$this->host->set($this, 'enable_share_anything',
checkbox_to_sql_bool($_POST['enable_share_anything'] ?? ''));
echo __('Data saved.');
}
function hook_prefs_edit_feed($feed_id) {
$enabled = $this->host->get_array($this, 'enabled_feeds');
$append = $this->host->get_array($this, 'append_feeds');
$backend = $this->backend_for((int) $feed_id);
?>
<header><?= __('Full-text extraction') ?></header>
<section>
<fieldset>
<label class='checkbox'>
<?= \Controls\checkbox_tag('af_fulltext_enabled', in_array($feed_id, $enabled)) ?>
<?= __('Extract full article content') ?>
</label>
</fieldset>
<fieldset>
<label class='checkbox'>
<?= \Controls\checkbox_tag('af_fulltext_append', in_array($feed_id, $append)) ?>
<?= __('Append to the summary instead of replacing it') ?>
</label>
</fieldset>
<fieldset>
<label><?= __('Fetch using') ?></label>
<?= \Controls\select_hash('af_fulltext_backend', $backend, [
self::BACKEND_DIRECT => __('Direct (fast)'),
self::BACKEND_FIRECRAWL => __('Firecrawl (renders JavaScript)'),
]) ?>
</fieldset>
</section>
<?php
}
function hook_prefs_save_feed($feed_id) {
$enabled = $this->toggle($this->host->get_array($this, 'enabled_feeds'), $feed_id,
(bool) checkbox_to_sql_bool($_POST['af_fulltext_enabled'] ?? ''));
$append = $this->toggle($this->host->get_array($this, 'append_feeds'), $feed_id,
(bool) checkbox_to_sql_bool($_POST['af_fulltext_append'] ?? ''));
$backends = $this->host->get_array($this, 'backend_feeds');
$chosen = $_POST['af_fulltext_backend'] ?? self::BACKEND_DIRECT;
if ($chosen === self::BACKEND_FIRECRAWL) $backends[$feed_id] = self::BACKEND_FIRECRAWL;
else unset($backends[$feed_id]);
$this->host->set($this, 'enabled_feeds', $enabled);
$this->host->set($this, 'append_feeds', $append);
$this->host->set($this, 'backend_feeds', $backends);
}
/**
* @param array<int> $list
* @return array<int>
*/
private function toggle(array $list, $feed_id, bool $on): array {
$key = array_search($feed_id, $list);
if ($on && $key === false) $list[] = $feed_id;
elseif (!$on && $key !== false) unset($list[$key]);
return array_values($list);
}
function hook_prefs_tab($args) {
if ($args != 'prefFeeds') return;
$enable_share_anything = sql_bool_to_bool($this->host->get($this, 'enable_share_anything'));
$enabled = $this->filter_unknown_feeds($this->host->get_array($this, 'enabled_feeds'));
$this->host->set($this, 'enabled_feeds', $enabled);
$append = $this->host->get_array($this, 'append_feeds');
$health = $this->host->get_array($this, 'health');
$refreshed = (int) $this->host->get($this, 'rules_refreshed_at');
$refresh_error = (string) $this->host->get($this, 'rules_refresh_error');
$rule_count = count(glob(__DIR__ . '/site_config/standard/*.txt') ?: [])
+ count(glob(__DIR__ . '/site_config/custom/*.txt') ?: []);
$degraded = array_filter($health, fn($h) => !empty($h['stale']));
?>
<div dojoType='dijit.layout.AccordionPane'
title="<i class='material-icons'>article</i> <?= __('Full-text extraction (af_fulltext)') ?>">
<?= format_notice('Enable per feed in the feed editor. ' . $rule_count . ' site rules loaded'
. ($refreshed ? ', refreshed ' . date('Y-m-d H:i', $refreshed) : ', never refreshed') . '.') ?>
<?php if ($refresh_error) { ?>
<?= format_warning('Rule refresh failed: ' . htmlspecialchars($refresh_error)) ?>
<?php } ?>
<?php if ($degraded) { ?>
<?= format_warning(sprintf(
'%d feed(s) have a site rule that no longer matches anything and are falling back to Readability. '
. 'That usually means the site was redesigned and the rule needs updating.',
count($degraded))) ?>
<?php } ?>
<form dojoType='dijit.form.Form'>
<?= \Controls\pluginhandler_tags($this, 'save') ?>
<script type="dojo/method" event="onSubmit" args="evt">
evt.preventDefault();
if (this.validate()) {
Notify.progress('Saving data...', true);
xhr.post("backend.php", this.getValues(), (reply) => {
Notify.info(reply);
})
}
</script>
<fieldset>
<label class='checkbox'>
<?= \Controls\checkbox_tag('enable_share_anything', $enable_share_anything) ?>
<?= __('Provide full-text services to core code (bookmarklets) and other plugins') ?>
</label>
</fieldset>
<hr/>
<?= \Controls\submit_tag(__('Save')) ?>
</form>
<?php if (count($enabled) > 0) { ?>
<hr/>
<h3><?= __('Currently enabled for (click to edit):') ?></h3>
<ul class='panel panel-scrollable list list-unstyled'>
<?php foreach ($enabled as $f) {
$h = $health[$f] ?? null;
?>
<li>
<?php if (Feeds::_has_icon($f)) { ?>
<img src='<?= Feeds::_get_icon_url($f) ?>' style="max-height: 20px" />
<?php } else { ?> <i class='material-icons'>rss_feed</i> <?php } ?>
<a href='#' onclick="CommonDialogs.editFeed(<?= $f ?>)">
<?= Feeds::_get_title($f, $this->host->get_owner_uid()) ?>
</a>
<?= in_array($f, $append) ? ' ' . __('(append)') : '' ?>
<?php if ($h) { ?>
<small style="opacity: .7"><?= htmlspecialchars($h['summary'] ?? '') ?></small>
<?php } ?>
</li>
<?php } ?>
</ul>
<?php } ?>
</div>
<?php
}
/**
* @param array<int> $feeds
* @return array<int>
*/
private function filter_unknown_feeds(array $feeds): array {
$out = [];
foreach ($feeds as $feed) {
$sth = $this->pdo->prepare('SELECT id FROM ttrss_feeds WHERE id = ? AND owner_uid = ?');
$sth->execute([$feed, $_SESSION['uid']]);
if ($sth->fetch()) $out[] = $feed;
}
return $out;
}
}