yellow-mastodonapi/yellow-mastodonapi.php
2025-07-12 21:13:05 +02:00

397 lines
No EOL
18 KiB
PHP

<?php
// MastodonAPI extension for Datenstrom Yellow
// Based on MastodonRSS extension by Simon Pipe, adapted for Mastodon API
// https://codeberg.org/simonpipe/yellow-mastodonapi/src/branch/main
class YellowMastodonAPI { // Klasse umbenannt zu MastodonAPI
const VERSION = "1.0"; // Version erhöht, da größere Änderungen
public $yellow;
// Handle initialisation
public function onLoad($yellow) {
$this->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 (<a>-Tags) im HTML-Inhalt, damit sie als Markdown-Links erscheinen.
// Konvertiert <a href="URL">TEXT</a> zu [TEXT](URL)
$processedContent = preg_replace_callback('/<a\s[^>]*?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('/<a\s[^>]*?href="[^"]*?\/tags\/[^"]*?".*?>#.*?<\/a>/iu', '', $processedContent);
// Entfernt verbleibende HTML-Tags (z.B. <p>, <span> 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 &#64; im Autorennamen für die HTML-Ausgabe
$htmlSafeAuthorName = str_replace('@', '&#64;', $originalAuthorName);
$sourceLinkHtml = '';
if (!empty($itemUrl)) {
$sourceLinkHtml = "<br><small>Originalbeitrag: <a target=\"_blank\" href=\"$itemUrl\">$itemUrl</a></small>";
}
return <<<MARKDOWN
---
Title: $title
TitleSlug: $titleSlug
Published: $formattedDate $formattedTime
Author: $yellowAuthor
Layout: blog
Tag: $yellowTag
$featuredImageFrontMatter---
$imageMarkdown
> "$processedContent"
>
> — <a target="_blank" href="$originalAuthorProfileUrl">$htmlSafeAuthorName</a>
$sourceLinkHtml
MARKDOWN;
}
private function getYellowConfig() {
if ($this->yellow) {
return $this->yellow->system;
}
return null;
}
private function sendResponse($message, $statusCode = 200) {
$htmlContent = "<!DOCTYPE html><html lang=\"de\"><head><meta charset=\"UTF-8\"><meta http-equiv=\"refresh\" content=\"3;url=/\"><title>Umleitung...</title></head><body><h1>" . htmlspecialchars($message) . "</h1><p>Sie werden in 3 Sekunden weitergeleitet. Falls nicht, klicken Sie <a href=\"/\">hier</a>.</p></body></html>";
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';
}
}