mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-18 14:29:01 +08:00
f61608df00
Closes #23328
152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
// Worker-compatible Playwright with ordinary remote WebSocket and Browser Run support.
|
|
import type { BrowserWorker } from '@cloudflare/playwright';
|
|
import { connect, launch } from '@cloudflare/playwright';
|
|
import type { DurableObjectNamespace, DurableObjectStub } from '@cloudflare/workers-types';
|
|
import type { Browser, Page } from 'patchright';
|
|
|
|
import { config } from '@/config';
|
|
|
|
import logger from './logger';
|
|
import { connectRemotePlaywright } from './playwright-remote.worker';
|
|
|
|
export { setPlaywrightServiceBinding } from './playwright-remote.worker';
|
|
|
|
type GotoOptions = Parameters<Page['goto']>[1];
|
|
let browserBinding: any;
|
|
let browserSessionBinding: DurableObjectNamespace | undefined;
|
|
|
|
export const setBrowserBinding = (binding: any) => {
|
|
browserBinding = binding;
|
|
};
|
|
|
|
export const setBrowserSessionBinding = (binding?: DurableObjectNamespace) => {
|
|
browserSessionBinding = binding;
|
|
};
|
|
|
|
async function getSessionId(stub: DurableObjectStub, invalidSessionId?: string) {
|
|
const response = await stub.fetch('https://browser-session/session', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(invalidSessionId ? { invalidSessionId } : {}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Browser session acquisition failed (${response.status}). Check the BROWSER and BROWSER_SESSIONS bindings.`);
|
|
}
|
|
const data = await response.json<{ sessionId?: unknown }>();
|
|
if (typeof data.sessionId !== 'string' || !data.sessionId) {
|
|
throw new Error('Browser session coordinator did not return a session ID.');
|
|
}
|
|
return data.sessionId;
|
|
}
|
|
|
|
async function connectSharedBrowser(binding: BrowserWorker, sessions: DurableObjectNamespace) {
|
|
const stub = sessions.get(sessions.idFromName('rsshub-browser'));
|
|
const sessionId = await getSessionId(stub);
|
|
try {
|
|
return await connect(binding, sessionId);
|
|
} catch (error) {
|
|
// Network and quota failures must not replace a session used by other requests.
|
|
if (!(error instanceof Error) || !/Unable to connect to browser: code: (?:400|404|410)\b/.test(error.message)) {
|
|
throw error;
|
|
}
|
|
const replacement = await getSessionId(stub, sessionId);
|
|
return connect(binding, replacement);
|
|
}
|
|
}
|
|
|
|
const launchBrowser = async (options: { javaScriptEnabled?: boolean; useConfiguredEndpoint?: boolean } = {}) => {
|
|
let browser: Browser;
|
|
if (config.playwrightWSEndpoint) {
|
|
browser = await connectRemotePlaywright(config.playwrightWSEndpoint);
|
|
} else {
|
|
if (options.useConfiguredEndpoint) {
|
|
throw new Error('Configure PLAYWRIGHT_WS_ENDPOINT to use the remote Playwright browser.');
|
|
}
|
|
if (!browserBinding) {
|
|
throw new Error('Configure PLAYWRIGHT_WS_ENDPOINT or a Cloudflare BROWSER binding. Browser Run requires remote mode or a deployed Worker.');
|
|
}
|
|
const binding = browserBinding;
|
|
const sessions = browserSessionBinding;
|
|
browser = (sessions ? await connectSharedBrowser(binding, sessions) : await launch(binding, { keep_alive: 60000 })) as unknown as Browser;
|
|
}
|
|
try {
|
|
const context = await browser.newContext({
|
|
ignoreHTTPSErrors: true,
|
|
...(options.javaScriptEnabled !== undefined && { javaScriptEnabled: options.javaScriptEnabled }),
|
|
});
|
|
return { browser, context };
|
|
} catch (error) {
|
|
await browser.close();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const cleanup = ({ browser, context }: Awaited<ReturnType<typeof launchBrowser>>, timeout = 30000) => {
|
|
let closing: Promise<void> | undefined;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const destroy = () => {
|
|
clearTimeout(timer);
|
|
return (closing ??= (async () => {
|
|
let contextTimer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
await Promise.race([
|
|
context.close(),
|
|
new Promise<never>((_resolve, reject) => {
|
|
contextTimer = setTimeout(() => reject(new Error('Playwright context cleanup timed out')), 5000);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(contextTimer);
|
|
// Remote Browser.close() disconnects this client; it does not stop the shared server.
|
|
await browser.close();
|
|
}
|
|
})());
|
|
};
|
|
if (timeout !== 0) {
|
|
timer = setTimeout(() => {
|
|
void destroy().catch(() => logger.warn('Playwright browser cleanup failed'));
|
|
}, timeout);
|
|
}
|
|
return destroy;
|
|
};
|
|
|
|
/** @returns Playwright browser context (native newPage() shares state across calls) */
|
|
export default async function outPlaywright() {
|
|
const session = await launchBrowser();
|
|
cleanup(session);
|
|
return session.context;
|
|
}
|
|
|
|
/** @returns Playwright page with explicit, idempotent cleanup */
|
|
export const getPlaywrightPage = async (
|
|
url: string,
|
|
instanceOptions: {
|
|
// Set to zero only when the caller always awaits destroy() in finally.
|
|
closeTimeout?: number;
|
|
gotoConfig?: GotoOptions;
|
|
javaScriptEnabled?: boolean;
|
|
noGoto?: boolean;
|
|
useConfiguredEndpoint?: boolean;
|
|
onBeforeLoad?: (page: Page, context?: Awaited<ReturnType<typeof launchBrowser>>['context']) => Promise<void> | void;
|
|
} = {}
|
|
) => {
|
|
const session = await launchBrowser(instanceOptions);
|
|
const { context } = session;
|
|
const destroy = cleanup(session, instanceOptions.closeTimeout);
|
|
try {
|
|
const page = await context.newPage();
|
|
if (instanceOptions.onBeforeLoad) {
|
|
await instanceOptions.onBeforeLoad(page, context);
|
|
}
|
|
if (!instanceOptions.noGoto) {
|
|
await page.goto(url, instanceOptions.gotoConfig || { waitUntil: 'domcontentloaded' });
|
|
}
|
|
return { context, destroy, page };
|
|
} catch (error) {
|
|
await destroy();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export { type Page } from 'patchright';
|