From 86d2191a25bf5f1e4fb1f9d2cb52064e275f43b9 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Sun, 18 Jan 2026 17:31:18 +0100 Subject: [PATCH] Update Extension files and add deploy script --- .cursor/debug.log | 6 + Extension/background.js | 32 ++- Extension/ebay-content-script.js | 297 ++++++++++++++++++++++++-- setup/deploy-upright-schema.ps1 | 345 +++++++++++++++++++++++++++++++ 4 files changed, 664 insertions(+), 16 deletions(-) create mode 100644 setup/deploy-upright-schema.ps1 diff --git a/.cursor/debug.log b/.cursor/debug.log index a294f44..06e7a90 100644 --- a/.cursor/debug.log +++ b/.cursor/debug.log @@ -52,3 +52,9 @@ {"location":"accountsService.js:133","message":"updateManagedAccount: before updateDocument","data":{"accountId":"696cbd07000703cf5437","payload":{"account_platform_market":"DE","account_platform_account_id":"ihaveitmusic","account_shop_name":"iHaveit","account_sells":280535},"payloadKeys":["account_platform_market","account_platform_account_id","account_shop_name","account_sells"]},"timestamp":1768741616428,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"} {"location":"AccountsPage.jsx:93","message":"handleRefreshAccount: update payload","data":{"payload":{"account_platform_market":"DE","account_platform_account_id":"ihaveitmusic","account_shop_name":"iHaveit","account_sells":280535}},"timestamp":1768741616427,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"} {"location":"accountsService.js:147","message":"updateManagedAccount: success","data":{"accountId":"696cbd07000703cf5437"},"timestamp":1768741616513,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"} +{"location":"ebayParserService.js:150","message":"getExtensionId: not found after retries","data":{"hasWindow":true},"timestamp":1768742766564,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"} +{"location":"ebayParserService.js:150","message":"getExtensionId: not found after retries","data":{"hasWindow":true},"timestamp":1768742773836,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"} +{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742796643,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"} +{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742817118,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"} +{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742900325,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"} +{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742905657,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"} diff --git a/Extension/background.js b/Extension/background.js index a489f49..133a6e9 100644 --- a/Extension/background.js +++ b/Extension/background.js @@ -218,10 +218,34 @@ async function handleScanProductsRequest(url, accountId, sendResponse) { // 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); + if (response && response.ok) { + // Prüfe ob items vorhanden und nicht leer + const items = response.items || response.data?.items || []; + const meta = response.meta || response.data?.meta || {}; + + // Log meta für Debugging + console.log("[SCAN meta]", meta); + + if (items.length === 0) { + // Leere items: behandele als Fehler mit meta + cleanupScanRequest(tabId, null, { + ok: false, + error: "empty_items", + meta: meta + }); + } else { + // Erfolg: sende items + meta + handleScanComplete(tabId, { items, meta }); + } } else { - cleanupScanRequest(tabId, null, { ok: false, error: response?.error || "Parsing failed" }); + // Fehler: sende error + meta + const meta = response?.meta || {}; + console.log("[SCAN meta]", meta); + cleanupScanRequest(tabId, null, { + ok: false, + error: response?.error || "Parsing failed", + meta: meta + }); } }) .catch(err => { @@ -273,8 +297,10 @@ async function cleanupScanRequest(tabId, data, error) { // Send response if (request.sendResponse) { if (error) { + // error kann bereits meta enthalten request.sendResponse(error); } else if (data) { + // data kann items + meta enthalten request.sendResponse({ ok: true, data: data }); } else { request.sendResponse({ ok: false, error: "Unknown error" }); diff --git a/Extension/ebay-content-script.js b/Extension/ebay-content-script.js index 5d1d6e0..f587656 100644 --- a/Extension/ebay-content-script.js +++ b/Extension/ebay-content-script.js @@ -26,17 +26,39 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } 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" + // 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 } }); @@ -471,10 +493,259 @@ function parseEbayPage() { } /** - * Parst Produktliste von eBay Storefront oder Seller Listings - * @returns {Array} Array von Produkt-Items + * Erkennt den Seitentyp basierend auf URL und DOM + * @returns {string} Page Type: "storefront" | "seller_profile" | "feedback" | "search_results" | "unknown" */ -function parseProductList() { +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(); diff --git a/setup/deploy-upright-schema.ps1 b/setup/deploy-upright-schema.ps1 new file mode 100644 index 0000000..80a9faa --- /dev/null +++ b/setup/deploy-upright-schema.ps1 @@ -0,0 +1,345 @@ +<# +Deploy Upright DB Schema for Appwrite 1.8.1 (TablesDB) +- Writes appwrite.config.json +- Runs: appwrite push tables --force + +Usage example: +.\deploy-upright-schema.ps1 ` + -ProjectId "YOUR_PROJECT_ID" ` + -Endpoint "https://YOUR_HOST_OR_REGION/v1" ` + -DatabaseId "upright" ` + -DatabaseName "upright" +#> + +param( + [Parameter(Mandatory=$true)][string]$ProjectId, + [Parameter(Mandatory=$true)][string]$Endpoint, + [Parameter(Mandatory=$true)][string]$DatabaseId, + [Parameter(Mandatory=$false)][string]$DatabaseName = "upright" +) + +$ErrorActionPreference = "Stop" + +function New-IndexObject { + param( + [Parameter(Mandatory=$true)][string]$Key, + [Parameter(Mandatory=$true)][ValidateSet("key","unique","fulltext")][string]$Type, + [Parameter(Mandatory=$true)][string[]]$Attributes + ) + return @{ + key = $Key + type = $Type + attributes = $Attributes + } +} + +function New-ColumnString { + param( + [Parameter(Mandatory=$true)][string]$Key, + [int]$Size = 255, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + return @{ + key = $Key + type = "string" + status = "available" + error = "" + required = $Required + array = $Array + size = $Size + default = $Default + } +} + +function New-ColumnUrl { + param( + [Parameter(Mandatory=$true)][string]$Key, + [int]$Size = 2048, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + # In config, url is usually type "string" with format "url" + return @{ + key = $Key + type = "string" + format = "url" + status = "available" + error = "" + required = $Required + array = $Array + size = $Size + default = $Default + } +} + +function New-ColumnDatetime { + param( + [Parameter(Mandatory=$true)][string]$Key, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + return @{ + key = $Key + type = "datetime" + status = "available" + error = "" + required = $Required + array = $Array + default = $Default + } +} + +function New-ColumnBoolean { + param( + [Parameter(Mandatory=$true)][string]$Key, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + return @{ + key = $Key + type = "boolean" + status = "available" + error = "" + required = $Required + array = $Array + default = $Default + } +} + +function New-ColumnInteger { + param( + [Parameter(Mandatory=$true)][string]$Key, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + return @{ + key = $Key + type = "integer" + status = "available" + error = "" + required = $Required + array = $Array + default = $Default + } +} + +function New-ColumnFloat { + param( + [Parameter(Mandatory=$true)][string]$Key, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + return @{ + key = $Key + type = "float" + status = "available" + error = "" + required = $Required + array = $Array + default = $Default + } +} + +function New-ColumnEnumString { + param( + [Parameter(Mandatory=$true)][string]$Key, + [Parameter(Mandatory=$true)][string[]]$Elements, + [int]$Size = 50, + [bool]$Required = $false, + [bool]$Array = $false, + $Default = $null + ) + # Enum in Appwrite config is typically string with format "enum" and "elements" + return @{ + key = $Key + type = "string" + format = "enum" + elements = $Elements + status = "available" + error = "" + required = $Required + array = $Array + size = $Size + default = $Default + } +} + +# Tables +$usersTableId = "users" +$accountsTableId = "accounts" +$productsTableId = "products" +$productDetailsTableId = "product_details" + +$tables = @() + +# users +$tables += @{ + '$id' = $usersTableId + '$permissions' = @( + 'create("any")','read("any")','update("any")','delete("any")' + ) + databaseId = $DatabaseId + name = "users" + enabled = $true + rowSecurity = $false + columns = @( + New-ColumnDatetime -Key "user_created_at" + New-ColumnDatetime -Key "user_updated_at" + ) + indexes = @() +} + +# accounts +$tables += @{ + '$id' = $accountsTableId + '$permissions' = @( + 'create("any")','read("any")','update("any")','delete("any")' + ) + databaseId = $DatabaseId + name = "accounts" + enabled = $true + rowSecurity = $false + columns = @( + New-ColumnString -Key "account_owner_user_id" -Size 128 -Required $false + New-ColumnBoolean -Key "account_team" -Required $true -Default $false + + New-ColumnEnumString -Key "account_platform" -Elements @("amazon","ebay") -Size 20 -Required $true + New-ColumnString -Key "account_platform_account_id" -Size 128 -Required $true + New-ColumnString -Key "account_platform_market" -Size 50 -Required $true + + New-ColumnString -Key "account_shop_name" -Size 256 -Required $false + New-ColumnUrl -Key "account_url" -Size 2048 -Required $false + + New-ColumnEnumString -Key "account_status" -Elements @("active","unknown","disabled") -Size 20 -Required $false + + New-ColumnDatetime -Key "account_created_at" + New-ColumnDatetime -Key "account_updated_at" + ) + indexes = @( + (New-IndexObject -Key "uniq_platform_market_accountid" -Type "unique" -Attributes @( + "account_platform","account_platform_market","account_platform_account_id" + )), + (New-IndexObject -Key "idx_owner_user" -Type "key" -Attributes @("account_owner_user_id")), + (New-IndexObject -Key "idx_team" -Type "key" -Attributes @("account_team")) + ) +} + +# products (sparscan + vollscan in einem) +$tables += @{ + '$id' = $productsTableId + '$permissions' = @( + 'create("any")','read("any")','update("any")','delete("any")' + ) + databaseId = $DatabaseId + name = "products" + enabled = $true + rowSecurity = $false + columns = @( + New-ColumnString -Key "product_account_id" -Size 36 -Required $true + + New-ColumnEnumString -Key "product_platform" -Elements @("amazon","ebay") -Size 20 -Required $true + New-ColumnString -Key "product_platform_market" -Size 50 -Required $true + New-ColumnString -Key "product_platform_product_id" -Size 128 -Required $true + + New-ColumnUrl -Key "product_url" -Size 2048 -Required $true + New-ColumnString -Key "product_title" -Size 512 -Required $false + + New-ColumnEnumString -Key "product_condition" -Elements @( + "new","used_like_new","used_good","used_ok","parts","unknown" + ) -Size 30 -Required $false -Default "unknown" + + New-ColumnEnumString -Key "product_status" -Elements @( + "active","ended","unknown" + ) -Size 20 -Required $false -Default "unknown" + + # volatile (sparscan) + New-ColumnFloat -Key "product_price" -Required $false + New-ColumnString -Key "product_currency" -Size 10 -Required $false + New-ColumnInteger -Key "product_quantity_available" -Required $false + New-ColumnInteger -Key "product_quantity_sold" -Required $false + New-ColumnInteger -Key "product_watch_count" -Required $false + New-ColumnInteger -Key "product_in_carts_count" -Required $false + New-ColumnDatetime -Key "product_last_seen_at" -Required $false + + # scan bookkeeping + New-ColumnDatetime -Key "product_first_fullscan_at" -Required $false + New-ColumnDatetime -Key "product_last_fullscan_at" -Required $false + New-ColumnDatetime -Key "product_last_sparscan_at" -Required $false + New-ColumnInteger -Key "product_details_version" -Required $false + ) + indexes = @( + (New-IndexObject -Key "idx_product_account" -Type "key" -Attributes @("product_account_id")), + (New-IndexObject -Key "uniq_account_platform_product" -Type "unique" -Attributes @( + "product_account_id","product_platform_product_id" + )), + (New-IndexObject -Key "idx_platform_market" -Type "key" -Attributes @( + "product_platform","product_platform_market" + )), + (New-IndexObject -Key "idx_status" -Type "key" -Attributes @("product_status")), + (New-IndexObject -Key "ft_title" -Type "fulltext" -Attributes @("product_title")) + ) +} + +# product_details (schwer) +# WICHTIG: Alle Attribute verwenden "product_detail_" Präfix (Singular) nicht "details_" +$tables += @{ + '$id' = $productDetailsTableId + '$permissions' = @( + 'create("any")','read("any")','update("any")','delete("any")' + ) + databaseId = $DatabaseId + name = "product_details" + enabled = $true + rowSecurity = $false + columns = @( + New-ColumnString -Key "product_id" -Size 36 -Required $true + + New-ColumnString -Key "product_detail_category_path" -Size 512 -Required $false + New-ColumnString -Key "product_detail_brand" -Size 128 -Required $false + New-ColumnString -Key "product_detail_mpn" -Size 128 -Required $false + New-ColumnString -Key "product_detail_gtin" -Size 128 -Required $false + + New-ColumnString -Key "product_detail_description_text" -Size 20000 -Required $false + + New-ColumnUrl -Key "product_detail_image_urls" -Size 2048 -Required $false -Array $true + + New-ColumnString -Key "product_detail_item_specifics_json" -Size 20000 -Required $false + New-ColumnBoolean -Key "product_detail_has_variations" -Required $false + New-ColumnString -Key "product_detail_variations_json" -Size 20000 -Required $false + + New-ColumnDatetime -Key "product_detail_last_updated_at" -Required $false + ) + indexes = @( + (New-IndexObject -Key "uniq_product_id" -Type "unique" -Attributes @("product_id")) + ) +} + +# appwrite.config.json root +$config = @{ + projectId = $ProjectId + endpoint = $Endpoint + tablesDB = @( + @{ + '$id' = $DatabaseId + name = $DatabaseName + enabled = $true + } + ) + tables = $tables +} + +$configPath = Join-Path -Path (Get-Location) -ChildPath "appwrite.config.json" +($config | ConvertTo-Json -Depth 20) | Set-Content -Path $configPath -Encoding UTF8 + +Write-Host "Wrote: $configPath" +Write-Host "Now pushing schema via: appwrite push tables --force" + +# Push schema (requires: appwrite login, appwrite init project) +& appwrite push tables --force