diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 47bdcff37e..c8e0b9a001 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -43,6 +43,7 @@ updates:
- '@opentelemetry/*'
oxc:
patterns:
+ - '@oxc-parser/*'
- '@oxlint/*'
- 'oxc-parser'
- 'oxfmt'
diff --git a/lib/routes/dongqiudi/special.ts b/lib/routes/dongqiudi/special.ts
index 5315385cc3..b42d2b6441 100644
--- a/lib/routes/dongqiudi/special.ts
+++ b/lib/routes/dongqiudi/special.ts
@@ -42,7 +42,7 @@ async function handler(ctx) {
cache.tryGet(item.link, async () => {
try {
const { data: response } = await got(item.link);
- processFeedType2(item, response);
+ await processFeedType2(item, response);
} catch (error) {
if (!(error instanceof FetchError) || error.statusCode !== 404) {
throw error;
diff --git a/lib/routes/dongqiudi/top-news.ts b/lib/routes/dongqiudi/top-news.ts
index 68ad93675a..f5d9d81726 100644
--- a/lib/routes/dongqiudi/top-news.ts
+++ b/lib/routes/dongqiudi/top-news.ts
@@ -49,7 +49,7 @@ async function handler(ctx) {
list.map((item) =>
cache.tryGet(item.link, async () => {
const { data: response } = await got(item.link);
- processFeedType2(item, response);
+ await processFeedType2(item, response);
return item;
})
)
diff --git a/lib/routes/dongqiudi/utils.ts b/lib/routes/dongqiudi/utils.ts
index 23f4fa8b4c..1d498b21a9 100644
--- a/lib/routes/dongqiudi/utils.ts
+++ b/lib/routes/dongqiudi/utils.ts
@@ -3,34 +3,24 @@ import { load } from 'cheerio';
import cache from '@/utils/cache';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { parseScriptData } from '@/utils/parse-script-data';
import timezone from '@/utils/timezone';
-const readNuxtData = (response: string) => {
- const $ = load(response);
- const script = $('script')
- .toArray()
- .map((element) => $(element).text())
- .find((source) => /\b__NUXT__\s*=/.test(source));
- return script ? parseScriptData<{ data?: any[] } | undefined>(script, '__NUXT__')?.data?.[0] : undefined;
-};
-
-const processVideo = (content) => {
- content('div.video').each((i, v) => {
+const processVideo = ($, scope) => {
+ scope.find('div.video').each((i, v) => {
let link = new URL(v.attribs.src);
if (link.host === 'm.miguvideo.com') {
- content(` ▶️ 观看视频
`).insertAfter(v);
- content(v).remove();
+ $(` ▶️ 观看视频
`).insertAfter(v);
+ $(v).remove();
} else {
link = v.attribs.src;
switch (v.attribs.site) {
case 'qiniu':
- content(``).insertAfter(v);
- content(v).remove();
+ $(``).insertAfter(v);
+ $(v).remove();
break;
case 'youku':
- content(``).insertAfter(v);
- content(v).remove();
+ $(``).insertAfter(v);
+ $(v).remove();
break;
default:
break;
@@ -39,16 +29,14 @@ const processVideo = (content) => {
});
// Process iframes
- content('iframe.media-iframe, .edui-faked-video').each((i, v) => {
+ scope.find('iframe.media-iframe, .edui-faked-video').each((i, v) => {
const link = v.attribs.src;
if (link.startsWith('http://ssports.iqiyi.com/')) {
- content(` ▶️ 观看视频
`).insertAfter(v);
+ $(` ▶️ 观看视频
`).insertAfter(v);
}
- content(v).remove();
+ $(v).remove();
});
-
- return content;
};
const processHref = (content) => {
@@ -77,19 +65,16 @@ export const processFeed = async (type, id) => {
const link = `https://www.dongqiudi.com/${type}/${id}.html`;
const apiUrl = 'https://api.dongqiudi.com/v3/archive/app/channel/feeds';
const { data: response } = await got(link);
+ const $ = load(response);
- const nuxtData = readNuxtData(response);
- if (!nuxtData) {
- throw new Error('Unable to extract Dongqiudi page data');
- }
let name;
let image;
if (type === 'team') {
- name = nuxtData.teamInfo.name;
- image = nuxtData.teamInfo.logo;
+ name = $('h1.tp-hero__name').text();
+ image = $('.tp-hero__logo').attr('src');
} else {
- name = nuxtData.detail.base_info.person_name;
- image = nuxtData.detail.base_info.person_logo;
+ name = $('h1.pp-hero__name').text().trim();
+ image = $('.pp-hero__avatar').attr('src');
}
const { data } = await got(apiUrl, {
@@ -110,12 +95,17 @@ export const processFeed = async (type, id) => {
}));
if (type === 'team' && list.length === 0) {
- list = nuxtData.newsList.map((news) => ({
- title: news.title,
- link: `https://www.dongqiudi.com/articles/${news.id}.html`,
- category: [news.category],
- pubDate: timezone(parseDate(news.time), 8),
- }));
+ list = $('.tp-news-item')
+ .toArray()
+ .map((element) => {
+ const news = $(element);
+ return {
+ title: news.find('.tp-news-item__title').text(),
+ link: new URL(news.attr('href')!, link).href,
+ category: [news.find('.tp-news-item__tag').text()],
+ pubDate: timezone(parseDate(news.find('.tp-news-item__time').text(), 'YYYY-MM-DD HH:mm'), 8),
+ };
+ });
}
const out = await Promise.all(
@@ -123,7 +113,7 @@ export const processFeed = async (type, id) => {
cache.tryGet(item.link, async () => {
const { data: response } = await got(item.link);
- processFeedType2(item, response);
+ await processFeedType2(item, response);
return item;
})
@@ -139,16 +129,18 @@ export const processFeed = async (type, id) => {
};
export const processFeedType2 = (item, response) => {
- const data = readNuxtData(response)?.article;
-
- // filter out undefined item
- if (!data) {
+ const $ = load(response);
+ const articleBody = $('.article-body');
+ if (!articleBody.length) {
return;
}
- const body = processVideo(load(data.rawBody, null, false));
- processHref(body('a'));
- processImg(body('img'));
- item.description = body.html();
- item.author = data.author;
+ processVideo($, articleBody);
+ processHref(articleBody.find('a'));
+ processImg(articleBody.find('img'));
+ item.description = articleBody.html();
+ const author = $('.article-head__author-name').text();
+ if (author) {
+ item.author = author;
+ }
};
diff --git a/lib/routes/hotukdeals/hottest.ts b/lib/routes/hotukdeals/hottest.ts
index cda2e96386..0bce0a5e56 100644
--- a/lib/routes/hotukdeals/hottest.ts
+++ b/lib/routes/hotukdeals/hottest.ts
@@ -1,8 +1,8 @@
import { load } from 'cheerio';
import type { Route } from '@/types';
+import { evaluateScriptData } from '@/utils/evaluate-script';
import got from '@/utils/got';
-import { parseScriptData } from '@/utils/parse-script-data';
export const route: Route = {
path: '/hottest',
@@ -37,7 +37,7 @@ async function handler() {
.map((element) => $(element).text())
.filter((source) => source.includes('__INITIAL_STATE__'))
.join('\n');
- const { widgets } = parseScriptData<{
+ const { widgets } = await evaluateScriptData<{
widgets: {
hottestWidget: {
threads: Array<{ title: string; mainImage: { path: string; name: string }; temperature: number; displayPrice: string; url: string }>;
diff --git a/lib/routes/lofter/tag.ts b/lib/routes/lofter/tag.ts
index 5d294971a0..a348f3644a 100644
--- a/lib/routes/lofter/tag.ts
+++ b/lib/routes/lofter/tag.ts
@@ -3,9 +3,9 @@ import { load } from 'cheerio';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import type { Route } from '@/types';
+import { evaluateScriptCallback } from '@/utils/evaluate-script';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { parseScriptCallback } from '@/utils/parse-script-data';
export const route: Route = {
path: '/tag/:name?/:type?',
@@ -83,7 +83,7 @@ async function handler(ctx) {
},
});
- const data = parseScriptCallback(response.data, 'dwr.engine._remoteHandleCallback');
+ const data = await evaluateScriptCallback(response.data, 'dwr.engine._remoteHandleCallback');
const title =
{
diff --git a/lib/routes/nintendo/utils.ts b/lib/routes/nintendo/utils.ts
index 22be8f0966..32dcc2471f 100644
--- a/lib/routes/nintendo/utils.ts
+++ b/lib/routes/nintendo/utils.ts
@@ -4,15 +4,15 @@ import { load } from 'cheerio';
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat.js';
+import { evaluateScriptData } from '@/utils/evaluate-script';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { parseScriptData } from '@/utils/parse-script-data';
import { renderEshopCnDescription } from './templates/eshop-cn';
dayjs.extend(localizedFormat);
-function nuxtReader(data) {
+async function nuxtReader(data) {
let nuxt;
try {
const $ = load(data);
@@ -20,7 +20,7 @@ function nuxtReader(data) {
.toArray()
.map((element) => $(element).text())
.find((source) => /\b__NUXT__\s*=/.test(source));
- nuxt = parseScriptData<{ data: any[] }>(script ?? '', '__NUXT__').data[0];
+ nuxt = (await evaluateScriptData<{ data: any[] }>(script ?? '', '__NUXT__')).data[0];
} catch {
throw new Error('Nuxt 框架信息提取失败,请报告这个问题');
}
diff --git a/lib/routes/pixiv/novel-api/content/nsfw.ts b/lib/routes/pixiv/novel-api/content/nsfw.ts
index c482debd7b..ab7e30efaf 100644
--- a/lib/routes/pixiv/novel-api/content/nsfw.ts
+++ b/lib/routes/pixiv/novel-api/content/nsfw.ts
@@ -2,8 +2,8 @@ import { load } from 'cheerio';
import queryString from 'query-string';
import cache from '@/utils/cache';
+import { evaluateScriptData } from '@/utils/evaluate-script';
import { parseDate } from '@/utils/parse-date';
-import { parseScriptData } from '@/utils/parse-script-data';
import { maskHeader } from '../../constants';
import got from '../../pixiv-got';
@@ -32,7 +32,7 @@ export async function getNSFWNovelContent(novelId: string, token: string): Promi
if (!script) {
throw new Error('No novel data found');
}
- const novelDetail = parseScriptData(script, 'window.pixiv.novel');
+ const novelDetail = await evaluateScriptData(script, 'window.pixiv.novel');
if (!novelDetail) {
throw new Error('No novel data found');
diff --git a/lib/routes/qq/kg/cache.ts b/lib/routes/qq/kg/cache.ts
index a440b54c74..e48b29fd20 100644
--- a/lib/routes/qq/kg/cache.ts
+++ b/lib/routes/qq/kg/cache.ts
@@ -1,8 +1,8 @@
import { load } from 'cheerio';
import cache from '@/utils/cache';
+import { evaluateScriptData } from '@/utils/evaluate-script';
import got from '@/utils/got';
-import { parseScriptData } from '@/utils/parse-script-data';
export default {
getPlayInfo: async (ctx, shareId, ksong_mid = '') => {
@@ -16,7 +16,7 @@ export default {
.map((element) => $(element).text())
.filter((source) => source.includes('__DATA__'))
.join('\n');
- const data = parseScriptData<{
+ const data = await evaluateScriptData<{
detail: {
song_name: string;
content: string;
diff --git a/lib/routes/qq/kg/user.ts b/lib/routes/qq/kg/user.ts
index 4f2d6e9e33..d12578057b 100644
--- a/lib/routes/qq/kg/user.ts
+++ b/lib/routes/qq/kg/user.ts
@@ -1,9 +1,9 @@
import { load } from 'cheerio';
import type { Route } from '@/types';
+import { evaluateScriptData } from '@/utils/evaluate-script';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { parseScriptData } from '@/utils/parse-script-data';
import cache from './cache';
@@ -36,7 +36,7 @@ async function handler(ctx) {
.map((element) => $(element).text())
.filter((source) => source.includes('__DATA__'))
.join('\n');
- const data = parseScriptData<{
+ const data = await evaluateScriptData<{
data: {
nickname: string;
head_img_url: string;
diff --git a/lib/routes/xueqiu/column.ts b/lib/routes/xueqiu/column.ts
index 22823cf7b6..67e2d5b87d 100644
--- a/lib/routes/xueqiu/column.ts
+++ b/lib/routes/xueqiu/column.ts
@@ -2,9 +2,9 @@ import { load } from 'cheerio';
import { CookieJar } from 'tough-cookie';
import type { Route } from '@/types';
+import { evaluateScriptData } from '@/utils/evaluate-script';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
-import { parseScriptData } from '@/utils/parse-script-data';
const cookieJar = new CookieJar();
const baseUrl = 'https://xueqiu.com';
@@ -50,7 +50,7 @@ async function handler(ctx) {
.map((element) => $(element).text())
.filter((source) => source.includes('SNOWMAN_TARGET'))
.join('\n');
- const snowmanTarget = parseScriptData<{ screen_name: string; description: string }>(script, 'SNOWMAN_TARGET');
+ const snowmanTarget = await evaluateScriptData<{ screen_name: string; description: string }>(script, 'SNOWMAN_TARGET');
const { data } = await got(`${baseUrl}/statuses/original/timeline.json`, {
cookieJar,
diff --git a/lib/routes/zhihu/activities.ts b/lib/routes/zhihu/activities.ts
index e7e83238b7..0bb615ff5b 100644
--- a/lib/routes/zhihu/activities.ts
+++ b/lib/routes/zhihu/activities.ts
@@ -2,7 +2,6 @@ import sanitizeHtml from 'sanitize-html';
import type { Route } from '@/types';
import { ViewType } from '@/types';
-import { isWorker } from '@/utils/is-worker';
import { parseDate } from '@/utils/parse-date';
import { processImage, withZhihuClient } from './utils';
@@ -21,7 +20,7 @@ export const route: Route = {
optional: true,
},
],
- requirePuppeteer: isWorker || process.env.WORKER_BUILD === 'true',
+ requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
diff --git a/lib/routes/zhihu/answers.ts b/lib/routes/zhihu/answers.ts
index 157ec11b35..3e45feba23 100644
--- a/lib/routes/zhihu/answers.ts
+++ b/lib/routes/zhihu/answers.ts
@@ -1,5 +1,4 @@
import type { Route } from '@/types';
-import { isWorker } from '@/utils/is-worker';
import { parseDate } from '@/utils/parse-date';
import { processImage, withZhihuClient } from './utils';
@@ -17,7 +16,7 @@ export const route: Route = {
optional: true,
},
],
- requirePuppeteer: isWorker || process.env.WORKER_BUILD === 'true',
+ requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
diff --git a/lib/routes/zhihu/jsdom.ts b/lib/routes/zhihu/jsdom.ts
deleted file mode 100644
index 2e3b104621..0000000000
--- a/lib/routes/zhihu/jsdom.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { JSDOM, VirtualConsole } from 'jsdom';
-import { VM } from 'vm2';
-
-import { config } from '@/config';
-import cache from '@/utils/cache';
-import { generateHeaders } from '@/utils/header-generator';
-import md5 from '@/utils/md5';
-import ofetch from '@/utils/ofetch';
-
-import { getSignedHeaders } from './sign';
-import type { ZhihuClient } from './utils';
-
-let isUnreachableRuntimeErrorGuarded = false;
-const pendingZseCredentials = new Map>();
-
-const preventUnreachableRuntimeError = () => {
- if (isUnreachableRuntimeErrorGuarded) {
- return;
- }
- isUnreachableRuntimeErrorGuarded = true;
- process.on('unhandledRejection', (reason) => {
- const error = reason as { name?: string; message?: string } | undefined;
- if (error?.name === 'RuntimeError' && error.message === 'unreachable') {
- return;
- }
- throw reason;
- });
-};
-
-const generateZseCk = async (url: string, apiPath: string, configuredDc0: string) => {
- preventUnreachableRuntimeError();
-
- // `__zse_ck` is checked against the user-agent that generated it.
- const ua = generateHeaders()['user-agent'];
- const headers = { 'user-agent': ua };
-
- let dc0 = configuredDc0;
- if (!dc0) {
- const seed = await ofetch.raw('https://www.zhihu.com/explore', {
- headers,
- redirect: 'manual',
- ignoreResponseError: true,
- });
- dc0 =
- (seed.headers.getSetCookie?.() ?? [])
- .find((line) => line.startsWith('d_c0='))
- ?.split(';', 1)[0]
- .slice('d_c0='.length)
- .split('|', 1)[0]
- .replace(/=+$/, '') || '';
- }
- if (!dc0) {
- throw new Error('zhihu: failed to obtain a guest d_c0 cookie');
- }
-
- const challenge = await ofetch.raw(`https://www.zhihu.com${apiPath}`, {
- headers: {
- ...headers,
- cookie: `d_c0=${dc0}; __zse_ck=005_x-x`,
- referer: url,
- 'x-requested-with': 'fetch',
- },
- ignoreResponseError: true,
- });
- const html = challenge._data ?? '';
- const meta = html.match(/id="zh-zse-ck"[^>]*content="([^"]*)"/)?.[1];
- const hash = html.match(/zse-ck\/v4\/([a-f0-9]+)\.js/)?.[1];
- if (!meta || !hash) {
- throw new Error('zhihu: challenge page did not contain an URL to __zse_ck meta/script');
- }
-
- const vmScript = await ofetch(`https://static.zhihu.com/zse-ck/v4/${hash}.js`, {
- headers,
- parseResponse: (text) => text,
- });
- const dom = new JSDOM(``, {
- url,
- referrer: 'https://www.zhihu.com/',
- runScripts: 'outside-only',
- pretendToBeVisual: true,
- virtualConsole: new VirtualConsole(),
- });
- const { window } = dom;
- Object.defineProperties(window.navigator, {
- userAgent: { value: ua, configurable: true },
- webdriver: { value: false, configurable: true },
- });
- window.TextEncoder = TextEncoder;
- window.TextDecoder = TextDecoder as typeof window.TextDecoder;
- window.atob = (value: string) => Buffer.from(value, 'base64').toString('binary');
- window.btoa = (value: string) => Buffer.from(value, 'binary').toString('base64');
- Object.assign(window, { __g: {} });
-
- const cookieDescriptor = Object.getOwnPropertyDescriptor(window.Document.prototype, 'cookie');
- const setCookie = cookieDescriptor?.set;
- if (!cookieDescriptor?.get || !setCookie) {
- window.close();
- throw new Error('zhihu: JSDOM did not provide document.cookie accessors');
- }
- const tokenPromise = new Promise((resolve) => {
- Object.defineProperty(window.document, 'cookie', {
- configurable: true,
- get: cookieDescriptor.get,
- set(value: string) {
- setCookie.call(window.document, value);
- const token = value.match(/__zse_ck=([^;]+)/)?.[1];
- if (token?.includes('-')) {
- resolve(token);
- }
- },
- });
- });
-
- let zseCk: string | undefined;
- let timeoutId: ReturnType | number | undefined;
- try {
- // Zhihu's challenge is intentionally delivered as executable JavaScript.
- new VM({ timeout: 3000, sandbox: window }).run(vmScript);
- const timeout = new Promise((resolve) => {
- timeoutId = setTimeout(resolve, 3000);
- });
- zseCk = await Promise.race([tokenPromise, timeout]);
- } finally {
- clearTimeout(timeoutId);
- window.close();
- }
- if (!zseCk) {
- throw new Error('zhihu: WASM VM did not produce a __zse_ck');
- }
- return { dc0, zseCk, ua };
-};
-
-const getGeneratedZseCredentials = (url: string, apiPath: string, configuredDc0: string) => {
- const cacheKey = `zhihu:zse-ck:v4:${configuredDc0 ? md5(configuredDc0) : 'guest'}`;
- const pending = pendingZseCredentials.get(cacheKey);
- if (pending) {
- return pending;
- }
-
- const created = (async () => {
- try {
- return await cache.tryGet(cacheKey, () => generateZseCk(url, apiPath, configuredDc0), config.cache.contentExpire, false);
- } finally {
- pendingZseCredentials.delete(cacheKey);
- }
- })();
- pendingZseCredentials.set(cacheKey, created);
- return created;
-};
-
-const mergeGeneratedCookies = (configured: string, dc0: string, zseCk: string) => {
- const remaining = configured
- .split(';')
- .map((pair) => pair.trim())
- .filter((pair) => {
- const name = pair.split('=', 1)[0];
- return name && name !== 'd_c0' && name !== '__zse_ck';
- });
- return [`__zse_ck=${zseCk}`, `d_c0=${dc0}`, ...remaining].join('; ');
-};
-
-export const createJSDOMClient = (pageUrl: string, configured: string, configuredDc0: string): ZhihuClient => {
- let sessionPromise: Promise<{ dc0: string; headers: { cookie: string; 'user-agent': string } }> | undefined;
- const startSession = async (apiPath: string) => {
- try {
- const { dc0, zseCk, ua } = await getGeneratedZseCredentials(pageUrl, apiPath, configuredDc0);
- return {
- dc0,
- headers: {
- // An isolated login cookie must not be mixed with a new guest identity.
- cookie: configuredDc0 ? mergeGeneratedCookies(configured, dc0, zseCk) : `__zse_ck=${zseCk}; d_c0=${dc0}`,
- 'user-agent': ua,
- },
- };
- } catch (error) {
- sessionPromise = undefined;
- throw error;
- }
- };
- const getSession = (apiPath = new URL(pageUrl).pathname + new URL(pageUrl).search) => (sessionPromise ??= startSession(apiPath));
-
- return {
- get: async (apiPath: string): Promise => {
- if (!apiPath.startsWith('/api/')) {
- throw new Error('zhihu: expected an API path');
- }
- const { dc0, headers } = await getSession(apiPath);
- return ofetch(`https://www.zhihu.com${apiPath}`, {
- headers: { ...getSignedHeaders(apiPath, dc0), ...headers, Referer: pageUrl },
- });
- },
- getPage: async () => {
- const { headers } = await getSession();
- return ofetch(pageUrl, { headers: { ...headers, Referer: pageUrl }, parseResponse: (text) => text });
- },
- };
-};
diff --git a/lib/routes/zhihu/jsdom.worker.ts b/lib/routes/zhihu/jsdom.worker.ts
deleted file mode 100644
index 053cb52b9f..0000000000
--- a/lib/routes/zhihu/jsdom.worker.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { createJSDOMClient as createNodeJSDOMClient } from './jsdom';
-
-// Exclude JSDOM from Worker bundles, including unreachable dynamic import chunks.
-export const createJSDOMClient: typeof createNodeJSDOMClient = () => {
- throw new Error('zhihu: JSDOM session initialization is unavailable on Workers; use a browser session');
-};
diff --git a/lib/routes/zhihu/posts.ts b/lib/routes/zhihu/posts.ts
index 9cd1a9938f..5d89cd6d03 100644
--- a/lib/routes/zhihu/posts.ts
+++ b/lib/routes/zhihu/posts.ts
@@ -1,6 +1,5 @@
import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { isWorker } from '@/utils/is-worker';
import { parseDate } from '@/utils/parse-date';
import type { Articles } from './types';
@@ -19,7 +18,7 @@ export const route: Route = {
optional: true,
},
],
- requirePuppeteer: isWorker || process.env.WORKER_BUILD === 'true',
+ requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
diff --git a/lib/routes/zhihu/question.ts b/lib/routes/zhihu/question.ts
index abf4811235..e0784172d6 100644
--- a/lib/routes/zhihu/question.ts
+++ b/lib/routes/zhihu/question.ts
@@ -1,5 +1,4 @@
import type { Route } from '@/types';
-import { isWorker } from '@/utils/is-worker';
import { parseDate } from '@/utils/parse-date';
import { processImage, withZhihuClient } from './utils';
@@ -17,7 +16,7 @@ export const route: Route = {
optional: true,
},
],
- requirePuppeteer: isWorker || process.env.WORKER_BUILD === 'true',
+ requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
diff --git a/lib/routes/zhihu/topic.ts b/lib/routes/zhihu/topic.ts
index b8b056c7b5..0edd937743 100644
--- a/lib/routes/zhihu/topic.ts
+++ b/lib/routes/zhihu/topic.ts
@@ -1,6 +1,5 @@
import type { Route } from '@/types';
import cache from '@/utils/cache';
-import { isWorker } from '@/utils/is-worker';
import { parseDate } from '@/utils/parse-date';
import { processImage, withZhihuClient } from './utils';
@@ -17,7 +16,7 @@ export const route: Route = {
description: 'A complete d_c0 and __zse_ck cookie pair skips session initialization. Otherwise Workers use a Playwright browser session; Docker and Vercel generate credentials with JSDOM.',
},
],
- requirePuppeteer: isWorker || process.env.WORKER_BUILD === 'true',
+ requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
diff --git a/lib/routes/zhihu/utils.ts b/lib/routes/zhihu/utils.ts
index a3b31b3989..7110741c25 100644
--- a/lib/routes/zhihu/utils.ts
+++ b/lib/routes/zhihu/utils.ts
@@ -1,10 +1,15 @@
import { load } from 'cheerio';
+import { JSDOM, VirtualConsole } from 'jsdom';
+import { VM } from 'vm2';
import { config } from '@/config';
+import cache from '@/utils/cache';
+import { generateHeaders } from '@/utils/header-generator';
import { isWorker } from '@/utils/is-worker';
+import md5 from '@/utils/md5';
import ofetch from '@/utils/ofetch';
-import type { createBrowserClient } from './browser';
+import { createBrowserClient } from './browser';
import { getSignedHeaders } from './sign';
export const header = {
@@ -72,22 +77,203 @@ export type ZhihuClient = {
getPage(): Promise;
};
+let isUnreachableRuntimeErrorGuarded = false;
+const pendingZseCredentials = new Map>();
+
+const preventUnreachableRuntimeError = () => {
+ if (isUnreachableRuntimeErrorGuarded) {
+ return;
+ }
+ isUnreachableRuntimeErrorGuarded = true;
+ process.on('unhandledRejection', (reason) => {
+ const error = reason as { name?: string; message?: string } | undefined;
+ if (error?.name === 'RuntimeError' && error.message === 'unreachable') {
+ return;
+ }
+ throw reason;
+ });
+};
+
+const generateZseCk = async (url: string, apiPath: string, configuredDc0: string) => {
+ preventUnreachableRuntimeError();
+
+ // `__zse_ck` is checked against the user-agent that generated it.
+ const ua = generateHeaders()['user-agent'];
+ const headers = { 'user-agent': ua };
+
+ let dc0 = configuredDc0;
+ if (!dc0) {
+ const seed = await ofetch.raw('https://www.zhihu.com/explore', {
+ headers,
+ redirect: 'manual',
+ ignoreResponseError: true,
+ });
+ dc0 =
+ (seed.headers.getSetCookie?.() ?? [])
+ .find((line) => line.startsWith('d_c0='))
+ ?.split(';', 1)[0]
+ .slice('d_c0='.length)
+ .split('|', 1)[0]
+ .replace(/=+$/, '') || '';
+ }
+ if (!dc0) {
+ throw new Error('zhihu: failed to obtain a guest d_c0 cookie');
+ }
+
+ const challenge = await ofetch.raw(`https://www.zhihu.com${apiPath}`, {
+ headers: {
+ ...headers,
+ cookie: `d_c0=${dc0}; __zse_ck=005_x-x`,
+ referer: url,
+ 'x-requested-with': 'fetch',
+ },
+ ignoreResponseError: true,
+ });
+ const html = challenge._data ?? '';
+ const meta = html.match(/id="zh-zse-ck"[^>]*content="([^"]*)"/)?.[1];
+ const hash = html.match(/zse-ck\/v4\/([a-f0-9]+)\.js/)?.[1];
+ if (!meta || !hash) {
+ throw new Error('zhihu: challenge page did not contain an URL to __zse_ck meta/script');
+ }
+
+ const vmScript = await ofetch(`https://static.zhihu.com/zse-ck/v4/${hash}.js`, {
+ headers,
+ parseResponse: (text) => text,
+ });
+ const dom = new JSDOM(``, {
+ url,
+ referrer: 'https://www.zhihu.com/',
+ runScripts: 'outside-only',
+ pretendToBeVisual: true,
+ virtualConsole: new VirtualConsole(),
+ });
+ const { window } = dom;
+ Object.defineProperties(window.navigator, {
+ userAgent: { value: ua, configurable: true },
+ webdriver: { value: false, configurable: true },
+ });
+ window.TextEncoder = TextEncoder;
+ window.TextDecoder = TextDecoder as typeof window.TextDecoder;
+ window.atob = (value: string) => Buffer.from(value, 'base64').toString('binary');
+ window.btoa = (value: string) => Buffer.from(value, 'binary').toString('base64');
+ Object.assign(window, { __g: {} });
+
+ const cookieDescriptor = Object.getOwnPropertyDescriptor(window.Document.prototype, 'cookie');
+ const setCookie = cookieDescriptor?.set;
+ if (!cookieDescriptor?.get || !setCookie) {
+ window.close();
+ throw new Error('zhihu: JSDOM did not provide document.cookie accessors');
+ }
+ const tokenPromise = new Promise((resolve) => {
+ Object.defineProperty(window.document, 'cookie', {
+ configurable: true,
+ get: cookieDescriptor.get,
+ set(value: string) {
+ setCookie.call(window.document, value);
+ const token = value.match(/__zse_ck=([^;]+)/)?.[1];
+ if (token?.includes('-')) {
+ resolve(token);
+ }
+ },
+ });
+ });
+
+ let zseCk: string | undefined;
+ let timeoutId: ReturnType | number | undefined;
+ try {
+ // Zhihu's challenge is intentionally delivered as executable JavaScript.
+ new VM({ timeout: 3000, sandbox: window }).run(vmScript);
+ const timeout = new Promise((resolve) => {
+ timeoutId = setTimeout(resolve, 3000);
+ });
+ zseCk = await Promise.race([tokenPromise, timeout]);
+ } finally {
+ clearTimeout(timeoutId);
+ window.close();
+ }
+ if (!zseCk) {
+ throw new Error('zhihu: WASM VM did not produce a __zse_ck');
+ }
+ return { dc0, zseCk, ua };
+};
+
+const getGeneratedZseCredentials = (url: string, apiPath: string, configuredDc0: string) => {
+ const cacheKey = `zhihu:zse-ck:v4:${configuredDc0 ? md5(configuredDc0) : 'guest'}`;
+ const pending = pendingZseCredentials.get(cacheKey);
+ if (pending) {
+ return pending;
+ }
+
+ const created = (async () => {
+ try {
+ return await cache.tryGet(cacheKey, () => generateZseCk(url, apiPath, configuredDc0), config.cache.contentExpire, false);
+ } finally {
+ pendingZseCredentials.delete(cacheKey);
+ }
+ })();
+ pendingZseCredentials.set(cacheKey, created);
+ return created;
+};
+
+const mergeGeneratedCookies = (configured: string, dc0: string, zseCk: string) => {
+ const remaining = configured
+ .split(';')
+ .map((pair) => pair.trim())
+ .filter((pair) => {
+ const name = pair.split('=', 1)[0];
+ return name && name !== 'd_c0' && name !== '__zse_ck';
+ });
+ return [`__zse_ck=${zseCk}`, `d_c0=${dc0}`, ...remaining].join('; ');
+};
+
+const createJSDOMClient = (pageUrl: string, configured: string, configuredDc0: string): ZhihuClient => {
+ let sessionPromise: Promise<{ dc0: string; headers: { cookie: string; 'user-agent': string } }> | undefined;
+ const startSession = async (apiPath: string) => {
+ try {
+ const { dc0, zseCk, ua } = await getGeneratedZseCredentials(pageUrl, apiPath, configuredDc0);
+ return {
+ dc0,
+ headers: {
+ // An isolated login cookie must not be mixed with a new guest identity.
+ cookie: configuredDc0 ? mergeGeneratedCookies(configured, dc0, zseCk) : `__zse_ck=${zseCk}; d_c0=${dc0}`,
+ 'user-agent': ua,
+ },
+ };
+ } catch (error) {
+ sessionPromise = undefined;
+ throw error;
+ }
+ };
+ const getSession = (apiPath = new URL(pageUrl).pathname + new URL(pageUrl).search) => (sessionPromise ??= startSession(apiPath));
+
+ return {
+ get: async (apiPath: string): Promise => {
+ if (!apiPath.startsWith('/api/')) {
+ throw new Error('zhihu: expected an API path');
+ }
+ const { dc0, headers } = await getSession(apiPath);
+ return ofetch(`https://www.zhihu.com${apiPath}`, {
+ headers: { ...getSignedHeaders(apiPath, dc0), ...headers, Referer: pageUrl },
+ });
+ },
+ getPage: async () => {
+ const { headers } = await getSession();
+ return ofetch(pageUrl, { headers: { ...headers, Referer: pageUrl }, parseResponse: (text) => text });
+ },
+ };
+};
+
export const withZhihuClient = async (pageUrl: string, callback: (client: ZhihuClient) => Promise): Promise => {
const configured = config.zhihu.cookies || '';
const dc0 = getCookieValueFrom(configured, 'd_c0');
const hasConfiguredSession = !!dc0 && !!getCookieValueFrom(configured, '__zse_ck');
if (!isWorker && !hasConfiguredSession) {
- const { createJSDOMClient } = await import('./jsdom');
return callback(createJSDOMClient(pageUrl, configured, dc0));
}
let browserPromise: ReturnType | undefined;
- const startBrowser = async (apiPath?: string) => {
- const { createBrowserClient } = await import('./browser');
- // Column APIs can initialize directly; other routes need their page session.
- return createBrowserClient(pageUrl, dc0 ? configured : '', apiPath?.startsWith('/api/v4/columns/') ? apiPath : undefined);
- };
- const getBrowser = (apiPath?: string) => (browserPromise ??= startBrowser(apiPath));
+ // Column APIs can initialize directly; other routes need their page session.
+ const getBrowser = (apiPath?: string) => (browserPromise ??= createBrowserClient(pageUrl, dc0 ? configured : '', apiPath?.startsWith('/api/v4/columns/') ? apiPath : undefined));
try {
return await callback({
diff --git a/lib/routes/zhihu/zhuanlan.ts b/lib/routes/zhihu/zhuanlan.ts
index dfa9ac16ad..9614ea5619 100644
--- a/lib/routes/zhihu/zhuanlan.ts
+++ b/lib/routes/zhihu/zhuanlan.ts
@@ -1,7 +1,6 @@
import { load } from 'cheerio';
import type { Route } from '@/types';
-import { isWorker } from '@/utils/is-worker';
import { parseDate } from '@/utils/parse-date';
import { processImage, withZhihuClient } from './utils';
@@ -18,7 +17,7 @@ export const route: Route = {
description: 'A complete d_c0 and __zse_ck cookie pair skips session initialization. Otherwise Workers use a Playwright browser session; Docker and Vercel generate credentials with JSDOM.',
},
],
- requirePuppeteer: isWorker || process.env.WORKER_BUILD === 'true',
+ requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
diff --git a/lib/shims/jsdom.ts b/lib/shims/jsdom.ts
new file mode 100644
index 0000000000..de178b98d4
--- /dev/null
+++ b/lib/shims/jsdom.ts
@@ -0,0 +1,5 @@
+const unavailable = () => {
+ throw new Error('jsdom is unavailable on Workers');
+};
+export const JSDOM = unavailable;
+export const VirtualConsole = unavailable;
diff --git a/lib/shims/vm2.ts b/lib/shims/vm2.ts
new file mode 100644
index 0000000000..db70b4e405
--- /dev/null
+++ b/lib/shims/vm2.ts
@@ -0,0 +1,3 @@
+export const VM = () => {
+ throw new Error('vm2 is unavailable on Workers');
+};
diff --git a/lib/utils/evaluate-script.test.ts b/lib/utils/evaluate-script.test.ts
new file mode 100644
index 0000000000..ec6727f070
--- /dev/null
+++ b/lib/utils/evaluate-script.test.ts
@@ -0,0 +1,133 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { evaluateScriptCallback, evaluateScriptData } from './evaluate-script';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('serialized script data', () => {
+ it.each(['__DATA__', 'window.__DATA__', 'globalThis.__DATA__', 'self.__DATA__'])('normalizes the global target %s', async (target) => {
+ await expect(evaluateScriptData(String.raw`window.__DATA__ = { title: "Hello\u0020世界", tags: ["a", "b"] };`, target)).resolves.toEqual({ title: 'Hello 世界', tags: ['a', 'b'] });
+ });
+
+ it('resolves variables, nested assignments, and data-only unary and logical expressions', async () => {
+ const source = 'var title="A & B", unused=analytics(); window.pixiv=window.pixiv||{}; pixiv.novel={title, count:-2, available:!0, absent:void 0};';
+ await expect(evaluateScriptData(source, 'window.pixiv.novel')).resolves.toEqual({ title: 'A & B', count: -2, available: true, absent: undefined });
+ });
+
+ it('reads parent-object assignments and static bracket targets', async () => {
+ await expect(evaluateScriptData('window.pixiv={novel:{title:"Book"}};', 'self["pixiv"].novel')).resolves.toEqual({ title: 'Book' });
+ });
+
+ it('reads Nuxt IIFE parameters and object mutations without changing outer bindings', async () => {
+ const source = 'var a="outer";window.__NUXT__=(function(a,b,c){a.title=b;a.tags=c;return {data:[a,a],error:null}}({},"News",["tag"]));';
+ const value = await evaluateScriptData<{ data: Array<{ title: string; tags: string[] }>; error: null }>(source, '__NUXT__');
+ expect(value).toEqual({
+ data: [
+ { title: 'News', tags: ['tag'] },
+ { title: 'News', tags: ['tag'] },
+ ],
+ error: null,
+ });
+ expect(value.data[0]).toBe(value.data[1]);
+ await expect(evaluateScriptData(source, 'a')).resolves.toBe('outer');
+ });
+
+ it('preserves repeated var declarations and evaluates unused IIFE arguments in order', async () => {
+ const source = 'var a={value:1};var a;window.__DATA__=(function(x){return x})(a,a.value=2);';
+ await expect(evaluateScriptData(source, '__DATA__')).resolves.toEqual({ value: 2 });
+ });
+
+ it('does not call unrelated application code or access the host global object', async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal('fetch', fetchSpy);
+
+ const value = await evaluateScriptData('globalThis.__scriptDataSideEffect="changed";fetch("https://example.invalid/");window.__DATA__={ok:true};', '__DATA__');
+
+ expect(value).toEqual({ ok: true });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect('__scriptDataSideEffect' in globalThis).toBe(false);
+ });
+
+ it.each([
+ 'window.__DATA__ = fetch("https://example.invalid/");',
+ 'window.__DATA__ = Function("return 42")();',
+ 'window.__DATA__ = { get secret() { return fetch("https://example.invalid/"); } };',
+ 'window.__DATA__ = (function(){ while(true){} return {}; })();',
+ 'window.__DATA__ = (function(){ return {}; })(fetch("https://example.invalid/"));',
+ ])('rejects unsupported expressions required by the target without executing them', async (source) => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal('fetch', fetchSpy);
+ await expect(evaluateScriptData(source, '__DATA__')).rejects.toThrow(/Unsupported|ordinary/);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it.each(['__proto__', 'constructor', 'prototype'])('rejects the unsafe property %s', async (key) => {
+ await expect(evaluateScriptData(`window.__DATA__={"${key}":{polluted:true}};`, '__DATA__')).rejects.toThrow('Unsafe script data property');
+ await expect(evaluateScriptData(`var value={};value["${key}"]={polluted:true};window.__DATA__=value;`, '__DATA__')).rejects.toThrow('Unsafe script data property');
+ expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
+ });
+
+ it.each(['window.__DATA__()', 'window[computed]', 'window.__DATA__; unrelated()', 'window.__proto__'])('rejects unsupported target paths: %s', async (target) => {
+ await expect(evaluateScriptData('window.__DATA__={};', target)).rejects.toThrow();
+ });
+
+ it('reports missing targets and unresolved variable dependencies', async () => {
+ await expect(evaluateScriptData('unrelated();', '__DATA__')).rejects.toThrow('target was not found');
+ await expect(evaluateScriptData('var data=unrelated();window.__DATA__=data;', '__DATA__')).rejects.toThrow('Unsupported script data expression');
+ });
+
+ it.each(['if(true){window.__DATA__.value=2;}', 'Object.assign(window.__DATA__,{value:2});', 'window.__DATA__.value++;', 'window.__DATA__[unknown()]={};'])(
+ 'rejects unsupported code that could change extracted data: %s',
+ async (suffix) => {
+ await expect(evaluateScriptData(`window.__DATA__={value:1};${suffix}`, '__DATA__')).rejects.toThrow('Unsupported script data');
+ }
+ );
+
+ it.each(['if(true){window.__DATA__.newField=2;}', 'var ignored=Object.assign(window.__DATA__,{value:2});', 'window.ignored=Object.assign(window.__DATA__,{value:2});'])(
+ 'rejects mutations hidden in new properties and unused assignments: %s',
+ async (suffix) => {
+ await expect(evaluateScriptData(`window.__DATA__={};${suffix}`, '__DATA__')).rejects.toThrow('Unsupported script data');
+ }
+ );
+
+ it('rejects cyclic data and excessively large sources or sparse arrays', async () => {
+ await expect(evaluateScriptData('var a={};a.self=a;window.__DATA__=a;', '__DATA__')).rejects.toThrow('Cyclic script data');
+ await expect(evaluateScriptData(' '.repeat(2_000_001), '__DATA__')).rejects.toThrow('size limit');
+ await expect(evaluateScriptData('window.__DATA__=(function(){var a=[];a[100000]={};return a})();', '__DATA__')).rejects.toThrow('bounded numeric indexes');
+ });
+});
+
+describe('serialized callback data', () => {
+ it('extracts a DWR callback argument without invoking the callback', async () => {
+ const callback = vi.fn();
+ vi.stubGlobal('dwr', { engine: { _remoteHandleCallback: callback } });
+ const source = '(function(){if(!window.dwr)return;var s0=[],s1={};s1.post={title:"Entry"};s0[0]=s1;dwr.engine._remoteHandleCallback("493053","0",s0);})();';
+
+ await expect(evaluateScriptCallback(source, 'dwr.engine._remoteHandleCallback')).resolves.toEqual([{ post: { title: 'Entry' } }]);
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('supports empty public DWR responses and explicit argument positions', async () => {
+ const source = '//#DWR-INSERT\n//#DWR-REPLY\ndwr.engine._remoteHandleCallback("493053","0",[]);';
+ await expect(evaluateScriptCallback(source, 'window.dwr.engine._remoteHandleCallback')).resolves.toEqual([]);
+ await expect(evaluateScriptCallback(source, 'dwr.engine._remoteHandleCallback', 0)).resolves.toBe('493053');
+ });
+
+ it('evaluates all callback arguments in order and does not keep an earlier successful capture after an invalid one', async () => {
+ const source = 'var a={value:1};dwr.engine._remoteHandleCallback(a.value=2,"0",a);';
+ await expect(evaluateScriptCallback(source, 'dwr.engine._remoteHandleCallback')).resolves.toEqual({ value: 2 });
+ await expect(evaluateScriptCallback(`${source}dwr.engine._remoteHandleCallback("0","0",unknown());`, 'dwr.engine._remoteHandleCallback')).rejects.toThrow();
+ });
+
+ it('rejects missing callbacks and external calls used as the data argument', async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal('fetch', fetchSpy);
+ await expect(evaluateScriptCallback('unrelated();', 'dwr.engine._remoteHandleCallback')).rejects.toThrow('callback was not found');
+ await expect(evaluateScriptCallback('dwr.engine._remoteHandleCallback("0","0",fetch("https://example.invalid/"));', 'dwr.engine._remoteHandleCallback')).rejects.toThrow();
+ await expect(evaluateScriptCallback('', 'callback', -1)).rejects.toThrow('non-negative integer');
+ await expect(evaluateScriptCallback('dwr["engine._remoteHandleCallback"]("0","0",[]);', 'dwr.engine._remoteHandleCallback')).rejects.toThrow('callback was not found');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/utils/parse-script-data.ts b/lib/utils/evaluate-script.ts
similarity index 62%
rename from lib/utils/parse-script-data.ts
rename to lib/utils/evaluate-script.ts
index 883b74630a..fa950efcc9 100644
--- a/lib/utils/parse-script-data.ts
+++ b/lib/utils/evaluate-script.ts
@@ -1,21 +1,27 @@
-import { type AnyNode, parse } from 'acorn';
+import { type Node } from 'oxc-parser';
+
+import { parseScriptSource } from './parse-js';
type DataValue = string | number | boolean | null | undefined | DataValue[] | DataObject | ScriptDataError;
type DataObject = { [key: string]: DataValue };
type Scope = { values: DataObject; parent?: Scope };
-type Reference = { object: DataObject | DataValue[]; key: string };
+type Reference = { object: DataObject; key: string };
const globalNames = new Set(['window', 'globalThis', 'self']);
const unsafeKeys = new Set(['__proto__', 'prototype', 'constructor']);
const maxScriptLength = 2_000_000;
-const maxSteps = 1e5;
+const maxSteps = 10000;
const maxDepth = 100;
class ScriptDataError extends Error {}
class UnsafePropertyError extends ScriptDataError {}
-const propertyKey = (value: unknown): string => {
- if (typeof value !== 'string' && typeof value !== 'number') {
+const isKey = (value: DataValue): value is string | number => typeof value === 'string' || typeof value === 'number';
+const isNumber = (value: DataValue): value is number => typeof value === 'number';
+const isObject = (value: DataValue): value is DataObject | DataValue[] => typeof value === 'object' && value !== null && !(value instanceof ScriptDataError);
+
+const propertyKey = (value: DataValue): string => {
+ if (!isKey(value)) {
throw new ScriptDataError('Script data property keys must be strings or numbers');
}
const key = String(value);
@@ -25,12 +31,12 @@ const propertyKey = (value: unknown): string => {
return key;
};
-const staticPath = (node: AnyNode): string[] => {
+const staticPath = (node: Node): string[] => {
if (node.type === 'Identifier') {
return [propertyKey(node.name)];
}
if (node.type === 'MemberExpression' && !node.optional) {
- const key = !node.computed && node.property.type === 'Identifier' ? node.property.name : node.property.type === 'Literal' ? node.property.value : undefined;
+ const key = !node.computed && node.property.type === 'Identifier' ? node.property.name : node.property.type === 'Literal' && !('regex' in node.property) && !('bigint' in node.property) ? node.property.value : undefined;
return [...staticPath(node.object), propertyKey(key)];
}
throw new ScriptDataError('Script data targets must be static property paths');
@@ -38,8 +44,16 @@ const staticPath = (node: AnyNode): string[] => {
const normalizePath = (path: string[]) => (globalNames.has(path[0]) ? path.slice(1) : path);
-const targetPath = (target: string): string[] => {
- const program = parse(target, { ecmaVersion: 'latest' });
+const parseProgram = async (source: string, errorMessage: string) => {
+ const { program, errors } = await parseScriptSource(source);
+ if (errors.length) {
+ throw new ScriptDataError(errorMessage);
+ }
+ return program;
+};
+
+const targetPath = async (target: string): Promise => {
+ const program = await parseProgram(target, 'Script data targets must be a single static property path');
if (program.body.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
throw new ScriptDataError('Script data targets must be a single static property path');
}
@@ -50,8 +64,6 @@ const targetPath = (target: string): string[] => {
return path;
};
-const isObject = (value: DataValue): value is DataObject | DataValue[] => typeof value === 'object' && value !== null && !(value instanceof ScriptDataError);
-
const readProperty = (object: DataValue, key: string): DataValue => {
if (object instanceof ScriptDataError) {
throw object;
@@ -63,25 +75,20 @@ const readProperty = (object: DataValue, key: string): DataValue => {
};
/** Reads serialized data with a limited AST vocabulary; it never executes JavaScript. */
-class ScriptDataReader {
- private root: Scope = { values: Object.create(null) };
- private steps = 0;
- private poisonedAssignments = new WeakSet();
- private captured = false;
- private callbackValue: DataValue;
+const readScriptData = async (source: string, target: string[], argumentIndex?: number): Promise => {
+ const root: Scope = { values: Object.create(null) };
+ const poisonedAssignments = new WeakSet();
+ let steps = 0;
+ let captured = false;
+ let callbackValue: DataValue;
- constructor(
- private target: string[],
- private argumentIndex?: number
- ) {}
-
- private step(depth: number) {
- if (++this.steps > maxSteps || depth > maxDepth) {
+ const step = (depth: number) => {
+ if (++steps > maxSteps || depth > maxDepth) {
throw new UnsafePropertyError('Script data exceeds the parsing complexity limit');
}
- }
+ };
- private identifier(name: string, scope: Scope): DataValue {
+ const identifier = (name: string, scope: Scope): DataValue => {
propertyKey(name);
for (let current: Scope | undefined = scope; current; current = current.parent) {
if (Object.hasOwn(current.values, name)) {
@@ -93,15 +100,15 @@ class ScriptDataReader {
}
}
if (globalNames.has(name)) {
- return this.root.values;
+ return root.values;
}
if (name === 'undefined') {
return undefined;
}
throw new ScriptDataError(`Unknown script data variable: ${name}`);
- }
+ };
- private reference(node: AnyNode, scope: Scope, depth: number): Reference {
+ const reference = (node: Node, scope: Scope, depth: number): Reference => {
if (node.type === 'Identifier') {
const key = propertyKey(node.name);
if (globalNames.has(key)) {
@@ -116,77 +123,77 @@ class ScriptDataReader {
if (node.type !== 'MemberExpression' || node.optional) {
throw new ScriptDataError('Unsupported script data assignment target');
}
- const object = this.evaluate(node.object, scope, depth + 1);
- const key = propertyKey(!node.computed && node.property.type === 'Identifier' ? node.property.name : this.evaluate(node.property, scope, depth + 1));
+ const object = evaluate(node.object, scope, depth + 1);
+ const key = propertyKey(!node.computed && node.property.type === 'Identifier' ? node.property.name : evaluate(node.property, scope, depth + 1));
if (!isObject(object)) {
throw new ScriptDataError('Cannot assign a property of non-object script data');
}
if (Array.isArray(object) && (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= maxSteps)) {
throw new ScriptDataError('Script data arrays require bounded numeric indexes');
}
- return { object, key };
- }
+ return { object: object as DataObject, key };
+ };
- private evaluate(node: AnyNode, scope: Scope, depth: number): DataValue {
- this.step(depth);
+ const evaluate = (node: Node, scope: Scope, depth: number): DataValue => {
+ step(depth);
switch (node.type) {
case 'Literal':
- if (node.regex || node.bigint) {
+ if ('regex' in node || 'bigint' in node) {
break;
}
- return node.value as string | number | boolean | null;
+ return node.value;
case 'Identifier':
- return this.identifier(node.name, scope);
+ return identifier(node.name, scope);
case 'ArrayExpression':
- return node.elements.map((element) => (element ? this.evaluate(element, scope, depth + 1) : undefined));
+ return node.elements.map((element) => (element ? evaluate(element, scope, depth + 1) : undefined));
case 'ObjectExpression': {
const value: DataObject = Object.create(null);
for (const property of node.properties) {
if (property.type !== 'Property' || property.kind !== 'init' || property.method) {
throw new ScriptDataError('Only ordinary script data object properties are supported');
}
- const key = propertyKey(!property.computed && property.key.type === 'Identifier' ? property.key.name : this.evaluate(property.key, scope, depth + 1));
- value[key] = this.evaluate(property.value, scope, depth + 1);
+ const key = propertyKey(!property.computed && property.key.type === 'Identifier' ? property.key.name : evaluate(property.key, scope, depth + 1));
+ value[key] = evaluate(property.value, scope, depth + 1);
}
return value;
}
case 'MemberExpression': {
- const key = propertyKey(!node.computed && node.property.type === 'Identifier' ? node.property.name : this.evaluate(node.property, scope, depth + 1));
- return readProperty(this.evaluate(node.object, scope, depth + 1), key);
+ const key = propertyKey(!node.computed && node.property.type === 'Identifier' ? node.property.name : evaluate(node.property, scope, depth + 1));
+ return readProperty(evaluate(node.object, scope, depth + 1), key);
}
case 'UnaryExpression': {
- const value = this.evaluate(node.argument, scope, depth + 1);
+ const value = evaluate(node.argument, scope, depth + 1);
if (node.operator === 'void') {
return undefined;
}
if (node.operator === '!') {
return !value;
}
- if (typeof value === 'number' && (node.operator === '-' || node.operator === '+')) {
+ if (isNumber(value) && (node.operator === '-' || node.operator === '+')) {
return node.operator === '-' ? -value : value;
}
break;
}
case 'LogicalExpression': {
- const left = this.evaluate(node.left, scope, depth + 1);
+ const left = evaluate(node.left, scope, depth + 1);
if ((node.operator === '||' && left) || (node.operator === '&&' && !left) || (node.operator === '??' && left !== undefined && left !== null)) {
return left;
}
- return this.evaluate(node.right, scope, depth + 1);
+ return evaluate(node.right, scope, depth + 1);
}
case 'AssignmentExpression': {
- const { object, key } = this.reference(node.left, scope, depth + 1);
+ const { object, key } = reference(node.left, scope, depth + 1);
try {
if (node.operator !== '=') {
throw new ScriptDataError('Only simple script data assignments are supported');
}
- const value = this.evaluate(node.right, scope, depth + 1);
- (object as DataObject)[key] = value;
+ const value = evaluate(node.right, scope, depth + 1);
+ object[key] = value;
return value;
} catch (error) {
if (error instanceof ScriptDataError) {
- (object as DataObject)[key] = error;
- this.poisonedAssignments.add(node);
+ object[key] = error;
+ poisonedAssignments.add(node);
}
throw error;
}
@@ -194,27 +201,27 @@ class ScriptDataReader {
case 'SequenceExpression': {
let value: DataValue;
for (const expression of node.expressions) {
- value = this.evaluate(expression, scope, depth + 1);
+ value = evaluate(expression, scope, depth + 1);
}
return value;
}
case 'CallExpression': {
- if (this.argumentIndex !== undefined && this.matchesCallback(node.callee)) {
- this.captured = false;
- this.callbackValue = undefined;
- const args = node.arguments.map((argument) => this.evaluate(argument, scope, depth + 1));
- if (this.argumentIndex >= args.length) {
+ if (argumentIndex !== undefined && matchesCallback(node.callee)) {
+ captured = false;
+ callbackValue = undefined;
+ const args = node.arguments.map((argument) => evaluate(argument, scope, depth + 1));
+ if (argumentIndex >= args.length) {
throw new ScriptDataError('Script data callback is missing its data argument');
}
- this.callbackValue = args[this.argumentIndex];
- this.captured = true;
+ callbackValue = args[argumentIndex];
+ captured = true;
return undefined;
}
const fn = node.callee;
if ((fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') || fn.async || fn.generator) {
break;
}
- const args = node.arguments.map((argument) => this.evaluate(argument, scope, depth + 1));
+ const args = node.arguments.map((argument) => evaluate(argument, scope, depth + 1));
const local: Scope = { values: Object.create(null), parent: scope };
for (const [index, parameter] of fn.params.entries()) {
if (parameter.type !== 'Identifier' || globalNames.has(parameter.name)) {
@@ -223,28 +230,31 @@ class ScriptDataReader {
const key = propertyKey(parameter.name);
local.values[key] = args[index];
}
- return fn.body.type === 'BlockStatement' ? this.statements(fn.body.body, local, depth + 1, true)?.value : this.evaluate(fn.body, local, depth + 1);
+ if (!fn.body) {
+ break;
+ }
+ return fn.body.type === 'BlockStatement' ? statements(fn.body.body, local, depth + 1, true)?.value : evaluate(fn.body, local, depth + 1);
}
default:
break;
}
throw new ScriptDataError(`Unsupported script data expression: ${node.type}`);
- }
+ };
- private matchesCallback(node: AnyNode): boolean {
+ const matchesCallback = (node: Node): boolean => {
try {
const path = normalizePath(staticPath(node));
- return path.length === this.target.length && path.every((key, index) => key === this.target[index]);
+ return path.length === target.length && path.every((key, index) => key === target[index]);
} catch (error) {
if (error instanceof UnsafePropertyError) {
throw error;
}
return false;
}
- }
+ };
- private isCallbackGuard(node: AnyNode): boolean {
- if (this.argumentIndex === undefined || node.type !== 'IfStatement' || node.alternate || node.test.type !== 'UnaryExpression' || node.test.operator !== '!') {
+ const isCallbackGuard = (node: Node): boolean => {
+ if (argumentIndex === undefined || node.type !== 'IfStatement' || node.alternate || node.test.type !== 'UnaryExpression' || node.test.operator !== '!') {
return false;
}
const consequent = node.consequent.type === 'BlockStatement' && node.consequent.body.length === 1 ? node.consequent.body[0] : node.consequent;
@@ -252,18 +262,18 @@ class ScriptDataReader {
return false;
}
const path = normalizePath(staticPath(node.test.argument));
- return path.length > 0 && path.length < this.target.length && path.every((key, index) => key === this.target[index]);
- }
+ return path.length > 0 && path.length < target.length && path.every((key, index) => key === target[index]);
+ };
- private referencesKnownData(node: AnyNode, scope: Scope, depth: number): boolean {
- this.step(depth);
+ const referencesKnownData = (node: Node, scope: Scope, depth: number): boolean => {
+ step(depth);
if (node.type === 'Identifier' || node.type === 'MemberExpression') {
try {
const path = staticPath(node);
- let value = this.identifier(path[0], scope);
+ let value = identifier(path[0], scope);
for (const key of path.slice(1)) {
if (!isObject(value) || !Object.hasOwn(value, key)) {
- return value !== this.root.values;
+ return value !== root.values;
}
value = readProperty(value, key);
}
@@ -277,15 +287,18 @@ class ScriptDataReader {
}
}
}
- return Object.values(node).some((value) => {
+ return Object.entries(node).some(([key, value]) => {
+ if (key === 'parent') {
+ return false;
+ }
const children = Array.isArray(value) ? value : [value];
- return children.some((child) => child && typeof child === 'object' && typeof child.type === 'string' && this.referencesKnownData(child, scope, depth + 1));
+ return children.some((child) => child?.type && referencesKnownData(child, scope, depth + 1));
});
- }
+ };
- private statements(nodes: AnyNode[], scope: Scope, depth: number, strict: boolean): { value: DataValue } | undefined {
+ const statements = (nodes: Node[], scope: Scope, depth: number, strict: boolean): { value: DataValue } | undefined => {
for (const node of nodes) {
- this.step(depth);
+ step(depth);
try {
switch (node.type) {
case 'VariableDeclaration':
@@ -296,10 +309,10 @@ class ScriptDataReader {
const key = propertyKey(declaration.id.name);
try {
if (declaration.init || !Object.hasOwn(scope.values, key)) {
- scope.values[key] = declaration.init ? this.evaluate(declaration.init, scope, depth + 1) : undefined;
+ scope.values[key] = declaration.init ? evaluate(declaration.init, scope, depth + 1) : undefined;
}
} catch (error) {
- if (strict || !(error instanceof ScriptDataError) || error instanceof UnsafePropertyError || (declaration.init && this.referencesKnownData(declaration.init, scope, depth + 1))) {
+ if (strict || !(error instanceof ScriptDataError) || error instanceof UnsafePropertyError || (declaration.init && referencesKnownData(declaration.init, scope, depth + 1))) {
throw error;
}
scope.values[key] = error;
@@ -307,14 +320,14 @@ class ScriptDataReader {
}
break;
case 'ExpressionStatement':
- this.evaluate(node.expression, scope, depth + 1);
+ evaluate(node.expression, scope, depth + 1);
break;
case 'ReturnStatement':
- return { value: node.argument ? this.evaluate(node.argument, scope, depth + 1) : undefined };
+ return { value: node.argument ? evaluate(node.argument, scope, depth + 1) : undefined };
case 'EmptyStatement':
break;
default:
- if (!this.isCallbackGuard(node)) {
+ if (!isCallbackGuard(node)) {
throw new ScriptDataError(`Unsupported script data statement: ${node.type}`);
}
}
@@ -326,17 +339,17 @@ class ScriptDataReader {
// Failed simple assignments already poison their destination. Other unsupported code
// must not silently leave stale data when it references a known serialized value.
const isPoisonedAssignment =
- node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression' && this.poisonedAssignments.has(node.expression) && !this.referencesKnownData(node.expression.right, scope, depth + 1);
- if (!isPoisonedAssignment && this.referencesKnownData(node, scope, depth + 1)) {
+ node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression' && poisonedAssignments.has(node.expression) && !referencesKnownData(node.expression.right, scope, depth + 1);
+ if (!isPoisonedAssignment && referencesKnownData(node, scope, depth + 1)) {
throw error;
}
}
}
return undefined;
- }
+ };
- private validate(value: DataValue, ancestors = new Set(), depth = 0): void {
- this.step(depth);
+ const validate = (value: DataValue, ancestors = new Set(), depth = 0): void => {
+ step(depth);
if (value instanceof ScriptDataError) {
throw value;
}
@@ -346,45 +359,47 @@ class ScriptDataReader {
}
ancestors.add(value);
for (const child of Object.values(value)) {
- this.validate(child, ancestors, depth + 1);
+ validate(child, ancestors, depth + 1);
}
ancestors.delete(value);
}
- }
+ };
- read(source: string): DataValue {
- if (source.length > maxScriptLength) {
- throw new ScriptDataError('Script data source exceeds the size limit');
- }
- this.statements(parse(source, { ecmaVersion: 'latest' }).body, this.root, 0, false);
- let value: DataValue = this.root.values;
- if (this.argumentIndex === undefined) {
- for (const key of this.target) {
- if (!isObject(value) || !Object.hasOwn(value, key)) {
- if (value instanceof ScriptDataError) {
- throw value;
- }
- throw new ScriptDataError('Script data target was not found');
+ if (source.length > maxScriptLength) {
+ throw new ScriptDataError('Script data source exceeds the size limit');
+ }
+ statements((await parseProgram(source, 'Script data source could not be parsed')).body, root, 0, false);
+ let value: DataValue = root.values;
+ if (argumentIndex === undefined) {
+ for (const key of target) {
+ if (!isObject(value) || !Object.hasOwn(value, key)) {
+ if (value instanceof ScriptDataError) {
+ throw value;
}
- value = readProperty(value, key);
+ throw new ScriptDataError('Script data target was not found');
}
- } else {
- if (!this.captured) {
- throw new ScriptDataError('Script data callback was not found or could not be parsed');
- }
- value = this.callbackValue;
+ value = readProperty(value, key);
}
- this.validate(value);
- return value;
+ } else {
+ if (!captured) {
+ throw new ScriptDataError('Script data callback was not found or could not be parsed');
+ }
+ value = callbackValue;
}
-}
+ validate(value);
+ return value as T;
+};
-export const parseScriptData = (source: string, target: string): T => new ScriptDataReader(targetPath(target)).read(source) as T;
+export const evaluateScriptData = async (source: string, target: string): Promise => {
+ const path = await targetPath(target);
+ return readScriptData(source, path);
+};
/** Extracts a serialized callback argument without invoking the callback or any external function. */
-export const parseScriptCallback = (source: string, callbackPath: string, argumentIndex = 2): T => {
+export const evaluateScriptCallback = async (source: string, callbackPath: string, argumentIndex = 2): Promise => {
if (!Number.isSafeInteger(argumentIndex) || argumentIndex < 0) {
throw new ScriptDataError('Script data callback argument index must be a non-negative integer');
}
- return new ScriptDataReader(targetPath(callbackPath), argumentIndex).read(source) as T;
+ const path = await targetPath(callbackPath);
+ return readScriptData(source, path, argumentIndex);
};
diff --git a/lib/utils/evaluate-script.worker.test.ts b/lib/utils/evaluate-script.worker.test.ts
new file mode 100644
index 0000000000..d17d7ed91f
--- /dev/null
+++ b/lib/utils/evaluate-script.worker.test.ts
@@ -0,0 +1 @@
+import './evaluate-script.test';
diff --git a/lib/utils/parse-date-in-timezone.worker.test.ts b/lib/utils/parse-date-in-timezone.worker.test.ts
new file mode 100644
index 0000000000..92365784ff
--- /dev/null
+++ b/lib/utils/parse-date-in-timezone.worker.test.ts
@@ -0,0 +1 @@
+import './parse-date-in-timezone.test';
diff --git a/lib/utils/parse-js.ts b/lib/utils/parse-js.ts
new file mode 100644
index 0000000000..6d2e06d588
--- /dev/null
+++ b/lib/utils/parse-js.ts
@@ -0,0 +1,11 @@
+import { type OxcError, parseSync, type Program, rawTransferSupported } from 'oxc-parser';
+
+export const parseScriptSource = (source: string): { program: Program; errors: OxcError[] } => {
+ const { program, errors } = parseSync('script.js', source, {
+ lang: 'js',
+ sourceType: 'script',
+ preserveParens: false,
+ experimentalRawTransfer: rawTransferSupported(),
+ } as never);
+ return { program, errors };
+};
diff --git a/lib/utils/parse-js.worker.ts b/lib/utils/parse-js.worker.ts
new file mode 100644
index 0000000000..bf57235e1d
--- /dev/null
+++ b/lib/utils/parse-js.worker.ts
@@ -0,0 +1,21 @@
+import parserWasm from '@oxc-parser/binding-wasm32-wasip1/wasm.wasm';
+import { instantiate } from '@oxc-parser/binding-wasm32-wasip1/workerd';
+import { type OxcError, type Program } from 'oxc-parser';
+import { wrap } from 'oxc-parser/src-js/wrap.js';
+
+let bindingPromise: ReturnType | undefined;
+/**
+ * @see https://github.com/oxc-project/oxc/pull/25442 still bleeding edge
+ * */
+export const parseScriptSource = async (source: string): Promise<{ program: Program; errors: OxcError[] }> => {
+ bindingPromise ??= instantiate(parserWasm);
+ const binding = await bindingPromise;
+ const { program, errors } = wrap(
+ binding.parseSync('script.js', source, {
+ lang: 'js',
+ sourceType: 'script',
+ preserveParens: false,
+ }) as never
+ );
+ return { program, errors };
+};
diff --git a/lib/utils/parse-script-data.test.ts b/lib/utils/parse-script-data.test.ts
deleted file mode 100644
index da79f3a03e..0000000000
--- a/lib/utils/parse-script-data.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { parseScriptCallback, parseScriptData } from './parse-script-data';
-
-afterEach(() => {
- vi.unstubAllGlobals();
-});
-
-describe('serialized script data', () => {
- it.each(['__DATA__', 'window.__DATA__', 'globalThis.__DATA__', 'self.__DATA__'])('normalizes the global target %s', (target) => {
- expect(parseScriptData(String.raw`window.__DATA__ = { title: "Hello\u0020世界", tags: ["a", "b"] };`, target)).toEqual({ title: 'Hello 世界', tags: ['a', 'b'] });
- });
-
- it('resolves variables, nested assignments, and data-only unary and logical expressions', () => {
- const source = 'var title="A & B", unused=analytics(); window.pixiv=window.pixiv||{}; pixiv.novel={title, count:-2, available:!0, absent:void 0};';
- expect(parseScriptData(source, 'window.pixiv.novel')).toEqual({ title: 'A & B', count: -2, available: true, absent: undefined });
- });
-
- it('reads parent-object assignments and static bracket targets', () => {
- expect(parseScriptData('window.pixiv={novel:{title:"Book"}};', 'self["pixiv"].novel')).toEqual({ title: 'Book' });
- });
-
- it('reads Nuxt IIFE parameters and object mutations without changing outer bindings', () => {
- const source = 'var a="outer";window.__NUXT__=(function(a,b,c){a.title=b;a.tags=c;return {data:[a,a],error:null}}({},"News",["tag"]));';
- const value = parseScriptData<{ data: Array<{ title: string; tags: string[] }>; error: null }>(source, '__NUXT__');
- expect(value).toEqual({
- data: [
- { title: 'News', tags: ['tag'] },
- { title: 'News', tags: ['tag'] },
- ],
- error: null,
- });
- expect(value.data[0]).toBe(value.data[1]);
- expect(parseScriptData(source, 'a')).toBe('outer');
- });
-
- it('preserves repeated var declarations and evaluates unused IIFE arguments in order', () => {
- const source = 'var a={value:1};var a;window.__DATA__=(function(x){return x})(a,a.value=2);';
- expect(parseScriptData(source, '__DATA__')).toEqual({ value: 2 });
- });
-
- it('does not call unrelated application code or access the host global object', () => {
- const fetchSpy = vi.fn();
- vi.stubGlobal('fetch', fetchSpy);
- vi.stubGlobal('__scriptDataSideEffect', 'unchanged');
-
- const value = parseScriptData('globalThis.__scriptDataSideEffect="changed";fetch("https://example.invalid/");window.__DATA__={ok:true};', '__DATA__');
-
- expect(value).toEqual({ ok: true });
- expect(fetchSpy).not.toHaveBeenCalled();
- expect(Reflect.get(globalThis, '__scriptDataSideEffect')).toBe('unchanged');
- });
-
- it.each([
- 'window.__DATA__ = fetch("https://example.invalid/");',
- 'window.__DATA__ = Function("return 42")();',
- 'window.__DATA__ = { get secret() { return fetch("https://example.invalid/"); } };',
- 'window.__DATA__ = (function(){ while(true){} return {}; })();',
- 'window.__DATA__ = (function(){ return {}; })(fetch("https://example.invalid/"));',
- ])('rejects unsupported expressions required by the target without executing them', (source) => {
- const fetchSpy = vi.fn();
- vi.stubGlobal('fetch', fetchSpy);
- expect(() => parseScriptData(source, '__DATA__')).toThrow(/Unsupported|ordinary/);
- expect(fetchSpy).not.toHaveBeenCalled();
- });
-
- it.each(['__proto__', 'constructor', 'prototype'])('rejects the unsafe property %s', (key) => {
- expect(() => parseScriptData(`window.__DATA__={"${key}":{polluted:true}};`, '__DATA__')).toThrow('Unsafe script data property');
- expect(() => parseScriptData(`var value={};value["${key}"]={polluted:true};window.__DATA__=value;`, '__DATA__')).toThrow('Unsafe script data property');
- expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
- });
-
- it.each(['window.__DATA__()', 'window[computed]', 'window.__DATA__; unrelated()', 'window.__proto__'])('rejects unsupported target paths: %s', (target) => {
- expect(() => parseScriptData('window.__DATA__={};', target)).toThrow();
- });
-
- it('reports missing targets and unresolved variable dependencies', () => {
- expect(() => parseScriptData('unrelated();', '__DATA__')).toThrow('target was not found');
- expect(() => parseScriptData('var data=unrelated();window.__DATA__=data;', '__DATA__')).toThrow('Unsupported script data expression');
- });
-
- it.each(['if(true){window.__DATA__.value=2;}', 'Object.assign(window.__DATA__,{value:2});', 'window.__DATA__.value++;', 'window.__DATA__[unknown()]={};'])(
- 'rejects unsupported code that could change extracted data: %s',
- (suffix) => {
- expect(() => parseScriptData(`window.__DATA__={value:1};${suffix}`, '__DATA__')).toThrow('Unsupported script data');
- }
- );
-
- it.each(['if(true){window.__DATA__.newField=2;}', 'var ignored=Object.assign(window.__DATA__,{value:2});', 'window.ignored=Object.assign(window.__DATA__,{value:2});'])(
- 'rejects mutations hidden in new properties and unused assignments: %s',
- (suffix) => {
- expect(() => parseScriptData(`window.__DATA__={};${suffix}`, '__DATA__')).toThrow('Unsupported script data');
- }
- );
-
- it('rejects cyclic data and excessively large sources or sparse arrays', () => {
- expect(() => parseScriptData('var a={};a.self=a;window.__DATA__=a;', '__DATA__')).toThrow('Cyclic script data');
- expect(() => parseScriptData(' '.repeat(2_000_001), '__DATA__')).toThrow('size limit');
- expect(() => parseScriptData('window.__DATA__=(function(){var a=[];a[100000]={};return a})();', '__DATA__')).toThrow('bounded numeric indexes');
- });
-});
-
-describe('serialized callback data', () => {
- it('extracts a DWR callback argument without invoking the callback', () => {
- const callback = vi.fn();
- vi.stubGlobal('dwr', { engine: { _remoteHandleCallback: callback } });
- const source = '(function(){if(!window.dwr)return;var s0=[],s1={};s1.post={title:"Entry"};s0[0]=s1;dwr.engine._remoteHandleCallback("493053","0",s0);})();';
-
- expect(parseScriptCallback(source, 'dwr.engine._remoteHandleCallback')).toEqual([{ post: { title: 'Entry' } }]);
- expect(callback).not.toHaveBeenCalled();
- });
-
- it('supports empty public DWR responses and explicit argument positions', () => {
- const source = '//#DWR-INSERT\n//#DWR-REPLY\ndwr.engine._remoteHandleCallback("493053","0",[]);';
- expect(parseScriptCallback(source, 'window.dwr.engine._remoteHandleCallback')).toEqual([]);
- expect(parseScriptCallback(source, 'dwr.engine._remoteHandleCallback', 0)).toBe('493053');
- });
-
- it('evaluates all callback arguments in order and does not keep an earlier successful capture after an invalid one', () => {
- const source = 'var a={value:1};dwr.engine._remoteHandleCallback(a.value=2,"0",a);';
- expect(parseScriptCallback(source, 'dwr.engine._remoteHandleCallback')).toEqual({ value: 2 });
- expect(() => parseScriptCallback(`${source}dwr.engine._remoteHandleCallback("0","0",unknown());`, 'dwr.engine._remoteHandleCallback')).toThrow();
- });
-
- it('rejects missing callbacks and external calls used as the data argument', () => {
- const fetchSpy = vi.fn();
- vi.stubGlobal('fetch', fetchSpy);
- expect(() => parseScriptCallback('unrelated();', 'dwr.engine._remoteHandleCallback')).toThrow('callback was not found');
- expect(() => parseScriptCallback('dwr.engine._remoteHandleCallback("0","0",fetch("https://example.invalid/"));', 'dwr.engine._remoteHandleCallback')).toThrow();
- expect(() => parseScriptCallback('', 'callback', -1)).toThrow('non-negative integer');
- expect(() => parseScriptCallback('dwr["engine._remoteHandleCallback"]("0","0",[]);', 'dwr.engine._remoteHandleCallback')).toThrow('callback was not found');
- expect(fetchSpy).not.toHaveBeenCalled();
- });
-});
diff --git a/tests/playwright-lifecycle.worker.test.ts b/lib/utils/playwright.worker.test.ts
similarity index 93%
rename from tests/playwright-lifecycle.worker.test.ts
rename to lib/utils/playwright.worker.test.ts
index 0fc45a53c6..ae759d8481 100644
--- a/tests/playwright-lifecycle.worker.test.ts
+++ b/lib/utils/playwright.worker.test.ts
@@ -1,18 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { getPlaywrightPage, setBrowserBinding } from '../lib/utils/playwright.worker';
+import { getPlaywrightPage, setBrowserBinding } from './playwright.worker';
const mocks = vi.hoisted(() => ({ endpoint: undefined as string | undefined, launch: vi.fn(), newContext: vi.fn(), newPage: vi.fn(), goto: vi.fn(), close: vi.fn(), contextClose: vi.fn(), connect: vi.fn() }));
vi.mock('@cloudflare/playwright', () => ({ launch: mocks.launch }));
-vi.mock('../lib/utils/playwright-remote.worker', () => ({ connectRemotePlaywright: mocks.connect, setPlaywrightServiceBinding: vi.fn() }));
-vi.mock('../lib/config', () => ({
+vi.mock('./playwright-remote.worker', () => ({ connectRemotePlaywright: mocks.connect, setPlaywrightServiceBinding: vi.fn() }));
+vi.mock('@/config', () => ({
config: {
get playwrightWSEndpoint() {
return mocks.endpoint;
},
},
}));
-vi.mock('../lib/utils/logger', () => ({ default: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() } }));
+vi.mock('./logger', () => ({ default: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() } }));
beforeEach(() => {
vi.useFakeTimers();
diff --git a/package.json b/package.json
index fe60aae7be..64154c2331 100644
--- a/package.json
+++ b/package.json
@@ -76,10 +76,10 @@
"@opentelemetry/sdk-metrics": "2.11.0",
"@opentelemetry/sdk-trace-base": "2.11.0",
"@opentelemetry/semantic-conventions": "1.43.0",
+ "@oxc-parser/binding-wasm32-wasip1": "0.0.1",
"@rss3/sdk": "0.0.25",
"@scalar/hono-api-reference": "0.12.1",
"@sentry/node": "10.74.0",
- "acorn": "8.18.0",
"cheerio": "1.2.0",
"city-timezones": "1.3.4",
"cross-env": "10.1.0",
@@ -114,6 +114,7 @@
"oauth-1.0a": "2.2.6",
"ofetch": "1.5.1",
"otplib": "13.5.0",
+ "oxc-parser": "0.149.0",
"p-map": "7.0.7",
"pac-proxy-agent": "9.1.0",
"patchright": "1.63.0",
@@ -190,7 +191,6 @@
"mockdate": "3.0.5",
"msw": "2.15.0",
"node-network-devtools": "1.0.30",
- "oxc-parser": "0.149.0",
"oxfmt": "0.67.0",
"oxlint": "1.82.0",
"oxlint-plugin-eslint": "1.82.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d14ae4be2e..5225b9a300 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -82,6 +82,9 @@ importers:
'@opentelemetry/semantic-conventions':
specifier: 1.43.0
version: 1.43.0
+ '@oxc-parser/binding-wasm32-wasip1':
+ specifier: 0.0.1
+ version: 0.0.1
'@rss3/sdk':
specifier: 0.0.25
version: 0.0.25
@@ -91,9 +94,6 @@ importers:
'@sentry/node':
specifier: 10.74.0
version: 10.74.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.1))
- acorn:
- specifier: 8.18.0
- version: 8.18.0
cheerio:
specifier: 1.2.0
version: 1.2.0
@@ -196,6 +196,9 @@ importers:
otplib:
specifier: 13.5.0
version: 13.5.0
+ oxc-parser:
+ specifier: 0.149.0
+ version: 0.149.0
p-map:
specifier: 7.0.7
version: 7.0.7
@@ -419,9 +422,6 @@ importers:
node-network-devtools:
specifier: 1.0.30
version: 1.0.30(undici@8.10.2)(utf-8-validate@5.0.10)
- oxc-parser:
- specifier: 0.149.0
- version: 0.149.0
oxfmt:
specifier: 0.67.0
version: 0.67.0
@@ -687,15 +687,24 @@ packages:
'@emnapi/core@1.11.2':
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
+ '@emnapi/core@2.0.0-alpha.3':
+ resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==}
+
'@emnapi/runtime@1.11.2':
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+ '@emnapi/runtime@2.0.0-alpha.3':
+ resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==}
+
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+ '@emnapi/wasi-threads@2.0.1':
+ resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==}
+
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
@@ -1601,6 +1610,13 @@ packages:
'@emnapi/core': ^2.0.0-alpha.3
'@emnapi/runtime': ^2.0.0-alpha.3
+ '@napi-rs/wasm-runtime@1.2.3':
+ resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -1906,6 +1922,10 @@ packages:
cpu: [arm64]
os: [openharmony]
+ '@oxc-parser/binding-wasm32-wasip1@0.0.1':
+ resolution: {integrity: sha512-p2X0okYbIkav6eB5zwwVc6eFRycAO4GbGpBCf9IJCI2fUzwoxysWaMXIyZzrq6Pg4/LsEqFb0Vp6TKovK541vA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+
'@oxc-parser/binding-win32-arm64-msvc@0.149.0':
resolution: {integrity: sha512-9hMvs3GZESAcbUGU3mqXFWRLgBd8TLeosNYfHusHp/hrBnk5lIP2nJtark3hZ6qT+lqSdS79sH8NoWHj1x9iUw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2724,8 +2744,8 @@ packages:
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
- '@types/aws-lambda@8.10.163':
- resolution: {integrity: sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==}
+ '@types/aws-lambda@8.10.162':
+ resolution: {integrity: sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==}
'@types/bluebird@3.5.42':
resolution: {integrity: sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==}
@@ -3274,8 +3294,8 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
- argparse@3.0.2:
- resolution: {integrity: sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==}
+ argparse@3.0.0:
+ resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
@@ -3818,9 +3838,6 @@ packages:
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
- es-module-lexer@3.0.2:
- resolution: {integrity: sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==}
-
es5-ext@0.10.64:
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
engines: {node: '>=0.10'}
@@ -4344,8 +4361,8 @@ packages:
resolution: {integrity: sha512-jrWWecWFifN3Feum2MqaTUupNeYSAdPEltKuwGdr8IPdxW72NZXf7ObVUfTWoUH2MPrli0q7SZm9pRt8ob/9XQ==}
engines: {node: '>=20.0.0'}
- import-in-the-middle@3.5.0:
- resolution: {integrity: sha512-O6wo8l0lhBuytKz3x8S65TvacFtoopji+gCVeEBbjCaRy75gkk4bCBQoVmY7OEZ63cbFK+yf77pDCTWaadV7hQ==}
+ import-in-the-middle@3.4.0:
+ resolution: {integrity: sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw==}
engines: {node: '>=18'}
import-without-cache@0.4.0:
@@ -4970,11 +4987,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@3.3.19:
- resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
@@ -5775,8 +5787,8 @@ packages:
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
hasBin: true
- tldts-core@7.4.12:
- resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==}
+ tldts-core@7.4.13:
+ resolution: {integrity: sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==}
tldts@7.4.12:
resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==}
@@ -5920,10 +5932,6 @@ packages:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'}
- type-fest@5.9.0:
- resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==}
- engines: {node: '>=20'}
-
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
@@ -5955,18 +5963,14 @@ packages:
undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
- undici@6.28.1:
- resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==}
+ undici@6.28.0:
+ resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
engines: {node: '>=18.17'}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
- undici@7.29.1:
- resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
- engines: {node: '>=20.18.1'}
-
undici@8.10.2:
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
engines: {node: '>=22.19.0'}
@@ -6413,17 +6417,17 @@ snapshots:
'@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6)
'@octokit/request': 10.0.11
'@octokit/request-error': 7.1.0
- undici: 6.28.1
+ undici: 6.28.0
'@actions/http-client@3.0.2':
dependencies:
tunnel: 0.0.6
- undici: 6.28.1
+ undici: 6.28.0
'@actions/http-client@4.0.1':
dependencies:
tunnel: 0.0.6
- undici: 6.28.1
+ undici: 6.28.0
'@actions/io@3.0.2': {}
@@ -6610,6 +6614,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/core@2.0.0-alpha.3':
+ dependencies:
+ '@emnapi/wasi-threads': 2.0.1
+ tslib: 2.8.1
+
'@emnapi/runtime@1.11.2':
dependencies:
tslib: 2.8.1
@@ -6620,11 +6629,19 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@2.0.0-alpha.3':
+ dependencies:
+ tslib: 2.8.1
+
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
optional: true
+ '@emnapi/wasi-threads@2.0.1':
+ dependencies:
+ tslib: 2.8.1
+
'@epic-web/invariant@1.0.0': {}
'@esbuild/aix-ppc64@0.27.7':
@@ -6923,7 +6940,7 @@ snapshots:
'@honeybadger-io/js@6.16.3':
dependencies:
'@honeybadger-io/core': 6.11.1
- '@types/aws-lambda': 8.10.163
+ '@types/aws-lambda': 8.10.162
'@types/express': 5.0.6
'@hono/node-server@2.1.1(hono@4.13.7)':
@@ -7221,6 +7238,12 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
+ dependencies:
+ '@emnapi/core': 2.0.0-alpha.3
+ '@emnapi/runtime': 2.0.0-alpha.3
+ '@tybys/wasm-util': 0.10.3
+
'@noble/ciphers@1.3.0': {}
'@noble/curves@1.9.7':
@@ -7342,7 +7365,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.220.0
- import-in-the-middle: 3.5.0
+ import-in-the-middle: 3.4.0
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -7477,6 +7500,12 @@ snapshots:
'@oxc-parser/binding-openharmony-arm64@0.149.0':
optional: true
+ '@oxc-parser/binding-wasm32-wasip1@0.0.1':
+ dependencies:
+ '@emnapi/core': 2.0.0-alpha.3
+ '@emnapi/runtime': 2.0.0-alpha.3
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
+
'@oxc-parser/binding-win32-arm64-msvc@0.149.0':
optional: true
@@ -7905,7 +7934,7 @@ snapshots:
dependencies:
'@scalar/helpers': 0.11.3
nanoid: 5.1.16
- type-fest: 5.9.0
+ type-fest: 5.8.0
zod: 4.6.2
'@scalar/validation@0.6.3': {}
@@ -7931,7 +7960,7 @@ snapshots:
'@sentry/conventions': 0.16.0
'@sentry/core': 10.74.0
'@sentry/opentelemetry': 10.74.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))
- import-in-the-middle: 3.5.0
+ import-in-the-middle: 3.4.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1)
@@ -7949,7 +7978,7 @@ snapshots:
'@sentry/node-core': 10.74.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))
'@sentry/opentelemetry': 10.74.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))
'@sentry/server-utils': 10.74.0
- import-in-the-middle: 3.5.0
+ import-in-the-middle: 3.4.0
transitivePeerDependencies:
- '@opentelemetry/core'
- '@opentelemetry/exporter-trace-otlp-http'
@@ -7994,9 +8023,8 @@ snapshots:
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
- optional: true
- '@types/aws-lambda@8.10.163': {}
+ '@types/aws-lambda@8.10.162': {}
'@types/bluebird@3.5.42': {}
@@ -8488,7 +8516,7 @@ snapshots:
arg@5.0.2: {}
- argparse@3.0.2: {}
+ argparse@3.0.0: {}
asn1@0.2.6:
dependencies:
@@ -8656,7 +8684,7 @@ snapshots:
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
- undici: 7.29.1
+ undici: 7.29.0
whatwg-mimetype: 4.0.0
chownr@3.0.0: {}
@@ -8961,8 +8989,6 @@ snapshots:
es-module-lexer@2.3.2: {}
- es-module-lexer@3.0.2: {}
-
es5-ext@0.10.64:
dependencies:
es6-iterator: 2.0.3
@@ -9680,10 +9706,10 @@ snapshots:
pino: 10.3.1
socks: 2.8.10
- import-in-the-middle@3.5.0:
+ import-in-the-middle@3.4.0:
dependencies:
cjs-module-lexer: 2.2.1
- es-module-lexer: 3.0.2
+ es-module-lexer: 2.3.2
module-details-from-path: 1.0.4
import-without-cache@0.4.0: {}
@@ -10070,7 +10096,7 @@ snapshots:
markdown-it@15.0.2:
dependencies:
- argparse: 3.0.2
+ argparse: 3.0.0
entities: 8.1.0
linkify-it: 6.1.0
mdurl: 2.1.0
@@ -10475,8 +10501,6 @@ snapshots:
nanoid@3.3.18: {}
- nanoid@3.3.19: {}
-
nanoid@5.1.16: {}
narou@2.0.1: {}
@@ -10844,7 +10868,7 @@ snapshots:
postcss@8.5.28:
dependencies:
- nanoid: 3.3.19
+ nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -11423,11 +11447,11 @@ snapshots:
tlds@1.261.0: {}
- tldts-core@7.4.12: {}
+ tldts-core@7.4.13: {}
tldts@7.4.12:
dependencies:
- tldts-core: 7.4.12
+ tldts-core: 7.4.13
to-no-case@1.0.2: {}
@@ -11536,10 +11560,6 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
- type-fest@5.9.0:
- dependencies:
- tagged-tag: 1.0.0
-
type@2.7.3: {}
typescript@6.0.3: {}
@@ -11582,12 +11602,10 @@ snapshots:
undici-types@8.9.0: {}
- undici@6.28.1: {}
+ undici@6.28.0: {}
undici@7.29.0: {}
- undici@7.29.1: {}
-
undici@8.10.2: {}
unenv@2.0.0-rc.24:
diff --git a/scripts/workflow/check-orphan-files.ts b/scripts/workflow/check-orphan-files.ts
index 54917e9369..ea5d85c6ee 100644
--- a/scripts/workflow/check-orphan-files.ts
+++ b/scripts/workflow/check-orphan-files.ts
@@ -22,7 +22,7 @@ export const findOrphanFiles = async (): Promise => {
return relative;
}
const dir = path.dirname(absolute);
- const base = path.basename(absolute).replace(/\.(?:spec|test)\.[cm]?[jt]sx?$/, '');
+ const base = path.basename(absolute).replace(/(?:\.worker)?\.(?:spec|test)\.[cm]?[jt]sx?$/, '');
const exists = entries.some((entry) => entry.parentPath === dir && entry.isFile() && new RegExp(`^${base.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)}\\.[cm]?[jt]sx?$`).test(entry.name));
return exists ? null : relative;
})
diff --git a/tests/lofter-tag.test.ts b/tests/lofter-tag.test.ts
deleted file mode 100644
index a3308d1c1f..0000000000
--- a/tests/lofter-tag.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { route } from '../lib/routes/lofter/tag';
-import got from '../lib/utils/got';
-
-vi.mock('../lib/config', () => ({ config: { lofter: { cookies: 'test-cookie' } } }));
-vi.mock('../lib/utils/got', () => ({ default: vi.fn() }));
-
-beforeEach(() => {
- vi.clearAllMocks();
-});
-
-const context = { req: { param: (name: string) => ({ name: 'Photography', type: 'date' })[name] } };
-
-describe('Lofter tag DWR response', () => {
- it('reads DWR object references and preserves feed content without executing the response', async () => {
- // A representative DWR protocol response; no private feed data is included.
- vi.mocked(got).mockResolvedValue({
- data: `//#DWR-INSERT
-//#DWR-REPLY
-var s0=[],s1={},s2={},s3={};
-s0[0]=s1;s1.post=s2;s2.blogInfo=s3;
-s3.blogNickName="Photographer";
-s2.blogPageUrl="https://example.lofter.com/post/1";
-s2.title="";
-s2.digest="A & B
";
-s2.publishTime=1704067200000;
-s2.tagList=["Photography"];
-s2.photoLinks='[{"orign":"https://example.com/photo.jpg"}]';
-s2.embed='{"h256Url":"https://example.com/video.mp4","video_img_url":"https://example.com/poster.jpg"}';
-dwr.engine._remoteHandleCallback('493053','0',s0);`,
- });
-
- const feed = await (route.handler as (ctx: typeof context) => Promise)(context);
-
- expect(feed.title).toBe('Photography - 日榜 | LOFTER');
- expect(feed.item).toEqual([
- {
- author: 'Photographer',
- link: 'https://example.lofter.com/post/1',
- title: 'Photographer:A & B',
- pubDate: new Date('2024-01-01T00:00:00.000Z'),
- description:
- '
A & B
',
- category: ['Photography'],
- },
- ]);
- expect(got).toHaveBeenCalledWith(expect.objectContaining({ headers: { Referer: 'https://www.lofter.com/tag/Photography', Cookie: 'test-cookie' } }));
- });
-
- it('reads the empty callback response returned by the public endpoint', async () => {
- vi.mocked(got).mockResolvedValue({ data: "//#DWR-INSERT\n//#DWR-REPLY\ndwr.engine._remoteHandleCallback('493053','0',[]);" });
-
- const feed = await (route.handler as (ctx: typeof context) => Promise)(context);
- expect(feed.item).toEqual([]);
- });
-
- it('rejects executable callback arguments', async () => {
- vi.mocked(got).mockResolvedValue({ data: "dwr.engine._remoteHandleCallback('493053','0',fetch('https://example.com'));" });
-
- await expect((route.handler as (ctx: typeof context) => Promise)(context)).rejects.toThrow();
- });
-});
diff --git a/tests/nuxt-routes.test.ts b/tests/nuxt-routes.test.ts
deleted file mode 100644
index 676e1612d9..0000000000
--- a/tests/nuxt-routes.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { processFeed, processFeedType2 } from '../lib/routes/dongqiudi/utils';
-import nintendo from '../lib/routes/nintendo/utils';
-import cache from '../lib/utils/cache';
-import got from '../lib/utils/got';
-
-vi.mock('../lib/utils/cache', () => ({
- default: { tryGet: vi.fn((_key, getValue) => getValue()) },
-}));
-vi.mock('../lib/utils/got', () => ({ default: vi.fn() }));
-
-beforeEach(() => {
- vi.clearAllMocks();
-});
-
-describe('Nintendo Nuxt data', () => {
- it('reads serialized object references and escaped content without executing page scripts', () => {
- const html = `
- `;
-
- expect(nintendo.nuxtReader(html)).toEqual({
- newsList: [{ title: 'News 中文', content: 'A&B
' }],
- newsData: { title: 'News 中文', content: 'A&B
' },
- });
- });
-
- it('preserves cached article content, category, and publication time', async () => {
- const link = 'https://www.nintendoswitch.com.cn/topics/test';
- vi.mocked(got).mockResolvedValue({
- data: '',
- });
-
- const items = await nintendo.ProcessNewsChina([{ link, description: '
' }], cache);
-
- expect(cache.tryGet).toHaveBeenCalledWith(link, expect.any(Function));
- expect(items[0]).toEqual({
- link,
- description: '
Article
',
- category: ['News'],
- pubDate: new Date('2024-01-01T00:00:00.000Z'),
- });
- });
-
- it.each(['No page data', ''])('rejects missing or executable page data', (html) => {
- expect(() => nintendo.nuxtReader(html)).toThrow('Nuxt 框架信息提取失败');
- });
-});
-
-describe('Dongqiudi Nuxt data', () => {
- const articleHtml = `
- `;
-
- it('preserves article content cleanup and the author', () => {
- const item: { title: string; description?: string; author?: string } = { title: 'Match' };
-
- processFeedType2(item, articleHtml);
-
- expect(item).toEqual({
- title: 'Match',
- description: 'Match report
Related
',
- author: 'Reporter',
- });
- });
-
- it.each(['No Nuxt data', '', ''])('leaves an item unchanged when article data is absent', (html) => {
- const item = { title: 'Match' };
- processFeedType2(item, html);
- expect(item).toEqual({ title: 'Match' });
- });
-
- it.each([
- ['team', 'teamInfo:{name:"Club",logo:"club.png"}', 'Club', 'club.png'],
- ['player', 'detail:{base_info:{person_name:"Player",person_logo:"player.png"}}', 'Player', 'player.png'],
- ])('keeps the %s metadata, API request, and article cache', async (type, metadata, name, image) => {
- vi.mocked(got)
- .mockResolvedValueOnce({ data: `` })
- .mockResolvedValueOnce({ data: { data: { articles: [{ id: 42, title: 'Match', category: 'Football', show_time: 1_704_067_200 }] } } })
- .mockResolvedValueOnce({ data: articleHtml });
-
- const feed = await processFeed(type, '123');
-
- expect(feed.title).toBe(`${name} - 相关新闻`);
- expect(feed.image).toBe(image);
- expect(feed.item).toHaveLength(1);
- expect(feed.item[0]).toMatchObject({ author: 'Reporter', category: ['Football'], pubDate: new Date('2024-01-01T00:00:00.000Z') });
- expect(cache.tryGet).toHaveBeenCalledWith('https://www.dongqiudi.com/articles/42.html', expect.any(Function));
- expect(got).toHaveBeenNthCalledWith(2, 'https://api.dongqiudi.com/v3/archive/app/channel/feeds', {
- searchParams: { id: '123', type, size: 20, platform: 'web', version: '' },
- });
- });
-});
diff --git a/tests/page-script-data-routes.test.ts b/tests/page-script-data-routes.test.ts
deleted file mode 100644
index 5fe5a9d9eb..0000000000
--- a/tests/page-script-data-routes.test.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import type { Context } from 'hono';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { route as hotukdealsRoute } from '../lib/routes/hotukdeals/hottest';
-import { getNSFWNovelContent } from '../lib/routes/pixiv/novel-api/content/nsfw';
-import { parseNovelContent } from '../lib/routes/pixiv/novel-api/content/utils';
-import playCache from '../lib/routes/qq/kg/cache';
-import { route as qqRoute } from '../lib/routes/qq/kg/user';
-import { route as xueqiuRoute } from '../lib/routes/xueqiu/column';
-import type { Data, Route } from '../lib/types';
-
-const mocks = vi.hoisted(() => ({
- request: Object.assign(vi.fn(), { get: vi.fn() }),
- pixivRequest: vi.fn(),
- tryGet: vi.fn(),
-}));
-
-vi.mock('../lib/utils/got', () => ({ default: mocks.request }));
-vi.mock('../lib/utils/cache', () => ({ default: { tryGet: mocks.tryGet } }));
-vi.mock('../lib/routes/pixiv/pixiv-got', () => ({ default: mocks.pixivRequest }));
-vi.mock('../lib/routes/pixiv/novel-api/content/utils', () => ({ parseNovelContent: vi.fn() }));
-
-const invoke = async (route: Route, params: Record = {}) => (await route.handler({ req: { param: (name: string) => params[name] } } as unknown as Context)) as Data;
-const page = (source: string) => ``;
-
-// QQ and Xueqiu use the JSON-assignment format observed in public responses on
-// 2026-09-06. Keep only the consumed fields and replace public account data.
-const playDetail = {
- song_name: 'Song & title',
- content: 'Line one
Line two & three',
- nick: 'Singer',
- cover: 'https://example.com/cover.jpg',
- playurl: 'https://example.com/song.m4a',
- ksong_mid: 'song-1',
- ctime: 1_700_000_000,
- comments: [{ nick: 'Listener', content: 'Nice song', ctime: 1_700_000_001, comment_id: 'comment-1' }],
-};
-
-// Pixiv requires authentication and HotUKDeals returned 403 during inspection.
-// Their fixtures cover the existing consumed-data contract, not live success.
-const novelDetail = {
- id: '42',
- title: 'Novel title',
- caption: 'Caption & summary
',
- text: 'A line\n[uploadedimage:7]',
- userId: 'author-1',
- rating: { bookmark: 3, view: 12, like: 2 },
- cdate: '2026-09-01T10:00:00+09:00',
- isOriginal: true,
- aiType: 0,
- tags: ['fiction'],
- coverUrl: 'https://example.com/novel.jpg',
- images: { '7': { urls: { original: 'https://example.com/image.jpg' } }, '8': {} },
- seriesId: 'series-1',
- seriesTitle: 'Series title',
-};
-
-beforeEach(() => {
- vi.resetAllMocks();
- const entries = new Map();
- mocks.tryGet.mockImplementation(async (key, getValue) => {
- if (!entries.has(key)) {
- entries.set(key, await getValue());
- }
- return entries.get(key);
- });
- mocks.request.mockRejectedValue(new Error('Unexpected upstream request'));
- mocks.request.get.mockRejectedValue(new Error('Unexpected upstream request'));
- mocks.pixivRequest.mockRejectedValue(new Error('Unexpected Pixiv request'));
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('External network access is disabled')));
-});
-
-afterEach(() => {
- vi.unstubAllGlobals();
-});
-
-describe('routes that parse embedded page data', () => {
- it('preserves QQ user feed metadata, audio attachments and play-detail cache keys', async () => {
- const userData = {
- data: {
- nickname: 'Singer',
- head_img_url: 'https://example.com/avatar.jpg',
- ugclist: [{ shareid: 'share-1', ksong_mid: 'song-1', title: 'Song & title', ctime: 1_600_000_000, avatar: 'https://example.com/fallback.jpg' }],
- },
- share: { content: 'Singer profile' },
- };
- mocks.request.mockResolvedValueOnce({ data: page(`window.__DATA__ = ${JSON.stringify(userData)};`) }).mockResolvedValueOnce({ data: page(`window.__DATA__ = ${JSON.stringify({ detail: playDetail })};`) });
-
- const result = await invoke(qqRoute, { userId: 'singer-1' });
-
- expect(result).toMatchObject({
- title: 'Singer - 全民K歌',
- link: 'https://node.kg.qq.com/personal?uid=singer-1',
- description: 'Singer profile',
- image: 'https://example.com/avatar.jpg',
- allowEmpty: true,
- itunes_author: 'Singer',
- itunes_category: '全民K歌',
- item: [
- {
- title: 'Song & title',
- description: playDetail.content,
- link: 'https://node.kg.qq.com/play?s=share-1',
- guid: 'ksong:song-1',
- author: 'Singer',
- pubDate: new Date(1_700_000_000_000),
- itunes_item_image: playDetail.cover,
- enclosure_url: playDetail.playurl,
- enclosure_type: 'audio/x-m4a',
- },
- ],
- });
- expect(mocks.tryGet).toHaveBeenCalledWith('ksong:song-1', expect.any(Function));
- expect(mocks.request.mock.calls.map(([url]) => url)).toEqual(['https://node.kg.qq.com/personal?uid=singer-1', 'https://node.kg.qq.com/play?s=share-1']);
- });
-
- it('preserves QQ comments and reuses a play-detail cache entry', async () => {
- mocks.request.mockResolvedValueOnce({ data: page(`window.__DATA__ = ${JSON.stringify({ detail: playDetail })};`) });
-
- const first = await playCache.getPlayInfo(undefined, 'share-1', 'song-1');
- const second = await playCache.getPlayInfo(undefined, 'share-1', 'song-1');
-
- expect(first).toMatchObject({ name: playDetail.song_name, description: playDetail.content, author: 'Singer', ksong_mid: 'song-1', comments: playDetail.comments });
- expect(second).toBe(first);
- expect(mocks.request).toHaveBeenCalledExactlyOnceWith('https://node.kg.qq.com/play?s=share-1');
- });
-
- it('preserves the Xueqiu cookie jar, first-page request and author data', async () => {
- mocks.request
- .mockResolvedValueOnce({ data: '' })
- .mockResolvedValueOnce({ data: page('window.SNOWMAN_TARGET = {"screen_name":"Author & name","description":"A profile & text"};') })
- .mockResolvedValueOnce({ data: { list: [{ title: 'Article', description: 'Body
', created_at: 1_700_000_000_000, target: '/author/123' }] } });
-
- const result = await invoke(xueqiuRoute, { id: 'author-1' });
-
- expect(result).toEqual({
- title: 'Author & name - 雪球',
- link: 'https://xueqiu.com/author-1/column',
- description: 'A profile & text',
- item: [{ title: 'Article', description: 'Body
', pubDate: new Date(1_700_000_000_000), link: 'https://xueqiu.com/author/123', author: 'Author & name' }],
- });
- const cookieJar = mocks.request.mock.calls[0][1].cookieJar;
- expect(mocks.request).toHaveBeenNthCalledWith(2, 'https://xueqiu.com/author-1/column', { cookieJar });
- expect(mocks.request).toHaveBeenNthCalledWith(3, 'https://xueqiu.com/statuses/original/timeline.json', { cookieJar, searchParams: { user_id: 'author-1', page: 1 } });
- });
-
- it('preserves HotUKDeals titles, image URLs, prices and temperatures', async () => {
- const state = { widgets: { hottestWidget: { threads: [{ title: 'A & B deal', mainImage: { path: 'images', name: 'deal' }, temperature: 123, displayPrice: '£12.99', url: 'https://example.com/deal' }] } } };
- mocks.request.get.mockResolvedValueOnce({ data: page(`window.__INITIAL_STATE__ = ${JSON.stringify(state)};`) });
-
- expect(await invoke(hotukdealsRoute)).toEqual({
- title: 'hotukdeals hottest',
- link: 'https://www.hotukdeals.com/',
- item: [{ title: 'A & B deal', description: '
123° A & B deal
£12.99', link: 'https://example.com/deal' }],
- });
- expect(mocks.request.get).toHaveBeenCalledExactlyOnceWith('https://www.hotukdeals.com/');
- });
-
- it.each(['parent object', 'nested assignment'])('preserves authenticated Pixiv novel data from a %s', async (form) => {
- const source = form === 'parent object' ? `window.pixiv = {novel: ${JSON.stringify(novelDetail)}};` : `window.pixiv = {};');
- mocks.content.mockResolvedValue('Thread content
');
- mocks.getPlaywrightPage.mockResolvedValue({
- page: { goto: mocks.goto, route: mocks.route, waitForSelector: mocks.waitForSelector, content: mocks.content },
- context: { addCookies: mocks.addCookies },
- destroy: mocks.destroy,
- });
-});
-
-describe('Yamibo browser fallback', () => {
- it('keeps ordinary HTML requests free of browser work', async () => {
- mocks.ofetch.mockResolvedValue('Ordinary HTML
');
- const result = await fetchThread('123', { ordertype: '1' });
- expect(result).toEqual({ link: 'https://bbs.yamibo.com/forum.php?mod=viewthread&tid=123&ordertype=1', data: 'Ordinary HTML
' });
- expect(mocks.ofetch).toHaveBeenCalledWith(result.link, { headers: { cookie: 'EeqY_2132_saltkey=test-salt; EeqY_2132_auth=test-auth' } });
- expect(mocks.getPlaywrightPage).not.toHaveBeenCalled();
- });
-
- it('reuses one browser and serializes navigation for concurrent article requests', async () => {
- const fetcher = new ThreadFetcher();
- let active = 0;
- let maximum = 0;
- mocks.goto.mockImplementation(async () => {
- active++;
- maximum = Math.max(maximum, active);
- await Promise.resolve();
- active--;
- });
- try {
- const results = await Promise.all([fetcher.fetchThread('123'), fetcher.fetchThread('456')]);
- expect(results.map((result) => result.data)).toEqual(Array.from({ length: 2 }, () => 'Thread content
'));
- expect(mocks.getPlaywrightPage).toHaveBeenCalledOnce();
- expect(mocks.goto).toHaveBeenCalledTimes(2);
- expect(maximum).toBe(1);
- expect(mocks.addCookies).toHaveBeenCalledWith([
- { name: 'EeqY_2132_saltkey', value: 'test-salt', url: 'https://bbs.yamibo.com' },
- { name: 'EeqY_2132_auth', value: 'test-auth', url: 'https://bbs.yamibo.com' },
- ]);
- } finally {
- await fetcher.close();
- }
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('closes the browser when the expected thread content never appears', async () => {
- mocks.waitForSelector.mockRejectedValue(new Error('Page timeout'));
- await expect(fetchThread('123')).rejects.toThrow('browser access did not return thread content');
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-});
diff --git a/tests/zhihu-browser.test.ts b/tests/zhihu-browser.test.ts
deleted file mode 100644
index 3eaf8fcbad..0000000000
--- a/tests/zhihu-browser.test.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-const mocks = vi.hoisted(() => ({
- cookies: '',
- ofetch: vi.fn(),
- getPlaywrightPage: vi.fn(),
- destroy: vi.fn(),
- addCookies: vi.fn(),
- browserCookies: vi.fn(),
- goto: vi.fn(),
- route: vi.fn(),
- waitForFunction: vi.fn(),
- evaluate: vi.fn(),
- content: vi.fn(),
- url: vi.fn(),
-}));
-
-vi.mock('../lib/config', () => ({
- config: {
- zhihu: {
- get cookies() {
- return mocks.cookies;
- },
- },
- },
-}));
-vi.mock('../lib/utils/ofetch', () => ({ default: mocks.ofetch }));
-vi.mock('../lib/utils/is-worker', () => ({ isWorker: true }));
-vi.mock('../lib/utils/playwright', () => ({ getPlaywrightPage: mocks.getPlaywrightPage }));
-
-const origin = 'https://www.zhihu.com';
-const pageUrl = `${origin}/people/example`;
-const apiPath = '/api/v4/members/example/activities?limit=20';
-const columnPageUrl = 'https://zhuanlan.zhihu.com/example';
-const columnApiPath = '/api/v4/columns/example/items';
-const feedData = { data: [{ id: 'api-item' }] };
-const pageHtml = 'Profile content';
-const nativeFetch = vi.fn();
-const navigationResponse = (contentType = 'text/html', status = 200, body = pageHtml) => ({
- headers: () => ({ 'content-type': contentType }),
- status: () => status,
- text: () => Promise.resolve(body),
-});
-
-beforeEach(() => {
- vi.resetModules();
- vi.resetAllMocks();
- mocks.cookies = '';
- mocks.goto.mockImplementation((url) => {
- mocks.url.mockReturnValue(url);
- return Promise.resolve(navigationResponse());
- });
- mocks.waitForFunction.mockResolvedValue(undefined);
- mocks.browserCookies.mockResolvedValue([
- { name: 'd_c0', value: 'browser-dc0' },
- { name: '__zse_ck', value: 'browser-zse' },
- ]);
- mocks.evaluate.mockImplementation((callback, argument) => callback(argument));
- mocks.content.mockResolvedValue(pageHtml);
- mocks.url.mockReturnValue(pageUrl);
- mocks.getPlaywrightPage.mockResolvedValue({
- page: { goto: mocks.goto, route: mocks.route, waitForFunction: mocks.waitForFunction, evaluate: mocks.evaluate, content: mocks.content, url: mocks.url },
- context: { addCookies: mocks.addCookies, cookies: mocks.browserCookies },
- destroy: mocks.destroy,
- });
- nativeFetch.mockImplementation(() => Promise.resolve(Response.json(feedData)));
- vi.stubGlobal('fetch', nativeFetch);
- vi.stubGlobal('window', { AbortSignal });
-});
-
-afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
-});
-
-describe('Zhihu client session', () => {
- it('uses complete configured credentials for API and HTML requests without starting a browser', async () => {
- mocks.cookies = 'd_c0=configured==; __zse_ck=existing; z_c0=login=value';
- mocks.ofetch.mockResolvedValueOnce(feedData).mockResolvedValueOnce(pageHtml);
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- const result = await withZhihuClient(pageUrl, async (client) => ({ data: await client.get(apiPath), html: await client.getPage() }));
-
- expect(result).toEqual({ data: feedData, html: pageHtml });
- expect(mocks.ofetch).toHaveBeenNthCalledWith(1, `${origin}${apiPath}`, {
- headers: expect.objectContaining({ cookie: mocks.cookies, Referer: pageUrl, 'x-zse-96': expect.stringMatching(/^2\.0_/) }),
- });
- expect(mocks.ofetch).toHaveBeenNthCalledWith(2, pageUrl, {
- headers: { cookie: mocks.cookies, Referer: pageUrl },
- parseResponse: expect.any(Function),
- });
- expect(mocks.ofetch.mock.calls[1][1].parseResponse(pageHtml)).toBe(pageHtml);
- expect(mocks.getPlaywrightPage).not.toHaveBeenCalled();
- expect(nativeFetch).not.toHaveBeenCalled();
- });
-
- it.each(['', 'z_c0=isolated-login', 'd_c0=; z_c0=isolated-login'])('initializes a guest session before the target without transferring an unrelated login: %s', async (cookies) => {
- mocks.cookies = cookies;
- mocks.browserCookies.mockResolvedValueOnce([{ name: 'd_c0', value: 'browser-dc0' }]);
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(pageUrl, (client) => client.get(apiPath))).toEqual(feedData);
-
- expect(mocks.addCookies).not.toHaveBeenCalled();
- expect(mocks.goto.mock.calls).toEqual([
- [`${origin}/explore`, { waitUntil: 'domcontentloaded' }],
- [pageUrl, { waitUntil: 'domcontentloaded' }],
- ]);
- expect(mocks.browserCookies).toHaveBeenCalledTimes(3);
- expect(mocks.browserCookies).toHaveBeenCalledWith(origin);
- expect(mocks.getPlaywrightPage).toHaveBeenCalledExactlyOnceWith(pageUrl, { noGoto: true, closeTimeout: 0 });
- expect(mocks.destroy).toHaveBeenCalledOnce();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- });
-
- it('keeps a configured identity and login intact and requests the API through the browser native fetch', async () => {
- mocks.cookies = 'd_c0=seed==;z_c0=login=value';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(pageUrl, (client) => client.get(apiPath));
-
- expect(mocks.addCookies).toHaveBeenCalledWith([
- { name: 'd_c0', value: 'seed==', domain: '.zhihu.com', path: '/' },
- { name: 'z_c0', value: 'login=value', domain: '.zhihu.com', path: '/' },
- ]);
- expect(mocks.goto).toHaveBeenCalledExactlyOnceWith(pageUrl, { waitUntil: 'domcontentloaded' });
- expect(mocks.evaluate).toHaveBeenCalledOnce();
- expect(nativeFetch).toHaveBeenCalledExactlyOnceWith(`${origin}${apiPath}`, {
- headers: expect.objectContaining({ 'x-zse-96': expect.stringMatching(/^2\.0_/) }),
- credentials: 'include',
- signal: expect.any(AbortSignal),
- });
- const headers = new Headers(nativeFetch.mock.calls[0][1]?.headers);
- expect(headers.has('cookie')).toBe(false);
- expect(headers.has('user-agent')).toBe(false);
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- const handler = mocks.route.mock.calls[0][1];
- await Promise.all(
- ['document', 'script', 'xhr', 'fetch', 'image'].map(async (resourceType) => {
- const continueRequest = vi.fn();
- const abortRequest = vi.fn();
- await handler({ request: () => ({ resourceType: () => resourceType }), continue: continueRequest, abort: abortRequest });
- expect(continueRequest).toHaveBeenCalledTimes(resourceType === 'image' ? 0 : 1);
- expect(abortRequest).toHaveBeenCalledTimes(resourceType === 'image' ? 1 : 0);
- })
- );
- });
-
- it('reuses one browser for API and HTML reads and signs each API request with its current identity', async () => {
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
- const signature = vi.spyOn(await import('../lib/routes/zhihu/sign'), 'getSignedHeaders');
- mocks.browserCookies
- .mockResolvedValueOnce([{ name: 'd_c0', value: 'guest-dc0' }])
- .mockResolvedValueOnce([
- { name: 'd_c0', value: 'guest-dc0' },
- { name: '__zse_ck', value: 'browser-zse' },
- ])
- .mockResolvedValueOnce([{ name: 'd_c0', value: 'first-dc0' }])
- .mockResolvedValueOnce([{ name: 'd_c0', value: 'updated-dc0' }]);
-
- const html = await withZhihuClient(pageUrl, async (client) => {
- expect(await client.get(apiPath)).toEqual(feedData);
- expect(await client.get('/api/v4/members/example')).toEqual(feedData);
- expect(mocks.destroy).not.toHaveBeenCalled();
- return client.getPage();
- });
-
- expect(html).toBe(pageHtml);
- expect(signature).toHaveBeenNthCalledWith(1, apiPath, 'first-dc0');
- expect(signature).toHaveBeenNthCalledWith(2, '/api/v4/members/example', 'updated-dc0');
- expect(mocks.getPlaywrightPage).toHaveBeenCalledOnce();
- expect(mocks.goto).toHaveBeenLastCalledWith(pageUrl, { waitUntil: 'domcontentloaded' });
- expect(mocks.content).toHaveBeenCalledOnce();
- expect(nativeFetch).toHaveBeenCalledTimes(2);
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('requests the signed API after an initial JSON error without waiting for an unnecessary session cookie', async () => {
- mocks.browserCookies.mockResolvedValue([{ name: 'd_c0', value: 'browser-dc0' }]);
- mocks.goto.mockImplementation((url) => {
- mocks.url.mockReturnValue(url);
- return Promise.resolve(url === `${origin}${columnApiPath}` ? navigationResponse('application/json; charset=utf-8', 403, '{"error":{"code":403}}') : navigationResponse());
- });
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(columnPageUrl, (client) => client.get(columnApiPath))).toEqual(feedData);
-
- expect(mocks.goto.mock.calls).toEqual([
- [`${origin}/explore`, { waitUntil: 'domcontentloaded' }],
- [`${origin}${columnApiPath}`, { waitUntil: 'domcontentloaded' }],
- ]);
- expect(mocks.waitForFunction).not.toHaveBeenCalled();
- expect(mocks.browserCookies).toHaveBeenCalledTimes(3);
- expect(mocks.evaluate).toHaveBeenCalledOnce();
- expect(nativeFetch).toHaveBeenCalledExactlyOnceWith(`${origin}${columnApiPath}`, expect.objectContaining({ credentials: 'include' }));
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('consumes a successful initial JSON response once without requesting the first API twice', async () => {
- mocks.browserCookies.mockResolvedValue([{ name: 'd_c0', value: 'browser-dc0' }]);
- mocks.goto.mockImplementation((url) => {
- mocks.url.mockReturnValue(url);
- return Promise.resolve(url === `${origin}${columnApiPath}` ? navigationResponse('application/json', 200, JSON.stringify(feedData)) : navigationResponse());
- });
- const refreshedData = { data: [{ id: 'refreshed-item' }] };
- nativeFetch.mockResolvedValue(Response.json(refreshedData));
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(columnPageUrl, async (client) => {
- expect(await client.get(columnApiPath)).toEqual(feedData);
- expect(nativeFetch).not.toHaveBeenCalled();
- expect(mocks.evaluate).not.toHaveBeenCalled();
- expect(await client.get(columnApiPath)).toEqual(refreshedData);
- });
-
- expect(mocks.goto).toHaveBeenCalledTimes(2);
- expect(mocks.waitForFunction).not.toHaveBeenCalled();
- expect(nativeFetch).toHaveBeenCalledOnce();
- expect(mocks.evaluate).toHaveBeenCalledOnce();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('initializes a column on the API origin and restores that origin after reading the column HTML', async () => {
- const columnUrl = 'https://zhuanlan.zhihu.com/example';
- const columnApi = '/api/v4/columns/example/items';
- mocks.url.mockReturnValue(columnUrl);
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(columnUrl, async (client) => {
- expect(await client.get(columnApi)).toEqual(feedData);
- expect(await client.getPage()).toBe(pageHtml);
- expect(await client.get(columnApi)).toEqual(feedData);
- });
-
- expect(mocks.goto.mock.calls).toEqual([
- [`${origin}/explore`, { waitUntil: 'domcontentloaded' }],
- [`${origin}${columnApi}`, { waitUntil: 'domcontentloaded' }],
- [columnUrl, { waitUntil: 'domcontentloaded' }],
- [`${origin}/explore`, { waitUntil: 'domcontentloaded' }],
- ]);
- expect(mocks.getPlaywrightPage).toHaveBeenCalledExactlyOnceWith(columnUrl, { noGoto: true, closeTimeout: 0 });
- expect(nativeFetch).toHaveBeenCalledTimes(2);
- expect(nativeFetch).toHaveBeenCalledWith(`${origin}${columnApi}`, expect.objectContaining({ credentials: 'include' }));
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('initializes a c_-style column through its API even when the page already uses the API origin', async () => {
- const columnUrl = `${origin}/column/c_123`;
- const columnApi = '/api/v4/columns/c_123/items';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(columnUrl, (client) => client.get(columnApi))).toEqual(feedData);
-
- expect(mocks.goto.mock.calls).toEqual([
- [`${origin}/explore`, { waitUntil: 'domcontentloaded' }],
- [`${origin}${columnApi}`, { waitUntil: 'domcontentloaded' }],
- ]);
- expect(mocks.getPlaywrightPage).toHaveBeenCalledExactlyOnceWith(columnUrl, { noGoto: true, closeTimeout: 0 });
- expect(nativeFetch).toHaveBeenCalledExactlyOnceWith(`${origin}${columnApi}`, expect.objectContaining({ credentials: 'include' }));
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it.each([
- [`${origin}/people/example`, '/api/v3/moments/example/activities?limit=5'],
- [`${origin}/topic/123/newest`, '/api/v5.1/topics/123/feeds/timeline_activity'],
- [`${origin}/question/123`, '/api/v4/questions/123/answers?limit=20'],
- ])('initializes the human page for a non-column API: %s', async (targetUrl, targetApi) => {
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(targetUrl, (client) => client.get(targetApi))).toEqual(feedData);
-
- expect(mocks.goto.mock.calls).toEqual([
- [`${origin}/explore`, { waitUntil: 'domcontentloaded' }],
- [targetUrl, { waitUntil: 'domcontentloaded' }],
- ]);
- expect(nativeFetch).toHaveBeenCalledExactlyOnceWith(`${origin}${targetApi}`, expect.objectContaining({ credentials: 'include' }));
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('does not initialize a browser when the callback performs no requests', async () => {
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(pageUrl, () => Promise.resolve('cached result'))).toBe('cached result');
-
- expect(mocks.getPlaywrightPage).not.toHaveBeenCalled();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(nativeFetch).not.toHaveBeenCalled();
- });
-
- it('closes the browser when the route callback fails after reading data', async () => {
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await expect(
- withZhihuClient(pageUrl, async (client) => {
- await client.get(apiPath);
- throw new Error('Route parsing failed');
- })
- ).rejects.toThrow('Route parsing failed');
-
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it.each([
- { status: 403, body: 'test-private-response', error: 'HTTP 403' },
- { status: 200, body: 'test-private-response', error: 'did not return JSON' },
- ])('rejects an unusable API response and closes the browser: $error', async ({ status, body, error }) => {
- nativeFetch.mockResolvedValue(new Response(body, { status }));
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- const result = withZhihuClient(pageUrl, (client) => client.get(apiPath));
-
- await expect(result).rejects.toThrow(error);
- await expect(result).rejects.not.toThrow('test-private-response');
- expect(mocks.destroy).toHaveBeenCalledOnce();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- });
-
- it('closes the browser when its native API fetch fails', async () => {
- nativeFetch.mockRejectedValue(new Error('Browser fetch failed'));
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await expect(withZhihuClient(pageUrl, (client) => client.get(apiPath))).rejects.toThrow('Browser fetch failed');
-
- expect(mocks.destroy).toHaveBeenCalledOnce();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- });
-
- it('closes the browser and gives an actionable phase when session cookies are unavailable', async () => {
- mocks.waitForFunction.mockRejectedValue(new Error('Page timeout'));
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- const result = withZhihuClient(pageUrl, (client) => client.get(apiPath));
-
- await expect(result).rejects.toThrow('session cookie wait');
- await expect(result).rejects.toThrow('Configure ZHIHU_COOKIES');
- expect(nativeFetch).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('stops before the target and closes the browser when guest navigation fails', async () => {
- mocks.goto.mockRejectedValueOnce(new Error('Navigation failed with test-private-diagnostic'));
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- const result = withZhihuClient(pageUrl, (client) => client.get(apiPath));
-
- await expect(result).rejects.toThrow('guest cookie initialization');
- await expect(result).rejects.toThrow('Configure ZHIHU_COOKIES');
- await expect(result).rejects.not.toThrow('test-private-diagnostic');
- expect(mocks.goto).toHaveBeenCalledExactlyOnceWith(`${origin}/explore`, { waitUntil: 'domcontentloaded' });
- expect(mocks.browserCookies).not.toHaveBeenCalled();
- expect(mocks.waitForFunction).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('stops before the target and closes the browser when the guest page does not set d_c0', async () => {
- mocks.browserCookies.mockResolvedValueOnce([{ name: '__zse_ck', value: 'browser-zse' }]);
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await expect(withZhihuClient(pageUrl, (client) => client.get(apiPath))).rejects.toThrow('guest cookie initialization');
-
- expect(mocks.goto).toHaveBeenCalledExactlyOnceWith(`${origin}/explore`, { waitUntil: 'domcontentloaded' });
- expect(mocks.browserCookies).toHaveBeenCalledExactlyOnceWith(origin);
- expect(mocks.waitForFunction).not.toHaveBeenCalled();
- expect(mocks.evaluate).not.toHaveBeenCalled();
- expect(mocks.destroy).toHaveBeenCalledOnce();
- });
-
- it('rejects non-API paths before initializing a session', async () => {
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await expect(withZhihuClient(pageUrl, (client) => client.get('//example.invalid/api/'))).rejects.toThrow('expected an API path');
-
- expect(mocks.getPlaywrightPage).not.toHaveBeenCalled();
- expect(nativeFetch).not.toHaveBeenCalled();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- });
-});
diff --git a/tests/zhihu-jsdom.test.ts b/tests/zhihu-jsdom.test.ts
deleted file mode 100644
index e77acd0246..0000000000
--- a/tests/zhihu-jsdom.test.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-const mocks = vi.hoisted(() => ({
- cookies: '',
- ofetch: vi.fn(),
- raw: vi.fn(),
- tryGet: vi.fn(),
- generateHeaders: vi.fn(),
- createBrowserClient: vi.fn(),
-}));
-
-vi.mock('../lib/config', () => ({
- config: {
- zhihu: {
- get cookies() {
- return mocks.cookies;
- },
- },
- cache: { contentExpire: 3600 },
- },
-}));
-vi.mock('../lib/utils/ofetch', () => ({ default: Object.assign(mocks.ofetch, { raw: mocks.raw }) }));
-vi.mock('../lib/utils/cache', () => ({ default: { tryGet: mocks.tryGet } }));
-vi.mock('../lib/utils/header-generator', () => ({ generateHeaders: mocks.generateHeaders }));
-vi.mock('../lib/utils/is-worker', () => ({ isWorker: false }));
-vi.mock('../lib/routes/zhihu/browser', () => ({ createBrowserClient: mocks.createBrowserClient }));
-
-const origin = 'https://www.zhihu.com';
-const pageUrl = `${origin}/people/example`;
-const apiPath = '/api/v4/members/example/activities?limit=20';
-const otherApiPath = '/api/v4/members/example?include=description';
-const scriptUrl = 'https://static.zhihu.com/zse-ck/v4/0123456789abcdef.js';
-const challengeHtml = '';
-const challengeScript = `
-if (navigator.userAgent !== 'RSSHub JSDOM fixture' || navigator.webdriver !== false) {
- throw new Error('The challenge did not receive the configured browser fingerprint');
-}
-if (document.getElementById('zh-zse-ck').getAttribute('content') !== 'challenge-fixture') {
- throw new Error('The challenge did not receive its page metadata');
-}
-document.cookie = '__zse_ck=generated-token; path=/';
-`;
-const userAgent = 'RSSHub JSDOM fixture';
-const feedData = { data: [{ id: 'api-item' }] };
-const pageHtml = 'Profile content';
-const cached = new Map();
-let initialRejectionListeners: Set[number]>;
-
-const requestHeaders = (url: string) => {
- const request = mocks.ofetch.mock.calls.find(([requestedUrl]) => requestedUrl === url);
- expect(request).toBeDefined();
- return new Headers(request?.[1]?.headers);
-};
-
-beforeEach(() => {
- vi.resetModules();
- vi.resetAllMocks();
- cached.clear();
- mocks.cookies = '';
- initialRejectionListeners = new Set(process.listeners('unhandledRejection'));
- vi.spyOn(Math, 'random').mockReturnValue(0.25);
- mocks.generateHeaders.mockReturnValue({ 'user-agent': userAgent });
- mocks.tryGet.mockImplementation(async (key: string, fetchValue: () => Promise) => {
- if (!cached.has(key)) {
- cached.set(key, await fetchValue());
- }
- return cached.get(key);
- });
- mocks.raw.mockImplementation((url: string) => Promise.resolve(url === `${origin}/explore` ? { headers: new Headers({ 'set-cookie': 'd_c0=guest-identity==|fixture; Path=/; Secure' }) } : { _data: challengeHtml }));
- mocks.ofetch.mockImplementation((url: string) => {
- if (url === scriptUrl) {
- return Promise.resolve(challengeScript);
- }
- if (url === pageUrl) {
- return Promise.resolve(pageHtml);
- }
- if (url.startsWith(`${origin}/api/`)) {
- return Promise.resolve(feedData);
- }
- throw new Error(`Unexpected fixture URL: ${url}`);
- });
-});
-
-afterEach(() => {
- for (const listener of process.listeners('unhandledRejection')) {
- if (!initialRejectionListeners.has(listener)) {
- process.removeListener('unhandledRejection', listener);
- }
- }
- expect(mocks.createBrowserClient).not.toHaveBeenCalled();
- vi.restoreAllMocks();
-});
-
-describe('Zhihu JSDOM client on Node', () => {
- it.each(['', 'z_c0=isolated-login', 'd_c0=; z_c0=isolated-login'])('executes the challenge in JSDOM and keeps guest credentials isolated: %s', async (cookies) => {
- mocks.cookies = cookies;
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(pageUrl, (client) => client.get(apiPath))).toEqual(feedData);
-
- expect(mocks.raw).toHaveBeenNthCalledWith(1, `${origin}/explore`, {
- headers: { 'user-agent': userAgent },
- redirect: 'manual',
- ignoreResponseError: true,
- });
- expect(mocks.raw).toHaveBeenNthCalledWith(2, `${origin}${apiPath}`, {
- headers: {
- 'user-agent': userAgent,
- cookie: 'd_c0=guest-identity; __zse_ck=005_x-x',
- referer: pageUrl,
- 'x-requested-with': 'fetch',
- },
- ignoreResponseError: true,
- });
- expect(requestHeaders(`${origin}${apiPath}`).get('cookie')).toBe('__zse_ck=generated-token; d_c0=guest-identity');
- expect(requestHeaders(`${origin}${apiPath}`).get('user-agent')).toBe(userAgent);
- expect(mocks.ofetch).toHaveBeenCalledWith(scriptUrl, { headers: { 'user-agent': userAgent }, parseResponse: expect.any(Function) });
- expect(mocks.tryGet).toHaveBeenCalledWith('zhihu:zse-ck:v4:guest', expect.any(Function), 3600, false);
- });
-
- it('preserves the configured identity and login cookies without creating a guest session', async () => {
- mocks.cookies = 'd_c0=configured==; z_c0=login=value; __zse_ck=; extra=retained';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(pageUrl, (client) => client.get(apiPath));
-
- expect(mocks.raw).toHaveBeenCalledExactlyOnceWith(`${origin}${apiPath}`, expect.objectContaining({ headers: expect.objectContaining({ cookie: 'd_c0=configured==; __zse_ck=005_x-x' }) }));
- expect(requestHeaders(`${origin}${apiPath}`).get('cookie')).toBe('__zse_ck=generated-token; d_c0=configured==; z_c0=login=value; extra=retained');
- expect(requestHeaders(`${origin}${apiPath}`).get('user-agent')).toBe(userAgent);
- });
-
- it('keeps API and HTML requests in the same generated identity and signs each API path separately', async () => {
- mocks.cookies = 'd_c0=configured==; z_c0=login';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
- const { getSignedHeaders } = await import('../lib/routes/zhihu/sign');
-
- const result = await withZhihuClient(pageUrl, async (client) => ({
- first: await client.get(apiPath),
- second: await client.get(otherApiPath),
- html: await client.getPage(),
- }));
-
- expect(result).toEqual({ first: feedData, second: feedData, html: pageHtml });
- expect(mocks.raw).toHaveBeenCalledTimes(1);
- expect(mocks.generateHeaders).toHaveBeenCalledTimes(1);
- for (const url of [`${origin}${apiPath}`, `${origin}${otherApiPath}`, pageUrl]) {
- const headers = requestHeaders(url);
- expect(headers.get('cookie')).toBe('__zse_ck=generated-token; d_c0=configured==; z_c0=login');
- expect(headers.get('user-agent')).toBe(userAgent);
- expect(headers.get('referer')).toBe(pageUrl);
- }
- expect(requestHeaders(`${origin}${apiPath}`).get('x-zse-96')).toBe(getSignedHeaders(apiPath, 'configured==')['x-zse-96']);
- expect(requestHeaders(`${origin}${otherApiPath}`).get('x-zse-96')).toBe(getSignedHeaders(otherApiPath, 'configured==')['x-zse-96']);
- expect(requestHeaders(`${origin}${apiPath}`).get('x-zse-96')).not.toBe(requestHeaders(`${origin}${otherApiPath}`).get('x-zse-96'));
- const htmlRequest = mocks.ofetch.mock.calls.find(([url]) => url === pageUrl);
- expect(htmlRequest?.[1]?.parseResponse(pageHtml)).toBe(pageHtml);
- });
-
- it('initializes from the page path when HTML is requested before an API', async () => {
- mocks.cookies = 'd_c0=configured==';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(pageUrl, async (client) => {
- expect(await client.getPage()).toBe(pageHtml);
- expect(await client.get(apiPath)).toEqual(feedData);
- });
-
- expect(mocks.raw).toHaveBeenCalledExactlyOnceWith(pageUrl, expect.objectContaining({ ignoreResponseError: true }));
- expect(requestHeaders(pageUrl).get('cookie')).toBe(requestHeaders(`${origin}${apiPath}`).get('cookie'));
- expect(requestHeaders(pageUrl).get('user-agent')).toBe(userAgent);
- });
-
- it('deduplicates concurrent challenge generation and reuses cached credentials across clients', async () => {
- mocks.cookies = 'd_c0=configured==';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- const results = await Promise.all([withZhihuClient(pageUrl, (client) => client.get(apiPath)), withZhihuClient(pageUrl, (client) => client.get(otherApiPath))]);
- expect(results).toEqual([feedData, feedData]);
- await withZhihuClient(pageUrl, (client) => client.get(apiPath));
-
- expect(mocks.raw).toHaveBeenCalledTimes(1);
- expect(mocks.generateHeaders).toHaveBeenCalledTimes(1);
- expect(mocks.ofetch.mock.calls.filter(([url]) => url === scriptUrl)).toHaveLength(1);
- });
-
- it('retries generation after a failed challenge without permanently caching the rejection', async () => {
- mocks.cookies = 'd_c0=configured==';
- mocks.raw.mockResolvedValueOnce({ _data: 'No challenge script' });
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(pageUrl, async (client) => {
- await expect(client.get(apiPath)).rejects.toThrow('challenge page');
- expect(await client.get(apiPath)).toEqual(feedData);
- });
-
- expect(mocks.raw).toHaveBeenCalledTimes(2);
- expect(mocks.tryGet).toHaveBeenCalledTimes(2);
- expect(mocks.ofetch.mock.calls.filter(([url]) => url === `${origin}${apiPath}`)).toHaveLength(1);
- });
-
- it('does not reuse generated credentials for a different configured identity', async () => {
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
- mocks.cookies = 'd_c0=first-identity; z_c0=first-login';
- await withZhihuClient(pageUrl, (client) => client.get(apiPath));
- mocks.cookies = 'd_c0=second-identity; z_c0=second-login';
- await withZhihuClient(pageUrl, (client) => client.get(otherApiPath));
-
- expect(mocks.raw).toHaveBeenCalledTimes(2);
- expect(mocks.tryGet.mock.calls[0][0]).not.toBe(mocks.tryGet.mock.calls[1][0]);
- expect(requestHeaders(`${origin}${apiPath}`).get('cookie')).toBe('__zse_ck=generated-token; d_c0=first-identity; z_c0=first-login');
- expect(requestHeaders(`${origin}${otherApiPath}`).get('cookie')).toBe('__zse_ck=generated-token; d_c0=second-identity; z_c0=second-login');
- });
-
- it('uses complete configured credentials without initializing JSDOM or a browser', async () => {
- mocks.cookies = 'd_c0=configured==; __zse_ck=existing-token; z_c0=login=value';
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient(pageUrl, async (client) => ({ data: await client.get(apiPath), html: await client.getPage() }))).toEqual({ data: feedData, html: pageHtml });
-
- expect(requestHeaders(`${origin}${apiPath}`).get('cookie')).toBe(mocks.cookies);
- expect(requestHeaders(pageUrl).get('cookie')).toBe(mocks.cookies);
- expect(mocks.raw).not.toHaveBeenCalled();
- expect(mocks.tryGet).not.toHaveBeenCalled();
- expect(mocks.generateHeaders).not.toHaveBeenCalled();
- });
-
- it.each(['', 'd_c0=configured==; __zse_ck=existing-token'])('rejects non-API URLs before any I/O: %s', async (cookies) => {
- mocks.cookies = cookies;
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- await withZhihuClient(pageUrl, async (client) => {
- await expect(client.get('https://example.com/api/v4/members/example')).rejects.toThrow('expected an API path');
- await expect(client.get('/people/example')).rejects.toThrow('expected an API path');
- });
-
- expect(mocks.ofetch).not.toHaveBeenCalled();
- expect(mocks.raw).not.toHaveBeenCalled();
- expect(mocks.tryGet).not.toHaveBeenCalled();
- expect(mocks.generateHeaders).not.toHaveBeenCalled();
- });
-});
diff --git a/tests/zhihu-session.worker.test.ts b/tests/zhihu-session.worker.test.ts
deleted file mode 100644
index 2a8189284d..0000000000
--- a/tests/zhihu-session.worker.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { isWorker } from '@/utils/is-worker';
-
-const mocks = vi.hoisted(() => ({
- cookies: '',
- ofetch: vi.fn(),
- createBrowserClient: vi.fn(),
- createJSDOMClient: vi.fn(() => {
- throw new Error('JSDOM must never run on Workers');
- }),
- get: vi.fn(),
- getPage: vi.fn(),
- close: vi.fn(),
-}));
-
-vi.mock('../lib/config', () => ({
- config: {
- zhihu: {
- get cookies() {
- return mocks.cookies;
- },
- },
- },
-}));
-vi.mock('../lib/utils/ofetch', () => ({ default: mocks.ofetch }));
-vi.mock('../lib/routes/zhihu/browser', () => ({ createBrowserClient: mocks.createBrowserClient }));
-vi.mock('../lib/routes/zhihu/jsdom', () => ({ createJSDOMClient: mocks.createJSDOMClient }));
-
-beforeEach(() => {
- vi.resetAllMocks();
- mocks.cookies = '';
- mocks.createBrowserClient.mockResolvedValue({ get: mocks.get, getPage: mocks.getPage, close: mocks.close });
-});
-
-describe('Zhihu runtime selection in workerd', () => {
- it('uses a browser session for both API and HTML requests', async () => {
- expect(isWorker).toBe(true);
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
- mocks.get.mockResolvedValue({ data: [1] });
- mocks.getPage.mockResolvedValue('Column');
-
- await withZhihuClient('https://zhuanlan.zhihu.com/example', async (client) => {
- expect(await client.get('/api/v4/columns/example/items')).toEqual({ data: [1] });
- expect(await client.getPage()).toBe('Column');
- });
-
- expect(mocks.createBrowserClient).toHaveBeenCalledExactlyOnceWith('https://zhuanlan.zhihu.com/example', '', '/api/v4/columns/example/items');
- expect(mocks.close).toHaveBeenCalledOnce();
- expect(mocks.createJSDOMClient).not.toHaveBeenCalled();
- expect(mocks.ofetch).not.toHaveBeenCalled();
- });
-
- it('uses complete configured cookies without either initializer', async () => {
- mocks.cookies = 'd_c0=configured; __zse_ck=existing';
- mocks.ofetch.mockResolvedValue({ data: [1] });
- const { withZhihuClient } = await import('../lib/routes/zhihu/utils');
-
- expect(await withZhihuClient('https://www.zhihu.com/people/example', (client) => client.get('/api/v4/members/example'))).toEqual({ data: [1] });
- expect(mocks.createBrowserClient).not.toHaveBeenCalled();
- expect(mocks.createJSDOMClient).not.toHaveBeenCalled();
- expect(mocks.ofetch).toHaveBeenCalledOnce();
- });
-});
diff --git a/tsdown-worker.config.ts b/tsdown-worker.config.ts
index 0b7fbb40f6..c901df65ce 100644
--- a/tsdown-worker.config.ts
+++ b/tsdown-worker.config.ts
@@ -85,6 +85,8 @@ export default defineConfig({
'@sentry/node': path.resolve('./lib/shims/sentry-node.ts'),
'@honeybadger-io/js': path.resolve('./lib/shims/honeybadger.ts'),
typescript: path.resolve('./lib/shims/typescript.ts'),
+ jsdom: path.resolve('./lib/shims/jsdom.ts'),
+ vm2: path.resolve('./lib/shims/vm2.ts'),
// Routes file with Worker-specific build (match relative import from lib/)
'../assets/build/routes.js': path.resolve('./assets/build/routes-worker.js'),
// routes.json is only used in test environment, but rolldown still tries to resolve it
@@ -95,6 +97,7 @@ export default defineConfig({
neverBundle: [
// Let Wrangler select the package's static-WASM workerd export.
'xxhash-wasm',
+ '@oxc-parser/binding-wasm32-wasip1/wasm.wasm',
// Exclude non-code files that might be accidentally imported
/\/_README$/,
/\.node$/,
diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts
index e162ed9ad6..872c1242e2 100644
--- a/vitest.workers.config.ts
+++ b/vitest.workers.config.ts
@@ -67,9 +67,11 @@ export default defineConfig({
resolve: {
alias: {
'dotenv/config': path.resolve('./lib/shims/dotenv-config.ts'),
+ jsdom: path.resolve('./lib/shims/jsdom.ts'),
+ vm2: path.resolve('./lib/shims/vm2.ts'),
},
},
test: {
- include: ['lib/**/*.worker.test.ts', 'tests/**/*.worker.test.ts', 'lib/utils/parse-script-data.test.ts', 'lib/utils/parse-date-in-timezone.test.ts', 'tests/source-date-routes.test.ts', 'tests/cache-coordination.test.ts'],
+ include: ['lib/**/*.worker.test.ts', 'tests/**/*.worker.test.ts'],
},
});