UNPKG

rsshub

Version:
282 lines (276 loc) • 10.2 kB
import { n as init_esm_shims, t as __dirname } from "./esm-shims-CzJ_djXG.mjs"; import "./config-C37vj7VH.mjs"; import "./dist-BInvbO1W.mjs"; import "./logger-Czu8UMNd.mjs"; import "./ofetch-BIyrKU3Y.mjs"; import { t as parseDate } from "./parse-date-BrP7mxXf.mjs"; import { t as cache_default } from "./cache-Bo__VnGm.mjs"; import "./helpers-DxBp0Pty.mjs"; import { t as art } from "./render-BQo6B4tL.mjs"; import { t as got_default } from "./got-KxxWdaxq.mjs"; import path from "node:path"; import { load } from "cheerio"; //#region lib/routes/kemono/const.ts init_esm_shims(); const KEMONO_ROOT_URL = "https://kemono.cr"; const KEMONO_API_URL = `${KEMONO_ROOT_URL}/api/v1`; const MIME_TYPE_MAP = { m4a: "audio/mp4", mp3: "audio/mpeg", mp4: "video/mp4" }; //#endregion //#region lib/routes/kemono/index.ts const headers = { Accept: "text/css" }; const 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: "" }, { source: ["kemono.cr/:source/user/:id"], target: "/:source/:id" }, { source: ["kemono.cr/:source/user/:id/announcements"], target: "/:source/:id/announcements" }, { source: ["kemono.cr/:source/user/:id/fancards"], target: "/:source/:id/fancards" }, { source: ["kemono.cr/discord/server/:id"], target: "/discord/:id" } ], 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) { 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, userId, contentType) { 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}/posts`; } function buildFrontendUrl(source, userId, contentType) { 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, userId) { try { return (await got_default({ method: "get", url: `${KEMONO_API_URL}/${source}/user/${userId}/profile`, headers })).data.name || "Unknown User"; } catch { return "Unknown User"; } } function processPostFiles(post) { const files = []; 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) { return filePath.replace(/.*\./, "").toLowerCase(); } function generateEnclosureInfo(htmlContent) { const $ = load(htmlContent); let enclosureInfo = {}; $("audio source, video source").each(function() { const src = $(this).attr("src"); if (!src) return; const mimeType = MIME_TYPE_MAP[extractFileExtension(src)]; if (mimeType) { enclosureInfo = { enclosure_url: new URL(src, KEMONO_ROOT_URL).toString(), enclosure_type: mimeType }; return false; } }); return enclosureInfo; } async function processDiscordMessages(channels, limit) { return (await Promise.all(channels.map((channel) => cache_default.tryGet(`discord_${channel.id}`, async () => { return (await got_default({ method: "get", url: `${KEMONO_ROOT_URL}/api/v1/discord/channel/${channel.id}?o=0`, headers })).data.filter((message) => message.content || message.attachments).toSorted((a, b) => b.id.localeCompare(a.id)).slice(0, limit).map((message) => ({ title: message.content || "Discord Message", description: art(path.join(__dirname, "templates/discord-b75b214c.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}` })); })))).flat(); } function processAnnouncements(announcements, authorName, source, userId, limit) { return announcements.slice(0, limit).map((announcement) => { const displayDate = parseDate(announcement.published || announcement.added); return { title: `Announcement from ${displayDate.toDateString()}`, description: `<div>${announcement.content || ""}</div>`, author: authorName, pubDate: displayDate, guid: `kemono:${source}:${userId}:announcement:${announcement.hash}`, link: `${KEMONO_ROOT_URL}/${source}/user/${userId}/announcements` }; }); } function processFancards(fancards, authorName, source, userId, limit) { 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, authorName, limit) { 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-7bbf197e.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_default({ method: "get", url: apiUrl, headers }); 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; let title; 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`; items = processPosts(isPostsMode ? response.data.posts : response.data, 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"}`); } } //#endregion export { route };