Add product scanning functionality to eBay extension

- Introduced a new scanProductsForAccount feature in the background script to handle product scanning requests.
- Implemented a timeout mechanism for scan requests to enhance reliability.
- Updated content scripts to parse product lists and extract relevant data from eBay storefronts.
- Enhanced error handling and logging for better debugging and user feedback.
- Added methods to extract items sold and shop names from eBay profiles.
This commit is contained in:
2026-01-18 14:19:42 +01:00
parent 99413db4f4
commit ed9a75a1dc
4 changed files with 598 additions and 26 deletions

View File

@@ -2,7 +2,9 @@ const STORAGE_KEY = "auth_jwt";
const BACKEND_URL = "http://localhost:5173"; // TODO: Backend URL konfigurieren
const PARSE_TIMEOUT_MS = 15000; // 15 seconds
const SCAN_TIMEOUT_MS = 20000; // 20 seconds (seller listing pages can be slower)
const activeParseRequests = new Map(); // Map<tabId, { timeout, originalSender, resolve }>
const activeScanRequests = new Map(); // Map<tabId, { timeout, sendResponse }>
// Messages from content script (der von der Web-App kommt)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
@@ -57,6 +59,11 @@ chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
handleParseRequest(msg.url, sendResponse);
return true; // async
}
if (msg?.action === "SCAN_PRODUCTS" && msg.url && msg.accountId) {
handleScanProductsRequest(msg.url, msg.accountId, sendResponse);
return true; // async
}
});
/**
@@ -167,6 +174,114 @@ async function cleanupParseRequest(tabId, data, error) {
}
}
/**
* Handles eBay product scan request
* Creates a hidden tab, waits for load, sends parse message to content script
*/
async function handleScanProductsRequest(url, accountId, sendResponse) {
try {
// Validate URL
if (!url || typeof url !== 'string' || !url.toLowerCase().includes('ebay.')) {
sendResponse({ ok: false, error: "Invalid eBay URL" });
return;
}
// Create hidden tab
const tab = await chrome.tabs.create({
url: url,
active: false
});
const tabId = tab.id;
// Set up timeout
const timeoutId = setTimeout(() => {
cleanupScanRequest(tabId, null, { ok: false, error: "timeout" });
}, SCAN_TIMEOUT_MS);
// Store request info
activeScanRequests.set(tabId, {
timeout: timeoutId,
sendResponse: sendResponse
});
// Wait for tab to load, then send parse message
const checkTabLoaded = (updatedTabId, changeInfo, updatedTab) => {
if (updatedTabId !== tabId) return;
// Tab is fully loaded
if (changeInfo.status === 'complete' && updatedTab.url) {
chrome.tabs.onUpdated.removeListener(checkTabLoaded);
// Small delay to ensure DOM is ready
setTimeout(() => {
// Send parse message to content script
chrome.tabs.sendMessage(tabId, { action: "PARSE_PRODUCT_LIST" })
.then(response => {
if (response && response.ok && response.data) {
handleScanComplete(tabId, response.data);
} else {
cleanupScanRequest(tabId, null, { ok: false, error: response?.error || "Parsing failed" });
}
})
.catch(err => {
console.error("Error sending parse message:", err);
cleanupScanRequest(tabId, null, { ok: false, error: "Content script error" });
});
}, 1000); // 1 second delay for DOM ready
}
};
chrome.tabs.onUpdated.addListener(checkTabLoaded);
} catch (error) {
console.error("Error in handleScanProductsRequest:", error);
sendResponse({ ok: false, error: error.message || "Unknown error" });
}
}
/**
* Handles scan complete response from content script
*/
function handleScanComplete(tabId, data) {
cleanupScanRequest(tabId, data, null);
}
/**
* Cleans up scan request: closes tab, clears timeout, sends response
*/
async function cleanupScanRequest(tabId, data, error) {
const request = activeScanRequests.get(tabId);
if (!request) return;
// Clear timeout
if (request.timeout) {
clearTimeout(request.timeout);
}
// Remove from active requests
activeScanRequests.delete(tabId);
// Close tab (always, even on error)
try {
await chrome.tabs.remove(tabId);
} catch (err) {
// Tab might already be closed
console.warn("Could not close tab:", err);
}
// Send response
if (request.sendResponse) {
if (error) {
request.sendResponse(error);
} else if (data) {
request.sendResponse({ ok: true, data: data });
} else {
request.sendResponse({ ok: false, error: "Unknown error" });
}
}
}
export async function getJwt() {
const data = await chrome.storage.local.get(STORAGE_KEY);

View File

@@ -3,20 +3,62 @@
const MESSAGE_SOURCE = "eship-webapp";
// Markiere Extension als verfügbar
// #region agent log
try {
console.log('[ESHIP-CONTENT] Content script loaded');
if (typeof window !== 'undefined') {
window.__EBAY_EXTENSION__ = true;
console.log('[ESHIP-CONTENT] window.__EBAY_EXTENSION__ set to true');
} else {
console.error('[ESHIP-CONTENT] window is undefined!');
// Markiere Extension als verfügbar - MEHRFACH versuchen, da Timing variieren kann
function setExtensionFlag() {
try {
const hasChrome = typeof chrome !== 'undefined';
const hasRuntime = hasChrome && chrome.runtime;
const runtimeId = hasRuntime ? chrome.runtime.id : null;
if (typeof window !== 'undefined' && hasChrome && hasRuntime && runtimeId) {
window.__EBAY_EXTENSION__ = true;
window.__EBAY_EXTENSION_ID__ = runtimeId; // Extension-ID für chrome.runtime.sendMessage
console.log('[ESHIP-CONTENT] window.__EBAY_EXTENSION__ set to true, ID:', runtimeId);
return true;
}
} catch (e) {
console.error('[ESHIP-CONTENT] Error setting flag:', e);
}
} catch (e) {
console.error('[ESHIP-CONTENT] Error setting flag:', e);
return false;
}
// #endregion
// Versuche Flag sofort zu setzen
console.log('[ESHIP-CONTENT] Content script loaded');
if (!setExtensionFlag()) {
// Wenn window nicht verfügbar, warte auf DOMContentLoaded oder document.readyState
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setExtensionFlag();
});
} else {
// DOM ist bereits geladen, versuche nochmal
setTimeout(() => {
setExtensionFlag();
}, 0);
}
}
// Sende Extension-ID an Web-App via postMessage (da Content Script Isolation verhindert, dass window-Properties geteilt werden)
// Die Web-App kann dann die Extension-ID in ihrem eigenen Context speichern
function sendExtensionIdToWebApp() {
try {
const runtimeId = chrome.runtime?.id;
if (runtimeId) {
// Sende Extension-ID an Web-App
window.postMessage({
source: "eship-extension",
type: "EXTENSION_ID",
extensionId: runtimeId
}, "*");
}
} catch (e) {
console.error('[ESHIP-CONTENT] Error sending extension ID:', e);
}
}
// Sende Extension-ID beim Laden
sendExtensionIdToWebApp();
// Auch nach kurzer Verzögerung nochmal (falls Web-App noch nicht bereit ist)
setTimeout(sendExtensionIdToWebApp, 500);
window.addEventListener("message", (event) => {
// Sicherheitscheck: Nur Nachrichten von derselben Origin akzeptieren

View File

@@ -24,6 +24,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
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
}
});
/**
@@ -143,7 +158,27 @@ function extractSellerId() {
*/
function extractShopName() {
try {
// Methode 1: Spezifische Selektoren versuchen
// Methode 1: Shop-Name aus Storefront-Link extrahieren
// Pattern: <a href="/str/storename" data-track="...">Shop Name</a>
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',
@@ -169,7 +204,7 @@ function extractShopName() {
}
}
// Methode 2: document.title parsen
// Methode 3: document.title parsen
try {
const title = document.title || "";
// Versuche Muster wie "Shop Name | eBay" zu extrahieren
@@ -185,7 +220,7 @@ function extractShopName() {
// Continue
}
// Methode 3: h1 Tag als Fallback
// Methode 4: h1 Tag als Fallback
try {
const h1 = document.querySelector('h1');
if (h1) {
@@ -204,6 +239,71 @@ function extractShopName() {
}
}
/**
* 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
@@ -320,6 +420,16 @@ function extractStats() {
// Continue
}
// Items Sold (Artikel verkauft)
try {
const itemsSold = extractItemsSold();
if (itemsSold !== null) {
stats.itemsSold = itemsSold;
}
} catch (e) {
// Continue
}
} catch (e) {
// Return empty stats object
}
@@ -358,4 +468,266 @@ function parseEbayPage() {
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/<id> or ?item=<id>
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;
}