Neu: Kemono & Coomer Bridge
This commit is contained in:
283
KemonoCoomerBridge.php
Normal file
283
KemonoCoomerBridge.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Kemono & Coomer Bridge
|
||||
* Returns posts from Kemono and Coomer archived creators across multiple services
|
||||
*/
|
||||
class KemonoCoomerBridge extends BridgeAbstract
|
||||
{
|
||||
const NAME = 'Kemono & Coomer';
|
||||
const URI = 'https://kemono.cr/';
|
||||
const DESCRIPTION = 'Returns posts from Kemono (Patreon, Fanbox, etc.) and Coomer (OnlyFans, Fansly, CandFans) archived creators';
|
||||
const MAINTAINER = 'Akamaru';
|
||||
const CACHE_TIMEOUT = 3600; // 1 hour
|
||||
|
||||
public function getIcon()
|
||||
{
|
||||
// Return appropriate icon based on selected service
|
||||
$service = $this->getInput('service');
|
||||
if ($service && in_array($service, ['onlyfans', 'fansly', 'candfans'])) {
|
||||
return 'https://www.google.com/s2/favicons?domain=coomer.st&sz=32';
|
||||
}
|
||||
return 'https://www.google.com/s2/favicons?domain=kemono.cr&sz=32';
|
||||
}
|
||||
|
||||
const PARAMETERS = [
|
||||
[
|
||||
'service' => [
|
||||
'name' => 'Service',
|
||||
'type' => 'list',
|
||||
'required' => true,
|
||||
'values' => [
|
||||
'Patreon' => 'patreon',
|
||||
'Fanbox' => 'fanbox',
|
||||
'Fantia' => 'fantia',
|
||||
'Gumroad' => 'gumroad',
|
||||
'SubscribeStar' => 'subscribestar',
|
||||
'Discord' => 'discord',
|
||||
'DLsite' => 'dlsite',
|
||||
'Boosty' => 'boosty',
|
||||
'Afdian' => 'afdian',
|
||||
'OnlyFans' => 'onlyfans',
|
||||
'Fansly' => 'fansly',
|
||||
'CandFans' => 'candfans'
|
||||
],
|
||||
'defaultValue' => 'patreon'
|
||||
],
|
||||
'user' => [
|
||||
'name' => 'User ID',
|
||||
'type' => 'text',
|
||||
'required' => true,
|
||||
'title' => 'The creator\'s user ID (numeric or username)',
|
||||
'exampleValue' => '6889522'
|
||||
],
|
||||
'limit' => [
|
||||
'name' => 'Limit',
|
||||
'type' => 'number',
|
||||
'required' => false,
|
||||
'title' => 'Maximum number of posts to return (default: 25, max: 50)',
|
||||
'defaultValue' => 25
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
private $creatorName = null;
|
||||
|
||||
/**
|
||||
* Check if the selected service is a Coomer service
|
||||
*/
|
||||
private function isCoomer(): bool
|
||||
{
|
||||
$service = $this->getInput('service') ?? '';
|
||||
return in_array($service, ['onlyfans', 'fansly', 'candfans']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URI based on the service (Kemono or Coomer)
|
||||
*/
|
||||
private function getBaseURI(): string
|
||||
{
|
||||
if ($this->isCoomer()) {
|
||||
return 'https://coomer.st/';
|
||||
}
|
||||
return self::URI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CDN domain based on the service
|
||||
*/
|
||||
private function getCDNDomain(): string
|
||||
{
|
||||
if ($this->isCoomer()) {
|
||||
return 'https://n1.coomer.st/data';
|
||||
}
|
||||
return 'https://n1.kemono.cr/data';
|
||||
}
|
||||
|
||||
public function collectData()
|
||||
{
|
||||
$service = $this->getInput('service');
|
||||
$user = $this->getInput('user');
|
||||
$limit = min((int)$this->getInput('limit'), 50);
|
||||
|
||||
// Fetch user profile to get creator name
|
||||
$this->fetchUserProfile($service, $user);
|
||||
|
||||
// Build API URL
|
||||
$apiUrl = $this->getBaseURI() . "api/v1/{$service}/user/{$user}/posts";
|
||||
|
||||
// Fetch posts with required header to bypass DDoS protection
|
||||
$headers = ['Accept: text/css'];
|
||||
$json = getContents($apiUrl, $headers);
|
||||
$posts = json_decode($json, true);
|
||||
|
||||
if (!is_array($posts)) {
|
||||
throw new \Exception('Invalid API response: expected array of posts');
|
||||
}
|
||||
|
||||
// Process posts (limited by user preference)
|
||||
$count = 0;
|
||||
foreach ($posts as $post) {
|
||||
if ($count >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$item = $this->parsePost($post, $service, $user);
|
||||
if ($item) {
|
||||
$this->items[] = $item;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a post from the API response into an RSS item
|
||||
*/
|
||||
private function parsePost(array $post, string $service, string $user): ?array
|
||||
{
|
||||
$postId = $post['id'] ?? null;
|
||||
if (!$postId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build post URL
|
||||
$postUrl = $this->getBaseURI() . "{$service}/user/{$user}/post/{$postId}";
|
||||
|
||||
// Get title (with fallback)
|
||||
$title = $post['title'] ?? 'Untitled Post';
|
||||
if (empty(trim($title))) {
|
||||
$title = 'Untitled Post';
|
||||
}
|
||||
|
||||
// Build content with embedded images
|
||||
$content = $this->buildPostContent($post);
|
||||
|
||||
// Parse timestamp
|
||||
$timestamp = null;
|
||||
if (isset($post['published'])) {
|
||||
$timestamp = strtotime($post['published']);
|
||||
}
|
||||
|
||||
// Build item
|
||||
$item = [
|
||||
'title' => $title,
|
||||
'uri' => $postUrl,
|
||||
'timestamp' => $timestamp,
|
||||
'content' => $content,
|
||||
'uid' => $postId,
|
||||
'enclosures' => []
|
||||
];
|
||||
|
||||
// Add author if we have creator name
|
||||
if ($this->creatorName) {
|
||||
$item['author'] = $this->creatorName;
|
||||
}
|
||||
|
||||
// Add main file as enclosure
|
||||
if (isset($post['file']['path'])) {
|
||||
$fileUrl = $this->getCDNDomain() . $post['file']['path'];
|
||||
$item['enclosures'][] = $fileUrl;
|
||||
}
|
||||
|
||||
// Add attachments as enclosures
|
||||
if (isset($post['attachments']) && is_array($post['attachments'])) {
|
||||
foreach ($post['attachments'] as $attachment) {
|
||||
if (isset($attachment['path'])) {
|
||||
$attachmentUrl = $this->getCDNDomain() . $attachment['path'];
|
||||
$item['enclosures'][] = $attachmentUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML content for the post with embedded images
|
||||
*/
|
||||
private function buildPostContent(array $post): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
// Add main image if present
|
||||
if (isset($post['file']['path'])) {
|
||||
$imageUrl = $this->getCDNDomain() . $post['file']['path'];
|
||||
$imageName = $post['file']['name'] ?? 'Image';
|
||||
$html .= '<p><img src="' . htmlspecialchars($imageUrl) . '" alt="' . htmlspecialchars($imageName) . '" /></p>';
|
||||
}
|
||||
|
||||
// Add content text (either full content or substring)
|
||||
if (isset($post['content']) && !empty(trim($post['content']))) {
|
||||
$html .= $post['content'];
|
||||
} elseif (isset($post['substring']) && !empty(trim($post['substring']))) {
|
||||
$html .= $post['substring'];
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile to get creator name
|
||||
*/
|
||||
private function fetchUserProfile(string $service, string $user): void
|
||||
{
|
||||
try {
|
||||
$profileUrl = $this->getBaseURI() . "api/v1/{$service}/user/{$user}/profile";
|
||||
$headers = ['Accept: text/css'];
|
||||
$json = getContents($profileUrl, $headers);
|
||||
$profile = json_decode($json, true);
|
||||
|
||||
if (isset($profile['name'])) {
|
||||
$this->creatorName = $profile['name'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If profile fetch fails, continue without creator name
|
||||
// This is not critical to the bridge functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getName to include creator name in feed title
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
if ($this->creatorName) {
|
||||
return $this->creatorName . ' - ' . self::NAME;
|
||||
}
|
||||
return parent::getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect parameters from Kemono and Coomer URLs
|
||||
*/
|
||||
public function detectParameters($url)
|
||||
{
|
||||
$pattern = '#(kemono\.cr|coomer\.st)/([^/]+)/(user|server)/([^/?\#]+)#i';
|
||||
|
||||
if (preg_match($pattern, $url, $matches)) {
|
||||
return [
|
||||
'service' => strtolower($matches[2]),
|
||||
'user' => $matches[4]
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getURI to return the user's page
|
||||
*/
|
||||
public function getURI()
|
||||
{
|
||||
$service = $this->getInput('service');
|
||||
$user = $this->getInput('user');
|
||||
|
||||
if ($service && $user) {
|
||||
return $this->getBaseURI() . "{$service}/user/{$user}";
|
||||
}
|
||||
|
||||
return parent::getURI();
|
||||
}
|
||||
}
|
||||
11
README.md
11
README.md
@@ -161,6 +161,17 @@ Diese Sammlung enthält verschiedene Bridge-Implementierungen für RSS-Bridge, u
|
||||
### [Kemono Friends Music News Bridge](https://bridge.ponywave.de/#bridge-KemonoFriendsMusicNewsBridge) (Von Akamaru)
|
||||
- **Beschreibung**: Zeigt die neuesten Nachrichten für Kemono Friends Musik
|
||||
|
||||
### [Kemono & Coomer Bridge](https://bridge.ponywave.de/#bridge-KemonoCoomerBridge) (Von Akamaru)
|
||||
- **Beschreibung**: Zeigt Posts von Kemono (Patreon, Fanbox, etc.) und Coomer (OnlyFans, Fansly, CandFans) archivierten Creators
|
||||
- **Parameter**:
|
||||
- **Service**: Patreon, Fanbox, Fantia, Gumroad, SubscribeStar, Discord, DLsite, Boosty, Afdian, OnlyFans, Fansly, CandFans
|
||||
- **User ID**: Creator's User-ID (numerisch oder Username)
|
||||
- **Limit** (optional): Maximale Anzahl an Posts (Standard: 25, max: 50)
|
||||
- **Hinweise**:
|
||||
- Unterstützt automatische URL-Erkennung für kemono.cr und coomer.st URLs
|
||||
- Bilder und Attachments werden als Enclosures hinzugefügt
|
||||
- Creator-Name wird als Autor gesetzt
|
||||
|
||||
### [Main-Post Bridge](https://bridge.ponywave.de/#bridge-MainPostBridge) (Von Akamaru)
|
||||
- **Beschreibung**: Nachrichten und Artikel von der Main-Post
|
||||
- **Parameter**:
|
||||
|
||||
Reference in New Issue
Block a user