Initial commit

This commit is contained in:
juluo
2025-10-08 14:48:45 +08:00
committed by GitHub
commit 8b93d6f42f
108 changed files with 27618 additions and 0 deletions

43
api/http.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import escapeRegExp from 'lodash.escaperegexp'
import { ajax } from './utils.js'
export default async function (req: VercelRequest, res: VercelResponse) {
if (!isAccepted(req)) {
return res.status(403).send('403')
}
try {
const { __PREFIX, __PATH } = req.query
const { data } = await ajax({
method: req.method ?? 'GET',
url: `/${encodeURI(`${__PREFIX}${__PATH ? '/' + __PATH : ''}`)}`,
params: req.query ?? {},
data: req.body || undefined,
headers: req.headers as Record<string, string>,
})
res.status(200).send(data)
} catch (e: any) {
res.status(e?.response?.status || 500).send(e?.response?.data || e)
}
}
function isAccepted(req: VercelRequest) {
const { UA_BLACKLIST = '[]' } = process.env
try {
const list: string[] = JSON.parse(UA_BLACKLIST)
const ua = req.headers['user-agent'] ?? ''
return (
!!ua &&
Array.isArray(list) &&
(list.length > 0
? !new RegExp(
`(${list.map((str) => escapeRegExp(str)).join('|')})`,
'gi'
).test(ua)
: true)
)
} catch (e) {
return false
}
}

82
api/image.ts Normal file
View File

@@ -0,0 +1,82 @@
import { VercelRequest, VercelResponse } from '@vercel/node'
import axios from 'axios'
import { USER_AGENT } from './utils.js'
export default async (req: VercelRequest, res: VercelResponse) => {
const { __PREFIX, __PATH } = req.query
if (!__PREFIX || !__PATH) {
return res.status(400).send({ message: 'Missing param(s)' })
}
let url = ''
switch (__PREFIX) {
case '-': {
url = `https://i.pximg.net/${__PATH}`
break
}
case '~': {
url = `https://s.pximg.net/${__PATH}`
break
}
default:
return res.status(400).send({ message: 'Invalid request' })
}
const proxyHeaders = [
'accept',
'accept-encoding',
'accept-language',
'range',
'if-range',
'if-none-match',
'if-modified-since',
'cache-control',
]
const headers = {} as Record<string, string>
for (const h of proxyHeaders) {
if (typeof req.headers[h] === 'string') {
headers[h] = req.headers[h]
}
}
Object.assign(headers, {
referer: 'https://www.pixiv.net/',
'user-agent': USER_AGENT,
})
console.log('Proxy image:', url, headers)
return axios
.get<ArrayBuffer>(url, {
responseType: 'arraybuffer',
headers,
})
.then(
({ data, headers, status }) => {
const exposeHeaders = [
'content-type',
'content-length',
'cache-control',
'content-disposition',
'last-modified',
'etag',
'accept-ranges',
'content-range',
'vary',
]
for (const h of exposeHeaders) {
if (typeof headers[h] === 'string') {
res.setHeader(h, headers[h])
}
}
res.status(status).send(Buffer.from(data))
},
(err) => {
console.error('Image proxy error:', err)
return res
.status(err?.response?.status || 500)
.send(err?.response?.data || err)
}
)
}

50
api/random.ts Normal file
View File

@@ -0,0 +1,50 @@
import { VercelRequest, VercelResponse } from '@vercel/node'
import { formatInTimeZone } from 'date-fns-tz'
import { PXIMG_BASEURL_I, ajax } from './utils.js'
import { Artwork } from '../src/types/Artworks.js'
type ArtworkOrAd = Artwork | { isAdContainer: boolean }
export default async (req: VercelRequest, res: VercelResponse) => {
const requestImage =
(req.headers.accept?.includes('image') || req.query.format === 'image') &&
req.query.format !== 'json'
try {
const data: { illusts?: ArtworkOrAd[] } = (
await ajax({
url: '/ajax/illust/discovery',
params: {
mode: req.query.mode ?? 'safe',
max: requestImage ? '1' : req.query.max ?? '18',
},
headers: req.headers,
})
).data
const illusts = (data.illusts ?? []).filter((value): value is Artwork =>
Object.keys(value).includes('id')
)
illusts.forEach((value) => {
const middle = `img/${formatInTimeZone(
value.updateDate,
'Asia/Tokyo',
'yyyy/MM/dd/HH/mm/ss'
)}/${value.id}`
value.urls = {
mini: `${PXIMG_BASEURL_I}c/48x48/img-master/${middle}_p0_square1200.jpg`,
thumb: `${PXIMG_BASEURL_I}c/250x250_80_a2/img-master/${middle}_p0_square1200.jpg`,
small: `${PXIMG_BASEURL_I}c/540x540_70/img-master/${middle}_p0_master1200.jpg`,
regular: `${PXIMG_BASEURL_I}img-master/${middle}_p0_master1200.jpg`,
original: `${PXIMG_BASEURL_I}img-original/${middle}_p0.jpg`,
}
})
if (requestImage) {
res.redirect(illusts[0].urls.regular)
return
} else {
res.send(illusts)
return
}
} catch (e: any) {
res.status(e?.response?.status ?? 500).send(e?.response?.data ?? e)
}
}

