// ==UserScript== // @name Marketplace Deal Finder // @name:de Marketplace Deal Finder // @namespace https://github.com/marmoris-x/tampermonkey-scripts // @version 31.0.30 // @author marmoris // @description Multi-provider AI deal aggregator for Willhaben & Kleinanzeigen. Supports Gemini, OpenAI, DeepSeek, Claude, OpenRouter & Portkey. // @description:de Multi-Provider KI-Deal-Aggregator fΓΌr Willhaben und Kleinanzeigen. UnterstΓΌtzt Gemini, OpenAI, DeepSeek, Claude, OpenRouter und Portkey. // @license MIT // @icon https://i.imgur.com/oQmtRjQ.png // @homepageURL https://github.com/marmoris-x/tampermonkey-scripts // @supportURL https://github.com/marmoris-x/tampermonkey-scripts/issues // @downloadURL https://raw.githubusercontent.com/marmoris-x/tampermonkey-scripts/main/dist/Marketplace%20Deal%20Finder.user.js // @updateURL https://raw.githubusercontent.com/marmoris-x/tampermonkey-scripts/main/dist/Marketplace%20Deal%20Finder.user.js // @match https://www.willhaben.at/iad/kaufen-und-verkaufen/* // @match https://www.kleinanzeigen.de/s-* // @match https://www.kleinanzeigen.de/z-* // @require https://raw.githubusercontent.com/Tampermonkey/utils/3b32b826e84ccc99a0a3e3d8d6e5ce0fa9834f23/requires/gh_2215_make_GM_xhr_more_parallel_again.js#sha256=qo7Imc1jg2AryApKESDzER/AO92l55w1dH7+xsxrx2I= // @sandbox raw // @tag search // @connect willhaben.at // @connect kleinanzeigen.de // @connect generativelanguage.googleapis.com // @connect api.openai.com // @connect api.deepseek.com // @connect api.anthropic.com // @connect openrouter.ai // @connect api.portkey.ai // @grant GM.getValue // @grant GM.setValue // @grant GM_addElement // @grant GM_xmlhttpRequest // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; function createLogger(prefix, debugMode) { debugMode = debugMode || false; const tag = "[" + prefix + "]"; return { log: function() { const args = [tag]; for (let i = 0; i < arguments.length; i++) args.push(arguments[i]); console.log.apply(console, args); }, warn: function() { const args = [tag]; for (let i = 0; i < arguments.length; i++) args.push(arguments[i]); console.warn.apply(console, args); }, error: function() { const args = [tag]; for (let i = 0; i < arguments.length; i++) args.push(arguments[i]); console.error.apply(console, args); }, info: function() { const args = [tag]; for (let i = 0; i < arguments.length; i++) args.push(arguments[i]); console.info.apply(console, args); }, debug: function() { if (debugMode) { const args = [tag]; for (let i = 0; i < arguments.length; i++) args.push(arguments[i]); console.debug.apply(console, args); } } }; } const PROVIDER_TYPES = { GEMINI: "gemini", OPENAI: "openai", DEEPSEEK: "deepseek", CLAUDE: "claude", OPENROUTER: "openrouter", PORTKEY: "portkey" }; const INITIAL_BATCH_SIZE = 4; const SETTINGS_VERSION = 3; const MAX_CACHE_SIZE = 100; const REQUEST_TIMEOUT = 6e4; const RE_RANK_MAX_DEALS = 30; const PAUSE_POLL_INTERVAL = 500; const SAME_PAGE_INCREMENT = 0; const NEW_PAGE_INCREMENT = 1; const MAX_INIT_RETRIES = 5; const MIN_TITLE_LENGTH = 5; const GEMINI_API_TIMEOUT = 6e4; const RATE_LIMIT_MAX_RETRIES = 5; const RETRY_BASE_DELAY = 2e3; const MAX_RATE_LIMIT_DELAY = 3e5; const JITTER_FACTOR = 0.2; const MAX_OUTPUT_TOKENS = 32e3; const PROVIDER_MAX_TOKENS = { gemini: 8192, deepseek: 8192, openai: 16384, claude: 32e3, openrouter: 16384, portkey: 16384 }; function getMaxTokensForProvider(providerType) { return PROVIDER_MAX_TOKENS[providerType] || MAX_OUTPUT_TOKENS; } const DESCRIPTION_FETCH_DELAY = 1e3; const DESCRIPTION_MAX_RETRIES = 2; const DESCRIPTION_BACKOFF_FACTOR = 2; const PAGE_TRANSITION_DELAY = 1500; const MODEL_PRESETS = { gemini: [ { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite", icon: "πŸ’‘", desc: "Most economical, high thinking", options: { thinkingBudget: -1 } }, { id: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite", icon: "⚑", desc: "Latest flash, high thinking", options: { thinkingLevel: "high" } }, { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", icon: "πŸ”₯", desc: "Preview, high thinking", options: { thinkingLevel: "high" } }, { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", icon: "🧠", desc: "Max intelligence, preview" } ], openai: [ { id: "gpt-5.4-nano", label: "GPT-5.4 Nano", icon: "πŸ’‘", desc: "Cheapest, high thinking", options: { reasoning_effort: "high" } }, { id: "gpt-5.4-mini", label: "GPT-5.4 Mini", icon: "⚑", desc: "Fast & cheap" }, { id: "gpt-5.5", label: "GPT-5.5", icon: "🧠", desc: "Flagship intelligence" } ], deepseek: [ { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", icon: "⚑", desc: "Fast 284B, max thinking", options: { reasoning_effort: "max", skip_response_format: true } }, { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro", icon: "🧠", desc: "1.6T params, max thinking", options: { reasoning_effort: "max", skip_response_format: true } } ], claude: [ { id: "claude-opus-4-8", label: "Claude Opus 4.8", icon: "🧠", desc: "Latest flagship, adaptive thinking", options: { thinking: { type: "adaptive" } } }, { id: "claude-opus-4-7", label: "Claude Opus 4.7", icon: "🧠", desc: "Flagship, adaptive thinking", options: { thinking: { type: "adaptive" } } }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", icon: "🎯", desc: "Best balance, extended thinking", options: { thinking: { type: "enabled", budget_tokens: 8e3 } } }, { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", icon: "⚑", desc: "Fast & cheap" } ], openrouter: [ { id: "google/gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite", icon: "πŸ’‘", desc: "Cheap via OR" }, { id: "deepseek/deepseek-v4-flash", label: "DeepSeek V4 Flash", icon: "⚑", desc: "Cheap via OR" }, { id: "openai/gpt-5.4-nano", label: "GPT-5.4 Nano", icon: "πŸ’‘", desc: "Cheapest smart model via OR" } ], portkey: [ { id: "gpt-5.4-nano", label: "GPT-5.4 Nano", icon: "πŸ’‘", desc: "Via Portkey config" }, { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", icon: "⚑", desc: "Via Portkey config" }, { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", icon: "⚑", desc: "Via Portkey config", options: { skip_response_format: true } } ] }; const Logger$9 = createLogger("MDF-WH"); function findAds$1() { const adSelectors = [ 'a[data-testid^="search-result-entry-header-"]', 'article[data-testid^="search-result-entry-"]', '[data-testid*="search-result-entry"]' ]; for (let si = 0; si < adSelectors.length; si++) { const entries = document.querySelectorAll(adSelectors[si]); if (entries.length > 0) { Logger$9.log("Found " + entries.length + " ads (selector: " + adSelectors[si] + ")"); return { adEntries: entries }; } } const uniqueUrls = new Set(); const uniqueAds = []; const urlRegex = /\/iad\/kaufen-und-verkaufen\/.*\/\d+/; document.querySelectorAll('a[href*="/iad/kaufen-und-verkaufen/"]').forEach(function(link) { const url = link.href; if (urlRegex.test(url) && !uniqueUrls.has(url)) { uniqueUrls.add(url); const container = link.closest('article, div[class*="box"], [data-testid*="search-result"], .ad-item, .list-item'); uniqueAds.push(container || link); } }); if (uniqueAds.length > 0) { Logger$9.log("Found " + uniqueAds.length + " ads (fallback method)"); return { adEntries: uniqueAds }; } return null; } function extractBasicInfo$1(ad) { let title = "Title not available"; ["h3", "h2", '[data-testid*="title"]'].forEach(function(s) { const el = ad.querySelector(s); if (el) { const text = el.textContent.trim(); if (text.length > MIN_TITLE_LENGTH && !isPriceOnlyText$1(text)) title = text; } }); let price = "Price not available"; var priceSelectors = [ '[data-testid*="price"]', '[class*="Price"]', '[class*="price"]', '[data-testid*="Price"]', ".AdSummaryI__PriceText" ]; var priceEl = null; for (var psi = 0; psi < priceSelectors.length; psi++) { priceEl = ad.querySelector(priceSelectors[psi]); if (priceEl) { var pt = priceEl.textContent.trim(); if (pt && pt.length < 20 && (pt.indexOf("€") !== -1 || pt.indexOf("EUR") !== -1)) { price = pt; break; } } } if (!priceEl) { const spans = ad.querySelectorAll("span, div, p"); for (let pi = 0; pi < spans.length; pi++) { const text = spans[pi].textContent.trim(); if ((text.indexOf("€") !== -1 || text.indexOf("EUR") !== -1) && text.length < 20 && text.indexOf("...") === -1) { price = text; break; } } } const url = ad.href || (ad.querySelector('a[href*="/iad/"]') ? ad.querySelector('a[href*="/iad/"]').href : "URL not available"); return { title, price, url }; } function isPriceOnlyText$1(text) { return /^\s*[\d.,]+\s*€?\s*(VB)?\s*$/i.test(text); } function descSelectors$1() { return [ '[data-testid="ad-description-Beschreibung"]', '[data-testid*="description"]', ".ad-description", '[class*="description"]' ]; } function goToNextPage$1(currentPage) { let nextButton = document.querySelector('[data-testid="pagination-bottom-next-button"]'); if (!nextButton) { const targetPage = currentPage + 1; const paginationLinks = document.querySelectorAll('[data-testid*="pagination"] a, nav a'); for (let li = 0; li < paginationLinks.length; li++) { const btn = paginationLinks[li]; const text = (btn.textContent || "").trim(); const href = btn.getAttribute("href"); if (text && (text === String(targetPage) || text.toLowerCase().indexOf("weiter") !== -1 || text.toLowerCase().indexOf("next") !== -1 || text === "β€Ί" || text === ">")) { if (!btn.hasAttribute("disabled") && btn.getAttribute("aria-disabled") !== "true" && href) { nextButton = btn; break; } } } } if (nextButton) { const isDisabled = nextButton.hasAttribute("disabled"); const ariaDisabled = nextButton.getAttribute("aria-disabled") === "true"; const href = nextButton.getAttribute("href"); Logger$9.log("Next button disabled:", isDisabled, "| aria-disabled:", ariaDisabled, "| href:", href); if (!isDisabled && !ariaDisabled && !href) { try { var url = new URL(location.href); var nextPage = currentPage + 1; url.searchParams.set("page", String(nextPage)); var nextUrl = url.pathname + url.search; Logger$9.log("Constructed next page URL from params:", nextUrl); return nextUrl; } catch (e) { Logger$9.warn("Failed to construct next page URL:", e); return false; } } if (!isDisabled && !ariaDisabled && href) { try { if (new URL(href, location.href).href === location.href) { Logger$9.log("Next button points to same page - skipped"); return false; } } catch (e) { Logger$9.warn("Invalid URL in next button:", href, e); return false; } return href; } Logger$9.log("Next button not usable"); } var hasPaginationContainer = document.querySelector('[data-testid*="pagination"], nav, [class*="pagination"]'); if (!hasPaginationContainer) { Logger$9.warn("No pagination container found β€” markup may have changed"); } else { Logger$9.log("Pagination container found but no next link β€” likely last page"); } return false; } const whScraper = Object.freeze( Object.defineProperty({ __proto__: null, descSelectors: descSelectors$1, extractBasicInfo: extractBasicInfo$1, findAds: findAds$1, goToNextPage: goToNextPage$1 }, Symbol.toStringTag, { value: "Module" })); const Logger$8 = createLogger("MDF-KA"); function findAds() { const adSelectors = ["article[data-adid]", "li.ad-listitem", ".aditem"]; for (let si = 0; si < adSelectors.length; si++) { const entries = document.querySelectorAll(adSelectors[si]); if (entries.length > 0) { Logger$8.log("Found " + entries.length + " ads (selector: " + adSelectors[si] + ")"); return { adEntries: entries }; } } const uniqueUrls = new Set(); const uniqueAds = []; const urlRegex = /\/s-anzeige\/.*\/\d+/; document.querySelectorAll('a[href*="/s-anzeige/"]').forEach(function(link) { const url = link.href; if (urlRegex.test(url) && !uniqueUrls.has(url)) { uniqueUrls.add(url); const container = link.closest("article, li, .aditem, .ad-listitem, [data-adid]"); uniqueAds.push(container || link); } }); if (uniqueAds.length > 0) { Logger$8.log("Found " + uniqueAds.length + " ads (fallback method)"); return { adEntries: uniqueAds }; } return null; } function extractBasicInfo(ad) { let title = "Title not available"; ["h2", "h3", 'a[class*="ellipsis"]', '[class*="title"]'].forEach(function(s) { const el = ad.querySelector(s); if (el) { const text = el.textContent.trim(); if (text.length > MIN_TITLE_LENGTH && !isPriceOnlyText(text)) title = text; } }); let price = "Price not available"; var priceSelectors = [ ".aditem-main--middle--price-shipping--price", '[class*="aditem-main--price"]', ".aditem-main--price", "[data-price]", '[class*="aditem-price"]' ]; var priceEl = null; for (var psi = 0; psi < priceSelectors.length; psi++) { priceEl = ad.querySelector(priceSelectors[psi]); if (priceEl) { var pt = priceEl.textContent.trim(); if (pt && pt.length < 30 && (pt.indexOf("€") !== -1 || /^(\d[\d.,]*\s*€?\s*)?VB$/i.test(pt))) { price = pt; break; } } } if (!priceEl) { const spans = ad.querySelectorAll("span, div, p, strong"); for (let pi = 0; pi < spans.length; pi++) { const text = spans[pi].textContent.trim(); if ((text.indexOf("€") !== -1 || text.indexOf("EUR") !== -1 || /^(\d[\d.,]*\s*€?\s*)?VB$/i.test(text.trim())) && text.length < 30 && text.indexOf("...") === -1) { price = text; break; } } } let url = ad.getAttribute("data-href") || ad.href || (ad.querySelector('a[href*="/s-anzeige/"]') ? ad.querySelector('a[href*="/s-anzeige/"]').href : "URL not available"); if (url && url.indexOf("/") === 0) { url = "https://www.kleinanzeigen.de" + url; } return { title, price, url }; } function isPriceOnlyText(text) { return /^\s*[\d.,]+\s*€?\s*(VB)?\s*$/i.test(text); } function descSelectors() { return [ "#viewad-description-text", ".ad-description", 'div[class*="description"]', '[class*="description"]' ]; } function goToNextPage(currentPage) { let nextButton = document.querySelector('a[class*="pagination-next"]'); if (!nextButton) { const paginationLinks = document.querySelectorAll('[class*="pagination"] a, nav a, .pagination a'); for (let li = 0; li < paginationLinks.length; li++) { const linkEl = paginationLinks[li]; const text = (linkEl.textContent || "").trim().toLowerCase(); const href = linkEl.getAttribute("href"); if ((text === "weiter" || text === ">" || text === "β€Ί") && href && href.indexOf("seite:") !== -1) { nextButton = linkEl; break; } } } if (!nextButton) { const targetPage = currentPage + 1; const seiteLinks = document.querySelectorAll('a[href*="seite:"]'); for (let sl = 0; sl < seiteLinks.length; sl++) { const href = seiteLinks[sl].getAttribute("href"); if (href && href.indexOf("seite:" + targetPage) !== -1) { nextButton = seiteLinks[sl]; break; } } } if (nextButton) { const href = nextButton.getAttribute("href"); Logger$8.log("Next button href:", href); if (href) { try { if (new URL(href, location.href).href === location.href) { Logger$8.log("Next button points to same page - skipped"); return false; } } catch (e) { Logger$8.warn(" Invalid URL in next button:", href, e); return false; } return href; } Logger$8.log("Next button has no href"); } var hasPaginationContainer = document.querySelector('[class*="pagination"], nav, .pagination'); if (!hasPaginationContainer) { Logger$8.warn("No pagination container found β€” markup may have changed"); } else { Logger$8.log("Pagination container found but no next link β€” likely last page"); } return false; } const kaScraper = Object.freeze( Object.defineProperty({ __proto__: null, descSelectors, extractBasicInfo, findAds, goToNextPage }, Symbol.toStringTag, { value: "Module" })); function getScraper() { const isWH = window.location.hostname.includes("willhaben.at"); return { ...isWH ? whScraper : kaScraper, siteName: isWH ? "WILLHABEN" : "KLEINANZEIGEN", storagePrefix: isWH ? "wh" : "ka", buttonGradient: isWH ? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" : "linear-gradient(135deg, #86a542 0%, #2d2d2d 100%)" }; } const Logger$7 = createLogger("MDF Storage"); async function loadSetting(key, defaultValue) { try { const raw = await GM.getValue(key); if (raw === void 0 || raw === null) return defaultValue; return raw; } catch (e) { return defaultValue; } } async function saveSetting(key, value) { try { await GM.setValue(key, value); } catch (e) { Logger$7.error("Failed to save setting:", key, e.message || e); } } async function saveCrawlState(state, storagePrefix) { await saveSetting(storagePrefix + "_dealfinder_crawl_state", JSON.stringify(state)); } async function loadCrawlState(storagePrefix) { const saved = await loadSetting(storagePrefix + "_dealfinder_crawl_state", null); if (!saved) return null; try { return typeof saved === "string" ? JSON.parse(saved) : saved; } catch (e) { await saveSetting(storagePrefix + "_dealfinder_crawl_state", null); return null; } } async function clearCrawlState(storagePrefix) { await saveSetting(storagePrefix + "_dealfinder_crawl_state", null); } async function saveResults(results, storagePrefix) { await saveSetting(storagePrefix + "_dealfinder_results", JSON.stringify(results)); } async function loadResults(storagePrefix) { const saved = await loadSetting(storagePrefix + "_dealfinder_results", null); if (!saved) return null; try { return typeof saved === "string" ? JSON.parse(saved) : saved; } catch (e) { await saveSetting(storagePrefix + "_dealfinder_results", null); return null; } } async function clearResults(storagePrefix) { await saveSetting(storagePrefix + "_dealfinder_results", null); } function deepCopySettings(settings) { if (!settings) return settings; const copy = { ...settings }; if (copy.provider) { copy.provider = { ...copy.provider }; if (copy.provider.options) copy.provider.options = { ...copy.provider.options }; } if (copy.providers) { copy.providers = { ...copy.providers }; Object.keys(copy.providers).forEach(function(k) { copy.providers[k] = { ...copy.providers[k] }; if (copy.providers[k].options) copy.providers[k].options = { ...copy.providers[k].options }; }); } return copy; } const Logger$6 = createLogger("MDF Settings"); const DEFAULT_SETTINGS = { version: SETTINGS_VERSION, currentProvider: PROVIDER_TYPES.GEMINI, providers: { gemini: { type: "gemini", apiKey: "", modelId: "gemini-2.5-flash-lite", baseUrl: "", options: {} }, openai: { type: "openai", apiKey: "", modelId: "gpt-5.4-nano", baseUrl: "", options: {} }, deepseek: { type: "deepseek", apiKey: "", modelId: "deepseek-v4-flash", baseUrl: "", options: {} }, claude: { type: "claude", apiKey: "", modelId: "claude-sonnet-4-6", baseUrl: "", options: {} }, openrouter: { type: "openrouter", apiKey: "", modelId: "google/gemini-3.1-flash-lite", baseUrl: "", options: {} }, portkey: { type: "portkey", apiKey: "", modelId: "gpt-5.4-nano", baseUrl: "", options: {} } }, searchContext: "", topX: 3, maxPages: 10 }; function isV1Settings(settings) { if (!settings || typeof settings !== "object") return false; return settings.version === 1 || typeof settings.apiKey === "string" && !settings.provider && !settings.providers; } function isV2Settings(settings) { if (!settings || typeof settings !== "object") return false; return settings.version === 2 || settings.provider && !settings.providers; } function migrateV1toV2(v1) { const modelMap = v1.modelMapping || {}; const modelKey = v1.model || "flash"; const modelId = modelMap[modelKey] || "gemini-2.5-flash"; return { version: 2, provider: { type: PROVIDER_TYPES.GEMINI, apiKey: v1.apiKey || "", modelId, baseUrl: "", options: {} }, searchContext: v1.searchContext || "", topX: v1.topX ?? 3, maxPages: v1.maxPages ?? 10 }; } function migrateV2toV3(v2) { const type = v2.provider ? v2.provider.type : PROVIDER_TYPES.GEMINI; const v3 = { version: SETTINGS_VERSION, currentProvider: type, providers: { gemini: { type: "gemini", apiKey: "", modelId: "gemini-2.5-flash-lite", baseUrl: "", options: {} }, openai: { type: "openai", apiKey: "", modelId: "gpt-5.4-nano", baseUrl: "", options: {} }, deepseek: { type: "deepseek", apiKey: "", modelId: "deepseek-v4-flash", baseUrl: "", options: {} }, claude: { type: "claude", apiKey: "", modelId: "claude-sonnet-4-6", baseUrl: "", options: {} }, openrouter: { type: "openrouter", apiKey: "", modelId: "google/gemini-3.1-flash-lite", baseUrl: "", options: {} }, portkey: { type: "portkey", apiKey: "", modelId: "gpt-5.4-nano", baseUrl: "", options: {} } }, searchContext: v2.searchContext || "", topX: v2.topX ?? 3, maxPages: v2.maxPages ?? 10 }; if (v2.provider) { const p = v2.provider; if (v3.providers[p.type]) { v3.providers[p.type] = { ...p }; } } return v3; } async function loadSettings(storagePrefix, cachedSettings) { if (cachedSettings != null) { const copy = deepCopySettings(cachedSettings); if (copy.providers && copy.currentProvider) { copy.provider = copy.providers[copy.currentProvider] || {}; } return { settings: copy, cachedSettings }; } const raw = await loadSetting(storagePrefix + "_dealfinder_settings", null); if (!raw) { const defaults = deepCopySettings(DEFAULT_SETTINGS); if (defaults.providers && defaults.currentProvider) { defaults.provider = defaults.providers[defaults.currentProvider] || {}; } return { settings: deepCopySettings(defaults), cachedSettings: defaults }; } try { let parsed = typeof raw === "string" ? JSON.parse(raw) : raw; const needsMigration = isV1Settings(parsed) || isV2Settings(parsed); if (isV1Settings(parsed)) parsed = migrateV1toV2(parsed); if (isV2Settings(parsed)) parsed = migrateV2toV3(parsed); const merged = { ...DEFAULT_SETTINGS, ...parsed, providers: { ...DEFAULT_SETTINGS.providers, ...parsed.providers || {} } }; if (needsMigration) { const toStore = { ...merged }; delete toStore.provider; await saveSetting(storagePrefix + "_dealfinder_settings", JSON.stringify(toStore)); } const cs = deepCopySettings(merged); cs.provider = cs.providers[cs.currentProvider] || {}; const copy = deepCopySettings(cs); copy.provider = copy.providers[copy.currentProvider] || {}; return { settings: copy, cachedSettings: cs }; } catch (e) { Logger$6.warn("Corrupted settings storage, resetting to defaults"); await saveSetting(storagePrefix + "_dealfinder_settings", null); const defs = deepCopySettings(DEFAULT_SETTINGS); defs.provider = defs.providers[defs.currentProvider] || {}; return { settings: deepCopySettings(defs), cachedSettings: defs }; } } async function saveSettings(storagePrefix, settings) { const toStore = { ...settings, providers: { ...settings.providers } }; if (toStore.provider && toStore.currentProvider && toStore.providers) { toStore.providers[toStore.currentProvider] = { ...toStore.provider }; } delete toStore.provider; await saveSetting(storagePrefix + "_dealfinder_settings", JSON.stringify(toStore)); } const S = { isRunning: false, isPaused: false, shouldStop: false, captchaPaused: false, finished: false, allTopDeals: [], currentPage: 1, initRetries: 0, descriptionCache: new Map(), cachedSettings: null, scraper: null, abortController: null, uiRoot: null, uiContent: null }; function setRunning(val) { S.isRunning = val; } function setCachedSettings(val) { S.cachedSettings = val; } function setScraper(val) { S.scraper = val; } function waitForElement(selector, timeout, root) { timeout = timeout || 1e4; root = root || document.body; return new Promise(function(resolve, reject) { const existing = root.querySelector(selector); if (existing) return resolve(existing); let timer; const observer = new MutationObserver(function() { const found = root.querySelector(selector); if (found) { cleanup(); resolve(found); } }); function cleanup() { observer.disconnect(); if (timer) clearTimeout(timer); } observer.observe(root, { childList: true, subtree: true }); if (timeout > 0) { timer = setTimeout(function() { cleanup(); reject(new Error("waitForElement timeout: " + selector)); }, timeout); } }); } function createShadowContainer(opts) { opts = opts || {}; const host = document.createElement(opts.tag || "div"); if (opts.id) host.id = opts.id; if (opts.className) host.className = opts.className; const root = host.attachShadow({ mode: "closed" }); if (opts.styles) { const style = document.createElement("style"); style.textContent = opts.styles; root.appendChild(style); } const content = document.createElement("div"); content.id = "content"; root.appendChild(content); document.body.appendChild(host); return { host, root, content }; } function createToast(message, opts) { opts = opts || {}; const duration = opts.duration || 3e3; const type = opts.type || "info"; const colors = { info: "#2196F3", success: "#4CAF50", error: "#F44336", warn: "#FF9800" }; const toast = document.createElement("div"); const root = toast.attachShadow({ mode: "closed" }); const toastStyle = document.createElement("style"); toastStyle.textContent = [ ":host { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:2147483647;", "background:" + (colors[type] || colors.info) + "; color:#fff; padding:10px 20px; border-radius:6px;", "font:13px/1.4 system-ui,sans-serif; box-shadow:0 4px 12px rgba(0,0,0,0.3);", "opacity:0; transition:opacity 0.3s ease; pointer-events:none; max-width:80vw; }", ":host(.show) { opacity:1; }" ].join(""); root.appendChild(toastStyle); const span = document.createElement("span"); span.textContent = message; root.appendChild(span); document.body.appendChild(toast); requestAnimationFrame(function() { toast.classList.add("show"); }); setTimeout(function() { toast.classList.remove("show"); setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); }, duration); return toast; } function esc(str) { if (!str) return ""; return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } function renderSettingsView(prefix, settings, savedResults, siteName) { const provider = settings.provider || {}; const providerType = provider.type || PROVIDER_TYPES.GEMINI; let displayContext = settings.searchContext || ""; if (!displayContext) { const urlParams = new URLSearchParams(window.location.search); const keyword = urlParams.get("keyword"); if (keyword) { displayContext = keyword; } else if (siteName === "KLEINANZEIGEN") { const pathMatch = window.location.pathname.match(/\/s-([^/]+)/); if (pathMatch) displayContext = decodeURIComponent(pathMatch[1].replace(/-/g, " ")); } } let savedTs = ""; if (savedResults && savedResults.timestamp) { const ts = savedResults.timestamp; savedTs = ts.indexOf("T") !== -1 ? new Date(ts).toLocaleString("de-DE") : ts; } const providerOptions = Object.values(PROVIDER_TYPES).map(function(type) { const labels = { gemini: "Google Gemini", openai: "OpenAI", deepseek: "DeepSeek", claude: "Anthropic Claude", openrouter: "OpenRouter", portkey: "Portkey" }; const selected = type === providerType ? " selected" : ""; return '"; }).join("\n"); const presets = MODEL_PRESETS[providerType] || []; const presetButtons = presets.map(function(preset) { const active = preset.id === provider.modelId ? " background:#6366f1; color:#fff; border-color:#6366f1;" : ""; const optionsJson = preset.options ? esc(JSON.stringify(preset.options)) : ""; return '"; }).join(""); return [ '
', '
', '

