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:
AiraNadih 2025-05-28 02:26:32 +08:00 committed by GitHub
parent 7bdedfebdd
commit ed47f43284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 327 additions and 146 deletions

View File

@ -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 };

View File

@ -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'}`);
}
}

View File

@ -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 };