/** * 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") { try { const items = parseProductList(); sendResponse({ ok: true, data: { items } }); } catch (error) { // Niemals unhandled throws - immer graceful response console.error("Error parsing product list:", error); sendResponse({ ok: false, error: error.message || "Failed to parse product list" }); } 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: {} }; } } /** * Parst Produktliste von eBay Storefront oder Seller Listings * @returns {Array} Array von Produkt-Items */ function parseProductList() { 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; }