162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
import { createRouter } from '../../create-router';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { chromium, type Browser } from 'playwright';
|
|
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
|
|
|
|
const MAX_CONTENT_LENGTH = 100_000;
|
|
|
|
let browserPromise: Promise<Browser> | null = null;
|
|
|
|
function getBrowser(): Promise<Browser> {
|
|
if (!browserPromise) {
|
|
browserPromise = chromium.launch({ headless: true }).catch((err) => {
|
|
browserPromise = null;
|
|
throw err;
|
|
});
|
|
}
|
|
return browserPromise;
|
|
}
|
|
|
|
function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80);
|
|
}
|
|
|
|
export const scrapeRouter = createRouter();
|
|
|
|
scrapeRouter.post('/', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const { url, sessionId, provider } = ctx.get('body') as {
|
|
url: string;
|
|
sessionId?: string;
|
|
provider?: 'claude' | 'opencode' | 'pi-mono';
|
|
};
|
|
|
|
if (!url) return ctx.json({ error: 'url is required' }, 400);
|
|
|
|
const browser = await getBrowser();
|
|
const page = await browser.newPage();
|
|
|
|
try {
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
|
|
// Scroll through the page to capture all content, including virtualized/lazy-loaded
|
|
// content (e.g. ChatGPT shared links remove DOM nodes as you scroll past them).
|
|
// We capture text incrementally at each scroll position and merge it.
|
|
const { accumulatedText, scrollableFound } = await page.evaluate(async () => {
|
|
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Find the deepest scrollable container (the one that actually scrolls content)
|
|
let bestScrollable: Element | null = null;
|
|
let bestOverflow = 0;
|
|
const elements = Array.from(document.querySelectorAll('*'));
|
|
for (const el of elements) {
|
|
const style = getComputedStyle(el);
|
|
const overflowY = style.overflowY;
|
|
if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) {
|
|
const overflow = el.scrollHeight - el.clientHeight;
|
|
if (overflow > bestOverflow) {
|
|
bestOverflow = overflow;
|
|
bestScrollable = el;
|
|
}
|
|
}
|
|
}
|
|
|
|
const scrollable = bestScrollable ?? document.scrollingElement;
|
|
if (!scrollable || scrollable.scrollHeight <= scrollable.clientHeight + 10) {
|
|
return { accumulatedText: '', scrollableFound: false };
|
|
}
|
|
|
|
// Collect text chunks as we scroll through
|
|
const seenChunks = new Set<string>();
|
|
const orderedChunks: string[] = [];
|
|
|
|
const captureVisible = () => {
|
|
const text = document.body.innerText;
|
|
// Split into paragraphs and capture new ones
|
|
const paragraphs = text.split(/\n{2,}/);
|
|
for (const p of paragraphs) {
|
|
const trimmed = p.trim();
|
|
if (trimmed && !seenChunks.has(trimmed)) {
|
|
seenChunks.add(trimmed);
|
|
orderedChunks.push(trimmed);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Start from the top
|
|
scrollable.scrollTop = 0;
|
|
await delay(500);
|
|
captureVisible();
|
|
|
|
// Scroll incrementally, capturing at each position
|
|
let stableCount = 0;
|
|
let lastChunkCount = orderedChunks.length;
|
|
for (let i = 0; i < 200; i++) {
|
|
scrollable.scrollTop += scrollable.clientHeight * 0.6;
|
|
await delay(300);
|
|
captureVisible();
|
|
|
|
// Check if we're at the bottom
|
|
const atBottom = scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollHeight - 5;
|
|
if (atBottom) {
|
|
// Wait a bit for potential dynamic loading
|
|
await delay(500);
|
|
captureVisible();
|
|
// If no new content appeared, we're done
|
|
if (orderedChunks.length === lastChunkCount) {
|
|
stableCount++;
|
|
if (stableCount >= 2) break;
|
|
} else {
|
|
stableCount = 0;
|
|
lastChunkCount = orderedChunks.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { accumulatedText: orderedChunks.join('\n\n'), scrollableFound: true };
|
|
});
|
|
|
|
const title = await page.title();
|
|
const html = await page.evaluate(() => document.documentElement.outerHTML);
|
|
|
|
// Use accumulated text from scrolling if available, otherwise fall back to current innerText
|
|
const rawText =
|
|
scrollableFound && accumulatedText ? accumulatedText : await page.evaluate(() => document.body.innerText);
|
|
|
|
// Strip excessive whitespace and truncate
|
|
const content = rawText
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.replace(/[ \t]+/g, ' ')
|
|
.trim()
|
|
.slice(0, MAX_CONTENT_LENGTH);
|
|
|
|
// Determine save location
|
|
const slug = slugify(title || 'page');
|
|
let attachmentId: string;
|
|
let saveDir: string;
|
|
|
|
if (sessionId && provider) {
|
|
attachmentId = `${slug}.html`;
|
|
saveDir = getAttachmentsDir(user.email, provider, sessionId);
|
|
} else {
|
|
attachmentId = `${crypto.randomUUID()}.html`;
|
|
saveDir = getTmpAttachmentsDir(user.email);
|
|
}
|
|
|
|
await mkdir(saveDir, { recursive: true });
|
|
await Bun.write(join(saveDir, attachmentId), html);
|
|
|
|
return ctx.json({ url, title, content, attachmentId });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Scrape failed';
|
|
return ctx.json({ error: message }, 500);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|