{"slug": "this-is-an-unofficial-scraper-for-https-duck-ai-duck-ai-has-no-public-api-this-a", "title": "This is an unofficial scraper for https://duck.ai/ // Duck.ai has no public API. This tool interacts with the // private chat endpoint by driving a real browser (puppeteer-real-browser).", "summary": "A developer operating under the name Omegatech released duckai_cli.js, an unofficial Node.js command-line scraper for DuckDuckGo's Duck.ai chat service, which has no public API. The tool uses puppeteer-real-browser to drive a real browser session against the private POST /duckchat/v1/chat SSE endpoint, automatically clearing the site's bot challenge, and supports multi-turn text chat, model selection, reasoning/source/tool-call extraction, and base64 image-generation capture saved to disk. The author warns it is for educational and personal use only and that DuckDuckGo may change its frontend or challenge at any time.", "body_md": "|  | // duckai_cli.js | \n|  | // Author : Omegatech | \n|  | // WhatsApp : https://whatsapp.com/channel/0029Vb785rSBlHpWSitPY61i | \n|  | // Name : Duck.ai Scraper (CLI) | \n|  | // Date : 15 October 2026 | \n|  | // | \n|  | // ────────────────────────────────────────────────────────────── | \n|  | // DISCLAIMER | \n|  | // ────────────────────────────────────────────────────────────── | \n|  | // This is an unofficial scraper for https://duck.ai/ | \n|  | // Duck.ai has no public API. This tool interacts with the | \n|  | // private chat endpoint by driving a real browser (puppeteer-real-browser). | \n|  | // | \n|  | // • Use at your own risk. | \n|  | // • Do not abuse rate limits or run high-volume automation. | \n|  | // • DuckDuckGo may change the frontend / challenge at any time. | \n|  | // • This project is for educational / personal use only. | \n|  | // • The author is not responsible for any account blocks, IP bans, | \n|  | // or legal consequences arising from misuse. | \n|  | // ────────────────────────────────────────────────────────────── | \n|  | // | \n|  | // SCRAPE INFO | \n|  | // ────────────────────────────────────────────────────────────── | \n|  | // Target : https://duck.ai/ | \n|  | // Method : Browser automation (puppeteer-real-browser) | \n|  | // Endpoint : POST /duckchat/v1/chat (SSE stream) | \n|  | // Challenge : Solved automatically by the real browser session | \n|  | // Features : | \n|  | // - Text chat (single or multi-turn) | \n|  | // - Model selection | \n|  | // - Reasoning / sources / tool-calls extraction | \n|  | // - Image generation capture (base64 → saved files) | \n|  | // - Debug mode | \n|  | // Dependencies: | \n|  | // npm i puppeteer-real-browser | \n|  | // ────────────────────────────────────────────────────────────── | \n|  | const { connect } = require(\"puppeteer-real-browser\"); | \n|  | const fs = require(\"fs\"); | \n|  | const path = require(\"path\"); | \n|  | // ── Config ── | \n|  | const DUCK_URL = \"https://duck.ai/\"; | \n|  | const INPUT_SELECTOR = | \n|  | 'textarea, div[contenteditable=\"true\"][role=\"textbox\"], div[contenteditable=\"true\"]'; | \n|  | const IMAGE_DIR = path.join(process.cwd(), \"duckai-images\"); | \n|  | const DEFAULT_TIMEOUT = 240000; | \n|  | // ── Fetch hook injected into the page ── | \n|  | const HOOK = () => { | \n|  | window.__duckaiCaptures = []; | \n|  | const origFetch = window.fetch.bind(window); | \n|  | window.fetch = async function (...args) { | \n|  | let url = \"\"; | \n|  | let reqBody = null; | \n|  | try { | \n|  | if (typeof args[0] === \"string\") url = args[0]; | \n|  | else if (args[0] && args[0].url) url = args[0].url; | \n|  | else if (args[0] && args[0].toString) url = args[0].toString(); | \n|  | if (args[1] && args[1].body) { | \n|  | reqBody = | \n|  | typeof args[1].body === \"string\" ? args[1].body : \"[non-string]\"; | \n|  | } | \n|  | } catch (_) {} | \n|  | const response = await origFetch(...args); | \n|  | if (url && url.indexOf(\"/duckchat/v1/chat\") !== -1) { | \n|  | const cap = { | \n|  | url, | \n|  | reqBody, | \n|  | ts: Date.now(), | \n|  | body: \"\", | \n|  | done: false, | \n|  | error: null, | \n|  | }; | \n|  | window.__duckaiCaptures.push(cap); | \n|  | try { | \n|  | const clone = response.clone(); | \n|  | const reader = clone.body.getReader(); | \n|  | const decoder = new TextDecoder(); | \n|  | (async () => { | \n|  | try { | \n|  | while (true) { | \n|  | const { done, value } = await reader.read(); | \n|  | if (done) break; | \n|  | cap.body += decoder.decode(value, { stream: true }); | \n|  | } | \n|  | cap.done = true; | \n|  | } catch (err) { | \n|  | cap.error = err && err.message ? err.message : String(err); | \n|  | cap.done = true; | \n|  | } | \n|  | })(); | \n|  | } catch (err) { | \n|  | cap.error = err && err.message ? err.message : String(err); | \n|  | cap.done = true; | \n|  | } | \n|  | } | \n|  | return response; | \n|  | }; | \n|  | }; | \n|  | // ── Browser helpers ── | \n|  | async function openDuck(options = {}) { | \n|  | const connection = await connect({ | \n|  | headless: options.headless === true, | \n|  | turnstile: true, | \n|  | fingerprint: true, | \n|  | args: [ | \n|  | \"--no-sandbox\", | \n|  | \"--disable-setuid-sandbox\", | \n|  | \"--disable-dev-shm-usage\", | \n|  | \"--disable-blink-features=AutomationControlled\", | \n|  | ], | \n|  | customConfig: {}, | \n|  | }); | \n|  | const { browser, page } = connection; | \n|  | await page.evaluateOnNewDocument(HOOK); | \n|  | await page.setViewport({ width: 1366, height: 900 }); | \n|  | await page.setExtraHTTPHeaders({ | \n|  | \"Accept-Language\": \"id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7\", | \n|  | }); | \n|  | await page.goto(DUCK_URL, { waitUntil: \"domcontentloaded\", timeout: 90000 }); | \n|  | await page.waitForSelector(INPUT_SELECTOR, { timeout: 60000 }); | \n|  | await new Promise((r) => setTimeout(r, 1500)); | \n|  | return { browser, page }; | \n|  | } | \n|  | // ── SSE parsing ── | \n|  | function parseSSE(body) { | \n|  | const events = []; | \n|  | if (!body) return events; | \n|  | const lines = body.split(\"\\n\"); | \n|  | for (const raw of lines) { | \n|  | const line = raw.trim(); | \n|  | if (!line.startsWith(\"data:\")) continue; | \n|  | const payload = line.slice(5).trim(); | \n|  | if (!payload \\|\\| payload === \"[DONE]\" \\|\\| payload === \"[PING]\") continue; | \n|  | try { | \n|  | events.push(JSON.parse(payload)); | \n|  | } catch (_) { | \n|  | events.push({ _raw: payload }); | \n|  | } | \n|  | } | \n|  | return events; | \n|  | } | \n|  | function assembleResponse(events) { | \n|  | const textParts = []; | \n|  | const reasoningParts = []; | \n|  | const sources = []; | \n|  | const toolCalls = []; | \n|  | const toolResults = []; | \n|  | const images = []; | \n|  | let model = null; | \n|  | let responseId = null; | \n|  | let summaryText = null; | \n|  | for (const e of events) { | \n|  | if (!model && e.model) model = e.model; | \n|  | if (!responseId && e.id) responseId = e.id; | \n|  | if (e.role === \"assistant\" && typeof e.message === \"string\") { | \n|  | textParts.push(e.message); | \n|  | } | \n|  | if (e.role === \"reasoning\" && e.state === \"text-delta\" && typeof e.text === \"string\") { | \n|  | reasoningParts.push(e.text); | \n|  | } | \n|  | if (e.role === \"reasoning\" && e.state === \"done\" && Array.isArray(e.summaryText)) { | \n|  | summaryText = e.summaryText.join(\"\\n\"); | \n|  | } | \n|  | if (e.role === \"source\" && e.source) { | \n|  | sources.push(e.source); | \n|  | } | \n|  | if (e.role === \"tool-invocation\" && e.state === \"call\") { | \n|  | let args = e.toolArguments; | \n|  | try { | \n|  | args = typeof args === \"string\" ? JSON.parse(args) : args; | \n|  | } catch (_) {} | \n|  | toolCalls.push({ name: e.toolName, args, callId: e.toolCallId }); | \n|  | } | \n|  | if (e.role === \"tool-invocation\" && e.state === \"result\") { | \n|  | toolResults.push({ | \n|  | callId: e.toolCallId, | \n|  | result: e.result, | \n|  | data: e.data, | \n|  | }); | \n|  | } | \n|  | if ( | \n|  | e.role === \"ui-component\" && | \n|  | e.name === \"generate-image\" && | \n|  | e.state === \"data\" && | \n|  | e.data && | \n|  | typeof e.data.b64Image === \"string\" && | \n|  | e.data.status === \"success\" | \n|  | ) { | \n|  | images.push({ | \n|  | id: e.id, | \n|  | toolCallId: e.toolCallId, | \n|  | format: e.data.format \\|\\| \"jpeg\", | \n|  | width: e.data.width \\|\\| null, | \n|  | height: e.data.height \\|\\| null, | \n|  | generationDurationMs: e.data.generationDurationMs \\|\\| null, | \n|  | moderationToken: e.data.moderationToken \\|\\| null, | \n|  | b64: e.data.b64Image, | \n|  | }); | \n|  | } | \n|  | } | \n|  | return { | \n|  | model, | \n|  | responseId, | \n|  | text: textParts.join(\"\"), | \n|  | reasoning: reasoningParts.join(\"\"), | \n|  | reasoningSummary: summaryText, | \n|  | sources, | \n|  | toolCalls, | \n|  | toolResults, | \n|  | images, | \n|  | }; | \n|  | } | \n|  | // ── Image saver ── | \n|  | function saveImages(result, dir) { | \n|  | if (!result.images \\|\\| !result.images.length) return []; | \n|  | const targetDir = dir \\|\\| IMAGE_DIR; | \n|  | if (!fs.existsSync(targetDir)) { | \n|  | fs.mkdirSync(targetDir, { recursive: true }); | \n|  | } | \n|  | const saved = []; | \n|  | const stamp = Date.now(); | \n|  | for (let i = 0; i < result.images.length; i++) { | \n|  | const img = result.images[i]; | \n|  | const ext = img.format === \"png\" ? \"png\" : \"jpg\"; | \n|  | const name = `duckai-${stamp}-${i}.${ext}`; | \n|  | const full = path.join(targetDir, name); | \n|  | fs.writeFileSync(full, Buffer.from(img.b64, \"base64\")); | \n|  | const toolMatch = result.toolCalls.find( | \n|  | (c) => c.name === \"GenerateImage\" && c.callId === img.toolCallId | \n|  | ); | \n|  | saved.push({ | \n|  | path: full, | \n|  | format: img.format, | \n|  | width: img.width, | \n|  | height: img.height, | \n|  | bytes: Buffer.byteLength(img.b64, \"base64\"), | \n|  | prompt: toolMatch && toolMatch.args ? toolMatch.args.imageGenPrompt : null, | \n|  | }); | \n|  | } | \n|  | return saved; | \n|  | } | \n|  | // ── Send single message ── | \n|  | async function sendChat(page, message, options = {}) { | \n|  | const prevCount = await page.evaluate(() => window.__duckaiCaptures.length); | \n|  | const input = await page.waitForSelector(INPUT_SELECTOR, { timeout: 30000 }); | \n|  | await input.click(); | \n|  | await new Promise((r) => setTimeout(r, 300)); | \n|  | // Clear existing content | \n|  | await page.evaluate((sel) => { | \n|  | const el = document.querySelector(sel); | \n|  | if (!el) return; | \n|  | el.focus(); | \n|  | if (el.tagName === \"TEXTAREA\") { | \n|  | el.value = \"\"; | \n|  | } else if (el.isContentEditable) { | \n|  | el.innerHTML = \"\"; | \n|  | } | \n|  | }, INPUT_SELECTOR); | \n|  | await new Promise((r) => setTimeout(r, 200)); | \n|  | // Type via CDP (more reliable) | \n|  | const client = await page.target().createCDPSession(); | \n|  | await client.send(\"Input.insertText\", { text: message }); | \n|  | await client.detach().catch(() => {}); | \n|  | await new Promise((r) => setTimeout(r, 700)); | \n|  | const typed = await page.evaluate((sel) => { | \n|  | const el = document.querySelector(sel); | \n|  | if (!el) return \"\"; | \n|  | return el.tagName === \"TEXTAREA\" | \n|  | ? el.value \\|\\| \"\" | \n|  | : el.textContent \\|\\| \"\"; | \n|  | }, INPUT_SELECTOR); | \n|  | if (options.debug) { | \n|  | console.log(\"[debug] typed:\", JSON.stringify(typed)); | \n|  | console.log(\"[debug] want :\", JSON.stringify(message)); | \n|  | } | \n|  | if (typed.trim().length === 0) { | \n|  | throw new Error(\"composer empty after typing\"); | \n|  | } | \n|  | // Click send button or press Enter | \n|  | const clicked = await page.evaluate(() => { | \n|  | const buttons = Array.from(document.querySelectorAll(\"button\")); | \n|  | const match = buttons.find((b) => { | \n|  | const label = ( | \n|  | b.getAttribute(\"aria-label\") \\|\\| | \n|  | b.getAttribute(\"title\") \\|\\| | \n|  | b.textContent \\|\\| | \n|  | \"\" | \n|  | ).toLowerCase(); | \n|  | const disabled = | \n|  | b.disabled \\|\\| b.getAttribute(\"aria-disabled\") === \"true\"; | \n|  | if (disabled) return false; | \n|  | return ( | \n|  | label.includes(\"send\") \\|\\| | \n|  | label.includes(\"kirim\") \\|\\| | \n|  | label.includes(\"submit\") \\|\\| | \n|  | label.includes(\"ask\") | \n|  | ); | \n|  | }); | \n|  | if (match) { | \n|  | match.click(); | \n|  | return true; | \n|  | } | \n|  | const submit = buttons.find( | \n|  | (b) => | \n|  | b.type === \"submit\" && | \n|  | !b.disabled && | \n|  | b.getAttribute(\"aria-disabled\") !== \"true\" | \n|  | ); | \n|  | if (submit) { | \n|  | submit.click(); | \n|  | return true; | \n|  | } | \n|  | return false; | \n|  | }); | \n|  | if (!clicked) { | \n|  | await page.keyboard.press(\"Enter\"); | \n|  | } | \n|  | // Wait for capture | \n|  | const deadline = Date.now() + (options.timeout \\|\\| DEFAULT_TIMEOUT); | \n|  | let capture = null; | \n|  | while (Date.now() < deadline) { | \n|  | const state = await page.evaluate((prev) => { | \n|  | const caps = window.__duckaiCaptures; | \n|  | if (caps.length <= prev) return null; | \n|  | const c = caps[caps.length - 1]; | \n|  | return { | \n|  | done: c.done, | \n|  | error: c.error, | \n|  | body: c.body, | \n|  | reqBody: c.reqBody, | \n|  | }; | \n|  | }, prevCount); | \n|  | if (state && state.done) { | \n|  | capture = state; | \n|  | break; | \n|  | } | \n|  | await new Promise((r) => setTimeout(r, 400)); | \n|  | } | \n|  | if (!capture) throw new Error(\"capture timeout\"); | \n|  | if (options.debug && capture.reqBody) { | \n|  | const m = capture.reqBody.match(/\"text\":\"((?:[^\"\\\\]\\|\\\\.){0,300})/); | \n|  | if (m) console.log(\"[debug] request text:\", JSON.stringify(m[1])); | \n|  | } | \n|  | const events = parseSSE(capture.body); | \n|  | const result = { events, ...assembleResponse(events) }; | \n|  | if (result.images.length && options.saveImages !== false) { | \n|  | result.savedImages = saveImages(result, options.imageDir); | \n|  | } | \n|  | return result; | \n|  | } | \n|  | // ── Public API ── | \n|  | async function ask(message, options = {}) { | \n|  | const { browser, page } = await openDuck(options); | \n|  | try { | \n|  | return await sendChat(page, message, options); | \n|  | } finally { | \n|  | if (!options.keepOpen) { | \n|  | try { | \n|  | await browser.close(); | \n|  | } catch (_) {} | \n|  | } | \n|  | } | \n|  | } | \n|  | async function askMany(messages, options = {}) { | \n|  | const { browser, page } = await openDuck(options); | \n|  | const results = []; | \n|  | try { | \n|  | for (const msg of messages) { | \n|  | const r = await sendChat(page, msg, options); | \n|  | results.push({ message: msg, ...r }); | \n|  | await new Promise((res) => | \n|  | setTimeout(res, options.gapMs \\|\\| 1800) | \n|  | ); | \n|  | } | \n|  | return results; | \n|  | } finally { | \n|  | if (!options.keepOpen) { | \n|  | try { | \n|  | await browser.close(); | \n|  | } catch (_) {} | \n|  | } | \n|  | } | \n|  | } | \n|  | // ── CLI ── | \n|  | function printResult(r) { | \n|  | console.log(\"MODEL:\", r.model \\|\\| \"unknown\"); | \n|  | console.log(\"─\".repeat(50)); | \n|  | console.log(r.text \\|\\| \"(empty)\"); | \n|  | if (r.reasoning) { | \n|  | console.log(\"\\n── reasoning ──\"); | \n|  | console.log(r.reasoning); | \n|  | } | \n|  | if (r.sources && r.sources.length) { | \n|  | console.log(\"\\n── sources ──\"); | \n|  | for (const s of r.sources) { | \n|  | console.log(s.url \\|\\| s.title \\|\\| JSON.stringify(s)); | \n|  | } | \n|  | } | \n|  | if (r.toolCalls && r.toolCalls.length) { | \n|  | console.log(\"\\n── tool calls ──\"); | \n|  | for (const t of r.toolCalls) { | \n|  | console.log(t.name, JSON.stringify(t.args)); | \n|  | } | \n|  | } | \n|  | if (r.savedImages && r.savedImages.length) { | \n|  | console.log(\"\\n── images ──\"); | \n|  | for (const img of r.savedImages) { | \n|  | console.log(\"saved:\", img.path); | \n|  | console.log( | \n|  | ` format=${img.format} size=${img.width}x${img.height} bytes=${img.bytes}` | \n|  | ); | \n|  | if (img.prompt) console.log(\" prompt:\", img.prompt); | \n|  | } | \n|  | } | \n|  | } | \n|  | async function main() { | \n|  | const args = process.argv.slice(2); | \n|  | const mode = args[0] \\|\\| \"single\"; | \n|  | const payload = args.slice(1).join(\" \") \\|\\| \"halo\"; | \n|  | const headless = args.includes(\"--headless\"); | \n|  | const debug = args.includes(\"--debug\"); | \n|  | console.log(\"Duck.ai Scraper (Omegatech)\"); | \n|  | console.log(\"─\".repeat(50)); | \n|  | try { | \n|  | if (mode === \"multi\") { | \n|  | const list = payload | \n|  | .split(\"\\|\") | \n|  | .map((s) => s.trim()) | \n|  | .filter(Boolean); | \n|  | const results = await askMany(list, { headless, debug }); | \n|  | for (const r of results) { | \n|  | console.log(\"\\n=== USER:\", r.message); | \n|  | printResult(r); | \n|  | } | \n|  | return; | \n|  | } | \n|  | // default: single | \n|  | const message = mode === \"single\" ? payload : args.join(\" \") \\|\\| \"halo\"; | \n|  | const r = await ask(message, { headless, debug }); | \n|  | printResult(r); | \n|  | } catch (err) { | \n|  | console.error(\"\\n[error]\", err && err.message ? err.message : err); | \n|  | process.exit(1); | \n|  | } | \n|  | } | \n|  | // Export for require() | \n|  | module.exports = { | \n|  | openDuck, | \n|  | sendChat, | \n|  | parseSSE, | \n|  | assembleResponse, | \n|  | saveImages, | \n|  | ask, | \n|  | askMany, | \n|  | }; | \n|  | // Run as CLI | \n|  | if (require.main === module) { | \n|  | main(); | \n|  | } |", "url": "https://wpnews.pro/news/this-is-an-unofficial-scraper-for-https-duck-ai-duck-ai-has-no-public-api-this-a", "canonical_source": "https://gist.github.com/Omegatech-01/78e4cd406c63684ef1c81ef30eb9d84d", "published_at": "2026-10-16 19:32:02+00:00", "updated_at": "2026-10-16 19:51:21.591181+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-crawlers", "large-language-models"], "entities": ["Duck.ai", "DuckDuckGo", "Omegatech", "puppeteer-real-browser", "Node.js"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/this-is-an-unofficial-scraper-for-https-duck-ai-duck-ai-has-no-public-api-this-a", "markdown": "https://wpnews.pro/news/this-is-an-unofficial-scraper-for-https-duck-ai-duck-ai-has-no-public-api-this-a.md", "text": "https://wpnews.pro/news/this-is-an-unofficial-scraper-for-https-duck-ai-duck-ai-has-no-public-api-this-a.txt", "jsonld": "https://wpnews.pro/news/this-is-an-unofficial-scraper-for-https-duck-ai-duck-ai-has-no-public-api-this-a.jsonld"}}