1004 lines
28 KiB
JavaScript
1004 lines
28 KiB
JavaScript
/**
|
|
* 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: <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',
|
|
'[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>} 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/<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;
|
|
} |