Spaces:
Paused
Paused
| // src/server.js β Crawlr v3 | |
| require("dotenv").config(); | |
| const express = require("express"); | |
| const { WebSocketServer } = require("ws"); | |
| const { v4: uuidv4 } = require("uuid"); | |
| const cors = require("cors"); | |
| const http = require("http"); | |
| const path = require("path"); | |
| const pLimit = require("p-limit"); | |
| const pdfParse = require("pdf-parse"); | |
| const fetch = require("node-fetch"); | |
| const { launchBrowser, newStealthPage, pageToMarkdown, getPageMeta, smartWait } = require("./scraper"); | |
| const { extractWithAI, extractWithSelectors } = require("./ai"); | |
| const { crawlSite, mapSite } = require("./crawler"); | |
| const { domainThrottle, withRetry, sendWebhook } = require("./queue"); | |
| const proxyManager = require("./proxy"); | |
| const captcha = require("./captcha"); | |
| const { setupMCP } = require("./mcp"); | |
| const app = express(); | |
| const server = http.createServer(app); | |
| const wss = new WebSocketServer({ server }); | |
| app.use(cors()); | |
| app.use(express.json({ limit: "10mb" })); | |
| app.use(express.static(path.join(__dirname, "../public"))); | |
| // ββ API Key auth (optional) βββββββββββββββββββββββββββββββββββββββββββββββ | |
| const API_KEY = process.env.CRAWLR_API_KEY; | |
| function authMiddleware(req, res, next) { | |
| if (!API_KEY) return next(); | |
| const key = req.headers["x-api-key"] || req.query.apiKey; | |
| if (key !== API_KEY) return res.status(401).json({ ok: false, error: "Invalid API key" }); | |
| next(); | |
| } | |
| app.use(["/scrape", "/crawl", "/map", "/extract", "/batch", "/session", "/mcp/call"], authMiddleware); | |
| // ββ Session + Job stores ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const sessions = new Map(); | |
| const crawlJobs = new Map(); | |
| const agentEvents = new Map(); // sessionId β events[] | |
| // ββ MCP setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| setupMCP(app, sessions); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // /scrape | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/scrape", async (req, res) => { | |
| const { url, formats = ["markdown"], useProxy = false, waitFor = 0, waitForSelector, networkIdle = false, actions = [], cookies, headers: reqHeaders } = req.body; | |
| if (!url) return res.status(400).json({ ok: false, error: "url দরΰ¦ΰ¦Ύΰ¦°" }); | |
| let browser, proxy = null; | |
| try { | |
| if (useProxy) { await proxyManager.fetchProxies(); proxy = proxyManager.getNextProxy(); } | |
| browser = await launchBrowser(proxy); | |
| const page = await newStealthPage(browser); | |
| if (cookies?.length) await page.setCookie(...cookies); | |
| if (reqHeaders) await page.setExtraHTTPHeaders(reqHeaders); | |
| await domainThrottle(url); | |
| await withRetry(() => page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }), { retries: 2 }); | |
| await smartWait(page, { waitFor, waitForSelector, networkIdle }); | |
| // CAPTCHA check | |
| const hasCaptcha = await page.evaluate(() => !!document.querySelector(".g-recaptcha,[data-sitekey],.h-captcha")); | |
| if (hasCaptcha) await captcha.solveRecaptchaV2(page).catch(() => {}); | |
| // Actions | |
| for (const a of actions) { | |
| try { | |
| if (a.type === "click") { await page.click(a.selector); await page.waitForNavigation({ timeout: 5000 }).catch(() => {}); } | |
| if (a.type === "type") { await page.type(a.selector, a.text); } | |
| if (a.type === "wait") await new Promise((r) => setTimeout(r, Math.min(a.ms || 1000, 10000))); | |
| if (a.type === "scroll") await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); | |
| if (a.type === "navigate") await page.goto(a.url, { waitUntil: "domcontentloaded" }); | |
| } catch (_) {} | |
| } | |
| const meta = await getPageMeta(page); | |
| const result = { ok: true, url: meta.url, title: meta.title, description: meta.description }; | |
| if (formats.includes("markdown")) result.markdown = await pageToMarkdown(page); | |
| if (formats.includes("html")) result.html = await page.content(); | |
| if (formats.includes("links")) result.links = meta.links; | |
| if (formats.includes("meta")) result.meta = meta; | |
| if (formats.includes("screenshot")) { | |
| const buf = await page.screenshot({ type: "jpeg", quality: 70 }); | |
| result.screenshot = buf.toString("base64"); | |
| } | |
| res.json(result); | |
| } catch (err) { | |
| if (proxy) proxyManager.removeCurrentProxy(); | |
| res.status(500).json({ ok: false, error: err.message }); | |
| } finally { | |
| if (browser) await browser.close().catch(() => {}); | |
| } | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // /crawl | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/crawl", async (req, res) => { | |
| const { url, maxPages = 20, maxDepth = 3, includeMarkdown = true, allowedPaths = [], excludedPaths = [], webhookUrl, useProxy = false } = req.body; | |
| if (!url) return res.status(400).json({ ok: false, error: "url দরΰ¦ΰ¦Ύΰ¦°" }); | |
| const jobId = uuidv4(); | |
| const job = { id: jobId, status: "running", startUrl: url, progress: 0, total: maxPages, results: [], errors: [] }; | |
| crawlJobs.set(jobId, job); | |
| res.json({ ok: true, jobId, statusUrl: `/crawl/${jobId}` }); | |
| // WebSocket ΰ¦ live events ΰ¦ͺাঠাবৠ| |
| const broadcast = (data) => { | |
| const msg = JSON.stringify({ jobId, ...data }); | |
| wss.clients.forEach((c) => { if (c.readyState === 1) c.send(msg); }); | |
| }; | |
| (async () => { | |
| let browser; | |
| try { | |
| let proxy = null; | |
| if (useProxy) { await proxyManager.fetchProxies(); proxy = proxyManager.getNextProxy(); } | |
| browser = await launchBrowser(proxy); | |
| await crawlSite(browser, url, { | |
| maxPages, maxDepth, includeMarkdown, allowedPaths, excludedPaths, webhookUrl, | |
| onEvent: (event) => { | |
| broadcast(event); | |
| if (event.type === "done") { job.progress = event.done; job.results.push({ url: event.url, title: event.title }); } | |
| if (event.type === "error") job.errors.push({ url: event.url, error: event.error }); | |
| }, | |
| }); | |
| job.status = "completed"; | |
| broadcast({ type: "job_complete", jobId, pagesFound: job.progress }); | |
| if (webhookUrl) await sendWebhook(webhookUrl, { event: "crawl.complete", jobId, pagesFound: job.progress }); | |
| } catch (err) { | |
| job.status = "failed"; job.error = err.message; | |
| broadcast({ type: "job_failed", jobId, error: err.message }); | |
| } finally { | |
| if (browser) await browser.close().catch(() => {}); | |
| } | |
| })(); | |
| }); | |
| app.get("/crawl/:jobId", (req, res) => { | |
| const job = crawlJobs.get(req.params.jobId); | |
| if (!job) return res.status(404).json({ ok: false, error: "Job নΰ§ΰ¦" }); | |
| res.json({ ok: true, ...job }); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // /map | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/map", async (req, res) => { | |
| const { url, maxLinks = 200 } = req.body; | |
| if (!url) return res.status(400).json({ ok: false, error: "url দরΰ¦ΰ¦Ύΰ¦°" }); | |
| let browser; | |
| try { | |
| browser = await launchBrowser(); | |
| const page = await newStealthPage(browser); | |
| const result = await mapSite(page, url, { maxLinks }); | |
| res.json({ ok: true, ...result }); | |
| } catch (err) { | |
| res.status(500).json({ ok: false, error: err.message }); | |
| } finally { | |
| if (browser) await browser.close().catch(() => {}); | |
| } | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // /extract β AI structured extraction | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/extract", async (req, res) => { | |
| const { url, schema, selectors, prompt, useProxy = false } = req.body; | |
| if (!url) return res.status(400).json({ ok: false, error: "url দরΰ¦ΰ¦Ύΰ¦°" }); | |
| if (!schema && !selectors) return res.status(400).json({ ok: false, error: "schema বা selectors দিন" }); | |
| let browser; | |
| try { | |
| let proxy = null; | |
| if (useProxy) { await proxyManager.fetchProxies(); proxy = proxyManager.getNextProxy(); } | |
| browser = await launchBrowser(proxy); | |
| const page = await newStealthPage(browser); | |
| await domainThrottle(url); | |
| await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); | |
| const meta = await getPageMeta(page); | |
| let extracted; | |
| if (schema) { | |
| const md = await pageToMarkdown(page); | |
| extracted = await extractWithAI(md, schema, { | |
| apiKey: req.headers["x-api-key"], | |
| apiUrl: process.env.AI_API_URL, | |
| model: process.env.AI_MODEL, | |
| prompt, | |
| }); | |
| } else { | |
| extracted = await extractWithSelectors(page, selectors); | |
| } | |
| res.json({ ok: true, url, title: meta.title, data: extracted }); | |
| } catch (err) { | |
| res.status(500).json({ ok: false, error: err.message }); | |
| } finally { | |
| if (browser) await browser.close().catch(() => {}); | |
| } | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // /batch/scrape | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/batch/scrape", async (req, res) => { | |
| const { urls, formats = ["markdown"], concurrency = 3 } = req.body; | |
| if (!urls?.length) return res.status(400).json({ ok: false, error: "urls array দিন" }); | |
| if (urls.length > 50) return res.status(400).json({ ok: false, error: "max 50 URLs" }); | |
| const limit = pLimit(Math.min(concurrency, 5)); | |
| const results = await Promise.all(urls.map((url) => | |
| limit(async () => { | |
| let browser; | |
| try { | |
| browser = await launchBrowser(); | |
| const page = await newStealthPage(browser); | |
| await domainThrottle(url); | |
| await page.goto(url, { waitUntil: "domcontentloaded", timeout: 25000 }); | |
| const meta = await getPageMeta(page); | |
| const r = { url, ok: true, title: meta.title }; | |
| if (formats.includes("markdown")) r.markdown = await pageToMarkdown(page); | |
| if (formats.includes("links")) r.links = meta.links; | |
| return r; | |
| } catch (err) { | |
| return { url, ok: false, error: err.message }; | |
| } finally { | |
| if (browser) await browser.close().catch(() => {}); | |
| } | |
| }) | |
| )); | |
| res.json({ ok: true, total: urls.length, results }); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/pdf", async (req, res) => { | |
| const { url } = req.body; | |
| if (!url) return res.status(400).json({ ok: false, error: "url দিন" }); | |
| try { | |
| const r = await fetch(url); | |
| if (!r.ok) throw new Error(`PDF fetch failed: ${r.status}`); | |
| const buf = await r.buffer(); | |
| const data = await pdfParse(buf); | |
| res.json({ ok: true, text: data.text, pages: data.numpages, info: data.info }); | |
| } catch (err) { | |
| res.status(500).json({ ok: false, error: err.message }); | |
| } | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Session routes | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post("/session/create", async (req, res) => { | |
| try { | |
| const browser = await launchBrowser(); | |
| const page = await newStealthPage(browser); | |
| if (req.body.cookies?.length) await page.setCookie(...req.body.cookies); | |
| if (req.body.headers) await page.setExtraHTTPHeaders(req.body.headers); | |
| const sessionId = uuidv4(); | |
| sessions.set(sessionId, { browser, page, events: [], createdAt: Date.now() }); | |
| res.json({ ok: true, sessionId, wsUrl: `ws://localhost:${PORT}?session=${sessionId}` }); | |
| } catch (err) { | |
| res.status(500).json({ ok: false, error: err.message }); | |
| } | |
| }); | |
| app.post("/session/:id/navigate", async (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (!s) return res.status(404).json({ ok: false, error: "Session নΰ§ΰ¦" }); | |
| try { | |
| await domainThrottle(req.body.url); | |
| await withRetry(() => s.page.goto(req.body.url, { waitUntil: "domcontentloaded", timeout: 30000 })); | |
| await smartWait(s.page, req.body); | |
| const [md, meta] = await Promise.all([pageToMarkdown(s.page), getPageMeta(s.page)]); | |
| s.events.push({ type: "navigate", url: req.body.url, ts: Date.now() }); | |
| broadcastSession(req.params.id, { type: "navigate", url: meta.url, title: meta.title }); | |
| res.json({ ok: true, ...meta, markdown: md }); | |
| } catch (err) { res.status(500).json({ ok: false, error: err.message }); } | |
| }); | |
| app.post("/session/:id/click", async (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (!s) return res.status(404).json({ ok: false, error: "Session নΰ§ΰ¦" }); | |
| try { | |
| await s.page.click(req.body.selector); | |
| await s.page.waitForNavigation({ timeout: 5000 }).catch(() => {}); | |
| const [md, meta] = await Promise.all([pageToMarkdown(s.page), getPageMeta(s.page)]); | |
| s.events.push({ type: "click", selector: req.body.selector, ts: Date.now() }); | |
| broadcastSession(req.params.id, { type: "click", selector: req.body.selector }); | |
| res.json({ ok: true, ...meta, markdown: md }); | |
| } catch (err) { res.status(500).json({ ok: false, error: err.message }); } | |
| }); | |
| app.post("/session/:id/type", async (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (!s) return res.status(404).json({ ok: false, error: "Session নΰ§ΰ¦" }); | |
| try { | |
| const { selector, text, pressEnter = false } = req.body; | |
| await s.page.click(selector); | |
| await s.page.evaluate((sel) => { const el = document.querySelector(sel); if (el) el.value = ""; }, selector); | |
| await s.page.type(selector, text, { delay: 40 }); | |
| if (pressEnter) { await s.page.keyboard.press("Enter"); await s.page.waitForNavigation({ timeout: 5000 }).catch(() => {}); } | |
| s.events.push({ type: "type", selector, text, ts: Date.now() }); | |
| broadcastSession(req.params.id, { type: "type", selector, text }); | |
| const [md, meta] = await Promise.all([pageToMarkdown(s.page), getPageMeta(s.page)]); | |
| res.json({ ok: true, ...meta, markdown: md }); | |
| } catch (err) { res.status(500).json({ ok: false, error: err.message }); } | |
| }); | |
| app.post("/session/:id/screenshot", async (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (!s) return res.status(404).json({ ok: false, error: "Session নΰ§ΰ¦" }); | |
| try { | |
| const buf = await s.page.screenshot({ type: "jpeg", quality: 70, fullPage: req.body.fullPage || false }); | |
| res.json({ ok: true, image: buf.toString("base64"), mimeType: "image/jpeg" }); | |
| } catch (err) { res.status(500).json({ ok: false, error: err.message }); } | |
| }); | |
| app.post("/session/:id/scroll", async (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (!s) return res.status(404).json({ ok: false, error: "Session নΰ§ΰ¦" }); | |
| const { direction = "down", amount = 500 } = req.body; | |
| await s.page.evaluate((d, a) => window.scrollBy(0, d === "down" ? a : -a), direction, amount); | |
| s.events.push({ type: "scroll", direction, ts: Date.now() }); | |
| res.json({ ok: true }); | |
| }); | |
| app.get("/session/:id/events", (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (!s) return res.status(404).json({ ok: false, error: "Session নΰ§ΰ¦" }); | |
| res.json({ ok: true, events: s.events }); | |
| }); | |
| app.post("/session/:id/close", async (req, res) => { | |
| const s = sessions.get(req.params.id); | |
| if (s) { await s.browser.close().catch(() => {}); sessions.delete(req.params.id); } | |
| res.json({ ok: true }); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Proxy + CAPTCHA status | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.get("/proxy/status", (req, res) => res.json({ ok: true, ...proxyManager.getStats() })); | |
| app.post("/proxy/refresh", async (req, res) => { await proxyManager.fetchProxies(); res.json({ ok: true, ...proxyManager.getStats() }); }); | |
| app.get("/captcha/status", (req, res) => res.json({ ok: true, available: captcha.isAvailable(), service: process.env.CAPTCHA_SERVICE || "2captcha" })); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Status | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.get("/status", (req, res) => { | |
| res.json({ | |
| ok: true, name: "Crawlr", version: "3.0.0", | |
| sessions: sessions.size, crawlJobs: crawlJobs.size, | |
| captchaAvailable: captcha.isAvailable(), | |
| aiProvider: process.env.GEMINI_API_KEY ? "gemini" : process.env.AI_API_KEY ? "custom" : "none", | |
| proxy: proxyManager.getStats(), | |
| mcp: { tools: 22, url: "/mcp" }, | |
| }); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // WebSocket β Live Agent View + session streaming | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function broadcastSession(sessionId, data) { | |
| const msg = JSON.stringify({ sessionId, ...data }); | |
| wss.clients.forEach((c) => { if (c.readyState === 1) c.send(msg); }); | |
| } | |
| wss.on("connection", (ws, req) => { | |
| const params = new URL(req.url, "http://localhost").searchParams; | |
| const sessionId = params.get("session"); | |
| const s = sessionId ? sessions.get(sessionId) : null; | |
| ws.send(JSON.stringify({ type: "connected", sessionId: sessionId || null })); | |
| if (s) { | |
| // Live screenshot stream | |
| let streaming = true; | |
| const sendFrame = async () => { | |
| if (!streaming || ws.readyState !== 1) return; | |
| try { | |
| const buf = await s.page.screenshot({ type: "jpeg", quality: 45 }); | |
| ws.send(JSON.stringify({ type: "frame", data: buf.toString("base64") })); | |
| } catch (_) {} | |
| if (streaming) setTimeout(sendFrame, 600); | |
| }; | |
| sendFrame(); | |
| ws.on("close", () => { streaming = false; }); | |
| } | |
| ws.on("message", async (raw) => { | |
| try { | |
| const msg = JSON.parse(raw); | |
| if (s) { | |
| if (msg.type === "navigate") await s.page.goto(msg.url, { waitUntil: "domcontentloaded" }).catch(() => {}); | |
| if (msg.type === "click") await s.page.click(msg.selector).catch(() => {}); | |
| if (msg.type === "type") await s.page.type(msg.selector, msg.text).catch(() => {}); | |
| } | |
| } catch (_) {} | |
| }); | |
| }); | |
| // ββ Auto-cleanup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| setInterval(() => { | |
| const now = Date.now(); | |
| for (const [id, s] of sessions) { | |
| if (now - s.createdAt > 30 * 60 * 1000) { | |
| s.browser.close().catch(() => {}); | |
| sessions.delete(id); | |
| } | |
| } | |
| }, 5 * 60 * 1000); | |
| const PORT = process.env.PORT || 7860; | |
| server.listen(PORT, () => { | |
| console.log(`\nπ·οΈ Crawlr v3 β http://localhost:${PORT}`); | |
| console.log(`π€ MCP β http://localhost:${PORT}/mcp`); | |
| console.log(`π Auth: ${API_KEY ? "enabled" : "disabled"}`); | |
| console.log(`π§ AI: ${process.env.GEMINI_API_KEY ? "Gemini β " : process.env.AI_API_KEY ? "Custom β " : "none β"}\n`); | |
| }); | |