Files
eship/Extension/background.js
KNSONWS a1201d572e feat: Integrate account_metrics collection with monthly refresh calendar
- Add account_metrics collection schema script
- Implement accountMetricsService with upsertAccountMetric, fetchAccountMetricsForMonth, calculateSalesBucket
- Extend accountsService with getLastSuccessfulAccountMetric
- Update AccountsPage to track daily metrics and display in calendar
- Calculate sales difference from last successful refresh
- Display refresh status and sales buckets in monthly calendar view
- Remove account_refresh_events dependency (use account_metrics only)
2026-01-27 20:36:47 +01:00

985 lines
32 KiB
JavaScript

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 = 45000; // 45 seconds (listing with _ipg=240 can be slow)
const ACCOUNT_EXTENDED_TIMEOUT_MS = 15000; // 15 seconds per sub-scan
const activeParseRequests = new Map(); // Map<tabId, { timeout, originalSender, resolve }>
const activeScanRequests = new Map(); // Map<tabId, { timeout, sendResponse }>;
const activeExtendedParseRequests = new Map(); // Map<requestId, { sendResponse, results }>
/** Aktueller Scan-Fortschritt für GET_SCAN_PROGRESS (Polling durch Web-App) */
let currentScanProgress = null;
// Messages from content script (der von der Web-App kommt)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Auth messages vom Content Script
if (msg?.type === "AUTH_JWT" && msg.jwt) {
chrome.storage.local.set({ [STORAGE_KEY]: msg.jwt }).then(() => {
sendResponse({ ok: true });
});
return true; // async
}
if (msg?.type === "AUTH_CLEARED") {
chrome.storage.local.remove(STORAGE_KEY).then(() => {
sendResponse({ ok: true });
});
return true;
}
// API calls vom Popup
if (msg?.type === "GET_JWT") {
getJwt().then(jwt => {
sendResponse({ jwt });
});
return true;
}
if (msg?.type === "CALL_API" && msg.path) {
callProtectedApi(msg.path, msg.payload).then(data => {
sendResponse({ ok: true, data });
}).catch(err => {
sendResponse({ ok: false, error: err.message });
});
return true;
}
// eBay Parsing Request (from Web App via content script or directly)
if (msg?.action === "PARSE_URL" && msg.url) {
handleParseRequest(msg.url, sendResponse);
return true; // async
}
// eBay Parsing Response (from eBay content script)
if (msg?.action === "PARSE_COMPLETE") {
handleParseComplete(sender.tab?.id, msg.data);
return true;
}
});
// Handle messages from external web apps (externally_connectable)
chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
if (msg?.action === "PARSE_URL" && msg.url) {
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
}
if (msg?.action === "PARSE_ACCOUNT_EXTENDED" && msg.url) {
handleParseAccountExtendedRequest(msg.url, sendResponse);
return true; // async
}
if (msg?.action === "GET_SCAN_PROGRESS") {
sendResponse(currentScanProgress ?? { percent: 0, phase: "idle", total: 0, current: 0, complete: false });
return false;
}
});
/**
* Handles eBay URL parsing request
* Creates a hidden tab, waits for load, sends parse message to content script
*/
async function handleParseRequest(url, sendResponse) {
try {
// Validate URL
if (!url || typeof url !== 'string' || !url.toLowerCase().includes('ebay.')) {
sendResponse({ ok: false, error: "Invalid eBay URL" });
return;
}
console.log("[BACKGROUND] Creating tab for parse request:", url);
// Create hidden tab
const tab = await chrome.tabs.create({
url: url,
active: false
});
const tabId = tab.id;
console.log("[BACKGROUND] Tab created:", tabId);
// Set up timeout
const timeoutId = setTimeout(() => {
cleanupParseRequest(tabId, null, { ok: false, error: "timeout" });
}, PARSE_TIMEOUT_MS);
// Store request info
activeParseRequests.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);
console.log("[BACKGROUND] Tab loaded, sending parse message:", updatedTab.url);
// Wait longer for content script to auto-load (if it does)
// Content scripts from manifest.json might need more time in hidden tabs
setTimeout(() => {
sendParseMessageWithRetry(tabId, 0);
}, 2000); // 2 seconds delay - give content script time to auto-load
}
};
chrome.tabs.onUpdated.addListener(checkTabLoaded);
} catch (error) {
console.error("Error in handleParseRequest:", error);
sendResponse({ ok: false, error: error.message || "Unknown error" });
}
}
/**
* Checks if a URL is an eBay URL
* @param {string} url - URL to check
* @returns {boolean} - true if URL is an eBay URL
*/
function isEbayUrl(url) {
if (!url) return false;
const urlLower = url.toLowerCase();
return urlLower.includes('ebay.');
}
/**
* Injects the eBay content script manually if it's not already loaded
* @param {number} tabId - Tab ID
* @returns {Promise<boolean>} - true if injection successful or already loaded
*/
async function ensureContentScriptInjected(tabId) {
try {
// First, check if tab URL is an eBay URL
const tab = await chrome.tabs.get(tabId);
if (!isEbayUrl(tab.url)) {
console.log("[BACKGROUND] Tab is not an eBay URL:", tab.url);
return false;
}
// Check if content script is already loaded by sending a ping
try {
await chrome.tabs.sendMessage(tabId, { action: "PING" });
console.log("[BACKGROUND] Content script already loaded");
return true;
} catch (pingError) {
// Content script not loaded, try to inject it manually
console.log("[BACKGROUND] Content script not found, injecting manually...");
try {
// Get tab info to check frames
const tabInfo = await chrome.tabs.get(tabId);
// Inject full ebay-content-script.js so PING, PARSE_EBAY and PARSE_PRODUCT_LIST are all handled.
// The previous inline injection only handled PING+PARSE_EBAY; SCAN sends PARSE_PRODUCT_LIST.
try {
await chrome.scripting.executeScript({
target: { tabId: tabId, frameIds: [0] },
files: ["ebay-content-script.js"]
});
console.log("[BACKGROUND] ebay-content-script.js injected successfully");
} catch (injectError) {
console.error("[BACKGROUND] Failed to inject ebay-content-script:", injectError);
throw injectError;
}
// Also try injecting a simple test script to verify injection works
try {
await chrome.scripting.executeScript({
target: { tabId: tabId, frameIds: [0] },
func: () => {
console.log("[TEST-INJECTION] Test script executed in main frame");
}
});
} catch (testError) {
}
// Wait longer for the script to fully initialize and register message listeners
// Retry PING to verify the script is ready
// Also try sending to frameId 0 explicitly
for (let pingAttempt = 0; pingAttempt < 10; pingAttempt++) {
await new Promise(resolve => setTimeout(resolve, 300)); // 300ms between pings
try {
// Try sending to main frame explicitly
try {
await chrome.tabs.sendMessage(tabId, { action: "PING" }, { frameId: 0 });
} catch (frameErr) {
// Fallback: try without frameId
await chrome.tabs.sendMessage(tabId, { action: "PING" });
}
console.log("[BACKGROUND] Content script ready after injection");
return true;
} catch (pingErr) {
// Continue to next attempt
}
}
// Even if PING failed, return true - the script might still work
console.warn("[BACKGROUND] Content script injected but PING not responding");
return true;
} catch (injectError) {
console.error("[BACKGROUND] Failed to inject content script:", injectError);
return false;
}
}
} catch (error) {
console.error("[BACKGROUND] Error checking/injecting content script:", error);
return false;
}
}
/**
* Sends parse message to content script with retry mechanism
* @param {number} tabId - Tab ID
* @param {number} attempt - Current attempt number (0-based)
*/
async function sendParseMessageWithRetry(tabId, attempt) {
const maxAttempts = 3;
const retryDelay = 500; // 500ms between retries
try {
console.log(`[BACKGROUND] Sending parse message (attempt ${attempt + 1}/${maxAttempts}) to tab:`, tabId);
// On first attempt, ensure content script is injected
if (attempt === 0) {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
throw new Error("Could not inject content script");
}
}
// Check if content script is injected by trying to send a ping
// If this fails, the content script might not be loaded yet
// Try sending to main frame explicitly first
let response;
try {
response = await chrome.tabs.sendMessage(tabId, { action: "PARSE_EBAY" }, { frameId: 0 });
} catch (frameErr) {
// Fallback: try without frameId
response = await chrome.tabs.sendMessage(tabId, { action: "PARSE_EBAY" });
}
console.log("[BACKGROUND] Parse response received:", response);
if (response && response.ok && response.data) {
handleParseComplete(tabId, response.data);
} else {
cleanupParseRequest(tabId, null, {
ok: false,
error: response?.error || "Parsing failed"
});
}
} catch (err) {
// Check if error is due to content script not being ready
const runtimeError = chrome.runtime.lastError?.message || "";
const isContentScriptError =
err.message?.includes("Could not establish connection") ||
err.message?.includes("Receiving end does not exist") ||
err.message?.includes("Could not inject content script") ||
runtimeError.includes("Receiving end does not exist") ||
runtimeError.includes("Could not establish connection");
console.log(`[BACKGROUND] Error sending parse message (attempt ${attempt + 1}):`, {
error: err.message,
runtimeError: runtimeError,
isContentScriptError: isContentScriptError
});
if (isContentScriptError && attempt < maxAttempts) {
// Content script not ready yet, retry after delay
console.log(`[BACKGROUND] Content script not ready, retrying in ${retryDelay}ms...`);
setTimeout(() => {
sendParseMessageWithRetry(tabId, attempt + 1);
}, retryDelay);
} else {
// Max retries reached or different error
const errorMessage = isContentScriptError
? "Content script konnte nicht geladen werden. Bitte Extension neu laden."
: (err.message || runtimeError || "Content script error");
cleanupParseRequest(tabId, null, {
ok: false,
error: errorMessage
});
}
}
}
/**
* Handles parse complete response from content script
*/
function handleParseComplete(tabId, data) {
cleanupParseRequest(tabId, data, null);
}
/**
* Cleans up parse request: closes tab, clears timeout, sends response
*/
async function cleanupParseRequest(tabId, data, error) {
const request = activeParseRequests.get(tabId);
if (!request) return;
// Clear timeout
if (request.timeout) {
clearTimeout(request.timeout);
}
// Remove from active requests
activeParseRequests.delete(tabId);
// Close tab
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" });
}
}
}
/**
* Transform account URL (e.g. /str/topmons) to listing URL with 240 items per page.
* Example: https://www.ebay.de/str/topmons?... → https://www.ebay.de/sch/i.html?_ssn=topmons&store_name=topmons&_ipg=240
*/
function transformAccountUrlTo240ItemsUrl(accountUrl) {
try {
const url = new URL(accountUrl);
const pathname = url.pathname;
const strMatch = pathname.match(/\/str\/([^\/\?]+)/);
if (!strMatch || !strMatch[1]) {
throw new Error("Could not extract account name from URL (expected /str/{name})");
}
const accountName = strMatch[1];
const baseUrl = `${url.protocol}//${url.hostname}`;
const listingUrl = `${baseUrl}/sch/i.html?_ssn=${encodeURIComponent(accountName)}&store_name=${encodeURIComponent(accountName)}&_ipg=240`;
return listingUrl;
} catch (e) {
throw new Error(`Failed to transform account URL: ${e.message}`);
}
}
/**
* Handles eBay product scan request
* Transforms account URL to listing URL with _ipg=240, creates hidden tab, parses, then loads each item in separate tab.
*/
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;
}
let targetUrl = url;
try {
targetUrl = transformAccountUrlTo240ItemsUrl(url);
console.log("[BACKGROUND] Scan using listing URL (240 items):", targetUrl);
} catch (transformErr) {
console.warn("[BACKGROUND] URL transform failed, using original URL:", transformErr.message);
// Fallback: use original URL (e.g. already /sch/ or non-/str/)
}
// Create hidden tab
const tab = await chrome.tabs.create({
url: targetUrl,
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
});
currentScanProgress = { percent: 0, phase: "listing", total: 0, current: 0, complete: false };
// 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(() => {
sendScanMessageWithRetry(tabId, 0);
}, 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" });
}
}
/**
* Sends scan message to content script with retry mechanism
* @param {number} tabId - Tab ID
* @param {number} attempt - Current attempt number (0-based)
*/
async function sendScanMessageWithRetry(tabId, attempt) {
const maxAttempts = 3;
const retryDelay = 500; // 500ms between retries
try {
console.log(`[BACKGROUND] Sending scan message (attempt ${attempt + 1}/${maxAttempts}) to tab:`, tabId);
// On first attempt, ensure content script is injected
if (attempt === 0) {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
throw new Error("Could not inject content script");
}
}
const response = await chrome.tabs.sendMessage(tabId, { action: "PARSE_PRODUCT_LIST" });
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 {
if (currentScanProgress) {
currentScanProgress.phase = "details";
currentScanProgress.total = items.length;
currentScanProgress.percent = 10;
}
await handleScanComplete(tabId, { items, meta });
}
} else {
// 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) {
// Check if error is due to content script not being ready
const runtimeError = chrome.runtime.lastError?.message || "";
const isContentScriptError =
err.message?.includes("Could not establish connection") ||
err.message?.includes("Receiving end does not exist") ||
err.message?.includes("Could not inject content script") ||
runtimeError.includes("Receiving end does not exist") ||
runtimeError.includes("Could not establish connection");
console.log(`[BACKGROUND] Error sending scan message (attempt ${attempt + 1}):`, {
error: err.message,
runtimeError: runtimeError,
isContentScriptError: isContentScriptError
});
if (isContentScriptError && attempt < maxAttempts) {
// Content script not ready yet, retry after delay
console.log(`[BACKGROUND] Content script not ready, retrying in ${retryDelay}ms...`);
setTimeout(() => {
sendScanMessageWithRetry(tabId, attempt + 1);
}, retryDelay);
} else {
// Max retries reached or different error
const errorMessage = isContentScriptError
? "Content script konnte nicht geladen werden. Bitte Extension neu laden."
: (err.message || runtimeError || "Content script error");
cleanupScanRequest(tabId, null, {
ok: false,
error: errorMessage,
meta: {
pageType: "unknown",
finalUrl: "",
attempts: attempt + 1,
reason: "content_script_error"
}
});
}
}
}
const ITEM_TAB_LOAD_TIMEOUT_MS = 3000;
/**
* Opens each item URL in a separate background tab, waits for load, parses detail page
* (title, price, currency, category, condition), merges into item, then closes tab.
*/
async function loadAndParseEachItemTab(items) {
if (!Array.isArray(items) || items.length === 0) return;
const total = items.length;
for (let i = 0; i < total; i++) {
const item = items[i];
if (!item || !item.url) continue;
try {
const tab = await chrome.tabs.create({
url: item.url,
active: false
});
await new Promise((resolve) => {
const listener = (tabId, changeInfo) => {
if (tabId === tab.id && changeInfo.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, ITEM_TAB_LOAD_TIMEOUT_MS);
});
const injected = await ensureContentScriptInjected(tab.id);
if (!injected) {
console.warn(`[BACKGROUND] Content script not ready in item tab ${i + 1}, skipping detail parse`);
}
try {
const response = injected
? await chrome.tabs.sendMessage(tab.id, { action: "PARSE_ITEM_DETAIL" })
: null;
if (response?.ok && response?.data) {
const d = response.data;
if (d.title != null && d.title !== "") item.title = d.title;
if (d.price != null) item.price = d.price;
if (d.currency != null && d.currency !== "") item.currency = d.currency;
if (d.category != null && d.category !== "") item.category = d.category;
if (d.condition != null && d.condition !== "") item.condition = d.condition;
if (d.quantityAvailable != null) item.quantityAvailable = d.quantityAvailable;
if (d.quantitySold != null) item.quantitySold = d.quantitySold;
if (d.watchCount != null) item.watchCount = d.watchCount;
if (d.inCartsCount != null) item.inCartsCount = d.inCartsCount;
}
} catch (parseErr) {
console.warn(`[BACKGROUND] PARSE_ITEM_DETAIL failed for ${item.url}:`, parseErr);
}
if (currentScanProgress && total > 0) {
currentScanProgress.current = i + 1;
currentScanProgress.percent = Math.min(99, 10 + Math.round((90 * (i + 1)) / total));
}
try {
await chrome.tabs.remove(tab.id);
} catch (removeErr) {
console.warn(`[BACKGROUND] Could not close item tab:`, removeErr);
}
} catch (e) {
console.warn(`[BACKGROUND] Failed to load/parse item tab ${i + 1}/${total}:`, item.url, e);
}
}
}
/**
* Handles scan complete response from content script
*/
async function handleScanComplete(tabId, data) {
const request = activeScanRequests.get(tabId);
if (request && request.timeout) {
clearTimeout(request.timeout);
request.timeout = null;
}
const items = data?.items || [];
if (items.length > 0) {
await loadAndParseEachItemTab(items);
}
if (currentScanProgress) {
currentScanProgress.percent = 100;
currentScanProgress.complete = true;
currentScanProgress.current = items.length;
}
await 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);
currentScanProgress = null;
// 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) {
// 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" });
}
}
}
/**
* Handles extended account parsing request
* Visits multiple URLs (base, feedback, about, store) and collects partial results
*/
async function handleParseAccountExtendedRequest(url, sendResponse) {
try {
// Validate URL
if (!url || typeof url !== 'string' || !url.toLowerCase().includes('ebay.')) {
sendResponse({ ok: false, error: "Invalid eBay URL" });
return;
}
console.log("[BACKGROUND] Starting extended account parse for:", url);
const requestId = `extended_${Date.now()}_${Math.random()}`;
const results = {
base: { ok: false, data: null, error: null },
feedback: { ok: false, data: null, error: null },
about: { ok: false, data: null, error: null },
store: { ok: false, data: null, error: null }
};
activeExtendedParseRequests.set(requestId, {
sendResponse: sendResponse,
results: results
});
// Step 1: Parse base URL (like normal parse)
try {
const baseData = await parseSingleTab(url, "PARSE_EBAY", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.base = { ok: true, data: baseData, error: null };
} catch (error) {
console.warn("[BACKGROUND] Base parse failed:", error);
results.base = { ok: false, data: null, error: error.message || "Base parse failed" };
}
// Step 2: Parse feedback tab
try {
const feedbackUrl = appendQueryParam(url, "_tab=feedback");
const feedbackData = await parseSingleTab(feedbackUrl, "PARSE_FEEDBACK", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.feedback = { ok: true, data: feedbackData, error: null };
} catch (error) {
console.warn("[BACKGROUND] Feedback parse failed:", error);
results.feedback = { ok: false, data: null, error: error.message || "Feedback parse failed" };
}
// Step 3: Parse about tab
try {
const aboutUrl = appendQueryParam(url, "_tab=about");
const aboutData = await parseSingleTab(aboutUrl, "PARSE_ABOUT", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.about = { ok: true, data: aboutData, error: null };
} catch (error) {
console.warn("[BACKGROUND] About parse failed:", error);
results.about = { ok: false, data: null, error: error.message || "About parse failed" };
}
// Step 4: Find and parse store URL
try {
let storeUrl = null;
// First, try to find store link on base page
if (results.base.ok) {
try {
// Re-open base page tab to search for store link
const baseTab = await chrome.tabs.create({
url: url,
active: false
});
await new Promise((resolve) => {
const checkLoaded = (tabId, changeInfo) => {
if (tabId === baseTab.id && changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(checkLoaded);
setTimeout(resolve, 2000);
}
};
chrome.tabs.onUpdated.addListener(checkLoaded);
setTimeout(resolve, 5000); // Max wait
});
const injected = await ensureContentScriptInjected(baseTab.id);
if (injected) {
try {
const linkResponse = await chrome.tabs.sendMessage(baseTab.id, { action: "FIND_STORE_LINK" });
if (linkResponse && linkResponse.ok && linkResponse.data && linkResponse.data.storeUrl) {
storeUrl = linkResponse.data.storeUrl;
}
} catch (e) {
console.warn("[BACKGROUND] Could not find store link via content script:", e);
}
}
await chrome.tabs.remove(baseTab.id);
} catch (e) {
console.warn("[BACKGROUND] Error searching for store link:", e);
}
}
// Fallback: heuristical derivation
if (!storeUrl) {
const sellerId = results.base.data?.sellerId || null;
storeUrl = deriveStoreUrl(url, sellerId);
}
if (storeUrl) {
const storeData = await parseSingleTab(storeUrl, "PARSE_STORE", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.store = { ok: true, data: storeData, error: null };
} else {
results.store = { ok: false, data: null, error: "Could not determine store URL" };
}
} catch (error) {
console.warn("[BACKGROUND] Store parse failed:", error);
results.store = { ok: false, data: null, error: error.message || "Store parse failed" };
}
// Combine all results
const combinedData = {
// Base data (always present if base.ok)
sellerId: results.base.data?.sellerId || "",
shopName: results.base.data?.shopName || "",
market: results.base.data?.market || "US",
status: results.base.data?.status || "unknown",
stats: results.base.data?.stats || {},
// Extended data (can be null)
responseTimeHours: results.about.data?.responseTimeHours || null,
followers: results.store.data?.followers || null,
feedbackTotal: results.feedback.data?.feedbackTotal || null,
feedback12mPositive: results.feedback.data?.feedback12mPositive || null,
feedback12mNeutral: results.feedback.data?.feedback12mNeutral || null,
feedback12mNegative: results.feedback.data?.feedback12mNegative || null,
// Partial results status
partialResults: {
base: { ok: results.base.ok, error: results.base.error },
feedback: { ok: results.feedback.ok, error: results.feedback.error },
about: { ok: results.about.ok, error: results.about.error },
store: { ok: results.store.ok, error: results.store.error }
}
};
activeExtendedParseRequests.delete(requestId);
sendResponse({ ok: true, data: combinedData });
} catch (error) {
console.error("Error in handleParseAccountExtendedRequest:", error);
sendResponse({ ok: false, error: error.message || "Unknown error" });
}
}
/**
* Parses a single tab with given action and timeout
* @param {string} url - URL to parse
* @param {string} action - Action to send to content script (PARSE_EBAY, PARSE_FEEDBACK, etc.)
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise<object>} Parsed data
*/
async function parseSingleTab(url, action, timeoutMs) {
return new Promise(async (resolve, reject) => {
let tabId = null;
let timeoutId = null;
try {
// Create hidden tab
const tab = await chrome.tabs.create({
url: url,
active: false
});
tabId = tab.id;
// Set up timeout
timeoutId = setTimeout(() => {
cleanupSingleTab(tabId);
reject(new Error("timeout"));
}, timeoutMs);
// Wait for tab to load
const checkTabLoaded = (updatedTabId, changeInfo) => {
if (updatedTabId !== tabId) return;
if (changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(checkTabLoaded);
setTimeout(async () => {
try {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
cleanupSingleTab(tabId);
reject(new Error("Could not inject content script"));
return;
}
const response = await chrome.tabs.sendMessage(tabId, { action: action });
cleanupSingleTab(tabId);
if (response && response.ok && response.data) {
resolve(response.data);
} else {
reject(new Error(response?.error || "Parsing failed"));
}
} catch (err) {
cleanupSingleTab(tabId);
reject(err);
}
}, 2000); // 2 second delay for DOM ready
}
};
chrome.tabs.onUpdated.addListener(checkTabLoaded);
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (tabId) cleanupSingleTab(tabId);
reject(error);
}
});
}
/**
* Cleans up a single tab
*/
async function cleanupSingleTab(tabId) {
try {
await chrome.tabs.remove(tabId);
} catch (err) {
// Tab might already be closed
console.warn("[BACKGROUND] Could not close tab:", err);
}
}
/**
* Appends a query parameter to a URL
* @param {string} url - Base URL
* @param {string} param - Query parameter (e.g. "_tab=feedback")
* @returns {string} URL with appended parameter
*/
function appendQueryParam(url, param) {
try {
const urlObj = new URL(url);
urlObj.search += (urlObj.search ? '&' : '?') + param;
return urlObj.href;
} catch (e) {
// Fallback: simple string append
return url + (url.includes('?') ? '&' : '?') + param;
}
}
/**
* Derives store URL from account URL
* @param {string} accountUrl - Account URL
* @param {string|null} sellerId - Seller ID if available
* @returns {string|null} Store URL or null
*/
function deriveStoreUrl(accountUrl, sellerId) {
try {
const urlObj = new URL(accountUrl);
const pathname = urlObj.pathname;
// Try to extract seller ID from URL if not provided
let id = sellerId;
if (!id) {
const usrMatch = pathname.match(/\/usr\/([^\/\?]+)/);
if (usrMatch && usrMatch[1]) {
id = usrMatch[1];
} else {
const strMatch = pathname.match(/\/str\/([^\/\?]+)/);
if (strMatch && strMatch[1]) {
id = strMatch[1];
}
}
}
if (id) {
return `${urlObj.protocol}//${urlObj.hostname}/str/${encodeURIComponent(id)}`;
}
return null;
} catch (e) {
console.warn("[BACKGROUND] Failed to derive store URL:", e);
return null;
}
}
export async function getJwt() {
const data = await chrome.storage.local.get(STORAGE_KEY);
return data[STORAGE_KEY] || "";
}
export async function callProtectedApi(path, payload) {
const jwt = await getJwt();
if (!jwt) throw new Error("Not authed");
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": `Bearer ${jwt}`
},
body: JSON.stringify(payload || {})
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return await res.json();
}