Deal Finder

', '', "
", savedResults ? '
' + savedResults.deals.length + ' gespeicherte Deals
Analysierte Seiten: ' + savedResults.pages + " | " + savedTs + '
' : "", '
', '', '", "
", '
', '', '', "
", providerType === PROVIDER_TYPES.PORTKEY ? '
Config-ID aus dem Portkey-Dashboard (pc-...)
' : "", '
', '', '', presetButtons ? '
' + presetButtons + "
" : "", "
", '
', '', '', "
", '
', '', '", "
", '
', '', '', 'Anzahl der besten Deals, die die AI pro Seite auswaehlt (1-10)', "
", '
', '', '', "
", '", '", '
', '', '', '', "
" ].join("\n"); } function renderResultsView(prefix, deals) { const sortedDeals = (deals || []).slice().sort(function(a, b) { return (b && b.score || 0) - (a && a.score || 0); }); const items = sortedDeals.map(function(deal, index) { const safeUrl = deal.url && deal.url.startsWith("https://") ? deal.url : "#"; const safeScore = Number.isFinite(Number(deal.score)) ? Math.min(100, Math.max(0, Number(deal.score))) : null; const scoreBar = safeScore !== null ? '
Score: ' + safeScore + '/100
= 40 ? "#ffc107" : "#dc3545") + ";height:100%;width:" + safeScore + '%;">
' : ""; const medal = index === 0 ? "#1" : index === 1 ? "#2" : index === 2 ? "#3" : "#" + (index + 1); const borderColor = index === 0 ? "#ffc107" : index === 1 ? "#28a745" : index === 2 ? "#17a2b8" : "#6c757d"; const bgColor = index === 0 ? "#fff8e1" : "#f8f9fa"; return [ '
', '
', '
' + medal + " " + esc(deal.title) + "
", '
S.' + deal.page + "
", "
", '
' + esc(deal.price) + "
", scoreBar, '
' + esc(deal.reasoning || "Keine Begruendung verfuegbar") + "
", deal.description ? '
' + esc(deal.description.substring(0, 150)) + (deal.description.length > 150 ? "..." : "") + "
" : "", 'Anzeige oeffnen', "
" ].join("\n"); }).join("\n"); return [ '
', '
', '

