diff --git a/yellow-mastodonapi.php b/yellow-mastodonapi.php new file mode 100644 index 0000000..ffa2261 --- /dev/null +++ b/yellow-mastodonapi.php @@ -0,0 +1,397 @@ +yellow = $yellow; + // NEUE KONFIGURATION FÜR DIE API + $this->yellow->system->setDefault("mastodonapiInstanceUrl", "https://mastodon.social"); // Deine Mastodon Instanz-URL + $this->yellow->system->setDefault("mastodonapiUserId", ""); // Deine Mastodon User ID (numerisch, z.B. 1234567890) + $this->yellow->system->setDefault("mastodonapiAccessToken", ""); // Optional: Dein persönlicher API-Token (read:statuses) + + $this->yellow->system->setDefault("mastodonapiHashtag", ""); // Umbenannt von mastodonrssHashtag zu mastodonapiHashtag + $this->yellow->system->setDefault("mastodonapiAuthor", "YellowUser"); // Umbenannt + $this->yellow->system->setDefault("mastodonapiTag", "Fediverse"); // Umbenannt + $this->yellow->system->setDefault("mastodonapiFolder", "content/1-blog/"); // Umbenannt + $this->yellow->system->setDefault("mastodonapiTriggerPath", "/mastodonapi-webhook-CHANGE_THIS_SECRET_KEY"); // Umbenannt + } + + // Handle web requests (NUR für den Webhook-Trigger) + public function onRequest($scheme, $address, $base, $location, $fileName) { + $triggerPath = $this->yellow->system->get("mastodonapiTriggerPath"); // Angepasster Konfigurationsname + if ($location == $triggerPath) { + $postsCreated = $this->processMastodonFeed(); + if ($postsCreated !== false) { + $this->sendResponse("Mastodon API feed processed. Created " . $postsCreated . " posts.", 200); + } else { + $this->sendResponse("Error processing Mastodon API feed. Check logs for details.", 500); + } + return 1; + } + return 0; + } + + // Handle command (CLI) + public function onCommand($command, $text) { + if (!$this->yellow) { + return 500; + } + switch ($command) { + case "mastodonapi": // Angepasster Kommando-Name + if (!empty($text)) { + return 400; + } + $postsCreated = $this->processMastodonFeed(); + if ($postsCreated !== false) { + return 0; + } else { + return 500; + } + case "clean": + $this->processCommandClean($command, $text); + return 0; + default: + return 0; + } + } + + // --- Core logic for fetching and processing Mastodon API feed --- + private function processMastodonFeed() { + $instanceUrl = $this->yellow->system->get("mastodonapiInstanceUrl"); + $userId = $this->yellow->system->get("mastodonapiUserId"); + $accessToken = $this->yellow->system->get("mastodonapiAccessToken"); + $hashtag = $this->yellow->system->get("mastodonapiHashtag"); + $defaultAuthor = $this->yellow->system->get("mastodonapiAuthor"); + $defaultTag = $this->yellow->system->get("mastodonapiTag"); + $targetFolder = $this->yellow->system->get("mastodonapiFolder"); + $postsCreated = 0; + + if (empty($instanceUrl) || empty($userId)) { + $this->yellow->log("error", "MastodonAPI: 'mastodonapiInstanceUrl' or 'mastodonapiUserId' is not configured."); + return false; + } + + // API Endpunkt für die Statusse eines Benutzers + $apiUrl = rtrim($instanceUrl, '/') . "/api/v1/accounts/" . $userId . "/statuses?exclude_replies=true&exclude_reblogs=true&limit=40"; + + $options = [ + 'http' => [ + 'follow_location' => 1, + 'max_redirects' => 10, + 'timeout' => 30, + 'method' => "GET", + 'header' => "User-Agent: Mozilla/5.0 (YellowCMS MastodonAPI Plugin)\r\n" + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]; + + // Wenn ein Access Token vorhanden ist, füge es zum Header hinzu + if (!empty($accessToken)) { + $options['http']['header'] .= "Authorization: Bearer " . $accessToken . "\r\n"; + } + + $context = stream_context_create($options); + + $apiResponse = @file_get_contents($apiUrl, false, $context); + if ($apiResponse === false) { + $this->yellow->log("error", "MastodonAPI: Failed to fetch data from API. Check URL or network issues. URL: " . $apiUrl); + return false; + } + + $mastodonData = json_decode($apiResponse, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->yellow->log("error", "MastodonAPI: Failed to parse JSON response: " . json_last_error_msg()); + return false; + } + + if (!is_array($mastodonData)) { + $this->yellow->log("error", "MastodonAPI: API response is not an array of statuses."); + return false; + } + + $config = $this->getYellowConfig(); + $yellowBaseDir = $config->get("coreServerDocument"); + $mediaImagesDir = $yellowBaseDir . 'media/images/'; + + if (!is_dir($mediaImagesDir)) { + if (!mkdir($mediaImagesDir, 0755, true)) { + $this->yellow->log("error", "MastodonAPI: Could not create media images directory: " . $mediaImagesDir); + } + } + + foreach ($mastodonData as $item) { + $contentHtml = $item['content']; + $currentPubDate = $item['created_at']; + $itemUrl = $item['url']; + $imageFilenamesForMarkdown = []; + + // Filtern nach Hashtag (wenn konfiguriert) + $hasRelevantHashtag = empty($hashtag) || + (isset($item['tags']) && is_array($item['tags']) && in_array(['name' => ltrim($hashtag, '#')], array_map(function($t){ return ['name' => $t['name']]; }, $item['tags']))) || // Prüft, ob der Hashtag in den Tag-Objekten ist + (strpos($contentHtml, $hashtag) !== false); + + if (!$hasRelevantHashtag) { + continue; + } + + $title = $this->generateTitle($contentHtml); + $titleSlug = $this->generateTitleSlug($title); + + // Eindeutigen Dateinamen mit Toot-ID + $fileName = date('Y-m-d', strtotime($currentPubDate)) . '-' . $item['id'] . '-' . $titleSlug . '.md'; + $fullPathToMarkdownFile = $config->get("coreServerDocument") . $targetFolder . $fileName; + + if (file_exists($fullPathToMarkdownFile)) { + continue; + } + + $targetPostDir = dirname($fullPathToMarkdownFile); + if (!is_dir($targetPostDir)) { + if (!mkdir($targetPostDir, 0755, true)) { + $this->yellow->log("error", "MastodonAPI: Could not create post directory: " . $targetPostDir); + continue; + } + } + + $authorName = $item['account']['display_name'] ?: $item['account']['username']; + $authorProfileUrl = $item['account']['url']; + + if (isset($item['media_attachments']) && is_array($item['media_attachments'])) { + foreach ($item['media_attachments'] as $media) { + if ($media['type'] === 'image' || $media['type'] === 'gifv') { + $mediaUrl = $media['url']; + $mediaAltText = $media['description'] ?? ''; + $imageFilename = $this->downloadAndSaveImage($mediaUrl, $mediaImagesDir, $media['mime_type'], $mediaAltText); + if ($imageFilename) { + $imageFilenamesForMarkdown[] = ['filename' => $imageFilename, 'alt' => $mediaAltText]; + } + } + } + } + + $markdownContent = $this->generateMarkdown( + $title, + $titleSlug, + $currentPubDate, + $contentHtml, + $authorName, + $authorProfileUrl, + $defaultAuthor, + $defaultTag, + $imageFilenamesForMarkdown, + $itemUrl // Den ursprünglichen Toot-Link übergeben + ); + + if (file_put_contents($fullPathToMarkdownFile, $markdownContent) === false) { + $this->yellow->log("error", "MastodonAPI: Could not write Markdown file: " . $fullPathToMarkdownFile); + } else { + $postsCreated++; + $this->yellow->log("info", "MastodonAPI: Created post: " . $fileName); + } + } + return $postsCreated; + } + + public function processCommandClean($command, $text) { + // Implementiere hier die Logik zum Aufräumen, falls benötigt + return 0; + } + + private function downloadAndSaveImage($imageUrl, $globalImageDirectoryPath, $mediaType = '', $altText = '') { + $fileName = basename(parse_url($imageUrl, PHP_URL_PATH)); + $fileExtension = pathinfo($fileName, PATHINFO_EXTENSION); + + if (empty($fileExtension) && !empty($mediaType)) { + $typeParts = explode('/', $mediaType); + if (count($typeParts) > 1) { + $fileExtension = $typeParts[1]; + } + } + + if (empty($fileExtension)) { + $fileExtension = 'jpeg'; + } + + $counter = 0; + $uniqueFileName = pathinfo($fileName, PATHINFO_FILENAME) . '.' . $fileExtension; + while (file_exists($globalImageDirectoryPath . $uniqueFileName)) { + $counter++; + $uniqueFileName = pathinfo($fileName, PATHINFO_FILENAME) . '-' . $counter . '.' . $fileExtension; + if ($counter > 100) { + $this->yellow->log("error", "MastodonAPI: Could not find unique filename for image: " . $fileName); + return false; + } + } + + $finalFileName = $uniqueFileName; + $fullPathToSavedImage = $globalImageDirectoryPath . $finalFileName; + + if (file_exists($fullPathToSavedImage)) { + return $finalFileName; + } + + $context = stream_context_create([ + 'http' => [ + 'follow_location' => 1, + 'max_redirects' => 10, + 'timeout' => 30, + 'method' => "GET", + 'header' => "User-Agent: Mozilla/5.0 (YellowCMS MastodonAPI Plugin)\r\n" . + "Referer: " . $this->yellow->system->get("mastodonapiInstanceUrl") . "\r\n" + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $imageData = @file_get_contents($imageUrl, false, $context); + if ($imageData === false) { + $this->yellow->log("error", "MastodonAPI: Failed to download image from URL: " . $imageUrl); + return false; + } + + if (file_put_contents($fullPathToSavedImage, $imageData) === false) { + $this->yellow->log("error", "MastodonAPI: Failed to save image to: " . $fullPathToSavedImage); + return false; + } + + return $finalFileName; + } + + private function generateTitle($content) { + // Da content jetzt HTML ist, strippen wir HTML, bevor wir den Titel generieren + $cleanHtmlContent = strip_tags($content); + $cleanContent = preg_replace('/https?:\/\/[^\s]+|#\w+/u', '', $cleanHtmlContent); // URLs und Hashtags entfernen + $cleanContent = preg_replace('/[^\p{L}\p{N}\s-]/u', '', $cleanContent); // Sonderzeichen entfernen + $cleanContent = trim(preg_replace('/\s+/', ' ', $cleanContent)); // Mehrere Leerzeichen durch eines ersetzen + + $words = explode(' ', $cleanContent); + $firstWords = array_slice($words, 0, 7); // Die ersten 7 Wörter als Titel + $title = implode(' ', $firstWords); + + if (empty($title)) { + $title = "Mastodon Post"; + } + + $finalTitle = trim(mb_substr($title, 0, 100, 'UTF-8')); + return $finalTitle; + } + + private function generateTitleSlug($title) { + $slug = mb_strtolower($title, 'UTF-8'); + $slug = str_replace(['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß', ' '], ['ae', 'oe', 'ue', 'ae', 'oe', 'ue', 'ss', '-'], $slug); + $slug = preg_replace('/[^a-z0-9-]+/', '', $slug); + $slug = preg_replace('/-+/', '-', $slug); + $slug = trim($slug, '-'); + return $slug; + } + + private function generateMarkdown($title, $titleSlug, $publishedDate, $content, $originalAuthorName, $originalAuthorProfileUrl, $yellowAuthor, $yellowTag, $imageFilenames = [], $itemUrl = '') { + $date = new DateTime($publishedDate); + $formattedDate = $date->format('Y-m-d'); + $formattedTime = $date->format('H:i:s'); + + $processedContent = $content; + + // Ersetze Links (-Tags) im HTML-Inhalt, damit sie als Markdown-Links erscheinen. + // Konvertiert TEXT zu [TEXT](URL) + $processedContent = preg_replace_callback('/]*?href="([^"]*?)".*?>(.*?)<\/a>/isu', function($matches) { + $url = $matches[1]; + $text = strip_tags($matches[2]); // Nur den Text des Links nehmen + + // Wenn der Linktext dem Link selbst entspricht, nur den Link anzeigen (kürzer) + if (trim($text) === trim($url) || trim($text) === 'Show more') { // "Show more" von Mastodon ignorieren + return '[' . $url . ']'; + } + return '[' . $text . '](' . $url . ')'; + }, $processedContent); + + // Entferne Mastodon-spezifische HTML-Hashtag-Links aus dem Content, da sie nicht als Tags + // im Yellow Markdown erscheinen sollen und bereits über die Hashtag-Filterung verarbeitet wurden. + // Diese Regex zielt auf Links ab, die /tags/ im href haben. + $processedContent = preg_replace('/]*?href="[^"]*?\/tags\/[^"]*?".*?>#.*?<\/a>/iu', '', $processedContent); + + // Entfernt verbleibende HTML-Tags (z.B.

