diff --git a/lib/routes/pixiv/api/get-novels-nsfw.ts b/lib/routes/pixiv/api/get-novels-nsfw.ts deleted file mode 100644 index bc6f125707e8f6..00000000000000 --- a/lib/routes/pixiv/api/get-novels-nsfw.ts +++ /dev/null @@ -1,247 +0,0 @@ -import got from '../pixiv-got'; -import { maskHeader } from '../constants'; -import queryString from 'query-string'; -import { config } from '@/config'; -import { JSDOM, VirtualConsole } from 'jsdom'; - -import pixivUtils from '../utils'; -import ConfigNotFoundError from '@/errors/types/config-not-found'; -import cache from '@/utils/cache'; -import { parseDate } from 'tough-cookie'; -import { getToken } from '../token'; - -interface nsfwNovelWork { - id: string; - title: string; - caption: string; - restrict: number; - x_restrict: number; - is_original: boolean; - image_urls: { - square_medium: string; - medium: string; - large: string; - }; - create_date: string; - tags: Array<{ - name: string; - translated_name: string | null; - added_by_uploaded_user: boolean; - }>; - page_count: number; - text_length: number; - user: { - id: number; - name: string; - account: string; - profile_image_urls: { - medium: string; - }; - is_followed: boolean; - is_access_blocking_user: boolean; - }; - series?: { - id?: number; - title?: string; - }; - total_bookmarks: number; - total_view: number; - total_comments: number; -} - -interface nsfwNovelsResponse { - data: { - user: { - id: number; - name: string; - account: string; - profile_image_urls: { - medium: string; - }; - is_followed: boolean; - is_access_blocking_user: boolean; - }; - novels: nsfwNovelWork[]; - }; -} - -interface nsfwNovelDetail { - id: string; - title: string; - seriesId: string | null; - seriesTitle: string | null; - seriesIsWatched: boolean | null; - userId: string; - coverUrl: string; - tags: string[]; - caption: string; - cdate: string; - rating: { - like: number; - bookmark: number; - view: number; - }; - text: string; - marker: null; - illusts: string[]; - images: { - [key: string]: { - novelImageId: string; - sl: string; - urls: { - '240mw': string; - '480mw': string; - '1200x1200': string; - '128x128': string; - original: string; - }; - }; - }; - seriesNavigation: { - nextNovel: null; - prevNovel: { - id: number; - viewable: boolean; - contentOrder: string; - title: string; - coverUrl: string; - viewableMessage: null; - } | null; - } | null; - glossaryItems: string[]; - replaceableItemIds: string[]; - aiType: number; - isOriginal: boolean; -} - -function getNovels(user_id: string, token: string): Promise { - return got('https://app-api.pixiv.net/v1/user/novels', { - headers: { - ...maskHeader, - Authorization: 'Bearer ' + token, - }, - searchParams: queryString.stringify({ - user_id, - filter: 'for_ios', - }), - }); -} - -async function getNovelFullContent(novel_id: string, token: string): Promise { - return (await cache.tryGet(`https://app-api.pixiv.net/webview/v2/novel:${novel_id}`, async () => { - // https://github.com/mikf/gallery-dl/blob/main/gallery_dl/extractor/pixiv.py - // https://github.com/mikf/gallery-dl/commit/db507e30c7431d4ed7e23c153a044ce1751c2847 - const response = await got('https://app-api.pixiv.net/webview/v2/novel', { - headers: { - ...maskHeader, - Authorization: 'Bearer ' + token, - }, - searchParams: queryString.stringify({ - id: novel_id, - viewer_version: '20221031_ai', - }), - }); - - const virtualConsole = new VirtualConsole().on('error', () => void 0); - - const { window } = new JSDOM(response.data, { - runScripts: 'dangerously', - virtualConsole, - }); - - const novelDetail = window.pixiv?.novel as nsfwNovelDetail; - - window.close(); - - if (!novelDetail) { - throw new Error('No novel data found'); - } - - return novelDetail; - })) as nsfwNovelDetail; -} - -function convertPixivProtocolExtended(caption: string): string { - const protocolMap = new Map([ - [/pixiv:\/\/novels\/(\d+)/g, 'https://www.pixiv.net/novel/show.php?id=$1'], - [/pixiv:\/\/illusts\/(\d+)/g, 'https://www.pixiv.net/artworks/$1'], - [/pixiv:\/\/users\/(\d+)/g, 'https://www.pixiv.net/users/$1'], - [/pixiv:\/\/novel\/series\/(\d+)/g, 'https://www.pixiv.net/novel/series/$1'], - ]); - - let convertedText = caption; - - for (const [pattern, replacement] of protocolMap) { - convertedText = convertedText.replace(pattern, replacement); - } - - return convertedText; -} - -export async function getR18Novels(id: string, fullContent: boolean, limit: number = 100) { - if (!config.pixiv || !config.pixiv.refreshToken) { - throw new ConfigNotFoundError( - '該用戶爲 R18 創作者,需要 PIXIV_REFRESHTOKEN。This user is an R18 creator, PIXIV_REFRESHTOKEN is required - pixiv RSS is disabled due to the lack of relevant config' - ); - } - - const token = await getToken(cache.tryGet); - if (!token) { - throw new ConfigNotFoundError('pixiv not login'); - } - - const response = await getNovels(id, token); - const novels = limit ? response.data.novels.slice(0, limit) : response.data.novels; - const username = novels[0].user.name; - - const items = await Promise.all( - novels.map(async (novel) => { - const baseItem = { - title: novel.series?.title ? `${novel.series.title} - ${novel.title}` : novel.title, - description: ` - -

${convertPixivProtocolExtended(novel.caption) || ''}

-

- 字數:${novel.text_length}
- 閱覽數:${novel.total_view}
- 收藏數:${novel.total_bookmarks}
- 評論數:${novel.total_comments}
-

`, - author: novel.user.name, - pubDate: parseDate(novel.create_date), - link: `https://www.pixiv.net/novel/show.php?id=${novel.id}`, - category: novel.tags.map((t) => t.name), - }; - - if (!fullContent) { - return baseItem; - } - - try { - const novelDetail = await getNovelFullContent(novel.id, token); - const images = Object.fromEntries( - Object.entries(novelDetail.images) - .filter(([, image]) => image?.urls?.original) - .map(([id, image]) => [id, image.urls.original.replace('https://i.pximg.net', config.pixiv.imgProxy || '')]) - ); - - const content = await pixivUtils.parseNovelContent(novelDetail.text, images, token); - - return { - ...baseItem, - description: `${baseItem.description}
${content}`, - }; - } catch { - return baseItem; - } - }) - ); - - return { - title: `${username}'s novels - pixiv`, - description: `${username} 的 pixiv 最新小说`, - image: pixivUtils.getProxiedImageUrl(novels[0].user.profile_image_urls.medium), - link: `https://www.pixiv.net/users/${id}/novels`, - item: items, - }; -} diff --git a/lib/routes/pixiv/api/get-novels-sfw.ts b/lib/routes/pixiv/api/get-novels-sfw.ts deleted file mode 100644 index 7bb74c8970a02e..00000000000000 --- a/lib/routes/pixiv/api/get-novels-sfw.ts +++ /dev/null @@ -1,201 +0,0 @@ -import got from '@/utils/got'; -import cache from '@/utils/cache'; -import pixivUtils from '../utils'; -import { parseDate } from '@/utils/parse-date'; - -const baseUrl = 'https://www.pixiv.net'; -interface sfwNovelWork { - id: string; - title: string; - genre: string; - xRestrict: number; - restrict: number; - url: string; - tags: string[]; - userId: string; - userName: string; - profileImageUrl: string; - textCount: number; - wordCount: number; - readingTime: number; - useWordCount: boolean; - description: string; - isBookmarkable: boolean; - bookmarkData: null; - bookmarkCount: number; - isOriginal: boolean; - marker: null; - titleCaptionTranslation: { - workTitle: null; - workCaption: null; - }; - createDate: string; - updateDate: string; - isMasked: boolean; - aiType: number; - seriesId: string; - seriesTitle: string; - isUnlisted: boolean; -} - -interface sfwNovelsResponse { - data: { - error: boolean; - message: string; - body: { - works: Record; - extraData: { - meta: { - title: string; - description: string; - canonical: string; - ogp: { - description: string; - image: string; - title: string; - type: string; - }; - twitter: { - description: string; - image: string; - title: string; - card: string; - }; - alternateLanguages: { - ja: string; - en: string; - }; - descriptionHeader: string; - }; - }; - }; - }; -} - -interface sfwNovelDetail { - body: { - content: string; - textEmbeddedImages: Record< - string, - { - novelImageId: string; - sl: string; - urls: { - original: string; - '1200x1200': string; - '480mw': string; - '240mw': string; - '128x128': string; - }; - } - >; - }; -} - -async function getNovelFullContent(novel_id: string): Promise<{ content: string; images: Record }> { - const url = `${baseUrl}/ajax/novel/${novel_id}`; - return (await cache.tryGet(url, async () => { - const response = await got(url, { - headers: { - referer: `${baseUrl}/novel/show.php?id=${novel_id}`, - }, - }); - - const novelDetail = response.data as sfwNovelDetail; - - if (!novelDetail) { - throw new Error('No novel data found'); - } - - const images: Record = {}; - - if (novelDetail.body.textEmbeddedImages) { - for (const [id, image] of Object.entries(novelDetail.body.textEmbeddedImages)) { - images[id] = pixivUtils.getProxiedImageUrl(image.urls.original); - } - } - - return { - content: novelDetail.body.content, - images, - }; - })) as { content: string; images: Record }; -} - -export async function getNonR18Novels(id: string, fullContent: boolean, limit: number = 100) { - const url = `${baseUrl}/users/${id}/novels`; - const { data: allData } = await got(`${baseUrl}/ajax/user/${id}/profile/all`, { - headers: { - referer: url, - }, - }); - - const novels = Object.keys(allData.body.novels) - .sort((a, b) => Number(b) - Number(a)) - .slice(0, Number.parseInt(String(limit), 10)); - - if (novels.length === 0) { - throw new Error('No novels found, fallback to R18 API'); - // Throw error early to avoid unnecessary API requests - // Since hasPixivAuth() check failed earlier and R18 API requires authentication, this will result in ConfigNotFoundError - } - - const searchParams = new URLSearchParams(); - for (const novel of novels) { - searchParams.append('ids[]', novel); - } - - const { data } = (await got(`${baseUrl}/ajax/user/${id}/profile/novels`, { - headers: { - referer: url, - }, - searchParams, - })) as sfwNovelsResponse; - - const items = await Promise.all( - Object.values(data.body.works).map(async (item) => { - const baseItem = { - title: item.title, - description: ` - -

${item.description}

-

- 字數:${item.textCount}
- 閱讀時間:${item.readingTime} 分鐘
- 收藏數:${item.bookmarkCount}
-

- `, - link: `${baseUrl}/novel/show.php?id=${item.id}`, - author: item.userName, - pubDate: parseDate(item.createDate), - updated: parseDate(item.updateDate), - category: item.tags, - }; - - if (!fullContent) { - return baseItem; - } - - try { - const { content: initialContent, images } = await getNovelFullContent(item.id); - - const content = await pixivUtils.parseNovelContent(initialContent, images); - - return { - ...baseItem, - description: `${baseItem.description}
${content}`, - }; - } catch { - return baseItem; - } - }) - ); - - return { - title: data.body.extraData.meta.title, - description: data.body.extraData.meta.ogp.description, - image: pixivUtils.getProxiedImageUrl(Object.values(data.body.works)[0].profileImageUrl), - link: url, - item: items, - }; -} diff --git a/lib/routes/pixiv/novel-api/content/nsfw.ts b/lib/routes/pixiv/novel-api/content/nsfw.ts new file mode 100644 index 00000000000000..73d3eb0287da49 --- /dev/null +++ b/lib/routes/pixiv/novel-api/content/nsfw.ts @@ -0,0 +1,73 @@ +import { JSDOM, VirtualConsole } from 'jsdom'; +import cache from '@/utils/cache'; +import got from '../../pixiv-got'; +import { maskHeader } from '../../constants'; +import queryString from 'query-string'; +import { parseNovelContent } from './utils'; +import type { NovelContent, NSFWNovelDetail } from './types'; +import { parseDate } from '@/utils/parse-date'; + +export async function getNSFWNovelContent(novelId: string, token: string): Promise { + return (await cache.tryGet(`https://app-api.pixiv.net/webview/v2/novel:${novelId}`, async () => { + const response = await got('https://app-api.pixiv.net/webview/v2/novel', { + headers: { + ...maskHeader, + Authorization: 'Bearer ' + token, + }, + searchParams: queryString.stringify({ + id: novelId, + viewer_version: '20221031_ai', + }), + }); + + const virtualConsole = new VirtualConsole().on('error', () => void 0); + + const { window } = new JSDOM(response.data, { + runScripts: 'dangerously', + virtualConsole, + }); + + const novelDetail = window.pixiv?.novel as NSFWNovelDetail; + + window.close(); + + if (!novelDetail) { + throw new Error('No novel data found'); + } + + const images = Object.fromEntries( + Object.entries(novelDetail.images) + .filter(([, image]) => image?.urls?.original) + .map(([id, image]) => [id, image.urls.original]) + ); + + const parsedContent = await parseNovelContent(novelDetail.text, images, token); + + return { + id: novelDetail.id, + title: novelDetail.title, + description: novelDetail.caption, + content: parsedContent, + + userId: novelDetail.userId, + userName: null, // NSFW API 不提供此資訊 + + bookmarkCount: novelDetail.rating.bookmark, + viewCount: novelDetail.rating.view, + likeCount: novelDetail.rating.like, + + createDate: parseDate(novelDetail.cdate), + updateDate: null, // NSFW API 不提供此資訊 + + isOriginal: novelDetail.isOriginal, + aiType: novelDetail.aiType, + tags: novelDetail.tags, + + coverUrl: novelDetail.coverUrl, + images, + + seriesId: novelDetail.seriesId || null, + seriesTitle: novelDetail.seriesTitle || null, + }; + })) as NovelContent; +} diff --git a/lib/routes/pixiv/novel-api/content/sfw.ts b/lib/routes/pixiv/novel-api/content/sfw.ts new file mode 100644 index 00000000000000..c58d2489d9a4e7 --- /dev/null +++ b/lib/routes/pixiv/novel-api/content/sfw.ts @@ -0,0 +1,63 @@ +import got from '@/utils/got'; +import cache from '@/utils/cache'; +import pixivUtils from '../../utils'; +import { parseNovelContent } from './utils'; +import { NovelContent, SFWNovelDetail } from './types'; +import { parseDate } from '@/utils/parse-date'; + +const baseUrl = 'https://www.pixiv.net'; + +export async function getSFWNovelContent(novelId: string): Promise { + const url = `${baseUrl}/ajax/novel/${novelId}`; + return (await cache.tryGet(url, async () => { + const response = await got(url, { + headers: { + referer: `${baseUrl}/novel/show.php?id=${novelId}`, + }, + }); + + const novelDetail = response.data as SFWNovelDetail; + + if (!novelDetail) { + throw new Error('No novel data found'); + } + + const body = novelDetail.body; + const images: Record = {}; + + if (novelDetail.body.textEmbeddedImages) { + for (const [id, image] of Object.entries(novelDetail.body.textEmbeddedImages)) { + images[id] = pixivUtils.getProxiedImageUrl(image.urls.original); + } + } + + const parsedContent = await parseNovelContent(novelDetail.body.content, images); + + return { + id: body.id, + title: body.title, + description: body.description, + content: parsedContent, + + userId: body.userId, + userName: body.userName, + + bookmarkCount: body.bookmarkCount, + viewCount: body.viewCount, + likeCount: body.likeCount, + + createDate: parseDate(body.createDate), + updateDate: parseDate(body.uploadDate), + + isOriginal: body.isOriginal, + aiType: body.aiType, + tags: body.tags.tags.map((tag) => tag.tag), + + coverUrl: body.coverUrl, + images, + + seriesId: body.seriesNavData?.seriesId?.toString() || null, + seriesTitle: body.seriesNavData?.title || null, + }; + })) as NovelContent; +} diff --git a/lib/routes/pixiv/novel-api/content/types.ts b/lib/routes/pixiv/novel-api/content/types.ts new file mode 100644 index 00000000000000..752878c73a40a3 --- /dev/null +++ b/lib/routes/pixiv/novel-api/content/types.ts @@ -0,0 +1,254 @@ +export interface NovelContent { + id: string; + title: string; + description: string; + content: string; + + userId: string; + userName: string | null; + + bookmarkCount: number; + viewCount: number; + likeCount: number; + + createDate: Date; + updateDate: Date | null; + + tags: string[]; + + coverUrl: string; + images: Record; + + seriesId: string | null; + seriesTitle: string | null; +} + +export interface SFWNovelDetail { + error: boolean; + message: string; + body: { + bookmarkCount: number; + commentCount: number; + markerCount: number; + createDate: string; + uploadDate: string; + description: string; + id: string; + title: string; + likeCount: number; + pageCount: number; + userId: string; + userName: string; + viewCount: number; + isOriginal: boolean; + isBungei: boolean; + xRestrict: number; + restrict: number; + content: string; + coverUrl: string; + suggestedSettings: { + viewMode: number; + themeBackground: number; + themeSize: null; + themeSpacing: null; + }; + isBookmarkable: boolean; + bookmarkData: null; + likeData: boolean; + pollData: null; + marker: null; + tags: { + authorId: string; + isLocked: boolean; + tags: Array<{ + tag: string; + locked: boolean; + deletable: boolean; + userId: string; + userName: string; + }>; + writable: boolean; + }; + seriesNavData: { + seriesType: string; + seriesId: number; + title: string; + isConcluded: boolean; + isReplaceable: boolean; + isWatched: boolean; + isNotifying: boolean; + order: number; + next: { + title: string; + order: number; + id: string; + available: boolean; + } | null; + prev: null; + } | null; + descriptionBoothId: null; + descriptionYoutubeId: null; + comicPromotion: null; + fanboxPromotion: null; + contestBanners: any[]; + contestData: null; + request: null; + imageResponseOutData: any[]; + imageResponseData: any[]; + imageResponseCount: number; + userNovels: { + [key: string]: { + id: string; + title: string; + genre: string; + xRestrict: number; + restrict: number; + url: string; + tags: string[]; + userId: string; + userName: string; + profileImageUrl: string; + textCount: number; + wordCount: number; + readingTime: number; + useWordCount: boolean; + description: string; + isBookmarkable: boolean; + bookmarkData: null; + bookmarkCount: number | null; + isOriginal: boolean; + marker: null; + titleCaptionTranslation: { + workTitle: null; + workCaption: null; + }; + createDate: string; + updateDate: string; + isMasked: boolean; + aiType: number; + seriesId?: string; + seriesTitle?: string; + isUnlisted: boolean; + } | null; + }; + hasGlossary: boolean; + zoneConfig: { + [key: string]: { + url: string; + }; + }; + extraData: { + meta: { + title: string; + description: string; + canonical: string; + descriptionHeader: string; + ogp: { + description: string; + image: string; + title: string; + type: string; + }; + twitter: { + description: string; + image: string; + title: string; + card: string; + }; + }; + }; + titleCaptionTranslation: { + workTitle: null; + workCaption: null; + }; + isUnlisted: boolean; + language: string; + textEmbeddedImages: { + [key: string]: { + novelImageId: string; + sl: string; + urls: { + '240mw': string; + '480mw': string; + '1200x1200': string; + '128x128': string; + original: string; + }; + }; + }; + commentOff: number; + characterCount: number; + wordCount: number; + useWordCount: boolean; + readingTime: number; + genre: string; + aiType: number; + noLoginData: { + breadcrumbs: { + successor: any[]; + current: { + ja: string; + }; + }; + zengoWorkData: { + nextWork: { + id: string; + title: string; + } | null; + prevWork: { + id: string; + title: string; + } | null; + }; + }; + }; +} + +export interface NSFWNovelDetail { + id: string; + title: string; + seriesId: string | null; + seriesTitle: string | null; + seriesIsWatched: boolean | null; + userId: string; + coverUrl: string; + tags: string[]; + caption: string; + cdate: string; + rating: { + like: number; + bookmark: number; + view: number; + }; + text: string; + marker: null; + illusts: string[]; + images: { + [key: string]: { + novelImageId: string; + sl: string; + urls: { + '240mw': string; + '480mw': string; + '1200x1200': string; + '128x128': string; + original: string; + }; + }; + }; + seriesNavigation: { + nextNovel: null; + prevNovel: { + id: number; + viewable: boolean; + contentOrder: string; + title: string; + coverUrl: string; + viewableMessage: null; + } | null; + } | null; + glossaryItems: string[]; + replaceableItemIds: string[]; + aiType: number; + isOriginal: boolean; +} diff --git a/lib/routes/pixiv/novel-api/content/utils.ts b/lib/routes/pixiv/novel-api/content/utils.ts new file mode 100644 index 00000000000000..8808cf6882d0f0 --- /dev/null +++ b/lib/routes/pixiv/novel-api/content/utils.ts @@ -0,0 +1,133 @@ +import { load } from 'cheerio'; +import getIllustDetail from '../../api/get-illust-detail'; +import pixivUtils from '../../utils'; + +export function convertPixivProtocolExtended(caption: string): string { + const protocolMap = new Map([ + [/pixiv:\/\/novels\/(\d+)/g, 'https://www.pixiv.net/novel/show.php?id=$1'], + [/pixiv:\/\/illusts\/(\d+)/g, 'https://www.pixiv.net/artworks/$1'], + [/pixiv:\/\/users\/(\d+)/g, 'https://www.pixiv.net/users/$1'], + [/pixiv:\/\/novel\/series\/(\d+)/g, 'https://www.pixiv.net/novel/series/$1'], + ]); + + let convertedText = caption; + for (const [pattern, replacement] of protocolMap) { + convertedText = convertedText.replace(pattern, replacement); + } + return convertedText; +} + +// docs: https://www.pixiv.help/hc/ja/articles/235584168-小説作品の本文内に使える特殊タグとは +export async function parseNovelContent(content: string, images: Record, token?: string): Promise { + try { + // 如果有 token,處理 pixiv 圖片引用 + // If token exists, process pixiv image references + if (token) { + const imageMatches = [...content.matchAll(/\[pixivimage:(\d+)(?:-(\d+))?\]/g)]; + const imageIdToUrl = new Map(); + + // 批量獲取圖片資訊 + // Batch fetch image information + await Promise.all( + imageMatches.map(async ([, illustId, pageNum]) => { + if (!illustId) { + return; + } + + try { + const illust = (await getIllustDetail(illustId, token)).data.illust; + const pixivimages = pixivUtils.getImgs(illust).map((img) => img.match(/src="([^"]+)"/)?.[1] || ''); + + const imageUrl = pixivimages[Number(pageNum) || 0]; + if (imageUrl) { + imageIdToUrl.set(pageNum ? `${illustId}-${pageNum}` : illustId, imageUrl); + } + } catch (error) { + // 記錄錯誤但不中斷處理 + // Log error but don't interrupt processing + logger.warn(`Failed to fetch illust detail for ID ${illustId}: ${error instanceof Error ? error.message : String(error)}`); + } + }) + ); + + // 替換 pixiv 圖片引用為 img 標籤 + // Replace pixiv image references with img tags + content = content.replaceAll(/\[pixivimage:(\d+)(?:-(\d+))?\]/g, (match, illustId, pageNum) => { + const key = pageNum ? `${illustId}-${pageNum}` : illustId; + const imageUrl = imageIdToUrl.get(key); + return imageUrl ? `pixiv illustration ${illustId}${pageNum ? ` page ${pageNum}` : ''}` : match; + }); + } else { + /* + * 處理 get-novels-sfw 的情況 + * 當沒有 PIXIV_REFRESHTOKEN 時,將 [pixivimage:(\d+)] 格式轉換為 artwork 連結 + * 因無法獲取 Pixiv 作品詳情,改為提供直接連結到原始作品頁面 + * + * Handle get-novels-sfw case + * When PIXIV_REFRESHTOKEN is not available, convert [pixivimage:(\d+)] format to artwork link + * Provide direct link to original artwork page since artwork details cannot be retrieved + */ + content = content.replaceAll(/\[pixivimage:(\d+)(?:-(\d+))?\]/g, (_, illustId) => `Pixiv Artwork #${illustId}`); + } + + // 處理作者上傳的圖片 + // Process author uploaded images + content = content.replaceAll(/\[uploadedimage:(\d+)\]/g, (match, imageId) => { + if (images[imageId]) { + return `novel illustration ${imageId}`; + } + return match; + }); + + // 基本格式處理 + // Basic formatting + content = content + // 換行轉換為 HTML 換行 + // Convert newlines to HTML breaks + .replaceAll('\n', '
') + // 連續換行轉換為段落 + // Convert consecutive breaks to paragraphs + .replaceAll(/(
){2,}/g, '

') + // ruby 標籤(為日文漢字標註讀音) + // ruby tags (for Japanese kanji readings) + .replaceAll(/\[\[rb:(.*?)>(.*?)\]\]/g, '$1$2') + // 外部連結 + // external links + .replaceAll(/\[\[jumpuri:(.*?)>(.*?)\]\]/g, '$1') + // 頁面跳轉,但由於 [newpage] 使用 hr 分隔,沒有頁數,沒必要跳轉,所以只顯示文字 + // Page jumps, but since [newpage] uses hr separators, without the page numbers, jumping isn't needed, so just display text + .replaceAll(/\[jump:(\d+)\]/g, 'Jump to page $1') + // 章節標題 + // chapter titles + .replaceAll(/\[chapter:(.*?)\]/g, '

$1

') + // 分頁符 + // page breaks + .replaceAll('[newpage]', '
'); + + // 使用 cheerio 進行 HTML 清理和優化 + // Use cheerio for HTML cleanup and optimization + const $content = load(`

${content}

`); + + // 處理嵌套段落:移除多餘的嵌套 + // Handle nested paragraphs: remove unnecessary nesting + $content('p p').each((_, elem) => { + const $elem = $content(elem); + $elem.replaceWith($elem.html() || ''); + }); + + // 處理段落中的標題:確保正確的 HTML 結構 + // Handle headings in paragraphs: ensure correct HTML structure + $content('p h2').each((_, elem) => { + const $elem = $content(elem); + const $parent = $elem.parent('p'); + const html = $elem.prop('outerHTML'); + if ($parent.length && html) { + $parent.replaceWith(`

${html}

`); + } + }); + + return $content.html() || ''; + } catch (error) { + throw new Error(`Error parsing novel content: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/lib/routes/pixiv/novel-api/series/nsfw.ts b/lib/routes/pixiv/novel-api/series/nsfw.ts new file mode 100644 index 00000000000000..a15ccde646fc20 --- /dev/null +++ b/lib/routes/pixiv/novel-api/series/nsfw.ts @@ -0,0 +1,87 @@ +import got from '../../pixiv-got'; +import { maskHeader } from '../../constants'; +import { getNSFWNovelContent } from '../content/nsfw'; +import pixivUtils from '../../utils'; +import { SeriesContentResponse, SeriesDetail, SeriesFeed } from './types'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import { getToken } from '../../token'; +import { config } from '@/config'; +import cache from '@/utils/cache'; + +const baseUrl = 'https://www.pixiv.net'; + +export async function getNSFWSeriesNovels(seriesId: string, limit: number = 10): Promise { + if (!config.pixiv || !config.pixiv.refreshToken) { + throw new ConfigNotFoundError( + `該用戶爲 R18 創作者,需要 PIXIV_REFRESHTOKEN。 + This user is an R18 creator, PIXIV_REFRESHTOKEN is required. + pixiv RSS is disabled due to the lack of relevant config.` + ); + } + + const token = await getToken(cache.tryGet); + if (!token) { + throw new ConfigNotFoundError('pixiv not login'); + } + + const seriesResponse = await got(`${baseUrl}/ajax/novel/series/${seriesId}`, { + headers: { + ...maskHeader, + Authorization: 'Bearer ' + token, + }, + }); + + const seriesData = seriesResponse.data as SeriesDetail; + + if (seriesData.error) { + throw new Error(seriesData.message || 'Failed to get series detail'); + } + + // Get chapters + const chaptersResponse = await got(`${baseUrl}/ajax/novel/series/${seriesId}/content_titles`, { + headers: { + ...maskHeader, + Authorization: 'Bearer ' + token, + }, + }); + + const data = chaptersResponse.data as SeriesContentResponse; + + if (data.error) { + throw new Error(data.message || 'Failed to get series data'); + } + + const chapters = data.body.slice(-Math.abs(limit)); + const chapterStartNum = Math.max(data.body.length - limit + 1, 1); + + const items = await Promise.all( + chapters.map(async (chapter, index) => { + const novelContent = await getNSFWNovelContent(chapter.id, token); + return { + title: `#${chapterStartNum + index} ${novelContent.title}`, + description: ` + +

${novelContent.description}

+

+ 收藏數:${novelContent.bookmarkCount}
+ 閱覧數:${novelContent.viewCount}
+ 喜歡數:${novelContent.likeCount}
+


+ ${novelContent.content} + `, + link: `${baseUrl}/novel/show.php?id=${novelContent.id}`, + pubDate: novelContent.createDate, + author: novelContent.userName || `User ID: ${novelContent.userId}`, + category: novelContent.tags, + }; + }) + ); + + return { + title: seriesData.body.title, + description: seriesData.body.caption, + link: `${baseUrl}/novel/series/${seriesId}`, + image: pixivUtils.getProxiedImageUrl(seriesData.body.cover.urls.original), + item: items, + }; +} diff --git a/lib/routes/pixiv/novel-api/series/sfw.ts b/lib/routes/pixiv/novel-api/series/sfw.ts new file mode 100644 index 00000000000000..41dc58a41a6a48 --- /dev/null +++ b/lib/routes/pixiv/novel-api/series/sfw.ts @@ -0,0 +1,74 @@ +import got from '@/utils/got'; +import { load } from 'cheerio'; +import { getSFWNovelContent } from '../content/sfw'; +import pixivUtils from '../../utils'; +import { SeriesContentResponse, SeriesFeed } from './types'; + +const baseUrl = 'https://www.pixiv.net'; + +export async function getSFWSeriesNovels(seriesId: string, limit: number = 10): Promise { + // Get series detail using cheerio + const seriesPage = await got(`${baseUrl}/novel/series/${seriesId}`); + const $ = load(seriesPage.data); + + const title = $('meta[property="og:title"]').attr('content') || ''; + const description = $('meta[property="og:description"]').attr('content') || ''; + const image = $('meta[property="og:image"]').attr('content') || ''; + + // Get chapters + const response = await got(`${baseUrl}/ajax/novel/series/${seriesId}/content_titles`, { + headers: { + referer: `${baseUrl}/novel/series/${seriesId}`, + }, + }); + + const data = response.data as SeriesContentResponse; + + if (data.error) { + throw new Error(data.message || 'Failed to get series data'); + } + + const chapters = data.body.slice(-Math.abs(limit)); + const chapterStartNum = Math.max(data.body.length - limit + 1, 1); + + const items = await Promise.all( + chapters + .map(async (chapter, index) => { + if (!chapter.available) { + return { + title: `#${chapterStartNum + index} ${chapter.title}`, + description: `需要 PIXIV_REFRESHTOKEN 才能查看完整內文。
PIXIV_REFRESHTOKEN is required to view the full content.`, + link: `${baseUrl}/novel/show.php?id=${chapter.id}`, + }; + } + + const novelContent = await getSFWNovelContent(chapter.id); + return { + title: `#${chapterStartNum + index} ${novelContent.title}`, + description: ` + +

${novelContent.description}

+

+ 收藏數:${novelContent.bookmarkCount}
+ 閱覧數:${novelContent.viewCount}
+ 喜歡數:${novelContent.likeCount}
+


+ ${novelContent.content} + `, + link: `${baseUrl}/novel/show.php?id=${novelContent.id}`, + pubDate: novelContent.createDate, + author: novelContent.userName || `User ID: ${novelContent.userId}`, + category: novelContent.tags, + }; + }) + .reverse() + ); + + return { + title, + description, + image: pixivUtils.getProxiedImageUrl(image), + link: `${baseUrl}/novel/series/${seriesId}`, + item: items, + }; +} diff --git a/lib/routes/pixiv/novel-api/series/types.ts b/lib/routes/pixiv/novel-api/series/types.ts new file mode 100644 index 00000000000000..13f9c189b851b9 --- /dev/null +++ b/lib/routes/pixiv/novel-api/series/types.ts @@ -0,0 +1,68 @@ +export interface SeriesChapter { + id: string; + title: string; + available: boolean; +} + +export interface SeriesContentResponse { + error: boolean; + message: string; + body: SeriesChapter[]; +} + +export interface SeriesDetail { + error: boolean; + message: string; + body: { + id: string; + userId: string; + userName: string; + title: string; + caption: string; + description?: string; + tags: string[]; + publishedContentCount: number; + createDate: string; + updateDate: string; + firstNovelId: string; + latestNovelId: string; + xRestrict: number; + isOriginal: boolean; + cover: { + urls: { + original: string; + small?: string; + regular?: string; + original_square?: string; + }; + }; + extraData: { + meta: { + title: string; + description: string; + canonical: string; + ogp: { + description: string; + image: string; + title: string; + type: string; + }; + }; + }; + }; +} + +export interface SeriesFeed { + title: string; + description: string; + image: string; + link: string; + item: Array<{ + title: string; + description: string; + link: string; + pubDate?: Date; + author?: string; + category?: string[]; + }>; +} diff --git a/lib/routes/pixiv/novel-api/user-novels/nsfw.ts b/lib/routes/pixiv/novel-api/user-novels/nsfw.ts new file mode 100644 index 00000000000000..09b823ba987736 --- /dev/null +++ b/lib/routes/pixiv/novel-api/user-novels/nsfw.ts @@ -0,0 +1,90 @@ +import got from '../../pixiv-got'; +import { maskHeader } from '../../constants'; +import queryString from 'query-string'; +import { config } from '@/config'; +import pixivUtils from '../../utils'; +import { getNSFWNovelContent } from '../content/nsfw'; +import { parseDate } from '@/utils/parse-date'; +import { convertPixivProtocolExtended } from '../content/utils'; +import type { NSFWNovelsResponse, NovelList } from './types'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import cache from '@/utils/cache'; +import { getToken } from '../../token'; +import InvalidParameterError from '@/errors/types/invalid-parameter'; + +function getNovels(user_id: string, token: string): Promise { + return got('https://app-api.pixiv.net/v1/user/novels', { + headers: { + ...maskHeader, + Authorization: 'Bearer ' + token, + }, + searchParams: queryString.stringify({ + user_id, + filter: 'for_ios', + }), + }); +} + +export async function getNSFWUserNovels(id: string, fullContent: boolean = false, limit: number = 100): Promise { + if (!config.pixiv || !config.pixiv.refreshToken) { + throw new ConfigNotFoundError( + `該用戶爲 R18 創作者,需要 PIXIV_REFRESHTOKEN。 + This user is an R18 creator, PIXIV_REFRESHTOKEN is required. + pixiv RSS is disabled due to the lack of relevant config.` + ); + } + + const token = await getToken(cache.tryGet); + if (!token) { + throw new ConfigNotFoundError('pixiv not login'); + } + + const response = await getNovels(id, token); + const novels = limit ? response.data.novels.slice(0, limit) : response.data.novels; + + if (novels.length === 0) { + throw new InvalidParameterError(`\nid ${id} 不是有效的用戶 ID,或者該用戶沒有小說作品。\n id ${id} is not a valid user ID, or the user has no novels.`); + } + + const username = novels[0].user.name; + + const items = await Promise.all( + novels.map(async (novel) => { + const baseItem = { + title: novel.series?.title ? `${novel.series.title} - ${novel.title}` : novel.title, + description: ` + +

${convertPixivProtocolExtended(novel.caption)}

+

+ 字數:${novel.text_length}
+ 閱覽數:${novel.total_view}
+ 收藏數:${novel.total_bookmarks}
+ 評論數:${novel.total_comments}
+

`, + author: novel.user.name, + pubDate: parseDate(novel.create_date), + link: `https://www.pixiv.net/novel/show.php?id=${novel.id}`, + category: novel.tags.map((t) => t.name), + }; + + if (!fullContent) { + return baseItem; + } + + const { content } = await getNSFWNovelContent(novel.id, token); + + return { + ...baseItem, + description: `${baseItem.description}
${content}`, + }; + }) + ); + + return { + title: `${username}'s novels - pixiv`, + description: `${username} 的 pixiv 最新小说`, + image: pixivUtils.getProxiedImageUrl(novels[0].user.profile_image_urls.medium), + link: `https://www.pixiv.net/users/${id}/novels`, + item: items, + }; +} diff --git a/lib/routes/pixiv/novel-api/user-novels/sfw.ts b/lib/routes/pixiv/novel-api/user-novels/sfw.ts new file mode 100644 index 00000000000000..40c46a6885388e --- /dev/null +++ b/lib/routes/pixiv/novel-api/user-novels/sfw.ts @@ -0,0 +1,77 @@ +import got from '@/utils/got'; +import { parseDate } from '@/utils/parse-date'; +import pixivUtils from '../../utils'; +import { getSFWNovelContent } from '../content/sfw'; +import type { SFWNovelsResponse, NovelList } from './types'; + +const baseUrl = 'https://www.pixiv.net'; + +export async function getSFWUserNovels(id: string, fullContent: boolean = false, limit: number = 100): Promise { + const url = `${baseUrl}/users/${id}/novels`; + const { data: allData } = await got(`${baseUrl}/ajax/user/${id}/profile/all`, { + headers: { + referer: url, + }, + }); + + const novels = Object.keys(allData.body.novels) + .sort((a, b) => Number(b) - Number(a)) + .slice(0, Number.parseInt(String(limit), 10)); + + if (novels.length === 0) { + throw new Error('No novels found for this user, or is an R18 creator, fallback to ConfigNotFoundError'); + } + + const searchParams = new URLSearchParams(); + for (const novel of novels) { + searchParams.append('ids[]', novel); + } + + const { data } = (await got(`${baseUrl}/ajax/user/${id}/profile/novels`, { + headers: { + referer: url, + }, + searchParams, + })) as SFWNovelsResponse; + + const items = await Promise.all( + Object.values(data.body.works).map(async (item) => { + const baseItem = { + title: item.title, + description: ` + +

${item.description}

+

+ 字數:${item.textCount}
+ 閱讀時間:${item.readingTime} 分鐘
+ 收藏數:${item.bookmarkCount}
+

+ `, + link: `${baseUrl}/novel/show.php?id=${item.id}`, + author: item.userName, + pubDate: parseDate(item.createDate), + updated: parseDate(item.updateDate), + category: item.tags, + }; + + if (!fullContent) { + return baseItem; + } + + const { content } = await getSFWNovelContent(item.id); + + return { + ...baseItem, + description: `${baseItem.description}
${content}`, + }; + }) + ); + + return { + title: data.body.extraData.meta.title, + description: data.body.extraData.meta.ogp.description, + image: pixivUtils.getProxiedImageUrl(Object.values(data.body.works)[0].profileImageUrl), + link: url, + item: items, + }; +} diff --git a/lib/routes/pixiv/novel-api/user-novels/types.ts b/lib/routes/pixiv/novel-api/user-novels/types.ts new file mode 100644 index 00000000000000..1ca3da4f81f8ba --- /dev/null +++ b/lib/routes/pixiv/novel-api/user-novels/types.ts @@ -0,0 +1,133 @@ +export interface SFWNovelsResponse { + data: { + error: boolean; + message: string; + body: { + works: Record; + extraData: { + meta: { + title: string; + description: string; + canonical: string; + ogp: { + description: string; + image: string; + title: string; + type: string; + }; + twitter: { + description: string; + image: string; + title: string; + card: string; + }; + alternateLanguages: { + ja: string; + en: string; + }; + descriptionHeader: string; + }; + }; + }; + }; +} + +export interface SFWNovelWork { + id: string; + title: string; + genre: string; + xRestrict: number; + restrict: number; + url: string; + tags: string[]; + userId: string; + userName: string; + profileImageUrl: string; + textCount: number; + wordCount: number; + readingTime: number; + useWordCount: boolean; + description: string; + isBookmarkable: boolean; + bookmarkData: null; + bookmarkCount: number; + isOriginal: boolean; + marker: null; + titleCaptionTranslation: { + workTitle: null; + workCaption: null; + }; + createDate: string; + updateDate: string; + isMasked: boolean; + aiType: number; + seriesId: string; + seriesTitle: string; + isUnlisted: boolean; +} + +export interface NSFWNovelsResponse { + data: { + user: { + id: number; + name: string; + account: string; + profile_image_urls: { + medium: string; + }; + is_followed: boolean; + is_access_blocking_user: boolean; + }; + novels: NSFWNovelWork[]; + }; +} + +export interface NSFWNovelWork { + id: string; + title: string; + caption: string; + restrict: number; + x_restrict: number; + image_urls: { + square_medium: string; + medium: string; + large: string; + }; + create_date: string; + tags: Array<{ + name: string; + translated_name: string | null; + added_by_uploaded_user: boolean; + }>; + text_length: number; + user: { + id: number; + name: string; + account: string; + profile_image_urls: { + medium: string; + }; + }; + series?: { + id?: number; + title?: string; + }; + total_bookmarks: number; + total_view: number; + total_comments: number; +} + +export interface NovelList { + title: string; + description: string; + image: string; + link: string; + item: Array<{ + title: string; + description: string; + author: string; + pubDate: Date; + link: string; + category: string[]; + }>; +} diff --git a/lib/routes/pixiv/novels.ts b/lib/routes/pixiv/novels.ts index 4bdc76e286e11f..68226b79ec7e01 100644 --- a/lib/routes/pixiv/novels.ts +++ b/lib/routes/pixiv/novels.ts @@ -1,8 +1,9 @@ import { Data, Route, ViewType } from '@/types'; import { fallback, queryToBoolean } from '@/utils/readable-social'; -import { getR18Novels } from './api/get-novels-nsfw'; -import { getNonR18Novels } from './api/get-novels-sfw'; import { config } from '@/config'; +import { getNSFWUserNovels } from './novel-api/user-novels/nsfw'; +import { getSFWUserNovels } from './novel-api/user-novels/sfw'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; export const route: Route = { path: '/user/novels/:id/:full_content?', @@ -40,12 +41,12 @@ refresh_token after Pixiv login, required for accessing R18 novels radar: [ { title: 'User Novels (簡介 Basic info)', - source: ['www.pixiv.net/users/:id/novels'], + source: ['www.pixiv.net/users/:id/novels', 'www.pixiv.net/users/:id'], target: '/user/novels/:id', }, { title: 'User Novels (全文 Full text)', - source: ['www.pixiv.net/users/:id/novels'], + source: ['www.pixiv.net/users/:id/novels', 'www.pixiv.net/users/:id'], target: '/user/novels/:id/true', }, ], @@ -72,20 +73,26 @@ const hasPixivAuth = () => Boolean(config.pixiv && config.pixiv.refreshToken); async function handler(ctx): Promise { const id = ctx.req.param('id'); const fullContent = fallback(undefined, queryToBoolean(ctx.req.param('full_content')), false); - const { limit } = ctx.req.query(); - // Use R18 API first if auth exists if (hasPixivAuth()) { - return await getR18Novels(id, fullContent, limit); + return await getNSFWUserNovels(id, fullContent, limit); } - // Attempt non-R18 API when Pixiv auth is missing - const nonR18Result = await getNonR18Novels(id, fullContent, limit).catch(() => null); + const nonR18Result = await getSFWUserNovels(id, fullContent, limit).catch((error) => { + if (error.name === 'Error') { + return null; + } + throw error; + }); + if (nonR18Result) { return nonR18Result; } - // Fallback to R18 API as last resort - return await getR18Novels(id, fullContent, limit); + throw new ConfigNotFoundError( + `\n該用戶可能沒有小說作品,或者該用戶爲 R18 創作者,需要 PIXIV_REFRESHTOKEN。 + This user may not have any novel works, or is an R18 creator, PIXIV_REFRESHTOKEN is required. + pixiv RSS is disabled due to the lack of relevant config.` + ); } diff --git a/lib/routes/pixiv/series.ts b/lib/routes/pixiv/series.ts new file mode 100644 index 00000000000000..a411a30ba6dde0 --- /dev/null +++ b/lib/routes/pixiv/series.ts @@ -0,0 +1,52 @@ +import { Data, Route } from '@/types'; +import { config } from '@/config'; +import { getNSFWSeriesNovels } from './novel-api/series/nsfw'; +import { getSFWSeriesNovels } from './novel-api/series/sfw'; + +export const route: Route = { + path: '/series/:id', + categories: ['social-media'], + example: '/pixiv/series/11586857', + parameters: { + id: 'Series id, can be found in URL', + }, + features: { + requireConfig: [ + { + name: 'PIXIV_REFRESHTOKEN', + optional: true, + description: ` +Pixiv 登錄後的 refresh_token,用於獲取 R18 小說 +refresh_token after Pixiv login, required for accessing R18 novels +[https://docs.rsshub.app/deploy/config#pixiv](https://docs.rsshub.app/deploy/config#pixiv)`, + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: 'Series Novels', + maintainers: ['SnowAgar25'], + handler, + radar: [ + { + source: ['www.pixiv.net/novel/series/:id'], + target: '/series/:id', + }, + ], +}; + +const hasPixivAuth = () => Boolean(config.pixiv && config.pixiv.refreshToken); + +async function handler(ctx): Promise { + const id = ctx.req.param('id'); + const { limit } = ctx.req.query(); + + if (hasPixivAuth()) { + return await getNSFWSeriesNovels(id, limit); + } + + return await getSFWSeriesNovels(id, limit); +} diff --git a/lib/routes/pixiv/utils.ts b/lib/routes/pixiv/utils.ts index 9bd7dab5a00f13..5d3e71b968b28c 100644 --- a/lib/routes/pixiv/utils.ts +++ b/lib/routes/pixiv/utils.ts @@ -1,6 +1,4 @@ import { config } from '@/config'; -import { load } from 'cheerio'; -import getIllustDetail from './api/get-illust-detail'; export default { getImgs(illust) { @@ -19,118 +17,4 @@ export default { getProxiedImageUrl(originalUrl: string): string { return originalUrl.replace('https://i.pximg.net', config.pixiv.imgProxy || ''); }, - // docs: https://www.pixiv.help/hc/ja/articles/235584168-小説作品の本文内に使える特殊タグとは - async parseNovelContent(content: string, images: Record, token?: string): Promise { - try { - // 如果有 token,處理 pixiv 圖片引用 - // If token exists, process pixiv image references - if (token) { - const imageMatches = [...content.matchAll(/\[pixivimage:(\d+)(?:-(\d+))?\]/g)]; - const imageIdToUrl = new Map(); - - // 批量獲取圖片資訊 - // Batch fetch image information - await Promise.all( - imageMatches.map(async ([, illustId, pageNum]) => { - if (!illustId) { - return; - } - - try { - const illust = (await getIllustDetail(illustId, token)).data.illust; - const pixivimages = this.getImgs(illust).map((img) => img.match(/src="([^"]+)"/)?.[1] || ''); - - const imageUrl = pixivimages[Number(pageNum) || 0]; - if (imageUrl) { - imageIdToUrl.set(pageNum ? `${illustId}-${pageNum}` : illustId, imageUrl); - } - } catch (error) { - // 記錄錯誤但不中斷處理 - // Log error but don't interrupt processing - logger.warn(`Failed to fetch illust detail for ID ${illustId}: ${error instanceof Error ? error.message : String(error)}`); - } - }) - ); - - // 替換 pixiv 圖片引用為 img 標籤 - // Replace pixiv image references with img tags - content = content.replaceAll(/\[pixivimage:(\d+)(?:-(\d+))?\]/g, (match, illustId, pageNum) => { - const key = pageNum ? `${illustId}-${pageNum}` : illustId; - const imageUrl = imageIdToUrl.get(key); - return imageUrl ? `pixiv illustration ${illustId}${pageNum ? ` page ${pageNum}` : ''}` : match; - }); - } else { - /* - * 處理 get-novels-sfw 的情況 - * 當沒有 PIXIV_REFRESHTOKEN 時,將 [pixivimage:(\d+)] 格式轉換為 artwork 連結 - * 因無法獲取 Pixiv 作品詳情,改為提供直接連結到原始作品頁面 - * - * Handle get-novels-sfw case - * When PIXIV_REFRESHTOKEN is not available, convert [pixivimage:(\d+)] format to artwork link - * Provide direct link to original artwork page since artwork details cannot be retrieved - */ - content = content.replaceAll(/\[pixivimage:(\d+)(?:-(\d+))?\]/g, (_, illustId) => `Pixiv Artwork #${illustId}`); - } - - // 處理作者上傳的圖片 - // Process author uploaded images - content = content.replaceAll(/\[uploadedimage:(\d+)\]/g, (match, imageId) => { - if (images[imageId]) { - return `novel illustration ${imageId}`; - } - return match; - }); - - // 基本格式處理 - // Basic formatting - content = content - // 換行轉換為 HTML 換行 - // Convert newlines to HTML breaks - .replaceAll('\n', '
') - // 連續換行轉換為段落 - // Convert consecutive breaks to paragraphs - .replaceAll(/(
){2,}/g, '

') - // ruby 標籤(為日文漢字標註讀音) - // ruby tags (for Japanese kanji readings) - .replaceAll(/\[\[rb:(.*?)>(.*?)\]\]/g, '$1$2') - // 外部連結 - // external links - .replaceAll(/\[\[jumpuri:(.*?)>(.*?)\]\]/g, '$1') - // 頁面跳轉,但由於 [newpage] 使用 hr 分隔,沒有頁數,沒必要跳轉,所以只顯示文字 - // Page jumps, but since [newpage] uses hr separators, without the page numbers, jumping isn't needed, so just display text - .replaceAll(/\[jump:(\d+)\]/g, 'Jump to page $1') - // 章節標題 - // chapter titles - .replaceAll(/\[chapter:(.*?)\]/g, '

$1

') - // 分頁符 - // page breaks - .replaceAll('[newpage]', '
'); - - // 使用 cheerio 進行 HTML 清理和優化 - // Use cheerio for HTML cleanup and optimization - const $content = load(`

${content}

`); - - // 處理嵌套段落:移除多餘的嵌套 - // Handle nested paragraphs: remove unnecessary nesting - $content('p p').each((_, elem) => { - const $elem = $content(elem); - $elem.replaceWith($elem.html() || ''); - }); - - // 處理段落中的標題:確保正確的 HTML 結構 - // Handle headings in paragraphs: ensure correct HTML structure - $content('p h2').each((_, elem) => { - const $elem = $content(elem); - const $parent = $elem.parent('p'); - const html = $elem.prop('outerHTML'); - if ($parent.length && html) { - $parent.replaceWith(`

${html}

`); - } - }); - - return $content.html() || ''; - } catch (error) { - throw new Error(`Error parsing novel content: ${error instanceof Error ? error.message : String(error)}`); - } - }, };