diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..0465677e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +{ + "name": "Node.js & TypeScript", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/typescript-node:20", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "latest", + "nvmVersion": "latest" + } + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.env.sample b/.env.sample index a51af8a1..db8dd629 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,3 @@ -VITE_ADSENSE_PUB_ID = -VITE_GOOGLE_ANALYTICS_ID = -VITE_GOOGLE_SEARCH_CONSOLE_VERIFICATION = -VITE_PXIMG_BASEURL_I = /-/ -VITE_PXIMG_BASEURL_S = /~/ +NUXT_ADSENSE_PUB_ID = +NUXT_GOOGLE_ANALYTICS_ID = +NUXT_GOOGLE_SEARCH_CONSOLE_VERIFICATION = diff --git a/.gitignore b/.gitignore index ab3bd2ed..fa31c087 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,5 @@ dist dev-test *.dev.* .vercel +.output .vs diff --git a/.vscode/settings.json b/.vscode/settings.json index 4e713441..9e22a673 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,3 @@ { - "i18n-ally.localesPaths": [ - "src/locales" - ] -} \ No newline at end of file + "i18n-ally.localesPaths": ["i18n/locales"] +} diff --git a/.vscode/vue.code-snippets b/.vscode/vue.code-snippets deleted file mode 100644 index abd10498..00000000 --- a/.vscode/vue.code-snippets +++ /dev/null @@ -1,19 +0,0 @@ -{ - "Init vue components": { - "scope": "vue", - "prefix": "vue", - "body": [ - "", - "", - "", - "", - "" - ], - "description": "Init vue components" - } -} \ No newline at end of file diff --git a/README.md b/README.md index 1f0dcb8c..1ee1775e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -
+
-![PixivNow Logo](src/assets/LogoH.png) +![PixivNow Logo](public/images/LogoH.png) Pixiv Service Proxy diff --git a/api/http.ts b/api/http.ts deleted file mode 100644 index f74dd74f..00000000 --- a/api/http.ts +++ /dev/null @@ -1,43 +0,0 @@ -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, - }) - 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 - } -} diff --git a/api/image.ts b/api/image.ts deleted file mode 100644 index 8c87b104..00000000 --- a/api/image.ts +++ /dev/null @@ -1,40 +0,0 @@ -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)' }) - } - - switch (__PREFIX) { - case '~': { - return axios - .get(`https://s.pximg.net/${__PATH}`, { - responseType: 'arraybuffer', - headers: { - referer: 'https://www.pixiv.net/', - 'user-agent': USER_AGENT, - }, - }) - .then( - ({ data, headers }) => { - res.setHeader('Content-Type', headers['content-type']) - res.setHeader( - 'Cache-Control', - `public, max-age=${12 * 60 * 60 * 3600}` - ) - res.status(200).send(Buffer.from(data)) - }, - (err) => { - return res - .status(err?.response?.status || 500) - .send(err?.response?.data || err) - } - ) - } - default: - return res.status(400).send({ message: 'Invalid request' }) - } -} diff --git a/api/random.ts b/api/random.ts deleted file mode 100644 index 51bdeed7..00000000 --- a/api/random.ts +++ /dev/null @@ -1,50 +0,0 @@ -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) - } -} diff --git a/api/user.ts b/api/user.ts deleted file mode 100644 index 271e45e5..00000000 --- a/api/user.ts +++ /dev/null @@ -1,137 +0,0 @@ -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 } -} diff --git a/api/utils.ts b/api/utils.ts deleted file mode 100644 index b26b3ea1..00000000 --- a/api/utils.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 -): Record | string { - if (typeof obj === 'string') return replacePximgUrlsInString(obj) - - return deepReplaceString(obj, replacePximgUrlsInString) -} - -function isObject(value: any): value is Record { - return typeof value === 'object' && value !== null -} - -export function deepReplaceString( - 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 = {} - 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 '' - } - visited.add(val) - } - - return val - } - - return JSON.stringify(value, replacer, space) -} - -JSON.safelyStringify = safelyStringify -declare global { - interface JSON { - safelyStringify: typeof safelyStringify - } -} diff --git a/app/app.config.ts b/app/app.config.ts new file mode 100644 index 00000000..2d8fbc69 --- /dev/null +++ b/app/app.config.ts @@ -0,0 +1,15 @@ +import { version } from '../package.json' + +export default defineAppConfig({ + version, + githubOwner: 'FreeNowOrg', + githubRepo: 'PixivNow', + githubUrl: 'https://github.com/FreeNowOrg/PixivNow', + projectName: 'PixivNow', + projectTagline: 'Enjoy Pixiv Now (pixiv.js.org)', + imageCacheSeconds: 12 * 60 * 60 * 1000, + siteEnv: + process.env.NODE_ENV === 'development' || version.includes('-') + ? 'development' + : 'production', +}) diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 00000000..d5e48983 --- /dev/null +++ b/app/app.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/styles/animate.sass b/app/assets/styles/animate.sass similarity index 100% rename from src/styles/animate.sass rename to app/assets/styles/animate.sass diff --git a/src/styles/elements.sass b/app/assets/styles/elements.sass similarity index 100% rename from src/styles/elements.sass rename to app/assets/styles/elements.sass diff --git a/src/styles/formats.sass b/app/assets/styles/formats.sass similarity index 100% rename from src/styles/formats.sass rename to app/assets/styles/formats.sass diff --git a/src/styles/index.sass b/app/assets/styles/index.sass similarity index 97% rename from src/styles/index.sass rename to app/assets/styles/index.sass index a8722b3e..57547ac4 100644 --- a/src/styles/index.sass +++ b/app/assets/styles/index.sass @@ -12,7 +12,7 @@ body * box-sizing: border-box -#app +#app-full-container font-family: Avenir, Helvetica, Arial, sans-serif -webkit-font-smoothing: antialiased -moz-osx-font-smoothing: grayscale diff --git a/src/styles/variables.sass b/app/assets/styles/variables.sass similarity index 100% rename from src/styles/variables.sass rename to app/assets/styles/variables.sass diff --git a/src/components/ArtTag.vue b/app/components/ArtTag.vue similarity index 71% rename from src/components/ArtTag.vue rename to app/components/ArtTag.vue index 3747fc42..8850683c 100644 --- a/src/components/ArtTag.vue +++ b/app/components/ArtTag.vue @@ -1,6 +1,6 @@ @@ -8,6 +8,8 @@ NTag.artwork-tag( diff --git a/src/components/ArtworksList/ArtworksByUser.vue b/app/components/Artwork/ArtworksByUser.vue similarity index 78% rename from src/components/ArtworksList/ArtworksByUser.vue rename to app/components/Artwork/ArtworksByUser.vue index 5652b106..a33625d8 100644 --- a/src/components/ArtworksList/ArtworksByUser.vue +++ b/app/components/Artwork/ArtworksByUser.vue @@ -19,21 +19,14 @@ diff --git a/src/components/AuthorCard.vue b/app/components/AuthorCard.vue similarity index 71% rename from src/components/AuthorCard.vue rename to app/components/AuthorCard.vue index 59c05392..a57ef452 100644 --- a/src/components/AuthorCard.vue +++ b/app/components/AuthorCard.vue @@ -3,12 +3,12 @@ .author-inner(v-if='user') .flex-center .left - RouterLink(:to='"/users/" + user.userId') + NuxtLink(:to='"/users/" + user.userId') img(:src='user.imageBig' alt='') .right .flex h4.plain - RouterLink(:to='"/users/" + user.userId') {{ user.name }} + NuxtLink(:to='"/users/" + user.userId') {{ user.name }} NButton( :loading='loadingUserFollow', :type='user.isFollowed ? "success" : undefined' @@ -16,11 +16,11 @@ round secondary size='small' - v-if='user.userId !== userStore.userId' + v-if='user.userId !== userStore.id' ) template(#icon) - IFasCheck(v-if='user.isFollowed') - IFasPlus(v-else) + ICheck(v-if='user.isFollowed') + IPlus(v-else) | {{ user.isFollowed ? '已关注' : '关注' }} NEllipsis.description.pre(:line-clamp='3', :tooltip='false') {{ user.comment }} ArtworkList.tiny(:list='user.illusts' inline) @@ -35,24 +35,17 @@ diff --git a/src/components/NProgress.vue b/app/components/NProgress.vue similarity index 92% rename from src/components/NProgress.vue rename to app/components/NProgress.vue index 5a9f6bec..9eaab7ab 100644 --- a/src/components/NProgress.vue +++ b/app/components/NProgress.vue @@ -1,4 +1,6 @@ - + diff --git a/src/components/SideNav/SideNav.vue b/app/components/SideNav/Body.vue similarity index 60% rename from src/components/SideNav/SideNav.vue rename to app/components/SideNav/Body.vue index 9247a69c..8cfdf48c 100644 --- a/src/components/SideNav/SideNav.vue +++ b/app/components/SideNav/Body.vue @@ -10,54 +10,44 @@ aside.global-side-nav(:class='{ hidden: !sideNavStore.isOpened }') .group .title 导航 ul - ListLink(link='/' text='首页') - IFasHome.link-icon - ListLink.not-allowed(link='' text='探索发现') - IFasImage.link-icon - ListLink(link='/ranking' text='排行榜') - IFasCrown.link-icon + SideNavListLink(link='/' text='首页') + IHome.svg--SideNavListLink + SideNavListLink.not-allowed(link='' text='插画') + IImage.svg--SideNavListLink + SideNavListLink(link='' text='用户') + IUser.svg--SideNavListLink + SideNavListLink(link='/ranking' text='排行榜') + ICrown.svg--SideNavListLink .group .title 用户 ul - ListLink( + SideNavListLink( :text='userStore.isLoggedIn ? "查看令牌" : "设置令牌"' link='/login' ) - IFasFingerprint.link-icon - ListLink( - :link='userStore.isLoggedIn ? `/users/${userStore.userId}` : `/login?back=${$route.fullPath}`' - text='我的页面' - ) - IFasUser.link-icon - ListLink( - :link='userStore.isLoggedIn ? `/users/${userStore.userId}/following` : `/login?back=${$route.fullPath}`' - text='我的关注' - ) - IFasUser.link-icon - ListLink(link='/following/latest' text='关注用户的作品') - IFasUser.link-icon + IFingerprint.svg--SideNavListLink .group .title PixivNow ul - ListLink(externalLink='https://www.pixiv.net/' text='Pixiv.net') - IFasExternalLinkAlt.link-icon - ListLink(link='/about' text='关于我们') - IFasHeart.link-icon + SideNavListLink( + externalLink='https://www.pixiv.net/' + text='Pixiv.net' + ) + IExternalLinkAlt.svg--SideNavListLink + SideNavListLink(link='/about' text='关于我们') + IHeart.svg--SideNavListLink diff --git a/src/assets/logo.png b/src/assets/logo.png deleted file mode 100644 index f3d2503f..00000000 Binary files a/src/assets/logo.png and /dev/null differ diff --git a/src/components/LazyLoad.vue b/src/components/LazyLoad.vue deleted file mode 100644 index 772fe87a..00000000 --- a/src/components/LazyLoad.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - diff --git a/src/components/userData.ts b/src/components/userData.ts deleted file mode 100644 index 840c1337..00000000 --- a/src/components/userData.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { PixivUser } from '@/types' -import Cookies from 'js-cookie' - -export function existsSessionId(): boolean { - const sessionId = Cookies.get('PHPSESSID') - if (sessionId) { - return true - } else { - Cookies.remove('CSRFTOKEN') - return false - } -} - -export async function initUser(): Promise { - try { - const { data } = await axios.get<{ userData: PixivUser; token: string }>( - `/api/user`, - { - headers: { - 'Cache-Control': 'no-store', - }, - } - ) - if (data.token) { - console.log('session ID认证成功', data) - Cookies.set('CSRFTOKEN', data.token, { secure: true, sameSite: 'Strict' }) - const res = data.userData - return res - } else { - Cookies.remove('CSRFTOKEN') - return Promise.reject('无效的session ID') - } - } catch (err) { - Cookies.remove('CSRFTOKEN') - return Promise.reject(err) - } -} - -export function login(token: string): Promise { - if (!validateSessionId(token)) { - console.error('访问令牌格式错误') - return Promise.reject('访问令牌格式错误') - } - Cookies.set('PHPSESSID', token, { - expires: 180, - path: '/', - secure: true, - sameSite: 'Strict', - }) - return initUser() -} - -export function logout(): void { - const token = Cookies.get('PHPSESSID') - if (token && confirm(`您要移除您的令牌吗?\n${token}`)) { - Cookies.remove('PHPSESSID') - Cookies.remove('CSRFTOKEN') - } -} - -export function validateSessionId(token: string): boolean { - return /^\d{2,10}_[0-9A-Za-z]{32}$/.test(token) -} - -export function exampleSessionId(): string { - const uid = new Uint32Array(1) - window.crypto.getRandomValues(uid) - const secret = (() => { - const strSet = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - const final = [] - const indexes = new Uint8Array(32) - window.crypto.getRandomValues(indexes) - for (const i of indexes) { - const charIndex = Math.floor((i * strSet.length) / 256) - final.push(strSet[charIndex]) - } - return final.join('') - })() - return `${uid[0]}_${secret}` -} diff --git a/src/composables/states.ts b/src/composables/states.ts deleted file mode 100644 index 74e9bb46..00000000 --- a/src/composables/states.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineStore } from 'pinia' -import { PixivUser } from '@/types' - -export const useSideNavStore = defineStore('sidenav', () => { - const openState = ref(false) - const isOpened = computed(() => openState.value) - function toggle() { - openState.value = !openState.value - } - function open() { - openState.value = true - } - function close() { - openState.value = false - } - return { openState, isOpened, toggle, open, close } -}) - -export const useUserStore = defineStore('user', () => { - const user = ref(null) - const isLoggedIn = computed(() => !!user.value) - const userId = computed(() => user.value?.id) - const userName = computed(() => user.value?.name) - const userPixivId = computed(() => user.value?.pixivId) - const userProfileImg = computed(() => user.value?.profileImg) - const userProfileImgBig = computed(() => user.value?.profileImgBig) - function login(data: PixivUser) { - user.value = data - } - function logout() { - user.value = null - } - return { - user, - isLoggedIn, - userId, - userName, - userPixivId, - userProfileImg, - userProfileImgBig, - login, - logout, - } -}) diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 4a7a186d..00000000 --- a/src/config.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Env -import { version } from '../package.json' -export { version } - -export const SITE_ENV = - import.meta.env.MODE === 'development' || - version.includes('-') || - location.hostname === 'pixiv-next.vercel.app' - ? 'development' - : 'production' - -// Copyright links -// Do not modify please -export const GITHUB_OWNER = 'FreeNowOrg' -export const GITHUB_REPO = 'PixivNow' -export const GITHUB_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}` - -// Site name -export const PROJECT_NAME = 'PixivNow' -export const PROJECT_TAGLINE = 'Enjoy Pixiv Now (pixiv.js.org)' - -// Image proxy cache seconds -export const IMAGE_CACHE_SECONDS = 12 * 60 * 60 * 1000 diff --git a/src/env.d.ts b/src/env.d.ts deleted file mode 100644 index f86d5a0a..00000000 --- a/src/env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// - -// declare module '*.vue' { -// import { ComponentOptions } from 'vue' -// const componentOptions: ComponentOptions -// export default componentOptions -// } diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 3cc4358c..00000000 --- a/src/main.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createApp } from 'vue' -import { SITE_ENV } from '@/config' -import { registerPlugins } from '@/plugins' -import App from './App.vue' -import '@/styles/index.sass' - -// Create App -const app = createApp(App) - -registerPlugins(app) - -// Mount -app.mount('#app') -document.body?.setAttribute('data-env', SITE_ENV) diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts deleted file mode 100644 index 55e63af6..00000000 --- a/src/plugins/i18n.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createI18n, I18n } from 'vue-i18n' - -export const SUPPORTED_LOCALES = ['zh-Hans'] - -export function setupI18n(options = { locale: 'zh-Hans' }) { - const i18n = createI18n({ ...options, legacy: false }) - setI18nLanguage(i18n, options.locale) - return i18n -} - -export function setI18nLanguage( - i18n: I18n, - locale: string -) { - i18n.global.locale.value = locale - document.querySelector('html')?.setAttribute('lang', locale) -} - -export async function loadLocaleMessages( - i18n: I18n, - locale: string -) { - const messages = await import( - /* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json` - ) - i18n.global.setLocaleMessage(locale, messages.default) - setI18nLanguage(i18n, locale) - - return nextTick() -} diff --git a/src/plugins/index.ts b/src/plugins/index.ts deleted file mode 100644 index 1001a1e1..00000000 --- a/src/plugins/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { router } from './router' -import { loadLocaleMessages, setupI18n } from './i18n' -import { createPinia } from 'pinia' -import { createGtag } from 'vue-gtag' -import type { App } from 'vue' - -export async function registerPlugins(app: App) { - const i18n = setupI18n() - const initialLocale = 'zh-Hans' - app.use(i18n) - app.use(router) - app.use(createPinia()) - - if (import.meta.env.VITE_GOOGLE_ANALYTICS_ID) { - app.use( - createGtag({ - tagId: import.meta.env.VITE_GOOGLE_ANALYTICS_ID, - pageTracker: { - router, - }, - }) - ) - } - - await loadLocaleMessages(i18n, initialLocale) -} diff --git a/src/plugins/router.ts b/src/plugins/router.ts deleted file mode 100644 index 1570cc7d..00000000 --- a/src/plugins/router.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' -import { createDiscreteApi } from 'naive-ui' -const { message } = createDiscreteApi(['message']) - -const routes: RouteRecordRaw[] = [ - { - path: '/', - name: 'home', - component: () => import('@/view/index.vue'), - }, - { - path: '/artworks/:id', - alias: ['/illust/:id', '/i/:id'], - name: 'artworks', - component: () => import('@/view/artworks.vue'), - }, - { - path: '/following/latest', - alias: ['/bookmark_new_illust'], - name: 'following-latest', - component: () => import('@/view/following-latest.vue'), - }, - { - path: '/users/:id', - name: 'users', - alias: ['/u/:id'], - component: () => import('@/view/users.vue'), - }, - { - path: '/users/:id/following', - name: 'following', - component: () => import('@/view/following.vue'), - }, - { - path: '/search/:keyword', - name: 'search-index-redirect', - redirect: (to) => `/search/${to.params.keyword}/1`, - }, - { - path: '/search/:keyword/:p', - name: 'search', - component: () => import('@/view/search.vue'), - }, - { - path: '/ranking', - name: 'ranking', - component: () => import('@/view/ranking.vue'), - }, - { - path: '/login', - name: 'user-login', - component: () => import('@/view/login.vue'), - }, - { - path: '/about', - name: 'about-us', - component: () => import('@/view/about.vue'), - }, - { - path: '/notifications/2024-04-26', - name: 'notification-2024-04-26', - component: () => import('@/view/notifications/2024-04-26.vue'), - }, - { - path: '/:pathMatch(.*)*', - name: 'not-found', - component: () => import('@/view/404.vue'), - }, -] - -if (import.meta.env.DEV) { - routes.push({ - path: '/_debug', - name: 'debug', - children: [ - { - path: 'zip', - name: 'debug-zip', - component: () => import('@/view/_debug/zip.vue'), - }, - ], - }) -} - -export const router = createRouter({ - history: createWebHistory(), - routes, - scrollBehavior(to, from, savedPosition) { - if (savedPosition) { - return savedPosition - } else { - return { - top: 0, - behavior: 'smooth', - } - } - }, -}) - -router.afterEach(({ name }) => { - document.body.setAttribute('data-route', name as string) - // Fix route when modal opened - document.body.style.overflow = 'visible' -}) - -router.onError((error, to, from) => { - console.log(error, to, from) - message.error(error) -}) - -export default router diff --git a/src/types/Users.ts b/src/types/Users.ts deleted file mode 100644 index ca42b952..00000000 --- a/src/types/Users.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Artwork, ArtworkInfo } from './Artworks' - -export enum UserXRestrict { - SAFE, - R18, - R18G, -} -export enum UserPrivacyLevel { - PUBLIC_FOR_ALL, - PUBLIC_FOR_FRIENDS, - PRIVATE, -} - -export interface User { - userId: `${number}` - name: string - image: string - imageBig: string - premium: boolean - isFollowed: boolean - isMypixiv: boolean - isBlocking: boolean - background: { - url: string | null - color: string | null - repeat: string | null - isPrivate: boolean - } | null - sketchLiveId: {} | null - partial: number - acceptRequest: boolean - sketchLives: any[] - following: number - followedBack: boolean - comment: string - commentHtml: string - webpage: string | null - social: { - twitter?: { - url: string - } - facebook?: { - url: string - } - instagram?: { - url: string - } - [key: string]: any - } - region: { - name: string - privacyLevel: UserPrivacyLevel - } | null - birthDay: { - name: string - privacyLevel: UserPrivacyLevel - } | null - gender: { - name: string - privacyLevel: UserPrivacyLevel - } | null - job: { - name: string - privacyLevel: UserPrivacyLevel - } | null - workspace: { - userWorkspacePc?: string - userWorkspaceMonitor?: string - userWorkspaceTool?: string - userWorkspaceScanner?: string - userWorkspaceTablet?: string - userWorkspaceMouse?: string - userWorkspacePrinter?: string - userWorkspaceDesktop?: string - userWorkspaceMusic?: string - userWorkspaceDesk?: string - userWorkspaceChair?: string - userWorkspaceComment?: string - wsUrl?: string - wsBigUrl?: string - } - official: boolean - group: null - illusts: ArtworkInfo[] - manga: ArtworkInfo[] - novels: ArtworkInfo[] -} - -export interface PixivUser { - id: `${number}` - pixivId: string - name: string - profileImg: string - profileImgBig: string - premium: boolean - xRestrict: UserXRestrict - adult: boolean - illustCreator: boolean - novelCreator: boolean - hideAiWorks: boolean - readingStatusEnabled: boolean - illustMaskRules: any[] - location: string - isSensitiveViewable: boolean -} - -export interface UserListItem { - userId: `${number}` - userName: string - profileImageUrl: string - userComment: string - following: boolean - followed: boolean - isBlocking: boolean - isMypixiv: boolean - illusts: ArtworkInfo[] - novels: any[] - acceptRequest: boolean -} diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index e319f702..00000000 --- a/src/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './Artworks' -export * from './Comment' -export * from './Users' diff --git a/src/utils/UgoiraPlayer.ts b/src/utils/UgoiraPlayer.ts deleted file mode 100644 index e7e3952e..00000000 --- a/src/utils/UgoiraPlayer.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { Artwork } from '@/types' -import { unzip } from 'fflate' -import Gif from 'gif.js' -import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url' -import { encode as encodeMp4 } from 'modern-mp4' -import { ZipDownloader, ZipDownloaderOptions } from './ZipDownloader' - -export interface UgoiraPlayerOptions { - onDownloadProgress?: ( - progress: number, - frameIndex: number, - totalFrames: number - ) => void - onDownloadComplete?: () => void - onDownloadError?: (error: Error) => void - zipDownloaderOptions?: ZipDownloaderOptions -} - -/** - * UgoiraPlayer - * @author dragon-fish - * @license MIT - */ -export class UgoiraPlayer { - private _canvas?: HTMLCanvasElement - private _illust!: Artwork - private _meta?: UgoiraMeta - private isPlaying = false - private curFrame = 0 - private lastFrameTime = 0 - private cachedImages: Map = new Map() - private objectURLs: Set = new Set() // 跟踪所有创建的 objectURL - private files: Record = {} - private zipDownloader?: ZipDownloader - private downloadProgress = 0 - private isDownloading = false - private isDownloadComplete = false - private downloadStartTime = 0 - private frameDownloadTimes: number[] = [] - - constructor( - illust: Artwork, - public options: UgoiraPlayerOptions = {} - ) { - this.reset(illust) - } - reset(illust: Artwork) { - this.destroy() - this._canvas = undefined - this._illust = illust - this.downloadProgress = 0 - this.isDownloading = false - this.isDownloadComplete = false - this.downloadStartTime = 0 - this.frameDownloadTimes = [] - } - setupCanvas(canvas: HTMLCanvasElement) { - this._canvas = canvas - this._canvas.width = this.initWidth - this._canvas.height = this.initHeight - } - - get isReady() { - return !!this._meta && !!Object.keys(this.files).length - } - get canExport() { - return this.isDownloadComplete && this.isReady - } - get downloadProgressPercent() { - return this.downloadProgress - } - get downloadStats() { - if (!this.isDownloadComplete && this.frameDownloadTimes.length === 0) { - return null - } - - const totalTime = performance.now() - this.downloadStartTime - const avgFrameTime = - this.frameDownloadTimes.length > 0 - ? this.frameDownloadTimes.reduce((a, b) => a + b, 0) / - this.frameDownloadTimes.length - : 0 - - return { - totalDownloadTime: totalTime, - averageFrameTime: avgFrameTime, - totalFrames: this.frameDownloadTimes.length, - isComplete: this.isDownloadComplete, - progress: this.downloadProgress, - } - } - get isUgoira() { - return this._illust.illustType === 2 - } - get canvas() { - return this._canvas - } - get illust() { - return this._illust - } - get meta() { - return this._meta - } - get totalFrames() { - return this._meta?.frames.length ?? 0 - } - get now() { - return performance.now() - } - get initWidth() { - return this._illust.width - } - get initHeight() { - return this._illust.height - } - get mimeType() { - return this._meta?.mime_type ?? '' - } - - async fetchMeta() { - this._meta = await fetch( - new URL(`/ajax/illust/${this._illust.id}/ugoira_meta`, location.href) - .href, - { - cache: 'default', - } - ).then((res) => res.json()) - return this - } - - async fetchFrames(originalQuality = false) { - if (!this._meta) { - await this.fetchMeta() - } - if (!this._meta) { - throw new Error('Failed to fetch meta') - } - - // 使用 ZipDownloader 进行优化下载 - return this.fetchFramesOptimized(originalQuality) - } - - /** - * 使用 ZipDownloader 优化的帧下载方法 - * 1. 首先拉取zip的元信息,确定文件分片情况 - * 2. 根据UgoiraMeta的frame信息,依次下载文件 - * 3. 下载完一张图片时:如果下载用时大于前一帧的delay,立即渲染到canvas,否则等待delay后渲染到canvas - * 4. 全部帧下载完毕,开始按照frame的定义正常循环播放动画,标记为可以导出为gif或mp4 - */ - private async fetchFramesOptimized(originalQuality = false) { - if (this.isDownloading) { - throw new Error('Download already in progress') - } - - this.isDownloading = true - this.downloadStartTime = performance.now() - this.downloadProgress = 0 - this.frameDownloadTimes = [] - - try { - const zipUrl = new URL( - this._meta![originalQuality ? 'originalSrc' : 'src'], - location.href - ).href - - // 1. 创建 ZipDownloader 并获取元信息 - this.zipDownloader = new ZipDownloader(zipUrl, { - chunkSize: 128 * 1024, - maxConcurrentRequests: 3, - tryDecompress: true, - timeoutMs: 10000, - retries: 2, - ...this.options.zipDownloaderOptions, - }) - - console.log('[UgoiraPlayer] 开始获取 ZIP 元信息...') - const overview = await this.zipDownloader.getCentralDirectory() - console.log('[UgoiraPlayer] ZIP 元信息获取完成:', { - entryCount: overview.entryCount, - entries: overview.entries.map((e) => e.fileName), - }) - - // 2. 根据 UgoiraMeta 的 frame 信息,依次下载文件 - const { frames } = this._meta! - const totalFrames = frames.length - - for (let i = 0; i < totalFrames; i++) { - const frame = frames[i] - const frameStartTime = performance.now() - - try { - // 下载单个帧文件 - const result = await this.zipDownloader.downloadByPath(frame.file) - this.files[frame.file] = result.bytes - - const frameDownloadTime = performance.now() - frameStartTime - this.frameDownloadTimes[i] = frameDownloadTime - - // 更新下载进度 - this.downloadProgress = ((i + 1) / totalFrames) * 100 - - console.log(`[UgoiraPlayer] 帧 ${i + 1}/${totalFrames} 下载完成:`, { - fileName: frame.file, - downloadTime: frameDownloadTime, - delay: frame.delay, - progress: this.downloadProgress.toFixed(1) + '%', - }) - - // 触发进度回调 - this.options.onDownloadProgress?.( - this.downloadProgress, - i, - totalFrames - ) - - // 3. 智能渲染逻辑:根据下载时间与delay比较决定立即渲染或等待 - await this.handleFrameRender(i, frame, frameDownloadTime) - } catch (error) { - console.error(`[UgoiraPlayer] 帧 ${i + 1} 下载失败:`, error) - throw new Error(`Failed to download frame ${i + 1}: ${frame.file}`, { - cause: error, - }) - } - } - - // 4. 全部帧下载完毕,标记为可以导出 - this.isDownloadComplete = true - this.isDownloading = false - - const totalDownloadTime = performance.now() - this.downloadStartTime - console.log('[UgoiraPlayer] 所有帧下载完成:', { - totalFrames, - totalDownloadTime: totalDownloadTime.toFixed(2) + 'ms', - averageFrameTime: (totalDownloadTime / totalFrames).toFixed(2) + 'ms', - canExport: this.canExport, - }) - - // 触发完成回调 - this.options.onDownloadComplete?.() - - return this - } catch (error) { - this.isDownloading = false - console.error('[UgoiraPlayer] 下载过程出错:', error) - - // 触发错误回调 - this.options.onDownloadError?.(error as Error) - - throw error - } - } - - /** - * 处理帧渲染逻辑 - * 如果下载用时大于前一帧的delay,立即渲染到canvas,否则等待delay后渲染到canvas - */ - private async handleFrameRender( - frameIndex: number, - frame: UgoiraFrame, - downloadTime: number - ) { - if (!this._canvas) { - return // 没有canvas,跳过渲染 - } - - const ctx = this._canvas.getContext('2d') - if (!ctx) { - return - } - - // 获取前一帧的delay(第一帧没有前一帧,使用当前帧的delay) - const previousDelay = - frameIndex > 0 ? this._meta!.frames[frameIndex - 1].delay : frame.delay - - // 如果下载时间大于前一帧的delay,立即渲染 - if (downloadTime > previousDelay) { - console.log( - `[UgoiraPlayer] 帧 ${frameIndex + 1} 下载时间(${downloadTime.toFixed(2)}ms) > 前一帧delay(${previousDelay}ms),立即渲染` - ) - await this.renderFrameToCanvas(frameIndex, frame) - } else { - // 否则等待delay后渲染 - const waitTime = previousDelay - downloadTime - console.log( - `[UgoiraPlayer] 帧 ${frameIndex + 1} 下载时间(${downloadTime.toFixed(2)}ms) <= 前一帧delay(${previousDelay}ms),等待${waitTime.toFixed(2)}ms后渲染` - ) - - await new Promise((resolve) => setTimeout(resolve, waitTime)) - await this.renderFrameToCanvas(frameIndex, frame) - } - } - - /** - * 将指定帧渲染到canvas - */ - private async renderFrameToCanvas(frameIndex: number, frame: UgoiraFrame) { - if (!this._canvas) return - - const ctx = this._canvas.getContext('2d') - if (!ctx) return - - try { - const img = await this.getImageAsync(frame.file) - ctx.drawImage(img, 0, 0, this.initWidth, this.initHeight) - console.log(`[UgoiraPlayer] 帧 ${frameIndex + 1} 已渲染到canvas`) - } catch (error) { - console.error(`[UgoiraPlayer] 帧 ${frameIndex + 1} 渲染失败:`, error) - } - } - - private getImage(fileName: string): HTMLImageElement { - if (this.cachedImages.has(fileName)) { - return this.cachedImages.get(fileName)! - } - const buf = this.files[fileName] - if (!buf) { - throw new Error(`File ${fileName} not found`) - } - const img = new Image() - const objectURL = URL.createObjectURL( - new Blob([buf], { type: this.mimeType }) - ) - this.objectURLs.add(objectURL) - img.src = objectURL - this.cachedImages.set(fileName, img) - return img - } - - private async getImageAsync(fileName: string): Promise { - if (this.cachedImages.has(fileName)) { - const img = this.cachedImages.get(fileName)! - // 如果图片已经加载完成,直接返回 - if (img.complete && img.naturalWidth > 0) { - return img - } - // 否则等待加载完成 - return new Promise((resolve, reject) => { - img.onload = () => resolve(img) - img.onerror = reject - }) - } - - const buf = this.files[fileName] - if (!buf) { - throw new Error(`File ${fileName} not found`) - } - - const img = new Image() - const objectURL = URL.createObjectURL( - new Blob([buf], { type: this.mimeType }) - ) - this.objectURLs.add(objectURL) - img.src = objectURL - this.cachedImages.set(fileName, img) - - return new Promise((resolve, reject) => { - img.onload = () => resolve(img) - img.onerror = reject - }) - } - - getRealFrameSize() { - if (!this.isReady) { - throw new Error('Ugoira assets not ready, please fetch first') - } - const firstFrame = this.getImage(this.meta!.frames[0].file) - return { - width: firstFrame.width, - height: firstFrame.height, - } - } - - private drawFrame() { - if (!this.canvas || !this._meta || !this.isPlaying) { - return - } - const ctx = this.canvas.getContext('2d')! - const frame = this._meta.frames[this.curFrame] - const delay = frame.delay - const now = this.now - const delta = now - this.lastFrameTime - if (delta > delay) { - this.lastFrameTime = now - this.curFrame = (this.curFrame + 1) % this.totalFrames - } - const img = this.getImage(frame.file) - ctx.drawImage(img, 0, 0, this.initWidth, this.initHeight) - requestAnimationFrame(() => this.drawFrame()) - } - - play() { - this.isPlaying = true - this.lastFrameTime = this.now - this.drawFrame() - } - - pause() { - this.isPlaying = false - } - - destroy() { - this.pause() - - // 清理所有 objectURL 防止内存泄漏 - this.objectURLs.forEach((url) => { - URL.revokeObjectURL(url) - }) - this.objectURLs.clear() - - this.cachedImages.clear() - this.files = {} - this._meta = undefined - this.zipDownloader = undefined - this.isDownloading = false - this.isDownloadComplete = false - this.downloadProgress = 0 - this.downloadStartTime = 0 - this.frameDownloadTimes = [] - } - - private genGifEncoder() { - const { width, height } = this.getRealFrameSize() - return new Gif({ - debug: import.meta.env.DEV, - workers: 5, - workerScript: gifWorkerUrl, - width, - height, - }) - } - async renderGif(): Promise { - if (!this.canExport) { - throw new Error( - 'Cannot export: download not complete or assets not ready' - ) - } - - const encoder = this.genGifEncoder() - const frames = this._meta!.frames - const imageList = await Promise.all( - frames.map((i) => { - return this.getImage(i.file) - }) - ) - return new Promise((resolve, reject) => { - imageList.forEach((item, index) => { - encoder.addFrame(item, { delay: frames[index].delay }) - }) - encoder.on('finished', async (blob) => { - console.info('[ENCODER]', 'render finished', encoder) - // FIXME: 渲染结束时释放内存 - // @ts-ignore - encoder.freeWorkers?.forEach((worker: Worker) => { - worker && worker.terminate() - }) - resolve(blob) - }) - console.info('[ENCODER]', 'render start', encoder) - encoder.render() - }) - } - - async renderMp4() { - if (!this.canExport) { - throw new Error( - 'Cannot export: download not complete or assets not ready' - ) - } - - const { width, height } = this.getRealFrameSize() - const frames = this._meta!.frames.map((i) => { - return { - data: this.getImage(i.file).src!, - duration: i.delay, - } - }) - console.info({ width, height, frames }) - const buf = await encodeMp4({ - frames, - width, - height, - audio: false, - }) - const blob = new Blob([buf], { type: 'video/mp4' }) - return blob - } - - async unzipAsync(payload: Uint8Array) { - return new Promise>((resolve, reject) => { - unzip(payload, (err, data) => { - if (err) { - return reject(err) - } - resolve(data) - }) - }) - } -} - -export interface UgoiraFrame { - file: string - delay: number -} -export interface UgoiraMeta { - frames: UgoiraFrame[] - mime_type: string - originalSrc: string - src: string -} diff --git a/src/utils/ZipDownloader.ts b/src/utils/ZipDownloader.ts deleted file mode 100644 index c36e9a7f..00000000 --- a/src/utils/ZipDownloader.ts +++ /dev/null @@ -1,983 +0,0 @@ -/** - * ZipDownloader - 支持分片下载和缓存的 ZIP 文件下载器 - * - * 使用示例: - * - * // 使用默认配置(128KB 分片,32KB 初始尾部抓取) - * const downloader = new ZipDownloader('https://example.com/file.zip') - * - * // 自定义分片大小和尾部抓取策略 - * const downloader = new ZipDownloader('https://example.com/file.zip', { - * chunkSize: 256 * 1024, // 256KB 分片 - * initialTailSize: 16 * 1024, // 16KB 初始尾部抓取 - * maxTailSize: 128 * 1024, // 128KB 最大尾部抓取 - * timeoutMs: 10000, // 10秒超时 - * retries: 3, // 重试3次 - * lruBytes: 32 * 1024 * 1024, // 32MB 缓存 - * parallelProbe: 4, // 4个并发探测 - * maxConcurrentRequests: 6, // 最大6个并发请求 - * tryDecompress: true // 尝试解压 - * }) - * - * // 获取文件列表 - * const overview = await downloader.getCentralDirectory() - * - * // 下载单个文件 - * const result = await downloader.downloadByIndex(0) - * const fileData = result.bytes - */ - -type FetchLike = ( - input: RequestInfo | URL, - init?: RequestInit -) => Promise - -export type ZipDownloaderOptions = { - fetch?: FetchLike // 替换 fetch(Node 可注入 node-fetch/undici) - timeoutMs?: number // 单请求超时 - retries?: number // 失败重试次数(指数退避) - lruBytes?: number // Range 缓存的最大总字节数(近似) - parallelProbe?: number // 批量探测本地头的并发度 - maxConcurrentRequests?: number // 最大并发请求数,默认 6 - tryDecompress?: boolean // 尝试解压(method 0/8) - chunkSize?: number // 分片大小(字节),默认 128KB - initialTailSize?: number // 初始尾部抓取大小(字节),默认 32KB - maxTailSize?: number // 最大尾部抓取大小(字节),默认 70KB -} - -export type ZipEntry = { - index: number - fileName: string - compressedSize: number - uncompressedSize: number - crc32: number - compressionMethod: number // 0=store, 8=deflate - generalPurposeBitFlag: number - localHeaderOffset: number - centralHeaderOffset: number - requiresZip64: boolean - mimeType?: string // 通过扩展名推断的MIME类型 -} - -export type ZipOverview = { - url: string - contentLength: number - centralDirectoryOffset: number - centralDirectorySize: number - entryCount: number - entries: ZipEntry[] -} - -export type DataRange = { - index: number - fileName: string - dataStart: number // 压缩数据起点(不含本地头/文件名/extra) - dataLength: number // 压缩数据长度 -} - -const SIG = { - EOCD: 0x06054b50, - ZIP64_EOCD_LOCATOR: 0x07064b50, - ZIP64_EOCD: 0x06064b50, - CD_FILE_HEADER: 0x02014b50, - LOCAL_FILE_HEADER: 0x04034b50, -} - -const TEXT_DECODER = new TextDecoder() - -// MIME类型映射表 -const MIME_TYPES: Record = { - // 图片格式 - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - bmp: 'image/bmp', - svg: 'image/svg+xml', - ico: 'image/x-icon', - tiff: 'image/tiff', - tif: 'image/tiff', - - // 视频格式 - mp4: 'video/mp4', - webm: 'video/webm', - avi: 'video/x-msvideo', - mov: 'video/quicktime', - wmv: 'video/x-ms-wmv', - flv: 'video/x-flv', - mkv: 'video/x-matroska', - - // 音频格式 - mp3: 'audio/mpeg', - wav: 'audio/wav', - ogg: 'audio/ogg', - aac: 'audio/aac', - flac: 'audio/flac', - m4a: 'audio/mp4', - - // 文档格式 - pdf: 'application/pdf', - doc: 'application/msword', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - xls: 'application/vnd.ms-excel', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ppt: 'application/vnd.ms-powerpoint', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - - // 文本格式 - txt: 'text/plain', - html: 'text/html', - htm: 'text/html', - css: 'text/css', - js: 'application/javascript', - json: 'application/json', - xml: 'application/xml', - csv: 'text/csv', - - // 压缩格式 - zip: 'application/zip', - rar: 'application/vnd.rar', - '7z': 'application/x-7z-compressed', - tar: 'application/x-tar', - gz: 'application/gzip', - - // 其他 - exe: 'application/x-msdownload', - dmg: 'application/x-apple-diskimage', - iso: 'application/x-iso9660-image', -} - -/** - * 通过文件扩展名获取MIME类型 - */ -function getMimeTypeFromExtension(fileName: string): string { - const extension = fileName.split('.').pop()?.toLowerCase() - if (!extension) return 'application/octet-stream' - return MIME_TYPES[extension] || 'application/octet-stream' -} - -/** - * 通过文件头(Magic Number)检测MIME类型 - */ -function getMimeTypeFromMagicNumber(data: Uint8Array): string { - if (data.length < 4) return 'application/octet-stream' - - // 检查文件头签名 - const header = Array.from(data.slice(0, 16)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - - // PNG: 89 50 4E 47 0D 0A 1A 0A - if (header.startsWith('89504e470d0a1a0a')) return 'image/png' - - // JPEG: FF D8 FF - if (header.startsWith('ffd8ff')) return 'image/jpeg' - - // GIF: 47 49 46 38 (GIF8) - if (header.startsWith('47494638')) return 'image/gif' - - // WebP: 52 49 46 46 ... 57 45 42 50 - if (header.startsWith('52494646') && data.length >= 12) { - const webpHeader = Array.from(data.slice(8, 12)) - .map((b) => String.fromCharCode(b)) - .join('') - if (webpHeader === 'WEBP') return 'image/webp' - } - - // BMP: 42 4D - if (header.startsWith('424d')) return 'image/bmp' - - // SVG: 检查是否以 = 4) { - const text = new TextDecoder('utf-8', { fatal: false }).decode( - data.slice(0, Math.min(100, data.length)) - ) - if (text.trim().toLowerCase().startsWith('= 8) { - const boxSize = new DataView(data.buffer, data.byteOffset, 4).getUint32( - 0, - false - ) - if (boxSize >= 8 && data.length >= boxSize) { - const boxType = new TextDecoder().decode(data.slice(4, 8)) - if (boxType === 'ftyp') return 'video/mp4' - } - } - - // MP3: FF FB 或 FF F3 或 FF F2 - if ( - header.startsWith('fffb') || - header.startsWith('fff3') || - header.startsWith('fff2') - ) { - return 'audio/mpeg' - } - - // WAV: 52 49 46 46 ... 57 41 56 45 - if (header.startsWith('52494646') && data.length >= 12) { - const wavHeader = Array.from(data.slice(8, 12)) - .map((b) => String.fromCharCode(b)) - .join('') - if (wavHeader === 'WAVE') return 'audio/wav' - } - - return 'application/octet-stream' -} - -/** - * 综合获取MIME类型(优先使用文件头检测,回退到扩展名) - */ -function getMimeType(fileName: string, data?: Uint8Array): string { - // 如果有文件数据,优先使用文件头检测 - if (data && data.length > 0) { - const magicMimeType = getMimeTypeFromMagicNumber(data) - // 如果文件头检测成功且不是默认值,使用文件头结果 - if (magicMimeType !== 'application/octet-stream') { - return magicMimeType - } - } - - // 回退到扩展名检测 - return getMimeTypeFromExtension(fileName) -} - -// 并发限制器 -class ConcurrencyLimiter { - private running = 0 - private queue: Array<() => void> = [] - - constructor(private maxConcurrent: number) {} - - async acquire(): Promise { - if (this.running < this.maxConcurrent) { - this.running++ - return - } - - return new Promise((resolve) => { - this.queue.push(resolve) - }) - } - - release(): void { - this.running-- - if (this.queue.length > 0) { - const next = this.queue.shift()! - this.running++ - next() - } - } - - async execute(fn: () => Promise): Promise { - await this.acquire() - try { - return await fn() - } finally { - this.release() - } - } -} - -// 简易 LRU 缓存(按插入近似控制体积) -class ByteLRU { - private map = new Map() - private total = 0 - constructor(private maxBytes: number) {} - get(k: string) { - const v = this.map.get(k) - if (!v) return - this.map.delete(k) - this.map.set(k, v) - return v - } - set(k: string, v: Uint8Array) { - if (v.byteLength > this.maxBytes) return // 太大就不缓存 - if (this.map.has(k)) { - const old = this.map.get(k)! - this.total -= old.byteLength - this.map.delete(k) - } - this.map.set(k, v) - this.total += v.byteLength - while (this.total > this.maxBytes && this.map.size) { - const [oldK, oldV] = this.map.entries().next().value as [ - string, - Uint8Array, - ] - this.map.delete(oldK) - this.total -= oldV.byteLength - } - } -} - -function withTimeout(p: Promise, ms?: number): Promise { - if (!ms || ms <= 0) return p - return new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error(`Timeout ${ms}ms`)), ms) - p.then( - (v) => { - clearTimeout(t) - resolve(v) - }, - (e) => { - clearTimeout(t) - reject(e) - } - ) - }) -} - -function sleep(ms: number) { - return new Promise((res) => setTimeout(res, ms)) -} - -async function retry( - fn: () => Promise, - retries: number, - baseDelay = 200 -): Promise { - let attempt = 0 - while (true) { - try { - return await fn() - } catch (e) { - if (attempt >= retries) throw e - const delay = baseDelay * Math.pow(2, attempt) * (1 + Math.random() * 0.2) - await sleep(delay) - attempt++ - } - } -} - -function findSignatureFromEnd(buf: Uint8Array, signature: number): number { - const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) - for (let i = buf.byteLength - 4; i >= 0; i--) { - if (dv.getUint32(i, true) === signature) return i - } - return -1 -} - -function readAscii(dv: DataView, offset: number, length: number): string { - const u8 = new Uint8Array(dv.buffer, dv.byteOffset + offset, length) - return TEXT_DECODER.decode(u8) -} - -async function streamDecompressIfPossible( - method: number, - data: Uint8Array, - tryDecompress: boolean -): Promise<{ bytes: Uint8Array; isDecompressed: boolean; method: number }> { - if (!tryDecompress) return { bytes: data, isDecompressed: false, method } - if (method === 0) return { bytes: data, isDecompressed: true, method } - if ( - method === 8 && - typeof (globalThis as any).DecompressionStream === 'function' - ) { - // ZIP 的 deflate 是 raw(不带 zlib 头) - const ds = new (globalThis as any).DecompressionStream('deflate-raw') - const r = new Response(new Blob([data]).stream().pipeThrough(ds)) - const ab = await r.arrayBuffer() - return { bytes: new Uint8Array(ab), isDecompressed: true, method } - } - // 其他情况(Node/无 DS/其他压缩法)降级为返回压缩数据 - return { bytes: data, isDecompressed: false, method } -} - -export class ZipDownloader { - public options: Required - private inflight = new Map>() // 统一去重:range/元数据/条目 - private rangeCache: ByteLRU - private overview?: ZipOverview - private dataRanges?: DataRange[] // 解析过的数据段定位缓存 - private pathToIndex = new Map() // fileName -> index - private concurrencyLimiter: ConcurrencyLimiter - - constructor( - private url: string, - options: ZipDownloaderOptions = {} - ) { - this.options = { - fetch: options.fetch ?? fetch.bind(globalThis), - timeoutMs: options.timeoutMs ?? 15000, - retries: options.retries ?? 2, - lruBytes: options.lruBytes ?? 16 * 1024 * 1024, // 16MB - parallelProbe: options.parallelProbe ?? 4, // 降低默认并发数 - maxConcurrentRequests: options.maxConcurrentRequests ?? 6, // 新增:最大并发请求数 - tryDecompress: options.tryDecompress ?? true, - chunkSize: options.chunkSize ?? 128 * 1024, // 128KB - initialTailSize: options.initialTailSize ?? 16 * 1024, // 16KB - maxTailSize: options.maxTailSize ?? 128 * 1024, // 128KB - } - this.rangeCache = new ByteLRU(this.options.lruBytes) - this.concurrencyLimiter = new ConcurrencyLimiter( - this.options.maxConcurrentRequests - ) - } - - /** 获取文件总长度(Content-Length) */ - async getSize(signal?: AbortSignal): Promise { - const key = `size` - if (!this.inflight.has(key)) { - this.inflight.set( - key, - this._getContentLength(signal).catch((error) => { - this.inflight.delete(key) - throw error - }) - ) - } - const size = await this.inflight.get(key)! - return size - } - - /** 获取 ZIP 中央目录信息(含缓存/去重) */ - async getCentralDirectory(signal?: AbortSignal): Promise { - if (this.overview) return this.overview - const key = `overview` - if (!this.inflight.has(key)) { - this.inflight.set( - key, - this._buildOverview(signal) - .then((ov) => { - this.overview = ov - // 快速路径映射 - ov.entries.forEach((e) => this.pathToIndex.set(e.fileName, e.index)) - return ov - }) - .catch((error) => { - this.inflight.delete(key) - throw error - }) - ) - } - return this.inflight.get(key)! - } - - /** 通过条目索引下载单个文件(默认尽力解压,若无法解压则返回压缩数据) */ - async downloadByIndex( - index: number, - signal?: AbortSignal - ): Promise<{ - fileName: string - index: number - bytes: Uint8Array - isDecompressed: boolean - method: number - compressedSize: number - uncompressedSize: number - mimeType: string // 通过文件头检测的准确MIME类型 - mimeTypeFromExtension: string // 通过扩展名推断的MIME类型 - }> { - const ov = await this.getCentralDirectory(signal) - if (index < 0 || index >= ov.entryCount) - throw new Error(`index out of range: ${index}`) - const e = ov.entries[index] - - // 解析数据段范围(带缓存/去重) - const ranges = await this._ensureDataRanges(signal) - const r = ranges[index] - - const key = `entry:${index}:${r.dataStart}-${r.dataLength}` - if (!this.inflight.has(key)) { - this.inflight.set( - key, - (async () => { - const body = await this._fetchRange( - r.dataStart, - r.dataStart + r.dataLength - 1, - signal - ) - const { bytes, isDecompressed, method } = - await streamDecompressIfPossible( - e.compressionMethod, - body, - this.options.tryDecompress - ) - - // 获取MIME类型 - const mimeTypeFromExtension = getMimeTypeFromExtension(e.fileName) - const mimeType = getMimeType(e.fileName, bytes) - - return { - fileName: e.fileName, - index: e.index, - bytes, - isDecompressed, - method, - compressedSize: e.compressedSize, - uncompressedSize: e.uncompressedSize, - mimeType, - mimeTypeFromExtension, - } - })().catch((error) => { - this.inflight.delete(key) - throw error - }) - ) - } - return this.inflight.get(key)! - } - - /** 通过路径下载单个文件(大小写敏感,完全匹配) */ - async downloadByPath(path: string, signal?: AbortSignal) { - const ov = await this.getCentralDirectory(signal) - const idx = this.pathToIndex.get(path) - if (idx == null) { - // 退而求其次:做一次 O(n) 查找 - const found = ov.entries.find((e) => e.fileName === path) - if (!found) throw new Error(`file not found in zip: ${path}`) - return this.downloadByIndex(found.index, signal) - } - return this.downloadByIndex(idx, signal) - } - - // ------------------- 内部实现 ------------------- - - private async _getContentLength(signal?: AbortSignal): Promise { - const doHead = async () => { - console.log('[ZipDownloader] Trying HEAD request for content-length') - const res = await withTimeout( - this.options.fetch(this.url, { method: 'HEAD', signal }), - this.options.timeoutMs - ) - console.log('[ZipDownloader] HEAD response:', { - status: res.status, - ok: res.ok, - contentLength: res.headers.get('content-length'), - }) - - if (!res.ok) { - throw new Error(`HEAD request failed with status ${res.status}`) - } - - const len = res.headers.get('content-length') - if (!len) { - throw new Error( - 'HEAD request succeeded but missing content-length header' - ) - } - - const length = Number(len) - if (isNaN(length) || length < 0) { - throw new Error(`Invalid content-length value: ${len}`) - } - - console.log( - '[ZipDownloader] HEAD request successful, content-length:', - length - ) - return length - } - - const doProbe = async () => { - console.log( - '[ZipDownloader] Trying Range: bytes=0-0 request for content-length' - ) - const res = await withTimeout( - this.options.fetch(this.url, { - headers: { Range: 'bytes=0-0' }, - signal, - }), - this.options.timeoutMs - ) - console.log('[ZipDownloader] Range response:', { - status: res.status, - ok: res.ok, - contentRange: res.headers.get('content-range'), - }) - - const cr = res.headers.get('content-range') // e.g. "bytes 0-0/12345" - if (!cr) { - throw new Error( - 'Server does not support range requests or missing content-range header' - ) - } - - const m = cr.match(/\/(\d+)$/) - if (!m) { - throw new Error(`Cannot parse content-range: ${cr}`) - } - - const length = Number(m[1]) - if (isNaN(length) || length < 0) { - throw new Error(`Invalid content-length from range: ${m[1]}`) - } - - console.log( - '[ZipDownloader] Range request successful, content-length:', - length - ) - return length - } - - return retry(async () => { - try { - return await doHead() - } catch (headError) { - console.log( - '[ZipDownloader] HEAD request failed, falling back to range request:', - (headError as Error).message - ) - return await doProbe() - } - }, this.options.retries) - } - - private async _buildOverview(signal?: AbortSignal): Promise { - const contentLength = await this._getContentLength(signal) - - // 渐进式抓取尾部数据:先尝试初始大小,不够再扩展 - let tailSize = Math.min(this.options.initialTailSize, contentLength) - let tailStart = contentLength - tailSize - let tail = await this._fetchRange(tailStart, contentLength - 1, signal) - let tailDV = new DataView(tail.buffer, tail.byteOffset, tail.byteLength) - - let eocdPos = findSignatureFromEnd(tail, SIG.EOCD) - - if (eocdPos > -1) { - console.log('[ZipDownloader] EOCD found in initial tail:', { - eocdPos, - tailSize, - contentLength, - }) - } - - // 如果初始大小不够找到 EOCD,逐步扩展 - while (eocdPos === -1 && tailSize < contentLength) { - // 扩展抓取范围,每次增加初始大小,最大不超过 maxTailSize - const newTailSize = Math.min( - tailSize + this.options.initialTailSize, - this.options.maxTailSize, - contentLength - ) - if (newTailSize === tailSize) break // 无法再扩展 - - console.log('[ZipDownloader] Fetching tail:', { - tailSize, - newTailSize, - contentLength, - }) - - tailSize = newTailSize - tailStart = contentLength - tailSize - tail = await this._fetchRange(tailStart, contentLength - 1, signal) - tailDV = new DataView(tail.buffer, tail.byteOffset, tail.byteLength) - eocdPos = findSignatureFromEnd(tail, SIG.EOCD) - } - - if (eocdPos === -1) - throw new Error('EOCD not found; invalid ZIP or clipped tail') - - let cdCount = 0 - let cdSize = 0 - let cdOffset = 0 - let zip64 = false - - // EOCD - { - const base = eocdPos - cdCount = tailDV.getUint16(base + 10, true) - cdSize = tailDV.getUint32(base + 12, true) - cdOffset = tailDV.getUint32(base + 16, true) - if ( - cdCount === 0xffff || - cdSize === 0xffffffff || - cdOffset === 0xffffffff - ) - zip64 = true - } - - if (zip64) { - const locPos = findSignatureFromEnd(tail, SIG.ZIP64_EOCD_LOCATOR) - if (locPos === -1) - throw new Error('ZIP64 locator not found, but EOCD indicates ZIP64') - const z64Off = Number(tailDV.getBigUint64(locPos + 8, true)) - const z64Hdr = await this._fetchRange(z64Off, z64Off + 160 - 1, signal) - const dv = new DataView( - z64Hdr.buffer, - z64Hdr.byteOffset, - z64Hdr.byteLength - ) - if (dv.getUint32(0, true) !== SIG.ZIP64_EOCD) - throw new Error('ZIP64 EOCD signature mismatch') - cdCount = Number(dv.getBigUint64(32, true)) - cdSize = Number(dv.getBigUint64(40, true)) - cdOffset = Number(dv.getBigUint64(48, true)) - } - - const cdBuf = await this._fetchRange( - cdOffset, - cdOffset + cdSize - 1, - signal - ) - const cdDV = new DataView(cdBuf.buffer, cdBuf.byteOffset, cdBuf.byteLength) - - const entries: ZipEntry[] = [] - let p = 0 - for (let i = 0; p < cdBuf.byteLength && i < cdCount; i++) { - const sig = cdDV.getUint32(p, true) - if (sig !== SIG.CD_FILE_HEADER) break - - const generalPurposeBitFlag = cdDV.getUint16(p + 8, true) - const compressionMethod = cdDV.getUint16(p + 10, true) - const crc32 = cdDV.getUint32(p + 16, true) - const compSize32 = cdDV.getUint32(p + 20, true) - const uncompSize32 = cdDV.getUint32(p + 24, true) - const fnLen = cdDV.getUint16(p + 28, true) - const extraLen = cdDV.getUint16(p + 30, true) - const commentLen = cdDV.getUint16(p + 32, true) - const localHeaderOffset32 = cdDV.getUint32(p + 42, true) - const name = readAscii(cdDV, p + 46, fnLen) - - let compressedSize = compSize32 - let uncompressedSize = uncompSize32 - let localHeaderOffset = localHeaderOffset32 - let requiresZip64 = false - - // 解析 extra - const extraStart = p + 46 + fnLen - const extra = new DataView( - cdBuf.buffer, - cdBuf.byteOffset + extraStart, - extraLen - ) - let ep = 0 - while (ep + 4 <= extraLen) { - const headerId = extra.getUint16(ep, true) - const dataSize = extra.getUint16(ep + 2, true) - const dataStart = ep + 4 - if (headerId === 0x0001) { - requiresZip64 = true - let z = dataStart - if ( - uncompressedSize === 0xffffffff && - z + 8 <= dataStart + dataSize - ) { - uncompressedSize = Number(extra.getBigUint64(z, true)) - z += 8 - } - if (compressedSize === 0xffffffff && z + 8 <= dataStart + dataSize) { - compressedSize = Number(extra.getBigUint64(z, true)) - z += 8 - } - if ( - localHeaderOffset === 0xffffffff && - z + 8 <= dataStart + dataSize - ) { - localHeaderOffset = Number(extra.getBigUint64(z, true)) - z += 8 - } - } - ep += 4 + dataSize - } - - entries.push({ - index: i, - fileName: name, - compressedSize, - uncompressedSize, - crc32, - compressionMethod, - generalPurposeBitFlag, - localHeaderOffset, - centralHeaderOffset: cdOffset + p, - requiresZip64, - mimeType: getMimeTypeFromExtension(name), // 添加MIME类型 - }) - - p = extraStart + extraLen + commentLen - } - - return { - url: this.url, - contentLength, - centralDirectoryOffset: cdOffset, - centralDirectorySize: cdSize, - entryCount: entries.length, - entries, - } - } - - private async _ensureDataRanges(signal?: AbortSignal): Promise { - if (this.dataRanges) return this.dataRanges - - const key = `ranges` - if (!this.inflight.has(key)) { - this.inflight.set( - key, - (async () => { - const ov = await this.getCentralDirectory(signal) - const results: DataRange[] = new Array(ov.entryCount) - const q: number[] = ov.entries.map((e) => e.index) - - // 使用更保守的并发控制策略 - const limit = Math.min( - this.options.parallelProbe, - this.options.maxConcurrentRequests - ) - const semaphore = new ConcurrencyLimiter(limit) - - // 批量处理,避免同时启动过多请求 - const batchSize = Math.min(limit, 8) // 每批最多8个 - const batches: number[][] = [] - for (let i = 0; i < q.length; i += batchSize) { - batches.push(q.slice(i, i + batchSize)) - } - - // 逐批处理,每批内部并发 - for (const batch of batches) { - const batchPromises = batch.map(async (idx) => { - const e = ov.entries[idx] - return semaphore.execute(async () => { - const dr = await this._probeLocalHeader(e, signal) - results[idx] = dr - }) - }) - await Promise.all(batchPromises) - } - - // 填补目录项(文件夹)可能没有数据段:compressedSize=0 - for (let j = 0; j < results.length; j++) { - if (!results[j]) { - const e = ov.entries[j] - results[j] = { - index: e.index, - fileName: e.fileName, - dataStart: e.localHeaderOffset + 30, - dataLength: e.compressedSize, - } - } - } - this.dataRanges = results - return results - })().catch((error) => { - this.inflight.delete(key) - throw error - }) - ) - } - return this.inflight.get(key)! - } - - private async _probeLocalHeader( - e: ZipEntry, - signal?: AbortSignal - ): Promise { - // 读取本地头(固定30字节)+ 可变 fileName/extra 上限(抓 30+512 足够绝大多数) - const key = `probe:${e.localHeaderOffset}` - if (!this.inflight.has(key)) { - this.inflight.set( - key, - (async () => { - const probe = await this._fetchRange( - e.localHeaderOffset, - e.localHeaderOffset + 30 + this.options.chunkSize - 1, - signal - ) - const dv = new DataView( - probe.buffer, - probe.byteOffset, - probe.byteLength - ) - if (dv.getUint32(0, true) !== SIG.LOCAL_FILE_HEADER) { - throw new Error( - `Local header signature mismatch at offset ${e.localHeaderOffset}` - ) - } - const fnLen = dv.getUint16(26, true) - const extraLen = dv.getUint16(28, true) - const dataStart = e.localHeaderOffset + 30 + fnLen + extraLen - return { - index: e.index, - fileName: e.fileName, - dataStart, - dataLength: e.compressedSize, - } - })().catch((error) => { - this.inflight.delete(key) - throw error - }) - ) - } - return this.inflight.get(key)! - } - - private async _fetchRange( - start: number, - end: number, - signal?: AbortSignal - ): Promise { - const cacheKey = `r:${start}-${end}` - const cached = this.rangeCache.get(cacheKey) - if (cached) return cached - - const inflightKey = `fetch:${start}-${end}` - if (!this.inflight.has(inflightKey)) { - const runner = async () => { - return this.concurrencyLimiter.execute(async () => { - const res = await retry(async () => { - const r = await withTimeout( - this.options.fetch(this.url, { - headers: { Range: `bytes=${start}-${end}` }, - signal, - }), - this.options.timeoutMs - ) - if (!(r.status === 206 || r.status === 200)) - throw new Error(`HTTP ${r.status} fetching range ${start}-${end}`) - return r - }, this.options.retries) - const u8 = new Uint8Array(await res.arrayBuffer()) - // 写缓存 - this.rangeCache.set(cacheKey, u8) - return u8 - }) - } - this.inflight.set( - inflightKey, - runner().catch((error) => { - this.inflight.delete(inflightKey) - throw error - }) - ) - } - const buf: Uint8Array = await this.inflight.get(inflightKey)! - return buf - } - - clearAll() { - this.inflight.clear() - this.rangeCache = new ByteLRU(this.options.lruBytes) - this.overview = undefined - this.dataRanges = undefined - this.pathToIndex.clear() - // 重新创建并发限制器,确保状态重置 - this.concurrencyLimiter = new ConcurrencyLimiter( - this.options.maxConcurrentRequests - ) - return this - } - - setUrl(url: string) { - if (this.url === url) return this - this.url = url - this.clearAll() - return this - } -} diff --git a/src/utils/ajax.ts b/src/utils/ajax.ts deleted file mode 100644 index 12ba3acf..00000000 --- a/src/utils/ajax.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AxiosRequestConfig } from 'axios' -import nprogress from 'nprogress' - -export const ajax = axios.create({ - timeout: 15 * 1000, - headers: { - 'Content-Type': 'application/json', - }, -}) -ajax.interceptors.request.use((config) => { - nprogress.start() - return config -}) -ajax.interceptors.response.use( - (res) => { - nprogress.done() - return res - }, - (err) => { - nprogress.done() - return Promise.reject(err) - } -) - -export const ajaxPostWithFormData = ( - url: string, - data: - | string - | string[][] - | Record - | URLSearchParams - | undefined, - config?: AxiosRequestConfig -) => - ajax.post(url, new URLSearchParams(data).toString(), { - ...config, - headers: { - ...config?.headers, - 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', - }, - }) diff --git a/src/utils/artworkActions.ts b/src/utils/artworkActions.ts deleted file mode 100644 index f0bdcf11..00000000 --- a/src/utils/artworkActions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ajax, ajaxPostWithFormData } from '@/utils/ajax' -import { ArtworkInfo, ArtworkInfoOrAd } from '@/types' - -export function sortArtList( - obj: Record -): T[] { - return Object.values(obj).sort((a, b) => +b.id - +a.id) -} - -export function isArtwork(item: ArtworkInfoOrAd): item is ArtworkInfo { - return Object.keys(item).includes('id') -} - -export async function addBookmark( - illust_id: number | `${number}` -): Promise { - return ( - await ajax.post('/ajax/illusts/bookmarks/add', { - illust_id, - restrict: 0, - comment: '', - tags: [], - }) - ).data -} - -export async function removeBookmark( - bookmark_id: number | `${number}` -): Promise { - return ( - await ajaxPostWithFormData('/ajax/illusts/bookmarks/delete', { - bookmark_id: '' + bookmark_id, - }) - ).data -} diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index f1db3608..00000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ArtworkInfo } from '@/types' - -export * from './artworkActions' -export * from './userActions' - -export const defaultArtwork: ArtworkInfo = { - id: '0', - title: '', - description: '', - createDate: '', - updateDate: '', - illustType: 0, - restrict: 0, - xRestrict: 0, - sl: 0, - userId: '0', - userName: '', - alt: '', - width: 0, - height: 0, - pageCount: 0, - isBookmarkable: false, - bookmarkData: null, - titleCaptionTranslation: { - workTitle: null, - workCaption: null, - }, - isUnlisted: false, - url: '', - tags: [], - profileImageUrl: '', - type: 'illust', - aiType: 1, -} diff --git a/src/utils/setTitle.ts b/src/utils/setTitle.ts deleted file mode 100644 index 4e9f1f16..00000000 --- a/src/utils/setTitle.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PROJECT_NAME, PROJECT_TAGLINE } from '@/config' - -export function setTitle(...args: (string | number | null | undefined)[]) { - return (document.title = [ - ...args.filter((i) => i !== null && typeof i !== 'undefined'), - `${PROJECT_NAME} - ${PROJECT_TAGLINE}`, - ].join(' | ')) -} diff --git a/src/utils/userActions.ts b/src/utils/userActions.ts deleted file mode 100644 index e3cc58a2..00000000 --- a/src/utils/userActions.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ajaxPostWithFormData } from '@/utils/ajax' - -export async function addUserFollow( - user_id: number | `${number}` -): Promise { - return ( - await ajaxPostWithFormData(`/bookmark_add.php`, { - mode: 'add', - type: 'user', - user_id: '' + user_id, - tag: '', - restrict: '0', - format: 'json', - }) - ).data -} - -export async function removeUserFollow( - user_id: number | `${number}` -): Promise { - return ( - await ajaxPostWithFormData(`/rpc_group_setting.php`, { - mode: 'del', - type: 'bookuser', - id: '' + user_id, - }) - ).data -} diff --git a/src/view/siteCache.ts b/src/view/siteCache.ts deleted file mode 100644 index af7fdae1..00000000 --- a/src/view/siteCache.ts +++ /dev/null @@ -1,10 +0,0 @@ -const _siteCacheData = new Map() -export function setCache(key: string | number, val: any) { - console.log('setCache', key, val) - _siteCacheData.set(key, val) -} -export function getCache(key: string | number) { - const val = _siteCacheData.get(key) - console.log('getCache', key, val) - return val -} diff --git a/test/illustRecommend.json b/tests/illustRecommend.json similarity index 100% rename from test/illustRecommend.json rename to tests/illustRecommend.json diff --git a/test/userBookmarks.json b/tests/userBookmarks.json similarity index 100% rename from test/userBookmarks.json rename to tests/userBookmarks.json diff --git a/tsconfig.json b/tsconfig.json index 26954b5c..a746f2a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,38 +1,4 @@ { - "compilerOptions": { - "baseUrl": ".", - "target": "esnext", - "module": "esnext", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true, - "jsx": "preserve", - "sourceMap": true, - "lib": ["esnext", "dom", "dom.iterable"], - "plugins": [], - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "isolatedModules": true, - "importHelpers": true, - "paths": { - "@/*": ["src/*"] - }, - "types": ["unplugin-icons/types/vue"] - }, - "include": [ - "api/**/*.ts", - "src/**/*.ts", - "src/**/*.d.ts", - "src/**/*.tsx", - "src/**/*.vue", - "src/**/*.json", - "vite.config.ts", - "auto-imports.d.ts", - "components.d.ts", - "middleware.ts" - ], - "exclude": ["**/dist"], - "vueCompilerOptions": { - "plugins": ["@vue/language-plugin-pug"] - } + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" } diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 808435e3..00000000 --- a/vercel.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "devCommand": "vite --port $PORT", - "framework": "vue", - "rewrites": [ - { - "source": "/:__PREFIX(~)/:__PATH(.+)", - "destination": "/api/image" - }, - { - "source": "/:__PREFIX(ajax|rpc|.+\\.php)/:__PATH*", - "destination": "/api/http" - }, - { - "source": "/api/illust/random", - "destination": "/api/random" - } - ], - "redirects": [ - { - "source": "/-/:__PATH*", - "destination": "https://pximg.wjghj.cn/:__PATH" - } - ], - "headers": [] -} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 5d613c20..00000000 --- a/vite.config.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import AutoImport from 'unplugin-auto-import/vite' -import Icons from 'unplugin-icons/vite' -import IconResolver from 'unplugin-icons/resolver' -import Components from 'unplugin-vue-components/vite' -import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' - -const PROD = process.env.NODE_ENV === 'production' - -export default defineConfig({ - plugins: [ - vue(), - AutoImport({ - dts: true, - imports: [ - 'vue', - 'vue-router', - 'vue-i18n', - '@vueuse/core', - { axios: [['default', 'axios']] }, - ], - resolvers: [ - IconResolver({ - alias: { - fas: 'fa-solid', - }, - }), - ], - dirs: ['src/components/**', 'src/composables', 'src/utils', 'src/types'], - }), - Components({ dts: true, resolvers: [NaiveUiResolver()] }), - Icons({ - scale: 1, - defaultClass: 'svg--inline', - }), - ], - build: {}, - esbuild: { - drop: PROD ? ['console'] : [], - }, - server: { host: true }, - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, - extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.json'], - }, -})