${content}
From cf7ce2470b1381cef2efc811e9c338a6ff73e866 Mon Sep 17 00:00:00 2001
From: Tsuyumi <40047364+SnowAgar25@users.noreply.github.com>
Date: Wed, 6 Nov 2024 03:56:50 +0800
Subject: [PATCH] =?UTF-8?q?feat(route/pixiv):=20add=20R18=20novels=20suppo?=
=?UTF-8?q?rt=20and=20full=20content=20toggle=20for=E2=80=A6=20(#17391)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(route/pixiv): add R18 novels support and full content toggle for user novels
* fix: information & image placeholders
* refactor: split novels fetching into SFW/NSFW modules and improve type definitions
* feat: add info for sfw
* feat: add radar
* refactor: use jsdom instead of regex
* feat: add limit support for nsfw novels
* docs: rename radar title
* revert: part of #17440
Object.entries(options.searchParams) returns `[]`
* fix: clean up
* feat: early exit when no SFW novels found
* refactor: combine novel parsing logic into utils
* docs: restore pixiv doc link
* feat: cache novel content
* refactor: cleanup
* refactor: full content function
---------
---
lib/routes/pixiv/api/get-illust-detail.ts | 22 ++
lib/routes/pixiv/api/get-novels-nsfw.ts | 247 ++++++++++++++++++++++
lib/routes/pixiv/api/get-novels-sfw.ts | 201 ++++++++++++++++++
lib/routes/pixiv/novels.ts | 110 ++++++----
lib/routes/pixiv/utils.ts | 119 +++++++++++
5 files changed, 654 insertions(+), 45 deletions(-)
create mode 100644 lib/routes/pixiv/api/get-illust-detail.ts
create mode 100644 lib/routes/pixiv/api/get-novels-nsfw.ts
create mode 100644 lib/routes/pixiv/api/get-novels-sfw.ts
diff --git a/lib/routes/pixiv/api/get-illust-detail.ts b/lib/routes/pixiv/api/get-illust-detail.ts
new file mode 100644
index 00000000000000..64f524f1f82941
--- /dev/null
+++ b/lib/routes/pixiv/api/get-illust-detail.ts
@@ -0,0 +1,22 @@
+import got from '../pixiv-got';
+import { maskHeader } from '../constants';
+import queryString from 'query-string';
+
+/**
+ * 获取插画详细信息
+ * @param {string} illust_id 插画作品 id
+ * @param {string} token pixiv oauth token
+ * @returns {Promise ${convertPixivProtocolExtended(novel.caption) || ''}
+ 字數:${novel.text_length} ${item.description}
+ 字數:${item.textCount}
+ 閱覽數:${novel.total_view}
+ 收藏數:${novel.total_bookmarks}
+ 評論數:${novel.total_comments}
+
${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
new file mode 100644
index 00000000000000..7bb74c8970a02e
--- /dev/null
+++ b/lib/routes/pixiv/api/get-novels-sfw.ts
@@ -0,0 +1,201 @@
+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
+ 閱讀時間:${item.readingTime} 分鐘
+ 收藏數:${item.bookmarkCount}
+
${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/novels.ts b/lib/routes/pixiv/novels.ts
index 9d9ccdf5b9d3a3..4bdc76e286e11f 100644
--- a/lib/routes/pixiv/novels.ts
+++ b/lib/routes/pixiv/novels.ts
@@ -1,15 +1,36 @@
-import { Route } from '@/types';
-import got from '@/utils/got';
-import { parseDate } from '@/utils/parse-date';
-const baseUrl = 'https://www.pixiv.net';
+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';
export const route: Route = {
- path: '/user/novels/:id',
+ path: '/user/novels/:id/:full_content?',
categories: ['social-media'],
+ view: ViewType.Articles,
example: '/pixiv/user/novels/27104704',
- parameters: { id: "User id, available in user's homepage URL" },
+ parameters: {
+ id: "User id, available in user's homepage URL",
+ full_content: {
+ description: 'Enable or disable the display of full content. ',
+ options: [
+ { value: 'true', label: 'true' },
+ { value: 'false', label: 'false' },
+ ],
+ default: 'false',
+ },
+ },
features: {
- requireConfig: false,
+ 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,
@@ -18,54 +39,53 @@ export const route: Route = {
},
radar: [
{
+ title: 'User Novels (簡介 Basic info)',
source: ['www.pixiv.net/users/:id/novels'],
+ target: '/user/novels/:id',
+ },
+ {
+ title: 'User Novels (全文 Full text)',
+ source: ['www.pixiv.net/users/:id/novels'],
+ target: '/user/novels/:id/true',
},
],
name: 'User Novels',
- maintainers: ['TonyRL'],
+ maintainers: ['TonyRL', 'SnowAgar25'],
handler,
+ description: `
+| 小說類型 Novel Type | full_content | PIXIV_REFRESHTOKEN | 返回內容 Content |
+|-------------------|--------------|-------------------|-----------------|
+| Non R18 | false | 不需要 Not Required | 簡介 Basic info |
+| Non R18 | true | 不需要 Not Required | 全文 Full text |
+| R18 | false | 需要 Required | 簡介 Basic info |
+| R18 | true | 需要 Required | 全文 Full text |
+
+Default value for \`full_content\` is \`false\` if not specified.
+
+Example:
+- \`/pixiv/user/novels/79603797\` → 簡介 Basic info
+- \`/pixiv/user/novels/79603797/true\` → 全文 Full text`,
};
-async function handler(ctx) {
+const hasPixivAuth = () => Boolean(config.pixiv && config.pixiv.refreshToken);
+
+async function handler(ctx): Promise {
const id = ctx.req.param('id');
- const { limit = 100 } = ctx.req.query();
- const url = `${baseUrl}/users/${id}/novels`;
- const { data: allData } = await got(`${baseUrl}/ajax/user/${id}/profile/all`, {
- headers: {
- referer: url,
- },
- });
+ const fullContent = fallback(undefined, queryToBoolean(ctx.req.param('full_content')), false);
- const novels = Object.keys(allData.body.novels)
- .sort((a, b) => b - a)
- .slice(0, Number.parseInt(limit, 10));
- const searchParams = new URLSearchParams();
- for (const novel of novels) {
- searchParams.append('ids[]', novel);
- }
+ const { limit } = ctx.req.query();
- const { data } = await got(`${baseUrl}/ajax/user/${id}/profile/novels`, {
- headers: {
- referer: url,
- },
- searchParams,
- });
+ // Use R18 API first if auth exists
+ if (hasPixivAuth()) {
+ return await getR18Novels(id, fullContent, limit);
+ }
- const items = Object.values(data.body.works).map((item) => ({
- title: item.seriesTitle || item.title,
- description: item.description || item.title,
- link: `${baseUrl}/novel/series/${item.id}`,
- author: item.userName,
- pubDate: parseDate(item.createDate),
- updated: parseDate(item.updateDate),
- category: item.tags,
- }));
+ // Attempt non-R18 API when Pixiv auth is missing
+ const nonR18Result = await getNonR18Novels(id, fullContent, limit).catch(() => null);
+ if (nonR18Result) {
+ return nonR18Result;
+ }
- return {
- title: data.body.extraData.meta.title,
- description: data.body.extraData.meta.ogp.description,
- image: Object.values(data.body.works)[0].profileImageUrl,
- link: url,
- item: items,
- };
+ // Fallback to R18 API as last resort
+ return await getR18Novels(id, fullContent, limit);
}
diff --git a/lib/routes/pixiv/utils.ts b/lib/routes/pixiv/utils.ts
index 824f742e3a95da..9bd7dab5a00f13 100644
--- a/lib/routes/pixiv/utils.ts
+++ b/lib/routes/pixiv/utils.ts
@@ -1,4 +1,6 @@
import { config } from '@/config';
+import { load } from 'cheerio';
+import getIllustDetail from './api/get-illust-detail';
export default {
getImgs(illust) {
@@ -14,4 +16,121 @@ export default {
}
return images;
},
+ 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
')
+ // 連續換行轉換為段落
+ // 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, '
${content}
`); + } + }); + + return $content.html() || ''; + } catch (error) { + throw new Error(`Error parsing novel content: ${error instanceof Error ? error.message : String(error)}`); + } + }, };