├── .gitattributes
├── .gitignore
├── HowToWork.md
├── LICENSE
├── README.md
├── config.js
├── core
├── api
│ ├── app.js
│ └── module
│ │ ├── follow
│ │ ├── README.md
│ │ ├── follow.js
│ │ ├── index.js
│ │ └── update-push.js
│ │ ├── illust
│ │ ├── README.md
│ │ ├── index.js
│ │ ├── pid.js
│ │ └── recommend.js
│ │ ├── index.js
│ │ ├── manga
│ │ ├── README.md
│ │ ├── index.js
│ │ ├── manga-pid.js
│ │ └── manga-series-pid.js
│ │ ├── novel
│ │ ├── README.md
│ │ ├── index.js
│ │ ├── pid.js
│ │ └── series-pid.js
│ │ ├── search
│ │ ├── README.md
│ │ ├── index.js
│ │ ├── no-premium.js
│ │ └── search.js
│ │ └── user
│ │ └── pid.js
└── pixiv-fetch
│ ├── agent.js
│ ├── base-option.js
│ ├── default-option.js
│ ├── fetch.js
│ ├── index.js
│ └── replace-url.js
├── index.js
├── package-lock.json
└── package.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Serverless directories
108 | .serverless/
109 |
110 | # FuseBox cache
111 | .fusebox/
112 |
113 | # DynamoDB Local files
114 | .dynamodb/
115 |
116 | # TernJS port file
117 | .tern-port
118 |
119 | # Stores VSCode versions used for testing VSCode extensions
120 | .vscode-test
121 |
122 | # yarn v2
123 | .yarn/cache
124 | .yarn/unplugged
125 | .yarn/build-state.yml
126 | .yarn/install-state.gz
127 | .pnp.*
128 |
129 | .idea/
130 | cache/
131 |
--------------------------------------------------------------------------------
/HowToWork.md:
--------------------------------------------------------------------------------
1 | # 工作方式
2 |
3 | ## 绕过防火墙
4 |
5 | 防火墙对Pixiv的封锁使用两种技术:
6 |
7 | 1. **DNS欺骗** 审查DNS查询目标域名, 返回错误的数据, 使应用无法获得到正确的目标主机IP地址
8 | 2. **SNI阻断** 审查`Client Hello`报文中的SNI信息, 对双方发送大量RST报文, 进行**TCP重置攻击**阻断连接
9 |
10 | #### 解决方案:
11 |
12 | 1. **DNS欺骗** 使用查询到的CDN静态IP直接访问, 不进行DNS查询
13 | 2. **SNI阻断** 擦去SNI信息中的`Server Name`数据 (域前置) 绕过审查, 使CDN返回默认证书`*.pixiv.net`继续连接
14 |
15 | ## 免会员搜索
16 |
17 | Pixiv为热门Tag的作品自动添加`xxusers入り`标签, 可利用此标签实现免会员搜索
18 |
19 | 标签必须严格匹配, 如 `东方project1000users入り` 与 `東方Project1000users入り` 是完全不同的, 后者为Pixiv自动添加
20 |
21 | #### 有效标签
22 |
23 | - `100users入り`
24 | - `250users入り`
25 | - `300users入り`
26 | - `500users入り`
27 | - `1000users入り`
28 | - `3000users入り`
29 | - `5000users入り`
30 | - `10000users入り`
31 | - `20000users入り`
32 | - `30000users入り`
33 |
34 | #### 工作流程
35 |
36 | 1. 将传入的收藏过滤器参数向上取最接近的值; 如 `blt=7200` 取 `10000users入り`
37 | 2. 进行**或搜索**, 向后取值; 如 `20000users入り` 处理后关键词为 `(30000users入り OR 20000users入り)`
38 |
39 | #### 注意
40 |
41 | 实验性质, 适用于单个热门标签, 多关键词搜索结果可能不尽人意
42 |
43 | 更改配置文件 `config.js` 中 `pixiv.premium = false` 启用此功能
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Dituon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # pixiv-api-http
2 |
3 | > [!IMPORTANT]
4 | > 请使用 `Node LTS` 版本 (例如 `18.20.0`), 经测试 `Node 20+` 存在LTS代理问题
5 |
6 | ## Intro
7 |
8 | 此API旨在为 Pixiv 开发者提供终极解决方案
9 |
10 | 可直接访问 Pixiv, 绕过防火墙封锁 (如 天朝 伊朗 等)
11 |
12 | **[工作原理](HowToWork.md)**
13 |
14 | 支持 `GET` `POST` `WebSocket` 协议
15 |
16 | 可选:
17 |
18 | - 绕过政府防火墙封锁
19 | - 使用图床原始IP地址
20 | - 非会员搜索优化
21 | - 本地图片反向代理
22 |
23 | ## API
24 |
25 | - **[Illust](./core/api/module/illust/README.md)**
26 | - **[Manga](core/api/module/manga/README.md)**
27 | - **[Novel](./core/api/module/novel/README.md)**
28 | - **[Search](./core/api/module/search/README.md)**
29 | - **[Follow](core/api/module/follow/README.md)**
30 | - **Proxy**
31 |
32 | *(可混用 `POST` `GET` 请求)*
33 |
34 | ## Config
35 |
36 | 配置文件 `config.js`
37 |
38 | ### `proxy`
39 |
40 | 代理设置
41 |
42 | **应用服务器 (`www.pixiv.net` `*.pixiv.net`)**
43 |
44 | - `bypassSNI` (boolean): 绕过防火墙的SNI封锁 (如天朝, 韩国, 伊朗等)
45 | - `serverHost` (IP[]): 服务器原始IP, 用于绕过DNS欺骗
46 |
47 | **图片服务器 (`i.pximg.net`)**
48 |
49 | - `useOriginIP` (boolean): 替换图片链接为图床原始IP, 可在墙内通过原始IP获取图片, 使用http连接加快访问速度
50 | - `useLocalProxy` (boolean): 启动代理服务器, 替换图片链接为本机代理, 免去Referer设置
51 | - `imageHost` (IP[]): 图片服务器原始IP, 用于绕过DNS欺骗
52 |
53 | | **`useOriginIP`** | **`useLocalProxy`** | **行为** |
54 | |-------------------|---------------------|-----------------------------------------|
55 | | `false` | `false` | 返回原始链接 |
56 | | `true` | `false` | 替换图片链接为原始IP |
57 | | `false` | `true` | 替换图片链接为本机代理, 启动代理服务器, 代理使用`HTTPS`访问图床域名 |
58 | | `true` | `true` | 替换图片链接为本机代理, 启动代理服务器, 代理使用`HTTP`访问图床IP |
59 |
60 | ### `pixiv`
61 |
62 | Pixiv设置
63 |
64 | - `lang` (Lang): 语言设置, 用于小说搜索, API报错等
65 |
66 | | `lang` | 语言 |
67 | |---------|---------|
68 | | `ja` | 日本語 |
69 | | `en` | English |
70 | | `zh-cn` | 简体中文 |
71 | | `zh-tw` | 繁體中文 |
72 |
73 | - `cookie` (string): 你的 Cookie (参考 [Tips](#Tips))
74 |
75 | - `premium` (boolean): 拥有pixiv会员, 填入`false`启用非会员搜索优化, 参考 [工作方式](./HowToWork.md)
76 |
77 | ### `httpServer`
78 |
79 | HTTP API 服务器设置
80 |
81 | - `port` (number): 端口
82 | - `host` (string): IP / 域名
83 |
84 | ### `websocketServer`
85 |
86 | WebSocket API 服务器设置
87 |
88 | - `port` (number): 端口
89 | - `host` (string): IP / 域名
90 |
91 | ## Deploy
92 |
93 | 0. install `node.js`
94 | 1. `git clone` `npm i`
95 | 2. 浏览器登录 `pixiv.net`, 获取 `cookie` (参考 [Tips](#Tips))
96 | 3. 编辑 `config.js`
97 | 4. `npm start`
98 |
99 | ## Tips
100 |
101 | - 部分功能设置`cookie`后可用
102 | - 获取`cookie`: 详见 **[#2](https://github.com/Dituon/pixiv-api-http/issues/2#issuecomment-2282201060)**
103 | - 部分功能需要Pixiv会员账户, 非会员搜索优化请参考 [工作方式](./HowToWork.md)
104 | - 设置 `Headers: Referer` 为 `https://www.pixiv.net/` 即可直接访问图片
105 |
106 | ## TODO
107 |
108 | - manga-series
109 | - gif
110 | - user
111 |
112 | ## About
113 |
114 | 交流群: `961494251`
115 |
--------------------------------------------------------------------------------
/config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | // Proxy setting
3 | // 代理设置
4 | proxy: {
5 |
6 | // bypass government SNI blocking (for China, Korea, Iran etc.)
7 | // 绕过防火墙的SNI封锁 (如天朝, 韩国, 伊朗等)
8 | bypassSNI: true,
9 | // query dns records, by www.nslookup.io
10 | serverHost: [ // *.pixiv.net
11 | '210.140.92.183',
12 | '210.140.92.187',
13 | '210.140.92.193',
14 | '210.140.131.226',
15 | '210.140.131.223',
16 | '210.140.131.218',
17 | '210.140.131.199',
18 | '210.140.131.201'
19 | ],
20 |
21 | // replace img url to original ip, improve access speed
22 | // 替换图片链接为原始IP, 可在墙内通过原始IP获取图片, 使用http连接加快访问速度
23 | useOriginIP: true,
24 | // replace img url to local proxy
25 | // 启动图片代理服务器 (host/proxy), 替换图片链接为本机代理
26 | useLocalProxy: true,
27 | imageHost: [ // i.pximg.net, direct connection by ip available
28 | '210.140.92.141',
29 | '210.140.92.149',
30 | '210.140.92.142',
31 | '210.140.92.148',
32 | '210.140.92.146',
33 | '210.140.92.144',
34 | '210.140.92.147',
35 | '210.140.92.145',
36 | '210.140.92.143'
37 | ]
38 | },
39 |
40 | pixiv: {
41 | /** @typedef {'ja'|'en'|'zh'|'zh-cn'|'zh-tw'} Lang */
42 | /** @type {Lang} */
43 | lang: 'zh',
44 |
45 | // your cookie here (see README.md#Tips)
46 | // 你的 Cookie (参考 README.md#Tips)
47 | cookie: '',
48 |
49 | premium: true,
50 |
51 | followUpdate: {
52 | // If undefined, the cookie will determine if it is enabled or not.
53 | // 如果为undefined, 则根据cookie判断是否启用
54 | enable: undefined,
55 | interval: 300 * 1000
56 | },
57 |
58 | cachePath: './cache/'
59 | },
60 |
61 | httpServer: {
62 | host: '127.0.0.1',
63 | port: 1145
64 | },
65 |
66 | websocketServer: {
67 | host: undefined,
68 | port: 4514
69 | }
70 | }
71 |
72 | if (config.pixiv.followUpdate.enable === undefined) {
73 | config.pixiv.followUpdate.enable = !!config.pixiv.cookie
74 | }
75 |
76 | if (config.websocketServer.host === undefined) {
77 | config.websocketServer.host = config.httpServer.host
78 | }
79 |
80 | export default config
81 |
--------------------------------------------------------------------------------
/core/api/app.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import bodyParser from 'body-parser'
3 | import proxy from 'express-http-proxy'
4 |
5 | import config from '../../config.js'
6 |
7 | import {getPidIllust, getPidImage, getPidImageList, getPidManga} from './module/illust/index.js'
8 | import {getPidNovelSeries, getPidNovelSeriesContent, getPidNovelSeriesInfo, getPidNovel} from './module/novel/index.js'
9 | import {searchFormat} from './module/search/search.js'
10 | import {imageServerHost, rawHost} from "../pixiv-fetch/replace-url.js";
11 | import {getPidRecommend, getPidRecommendIds} from "./module/illust/recommend.js";
12 | import {getFollowUpdateFormat} from "./module/follow/follow.js";
13 | import {initFollowUpdatePush} from "./module/follow/update-push.js";
14 |
15 | export const app = express()
16 | app.use(bodyParser.json())
17 | app.use((req, res, next) => {
18 | req.body = {...req.query, ...req.body}
19 | next()
20 | })
21 | app.listen(config.httpServer.port, config.httpServer.host)
22 |
23 | // illust
24 | app.all('/illust/:id', async (req, res) => {
25 | res.json(await getPidIllust(req.params.id))
26 | })
27 | app.all('/illust/:id/images', async (req, res) => {
28 | res.json(await getPidImageList(req.params.id))
29 | })
30 | app.all('/illust/:id/images/:page', async (req, res) => {
31 | res.json(await getPidImage(req.params.id, req.params.page))
32 | })
33 | app.all('/illust/:id/recommend', async (req, res) => {
34 | res.json(await getPidRecommend(req.params.id, req.body.size))
35 | })
36 |
37 | app.all('/illust/:id/recommend/ids', async (req, res) => {
38 | res.json(await getPidRecommendIds(req.params.id, req.body.size))
39 | })
40 |
41 |
42 | // manga
43 | app.all('/manga/:id', async (req, res) => {
44 | res.json(await getPidManga(req.params.id))
45 | })
46 |
47 | // novel
48 | app.all('/novel/:id', async (req, res) => {
49 | res.json(await getPidNovel(req.params.id))
50 | })
51 | app.all('/novel/series/:id', async (req, res) => {
52 | res.json(await getPidNovelSeries(req.params.id))
53 | })
54 | app.all('/novel/series/:id/info', async (req, res) => {
55 | res.json(await getPidNovelSeriesInfo(req.params.id))
56 | })
57 | app.all('/novel/series/:id/content', async (req, res) => {
58 | res.json(await getPidNovelSeriesContent(req.params.id))
59 | })
60 |
61 | // search
62 | app.all('/search', async (req, res) => {
63 | res.json(await searchFormat(req.body))
64 | })
65 |
66 | // follow
67 | app.all('/follow', async (req, res) => {
68 | res.json(await getFollowUpdateFormat(req.body))
69 | })
70 |
71 | app.all('/follow/:type', async (req, res) => {
72 | res.json(await getFollowUpdateFormat({type: req.params.type, ...req.body}))
73 | })
74 |
75 | // proxy
76 | if (config.proxy.useLocalProxy) app.use(
77 | '/proxy',
78 | proxy(config.proxy.useOriginIP ? imageServerHost : rawHost,
79 | {
80 | proxyReqOptDecorator: opts => {
81 | opts.headers['Referer'] = 'https://www.pixiv.net/'
82 | return opts
83 | },
84 | https: !config.proxy.useOriginIP
85 | })
86 | )
87 |
88 | // follow update
89 | if (config.pixiv.followUpdate.enable) initFollowUpdatePush()
90 |
91 | app.use((err, req, res, next) => {
92 | console.warn(err.stack)
93 | res.status(400).send(err.message)
94 | })
--------------------------------------------------------------------------------
/core/api/module/follow/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | POST
/follow
4 |
5 |
6 |
7 | ### 获取已关注用户的最新作品
8 |
9 | (**需设置Cookie**)
10 |
11 | ### `FollowUpdateParam`:
12 |
13 | | 参数 | 类型 | 描述 | 默认值 |
14 | |------------|---------------|------|----------|
15 | | `type` | `ArtworkType` | 搜索类型 | `illust` |
16 | | `restrict` | `Restrict` | 限制等级 | `safe` |
17 | | `start` | `number` | 起始索引 | `0` |
18 | | `length` | `number` | 索引长度 | `60` |
19 | | `lang` | `Lang` | 语言 | 配置文件 |
20 |
21 | ##### `ArtworkType`
22 |
23 | 限定作品类型
24 |
25 | | Name | Description |
26 | |----------|--------------|
27 | | `illust` | 插画 (动态插画与漫画) |
28 | | `novel` | 小说 |
29 |
30 | ##### `Restrict`
31 |
32 | 指定限制级
33 |
34 | | Name | Description |
35 | |--------|----------------|
36 | | `safe` | 全年龄向 |
37 | | `r18` | `R-18` `R-18G` |
38 | | `all` | 全部 |
39 |
40 | 示例: `http://127.0.0.1:1145/follow`
41 |
42 | 返回: `ResultPreviewDTO[]`
43 |
44 | ```
45 | [
46 | {
47 | "id": 105680390,
48 | "title": "天衣無縫",
49 | "cover": "...",
50 | "tags": [
51 | "東方",
52 | "東方緋想天",
53 | ...
54 | ],
55 | "createTime": 1677250811000,
56 | "updateTime": 1677250811000,
57 | "restrict": 'safe',
58 | "total": 1,
59 | "author": {
60 | "name": "久蒼穹",
61 | "id": 66038798
62 | }
63 | },
64 | ...
65 | ]
66 | ```
67 |
68 | ---
69 |
70 |
71 |
72 |
73 | WebSocket
/follow-update
74 |
75 |
76 |
77 | ### 获取更新推送
78 |
79 | 每**5分钟**从Pixiv服务器请求一次数据, 如果有已关注用户的最新作品更新, 则推送 `ResultPreviewDTO[]`
80 |
81 | 示例: `ws://127.0.0.1:4514/follow-update`
82 |
83 | 推送: `ResultPreviewDTO[]`
84 |
85 | ```
86 | [
87 | {
88 | "id": 105680390,
89 | "title": "天衣無縫",
90 | "cover": "...",
91 | "tags": [
92 | "東方",
93 | "東方緋想天",
94 | ...
95 | ],
96 | "createTime": 1677250811000,
97 | "updateTime": 1677250811000,
98 | "restrict": 'safe',
99 | "total": 1,
100 | "author": {
101 | "name": "久蒼穹",
102 | "id": 66038798
103 | }
104 | },
105 | ...
106 | ]
107 | ```
108 |
109 | ---
110 |
--------------------------------------------------------------------------------
/core/api/module/follow/follow.js:
--------------------------------------------------------------------------------
1 | import {pixivJsonFetch, replaceURL} from "../../../pixiv-fetch/index.js";
2 | import config from "../../../../config.js";
3 | import {search, searchFormat} from "../search/index.js";
4 | import {fixParam} from "../search/no-premium.js";
5 |
6 | const PAGE_SIZE = 60
7 | const lang = config.pixiv.lang
8 |
9 | /**
10 | * @typedef {object} FollowUpdateParam
11 | * @property { 'illust' | 'novel' } type
12 | * @property {Restrict} [restrict='safe']
13 | * @property {Restrict} [mode]
14 | * @property {number} [start=0]
15 | * @property {number} [length=60]
16 | * @property {number} [p=1]
17 | * @property {Lang} [lang]
18 | */
19 |
20 | const defaultParam = {
21 | mode: 'all',
22 | type: 'illust',
23 | start: 0,
24 | length: 60,
25 | lang
26 | }
27 |
28 | /**
29 | * @param {FollowUpdateParam} param
30 | * @return {Promise}
31 | */
32 | export async function getFollowUpdateFormat(param) {
33 | param.mode = param.restrict ?? 'safe'
34 | param = {...defaultParam, ...param}
35 | if (param.p) return getFollowUpdate(param)
36 |
37 | const promiseArr = []
38 |
39 | param.start = +param.start
40 | param.length = +param.length
41 | let end = param.start + param.length
42 | let e = Math.ceil(end / PAGE_SIZE)
43 | let s = Math.ceil(param.start / PAGE_SIZE)
44 | delete param.start
45 | delete param.length
46 | for (let p = s + 1 ; p <= e; p++) {
47 | promiseArr.push(getFollowUpdate({...param, p}))
48 | }
49 | return (await Promise.all(promiseArr)).flat(1).slice(param.start, end)
50 | }
51 |
52 | /**
53 | * @param {FollowUpdateParam} param
54 | * @return {Promise}
55 | */
56 | export async function getFollowUpdate(param) {
57 | const type = param.type
58 | delete param.type
59 | const data = await pixivJsonFetch('/ajax/follow_latest/' + type, param)
60 | const target = data.thumbnails[type]
61 | const results = []
62 | for (const single of target) {
63 | results.push({
64 | id: +(single.id),
65 | title: single.title,
66 | cover: replaceURL(single.url),
67 | tags: single.tags,
68 | createTime: new Date(single.createDate).getTime(),
69 | updateTime: new Date(single.updateDate).getTime(),
70 | restrict: single.xRestrict === 0 ? 'safe' : 'r18',
71 | total: single.pageCount,
72 | author: {
73 | name: single.userName,
74 | id: +(single.userId)
75 | }
76 | })
77 | }
78 | return results
79 | }
--------------------------------------------------------------------------------
/core/api/module/follow/index.js:
--------------------------------------------------------------------------------
1 | export * from "./follow.js";
2 |
--------------------------------------------------------------------------------
/core/api/module/follow/update-push.js:
--------------------------------------------------------------------------------
1 | import config from "../../../../config.js"
2 | import {getFollowUpdate} from "./follow.js"
3 | import fs from "fs"
4 | import {WebSocketServer} from "ws"
5 |
6 | export const CACHE_FILE_NAME = 'follow-update-cache.json'
7 |
8 | export function initFollowUpdatePush() {
9 | const wss = new WebSocketServer({
10 | host: config.websocketServer.host,
11 | port: config.websocketServer.port,
12 | path: '/follow-update'
13 | })
14 |
15 | const cachePath = config.pixiv.cachePath;
16 | const file = cachePath + CACHE_FILE_NAME
17 |
18 | fs.mkdirSync(cachePath, {recursive: true})
19 |
20 | const proxyData = new Proxy(
21 | fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf-8')) : {
22 | illust: 0,
23 | novel: 0
24 | },
25 | {
26 | set(target, key, value) {
27 | target[key] = value
28 | clearTimeout(this.timer)
29 | this.timer = setTimeout(
30 | () => fs.writeFile(file, JSON.stringify(target), console.warn),
31 | 5000
32 | )
33 | return true
34 | }
35 | }
36 | )
37 |
38 | /** @param {ResultPreviewDTO[]} data */
39 | const pushUpdate = data => {
40 | if (!data || !data.length) return
41 | for (const client of wss.clients) {
42 | if (client.readyState !== WebSocket.OPEN) continue
43 | client.send(JSON.stringify(data))
44 | }
45 | }
46 |
47 | const cacheData = async type => {
48 | const p = 1
49 | const data = await getFollowUpdate({p, type, mode: 'all'})
50 | const lastId = proxyData[type]
51 | const index = data.findIndex(item => item.id === lastId)
52 | pushUpdate(data.slice(0, index))
53 | proxyData[type] = data[0].id
54 | }
55 |
56 | setInterval(() => {
57 | cacheData('illust')
58 | cacheData('novel')
59 | }, config.pixiv.followUpdate.interval)
60 |
61 | setImmediate(() => {
62 | cacheData('illust')
63 | cacheData('novel')
64 | })
65 | }
66 |
--------------------------------------------------------------------------------
/core/api/module/illust/README.md:
--------------------------------------------------------------------------------
1 | ## HTTP 请求
2 |
3 |
4 |
5 | GET
/illust/{id}
6 |
7 |
8 |
9 | ### 获取插画基本信息
10 |
11 | | 参数 | 类型 | 描述 |
12 | |------|----------|-----|
13 | | `id` | `number` | Pid |
14 |
15 | 示例: `http://127.0.0.1:1145/illust/104577879`
16 |
17 | 返回: `IllustDTO`
18 |
19 | | key | 类型 | 描述 |
20 | |-----------------|--------------|-------------|
21 | | `id` | `number` | Pid |
22 | | `title` | `string` | 标题 |
23 | | `total` | `number` | 图片数量 |
24 | | `images` | `ImageDTO[]` | 图片数组 |
25 | | `createTime` | `number` | 创建日期 |
26 | | `updateTime` | `number` | 更新日期 |
27 | | `tags` | `string[]` | 标签数组 |
28 | | `restrict` | `Restrict` | 限制等级 |
29 | | `description` | `string` | 介绍 |
30 | | `bookmarkCount` | `number` | 收藏数 (❤ 图标) |
31 | | `likeCount` | `number` | 喜欢数 (😊 图标) |
32 | | `viewCount` | `number` | 浏览量 (👁 图标) |
33 | | `author` | `AuthorDTO` | 作者 |
34 |
35 | ```
36 | {
37 | "id": 104577879,
38 | "title": "おでかけ",
39 | "total": 1,
40 | "images": [
41 | {
42 | "urls": {
43 | "small": "...",
44 | "regular": "...",
45 | "original": "..."
46 | },
47 | "width": 1620,
48 | "height": 2364
49 | }
50 | ],
51 | "createTime": 1673881211,
52 | "updateTime": 1673881211,
53 | "tags": [
54 | "東方",
55 | "..."
56 | ],
57 | "restrict": "safe",
58 | "description": "...",
59 | "bookmarkCount": 3802,
60 | "likeCount": 2333,
61 | "viewCount": 14240,
62 | "author": {
63 | "name": "久蒼穹",
64 | "id": 66038798
65 | }
66 | }
67 | ```
68 |
69 | ---
70 |
71 |
72 |
73 |
74 | GET
/illust/{id}/images
75 |
76 |
77 |
78 | ### 获取插画图片
79 |
80 | | 参数 | 类型 | 描述 |
81 | |------|----------|-----|
82 | | `id` | `number` | Pid |
83 |
84 | 示例: `http://127.0.0.1:1145/illust/104577879/images`
85 |
86 | 返回: `ImageDTO[]`
87 |
88 | ```
89 | [
90 | {
91 | "urls": {
92 | "small": "...",
93 | "regular": "...",
94 | "original": "..."
95 | },
96 | "width": 1620,
97 | "height": 2364
98 | }
99 | ]
100 | ```
101 |
102 | ---
103 |
104 |
105 |
106 |
107 | GET
/illust/{id}/images/{page}
108 |
109 |
110 |
111 | ### 获取插画图片(指定页码数)
112 |
113 | | 参数 | 类型 | 描述 |
114 | |--------|----------|-----|
115 | | `id` | `number` | Pid |
116 | | `page` | `number` | 页码数 |
117 |
118 | 示例: `http://127.0.0.1:1145/illust/104577879/images/1`
119 |
120 | 返回: `ImageDTO`
121 |
122 | ```
123 | {
124 | "urls": {
125 | "small": "...",
126 | "regular": "...",
127 | "original": "..."
128 | },
129 | "width": 1620,
130 | "height": 2364
131 | }
132 | ```
133 |
134 | ---
135 |
136 |
137 |
138 |
139 | GET
/illust/{id}/recommend
140 |
141 |
142 |
143 | ### 获取插画相关作品信息
144 |
145 | | 参数 | 类型 | 描述 | 默认值 |
146 | |--------|----------|-----|------|
147 | | `id` | `number` | Pid | N/A |
148 | | `size` | `number` | 容量 | `20` |
149 |
150 | 示例: `http://127.0.0.1:1145/illust/105001750/recommend?size=10`
151 |
152 | 返回: `IllustRecommendDTO[]`
153 |
154 | ```
155 | [
156 | {
157 | "id": 102773441,
158 | "title": "諏訪子",
159 | "type": "illust",
160 | "tags": [
161 | "東方",
162 | "東方Project",
163 | "洩矢諏訪子",
164 | ...
165 | ],
166 | "cover": "...",
167 | "restrict": "safe",
168 | "createTime": 1668351819000,
169 | "updateTime": 1668351819000,
170 | "total": 1,
171 | "author": {
172 | "id": 49675420,
173 | "name": "かめぱすた"
174 | }
175 | },
176 | ...
177 | ]
178 | ```
179 |
180 |
181 |
182 |
183 |
184 | GET
/illust/{id}/recommend/ids
185 |
186 |
187 |
188 | ### 获取插画相关作品ID
189 |
190 | | 参数 | 类型 | 描述 | 默认值 |
191 | |--------|----------|-----|------|
192 | | `id` | `number` | Pid | N/A |
193 | | `size` | `number` | 容量 | `20` |
194 |
195 | 示例: `http://127.0.0.1:1145/illust/105001750/recommend/ids?size=10`
196 |
197 | 返回: `number[]`
198 |
199 | ```
200 | [
201 | 104999610,
202 | 104104387,
203 | 104685723,
204 | 105373312,
205 | 104920874,
206 | 105515966,
207 | 104385177,
208 | 104678965,
209 | 105587193,
210 | 105669998
211 | ]
212 | ```
213 |
214 |
--------------------------------------------------------------------------------
/core/api/module/illust/index.js:
--------------------------------------------------------------------------------
1 | export * from './pid.js'
2 | export * from '../manga/manga-pid.js'
--------------------------------------------------------------------------------
/core/api/module/illust/pid.js:
--------------------------------------------------------------------------------
1 | import { pixivJsonFetch, replaceURL } from '../../../pixiv-fetch/index.js'
2 | /**
3 | * @typedef {BaseItemInfoDTO & {
4 | * total: number,
5 | * images: ImageDTO[]
6 | * }} IllustDTO
7 | */
8 |
9 | /**
10 | * @typedef {object} ImageDTO
11 | *
12 | * @property {object} urls
13 | * @property {string} urls.small
14 | * @property {string} urls.regular
15 | * @property {string} urls.original
16 | * @property {number} width
17 | * @property {number} height
18 | */
19 |
20 | /**
21 | * @param {number} id
22 | * @return {Promise}
23 | */
24 | export async function getPidIllust(id) {
25 | const illust = await pixivJsonFetch(
26 | '/touch/ajax/illust/details?illust_id=' + id
27 | )
28 | return getBaseIllustDTO(illust.illust_details)
29 | }
30 |
31 | /**
32 | * @param {number} id
33 | * @return {Promise}
34 | */
35 | export async function getPidImageList(id) {
36 | return await pixivJsonFetch(
37 | `/ajax/illust/${id}/pages`
38 | )
39 | }
40 |
41 | /**
42 | * @param {number} id
43 | * @param {number} page index + 1 of illust
44 | * @return {Promise}
45 | */
46 | export async function getPidImage(id, page) {
47 | const imgs = await getPidImageList(id)
48 | try {
49 | return imgs[page - 1]
50 | } catch {
51 | throw new RangeError(`no page ${page} in illust ${id}`)
52 | }
53 | }
54 |
55 | /** @return {IllustDTO} */
56 | export function getBaseIllustDTO(details) {
57 | const authorDetails = details.author_details
58 | const pages = +(details.page_count)
59 | /** @type { ImageDTO[] } */
60 | const images = []
61 | if (pages === 1) {
62 | images.push({
63 | urls: {
64 | small: replaceURL(details.url_s),
65 | regular: replaceURL(details.url),
66 | original: replaceURL(details.url_big)
67 | },
68 | width: +(details.illust_images[0].illust_image_width),
69 | height: +(details.illust_images[0].illust_image_height)
70 | })
71 | } else {
72 | for (let i = 0; i < pages; i++) {
73 | const {
74 | illust_image_width: width,
75 | illust_image_height: height
76 | } = details.illust_images[i]
77 | const o = details.manga_a[i]
78 | images.push({
79 | urls: {
80 | small: replaceURL(o.url_small),
81 | regular: replaceURL(o.url),
82 | original: replaceURL(o.url_big)
83 | },
84 | width: +(width),
85 | height: +(height)
86 | })
87 | }
88 | }
89 | return {
90 | id: +(details.id),
91 | title: details.title,
92 | total: pages,
93 | images,
94 | createTime: details.create_timestamp ?? details.upload_timestamp,
95 | updateTime: details.upload_timestamp,
96 | tags: details.tags,
97 | restrict: details.x_restrict == 0 ? 'safe' : 'r18',
98 | description: details.comment,
99 | bookmarkCount: details.bookmark_user_total,
100 | likeCount: +(details.rating_count),
101 | viewCount: +(details.rating_view),
102 | author: {
103 | name: authorDetails.user_name,
104 | id: +(authorDetails.user_id)
105 | }
106 | }
107 | }
--------------------------------------------------------------------------------
/core/api/module/illust/recommend.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {BaseRecommendDTO & {type: string}} IllustRecommendDTO
3 | */
4 |
5 | import {pixivJsonFetch} from "../../../pixiv-fetch/index.js";
6 | import replaceUrl from "../../../pixiv-fetch/replace-url.js";
7 |
8 | /**
9 | * @param {number} id
10 | * @param {number} [size=20]
11 | * @return {Promise}
12 | */
13 | export async function getPidRecommend(id, size = 20) {
14 | const data = await pixivJsonFetch(`/ajax/illust/${id}/recommend/init?limit=${size}`)
15 | /** @type {IllustRecommendDTO[]} */
16 | const list = []
17 | let len = data.illusts.length
18 | for (let i = 0; i < len; i++) {
19 | const single = data.illusts[i]
20 | if (!single.id) continue
21 | list.push({
22 | id: +(single.id),
23 | title: single.title,
24 | type: single.type,
25 | tags: single.tags,
26 | cover: replaceUrl(single.url),
27 | restrict: single.xRestrict === 0 ? 'safe' : 'r18',
28 | createTime: new Date(single.createDate).getTime(),
29 | updateTime: new Date(single.updateDate).getTime(),
30 | total: single.pageCount,
31 | author: {
32 | id: +(single.userId),
33 | name: single.userName
34 | }
35 | })
36 | }
37 | return list
38 | }
39 |
40 | export async function getPidRecommendIds(id, size = 20) {
41 | const data = await pixivJsonFetch(`/ajax/illust/${id}/recommend/init?limit=${size}`)
42 | return data.nextIds.slice(0, size).map(i => +(i))
43 | }
--------------------------------------------------------------------------------
/core/api/module/index.js:
--------------------------------------------------------------------------------
1 | /** @typedef {import('../../../../config.js').Lang} Lang */
2 |
3 | /**
4 | * @typedef {object} BaseItemInfoDTO
5 | *
6 | * @property {number} id
7 | * @property {string} title
8 | * @property {string} description
9 | * @property {number} createTime create timestamp
10 | * @property {number} updateTime update timestamp
11 | * @property {string[]} tags
12 | * @property {Restrict} restrict
13 | * @property {number} bookmarkCount ❤ icon
14 | * @property {number} likeCount 😊 icon
15 | * @property {number} viewCount 👁 icon
16 | */
17 |
18 | /**
19 | * @typedef {object} BaseItemServiceInfoDTO
20 | *
21 | * @property {number} id
22 | * @property {string} title
23 | * @property {number} order
24 | * @property {BaseItemHeadInfoDTO} prev
25 | * @property {BaseItemHeadInfoDTO} next
26 | */
27 |
28 | /** @typedef {{id: number, title: string}} BaseItemHeadInfoDTO */
29 |
30 | /**
31 | * @typedef {'all'|'safe'|'r18'} Restrict
32 | * @typedef { {name: string, id: number} } AuthorDTO
33 | */
34 |
35 | /**
36 | * @typedef {object} ResultPreviewDTO
37 | * @property {number} id
38 | * @property {string} title
39 | * @property {string} cover
40 | * @property {string[]} tags
41 | * @property {number} createTime
42 | * @property {number} updateTime
43 | * @property {Restrict} restrict
44 | * @property {number} total
45 | * @property {AuthorDTO} author
46 | */
47 |
48 | /**
49 | * @typedef {ResultPreviewDTO} BaseRecommendDTO
50 | */
51 |
52 | /** @typedef {'artwork'|'illust'|'gif'|'illust_and_gif'|'manga'|'novel'} WorkType */
--------------------------------------------------------------------------------
/core/api/module/manga/README.md:
--------------------------------------------------------------------------------
1 | ## HTTP 请求
2 |
3 |
4 |
5 | GET
/manga/{id}
6 |
7 |
8 |
9 | ### 获取漫画基本信息
10 |
11 | | 参数 | 类型 | 描述 |
12 | | ---- | -------- | ---- |
13 | | `id` | `number` | Pid |
14 |
15 | 示例: `http://127.0.0.1:1145/manga/98019984`
16 |
17 | 返回: `MangaDTO`
18 |
19 | | key | 类型 | 描述 |
20 | | -------- | --------------- | --------------------------------------- |
21 | | ... | `IllustDTO` | 参见 [`IllustDTO`](../illust/README.md) |
22 | | `series` | `ItemSeriesDTO` | 系列数据 |
23 |
24 | ```
25 | {
26 | "id": 98019984,
27 | "title": "へっぽこ吸血鬼ちゃんは血が欲しい",
28 | "total": 5,
29 | "images": [
30 | {
31 | "urls": {
32 | "small": "...",
33 | "regular": "...",
34 | "original": "..."
35 | },
36 | "width": 800,
37 | "height": 1301
38 | },
39 | ...
40 | ],
41 | "createTime": 1651395658,
42 | "updateTime": 1651395658,
43 | "tags": [
44 | "漫画",
45 | "..."
46 | ],
47 | "restrict": "safe",
48 | "description": "...",
49 | "bookmarkCount": 16237,
50 | "likeCount": 14008,
51 | "viewCount": 327481,
52 | "author": {
53 | "name": "にいち",
54 | "id": 1035047
55 | },
56 | "series": {
57 | "id": 718,
58 | "title": "少女アラカルト",
59 | "order": 67,
60 | "prev": {
61 | "id": "97536981",
62 | "title": "花の季節"
63 | },
64 | "next": null
65 | }
66 | }
67 | ```
68 | ---
69 |
--------------------------------------------------------------------------------
/core/api/module/manga/index.js:
--------------------------------------------------------------------------------
1 | export * from './pid.js'
--------------------------------------------------------------------------------
/core/api/module/manga/manga-pid.js:
--------------------------------------------------------------------------------
1 | import { pixivJsonFetch } from "../../../pixiv-fetch/index.js"
2 | import { getBaseIllustDTO } from "../illust/pid.js"
3 | /** @typedef { import('../illust/pid.js').IllustDTO } IllustDTO */
4 |
5 | /**
6 | * @typedef {IllustDTO & {
7 | * series: BaseItemServiceInfoDTO
8 | * }} MangaDTO
9 | */
10 |
11 | /**
12 | * @param {number} id
13 | * @return {Promise}
14 | */
15 | export async function getPidManga(id) {
16 | const illust = await pixivJsonFetch(
17 | '/touch/ajax/illust/details?illust_id=' + id
18 | )
19 | const series = illust.illust_details.series
20 | return {
21 | ...getBaseIllustDTO(illust.illust_details),
22 | series: !series ? null : {
23 | id: +(series.id),
24 | title: series.title,
25 | order: +(series.content_order),
26 | prev: !series.prev_illust ? null : {
27 | id: series.prev_illust.illust_id,
28 | title: series.prev_illust.illust_title
29 | },
30 | next: !series.next_illust ? null : {
31 | id: series.next_illust.illust_id,
32 | title: series.next_illust.illust_title
33 | }
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/core/api/module/manga/manga-series-pid.js:
--------------------------------------------------------------------------------
1 | //TODO
--------------------------------------------------------------------------------
/core/api/module/novel/README.md:
--------------------------------------------------------------------------------
1 | ## HTTP 请求
2 |
3 |
4 |
5 | GET
/novel/{id}
6 |
7 |
8 |
9 | ### 获取小说基本信息
10 |
11 | | 参数 | 类型 | 描述 |
12 | | ---- | -------- | ---- |
13 | | `id` | `number` | Pid |
14 |
15 | 示例: `http://127.0.0.1:1145/novel/15927906`
16 |
17 | 返回: `NovelDTO`
18 |
19 | | key | 类型 | 描述 |
20 | | --------------- | --------------- | --------------- |
21 | | `id` | `number` | Pid |
22 | | `title` | `string` | 标题 |
23 | | `description` | `string` | 介绍 |
24 | | `tags` | `string[]` | 标签数组 |
25 | | `lang` | `Language` | 语言枚举 |
26 | | `restrict` | `Restrict` | 限制等级 |
27 | | `charCount` | `number` | 字节数 |
28 | | `wordCount` | `number` | 词数 |
29 | | `createTime` | `number` | 创建日期 |
30 | | `updateTime` | `number` | 更新日期 |
31 | | `readingTime` | `number` | 阅读时间 |
32 | | `bookmarkCount` | `number` | 收藏数 (❤ 图标) |
33 | | `likeCount` | `number` | 喜欢数 (😊 图标) |
34 | | `viewCount` | `number` | 浏览量 (👁 图标) |
35 | | `cover` | `string` | 封面图片 |
36 | | `series` | `ItemSeriesDTO` | 系列数据 |
37 | | `author` | `AuthorDTO` | 作者 |
38 | | `content` | `string` | 正文 |
39 |
40 | ```
41 | {
42 | "id": 19115002,
43 | "title": "第2話『はじめての登下校』",
44 | "description": "...",
45 | "tags": [
46 | "百合",
47 | "..."
48 | ],
49 | "lang": "ja",
50 | "restrict": "safe",
51 | "charCount": 4282,
52 | "wordCount": 1888,
53 | "readingTime": 513,
54 | "bookmarkCount": 11,
55 | "likeCount": 9,
56 | "viewCount": 353,
57 | "cover": "...",
58 | "series": {
59 | "id": 9943394,
60 | "title": "言語チート転生〜幼女VTuberは世界を救う〜",
61 | "order": 2,
62 | "prev": {
63 | "id": 19114998,
64 | "title": "第1話『終わりとはじまり』"
65 | },
66 | "next": {
67 | "id": 19115003,
68 | "title": "第3話『お姉ちゃんのお願い』"
69 | }
70 | },
71 | "author": {
72 | "id": 90197256,
73 | "name": "可愛ケイ@VTuber兼小説家"
74 | },
75 | "content": "..."
76 | }
77 | ```
78 | ---
79 |
80 |
81 |
82 |
83 | GET
/novel/series/{id}
84 |
85 |
86 |
87 | ### 获取小说系列基本信息
88 |
89 | | 参数 | 类型 | 描述 |
90 | | ---- | -------- | ---- |
91 | | `id` | `number` | Pid |
92 |
93 | 示例: `http://127.0.0.1:1145/novel/series/9943394`
94 |
95 | 返回: `NovelSericeInfoDTO`
96 |
97 | | key | 类型 | 描述 |
98 | | -------- | -------------------- | ----------------------------- |
99 | | `...` | `NovelSericeInfoDTO` | 参考下文 `NovelSericeInfoDTO` |
100 | | `novels` | `NovelInfoDTO[]` | 参考下文 `NovelInfoDTO` |
101 |
102 | ```
103 | {
104 | "id": 9943394,
105 | "title": "言語チート転生〜幼女VTuberは世界を救う〜",
106 | "tags": [
107 | "百合",
108 | "..."
109 | ],
110 | "lang": "ja",
111 | "cover": "...",
112 | "restrict": "safe",
113 | "concluded": true,
114 | "total": 45,
115 | "charCount": 128761,
116 | "wordCount": 58687,
117 | "readingTime": 15451,
118 | "createTime": 1673809337,
119 | "updateTime": 1674504831,
120 | "author": {
121 | "name": "可愛ケイ@VTuber兼小説家",
122 | "id": 90197256
123 | },
124 |
125 | "novels": [
126 | {
127 | "id": 19114998,
128 | "title": "第1話『終わりとはじまり』",
129 | "description": "...",
130 | "tags": [
131 | "百合",
132 | "..."
133 | ],
134 | "restrict": "safe",
135 | "wordCount": 1095,
136 | "readingTime": 296000,
137 | "createTime": 1673810153000,
138 | "updateTime": 1674364856000,
139 | "bookmarkCount": 48,
140 | "author": {
141 | "name": "可愛ケイ@VTuber兼小説家",
142 | "id": 90197256
143 | }
144 | },
145 | ...
146 | ]
147 | }
148 | ```
149 | ---
150 |
151 |
152 |
153 |
154 | GET
/novel/series/{id}/info
155 |
156 |
157 |
158 | ### 获取小说系列基本信息(不含作品)
159 |
160 | | 参数 | 类型 | 描述 |
161 | | ---- | -------- | ---- |
162 | | `id` | `number` | Pid |
163 |
164 | 示例: `http://127.0.0.1:1145/novel/series/9943394/info`
165 |
166 | 返回: `NovelSericeInfoDTO`
167 |
168 | | key | 类型 | 描述 |
169 | | ------------- | ----------- | -------- |
170 | | `id` | `number` | Pid |
171 | | `title` | `string` | 标题 |
172 | | `description` | `string` | 介绍 |
173 | | `tags` | `string[]` | 标签数组 |
174 | | `lang` | `Language` | 语言枚举 |
175 | | `cover` | `string` | 封面图片 |
176 | | `restrict` | `Restrict` | 限制等级 |
177 | | `concluded` | `boolean` | 完结状态 |
178 | | `total` | `number` | 总篇数 |
179 | | `charCount` | `number` | 字节数 |
180 | | `wordCount` | `number` | 词数 |
181 | | `createTime` | `number` | 创建日期 |
182 | | `updateTime` | `number` | 更新日期 |
183 | | `readingTime` | `number` | 阅读时间 |
184 | | `author` | `AuthorDTO` | 作者 |
185 |
186 | ```
187 | {
188 | "id": 9943394,
189 | "title": "言語チート転生〜幼女VTuberは世界を救う〜",
190 | "description": "...",
191 | "tags": [
192 | "百合",
193 | "..."
194 | ],
195 | "lang": "ja",
196 | "cover": "...",
197 | "restrict": "safe",
198 | "concluded": true,
199 | "total": 45,
200 | "charCount": 128761,
201 | "wordCount": 58687,
202 | "readingTime": 15451,
203 | "createTime": 1673809337,
204 | "updateTime": 1674504831,
205 | "author": {
206 | "name": "可愛ケイ@VTuber兼小説家",
207 | "id": 90197256
208 | }
209 | }
210 | ```
211 | ---
212 |
213 |
214 |
215 |
216 | GET
/novel/series/{id}/content
217 |
218 |
219 |
220 | ### 获取小说系列作品表列
221 |
222 | | 参数 | 类型 | 描述 |
223 | | ---- | -------- | ---- |
224 | | `id` | `number` | Pid |
225 |
226 | 示例: `http://127.0.0.1:1145/novel/series/9943394/content`
227 |
228 | 返回: `NovelInfoDTO[]`
229 |
230 | *参考上文 `NovelDTO`*
231 |
232 | ```
233 | [
234 | {
235 | "id": 19114998,
236 | "title": "第1話『終わりとはじまり』",
237 | "description": "...",
238 | "tags": [
239 | "百合",
240 | "..."
241 | ],
242 | "restrict": "safe",
243 | "wordCount": 1095,
244 | "readingTime": 296000,
245 | "createTime": 1673810153000,
246 | "updateTime": 1674364856000,
247 | "bookmarkCount": 48,
248 | "author": {
249 | "name": "可愛ケイ@VTuber兼小説家",
250 | "id": 90197256
251 | }
252 | },
253 | ...
254 | ]
255 | ```
256 | ---
257 |
--------------------------------------------------------------------------------
/core/api/module/novel/index.js:
--------------------------------------------------------------------------------
1 | export * from './pid.js'
2 |
3 | export {
4 | default as getPidNovelSeries,
5 | getPidNovelSeriesInfo,
6 | getPidNovelSeriesContent
7 | } from './series-pid.js'
--------------------------------------------------------------------------------
/core/api/module/novel/pid.js:
--------------------------------------------------------------------------------
1 | import { pixivJsonFetch, replaceURL } from "../../../pixiv-fetch/index.js";
2 | /** @typedef {import('../../../../config.js').Lang} Lang */
3 |
4 | /**
5 | * @typedef {BaseItemInfoDTO & {
6 | * lang: Lang,
7 | * cover: string,
8 | * restrict: Restrict,
9 | * charCount: number,
10 | * wordCount: number,
11 | * readingTime: number
12 | * }} NovelInfoDTO
13 | */
14 |
15 | /**
16 | * @typedef {NovelInfoDTO & {
17 | * content: string,
18 | * series: BaseItemServiceInfoDTO
19 | * }} NovelItemDTO
20 | */
21 |
22 | export async function getPidNovel(id) {
23 | const novel = await pixivJsonFetch('/touch/ajax/novel/details?novel_id=' + id)
24 | const details = novel.novel_details
25 | const series = details.series
26 | return {
27 | id: +(id),
28 | title: details.title,
29 | description: details.comment,
30 | tags: details.tags,
31 | lang: details.language,
32 | restrict: details.x_restrict == 0 ? 'safe' : 'r18',
33 | charCount: +(details.character_count),
34 | wordCount: details.word_count,
35 | readingTime: details.reading_time,
36 | createTime: details.create_time,
37 | updateTime: details.update_time,
38 | bookmarkCount: details.bookmark_count,
39 | likeCount: +(details.rating_count),
40 | viewCount: +(details.rating_view),
41 |
42 | cover: replaceURL(details.url),
43 | series: !series ? null : {
44 | id: +(series.id),
45 | title: series.title,
46 | order: series.content_order,
47 | prev: !series.prev_novel ? null : {
48 | id: series.prev_novel.id,
49 | title: series.prev_novel.title
50 | },
51 | next: !series.next_novel ? null : {
52 | id: series.next_novel.id,
53 | title: series.next_novel.title
54 | }
55 | },
56 | author: {
57 | id: +(details.user_id),
58 | name: details.user_name
59 | },
60 | content: details.text
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/core/api/module/novel/series-pid.js:
--------------------------------------------------------------------------------
1 | import { pixivJsonFetch, replaceURL } from "../../../pixiv-fetch/index.js";
2 | /** @typedef {import('../../../../config.js').Lang} Lang */
3 | /** @typedef {import('../illust/pid.js').AuthorDTO} AuthorDTO */
4 | /** @typedef {import('./pid.js').NovelInfoDTO} NovelInfoDTO */
5 |
6 | /**
7 | * @typedef {NovelInfoDTO & {
8 | * total: number,
9 | * concluded: boolean
10 | * }} NovelSeriesInfoDTO
11 | */
12 |
13 | /**
14 | * @param {number} id
15 | * @return {Promise}
16 | */
17 | export async function getPidNovelSeriesInfo(id) {
18 | const series = await pixivJsonFetch('/ajax/novel/series/' + id)
19 | return {
20 | id: +(id),
21 | title: series.title,
22 | description: series.caption,
23 | tags: series.tags,
24 | lang: series.language,
25 | cover: replaceURL(series.cover.urls.original),
26 | restrict: series.xRestrict == 0 ? 'safe' : 'r18',
27 |
28 | concluded: series.isConcluded,
29 | total: series.publishedContentCount,
30 | charCount: series.publishedTotalCharacterCount,
31 | wordCount: series.publishedTotalWordCount,
32 | readingTime: series.publishedReadingTime,
33 | createTime: series.createdTimestamp,
34 | updateTime: series.updatedTimestamp,
35 |
36 | author: {
37 | name: series.userName,
38 | id: +(series.userId)
39 | }
40 | }
41 | }
42 |
43 | /**
44 | * @param {number} id
45 | */
46 | export async function getPidNovelSeriesContent(id) {
47 | const { thumbnails } = await pixivJsonFetch('/ajax/novel/series_content/' + id, {
48 | limit: 30,
49 | last_order: 0,
50 | order_by: 'asc'
51 | })
52 | const resultArr = []
53 | for (const singleNovel of thumbnails.novel) {
54 | resultArr.push({
55 | id: +(singleNovel.id),
56 | title: singleNovel.title,
57 | description: singleNovel.description,
58 | tags: singleNovel.tags,
59 | restrict: singleNovel.xRestrict == 0 ? 'safe' : 'r18',
60 | charCount: singleNovel.characterCount,
61 | wordCount: singleNovel.wordCount,
62 | readingTime: singleNovel.readingTime * 1000,
63 | createTime: new Date(singleNovel.createDate).getTime(),
64 | updateTime: new Date(singleNovel.updateDate).getTime(),
65 | bookmarkCount: singleNovel.bookmarkCount,
66 | author: {
67 | name: singleNovel.userName,
68 | id: +(singleNovel.userId)
69 | }
70 | })
71 | }
72 | return resultArr
73 | }
74 |
75 | export default async function getPidNovelSeries(id) {
76 | const result = await Promise.all([
77 | getPidNovelSeriesInfo(id),
78 | getPidNovelSeriesContent(id)
79 | ])
80 | return {
81 | ...result[0],
82 | novels: result[1]
83 | }
84 | }
--------------------------------------------------------------------------------
/core/api/module/search/README.md:
--------------------------------------------------------------------------------
1 | ## HTTP 请求
2 |
3 |
4 |
5 | POST
/search
6 |
7 |
8 | ### `SearchParam`:
9 |
10 | | 参数 | 类型 | 描述 | 默认值 |
11 | | ---------- | -------------- | ---------- | -------- |
12 | | `word` | `string` | 搜索词 | 必填 |
13 | | `type` | `SearchType` | 搜索类型 | `illust` |
14 | | `template` | `TemplateType` | 预设模板 | 无 |
15 | | `mode` | `SearchMode` | 搜索模式 | `tag` |
16 | | `order` | `SearchOrder` | 排序方法 | `date` |
17 | | `blt` | `number` | 最少收藏数 | `0` |
18 | | `restrict` | `Restrict` | 限制等级 | `safe` |
19 | | `start` | `number` | 起始索引 | `0` |
20 | | `length` | `number` | 索引长度 | `60` |
21 | | `lang` | `Lang` | 语言 | 配置文件 |
22 |
23 | ### ENUM:
24 |
25 | ##### `SearchType`
26 |
27 | 限定搜索类型
28 |
29 | | Name | Description |
30 | | ---------------- | -------------------- |
31 | | `illust` | 插画 |
32 | | `gif` | 动态插画 (GIF) |
33 | | `illust_and_gif` | 插画与动态插画 |
34 | | `manga` | 漫画 |
35 | | `artwork` | 插画, 动态插画与漫画 |
36 | | `novel` | 小说 |
37 |
38 | ##### `TemplateType`
39 |
40 | 使用预设模板
41 |
42 | | Name | Description |
43 | | --------- |--------------------|
44 | | `top` | 收数数降序排列 |
45 | | `default` | 时间倒序, 收藏数不少于`1000` |
46 | | `enhance` | 时间倒序, 收藏数不少于`50` |
47 |
48 | ##### `SearchMode`
49 |
50 | 指定搜索模式
51 |
52 | | Name | Description |
53 | | --------- | ------------------ |
54 | | `tag` | 标签 部分匹配 |
55 | | `full` | 标签 完全匹配 |
56 | | `content` | 标题/正文 部分匹配 |
57 |
58 | ##### `SearchOrder`
59 |
60 | 指定排序方式
61 |
62 | | Name | Description |
63 | | --------- | ----------- |
64 | | `date` | 日期倒序 |
65 | | `popular` | 收藏数降序 |
66 |
67 | ##### `Restrict`
68 |
69 | 指定限制级
70 |
71 | | Name | Description |
72 | | ------ | -------------- |
73 | | `safe` | 全年龄向 |
74 | | `r18` | `R-18` `R-18G` |
75 | | `all` | 全部 |
76 |
77 | 示例请求: `http://127.0.0.1:1145/search`
78 |
79 | ```
80 | {
81 | "word": "東方Project",
82 | "type": "illust",
83 | "template": "default",
84 | "length": 20
85 | }
86 | ```
87 |
88 | 返回: `SearchResultDTO`
89 |
90 | | key | 类型 | 描述 |
91 | | --------- | -------------------- | ------------ |
92 | | `results` | `ResultPreviewDTO[]` | 作品预览数组 |
93 | | `total` | `number` | 作品总数 |
94 |
95 | ```
96 | {
97 | "results": [
98 | {
99 | "id": 105680390,
100 | "title": "天衣無縫",
101 | "cover": "...",
102 | "tags": [
103 | "東方",
104 | "東方緋想天",
105 | ...
106 | ],
107 | "createTime": 1677250811000,
108 | "updateTime": 1677250811000,
109 | "restrict": 'safe',
110 | "total": 1,
111 | "author": {
112 | "name": "久蒼穹",
113 | "id": 66038798
114 | }
115 | }
116 | ],
117 | "relatedTags": [
118 | "東方Project",
119 | "東方Project3000users入り",
120 | "博麗霊夢",
121 | ...
122 | ],
123 | "total": 19590
124 | }
125 | ```
126 |
127 |
--------------------------------------------------------------------------------
/core/api/module/search/index.js:
--------------------------------------------------------------------------------
1 | // 插画: illustrations -> type: illust_and_ugoira
2 | // 漫画: manga -> type: manga
3 | // 插画和漫画: artworks -> type: all
4 | // 小说: novels -> notype, work_lang: lang
5 |
6 | /** @typedef {'date'|'popular'} SearchOrder */
7 | /** @typedef {'date_d'|'popular_male_d'} RawSearchOrder */
8 |
9 | /** @typedef {'tag'|'full'|'content'} SearchMode */
10 | /** @typedef {'s_tag'|'s_tag_full'|'s_tc'} RawSearchMode */
11 |
12 | /** @typedef {'all'|'illust'|'ugoira'|'illust_and_ugoira'|'manga'} RawSearchType */
13 |
14 | /** @typedef {'top'|'default'|'enhance'} TemplateType */
15 |
16 | /** @typedef {Object} SearchTypeInfoMap */
17 | /** @typedef {{path: SearchPath, type: RawSearchType | '', name: string}} SearchTypeInfo */
18 | /** @typedef {'illustrations'|'artworks'|'manga'|'novels'} SearchPath */
19 |
20 | /**
21 | * @typedef {object} SearchParam
22 | * @property {string} word
23 | * @property {TemplateType} [template]
24 | * @property {SearchOrder} [order]
25 | * @property {number} [blt=0]
26 | * @property {SearchMode|RawSearchMode} [mode]
27 | * @property {WorkType|RawSearchType} [type]
28 | * @property {Restrict} [restrict='safe']
29 | * @property {number} [start=0]
30 | * @property {number} [length=60]
31 | * @property {number} [p=1]
32 | * @property {Lang} [lang]
33 | */
34 |
35 | /**
36 | * @typedef {object} RawSearchParam
37 | * @property {string} word
38 | * @property {RawSearchOrder} order
39 | * @property {number} blt
40 | * @property {RawSearchMode} s_mode
41 | * @property {RawSearchType} type
42 | * @property {Restrict} mode
43 | * @property {number} p
44 | * @property {Lang} lang
45 | */
46 |
47 | /**
48 | * @typedef {object} SearchResultDTO
49 | * @property {ResultPreviewDTO[]} results
50 | * @property {string[]} relatedTags
51 | * @property {number} total
52 | */
53 |
54 | export {search, searchFormat} from './search.js'
--------------------------------------------------------------------------------
/core/api/module/search/no-premium.js:
--------------------------------------------------------------------------------
1 | // import {pixivJsonFetch} from "../../../pixiv-fetch/index.js";
2 | import {search} from "./search.js";
3 |
4 | const arr = [100, 250, 300, 500, 1000, 3000, 5000, 10000, 20000, 30000]
5 | const map = new Map(arr.reduceRight(
6 | (res, i) =>
7 | [...res, [i, res.push([i, i + 'users入り']) && res.slice(-2).map(s => s[1]).join(' OR ')]],
8 | []
9 | ).map(s => [s[0], `(${s[1]})`]))
10 |
11 | /**
12 | * @param {SearchParam} param
13 | * @param {SearchTypeInfo} typeInfo
14 | */
15 | export async function fixParam(param, typeInfo) {
16 | // let singleWordFlag = !param.word.includes(' ')
17 | param.word += ' ' + map.get(param.blt) ?? map.get(
18 | arr.find(n => !(n - param.blt & 0x80000000)) ?? 30000
19 | )
20 | // const data = await searchNoPremium({...param, p: 1}, typeInfo)
21 | // let mainTag = data.relatedTags.find(w => w.endsWith(extraTag))
22 | // if (mainTag === param.word.replace(' ', '')) return data.raw
23 | // if (singleWordFlag) param.word = mainTag
24 | // console.log(param.word)
25 | return search(typeInfo.path, typeInfo.name, param)
26 | }
27 |
28 | // /**
29 | // * @param {SearchParam} param
30 | // * @param {SearchTypeInfo} typeInfo
31 | // * @return {Promise<{relatedTags: string[], raw: SearchResultDTO}>}
32 | // */
33 | // async function searchNoPremium(param, typeInfo) {
34 | // const data = await pixivJsonFetch(`/ajax/search/${typeInfo.path}/${encodeURIComponent(param.word)}`, param)
35 | // const results = []
36 | // for (const single of data[typeInfo.name].data) {
37 | // results.push({
38 | // id: +(single.id),
39 | // title: single.title,
40 | // cover: single.url,
41 | // tags: single.tags,
42 | // createTime: new Date(single.createDate).getTime(),
43 | // updateTime: new Date(single.updateDate).getTime(),
44 | // author: {
45 | // name: single.userName,
46 | // id: +(single.userId)
47 | // }
48 | // })
49 | // }
50 | // return {
51 | // relatedTags: data.relatedTags,
52 | // raw: {
53 | // results,
54 | // total: data[typeInfo.name].total
55 | // }
56 | // }
57 | // }
--------------------------------------------------------------------------------
/core/api/module/search/search.js:
--------------------------------------------------------------------------------
1 | import {pixivJsonFetch, replaceURL} from '../../../pixiv-fetch/index.js'
2 | import config from '../../../../config.js'
3 | import {fixParam} from "./no-premium.js";
4 |
5 | /** @private @readonly */
6 | const PAGE_SIZE = 60
7 | const lang = config.pixiv.lang
8 | const noPremium = !config.pixiv.premium
9 |
10 | const defaultParams = {
11 | word: '',
12 | order: 'date_d',
13 | start: 0,
14 | length: PAGE_SIZE,
15 | blt: 0,
16 | s_mode: 's_tag',
17 | type: 'illust',
18 | mode: 'safe',
19 | lang
20 | }
21 |
22 | const templates = {
23 | top: {
24 | order: 'popular_male_d',
25 | blt: 0
26 | },
27 | default: {
28 | order: 'date_d',
29 | blt: 1000
30 | },
31 | enhance: {
32 | order: 'date_d',
33 | blt: 50
34 | }
35 | }
36 |
37 | const searchOrders = {
38 | date: 'date_d', popular: 'popular_male_d'
39 | }
40 |
41 | const searchModes = {
42 | tag: 's_tag', full: 's_tag_full', content: 's_tc'
43 | }
44 |
45 | /** @type {SearchTypeInfoMap} */
46 | const searchTypes = {
47 | artwork: {path: 'artworks', type: 'all', name: 'illustManga'},
48 | illust: {path: 'illustrations', type: 'illust', name: 'illust'},
49 | gif: {path: 'illustrations', type: 'ugoira', name: 'illust'},
50 | illust_and_gif: {path: 'illustrations', type: 'illust_and_ugoira', name: 'illust'},
51 | manga: {path: 'manga', type: 'manga', name: 'manga'},
52 | novel: {path: 'novels', type: '', name: 'novel'}
53 | }
54 |
55 | /**
56 | * @param {SearchParam} param
57 | * @return {Promise}
58 | */
59 | export async function searchFormat(param) {
60 | param = {...defaultParams, ...templates[param.template], ...param}
61 | param.order = searchOrders[param.order] ?? param.order
62 | param.s_mode = searchModes[param.mode] ?? param.mode
63 | param.mode = param.restrict ?? 'safe'
64 | const inf = searchTypes[param.type]
65 | param.type = inf?.type ?? param.type
66 | const path = inf?.path ?? 'illustrations'
67 |
68 | if (param.type === 'novel') param.work_lang = param.lang
69 |
70 | const promiseArr = []
71 | if (noPremium) {
72 | promiseArr.push(await fixParam(param, inf))
73 | } else if (param.p) {
74 | return search(path, inf.name, param)
75 | }
76 |
77 | param.start = +param.start
78 | param.length = +param.length
79 | let end = param.start + param.length
80 | let e = Math.ceil(end / PAGE_SIZE)
81 | let s = Math.ceil(param.start / PAGE_SIZE)
82 | // delete param.start
83 | // delete param.length
84 | for (let p = s + 1 + noPremium; p <= e; p++) {
85 | promiseArr.push(search(path, inf.name, {...param, p}))
86 | }
87 | const res = (await Promise.all(promiseArr)).reduce((result, pageData) => {
88 | result.results.push(...pageData.results)
89 | result.relatedTags = pageData.relatedTags
90 | result.total = pageData.total
91 | return result
92 | }, {
93 | results: [],
94 | relatedTags: [],
95 | total: 0
96 | })
97 | res.results = res.results.slice(param.start, end)
98 | return res
99 | }
100 |
101 | /**
102 | * @param {SearchPath} path
103 | * @param {string} dataName
104 | * @param {RawSearchParam} param
105 | * @return {Promise}
106 | */
107 | export async function search(path, dataName, param) {
108 | // param = { ...defaultParams, ...templates[param.template], ...param }
109 | const data = await pixivJsonFetch(`/ajax/search/${path}/${encodeURIComponent(param.word)}`, param)
110 | const results = []
111 | for (const single of data[dataName].data) {
112 | results.push({
113 | id: +(single.id),
114 | title: single.title,
115 | cover: replaceURL(single.url),
116 | tags: single.tags,
117 | createTime: new Date(single.createDate).getTime(),
118 | updateTime: new Date(single.updateDate).getTime(),
119 | restrict: single.xRestrict === 0 ? 'safe' : 'r18',
120 | total: single.pageCount,
121 | author: {
122 | name: single.userName,
123 | id: +(single.userId)
124 | }
125 | })
126 | }
127 | return {
128 | results,
129 | relatedTags: data.relatedTags,
130 | total: data[dataName].total
131 | }
132 | }
--------------------------------------------------------------------------------
/core/api/module/user/pid.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dituon/pixiv-api-http/51ddcb9c6852b742a851a257a3884b91bd5136ca/core/api/module/user/pid.js
--------------------------------------------------------------------------------
/core/pixiv-fetch/agent.js:
--------------------------------------------------------------------------------
1 | import https from 'https'
2 | import config from "../../config.js"
3 | import baseOpiton from './base-option.js'
4 |
5 | const bypassSNI = config.proxy.bypassSNI
6 |
7 | export const staticAgent = ip => new https.Agent({
8 | rejectUnauthorized: true,
9 | servername: '',
10 | lookup: (_0, _1, callback) => callback(null, ip, 4)
11 | })
12 |
13 | export let agent = !bypassSNI ?
14 | https.globalAgent : staticAgent(config.proxy.serverHost[0])
15 |
16 | if (bypassSNI) { //test ip
17 | for (const hostIp of config.proxy.serverHost) {
18 | const { status, ip } = await new Promise((res, rej) => {
19 | const request = https.request({
20 | ...baseOpiton,
21 | agent: staticAgent(hostIp)
22 | }, r => res({ status: r.statusCode, ip: hostIp }))
23 |
24 | request.on('error', rej)
25 | request.end()
26 | }).catch(err => {
27 | throw new Error(err, 'can not connect pixiv server, retrying...')
28 | })
29 |
30 | if (status !== 200) continue
31 |
32 | console.log('pixiv connect success, server ip: %s', ip)
33 | agent = staticAgent(ip)
34 | break
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/core/pixiv-fetch/base-option.js:
--------------------------------------------------------------------------------
1 | import config from '../../config.js'
2 | const cookie = config.pixiv.cookie
3 |
4 | export default {
5 | host: 'www.pixiv.net',
6 | port: 443,
7 | path: '/',
8 | method: 'GET',
9 | headers: {
10 | 'Referer': 'https://www.pixiv.net',
11 | 'Cookie': cookie,
12 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 Edg/111.0.1661.41'
13 | }
14 | }
--------------------------------------------------------------------------------
/core/pixiv-fetch/default-option.js:
--------------------------------------------------------------------------------
1 | import { agent } from "./agent.js"
2 | import baseOption from "./base-option.js"
3 |
4 | export default {
5 | ...baseOption, agent
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/core/pixiv-fetch/fetch.js:
--------------------------------------------------------------------------------
1 | import https from 'https'
2 | import url from 'url'
3 | import defaultOption from "./default-option.js"
4 |
5 | /**
6 | * @param { https.RequestOptions | string } option
7 | * @return { Promise }
8 | */
9 | export default async function pixivFetch(option) {
10 | if (typeof option === 'string') {
11 | const u = url.parse(option)
12 | option = {
13 | host: u.host,
14 | path: u.pathname || '/' + u.search || ''
15 | }
16 | }
17 | const connectOption = { ...defaultOption, ...option }
18 | return new Promise((res, rej) => {
19 | const request = https.request(connectOption, (response) => {
20 | let data = []
21 | response.on('data', chunk => data.push(chunk))
22 | response.on('end', () => res(Buffer.concat(data).toString()))
23 | })
24 | request.on('error', rej)
25 | request.end()
26 | })
27 | }
28 |
29 | /**
30 | * www.pixiv.net + $path + ?$query
31 | * @param {string} path
32 | * @param {object} [query]
33 | * @return { Promise