1
0

Neu: Kemono & Coomer Bridge

This commit is contained in:
Akamaru
2025-12-05 12:23:55 +01:00
parent 8c283e0bc4
commit 5f2f737267
2 changed files with 294 additions and 0 deletions

283
KemonoCoomerBridge.php Normal file
View 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();
}
}