137
api/user.ts Normal file
View File

@@ -0,0 +1,137 @@
import { VercelRequest, VercelResponse } from '@vercel/node'
import { CheerioAPI, load } from 'cheerio'
import { ajax, replacePximgUrlsInObject } from './utils.js'
export default async (req: VercelRequest, res: VercelResponse) => {
const token = req.cookies.PHPSESSID || req.query.token
if (!token) {
return res.status(403).send({ message: '未配置用户密钥' })
}
ajax
.get('/', { params: req.query, headers: req.headers })
.then(async ({ data }) => {
const $ = load(data)
let meta: { userData: any; token: string }
const $legacyGlobalMeta = $('meta[name="global-data"]')
const $nextDataScript = $('script#__NEXT_DATA__')
try {
if ($legacyGlobalMeta.length > 0) {
meta = resolveLegacyGlobalMeta($)
} else if ($nextDataScript.length > 0) {
meta = resolveNextData($)
} else {
throw new Error('未知的元数据类型', {
cause: {
error: new TypeError('No valid resolver found'),
meta: null,
},
})
}
} catch (error: any) {
return res.status(401).send({
message: error.message,
cause: error.cause,
})
}
res.setHeader('cache-control', 'no-cache')
res.setHeader(
'set-cookie',
`CSRFTOKEN=${meta.token}; path=/; secure; sameSite=Lax`
)
res.send(replacePximgUrlsInObject(meta))
})
.catch((err) => {
return res
.status(err?.response?.status || 500)
.send(err?.response?.data || err)
})
}
function resolveLegacyGlobalMeta($: CheerioAPI): {
userData: any
token: string
} {
const $meta = $('meta[name="global-data"]')
if ($meta.length === 0 || !$meta.attr('content')) {
throw new Error('无效的用户密钥', {
cause: {
error: new TypeError('No global-data meta found'),
meta: $meta.prop('outerHTML'),
},
})
}
let meta: any
try {
meta = JSON.parse($meta.attr('content') as string)
} catch (error) {
throw new Error('解析元数据时出错', {
cause: {
error,
meta: $meta.attr('content'),
},
})
}
if (!meta.userData) {
throw new Error('无法获取登录状态', {
cause: {
error: new TypeError('userData is not defined'),
meta,
},
})
}
return {
userData: meta.userData,
token: meta.token || '',
}
}
function resolveNextData($: CheerioAPI): {
userData: any
token: string
} {
const $nextDataScript = $('script#__NEXT_DATA__')
if ($nextDataScript.length === 0) {
throw new Error('无法获取元数据', {
cause: {
error: new TypeError('No #__NEXT_DATA__ script found'),
meta: null,
},
})
}
let nextData: any
let perloadState: any
try {
nextData = JSON.parse($nextDataScript.text())
perloadState = JSON.parse(
nextData?.props?.pageProps?.serverSerializedPreloadedState
)
} catch (error) {
throw new Error('解析元数据时出错', {
cause: {
error,
meta: $nextDataScript.text(),
},
})
}
const userData = perloadState?.userData?.self
if (!userData) {
throw new Error('意料外的元数据', {
cause: {
error: new TypeError('userData is not defined'),
meta: nextData,
},
})
}
const token = perloadState?.api?.token || ''
return { userData, token }
}

186
api/utils.ts Normal file
View File

