rss-bridge/bridges/MangaDexBridge.php
2024-01-24 15:22:16 +01:00

780 lines
28 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
class MangaDexBridge extends BridgeAbstract
{
const NAME = 'MangaDex Bridge';
const URI = 'https://mangadex.org/';
const API_ROOT = 'https://api.mangadex.org/';
const DESCRIPTION = 'Returns MangaDex items using the API';
const PARAMETERS = [
'Title Chapters' => [
'url' => [
'name' => 'URL to title page',
'exampleValue' => 'https://mangadex.org/title/f9c33607-9180-4ba6-b85c-e4b5faee7192/official-test-manga',
'required' => true
],
'external' => [
'name' => 'Allow external feed items',
'type' => 'checkbox',
'title' => 'Some chapters are inaccessible or only available on an external site. Include these?'
],
'limit' => [
'name' => 'Item Limit',
'type' => 'number',
'defaultValue' => 10,
'required' => true
],
'lang' => [
'name' => 'Chapter Languages (default=all)',
'title' => 'comma-separated, two-letter language codes (example "en,jp")',
'exampleValue' => 'en,jp',
'required' => false
],
'images' => [
'name' => 'Fetch chapter page images',
'type' => 'list',
'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',
'defaultValue' => 'no',
'values' => [
'None' => 'no',
'Data Saver' => 'saver',
'Full Quality' => 'yes'
]
]
],
'Search Chapters' => [
'chapter' => [
'name' => 'Chapter Number (default=all)',
'title' => 'The example value finds the newest first chapters',
'exampleValue' => 1,
'required' => false
],
'groups' => [
'name' => 'Group UUID (default=all)',
'title' => 'This can be found in the MangaDex Group Page URL',
'exampleValue' => '00e03853-1b96-4f41-9542-c71b8692033b',
'required' => false,
],
'uploader' => [
'name' => 'User UUID (default=all)',
'title' => 'This can be found in the MangaDex User Page URL',
'exampleValue' => 'd2ae45e0-b5e2-4e7f-a688-17925c2d7d6b',
'required' => false,
],
'external' => [
'name' => 'Allow external feed items',
'type' => 'checkbox',
'title' => 'Some chapters are inaccessible or only available on an external site. Include these?'
],
'limit' => [
'name' => 'Item Limit',
'type' => 'number',
'defaultValue' => 10,
'required' => true
],
'lang' => [
'name' => 'Chapter Languages (default=all)',
'title' => 'comma-separated, two-letter language codes (example "en,jp")',
'exampleValue' => 'en,jp',
'required' => false
],
'images' => [
'name' => 'Fetch chapter page images',
'type' => 'list',
'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',
'defaultValue' => 'no',
'values' => [
'None' => 'no',
'Data Saver' => 'saver',
'Full Quality' => 'yes'
]
]
],
// Future Manga Contexts:
// Manga List (by author or tags): https://api.mangadex.org/swagger.html#/Manga/get-search-manga
// Random Manga: https://api.mangadex.org/swagger.html#/Manga/get-manga-random
// Future Chapter Contexts:
// User Lists https://api.mangadex.org/swagger.html#/Feed/get-list-id-feed
//
// https://api.mangadex.org/docs/get-covers/
'New manga' => [
'originalLanguages' => [
'name' => 'Original languages',
'type' => 'text',
'title' => 'Include only chapters originally in these languages',
'exampleValue' => 'ja,ko,zh',
],
'excludeOriginalLanguages' => [
'name' => 'Exclude original languages',
'type' => 'checkbox',
'title' => 'Invert the selection in original languages',
],
'translatedLanguage' => [
'name' => 'Translated language',
'type' => 'list',
'values' => [
'Any' => '',
'Chinese (simplified)' => 'zh',
'English' => 'en',
'French' => 'fr',
'Korean' => 'ko',
'Spanish (LATAM)' => 'es-la',
'Spanish' => 'es',
],
],
'order' => [
'name' => 'Sort Order',
'type' => 'list',
'values' => [
'Recent chapter' => 'recentChapter',
'Recently added' => 'createdAt',
'Recently updated' => 'updatedAt',
],
'defaultValue' => 'createdAt',
],
'safe' => [
'name' => 'Safe',
'type' => 'checkbox',
'defaultValue' => 'checked'
],
'suggestive' => [
'name' => 'Suggestive',
'type' => 'checkbox',
'defaultValue' => 'checked'
],
'erotica' => [
'name' => 'Erotica',
'type' => 'checkbox',
'defaultValue' => 'checked'
],
'pornographic' => [
'name' => 'Pornographic',
'type' => 'checkbox'
],
],
];
private const CDN_URI = 'https://uploads.mangadex.org/';
private const CUSTOM_TAGS = ['content' => 'contentRating', 'demos' => 'publicationDemographic', 'statuses' => 'status'];
const TITLE_REGEX = '#title/(?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})#';
protected $feedName = '';
protected $feedURI = '';
protected function buildArrayQuery($name, $array)
{
$query = '';
foreach ($array as $item) {
$query .= '&' . $name . '=' . $item;
}
return $query;
}
protected function getAPI()
{
$params = [
'limit' => $this->getInput('limit')
];
$array_params = [];
if (!empty($this->getInput('lang'))) {
$array_params['translatedLanguage[]'] = explode(',', $this->getInput('lang'));
}
switch ($this->queriedContext) {
case 'Title Chapters':
preg_match(self::TITLE_REGEX, $this->getInput('url'), $matches)
or returnClientError('Invalid URL Parameter');
$this->feedURI = self::URI . 'title/' . $matches['uuid'];
$params['order[readableAt]'] = 'desc';
if (!$this->getInput('external')) {
$params['includeFutureUpdates'] = '0';
}
$array_params['includes[]'] = ['manga', 'scanlation_group', 'user'];
$uri = self::API_ROOT . 'manga/' . $matches['uuid'] . '/feed';
break;
case 'Search Chapters':
$params['chapter'] = $this->getInput('chapter');
$params['groups[]'] = $this->getInput('groups');
$params['uploader'] = $this->getInput('uploader');
$params['order[readableAt]'] = 'desc';
if (!$this->getInput('external')) {
$params['includeFutureUpdates'] = '0';
}
$array_params['includes[]'] = ['manga', 'scanlation_group', 'user'];
$uri = self::API_ROOT . 'chapter';
break;
default:
returnServerError('Unimplemented Context (getAPI)');
}
// Remove null keys
$params = array_filter($params, function ($v) {
return !empty($v);
});
$uri .= '?' . http_build_query($params);
// Arrays are passed as repeated keys to MangaDex
// This cannot be handled by http_build_query
foreach ($array_params as $name => $array_param) {
$uri .= $this->buildArrayQuery($name, $array_param);
}
return $uri;
}
public function getName()
{
if ($this->queriedContext === 'New manga') {
if ($this->getInput('order') !== 'recentChapter') {
return $this->getKey('order') . ' manga - ' . parent::getName();
}
$title = 'Latest';
$translatedLanguage = $this->getKey('translatedLanguage');
if ($translatedLanguage !== 'Any') {
$title .= ' ' . $translatedLanguage;
}
return $title . ' chapters - ' . parent::getName();
}
switch ($this->queriedContext) {
case 'Title Chapters':
return $this->feedName . ' Chapters';
case 'Search Chapters':
return 'MangaDex Chapter Search';
default:
return parent::getName();
}
}
public function getURI()
{
switch ($this->queriedContext) {
case 'Title Chapters':
return $this->feedURI;
default:
return parent::getURI();
}
}
public function collectData()
{
if ($this->queriedContext === 'New manga') {
$queryParts = [];
$queryParts['limit'] = 50;
if (!empty($this->getInput('originalLanguages'))) {
$queryPart = $this->getInput('excludeOriginalLanguages') === true ? 'excludedOriginalLanguage' : 'originalLanguage';
foreach (explode(',', $this->getInput('originalLanguages')) as $language) {
$language = trim($language);
$queryParts[$queryPart][] = $language;
}
}
$queryParts['contentRating'] = array_filter(['safe', 'suggestive', 'erotica', 'pornographic'], function ($rating) {
return $this->getInput($rating) === true;
});
if ($this->getInput('order') === 'recentChapter') {
$this->items = $this->collectChapters($queryParts);
} else {
$this->items = $this->collectManga($queryParts);
}
return;
}
$api_uri = $this->getAPI();
$header = [
'Content-Type: application/json'
];
$content = json_decode(getContents($api_uri, $header), true);
if ($content['result'] == 'ok') {
$content = $content['data'];
} else {
returnServerError('Could not retrieve API results');
}
switch ($this->queriedContext) {
case 'Title Chapters':
$this->getChapters($content);
break;
case 'Search Chapters':
$this->getChapters($content);
break;
default:
returnServerError('Unimplemented Context (collectData)');
}
}
protected function getChapters($content)
{
foreach ($content as $chapter) {
$item = [];
$item['uid'] = $chapter['id'];
$item['uri'] = self::URI . 'chapter/' . $chapter['id'];
// External chapter
if (!$this->getInput('external') && $chapter['attributes']['pages'] == 0) {
continue;
}
$item['title'] = '';
if (isset($chapter['attributes']['volume'])) {
$item['title'] .= 'Volume ' . $chapter['attributes']['volume'] . ' ';
}
if (isset($chapter['attributes']['chapter'])) {
$item['title'] .= 'Chapter ' . $chapter['attributes']['chapter'];
}
if (!empty($chapter['attributes']['title'])) {
$item['title'] .= ' - ' . $chapter['attributes']['title'];
}
$item['title'] .= ' [' . $chapter['attributes']['translatedLanguage'] . ']';
$item['timestamp'] = $chapter['attributes']['readableAt'];
$groups = [];
$users = [];
foreach ($chapter['relationships'] as $rel) {
switch ($rel['type']) {
case 'scanlation_group':
$groups[] = $rel['attributes']['name'];
break;
case 'manga':
if (empty($this->feedName)) {
$this->feedName = reset($rel['attributes']['title']);
}
if ($this->queriedContext !== 'Title Chapters') {
$item['title'] = reset($rel['attributes']['title']) . ' ' . $item['title'];
}
break;
case 'user':
if (isset($item['author'])) {
$users[] = $rel['attributes']['username'];
} else {
$item['author'] = $rel['attributes']['username'];
}
break;
}
}
$item['content'] = 'Groups: ' .
(empty($groups) ? 'No Group' : implode(', ', $groups));
if (!empty($users)) {
$item['content'] .= '<br>Other Users: ' . implode(', ', $users);
}
// Fetch chapter page images if desired and add to content
if ($this->getInput('images') !== 'no') {
$api_uri = self::API_ROOT . 'at-home/server/' . $item['uid'];
$header = ['Content-Type: application/json'];
$pages = json_decode(getContents($api_uri, $header), true);
if ($pages['result'] != 'ok') {
returnServerError('Could not retrieve API results');
}
if ($this->getInput('images') == 'saver') {
$page_base = $pages['baseUrl'] . '/data-saver/' . $pages['chapter']['hash'] . '/';
foreach ($pages['chapter']['dataSaver'] as $image) {
$item['content'] .= '<br><img src="' . $page_base . $image . '"/>';
}
} else {
$page_base = $pages['baseUrl'] . '/data/' . $pages['chapter']['hash'] . '/';
foreach ($pages['chapter']['data'] as $image) {
$item['content'] .= '<br><img src="' . $page_base . $image . '"/>';
}
}
}
$this->items[] = $item;
}
}
private function collectChapters(array $queryParts): array
{
if (!empty($this->getInput('translatedLanguage'))) {
$queryParts['translatedLanguage'] = [$this->getInput('translatedLanguage')];
}
$queryParts['order'] = ['readableAt' => 'desc'];
$queryParts['includes'] = ['manga'];
$res = static::getMangaDexContents(static::API_ROOT . 'chapter?' . preg_replace('/\%5B\d+\%5D/', '%5B%5D', http_build_query($queryParts)));
$mangaWithoutCover = [];
$covers = $this->loadCacheValue('covers') ?? [];
foreach ($res['data'] as $chapter) {
foreach ($chapter['relationships'] as $relationship) {
if ($relationship['type'] !== 'manga') {
continue;
}
if (empty($covers[$relationship['id']])) {
$mangaWithoutCover[$relationship['id']] = $relationship['id'];
}
break;
}
}
$queryParts = [];
$queryParts['limit'] = 100;
$queryParts['order'] = ['volume' => 'desc'];
while ($mangaWithoutCover) {
$queryParts['manga'] = array_values($mangaWithoutCover);
$coverRes = static::getMangaDexContents(static::API_ROOT . 'cover?' . preg_replace('/\%5B\d+\%5D/', '%5B%5D', http_build_query($queryParts)));
foreach ($coverRes['data'] as $cover) {
foreach ($cover['relationships'] as $relationship) {
if ($relationship['type'] !== 'manga') {
continue;
}
if (empty($covers[$relationship['id']])) {
$covers[$relationship['id']] = $cover;
unset($mangaWithoutCover[$relationship['id']]);
}
break;
}
}
}
$this->saveCacheValue('covers', $covers);
$items = [];
foreach ($res['data'] as $chapter) {
$manga = [];
foreach ($chapter['relationships'] as $relationship) {
if ($relationship['type'] === 'manga') {
$manga = $relationship;
break;
}
}
$title = static::getItemTitle($manga, $chapter, $this->getInput('translatedLanguage'));
if (!empty($manga['attributes']['originalLanguage'])) {
$title = '[' . $manga['attributes']['originalLanguage'] . '] ' . $title;
}
$coverURIs = [];
if (!empty($covers[$manga['id']])) {
$coverURIs = [static::CDN_URI . 'covers/' . $manga['id'] . '/' . $covers[$manga['id']]['attributes']['fileName']];
}
$categories = static::getMangaCategories($manga);
$item = [
'uri' => static::getMangaURI($manga),
'title' => $title,
'timestamp' => $chapter['attributes']['readableAt'],
'content' => static::getMangaContent($manga, $chapter, $coverURIs, $this->getInput('translatedLanguage')),
'enclosures' => $coverURIs,
'categories' => $categories,
'uid' => static::URI . $chapter['id'],
];
$items[] = $item;
}
return $items;
}
private function collectManga(array $queryParts): array
{
if (!empty($this->getInput('translatedLanguage'))) {
$queryParts['availableTranslatedLanguage'] = [$this->getInput('translatedLanguage')];
}
$queryParts['order'] = [$this->getInput('order') => 'desc'];
$queryParts['includes'] = ['cover_art', 'author', 'artist'];
$res = static::getMangaDexContents(static::API_ROOT . 'manga?' . preg_replace('/\%5B\d+\%5D/', '%5B%5D', http_build_query($queryParts)));
$items = [];
foreach ($res['data'] as $manga) {
$authors = [];
$covers = [];
foreach ($manga['relationships'] as $relationship) {
switch ($relationship['type']) {
case 'author':
$authors[] = $relationship['attributes']['name'];
break;
case 'cover_art':
$covers[] = static::CDN_URI . 'covers/' . $manga['id'] . '/' . $relationship['attributes']['fileName'];
break;
}
}
switch ($this->getInput('order')) {
case 'latestUploadedChapter':
$timestamp = $manga['attributes']['updatedAt'];
$uid = static::URI . $manga['attributes']['latestUploadedChapter'];
break;
case 'updatedAt':
$timestamp = $manga['attributes']['updatedAt'];
$uid = static::URI . $manga['id'] . $manga['attributes']['updatedAt'];
break;
default:
$timestamp = $manga['attributes']['createdAt'];
$uid = static::URI . $manga['id'];
break;
}
$title = static::getItemTitle($manga, [], $this->getInput('translatedLanguage'));
if (!empty($manga['attributes']['originalLanguage'])) {
$title = '[' . $manga['attributes']['originalLanguage'] . '] ' . $title;
}
$categories = static::getMangaCategories($manga);
$item = [
'uri' => static::getMangaURI($manga),
'title' => $title,
'timestamp' => $timestamp,
'author' => reset($authors),
'content' => static::getMangaContent($manga, [], $covers, $this->getInput('translatedLanguage')),
'enclosures' => $covers,
'categories' => $categories,
'uid' => $uid,
];
$items[] = $item;
}
return $items;
}
/**
* @param string $uri api.mangadex.org URL
*
* @return array JSON array
*/
private static function getMangaDexContents(string $uri): array
{
$contents = getContents($uri);
$json = json_decode($contents, true);
if (($json['result'] ?? null) !== 'ok') {
returnServerError('Invalid response from server.');
}
return $json;
}
/**
* @param array $manga JSON array of manga contents
*
* @return array Manga tags including content rating, demographic and status
*/
private static function getMangaCategories(array $manga): array
{
$categories = [];
foreach (static::CUSTOM_TAGS as $searchFilter => $customTag) {
if (empty($manga['attributes'][$customTag])) {
continue;
}
$categories[$searchFilter] = htmlspecialchars(ucfirst($manga['attributes'][$customTag]), ENT_XML1);
}
foreach ($manga['attributes']['tags'] ?? [] as $tag) {
$categories[$tag['id']] = htmlspecialchars(reset($tag['attributes']['name']), ENT_XML1);
}
return $categories;
}
/**
* @param array $manga JSON array of manga contents
*
* @return string Canonical URI of manga
*/
private static function getMangaURI(array $manga): string
{
$title = $manga['attributes']['title'] ?? [];
$title = array_reverse($title);
$title = array_pop($title) ?? '';
$title = preg_replace('/\s*\([^)]*\)\s*/', '', $title);
$title = str_replace('ā', 'a', $title);
$title = str_replace(['é', 'ē'], 'e', $title);
$title = preg_replace('/[^A-Za-z0-9]+/', '-', $title);
$title = strtolower($title);
$title = rtrim($title, '-');
if (strlen($title) > 100) {
$title = substr($title, 0, 101);
$title = implode('-', explode('-', $title, -1));
}
return static::URI . 'title/' . $manga['id'] . '/' . $title;
}
/**
* @param array $manga JSON array of manga contents
* @param array $chapter JSON array of chapter contents
* @param string $preferredLanguage Language code, e.g. "en"
*
* @return string Feed item title in preferred language
*/
private static function getItemTitle(array $manga, array $chapter, string $preferredLanguage): string
{
$title = '';
$chapterTitle = '';
if (!empty($chapter['attributes']['volume'])) {
$chapterTitle .= 'Vol. ' . $chapter['attributes']['volume'];
}
if (!empty($chapter['attributes']['chapter'])) {
if (!empty($chapterTitle)) {
$chapterTitle .= ' ';
}
$chapterTitle .= 'Ch. ' . $chapter['attributes']['chapter'];
}
if (!empty($chapter['attributes']['title'])) {
$chapterTitle .= ': ' . $chapter['attributes']['title'];
}
if (!empty($chapterTitle)) {
$title .= $chapterTitle . ' | ';
}
$mangaTitles = static::getMangaTitles($manga);
$mangaTitle = '';
foreach ($mangaTitles as $currentTitle) {
foreach ($currentTitle as $language => $value) {
if (empty($mangaTitle)) {
$mangaTitle = $value;
}
if (empty($preferredLanguage) || $language === $preferredLanguage) {
return $title . $value;
}
}
}
return $title . $mangaTitle;
}
/**
* @param array $manga JSON array of manga contents
* @param array $chapter JSON array of chapter contents
* @param array $coverURIs of manga cover images
* @param string $preferredLanguage for titles, etc.
*
* @return string Feed item content HTML code
*/
private static function getMangaContent(array $manga, array $chapter, array $coverURIs, string $preferredLanguage): string
{
$content = '';
if (!empty($coverURIs)) {
foreach ($coverURIs as $coverURI) {
$content .= '<p><img src="' . htmlspecialchars($coverURI) . '.256.jpg"></p>';
}
}
if (!empty($manga)) {
$content .= '<p>Manga link: <a href="' . static::getMangaURI($manga) . '">' . htmlspecialchars(reset($manga['attributes']['title']) ?? '') . '</a></p>';
}
if (!empty($chapter)) {
$chapterTitle = $chapter['attributes']['title'] ?: 'No chapter title';
$content .= '<p>Chapter link: <a href="' . static::URI . 'chapter/' . htmlspecialchars($chapter['id']) . '">' . htmlspecialchars($chapterTitle) . '</a></p>';
}
$descriptions = $manga['attributes']['description'] ?? [];
$description = '';
foreach ($descriptions as $key => $value) {
if (empty($description)) {
$description = $value;
}
if (empty($preferredLanguage) || $key === $preferredLanguage) {
$description = $value;
break;
}
}
$content .= '<p>' . nl2br(htmlspecialchars($description), false) . '</p><hr>';
$categories = static::getMangaCategories($manga);
if (!empty($categories)) {
$htmlCategories = [];
foreach ($categories as $key => $value) {
if (empty(static::CUSTOM_TAGS[$key])) {
$href = static::URI . 'tag/' . htmlspecialchars($key);
} else {
$href = static::URI . 'titles?' . htmlspecialchars($key) . '=' . htmlspecialchars(strtolower($value));
}
$htmlCategories[] = '<a href="' . $href . '">' . htmlspecialchars($value) . '</a>';
}
$content .= '<p>Tags: ' . implode(', ', $htmlCategories) . '</p>';
}
$authors = [];
$artists = [];
foreach ($manga['relationships'] ?? [] as $relationship) {
switch ($relationship['type']) {
case 'author':
$authors[$relationship['id']] = $relationship['attributes']['name'];
break;
case 'artist':
$artists[$relationship['id']] = $relationship['attributes']['name'];
break;
}
}
foreach (['Authors' => $authors, 'Artists' => $artists] as $description => $items) {
if (empty($items)) {
continue;
}
asort($items);
$htmlItems = [];
foreach ($items as $id => $name) {
$htmlItems[] = '<a href="' . static::URI . 'author/' . htmlspecialchars($id) . '">' . htmlspecialchars($name) . '</a>';
}
$content .= '<p>' . substr($description, 0, 1 < count($htmlItems) ? PHP_INT_MAX : -1) . ': ' . implode(', ', $htmlItems) . '</p>';
}
$mangaTitles = static::getMangaTitles($manga);
if (!empty($mangaTitles)) {
$htmlAltTitles = [];
foreach ($mangaTitles as $altTitle) {
foreach ($altTitle as $language => $title) {
$htmlAltTitles[] = 'Title (' . htmlspecialchars($language) . '): ' . htmlspecialchars($title);
}
}
sort($htmlAltTitles);
$content .= '<p>' . implode('<br>', $htmlAltTitles) . '</p>';
}
$content = str_replace('', '', $content);
return $content;
}
private static function getMangaTitles(array $manga): array
{
$mangaTitles = [];
if (!empty($manga['attributes']['title'])) {
$mangaTitles[] = $manga['attributes']['title'];
}
if (!empty($manga['attributes']['altTitles'])) {
$mangaTitles = array_merge($mangaTitles, $manga['attributes']['altTitles']);
}
return $mangaTitles;
}
}