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 ? `` : 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 ``;
+ }
+ 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')
+ // 外部連結
+ // 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 ? `` : 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 ``;
- }
- 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')
- // 外部連結
- // 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)}`);
- }
- },
};