/** * eBay Content Script * Wird auf eBay-Seiten ausgeführt und extrahiert Verkäufer-/Shop-Daten aus dem DOM */ // Message Listener für Parsing-Anfragen chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "PARSE_EBAY") { try { const parsedData = parseEbayPage(); sendResponse({ ok: true, data: parsedData }); } catch (error) { // Niemals unhandled throws - immer graceful response sendResponse({ ok: true, data: { sellerId: "", shopName: "", market: extractMarketFromHostname(), status: "unknown", stats: {} } }); } return true; // async response } if (message.action === "PARSE_PRODUCT_LIST") { // async function, need to return promise parseProductList() .then(result => { // result hat bereits die Struktur { ok, items?, error?, meta } if (result.ok) { sendResponse({ ok: true, items: result.items || [], meta: result.meta || {} }); } else { sendResponse({ ok: false, error: result.error || "Failed to parse product list", meta: result.meta || {} }); } }) .catch(error => { // Niemals unhandled throws - immer graceful response console.error("Error parsing product list:", error); const pageType = detectPageType(); sendResponse({ ok: false, error: error.message || "Failed to parse product list", meta: { pageType, finalUrl: window.location.href, attempts: 1, reason: "unhandled_error" } }); }); return true; // async response } }); /** * Extrahiert Marktplatz aus hostname * @returns {string} Market Code (z.B. "DE", "US", "UK") */ function extractMarketFromHostname() { try { const hostname = window.location.hostname.toLowerCase(); // eBay Domain-Patterns if (hostname.includes('.de') || hostname.includes('ebay.de')) { return 'DE'; } if (hostname.includes('.com') && !hostname.includes('.uk')) { return 'US'; } if (hostname.includes('.uk') || hostname.includes('ebay.co.uk')) { return 'UK'; } if (hostname.includes('.fr') || hostname.includes('ebay.fr')) { return 'FR'; } if (hostname.includes('.it') || hostname.includes('ebay.it')) { return 'IT'; } if (hostname.includes('.es') || hostname.includes('ebay.es')) { return 'ES'; } if (hostname.includes('.nl') || hostname.includes('ebay.nl')) { return 'NL'; } if (hostname.includes('.at') || hostname.includes('ebay.at')) { return 'AT'; } if (hostname.includes('.ch') || hostname.includes('ebay.ch')) { return 'CH'; } // Fallback: erster Teil der Domain nach "ebay." const match = hostname.match(/ebay\.([a-z]{2,3})/); if (match && match[1]) { return match[1].toUpperCase(); } return 'US'; // Default } catch (e) { return 'US'; } } /** * Extrahiert Seller ID aus URL oder DOM * @returns {string} Seller ID */ function extractSellerId() { try { // Methode 1: URL-Patterns const url = window.location.href; const urlLower = url.toLowerCase(); // Pattern: /usr/username oder /str/storename const usrMatch = url.match(/\/usr\/([^\/\?]+)/i); if (usrMatch && usrMatch[1]) { return usrMatch[1].trim(); } const strMatch = url.match(/\/str\/([^\/\?]+)/i); if (strMatch && strMatch[1]) { return strMatch[1].trim(); } // Methode 2: DOM-Elemente suchen // Suche nach verschiedenen Selektoren, die Seller-ID enthalten könnten const possibleSelectors = [ '[data-testid*="seller"]', '.seller-username', '.member-info-username', '[class*="seller-id"]', '[class*="username"]', '[id*="seller"]' ]; for (const selector of possibleSelectors) { try { const element = document.querySelector(selector); if (element) { const text = element.textContent?.trim(); if (text && text.length > 0 && text.length < 100) { return text; } } } catch (e) { // Continue to next selector } } // Methode 3: Meta-Tags try { const metaSeller = document.querySelector('meta[property*="seller"], meta[name*="seller"]'); if (metaSeller && metaSeller.content) { return metaSeller.content.trim(); } } catch (e) { // Continue } return ""; // Nicht gefunden } catch (e) { return ""; } } /** * Extrahiert Shop Name aus DOM * @returns {string} Shop Name */ function extractShopName() { try { // Methode 1: Shop-Name aus Storefront-Link extrahieren // Pattern: Shop Name try { const storefrontLinks = document.querySelectorAll('a[href*="/str/"]'); for (const link of storefrontLinks) { // Prüfe ob Link data-track Attribut hat (typisch für Storefront-Links) if (link.hasAttribute('data-track') || link.href.includes('/str/')) { const linkText = link.textContent?.trim(); if (linkText && linkText.length > 0 && linkText.length < 200) { // Prüfe dass es nicht nur ein URL-Pfad ist if (!linkText.match(/^https?:\/\//) && !linkText.match(/^\/str\//)) { return linkText; } } } } } catch (e) { // Continue to next method } // Methode 2: Spezifische Selektoren versuchen const shopNameSelectors = [ 'h1.shop-name', '.store-name', '[data-testid="store-name"]', '.store-title', '[class*="shop-name"]', '[class*="store-title"]', 'h1[class*="store"]', 'h1[class*="shop"]' ]; for (const selector of shopNameSelectors) { try { const element = document.querySelector(selector); if (element) { const text = element.textContent?.trim(); if (text && text.length > 0) { return text; } } } catch (e) { // Continue to next selector } } // Methode 3: document.title parsen try { const title = document.title || ""; // Versuche Muster wie "Shop Name | eBay" zu extrahieren const titleMatch = title.match(/^(.+?)\s*[\|\-]\s*eBay/i); if (titleMatch && titleMatch[1]) { return titleMatch[1].trim(); } // Wenn Titel einfach genug ist, verwende ihn direkt if (title && title.length < 100 && !title.toLowerCase().includes('ebay')) { return title.trim(); } } catch (e) { // Continue } // Methode 4: h1 Tag als Fallback try { const h1 = document.querySelector('h1'); if (h1) { const text = h1.textContent?.trim(); if (text && text.length > 0 && text.length < 200) { return text; } } } catch (e) { // Continue } return ""; // Nicht gefunden } catch (e) { return ""; } } /** * Extrahiert "Artikel verkauft" aus Storefront-Profilen * @returns {number|null} Anzahl verkaufter Artikel oder null */ function extractItemsSold() { try { // Suche nach Container const container = document.querySelector(".str-seller-card__store-stats-content"); if (!container) { return null; } // Finde div children, die "Artikel verkauft" enthalten const divs = container.querySelectorAll("div"); for (const div of divs) { const divText = div.textContent || ""; if (divText.includes("Artikel verkauft")) { // Suche nach span mit Klasse str-text-span BOLD (Klasse kann als "str-text-span BOLD" sein) const span1 = div.querySelector("span.str-text-span.BOLD"); const span2 = div.querySelector("span.BOLD"); const span3 = div.querySelector('span[class*="BOLD"]'); const span = span1 || span2 || span3; if (span) { let valueText = span.textContent?.trim() || ""; // Normalisierung: Entferne Leerzeichen valueText = valueText.replace(/\s/g, ""); // Ersetze Tausendertrenner (. und ,) durch leeren String valueText = valueText.replace(/[.,]/g, ""); // Nur Digits behalten valueText = valueText.replace(/\D/g, ""); // Parse zu Integer if (valueText.length > 0) { const parsedValue = parseInt(valueText, 10); if (!isNaN(parsedValue) && parsedValue >= 0) { return parsedValue; } } } // Fallback: Regex auf div-Text, falls Struktur abweicht const regexMatch = divText.match(/([\d.,]+)\s*Artikel\s*verkauft/i); if (regexMatch && regexMatch[1]) { let valueText = regexMatch[1].trim(); valueText = valueText.replace(/\s/g, "").replace(/[.,]/g, "").replace(/\D/g, ""); if (valueText.length > 0) { const parsedValue = parseInt(valueText, 10); if (!isNaN(parsedValue) && parsedValue >= 0) { return parsedValue; } } } } } return null; } catch (e) { // Graceful fallback bei Fehlern return null; } } /** * Extrahiert Stats aus DOM (Feedback Score, Positive Rate, etc.) * @returns {object} Stats Objekt */ function extractStats() { const stats = {}; try { const pageText = document.body?.textContent || ""; // Feedback Score try { const feedbackScorePatterns = [ /feedback\s*score[:\s]+(\d+)/i, /(?:score|bewertung)[:\s]+(\d+)/i, /(\d+)\s*feedback/i ]; for (const pattern of feedbackScorePatterns) { const match = pageText.match(pattern); if (match && match[1]) { const score = parseInt(match[1], 10); if (!isNaN(score) && score >= 0) { stats.feedbackScore = score; break; } } } } catch (e) { // Continue } // Positive Rate (%) try { const positiveRatePatterns = [ /positive[:\s]+(\d+\.?\d*)\s*%/i, /(\d+\.?\d*)\s*%\s*positive/i, /(?:rate|quote)[:\s]+(\d+\.?\d*)\s*%/i ]; for (const pattern of positiveRatePatterns) { const match = pageText.match(pattern); if (match && match[1]) { const rate = parseFloat(match[1]); if (!isNaN(rate) && rate >= 0 && rate <= 100) { stats.positiveRate = rate; break; } } } } catch (e) { // Continue } // Feedback Count try { const feedbackCountPatterns = [ /(\d+)\s*(?:bewertungen|ratings|feedbacks?)/i, /(?:count|anzahl)[:\s]+(\d+)/i ]; for (const pattern of feedbackCountPatterns) { const match = pageText.match(pattern); if (match && match[1]) { const count = parseInt(match[1], 10); if (!isNaN(count) && count >= 0) { stats.feedbackCount = count; break; } } } } catch (e) { // Continue } // Items for Sale try { const itemsPatterns = [ /items?\s*(?:for\s*)?sale[:\s]+(\d+)/i, /(\d+)\s*artikel/i, /(\d+)\s*(?:items?|artikel)\s*(?:for\s*)?sale/i ]; for (const pattern of itemsPatterns) { const match = pageText.match(pattern); if (match && match[1]) { const items = parseInt(match[1], 10); if (!isNaN(items) && items >= 0) { stats.itemsForSale = items; break; } } } } catch (e) { // Continue } // Member Since try { const memberSincePatterns = [ /member\s*since[:\s]+(\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})/i, /mitglied\s*seit[:\s]+(\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})/i, /(?:since|seit)[:\s]+(\d{4})/i ]; for (const pattern of memberSincePatterns) { const match = pageText.match(pattern); if (match && match[1]) { stats.memberSince = match[1].trim(); break; } } } catch (e) { // Continue } // Items Sold (Artikel verkauft) try { const itemsSold = extractItemsSold(); if (itemsSold !== null) { stats.itemsSold = itemsSold; } } catch (e) { // Continue } } catch (e) { // Return empty stats object } return stats; } /** * Haupt-Parsing-Funktion * @returns {object} Parsed eBay Account Data */ function parseEbayPage() { try { const sellerId = extractSellerId(); const shopName = extractShopName(); const market = extractMarketFromHostname(); const stats = extractStats(); // Status bestimmen: "active" wenn wir mindestens Shop Name oder Seller ID haben const status = (sellerId || shopName) ? "active" : "unknown"; return { sellerId: sellerId || "", shopName: shopName || "", market: market, status: status, stats: stats }; } catch (error) { // Graceful fallback return { sellerId: "", shopName: "", market: extractMarketFromHostname(), status: "unknown", stats: {} }; } } /** * Erkennt den Seitentyp basierend auf URL und DOM * @returns {string} Page Type: "storefront" | "seller_profile" | "feedback" | "search_results" | "unknown" */ function detectPageType() { try { const pathname = window.location.pathname.toLowerCase(); const search = window.location.search.toLowerCase(); // Storefront if (pathname.includes("/str/")) { return "storefront"; } // Seller Profile if (pathname.includes("/usr/")) { return "seller_profile"; } // Feedback if (search.includes("_tab=feedback") || pathname.includes("feedback")) { return "feedback"; } // Search Results if (document.querySelector("ul.srp-results")) { return "search_results"; } return "unknown"; } catch (e) { return "unknown"; } } /** * Wartet auf Item-Links, bis minLinks gefunden wurden oder Timeout * @param {number} maxMs - Maximale Wartezeit in ms (default: 4000) * @param {number} intervalMs - Intervall zwischen Checks in ms (default: 250) * @param {number} minLinks - Minimale Anzahl Links (default: 5) * @returns {Promise} Array von Link-Elementen (kann 0 sein) */ async function waitForItemLinks(maxMs = 4000, intervalMs = 250, minLinks = 5) { const startTime = Date.now(); while (Date.now() - startTime < maxMs) { const links = findItemLinks(); if (links.length >= minLinks) { return links; } // Warte auf nächsten Check await new Promise(resolve => setTimeout(resolve, intervalMs)); } // Timeout: gib gefundene Links zurück (kann 0 sein) return findItemLinks(); } /** * Findet Navigations-Link zu Items-Seite (Angebote/Artikel) * @returns {string|null} Absolute URL oder null */ function findItemsNavigationLink() { try { const allLinks = Array.from(document.querySelectorAll("a")); const candidates = []; for (const link of allLinks) { const href = link.href || link.getAttribute("href"); const text = (link.textContent || "").toLowerCase().trim(); if (!href) continue; const hrefLower = href.toLowerCase(); // Prüfe Anchor-Text const textMatches = text.includes("artikel") || text.includes("angebote") || text.includes("items") || text.includes("items for sale") || text.includes("shop") || text.includes("shop anzeigen"); // Prüfe href-Patterns const hrefMatches = hrefLower.includes("/sch/") || hrefLower.includes("/s/i.html") || hrefLower.includes("/str/") || hrefLower.includes("?_tab=selling"); if (textMatches || hrefMatches) { // Berechne Priorität let priority = 0; if (hrefLower.includes("/sch/")) priority = 1; else if (hrefLower.includes("/str/")) priority = 2; else if (hrefLower.includes("/s/i.html")) priority = 3; else if (text.includes("items")) priority = 4; else priority = 5; candidates.push({ link, href, priority }); } } if (candidates.length === 0) { return null; } // Sortiere nach Priorität (niedrigste Zahl = höchste Priorität) candidates.sort((a, b) => a.priority - b.priority); const bestCandidate = candidates[0].href; const currentUrl = window.location.href; // Prüfe ob URL sich unterscheidet if (bestCandidate === currentUrl) { return null; // Gleiche URL, keine Navigation nötig } // Erstelle absolute URL falls nötig try { const url = new URL(bestCandidate, window.location.origin); return url.href; } catch (e) { return null; } } catch (e) { return null; } } /** * Parst Produktliste von eBay Storefront oder Seller Listings * @returns {Promise<{ok: boolean, items?: Array, error?: string, meta: object}>} */ async function parseProductList() { let attempts = 1; let pageType = detectPageType(); let finalUrl = window.location.href; let reason = null; try { // Schritt 1: Warte auf Item-Links (Polling) let links = await waitForItemLinks(4000, 250, 5); // Schritt 2: Wenn keine Links gefunden, versuche Auto-Navigation (max. 1 Retry) if (links.length === 0 && attempts === 1) { const itemsLink = findItemsNavigationLink(); if (itemsLink && itemsLink !== window.location.href) { // Navigiere zur Items-Seite window.location.href = itemsLink; // Warte auf DOM-Ready nach Navigation await new Promise(resolve => setTimeout(resolve, 1200)); // Warte erneut auf Item-Links (längeres Timeout nach Navigation) links = await waitForItemLinks(5000, 250, 5); attempts = 2; pageType = detectPageType(); finalUrl = window.location.href; reason = "navigated_to_items_page"; // Wenn immer noch keine Links, gib Fehler zurück if (links.length === 0) { return { ok: false, error: "no_items_found", meta: { pageType, finalUrl, attempts, reason: "no_items_found_after_navigation" } }; } } else { // Kein Navigations-Link gefunden return { ok: false, error: "no_items_found", meta: { pageType, finalUrl, attempts, reason: links.length === 0 ? "no_items_links_on_page" : "timed_out_waiting_for_items" } }; } } // Schritt 3: Parse Items if (links.length === 0) { return { ok: false, error: "no_items_found", meta: { pageType, finalUrl, attempts, reason: reason || "no_items_links_on_page" } }; } // Parse each item const parsedItems = []; const seenIds = new Set(); for (const itemLink of links) { try { const item = parseItemFromLink(itemLink); // Deduplicate by platformProductId if (item.platformProductId && !seenIds.has(item.platformProductId)) { seenIds.add(item.platformProductId); parsedItems.push(item); } } catch (e) { // Continue with next item if one fails console.warn("Failed to parse item:", e); } } // Return max 60 items (first page) const result = parsedItems.slice(0, 60); return { ok: true, items: result, meta: { pageType, finalUrl, attempts, reason: reason || (result.length > 0 ? "items_found" : "parsed_zero_items") } }; } catch (error) { // Graceful error handling return { ok: false, error: error.message || "Failed to parse product list", meta: { pageType, finalUrl, attempts, reason: "parse_error" } }; } } try { const url = window.location.href; const urlLower = url.toLowerCase(); // Determine page type const isStorePage = urlLower.includes('/str/') || urlLower.includes('/store/'); const isSellerPage = urlLower.includes('/usr/'); // Check if seller profile without items (try to find link to listings) if (isSellerPage && !isStorePage) { const itemsLink = document.querySelector('a[href*="/usr/"][href*="?items="]') || document.querySelector('a[href*="schid=mksr"]') || Array.from(document.querySelectorAll('a')).find(a => { const text = (a.textContent || '').toLowerCase(); return text.includes('artikel') || text.includes('angebote') || text.includes('items for sale') || text.includes('see all items'); }); if (!itemsLink) { // Try to find item cards directly const hasItems = findItemLinks().length > 0; if (!hasItems) { throw new Error("no_items_page"); } } } // Extract items const items = findItemLinks(); if (items.length === 0) { throw new Error("no_items_found"); } // Parse each item const parsedItems = []; const seenIds = new Set(); for (const itemLink of items) { try { const item = parseItemFromLink(itemLink); // Deduplicate by platformProductId if (item.platformProductId && !seenIds.has(item.platformProductId)) { seenIds.add(item.platformProductId); parsedItems.push(item); } } catch (e) { // Continue with next item if one fails console.warn("Failed to parse item:", e); } } // Return max 60 items (first page) return parsedItems.slice(0, 60); } catch (error) { // Re-throw to be caught by message handler throw error; } } /** * Findet Item-Links auf der Seite * @returns {Array} Array von Link-Elementen */ function findItemLinks() { const links = []; try { // Multiple selector fallbacks for item cards const selectors = [ 'a[href*="/itm/"]', // Direct item links '.s-item a[href*="/itm/"]', // Search result items '.srp-results .s-item a', // Search results '.ebay-item-card a[href*="/itm/"]', // Item cards '[class*="item-card"] a[href*="/itm/"]', // Generic item cards ]; for (const selector of selectors) { try { const found = document.querySelectorAll(selector); if (found.length > 0) { // Filter out duplicates by href const hrefs = new Set(); for (const link of found) { const href = link.href || link.getAttribute('href'); if (href && href.includes('/itm/') && !hrefs.has(href)) { hrefs.add(href); links.push(link); } } if (links.length > 0) break; // Found items, stop trying other selectors } } catch (e) { // Continue to next selector } } } catch (e) { // Return empty array on error } return links; } /** * Parst ein einzelnes Item aus einem Link-Element * @param {Element} itemLink - Link-Element zu einem eBay-Item * @returns {object} Parsed Item-Data */ function parseItemFromLink(itemLink) { const item = { platformProductId: null, title: "", price: null, currency: null, url: "", status: "active", category: null, condition: null }; try { // URL (absolute) const href = itemLink.href || itemLink.getAttribute('href'); if (href) { item.url = href.startsWith('http') ? href : new URL(href, window.location.origin).href; // Extract platformProductId from URL: /itm/ or ?item= const itmMatch = item.url.match(/\/itm\/(\d+)/); if (itmMatch && itmMatch[1]) { item.platformProductId = itmMatch[1]; } else { const itemParamMatch = item.url.match(/[?&]item=(\d+)/); if (itemParamMatch && itemParamMatch[1]) { item.platformProductId = itemParamMatch[1]; } } } // Fallback: data attributes if present if (!item.platformProductId) { const dataItemId = itemLink.getAttribute('data-item-id') || itemLink.closest('[data-item-id]')?.getAttribute('data-item-id'); if (dataItemId) { item.platformProductId = dataItemId; } } // Title: from link text or title element try { const titleElement = itemLink.querySelector('.s-item__title') || itemLink.querySelector('[class*="title"]') || itemLink.closest('.s-item')?.querySelector('.s-item__title'); if (titleElement) { item.title = titleElement.textContent?.trim() || ""; } else { // Fallback: link text itself item.title = itemLink.textContent?.trim() || ""; } // Normalize title (remove extra whitespace) item.title = item.title.replace(/\s+/g, ' ').trim(); } catch (e) { // Continue without title } // Price + Currency try { const itemCard = itemLink.closest('.s-item') || itemLink.closest('[class*="item-card"]'); if (itemCard) { const priceElement = itemCard.querySelector('.s-item__price') || itemCard.querySelector('[class*="price"]') || itemCard.querySelector('.BOLD'); if (priceElement) { const priceText = priceElement.textContent?.trim() || ""; const parsed = parsePrice(priceText); item.price = parsed.price; item.currency = parsed.currency; } } } catch (e) { // Continue without price } // Category (optional) try { const categoryElement = itemLink.closest('.s-item')?.querySelector('[class*="category"]'); if (categoryElement) { item.category = categoryElement.textContent?.trim() || null; } } catch (e) { // Continue without category } // Condition (optional) try { const conditionElement = itemLink.closest('.s-item')?.querySelector('[class*="condition"]'); if (conditionElement) { item.condition = conditionElement.textContent?.trim() || null; } } catch (e) { // Continue without condition } // Status: default "active" (storefront shows active listings) // Could check for "ended" indicators, but MVP keeps it simple item.status = "active"; } catch (e) { // Continue with partial data console.warn("Error parsing item:", e); } return item; } /** * Parst Preis-String in Zahl und Währung * @param {string} priceText - Preis-String z.B. "EUR 12,99" oder "$15.50" * @returns {object} { price: number|null, currency: string|null } */ function parsePrice(priceText) { const result = { price: null, currency: null }; try { if (!priceText) return result; // Extract currency symbols/codes const currencyMatch = priceText.match(/(EUR|USD|GBP|€|\$|£)/i); if (currencyMatch) { const currencyCode = currencyMatch[1].toUpperCase(); if (currencyCode === '€') result.currency = 'EUR'; else if (currencyCode === '$') result.currency = 'USD'; else if (currencyCode === '£') result.currency = 'GBP'; else result.currency = currencyCode; } // Extract numeric value (handle both comma and dot as decimal separator) const normalized = priceText .replace(/[^\d,.-]/g, '') // Remove non-numeric except , . - .replace(/\./g, '') // Remove thousand separators (assume dot) .replace(',', '.'); // Replace comma with dot for decimal const price = parseFloat(normalized); if (!isNaN(price) && price >= 0) { result.price = price; } } catch (e) { // Return null values on error } return result; }