, etc.) + $processedContent = strip_tags($processedContent); + $processedContent = html_entity_decode($processedContent, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $processedContent = trim(preg_replace('/\s+/', ' ', $processedContent)); // Mehrere Leerzeichen durch eines ersetzen + + $imageMarkdown = ''; + foreach ($imageFilenames as $imageData) { + $imageMarkdown .= "\n[image " . $imageData['filename']; + if (!empty($imageData['alt'])) { + $imageMarkdown .= ' alt="' . htmlspecialchars($imageData['alt'], ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"'; + } + $imageMarkdown .= "]\n"; + } + + $featuredImageFrontMatter = ''; + if (!empty($imageFilenames)) { + $featuredImageFrontMatter = "Image: " . $imageFilenames[0]['filename'] . "\n"; + } + + // Ersetze @ mit @ im Autorennamen für die HTML-Ausgabe + $htmlSafeAuthorName = str_replace('@', '@', $originalAuthorName); + + $sourceLinkHtml = ''; + if (!empty($itemUrl)) { + $sourceLinkHtml = "
Originalbeitrag: $itemUrl"; + } + + return << "$processedContent" +> +> — $htmlSafeAuthorName +$sourceLinkHtml + +MARKDOWN; + } + + private function getYellowConfig() { + if ($this->yellow) { + return $this->yellow->system; + } + return null; + } + + private function sendResponse($message, $statusCode = 200) { + $htmlContent = "Umleitung...

" . htmlspecialchars($message) . "

Sie werden in 3 Sekunden weitergeleitet. Falls nicht, klicken Sie hier.

"; + if (!headers_sent()) { + header("HTTP/1.1 " . $statusCode . " " . $this->getHttpStatusMessage($statusCode)); + header("Content-Type: text/html"); + } + echo $htmlContent; + exit; + } + + private function getHttpStatusMessage($statusCode) { + $httpStatus = array( + 100 => 'Continue', 101 => 'Switching Protocols', + 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', + 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 307 => 'Temporary Redirect', + 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', 408 => 'Request Timeout', 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', 413 => 'Payload Too Large', 414 => 'URI Too Long', 415 => 'Unsupported Media Type', 416 => 'Range Not Satisfiable', 417 => 'Expectation Failed', 426 => 'Upgrade Required', 429 => 'Too Many Requests', 431 => 'Request Header Fields Too Large', 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 505 => 'HTTP Version Not Supported' + ); + return ($httpStatus[$statusCode]) ? $httpStatus[$statusCode] : 'Unknown Status'; + } +} \ No newline at end of file