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
362 lines
12 KiB
PHP
362 lines
12 KiB
PHP
<?php
|
|
namespace AfFulltext;
|
|
|
|
use fivefilters\Readability\Configuration;
|
|
use fivefilters\Readability\Readability;
|
|
|
|
/**
|
|
* Turns a URL into clean article HTML.
|
|
*
|
|
* Rule-driven where a site config exists, Readability where one does not. The
|
|
* rule format is FiveFilters' own, so the ~2000 community rules in
|
|
* fivefilters/ftr-site-config apply unmodified and anything written here can go
|
|
* back upstream as a pull request.
|
|
*/
|
|
final class Extractor {
|
|
private const MAX_PAGES = 5;
|
|
|
|
public function __construct(
|
|
private readonly RuleSet $rules,
|
|
private readonly int $max_bytes = 4_000_000,
|
|
) {}
|
|
|
|
public function extract(string $url, Fetcher $fetcher): ExtractResult {
|
|
$started = microtime(true);
|
|
|
|
$result = new ExtractResult();
|
|
$result->backend = $fetcher->name();
|
|
$result->effective_url = $url;
|
|
|
|
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
|
|
$rule = $this->rules->find($host);
|
|
|
|
if ($rule) $result->rule_sources = $rule->sources;
|
|
|
|
$fetched = $fetcher->fetch($url, $rule?->http_headers ?? []);
|
|
|
|
if (!$fetched->ok()) {
|
|
$result->errors[] = $fetched->error ?? 'fetch failed';
|
|
$result->timing_ms = (int) round((microtime(true) - $started) * 1000);
|
|
return $result;
|
|
}
|
|
|
|
if (strlen($fetched->html) > $this->max_bytes)
|
|
$result->errors[] = sprintf('page is %dKB, truncating parse', strlen($fetched->html) / 1024);
|
|
|
|
$result->effective_url = $fetched->effective_url ?: $url;
|
|
|
|
$doc = Html::parse(substr($fetched->html, 0, $this->max_bytes));
|
|
$base = Html::base_url($doc, $result->effective_url);
|
|
|
|
// A print/single-page view, where one exists, is both cleaner and cheaper
|
|
// to extract than following next_page_link through N requests.
|
|
if ($rule && $rule->single_page_links) {
|
|
$single = $this->first_url($doc, $rule->single_page_links, $base);
|
|
|
|
if ($single && $single !== $result->effective_url) {
|
|
$again = $fetcher->fetch($single, $rule->http_headers);
|
|
|
|
if ($again->ok()) {
|
|
$doc = Html::parse(substr($again->html, 0, $this->max_bytes));
|
|
$result->effective_url = $again->effective_url ?: $single;
|
|
$base = Html::base_url($doc, $result->effective_url);
|
|
} else {
|
|
$result->errors[] = 'single_page_link: ' . ($again->error ?? 'failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
$html = $this->extract_from_doc($doc, $base, $rule, $result);
|
|
|
|
// Multi-page articles: keep appending until the chain ends or we hit the cap.
|
|
if ($rule && $rule->next_page_links) {
|
|
$seen = [$result->effective_url => true];
|
|
$current = $doc;
|
|
$current_base = $base;
|
|
|
|
while ($result->pages < self::MAX_PAGES) {
|
|
$next = $this->first_url($current, $rule->next_page_links, $current_base);
|
|
if (!$next || isset($seen[$next])) break;
|
|
|
|
$seen[$next] = true;
|
|
$page = $fetcher->fetch($next, $rule->http_headers);
|
|
|
|
if (!$page->ok()) { $result->errors[] = 'next_page_link: ' . ($page->error ?? 'failed'); break; }
|
|
|
|
$current = Html::parse(substr($page->html, 0, $this->max_bytes));
|
|
$current_base = Html::base_url($current, $page->effective_url ?: $next);
|
|
|
|
$more = $this->extract_from_doc($current, $current_base, $rule, new ExtractResult());
|
|
if ($more === '') break;
|
|
|
|
$html .= "\n" . $more;
|
|
$result->pages++;
|
|
}
|
|
}
|
|
|
|
if ($rule) {
|
|
$html = $this->apply_replacements($html, $rule);
|
|
$html = $this->apply_wrap_in($html, $rule);
|
|
}
|
|
|
|
$result->html = trim($html);
|
|
$result->timing_ms = (int) round((microtime(true) - $started) * 1000);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Run the rule pipeline over one parsed document.
|
|
*
|
|
* Order matters: lazy images are resolved and URLs made absolute before
|
|
* anything is removed or selected, so every later step sees the same, final
|
|
* attribute values.
|
|
*/
|
|
private function extract_from_doc(\DOMDocument $doc, string $base, ?Rule $rule, ExtractResult $result): string {
|
|
Html::unlazy($doc);
|
|
Html::absolutize($doc, $base);
|
|
|
|
$xpath = new \DOMXPath($doc);
|
|
|
|
if ($rule) {
|
|
$this->apply_removals($doc, $xpath, $rule);
|
|
$this->apply_dissolve($xpath, $rule);
|
|
|
|
$result->title ??= $this->first_string($xpath, $rule->titles);
|
|
$result->author ??= $this->first_string($xpath, $rule->authors);
|
|
$result->date ??= $this->first_string($xpath, $rule->dates);
|
|
}
|
|
|
|
if ($rule && $rule->bodies) {
|
|
$nodes = $this->select_body($xpath, $rule->bodies);
|
|
|
|
if ($nodes) {
|
|
$result->rule_matched = true;
|
|
|
|
$out = '';
|
|
foreach ($nodes as $node) {
|
|
Html::sanitize($node);
|
|
$out .= Html::outer_html($node) . "\n";
|
|
}
|
|
|
|
return trim($out);
|
|
}
|
|
|
|
// A rule exists and selected nothing. Almost always a site redesign.
|
|
$result->rule_stale = true;
|
|
$result->errors[] = 'body rule matched no nodes (' . implode(', ', $rule->bodies) . ')';
|
|
|
|
if (!$rule->autodetect_on_failure) return '';
|
|
}
|
|
|
|
$result->fell_back = true;
|
|
|
|
return $this->readability($doc, $base, $result);
|
|
}
|
|
|
|
/** @return \DOMNode[] */
|
|
private function select_body(\DOMXPath $xpath, array $expressions): array {
|
|
$nodes = [];
|
|
|
|
foreach ($expressions as $expr) {
|
|
$found = @$xpath->query($expr);
|
|
if ($found === false) continue;
|
|
|
|
foreach ($found as $node) {
|
|
if (Html::text_length($node) > 0 || $this->has_media($node)) $nodes[] = $node;
|
|
}
|
|
}
|
|
|
|
return $nodes;
|
|
}
|
|
|
|
/**
|
|
* A node with no text is not necessarily empty -- on a webcomic the entire
|
|
* article is a single <img>, and discarding it for having no words is exactly
|
|
* the bug this plugin exists to fix.
|
|
*/
|
|
private const MEDIA_TAGS = ['img', 'video', 'audio', 'iframe', 'picture', 'source', 'svg', 'embed', 'object'];
|
|
|
|
private function has_media(\DOMNode $node): bool {
|
|
if (!$node instanceof \DOMElement && !$node instanceof \DOMDocument) return false;
|
|
|
|
// The node may BE the media. `body: //img[@id='strip']` is a perfectly
|
|
// ordinary rule for an image-only comic, and only looking at descendants
|
|
// throws that selection away.
|
|
if ($node instanceof \DOMElement && in_array(strtolower($node->tagName), self::MEDIA_TAGS, true))
|
|
return true;
|
|
|
|
$doc = $node instanceof \DOMDocument ? $node : $node->ownerDocument;
|
|
if (!$doc) return false;
|
|
|
|
$query = implode(' | ', array_map(fn(string $t) => ".//$t", self::MEDIA_TAGS));
|
|
$found = @(new \DOMXPath($doc))->query($query, $node);
|
|
|
|
return $found !== false && $found->length > 0;
|
|
}
|
|
|
|
private function apply_removals(\DOMDocument $doc, \DOMXPath $xpath, Rule $rule): void {
|
|
foreach ($rule->strips as $expr) {
|
|
$found = @$xpath->query($expr);
|
|
if ($found === false) continue;
|
|
foreach (iterator_to_array($found) as $node) $node->parentNode?->removeChild($node);
|
|
}
|
|
|
|
foreach ($rule->strip_id_or_class as $needle) {
|
|
$q = $this->contains_lower('@id', $needle) . ' or ' . $this->contains_lower('@class', $needle);
|
|
$found = @$xpath->query("//*[$q]");
|
|
if ($found === false) continue;
|
|
foreach (iterator_to_array($found) as $node) $node->parentNode?->removeChild($node);
|
|
}
|
|
|
|
foreach ($rule->strip_image_src as $needle) {
|
|
$found = @$xpath->query('//img[' . $this->contains_lower('@src', $needle) . ']');
|
|
if ($found === false) continue;
|
|
foreach (iterator_to_array($found) as $node) $node->parentNode?->removeChild($node);
|
|
}
|
|
}
|
|
|
|
/** Case-insensitive substring test, spelled out because XPath 1.0 has no lower-case(). */
|
|
private function contains_lower(string $attr, string $needle): string {
|
|
$upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
$lower = 'abcdefghijklmnopqrstuvwxyz';
|
|
$q = $this->xpath_literal(strtolower($needle));
|
|
|
|
return "contains(translate($attr, '$upper', '$lower'), $q)";
|
|
}
|
|
|
|
/** Quote a string for XPath 1.0, which has no escape syntax. */
|
|
private function xpath_literal(string $s): string {
|
|
if (!str_contains($s, "'")) return "'$s'";
|
|
if (!str_contains($s, '"')) return "\"$s\"";
|
|
|
|
return 'concat(' . implode(", \"'\", ", array_map(fn($p) => "'$p'", explode("'", $s))) . ')';
|
|
}
|
|
|
|
/** Replace matched elements with their own children. */
|
|
private function apply_dissolve(\DOMXPath $xpath, Rule $rule): void {
|
|
foreach ($rule->dissolve as $expr) {
|
|
$found = @$xpath->query($expr);
|
|
if ($found === false) continue;
|
|
|
|
foreach (iterator_to_array($found) as $node) {
|
|
$parent = $node->parentNode;
|
|
if (!$parent) continue;
|
|
|
|
while ($node->firstChild) $parent->insertBefore($node->firstChild, $node);
|
|
$parent->removeChild($node);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Readability fallback.
|
|
*
|
|
* The engine emits notices on malformed pages (null property reads on
|
|
* documents with no discernible body). Those must not reach output: inside
|
|
* tt-rss this runs mid-request and a stray warning corrupts the response. So
|
|
* diagnostics are captured into the result instead of being printed, and the
|
|
* previous handler is always restored.
|
|
*/
|
|
private function readability(\DOMDocument $doc, string $base, ExtractResult $result): string {
|
|
$config = new Configuration();
|
|
$config->setOriginalURL($base);
|
|
$config->setFixRelativeURLs(true);
|
|
$config->setParser('html5');
|
|
// Class names carry meaning for downstream styling and for anyone writing
|
|
// a rule off the extracted output.
|
|
$config->setKeepClasses(true);
|
|
|
|
$notices = [];
|
|
|
|
set_error_handler(function (int $no, string $msg) use (&$notices): bool {
|
|
$notices[] = $msg;
|
|
return true;
|
|
});
|
|
|
|
try {
|
|
$readability = new Readability($config);
|
|
$readability->parse((string) $doc->saveHTML());
|
|
|
|
$result->title ??= $readability->getTitle();
|
|
$result->author ??= $readability->getAuthor();
|
|
|
|
$content = (string) $readability->getContent();
|
|
} catch (\Throwable $e) {
|
|
$result->errors[] = 'readability: ' . $e->getMessage();
|
|
$content = '';
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
|
|
if ($notices)
|
|
$result->errors[] = sprintf('readability emitted %d notice(s): %s',
|
|
count($notices), $notices[0]);
|
|
|
|
return $content;
|
|
}
|
|
|
|
private function apply_replacements(string $html, Rule $rule): string {
|
|
foreach ($rule->find_strings as $i => $find) {
|
|
$replace = $rule->replace_strings[$i] ?? '';
|
|
if ($find !== '') $html = str_replace($find, $replace, $html);
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
private function apply_wrap_in(string $html, Rule $rule): string {
|
|
// wrap_in targets nodes in the source document; applying it to the
|
|
// already-serialized result would need a reparse for little gain, so v1
|
|
// only honours a document-wide wrapper.
|
|
foreach ($rule->wrap_in as $expr => $spec) {
|
|
if ($expr !== '//*' && $expr !== '/') continue;
|
|
|
|
[$tag, $class] = array_pad(explode('.', $spec, 2), 2, null);
|
|
$tag = $tag ?: 'div';
|
|
$attr = $class ? ' class="' . htmlspecialchars($class, ENT_QUOTES) . '"' : '';
|
|
$html = "<$tag$attr>$html</$tag>";
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/** First non-empty string value produced by any of the expressions. */
|
|
private function first_string(\DOMXPath $xpath, array $expressions): ?string {
|
|
foreach ($expressions as $expr) {
|
|
$value = @$xpath->evaluate($expr);
|
|
|
|
if (is_string($value) && trim($value) !== '') return trim($value);
|
|
|
|
if ($value instanceof \DOMNodeList && $value->length > 0) {
|
|
$text = trim((string) $value->item(0)?->textContent);
|
|
if ($text !== '') return $text;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** First absolute URL produced by any of the expressions. */
|
|
private function first_url(\DOMDocument $doc, array $expressions, string $base): ?string {
|
|
$xpath = new \DOMXPath($doc);
|
|
|
|
foreach ($expressions as $expr) {
|
|
$value = @$xpath->evaluate($expr);
|
|
$raw = null;
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
$raw = trim($value);
|
|
} elseif ($value instanceof \DOMNodeList && $value->length > 0) {
|
|
$node = $value->item(0);
|
|
$raw = $node instanceof \DOMElement ? ($node->getAttribute('href') ?: trim($node->textContent))
|
|
: trim((string) $node?->nodeValue);
|
|
}
|
|
|
|
if ($raw) return Html::resolve($raw, $base);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|