fix(route/kemono): improve JSON field parsing with nested string handling (#19190)
* fix(route/kemono): improve JSON field parsing with nested string handling * feat(route/kemono): add announcements and fancards support (close #16269) * refactor(route/kemono): restructure codebase with constants extraction and type definitions * Update lib/routes/kemono/index.ts --------- Co-authored-by: AiraNadih <AiraNadih@users.noreply.github.com>
This commit is contained in:
parent
7bdedfebdd
commit
ed47f43284
|
|
@ -0,0 +1,9 @@
|
|||
const KEMONO_ROOT_URL = 'https://kemono.su';
|
||||
const KEMONO_API_URL = `${KEMONO_ROOT_URL}/api/v1`;
|
||||
const MIME_TYPE_MAP = {
|
||||
m4a: 'audio/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
} as const;
|
||||
|
||||
export { KEMONO_API_URL, KEMONO_ROOT_URL, MIME_TYPE_MAP };
|
||||
|
|
@ -6,12 +6,18 @@ import { load } from 'cheerio';
|
|||
import { parseDate } from '@/utils/parse-date';
|
||||
import { art } from '@/utils/render';
|
||||
import path from 'node:path';
|
||||
import { KEMONO_API_URL, KEMONO_ROOT_URL, MIME_TYPE_MAP } from './const';
|
||||
import { KemonoPost, KemonoFile, DiscordMessage } from './types';
|
||||
|
||||
export const route: Route = {
|
||||
path: '/:source?/:id?',
|
||||
path: '/:source?/:id?/:type?',
|
||||
categories: ['anime'],
|
||||
example: '/kemono',
|
||||
parameters: { source: 'Source, see below, Posts by default', id: 'User id, can be found in URL' },
|
||||
parameters: {
|
||||
source: 'Source, see below, Posts by default',
|
||||
id: 'User id, can be found in URL',
|
||||
type: 'Content type: announcements or fancards',
|
||||
},
|
||||
features: {
|
||||
requireConfig: false,
|
||||
requirePuppeteer: false,
|
||||
|
|
@ -22,11 +28,24 @@ export const route: Route = {
|
|||
},
|
||||
radar: [
|
||||
{
|
||||
source: ['kemono.su/:source/user/:id', 'kemono.su/'],
|
||||
source: ['kemono.su/'],
|
||||
target: '/kemono',
|
||||
},
|
||||
{
|
||||
source: ['kemono.su/:source/user/:id'],
|
||||
target: '/kemono/:source/:id',
|
||||
},
|
||||
{
|
||||
source: ['kemono.su/:source/user/:id/announcements'],
|
||||
target: '/kemono/:source/:id/announcements',
|
||||
},
|
||||
{
|
||||
source: ['kemono.su/:source/user/:id/fancards'],
|
||||
target: '/kemono/:source/:id/fancards',
|
||||
},
|
||||
],
|
||||
name: 'Posts',
|
||||
maintainers: ['nczitzk'],
|
||||
maintainers: ['nczitzk', 'AiraNadih'],
|
||||
handler,
|
||||
description: `Sources
|
||||
|
||||
|
|
@ -37,153 +56,275 @@ export const route: Route = {
|
|||
::: tip
|
||||
When \`posts\` is selected as the value of the parameter **source**, the parameter **id** does not take effect.
|
||||
There is an optinal parameter **limit** which controls the number of posts to fetch, default value is 25.
|
||||
|
||||
Support for announcements and fancards:
|
||||
- Use \`/:source/:id/announcements\` to get announcements
|
||||
- Use \`/:source/:id/fancards\` to get fancards
|
||||
:::`,
|
||||
};
|
||||
|
||||
async function handler(ctx) {
|
||||
const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 25;
|
||||
const source = ctx.req.param('source') ?? 'posts';
|
||||
const id = ctx.req.param('id');
|
||||
const isPosts = source === 'posts';
|
||||
|
||||
const rootUrl = 'https://kemono.su';
|
||||
const apiUrl = `${rootUrl}/api/v1`;
|
||||
const discordUrl = `${apiUrl}/discord/channel/lookup/${id}`;
|
||||
const currentUrl = isPosts ? `${apiUrl}/posts` : `${apiUrl}/${source}/user/${id}`;
|
||||
|
||||
const headers = {
|
||||
cookie: '__ddg2=sBQ4uaaGecmfEUk7',
|
||||
};
|
||||
|
||||
const response = await got({
|
||||
method: 'get',
|
||||
url: source === 'discord' ? discordUrl : currentUrl,
|
||||
headers,
|
||||
});
|
||||
|
||||
let items, title, image;
|
||||
|
||||
if (source === 'discord') {
|
||||
title = `Posts of ${id} from Discord | Kemono`;
|
||||
|
||||
items = await Promise.all(
|
||||
response.data.map((channel) =>
|
||||
cache.tryGet(channel.id, async () => {
|
||||
const channelResponse = await got({
|
||||
method: 'get',
|
||||
url: `${rootUrl}/api/v1/discord/channel/${channel.id}?o=0`,
|
||||
headers,
|
||||
});
|
||||
|
||||
return channelResponse.data
|
||||
.filter((i) => i.content || i.attachments)
|
||||
.slice(0, limit)
|
||||
.map((i) => ({
|
||||
title: i.content,
|
||||
description: art(path.join(__dirname, 'templates/discord.art'), { i }),
|
||||
author: `${i.author.username}#${i.author.discriminator}`,
|
||||
pubDate: parseDate(i.published),
|
||||
category: channel.name,
|
||||
guid: `kemono:${source}:${i.server}:${i.channel}:${i.id}`,
|
||||
link: `https://discord.com/channels/${i.server}/${i.channel}/${i.id}`,
|
||||
}));
|
||||
})
|
||||
)
|
||||
);
|
||||
items = items.flat();
|
||||
} else {
|
||||
const author = isPosts ? '' : await getAuthor(currentUrl, headers);
|
||||
title = isPosts ? 'Kemono Posts' : `Posts of ${author} from ${source} | Kemono`;
|
||||
image = isPosts ? `${rootUrl}/favicon.ico` : `https://img.kemono.su/icons/${source}/${id}`;
|
||||
const responseData = isPosts ? response.data.posts : response.data;
|
||||
items = responseData
|
||||
.filter((i) => i.content || i.attachments)
|
||||
.slice(0, limit)
|
||||
.map((i) => {
|
||||
i.files = [];
|
||||
if ('path' in i.file) {
|
||||
i.files.push({
|
||||
name: i.file.name,
|
||||
path: i.file.path,
|
||||
extension: i.file.path.replace(/.*\./, '').toLowerCase(),
|
||||
});
|
||||
}
|
||||
for (const attachment of i.attachments) {
|
||||
i.files.push({
|
||||
name: attachment.name,
|
||||
path: attachment.path,
|
||||
extension: attachment.path.replace(/.*\./, '').toLowerCase(),
|
||||
});
|
||||
}
|
||||
const filesHTML = art(path.join(__dirname, 'templates/source.art'), { i });
|
||||
let $ = load(filesHTML);
|
||||
const kemonoFiles = $('img, a, audio, video').map(function () {
|
||||
return $(this).prop('outerHTML')!;
|
||||
});
|
||||
let desc = '';
|
||||
if (i.content) {
|
||||
desc += `<div>${i.content}</div>`;
|
||||
}
|
||||
$ = load(desc);
|
||||
let count = 0;
|
||||
const regex = /downloads.fanbox.cc/;
|
||||
$('a').each(function () {
|
||||
const link = $(this).attr('href');
|
||||
if (regex.test(link!)) {
|
||||
count++;
|
||||
$(this).replaceWith(kemonoFiles[count]);
|
||||
}
|
||||
});
|
||||
desc = (kemonoFiles.length > 0 ? kemonoFiles[0] : '') + $.html();
|
||||
for (const kemonoFile of kemonoFiles.slice(count + 1)) {
|
||||
desc += kemonoFile;
|
||||
}
|
||||
|
||||
let enclosureInfo = {};
|
||||
load(desc)('audio source, video source').each(function () {
|
||||
const src = $(this).attr('src') ?? '';
|
||||
const mimeType =
|
||||
{
|
||||
m4a: 'audio/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
}[src.replace(/.*\./, '').toLowerCase()] || null;
|
||||
|
||||
if (mimeType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
enclosureInfo = {
|
||||
enclosure_url: new URL(src, rootUrl).toString(),
|
||||
enclosure_type: mimeType,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: i.title,
|
||||
description: desc,
|
||||
author,
|
||||
pubDate: parseDate(i.published),
|
||||
guid: `${apiUrl}/${i.service}/user/${i.user}/post/${i.id}`,
|
||||
link: `${rootUrl}/${i.service}/user/${i.user}/post/${i.id}`,
|
||||
...enclosureInfo,
|
||||
};
|
||||
});
|
||||
function parseJsonField(field: any): any {
|
||||
if (typeof field !== 'string') {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
image,
|
||||
link: isPosts ? `${rootUrl}/posts` : (source === 'discord' ? `${rootUrl}/${source}/server/${id}` : `${rootUrl}/${source}/user/${id}`),
|
||||
item: items,
|
||||
};
|
||||
try {
|
||||
let parsedData = JSON.parse(field);
|
||||
if (typeof parsedData === 'string') {
|
||||
parsedData = JSON.parse(parsedData);
|
||||
}
|
||||
return parsedData;
|
||||
} catch {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthor(currentUrl, headers) {
|
||||
const profileResponse = await got({
|
||||
method: 'get',
|
||||
url: `${currentUrl}/profile`,
|
||||
headers,
|
||||
});
|
||||
return profileResponse.data.name;
|
||||
function buildApiUrl(source: string, userId?: string, contentType?: string): string {
|
||||
if (source === 'posts') {
|
||||
return `${KEMONO_API_URL}/posts`;
|
||||
}
|
||||
|
||||
if (source === 'discord' && userId) {
|
||||
return `${KEMONO_API_URL}/discord/channel/lookup/${userId}`;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required for non-posts sources');
|
||||
}
|
||||
|
||||
const basePath = `${KEMONO_API_URL}/${source}/user/${userId}`;
|
||||
return contentType ? `${basePath}/${contentType}` : basePath;
|
||||
}
|
||||
|
||||
function buildFrontendUrl(source: string, userId?: string, contentType?: string): string {
|
||||
if (source === 'posts') {
|
||||
return `${KEMONO_ROOT_URL}/posts`;
|
||||
}
|
||||
|
||||
if (source === 'discord' && userId) {
|
||||
return `${KEMONO_ROOT_URL}/${source}/server/${userId}`;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required for non-posts sources');
|
||||
}
|
||||
|
||||
const basePath = `${KEMONO_ROOT_URL}/${source}/user/${userId}`;
|
||||
return contentType ? `${basePath}/${contentType}` : basePath;
|
||||
}
|
||||
|
||||
async function fetchUserProfile(source: string, userId: string): Promise<string> {
|
||||
try {
|
||||
const profileUrl = `${KEMONO_API_URL}/${source}/user/${userId}/profile`;
|
||||
const response = await got({ method: 'get', url: profileUrl });
|
||||
return response.data.name || 'Unknown User';
|
||||
} catch {
|
||||
return 'Unknown User';
|
||||
}
|
||||
}
|
||||
|
||||
function processPostFiles(post: KemonoPost): KemonoFile[] {
|
||||
const files: KemonoFile[] = [];
|
||||
|
||||
if (post.file) {
|
||||
const parsedFile = parseJsonField(post.file);
|
||||
if (parsedFile && typeof parsedFile === 'object' && 'path' in parsedFile) {
|
||||
files.push({
|
||||
name: parsedFile.name || 'Unnamed File',
|
||||
path: parsedFile.path,
|
||||
extension: extractFileExtension(parsedFile.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(post.attachments)) {
|
||||
for (const attachment of post.attachments) {
|
||||
const parsedAttachment = parseJsonField(attachment);
|
||||
if (parsedAttachment && typeof parsedAttachment === 'object' && 'path' in parsedAttachment) {
|
||||
files.push({
|
||||
name: parsedAttachment.name || 'Unnamed Attachment',
|
||||
path: parsedAttachment.path,
|
||||
extension: extractFileExtension(parsedAttachment.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractFileExtension(filePath: string): string {
|
||||
return filePath.replace(/.*\./, '').toLowerCase();
|
||||
}
|
||||
|
||||
function generateEnclosureInfo(htmlContent: string): { enclosure_url?: string; enclosure_type?: string } {
|
||||
const $ = load(htmlContent);
|
||||
let enclosureInfo = {};
|
||||
|
||||
$('audio source, video source').each(function () {
|
||||
const src = $(this).attr('src');
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = extractFileExtension(src);
|
||||
const mimeType = MIME_TYPE_MAP[extension as keyof typeof MIME_TYPE_MAP];
|
||||
|
||||
if (mimeType) {
|
||||
enclosureInfo = {
|
||||
enclosure_url: new URL(src, KEMONO_ROOT_URL).toString(),
|
||||
enclosure_type: mimeType,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return enclosureInfo;
|
||||
}
|
||||
|
||||
async function processDiscordMessages(channels: any[], limit: number) {
|
||||
const items = await Promise.all(
|
||||
channels.map((channel) =>
|
||||
cache.tryGet(`discord_${channel.id}`, async () => {
|
||||
const channelResponse = await got({
|
||||
method: 'get',
|
||||
url: `${KEMONO_ROOT_URL}/api/v1/discord/channel/${channel.id}?o=0`,
|
||||
});
|
||||
|
||||
return channelResponse.data
|
||||
.filter((message: DiscordMessage) => message.content || message.attachments)
|
||||
.slice(0, limit)
|
||||
.map((message: DiscordMessage) => ({
|
||||
title: message.content || 'Discord Message',
|
||||
description: art(path.join(__dirname, 'templates/discord.art'), { i: message }),
|
||||
author: `${message.author.username}#${message.author.discriminator}`,
|
||||
pubDate: parseDate(message.published),
|
||||
category: channel.name,
|
||||
guid: `kemono:discord:${message.server}:${message.channel}:${message.id}`,
|
||||
link: `https://discord.com/channels/${message.server}/${message.channel}/${message.id}`,
|
||||
}));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return items.flat();
|
||||
}
|
||||
|
||||
function processAnnouncements(announcements: any[], authorName: string, source: string, userId: string, limit: number) {
|
||||
return announcements.slice(0, limit).map((announcement) => ({
|
||||
title: `Announcement from ${announcement.published ? parseDate(announcement.published).toDateString() : 'Unknown Date'}`,
|
||||
description: `<div>${announcement.content || ''}</div>`,
|
||||
author: authorName,
|
||||
pubDate: parseDate(announcement.published),
|
||||
guid: `kemono:${source}:${userId}:announcement:${announcement.hash}`,
|
||||
link: `${KEMONO_ROOT_URL}/${source}/user/${userId}/announcements`,
|
||||
}));
|
||||
}
|
||||
|
||||
function processFancards(fancards: any[], authorName: string, source: string, userId: string, limit: number) {
|
||||
return fancards.slice(0, limit).map((fancard) => {
|
||||
const imageUrl = `${fancard.server}${fancard.path}`;
|
||||
|
||||
return {
|
||||
title: `Fancard ${fancard.id}`,
|
||||
description: `<img src="${imageUrl}" alt="Fancard ${fancard.id}" />`,
|
||||
author: authorName,
|
||||
pubDate: parseDate(fancard.added),
|
||||
guid: `kemono:${source}:${userId}:fancard:${fancard.id}`,
|
||||
link: imageUrl,
|
||||
enclosure_url: imageUrl,
|
||||
enclosure_type: fancard.mime,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function processPosts(posts: KemonoPost[], authorName: string, limit: number) {
|
||||
return posts
|
||||
.filter((post) => post.content || post.attachments)
|
||||
.slice(0, limit)
|
||||
.map((post) => {
|
||||
const files = processPostFiles(post);
|
||||
const postWithFiles = { ...post, files };
|
||||
|
||||
const filesHtml = art(path.join(__dirname, 'templates/source.art'), { i: postWithFiles });
|
||||
let description = post.content ? `<div>${post.content}</div>` : '';
|
||||
|
||||
const $ = load(description);
|
||||
const kemonoFileElements = load(filesHtml)('img, a, audio, video')
|
||||
.toArray()
|
||||
.map((el) => $(el).prop('outerHTML')!);
|
||||
|
||||
let replacementCount = 0;
|
||||
const fanboxRegex = /downloads\.fanbox\.cc/;
|
||||
$('a').each(function () {
|
||||
const link = $(this).attr('href');
|
||||
if (link && fanboxRegex.test(link)) {
|
||||
$(this).replaceWith(kemonoFileElements[replacementCount] || '');
|
||||
replacementCount++;
|
||||
}
|
||||
});
|
||||
|
||||
description = (kemonoFileElements[0] || '') + $.html();
|
||||
for (const fileElement of kemonoFileElements.slice(replacementCount + 1)) {
|
||||
description += fileElement;
|
||||
}
|
||||
|
||||
return {
|
||||
title: post.title || 'Untitled Post',
|
||||
description,
|
||||
author: authorName,
|
||||
pubDate: parseDate(post.published),
|
||||
guid: `${KEMONO_API_URL}/${post.service}/user/${post.user}/post/${post.id}`,
|
||||
link: `${KEMONO_ROOT_URL}/${post.service}/user/${post.user}/post/${post.id}`,
|
||||
...generateEnclosureInfo(description),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handler(ctx) {
|
||||
const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : 25;
|
||||
const source = ctx.req.param('source') || 'posts';
|
||||
const userId = ctx.req.param('id');
|
||||
const contentType = ctx.req.param('type');
|
||||
|
||||
const isPostsMode = source === 'posts';
|
||||
const isDiscordMode = source === 'discord';
|
||||
|
||||
try {
|
||||
const apiUrl = buildApiUrl(source, userId, contentType);
|
||||
const frontendUrl = buildFrontendUrl(source, userId, contentType);
|
||||
|
||||
const response = await got({ method: 'get', url: apiUrl });
|
||||
|
||||
const authorName = isPostsMode || isDiscordMode || !userId ? '' : await fetchUserProfile(source, userId);
|
||||
|
||||
const iconUrl = isPostsMode || isDiscordMode ? `${KEMONO_ROOT_URL}/favicon.ico` : `https://img.kemono.su/icons/${source}/${userId}`;
|
||||
|
||||
let items: any[];
|
||||
let title: string;
|
||||
|
||||
if (isDiscordMode) {
|
||||
title = `Posts of ${userId} from Discord | Kemono`;
|
||||
items = await processDiscordMessages(response.data, limit);
|
||||
} else if (contentType === 'announcements') {
|
||||
title = `Announcements of ${authorName} from ${source} | Kemono`;
|
||||
items = processAnnouncements(response.data, authorName, source, userId, limit);
|
||||
} else if (contentType === 'fancards') {
|
||||
title = `Fancards of ${authorName} from ${source} | Kemono`;
|
||||
items = processFancards(response.data, authorName, source, userId, limit);
|
||||
} else {
|
||||
title = isPostsMode ? 'Kemono Posts' : `Posts of ${authorName} from ${source} | Kemono`;
|
||||
const posts = isPostsMode ? response.data.posts : response.data;
|
||||
items = processPosts(posts, authorName, limit);
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
image: iconUrl,
|
||||
link: frontendUrl,
|
||||
item: items,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch data from Kemono: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
interface KemonoPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
published: string;
|
||||
service: string;
|
||||
user: string;
|
||||
file?: any;
|
||||
attachments?: any[];
|
||||
}
|
||||
|
||||
interface KemonoFile {
|
||||
name: string;
|
||||
path: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
interface DiscordMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
published: string;
|
||||
author: {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
};
|
||||
server: string;
|
||||
channel: string;
|
||||
attachments?: any[];
|
||||
}
|
||||
|
||||
export type { KemonoPost, KemonoFile, DiscordMessage };
|
||||
Loading…
Reference in New Issue