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)
This commit is contained in:
KNSONWS
2026-01-27 20:36:47 +01:00
parent 75aef1941e
commit a1201d572e
11 changed files with 2641 additions and 49 deletions

View File

@@ -72,6 +72,50 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.action === "PARSE_FEEDBACK") {
try {
const feedbackData = extractFeedbackData();
sendResponse({ ok: true, data: feedbackData });
} catch (error) {
console.error("[EBAY-CONTENT] PARSE_FEEDBACK error:", error);
sendResponse({ ok: false, error: error.message || "Failed to extract feedback data" });
}
return true;
}
if (message.action === "PARSE_ABOUT") {
try {
const aboutData = extractResponseTime();
sendResponse({ ok: true, data: aboutData });
} catch (error) {
console.error("[EBAY-CONTENT] PARSE_ABOUT error:", error);
sendResponse({ ok: false, error: error.message || "Failed to extract response time" });
}
return true;
}
if (message.action === "PARSE_STORE") {
try {
const storeData = extractFollowers();
sendResponse({ ok: true, data: storeData });
} catch (error) {
console.error("[EBAY-CONTENT] PARSE_STORE error:", error);
sendResponse({ ok: false, error: error.message || "Failed to extract followers" });
}
return true;
}
if (message.action === "FIND_STORE_LINK") {
try {
const storeUrl = findStoreLink();
sendResponse({ ok: true, data: { storeUrl } });
} catch (error) {
console.error("[EBAY-CONTENT] FIND_STORE_LINK error:", error);
sendResponse({ ok: false, error: error.message || "Failed to find store link" });
}
return true;
}
if (message.action === "PARSE_PRODUCT_LIST") {
// async function, need to return promise
parseProductList()
@@ -1051,6 +1095,325 @@ function parseItemFromLink(itemLink) {
return item;
}
/**
* Extrahiert Feedback-Daten von ?_tab=feedback Seite
* Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex
* @returns {object} { feedbackTotal, feedback12mPositive, feedback12mNeutral, feedback12mNegative }
*/
function extractFeedbackData() {
const result = {
feedbackTotal: null,
feedback12mPositive: null,
feedback12mNeutral: null,
feedback12mNegative: null
};
try {
const bodyText = document.body?.innerText || "";
// Strategy 1: Label-basiert - Suche "Verkäuferbewertungen" / "Gesamtbewertungen"
const totalMatch = bodyText.match(/(?:Verkäuferbewertungen|Gesamtbewertungen)[\s\S]*?\(([\d.,]+)\)/i);
if (totalMatch && totalMatch[1]) {
result.feedbackTotal = parseNumberWithSeparators(totalMatch[1]);
}
// Strategy 2: Label-basiert - Suche "positive Bewertungen der letzten 12 Monate"
const positiveMatch = bodyText.match(/(\d+[\d.,]*)\s*(?:positive|positive\s*Bewertungen)\s*der\s*letzten\s*12\s*Monate/i);
if (positiveMatch && positiveMatch[1]) {
result.feedback12mPositive = parseNumberWithSeparators(positiveMatch[1]);
}
// Strategy 3: Selector-Fallback - Bekannte Klassen
try {
const titleEl = document.querySelector('h2.fdbk-detail-list__title, h2[class*="fdbk-detail-list__title"]');
if (titleEl) {
const titleText = titleEl.textContent || "";
const match = titleText.match(/\(([\d.,]+)\)/);
if (match && match[1] && !result.feedbackTotal) {
result.feedbackTotal = parseNumberWithSeparators(match[1]);
}
}
const ratingDetails = document.querySelector('.fdbk-overall-rating__details, [class*="fdbk-overall-rating__details"]');
if (ratingDetails) {
const links = ratingDetails.querySelectorAll('a');
for (const link of links) {
const linkText = link.textContent || "";
const numberMatch = linkText.match(/(\d+[\d.,]*)/);
if (numberMatch && numberMatch[1]) {
const num = parseNumberWithSeparators(numberMatch[1]);
if (linkText.toLowerCase().includes('positiv') && !result.feedback12mPositive) {
result.feedback12mPositive = num;
} else if (linkText.toLowerCase().includes('neutral') && !result.feedback12mNeutral) {
result.feedback12mNeutral = num;
} else if (linkText.toLowerCase().includes('negativ') && !result.feedback12mNegative) {
result.feedback12mNegative = num;
}
}
}
}
} catch (e) {
// Continue to regex fallback
}
// Strategy 4: Regex-Fallback auf bodyText
if (!result.feedback12mPositive) {
const posMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*positive\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i);
if (posMatch && posMatch[1]) {
result.feedback12mPositive = parseNumberWithSeparators(posMatch[1]);
}
}
if (!result.feedback12mNeutral) {
const neuMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*neutrale\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i);
if (neuMatch && neuMatch[1]) {
result.feedback12mNeutral = parseNumberWithSeparators(neuMatch[1]);
}
}
if (!result.feedback12mNegative) {
const negMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*negative\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i);
if (negMatch && negMatch[1]) {
result.feedback12mNegative = parseNumberWithSeparators(negMatch[1]);
}
}
} catch (e) {
console.warn("[EBAY-CONTENT] Error extracting feedback data:", e);
}
return result;
}
/**
* Extrahiert Response Time von ?_tab=about Seite
* Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex
* @returns {object} { responseTimeHours }
*/
function extractResponseTime() {
const result = {
responseTimeHours: null
};
try {
const bodyText = document.body?.innerText || "";
// Strategy 1: Label-basiert - Suche "Antwortzeit:"
const labelMatch = bodyText.match(/Antwortzeit[:\s]+(?:innerhalb\s+)?(\d+)\s*(?:Stunde|Stunden)/i);
if (labelMatch && labelMatch[1]) {
result.responseTimeHours = parseInt(labelMatch[1], 10);
if (!isNaN(result.responseTimeHours) && result.responseTimeHours > 0) {
return result;
}
}
// Strategy 2: Selector-Fallback
try {
const spans = document.querySelectorAll('span.str-text-span, span[class*="str-text-span"]');
for (const span of spans) {
const spanText = span.textContent || "";
if (spanText.includes('Antwortzeit')) {
const nextSpan = span.nextElementSibling;
if (nextSpan) {
const timeText = nextSpan.textContent || "";
const match = timeText.match(/(?:innerhalb\s+)?(\d+)\s*(?:Stunde|Stunden)/i);
if (match && match[1]) {
const hours = parseInt(match[1], 10);
if (!isNaN(hours) && hours > 0) {
result.responseTimeHours = hours;
return result;
}
}
}
}
}
} catch (e) {
// Continue to regex fallback
}
// Strategy 3: Regex-Fallback
const regexMatch = bodyText.match(/innerhalb\s+(\d+)\s*(?:Stunde|Stunden)/i);
if (regexMatch && regexMatch[1]) {
const hours = parseInt(regexMatch[1], 10);
if (!isNaN(hours) && hours > 0) {
result.responseTimeHours = hours;
}
}
} catch (e) {
console.warn("[EBAY-CONTENT] Error extracting response time:", e);
}
return result;
}
/**
* Extrahiert Follower von Store-Seite
* Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex
* @returns {object} { followers }
*/
function extractFollowers() {
const result = {
followers: null
};
try {
const bodyText = document.body?.innerText || "";
// Strategy 1: Label-basiert - Suche "Follower"
const followerMatch = bodyText.match(/(\d+[\d.,]*)\s*Follower/i);
if (followerMatch && followerMatch[1]) {
result.followers = parseNumberWithSeparators(followerMatch[1]);
if (result.followers !== null) {
return result;
}
}
// Strategy 2: Selector-Fallback
try {
const statsContainer = document.querySelector('.str-seller-card__store-stats-content, [class*="str-seller-card__store-stats-content"]');
if (statsContainer) {
const divs = statsContainer.querySelectorAll('div');
for (const div of divs) {
const divText = div.textContent || "";
if (divText.includes('Follower')) {
const boldSpan = div.querySelector('span.BOLD, span[class*="BOLD"], .str-text-span.BOLD');
if (boldSpan) {
const followerText = boldSpan.textContent || "";
result.followers = parseNumberWithSeparators(followerText);
if (result.followers !== null) {
return result;
}
}
// Fallback: regex on div text
const match = divText.match(/(\d+[\d.,]*)\s*Follower/i);
if (match && match[1]) {
result.followers = parseNumberWithSeparators(match[1]);
if (result.followers !== null) {
return result;
}
}
}
}
}
} catch (e) {
// Continue to regex fallback
}
// Strategy 3: Regex-Fallback auf bodyText (wenn noch nicht gefunden)
if (result.followers === null) {
const regexMatch = bodyText.match(/(\d+[\d.,]*)\s*Follower/i);
if (regexMatch && regexMatch[1]) {
result.followers = parseNumberWithSeparators(regexMatch[1]);
}
}
} catch (e) {
console.warn("[EBAY-CONTENT] Error extracting followers:", e);
}
return result;
}
/**
* Findet Store-URL auf der aktuellen Seite
* Sucht nach Links mit Text "Store" / "Shop" oder href enthält "/str/"
* @returns {string|null} Store URL oder null
*/
function findStoreLink() {
try {
// Strategy 1: Suche Links mit Text "Store" / "Shop"
const allLinks = document.querySelectorAll('a[href]');
for (const link of allLinks) {
const linkText = (link.textContent || "").toLowerCase().trim();
const href = link.href || link.getAttribute('href') || "";
if (href.includes('/str/')) {
// Absoluter oder relativer URL
try {
const url = new URL(href, window.location.origin);
if (url.hostname.includes('ebay.')) {
return url.href;
}
} catch (e) {
// Invalid URL, continue
}
}
// Prüfe Link-Text
if ((linkText.includes('store') || linkText.includes('shop')) && href.includes('/str/')) {
try {
const url = new URL(href, window.location.origin);
if (url.hostname.includes('ebay.')) {
return url.href;
}
} catch (e) {
// Invalid URL, continue
}
}
}
// Strategy 2: Suche nach href mit /str/ Pattern
for (const link of allLinks) {
const href = link.href || link.getAttribute('href') || "";
if (href.includes('/str/')) {
try {
const url = new URL(href, window.location.origin);
if (url.hostname.includes('ebay.')) {
return url.href;
}
} catch (e) {
// Invalid URL, continue
}
}
}
return null;
} catch (e) {
console.warn("[EBAY-CONTENT] Error finding store link:", e);
return null;
}
}
/**
* Parst eine Zahl mit Tausendertrennern und "Mio." Unterstützung
* @param {string} text - Zahl-String z.B. "884.318" oder "2,4 Mio."
* @returns {number|null} Geparste Zahl oder null
*/
function parseNumberWithSeparators(text) {
if (!text || typeof text !== 'string') return null;
try {
// Entferne Leerzeichen
let normalized = text.trim().replace(/\s/g, '');
// Prüfe auf "Mio." / "Millionen"
const mioMatch = normalized.match(/([\d,]+)\s*[,.]?\s*(?:Mio|Millionen)/i);
if (mioMatch && mioMatch[1]) {
const numStr = mioMatch[1].replace(/,/g, '.');
const num = parseFloat(numStr);
if (!isNaN(num)) {
return Math.round(num * 1000000);
}
}
// Entferne Tausendertrenner (Punkte und Kommas)
normalized = normalized.replace(/\./g, ''); // Entferne Punkte (Tausendertrenner)
normalized = normalized.replace(/,/g, '.'); // Ersetze Kommas durch Punkte (Dezimaltrenner)
// Nur Digits und einen Dezimalpunkt behalten
normalized = normalized.replace(/[^\d.]/g, '');
// Parse zu Integer (für Follower/Feedback sind nur ganze Zahlen relevant)
const num = parseInt(normalized, 10);
if (!isNaN(num) && num >= 0) {
return num;
}
return null;
} catch (e) {
return null;
}
}
/**
* Parst Preis-String in Zahl und Währung
* @param {string} priceText - Preis-String z.B. "EUR 12,99" oder "$15.50"