UNPKG

rsshub

Version:
332 lines (284 loc) • 12.2 kB
import { Route } from '@/types'; import cache from '@/utils/cache'; import got from '@/utils/got'; 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?/:type?', categories: ['anime'], example: '/kemono', 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, antiCrawler: false, supportBT: false, supportPodcast: false, supportScihub: false, nsfw: true, }, radar: [ { source: ['kemono.cr/'], target: '/kemono', }, { source: ['kemono.cr/:source/user/:id'], target: '/kemono/:source/:id', }, { source: ['kemono.cr/:source/user/:id/announcements'], target: '/kemono/:source/:id/announcements', }, { source: ['kemono.cr/:source/user/:id/fancards'], target: '/kemono/:source/:id/fancards', }, ], name: 'Posts', maintainers: ['nczitzk', 'AiraNadih'], handler, description: `Sources | Posts | Patreon | Pixiv Fanbox | Gumroad | SubscribeStar | DLsite | Discord | Fantia | | ----- | ------- | ------------ | ------- | ------------- | ------ | ------- | ------ | | posts | patreon | fanbox | gumroad | subscribestar | dlsite | discord | fantia | ::: 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 :::`, }; function parseJsonField(field: any): any { if (typeof field !== 'string') { return field; } try { let parsedData = JSON.parse(field); if (typeof parsedData === 'string') { parsedData = JSON.parse(parsedData); } return parsedData; } catch { return field; } } 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:${post.service}:${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.cr/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'}`); } }