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 $article * @return array */ 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'] .= '
' . $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"; } 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 "article"; } 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); ?>
__('Direct (fast)'), self::BACKEND_FIRECRAWL => __('Firecrawl (renders JavaScript)'), ]) ?>
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 $list * @return array */ 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'])); ?>

0) { ?>

$feeds * @return array */ 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; } }