PHP Classes

File: scripts/rss_poster.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/rss_poster.php   Download  
File: scripts/rss_poster.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 25,711 bytes
 

Contents

Class file image Download
#!/usr/bin/env php <?php /** * Auto Feed: Feed to Echoarea Poster * * Monitors configured feeds and posts new items to specified echoareas. * Run periodically via cron or manually to check for new feed items. * * Usage: * php rss_poster.php # Check all active feeds * php rss_poster.php --feed-id=1 # Check specific feed * php rss_poster.php --force # Force check even if recently checked * php rss_poster.php --verbose # Show detailed output * * Configuration is stored in the database (auto_feed_sources table). */ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Database; use BinktermPHP\MessageHandler; // Parse command line arguments $options = getopt('', ['feed-id:', 'force', 'verbose', 'help']); if (isset($options['help'])) { showHelp(); exit(0); } $feedId = $options['feed-id'] ?? null; $force = isset($options['force']); $verbose = isset($options['verbose']); // Get database connection $db = Database::getInstance()->getPdo(); // Initialize message handler $messageHandler = new MessageHandler(); // Get feeds to check if ($feedId) { $stmt = $db->prepare(" SELECT f.*, e.tag as echoarea_tag, e.domain as echoarea_domain FROM auto_feed_sources f JOIN echoareas e ON e.id = f.echoarea_id WHERE f.id = ? AND f.active = TRUE "); $stmt->execute([$feedId]); $feeds = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($feeds)) { echo "Error: Feed ID $feedId not found or inactive\n"; exit(1); } } else { $stmt = $db->query(" SELECT f.*, e.tag as echoarea_tag, e.domain as echoarea_domain FROM auto_feed_sources f JOIN echoareas e ON e.id = f.echoarea_id WHERE f.active = TRUE ORDER BY f.id "); $feeds = $stmt->fetchAll(PDO::FETCH_ASSOC); } if (empty($feeds)) { echo "No active feeds configured\n"; exit(0); } echo "Auto Feed - Checking " . count($feeds) . " feed(s)\n"; echo str_repeat('=', 60) . "\n"; $totalPosted = 0; $totalErrors = 0; foreach ($feeds as $feed) { try { echo sprintf("[Feed #%d] Checking %s (%s)...\n", $feed['id'], $feed['feed_name'] ?: 'Unnamed Feed', $feed['echoarea_tag'] ); $posted = processFeed($db, $messageHandler, $feed, $force, $verbose); $totalPosted += $posted; echo sprintf("[Feed #%d] Complete: %d new article(s)\n", $feed['id'], $posted); } catch (Exception $e) { $totalErrors++; echo sprintf("[Feed #%d] ERROR: %s\n", $feed['id'], $e->getMessage()); // Log error to database $stmt = $db->prepare("UPDATE auto_feed_sources SET last_error = ?, updated_at = NOW() WHERE id = ?"); $stmt->execute([$e->getMessage(), $feed['id']]); } } echo str_repeat('-', 60) . "\n"; echo "Complete: $totalPosted article(s) posted"; if ($totalErrors > 0) { echo ", $totalErrors error(s)"; } echo "\n"; exit($totalErrors > 0 ? 1 : 0); /** * Process a single RSS feed * * @param PDO $db Database connection * @param MessageHandler $messageHandler Message handler * @param array $feed Feed configuration * @param bool $force Force check even if recently checked * @param bool $verbose Show verbose output * @return int Number of articles posted */ function processFeed($db, $messageHandler, $feed, $force, $verbose) { // Check if feed should be checked (don't check too frequently) if (!$force && $feed['last_check']) { $lastCheck = strtotime($feed['last_check']); $minInterval = 300; // 5 minutes minimum between checks if (time() - $lastCheck < $minInterval) { if ($verbose) { echo sprintf("[Feed #%d] Skipping (checked %d seconds ago)\n", $feed['id'], time() - $lastCheck); } return 0; } } // Fetch source feed if ($verbose) { echo sprintf("[Feed #%d] Fetching %s feed from %s\n", $feed['id'], getFeedSourceType($feed), $feed['feed_url']); } $articles = fetchArticlesForFeed($feed); if ($verbose) { echo sprintf("[Feed #%d] Found %d item(s) in feed\n", $feed['id'], count($articles)); } if (empty($articles)) { if ($verbose) { echo sprintf("[Feed #%d] No items found in feed\n", $feed['id']); } updateLastCheck($db, $feed['id']); return 0; } // Limit articles per check $maxArticles = $feed['max_articles_per_check'] ?? 10; $articles = array_slice($articles, 0, $maxArticles); // Filter out already-posted articles $newArticles = filterNewArticles($db, $feed['id'], $articles); if (empty($newArticles)) { if ($verbose) { echo sprintf("[Feed #%d] No new items\n", $feed['id']); } updateLastCheck($db, $feed['id']); return 0; } // Post new articles to echoarea. // Articles arrive newest-first from the feed; reverse so parents are written // to the DB before their replies within the same batch, enabling within-run threading. $newestGuid = $newArticles[0]['guid'] ?? null; $posted = 0; foreach (array_reverse($newArticles) as $article) { try { postArticleToEchoarea($db, $messageHandler, $feed, $article, $verbose); $posted++; } catch (Exception $e) { echo sprintf("[Feed #%d] Failed to post item '%s': %s\n", $feed['id'], substr($article['title'], 0, 50), $e->getMessage()); } } // Track the newest article GUID (first in original feed order) for deduplication. if ($newestGuid !== null) { $stmt = $db->prepare("UPDATE auto_feed_sources SET last_article_guid = ? WHERE id = ?"); $stmt->execute([$newestGuid, $feed['id']]); } // Update last check time updateLastCheck($db, $feed['id']); return $posted; } /** * Fetch and parse articles for a configured feed source. * * @param array $feed Feed configuration * @return array Array of normalized article records * @throws Exception on fetch or parse failure */ function fetchArticlesForFeed($feed) { $sourceType = getFeedSourceType($feed); if ($sourceType === 'bluesky') { $requestLimit = min(100, max(10, (int)($feed['max_articles_per_check'] ?? 10) * 5)); return fetchBlueskyFeed($feed['feed_url'], $requestLimit); } $xml = fetchRssFeed($feed['feed_url']); return parseRssFeed($xml); } /** * Resolve the feed source type. Older rows default to RSS, while Bluesky profile * URLs are auto-detected to keep existing configuration simple. * * @param array $feed Feed configuration * @return string Source type */ function getFeedSourceType($feed) { $sourceType = strtolower((string)($feed['source_type'] ?? '')); if ($sourceType !== '') { return $sourceType; } return isBlueskyProfileUrl((string)$feed['feed_url']) ? 'bluesky' : 'rss'; } /** * Fetch public Bluesky author posts and normalize posts with media attachments. * * @param string $url Bluesky profile URL, handle, or DID * @param int $limit Maximum number of posts to request * @return array Normalized article records ordered newest first * @throws Exception on fetch or parse failure */ function fetchBlueskyFeed($url, $limit = 10) { $actor = extractBlueskyActor($url); if ($actor === '') { throw new Exception("Invalid Bluesky profile URL: $url"); } $limit = max(1, min(100, $limit)); $apiUrl = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed' . '?actor=' . rawurlencode($actor) . '&filter=posts_no_replies' . '&limit=' . $limit; $json = fetchJson($apiUrl, 'BinktermPHP Auto Feed Bluesky/1.0'); $items = $json['feed'] ?? null; if (!is_array($items)) { throw new Exception('Bluesky response did not include a feed array'); } $articles = []; foreach ($items as $item) { if (!empty($item['reason'])) { continue; } $post = $item['post'] ?? null; if (!is_array($post)) { continue; } $article = normalizeBlueskyPost($post); if ($article !== null) { $articles[] = $article; } } return $articles; } /** * Normalize a Bluesky post view into the common article structure. * * @param array $post Bluesky post view * @return array|null Article data, or null when the post has no media URL */ function normalizeBlueskyPost($post) { $mediaUrls = extractBlueskyMediaUrls($post['embed'] ?? null); if (empty($mediaUrls)) { return null; } $record = is_array($post['record'] ?? null) ? $post['record'] : []; $author = is_array($post['author'] ?? null) ? $post['author'] : []; $text = trim((string)($record['text'] ?? '')); $handle = (string)($author['handle'] ?? ''); $displayName = trim((string)($author['displayName'] ?? '')); $postedBy = $displayName !== '' ? $displayName : ($handle !== '' ? '@' . $handle : 'Bluesky'); $createdAt = (string)($record['createdAt'] ?? $post['indexedAt'] ?? ''); $uri = (string)($post['uri'] ?? ''); $cid = (string)($post['cid'] ?? ''); $link = buildBlueskyPostUrl($handle, $uri); $title = $text !== '' ? firstLine($text) : 'Bluesky media post by ' . $postedBy; return [ 'guid' => $uri !== '' ? $uri : $cid, 'title' => $title, 'link' => $link, 'description' => $text, 'pubDate' => $createdAt, 'sourceType' => 'bluesky', 'author' => $postedBy, 'mediaUrls' => $mediaUrls ]; } /** * Extract image/video thumbnail URLs from supported Bluesky embed shapes. * * @param mixed $embed Bluesky embed view * @return array Unique media URLs */ function extractBlueskyMediaUrls($embed) { if (!is_array($embed)) { return []; } $urls = []; $type = (string)($embed['$type'] ?? ''); if ($type === 'app.bsky.embed.images#view' && is_array($embed['images'] ?? null)) { foreach ($embed['images'] as $image) { if (!is_array($image)) { continue; } $url = (string)($image['fullsize'] ?? $image['thumb'] ?? ''); if ($url !== '') { $urls[] = $url; } } } elseif ($type === 'app.bsky.embed.video#view') { $url = (string)($embed['thumbnail'] ?? ''); if ($url !== '') { $urls[] = $url; } } elseif ($type === 'app.bsky.embed.external#view' && is_array($embed['external'] ?? null)) { $url = (string)($embed['external']['thumb'] ?? ''); if ($url !== '') { $urls[] = $url; } } elseif ($type === 'app.bsky.embed.recordWithMedia#view') { $urls = array_merge($urls, extractBlueskyMediaUrls($embed['media'] ?? null)); } return array_values(array_unique(array_filter($urls, function($url) { return preg_match('/^https:\/\//i', $url) === 1; }))); } /** * Extract a Bluesky actor handle or DID from a profile URL or raw input. * * @param string $url Profile URL, handle, or DID * @return string Actor identifier */ function extractBlueskyActor($url) { $value = trim($url); if ($value === '') { return ''; } if (preg_match('/^did:[a-z0-9:._-]+$/i', $value)) { return $value; } if (preg_match('/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i', $value)) { return $value; } $parts = parse_url($value); $host = strtolower((string)($parts['host'] ?? '')); $path = (string)($parts['path'] ?? ''); if (($host === 'bsky.app' || $host === 'www.bsky.app') && preg_match('#^/profile/([^/]+)#', $path, $matches)) { return rawurldecode($matches[1]); } return ''; } /** * Build a human-facing Bluesky post URL. * * @param string $handle Author handle * @param string $uri AT URI * @return string Public post URL */ function buildBlueskyPostUrl($handle, $uri) { if ($handle === '' || !preg_match('#/app\.bsky\.feed\.post/([^/]+)$#', $uri, $matches)) { return ''; } return 'https://bsky.app/profile/' . rawurlencode($handle) . '/post/' . rawurlencode($matches[1]); } /** * Return the first non-empty line of text. * * @param string $text Input text * @return string First line */ function firstLine($text) { $lines = preg_split('/\R/', $text); foreach ($lines as $line) { $line = trim($line); if ($line !== '') { return $line; } } return ''; } /** * Fetch and decode JSON from an HTTP endpoint. * * @param string $url Endpoint URL * @param string $userAgent HTTP user agent * @return array Decoded JSON * @throws Exception on fetch or decode failure */ function fetchJson($url, $userAgent) { $context = stream_context_create([ 'http' => [ 'timeout' => 30, 'user_agent' => $userAgent, 'follow_location' => true, 'max_redirects' => 5, 'header' => "Accept: application/json\r\n" ] ]); $json = @file_get_contents($url, false, $context); if ($json === false) { throw new Exception("Failed to fetch JSON: $url"); } $decoded = json_decode($json, true); if (!is_array($decoded)) { throw new Exception('Failed to parse JSON response'); } return $decoded; } /** * Determine whether a URL points at a Bluesky profile. * * @param string $url URL to test * @return bool True for Bluesky profile URLs */ function isBlueskyProfileUrl($url) { return extractBlueskyActor($url) !== ''; } /** * Fetch RSS feed content * * @param string $url Feed URL * @return SimpleXMLElement Parsed XML * @throws Exception on fetch failure */ function fetchRssFeed($url) { $context = stream_context_create([ 'http' => [ 'timeout' => 30, 'user_agent' => 'BinktermPHP RSS Poster/1.0', 'follow_location' => true, 'max_redirects' => 5 ], 'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false ] ]); $xml = @file_get_contents($url, false, $context); if ($xml === false) { throw new Exception("Failed to fetch feed: $url"); } libxml_use_internal_errors(true); $parsed = @simplexml_load_string($xml); if ($parsed === false) { $errors = libxml_get_errors(); $errorMsg = !empty($errors) ? $errors[0]->message : 'Invalid XML'; throw new Exception("Failed to parse feed XML: $errorMsg"); } return $parsed; } /** * Parse RSS/Atom feed into article array * * @param SimpleXMLElement $xml Parsed XML * @return array Array of articles with guid, title, link, description, pubDate */ function parseRssFeed($xml) { $articles = []; // Detect feed type (RSS 2.0, RSS 1.0/RDF, Atom, etc.) if (isset($xml->channel->item)) { // RSS 2.0 getServerLogger()->info("Auto Feed: Parsing RSS 2.0 feed with " . count($xml->channel->item) . " items"); foreach ($xml->channel->item as $item) { $articles[] = [ 'guid' => (string)($item->guid ?? $item->link), 'title' => (string)$item->title, 'link' => (string)$item->link, 'description' => (string)($item->description ?? ''), 'pubDate' => (string)($item->pubDate ?? '') ]; } } elseif (isset($xml->item)) { // RSS 1.0 (RDF) - items are direct children of root getServerLogger()->info("Auto Feed: Parsing RSS 1.0 (RDF) feed with " . count($xml->item) . " items"); foreach ($xml->item as $item) { $articles[] = [ 'guid' => (string)($item->guid ?? $item->link), 'title' => (string)$item->title, 'link' => (string)$item->link, 'description' => (string)($item->description ?? ''), 'pubDate' => (string)($item->pubDate ?? $item->date ?? '') ]; } } elseif (isset($xml->entry)) { // Atom getServerLogger()->info("Auto Feed: Parsing Atom feed with " . count($xml->entry) . " entries"); foreach ($xml->entry as $entry) { $link = ''; if (isset($entry->link)) { $link = (string)$entry->link['href']; } $articles[] = [ 'guid' => (string)($entry->id ?? $link), 'title' => (string)$entry->title, 'link' => $link, 'description' => (string)($entry->summary ?? $entry->content ?? ''), 'pubDate' => (string)($entry->published ?? $entry->updated ?? '') ]; } } else { getServerLogger()->warning("Auto Feed: Unknown feed format - root element: " . $xml->getName()); getServerLogger()->warning("Auto Feed: XML structure: " . print_r(array_keys((array)$xml), true)); } return $articles; } /** * Filter articles to only those not already posted * * @param PDO $db Database connection * @param int $feedId Feed ID * @param array $articles Articles to filter (ordered newest first) * @return array New articles only */ function filterNewArticles($db, $feedId, $articles) { if (empty($articles)) { return []; } // Get last posted article GUID $stmt = $db->prepare("SELECT last_article_guid FROM auto_feed_sources WHERE id = ?"); $stmt->execute([$feedId]); $lastGuid = $stmt->fetchColumn(); // If no last GUID, this is first run - return all articles if (!$lastGuid) { return $articles; } // Find position of last posted article $newArticles = []; $foundLast = false; foreach ($articles as $article) { if ($article['guid'] === $lastGuid) { $foundLast = true; break; // Stop here, everything after this is old } $newArticles[] = $article; } // If we didn't find the last GUID, the feed may have been cleared // Return all articles to be safe (but limited by max_articles_per_check) if (!$foundLast && count($articles) > 0) { return $articles; } return $newArticles; } /** * Strip RE:/Fwd: and similar reply prefixes from a subject, handling multiple levels. * * @param string $subject Raw subject * @return string Subject with all reply prefixes removed */ function stripReplyPrefixes(string $subject): string { $pattern = '/^(re|fwd?|aw|sv)\s*:\s*/iu'; do { $prev = $subject; $subject = (string)preg_replace($pattern, '', $subject); } while ($subject !== $prev); return trim($subject); } /** * Find the echomail ID of the most recent message that appears to be the parent * of a reply, by matching the stripped subject against recent messages in the area. * Returns null when the given subject has no reply prefix or no parent is found. * * @param PDO $db Database connection * @param int $echoareaId Echoarea to search within * @param string $subject Raw subject of the article being posted * @param int $limit Maximum number of recent messages to scan * @return int|null Parent echomail ID, or null */ function findParentMessageId(PDO $db, int $echoareaId, string $subject, int $limit = 1000): ?int { $base = stripReplyPrefixes($subject); if (strtolower($base) === strtolower(trim($subject))) { return null; // no reply prefix ? not a reply } $baseLower = strtolower($base); // Fast path: exact case-insensitive match on the bare subject $stmt = $db->prepare( "SELECT id FROM echomail WHERE echoarea_id = ? AND LOWER(subject) = ? ORDER BY date_received DESC LIMIT 1" ); $stmt->execute([$echoareaId, $baseLower]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { return (int)$row['id']; } // Slow path: scan recent messages and strip prefixes in PHP to handle RE: chains $stmt = $db->prepare( "SELECT id, subject FROM echomail WHERE echoarea_id = ? ORDER BY date_received DESC LIMIT ?" ); $stmt->execute([$echoareaId, $limit]); foreach ($stmt as $row) { if (strtolower(stripReplyPrefixes((string)$row['subject'])) === $baseLower) { return (int)$row['id']; } } return null; } /** * Post an article to echoarea * * @param PDO $db Database connection * @param MessageHandler $messageHandler Message handler * @param array $feed Feed configuration * @param array $article Article data * @param bool $verbose Show verbose output */ function postArticleToEchoarea($db, $messageHandler, $feed, $article, $verbose) { // Format message body $body = formatArticleMessage($article); // Determine user ID to post as (default to system user 1) $userId = $feed['post_as_user_id'] ?? 1; // Post to echoarea $subject = truncate($article['title'], 72); // FTN subject line limit // Attempt subject-based threading when enabled for this feed $replyToId = null; if (!empty($feed['thread_replies'])) { $lookupLimit = max(100, (int)($feed['thread_lookup_limit'] ?? 1000)); $replyToId = findParentMessageId($db, (int)$feed['echoarea_id'], $article['title'], $lookupLimit); } if ($verbose) { echo sprintf(" Posting: %s\n", $subject); echo sprintf(" Echo: %s @ %s\n", $feed['echoarea_tag'], $feed['echoarea_domain'] ?: '(blank)'); if ($replyToId !== null) { echo sprintf(" Threading as reply to message ID: %d\n", $replyToId); } } $messageId = $messageHandler->postEchomail( $userId, $feed['echoarea_tag'], $feed['echoarea_domain'] ?: '', 'All', $subject, $body, $replyToId, // null unless threading detected a parent 'BinktermPHP Auto Feed', // tagline false, // skipCredits null, // markupType '', // prependKludges 'BinktermPHP Auto Feed' // tearlineComponent ); // Increment posted article counter $stmt = $db->prepare(" UPDATE auto_feed_sources SET articles_posted = articles_posted + 1, updated_at = NOW() WHERE id = ? "); $stmt->execute([$feed['id']]); } /** * Format article into message body * * @param array $article Article data * @return string Formatted message */ function formatArticleMessage($article) { $body = ''; // Add title (if not already in subject) if (!empty($article['title'])) { $body .= wordwrap($article['title'], 79) . "\n"; $body .= str_repeat('=', min(strlen($article['title']), 79)) . "\n\n"; } if (($article['sourceType'] ?? '') === 'bluesky' && !empty($article['author'])) { $body .= "Posted by: " . $article['author'] . "\n\n"; } // Add description/summary if (!empty($article['description'])) { // Strip HTML tags $description = strip_tags($article['description']); // Decode HTML entities $description = html_entity_decode($description, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Wrap text to 79 characters $description = wordwrap(trim($description), 79); $body .= $description . "\n\n"; } if (!empty($article['mediaUrls']) && is_array($article['mediaUrls'])) { $body .= "Media:\n"; foreach ($article['mediaUrls'] as $mediaUrl) { $body .= $mediaUrl . "\n"; } $body .= "\n"; } // Add link if (!empty($article['link'])) { $label = (($article['sourceType'] ?? '') === 'bluesky') ? 'View post' : 'Read more'; $body .= $label . ": " . $article['link'] . "\n"; } // Add publication date if (!empty($article['pubDate'])) { $timestamp = strtotime($article['pubDate']); if ($timestamp) { $body .= "\nPublished: " . date('Y-m-d H:i:s T', $timestamp) . "\n"; } } return $body; } /** * Update last check timestamp for feed * * @param PDO $db Database connection * @param int $feedId Feed ID */ function updateLastCheck($db, $feedId) { $stmt = $db->prepare(" UPDATE auto_feed_sources SET last_check = NOW(), last_error = NULL, updated_at = NOW() WHERE id = ? "); $stmt->execute([$feedId]); } /** * Truncate string to specified length * * @param string $str String to truncate * @param int $length Maximum length * @return string Truncated string */ function truncate($str, $length) { if (strlen($str) <= $length) { return $str; } return substr($str, 0, $length - 3) . '...'; } /** * Show help message */ function showHelp() { echo <<<HELP Auto Feed: Feed to Echoarea Poster Usage: php rss_poster.php [options] Options: --feed-id=<id> Check only the specified feed ID --force Force check even if recently checked --verbose Show detailed output --help Show this help message Examples: php rss_poster.php # Check all active feeds php rss_poster.php --verbose # Check with detailed output php rss_poster.php --feed-id=1 # Check only feed #1 php rss_poster.php --force # Force immediate check Configuration: Feeds are configured via the Admin -> Auto Feed web interface. Feeds are stored in the auto_feed_sources database table. Cron Example: */30 * * * * cd /path/to/binktest && php scripts/rss_poster.php >> data/logs/auto_feed.log 2>&1 HELP; }