@@ -0,0 +1,186 @@
import { VercelRequest, VercelResponse } from '@vercel/node'
import axios from 'axios'
import colors from 'picocolors'
// HTTP handler
export default async function (req: VercelRequest, res: VercelResponse) {
res.status(404).send({
error: true,
message: 'Not Found',
body: null,
})
}
export const PROD = process.env.NODE_ENV === 'production'
export const DEV = !PROD
export const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'
export const PXIMG_BASEURL_I = (() => {
const i = process.env.VITE_PXIMG_BASEURL_I
return i ? i.replace(/\/$/, '') + '/' : 'https://i.pximg.net/'
})()
export const PXIMG_BASEURL_S = (() => {
const s = process.env.VITE_PXIMG_BASEURL_S
return s ? s.replace(/\/$/, '') + '/' : 'https://s.pximg.net/'
})()
export class CookieUtils {
static toJSON(raw: string) {
return Object.fromEntries(new URLSearchParams(raw.replace(/;\s*/g, '&')))
}
static toString(obj: any) {
return Object.keys(obj)
.map((i) => `${i}=${obj[i]}`)
.join(';')
}
}
export const ajax = axios.create({
baseURL: 'https://www.pixiv.net/',
params: {},
headers: {
'user-agent': USER_AGENT,
},
timeout: 9 * 1000,
})
ajax.interceptors.request.use((ctx) => {
// 去除内部参数
ctx.params = ctx.params || {}
delete ctx.params.__PATH
delete ctx.params.__PREFIX
const cookies = CookieUtils.toJSON(ctx.headers.cookie || '')
const csrfToken = ctx.headers['x-csrf-token'] ?? cookies.CSRFTOKEN ?? ''
// 强制覆写部分 headers
ctx.headers = ctx.headers || {}
ctx.headers.host = 'www.pixiv.net'
ctx.headers.origin = 'https://www.pixiv.net'
ctx.headers.referer = 'https://www.pixiv.net/'
ctx.headers['user-agent'] = USER_AGENT
ctx.headers['accept-language'] ??=
'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6'
csrfToken && (ctx.headers['x-csrf-token'] = csrfToken)
if (DEV) {
console.info(
colors.green(`[${ctx.method?.toUpperCase()}] <`),
colors.cyan(ctx.url || '')
)
console.info({
params: ctx.params,
data: ctx.data,
cookies,
})
}
return ctx
})
ajax.interceptors.response.use((ctx) => {
typeof ctx.data === 'object' &&
(ctx.data = replacePximgUrlsInObject(ctx.data?.body ?? ctx.data))
if (DEV) {
const out: string =
typeof ctx.data === 'object'
? JSON.stringify(ctx.data, null, 2)
: ctx.data.toString().trim()
console.info(
colors.green('[SEND] >'),
colors.cyan(ctx.request?.path?.replace('https://www.pixiv.net', '')),
`\n${colors.yellow(typeof ctx.data)} ${
out.length >= 200 ? out.slice(0, 200).trim() + '\n...' : out
}`
)
}
return ctx
})
export function replacePximgUrlsInString(str: string): string {
if (!str.includes('pximg.net')) return str
return str
.replaceAll('https://i.pximg.net/', PXIMG_BASEURL_I)
.replaceAll('https://s.pximg.net/', PXIMG_BASEURL_S)
}
export function replacePximgUrlsInObject(
obj: Record<string, any> | string
): Record<string, any> | string {
if (typeof obj === 'string') return replacePximgUrlsInString(obj)
return deepReplaceString(obj, replacePximgUrlsInString)
}
function isObject(value: any): value is Record<string, any> {
return typeof value === 'object' && value !== null
}
export function deepReplaceString<T>(
obj: T,
replacer: (value: string) => string
): T {
if (Array.isArray(obj)) {
return obj.map((value) =>
deepReplaceString(value, replacer)
) as unknown as T
} else if (isObject(obj)) {
if (
['arraybuffer', 'blob', 'formdata'].includes(
obj.constructor.name.toLowerCase()
)
) {
return obj
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = deepReplaceString(value, replacer)
}
return result as T
} else if (typeof obj === 'string') {
return replacer(obj) as unknown as T
}
return obj
}
export function safelyStringify(value: any, space?: number) {
const visited = new WeakSet()
const replacer = (key: string, val: any) => {
// 处理 BigInt
if (typeof val === 'bigint') {
return val.toString()
}
// 处理 Set
if (val instanceof Set) {
return Array.from(val)
}
// 处理 Map
if (val instanceof Map) {
return Array.from(val.entries())
}
// 处理 function
if (typeof val === 'function') {
return val.toString()
}
// 处理自循环引用
if (typeof val === 'object' && val !== null) {
if (visited.has(val)) {
return '<circular>'
}
visited.add(val)
}
return val
}
return JSON.stringify(value, replacer, space)
}
JSON.safelyStringify = safelyStringify
declare global {
interface JSON {
safelyStringify: typeof safelyStringify
}
}