Initial commit
This commit is contained in:
43
api/http.ts
Normal file
43
api/http.ts
Normal 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
82
api/image.ts
Normal 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
50
api/random.ts
Normal 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
137
api/user.ts
Normal 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
186
api/utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user