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