Top-Deals

', '', "
", '
', '
' + deals.length + "
", '
Top-Deals gefunden
', "
", '
' + items + "
", '
', '', '', '', '', "
", '', "
" ].join("\n"); } const SIDEBAR_WIDTH = "400px"; const SHADOW_STYLES = [ ":host { all: initial; contain: strict; isolation: isolate; }", ":host { display: none; position: fixed; top: 0; right: 0; width: 400px; height: 100vh;", " background: white; z-index: 999999; box-shadow: -5px 0 20px rgba(0,0,0,0.2);", " overflow-y: auto; transition: transform 0.3s ease;", " font-family: system-ui, -apple-system, sans-serif;", " pointer-events: auto; }", ":host([open]) { display: block; }" ].join("\n"); function createModal(prefix) { var modalId = prefix + "-dealfinder-modal"; if (document.getElementById(modalId)) return; var container = createShadowContainer({ tag: "div", id: modalId, styles: SHADOW_STYLES }); S.uiRoot = container.root; S.uiContent = container.content; } function openModal(prefix) { var modal = document.getElementById(prefix + "-dealfinder-modal"); var floatBtn = document.getElementById(prefix + "-dealfinder-btn"); if (modal) modal.setAttribute("open", ""); if (floatBtn) floatBtn.style.display = "none"; document.documentElement.style.transition = "margin-right 0.3s ease"; document.documentElement.style.marginRight = SIDEBAR_WIDTH; } function closeModal(prefix, isRunning) { if (isRunning) { var closeBtn = S.uiRoot ? S.uiRoot.getElementById(prefix + "-close-btn-x") : null; if (closeBtn) { closeBtn.style.color = "#dc3545"; closeBtn.title = "Crawl laeuft - erst stoppen"; setTimeout(function() { closeBtn.style.color = "#999"; closeBtn.title = ""; }, 1e3); } return; } var modal = document.getElementById(prefix + "-dealfinder-modal"); var floatBtn = document.getElementById(prefix + "-dealfinder-btn"); if (modal) modal.removeAttribute("open"); if (floatBtn) floatBtn.style.display = "block"; document.documentElement.style.marginRight = ""; } function createDealFinderButton(prefix, gradient) { var buttonId = prefix + "-dealfinder-btn"; if (document.getElementById(buttonId)) return; var button = document.createElement("button"); button.id = buttonId; button.textContent = "Deal Finder"; button.style.cssText = [ "position: fixed; top: 140px; right: 0; z-index: 99999;", "padding: 12px 16px; background: " + gradient + ";", "color: white; border: none; border-radius: 8px 0 0 8px; cursor: pointer;", "box-shadow: -3px 3px 12px rgba(0,0,0,0.25); font-size: 15px; font-weight: bold;", "transition: padding-right 0.2s ease, box-shadow 0.2s ease;" ].join(" "); button.addEventListener("mouseenter", function() { button.style.paddingRight = "22px"; button.style.boxShadow = "-5px 4px 18px rgba(0,0,0,0.35)"; }); button.addEventListener("mouseleave", function() { button.style.paddingRight = "16px"; button.style.boxShadow = "-3px 3px 12px rgba(0,0,0,0.25)"; }); document.body.appendChild(button); return button; } function switchToResultsView(prefix, deals) { if (!S.uiContent) return; S.uiContent.innerHTML = renderResultsView(prefix, deals || []); } class AIProvider { constructor(config) { if (new.target === AIProvider) { throw new TypeError("AIProvider is abstract β€” must extend it"); } this.config = config; } getEndpoint() { throw new Error("Not implemented: getEndpoint() must be overridden"); } getAuthHeaders() { throw new Error("Not implemented: getAuthHeaders() must be overridden"); } buildRequest(prompt, options = {}) { throw new Error("Not implemented: buildRequest() must be overridden"); } parseResponse(response) { throw new Error("Not implemented: parseResponse() must be overridden"); } isRateLimitError(status) { return status === 429 || status === 503; } isAuthError(status) { return status === 401 || status === 403; } } const registry = new Map(); function registerProvider(type, providerClass) { if (!(providerClass.prototype instanceof AIProvider)) { throw new TypeError("providerClass must extend AIProvider"); } registry.set(type, providerClass); } function createProvider(type, config) { const ProviderClass = registry.get(type); if (!ProviderClass) { throw new Error(`Unknown provider type: "${type}". Available: ${Array.from(registry.keys()).join(", ")}`); } return new ProviderClass(config); } const Logger$5 = createLogger("MDF API"); function getTimeoutForProvider(providerType) { return providerType === PROVIDER_TYPES.GEMINI ? GEMINI_API_TIMEOUT : REQUEST_TIMEOUT; } function gmRequest(params, signal) { return new Promise((resolve, reject) => { if (signal == null ? void 0 : signal.aborted) { return reject(new DOMException("Aborted", "AbortError")); } const details = { ...params, onload(resp) { cleanup(); resolve({ status: resp.status, responseText: resp.responseText, responseHeaders: resp.responseHeaders }); }, onerror(err) { cleanup(); reject(new Error(`GM_xmlhttpRequest failed: ${(err == null ? void 0 : err.finalUrl) || (err == null ? void 0 : err.status) || "network error"}`)); }, ontimeout() { cleanup(); reject(new Error(`GM_xmlhttpRequest timed out after ${params.timeout || REQUEST_TIMEOUT}ms`)); } }; let handle = null; const cleanup = () => { if (signal) signal.removeEventListener("abort", abortHandler); }; const abortHandler = () => { cleanup(); try { if (handle && handle.abort) handle.abort(); } catch (e) { } reject(new DOMException("Aborted", "AbortError")); }; if (signal) { signal.addEventListener("abort", abortHandler, { once: true }); } try { handle = GM_xmlhttpRequest(details); } catch (err) { reject(new Error(`GM_xmlhttpRequest threw: ${err.message}`)); } }); } function buildRequestBody(provider, prompt, userOptions = {}) { return provider.buildRequest(prompt, userOptions); } function calculateBackoff(retryCount, baseDelay = RETRY_BASE_DELAY) { const exponential = baseDelay * Math.pow(2, retryCount); const capped = Math.min(exponential, MAX_RATE_LIMIT_DELAY); const jitter = capped * JITTER_FACTOR * (Math.random() * 2 - 1); return Math.round(capped + jitter); } function cleanAIJson(text) { let cleaned = (text || "").trim(); if (!cleaned) return cleaned; if (cleaned.startsWith("```")) { cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, ""); cleaned = cleaned.trim(); } const firstBrace = cleaned.indexOf("{"); if (firstBrace === -1) return cleaned; let depth = 0, inString = false, escaped = false, matchEnd = -1; for (let i = firstBrace; i < cleaned.length; i++) { const ch = cleaned[i]; if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) { matchEnd = i + 1; break; } } } if (matchEnd > 0) cleaned = cleaned.substring(firstBrace, matchEnd); cleaned = cleaned.replace(/,(\s*[}\]])/g, "$1"); return cleaned; } async function callAI(prompt, settings, options = {}) { var _a, _b; const provider = createProvider(settings.providerType, { type: settings.providerType, apiKey: settings.apiKey, modelId: settings.modelId, baseUrl: settings.baseUrl, providerOptions: settings.providerOptions }); const endpoint = provider.getEndpoint(); const headers = provider.getAuthHeaders(); const body = buildRequestBody(provider, prompt, options); const timeout = getTimeoutForProvider(settings.providerType); let lastError = null; for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) { if ((_a = options.signal) == null ? void 0 : _a.aborted) { throw new DOMException("Aborted", "AbortError"); } try { const resp = await gmRequest({ method: "POST", url: endpoint, headers, data: JSON.stringify(body), timeout }, options.signal); if (provider.isAuthError(resp.status)) { throw new Error(`Auth error (${resp.status}): API key rejected for ${settings.providerType}`); } if (provider.isRateLimitError(resp.status)) { lastError = new Error(`Rate limited (${resp.status})`); if (attempt < RATE_LIMIT_MAX_RETRIES) { const delay = calculateBackoff(attempt); if (options.onRetry) { options.onRetry(attempt + 1, lastError); } await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw lastError; } if (resp.status < 200 || resp.status >= 300) { throw new Error(`API returned status ${resp.status}: ${(_b = resp.responseText) == null ? void 0 : _b.slice(0, 200)}`); } let parsed; try { parsed = JSON.parse(resp.responseText); } catch (parseErr) { throw new Error(`Failed to parse API response as JSON: ${parseErr.message}`); } let text; try { text = provider.parseResponse(parsed); } catch (parseErr) { throw new Error(`Provider parse error: ${parseErr.message}`); } if (!text || text.trim().length === 0) { throw new Error("Provider returned empty text content"); } let result; try { result = JSON.parse(text); } catch (parseErr) { try { const repaired = cleanAIJson(text); result = JSON.parse(repaired); } catch (repairErr) { Logger$5.warn("Raw AI output (first 500 chars):", (text || "").substring(0, 500)); throw new Error(`Failed to parse provider output as JSON: ${parseErr.message} | Repair error: ${repairErr.message}`); } } if (!result.topDeals || !Array.isArray(result.topDeals)) { throw new Error("Provider response missing topDeals array"); } return { topDeals: result.topDeals }; } catch (err) { if (err.name === "AbortError") throw err; lastError = err; if (attempt < RATE_LIMIT_MAX_RETRIES) { const message = err.message || ""; const isRetryable = /timed out|network error|failed to parse (?:api response|provider output)/i.test(message); if (isRetryable) { const delay = calculateBackoff(attempt); if (options.onRetry) { options.onRetry(attempt + 1, err); } await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw lastError; } } } throw lastError || new Error(`callAI failed after ${RATE_LIMIT_MAX_RETRIES + 1} attempts`); } function getValidScore(score) { const num = Number(score); return Number.isFinite(num) ? num : null; } function isValidScore(score) { return getValidScore(score) !== null; } function normalizeUrl(url) { if (!url) return url; return url.split("#")[0]; } function deduplicateDeals(deals) { const seen = new Map(); for (let i = 0; i < deals.length; i++) { const d = deals[i]; const key = normalizeUrl(d.url) || d.url; if (!seen.has(key)) seen.set(key, d); } return Array.from(seen.values()); } function sortDealsByScore(deals) { return deals.slice().sort(function(a, b) { return (getValidScore(b.score) ?? 0) - (getValidScore(a.score) ?? 0); }); } function computePriceStats(adsData) { const prices = []; for (let ai = 0; ai < adsData.length; ai++) { const ad = adsData[ai]; const match = (ad.price || "").replace(/\./g, "").replace(/,/g, ".").match(/(\d+(?:\.\d+)?)/); if (match) { const p = parseFloat(match[1]); if (p > 0) prices.push(p); } } if (prices.length === 0) return null; const sorted = prices.slice().sort(function(a, b) { return a - b; }); const mid = Math.floor(sorted.length / 2); const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; let sum = 0; for (let si = 0; si < prices.length; si++) sum += prices[si]; const mean = sum / prices.length; return { min: sorted[0], max: sorted[sorted.length - 1], mean: Math.round(mean), median: Math.round(median), count: prices.length }; } function updateProgress(prefix, text, percentage, type) { type = type || "info"; const container = S.uiRoot.getElementById(prefix + "-progress-container"); const progressText = S.uiRoot.getElementById(prefix + "-progress-text"); const progressBar = S.uiRoot.getElementById(prefix + "-progress-bar"); const borderColor = type === "error" ? "#dc3545" : type === "warning" ? "#ffc107" : type === "success" ? "#28a745" : "#667eea"; const textColor = type === "error" ? "#dc3545" : type === "warning" ? "#ffc107" : type === "success" ? "#28a745" : "#333"; const barColor = type === "error" ? "#dc3545" : type === "warning" ? "#ffc107" : type === "success" ? "#28a745" : "#007bff"; if (container) { container.style.display = "block"; container.style.borderLeftColor = borderColor; } if (progressText) { progressText.textContent = text; progressText.style.color = textColor; } if (progressBar) { progressBar.style.width = percentage + "%"; progressBar.style.backgroundColor = barColor; progressBar.style.transition = "width 0.3s ease, background-color 0.3s ease"; } } function showWarning(prefix, message, percentage) { updateProgress(prefix, "Warnung: " + message, percentage || 0, "warning"); } function resetUI(prefix) { const startBtn = S.uiRoot.getElementById(prefix + "-start-btn"); const pauseBtn = S.uiRoot.getElementById(prefix + "-pause-btn"); const stopBtn = S.uiRoot.getElementById(prefix + "-stop-btn"); const apiKeyInput = S.uiRoot.getElementById(prefix + "-api-key"); const searchInput = S.uiRoot.getElementById(prefix + "-search-context"); const topXInput = S.uiRoot.getElementById(prefix + "-top-x"); const providerSelect = S.uiRoot.getElementById(prefix + "-provider-select"); const modelIdInput = S.uiRoot.getElementById(prefix + "-model-id"); const baseUrlInput = S.uiRoot.getElementById(prefix + "-base-url"); if (startBtn) startBtn.style.display = "block"; if (pauseBtn) pauseBtn.style.display = "none"; if (stopBtn) stopBtn.style.display = "none"; if (apiKeyInput) apiKeyInput.disabled = false; if (searchInput) searchInput.disabled = false; if (topXInput) topXInput.disabled = false; if (providerSelect) providerSelect.disabled = false; if (modelIdInput) modelIdInput.disabled = false; if (baseUrlInput) baseUrlInput.disabled = false; const liveRanking = S.uiRoot.getElementById(prefix + "-live-ranking"); if (liveRanking) liveRanking.style.display = "none"; const progressContainer = S.uiRoot.getElementById(prefix + "-progress-container"); if (progressContainer) progressContainer.style.display = "none"; setRunning(false); } function setUIRunningState(prefix) { const startBtn = S.uiRoot.getElementById(prefix + "-start-btn"); const pauseBtn = S.uiRoot.getElementById(prefix + "-pause-btn"); const stopBtn = S.uiRoot.getElementById(prefix + "-stop-btn"); const apiKeyInput = S.uiRoot.getElementById(prefix + "-api-key"); const searchInput = S.uiRoot.getElementById(prefix + "-search-context"); const topXInput = S.uiRoot.getElementById(prefix + "-top-x"); const providerSelect = S.uiRoot.getElementById(prefix + "-provider-select"); const modelIdInput = S.uiRoot.getElementById(prefix + "-model-id"); const baseUrlInput = S.uiRoot.getElementById(prefix + "-base-url"); if (startBtn) startBtn.style.display = "none"; if (pauseBtn) pauseBtn.style.display = "block"; if (stopBtn) stopBtn.style.display = "block"; if (apiKeyInput) apiKeyInput.disabled = true; if (searchInput) searchInput.disabled = true; if (topXInput) topXInput.disabled = true; if (providerSelect) providerSelect.disabled = true; if (modelIdInput) modelIdInput.disabled = true; if (baseUrlInput) baseUrlInput.disabled = true; } function updateLiveRanking(prefix, allTopDeals, cachedSettings) { const container = S.uiRoot.getElementById(prefix + "-live-ranking"); const content = S.uiRoot.getElementById(prefix + "-live-ranking-content"); if (!container || !content) return; if (!S.isRunning) { container.style.display = "none"; return; } if (!allTopDeals || allTopDeals.length === 0) { container.style.display = "none"; return; } let currentPage = 0; for (let di = 0; di < allTopDeals.length; di++) { if (allTopDeals[di].page > currentPage) currentPage = allTopDeals[di].page; } const pageDeals = []; for (let di = 0; di < allTopDeals.length; di++) { if (allTopDeals[di].page === currentPage) pageDeals.push(allTopDeals[di]); } if (pageDeals.length === 0) { container.style.display = "none"; return; } const topX = cachedSettings && cachedSettings.topX || 3; container.style.display = "block"; pageDeals.sort(function(a, b) { return (b && b.score || 0) - (a && a.score || 0); }); const topItems = pageDeals.slice(0, Math.min(3, topX)); content.innerHTML = topItems.map(function(deal, idx) { const borderStyle = idx < topItems.length - 1 ? "border-bottom:1px solid #ffe082;" : ""; return [ '
', '
' + (idx + 1) + ". " + esc(deal.title) + "
", '
' + esc(deal.price) + "
", '
Seite ' + deal.page + "
", "
" ].join("\n"); }).join("\n"); } const Logger$4 = createLogger("MDF UI"); function attachSettingsListeners(prefix, callbacks) { const startBtn = S.uiRoot.getElementById(prefix + "-start-btn"); const pauseBtn = S.uiRoot.getElementById(prefix + "-pause-btn"); const stopBtn = S.uiRoot.getElementById(prefix + "-stop-btn"); const closeBtn = S.uiRoot.getElementById(prefix + "-close-btn-x"); const showResultsBtn = S.uiRoot.getElementById(prefix + "-show-results-btn"); const apiKeyInput = S.uiRoot.getElementById(prefix + "-api-key"); const searchContextInput = S.uiRoot.getElementById(prefix + "-search-context"); const providerSelect = S.uiRoot.getElementById(prefix + "-provider-select"); const modelIdInput = S.uiRoot.getElementById(prefix + "-model-id"); const baseUrlInput = S.uiRoot.getElementById(prefix + "-base-url"); const presetContainer = S.uiRoot.getElementById(prefix + "-model-presets"); if (startBtn && callbacks.start) { startBtn.addEventListener("click", function() { callbacks.start()["catch"](function(error) { Logger$4.error("Unhandled error in start:", error); updateProgress(prefix, "Fehler: " + error.message, 0, "error"); resetUI(prefix); }); }); } if (pauseBtn && callbacks.pause) { pauseBtn.addEventListener("click", callbacks.pause); } if (stopBtn && callbacks.stop) { stopBtn.addEventListener("click", callbacks.stop); } if (closeBtn && callbacks.close) { closeBtn.addEventListener("click", callbacks.close); } if (showResultsBtn && callbacks.showSavedResults) { showResultsBtn.addEventListener("click", callbacks.showSavedResults); } if (apiKeyInput && callbacks.apiKeyChange) { apiKeyInput.addEventListener("blur", function() { callbacks.apiKeyChange(apiKeyInput.value.trim()); }); } if (searchContextInput && callbacks.searchContextChange) { searchContextInput.addEventListener("blur", function() { callbacks.searchContextChange(searchContextInput.value.trim()); }); } if (providerSelect && callbacks.providerChange) { providerSelect.addEventListener("change", function() { callbacks.providerChange(providerSelect.value)["catch"](function(err) { Logger$4.error("Provider change error:", err); }); }); } if (modelIdInput && callbacks.modelIdChange) { modelIdInput.addEventListener("blur", function() { callbacks.modelIdChange(modelIdInput.value.trim()); }); } if (baseUrlInput && callbacks.baseUrlChange) { baseUrlInput.addEventListener("blur", function() { callbacks.baseUrlChange(baseUrlInput.value.trim()); }); } const portkeyConfigInput = S.uiRoot.getElementById(prefix + "-portkey-config"); if (portkeyConfigInput && callbacks.portkeyConfigChange) { portkeyConfigInput.addEventListener("blur", function() { callbacks.portkeyConfigChange(portkeyConfigInput.value.trim()); }); } if (presetContainer && callbacks.modelPresetClick) { presetContainer.addEventListener("click", function(e) { const btn = e.target.closest("[data-model-id]"); if (btn) { const modelId = btn.getAttribute("data-model-id"); const rawOpts = btn.getAttribute("data-options"); let options = null; if (rawOpts) { try { options = JSON.parse(rawOpts); } catch (e2) { } } callbacks.modelPresetClick(modelId, options); } }); } [startBtn, pauseBtn, stopBtn, showResultsBtn].forEach(function(btn) { if (btn) { btn.addEventListener("mouseenter", function() { btn.style.opacity = "0.9"; }); btn.addEventListener("mouseleave", function() { btn.style.opacity = "1"; }); } }); } function attachResultsListeners(prefix, callbacks) { const closeBtn = S.uiRoot.getElementById(prefix + "-close-btn-x"); const backBtn = S.uiRoot.getElementById(prefix + "-back-to-settings"); const exportMdBtn = S.uiRoot.getElementById(prefix + "-export-markdown-btn"); const exportJsonBtn = S.uiRoot.getElementById(prefix + "-export-json-btn"); const exportCsvBtn = S.uiRoot.getElementById(prefix + "-export-csv-btn"); const clearBtn = S.uiRoot.getElementById(prefix + "-clear-results-btn"); if (closeBtn && callbacks.close) closeBtn.addEventListener("click", callbacks.close); if (backBtn && callbacks.backToSettings) backBtn.addEventListener("click", callbacks.backToSettings); if (exportMdBtn && callbacks.exportMarkdown) exportMdBtn.addEventListener("click", callbacks.exportMarkdown); if (exportJsonBtn && callbacks.exportJSON) exportJsonBtn.addEventListener("click", callbacks.exportJSON); if (exportCsvBtn && callbacks.exportCSV) exportCsvBtn.addEventListener("click", callbacks.exportCSV); if (clearBtn && callbacks.clearResults) clearBtn.addEventListener("click", callbacks.clearResults); [closeBtn, backBtn, exportMdBtn, exportJsonBtn, exportCsvBtn, clearBtn].forEach(function(btn) { if (btn) { btn.addEventListener("mouseenter", function() { btn.style.opacity = "0.9"; }); btn.addEventListener("mouseleave", function() { btn.style.opacity = "1"; }); } }); } function escapeTable(str) { return (str || "").replace(/\|/g, "\\|"); } function formatDate(isoString) { try { let d = new Date(isoString); return d.toLocaleDateString("de-DE", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch (e) { return isoString; } } function generateMarkdown(deals, pages, timestamp, siteName) { let ts = timestamp || ( new Date()).toISOString(); let md = "# " + siteName + " β€” Top-Deals\n\n"; md += "- **Gefunden:** " + deals.length + " Top-Deals\n"; md += "- **Analysierte Seiten:** " + pages + "\n"; md += "- **Erstellt:** " + formatDate(ts) + "\n\n"; let stats = computePriceStats(deals); if (stats) { md += "### Preis-Statistik\n\n"; md += "| Kennzahl | Wert |\n"; md += "|----------|-----|\n"; md += "| Minimal | " + stats.min.toLocaleString("de-DE") + " EUR |\n"; md += "| Maximal | " + stats.max.toLocaleString("de-DE") + " EUR |\n"; md += "| Median | " + stats.median.toLocaleString("de-DE") + " EUR |\n"; md += "| Durchschnitt | " + stats.mean.toLocaleString("de-DE") + " EUR |\n\n"; } md += "## Uebersicht\n\n"; md += "| # | Titel | Preis | Score | Seite |\n"; md += "|---|-------|-------|-------|------|\n"; for (let i = 0; i < deals.length; i++) { let deal = deals[i]; let rank = i + 1; let title = escapeTable(deal.title || "Unbekannt"); let price = escapeTable(deal.price || "-"); let score = deal.score !== void 0 && isValidScore(deal.score) ? deal.score + "/100" : "-"; let page = deal.page || "-"; md += "| " + rank + " | " + title + " | " + price + " | " + score + " | " + page + " |\n"; } md += "\n"; md += "---\n\n## Details\n\n"; for (let j = 0; j < deals.length; j++) { let d = deals[j]; let r = j + 1; md += "### #" + r + " β€” " + (d.title || "Unbekannt") + "\n\n"; md += "- **Preis:** " + (d.price || "Unbekannt") + "\n"; if (d.score !== void 0 && isValidScore(d.score)) { md += "- **Score:** " + d.score + "/100\n"; } md += "- **Begruendung:** " + (d.reasoning || "Keine Begruendung") + "\n"; md += "- **Seite:** " + (d.page || "?") + "\n\n"; if (d.description) { var safeDesc = d.description.replace(/`{3,}/g, function(m) { return "\\`".repeat(m.length); }); md += "**Beschreibung:**\n\n> " + safeDesc.replace(/\n/g, "\n> ") + "\n\n"; } else { md += "*Keine Beschreibung geladen.*\n\n"; } md += "[Anzeige oeffnen](" + d.url + ")\n\n"; if (j < deals.length - 1) { md += "---\n\n"; } } return md; } function escapeCsv(val) { if (val === null || val === void 0) return ""; let str = String(val); str = str.replace(/[\r\n]+/g, " "); return '"' + str.replace(/"/g, '""') + '"'; } async function exportMarkdown(prefix) { let results = await loadResults(prefix); if (!results) { createToast("Keine Results verfuegbar!", { type: "error" }); return; } let siteName = prefix === "wh" ? "WILLHABEN" : "KLEINANZEIGEN"; let md = generateMarkdown(results.deals, results.pages, results.timestamp, siteName); try { await navigator.clipboard.writeText(md); var btn = S.uiRoot.getElementById(prefix + "-export-markdown-btn"); if (btn) { let orig = btn.textContent; btn.textContent = "Kopiert!"; setTimeout(function() { btn.textContent = orig; }, 2e3); } } catch (error) { createToast("Fehler beim Kopieren. Bitte Fenster fokussieren und nochmal versuchen.", { type: "error", duration: 5e3 }); } } async function exportJSON(prefix) { let results = await loadResults(prefix); if (!results) { createToast("Keine Results verfuegbar!", { type: "error" }); return; } let siteName = prefix === "wh" ? "WILLHABEN" : "KLEINANZEIGEN"; let deals = results.deals || []; let metaJson = JSON.stringify({ site: siteName, totalDeals: deals.length, pages: results.pages || 0, timestamp: results.timestamp || ( new Date()).toISOString() }); let jsonOut = '{\n "metadata": ' + metaJson + ',\n "deals": [\n'; for (let i = 0; i < deals.length; i++) { jsonOut += " " + JSON.stringify(deals[i]); if (i < deals.length - 1) jsonOut += ",\n"; else jsonOut += "\n"; } jsonOut += " ]\n}\n"; let blob = new Blob([jsonOut], { type: "application/json" }); let url = URL.createObjectURL(blob); let a = GM_addElement("a", { href: url, download: "deals-" + Date.now() + ".json" }); a.click(); setTimeout(function() { URL.revokeObjectURL(url); a.remove(); }, 1e3); } async function exportCSV(prefix) { let results = await loadResults(prefix); if (!results) { createToast("Keine Results verfuegbar!", { type: "error" }); return; } let deals = results.deals || []; let header = ["Rang", "Titel", "Preis", "Score", "Begruendung", "Seite", "Beschreibung", "URL"]; let rows = deals.map(function(d, i) { return [ i + 1, escapeCsv(d.title), escapeCsv(d.price), d.score !== void 0 && Number.isFinite(Number(d.score)) ? d.score : "", escapeCsv(d.reasoning || d.reason), d.page || "", escapeCsv(d.description), escapeCsv(d.url) ].join(";"); }); let csv = "\uFEFF" + header.join(";") + "\r\n" + rows.join("\r\n"); let blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); let url = URL.createObjectURL(blob); let a = GM_addElement("a", { href: url, download: "deals-" + Date.now() + ".csv" }); a.click(); setTimeout(function() { URL.revokeObjectURL(url); a.remove(); }, 1e3); } const Logger$3 = createLogger("MDF Crawler"); function escapeForPrompt(str) { return (str || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ""); } function gradualScroll(targetY, durationMs) { return new Promise(function(resolve) { var startY = window.scrollY; var startTime = performance.now(); function step() { var elapsed = performance.now() - startTime; var progress = Math.min(elapsed / durationMs, 1); var eased = 1 - Math.pow(1 - progress, 3); window.scrollTo(0, startY + (targetY - startY) * eased); if (progress < 1) { requestAnimationFrame(step); } else { resolve(); } } requestAnimationFrame(step); }); } function buildAnalysisPrompt(adsData, searchContext, topX, siteName, maxDescLength) { maxDescLength = maxDescLength || 400; const stats = computePriceStats(adsData); const statsSection = stats ? "\n\n## Price Distribution\n- Min: " + stats.min + " EUR\n- Max: " + stats.max + " EUR\n- Avg: " + stats.mean + " EUR\n- Median: " + stats.median + " EUR\n- Listings with price: " + stats.count : ""; let prompt = "You are a deal and price analysis expert.\n\nSEARCH CONTEXT: " + escapeForPrompt(searchContext) + "\n\nTASK:\nAnalyze the following " + siteName + " listings and find the " + topX + " BEST deals.\n\nCRITERIA for a good deal:\n- 35-90% below the usual new price\n- Guaranteed profit on resale possible\n- MUST BUY quality\n- Real added value for the buyer" + statsSection + "\n\nLISTINGS:\n"; for (let adi = 0; adi < adsData.length; adi++) { const ad = adsData[adi]; prompt += "\nListing " + (adi + 1) + ":\nTitle: " + escapeForPrompt(ad.title) + "\nPrice: " + escapeForPrompt(ad.price) + "\nDescription: " + escapeForPrompt(ad.description).substring(0, maxDescLength) + "\nURL: " + escapeForPrompt(ad.url) + "\n---\n"; } prompt += '\nRESPONSE FORMAT (JSON ONLY, NO EXTRA TEXT):\n{\n "topDeals": [\n {\n "title": "...",\n "price": "...",\n "description": "...",\n "url": "...",\n "reasoning": "Why is this a top deal? (1-2 sentences)",\n "score": 85\n }\n ]\n}\n\nSort the top ' + topX + " deals by quality (best first). Score is 0-100 (100 = absolute bargain).\nReturn ONLY valid JSON. No markdown, no code fences, no extra text."; return prompt; } async function waitIfPaused() { while (S.isPaused && !S.shouldStop) { await new Promise(function(r) { setTimeout(r, PAUSE_POLL_INTERVAL); }); } } function deepFindDescription(obj, depth) { depth = depth || 0; if (!obj || typeof obj !== "object" || depth > 10) return null; if (obj["@type"]) { var typeStr = String(obj["@type"]).toLowerCase(); if (/product|offer|article|vehicle|residence|apartment|car/i.test(typeStr)) { if (typeof obj.description === "string" && obj.description.trim().length > 20) return obj.description; if (typeof obj.body === "string" && obj.body.trim().length > 20) return obj.body; if (typeof obj.teaser === "string" && obj.teaser.trim().length > 20) return obj.teaser; } } if (typeof obj.description === "string" && obj.description.trim().length > 20) return obj.description; if (typeof obj.body === "string" && obj.body.trim().length > 20) return obj.body; if (typeof obj.text === "string" && obj.text.trim().length > 100) return obj.text; if (Array.isArray(obj)) { for (var ai = 0; ai < obj.length; ai++) { var result = deepFindDescription(obj[ai], depth + 1); if (result) return result; } } else { var keys = Object.keys(obj); for (var ki = 0; ki < keys.length; ki++) { var val = obj[keys[ki]]; if (val && typeof val === "object") { var result = deepFindDescription(val, depth + 1); if (result) return result; } } } return null; } function decodeEntities(str) { return (str || "").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " "); } function extractDescriptionFromHtml(html, descSelectors2) { var candidates = []; var push = function(via, text) { if (text) { var clean = decodeEntities(text).replace(/\s+/g, " ").trim(); if (clean.length >= 20) candidates.push({ via, text: clean }); } }; var doc = null; try { doc = new DOMParser().parseFromString(html, "text/html"); } catch (e) { } try { var nextMatch = html.match(/]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i); if (nextMatch && nextMatch[1]) { var data = JSON.parse(nextMatch[1]); var nextDesc = deepFindDescription(data); push("next_data", nextDesc); } } catch (e) { } if (doc) { var ldScripts = doc.querySelectorAll('script[type="application/ld+json"]'); for (var ldi = 0; ldi < ldScripts.length; ldi++) { try { var json = JSON.parse(ldScripts[ldi].textContent); var nodes = Array.isArray(json) ? json : json["@graph"] ? json["@graph"] : [json]; for (var ni = 0; ni < nodes.length; ni++) { var n = nodes[ni]; if (n && typeof n.description === "string") push("jsonld", n.description); } } catch (e) { } } } if (doc) { var og = doc.querySelector('meta[property="og:description"]'); if (og) push("og", og.getAttribute("content")); var metaDesc = doc.querySelector('meta[name="description"]'); if (metaDesc) push("meta", metaDesc.getAttribute("content")); } if (doc) { for (var si = 0; si < descSelectors2.length; si++) { var el = doc.querySelector(descSelectors2[si]); if (el && el.textContent.trim().length > 20) { push("dom", el.textContent); break; } } var ip = doc.querySelector('[itemprop="description"]'); if (ip) push("itemprop", ip.textContent || ip.getAttribute("content")); } if (candidates.length === 0) return { ok: false, via: "none", text: "" }; candidates.sort(function(a, b) { return b.text.length - a.text.length; }); return { ok: true, via: candidates[0].via, text: candidates[0].text }; } async function loadDescCache(prefix) { try { var raw = await GM.getValue(prefix + "_desc_cache", null); if (raw && typeof raw === "object") { var keys = Object.keys(raw); for (var ki = 0; ki < keys.length; ki++) { if (raw[keys[ki]] && S.descriptionCache.size < MAX_CACHE_SIZE) { S.descriptionCache.set(keys[ki], raw[keys[ki]]); } } Logger$3.log("Loaded " + S.descriptionCache.size + " cached descriptions"); } } catch (e) { Logger$3.debug("No desc cache to load"); } } async function saveDescCache(prefix) { try { var obj = {}; var count = 0; var entries = Array.from(S.descriptionCache.entries()); for (var ei = 0; ei < entries.length && count < MAX_CACHE_SIZE; ei++) { var key = entries[ei][0]; var val = entries[ei][1]; if (val && key) { obj[key] = (val || "").slice(0, 2e3); count++; } } await GM.setValue(prefix + "_desc_cache", obj); } catch (e) { Logger$3.debug("Failed to persist desc cache:", e && e.message || String(e)); } } function fetchFullDescription(url, descSelectors2, retryCount) { retryCount = retryCount || 0; if (S.descriptionCache.has(url)) { const desc = S.descriptionCache.get(url); S.descriptionCache.delete(url); S.descriptionCache.set(url, desc); return Promise.resolve({ success: true, description: desc }); } return new Promise(function(resolve) { var handle = null; var done = false; var abortSignal = S.abortController ? S.abortController.signal : null; function abortIfStopped() { if (done) return true; if (S.shouldStop || abortSignal && abortSignal.aborted) { done = true; try { if (handle && handle.abort) handle.abort(); } catch (e) { } resolve({ success: false, description: "" }); return true; } return false; } handle = GM_xmlhttpRequest({ method: "GET", url, timeout: 1e4, headers: { "Accept-Language": "de-DE,de;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Referer": window.location.href }, onload: function(response) { if (abortIfStopped()) return; try { if (response.status >= 200 && response.status < 300) { var responseText = response.responseText; var extracted = extractDescriptionFromHtml(responseText, descSelectors2); if (extracted.ok) { done = true; if (S.descriptionCache.size >= MAX_CACHE_SIZE) { const firstKey = S.descriptionCache.keys().next().value; S.descriptionCache.delete(firstKey); } S.descriptionCache.set(url, extracted.text); Logger$3.debug("Desc OK", { url, via: extracted.via, status: response.status, len: extracted.text.length }); resolve({ success: true, description: extracted.text, via: extracted.via }); return; } Logger$3.debug("Desc MISS", { url, status: response.status, len: (responseText || "").length }); } else { Logger$3.debug("Desc HTTP " + response.status, { url, status: response.status }); if (response.status === 403 || response.status === 429) { Logger$3.debug("Anti-bot response " + response.status, { url, status: response.status }); } } } catch (e) { Logger$3.debug("Desc parse error", { url, error: e && e.message || String(e) }); } if (abortIfStopped()) return; var antiBot = response.status === 403 || response.status === 429; var baseDelay = antiBot ? DESCRIPTION_FETCH_DELAY * 3 : DESCRIPTION_FETCH_DELAY; var delay = baseDelay * Math.pow(DESCRIPTION_BACKOFF_FACTOR, retryCount) * (1 + Math.random() * JITTER_FACTOR); if (retryCount < DESCRIPTION_MAX_RETRIES) { if (S.shouldStop) { done = true; resolve({ success: false, description: "" }); return; } done = true; setTimeout(function() { fetchFullDescription(url, descSelectors2, retryCount + 1).then(resolve); }, Math.round(delay)); } else { done = true; resolve({ success: false, description: "" }); } }, onerror: function() { if (abortIfStopped()) return; var delay = DESCRIPTION_FETCH_DELAY * Math.pow(DESCRIPTION_BACKOFF_FACTOR, retryCount) * (1 + Math.random() * JITTER_FACTOR); if (retryCount < DESCRIPTION_MAX_RETRIES) { if (S.shouldStop) { done = true; resolve({ success: false, description: "" }); return; } done = true; setTimeout(function() { fetchFullDescription(url, descSelectors2, retryCount + 1).then(resolve); }, Math.round(delay)); } else { done = true; resolve({ success: false, description: "" }); } }, ontimeout: function() { if (abortIfStopped()) return; var delay = DESCRIPTION_FETCH_DELAY * Math.pow(DESCRIPTION_BACKOFF_FACTOR, retryCount) * (1 + Math.random() * JITTER_FACTOR); if (retryCount < DESCRIPTION_MAX_RETRIES) { if (S.shouldStop) { done = true; resolve({ success: false, description: "" }); return; } done = true; setTimeout(function() { fetchFullDescription(url, descSelectors2, retryCount + 1).then(resolve); }, Math.round(delay)); } else { done = true; resolve({ success: false, description: "" }); } } }); }); } async function saveCrawlStateAndNavigate(href, settings) { var strippedDeals = []; for (var di = 0; di < S.allTopDeals.length; di++) { var d = S.allTopDeals[di]; strippedDeals.push({ url: d.url, title: d.title, price: d.price, score: d.score, page: d.page, reasoning: d.reasoning, description: (d.description || "").slice(0, 2e3) }); } const crawlState = { currentPage: S.currentPage, currentUrl: window.location.href, allTopDeals: strippedDeals, maxPages: settings.maxPages }; await saveCrawlState(crawlState, S.scraper.storagePrefix); await saveSetting( S.scraper.storagePrefix + "_dealfinder_resume", JSON.stringify({ u: normalizeUrl(new URL(href, window.location.href).href) }) ); window.location.href = href; } async function startDealFinder() { const prefix = S.scraper.storagePrefix; if (S.isRunning) { Logger$3.warn("Crawl already running, ignoring duplicate start"); return; } const apiKey = S.uiRoot.getElementById(prefix + "-api-key").value.trim(); const modelId = S.uiRoot.getElementById(prefix + "-model-id").value.trim(); const searchContext = S.uiRoot.getElementById(prefix + "-search-context").value.trim(); const topX = parseInt(S.uiRoot.getElementById(prefix + "-top-x").value); const maxPages = parseInt(S.uiRoot.getElementById(prefix + "-max-pages").value) || 10; const providerType = S.uiRoot.getElementById(prefix + "-provider-select") ? S.uiRoot.getElementById(prefix + "-provider-select").value : "gemini"; const baseUrl = S.uiRoot.getElementById(prefix + "-base-url") ? S.uiRoot.getElementById(prefix + "-base-url").value.trim() : ""; if (!apiKey) { alert("Bitte gib deinen API Key ein!"); return; } if (!searchContext) { alert("Bitte gib einen Suchkontext ein!"); return; } if (!Number.isFinite(topX) || topX < 1 || topX > 10) { alert("AI-Picks muss zwischen 1 und 10 liegen!"); return; } if (!Number.isFinite(maxPages) || maxPages < 1 || maxPages > 100) { alert("Maximale Seiten muss zwischen 1 und 100 liegen!"); return; } const result = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = result.cachedSettings; const settings = result.settings; settings.currentProvider = providerType; settings.provider = { type: providerType, apiKey, modelId, baseUrl, options: settings.provider.options || {} }; settings.searchContext = searchContext; settings.topX = topX; settings.maxPages = maxPages; await saveSettings(prefix, settings); S.cachedSettings = deepCopySettings(settings); S.cachedSettings.provider = S.cachedSettings.providers[S.cachedSettings.currentProvider] || {}; if ("Notification" in window && Notification.permission === "default") { Notification.requestPermission()["catch"](function() { }); } S.abortController = new AbortController(); await loadDescCache(prefix); S.currentPage = 1; S.allTopDeals = []; S.isRunning = true; S.isPaused = false; S.shouldStop = false; S.captchaPaused = false; S.finished = false; setUIRunningState(prefix); try { await processCurrentPage(settings); } catch (error) { Logger$3.error("Error:", error); updateProgress(prefix, "Fehler: " + error.message, 0, "error"); if (S.allTopDeals.length > 0) { await finishDealFinder(); } else { resetUI(prefix); alert("Fehler: " + error.message); } } } function pauseDealFinder() { S.isPaused = true; const prefix = S.scraper.storagePrefix; const pauseBtn = S.uiRoot.getElementById(prefix + "-pause-btn"); if (!pauseBtn) return; pauseBtn.textContent = "Fortsetzen"; pauseBtn.style.background = "#28a745"; pauseBtn.removeEventListener("click", pauseDealFinder); pauseBtn.addEventListener("click", resumeDealFinder); updateProgress(prefix, "Pausiert - Klicke Fortsetzen...", 50, "warning"); } function resumeDealFinder() { S.isPaused = false; const prefix = S.scraper.storagePrefix; const pauseBtn = S.uiRoot.getElementById(prefix + "-pause-btn"); if (!pauseBtn) return; pauseBtn.textContent = "Pause"; pauseBtn.style.background = "#ffc107"; pauseBtn.removeEventListener("click", resumeDealFinder); pauseBtn.addEventListener("click", pauseDealFinder); if (S.isRunning && S.captchaPaused) { S.captchaPaused = false; const cs = S.cachedSettings || {}; processCurrentPage(cs)["catch"](function(error) { Logger$3.error("Resume error:", error); updateProgress(prefix, "Fehler: " + error.message, 0, "error"); if (S.allTopDeals.length > 0) { finishDealFinder()["catch"](function(e) { Logger$3.error("finishDealFinder after resume error:", e); }); } else { resetUI(prefix); } }); } } async function stopDealFinder() { S.shouldStop = true; S.isPaused = false; S.captchaPaused = false; if (S.abortController) { S.abortController.abort(); S.abortController = null; } const prefix = S.scraper.storagePrefix; await clearCrawlState(prefix); Logger$3.log("Crawl stopped by user"); updateProgress(prefix, "Stoppe nach aktueller Seite...", 95, "warning"); } async function processCurrentPage(settings) { const prefix = S.scraper.storagePrefix; const scraper = S.scraper; const maxPages = settings.maxPages || 10; await waitIfPaused(); if (S.shouldStop) { await finishDealFinder(); return; } if (S.currentPage > maxPages) { await finishDealFinder(); return; } updateProgress(prefix, "Seite " + S.currentPage + ": Lade alle Anzeigen...", 10, "info"); var settleMs = 800; var hardTimeoutMs = 8e3; var scrollDone = false; await gradualScroll(document.body.scrollHeight, 2e3); scrollDone = true; await new Promise(function(r) { setTimeout(r, 500); }); await gradualScroll(0, 1500); var settleTimer = null; var observerDone = false; await new Promise(function(resolveObserve) { var observer = new MutationObserver(function() { if (!scrollDone) return; if (settleTimer) clearTimeout(settleTimer); settleTimer = setTimeout(function() { observer.disconnect(); clearTimeout(hardTimer); observerDone = true; resolveObserve(); }, settleMs); }); var hardTimer = setTimeout(function() { try { observer.disconnect(); } catch (e) { } observerDone = true; resolveObserve(); }, hardTimeoutMs); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(function() { if (!observerDone) { observer.disconnect(); clearTimeout(hardTimer); observerDone = true; resolveObserve(); } }, settleMs + 1500); }); updateProgress(prefix, "Seite " + S.currentPage + ": Sammle Anzeigen...", 15, "info"); const selectors = scraper.findAds(); if (!selectors) { const captchaSelectors = [ 'iframe[src*="challenges.cloudflare.com"]', 'iframe[src*="hcaptcha.com"]', 'iframe[src*="google.com/recaptcha"]', "#challenge-form", ".g-recaptcha", '[id*="captcha"]', '[class*="captcha"]' ]; var hasCaptchaElement = false; for (var ci = 0; ci < captchaSelectors.length; ci++) { if (document.querySelector(captchaSelectors[ci])) { hasCaptchaElement = true; break; } } const pageText = (document.title + " " + document.body.innerText).toLowerCase(); if (hasCaptchaElement || pageText.indexOf("captcha") !== -1 || pageText.indexOf("challenge") !== -1) { S.captchaPaused = true; const crawlState = { currentPage: S.currentPage, currentUrl: window.location.href, allTopDeals: S.allTopDeals, maxPages }; await saveCrawlState(crawlState, prefix); pauseDealFinder(); updateProgress(prefix, "CAPTCHA erkannt! Bitte loesen und Fortsetzen klicken", 50, "warning"); return; } throw new Error("Keine Anzeigen gefunden"); } updateProgress(prefix, "Seite " + S.currentPage + ": Sammle Basis-Daten...", 20, "info"); const seenUrls = new Set(); const adsData = []; const adArray = Array.from(selectors.adEntries); for (let adi = 0; adi < adArray.length; adi++) { const info = scraper.extractBasicInfo(adArray[adi]); info.url = normalizeUrl(info.url) || info.url; if (!seenUrls.has(info.url)) { seenUrls.add(info.url); adsData.push(info); } } Logger$3.log(adsData.length + " ads found (deduplicated)"); updateProgress(prefix, "Seite " + S.currentPage + ": Lade Details (0/" + adsData.length + ")...", 30, "info"); let completedCount = 0; for (let bi = 0; bi < adsData.length; bi += INITIAL_BATCH_SIZE) { await waitIfPaused(); if (S.shouldStop) break; const batch = adsData.slice(bi, Math.min(bi + INITIAL_BATCH_SIZE, adsData.length)); const batchFns = batch.map(function(ad, idx) { const absoluteIndex = bi + idx; const fetchPromise = ad.url && ad.url.indexOf("http") === 0 ? fetchFullDescription(ad.url, scraper.descSelectors()) : Promise.resolve({ success: false, description: "" }); return fetchPromise.then(function(result) { completedCount++; if (completedCount % 5 === 0 || completedCount === adsData.length) { updateProgress( prefix, "Seite " + S.currentPage + ": Lade Details (" + completedCount + "/" + adsData.length + ")...", 30 + completedCount / adsData.length * 40, "info" ); } adsData[absoluteIndex].description = result.description; }); }); await Promise.allSettled(batchFns); if (bi + INITIAL_BATCH_SIZE < adsData.length) { await new Promise(function(r) { setTimeout(r, 500 + Math.random() * 1e3); }); } } if (S.shouldStop) { await finishDealFinder(); return; } var descOk = 0; for (var dci = 0; dci < adsData.length; dci++) { if (adsData[dci].description) descOk++; } Logger$3.log("Descriptions: " + descOk + "/" + adsData.length + " on page " + S.currentPage); if (adsData.length > 4 && descOk < adsData.length * 0.3) { Logger$3.warn("Low description yield (" + descOk + "/" + adsData.length + ") β€” possible anti-bot throttling. Consider reducing page count or waiting between crawls."); } saveDescCache(prefix)["catch"](function(e) { Logger$3.debug("saveDescCache:", e && e.message || String(e)); }); updateProgress(prefix, "Seite " + S.currentPage + ": AI analysiert Angebote...", 75, "info"); Logger$3.log("Sending " + adsData.length + " listings to " + settings.provider.type + "..."); const prompt = buildAnalysisPrompt(adsData, settings.searchContext, settings.topX, scraper.siteName); const aiCallStart = Date.now(); const onRetry = function(retryNum, error) { showWarning(prefix, "API " + (error.status || "error") + " - Retry " + retryNum + "...", 75); }; let aiResult = null; try { aiResult = await callAI(prompt, { providerType: settings.provider.type, apiKey: settings.provider.apiKey, modelId: settings.provider.modelId, baseUrl: settings.provider.baseUrl || void 0, providerOptions: settings.provider.options || {} }, { temperature: 0.1, maxOutputTokens: getMaxTokensForProvider(settings.provider.type), onRetry, signal: S.abortController && S.abortController.signal }); Logger$3.log("AI response received in " + (Date.now() - aiCallStart) + "ms"); } catch (error) { if (error.name === "AbortError" || S.shouldStop) { await finishDealFinder(); return; } Logger$3.warn("AI analysis failed β€” attempting salvage retry..."); updateProgress(prefix, "Seite " + S.currentPage + ": Erneuter Analyse-Versuch...", 75, "warning"); try { var salvagePrompt = 'Return ONLY a JSON object with a "topDeals" array containing exactly ' + Math.min(3, adsData.length) + ' deals. Format: {"topDeals":[{"title":"...","price":"...","score":0-100,"reasoning":"...","url":"..."}]}. If you cannot analyze these listings, return {"topDeals":[]}. DO NOT add commentary.'; var salvageListingData = adsData.slice(0, Math.min(20, adsData.length)).map(function(ad) { return (ad.title || "") + " | " + (ad.price || "") + " | " + (ad.url || ""); }).join("\n"); aiResult = await callAI(salvagePrompt + "\n\nListings:\n" + salvageListingData, { providerType: settings.provider.type, apiKey: settings.provider.apiKey, modelId: settings.provider.modelId, baseUrl: settings.provider.baseUrl || void 0, providerOptions: settings.provider.options || {} }, { temperature: 0, maxOutputTokens: 4096, signal: S.abortController && S.abortController.signal }); Logger$3.log("Salvage retry succeeded"); } catch (salvageError) { if (salvageError.name === "AbortError" || S.shouldStop) { await finishDealFinder(); return; } Logger$3.warn("Salvage retry also failed β€” using price heuristics for this page"); var priceAds = adsData.filter(function(ad) { return ad.price && ad.url; }); priceAds.sort(function(a, b) { var pa = parseFloat(String(a.price).replace(/[^0-9,.-]/g, "").replace(",", ".")); var pb = parseFloat(String(b.price).replace(/[^0-9,.-]/g, "").replace(",", ".")); return (isNaN(pa) ? Infinity : pa) - (isNaN(pb) ? Infinity : pb); }); var fallbackTopX = Math.min(settings.topX, priceAds.length); aiResult = { topDeals: priceAds.slice(0, fallbackTopX).map(function(ad, idx) { return { title: ad.title || "Unknown", price: ad.price || "Unknown", score: Math.round(80 - idx * 5), reasoning: "Preis-Heuristik (KI-Analyse nicht verfuegbar)", url: ad.url }; }) }; } } if (aiResult && aiResult.topDeals && aiResult.topDeals.length > 0) { Logger$3.log("AI found " + aiResult.topDeals.length + " top deals"); const scrapedDescs = new Map(); const pathDescs = new Map(); for (let adi = 0; adi < adsData.length; adi++) { if (adsData[adi].description && adsData[adi].url) { scrapedDescs.set(adsData[adi].url, adsData[adi].description); try { var pUrl = new URL(adsData[adi].url); var pathKey = pUrl.hostname + pUrl.pathname; if (!pathDescs.has(pathKey)) pathDescs.set(pathKey, adsData[adi].description); } catch (e) { } } } for (let tdi = 0; tdi < aiResult.topDeals.length; tdi++) { const rawDeal = aiResult.topDeals[tdi]; let description = rawDeal.description || ""; var lookupKey = normalizeUrl(rawDeal.url) || rawDeal.url; var fullDesc = lookupKey && scrapedDescs.has(lookupKey) ? scrapedDescs.get(lookupKey) : ""; if (!fullDesc && lookupKey) { try { var lUrl = new URL(lookupKey); var lPathKey = lUrl.hostname + lUrl.pathname; fullDesc = pathDescs.get(lPathKey) || ""; } catch (e) { } } if (fullDesc) description = fullDesc; const normalized = { title: rawDeal.title || "Unknown", price: rawDeal.price || "Unknown", description: description || "", url: normalizeUrl(rawDeal.url) || "", score: rawDeal.score, reasoning: rawDeal.reasoning || rawDeal.reason || "Keine Begruendung", page: S.currentPage }; S.allTopDeals.push(normalized); } updateProgress(prefix, "Seite " + S.currentPage + ": " + aiResult.topDeals.length + " Top-Deals gefunden!", 90, "success"); updateLiveRanking(prefix, S.allTopDeals, S.cachedSettings); } await new Promise(function(r) { setTimeout(r, PAGE_TRANSITION_DELAY); }); if (!S.shouldStop) { const nextUrl = scraper.goToNextPage(S.currentPage); if (nextUrl) { Logger$3.log("Navigating to next page: " + nextUrl); await saveCrawlStateAndNavigate(nextUrl, settings); } else { Logger$3.log("No more pages available - ending crawl"); await finishDealFinder(); } } else { await finishDealFinder(); } } async function finishDealFinder() { if (S.finished) return; S.finished = true; const prefix = S.scraper.storagePrefix; updateProgress(prefix, "Erstelle finale Ranking-Liste...", 95, "info"); await clearCrawlState(prefix); if (S.allTopDeals.length === 0) { updateProgress(prefix, "Keine Deals gefunden!", 100, "error"); alert("Keine Top-Deals gefunden! Versuche andere Suchkriterien."); S.allTopDeals = []; resetUI(prefix); return; } if (S.shouldStop) { updateProgress(prefix, "Crawl gestoppt. Speichere bisherige Deals...", 100, "warning"); const dedupedStopped = deduplicateDeals(S.allTopDeals); await saveResults({ deals: dedupedStopped, pages: S.currentPage, timestamp: ( new Date()).toISOString() }, prefix); switchToResultsView(prefix, dedupedStopped); attachResultsListeners(prefix, makeResultsCallbacks(prefix)); S.allTopDeals = []; resetUI(prefix); return; } const deduped = deduplicateDeals(S.allTopDeals); S.allTopDeals = deduped; var cs = S.cachedSettings || {}; var canReRank = cs.provider && cs.provider.apiKey && cs.provider.type; if (S.allTopDeals.length > 1 && canReRank) { updateProgress(prefix, "Globales Re-Ranking aller Deals...", 97, "info"); try { const sortedTopDeals = sortDealsByScore(S.allTopDeals); const dealsToReRank = sortedTopDeals.slice(0, RE_RANK_MAX_DEALS); const reRankPrompt = buildAnalysisPrompt( dealsToReRank.map(function(d) { return { title: d.title, price: d.price, description: d.description || "", url: d.url }; }), cs.searchContext || "", dealsToReRank.length, S.scraper.siteName, 3e3 ); const reRankResult = await callAI(reRankPrompt, { providerType: cs.provider.type, apiKey: cs.provider.apiKey, modelId: cs.provider.modelId, baseUrl: cs.provider.baseUrl || void 0, providerOptions: cs.provider.options || {} }, { temperature: 0.1, maxOutputTokens: getMaxTokensForProvider(cs.provider.type), signal: S.abortController && S.abortController.signal }); if (reRankResult && reRankResult.topDeals) { const urlToDeal = new Map(); for (let ri = 0; ri < dealsToReRank.length; ri++) { urlToDeal.set(dealsToReRank[ri].url, dealsToReRank[ri]); } const reRankedDeals = reRankResult.topDeals.map(function(rd) { const orig = urlToDeal.get(rd.url); return { title: orig && orig.title || rd.title, price: orig && orig.price || rd.price, description: orig && orig.description || rd.description, url: orig && orig.url || rd.url, score: rd.score, reasoning: rd.reasoning || rd.reason || "", page: orig && orig.page || "unknown" }; }); const reRankedUrls = new Set(reRankedDeals.map(function(d) { return d.url; })); const remainingDeals = sortedTopDeals.filter(function(d) { return !reRankedUrls.has(d.url); }); S.allTopDeals = sortDealsByScore(reRankedDeals.concat(remainingDeals)); Logger$3.log("Global re-ranking complete"); } } catch (e) { Logger$3.warn("Global re-ranking failed:", e); showWarning(prefix, "Re-Ranking fehlgeschlagen β€” Ergebnisse ohne Neusortierung", 95); } } var refetchCount = 0; for (var rfi = 0; rfi < S.allTopDeals.length; rfi++) { var deal = S.allTopDeals[rfi]; if (!deal.description || deal.description.length < 20) { try { var refetched = await fetchFullDescription(deal.url, S.scraper.descSelectors()); if (refetched && refetched.success && refetched.description) { deal.description = refetched.description; refetchCount++; } } catch (e) { } } } if (refetchCount > 0) Logger$3.log("Refetch filled " + refetchCount + " missing descriptions"); await saveResults({ deals: S.allTopDeals, pages: S.currentPage, timestamp: ( new Date()).toISOString() }, prefix); updateProgress(prefix, S.allTopDeals.length + " Deals gespeichert!", 100, "success"); if ("Notification" in window && Notification.permission === "granted") { try { new Notification("Deal Finder fertig", { body: S.allTopDeals.length + " Deals auf " + S.currentPage + " Seiten gefunden" }); } catch (e) { } } switchToResultsView(prefix, S.allTopDeals); attachResultsListeners(prefix, makeResultsCallbacks(prefix)); S.allTopDeals = []; resetUI(prefix); } async function setupSettingsView(scraper) { const prefix = scraper.storagePrefix; const result = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = result.cachedSettings; const settings = result.settings; const savedResults = await loadResults(prefix); if (!S.uiContent) return; S.uiContent.innerHTML = renderSettingsView(prefix, settings, savedResults, scraper.siteName); attachSettingsListeners(prefix, { start: startDealFinder, pause: pauseDealFinder, stop: stopDealFinder, close: function() { const isRunning = S.isRunning; closeModal(prefix, isRunning); }, showSavedResults: async function() { const sr = await loadResults(prefix); if (sr) { switchToResultsView(prefix, sr.deals); attachResultsListeners(prefix, makeResultsCallbacks(prefix)); } }, apiKeyChange: async function(newKey) { const s = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = s.cachedSettings; const settingsObj = s.settings; if (settingsObj.provider.apiKey !== newKey) { settingsObj.provider.apiKey = newKey; await saveSettings(prefix, settingsObj); S.cachedSettings = deepCopySettings(settingsObj); } }, searchContextChange: async function(newContext) { const s = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = s.cachedSettings; const settingsObj = s.settings; if (settingsObj.searchContext !== newContext) { settingsObj.searchContext = newContext; await saveSettings(prefix, settingsObj); S.cachedSettings = deepCopySettings(settingsObj); } }, providerChange: async function(newType) { const s = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = s.cachedSettings; const settingsObj = s.settings; if (settingsObj.currentProvider !== newType) { settingsObj.currentProvider = newType; settingsObj.provider = settingsObj.providers[newType] || {}; settingsObj.provider.type = newType; if (!settingsObj.provider.modelId) { settingsObj.provider.modelId = getDefaultModelForProvider(newType); } await saveSettings(prefix, settingsObj); S.cachedSettings = deepCopySettings(settingsObj); S.cachedSettings.provider = S.cachedSettings.providers[S.cachedSettings.currentProvider] || {}; await setupSettingsView(scraper)["catch"](function(err) { Logger$3.error("Re-render after provider change failed:", err); }); } }, modelIdChange: async function(newId) { const s = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = s.cachedSettings; const settingsObj = s.settings; if (settingsObj.provider.modelId !== newId) { settingsObj.provider.modelId = newId; const matchingPreset = (MODEL_PRESETS[settingsObj.currentProvider] || []).find(function(p) { return p.id === newId; }); settingsObj.provider.options = matchingPreset && matchingPreset.options ? matchingPreset.options : {}; await saveSettings(prefix, settingsObj); S.cachedSettings = deepCopySettings(settingsObj); S.uiRoot.querySelectorAll("#" + prefix + "-model-presets [data-model-id]").forEach(function(btn) { const isActive = btn.getAttribute("data-model-id") === newId; btn.style.background = isActive ? "#6366f1" : "#f8f9fa"; btn.style.color = isActive ? "#fff" : "#333"; btn.style.borderColor = isActive ? "#6366f1" : "#ddd"; }); } }, modelPresetClick: async function(modelId, options) { const modelIdInput = S.uiRoot.getElementById(prefix + "-model-id"); if (modelIdInput) { modelIdInput.value = modelId; const s = await loadSettings(prefix, S.cachedSettings); s.settings.provider.modelId = modelId; if (options) s.settings.provider.options = options; await saveSettings(prefix, s.settings); S.cachedSettings = deepCopySettings(s.settings); S.uiRoot.querySelectorAll("#" + prefix + "-model-presets [data-model-id]").forEach(function(btn) { const isActive = btn.getAttribute("data-model-id") === modelId; btn.style.background = isActive ? "#6366f1" : "#f8f9fa"; btn.style.color = isActive ? "#fff" : "#333"; btn.style.borderColor = isActive ? "#6366f1" : "#ddd"; }); } }, baseUrlChange: async function(newUrl) { const s = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = s.cachedSettings; const settingsObj = s.settings; if (settingsObj.provider.baseUrl !== newUrl) { settingsObj.provider.baseUrl = newUrl; await saveSettings(prefix, settingsObj); S.cachedSettings = deepCopySettings(settingsObj); } }, portkeyConfigChange: async function(newConfig) { const s = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = s.cachedSettings; const settingsObj = s.settings; if (!settingsObj.provider.options) settingsObj.provider.options = {}; if (settingsObj.provider.options.config !== newConfig) { settingsObj.provider.options.config = newConfig; await saveSettings(prefix, settingsObj); S.cachedSettings = deepCopySettings(settingsObj); } } }); } function getDefaultModelForProvider(providerType) { const presets = MODEL_PRESETS[providerType]; return presets && presets.length > 0 ? presets[0].id : "gemini-2.5-flash"; } function makeResultsCallbacks(prefix) { return { close: function() { closeModal(prefix, S.isRunning); }, backToSettings: function() { setupSettingsView(S.scraper); }, exportMarkdown: async function() { await exportMarkdown(prefix); }, exportJSON: function() { exportJSON(prefix); }, exportCSV: function() { exportCSV(prefix); }, clearResults: async function() { if (!confirm("Moechtest du die gespeicherten Results wirklich loeschen?")) return; await clearResults(prefix); setupSettingsView(S.scraper); } }; } async function resumeCrawlIfActive(scraper) { const prefix = scraper.storagePrefix; const rawState = await loadCrawlState(prefix); if (!rawState) { Logger$3.log("Normal session"); return; } const currentAds = scraper.findAds(); if (!currentAds) { Logger$3.log("Stale crawl state found but current page has no search results β€” clearing"); await clearCrawlState(prefix); return; } const normalizedCurrentUrl = normalizeUrl(rawState.currentUrl); const normalizedWindowUrl = normalizeUrl(window.location.href); const samePage = normalizedCurrentUrl && normalizedCurrentUrl === normalizedWindowUrl; var looseMatch = false; if (!samePage && normalizedCurrentUrl && normalizedWindowUrl) { try { var savedUrl = new URL(normalizedCurrentUrl); var currentUrl = new URL(normalizedWindowUrl); if (savedUrl.hostname === currentUrl.hostname && savedUrl.pathname.replace(/\/$/, "") === currentUrl.pathname.replace(/\/$/, "")) { var trackingKeys = [ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "ref", "source", "campaign" ]; var savedParams = new URLSearchParams(savedUrl.search); var currentParams = new URLSearchParams(currentUrl.search); trackingKeys.forEach(function(k) { savedParams.delete(k); currentParams.delete(k); }); savedParams.sort(); currentParams.sort(); if (savedParams.toString() === currentParams.toString()) { looseMatch = true; Logger$3.log("Loose URL match β€” same search, different tracking params"); } } } catch (e) { } } const resumeRaw = await loadSetting(prefix + "_dealfinder_resume", null); await saveSetting(prefix + "_dealfinder_resume", null); let isScriptNavigation = false; if (resumeRaw) { try { const flag = JSON.parse(resumeRaw); isScriptNavigation = normalizeUrl(flag.u || "") === normalizeUrl(window.location.href); } catch (e) { } } if (!isScriptNavigation && !samePage && !looseMatch) { Logger$3.log("Stale crawl state from different search β€” clearing"); await clearCrawlState(prefix); return; } const pageIncrement = samePage ? SAME_PAGE_INCREMENT : NEW_PAGE_INCREMENT; Logger$3.log("Crawl state found - resuming from page " + (rawState.currentPage + pageIncrement)); S.currentPage = rawState.currentPage + pageIncrement; S.allTopDeals = rawState.allTopDeals || []; S.isRunning = true; S.scraper = scraper; openModal(prefix); const result = await loadSettings(prefix, S.cachedSettings); S.cachedSettings = result.cachedSettings; const settings = result.settings; const maxPages = rawState.maxPages || settings.maxPages || 10; settings.maxPages = maxPages; if (S.cachedSettings) { S.cachedSettings.maxPages = maxPages; } setUIRunningState(prefix); try { await processCurrentPage(settings); } catch (error) { Logger$3.error("Error resuming:", error); updateProgress(prefix, "Fehler: " + error.message, 0, "error"); await clearCrawlState(prefix); if (S.allTopDeals.length > 0) { await finishDealFinder(); } else { resetUI(prefix); alert("Fehler beim Fortsetzen: " + error.message); } } } const Logger$2 = createLogger("MDF Main"); async function waitForPage(scraper) { const isWH = scraper.siteName === "WILLHABEN"; if (isWH) { const searchIndicators = [ '[data-testid="result-list-title"]', '[data-testid*="search-result"]', 'a[href*="/iad/"]' ]; let hasIndicator = false; for (let ssi = 0; ssi < searchIndicators.length; ssi++) { if (document.querySelector(searchIndicators[ssi])) { hasIndicator = true; break; } } if (!hasIndicator) { throw new Error("No search results detected"); } } else { try { await waitForElement("article[data-adid], #srchrslt-adtable", 1e4); } catch (e) { throw new Error("No ad list detected"); } } } async function init(retryCount) { retryCount = retryCount || 0; try { const scraper = getScraper(); setScraper(scraper); const result = await loadSettings(scraper.storagePrefix); setCachedSettings(result.cachedSettings); Logger$2.log("Initializing..."); for (let attempt = 0; attempt < MAX_INIT_RETRIES; attempt++) { try { await waitForPage(scraper); break; } catch (e) { if (attempt < MAX_INIT_RETRIES - 1) { Logger$2.log("Page not ready, retrying in 3s..."); await new Promise(function(r) { setTimeout(r, 3e3); }); } else { Logger$2.warn("Max retries reached - showing button anyway"); } } } await new Promise(function(r) { setTimeout(r, 1500); }); createModal(scraper.storagePrefix); createDealFinderButton(scraper.storagePrefix, scraper.buttonGradient); const floatBtn = document.getElementById(scraper.storagePrefix + "-dealfinder-btn"); if (floatBtn) { floatBtn.addEventListener("click", function() { openModal(scraper.storagePrefix); setupSettingsView(scraper)["catch"](function(err) { Logger$2.error("Failed to load settings view:", err); }); }); } await setupSettingsView(scraper); await resumeCrawlIfActive(scraper); } catch (error) { Logger$2.error("Initialization error:", error); if (retryCount >= MAX_INIT_RETRIES) { Logger$2.error("Fatal init failure after " + MAX_INIT_RETRIES + " retries:", error); Logger$2.error("Could not initialize. Please reload the page or check the console for details."); return; } await new Promise(function(r) { setTimeout(r, 3e3); }); return init(retryCount + 1); } } const Logger$1 = createLogger("MDF Gemini"); class GeminiProvider extends AIProvider { constructor(config) { super(config); } getEndpoint() { return `https://generativelanguage.googleapis.com/v1beta/models/${this.config.modelId}:generateContent`; } getAuthHeaders() { return { "x-goog-api-key": this.config.apiKey, "Content-Type": "application/json" }; } buildRequest(prompt, options = {}) { const body = { contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: options.temperature ?? 0.1, topK: 32, topP: 0.95, maxOutputTokens: options.maxOutputTokens ?? 8192, responseMimeType: "application/json", responseSchema: { type: "object", properties: { topDeals: { type: "array", items: { type: "object", properties: { url: { type: "string" }, title: { type: "string" }, price: { type: "string" }, description: { type: "string" }, score: { type: "integer" }, reasoning: { type: "string" } }, required: ["url", "title", "price", "score", "reasoning"] } } }, required: ["topDeals"] } } }; const opts = this.config.providerOptions || {}; if (opts.thinkingBudget !== void 0) { body.generationConfig.thinkingConfig = { thinkingBudget: opts.thinkingBudget }; } else if (opts.thinkingLevel) { body.generationConfig.thinkingConfig = { thinkingLevel: opts.thinkingLevel }; } return body; } parseResponse(response) { var _a; if (!response || !response.candidates || response.candidates.length === 0) { throw new Error("Gemini: empty response β€” no candidates returned"); } const candidate = response.candidates[0]; if (candidate.finishReason === "SAFETY" || candidate.finishReason === "BLOCKLIST") { throw new Error(`Gemini: blocked β€” finishReason: ${candidate.finishReason}`); } if (candidate.finishReason === "MAX_TOKENS") { Logger$1.warn("Gemini response may be truncated β€” max tokens reached"); } const parts = (_a = candidate.content) == null ? void 0 : _a.parts; if (!parts || parts.length === 0) { throw new Error("Gemini: response missing content parts"); } return parts[0].text || ""; } } const ENDPOINTS = { [PROVIDER_TYPES.OPENAI]: "https://api.openai.com/v1/chat/completions", [PROVIDER_TYPES.DEEPSEEK]: "https://api.deepseek.com/chat/completions", [PROVIDER_TYPES.OPENROUTER]: "https://openrouter.ai/api/v1/chat/completions", [PROVIDER_TYPES.PORTKEY]: "https://api.portkey.ai/v1/chat/completions" }; class OpenAICompatibleProvider extends AIProvider { constructor(config) { super(config); } getEndpoint() { return this.config.baseUrl || ENDPOINTS[this.config.type]; } getAuthHeaders() { const headers = { "Content-Type": "application/json" }; if (this.config.type === PROVIDER_TYPES.PORTKEY) { headers["x-portkey-api-key"] = this.config.apiKey; const opts = this.config.providerOptions || {}; if (opts.config) { headers["x-portkey-config"] = opts.config; headers["x-portkey-debug"] = "false"; } else { if (opts.providerSlug) { headers["x-portkey-provider"] = opts.providerSlug; } if (opts.virtualKey) { headers["x-portkey-virtual-key"] = opts.virtualKey; } } } else { headers["Authorization"] = `Bearer ${this.config.apiKey}`; } if (this.config.type === PROVIDER_TYPES.OPENROUTER) { headers["HTTP-Referer"] = "https://github.com/marmoris-x/tampermonkey-scripts"; headers["X-OpenRouter-Title"] = "Marketplace Deal Finder"; } return headers; } buildRequest(prompt, options = {}) { const body = { model: this.config.modelId, messages: [ { role: "system", content: "You extract structured deal data from classified ads. Always respond with valid JSON matching the requested schema." }, { role: "user", content: prompt } ], temperature: options.temperature ?? 0.1, max_tokens: options.maxOutputTokens ?? 8192 }; const opts = this.config.providerOptions || {}; if (!opts.skip_response_format) { body.response_format = { type: "json_object" }; } if (opts.reasoning_effort) { body.reasoning_effort = opts.reasoning_effort; } if (this.config.type === PROVIDER_TYPES.DEEPSEEK && opts.reasoning_effort === "max") { body.thinking = { type: "enabled" }; } return body; } parseResponse(response) { var _a; if (!response || !response.choices || response.choices.length === 0) { throw new Error(`${this.config.type}: empty response β€” no choices returned`); } const choice = response.choices[0]; if (choice.finish_reason && choice.finish_reason !== "stop" && choice.finish_reason !== "length") { throw new Error(`${this.config.type}: unexpected finish_reason: ${choice.finish_reason}`); } const content = (_a = choice.message) == null ? void 0 : _a.content; if (!content) { throw new Error(`${this.config.type}: response missing message content`); } return content; } } const Logger = createLogger("MDF Claude"); class ClaudeProvider extends AIProvider { constructor(config) { super(config); } getEndpoint() { return "https://api.anthropic.com/v1/messages"; } getAuthHeaders() { return { "x-api-key": this.config.apiKey, "anthropic-version": "2023-06-01", "Content-Type": "application/json" }; } buildRequest(prompt, options = {}) { const opts = this.config.providerOptions || {}; const isLegacyThinking = opts.thinking && opts.thinking.type === "enabled"; const isAdaptiveThinking = opts.thinking && opts.thinking.type === "adaptive"; const body = { model: this.config.modelId, max_tokens: options.maxOutputTokens ?? 32e3, system: "You extract structured deal data from classified ads. Always respond with valid JSON matching the requested schema. Return ONLY valid JSON β€” no markdown, no code fences, no explanation.", messages: [ { role: "user", content: prompt } ] }; if (isLegacyThinking) { if (!opts.thinking.budget_tokens || typeof opts.thinking.budget_tokens !== "number") { Logger.warn("Claude legacy thinking enabled but budget_tokens missing β€” disabling"); } else { const minMax = opts.thinking.budget_tokens + 4096; if (body.max_tokens <= opts.thinking.budget_tokens) { body.max_tokens = Math.max(body.max_tokens, minMax); } body.thinking = opts.thinking; } } if (isAdaptiveThinking) { body.thinking = opts.thinking; } if (!body.thinking) { body.temperature = options.temperature ?? 0.1; } return body; } parseResponse(response) { if (!response || !response.content || response.content.length === 0) { throw new Error("Claude: empty response β€” no content returned"); } const block = response.content[0]; if (block.type !== "text") { throw new Error(`Claude: unexpected content block type: ${block.type}`); } if (response.stop_reason && response.stop_reason !== "end_turn" && response.stop_reason !== "stop" && response.stop_reason !== "max_tokens") { throw new Error(`Claude: unexpected stop_reason: ${response.stop_reason}`); } if (response.stop_reason === "max_tokens") { Logger.warn("Claude response may be truncated β€” max tokens reached"); } return block.text || ""; } } registerProvider("gemini", GeminiProvider); registerProvider("openai", OpenAICompatibleProvider); registerProvider("deepseek", OpenAICompatibleProvider); registerProvider("openrouter", OpenAICompatibleProvider); registerProvider("portkey", OpenAICompatibleProvider); registerProvider("claude", ClaudeProvider); init(); })();