397 lines
No EOL
18 KiB
PHP
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 @ im Autorennamen für die HTML-Ausgabe
|
|
$htmlSafeAuthorName = str_replace('@', '@', $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';
|
|
}
|
|
} |