// ==UserScript==
// @name Makaytron Etsy Listing Analyzer
// @name:tr Makaytron Etsy Listing Analyzer
// @name:en Makaytron Etsy Listing Analyzer
// @version 1.0.5
// @description Etsy listing performansını izleyin, geçmişle karşılaştırın ve kullanıcı onaylı iyileştirme kuyrukları hazırlayın.
// @description:tr Etsy listing performansını izleyin, geçmişle karşılaştırın ve kullanıcı onaylı iyileştirme kuyrukları hazırlayın.
// @description:en Track Etsy listing performance, compare history, and prepare user-approved improvement queues.
// @namespace https://github.com/Makaytron/EtsyScript
// @author Makaytron (@Makaytron)
// @license MIT
// @antifeature tracking
// @homepageURL https://github.com/Makaytron/Etsy-Automation-Tools/tree/main/scripts/etsy-listing-analyzer
// @supportURL https://github.com/Makaytron/Etsy-Automation-Tools/issues
// @updateURL https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/scripts/etsy-listing-analyzer/Makaytron-Etsy-Listing-Analyzer.user.js
// @downloadURL https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/scripts/etsy-listing-analyzer/Makaytron-Etsy-Listing-Analyzer.user.js
// @match https://www.etsy.com/your/shops/*/tools/listings*
// @match https://www.etsy.com/your/shops/*/listing-editor/edit/*
// @icon https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/assets/makaytron-logo.png
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.addValueChangeListener
// @grant GM.xmlHttpRequest
// @grant GM.openInTab
// @grant GM_info
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect raw.githubusercontent.com
// @connect sjwibgcflufmzaorlwqe.supabase.co
// @connect api.github.com
// @noframes
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const APP_VERSION = '1.0.5';
const TELEMETRY_ENDPOINT = 'https://sjwibgcflufmzaorlwqe.supabase.co/functions/v1/telemetry-ingest';
const TELEMETRY_HEADER_NAME = 'x-makaytron-telemetry';
const TELEMETRY_HEADER_VALUE = '1';
const TELEMETRY_SCRIPT_ID = 'etsy-listing-analyzer';
const TELEMETRY_ALLOWED_EVENTS = new Set(['script_opened', 'listing_full_scan_completed']);
const TELEMETRY_ALLOWED_ERROR_CODES = new Set(['selector_listing_cards', 'selector_listing_pagination', 'selector_listing_editor', 'selector_listing_publish_verify', 'selector_listing_deactivate_verify', 'runtime_listing_scan', 'storage_listing_state']);
const TELEMETRY_PRIVACY_URL = 'https://github.com/Makaytron/Etsy-Automation-Tools/blob/main/PRIVACY.en.md';
const TELEMETRY_STORAGE_PREFIX = `makaytron-telemetry:${TELEMETRY_SCRIPT_ID}:v1`;
const TELEMETRY_KEYS = Object.freeze({
installationId: `${TELEMETRY_STORAGE_PREFIX}:installation-id`,
enabled: `${TELEMETRY_STORAGE_PREFIX}:enabled`,
enablePending: `${TELEMETRY_STORAGE_PREFIX}:enable-pending`,
consentOperation: `${TELEMETRY_STORAGE_PREFIX}:consent-operation`,
noticeSeen: `${TELEMETRY_STORAGE_PREFIX}:notice-seen:error-codes-v1`,
sentDays: `${TELEMETRY_STORAGE_PREFIX}:sent-days`,
});
const TELEMETRY_MISSING_VALUE = `${TELEMETRY_STORAGE_PREFIX}:missing`;
const TELEMETRY_CONSENT_OPERATION_TTL_MS = 30_000;
const TELEMETRY_SENT_DAYS_OWNER_KEY = '__installationId';
const TELEMETRY_SENT_DAYS_LOCK_NAME = `${TELEMETRY_STORAGE_PREFIX}:sent-days-operation`;
const TELEMETRY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const telemetryPendingEvents = new Set();
const telemetryRequests = new Set();
let telemetrySentDaysWriteChain = Promise.resolve(false);
let telemetryOperationChain = Promise.resolve(false);
let telemetryInstallationPromise = null;
let telemetryInstallationListenerPromise = null;
let telemetryInstallationListenerReady = false;
let telemetryInstallationChangeChain = Promise.resolve(false);
let telemetryRemoteSentDaysRevision = 0;
let telemetryLatestRemoteSentDays = null;
let telemetryBlockedInSession = false;
let telemetryMenuRegistered = false;
async function telemetryReadValue(key, fallback) {
try {
if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') return { ok: true, value: await GM.getValue(key, fallback) };
if (typeof GM_getValue === 'function') return { ok: true, value: GM_getValue(key, fallback) };
} catch { return { ok: false, value: fallback }; }
return { ok: false, value: fallback };
}
async function telemetryGetValue(key, fallback) {
return (await telemetryReadValue(key, fallback)).value;
}
async function telemetrySetValue(key, value) {
try {
if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') await GM.setValue(key, value);
else if (typeof GM_setValue === 'function') GM_setValue(key, value);
else return false;
return true;
} catch { return false; }
}
async function telemetryDeleteValue(key) {
try {
if (typeof GM !== 'undefined' && typeof GM.deleteValue === 'function') await GM.deleteValue(key);
else if (typeof GM_deleteValue === 'function') GM_deleteValue(key);
else return telemetrySetValue(key, null);
return true;
} catch { return false; }
}
function telemetrySuppressed() {
return navigator.webdriver === true
|| navigator.doNotTrack === '1'
|| globalThis.doNotTrack === '1'
|| navigator.globalPrivacyControl === true;
}
function telemetryUuid() {
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
const bytes = new Uint8Array(16);
if (typeof globalThis.crypto?.getRandomValues === 'function') globalThis.crypto.getRandomValues(bytes);
else return '';
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map(value => value.toString(16).padStart(2, '0'));
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
}
function telemetryWebLocksAvailable() {
const locks = typeof navigator !== 'undefined' ? navigator.locks : null;
return !!locks && typeof locks.request === 'function';
}
function withTelemetrySentDaysLock(operation) {
const locks = typeof navigator !== 'undefined' ? navigator.locks : null;
if (locks && typeof locks.request === 'function') {
return locks.request(
TELEMETRY_SENT_DAYS_LOCK_NAME,
{ mode: 'exclusive' },
() => operation(true),
);
}
return Promise.resolve(false);
}
async function telemetryDeleteAndConfirmMissing(key) {
await telemetryDeleteValue(key);
const readback = await telemetryReadValue(key, TELEMETRY_MISSING_VALUE);
const cleared = readback.ok && readback.value === TELEMETRY_MISSING_VALUE;
if (!cleared) telemetryBlockedInSession = true;
return cleared;
}
async function clearTelemetrySentDaysForInstallationLocked(installationId) {
const remoteRevisionBefore = telemetryRemoteSentDaysRevision;
const read = await telemetryReadValue(TELEMETRY_KEYS.sentDays, TELEMETRY_MISSING_VALUE);
if (!read.ok) { telemetryBlockedInSession = true; return false; }
if (read.value === TELEMETRY_MISSING_VALUE) return true;
const stored = read.value && typeof read.value === 'object' && !Array.isArray(read.value) ? read.value : null;
const owner = stored?.[TELEMETRY_SENT_DAYS_OWNER_KEY];
if (typeof owner === 'string' && TELEMETRY_UUID_PATTERN.test(owner) && owner !== installationId) return true;
const cleared = await telemetryDeleteAndConfirmMissing(TELEMETRY_KEYS.sentDays);
const remoteValue = telemetryLatestRemoteSentDays;
const remoteOwner = remoteValue?.[TELEMETRY_SENT_DAYS_OWNER_KEY];
if (telemetryRemoteSentDaysRevision !== remoteRevisionBefore
&& typeof remoteOwner === 'string' && TELEMETRY_UUID_PATTERN.test(remoteOwner)
&& remoteOwner !== installationId) {
await telemetrySetValue(TELEMETRY_KEYS.sentDays, remoteValue);
const restored = await telemetryReadValue(TELEMETRY_KEYS.sentDays, null);
const preserved = restored.ok
&& restored.value?.[TELEMETRY_SENT_DAYS_OWNER_KEY] === remoteOwner;
if (!preserved) telemetryBlockedInSession = true;
return preserved;
}
return cleared;
}
function clearTelemetrySentDaysForInstallation(installationId) {
return withTelemetrySentDaysLock(
() => clearTelemetrySentDaysForInstallationLocked(installationId),
{ destructive: true },
);
}
async function reconcileReplacedTelemetryInstallation(oldInstallationId) {
const [deleted, sentDaysCleared] = await Promise.all([
telemetryDeleteInstallation(oldInstallationId),
clearTelemetrySentDaysForInstallation(oldInstallationId),
]);
if (!deleted || !sentDaysCleared) telemetryBlockedInSession = true;
return deleted && sentDaysCleared;
}
function queueTelemetryInstallationReconciliation(oldValue, newValue, remote) {
if (remote !== true
|| typeof oldValue !== 'string'
|| !TELEMETRY_UUID_PATTERN.test(oldValue)
|| oldValue === newValue) return;
const cleanup = telemetryInstallationChangeChain.then(
() => reconcileReplacedTelemetryInstallation(oldValue),
() => reconcileReplacedTelemetryInstallation(oldValue),
);
telemetryInstallationChangeChain = cleanup.catch(() => {
telemetryBlockedInSession = true;
return false;
});
}
async function ensureTelemetryInstallationListener() {
if (telemetryInstallationListenerReady) return true;
if (telemetryInstallationListenerPromise) return telemetryInstallationListenerPromise;
const pending = (async () => {
try {
const modern = typeof GM !== 'undefined' && typeof GM.addValueChangeListener === 'function'
? GM.addValueChangeListener.bind(GM)
: null;
const legacy = typeof GM_addValueChangeListener === 'function' ? GM_addValueChangeListener : null;
const register = modern || legacy;
if (!register) return false;
const installationListenerId = await register(
TELEMETRY_KEYS.installationId,
(_key, oldValue, newValue, remote) => queueTelemetryInstallationReconciliation(oldValue, newValue, remote),
);
const sentDaysListenerId = await register(
TELEMETRY_KEYS.sentDays,
(_key, _oldValue, newValue, remote) => {
if (remote !== true) return;
telemetryRemoteSentDaysRevision += 1;
telemetryLatestRemoteSentDays = newValue && typeof newValue === 'object' && !Array.isArray(newValue)
? { ...newValue } : null;
},
);
telemetryInstallationListenerReady = installationListenerId !== undefined && installationListenerId !== null
&& sentDaysListenerId !== undefined && sentDaysListenerId !== null;
return telemetryInstallationListenerReady;
} catch { return false; }
})();
telemetryInstallationListenerPromise = pending;
try { return await pending; }
finally { if (telemetryInstallationListenerPromise === pending) telemetryInstallationListenerPromise = null; }
}
function normalizeTelemetryConsentOperation(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const token = typeof value.token === 'string' ? value.token : '';
const intent = value.intent === 'enable' || value.intent === 'disable' ? value.intent : '';
const startedAt = Number(value.startedAt);
return token && intent && Number.isFinite(startedAt) ? { token, intent, startedAt } : null;
}
function telemetryConsentOperationActive(operation) {
const age = Date.now() - Number(operation?.startedAt);
return !!operation && age >= -5_000 && age < TELEMETRY_CONSENT_OPERATION_TTL_MS;
}
function telemetryConsentOperationToken() {
const uuid = telemetryUuid();
return TELEMETRY_UUID_PATTERN.test(uuid)
? uuid
: `op-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
}
async function telemetryConsentIdle() {
const read = await telemetryReadValue(TELEMETRY_KEYS.consentOperation, TELEMETRY_MISSING_VALUE);
if (!read.ok) { telemetryBlockedInSession = true; return false; }
if (read.value === TELEMETRY_MISSING_VALUE) return true;
const operation = normalizeTelemetryConsentOperation(read.value);
if (!operation) { telemetryBlockedInSession = true; return false; }
return !telemetryConsentOperationActive(operation);
}
async function beginTelemetryConsentOperation(intent) {
const existingRead = await telemetryReadValue(TELEMETRY_KEYS.consentOperation, TELEMETRY_MISSING_VALUE);
if (!existingRead.ok) { telemetryBlockedInSession = true; return ''; }
if (existingRead.value !== TELEMETRY_MISSING_VALUE) {
const existing = normalizeTelemetryConsentOperation(existingRead.value);
if (!existing || telemetryConsentOperationActive(existing)) return '';
}
const operation = { token: telemetryConsentOperationToken(), intent, startedAt: Date.now() };
await telemetrySetValue(TELEMETRY_KEYS.consentOperation, operation);
let verified = await telemetryReadValue(TELEMETRY_KEYS.consentOperation, null);
if (!verified.ok || normalizeTelemetryConsentOperation(verified.value)?.token !== operation.token) return '';
await new Promise((resolve) => setTimeout(resolve, 0));
verified = await telemetryReadValue(TELEMETRY_KEYS.consentOperation, null);
return verified.ok && normalizeTelemetryConsentOperation(verified.value)?.token === operation.token
? operation.token : '';
}
async function telemetryConsentOperationOwned(token, intent) {
const read = await telemetryReadValue(TELEMETRY_KEYS.consentOperation, null);
const operation = read.ok ? normalizeTelemetryConsentOperation(read.value) : null;
return !!operation && operation.token === token && operation.intent === intent
&& telemetryConsentOperationActive(operation);
}
async function releaseTelemetryConsentOperation(token, intent) {
if (!await telemetryConsentOperationOwned(token, intent)) return false;
await telemetryDeleteValue(TELEMETRY_KEYS.consentOperation);
const readback = await telemetryReadValue(TELEMETRY_KEYS.consentOperation, TELEMETRY_MISSING_VALUE);
const released = readback.ok && readback.value === TELEMETRY_MISSING_VALUE;
if (!released) telemetryBlockedInSession = true;
return released;
}
async function telemetryInstallationId() {
if (telemetryInstallationPromise) return telemetryInstallationPromise;
const pending = (async () => {
const listenerReady = await ensureTelemetryInstallationListener();
const storedRead = await telemetryReadValue(TELEMETRY_KEYS.installationId, '');
if (!storedRead.ok) return '';
const stored = storedRead.value;
if (typeof stored === 'string' && TELEMETRY_UUID_PATTERN.test(stored)) return stored;
if (!listenerReady && !telemetryWebLocksAvailable()) return '';
const created = telemetryUuid();
if (!TELEMETRY_UUID_PATTERN.test(created) || !await telemetrySetValue(TELEMETRY_KEYS.installationId, created)) return '';
const verifiedRead = await telemetryReadValue(TELEMETRY_KEYS.installationId, '');
return verifiedRead.ok && typeof verifiedRead.value === 'string' && TELEMETRY_UUID_PATTERN.test(verifiedRead.value)
? verifiedRead.value : '';
})();
telemetryInstallationPromise = pending;
try { return await pending; }
finally { if (telemetryInstallationPromise === pending) telemetryInstallationPromise = null; }
}
function sendTelemetry(method, payload) {
return new Promise(resolve => {
const modernRequest = typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function'
? GM.xmlHttpRequest.bind(GM)
: null;
const request = modernRequest || (typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null);
if (!request) { resolve(false); return; }
let settled = false;
const finish = value => {
if (settled) return;
settled = true;
resolve(Boolean(value));
};
const options = {
method,
url: TELEMETRY_ENDPOINT,
headers: {
'Content-Type': 'application/json',
[TELEMETRY_HEADER_NAME]: TELEMETRY_HEADER_VALUE,
},
data: JSON.stringify(payload),
anonymous: true,
timeout: 8000,
onload: response => finish(Number(response?.status) === 202),
onerror: () => finish(false),
onabort: () => finish(false),
ontimeout: () => finish(false),
};
try {
const pending = request(options);
if (pending && typeof pending.then === 'function') {
pending.then(response => finish(Number(response?.status) === 202)).catch(() => finish(false));
}
} catch { finish(false); }
});
}
function dispatchTelemetry(method, payload) {
const request = sendTelemetry(method, payload);
telemetryRequests.add(request);
void request.finally(() => telemetryRequests.delete(request));
return request;
}
function withTelemetryOperationLock(callback) {
const run = () => {
const locks = typeof navigator !== 'undefined' ? navigator.locks : null;
return locks && typeof locks.request === 'function'
? locks.request(`${TELEMETRY_STORAGE_PREFIX}:operation`, { mode: 'exclusive' }, callback)
: callback();
};
const operation = telemetryOperationChain.then(run, run);
telemetryOperationChain = operation.catch(() => false);
return operation;
}
function telemetryDeleteInstallation(installationId) {
if (typeof installationId !== 'string' || !TELEMETRY_UUID_PATTERN.test(installationId)) return Promise.resolve(false);
return dispatchTelemetry('DELETE', {
schema: 1,
installation_id: installationId,
script_id: TELEMETRY_SCRIPT_ID,
app_version: APP_VERSION,
});
}
async function compensateTelemetryInstallation(installationId) {
telemetryBlockedInSession = true;
return telemetryDeleteInstallation(installationId);
}
async function telemetrySignalStillEnabled(installationId) {
if (telemetryBlockedInSession) return false;
if (!await telemetryConsentIdle()) return false;
const enabledRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
if (!enabledRead.ok || enabledRead.value !== true || telemetryBlockedInSession) return false;
const pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
if (!pendingRead.ok || pendingRead.value !== TELEMETRY_MISSING_VALUE || telemetryBlockedInSession) return false;
const installationRead = await telemetryReadValue(TELEMETRY_KEYS.installationId, '');
return installationRead.ok && installationRead.value === installationId && !telemetryBlockedInSession;
}
function markTelemetrySignalSent(signalKey, utcDay, installationId) {
const write = telemetrySentDaysWriteChain.then(() => withTelemetrySentDaysLock(async (lockHeld) => {
const failClosed = async (clearSentDays = false) => {
if (clearSentDays && lockHeld) {
await clearTelemetrySentDaysForInstallationLocked(installationId);
}
await compensateTelemetryInstallation(installationId);
return false;
};
if (!await telemetrySignalStillEnabled(installationId)) return failClosed();
const storedDaysRead = await telemetryReadValue(TELEMETRY_KEYS.sentDays, {});
if (!storedDaysRead.ok) return failClosed();
const storedDays = storedDaysRead.value;
const sentDays = storedDays && typeof storedDays === 'object' && !Array.isArray(storedDays)
&& storedDays[TELEMETRY_SENT_DAYS_OWNER_KEY] === installationId ? storedDays : {};
if (!await telemetrySetValue(TELEMETRY_KEYS.sentDays, {
...sentDays,
[TELEMETRY_SENT_DAYS_OWNER_KEY]: installationId,
[signalKey]: utcDay,
})) return failClosed(true);
if (await telemetrySignalStillEnabled(installationId)) return true;
return failClosed(true);
}));
telemetrySentDaysWriteChain = write.catch(() => false);
return write;
}
async function trackTelemetry(eventName) {
if (!TELEMETRY_ALLOWED_EVENTS.has(eventName) || telemetrySuppressed() || !telemetryWebLocksAvailable()) return false;
return withTelemetryOperationLock(async () => {
if (telemetryBlockedInSession || telemetryPendingEvents.has(eventName)) return false;
telemetryPendingEvents.add(eventName);
try {
const enabledRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
if (!enabledRead.ok || enabledRead.value !== true || telemetryBlockedInSession) return false;
if (!await telemetryConsentIdle()) return false;
const pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
if (!pendingRead.ok || pendingRead.value !== TELEMETRY_MISSING_VALUE || telemetryBlockedInSession) return false;
const installationId = await telemetryInstallationId();
if (!installationId || telemetryBlockedInSession) return false;
const utcDay = new Date().toISOString().slice(0, 10);
const storedDaysRead = await telemetryReadValue(TELEMETRY_KEYS.sentDays, {});
if (!storedDaysRead.ok) return false;
const storedDays = storedDaysRead.value;
const sentDays = storedDays && typeof storedDays === 'object' && !Array.isArray(storedDays)
&& storedDays[TELEMETRY_SENT_DAYS_OWNER_KEY] === installationId ? storedDays : {};
if (sentDays[eventName] === utcDay) return false;
if (telemetryBlockedInSession) return false;
const accepted = await dispatchTelemetry('POST', {
schema: 1,
installation_id: installationId,
script_id: TELEMETRY_SCRIPT_ID,
event_name: eventName,
app_version: APP_VERSION,
});
if (!accepted) return false;
if (!await telemetrySignalStillEnabled(installationId)) {
await compensateTelemetryInstallation(installationId);
return false;
}
return markTelemetrySignalSent(eventName, utcDay, installationId);
} catch { return false; }
finally { telemetryPendingEvents.delete(eventName); }
});
}
async function trackTelemetryError(errorCode) {
if (!TELEMETRY_ALLOWED_ERROR_CODES.has(errorCode) || telemetrySuppressed() || !telemetryWebLocksAvailable()) return false;
const signalKey = `error:${errorCode}`;
return withTelemetryOperationLock(async () => {
if (telemetryBlockedInSession || telemetryPendingEvents.has(signalKey)) return false;
telemetryPendingEvents.add(signalKey);
try {
const enabledRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
if (!enabledRead.ok || enabledRead.value !== true || telemetryBlockedInSession) return false;
if (!await telemetryConsentIdle()) return false;
const pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
if (!pendingRead.ok || pendingRead.value !== TELEMETRY_MISSING_VALUE || telemetryBlockedInSession) return false;
const installationId = await telemetryInstallationId();
if (!installationId || telemetryBlockedInSession) return false;
const utcDay = new Date().toISOString().slice(0, 10);
const storedDaysRead = await telemetryReadValue(TELEMETRY_KEYS.sentDays, {});
if (!storedDaysRead.ok) return false;
const storedDays = storedDaysRead.value;
const sentDays = storedDays && typeof storedDays === 'object' && !Array.isArray(storedDays)
&& storedDays[TELEMETRY_SENT_DAYS_OWNER_KEY] === installationId ? storedDays : {};
if (sentDays[signalKey] === utcDay || telemetryBlockedInSession) return false;
const accepted = await dispatchTelemetry('POST', {
schema: 1,
installation_id: installationId,
script_id: TELEMETRY_SCRIPT_ID,
error_code: errorCode,
app_version: APP_VERSION,
});
if (!accepted) return false;
if (!await telemetrySignalStillEnabled(installationId)) {
await compensateTelemetryInstallation(installationId);
return false;
}
return markTelemetrySignalSent(signalKey, utcDay, installationId);
} catch { return false; }
finally { telemetryPendingEvents.delete(signalKey); }
});
}
async function disableTelemetryAndDelete() {
return withTelemetryOperationLock(async () => {
telemetryBlockedInSession = true;
const consentToken = await beginTelemetryConsentOperation('disable');
if (!consentToken) return { disabled: false, deleted: false };
let outcome = { disabled: false, deleted: false };
try {
outcome = await (async () => {
const settingSaved = await telemetrySetValue(TELEMETRY_KEYS.enabled, false);
const settingRead = settingSaved
? await telemetryReadValue(TELEMETRY_KEYS.enabled, true)
: { ok: false, value: true };
if (!settingRead.ok || settingRead.value !== false) return { disabled: false, deleted: false };
if (!await telemetryConsentOperationOwned(consentToken, 'disable')) return { disabled: false, deleted: false };
await Promise.allSettled([...telemetryRequests]);
await telemetrySentDaysWriteChain.catch(() => false);
const installationRead = await telemetryReadValue(TELEMETRY_KEYS.installationId, '');
if (!installationRead.ok) return { disabled: true, deleted: false };
if (!await telemetryConsentOperationOwned(consentToken, 'disable')) return { disabled: false, deleted: false };
const installationId = installationRead.value;
let deleted = true;
if (typeof installationId === 'string' && TELEMETRY_UUID_PATTERN.test(installationId)) {
deleted = await dispatchTelemetry('DELETE', {
schema: 1,
installation_id: installationId,
script_id: TELEMETRY_SCRIPT_ID,
app_version: APP_VERSION,
});
}
if (!await telemetryConsentOperationOwned(consentToken, 'disable')) return { disabled: false, deleted: false };
if (deleted) {
const sentDaysCleared = await withTelemetrySentDaysLock(
() => telemetryDeleteAndConfirmMissing(TELEMETRY_KEYS.sentDays),
{ destructive: true },
);
if (!sentDaysCleared) return { disabled: true, deleted: false };
const installationCleared = await telemetryDeleteAndConfirmMissing(TELEMETRY_KEYS.installationId);
const pendingCleared = await telemetryDeleteAndConfirmMissing(TELEMETRY_KEYS.enablePending);
return { disabled: true, deleted: installationCleared && sentDaysCleared && pendingCleared };
}
return { disabled: true, deleted: false };
})();
} finally {
const released = await releaseTelemetryConsentOperation(consentToken, 'disable');
if (!released && outcome.disabled) outcome = { disabled: false, deleted: false };
}
return outcome;
});
}
async function enableTelemetry() {
if (!telemetryWebLocksAvailable()) {
telemetryBlockedInSession = true;
return false;
}
return withTelemetryOperationLock(async () => {
telemetryBlockedInSession = true;
const consentToken = await beginTelemetryConsentOperation('enable');
if (!consentToken) return false;
let enabled = false;
try {
const installationId = await telemetryInstallationId();
if (!installationId || !await telemetryConsentOperationOwned(consentToken, 'enable')) return false;
await telemetrySetValue(TELEMETRY_KEYS.enablePending, installationId);
let pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
if (!pendingRead.ok || pendingRead.value !== installationId) {
pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
if (!pendingRead.ok || pendingRead.value !== installationId) return false;
}
if (!await telemetryConsentOperationOwned(consentToken, 'enable')) return false;
await telemetrySetValue(TELEMETRY_KEYS.enabled, true);
let settingRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, false);
if (!settingRead.ok || settingRead.value !== true) {
settingRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, false);
}
let installationRead = await telemetryReadValue(TELEMETRY_KEYS.installationId, '');
if (!installationRead.ok || installationRead.value !== installationId) {
installationRead = await telemetryReadValue(TELEMETRY_KEYS.installationId, '');
}
if (settingRead.ok && settingRead.value === true
&& installationRead.ok && installationRead.value === installationId
&& await telemetryConsentOperationOwned(consentToken, 'enable')) {
if (!await telemetryDeleteAndConfirmMissing(TELEMETRY_KEYS.enablePending)) return false;
enabled = true;
} else if (await telemetryConsentOperationOwned(consentToken, 'enable')) {
await telemetrySetValue(TELEMETRY_KEYS.enabled, false);
let rollbackRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
if (!rollbackRead.ok || rollbackRead.value !== false) {
rollbackRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
}
if (rollbackRead.ok && rollbackRead.value === false) {
await telemetryDeleteAndConfirmMissing(TELEMETRY_KEYS.enablePending);
}
}
} finally {
const released = await releaseTelemetryConsentOperation(consentToken, 'enable');
if (!released) enabled = false;
telemetryBlockedInSession = !enabled;
}
return enabled;
});
}
async function showTelemetryFirstRunNotice() {
const noticeId = `makaytron-telemetry-notice-${TELEMETRY_SCRIPT_ID}`;
if (!document.documentElement || document.getElementById(noticeId)) return;
const enabledRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
if (!enabledRead.ok) { telemetryBlockedInSession = true; return; }
if (enabledRead.value !== true) return;
if (!await telemetryConsentIdle()) return;
const pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
if (!pendingRead.ok || pendingRead.value !== TELEMETRY_MISSING_VALUE) { telemetryBlockedInSession = true; return; }
const noticeSeenRead = await telemetryReadValue(TELEMETRY_KEYS.noticeSeen, false);
if (!noticeSeenRead.ok) { telemetryBlockedInSession = true; return; }
if (noticeSeenRead.value === true) return;
const notice = document.createElement('aside');
notice.id = noticeId;
notice.setAttribute('role', 'status');
notice.style.cssText = 'all:initial;position:fixed;left:16px;bottom:16px;z-index:2147483647;box-sizing:border-box;width:min(380px,calc(100vw - 32px));padding:14px;border:1px solid #d6d6d6;border-radius:10px;background:#fff;color:#202020;box-shadow:0 16px 42px rgba(0,0,0,.2);font:13px/1.45 Inter,system-ui,sans-serif';
notice.innerHTML = `Privacy-preserving usage metricsUsage metrics are enabled by default. Only the script ID, version, a random installation ID, allowlisted open/success signals, and fixed error codes are sent. No raw error text or Etsy content is collected.
`;
document.documentElement.appendChild(notice);
const noticeSaved = await telemetrySetValue(TELEMETRY_KEYS.noticeSeen, true);
const noticeVerified = noticeSaved
? await telemetryReadValue(TELEMETRY_KEYS.noticeSeen, false)
: { ok: false, value: false };
if (!noticeVerified.ok || noticeVerified.value !== true) telemetryBlockedInSession = true;
notice.querySelector('[data-close]')?.addEventListener('click', () => notice.remove());
notice.querySelector('[data-disable]')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
const result = await disableTelemetryAndDelete();
const message = notice.querySelector('[data-message]');
if (message) message.textContent = !result.disabled
? 'Usage metrics could not be disabled because the setting was not saved. Metrics remain blocked in this tab; retry from the userscript menu.'
: result.deleted
? 'Usage metrics are disabled and server data was deleted.'
: 'Usage metrics are disabled, but cleanup could not be completed; retry from the userscript menu.';
button.disabled = false;
});
}
async function openTelemetrySettings() {
const modalId = `makaytron-telemetry-settings-${TELEMETRY_SCRIPT_ID}`;
if (!document.documentElement || document.getElementById(modalId)) return;
const modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;display:grid;place-items:center;padding:16px;background:rgba(0,0,0,.42);font:13px/1.45 Inter,system-ui,sans-serif';
modal.innerHTML = `Privacy-preserving usage metrics
`;
document.documentElement.appendChild(modal);
const renderState = async result => {
const enabledRead = await telemetryReadValue(TELEMETRY_KEYS.enabled, true);
const pendingRead = await telemetryReadValue(TELEMETRY_KEYS.enablePending, TELEMETRY_MISSING_VALUE);
const consentIdle = await telemetryConsentIdle();
if (!enabledRead.ok || !pendingRead.ok) telemetryBlockedInSession = true;
const enabledState = !enabledRead.ok || !pendingRead.ok || !consentIdle
|| pendingRead.value !== TELEMETRY_MISSING_VALUE
|| (enabledRead.value !== false && telemetryBlockedInSession)
? 'unavailable'
: enabledRead.value === false ? 'disabled' : 'enabled';
const state = modal.querySelector('[data-state]');
const output = modal.querySelector('[data-result]');
const enableButton = modal.querySelector('[data-enable]');
if (state) state.textContent = enabledState === 'enabled'
? 'Status: enabled. Allowlisted open/success signals and fixed error codes only; no raw error text or Etsy content is collected.'
: enabledState === 'disabled'
? 'Status: disabled.'
: 'Status: unavailable. Usage metrics remain blocked in this tab until the setting can be read and verified.';
if (output) output.textContent = result || '';
if (enableButton) enableButton.hidden = enabledState === 'enabled';
};
modal.querySelector('[data-close]')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', event => { if (event.target === modal) modal.remove(); });
modal.querySelector('[data-enable]')?.addEventListener('click', async () => {
await renderState(await enableTelemetry() ? 'Usage metrics enabled.' : 'The setting could not be saved.');
});
modal.querySelector('[data-disable]')?.addEventListener('click', async () => {
const result = await disableTelemetryAndDelete();
await renderState(!result.disabled
? 'Usage metrics could not be disabled because the setting was not saved. Metrics remain blocked in this tab; retry here.'
: result.deleted
? 'Usage metrics disabled; server data deleted.'
: 'Usage metrics are disabled, but cleanup could not be completed. You can retry here.');
});
await renderState('');
}
function telemetryPanelOpened() {
if (telemetrySuppressed()) return;
void showTelemetryFirstRunNotice()
.then(() => trackTelemetry('script_opened'))
.catch(() => {});
}
function registerTelemetryMenuCommand() {
if (telemetryMenuRegistered) return;
try {
if (typeof GM !== 'undefined' && typeof GM.registerMenuCommand === 'function') GM.registerMenuCommand('Makaytron · Usage metrics settings', openTelemetrySettings);
else if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand('Makaytron · Usage metrics settings', openTelemetrySettings);
else return;
telemetryMenuRegistered = true;
} catch {}
}
const RECORD_SCHEMA_VERSION = 1;
const HEALTH_RESULT_SCHEMA_VERSION = 1;
const HEALTH_ENGINE_VERSION = 3;
const HEALTH_POLICY_VERSION = 1;
const COLLECTION_SCHEMA_VERSION = 2;
const MAX_COLLECTION_PAGES = 250;
const COLLECTION_TRANSITION_TIMEOUT_MS = 15000;
const COLLECTION_RETRY_ATTEMPTS = 3;
const COLLECTION_RETRY_DELAYS_MS = Object.freeze([800, 1800]);
const COLLECTION_STABLE_SAMPLES = 3;
const COLLECTION_STABLE_SAMPLE_INTERVAL_MS = 250;
const COLLECTION_STABLE_READ_TIMEOUT_MS = 4000;
const SNAPSHOT_OBSERVATION_MAX_SKEW_MS = 15 * 60 * 1000;
const ANALYSIS_BATCH_SIZE = 40;
const ANALYSIS_FRESHNESS_MS = 24 * 60 * 60 * 1000;
const MAX_FILTER_PRESETS = 8;
const MAX_BACKUP_RECORDS = 5000;
const MAX_BACKUP_BYTES = 25 * 1024 * 1024;
const STORAGE_WARNING_BYTES = 8 * 1024 * 1024;
const MAX_FEEDBACK_ENTRIES = 20;
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const UPDATE_CHECK_TIMEOUT_MS = 12000;
const GITHUB_SCRIPT_PATH = 'scripts/etsy-listing-analyzer/Makaytron-Etsy-Listing-Analyzer.user.js';
const GITHUB_RAW_REPOSITORY_URL = 'https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools';
const GITHUB_CANONICAL_SCRIPT_URL = `${GITHUB_RAW_REPOSITORY_URL}/main/${GITHUB_SCRIPT_PATH}`;
const GITHUB_API_REF_URL = 'https://api.github.com/repos/Makaytron/Etsy-Automation-Tools/commits/main';
const RESEARCH_CHANNEL_NAME = 'makaytron:etsy-keyword-market-analyzer:v1';
const RESEARCH_ENVELOPE_SCHEMA = 'makaytron-etsy-keyword-market-analyzer-envelope/v1';
const RESEARCH_REQUEST_SCHEMA = 'makaytron-listing-research-request/v1';
const RESEARCH_RESULT_SCHEMA = 'makaytron-listing-research-result/v1';
const RESEARCH_EVIDENCE_SCHEMA = 'makaytron-research-evidence/v1';
const RESEARCH_INSTALL_URL = 'https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/scripts/etsy-keyword-market-analyzer/Makaytron-Etsy-Keyword-Market-Analyzer.user.js';
const RESEARCH_INSIGHTS_URL = 'https://www.etsy.com/your/shops/me/marketplace-insights/search';
const RESEARCH_MESSAGE_MAX_BYTES = 64 * 1024;
const RESEARCH_REQUEST_TTL_MS = 10 * 60 * 1000;
const RESEARCH_MAX_TTL_MS = 30 * 60 * 1000;
const RESEARCH_PROBE_TIMEOUT_MS = 3600;
const RESEARCH_PROBE_RETRY_DELAYS_MS = Object.freeze([0, 600, 1500, 2600]);
const RESEARCH_UI_TIMEOUT_MS = 2 * 60 * 1000;
const RESEARCH_MAX_PENDING = 10;
const RESEARCH_REMOTE_MAX_QUEUE = 30;
const STORAGE_MUTATION_LOCK = 'makaytron-listing-analyzer:storage-mutation';
const COLLECTION_HANDOFF_KEY = 'meli:v1:collection-navigation-handoff';
const APP = Object.freeze({
name: 'Makaytron Etsy Listing Analyzer',
shortName: 'Listing Analyzer',
storagePrefix: 'meli:v1',
schema: RECORD_SCHEMA_VERSION,
retentionDays: 400,
maxSnapshots: 120,
maxImprovements: 80,
maxAuditEntries: 300,
});
const HEALTH_RULES = Object.freeze({
anchorToleranceDays: Object.freeze({ 30: 7, 60: 10, 90: 14 }),
minimumCohortSize: 8,
renewalWasteMinimum: 2,
deactivationHistoryDays: 60,
deactivationZeroObservationGapDays: 14,
improvementCooldownDays: 45,
experimentDays: 30,
experimentEvaluationGraceDays: 7,
recentSaleProtectionDays: 30,
});
const SNAPSHOT_NUMBER_FIELDS = Object.freeze(['visits', 'favorites', 'sales', 'revenue', 'renewals', 'stock', 'priceMin', 'priceMax']);
const HEALTH_METRIC_FIELDS = Object.freeze(['visits', 'favorites', 'sales', 'revenue', 'renewals']);
const EDITABLE_FIELDS = Object.freeze(['title', 'description', 'tags', 'materials']);
const KEYS = Object.freeze({
settings: `${APP.storagePrefix}:settings`,
index: `${APP.storagePrefix}:listing-index`,
queue: `${APP.storagePrefix}:queue`,
audit: `${APP.storagePrefix}:audit`,
lease: `${APP.storagePrefix}:lease`,
collection: `${APP.storagePrefix}:collection`,
collectionLease: `${APP.storagePrefix}:collection-lease`,
analysisFilters: `${APP.storagePrefix}:analysis-filters`,
filterPresets: `${APP.storagePrefix}:filter-presets`,
updateCheck: `${APP.storagePrefix}:update-check`,
aiRequests: `${APP.storagePrefix}:ai-requests`,
feedback: `${APP.storagePrefix}:feedback`,
researchRequests: `${APP.storagePrefix}:research-requests`,
record: (listingId) => `${APP.storagePrefix}:listing:${listingId}`,
});
const DEFAULT_SETTINGS = Object.freeze({
language: 'tr',
collapsed: false,
minVisitsToImprove: 20,
minVisitsToProtect: 60,
minRenewalsToReview: 2,
declinePercent: 35,
retentionDays: APP.retentionDays,
maxSnapshots: APP.maxSnapshots,
});
const DEFAULT_ANALYSIS_FILTERS = Object.freeze({
scope: 'all',
lifecycle: '',
diagnosis: '',
recommendation: '',
performance: '',
trend: '',
stock: '',
confidence: '',
sort: 'priority',
});
const BUILTIN_FILTER_PRESETS = Object.freeze([
Object.freeze({ id: 'growing', labelKey: 'presetGrowing', filters: Object.freeze({ lifecycle: 'ACTIVE_GROWING' }) }),
Object.freeze({ id: 'improve', labelKey: 'presetImprove', filters: Object.freeze({ recommendation: 'improve' }) }),
Object.freeze({ id: 'declining', labelKey: 'presetDeclining', filters: Object.freeze({ lifecycle: 'ACTIVE_DECLINING' }) }),
Object.freeze({ id: 'deactivate', labelKey: 'presetDeactivate', filters: Object.freeze({ lifecycle: 'DEACTIVATION_REVIEW' }) }),
Object.freeze({ id: 'experiments', labelKey: 'presetExperiments', filters: Object.freeze({ lifecycle: 'EXPERIMENT_RUNNING' }) }),
Object.freeze({ id: 'missing', labelKey: 'presetMissing', filters: Object.freeze({ performance: 'missing' }) }),
]);
const FILTER_FACET_VALUES = Object.freeze({
scope: Object.freeze(['all', 'page']),
lifecycle: Object.freeze(['', 'DATA_GAP', 'BASELINE', 'LEARNING', 'ACTIVE_STABLE', 'ACTIVE_GROWING', 'ACTIVE_DECLINING', 'PROTECTED', 'EXPERIMENT_RUNNING', 'DORMANT', 'DEACTIVATION_REVIEW', 'INACTIVE']),
diagnosis: Object.freeze(['', 'DISCOVERY_WEAK', 'ENGAGEMENT_WEAK', 'PURCHASE_FRICTION', 'SCALE_DISCOVERY', 'HEALTHY_OR_MIXED', 'INSUFFICIENT_SIGNAL']),
recommendation: Object.freeze(['', 'improve', 'declining', 'deactivateReview', 'protected', 'monitor', 'waiting', 'growing']),
performance: Object.freeze(['', 'sales', 'traffic-no-sales', 'no-activity', 'missing']),
trend: Object.freeze(['', 'rising', 'falling', 'stable', 'unknown']),
stock: Object.freeze(['', 'in', 'out', 'unknown']),
confidence: Object.freeze(['', 'low', 'medium', 'high']),
});
const I18N = Object.freeze({
tr: Object.freeze({
launcher: 'Listing Analyzer panelini aç',
panelAria: 'Makaytron Etsy Listing Analyzer kontrol paneli',
product: 'Listing Analyzer',
overview: 'Genel bakış',
wideView: 'Geniş görünüm',
compactView: 'Dar görünüm',
ready: 'Hazır',
scanning: 'Taranıyor',
error: 'Hata',
blocked: 'Durduruldu',
saved: 'Kaydedildi',
collapse: 'Paneli gizle',
expand: 'Paneli aç',
switchLanguage: 'Switch to English',
pageReady: 'Bu sayfadaki listingler analiz için hazır.',
editorReady: 'Bu listing için düzenleme ve işlem kuyruğu hazır.',
unsupportedPage: 'Bu araç yalnız listing listesi ve listing düzenleyicisinde çalışır.',
scanPage: 'Bu sayfayı tara',
scanAndNext: 'Tara ve sonraki sayfayı aç',
openAnalysis: 'Listing analizleri',
aiExchange: 'AI önerileri',
exportData: 'Yedek indir',
queueTitle: 'İşlem kuyruğu',
noQueue: 'Aktif işlem kuyruğu yok.',
listings: 'Kayıtlı listing',
pageListings: 'Bu sayfa',
growing: 'Yükselişte',
improve: 'İyileştir',
deactivateReview: 'Kapatmayı incele',
protected: 'Korunmalı',
monitor: 'İzle',
waiting: 'Veri bekleniyor',
declining: 'Düşüşte',
scanComplete: '{count} listing okundu; {saved} kayıt güncellendi.',
noCards: 'Listing kartları bulunamadı. Etsy istatistik görünümünü açın.',
nextMissing: 'Sonraki sayfa düğmesi bulunamadı veya devre dışı.',
pageProgress: 'Sayfa {current}/{total}',
analysisTitle: 'Listing analizleri',
search: 'Başlık, SKU veya ID ara',
filterAll: 'Tüm öneriler',
selected: '{count} seçili',
selectAll: 'Görünenleri seç',
clearSelection: 'Seçimi kaldır',
listing: 'Listing',
performance: 'Performans',
recommendation: 'Öneri',
trend: 'Değişim',
actions: 'İşlemler',
visits: 'Ziyaret',
favorites: 'Favori',
sales: 'Satış',
revenue: 'Gelir',
renewals: 'Yenileme',
stock: 'Stok',
price: 'Fiyat',
noData: 'Henüz kayıtlı listing yok.',
editProposal: 'İyileştirme planı',
history: 'Geçmiş',
openEtsy: 'Etsy’de aç',
proposalTitle: 'Listing iyileştirme önerisi',
actionLabel: 'Planlanan işlem',
actionUpdate: 'Seçtiğim alanları güncelle',
actionDeactivate: 'Deaktivasyonu kullanıcıya öner',
actionSkip: 'İşlem yapma',
title: 'Başlık',
description: 'Açıklama',
tags: 'Etiketler',
materials: 'Materyaller',
tagsHelp: 'Virgülle ayırın; en fazla 13 etiket ve etiket başına 20 karakter.',
materialsHelp: 'Virgülle ayırın.',
reason: 'Gerekçe / not',
saveProposal: 'Öneriyi kaydet',
cancel: 'İptal',
close: 'Kapat',
invalidTitle: 'Başlık 1–140 karakter olmalı.',
invalidTags: 'En fazla 13 etiket kullanılabilir ve her etiket 20 karakteri geçemez.',
changeField: 'Bu alanı değiştir',
selectChangeField: 'UPDATE işlemi için değiştirilecek en az bir alan seçin.',
proposalSaved: 'Öneri kaydedildi ve iyileştirme geçmişine başlangıç kaydı eklendi.',
buildQueue: 'Seçilenlerden kuyruk hazırla',
queueConfirm: '{count} listing için kullanıcı onaylı işlem kuyruğu hazırlansın mı? Hiçbir listing bu onayla otomatik yayımlanmayacak.',
queueCreated: '{count} listing işlem kuyruğuna eklendi.',
noProposalSelection: 'Seçmek tek başına kuyruk oluşturmaz. Önce İyileştirme planı’nda uygulanabilir bir işlem kaydedin veya geçerli AI öneri JSON’unu içe aktarın; ardından kaydedilmiş önerisi olan listingi seçip yeniden deneyin.',
goFirst: 'İlk listingi aç',
currentItem: 'Kuyruk {current}/{total}',
applyForm: 'Öneriyi forma uygula',
publishAfterReview: 'İnceledim, Etsy’de yayımla',
openDeactivate: 'Etsy seçeneklerini aç',
deactivateConfirm: '{id} numaralı listing için yalnız Etsy seçenekleri menüsünü açmak istediğinizi onaylıyor musunuz? Deactivate öğesine ve son onaya Etsy’de siz tıklayacaksınız.',
verifyDeactivate: 'Deaktivasyonu doğrula ve devam et',
skipItem: 'Bu listingi atla',
stopQueue: 'Kuyruğu durdur',
continueQueue: 'Sonraki listing',
routeMismatch: 'Kuyruk listing ID’si ile açık sayfa uyuşmuyor. İşlem güvenli biçimde durduruldu.',
formNotReady: 'Gerekli Etsy form alanları hazır değil; hiçbir değişiklik uygulanmadı.',
formApplied: 'Öneri forma uygulandı. Etsy alanlarını inceleyin; henüz yayımlanmadı.',
publishDisabled: 'Etsy “Publish changes” düğmesi etkin değil. Form doğrulamalarını kontrol edin.',
publishConfirm: 'Listing {id} için {fields} alanlarını Etsy’de yayımlamak istediğinizi onaylıyor musunuz?',
publishSubmitted: 'Yayınlama Etsy’ye gönderildi; sonuç doğrulanıyor.',
publishVerified: 'Etsy kaydı doğrulandı. İşlem geçmişe kaydedildi.',
publishUnverified: 'Etsy sonucu doğrulanamadı. Körlemesine sonraki listinge geçilmedi.',
deactivateOpened: 'Etsy seçenekleri açıldı. Deactivate öğesine ve Etsy’nin son onayına siz tıklayın.',
deactivateNotVerified: 'Listing hâlâ aktif görünüyor; kuyruk ilerletilmedi.',
queueStopped: 'İşlem kuyruğu durduruldu.',
queueComplete: 'İşlem kuyruğu tamamlandı.',
aiTitle: 'AI öneri alışverişi',
aiIntro: 'Bu sürüm AI servisine ağ isteği göndermez. Seçili listingleri prompt/JSON olarak kopyalayın; AI sonucunu aşağıya yapıştırıp doğrulayın.',
copyAiRequest: 'AI isteğini kopyala',
importAiResponse: 'AI yanıtını içe aktar',
aiResponseLabel: 'AI öneri JSON’u',
aiCopied: '{count} listing için AI istek paketi panoya kopyalandı.',
aiImportSuccess: '{count} geçerli AI önerisi içe aktarıldı.',
aiImportError: 'AI JSON’u doğrulanamadı: {message}',
selectForAi: 'Önce listing analiz kartlarından seçim yapın.',
exportComplete: 'Yerel analiz yedeği indirildi.',
historyTitle: 'Analiz ve iyileştirme geçmişi',
capturedAt: 'Kayıt zamanı',
changes: 'Değişiklikler',
baseline: 'Başlangıç',
latest: 'Güncel',
noHistory: 'Karşılaştırma için en az iki farklı gün kaydı gerekir.',
settingsTitle: 'Analiz eşikleri',
minVisitsToImprove: 'Satış yoksa iyileştirme eşiği (ziyaret)',
minVisitsToProtect: 'Güçlü listing ziyaret eşiği',
minRenewalsToReview: 'Deaktivasyon inceleme eşiği (yenileme)',
declinePercent: 'Düşüş uyarısı yüzdesi',
saveSettings: 'Ayarları kaydet',
settingsSaved: 'Analiz eşikleri kaydedildi.',
openSettings: 'Eşikler',
editThresholds: 'Analiz eşiklerini düzenle',
clearData: 'Yerel analiz verilerini temizle',
clearDataConfirm: 'Tüm listing snapshotları, iyileştirme kayıtları, AI eşleştirmeleri, kuyruk ve işlem geçmişi bu tarayıcıdan silinsin mi? Dil ve eşik ayarları korunur.',
dataCleared: 'Yerel listing analiz verileri temizlendi; dil ve eşik ayarları korundu.',
deleteUnsupported: 'Silme işlemi güvenlik nedeniyle desteklenmez.',
leaseBlocked: 'İşlem kuyruğu başka bir sekmede açık. Bu sekmede yazma işlemi başlatılmadı.',
reasonNew: 'Geçmiş karşılaştırması için yeni kayıt.',
reasonGrowth: 'Önceki kayda göre satış, gelir, ziyaret veya favori artışı var.',
reasonProtect: 'Satış ve gelir üreten güçlü listing; riskli toplu değişikliklerden koruyun.',
reasonImproveTraffic: 'Ziyaret var ancak satış yok; başlık, görsel, fiyat ve teklif uyumunu inceleyin.',
reasonDeactivate: 'En az {renewals} yenilemeye rağmen satış, ziyaret ve favori yok; deaktivasyonu inceleyin.',
reasonDecline: 'Son 30 gün ziyareti önceki kayda göre %{percent} veya daha fazla düştü.',
reasonMonitor: 'Acil aksiyon sinyali yok; yeni veri geldikçe izleyin.',
reasonUnknown: 'Bazı performans metrikleri okunamadı; eksik değerler sıfır kabul edilmedi ve karar üretilmedi.',
reasonLearning: 'Güvenilir karar için en az 30 günlük karşılaştırma geçmişi toplanıyor.',
reasonExperiment: 'Yayımlanan iyileştirme 30 günlük değerlendirme penceresinde izleniyor.',
reasonDormant: 'Listing hareketsiz görünüyor; kapatma kararı için güvenlik koşulları henüz tamamlanmadı.',
reasonDeactivateSafe: '60+ günlük geçmiş, tekrarlanan sıfır trafik ve satış olmaması kapatma incelemesini destekliyor; son karar kullanıcıya aittir.',
reasonInactive: 'Listing Etsy üzerinde pasif görünüyor; yazma işlemi önerilmedi.',
health: 'Sağlık',
healthy: 'Koru / izle',
needsReview: 'İncelenecek',
activeExperiments: 'Aktif deney',
listingHealth: 'Listing sağlığı',
healthAndHistory: 'Listing sağlığı ve geçmişi',
lifecycle: 'Yaşam döngüsü',
diagnosis: 'Performans teşhisi',
confidence: 'Güven',
evidence: 'Kanıtlar',
showAllEvidence: 'Tüm kanıtları göster ({count})',
confidenceDetails: 'Güven bileşenleri',
dataQuality: 'Veri kalitesi',
historyDepth: 'Geçmiş yeterliliği',
trafficSample: 'Trafik örneklemi',
cohortStrength: 'Karşılaştırma grubu',
freshness: 'Güncellik',
dataIntegrity: 'Veri tutarlılığı',
shopBenchmark: 'Mağaza karşılaştırması',
experiment: 'Deney',
experimentState: 'Deney durumu',
primaryMetric: 'Ana metrik',
adjustedEffect: 'Düzeltilmiş etki',
nextReview: 'Sonraki inceleme',
afterNextSnapshot: 'Sonraki snapshot sonrası',
missingData: 'Eksik veriler',
whyRecommendation: 'Neden bu öneri?',
details: 'Ayrıntılar',
confidenceLimited: 'Güven, yetersiz geçmiş veya veri kalitesi nedeniyle sınırlandı.',
lifecycleDataGap: 'Veri eksik',
lifecycleBaseline: 'Başlangıç verisi',
lifecycleLearning: 'Veri topluyor',
lifecycleStable: 'Dengeli',
lifecycleGrowing: 'Yükselişte',
lifecycleDeclining: 'Düşüşte',
lifecycleProtected: 'Korumalı',
lifecycleExperiment: 'Deneyde',
lifecycleDormant: 'Hareketsiz',
lifecycleDeactivate: 'Kapatmayı incele',
lifecycleInactive: 'Pasif',
diagnosisDiscovery: 'Görünürlük zayıf',
diagnosisEngagement: 'Ziyaret sonrası ilgi zayıf',
diagnosisPurchase: 'Satın alma engeli olabilir',
diagnosisScale: 'Erişim artırılabilir',
diagnosisHealthy: 'Belirgin sorun yok',
diagnosisInsufficient: 'Teşhis için veri az',
confidenceLow: 'Düşük',
confidenceMedium: 'Orta',
confidenceHigh: 'Yüksek',
confidenceVeryHigh: 'Çok yüksek',
evidenceHistory: '{days} günlük geçmiş · {count} snapshot',
evidenceTraffic: '30 günlük ziyaret: {current}; önceki pencere: {previous} · %{percent}',
evidenceRecentSales: 'Yaklaşık son 30 gün: {sales} satış · {revenue} gelir',
evidenceCohort: '{size} mağaza listingi içinde trafik yüzdeliği: {percentile}',
evidenceAnomaly: '{count} veri tutarlılığı uyarısı bulundu.',
guardHistory: '60+ gün ve en az 3 tam snapshot',
guardZeroTraffic: 'En az 14 gün arayla tekrarlanan sıfır trafik',
guardNoSales: 'Son 60 günde yeni satış veya gelir yok',
guardRenewals: 'Satış olmadan yenileme maliyeti devam ediyor',
guardActiveStock: 'Listing aktif ve stoklu',
guardNoExperiment: 'Aktif deney yok',
guardCooldown: 'Son 45 günde yayımlanan iyileştirme yok',
guardSeasonal: 'Mevsimsel olmadığı açıkça doğrulandı',
guardConfidence: 'Güven puanı en az 80',
experimentPlanned: 'Planlandı',
experimentObserving: 'İzleniyor · {day}/30 gün',
experimentWinner: 'Kazandı',
experimentUnderperformed: 'Beklentinin altında',
experimentInconclusive: 'Belirsiz',
experimentContaminated: 'Başka değişiklik nedeniyle ayrıştırılamadı',
experimentStopped: 'Durduruldu',
contentChanged: 'Listing içeriği öneri kaydedildikten sonra değişti. Form korunarak işlem durduruldu; öneriyi yeniden inceleyin.',
proposalStale: 'Listing verisi veya analiz ayarları öneri kaydedildikten sonra değişti. Kuyruk oluşturulmadı; öneriyi yeniden inceleyin.',
manualApproval: 'Her Etsy yazma işlemi listing bazında kullanıcı onayı ister.',
}),
en: Object.freeze({
launcher: 'Open Listing Analyzer panel', panelAria: 'Makaytron Etsy Listing Analyzer control panel', product: 'Listing Analyzer', overview: 'Overview', wideView: 'Wide view', compactView: 'Compact view', ready: 'Ready', scanning: 'Scanning', error: 'Error', blocked: 'Stopped', saved: 'Saved', collapse: 'Hide panel', expand: 'Open panel', switchLanguage: 'Türkçeye geç', pageReady: 'Listings on this page are ready for analysis.', editorReady: 'The edit and action queue is ready for this listing.', unsupportedPage: 'This tool only runs on the listing list and listing editor.', scanPage: 'Scan this page', scanAndNext: 'Scan and open next page', openAnalysis: 'Listing analysis', aiExchange: 'AI proposals', exportData: 'Download backup', queueTitle: 'Action queue', noQueue: 'There is no active action queue.', listings: 'Stored listings', pageListings: 'This page', growing: 'Growing', improve: 'Improve', deactivateReview: 'Review deactivation', protected: 'Protect', monitor: 'Monitor', waiting: 'Waiting for data', declining: 'Declining', scanComplete: '{count} listings read; {saved} records updated.', noCards: 'Listing cards were not found. Enable Etsy statistics view.', nextMissing: 'The next-page control was not found or is disabled.', pageProgress: 'Page {current}/{total}', analysisTitle: 'Listing analysis', search: 'Search title, SKU, or ID', filterAll: 'All recommendations', selected: '{count} selected', selectAll: 'Select visible', clearSelection: 'Clear selection', listing: 'Listing', performance: 'Performance', recommendation: 'Recommendation', trend: 'Change', actions: 'Actions', visits: 'Visits', favorites: 'Favorites', sales: 'Sales', revenue: 'Revenue', renewals: 'Renewals', stock: 'Stock', price: 'Price', noData: 'No listings have been stored yet.', editProposal: 'Improvement plan', history: 'History', openEtsy: 'Open on Etsy', proposalTitle: 'Listing improvement proposal', actionLabel: 'Planned action', actionUpdate: 'Update selected fields', actionDeactivate: 'Recommend deactivation to the user', actionSkip: 'Do nothing', title: 'Title', description: 'Description', tags: 'Tags', materials: 'Materials', tagsHelp: 'Comma-separated; up to 13 tags and 20 characters per tag.', materialsHelp: 'Separate with commas.', reason: 'Reason / note', saveProposal: 'Save proposal', cancel: 'Cancel', close: 'Close', invalidTitle: 'The title must contain 1–140 characters.', invalidTags: 'Use at most 13 tags and no more than 20 characters per tag.', changeField: 'Change this field', selectChangeField: 'Select at least one field to change for an UPDATE action.', proposalSaved: 'Proposal saved and an improvement baseline was recorded.', buildQueue: 'Build queue from selection', queueConfirm: 'Prepare a user-approved action queue for {count} listings? This confirmation will not publish any listing automatically.', queueCreated: '{count} listings were added to the action queue.', noProposalSelection: 'Selection alone does not create a queue. First save an actionable item in Improvement plan or import valid AI proposal JSON; then select the listing with that saved proposal and try again.', goFirst: 'Open first listing', currentItem: 'Queue {current}/{total}', applyForm: 'Apply proposal to form', publishAfterReview: 'Reviewed; publish on Etsy', openDeactivate: 'Open Etsy options', deactivateConfirm: 'Do you confirm opening only Etsy’s options menu for listing {id}? You will click Deactivate and the final confirmation yourself on Etsy.', verifyDeactivate: 'Verify deactivation and continue', skipItem: 'Skip this listing', stopQueue: 'Stop queue', continueQueue: 'Next listing', routeMismatch: 'The queued listing ID does not match the open page. Processing stopped safely.', formNotReady: 'Required Etsy form fields are not ready; no changes were applied.', formApplied: 'Proposal applied to the form. Review the Etsy fields; nothing has been published yet.', publishDisabled: 'Etsy’s “Publish changes” button is not enabled. Check form validation.', publishConfirm: 'Do you confirm publishing {fields} for listing {id} on Etsy?', publishSubmitted: 'Publish was submitted to Etsy; verifying the result.', publishVerified: 'Etsy save was verified and added to history.', publishUnverified: 'The Etsy result could not be verified. The queue did not advance blindly.', deactivateOpened: 'Etsy options opened. Click Deactivate and Etsy’s final confirmation yourself.', deactivateNotVerified: 'The listing still appears active; the queue did not advance.', queueStopped: 'The action queue was stopped.', queueComplete: 'The action queue is complete.', aiTitle: 'AI proposal exchange', aiIntro: 'This version sends no network request to an AI service. Copy selected listings as prompt/JSON, then paste and validate the AI result below.', copyAiRequest: 'Copy AI request', importAiResponse: 'Import AI response', aiResponseLabel: 'AI proposal JSON', aiCopied: 'AI request package for {count} listings copied to the clipboard.', aiImportSuccess: '{count} valid AI proposals imported.', aiImportError: 'AI JSON could not be validated: {message}', selectForAi: 'Select listings from the listing-analysis cards first.', exportComplete: 'Local analysis backup downloaded.', historyTitle: 'Analysis and improvement history', capturedAt: 'Captured at', changes: 'Changes', baseline: 'Baseline', latest: 'Latest', noHistory: 'At least two records from different days are required for comparison.', settingsTitle: 'Analysis thresholds', minVisitsToImprove: 'Improvement threshold when there are no sales (visits)', minVisitsToProtect: 'Strong-listing visit threshold', minRenewalsToReview: 'Deactivation review threshold (renewals)', declinePercent: 'Decline warning percentage', saveSettings: 'Save settings', settingsSaved: 'Analysis thresholds saved.', openSettings: 'Thresholds', editThresholds: 'Edit analysis thresholds', clearData: 'Clear local analysis data', clearDataConfirm: 'Delete all listing snapshots, improvement records, AI mappings, queue, and action history from this browser? Language and threshold settings are preserved.', dataCleared: 'Local listing analysis data was cleared; language and threshold settings were preserved.', deleteUnsupported: 'Deletion is not supported for safety.', leaseBlocked: 'The action queue is open in another tab. No write action started here.', reasonNew: 'New record; more history is needed for comparison.', reasonGrowth: 'Sales, revenue, visits, or favorites increased since the previous record.', reasonProtect: 'A strong revenue-generating listing; protect it from risky bulk changes.', reasonImproveTraffic: 'It has visits but no sales; review title, images, pricing, and offer alignment.', reasonDeactivate: 'No sales, visits, or favorites after at least {renewals} renewals; review deactivation.', reasonDecline: 'Last-30-day visits fell by at least {percent}% since the previous record.', reasonMonitor: 'No urgent action signal; continue collecting data.', reasonUnknown: 'Some performance metrics could not be read; missing values were not treated as zero and no decision was produced.',
reasonLearning: 'At least 30 days of comparison history is being collected before a reliable decision.', reasonExperiment: 'The published improvement is being observed through its 30-day evaluation window.', reasonDormant: 'The listing looks dormant, but the safety conditions for a deactivation review are not complete.', reasonDeactivateSafe: 'A 60+ day history, repeated zero traffic, and no sales support a deactivation review; the user makes the final decision.', reasonInactive: 'The listing appears inactive on Etsy; no write action was recommended.',
health: 'Health', healthy: 'Protect / monitor', needsReview: 'Needs review', activeExperiments: 'Active experiments', listingHealth: 'Listing health', healthAndHistory: 'Listing health & history', lifecycle: 'Lifecycle', diagnosis: 'Performance diagnosis', confidence: 'Confidence', evidence: 'Evidence', showAllEvidence: 'Show all evidence ({count})', confidenceDetails: 'Confidence details', dataQuality: 'Data quality', historyDepth: 'History depth', trafficSample: 'Traffic sample', cohortStrength: 'Cohort strength', freshness: 'Freshness', dataIntegrity: 'Data integrity', shopBenchmark: 'Shop benchmark', experiment: 'Experiment', experimentState: 'Experiment status', primaryMetric: 'Primary metric', adjustedEffect: 'Adjusted effect', nextReview: 'Next review', afterNextSnapshot: 'After the next snapshot', missingData: 'Missing data', whyRecommendation: 'Why this recommendation?', details: 'Details', confidenceLimited: 'Confidence is limited by insufficient history or data quality.',
lifecycleDataGap: 'Data incomplete', lifecycleBaseline: 'Building baseline', lifecycleLearning: 'Learning', lifecycleStable: 'Stable', lifecycleGrowing: 'Growing', lifecycleDeclining: 'Declining', lifecycleProtected: 'Protected', lifecycleExperiment: 'Experiment running', lifecycleDormant: 'Dormant', lifecycleDeactivate: 'Review deactivation', lifecycleInactive: 'Inactive',
diagnosisDiscovery: 'Low discovery', diagnosisEngagement: 'Weak post-visit interest', diagnosisPurchase: 'Possible purchase friction', diagnosisScale: 'Ready for more reach', diagnosisHealthy: 'No clear issue', diagnosisInsufficient: 'Not enough signal', confidenceLow: 'Low', confidenceMedium: 'Medium', confidenceHigh: 'High', confidenceVeryHigh: 'Very high',
evidenceHistory: '{days} days of history · {count} snapshots', evidenceTraffic: '30-day visits: {current}; previous window: {previous} · {percent}%', evidenceRecentSales: 'Approximate last 30 days: {sales} sales · {revenue} revenue', evidenceCohort: 'Traffic percentile among {size} shop listings: {percentile}', evidenceAnomaly: '{count} data-integrity warning(s) found.',
guardHistory: '60+ days and at least 3 complete snapshots', guardZeroTraffic: 'Repeated zero traffic at least 14 days apart', guardNoSales: 'No new sales or revenue in the last 60 days', guardRenewals: 'Renewal cost continues without sales', guardActiveStock: 'Listing is active and in stock', guardNoExperiment: 'No active experiment', guardCooldown: 'No published improvement in the last 45 days', guardSeasonal: 'Explicitly confirmed as non-seasonal', guardConfidence: 'Confidence score is at least 80',
experimentPlanned: 'Planned', experimentObserving: 'Observing · day {day}/30', experimentWinner: 'Winner', experimentUnderperformed: 'Underperformed', experimentInconclusive: 'Inconclusive', experimentContaminated: 'Contaminated by another change', experimentStopped: 'Stopped', contentChanged: 'The listing content changed after the proposal was saved. The form was preserved and processing stopped; review the proposal again.', proposalStale: 'Listing data or analysis settings changed after the proposal was saved. No queue was created; review the proposal again.', manualApproval: 'Every Etsy write action requires listing-level user approval.',
}),
});
const EXTRA_I18N = Object.freeze({
tr: Object.freeze({
scanAllPages: 'Tüm sayfaları tara', stopCollection: 'Taramayı durdur', resumeCollection: 'Taramaya devam et', collectionShortcut: 'Kısayol: CTRL + ALT + A',
collectionStarting: 'Tüm sayfa taraması başlatılıyor…', collectionFirstPage: 'İlk sayfaya geçiliyor…', collectionProgress: 'Sayfa {current}/{total} · {pages} sayfa · {count} benzersiz listing', collectionComplete: '{pages} sayfada {count} benzersiz listing toplandı.', collectionPaused: 'Tarama durduruldu. Aynı düğmeyle kaldığınız yerden devam edebilirsiniz.', collectionBlocked: 'Tarama güvenli biçimde durdu: {reason}', collectionBusy: 'Tüm sayfa taraması başka bir sekmede çalışıyor.', collectionRouteRequired: 'Tüm sayfa taraması yalnız Etsy listing sayfasında başlatılabilir.', collectionPageChanged: 'Beklenen Etsy sayfası yüklenmedi.', collectionRepeatedPage: 'Aynı Etsy sayfası tekrar açıldığı için döngü durduruldu.', collectionOverlap: 'Farklı sayfalarda aynı listing görüldü; eksik veya karışık analiz oluşmaması için tarama durduruldu.', collectionStorageFailed: 'Yerel depolama yazılamadı; kota veya kapasite dolu olabilir. Yedek alın, eski analiz verilerini temizleyin ve yeniden deneyin.', collectionNewerSchema: 'Tarama kaydı daha yeni bir script sürümüne ait; veri korunarak işlem durduruldu.', collectionLimit: 'Güvenlik için {count} sayfa sınırında tarama durduruldu.', collectionPages: 'Toplanan sayfa', collectionListings: 'Benzersiz listing', collectionStatus: 'Toplu veri toplama',
filters: 'Filtreler', hideFilters: 'Filtreleri gizle', clearFilters: 'Filtreleri sıfırla', resultsCount: '{visible} / {total} listing', showingCount: '{shown} gösteriliyor · {total} eşleşme', scope: 'Kapsam', scopeAll: 'Kayıtlı tüm listingler', scopePage: 'Bu Etsy sayfası', lifecycleFilter: 'Durum', diagnosisFilter: 'Sorun / fırsat', recommendationFilter: 'Öneri', performanceFilter: '30 günlük performans', trendFilter: 'Değişim', stockFilter: 'Stok', confidenceFilter: 'Veri güveni', sortBy: 'Sırala',
analysisOverdueTitle: 'Analiz saati gecikti.', analysisOverdueCopy: 'Doğru veriler için tüm listinglerin analizi yapılmalı.', startAnalysis: 'Analizi başlat', openListingsForAnalysis: 'Listing sayfasına git', collectionActionStale: 'Listing analizi değişti veya süresi doldu. Doğru veriler için analizi yeniden başlatın.',
optionAll: 'Tümü', performanceSales: 'Son 30 günde satış', performanceTrafficNoSales: 'Son 30 günde ziyaret var, satış yok', performanceNoActivity: 'Son 30 günde hareket yok', performanceMissing: 'Eksik veya tutarsız veri', trendRising: 'Yükselenler', trendFalling: 'Düşenler', trendStable: 'Değişmeyenler', trendUnknown: 'Karşılaştırması olmayanlar', stockIn: 'Stokta', stockOut: 'Tükendi', stockUnknown: 'Stok bilinmiyor', confidenceLowFilter: 'Düşük güven', confidenceMediumFilter: 'Orta güven', confidenceHighFilter: 'Yüksek / çok yüksek güven', sortPriority: 'Öncelikli öneri', sortScore: 'En yüksek 30 günlük erişim/ilgi', sortVisits: 'Son 30g en çok ziyaret', sortSales: 'Tüm-zaman en çok satış', sortRevenue: 'Tüm-zaman en yüksek gelir', sortConfidence: 'En yüksek güven', sortTitle: 'Başlık A–Z', noFilterResults: 'Bu filtrelere uyan listing bulunamadı.', loadMore: 'Daha fazla göster', hiddenSelected: '{count} seçim filtre nedeniyle gizli',
performanceScore: '30 günlük erişim/ilgi', currentFunnelScore: '30 günlük erişim/ilgi', snapshotScore: '30 günlük erişim/ilgi', longitudinalScore: '30 günlük erişim/ilgi', analysisConfidence: 'Analiz güveni', historyConfidence: 'Geçmiş güveni', scoreOutOf: '{score}/100', snapshotBasis: 'Puan yalnızca son 30 günlük ziyaret ve favori oranını ölçer. Tüm-zaman satış, gelir ve yenilemeler karar sinyalidir; puanı yapay olarak yükseltmez.', longitudinalBasis: 'Puan son 30 günlük ziyaret ve favori oranını ölçer; 30/60 günlük kayıtlar ayrıca trend ve güvenlik kararlarında kullanılır.', insufficientBasis: 'Güncel metrik eksik, eski veya tutarsız olduğu için erişim/ilgi puanı üretilmedi.', snapshotConfidenceLimited: 'İlk tarama teşhisi kullanılabilir; büyüme, düşüş ve kapatma güveni 30/60 günlük geçmiş oluşana kadar sınırlıdır.',
visits30dLabel: 'Ziyaret · 30g', favorites30dLabel: 'Favori · 30g', salesAllTimeLabel: 'Satış · tüm-zaman', revenueAllTimeLabel: 'Gelir · tüm-zaman', renewalsAllTimeLabel: 'Yenileme · tüm-zaman',
evidenceCurrentCounters: 'Son 30g: {visits} ziyaret · {favorites} favori; tüm-zaman: {sales} satış · {revenue} gelir · {renewals} yenileme', evidenceSnapshotScoreCohort: '30 günlük erişim/ilgi: {score}/100 · mağaza içi karşılaştırma', evidenceSnapshotScoreAbsolute: '30 günlük erişim/ilgi: {score}/100 · mutlak eşikler',
snapshotRenewalWaste: 'Yenileme verimsizliği', snapshotWeakDiscovery: 'Görünürlük zayıf', snapshotWeakEngagement: 'İlgi zayıf', snapshotPurchaseFriction: 'Satış dönüşümü zayıf', snapshotProvenDemand: 'Satış kanıtı var', snapshotStrongCurrent: 'Güncel erişim/ilgi güçlü', snapshotMixed: 'Güncel sinyaller karışık', snapshotNoActivity: 'Güncel hareket yok', snapshotInsufficient: 'Güncel metrik okunamadı',
reasonSnapshotRenewalWaste: '{renewals} yenilemeye rağmen satış, gelir ve favori yok. İyileştirmeyi önceliklendirin; kapatma için tarihsel kanıt gerekir.', reasonSnapshotDiscovery: 'Güncel ziyaret düzeyi mağaza içindeki görünürlüğün zayıf olduğunu gösteriyor.', reasonSnapshotEngagement: 'Yeterli ziyaret görülmesine rağmen favori ilgisi zayıf; görsel, başlık ve teklif uyumunu inceleyin.', reasonSnapshotPurchase: 'Ziyaret veya favori var fakat tüm-zaman satış ve gelir yok; fiyat, görseller ve teklif sürtünmesini inceleyin.', reasonSnapshotDemand: 'Tüm-zaman satış veya gelir kanıtı bu listingi korur; güncel erişim/ilgi ayrıca değerlendirilir ve zayıf olabilir. Tarihsel kanıt puanı yapay olarak yükseltmez.', reasonSnapshotStrong: 'Son 30 günlük ziyaret ve favori oranı güçlü bir konuma işaret ediyor; trend doğrulaması için yeni taramalar toplayın.', reasonSnapshotMixed: 'Güncel sayaçlarda tek başına güçlü bir sorun veya fırsat sinyali yok; karşılaştırmalı geçmiş toplamaya devam edin.', reasonSnapshotNoActivity: 'Sayaçlar eksiksiz okundu: son 30 günde ziyaret veya favori, tüm-zaman geçmişinde de satış veya gelir yok. Veri eksik değil; şimdilik izleyin.',
filterPresets: 'Hazır filtreler', presetGrowing: 'Yükselişte', presetImprove: 'İyileştirilecek', presetDeclining: 'Düşüşte', presetDeactivate: 'Kapatmayı incele', presetExperiments: 'Aktif deneyler', presetMissing: 'Eksik / tutarsız veri', presetName: 'Preset adı', savePreset: 'Mevcut filtreyi kaydet', deletePreset: 'Preseti sil', presetSaved: 'Filtre preseti kaydedildi.', presetDeleted: 'Filtre preseti silindi.', presetLimit: 'En fazla {count} özel filtre preseti kaydedilebilir.', presetInvalid: 'Preset adı 2–32 karakter olmalı.',
historyCharts: 'Tarihsel değişim grafikleri', noChartData: 'Grafik için en az iki geçerli kayıt gerekir.', experimentTimeline: 'İyileştirme deney zaman çizelgesi', timelinePlanned: 'Öneri planlandı', timelinePublished: 'Etsy’de yayınlandı', timelineObserving: 'Gözlem başladı', timelineEvaluationDue: 'Değerlendirme tarihi', timelineEvaluated: 'Deney sonucu', timelineNotApplied: 'Henüz uygulanmadı',
aiComparison: 'AI önerisi önce / sonra', aiComparisonEmpty: 'İçe aktarılmış bir AI önerisi henüz yok.', beforeValue: 'Önce', proposedValue: 'AI önerisi', appliedValue: 'Doğrulanan sonuç', changedFields: 'Değişen alanlar', valueNotCaptured: 'Önceki değer yakalanmadı.',
collectionRetrying: 'Geçici sayfa hatası · {attempt}/{max} yeniden deneniyor…', errorReport: 'Ayrıntılı hata raporu', errorReportTitle: 'Sayfa tarama hata raporu', copyErrorReport: 'Raporu kopyala', reportCopied: 'Hata raporu panoya kopyalandı.', reportPhase: 'Aşama', reportExpectedPage: 'Beklenen sayfa', reportObservedPage: 'Görülen sayfa', reportAttempts: 'Deneme', reportTime: 'Hata zamanı', reportNoSensitiveData: 'Rapor yalnız teknik tarama durumunu içerir; çerez, oturum ve sayfa HTML’i içermez.',
checkUpdate: 'Güncellemeyi denetle', updateChecking: 'Güncelleme denetleniyor…', updateAvailable: 'Yeni sürüm hazır: v{version}', updateCurrent: 'Listing Analyzer güncel.', updateFailed: 'Güncelleme denetlenemedi: {message}', installUpdate: 'Tampermonkey’de güncelle', updateInstallHelp: 'Kurulum Tampermonkey onay ekranında tamamlanır; script kendisini sessizce değiştirmez.', updateBlocked: 'Aktif tarama veya işlem kuyruğu varken güncelleme açılamaz.',
importBackup: 'Yedek içe aktar', backupImportTitle: 'Analiz yedeğini içe aktar', backupImportIntro: 'JSON yedeği önce tamamen doğrulanır, sonra mevcut yerel verilerle güvenli biçimde birleştirilir. Eski işlem kuyruğu etkinleştirilmez.', chooseBackup: 'JSON yedeği seç', backupPreview: '{records} listing kaydı · {presets} özel preset', backupImportAction: 'Doğrula ve birleştir', backupInvalid: 'Yedek doğrulanamadı: {message}', backupImported: '{records} listing ve {presets} preset içe aktarıldı.', backupTooLarge: 'Yedek dosyası en fazla 25 MB olabilir.', backupQueueSkipped: 'Yedekteki işlem kuyruğu güvenlik nedeniyle otomatik etkinleştirilmedi.',
storageUsage: 'Tahmini yerel veri', storageUsageValue: '{size} MB', storageQuotaWarning: 'Yerel depolama sınırına yaklaşıldı veya yazma reddedildi. Yedek alın, eski verileri azaltın ve işlemi yeniden deneyin.', storageWriteFailed: 'Yerel veri yazılamadı. Hiçbir toplu işlem eksik kayıtla devam etmedi.',
errorReportHistory: 'Hata geçmişi', errorReportGuidance: 'Önce sayfanın tamamen yüklendiğini kontrol edin. Ardından taramayı kaldığı yerden güvenle yeniden deneyebilirsiniz.', retryCollection: 'Taramayı yeniden dene', downloadErrorReports: 'Tüm raporları indir', reportDownloaded: 'Hata raporları indirildi.',
lastAnalysis: 'Son başarılı analiz: {time}', analysisValidity: 'Analizler tamamlandıktan sonra 24 saat geçerlidir.',
thresholdCalibration: 'Mağaza verisine göre önerilen eşikler', thresholdCalibrationCopy: 'Öneriler yalnız tam mağaza taramasındaki dağılımlardan hesaplanır; otomatik uygulanmaz.', calibrationAvailable: '{count} listing ile kalibre edildi.', calibrationInsufficient: 'Kalibrasyon için en az 8 okunabilir listing gerekir.', useRecommended: 'Önerilenleri forma uygula', recommendedValue: 'Öneri: {value}', thresholdImpact: 'Bu değerlerle yaklaşık {improve} listing iyileştirme, {protect} listing güçlü performans adayı olur.', thresholdRelationship: 'Güçlü listingleri koruma eşiği, iyileştirme eşiğinden büyük olmalıdır.', thresholdHelpImprove: 'Satış yokken iyileştirme incelemesini başlatacak en düşük ziyaret sayısı.', thresholdHelpProtect: 'Güçlü listing koruması için gereken ziyaret sayısı; iyileştirme eşiğinden yüksek olmalıdır.', thresholdHelpRenewals: 'Deaktivasyon incelemesinden önce gereken en düşük yenileme sayısı.', thresholdHelpDecline: 'Düşüş uyarısını başlatan yüzde değişim.', resetDefaults: 'Güvenli varsayılanlara dön',
experimentOverlapTitle: 'Devam eden deneyle çakışma var', experimentOverlapCopy: 'Bu listingde hâlen izlenen bir iyileştirme deneyi var. Yeni değişiklik önceki deneyin sonucunu ayrıştırılamaz duruma getirebilir.', acknowledgeOverlap: 'Çakışmayı anladım; öneriyi yine de hazırlamak istiyorum.', experimentOverlapRequired: 'Devam eden deney çakışmasını onaylamadan bu öneri kuyruğa eklenemez.',
aiErrorJson: 'JSON biçimi okunamadı. Fazladan virgül, eksik tırnak veya kod bloğu işareti olup olmadığını kontrol edin.', aiErrorSchema: 'Yanıt şeması geçersiz. schema alanı makaytron-listing-ai-proposals/v1 ve proposals alanı bir dizi olmalı.', aiErrorRequest: 'requestId bilinmiyor veya süresi dolmuş. AI istek paketini yeniden kopyalayın.', aiErrorProposal: 'Öneri doğrulanamadı: {path}.', aiErrorHelp: 'Beklenen alanı düzeltip aynı JSON’u yeniden deneyin; geçerli kayıtlar hata varken yazılmaz.', aiFormatHelp: 'tags ve materials alanları JSON dizisi (array), title ve description alanları metin olmalıdır.', copyAiTemplate: 'Örnek JSON’u kopyala', aiTemplateCopied: 'Örnek AI JSON’u panoya kopyalandı.',
queueRecoveryTitle: 'Yarım kalan işlem kuyruğu bulundu', queueRecoveryCopy: 'Son adım tamamlanmadan sayfa kapanmış olabilir. Listing durumunu incelemeden aynı yazma işlemi tekrarlanmaz.', queueRecoveryRetry: 'Öğeyi güvenle yeniden hazırla', queueRecoveryConfirm: 'Mevcut Etsy formunu önce kontrol ettiniz mi? Öğe yalnız bekleyen duruma alınacak; hiçbir şey otomatik yayımlanmayacak.', queueRecoverySubmitted: 'Bu listing Etsy’ye gönderilmiş ancak sonucu doğrulanamamış olabilir. Çift gönderimi önlemek için otomatik tekrar kapalıdır; Etsy durumunu elle doğrulayın veya kuyruğu durdurun.', queueRecoveryOpen: 'Listing sayfasını güvenli inceleme için aç', queueRecoveryStop: 'Kuyruğu durdur',
userFeedback: 'Geri bildirim', feedbackTitle: 'Listing Analyzer geri bildirimi', feedbackIntro: 'Notunuz önce yerel olarak kaydedilir. GitHub formu yalnız düğmeye bastığınızda açılır; listing başlıkları ve ID’leri eklenmez.', feedbackCategory: 'Konu', feedbackRating: 'Deneyim puanı', feedbackNote: 'Notunuz', feedbackDiagnostics: 'Anonim teknik özeti ekle', feedbackSend: 'Kaydet, kopyala ve GitHub formunu aç', feedbackSaved: 'Geri bildirim yerel olarak kaydedildi ve panoya kopyalandı.', feedbackInvalid: 'Lütfen 10–800 karakter arasında açıklayıcı bir not yazın.', feedbackBug: 'Hata', feedbackIdea: 'Öneri', feedbackUsability: 'Kullanılabilirlik', feedbackAnalysis: 'Analiz sonucu', feedbackCount: 'Yerel geri bildirim',
researchStart: 'Marketplace Insights ile araştır', researchOneListing: 'Araştırma için tam olarak bir listing seçin.', researchProbing: 'Etsy Keyword & Market Analyzer aranıyor…', researchCompanionMissingTitle: 'Etsy Keyword & Market Analyzer gerekli', researchCompanionMissing: 'Bağlantılı Marketplace Insights araştırması için ayrı Etsy Keyword & Market Analyzer scripti gerekir. Listing Analyzer diğer tüm özellikleriyle bağımsız çalışmaya devam eder.', researchInstallHelp: 'Script sessizce kurulamaz. Kurulum sayfası yeni sekmede açılır ve son onayı userscript yöneticisinde siz verirsiniz.', researchOpenInstall: 'Yükleme sayfasını aç', researchTransferTitle: 'Marketplace Insights araştırması', researchWaitingReady: 'Insights sekmesinin hazır olması bekleniyor…', researchRequestSent: 'Araştırma isteği gönderildi; Analyzer açık kalmalıdır.', researchAcknowledged: 'İstek alındı; Etsy Marketplace Insights sonuçları bekleniyor…', researchTimedOut: 'Otomatik yanıt süresi doldu. Analyzer açık kalır ve geçerli sonuç süresi dolana kadar kabul edilir; gerekirse JSON kurtarma araçlarını kullanın.', researchComplete: '{count} anahtar kelime kanıtı doğrulandı; öneri otomatik yayımlanmadı.', researchSavedForReview: 'Başlık ve etiket önerisi inceleme için kaydedildi; hiçbir Etsy alanı değiştirilmedi.', researchNeedsEditor: 'Kanıt ve öneri kaydedildi. Etiketleri güvenle karşılaştırmak için listing düzenleyicisini bir kez açın; işlem kuyruğu oluşturulmadı.', researchFailed: 'Araştırma sonucu doğrulanamadı: {message}', researchCopyRequest: 'İstek JSON’unu kopyala', researchRequestCopied: 'Araştırma istek JSON’u panoya kopyalandı.', researchImportResult: 'Sonuç JSON’unu içe aktar', researchResultLabel: 'Tam RESEARCH_RESULT envelope JSON’u', researchReopenInsights: 'Insights sayfasını yeniden aç', researchEvidenceTitle: 'Marketplace Insights kanıtı', researchSearches30d: '30 günlük arama', researchSearchResults: 'Arama sonucu / rekabet göstergesi', researchOpportunity: 'Makaytron fırsat puanı', researchSourceNote: 'Kaynak: Etsy Marketplace Insights görünür sayfa verisi. Fırsat puanı Makaytron tarafından türetilmiştir.', researchStale: 'Listing başlığı veya etiketleri araştırma başladıktan sonra değişti; sonuç güvenli biçimde reddedildi.', researchNoSeeds: 'Araştırma için kullanılabilecek başlık veya etiket bulunamadı.', researchOpenProposal: 'Öneriyi incele', researchPendingExists: 'Bu listing için bekleyen araştırma yeniden açıldı.', researchErrorRemote: 'Keyword Analyzer araştırmayı tamamlayamadı: {message}', researchProposalReason: 'Marketplace Insights kanıtı: “{keyword}” — {searches} arama / 30 gün, {results} arama sonucu. Makaytron fırsat puanı türetilmiş metriktir.', researchTagReplacement: '13 etiket dolu olduğu için düşük kanıtlı “{old}” yerine “{keyword}” önerildi; değişiklik yalnız inceleme taslağıdır.', updateManagedExternally: 'Güncellemeler kurulum kaynağınız tarafından yönetiliyor; GitHub güncelleme akışı zorlanmadı.',
}),
en: Object.freeze({
scanAllPages: 'Collect all pages', stopCollection: 'Stop collection', resumeCollection: 'Resume collection', collectionShortcut: 'Shortcut: CTRL + ALT + A',
collectionStarting: 'Starting all-page collection…', collectionFirstPage: 'Opening the first page…', collectionProgress: 'Page {current}/{total} · {pages} pages · {count} unique listings', collectionComplete: '{count} unique listings collected across {pages} pages.', collectionPaused: 'Collection stopped. Use the same button to resume.', collectionBlocked: 'Collection stopped safely: {reason}', collectionBusy: 'All-page collection is running in another tab.', collectionRouteRequired: 'All-page collection can only start on the Etsy listings page.', collectionPageChanged: 'The expected Etsy page did not load.', collectionRepeatedPage: 'Collection stopped because the same Etsy page appeared again.', collectionOverlap: 'The same listing appeared on different pages; collection stopped to avoid an incomplete or mixed analysis.', collectionStorageFailed: 'Local storage could not be written; its quota or capacity may be full. Export a backup, clear old analysis data, and retry.', collectionNewerSchema: 'The collection record belongs to a newer script schema; it was preserved and collection stopped.', collectionLimit: 'Collection stopped at the {count}-page safety limit.', collectionPages: 'Pages collected', collectionListings: 'Unique listings', collectionStatus: 'Bulk data collection',
filters: 'Filters', hideFilters: 'Hide filters', clearFilters: 'Reset filters', resultsCount: '{visible} / {total} listings', showingCount: '{shown} shown · {total} matches', scope: 'Scope', scopeAll: 'All stored listings', scopePage: 'This Etsy page', lifecycleFilter: 'Status', diagnosisFilter: 'Issue / opportunity', recommendationFilter: 'Recommendation', performanceFilter: '30-day performance', trendFilter: 'Change', stockFilter: 'Stock', confidenceFilter: 'Data confidence', sortBy: 'Sort',
analysisOverdueTitle: 'Analysis is overdue.', analysisOverdueCopy: 'All listings must be analyzed to show reliable data.', startAnalysis: 'Start analysis', openListingsForAnalysis: 'Open Listings', collectionActionStale: 'The listing analysis changed or expired. Start the analysis again before continuing.',
optionAll: 'All', performanceSales: 'Sales in the last 30 days', performanceTrafficNoSales: 'Traffic but no sales in the last 30 days', performanceNoActivity: 'No activity in the last 30 days', performanceMissing: 'Missing or inconsistent data', trendRising: 'Rising', trendFalling: 'Falling', trendStable: 'Unchanged', trendUnknown: 'No comparison', stockIn: 'In stock', stockOut: 'Out of stock', stockUnknown: 'Stock unknown', confidenceLowFilter: 'Low confidence', confidenceMediumFilter: 'Medium confidence', confidenceHighFilter: 'High / very high confidence', sortPriority: 'Action priority', sortScore: 'Highest 30-day reach/engagement', sortVisits: 'Most visits · last 30d', sortSales: 'Most sales · all-time', sortRevenue: 'Highest revenue · all-time', sortConfidence: 'Highest confidence', sortTitle: 'Title A–Z', noFilterResults: 'No listings match these filters.', loadMore: 'Show more', hiddenSelected: '{count} selected item(s) hidden by filters',
performanceScore: '30-day reach/engagement', currentFunnelScore: '30-day reach/engagement', snapshotScore: '30-day reach/engagement', longitudinalScore: '30-day reach/engagement', analysisConfidence: 'Analysis confidence', historyConfidence: 'History confidence', scoreOutOf: '{score}/100', snapshotBasis: 'The score measures only last-30-day visits and favorite rate. All-time sales, revenue, and renewals are decision signals and do not artificially raise the score.', longitudinalBasis: 'The score measures last-30-day visits and favorite rate; 30/60-day records separately drive trend and safety decisions.', insufficientBasis: 'No reach/engagement score was produced because a current metric is missing, stale, or inconsistent.', snapshotConfidenceLimited: 'The first-scan diagnosis is usable; growth, decline, and deactivation confidence remain limited until 30/60-day history is available.',
visits30dLabel: 'Visits · 30d', favorites30dLabel: 'Favorites · 30d', salesAllTimeLabel: 'Sales · all-time', revenueAllTimeLabel: 'Revenue · all-time', renewalsAllTimeLabel: 'Renewals · all-time',
evidenceCurrentCounters: 'Last 30d: {visits} visits · {favorites} favorites; all-time: {sales} sales · {revenue} revenue · {renewals} renewals', evidenceSnapshotScoreCohort: '30-day reach/engagement: {score}/100 · within-shop comparison', evidenceSnapshotScoreAbsolute: '30-day reach/engagement: {score}/100 · absolute thresholds',
snapshotRenewalWaste: 'Renewal waste', snapshotWeakDiscovery: 'Weak discovery', snapshotWeakEngagement: 'Weak engagement', snapshotPurchaseFriction: 'Weak sales conversion', snapshotProvenDemand: 'Proven demand', snapshotStrongCurrent: 'Strong current reach/engagement', snapshotMixed: 'Mixed current signals', snapshotNoActivity: 'No current activity', snapshotInsufficient: 'Current metric unreadable',
reasonSnapshotRenewalWaste: '{renewals} renewals produced no sales, revenue, or favorites. Prioritize improvement; deactivation still requires historical evidence.', reasonSnapshotDiscovery: 'The current visit level indicates weak discovery within the shop.', reasonSnapshotEngagement: 'There is enough traffic, but favorite interest is weak; review imagery, title, and offer alignment.', reasonSnapshotPurchase: 'Visits or favorites exist, but all-time sales and revenue are zero; review pricing, imagery, and offer friction.', reasonSnapshotDemand: 'All-time sales or revenue protect this listing; current reach/engagement is assessed separately and may still be weak. Historical proof does not inflate the score.', reasonSnapshotStrong: 'Last-30-day visits and favorite rate indicate a strong position; collect new scans to confirm the trend.', reasonSnapshotMixed: 'Current counters do not show one strong issue or opportunity; continue collecting comparative history.', reasonSnapshotNoActivity: 'Every counter was read successfully: there are no last-30-day visits or favorites and no all-time sales or revenue. The data is complete; monitor for now.',
filterPresets: 'Filter presets', presetGrowing: 'Growing', presetImprove: 'Needs improvement', presetDeclining: 'Declining', presetDeactivate: 'Review deactivation', presetExperiments: 'Active experiments', presetMissing: 'Missing / inconsistent data', presetName: 'Preset name', savePreset: 'Save current filters', deletePreset: 'Delete preset', presetSaved: 'Filter preset saved.', presetDeleted: 'Filter preset deleted.', presetLimit: 'You can save up to {count} custom filter presets.', presetInvalid: 'Preset names must contain 2–32 characters.',
historyCharts: 'Historical change charts', noChartData: 'At least two valid records are required for a chart.', experimentTimeline: 'Improvement experiment timeline', timelinePlanned: 'Proposal planned', timelinePublished: 'Published on Etsy', timelineObserving: 'Observation started', timelineEvaluationDue: 'Evaluation due', timelineEvaluated: 'Experiment result', timelineNotApplied: 'Not applied yet',
aiComparison: 'AI proposal before / after', aiComparisonEmpty: 'No imported AI proposal is available yet.', beforeValue: 'Before', proposedValue: 'AI proposal', appliedValue: 'Verified result', changedFields: 'Changed fields', valueNotCaptured: 'The previous value was not captured.',
collectionRetrying: 'Temporary page error · retry {attempt}/{max}…', errorReport: 'Detailed error report', errorReportTitle: 'Page collection error report', copyErrorReport: 'Copy report', reportCopied: 'Error report copied to the clipboard.', reportPhase: 'Phase', reportExpectedPage: 'Expected page', reportObservedPage: 'Observed page', reportAttempts: 'Attempts', reportTime: 'Failure time', reportNoSensitiveData: 'The report contains technical collection state only; it excludes cookies, sessions, and page HTML.',
checkUpdate: 'Check for updates', updateChecking: 'Checking for updates…', updateAvailable: 'New version available: v{version}', updateCurrent: 'Listing Analyzer is up to date.', updateFailed: 'Update check failed: {message}', installUpdate: 'Update in Tampermonkey', updateInstallHelp: 'Installation finishes in Tampermonkey’s confirmation screen; the script never replaces itself silently.', updateBlocked: 'An update cannot be opened while collection or the action queue is active.',
importBackup: 'Import backup', backupImportTitle: 'Import analysis backup', backupImportIntro: 'The JSON backup is fully validated first, then safely merged with current local data. An old action queue is never activated.', chooseBackup: 'Choose JSON backup', backupPreview: '{records} listing records · {presets} custom presets', backupImportAction: 'Validate and merge', backupInvalid: 'Backup validation failed: {message}', backupImported: 'Imported {records} listings and {presets} presets.', backupTooLarge: 'The backup file must be 25 MB or smaller.', backupQueueSkipped: 'The action queue in the backup was not activated for safety.',
storageUsage: 'Estimated local data', storageUsageValue: '{size} MB', storageQuotaWarning: 'Local storage is near its limit or rejected a write. Export a backup, reduce old data, and retry.', storageWriteFailed: 'Local data could not be written. No bulk workflow continued with an incomplete record.',
errorReportHistory: 'Failure history', errorReportGuidance: 'First verify that the page has fully loaded. You can then retry the collection safely from its saved position.', retryCollection: 'Retry collection', downloadErrorReports: 'Download all reports', reportDownloaded: 'Failure reports downloaded.',
lastAnalysis: 'Last successful analysis: {time}', analysisValidity: 'Completed analyses remain valid for 24 hours.',
thresholdCalibration: 'Thresholds suggested from shop data', thresholdCalibrationCopy: 'Suggestions use distributions from a complete shop collection and are never applied automatically.', calibrationAvailable: 'Calibrated from {count} listings.', calibrationInsufficient: 'At least 8 readable listings are required for calibration.', useRecommended: 'Fill suggested values', recommendedValue: 'Suggested: {value}', thresholdImpact: 'With these values, about {improve} listings qualify for improvement review and {protect} as strong performers.', thresholdRelationship: 'The strong-listing protection threshold must be greater than the improvement threshold.', thresholdHelpImprove: 'Minimum visits that trigger improvement review when there are no sales.', thresholdHelpProtect: 'Visits required to protect a strong listing; this must exceed the improvement threshold.', thresholdHelpRenewals: 'Minimum renewals required before deactivation review.', thresholdHelpDecline: 'Percentage change that triggers a decline warning.', resetDefaults: 'Restore safe defaults',
experimentOverlapTitle: 'This conflicts with an active experiment', experimentOverlapCopy: 'This listing still has an observed improvement experiment. A new change can make the earlier result impossible to isolate.', acknowledgeOverlap: 'I understand the overlap and still want to prepare this proposal.', experimentOverlapRequired: 'A proposal that overlaps an active experiment cannot enter the queue until you acknowledge it.',
aiErrorJson: 'The JSON could not be parsed. Check for a trailing comma, missing quote, or code-fence markers.', aiErrorSchema: 'The response schema is invalid. schema must be makaytron-listing-ai-proposals/v1 and proposals must be an array.', aiErrorRequest: 'The requestId is unknown or expired. Copy a fresh AI request package.', aiErrorProposal: 'The proposal failed validation at {path}.', aiErrorHelp: 'Fix the expected field and retry the same JSON; no proposal is written while an error exists.', aiFormatHelp: 'tags and materials must be JSON arrays; title and description must be strings.', copyAiTemplate: 'Copy example JSON', aiTemplateCopied: 'Example AI JSON copied to the clipboard.',
queueRecoveryTitle: 'An interrupted action queue was found', queueRecoveryCopy: 'The page may have closed before the last step finished. The same write is never repeated until you inspect the listing state.', queueRecoveryRetry: 'Safely prepare item again', queueRecoveryConfirm: 'Have you checked the current Etsy form? The item will only return to pending; nothing will publish automatically.', queueRecoverySubmitted: 'This listing may have been submitted to Etsy without a verified result. Automatic retry is disabled to prevent a duplicate write; verify Etsy manually or stop the queue.', queueRecoveryOpen: 'Open listing for safe review', queueRecoveryStop: 'Stop queue',
userFeedback: 'Feedback', feedbackTitle: 'Listing Analyzer feedback', feedbackIntro: 'Your note is stored locally first. GitHub opens only when you press the button; listing titles and IDs are excluded.', feedbackCategory: 'Topic', feedbackRating: 'Experience rating', feedbackNote: 'Your note', feedbackDiagnostics: 'Include anonymous technical summary', feedbackSend: 'Save, copy, and open GitHub form', feedbackSaved: 'Feedback saved locally and copied to the clipboard.', feedbackInvalid: 'Please enter a clear note containing 10–800 characters.', feedbackBug: 'Bug', feedbackIdea: 'Idea', feedbackUsability: 'Usability', feedbackAnalysis: 'Analysis result', feedbackCount: 'Local feedback',
researchStart: 'Research with Marketplace Insights', researchOneListing: 'Select exactly one listing for research.', researchProbing: 'Looking for Etsy Keyword & Market Analyzer…', researchCompanionMissingTitle: 'Etsy Keyword & Market Analyzer required', researchCompanionMissing: 'Connected Marketplace Insights research requires the separate Etsy Keyword & Market Analyzer script. Listing Analyzer continues to work independently with all other features.', researchInstallHelp: 'The script cannot install silently. Its install page opens in a new tab and you give the final approval in your userscript manager.', researchOpenInstall: 'Open install page', researchTransferTitle: 'Marketplace Insights research', researchWaitingReady: 'Waiting for the Insights tab to become ready…', researchRequestSent: 'Research request sent; keep Analyzer open.', researchAcknowledged: 'Request accepted; waiting for Etsy Marketplace Insights results…', researchTimedOut: 'The automatic response window elapsed. Analyzer stays open and accepts a valid result until it expires; use the JSON recovery controls if needed.', researchComplete: '{count} keyword evidence item(s) validated; the proposal was not published automatically.', researchSavedForReview: 'Title and tag proposal saved for review; no Etsy field was changed.', researchNeedsEditor: 'Evidence and suggestion were saved. Open the listing editor once to compare tags safely; no action queue item was created.', researchFailed: 'Research result could not be validated: {message}', researchCopyRequest: 'Copy request JSON', researchRequestCopied: 'Research request JSON copied to the clipboard.', researchImportResult: 'Import result JSON', researchResultLabel: 'Complete RESEARCH_RESULT envelope JSON', researchReopenInsights: 'Reopen Insights', researchEvidenceTitle: 'Marketplace Insights evidence', researchSearches30d: '30-day searches', researchSearchResults: 'Search results / competition indicator', researchOpportunity: 'Makaytron opportunity score', researchSourceNote: 'Source: visible Etsy Marketplace Insights page data. The opportunity score is derived by Makaytron.', researchStale: 'The listing title or tags changed after research started; the result was rejected safely.', researchNoSeeds: 'No usable title or tag was available for research.', researchOpenProposal: 'Review proposal', researchPendingExists: 'The pending research for this listing was reopened.', researchErrorRemote: 'Keyword Analyzer could not complete the research: {message}', researchProposalReason: 'Marketplace Insights evidence: “{keyword}” — {searches} searches / 30 days, {results} search results. The Makaytron opportunity score is a derived metric.', researchTagReplacement: 'Because all 13 tag slots are full, low-evidence “{old}” was replaced by “{keyword}” in the review draft only.', updateManagedExternally: 'Updates are managed by the installation source; the GitHub updater was not forced.',
}),
});
const state = {
settings: { ...DEFAULT_SETTINGS },
records: [],
pageListings: [],
selectedIds: new Set(),
queue: null,
host: null,
shadow: null,
panel: null,
launcher: null,
modal: null,
activeView: 'overview',
wide: false,
status: { key: 'ready', tone: 'ready', params: {} },
menuIds: [],
routeKey: '',
routeTimer: 0,
leaseTimer: 0,
leaseToken: '',
collection: null,
collectionLoop: null,
collectionPauseRequested: false,
collectionLeaseTimer: 0,
collectionLeaseToken: '',
collectionSyncTimer: 0,
analysisExpiryTimer: 0,
renderedAnalysisFresh: null,
tabPresenceChannel: null,
analysisQuery: '',
analysisFilters: { ...DEFAULT_ANALYSIS_FILTERS },
filterPresets: [],
analysisFilterDrawerOpen: false,
analysisLimit: ANALYSIS_BATCH_SIZE,
updateState: { status: 'idle', latestVersion: '', checkedAt: 0, error: '', commitSha: '', installUrl: '' },
storageHealth: { estimateBytes: 0, warning: '', failedKey: '', failedAt: '' },
feedback: [],
modalReturnFocus: null,
researchChannel: null,
researchProbeWaiters: new Map(),
researchUiTimers: new Map(),
};
function randomId(prefix = 'id') {
try { return `${prefix}-${crypto.randomUUID()}`; }
catch { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
}
function randomNonce() {
try {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
} catch { return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; }
}
let tabId = (() => {
try {
const key = `${APP.storagePrefix}:tab-id`;
const existing = sessionStorage.getItem(key);
if (existing) return existing;
const created = randomId('tab');
sessionStorage.setItem(key, created);
return created;
} catch { return randomId('tab'); }
})();
const pageInstanceId = randomId('page');
async function ensureUniqueTabIdentity() {
if (typeof BroadcastChannel !== 'function') return tabId;
try {
const channel = new BroadcastChannel(`${APP.storagePrefix}:tab-presence`);
const probeId = randomId('probe');
let duplicate = false;
channel.addEventListener('message', (event) => {
const message = event.data;
if (!message || message.instanceId === pageInstanceId || message.tabId !== tabId) return;
if (message.type === 'probe') channel.postMessage({ type: 'present', tabId, probeId: message.probeId, instanceId: pageInstanceId });
if (message.type === 'present' && message.probeId === probeId) duplicate = true;
});
channel.postMessage({ type: 'probe', tabId, probeId, instanceId: pageInstanceId });
await sleep(80);
if (duplicate) {
tabId = randomId('tab');
try { sessionStorage.setItem(`${APP.storagePrefix}:tab-id`, tabId); } catch { /* optional */ }
}
state.tabPresenceChannel = channel;
} catch { /* BroadcastChannel is an optional duplicate-tab hardening layer. */ }
return tabId;
}
function t(key, params = {}) {
const dictionary = I18N[state.settings.language] || I18N.tr;
const extra = EXTRA_I18N[state.settings.language] || EXTRA_I18N.tr;
const template = dictionary[key] ?? extra[key] ?? I18N.tr[key] ?? EXTRA_I18N.tr[key] ?? key;
return String(template).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[character]);
}
function normalizeSpace(value) { return String(value ?? '').replace(/\s+/g, ' ').trim(); }
function nowIso() { return new Date().toISOString(); }
function dayKey(iso = nowIso()) { return String(iso).slice(0, 10); }
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
async function withNamedLock(name, callback) {
if (!navigator.locks?.request) {
const error = new Error('Cross-tab storage locking is unavailable.');
error.code = 'STORAGE_LOCK_UNAVAILABLE';
throw error;
}
return navigator.locks.request(name, { mode: 'exclusive' }, callback);
}
function clamp(value, min, max) { return Math.min(max, Math.max(min, Number(value) || 0)); }
function uniqueStrings(values) {
const seen = new Set();
return values.map(normalizeSpace).filter((value) => value && !seen.has(value.toLocaleLowerCase()) && seen.add(value.toLocaleLowerCase()));
}
function sameStringSet(left, right) {
const normalize = (values) => uniqueStrings(Array.isArray(values) ? values : []).map((value) => value.toLocaleLowerCase()).sort();
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
}
const DAY_MS = 86400000;
function finiteOrNull(value) { return Number.isFinite(Number(value)) && value !== '' && value !== null ? Number(value) : null; }
function validTime(value) { const time = Date.parse(value); return Number.isFinite(time) ? time : null; }
function daysBetween(newer, older) {
const newerTime = validTime(newer); const olderTime = validTime(older);
return newerTime === null || olderTime === null ? null : (newerTime - olderTime) / DAY_MS;
}
function addDays(value, days) {
const time = validTime(value);
return time === null ? null : new Date(time + Number(days || 0) * DAY_MS).toISOString();
}
function median(values) {
const sorted = values.map(finiteOrNull).filter(Number.isFinite).sort((a, b) => a - b);
if (!sorted.length) return null;
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
function percentileRank(values, value) {
const target = finiteOrNull(value);
const sorted = values.map(finiteOrNull).filter(Number.isFinite).sort((a, b) => a - b);
if (target === null || !sorted.length) return null;
if (sorted.length === 1) return 50;
const positions = sorted.map((item, index) => item === target ? index : -1).filter((index) => index >= 0);
if (positions.length) return Math.round((median(positions) / (sorted.length - 1)) * 100);
const below = sorted.filter((item) => item < target).length;
return clamp(Math.round((below / (sorted.length - 1)) * 100), 0, 100);
}
function percentChange(current, previous) {
const currentValue = finiteOrNull(current); const previousValue = finiteOrNull(previous);
if (currentValue === null || previousValue === null || previousValue === 0) return null;
return Math.round(((currentValue - previousValue) / previousValue) * 1000) / 10;
}
function safeRatio(numerator, denominator, multiplier = 1) {
const top = finiteOrNull(numerator); const bottom = finiteOrNull(denominator);
if (top === null || bottom === null || bottom <= 0) return null;
return Math.round((top / bottom) * multiplier * 1000) / 1000;
}
function fnv1a(value) {
let hash = 0x811c9dc5;
for (const character of String(value)) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function parseDecimal(raw) {
let value = String(raw ?? '').replace(/[^\d,.-]/g, '');
if (!value) return null;
const comma = value.lastIndexOf(',');
const dot = value.lastIndexOf('.');
if (comma >= 0 && dot >= 0) {
const decimal = comma > dot ? ',' : '.';
const thousands = decimal === ',' ? /\./g : /,/g;
value = value.replace(thousands, '').replace(decimal, '.');
} else if (comma >= 0) {
const decimals = value.length - comma - 1;
value = decimals > 0 && decimals <= 2 ? value.replace(',', '.') : value.replace(/,/g, '');
} else if (dot >= 0) {
const decimals = value.length - dot - 1;
if (decimals > 2) value = value.replace(/\./g, '');
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^{}$()|[\]\\]/g, '\\$&');
}
function parseCountValue(raw) {
const source = normalizeSpace(raw);
const compact = source.match(/^(-?[\d][\d.,\s]*?)\s*([kmb])?$/i);
if (!compact) return null;
if (compact[2]) {
const base = parseDecimal(compact[1]);
const multiplier = { k: 1_000, m: 1_000_000, b: 1_000_000_000 }[compact[2].toLowerCase()];
return Number.isFinite(base) ? Math.round(base * multiplier) : null;
}
const parsed = Number(compact[1].replace(/[^\d-]/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
function labeledPrefix(rows, labels) {
const labelPattern = labels.map(escapeRegExp).join('|');
const matcher = new RegExp(`^(.+?)\\s+(?:${labelPattern})$`, 'i');
for (const row of rows) {
const match = normalizeSpace(row).match(matcher);
if (match) return match[1];
}
return null;
}
function parseCount(text, labels) {
const prefix = labeledPrefix([text], labels);
return prefix === null ? null : parseCountValue(prefix);
}
function parseCountFromRows(rows, labels) {
const prefix = labeledPrefix(rows, labels);
return prefix === null ? null : parseCountValue(prefix);
}
function parseMoneyFromLabel(text, labels) {
const prefix = labeledPrefix([text], labels);
return prefix === null ? null : parseDecimal(prefix);
}
function currencyMarker(text) {
const source = normalizeSpace(text);
const symbol = source.match(/[$€£₺¥₹₽₩]/)?.[0];
if (symbol) return symbol;
const code = source.match(/\b(?:USD|EUR|GBP|TRY|TL|CAD|AUD|NZD|JPY|CNY|INR|BRL|MXN|SEK|NOK|DKK|CHF|PLN)\b/i)?.[0];
if (code?.toUpperCase() === 'TL') return '₺';
return code ? `${code.toUpperCase()} ` : '';
}
function parseListingMetrics(rows) {
const source = Array.isArray(rows) ? rows.map(normalizeSpace).filter(Boolean) : [];
const revenuePrefix = labeledPrefix(source, ['revenue', 'gelir']);
return {
visits: parseCountFromRows(source, ['visit', 'visits', 'ziyaret', 'ziyaretler']),
favorites: parseCountFromRows(source, ['favorite', 'favorites', 'favori', 'favoriler']),
sales: parseCountFromRows(source, ['sale', 'sales', 'satış']),
revenue: revenuePrefix === null ? null : parseDecimal(revenuePrefix),
renewals: parseCountFromRows(source, ['renewal', 'renewals', 'yenileme', 'yenilemeler']),
currency: revenuePrefix === null ? '' : currencyMarker(revenuePrefix),
};
}
function parsePriceRange(text) {
const values = String(text ?? '').split(/\s*[-–—]\s*/).map(parseDecimal).filter(Number.isFinite);
return { min: values[0] ?? null, max: values[1] ?? values[0] ?? null, label: normalizeSpace(text) };
}
function formatNumber(value) {
if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) return '—';
return new Intl.NumberFormat(state.settings.language === 'tr' ? 'tr-TR' : 'en-US').format(Number(value));
}
function formatMoney(value, currency = '$') {
if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) return '—';
return `${currency}${new Intl.NumberFormat(state.settings.language === 'tr' ? 'tr-TR' : 'en-US', { maximumFractionDigits: 2 }).format(Number(value))}`;
}
function formatDate(value) {
if (!value) return '—';
try { return new Intl.DateTimeFormat(state.settings.language === 'tr' ? 'tr-TR' : 'en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)); }
catch { return String(value); }
}
function normalizeUpdateState(raw) {
const source = raw && typeof raw === 'object' ? raw : {};
const allowed = ['idle', 'checking', 'current', 'available', 'error', 'managed'];
const commitSha = /^[a-f0-9]{40}$/.test(String(source.commitSha || '')) ? String(source.commitSha) : '';
const installUrl = commitSha ? pinnedUpdateUrl(commitSha) : '';
const hasVerifiedInstall = Boolean(installUrl && String(source.installUrl || '') === installUrl);
const requestedStatus = allowed.includes(source.status) && source.status !== 'checking' ? source.status : 'idle';
return {
status: ['current', 'available'].includes(requestedStatus) && !hasVerifiedInstall ? 'idle' : requestedStatus,
latestVersion: /^\d+\.\d+\.\d+$/.test(String(source.latestVersion || '')) ? String(source.latestVersion) : '',
checkedAt: Math.max(0, Number(source.checkedAt) || 0),
error: normalizeSpace(source.error).slice(0, 240),
commitSha: hasVerifiedInstall ? commitSha : '',
installUrl: hasVerifiedInstall ? installUrl : '',
source: ['github', 'greasyfork', 'external'].includes(source.source) ? source.source : '',
};
}
function serializedBytes(value) {
try { return new TextEncoder().encode(JSON.stringify(value)).byteLength; }
catch { return Number.POSITIVE_INFINITY; }
}
function storageErrorCode(error) {
const text = `${error?.name || ''} ${error?.message || error || ''}`;
return /quota|storage[^a-z]*(?:full|limit)|exceed/i.test(text) ? 'STORAGE_QUOTA_EXCEEDED' : 'STORAGE_WRITE_FAILED';
}
function rememberStorageFailure(key, error) {
state.storageHealth = {
...state.storageHealth,
warning: storageErrorCode(error),
failedKey: String(key || ''),
failedAt: nowIso(),
};
}
function refreshStorageEstimate() {
const estimate = serializedBytes({
records: state.records,
queue: state.queue,
collection: state.collection,
analysisFilters: state.analysisFilters,
filterPresets: state.filterPresets,
feedback: state.feedback,
});
state.storageHealth.estimateBytes = Number.isFinite(estimate) ? estimate : 0;
if (estimate >= STORAGE_WARNING_BYTES && !state.storageHealth.warning) state.storageHealth.warning = 'STORAGE_NEAR_LIMIT';
if (estimate < STORAGE_WARNING_BYTES && state.storageHealth.warning === 'STORAGE_NEAR_LIMIT') state.storageHealth.warning = '';
return state.storageHealth;
}
const GMX = {
async get(key, fallback) {
try { return await GM.getValue(key, fallback); } catch { void trackTelemetryError('storage_listing_state'); return fallback; }
},
async set(key, value) {
try {
await GM.setValue(key, value);
if (state.storageHealth.failedKey === String(key)) state.storageHealth = { ...state.storageHealth, warning: '', failedKey: '', failedAt: '' };
return true;
} catch (error) {
rememberStorageFailure(key, error);
void trackTelemetryError('storage_listing_state');
return false;
}
},
async remove(key) {
try { await GM.deleteValue(key); return true; } catch { void trackTelemetryError('storage_listing_state'); return false; }
},
register(label, callback) {
try { return GM_registerMenuCommand(label, callback); } catch { return null; }
},
unregister(id) {
try { if (id != null) GM_unregisterMenuCommand(id); } catch { /* ignored */ }
},
openTab(url) {
try {
if (typeof GM?.openInTab === 'function') return GM.openInTab(url, { active: true, insert: true, setParent: true });
} catch { /* fall through to a safe, user-initiated link */ }
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.click();
return null;
},
};
async function requireStored(key, value) {
if (await GMX.set(key, value)) return value;
const error = new Error(`Unable to persist ${key}.`);
error.code = state.storageHealth.failedKey === String(key) ? state.storageHealth.warning || 'STORAGE_WRITE_FAILED' : 'STORAGE_WRITE_FAILED';
throw error;
}
const Store = {
async loadSettings() {
const stored = await GMX.get(KEYS.settings, {});
state.settings = { ...DEFAULT_SETTINGS, ...(stored && typeof stored === 'object' ? stored : {}) };
state.settings.language = ['tr', 'en'].includes(state.settings.language) ? state.settings.language : 'tr';
state.settings.retentionDays = clamp(state.settings.retentionDays, 30, APP.retentionDays);
state.settings.maxSnapshots = clamp(state.settings.maxSnapshots, 10, APP.maxSnapshots);
return state.settings;
},
async saveSettings() { return GMX.set(KEYS.settings, state.settings); },
async loadAnalysisFilters() {
state.analysisFilters = normalizeAnalysisFilters(await GMX.get(KEYS.analysisFilters, DEFAULT_ANALYSIS_FILTERS));
return state.analysisFilters;
},
async saveAnalysisFilters() { return GMX.set(KEYS.analysisFilters, normalizeAnalysisFilters(state.analysisFilters)); },
async loadFilterPresets() {
state.filterPresets = normalizeFilterPresets(await GMX.get(KEYS.filterPresets, { schema: 1, items: [] }));
return state.filterPresets;
},
async saveFilterPresets() {
state.filterPresets = normalizeFilterPresets(state.filterPresets);
return GMX.set(KEYS.filterPresets, { schema: 1, items: state.filterPresets });
},
async loadFeedback() {
const stored = await GMX.get(KEYS.feedback, []);
state.feedback = (Array.isArray(stored) ? stored : []).map((item) => {
if (!item || typeof item !== 'object') return null;
const note = normalizeSpace(item.note).slice(0, 800);
if (note.length < 10) return null;
return {
id: String(item.id || randomId('feedback')),
at: validTime(item.at) ? String(item.at) : nowIso(),
category: ['bug', 'idea', 'usability', 'analysis'].includes(item.category) ? item.category : 'idea',
rating: clamp(item.rating, 1, 5), note,
diagnostics: item.diagnostics && typeof item.diagnostics === 'object' ? item.diagnostics : null,
};
}).filter(Boolean).slice(-MAX_FEEDBACK_ENTRIES);
return state.feedback;
},
async saveFeedback() {
state.feedback = state.feedback.slice(-MAX_FEEDBACK_ENTRIES);
return requireStored(KEYS.feedback, state.feedback);
},
async loadUpdateState() {
state.updateState = normalizeUpdateState(await GMX.get(KEYS.updateCheck, {}));
return state.updateState;
},
async saveUpdateState() {
const candidate = normalizeUpdateState(state.updateState);
state.updateState = candidate;
return GMX.set(KEYS.updateCheck, candidate);
},
async getIndex() {
const index = await GMX.get(KEYS.index, []);
return Array.isArray(index) ? uniqueStrings(index.map(String)) : [];
},
async getRecord(listingId) {
const record = await GMX.get(KEYS.record(listingId), null);
return record && typeof record === 'object' ? normalizeRecord(record, listingId) : null;
},
async putRecordLocked(record, batchIndex = null) {
if (!record || typeof record !== 'object') throw new Error('Invalid listing record.');
if (Number(record.schema || RECORD_SCHEMA_VERSION) > RECORD_SCHEMA_VERSION || record.unsupportedSchema) {
throw new Error('This listing record was created by a newer data schema and was not overwritten.');
}
const listingId = String(record.listingId);
const currentRaw = await GMX.get(KEYS.record(listingId), null);
const current = currentRaw && typeof currentRaw === 'object' ? normalizeRecord(currentRaw, listingId) : null;
const merged = mergeRecordCopies(current, normalizeRecord(record, listingId));
merged.lastWriteId = randomId('record-write');
const index = batchIndex?.items || await this.getIndex();
await requireStored(KEYS.record(listingId), merged);
const verified = await GMX.get(KEYS.record(listingId), null);
if (verified?.lastWriteId !== merged.lastWriteId) {
const error = new Error('Listing record write verification failed.'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
if (!index.includes(listingId)) {
index.push(listingId);
if (batchIndex) batchIndex.dirty = true;
else await requireStored(KEYS.index, index);
}
return merged;
},
async putRecord(record) {
return withNamedLock(STORAGE_MUTATION_LOCK, () => this.putRecordLocked(record));
},
async listRecords() {
const index = await this.getIndex();
const records = await Promise.all(index.map((id) => this.getRecord(id)));
return records.filter(Boolean);
},
prepareSnapshot(listing, existingRecord = null) {
const capturedAt = listing.capturedAt || nowIso();
const existing = existingRecord || {
schema: APP.schema,
listingId: String(listing.listingId),
meta: {}, history: [], improvements: [], proposal: null,
};
if (existing.unsupportedSchema || Number(existing.schema || RECORD_SCHEMA_VERSION) > RECORD_SCHEMA_VERSION) {
throw new Error('A newer listing record schema cannot be modified by this version.');
}
existing.meta = {
...existing.meta,
title: listing.title || existing.meta?.title || '', sku: listing.sku || existing.meta?.sku || '', editUrl: listing.editUrl || existing.meta?.editUrl || '',
publicUrl: listing.publicUrl || existing.meta.publicUrl || '', imageUrl: listing.imageUrl || existing.meta.imageUrl || '',
listingState: normalizeListingState(listing.listingState) || existing.meta?.listingState || '',
statusLabel: listing.statusLabel || existing.meta?.statusLabel || '', renewalLabel: listing.renewalLabel || existing.meta?.renewalLabel || '', lastSeenAt: capturedAt,
shopKey: listing.shopKey || existing.meta?.shopKey || shopKeyFromUrl(listing.editUrl),
};
const snapshot = snapshotFromListing(listing, capturedAt);
const history = Array.isArray(existing.history) ? existing.history : [];
const sameDayIndex = history.findIndex((item) => item.day === snapshot.day);
if (sameDayIndex >= 0) history[sameDayIndex] = mergeDailySnapshot(history[sameDayIndex], snapshot);
else history.push(snapshot);
const cutoff = Date.now() - clamp(state.settings.retentionDays, 30, APP.retentionDays) * 86400000;
existing.history = history
.filter((item) => Number.isFinite(Date.parse(item.at)) && Date.parse(item.at) >= cutoff)
.sort((a, b) => String(a.at).localeCompare(String(b.at)))
.slice(-clamp(state.settings.maxSnapshots, 10, APP.maxSnapshots));
existing.improvements = (Array.isArray(existing.improvements) ? existing.improvements : []).slice(-APP.maxImprovements);
updateExperimentEvaluations(existing, capturedAt);
const evaluated = evaluateHealthRecords([existing], state.settings, capturedAt).get(existing.listingId);
if (evaluated) { existing.health = evaluated; existing.analysis = evaluated.result; }
return existing;
},
async saveSnapshot(listing) {
const candidate = this.prepareSnapshot(listing, await this.getRecord(listing.listingId));
return withNamedLock(STORAGE_MUTATION_LOCK, () => this.putRecordLocked(candidate));
},
async saveSnapshots(listings, guard = null) {
const source = Array.isArray(listings) ? listings : [];
const batchIndex = { items: await this.getIndex(), dirty: false };
const saved = [];
for (const listing of source) {
if (guard) await guard(listing, saved.length);
const candidate = this.prepareSnapshot(listing, await this.getRecord(listing.listingId));
saved.push(await withNamedLock(STORAGE_MUTATION_LOCK, () => this.putRecordLocked(candidate, batchIndex)));
}
if (batchIndex.dirty) {
await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const current = await this.getIndex();
await requireStored(KEYS.index, uniqueStrings([...current, ...batchIndex.items]));
});
}
return saved;
},
async saveProposalLocked(listingId, proposal) {
const record = await this.getRecord(listingId);
if (!record) throw new Error(`Unknown listing ${listingId}`);
const before = captureEditableFieldsFromRecord(record);
const fields = proposal.action === 'UPDATE' ? proposalFields(proposal, true) : [];
if (proposal.action === 'UPDATE' && !fields.length) throw new Error('An UPDATE proposal must select at least one Etsy field.');
const overlap = proposal.action === 'UPDATE' ? proposalExperimentOverlap(record) : null;
record.improvements = Array.isArray(record.improvements) ? record.improvements : [];
const savedAt = nowIso();
const basis = recommendationBasis(record);
let improvementId = null;
let baselineFingerprint = null;
const baselineCapturedAt = record.editor?.capturedAt || null;
if (proposal.action === 'UPDATE') {
record.improvements.forEach((entry) => {
if (entry?.status === 'planned') { entry.status = 'superseded'; entry.supersededAt = savedAt; }
});
improvementId = `improvement-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
baselineFingerprint = await contentFingerprint(before);
record.improvements.push({
id: improvementId,
at: savedAt, status: 'planned', action: proposal.action, note: proposal.reason || '',
source: String(proposal.source || 'manual'), requestId: String(proposal.requestId || ''), reference: String(proposal.reference || ''),
fields, before, proposed: Object.fromEntries(fields.map((field) => [field, proposal[field]])),
baselineSnapshot: record.history?.at(-1) || null, baselineFingerprint, baselineCapturedAt, basis,
experimentOverlap: overlap,
experiment: { state: 'planned', durationDays: HEALTH_RULES.experimentDays, primaryMetric: primaryMetricForFields(fields) },
});
}
record.proposal = {
...proposal, fields, improvementId, baselineFingerprint, baselineCapturedAt, basis,
experimentOverlap: overlap,
experimentOverlapAcceptedAt: overlap && proposal.acknowledgeExperimentOverlap ? savedAt : null,
updatedAt: savedAt,
};
record.improvements = record.improvements.slice(-APP.maxImprovements);
return this.putRecordLocked(record);
},
async saveProposal(listingId, proposal) {
return withNamedLock(STORAGE_MUTATION_LOCK, () => this.saveProposalLocked(listingId, proposal));
},
async loadQueue() {
const queue = await GMX.get(KEYS.queue, null);
state.queue = queue && typeof queue === 'object' ? queue : null;
return state.queue;
},
async saveQueueLocked(queue) {
const candidate = { ...queue, lastWriteId: randomId('queue-write') };
await requireStored(KEYS.queue, candidate);
const verified = await GMX.get(KEYS.queue, null);
if (verified?.lastWriteId !== candidate.lastWriteId) {
const error = new Error('Queue write verification failed.'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
state.queue = candidate;
return state.queue;
},
async saveQueue(queue) {
return withNamedLock(STORAGE_MUTATION_LOCK, () => this.saveQueueLocked(queue));
},
async loadCollection() {
state.collection = normalizeCollection(await GMX.get(KEYS.collection, null));
return state.collection;
},
async saveCollection(collection, expected = {}) {
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const normalized = normalizeCollection(collection);
const stored = normalizeCollection(await GMX.get(KEYS.collection, null));
if (normalized?.unsupportedSchema || stored?.unsupportedSchema) {
const error = new Error('A newer collection schema cannot be overwritten by this version.');
error.code = 'COLLECTION_NEWER_SCHEMA';
throw error;
}
if (expected.id && (!stored || stored.id !== expected.id)) {
const error = new Error('Collection ownership changed.'); error.code = 'COLLECTION_LEASE_LOST'; throw error;
}
if (expected.token && stored?.leaseToken !== expected.token) {
const error = new Error('Collection fencing token changed.'); error.code = 'COLLECTION_LEASE_LOST'; throw error;
}
if (expected.leaseToken) {
const lease = await GMX.get(KEYS.collectionLease, null);
const validLease = lease?.owner === tabId && lease?.instanceId === pageInstanceId
&& lease?.token === expected.leaseToken && Number(lease.expiresAt) > Date.now();
if (!validLease) { const error = new Error('Collection lease changed.'); error.code = 'COLLECTION_LEASE_LOST'; throw error; }
}
await requireStored(KEYS.collection, normalized);
const verified = normalizeCollection(await GMX.get(KEYS.collection, null));
if (!verified || verified.id !== normalized.id || verified.updatedAt !== normalized.updatedAt || verified.leaseToken !== normalized.leaseToken) {
const error = new Error('Collection write verification failed.'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
state.collection = normalized;
return state.collection;
});
},
async appendAuditLocked(entry) {
const audit = await GMX.get(KEYS.audit, []);
const items = Array.isArray(audit) ? audit : [];
const auditEntry = { at: nowIso(), ...entry, writeId: randomId('audit-write') };
items.push(auditEntry);
await requireStored(KEYS.audit, items.slice(-APP.maxAuditEntries));
const verified = await GMX.get(KEYS.audit, []);
if (!Array.isArray(verified) || !verified.some((item) => item?.writeId === auditEntry.writeId)) {
const error = new Error('Audit write verification failed.'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
return auditEntry;
},
async appendAudit(entry) {
return withNamedLock(STORAGE_MUTATION_LOCK, () => this.appendAuditLocked(entry));
},
async clearAnalysisData() {
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const lease = await GMX.get(KEYS.lease, null);
if ((lease?.token || lease?.owner) && Number(lease.expiresAt) > Date.now()) return false;
const collectionLease = await GMX.get(KEYS.collectionLease, null);
if (collectionLease?.token && Number(collectionLease.expiresAt) > Date.now()) return false;
const storedCollection = normalizeCollection(await GMX.get(KEYS.collection, null));
if (['starting', 'running'].includes(storedCollection?.status)) return false;
const index = await this.getIndex();
if (!await GMX.remove(KEYS.collection)) return false;
state.collection = null; state.selectedIds.clear(); clearCollectionHandoff();
for (const listingId of index) if (!await GMX.remove(KEYS.record(listingId))) return false;
for (const key of [KEYS.index, KEYS.audit, KEYS.queue, KEYS.collectionLease, KEYS.aiRequests, KEYS.researchRequests]) if (!await GMX.remove(key)) return false;
state.records = []; state.pageListings = []; state.queue = null;
return true;
});
},
};
function compareSemver(left, right) {
const parse = (value) => /^\d+\.\d+\.\d+$/.test(String(value || '')) ? String(value).split('.').map(Number) : null;
const a = parse(left); const b = parse(right);
if (!a || !b) return null;
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
}
return 0;
}
function pinnedUpdateUrl(commitSha) {
return /^[a-f0-9]{40}$/.test(String(commitSha || ''))
? `${GITHUB_RAW_REPOSITORY_URL}/${commitSha}/${GITHUB_SCRIPT_PATH}`
: '';
}
function exactHttpsTarget(actualValue, expectedValue) {
try {
const actual = new URL(actualValue);
const expected = new URL(expectedValue);
return actual.protocol === 'https:'
&& actual.protocol === expected.protocol
&& actual.hostname === expected.hostname
&& actual.port === expected.port
&& actual.pathname === expected.pathname
&& actual.search === expected.search
&& actual.hash === expected.hash
&& !actual.username && !actual.password;
} catch {
return false;
}
}
function metadataValues(source, key) {
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return Array.from(
String(source || '').matchAll(new RegExp(`^// @${escapedKey}\\s+(.+?)\\s*$`, 'gm')),
(match) => match[1].trim(),
);
}
function distributionUrlKind(value) {
const candidate = String(value || '').trim();
if (candidate === GITHUB_CANONICAL_SCRIPT_URL) return 'github';
try {
const url = new URL(candidate);
const authority = candidate.match(/^https:\/\/([^/?#]+)/i)?.[1] || '';
const explicitPort = /:\d+$/.test(authority);
const safeAuthority = url.protocol === 'https:' && !url.username && !url.password && !url.port && !explicitPort;
const host = url.hostname.toLowerCase();
if (safeAuthority && (host === 'greasyfork.org' || host.endsWith('.greasyfork.org'))) return 'greasyfork';
} catch { /* malformed or non-HTTPS distribution URLs are external */ }
return 'external';
}
function installedDistributionSource() {
let info = null;
try { if (typeof GM_info !== 'undefined') info = GM_info; } catch { /* optional userscript metadata */ }
const candidates = [info?.script?.updateURL, info?.script?.downloadURL, info?.scriptUpdateURL, info?.scriptDownloadURL]
.map((value) => String(value || '').trim()).filter(Boolean);
if (!candidates.length) return 'github';
const kinds = candidates.map(distributionUrlKind);
if (kinds.every((kind) => kind === 'github')) return 'github';
if (kinds.every((kind) => kind === 'greasyfork')) return 'greasyfork';
return 'external';
}
function requestExactText(url, accept = 'text/plain') {
return new Promise((resolve, reject) => {
if (typeof GM === 'undefined' || typeof GM.xmlHttpRequest !== 'function') { reject(new Error('Tampermonkey request API is unavailable.')); return; }
let settled = false;
const finish = (handler, value) => { if (settled) return; settled = true; handler(value); };
try {
GM.xmlHttpRequest({
method: 'GET', url, timeout: UPDATE_CHECK_TIMEOUT_MS,
anonymous: true, headers: { Accept: accept },
onload: (response) => {
try {
if (!response.finalUrl) throw new Error('The update response did not expose its final address.');
if (!exactHttpsTarget(response.finalUrl, url)) throw new Error('GitHub response left the canonical update address.');
if (Number(response.status) !== 200) throw new Error(`GitHub HTTP ${Number(response.status) || 0}`);
const text = String(response.responseText || '');
if (!text || text.length > 2_000_000) throw new Error('The update response is empty or too large.');
finish(resolve, text);
} catch (error) { finish(reject, error); }
},
onerror: () => finish(reject, new Error('GitHub update request failed.')),
ontimeout: () => finish(reject, new Error('GitHub update request timed out.')),
});
} catch (error) { finish(reject, error); }
});
}
async function requestCanonicalScript() {
const refText = await requestExactText(GITHUB_API_REF_URL, 'application/vnd.github+json');
let ref;
try { ref = JSON.parse(refText); }
catch { throw new Error('The GitHub commit response is invalid.'); }
const commitSha = /^[a-f0-9]{40}$/.test(String(ref?.sha || '')) ? String(ref.sha) : '';
if (!commitSha) throw new Error('The GitHub commit identity is invalid.');
const installUrl = pinnedUpdateUrl(commitSha);
const source = await requestExactText(installUrl, 'text/plain');
if (!/^\/\/ ==UserScript==[\r\n]/.test(source)) throw new Error('The update metadata block is missing.');
const namespaces = metadataValues(source, 'namespace');
const names = metadataValues(source, 'name');
const updateUrls = metadataValues(source, 'updateURL');
const downloadUrls = metadataValues(source, 'downloadURL');
const versions = metadataValues(source, 'version');
if (namespaces.length !== 1 || namespaces[0] !== 'https://github.com/Makaytron/EtsyScript') {
throw new Error('The update namespace is not trusted.');
}
if (names.length !== 1 || names[0] !== 'Makaytron Etsy Listing Analyzer') {
throw new Error('The update product name is not trusted.');
}
if (updateUrls.length !== 1 || downloadUrls.length !== 1
|| updateUrls[0] !== GITHUB_CANONICAL_SCRIPT_URL || downloadUrls[0] !== GITHUB_CANONICAL_SCRIPT_URL) {
throw new Error('The update distribution address is not trusted.');
}
const version = versions.length === 1 && /^\d+\.\d+\.\d+$/.test(versions[0]) ? versions[0] : '';
if (!version || compareSemver(version, APP_VERSION) === null) throw new Error('The update version is invalid.');
return { version, commitSha, installUrl };
}
async function checkForUpdates(options = {}) {
const manual = Boolean(options.manual);
const force = Boolean(options.force);
const age = Date.now() - Number(state.updateState.checkedAt || 0);
if (!force && state.updateState.checkedAt && age >= 0 && age < UPDATE_CHECK_INTERVAL_MS) return state.updateState;
if (state.updateState.status === 'checking') return state.updateState;
const distributionSource = installedDistributionSource();
if (distributionSource !== 'github') {
state.updateState = { status: 'managed', latestVersion: '', checkedAt: Date.now(), error: '', commitSha: '', installUrl: '', source: distributionSource };
await Store.saveUpdateState();
if (manual) { UI.setStatus('updateManagedExternally', 'ready'); UI.render(true); }
return state.updateState;
}
state.updateState = { ...state.updateState, status: 'checking', error: '' };
if (manual) { UI.setStatus('updateChecking', 'scanning'); UI.render(true); }
try {
const remote = await requestCanonicalScript();
const comparison = compareSemver(remote.version, APP_VERSION);
if (comparison === null) throw new Error('The update version could not be compared.');
state.updateState = { status: comparison > 0 ? 'available' : 'current', latestVersion: remote.version, checkedAt: Date.now(), error: '', commitSha: remote.commitSha, installUrl: remote.installUrl, source: 'github' };
await Store.saveUpdateState();
UI.setStatus(comparison > 0 ? 'updateAvailable' : 'updateCurrent', comparison > 0 ? 'blocked' : 'ready', { version: remote.version });
UI.render(true);
return state.updateState;
} catch (error) {
const message = normalizeSpace(error?.message || error || 'Unknown error').slice(0, 240);
state.updateState = { status: 'error', latestVersion: '', checkedAt: Date.now(), error: message, commitSha: '', installUrl: '' };
await Store.saveUpdateState();
if (manual) { UI.setStatus('updateFailed', 'error', { message }); UI.render(true); }
return state.updateState;
}
}
function openTampermonkeyUpdate() {
const queueActive = Boolean(Queue?.activeItem?.());
if (state.collection?.status === 'running' || queueActive) { UI.setStatus('updateBlocked', 'blocked'); UI.render(true); return false; }
if (installedDistributionSource() !== 'github') { UI.setStatus('updateManagedExternally', 'ready'); UI.render(true); return false; }
const installUrl = pinnedUpdateUrl(state.updateState.commitSha);
if (!installUrl || state.updateState.installUrl !== installUrl) { UI.setStatus('updateFailed', 'error', { message: 'Verified update identity is unavailable.' }); UI.render(true); return false; }
const link = document.createElement('a');
link.href = installUrl; link.target = '_blank'; link.rel = 'noopener noreferrer';
link.click();
return true;
}
function bindInstallUpdate(button) {
if (!button || typeof button.addEventListener !== 'function') return false;
button.addEventListener('click', () => { openTampermonkeyUpdate(); });
return true;
}
function captureEditableFieldsFromRecord(record) {
return {
title: record?.editor?.title || record?.meta?.title || '',
description: record?.editor?.description || '',
tags: Array.isArray(record?.editor?.tags) ? record.editor.tags : [],
materials: Array.isArray(record?.editor?.materials) ? record.editor.materials : [],
};
}
function proposalFields(proposal, strict = false) {
const raw = proposal?.fields;
if (raw === undefined || raw === null) {
if (strict) throw new Error('fields must explicitly list the Etsy fields to change');
return EDITABLE_FIELDS.filter((field) => {
if (field === 'title' || field === 'description') return typeof proposal?.[field] === 'string' && proposal[field].trim().length > 0;
return Array.isArray(proposal?.[field]) && proposal[field].length > 0;
});
}
if (!Array.isArray(raw)) throw new Error('fields must be an array');
const normalized = raw.map((field) => String(field).trim());
if (normalized.some((field) => !EDITABLE_FIELDS.includes(field))) throw new Error('fields contains an unsupported Etsy field');
if (new Set(normalized).size !== normalized.length) throw new Error('fields contains a duplicate Etsy field');
return normalized;
}
function editorMatchesProposal(editor, proposal) {
const fields = proposalFields(proposal, true);
return fields.every((field) => {
if (field === 'title') return normalizeSpace(editor?.title) === normalizeSpace(proposal.title);
if (field === 'description') return String(editor?.description ?? '') === String(proposal.description ?? '');
if (field === 'tags') return sameStringSet(editor?.tags, proposal.tags);
if (field === 'materials') return sameStringSet(editor?.materials, proposal.materials);
return false;
});
}
function currentShopKey(root = document) {
const keys = Array.from(root?.querySelectorAll?.('[data-seller-nav="true"] a[href*="/shop/"]') || []).flatMap((anchor) => {
try {
const url = new URL(anchor.href, location.href);
const match = url.pathname.match(/^\/shop\/([A-Za-z0-9]+)\/?$/);
if (url.protocol !== 'https:' || url.hostname !== 'www.etsy.com' || url.port || url.username || url.password || !match) return [];
return [`etsy-shop:${match[1].toLowerCase()}`];
} catch { return []; }
});
const unique = uniqueStrings(keys);
return unique.length === 1 ? unique[0] : '';
}
function normalizeListingState(value) {
const normalized = normalizeSpace(value).toLowerCase().replace(/[\s-]+/g, '_');
const aliases = {
active: 'active', aktif: 'active',
draft: 'draft', taslak: 'draft',
expired: 'expired', süresi_dolmuş: 'expired', suresi_dolmus: 'expired',
sold_out: 'sold_out', tükendi: 'sold_out', tukendi: 'sold_out',
inactive: 'inactive', deactivated: 'inactive', pasif: 'inactive', devre_dışı: 'inactive', devre_disi: 'inactive',
};
return aliases[normalized] || '';
}
function listingStateLabel(value) {
return { active: 'Active', draft: 'Draft', expired: 'Expired', sold_out: 'Sold out', inactive: 'Inactive' }[normalizeListingState(value)] || '';
}
function pageListingState(root = document, href = location.href) {
const signals = [];
try {
const explicit = normalizeListingState(new URL(href, location.href).searchParams.get('item_status'));
if (explicit) signals.push(explicit);
} catch { return ''; }
Array.from(root?.querySelectorAll?.('input[name="item_status"]') || []).forEach((input) => {
const label = input.closest?.('label');
let selected = Boolean(input.checked || input.getAttribute?.('checked') !== null || input.getAttribute?.('aria-checked') === 'true');
try { selected = selected || input.matches?.(':checked'); } catch { /* optional selector support */ }
selected = selected || label?.getAttribute?.('aria-current') === 'true'
|| Boolean(label?.querySelector?.('[data-clg-id="WtAnnouncement"][role="status"]'));
const stateValue = selected ? normalizeListingState(input.value) : '';
if (stateValue) signals.push(stateValue);
});
const unique = uniqueStrings(signals);
return unique.length === 1 ? unique[0] : '';
}
function recordListingState(record, derived = null) {
const metaState = normalizeListingState(record?.meta?.listingState || record?.meta?.statusLabel);
const currentState = normalizeListingState(derived?.current?.listingState || derived?.current?.statusLabel);
if (metaState && currentState && metaState !== currentState) return '';
return currentState || metaState;
}
function shopKeyFromUrl(url) {
const routeShop = String(url || '').match(/\/your\/shops\/([^/]+)/i)?.[1]?.toLowerCase() || '';
if (routeShop && routeShop !== 'me') return `etsy-shop:${routeShop}`;
return currentShopKey();
}
function snapshotMetricIsCurrent(snapshot, field) {
if (!Number.isFinite(snapshot?.[field])) return false;
const capturedAt = validTime(snapshot.at);
const observedAt = validTime(snapshot.observedAt?.[field]);
if (capturedAt === null || observedAt === null || observedAt > capturedAt + 1000) return false;
return capturedAt - observedAt <= SNAPSHOT_OBSERVATION_MAX_SKEW_MS;
}
function snapshotFromListing(listing, capturedAt) {
const snapshot = {
at: capturedAt, day: dayKey(capturedAt), visits: finiteOrNull(listing.visits), favorites: finiteOrNull(listing.favorites),
sales: finiteOrNull(listing.sales), revenue: finiteOrNull(listing.revenue), renewals: finiteOrNull(listing.renewals), stock: finiteOrNull(listing.stock),
priceMin: finiteOrNull(listing.price?.min), priceMax: finiteOrNull(listing.price?.max), priceLabel: normalizeSpace(listing.price?.label),
currency: normalizeSpace(listing.currency), listingState: normalizeListingState(listing.listingState),
statusLabel: normalizeSpace(listing.statusLabel), renewalLabel: normalizeSpace(listing.renewalLabel), observedAt: {},
};
SNAPSHOT_NUMBER_FIELDS.forEach((field) => { if (Number.isFinite(snapshot[field])) snapshot.observedAt[field] = capturedAt; });
snapshot.quality = {
parserVersion: 3,
observedFields: HEALTH_METRIC_FIELDS.filter((field) => Number.isFinite(snapshot[field])),
missingFields: HEALTH_METRIC_FIELDS.filter((field) => !Number.isFinite(snapshot[field])),
capturedAt,
};
return snapshot;
}
function normalizeSnapshot(raw) {
if (!raw || typeof raw !== 'object' || validTime(raw.at) === null) return null;
const at = new Date(validTime(raw.at)).toISOString();
const snapshot = { ...raw, at, day: /^\d{4}-\d{2}-\d{2}$/.test(String(raw.day || '')) ? String(raw.day) : dayKey(at), observedAt: {} };
SNAPSHOT_NUMBER_FIELDS.forEach((field) => {
snapshot[field] = finiteOrNull(raw[field]);
const observed = validTime(raw.observedAt?.[field]);
if (Number.isFinite(snapshot[field])) snapshot.observedAt[field] = observed === null ? at : new Date(observed).toISOString();
});
snapshot.priceLabel = normalizeSpace(raw.priceLabel);
snapshot.currency = normalizeSpace(raw.currency);
snapshot.listingState = normalizeListingState(raw.listingState || raw.statusLabel);
snapshot.statusLabel = normalizeSpace(raw.statusLabel);
snapshot.renewalLabel = normalizeSpace(raw.renewalLabel);
const observedFields = HEALTH_METRIC_FIELDS.filter((field) => snapshotMetricIsCurrent(snapshot, field));
snapshot.quality = {
parserVersion: Number(raw.quality?.parserVersion) || 1,
observedFields,
missingFields: HEALTH_METRIC_FIELDS.filter((field) => !observedFields.includes(field)),
capturedAt: validTime(raw.quality?.capturedAt) === null ? at : new Date(validTime(raw.quality.capturedAt)).toISOString(),
};
return snapshot;
}
function mergeDailySnapshot(previousRaw, incomingRaw) {
const left = normalizeSnapshot(previousRaw); const right = normalizeSnapshot(incomingRaw);
if (!left) return right;
if (!right) return left;
if (left.day !== right.day) return validTime(right.at) >= validTime(left.at) ? right : left;
const [previous, incoming] = validTime(left.at) <= validTime(right.at) ? [left, right] : [right, left];
const merged = { ...previous, at: incoming.at, day: previous.day, observedAt: { ...previous.observedAt } };
SNAPSHOT_NUMBER_FIELDS.forEach((field) => {
if (Number.isFinite(incoming[field])) {
merged[field] = incoming[field];
merged.observedAt[field] = incoming.observedAt?.[field] || incoming.at;
} else {
merged[field] = null;
delete merged.observedAt[field];
}
});
['priceLabel', 'currency', 'listingState', 'statusLabel', 'renewalLabel'].forEach((field) => { if (normalizeSpace(incoming[field])) merged[field] = normalizeSpace(incoming[field]); });
const observedFields = HEALTH_METRIC_FIELDS.filter((field) => snapshotMetricIsCurrent(merged, field));
merged.quality = {
parserVersion: Math.max(Number(previous.quality?.parserVersion) || 1, Number(incoming.quality?.parserVersion) || 1),
observedFields,
missingFields: HEALTH_METRIC_FIELDS.filter((field) => !observedFields.includes(field)),
capturedAt: merged.at,
mergedCaptures: (Number(previous.quality?.mergedCaptures) || 1) + 1,
};
return merged;
}
function normalizeRecord(raw, expectedListingId = '') {
if (!raw || typeof raw !== 'object') return null;
const schema = Number(raw.schema ?? RECORD_SCHEMA_VERSION);
const listingId = String(raw.listingId || expectedListingId || '');
if (schema > RECORD_SCHEMA_VERSION) return { ...raw, listingId, unsupportedSchema: true };
const record = {
...raw, schema: RECORD_SCHEMA_VERSION, listingId,
meta: raw.meta && typeof raw.meta === 'object' ? { ...raw.meta } : {},
improvements: Array.isArray(raw.improvements) ? [...raw.improvements] : [],
proposal: raw.proposal && typeof raw.proposal === 'object' ? { ...raw.proposal } : null,
};
record.meta.shopKey = record.meta.shopKey || shopKeyFromUrl(record.meta.editUrl);
record.meta.listingState = normalizeListingState(record.meta.listingState || record.meta.statusLabel);
const quarantine = Array.isArray(raw.quarantine) ? [...raw.quarantine] : [];
const historySource = Array.isArray(raw.history) ? raw.history : [];
if (raw.history !== undefined && !Array.isArray(raw.history)) quarantine.push({ reason: 'invalid-history-container', value: raw.history });
const history = [];
historySource.forEach((item) => {
const snapshot = normalizeSnapshot(item);
if (!snapshot) { quarantine.push({ reason: 'invalid-snapshot', value: item }); return; }
const sameDay = history.findIndex((entry) => entry.day === snapshot.day);
if (sameDay >= 0) history[sameDay] = mergeDailySnapshot(history[sameDay], snapshot);
else history.push(snapshot);
});
record.history = history.sort((a, b) => String(a.at).localeCompare(String(b.at)));
if (quarantine.length) record.quarantine = quarantine.slice(-20);
if (raw.analysis && !raw.health && !raw.legacyAnalysis) record.legacyAnalysis = raw.analysis;
return record;
}
function recordObjectTime(value) {
if (!value || typeof value !== 'object') return 0;
const direct = ['updatedAt', 'appliedAt', 'publishedAt', 'completedAt', 'supersededAt', 'verifiedAt', 'capturedAt', 'lastSeenAt', 'startAt', 'evaluateAt', 'contaminatedAt', 'at'];
const nested = ['startAt', 'evaluateAt', 'contaminatedAt', 'completedAt'].map((key) => value.experiment?.[key]);
return Math.max(0, ...direct.map((key) => validTime(value[key]) || 0), ...nested.map((item) => validTime(item) || 0));
}
function mergeTimedObject(current, incoming) {
if (!current || typeof current !== 'object') return incoming && typeof incoming === 'object' ? { ...incoming } : incoming;
if (!incoming || typeof incoming !== 'object') return { ...current };
const incomingWins = recordObjectTime(incoming) >= recordObjectTime(current);
const older = incomingWins ? current : incoming;
const newer = incomingWins ? incoming : current;
return { ...older, ...newer };
}
function mergeRecordCopies(currentRaw, incomingRaw) {
const incoming = normalizeRecord(incomingRaw, incomingRaw?.listingId);
const current = normalizeRecord(currentRaw, incoming?.listingId);
if (!current) return incoming;
if (!incoming) return current;
if (current.unsupportedSchema || incoming.unsupportedSchema) throw new Error('A newer listing record schema cannot be merged.');
const merged = { ...current, ...incoming, schema: RECORD_SCHEMA_VERSION, listingId: incoming.listingId || current.listingId };
merged.meta = mergeTimedObject(current.meta, incoming.meta) || {};
merged.editor = mergeTimedObject(current.editor, incoming.editor);
merged.proposal = mergeTimedObject(current.proposal, incoming.proposal);
merged.deactivation = mergeTimedObject(current.deactivation, incoming.deactivation);
const history = [...(current.history || []), ...(incoming.history || [])]
.map(normalizeSnapshot).filter(Boolean).sort((left, right) => String(left.at).localeCompare(String(right.at)));
merged.history = [];
history.forEach((snapshot) => {
const sameDay = merged.history.findIndex((item) => item.day === snapshot.day);
if (sameDay >= 0) {
if (JSON.stringify(merged.history[sameDay]) !== JSON.stringify(snapshot)) merged.history[sameDay] = mergeDailySnapshot(merged.history[sameDay], snapshot);
} else merged.history.push(snapshot);
});
const cutoff = Date.now() - clamp(state.settings.retentionDays, 30, APP.retentionDays) * DAY_MS;
merged.history = merged.history.filter((snapshot) => (validTime(snapshot.at) || 0) >= cutoff)
.slice(-clamp(state.settings.maxSnapshots, 10, APP.maxSnapshots));
const improvements = new Map();
[...(current.improvements || []), ...(incoming.improvements || [])].forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return;
const key = String(entry.id || `legacy-${recordObjectTime(entry)}-${index}`);
const previous = improvements.get(key);
const combined = mergeTimedObject(previous, entry);
if (previous?.experiment || entry.experiment) combined.experiment = mergeTimedObject(previous?.experiment, entry.experiment);
improvements.set(key, combined);
});
merged.improvements = [...improvements.values()]
.sort((left, right) => recordObjectTime(left) - recordObjectTime(right))
.slice(-APP.maxImprovements);
const currentLatest = validTime(current.history?.at(-1)?.at) || 0;
const incomingLatest = validTime(incoming.history?.at(-1)?.at) || 0;
if (currentLatest > incomingLatest) {
merged.health = current.health;
merged.analysis = current.analysis;
}
const quarantine = [...(current.quarantine || []), ...(incoming.quarantine || [])];
if (quarantine.length) merged.quarantine = quarantine.slice(-20);
merged.revision = Math.max(Number(current.revision) || 0, Number(incoming.revision) || 0) + 1;
return merged;
}
function healthPolicy(settings = {}) {
const thresholds = {
minVisitsToImprove: clamp(settings.minVisitsToImprove ?? DEFAULT_SETTINGS.minVisitsToImprove, 0, 1000000),
minVisitsToProtect: clamp(settings.minVisitsToProtect ?? DEFAULT_SETTINGS.minVisitsToProtect, 1, 1000000),
minRenewalsToReview: clamp(settings.minRenewalsToReview ?? DEFAULT_SETTINGS.minRenewalsToReview, 0, 1000000),
declinePercent: clamp(settings.declinePercent ?? DEFAULT_SETTINGS.declinePercent, 1, 100),
minimumCohortSize: HEALTH_RULES.minimumCohortSize,
};
return { version: HEALTH_POLICY_VERSION, fingerprint: `hp${HEALTH_POLICY_VERSION}-${fnv1a(JSON.stringify(thresholds))}`, thresholds };
}
function recommendationBasis(record) {
return {
engineVersion: Number(record.health?.engineVersion) || HEALTH_ENGINE_VERSION,
policyFingerprint: record.health?.policy?.fingerprint || healthPolicy(state.settings).fingerprint,
latestAt: record.history?.at(-1)?.at || null,
};
}
function recommendationBasisMatches(record) {
const saved = record.proposal?.basis;
const current = recommendationBasis(record);
return Boolean(saved)
&& Number(saved.engineVersion) === Number(current.engineVersion)
&& saved.policyFingerprint === current.policyFingerprint
&& saved.latestAt === current.latestAt;
}
function findAnchor(history, current, targetDays) {
const tolerance = HEALTH_RULES.anchorToleranceDays[targetDays] ?? 7;
let best = null;
history.forEach((snapshot) => {
if (snapshot === current) return;
if (!HEALTH_METRIC_FIELDS.every((field) => Number.isFinite(snapshot?.[field]))) return;
const actualDays = daysBetween(current.at, snapshot.at);
if (!Number.isFinite(actualDays) || actualDays <= 0) return;
const distance = Math.abs(actualDays - targetDays);
if (!best || distance < best.distance || (distance === best.distance && actualDays < best.actualDays)) best = { snapshot, targetDays, actualDays, distance };
});
return best && best.distance <= tolerance ? { ...best, complete: true } : { snapshot: null, targetDays, actualDays: null, distance: best?.distance ?? null, complete: false };
}
function snapshotForAnalysis(raw) {
const snapshot = normalizeSnapshot(raw);
if (!snapshot) return null;
const staleObservedFields = [];
SNAPSHOT_NUMBER_FIELDS.forEach((field) => {
if (Number.isFinite(snapshot[field]) && !snapshotMetricIsCurrent(snapshot, field)) {
snapshot[field] = null;
staleObservedFields.push(field);
}
});
snapshot.staleObservedFields = staleObservedFields;
return snapshot;
}
function cumulativeWindow(current, anchor, field, targetDays) {
if (!anchor?.snapshot || !Number.isFinite(current?.[field]) || !Number.isFinite(anchor.snapshot[field])) return { raw: null, normalized: null };
const raw = current[field] - anchor.snapshot[field];
if (raw < 0) return { raw: null, normalized: null };
const normalized = anchor.actualDays > 0 ? Math.round(raw * (targetDays / anchor.actualDays) * 1000) / 1000 : null;
return { raw, normalized };
}
function inspectHistory(history, evaluatedAt) {
const anomalies = [];
history.forEach((snapshot, index) => {
(snapshot.staleObservedFields || []).forEach((field) => anomalies.push(`stale-observation-${field}`));
SNAPSHOT_NUMBER_FIELDS.forEach((field) => { if (Number.isFinite(snapshot[field]) && snapshot[field] < 0) anomalies.push(`negative-${field}`); });
if (snapshot.visits === 0 && Number(snapshot.favorites) > 0) anomalies.push('favorites-without-visits');
if (snapshot.sales === 0 && Number(snapshot.revenue) > 0) anomalies.push('revenue-without-sales');
if (daysBetween(snapshot.at, evaluatedAt) > 1) anomalies.push('future-snapshot');
if (index > 0) {
['sales', 'revenue', 'renewals'].forEach((field) => {
if (Number.isFinite(history[index - 1]?.[field]) && Number.isFinite(snapshot[field]) && snapshot[field] < history[index - 1][field]) anomalies.push(`cumulative-decrease-${field}`);
});
}
});
return uniqueStrings(anomalies);
}
function deriveRecordMetrics(record, evaluatedAt) {
const history = Array.isArray(record.history) ? record.history.map(snapshotForAnalysis).filter(Boolean).sort((a, b) => String(a.at).localeCompare(String(b.at))) : [];
const current = history.at(-1) || null;
if (!current) return { history, current: null, anchors: {}, anomalies: ['missing-history'], complete: false, snapshotCount: 0, completeSnapshotCount: 0, historySpanDays: 0 };
const anchors = { d30: findAnchor(history, current, 30), d60: findAnchor(history, current, 60), d90: findAnchor(history, current, 90) };
const sales30 = cumulativeWindow(current, anchors.d30, 'sales', 30);
const revenue30 = cumulativeWindow(current, anchors.d30, 'revenue', 30);
const renewals30 = cumulativeWindow(current, anchors.d30, 'renewals', 30);
const sales60 = cumulativeWindow(current, anchors.d60, 'sales', 60);
const revenue60 = cumulativeWindow(current, anchors.d60, 'revenue', 60);
const renewals60 = cumulativeWindow(current, anchors.d60, 'renewals', 60);
const trafficChangePercent = percentChange(current.visits, anchors.d30.snapshot?.visits);
const priorTrafficChangePercent = anchors.d30.snapshot && anchors.d60.snapshot ? percentChange(anchors.d30.snapshot.visits, anchors.d60.snapshot.visits) : null;
const completeSnapshots = history.filter((snapshot) => HEALTH_METRIC_FIELDS.every((field) => Number.isFinite(snapshot[field])));
const completeSnapshotCount = completeSnapshots.length;
const historySpanDays = Math.max(0, daysBetween(current.at, history[0]?.at) || 0);
const completeHistorySpanDays = Math.max(0, daysBetween(current.at, completeSnapshots[0]?.at) || 0);
return {
history, current, anchors, snapshotCount: history.length, completeSnapshotCount, historySpanDays, completeHistorySpanDays,
complete: HEALTH_METRIC_FIELDS.every((field) => Number.isFinite(current[field])),
observedMetrics: HEALTH_METRIC_FIELDS.filter((field) => Number.isFinite(current[field])),
missingMetrics: HEALTH_METRIC_FIELDS.filter((field) => !Number.isFinite(current[field])),
anomalies: inspectHistory(history, evaluatedAt), freshnessDays: Math.max(0, daysBetween(evaluatedAt, current.at) || 0),
visits30: current.visits, favorites30: current.favorites,
favoriteRate: safeRatio(current.favorites, current.visits, 100),
salesRateProxy: safeRatio(sales30.normalized, current.visits, 100),
revenuePerVisitProxy: safeRatio(revenue30.normalized, current.visits),
sales30: sales30.normalized, sales30Raw: sales30.raw, revenue30: revenue30.normalized, revenue30Raw: revenue30.raw, renewals30: renewals30.normalized, renewals30Raw: renewals30.raw,
sales60: sales60.normalized, sales60Raw: sales60.raw, revenue60: revenue60.normalized, revenue60Raw: revenue60.raw, renewals60: renewals60.normalized, renewals60Raw: renewals60.raw,
trafficChangePercent, priorTrafficChangePercent,
deltas: {
visits: anchors.d30.snapshot && Number.isFinite(current.visits) && Number.isFinite(anchors.d30.snapshot.visits) ? current.visits - anchors.d30.snapshot.visits : null,
favorites: anchors.d30.snapshot && Number.isFinite(current.favorites) && Number.isFinite(anchors.d30.snapshot.favorites) ? current.favorites - anchors.d30.snapshot.favorites : null,
sales: sales30.raw, revenue: revenue30.raw, renewals: renewals30.raw,
},
};
}
function quantile(values, percentile) {
const sorted = values.map(finiteOrNull).filter(Number.isFinite).sort((a, b) => a - b);
if (!sorted.length) return null;
const index = (sorted.length - 1) * percentile;
const lower = Math.floor(index); const upper = Math.ceil(index);
return lower === upper ? sorted[lower] : sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower);
}
function activeExperiment(record, evaluatedAt) {
const entries = Array.isArray(record.improvements) ? record.improvements : [];
return [...entries].reverse().find((entry) => entry?.action === 'UPDATE' && entry?.status === 'published' && entry?.experiment?.state === 'observing' && (!entry.experiment.evaluateAt || validTime(entry.experiment.evaluateAt) >= validTime(evaluatedAt))) || null;
}
function buildCohortBenchmark(record, derivations, records, evaluatedAt, policy) {
const target = derivations.get(record.listingId);
const targetShop = record.meta?.shopKey || shopKeyFromUrl(record.meta?.editUrl);
let cohort = records.filter((candidate) => {
const derived = derivations.get(candidate.listingId);
if (!/^etsy-shop:[a-z0-9]+$/.test(targetShop)) return false;
if (!derived?.complete || recordListingState(candidate, derived) !== 'active') return false;
if (derived.freshnessDays > 1 || derived.anomalies?.length) return false;
if (!Number.isFinite(derived.current?.stock) || derived.current.stock <= 0) return false;
if ((candidate.meta?.shopKey || shopKeyFromUrl(candidate.meta?.editUrl)) !== targetShop) return false;
if (activeExperiment(candidate, evaluatedAt)) return false;
const recentChange = [...(candidate.improvements || [])].reverse().find((entry) => entry?.publishedAt && daysBetween(evaluatedAt, entry.publishedAt) <= HEALTH_RULES.improvementCooldownDays);
return !recentChange;
});
const targetPrice = finiteOrNull(target?.current?.priceMin);
let priceBandApplied = false;
if (targetPrice !== null) {
const priceBand = cohort.filter((candidate) => {
const price = finiteOrNull(derivations.get(candidate.listingId)?.current?.priceMin);
return price !== null && price >= targetPrice * 0.7 && price <= targetPrice * 1.3;
});
if (priceBand.length >= policy.thresholds.minimumCohortSize) {
cohort = priceBand;
priceBandApplied = true;
}
}
const metricNames = ['visits30', 'favoriteRate', 'salesRateProxy', 'revenuePerVisitProxy', 'sales30'];
const metrics = {};
metricNames.forEach((name) => {
const values = cohort.map((candidate) => derivations.get(candidate.listingId)?.[name]).filter(Number.isFinite);
metrics[name] = {
median: median(values), p25: quantile(values, 0.25), p75: quantile(values, 0.75),
percentile: percentileRank(values, target?.[name]), samples: values.length,
reliable: values.length >= policy.thresholds.minimumCohortSize,
};
});
const reliable = cohort.length >= policy.thresholds.minimumCohortSize;
return { size: cohort.length, reliable, scope: priceBandApplied ? 'shop-price-band' : 'shop', metrics };
}
function bootstrapMetricScore(metric, absoluteScore) {
const absolute = clamp(Math.round(Number(absoluteScore) || 0), 0, 100);
if (absolute === 0) return 0;
const percentile = metric?.reliable ? finiteOrNull(metric.percentile) : null;
return percentile === null ? absolute : clamp(Math.round(percentile * 0.65 + absolute * 0.35), 0, 100);
}
function bootstrapAssessment(record, derived, benchmark, policy) {
const unavailable = {
available: false, signal: 'INSUFFICIENT', diagnosis: 'INSUFFICIENT_SIGNAL', code: 'waiting', reasonKey: 'reasonNew',
score: null, priority: 99, source: 'none', components: {},
};
if (!derived?.complete || !derived.current || derived.freshnessDays > 1 || derived.anomalies?.length
|| recordListingState(record, derived) !== 'active' || !Number.isFinite(derived.current.stock) || derived.current.stock <= 0) return unavailable;
const current = derived.current;
const visits = Number(current.visits); const favorites = Number(current.favorites);
const sales = Number(current.sales); const revenue = Number(current.revenue); const renewals = Number(current.renewals);
const minVisits = Math.max(1, Number(policy.thresholds.minVisitsToImprove) || 1);
const minRenewals = HEALTH_RULES.renewalWasteMinimum;
const protectVisits = Math.max(minVisits, Number(policy.thresholds.minVisitsToProtect) || minVisits);
const visibilityAbsolute = clamp((visits / protectVisits) * 100, 0, 100);
const engagementEligible = visits >= minVisits;
const engagementAbsolute = engagementEligible ? clamp((Number(derived.favoriteRate) / 5) * 100, 0, 100) : null;
const visibility = bootstrapMetricScore(benchmark.metrics?.visits30, visibilityAbsolute);
const engagement = engagementEligible ? bootstrapMetricScore(benchmark.metrics?.favoriteRate, engagementAbsolute) : null;
const weighted = [{ value: visibility, weight: 65 }];
if (engagement !== null) weighted.push({ value: engagement, weight: 35 });
const weight = weighted.reduce((sum, item) => sum + item.weight, 0);
const score = weight ? clamp(Math.round(weighted.reduce((sum, item) => sum + item.value * item.weight, 0) / weight), 0, 100) : null;
const noDemand = sales === 0 && revenue === 0;
const provenDemand = sales > 0 || revenue > 0;
const renewalMature = renewals >= minRenewals;
const lowDiscovery = visits < minVisits || visibility <= 25;
const weakEngagement = engagementEligible && (Number(derived.favoriteRate) < 3 || engagement <= 25);
let funnelSignal = 'MIXED'; let diagnosis = 'HEALTHY_OR_MIXED';
if (visits === 0 && favorites === 0) { funnelSignal = 'NO_ACTIVITY'; diagnosis = 'DISCOVERY_WEAK'; }
else if (lowDiscovery) { funnelSignal = 'WEAK_DISCOVERY'; diagnosis = 'DISCOVERY_WEAK'; }
else if (weakEngagement) { funnelSignal = 'WEAK_ENGAGEMENT'; diagnosis = 'ENGAGEMENT_WEAK'; }
else if (Number(score) >= 70) funnelSignal = 'STRONG_CURRENT';
const cumulativeSignal = provenDemand ? 'PROVEN_DEMAND' : noDemand && renewalMature && favorites === 0 ? 'RENEWAL_WASTE' : noDemand ? 'NO_DEMAND' : null;
let signal = funnelSignal; let code = 'monitor'; let reasonKey = funnelSignal === 'NO_ACTIVITY' ? 'reasonSnapshotNoActivity'
: funnelSignal === 'WEAK_DISCOVERY' ? 'reasonSnapshotDiscovery'
: funnelSignal === 'WEAK_ENGAGEMENT' ? 'reasonSnapshotEngagement'
: funnelSignal === 'STRONG_CURRENT' ? 'reasonSnapshotStrong' : 'reasonSnapshotMixed';
let priority = funnelSignal === 'STRONG_CURRENT' ? 4 : 5;
if (provenDemand) {
code = 'protected'; reasonKey = 'reasonSnapshotDemand'; priority = 6;
} else if (noDemand && renewalMature && favorites === 0) {
signal = 'RENEWAL_WASTE'; diagnosis = lowDiscovery ? 'DISCOVERY_WEAK' : 'ENGAGEMENT_WEAK'; code = 'improve'; reasonKey = 'reasonSnapshotRenewalWaste'; priority = 0;
} else if (funnelSignal === 'WEAK_DISCOVERY') {
code = 'improve'; priority = 1;
} else if (funnelSignal === 'WEAK_ENGAGEMENT') {
code = 'improve'; priority = 2;
} else if (noDemand && (favorites > 0 || engagementEligible || renewalMature)) {
signal = 'PURCHASE_FRICTION'; diagnosis = 'PURCHASE_FRICTION'; code = 'improve'; reasonKey = 'reasonSnapshotPurchase'; priority = 3;
}
const severity = signal === 'RENEWAL_WASTE' ? renewals
: signal === 'PURCHASE_FRICTION' ? score
: code === 'improve' ? 100 - score : 0;
return {
available: true, signal, funnelSignal, cumulativeSignal, diagnosis, code, reasonKey, score, priority, severity,
source: benchmark.metrics?.visits30?.reliable ? 'shop-cohort' : 'absolute-thresholds',
components: { visibility, engagement },
evidence: { visits, favorites, sales, revenue, renewals },
};
}
function confidenceFor(derived, benchmark, policy) {
const completeness = Math.round((derived.observedMetrics?.length || 0) / HEALTH_METRIC_FIELDS.length * 100);
const historyDepth = Math.round(Math.min(100, (Math.min(derived.completeHistorySpanDays || 0, 60) / 60) * 70 + (Math.min(derived.completeSnapshotCount || 0, 3) / 3) * 30));
const zeroTrafficSnapshots = (derived.history || []).filter((snapshot) => HEALTH_METRIC_FIELDS.every((field) => Number.isFinite(snapshot?.[field])) && snapshot.visits === 0);
const repeatedZeroEvidence = derived.current?.visits === 0 && zeroTrafficSnapshots.some((left, index) => zeroTrafficSnapshots.slice(index + 1)
.some((right) => Math.abs(daysBetween(right.at, left.at) || 0) >= HEALTH_RULES.deactivationZeroObservationGapDays));
const trafficSample = repeatedZeroEvidence ? 100 : Math.round(Math.min(100, ((finiteOrNull(derived.current?.visits) || 0) / Math.max(1, policy.thresholds.minVisitsToProtect)) * 100));
const comparableSamples = Number(benchmark.metrics?.visits30?.samples) || 0;
const cohortStrength = Math.round(Math.min(100, (comparableSamples / HEALTH_RULES.minimumCohortSize) * 100));
const freshness = derived.freshnessDays <= 1 ? 100 : derived.freshnessDays <= 7 ? 75 : derived.freshnessDays <= 30 ? 35 : 0;
const integrity = Math.max(0, 100 - (derived.anomalies?.length || 0) * 25);
const components = { dataQuality: completeness, historyDepth, trafficSample, cohortStrength, freshness, dataIntegrity: integrity };
let score = Math.round(completeness * 0.25 + historyDepth * 0.25 + trafficSample * 0.15 + cohortStrength * 0.10 + freshness * 0.10 + integrity * 0.15);
const caps = [];
if (!derived.complete) { score = Math.min(score, 39); caps.push('missing-metrics'); }
if (!derived.anchors?.d30?.complete) { score = Math.min(score, 39); caps.push('insufficient-30-day-history'); }
else if (!derived.anchors?.d60?.complete || derived.completeSnapshotCount < 3) { score = Math.min(score, 69); caps.push('insufficient-60-day-history'); }
if ((derived.anomalies || []).some((item) => /future|cumulative-decrease|favorites-without|revenue-without-sales|stale-observation|negative-/.test(item))) { score = Math.min(score, 39); caps.push('data-integrity'); }
const band = score < 40 ? 'low' : score < 70 ? 'medium' : score < 85 ? 'high' : 'veryHigh';
return { score, band, components, caps };
}
function diagnosePerformance(derived, benchmark, policy, confidence) {
if (!derived.complete || confidence.score < 40) return 'INSUFFICIENT_SIGNAL';
const visits = derived.visits30; const favoriteRate = derived.favoriteRate; const sales = derived.sales30;
if (benchmark.reliable) {
const visitsRank = benchmark.metrics.visits30.reliable ? benchmark.metrics.visits30.percentile : null;
const favoriteRank = benchmark.metrics.favoriteRate.reliable ? benchmark.metrics.favoriteRate.percentile : null;
const salesRank = benchmark.metrics.salesRateProxy.reliable ? benchmark.metrics.salesRateProxy.percentile : null;
if (Number.isFinite(visitsRank) && visitsRank <= 25) return 'DISCOVERY_WEAK';
if (Number.isFinite(favoriteRank) && favoriteRank <= 25 && visitsRank >= 40) return 'ENGAGEMENT_WEAK';
if (((Number.isFinite(sales) && sales === 0) || (Number.isFinite(salesRank) && salesRank <= 25)) && favoriteRank >= 50 && visitsRank >= 40) return 'PURCHASE_FRICTION';
if (Number.isFinite(sales) && sales > 0 && salesRank >= 50 && visitsRank < 50) return 'SCALE_DISCOVERY';
}
if (Number(visits) < policy.thresholds.minVisitsToImprove) return 'DISCOVERY_WEAK';
if (Number.isFinite(favoriteRate) && favoriteRate < 3) return 'ENGAGEMENT_WEAK';
if (Number.isFinite(sales) && sales === 0 && Number(favoriteRate) >= 5) return 'PURCHASE_FRICTION';
if (Number.isFinite(sales) && sales > 0 && Number(visits) < policy.thresholds.minVisitsToProtect) return 'SCALE_DISCOVERY';
return 'HEALTHY_OR_MIXED';
}
function deactivationSafeguards(record, derived, confidence, evaluatedAt, policy) {
const within60 = derived.history.filter((snapshot) => {
const age = daysBetween(derived.current.at, snapshot.at);
return Number.isFinite(age) && age >= 0 && age <= HEALTH_RULES.deactivationHistoryDays + 2;
});
const zeroObservations = within60.filter((snapshot) => snapshot.visits === 0 && snapshot.favorites === 0 && snapshot.sales === 0 && snapshot.revenue === 0);
const currentZeroObservation = derived.current.visits === 0 && derived.current.favorites === 0 && derived.current.sales === 0 && derived.current.revenue === 0;
const separatedZeros = zeroObservations.some((left, index) => zeroObservations.slice(index + 1).some((right) => Math.abs(daysBetween(right.at, left.at) || 0) >= HEALTH_RULES.deactivationZeroObservationGapDays));
const recentImprovement = [...(record.improvements || [])].reverse().find((entry) => entry?.publishedAt && daysBetween(evaluatedAt, entry.publishedAt) <= HEALTH_RULES.improvementCooldownDays);
const experiment = activeExperiment(record, evaluatedAt);
const checks = [
{ key: 'guardHistory', passed: derived.completeHistorySpanDays >= 58 && derived.completeSnapshotCount >= 3 },
{ key: 'guardZeroTraffic', passed: currentZeroObservation && zeroObservations.length >= 2 && separatedZeros },
{ key: 'guardNoSales', passed: derived.sales60Raw === 0 && derived.revenue60Raw === 0 && derived.current.sales === 0 && derived.current.revenue === 0 },
{ key: 'guardRenewals', passed: Number(derived.renewals60Raw) >= 1 && Number(derived.current.renewals) >= policy.thresholds.minRenewalsToReview },
{ key: 'guardActiveStock', passed: recordListingState(record, derived) === 'active' && Number(derived.current.stock) > 0 },
{ key: 'guardNoExperiment', passed: !experiment },
{ key: 'guardCooldown', passed: !recentImprovement },
{ key: 'guardSeasonal', passed: record.meta?.seasonal === false },
{ key: 'guardConfidence', passed: confidence.score >= 80 },
];
return { checks, passed: checks.every((item) => item.passed), zeroObservationCount: zeroObservations.length };
}
function evidenceFor(derived, benchmark, bootstrap = null) {
const evidence = [{ key: 'evidenceHistory', params: { days: Math.round(derived.historySpanDays || 0), count: derived.snapshotCount || 0 } }];
if (derived.current) {
evidence.push({ key: 'evidenceCurrentCounters', params: {
visits: derived.current.visits ?? '—', favorites: derived.current.favorites ?? '—', sales: derived.current.sales ?? '—',
revenue: derived.current.revenue ?? '—', renewals: derived.current.renewals ?? '—',
} });
}
if (bootstrap?.available) evidence.push({
key: bootstrap.source === 'shop-cohort' ? 'evidenceSnapshotScoreCohort' : 'evidenceSnapshotScoreAbsolute',
params: { score: bootstrap.score ?? '—' },
});
if (derived.anchors?.d30?.complete) {
evidence.push(
{ key: 'evidenceTraffic', params: { current: derived.current?.visits ?? '—', previous: derived.anchors?.d30?.snapshot?.visits ?? '—', percent: derived.trafficChangePercent ?? '—' } },
{ key: 'evidenceRecentSales', params: { sales: derived.sales30 ?? '—', revenue: derived.revenue30 ?? '—' } },
);
}
if (benchmark.size) evidence.push({ key: 'evidenceCohort', params: { size: benchmark.size, percentile: benchmark.metrics.visits30.percentile ?? '—' } });
if (derived.anomalies?.length) evidence.push({ key: 'evidenceAnomaly', params: { count: derived.anomalies.length } });
return evidence;
}
function classifyHealth(record, derived, benchmark, policy, evaluatedAt) {
const confidence = confidenceFor(derived, benchmark, policy);
const bootstrap = bootstrapAssessment(record, derived, benchmark, policy);
const listingState = recordListingState(record, derived);
const assessmentEligible = Boolean(derived.complete && derived.current && derived.freshnessDays <= 1
&& !confidence.caps.includes('data-integrity') && listingState === 'active'
&& Number.isFinite(derived.current.stock) && derived.current.stock > 0);
const longitudinalReady = Boolean(assessmentEligible && derived.anchors?.d30?.complete);
const diagnosis = longitudinalReady ? diagnosePerformance(derived, benchmark, policy, confidence) : bootstrap.diagnosis;
const deactivation = derived.current ? deactivationSafeguards(record, derived, confidence, evaluatedAt, policy) : { checks: [], passed: false };
const experiment = activeExperiment(record, evaluatedAt);
const inactive = Boolean(listingState && listingState !== 'active');
const persistentGrowth = Number(derived.trafficChangePercent) >= 15 && Number(derived.priorTrafficChangePercent) >= 5;
const persistentDecline = Number(derived.trafficChangePercent) <= -policy.thresholds.declinePercent && Number(derived.priorTrafficChangePercent) <= -10;
const recentStrength = Number(derived.sales30) >= 2 || Number(derived.salesRateProxy) >= 2 || Number(derived.revenue30) > 0;
let lifecycle = 'ACTIVE_STABLE';
if (record.unsupportedSchema || !derived.current || !derived.complete || !listingState || confidence.caps.includes('data-integrity')) lifecycle = 'DATA_GAP';
else if (inactive) lifecycle = 'INACTIVE';
else if (experiment) lifecycle = 'EXPERIMENT_RUNNING';
else if (deactivation.passed) lifecycle = 'DEACTIVATION_REVIEW';
else if (derived.snapshotCount === 1) lifecycle = 'BASELINE';
else if (!derived.anchors.d30.complete) lifecycle = 'LEARNING';
else if (confidence.score < 40 || diagnosis === 'INSUFFICIENT_SIGNAL') lifecycle = 'DATA_GAP';
else if (derived.current.visits === 0 && derived.current.favorites === 0
&& derived.anchors.d60.complete && derived.sales60Raw === 0 && derived.revenue60Raw === 0) lifecycle = 'DORMANT';
else if (persistentGrowth) lifecycle = 'ACTIVE_GROWING';
else if (persistentDecline && recentStrength) lifecycle = 'PROTECTED';
else if (persistentDecline) lifecycle = 'ACTIVE_DECLINING';
else if (recentStrength) lifecycle = 'PROTECTED';
const weakDiagnoses = new Set(['DISCOVERY_WEAK', 'ENGAGEMENT_WEAK', 'PURCHASE_FRICTION']);
const legacyByLifecycle = {
DATA_GAP: 'waiting', BASELINE: 'waiting', LEARNING: 'waiting', ACTIVE_GROWING: 'growing', ACTIVE_DECLINING: 'declining',
PROTECTED: 'protected', EXPERIMENT_RUNNING: 'monitor', DORMANT: 'improve', DEACTIVATION_REVIEW: 'deactivateReview', INACTIVE: 'monitor', ACTIVE_STABLE: weakDiagnoses.has(diagnosis) ? 'improve' : 'monitor',
};
const bootstrapLifecycle = ['BASELINE', 'LEARNING'].includes(lifecycle) && bootstrap.available;
const code = bootstrapLifecycle ? bootstrap.code : legacyByLifecycle[lifecycle] || 'waiting';
let tone = {
ACTIVE_GROWING: 'success', PROTECTED: 'success', ACTIVE_STABLE: 'balanced', EXPERIMENT_RUNNING: 'balanced',
ACTIVE_DECLINING: 'danger', DEACTIVATION_REVIEW: 'danger', DORMANT: 'warning',
}[lifecycle] || 'neutral';
if (bootstrapLifecycle) {
const currentProblem = ['DISCOVERY_WEAK', 'ENGAGEMENT_WEAK', 'PURCHASE_FRICTION'].includes(bootstrap.diagnosis);
tone = currentProblem ? 'warning' : bootstrap.funnelSignal === 'STRONG_CURRENT' ? 'success' : 'balanced';
}
const reasonKey = bootstrapLifecycle ? bootstrap.reasonKey : ({
DATA_GAP: 'reasonUnknown', BASELINE: 'reasonNew', LEARNING: 'reasonLearning', ACTIVE_GROWING: 'reasonGrowth', ACTIVE_DECLINING: 'reasonDecline',
PROTECTED: 'reasonProtect', EXPERIMENT_RUNNING: 'reasonExperiment', DORMANT: 'reasonDormant', DEACTIVATION_REVIEW: 'reasonDeactivateSafe', INACTIVE: 'reasonInactive', ACTIVE_STABLE: weakDiagnoses.has(diagnosis) ? 'reasonImproveTraffic' : 'reasonMonitor',
}[lifecycle] || 'reasonMonitor');
const nextReviewAt = lifecycle === 'EXPERIMENT_RUNNING' ? experiment.experiment?.evaluateAt : addDays(evaluatedAt, lifecycle === 'DEACTIVATION_REVIEW' ? 7 : lifecycle === 'DATA_GAP' ? 1 : 30);
const assessmentMode = longitudinalReady ? 'longitudinal' : bootstrap.available ? 'snapshot' : 'insufficient';
const currentAssessment = assessmentEligible && bootstrap.available ? bootstrap : null;
const score = currentAssessment?.score ?? null;
const deactivationHistoryReady = Boolean(assessmentEligible && derived.anchors?.d60?.complete
&& deactivation.checks.find((item) => item.key === 'guardHistory')?.passed);
return {
algorithmVersion: HEALTH_ENGINE_VERSION, lifecycle, diagnosis, code, tone, reasonKey,
reasonParams: { renewals: bootstrapLifecycle ? bootstrap.evidence.renewals : policy.thresholds.minRenewalsToReview, percent: policy.thresholds.declinePercent },
score, assessmentMode, scoreBasis: currentAssessment ? 'current-30d-reach-engagement' : 'insufficient',
currentAssessment, bootstrap: assessmentMode === 'snapshot' ? bootstrap : null,
readiness: { snapshot: bootstrap.available, trend: longitudinalReady, deactivationHistory: deactivationHistoryReady },
confidence: confidence.score, confidenceBand: confidence.band, confidenceComponents: confidence.components, confidenceCaps: confidence.caps,
derived: {
visits30: derived.visits30 ?? null, favorites30: derived.favorites30 ?? null, favoriteRate: derived.favoriteRate ?? null,
sales30: derived.sales30 ?? null, revenue30: derived.revenue30 ?? null, renewals30: derived.renewals30 ?? null,
current: derived.current ? Object.fromEntries(HEALTH_METRIC_FIELDS.map((field) => [field, derived.current[field] ?? null])) : null,
sales60: derived.sales60 ?? null, revenue60: derived.revenue60 ?? null, renewals60: derived.renewals60 ?? null,
salesRateProxy: derived.salesRateProxy ?? null, revenuePerVisitProxy: derived.revenuePerVisitProxy ?? null,
trafficChangePercent: derived.trafficChangePercent ?? null, previousTrafficChangePercent: derived.priorTrafficChangePercent ?? null,
historySpanDays: Math.round(derived.historySpanDays || 0), completeHistorySpanDays: Math.round(derived.completeHistorySpanDays || 0), snapshotCount: derived.snapshotCount || 0,
anchors: Object.fromEntries(Object.entries(derived.anchors || {}).map(([key, anchor]) => [key, anchor?.snapshot ? { at: anchor.snapshot.at, actualDays: Math.round(anchor.actualDays * 10) / 10 } : null])),
},
benchmark, evidence: evidenceFor(derived, benchmark, bootstrap), safeguards: deactivation.checks, anomalies: derived.anomalies || [], experiment: experiment?.experiment || null,
deltas: derived.deltas || {}, declinePercent: Number(derived.trafficChangePercent) < 0 ? Math.abs(Math.round(derived.trafficChangePercent)) : 0,
nextReviewAt, calculatedAt: evaluatedAt,
};
}
function evaluateHealthRecords(records, settings = DEFAULT_SETTINGS, evaluatedAt = nowIso()) {
const policy = healthPolicy(settings);
const normalized = records.map((record) => normalizeRecord(record, record?.listingId)).filter(Boolean);
const derivations = new Map(normalized.map((record) => [record.listingId, deriveRecordMetrics(record, evaluatedAt)]));
const output = new Map();
normalized.forEach((record) => {
const derived = derivations.get(record.listingId);
const benchmark = buildCohortBenchmark(record, derivations, normalized, evaluatedAt, policy);
const result = classifyHealth(record, derived, benchmark, policy, evaluatedAt);
output.set(record.listingId, {
schemaVersion: HEALTH_RESULT_SCHEMA_VERSION, engineVersion: HEALTH_ENGINE_VERSION,
policy, input: { latestAt: derived.current?.at || null, anchor30At: derived.anchors?.d30?.snapshot?.at || null, anchor60At: derived.anchors?.d60?.snapshot?.at || null, observedMetrics: derived.observedMetrics || [] },
result, calculatedAt: evaluatedAt,
});
});
return output;
}
function analyseRecord(record, allRecords = [record], evaluatedAt = nowIso()) {
return evaluateHealthRecords(allRecords?.length ? allRecords : [record], state.settings, evaluatedAt).get(String(record.listingId))?.result || {
algorithmVersion: HEALTH_ENGINE_VERSION, lifecycle: 'DATA_GAP', diagnosis: 'INSUFFICIENT_SIGNAL', code: 'waiting', tone: 'neutral', reasonKey: 'reasonUnknown', deltas: {}, confidence: 0, confidenceBand: 'low', calculatedAt: evaluatedAt,
};
}
function analysisLabel(code) {
return t({ growing: 'growing', protected: 'protected', improve: 'improve', deactivateReview: 'deactivateReview', declining: 'declining', monitor: 'monitor' }[code] || 'waiting');
}
function lifecycleLabel(lifecycle) {
return t({ DATA_GAP: 'lifecycleDataGap', BASELINE: 'lifecycleBaseline', LEARNING: 'lifecycleLearning', ACTIVE_STABLE: 'lifecycleStable', ACTIVE_GROWING: 'lifecycleGrowing', ACTIVE_DECLINING: 'lifecycleDeclining', PROTECTED: 'lifecycleProtected', EXPERIMENT_RUNNING: 'lifecycleExperiment', DORMANT: 'lifecycleDormant', DEACTIVATION_REVIEW: 'lifecycleDeactivate', INACTIVE: 'lifecycleInactive' }[lifecycle] || 'lifecycleDataGap');
}
function diagnosisLabel(diagnosis) {
return t({ DISCOVERY_WEAK: 'diagnosisDiscovery', ENGAGEMENT_WEAK: 'diagnosisEngagement', PURCHASE_FRICTION: 'diagnosisPurchase', SCALE_DISCOVERY: 'diagnosisScale', HEALTHY_OR_MIXED: 'diagnosisHealthy', INSUFFICIENT_SIGNAL: 'diagnosisInsufficient' }[diagnosis] || 'diagnosisInsufficient');
}
function assessmentLabel(analysis) {
if (analysis?.assessmentMode !== 'snapshot') return diagnosisLabel(analysis?.diagnosis);
return t({
RENEWAL_WASTE: 'snapshotRenewalWaste', WEAK_DISCOVERY: 'snapshotWeakDiscovery', WEAK_ENGAGEMENT: 'snapshotWeakEngagement',
PURCHASE_FRICTION: 'snapshotPurchaseFriction', PROVEN_DEMAND: 'snapshotProvenDemand', STRONG_CURRENT: 'snapshotStrongCurrent',
MIXED: 'snapshotMixed', NO_ACTIVITY: 'snapshotNoActivity', INSUFFICIENT: 'snapshotInsufficient',
}[analysis.bootstrap?.signal] || 'snapshotInsufficient');
}
function analysisScoreLabel(analysis) {
return t('currentFunnelScore');
}
function analysisConfidenceLabel(analysis) {
return t(analysis?.assessmentMode === 'snapshot' ? 'historyConfidence' : 'analysisConfidence');
}
function confidenceLabel(band) { return t({ low: 'confidenceLow', medium: 'confidenceMedium', high: 'confidenceHigh', veryHigh: 'confidenceVeryHigh' }[band] || 'confidenceLow'); }
function normalizeAnalysisFilters(raw) {
const source = raw && typeof raw === 'object' ? raw : {};
const allowed = {
scope: ['all', 'page'],
lifecycle: ['', 'DATA_GAP', 'BASELINE', 'LEARNING', 'ACTIVE_STABLE', 'ACTIVE_GROWING', 'ACTIVE_DECLINING', 'PROTECTED', 'EXPERIMENT_RUNNING', 'DORMANT', 'DEACTIVATION_REVIEW', 'INACTIVE'],
diagnosis: ['', 'DISCOVERY_WEAK', 'ENGAGEMENT_WEAK', 'PURCHASE_FRICTION', 'SCALE_DISCOVERY', 'HEALTHY_OR_MIXED', 'INSUFFICIENT_SIGNAL'],
recommendation: ['', 'improve', 'declining', 'deactivateReview', 'protected', 'monitor', 'waiting', 'growing'],
performance: ['', 'sales', 'traffic-no-sales', 'no-activity', 'missing'],
trend: ['', 'rising', 'falling', 'stable', 'unknown'],
stock: ['', 'in', 'out', 'unknown'],
confidence: ['', 'low', 'medium', 'high'],
sort: ['priority', 'score', 'visits', 'sales', 'revenue', 'confidence', 'title'],
};
return Object.fromEntries(Object.entries(DEFAULT_ANALYSIS_FILTERS).map(([key, fallback]) => {
const value = String(source[key] ?? fallback);
return [key, allowed[key].includes(value) ? value : fallback];
}));
}
function normalizeFilterPresets(raw) {
const source = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : [];
const names = new Set();
return source.map((item) => {
if (!item || typeof item !== 'object') return null;
const name = normalizeSpace(item.name).slice(0, 32);
const folded = name.toLocaleLowerCase();
if (name.length < 2 || names.has(folded)) return null;
names.add(folded);
const legacy = item.filters && typeof item.filters === 'object' ? item.filters
: item.filter && typeof item.filter === 'object' ? item.filter
: item.criteria && typeof item.criteria === 'object' ? item.criteria : {};
const recommendation = String(legacy.recommendation || item.recommendation || '');
const lifecycleAliases = {
growing: 'ACTIVE_GROWING', declining: 'ACTIVE_DECLINING', deactivateReview: 'DEACTIVATION_REVIEW',
protected: 'PROTECTED', experiments: 'EXPERIMENT_RUNNING', missing: 'DATA_GAP',
};
const migratedFilters = {
...legacy,
scope: legacy.scope ?? legacy.range,
lifecycle: legacy.lifecycle ?? legacy.status ?? lifecycleAliases[recommendation],
diagnosis: legacy.diagnosis ?? legacy.issue,
recommendation: legacy.recommendation ?? recommendation,
performance: legacy.performance,
trend: legacy.trend ?? legacy.change,
stock: legacy.stock ?? legacy.availability,
confidence: legacy.confidence ?? legacy.dataConfidence,
sort: legacy.sort ?? legacy.order,
};
return {
id: /^preset-[a-z0-9-]{6,80}$/i.test(String(item.id || '')) ? String(item.id) : randomId('preset'),
name,
filters: normalizeAnalysisFilters(migratedFilters),
query: normalizeSpace(item.query).slice(0, 120) || normalizeSpace(item.search).slice(0, 120),
createdAt: validTime(item.createdAt) ? String(item.createdAt) : nowIso(),
updatedAt: validTime(item.updatedAt) ? String(item.updatedAt) : nowIso(),
};
}).filter(Boolean).slice(-MAX_FILTER_PRESETS);
}
function analysisFilterCount(filters = state.analysisFilters) {
const normalized = normalizeAnalysisFilters(filters);
return ['scope', 'lifecycle', 'diagnosis', 'recommendation', 'performance', 'trend', 'stock', 'confidence']
.filter((key) => normalized[key] !== DEFAULT_ANALYSIS_FILTERS[key]).length;
}
function recordTrendDirection(record) {
const analysis = record.analysis || record.health?.result || {};
if (analysis.lifecycle === 'ACTIVE_GROWING') return 'rising';
if (analysis.lifecycle === 'ACTIVE_DECLINING') return 'falling';
const deltas = analysis.deltas || {};
const weighted = [['sales', 4], ['revenue', 4], ['visits', 2], ['favorites', 2]]
.map(([key, weight]) => {
const value = finiteOrNull(deltas[key]);
return value === null ? null : Math.sign(value) * weight;
})
.filter(Number.isFinite);
if (!weighted.length) return 'unknown';
const total = weighted.reduce((sum, value) => sum + value, 0);
if (total > 0) return 'rising';
if (total < 0) return 'falling';
return 'stable';
}
function recentPerformanceMetrics(record) {
const derived = record?.analysis?.derived || record?.health?.result?.derived || {};
const latest = record?.history?.at(-1) || {};
const recentOrGuaranteedZero = (recent, cumulative) => {
const value = finiteOrNull(recent);
if (value !== null) return value;
return finiteOrNull(cumulative) === 0 ? 0 : null;
};
return {
visits: finiteOrNull(derived.visits30),
favorites: finiteOrNull(derived.favorites30),
sales: recentOrGuaranteedZero(derived.sales30, latest.sales),
revenue: recentOrGuaranteedZero(derived.revenue30, latest.revenue),
renewals: recentOrGuaranteedZero(derived.renewals30, latest.renewals),
};
}
function recordMatchesAnalysisFilters(record, filters, query, pageIds) {
const normalized = normalizeAnalysisFilters(filters);
const latest = record.history?.at(-1) || {};
const analysis = record.analysis || record.health?.result || {};
const haystack = `${record.listingId} ${record.meta?.title || ''} ${record.meta?.sku || ''}`.toLocaleLowerCase();
if (query && !haystack.includes(query)) return false;
if (normalized.scope === 'page' && !pageIds.has(String(record.listingId))) return false;
if (normalized.lifecycle && analysis.lifecycle !== normalized.lifecycle) return false;
if (normalized.diagnosis && analysis.diagnosis !== normalized.diagnosis) return false;
if (normalized.recommendation && analysis.code !== normalized.recommendation) return false;
if (normalized.performance) {
const recent = recentPerformanceMetrics(record);
const activityFields = ['visits', 'favorites', 'sales', 'revenue'];
const recentMissing = activityFields.some((field) => recent[field] === null);
const unreadCurrentMetric = HEALTH_METRIC_FIELDS.some((field) => finiteOrNull(latest[field]) === null);
const matches = {
sales: recent.sales !== null && recent.sales > 0,
'traffic-no-sales': recent.visits > 0 && recent.sales === 0,
'no-activity': !recentMissing && activityFields.every((field) => recent[field] === 0),
missing: recentMissing || unreadCurrentMetric || analysis.lifecycle === 'DATA_GAP',
};
if (!matches[normalized.performance]) return false;
}
if (normalized.trend && recordTrendDirection(record) !== normalized.trend) return false;
if (normalized.stock) {
const stock = finiteOrNull(latest.stock);
const stockState = stock === null ? 'unknown' : stock > 0 ? 'in' : 'out';
if (stockState !== normalized.stock) return false;
}
if (normalized.confidence) {
const band = analysis.confidenceBand || 'low';
const group = ['high', 'veryHigh'].includes(band) ? 'high' : band === 'medium' ? 'medium' : 'low';
if (group !== normalized.confidence) return false;
}
return true;
}
function analysisFacetCounts(records, filters, query, pageIds) {
const normalized = normalizeAnalysisFilters(filters);
return Object.fromEntries(Object.entries(FILTER_FACET_VALUES).map(([facet, values]) => [facet, Object.fromEntries(values.map((value) => {
const candidate = normalizeAnalysisFilters({ ...normalized, [facet]: value });
const count = records.filter((record) => recordMatchesAnalysisFilters(record, candidate, query, pageIds)).length;
return [value, count];
}))]));
}
function presetResultCount(preset, records = collectedAnalysisRecords()) {
const filters = normalizeAnalysisFilters({ ...DEFAULT_ANALYSIS_FILTERS, ...(preset?.filters || {}) });
const query = normalizeSpace(preset?.query).toLocaleLowerCase();
const pageIds = new Set(state.pageListings.map((item) => String(item.listingId)));
return records.filter((record) => recordMatchesAnalysisFilters(record, filters, query, pageIds)).length;
}
function analysisPresetIsActive(preset) {
const targetFilters = normalizeAnalysisFilters({ ...DEFAULT_ANALYSIS_FILTERS, ...(preset?.filters || {}) });
return JSON.stringify(targetFilters) === JSON.stringify(normalizeAnalysisFilters(state.analysisFilters))
&& normalizeSpace(preset?.query) === normalizeSpace(state.analysisQuery);
}
function sortAnalysisRecords(records, sort = state.analysisFilters.sort) {
const latestValue = (record, key) => finiteOrNull(record.history?.at(-1)?.[key]) ?? Number.NEGATIVE_INFINITY;
const scoreValue = (record) => finiteOrNull(record.analysis?.score) ?? Number.NEGATIVE_INFINITY;
const priority = { deactivateReview: 0, declining: 1, improve: 2, waiting: 3, growing: 4, monitor: 5, protected: 6 };
const output = [...records];
output.sort((left, right) => {
if (sort === 'title') return String(left.meta?.title || '').localeCompare(String(right.meta?.title || ''), state.settings.language);
if (sort === 'confidence') return Number(right.analysis?.confidence || 0) - Number(left.analysis?.confidence || 0);
if (sort === 'score') return scoreValue(right) - scoreValue(left) || String(left.listingId).localeCompare(String(right.listingId));
if (['visits', 'sales', 'revenue'].includes(sort)) return latestValue(right, sort) - latestValue(left, sort);
const difference = (priority[left.analysis?.code] ?? 99) - (priority[right.analysis?.code] ?? 99);
if (difference) return difference;
if (left.analysis?.code === 'improve') {
const leftAssessment = left.analysis?.currentAssessment || left.analysis?.bootstrap;
const rightAssessment = right.analysis?.currentAssessment || right.analysis?.bootstrap;
const bootstrapPriority = Number(leftAssessment?.priority ?? 99) - Number(rightAssessment?.priority ?? 99);
if (bootstrapPriority) return bootstrapPriority;
const bootstrapSeverity = Number(rightAssessment?.severity ?? 0) - Number(leftAssessment?.severity ?? 0);
if (bootstrapSeverity) return bootstrapSeverity;
const scoreDifference = scoreValue(left) - scoreValue(right);
if (scoreDifference) return scoreDifference;
}
return Number(right.analysis?.confidence || 0) - Number(left.analysis?.confidence || 0)
|| scoreValue(right) - scoreValue(left) || String(left.listingId).localeCompare(String(right.listingId));
});
return output;
}
function readableCalibrationRows(records = null, evaluatedAt = nowIso()) {
const candidates = Array.isArray(records) ? records : (analysisCollectionIsFresh() ? collectedAnalysisRecords() : []);
return candidates.map((candidate) => {
const record = normalizeRecord(candidate, candidate?.listingId);
const derived = record ? deriveRecordMetrics(record, evaluatedAt) : null;
return { record, derived };
}).filter(({ record, derived }) => record && derived?.complete && derived.anchors?.d30?.complete
&& derived.freshnessDays <= 1 && !derived.anomalies?.length
&& Number.isFinite(derived.visits30) && Number.isFinite(derived.sales30)
&& recordListingState(record, derived) === 'active');
}
function thresholdImpactCounts(records = null, values = state.settings, evaluatedAt = nowIso()) {
const readable = readableCalibrationRows(records, evaluatedAt);
return {
improve: readable.filter(({ derived }) => derived.sales30 === 0 && derived.visits30 >= Number(values.minVisitsToImprove)).length,
protect: readable.filter(({ derived }) => derived.sales30 > 0 && derived.visits30 >= Number(values.minVisitsToProtect)).length,
};
}
function thresholdCalibration(records = null, evaluatedAt = nowIso()) {
const readable = readableCalibrationRows(records, evaluatedAt);
if (readable.length < 8) return { available: false, sampleSize: readable.length, values: null };
const visits = readable.map(({ derived }) => finiteOrNull(derived.visits30)).filter(Number.isFinite);
const noSaleVisits = readable.filter(({ derived }) => derived.sales30 === 0)
.map(({ derived }) => finiteOrNull(derived.visits30)).filter((value) => Number.isFinite(value) && value > 0);
const sellingVisits = readable.filter(({ derived }) => Number(derived.sales30) > 0)
.map(({ derived }) => finiteOrNull(derived.visits30)).filter(Number.isFinite);
const dormantRenewals = readable.filter(({ derived }) => derived.anchors?.d60?.complete
&& derived.sales60Raw === 0 && derived.revenue60Raw === 0
&& derived.current?.sales === 0 && derived.current?.revenue === 0)
.map(({ derived }) => finiteOrNull(derived.current?.renewals)).filter(Number.isFinite);
const declines = readable.map(({ derived }) => finiteOrNull(derived.trafficChangePercent))
.filter((value) => Number.isFinite(value) && value < 0).map(Math.abs);
const improve = Math.max(10, Math.round(quantile(noSaleVisits.length >= 3 ? noSaleVisits : visits, 0.25) || DEFAULT_SETTINGS.minVisitsToImprove));
const protectBase = Math.round(quantile(sellingVisits.length >= 3 ? sellingVisits : visits, 0.75) || DEFAULT_SETTINGS.minVisitsToProtect);
return {
available: true,
sampleSize: readable.length,
values: {
minVisitsToImprove: improve,
minVisitsToProtect: Math.max(improve + 10, protectBase),
minRenewalsToReview: clamp(Math.round(median(dormantRenewals) || DEFAULT_SETTINGS.minRenewalsToReview), 2, 10),
declinePercent: clamp(Math.round(median(declines) || DEFAULT_SETTINGS.declinePercent), 20, 60),
},
};
}
function activeObservedImprovement(record) {
return [...(Array.isArray(record?.improvements) ? record.improvements : [])].reverse().find((entry) => (
entry?.action === 'UPDATE' && entry?.status === 'published' && entry?.experiment?.state === 'observing'
)) || null;
}
function proposalExperimentOverlap(record) {
const improvement = activeObservedImprovement(record);
return improvement ? {
improvementId: String(improvement.id || ''),
primaryMetric: String(improvement.experiment?.primaryMetric || ''),
evaluateAt: String(improvement.experiment?.evaluateAt || ''),
} : null;
}
function collectedAnalysisRecords(records = state.records) {
const collectedIds = new Set((state.collection?.uniqueIds || []).map(String));
return records.filter((record) => collectedIds.has(String(record.listingId)));
}
function selectedCurrentCollectionRecords() {
if (!analysisCollectionIsFresh()) return [];
return collectedAnalysisRecords().filter((record) => state.selectedIds.has(String(record.listingId)));
}
function pruneSelectionToCollection() {
const allowed = new Set((state.collection?.status === 'completed' ? state.collection.uniqueIds : []).map(String));
[...state.selectedIds].forEach((listingId) => { if (!allowed.has(String(listingId))) state.selectedIds.delete(listingId); });
}
function filteredAnalysisRecords(records = collectedAnalysisRecords()) {
const query = normalizeSpace(state.analysisQuery).toLocaleLowerCase();
const pageIds = new Set(state.pageListings.map((item) => String(item.listingId)));
return sortAnalysisRecords(records.filter((record) => recordMatchesAnalysisFilters(record, state.analysisFilters, query, pageIds)), state.analysisFilters.sort);
}
function experimentStateLabel(experiment) {
if (!experiment) return '—';
const key = { planned: 'experimentPlanned', observing: 'experimentObserving', winner: 'experimentWinner', underperformed: 'experimentUnderperformed', inconclusive: 'experimentInconclusive', contaminated: 'experimentContaminated', stopped: 'experimentStopped' }[experiment.state] || 'experimentInconclusive';
return t(key, { day: experiment.day ?? 0 });
}
function analysisReason(record) {
const analysis = record.analysis || record.health?.result || analyseRecord(record, state.records);
return t(analysis.reasonKey || 'reasonMonitor', {
renewals: state.settings.minRenewalsToReview,
percent: state.settings.declinePercent,
...(analysis.reasonParams || {}),
});
}
function canonicalEditableContent(editor) {
const normalizeList = (values) => uniqueStrings(Array.isArray(values) ? values : []).map((value) => value.toLocaleLowerCase()).sort((a, b) => a.localeCompare(b));
return {
description: String(editor?.description ?? '').replace(/\r\n?/g, '\n').trim(),
materials: normalizeList(editor?.materials),
tags: normalizeList(editor?.tags),
title: normalizeSpace(editor?.title),
};
}
async function contentFingerprint(editor) {
const canonical = JSON.stringify(canonicalEditableContent(editor));
if (globalThis.crypto?.subtle) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
return `fnv1a-${fnv1a(canonical)}`;
}
function researchJsonSize(value) {
try { return new TextEncoder().encode(JSON.stringify(value)).byteLength; }
catch { return Number.POSITIVE_INFINITY; }
}
function requireExactResearchObject(value, keys, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) throw new Error(`${label} keys mismatch`);
return value;
}
function researchTime(value) {
if (Number.isFinite(Number(value)) && value !== '') return Number(value);
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : null;
}
function researchEnvelope(type, requestId, nonce, payload, expiresAt = Date.now() + RESEARCH_REQUEST_TTL_MS) {
const sentAt = Date.now();
const message = {
schema: RESEARCH_ENVELOPE_SCHEMA,
type,
requestId: String(requestId || ''),
nonce: String(nonce || ''),
sentAt,
expiresAt: Number(expiresAt),
sender: 'listing-analyzer',
payload: payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {},
};
if (researchJsonSize(message) > RESEARCH_MESSAGE_MAX_BYTES) {
const error = new Error('research message exceeds 64 KiB'); error.code = 'RESEARCH_SIZE'; throw error;
}
return message;
}
function validateResearchEnvelope(raw, expectedTypes = null) {
requireExactResearchObject(raw, ['schema', 'type', 'requestId', 'nonce', 'sentAt', 'expiresAt', 'sender', 'payload'], 'research envelope');
if (researchJsonSize(raw) > RESEARCH_MESSAGE_MAX_BYTES) throw new Error('research envelope exceeds 64 KiB');
if (raw.schema !== RESEARCH_ENVELOPE_SCHEMA) throw new Error('research envelope schema mismatch');
const type = String(raw.type || '');
const allowed = ['CAPABILITIES', 'RESEARCH_READY', 'RESEARCH_ACK', 'RESEARCH_RESULT', 'ERROR'];
if (!allowed.includes(type) || (expectedTypes && !expectedTypes.includes(type))) throw new Error(`unsupported research message ${type || '(missing)'}`);
if (raw.sender !== 'keyword-market-analyzer') throw new Error('unexpected research sender');
const requestId = String(raw.requestId || '');
const nonce = String(raw.nonce || '');
if (!/^[A-Za-z0-9:_-]{8,160}$/.test(requestId)) throw new Error('invalid research requestId');
if (!/^[A-Za-z0-9_-]{16,160}$/.test(nonce)) throw new Error('invalid research nonce');
if (!Number.isInteger(raw.sentAt) || !Number.isInteger(raw.expiresAt)) throw new Error('research envelope times must be integer epoch milliseconds');
const sentAt = raw.sentAt;
const expiresAt = raw.expiresAt;
const now = Date.now();
if (sentAt === null || expiresAt === null || expiresAt <= sentAt || expiresAt - sentAt > RESEARCH_MAX_TTL_MS) throw new Error('invalid research TTL');
if (sentAt > now + 60_000 || expiresAt < now) throw new Error('expired research envelope');
if (!raw.payload || typeof raw.payload !== 'object' || Array.isArray(raw.payload)) throw new Error('research payload must be an object');
return { ...raw, type, requestId, nonce, sentAt, expiresAt };
}
function canonicalResearchContent(record) {
const editable = captureEditableFieldsFromRecord(record);
return { title: normalizeSpace(editable.title), tags: uniqueStrings(editable.tags || []).map((tag) => tag.toLocaleLowerCase()).sort((a, b) => a.localeCompare(b)) };
}
async function researchContentHash(record) {
const canonical = JSON.stringify(canonicalResearchContent(record));
if (!globalThis.crypto?.subtle) { const error = new Error('SHA-256 is unavailable'); error.code = 'RESEARCH_HASH_UNAVAILABLE'; throw error; }
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function researchSeedKeywords(record) {
const editable = captureEditableFieldsFromRecord(record);
const fromTags = uniqueStrings(editable.tags || []).filter((value) => value.length >= 2 && value.length <= 50);
const title = normalizeSpace(editable.title);
const titleParts = uniqueStrings(title.split(/[|,;/]+/)).filter((value) => value.length >= 2 && value.length <= 50);
let titleFallback = title.slice(0, 50).replace(/\s+\S*$/, '').trim();
if (!titleFallback) titleFallback = title.slice(0, 50).trim();
return uniqueStrings([...fromTags, ...titleParts, titleFallback]).slice(0, 3);
}
function normalizeResearchCache(raw) {
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const now = Date.now();
return Object.fromEntries(Object.entries(source).filter(([requestId, entry]) => {
if (!/^[A-Za-z0-9:_-]{8,160}$/.test(requestId) || !entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
const expiresAt = researchTime(entry.expiresAt);
const completedAt = researchTime(entry.completedAt);
return expiresAt !== null && (expiresAt >= now || (completedAt !== null && completedAt + DAY_MS >= now));
}).sort((left, right) => String(left[1]?.updatedAt || left[1]?.createdAt).localeCompare(String(right[1]?.updatedAt || right[1]?.createdAt))).slice(-RESEARCH_MAX_PENDING));
}
async function persistResearchEntryLocked(entry) {
const cache = normalizeResearchCache(await GMX.get(KEYS.researchRequests, {}));
const writeId = randomId('research-write');
const candidate = { ...entry, updatedAt: nowIso(), writeId };
cache[candidate.requestId] = candidate;
const bounded = normalizeResearchCache(cache);
if (!bounded[candidate.requestId]) throw new Error('research request cache is full');
await requireStored(KEYS.researchRequests, bounded);
const verified = await GMX.get(KEYS.researchRequests, {});
if (verified?.[candidate.requestId]?.writeId !== writeId) {
const error = new Error('research request write verification failed'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
return candidate;
}
async function getResearchEntry(requestId) {
return normalizeResearchCache(await GMX.get(KEYS.researchRequests, {}))[String(requestId || '')] || null;
}
async function activeResearchForListing(listingId) {
const cache = normalizeResearchCache(await GMX.get(KEYS.researchRequests, {}));
return Object.values(cache).find((entry) => String(entry.listingId) === String(listingId) && !['completed', 'failed'].includes(entry.status) && researchTime(entry.expiresAt) > Date.now()) || null;
}
async function prepareResearchEntry(record, expectedIdentity) {
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const fenced = await assertFreshCollectionLocked(expectedIdentity);
const latest = fenced.records.find((item) => String(item.listingId) === String(record.listingId));
if (!latest) { const error = new Error('selected listing is outside the current collection'); error.code = 'COLLECTION_INCOMPLETE'; throw error; }
const seeds = researchSeedKeywords(latest).slice(0, 1);
if (!seeds.length) { const error = new Error(t('researchNoSeeds')); error.code = 'RESEARCH_NO_SEEDS'; throw error; }
const requestId = randomId('research');
const nonce = randomNonce();
const createdAt = nowIso();
const expiresAt = new Date(Date.now() + RESEARCH_REQUEST_TTL_MS).toISOString();
const contentHash = await researchContentHash(latest);
const editable = captureEditableFieldsFromRecord(latest);
if (!editable.title || editable.title.length > 140) throw new Error('research title must contain 1-140 characters');
if (!Array.isArray(editable.tags) || editable.tags.length > 13 || editable.tags.some((tag) => !normalizeSpace(tag) || normalizeSpace(tag).length > 20)) throw new Error('research tags are invalid');
if (seeds.length < 1 || seeds.length > 3 || new Set(seeds.map((seed) => seed.toLocaleLowerCase())).size !== seeds.length) throw new Error('research seed keywords are invalid');
const payload = {
schema: RESEARCH_REQUEST_SCHEMA,
opaqueReference: 'L001',
contentHash,
title: editable.title,
tags: editable.tags,
seedKeywords: seeds,
maxSeeds: 1,
};
if (researchJsonSize(payload) > RESEARCH_MESSAGE_MAX_BYTES / 2) throw new Error('research request payload is too large');
return persistResearchEntryLocked({
schema: 'makaytron-listing-research-pending/v1', requestId, nonce, createdAt, expiresAt,
ownerTabId: tabId, listingId: String(latest.listingId), opaqueReference: 'L001', contentHash,
collection: collectionIdentity(fenced.collection), editableDataCaptured: Boolean(latest.editor?.capturedAt),
status: 'waiting-ready', request: payload,
});
});
}
function researchRequestEnvelope(entry) {
return researchEnvelope('RESEARCH_REQUEST', entry.requestId, entry.nonce, entry.request, researchTime(entry.expiresAt));
}
function researchInsightsUrl() { return RESEARCH_INSIGHTS_URL; }
function postResearchEnvelope(message) {
if (!state.researchChannel) throw new Error('BroadcastChannel is unavailable');
if (researchJsonSize(message) > RESEARCH_MESSAGE_MAX_BYTES) throw new Error('research message exceeds 64 KiB');
state.researchChannel.postMessage(message);
}
function clearResearchProbeTimers(waiter) {
clearTimeout(waiter?.timeout);
(waiter?.retryTimers || []).forEach((timer) => clearTimeout(timer));
if (waiter) waiter.retryTimers = [];
}
async function probeResearchCompanion() {
if (!state.researchChannel) return null;
const requestId = randomId('probe');
const nonce = randomNonce();
return new Promise((resolve) => {
const timeout = window.setTimeout(() => {
const waiter = state.researchProbeWaiters.get(requestId);
clearResearchProbeTimers(waiter);
state.researchProbeWaiters.delete(requestId);
resolve(null);
}, RESEARCH_PROBE_TIMEOUT_MS);
const waiter = { nonce, resolve, timeout, retryTimers: [], capabilities: null, readyState: '' };
state.researchProbeWaiters.set(requestId, waiter);
const sendProbe = () => {
if (state.researchProbeWaiters.get(requestId) !== waiter) return;
try { postResearchEnvelope(researchEnvelope('PROBE', requestId, nonce, { wants: ['research'] }, Date.now() + 10_000)); }
catch {
clearResearchProbeTimers(waiter);
state.researchProbeWaiters.delete(requestId);
resolve(null);
}
};
sendProbe();
if (state.researchProbeWaiters.get(requestId) === waiter) {
waiter.retryTimers = RESEARCH_PROBE_RETRY_DELAYS_MS.slice(1).map((delay) => window.setTimeout(sendProbe, delay));
}
});
}
function validateResearchCapabilities(payload) {
requireExactResearchObject(payload, ['version', 'standalone', 'maxSeedKeywords', 'maxRelatedKeywords', 'cacheTtlDays', 'networkAccess'], 'research capabilities');
if (typeof payload.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(payload.version)) throw new Error('invalid companion version');
if (payload.standalone !== true || payload.networkAccess !== false) throw new Error('unsupported companion capabilities');
if (!Number.isInteger(payload.maxSeedKeywords) || payload.maxSeedKeywords < 1 || payload.maxSeedKeywords > 3) throw new Error('invalid seed limit');
if (!Number.isInteger(payload.maxRelatedKeywords) || payload.maxRelatedKeywords < 1 || payload.maxRelatedKeywords > 25) throw new Error('invalid related-keyword limit');
if (payload.cacheTtlDays !== 7) throw new Error('unsupported companion cache TTL');
return payload;
}
function validateResearchReadyPayload(payload) {
const stateValue = payload?.state;
requireExactResearchObject(payload, stateValue === 'busy' ? ['state', 'activeRequestId'] : ['state'], 'research ready');
if (!['ready', 'busy'].includes(stateValue)) throw new Error('invalid research ready state');
if (stateValue === 'busy' && (typeof payload.activeRequestId !== 'string' || !/^[A-Za-z0-9:_-]{8,160}$/.test(payload.activeRequestId))) throw new Error('invalid active research request');
return { ...payload };
}
function validateResearchAckPayload(payload) {
requireExactResearchObject(payload, ['accepted', 'queuePosition'], 'research acknowledgement');
if (payload.accepted !== true || !Number.isInteger(payload.queuePosition) || payload.queuePosition < 0 || payload.queuePosition >= RESEARCH_REMOTE_MAX_QUEUE) throw new Error('invalid research acknowledgement');
return { accepted: true, queuePosition: payload.queuePosition };
}
function validateResearchErrorPayload(payload) {
requireExactResearchObject(payload, ['code', 'message', 'retryable'], 'research error');
if (typeof payload.code !== 'string' || !/^[A-Z][A-Z0-9_]{1,63}$/.test(payload.code)) throw new Error('invalid research error code');
if (typeof payload.message !== 'string' || !normalizeSpace(payload.message) || payload.message.length > 300) throw new Error('invalid research error message');
if (typeof payload.retryable !== 'boolean') throw new Error('invalid research retryable flag');
return { code: payload.code, message: normalizeSpace(payload.message), retryable: payload.retryable };
}
function researchMetric(value, label, options = {}) {
if (value === null) return null;
const min = options.min ?? 0;
const max = options.max ?? 1_000_000_000_000;
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) throw new Error(`invalid ${label}`);
return value;
}
function validateResearchKeywords(rawKeywords) {
if (!Array.isArray(rawKeywords) || rawKeywords.length < 1 || rawKeywords.length > 28) throw new Error('research keywords must contain 1-28 items');
const seen = new Set();
return rawKeywords.map((item) => {
requireExactResearchObject(item, ['keyword', 'searches30d', 'searchResults', 'trend7dPercent', 'opportunity'], 'research keyword');
if (typeof item.keyword !== 'string') throw new Error('research keyword text must be a string');
const keyword = normalizeSpace(item.keyword);
const key = keyword.toLocaleLowerCase();
if (!keyword || keyword.length > 80 || seen.has(key)) throw new Error(`invalid or duplicate research keyword ${keyword || '(missing)'}`);
seen.add(key);
const searches30d = researchMetric(item.searches30d, `${keyword} searches30d`);
const searchResults = researchMetric(item.searchResults, `${keyword} searchResults`);
const trend7dPercent = researchMetric(item.trend7dPercent, `${keyword} trend7dPercent`, { min: -1000, max: 1000 });
const opportunity = requireExactResearchObject(item.opportunity, ['score', 'label', 'metric'], 'research opportunity');
if (opportunity.metric !== 'makaytron-derived' || typeof opportunity.label !== 'string') throw new Error(`invalid opportunity for ${keyword}`);
const score = researchMetric(opportunity.score, `${keyword} opportunity score`, { min: 0, max: 100 });
if (score === null) throw new Error(`missing opportunity score for ${keyword}`);
const label = opportunity.label.toUpperCase();
if (!['LOW', 'MEDIUM', 'HIGH'].includes(label)) throw new Error(`invalid opportunity label for ${keyword}`);
return { keyword, searches30d, searchResults, trend7dPercent, opportunity: { score, label, metric: 'makaytron-derived' } };
});
}
function validateResearchResultPayload(raw, entry) {
requireExactResearchObject(raw, ['schema', 'opaqueReference', 'contentHash', 'capturedAt', 'source', 'keywords'], 'research result');
if (researchJsonSize(raw) > RESEARCH_MESSAGE_MAX_BYTES) throw new Error('research result exceeds 64 KiB');
if (raw.schema !== RESEARCH_RESULT_SCHEMA) throw new Error('research result schema mismatch');
if (typeof raw.opaqueReference !== 'string' || raw.opaqueReference !== entry.opaqueReference) throw new Error('research opaque reference mismatch');
if (typeof raw.contentHash !== 'string' || !/^[a-f0-9]{64}$/.test(raw.contentHash) || raw.contentHash !== entry.contentHash) { const error = new Error(t('researchStale')); error.code = 'RESEARCH_STALE'; throw error; }
if (raw.source !== 'etsy-marketplace-insights-dom') throw new Error('unsupported research source');
if (typeof raw.capturedAt !== 'string') throw new Error('invalid research capture time');
const capturedAt = researchTime(raw.capturedAt);
if (capturedAt === null || capturedAt > Date.now() + 60_000 || capturedAt < Date.now() - 7 * DAY_MS) throw new Error('invalid research capture time');
const keywords = validateResearchKeywords(raw.keywords);
return { schema: RESEARCH_RESULT_SCHEMA, opaqueReference: entry.opaqueReference, contentHash: entry.contentHash, capturedAt: new Date(capturedAt).toISOString(), source: raw.source, keywords };
}
function validateResearchEvidence(raw) {
requireExactResearchObject(raw, ['schema', 'requestId', 'opaqueReference', 'contentHash', 'source', 'capturedAt', 'keywords'], 'research evidence');
if (raw.schema !== RESEARCH_EVIDENCE_SCHEMA) throw new Error('research evidence schema mismatch');
if (typeof raw.requestId !== 'string' || !/^[A-Za-z0-9:_-]{8,160}$/.test(raw.requestId)) throw new Error('invalid research evidence requestId');
if (typeof raw.opaqueReference !== 'string' || !/^[A-Za-z0-9:_-]{1,128}$/.test(raw.opaqueReference)) throw new Error('invalid research evidence reference');
if (typeof raw.contentHash !== 'string' || !/^[a-f0-9]{64}$/.test(raw.contentHash)) throw new Error('invalid research evidence hash');
if (raw.source !== 'etsy-marketplace-insights-dom') throw new Error('invalid research evidence source');
if (typeof raw.capturedAt !== 'string') throw new Error('invalid research evidence time');
const capturedAt = researchTime(raw.capturedAt);
if (capturedAt === null || capturedAt > Date.now() + 60_000) throw new Error('invalid research evidence time');
return {
schema: RESEARCH_EVIDENCE_SCHEMA,
requestId: raw.requestId,
opaqueReference: raw.opaqueReference,
contentHash: raw.contentHash,
source: raw.source,
capturedAt: new Date(capturedAt).toISOString(),
keywords: validateResearchKeywords(raw.keywords),
};
}
function validateResearchSuggestion(raw) {
requireExactResearchObject(raw, ['schema', 'action', 'fields', 'title', 'tags', 'reason', 'source', 'requestId', 'reference', 'researchEvidence', 'generatedAt', 'requiresEditorCapture'], 'research suggestion');
if (raw.schema !== 'makaytron-listing-research-suggestion/v1') throw new Error('research suggestion schema mismatch');
if (!['UPDATE', 'SKIP'].includes(raw.action) || !Array.isArray(raw.fields)) throw new Error('invalid research suggestion action');
const fields = raw.fields.map((field) => String(field));
if (fields.some((field) => !['title', 'tags'].includes(field)) || new Set(fields).size !== fields.length) throw new Error('invalid research suggestion fields');
if ((raw.action === 'UPDATE') !== (fields.length > 0)) throw new Error('research suggestion action/fields mismatch');
if (typeof raw.title !== 'string' || !normalizeSpace(raw.title) || raw.title.length > 140) throw new Error('invalid research suggestion title');
if (!Array.isArray(raw.tags) || raw.tags.length > 13 || raw.tags.some((tag) => typeof tag !== 'string' || !normalizeSpace(tag) || tag.length > 20)) throw new Error('invalid research suggestion tags');
if (typeof raw.reason !== 'string' || !normalizeSpace(raw.reason) || raw.reason.length > 1000) throw new Error('invalid research suggestion reason');
if (raw.source !== 'marketplace-insights') throw new Error('invalid research suggestion source');
if (typeof raw.requestId !== 'string' || !/^[A-Za-z0-9:_-]{8,160}$/.test(raw.requestId)) throw new Error('invalid research suggestion requestId');
if (typeof raw.reference !== 'string' || !/^[A-Za-z0-9:_-]{1,128}$/.test(raw.reference)) throw new Error('invalid research suggestion reference');
if (typeof raw.generatedAt !== 'string' || researchTime(raw.generatedAt) === null) throw new Error('invalid research suggestion time');
if (typeof raw.requiresEditorCapture !== 'boolean') throw new Error('invalid research suggestion capture flag');
const researchEvidence = validateResearchEvidence(raw.researchEvidence);
if (researchEvidence.requestId !== raw.requestId || researchEvidence.opaqueReference !== raw.reference) throw new Error('research suggestion evidence mismatch');
return {
schema: raw.schema, action: raw.action, fields, title: normalizeSpace(raw.title), tags: uniqueStrings(raw.tags),
reason: normalizeSpace(raw.reason), source: raw.source, requestId: raw.requestId, reference: raw.reference,
researchEvidence, generatedAt: new Date(researchTime(raw.generatedAt)).toISOString(), requiresEditorCapture: raw.requiresEditorCapture,
};
}
function buildResearchSuggestion(record, result, entry) {
const current = captureEditableFieldsFromRecord(record);
const titleLower = normalizeSpace(current.title).toLocaleLowerCase();
const ranked = [...result.keywords].sort((left, right) => Number(right.opportunity.score || 0) - Number(left.opportunity.score || 0) || Number(right.searches30d || 0) - Number(left.searches30d || 0));
const titleKeyword = ranked.find((item) => item.keyword.length <= 50 && !titleLower.includes(item.keyword.toLocaleLowerCase()));
let title = normalizeSpace(current.title);
if (titleKeyword) {
const prefix = `${titleKeyword.keyword} | `;
const room = Math.max(0, 140 - prefix.length);
let tail = title.slice(0, room);
if (tail.length < title.length) tail = tail.replace(/\s+\S*$/, '').trim();
title = normalizeSpace(`${prefix}${tail}`).slice(0, 140);
}
const existingTags = uniqueStrings(current.tags || []);
const existingTagSet = new Set(existingTags.map((tag) => tag.toLocaleLowerCase()));
const availableSlots = entry.editableDataCaptured ? Math.max(0, 13 - existingTags.length) : 0;
const tagCandidates = ranked.filter((item) => item.keyword.length <= 20
&& !existingTagSet.has(item.keyword.toLocaleLowerCase())
&& item.searches30d !== null && item.searchResults !== null);
const addedTags = tagCandidates.slice(0, Math.min(3, availableSlots)).map((item) => item.keyword);
const tags = [...existingTags, ...addedTags];
let tagReplacement = null;
if (entry.editableDataCaptured && existingTags.length === 13 && tagCandidates.length) {
const evidenceByKeyword = new Map(ranked.map((item) => [item.keyword.toLocaleLowerCase(), item]));
const weakest = existingTags.map((tag, index) => {
const evidenceItem = evidenceByKeyword.get(tag.toLocaleLowerCase());
return { tag, index, score: evidenceItem ? evidenceItem.opportunity.score : -1, searches: evidenceItem?.searches30d ?? -1 };
}).sort((left, right) => left.score - right.score || left.searches - right.searches || right.index - left.index)[0];
const strongestCandidate = tagCandidates[0];
if (weakest && strongestCandidate.opportunity.score > weakest.score) {
tags[weakest.index] = strongestCandidate.keyword;
tagReplacement = { old: weakest.tag, keyword: strongestCandidate.keyword };
}
}
const fields = [];
if (title && title !== normalizeSpace(current.title)) fields.push('title');
if (!sameStringSet(tags, existingTags)) fields.push('tags');
const strongest = ranked[0];
const evidence = {
schema: RESEARCH_EVIDENCE_SCHEMA,
requestId: entry.requestId,
opaqueReference: entry.opaqueReference,
contentHash: entry.contentHash,
source: result.source,
capturedAt: result.capturedAt,
keywords: ranked,
};
let reason = t('researchProposalReason', { keyword: strongest.keyword, searches: formatNumber(strongest.searches30d), results: formatNumber(strongest.searchResults) });
if (tagReplacement) reason = `${reason} ${t('researchTagReplacement', tagReplacement)}`;
return {
schema: 'makaytron-listing-research-suggestion/v1',
action: fields.length ? 'UPDATE' : 'SKIP', fields, title, tags,
reason,
source: 'marketplace-insights', requestId: entry.requestId, reference: entry.opaqueReference,
researchEvidence: evidence, generatedAt: nowIso(), requiresEditorCapture: !entry.editableDataCaptured,
};
}
function researchSuggestionForRecord(record) {
let stored = null;
try { stored = validateResearchSuggestion(record?.researchSuggestion); }
catch { return null; }
if (!stored || !stored.requiresEditorCapture || !record?.editor?.capturedAt) return stored;
const evidence = stored.researchEvidence;
const entry = {
requestId: String(stored.requestId || evidence.requestId || ''),
opaqueReference: String(stored.reference || evidence.opaqueReference || ''),
contentHash: String(evidence.contentHash || ''),
editableDataCaptured: true,
};
try {
const result = validateResearchResultPayload({
schema: RESEARCH_RESULT_SCHEMA,
opaqueReference: entry.opaqueReference,
contentHash: entry.contentHash,
capturedAt: evidence.capturedAt,
source: evidence.source,
keywords: evidence.keywords,
}, entry);
return { ...buildResearchSuggestion(record, result, entry), generatedAt: stored.generatedAt || nowIso(), rematerializedAt: nowIso() };
} catch { return stored; }
}
async function consumeResearchResult(envelope) {
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const cache = normalizeResearchCache(await GMX.get(KEYS.researchRequests, {}));
let entry = cache[envelope.requestId];
if (!entry || entry.nonce !== envelope.nonce) throw new Error('unknown or expired research request');
if (entry.ownerTabId !== tabId) throw new Error('research request belongs to another Analyzer tab');
if (entry.status === 'completed') {
const duplicateResult = validateResearchResultPayload(envelope.payload, entry);
const duplicateHash = `fnv1a-${fnv1a(JSON.stringify(duplicateResult))}`;
if (duplicateHash !== entry.resultHash) throw new Error('conflicting research replay rejected');
return { duplicate: true, entry, count: Number(entry.evidenceCount) || 0 };
}
if (researchTime(entry.expiresAt) < Date.now()) throw new Error('research request expired');
const expectedIdentity = entry.collection || {};
const fenced = await assertFreshCollectionLocked(expectedIdentity);
const record = fenced.records.find((item) => String(item.listingId) === String(entry.listingId));
if (!record) throw new Error('research listing is no longer in the current collection');
const currentHash = await researchContentHash(record);
if (currentHash !== entry.contentHash) { const error = new Error(t('researchStale')); error.code = 'RESEARCH_STALE'; throw error; }
const result = validateResearchResultPayload(envelope.payload, entry);
const resultHash = `fnv1a-${fnv1a(JSON.stringify(result))}`;
entry = await persistResearchEntryLocked({ ...entry, status: 'processing', resultHash });
try {
const suggestion = buildResearchSuggestion(record, result, entry);
record.researchEvidence = suggestion.researchEvidence;
record.researchSuggestion = suggestion;
await Store.putRecordLocked(record);
entry = await persistResearchEntryLocked({ ...entry, status: 'completed', completedAt: nowIso(), evidenceCount: result.keywords.length, suggestionPrepared: suggestion.fields.length > 0 });
return { duplicate: false, entry, count: result.keywords.length, suggestion };
} catch (error) {
await persistResearchEntryLocked({ ...entry, status: 'acknowledged', resultHash: '', lastError: normalizeSpace(error?.message).slice(0, 240) });
throw error;
}
});
}
async function updateResearchEntry(requestId, mutate) {
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const entry = await getResearchEntry(requestId);
if (!entry) return null;
return persistResearchEntryLocked(mutate({ ...entry }) || entry);
});
}
async function rejectResearchResult(envelope, error) {
if (error?.code === 'STORAGE_WRITE_FAILED') return false;
const entry = await getResearchEntry(envelope?.requestId);
if (!entry || entry.nonce !== envelope?.nonce || entry.ownerTabId !== tabId) return false;
const message = normalizeSpace(error?.message || error || 'Research result rejected.').slice(0, 300) || 'Research result rejected.';
if (entry.status !== 'completed') {
try {
await updateResearchEntry(entry.requestId, (current) => ({ ...current, status: 'failed', failedAt: nowIso(), lastError: message }));
} catch { /* Still notify the companion so it can release its queue item. */ }
}
clearResearchUiTimer(entry.requestId);
try {
postResearchEnvelope(researchEnvelope('ERROR', entry.requestId, entry.nonce, { code: 'RESULT_REJECTED', message, retryable: false }, Date.now() + 60_000));
} catch { /* Local rejection remains terminal even if the companion tab closed. */ }
return true;
}
function clearResearchUiTimer(requestId) {
const timer = state.researchUiTimers.get(requestId);
if (timer) clearTimeout(timer);
state.researchUiTimers.delete(requestId);
}
function scheduleResearchUiTimeout(requestId) {
clearResearchUiTimer(requestId);
state.researchUiTimers.set(requestId, window.setTimeout(() => {
state.researchUiTimers.delete(requestId);
UI.updateResearchTransfer('researchTimedOut', 'blocked');
}, RESEARCH_UI_TIMEOUT_MS));
}
async function handleResearchMessage(raw) {
let envelope;
try { envelope = validateResearchEnvelope(raw); }
catch { return; }
if (envelope.type === 'CAPABILITIES') {
const waiter = state.researchProbeWaiters.get(envelope.requestId);
if (!waiter || waiter.nonce !== envelope.nonce) return;
try {
waiter.capabilities = validateResearchCapabilities(envelope.payload);
if (waiter.readyState) {
clearResearchProbeTimers(waiter); state.researchProbeWaiters.delete(envelope.requestId);
waiter.resolve({ ...waiter.capabilities, readyState: waiter.readyState });
}
} catch { /* A malformed capability response is equivalent to no compatible companion. */ }
return;
}
if (envelope.type === 'RESEARCH_READY') {
const waiter = state.researchProbeWaiters.get(envelope.requestId);
if (waiter && waiter.nonce === envelope.nonce) {
let ready;
try { ready = validateResearchReadyPayload(envelope.payload); }
catch { return; }
waiter.readyState = ready.state;
if (waiter.capabilities) {
clearResearchProbeTimers(waiter); state.researchProbeWaiters.delete(envelope.requestId);
waiter.resolve({ ...waiter.capabilities, readyState: ready.state });
}
return;
}
}
const entry = await getResearchEntry(envelope.requestId);
if (!entry || entry.nonce !== envelope.nonce || entry.ownerTabId !== tabId || researchTime(entry.expiresAt) < Date.now()) return;
if (envelope.type === 'RESEARCH_READY') {
let ready;
try { ready = validateResearchReadyPayload(envelope.payload); }
catch { return; }
const remoteState = ready.state;
if (remoteState === 'busy') { UI.updateResearchTransfer('researchWaitingReady', 'scanning'); return; }
try {
if (!['waiting-ready', 'request-sent'].includes(entry.status)) return;
const current = await updateResearchEntry(entry.requestId, (candidate) => ['waiting-ready', 'request-sent'].includes(candidate.status)
? { ...candidate, status: 'request-sent', requestSentAt: candidate.requestSentAt || nowIso() }
: candidate);
if (!current || current.status !== 'request-sent') return;
postResearchEnvelope(researchRequestEnvelope(current));
UI.updateResearchTransfer('researchRequestSent', 'scanning');
scheduleResearchUiTimeout(entry.requestId);
} catch (error) { UI.updateResearchTransfer('researchFailed', 'error', { message: normalizeSpace(error?.message) }); }
return;
}
if (envelope.type === 'RESEARCH_ACK') {
try { validateResearchAckPayload(envelope.payload); }
catch { return; }
await updateResearchEntry(entry.requestId, (current) => ({ ...current, status: 'acknowledged', acknowledgedAt: nowIso() }));
UI.updateResearchTransfer('researchAcknowledged', 'scanning');
return;
}
if (envelope.type === 'ERROR') {
let remoteError;
try { remoteError = validateResearchErrorPayload(envelope.payload); }
catch { return; }
const message = remoteError.message.slice(0, 240);
await updateResearchEntry(entry.requestId, (current) => ({ ...current, status: 'failed', failedAt: nowIso(), lastError: message }));
clearResearchUiTimer(entry.requestId);
UI.updateResearchTransfer('researchErrorRemote', 'error', { message });
return;
}
if (envelope.type === 'RESEARCH_RESULT') {
try {
const consumed = await consumeResearchResult(envelope);
postResearchEnvelope(researchEnvelope('RESEARCH_RECEIVED', entry.requestId, entry.nonce, { accepted: true }, Date.now() + 60_000));
clearResearchUiTimer(entry.requestId);
await refreshRecords();
UI.setStatus('researchComplete', 'ready', { count: consumed.count });
UI.updateResearchTransfer(consumed.suggestion?.requiresEditorCapture ? 'researchNeedsEditor' : 'researchSavedForReview', 'ready');
UI.toast(t('researchComplete', { count: consumed.count }), 'success');
} catch (error) {
const message = error?.code === 'RESEARCH_STALE' ? t('researchStale') : normalizeSpace(error?.message || error);
await rejectResearchResult(envelope, error);
UI.updateResearchTransfer('researchFailed', 'error', { message });
}
}
}
function installResearchBridge() {
if (typeof BroadcastChannel !== 'function') return false;
try {
const channel = new BroadcastChannel(RESEARCH_CHANNEL_NAME);
channel.addEventListener('message', (event) => { void handleResearchMessage(event.data); });
state.researchChannel = channel;
return true;
} catch { return false; }
}
function primaryMetricForFields(fields) {
if (fields.includes('title') || fields.includes('tags')) return 'visits30';
if (fields.includes('description')) return 'salesRateProxy';
return 'favoriteRate';
}
function relativeEffectPercent(before, after) {
if (!Number.isFinite(before) || !Number.isFinite(after) || before < 0 || after < 0) return null;
if (before === 0) return after > 0 ? 100 : 0;
return Math.round(((after - before) / before) * 1000) / 10;
}
function experimentMetricResult(metric, baseline, current, baselineHistory) {
const baselineVisits = finiteOrNull(baseline?.visits);
const currentVisits = finiteOrNull(current?.visits);
if (metric === 'visits30') {
const before = baselineVisits; const after = currentVisits;
return {
valid: Number.isFinite(before) && Number.isFinite(after), effect: relativeEffectPercent(before, after),
baseline: { metricValue: before, visits: baselineVisits }, current: { metricValue: after, visits: currentVisits },
winnerReady: Number(after) >= 20, underperformedReady: Number(after) >= 20,
};
}
if (metric === 'favoriteRate') {
const before = safeRatio(finiteOrNull(baseline?.favorites), baselineVisits, 100);
const after = safeRatio(finiteOrNull(current?.favorites), currentVisits, 100);
return {
valid: Number.isFinite(before) && Number.isFinite(after), effect: relativeEffectPercent(before, after),
baseline: { metricValue: before, rate: before, favorites: finiteOrNull(baseline?.favorites), visits: baselineVisits },
current: { metricValue: after, rate: after, favorites: finiteOrNull(current?.favorites), visits: currentVisits },
winnerReady: Number(currentVisits) >= 20 && Number(current?.favorites) >= 2,
underperformedReady: Number(currentVisits) >= 20,
};
}
const baselineAnchor = findAnchor(baselineHistory, baseline, 30);
const baselineSales = cumulativeWindow(baseline, baselineAnchor, 'sales', 30).normalized;
const afterSales = Number.isFinite(current?.sales) && Number.isFinite(baseline?.sales) ? current.sales - baseline.sales : null;
const before = safeRatio(baselineSales, baselineVisits, 100);
const after = safeRatio(afterSales, currentVisits, 100);
return {
valid: Number.isFinite(afterSales) && afterSales >= 0 && Number.isFinite(before) && Number.isFinite(after),
effect: relativeEffectPercent(before, after),
baseline: { metricValue: before, sales: baselineSales, rate: before, visits: baselineVisits },
current: { metricValue: after, sales: afterSales, rate: after, visits: currentVisits },
winnerReady: Number(afterSales) >= Math.max(2, Number(baselineSales) || 0),
underperformedReady: Number(currentVisits) >= 20,
};
}
function updateExperimentEvaluations(record, evaluatedAt = nowIso()) {
const history = Array.isArray(record.history) ? record.history.map(normalizeSnapshot).filter(Boolean).sort((a, b) => String(a.at).localeCompare(String(b.at))) : [];
const published = (record.improvements || []).filter((entry) => entry?.action === 'UPDATE' && entry?.status === 'published' && entry?.publishedAt).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt)));
const terminalStates = new Set(['winner', 'underperformed', 'inconclusive', 'contaminated', 'stopped']);
published.forEach((entry, index) => {
entry.experiment = { state: 'observing', durationDays: HEALTH_RULES.experimentDays, primaryMetric: primaryMetricForFields(entry.fields || []), ...(entry.experiment || {}) };
const finishExperiment = (nextState, at = evaluatedAt) => {
entry.experiment.state = nextState;
if (!entry.experiment.evaluatedAt) entry.experiment.evaluatedAt = at;
};
const startAt = entry.publishedAt;
const evaluateAt = entry.experiment.evaluateAt || addDays(startAt, HEALTH_RULES.experimentDays);
const day = Math.max(0, Math.floor(daysBetween(evaluatedAt, startAt) || 0));
Object.assign(entry.experiment, { startAt, evaluateAt, day });
if (terminalStates.has(entry.experiment.state) && entry.experiment.evaluatedAt) return;
if (published[index + 1] && validTime(published[index + 1].publishedAt) < validTime(evaluateAt)) {
entry.experiment.contaminatedAt = published[index + 1].publishedAt; finishExperiment('contaminated', entry.experiment.contaminatedAt); return;
}
if (day < HEALTH_RULES.experimentDays) { entry.experiment.state = 'observing'; return; }
const baseline = normalizeSnapshot(entry.baselineSnapshot) || [...history].reverse().find((snapshot) => validTime(snapshot.at) <= validTime(startAt));
const current = history.find((snapshot) => validTime(snapshot.at) >= validTime(evaluateAt));
const evaluationDelay = current ? daysBetween(current.at, evaluateAt) : null;
if (!baseline || !current || !Number.isFinite(evaluationDelay) || evaluationDelay > HEALTH_RULES.experimentEvaluationGraceDays) {
finishExperiment('inconclusive'); return;
}
const baselineHistory = history.filter((snapshot) => validTime(snapshot.at) <= validTime(baseline.at));
const metric = entry.experiment.primaryMetric || primaryMetricForFields(entry.fields || []);
const result = experimentMetricResult(metric, baseline, current, baselineHistory);
entry.experiment.baseline = result.baseline;
entry.experiment.current = result.current;
entry.experiment.effectPercent = result.effect;
entry.experiment.evaluationSnapshotAt = current.at;
if (!result.valid || !Number.isFinite(result.effect)) finishExperiment('inconclusive');
else if (result.winnerReady && result.effect >= 15) finishExperiment('winner');
else if (result.underperformedReady && result.effect <= -15) finishExperiment('underperformed');
else finishExperiment('inconclusive');
});
return record;
}
function buildHistoryChartModel(history, metric) {
const snapshots = (Array.isArray(history) ? history : []).map(normalizeSnapshot).filter(Boolean).sort((a, b) => String(a.at).localeCompare(String(b.at)));
const values = snapshots.map((snapshot) => finiteOrNull(snapshot[metric]));
const finiteValues = values.filter(Number.isFinite);
if (snapshots.length < 2 || finiteValues.length < 2) return { metric, snapshots, segments: [], points: [], min: null, max: null, tone: 'neutral' };
const width = 320; const height = 92; const paddingX = 10; const paddingY = 10;
const min = Math.min(...finiteValues); const max = Math.max(...finiteValues); const range = max - min || 1;
const times = snapshots.map((snapshot) => validTime(snapshot.at));
const firstTime = Math.min(...times); const lastTime = Math.max(...times); const timeRange = lastTime - firstTime;
const coordinates = values.map((value, index) => Number.isFinite(value) ? {
index, value, at: snapshots[index].at,
x: paddingX + (timeRange > 0 ? (times[index] - firstTime) / timeRange : index / Math.max(1, snapshots.length - 1)) * (width - paddingX * 2),
y: paddingY + ((max - value) / range) * (height - paddingY * 2),
} : null);
const segments = []; let current = [];
coordinates.forEach((point) => {
if (point) current.push(point);
else if (current.length) { segments.push(current); current = []; }
});
if (current.length) segments.push(current);
const points = coordinates.filter(Boolean);
const change = points.at(-1).value - points[0].value;
return { metric, snapshots, segments, points, min, max, tone: change > 0 ? 'up' : change < 0 ? 'down' : 'neutral', width, height };
}
function buildExperimentTimeline(record) {
const terminal = new Set(['winner', 'underperformed', 'inconclusive', 'contaminated', 'stopped']);
const events = [];
(Array.isArray(record?.improvements) ? record.improvements : []).forEach((improvement) => {
const experiment = improvement?.experiment || {};
if (improvement?.at) events.push({ at: improvement.at, key: 'timelinePlanned', type: 'planned', improvement, experiment });
if (improvement?.publishedAt) events.push({ at: improvement.publishedAt, key: 'timelinePublished', type: 'published', improvement, experiment });
if (experiment.startAt) events.push({ at: experiment.startAt, key: 'timelineObserving', type: 'observing', improvement, experiment });
if (experiment.evaluateAt) events.push({ at: experiment.evaluateAt, key: 'timelineEvaluationDue', type: 'due', improvement, experiment });
if (terminal.has(experiment.state)) events.push({ at: experiment.evaluatedAt || experiment.contaminatedAt || experiment.evaluateAt || improvement.publishedAt || improvement.at, key: 'timelineEvaluated', type: experiment.state, improvement, experiment });
});
const causalOrder = { planned: 0, published: 1, observing: 2, due: 3, winner: 4, underperformed: 4, inconclusive: 4, contaminated: 4, stopped: 4 };
return events.filter((event) => validTime(event.at)).sort((left, right) => validTime(left.at) - validTime(right.at) || (causalOrder[left.type] ?? 9) - (causalOrder[right.type] ?? 9));
}
function routeKind(pathname = location.pathname) {
if (/\/your\/shops\/[^/]+\/tools\/listings\/?$/i.test(pathname)) return 'listings';
if (/\/your\/shops\/[^/]+\/listing-editor\/edit\/\d+/i.test(pathname)) return 'editor';
return 'unsupported';
}
function currentListingId(pathname = location.pathname) {
return pathname.match(/\/listing-editor\/edit\/(\d+)/i)?.[1] || '';
}
function elementIsUsable(element) {
if (!element || element.closest?.('[hidden],[aria-hidden="true"],[inert]')) return false;
try {
if (typeof element.checkVisibility === 'function' && !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
} catch { /* Fall back to computed style. */ }
try {
const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : null;
if (style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')) return false;
} catch { /* A detached transient node is not accepted below. */ }
return !document.documentElement?.contains || document.documentElement.contains(element);
}
const ListingPageAdapter = {
cardRoots() {
return Array.from(document.querySelectorAll('li.wt-block-grid__item')).filter(elementIsUsable);
},
cardLinks() {
const found = [];
const seen = new Set();
this.cardRoots().forEach((card) => {
const candidates = Array.from(card.querySelectorAll('a[href*="/listing-editor/edit/"]')).filter(elementIsUsable);
const link = candidates.find((item) => item.matches('a.card-body'))
|| candidates.find((item) => !item.closest('[role="menu"],.card-actions') && item.querySelector('.card-title, h2[title], h2'));
const id = link?.href.match(/\/listing-editor\/edit\/(\d+)/i)?.[1];
if (id && !seen.has(id)) { seen.add(id); found.push(link); }
});
return found;
},
metricRows(link) {
const containers = Array.from(link?.querySelectorAll('.card-meta') || []).filter((container) => container.querySelector('h6'));
return containers.flatMap((container) => Array.from(container.querySelectorAll('.card-meta-row-item')).map((item) => normalizeSpace(item.textContent))).filter(Boolean);
},
parseCard(link, verifiedShopKey = currentShopKey(), verifiedListingState = pageListingState()) {
const card = link?.closest('li.wt-block-grid__item');
if (!card || !elementIsUsable(card) || !elementIsUsable(link)) return null;
const editId = link.href.match(/\/listing-editor\/edit\/(\d+)/i)?.[1] || '';
const publicLink = card.querySelector('a[href*="/listing/"]');
const publicId = publicLink?.href.match(/\/listing\/(\d+)(?:\/|$)/i)?.[1] || '';
const statsLink = card.querySelector('a[href*="/stats/listings/"]');
const statsId = statsLink?.href.match(/\/stats\/listings\/(\d+)/i)?.[1] || '';
const ids = [editId, publicId, statsId].filter(Boolean);
if (!editId || new Set(ids).size > 1) return null;
const title = normalizeSpace(card.querySelector('.card-title, h2[title], h2')?.getAttribute('title') || card.querySelector('.card-title, h2')?.textContent);
const sku = normalizeSpace(card.querySelector('.card-meta-row-sku [data-value="true"], .card-meta-row-sku')?.textContent);
const quantityText = normalizeSpace(card.querySelector('.card-meta-row-quantity')?.textContent);
const priceText = normalizeSpace(card.querySelector('.card-meta-row-price span, .card-meta-row-price')?.textContent);
const renewalLabel = normalizeSpace(card.querySelector('.card-meta-row-status')?.textContent);
const image = card.querySelector('.card-img-wrap img, img');
const metrics = parseListingMetrics(this.metricRows(link));
if (!verifiedShopKey || !normalizeListingState(verifiedListingState) || !title
|| !HEALTH_METRIC_FIELDS.every((field) => Number.isFinite(metrics[field]))) return null;
const listingState = normalizeListingState(verifiedListingState);
return {
listingId: editId, title, sku, editUrl: link.href, shopKey: verifiedShopKey,
publicUrl: publicLink?.href || '', imageUrl: image?.currentSrc || image?.src || '',
stock: parseCount(quantityText, ['in stock', 'stokta', 'stock', 'stok']),
price: parsePriceRange(priceText), currency: metrics.currency || currencyMarker(priceText),
listingState, statusLabel: listingStateLabel(listingState), renewalLabel,
visits: metrics.visits, favorites: metrics.favorites, sales: metrics.sales,
revenue: metrics.revenue, renewals: metrics.renewals, capturedAt: nowIso(),
};
},
scan() {
const shopKey = currentShopKey();
const listingState = pageListingState();
if (!shopKey || !listingState) return [];
return this.cardLinks().map((link) => this.parseCard(link, shopKey, listingState)).filter(Boolean);
},
contentSignature(listings = this.scan()) {
return listings.map((item) => String(item.listingId)).sort().join('\u001f');
},
readSignature(listings) {
return JSON.stringify([...listings].sort((left, right) => String(left.listingId).localeCompare(String(right.listingId))).map((item) => [
String(item.listingId), item.title, item.sku, item.listingState, item.statusLabel, item.renewalLabel, item.stock,
item.price?.min, item.price?.max, item.visits, item.favorites, item.sales, item.revenue, item.renewals,
]));
},
paginationNav() {
const candidates = Array.from(document.querySelectorAll('nav[aria-label="Listings pagination"],nav[aria-label*="pagination" i],nav[aria-label*="sayfa" i]'));
return candidates.find(elementIsUsable) || null;
},
pageSelect(nav = this.paginationNav()) {
const host = nav?.querySelector('clg-select');
return host?.shadowRoot?.querySelector('select') || nav?.querySelector('select') || null;
},
pageInfo() {
const nav = this.paginationNav();
const cardCount = this.cardLinks().length;
let routeCurrent = null;
let routeOffset = 0;
let routePageValid = true;
if (routeKind() === 'listings') {
try {
const params = new URL(location.href).searchParams;
const values = params.getAll('page');
if (!values.length) routeCurrent = 1;
else if (values.length === 1 && /^[1-9]\d*$/.test(values[0])) routeCurrent = Number(values[0]);
else routePageValid = false;
const offsets = params.getAll('offset');
if (!offsets.length) routeOffset = 0;
else if (offsets.length === 1 && offsets[0] === '0') routeOffset = 0;
else routePageValid = false;
} catch { routePageValid = false; }
}
if (!nav) {
const ambiguous = cardCount >= ANALYSIS_BATCH_SIZE;
let explicitPagedLocation = false;
try {
const url = new URL(location.href);
explicitPagedLocation = (routeCurrent ?? Number(url.searchParams.get('page') || 1)) > 1 || routeOffset > 0 || Number(url.searchParams.get('offset') || 0) > 0;
} catch { explicitPagedLocation = true; }
return { current: 1, total: 1, valid: routePageValid && cardCount > 0 && !ambiguous && !explicitPagedLocation, hasPagination: false, ambiguous, explicitPagedLocation };
}
const text = normalizeSpace(nav.textContent);
const match = text.match(/(?:Page|Sayfa)\s*(\d+).*?(?:of|\/)\s*(\d+)/i);
const select = this.pageSelect(nav);
const current = routeCurrent ?? Number(select?.value || match?.[1]);
const total = Number(match?.[2] || select?.options?.length);
const valid = routePageValid && Number.isInteger(current) && Number.isInteger(total) && current >= 1 && total >= 1 && current <= total;
return { current: valid ? current : 0, total: valid ? total : 0, valid, hasPagination: true, ambiguous: false };
},
pageSignature(listings = this.scan(), page = this.pageInfo().current) {
const ids = listings.map((item) => String(item.listingId)).sort();
return `${page}|${ids.length}|${ids.join('\u001f')}`;
},
snapshotState(options = {}) {
const pageInfo = this.pageInfo();
const links = this.cardLinks();
const listings = this.scan();
const expectedCount = Math.max(0, Number(options.expectedCount) || 0);
let valid = pageInfo.valid && links.length > 0 && listings.length === links.length;
if (options.requirePagination && !pageInfo.hasPagination) valid = false;
if (expectedCount) valid = valid && listings.length === expectedCount;
else if (pageInfo.total > 1 && pageInfo.current < pageInfo.total) valid = valid && listings.length === ANALYSIS_BATCH_SIZE;
else valid = valid && listings.length <= ANALYSIS_BATCH_SIZE;
const signature = valid ? JSON.stringify([pageInfo.current, pageInfo.total, this.readSignature(listings)]) : '';
return { valid, pageInfo, links, listings, signature };
},
async readStable(options = {}) {
const timeout = window.__MAKAYTRON_LISTING_TEST__ === true
? (Number(options.timeout) || 600)
: (Number(options.timeout) || COLLECTION_STABLE_READ_TIMEOUT_MS);
const interval = window.__MAKAYTRON_LISTING_TEST__ === true ? 10 : COLLECTION_STABLE_SAMPLE_INTERVAL_MS;
const deadline = Date.now() + timeout;
let previousSignature = '';
let stableSamples = 0;
let deferredUnboundedPage = null;
while (Date.now() <= deadline) {
const snapshot = this.snapshotState(options);
if (snapshot.valid) {
const sameSignature = snapshot.signature === previousSignature;
const requiresFullObservation = !Number(options.expectedCount)
&& snapshot.pageInfo.current === snapshot.pageInfo.total;
if (!sameSignature || !requiresFullObservation) deferredUnboundedPage = null;
stableSamples = sameSignature ? stableSamples + 1 : 1;
previousSignature = snapshot.signature;
if (stableSamples >= COLLECTION_STABLE_SAMPLES) {
if (!requiresFullObservation) return snapshot;
deferredUnboundedPage = snapshot;
}
} else {
previousSignature = '';
stableSamples = 0;
deferredUnboundedPage = null;
}
await sleep(interval);
}
return deferredUnboundedPage;
},
nextButton() {
const nav = this.paginationNav();
const matchesNext = (button) => /^(?:next|next page|sonraki|sonraki sayfa)$/i.test(normalizeSpace(button?.getAttribute('aria-label')));
const shadowButtons = Array.from(nav?.querySelectorAll('clg-icon-button') || []).map((host) => host.shadowRoot?.querySelector('button')).filter(Boolean);
return shadowButtons.find(matchesNext) || Array.from(nav?.querySelectorAll('button') || []).find(matchesNext) || null;
},
pageControl(page) {
const nav = this.paginationNav();
const select = this.pageSelect(nav);
const option = Array.from(select?.options || []).find((item) => Number(item.value || normalizeSpace(item.textContent)) === Number(page));
if (select && option) return { type: 'select', element: select, value: option.value };
const candidates = Array.from(nav?.querySelectorAll('a[href],button') || []);
const element = candidates.find((item) => Number(normalizeSpace(item.textContent)) === Number(page) || Number(item.getAttribute('aria-label')?.match(/\d+/)?.[0]) === Number(page));
return element ? { type: 'click', element } : null;
},
isDisabled(button) {
return !button || Boolean(button.disabled) || button.getAttribute('aria-disabled') === 'true';
},
};
function setNativeValue(element, value) {
if (!element) return false;
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (setter) setter.call(element, String(value ?? ''));
else element.value = String(value ?? '');
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: String(value ?? '') }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
return true;
}
const PILL_REMOVE_SELECTOR = [
'button.le-chip-remove',
'button[aria-label^="Delete tag "]',
'button[aria-label^="Delete material "]',
'button[aria-label^="Etiketi sil "]',
'button[aria-label^="Malzemeyi sil "]',
'button[aria-label="Remove"]',
'button[aria-label="Kaldır"]',
].join(',');
function pillValues(container) {
if (!container) return [];
return Array.from(container.querySelectorAll('li')).filter((item) => item.querySelector(PILL_REMOVE_SELECTOR)).map((item) => {
const clone = item.cloneNode(true);
clone.querySelectorAll('button,[aria-label="Remove"],[aria-label="Kaldır"]').forEach((button) => button.remove());
return normalizeSpace(clone.textContent).replace(/All \d+ used|Tüm \d+.*$/i, '').trim();
}).filter(Boolean);
}
function isInactiveStatus(value) {
return /^(?:Inactive|Deactivated|Pasif|Devre dışı)$/i.test(normalizeSpace(value));
}
async function waitFor(predicate, timeout = 5000, interval = 100) {
const started = Date.now();
while (Date.now() - started < timeout) {
try { const result = predicate(); if (result) return result; } catch { /* retry */ }
await sleep(interval);
}
return null;
}
const EditorAdapter = {
visibleTitle() { return normalizeSpace(document.querySelector('header h3[role="button"].wt-text-title-large')?.textContent); },
ready() {
const title = document.querySelector('#listing-title-input');
const description = document.querySelector('#listing-description-textarea');
return Boolean(title && description && normalizeSpace(title.value || this.visibleTitle()) && String(description.value || '').trim());
},
read() {
const tagsField = document.querySelector('#field-tags');
const materialsField = document.querySelector('#field-materials');
return {
title: document.querySelector('#listing-title-input')?.value || this.visibleTitle(),
description: document.querySelector('#listing-description-textarea')?.value || '',
tags: pillValues(tagsField), materials: pillValues(materialsField),
quantity: document.querySelector('#listing-quantity-input')?.value || '',
sku: document.querySelector('#listing-sku-input')?.value || '',
};
},
async captureCurrent() {
const listingId = currentListingId();
const shopKey = currentShopKey();
if (!listingId || !shopKey || !this.ready()) return null;
const record = await Store.getRecord(listingId) || {
schema: APP.schema, listingId, meta: { title: this.read().title, editUrl: location.href, shopKey, lastSeenAt: nowIso() },
history: [], improvements: [], proposal: null,
};
record.editor = { ...this.read(), capturedAt: nowIso() };
record.meta = { ...record.meta, title: record.editor.title || record.meta?.title || '', editUrl: location.href, shopKey, lastSeenAt: nowIso() };
await Store.putRecord(record);
return record;
},
async syncPills(fieldSelector, inputSelector, buttonSelector, desiredValues) {
const field = document.querySelector(fieldSelector);
const input = document.querySelector(inputSelector);
const addButton = document.querySelector(buttonSelector);
if (!field || !input || !addButton) return false;
const desired = uniqueStrings(desiredValues);
const desiredLower = new Set(desired.map((value) => value.toLocaleLowerCase()));
const items = Array.from(field.querySelectorAll('li'));
for (const item of items) {
if (!item.querySelector(PILL_REMOVE_SELECTOR)) continue;
const clone = item.cloneNode(true);
clone.querySelectorAll('button').forEach((button) => button.remove());
const value = normalizeSpace(clone.textContent).replace(/All \d+ used|Tüm \d+.*$/i, '').trim();
if (value && !desiredLower.has(value.toLocaleLowerCase())) {
const remove = item.querySelector(PILL_REMOVE_SELECTOR);
if (!remove) return false;
remove.click();
if (!await waitFor(() => !item.isConnected, 3000)) return false;
}
}
let current = new Set(pillValues(field).map((value) => value.toLocaleLowerCase()));
for (const value of desired) {
if (current.has(value.toLocaleLowerCase())) continue;
const ready = await waitFor(() => !input.disabled && !addButton.disabled && addButton.getAttribute('aria-disabled') !== 'true', 3000);
if (!ready) return false;
setNativeValue(input, value);
addButton.click();
const added = await waitFor(() => pillValues(field).some((item) => item.toLocaleLowerCase() === value.toLocaleLowerCase()), 3000);
if (!added) return false;
current = new Set(pillValues(field).map((item) => item.toLocaleLowerCase()));
}
const finalValues = pillValues(field).map((value) => value.toLocaleLowerCase());
const finalSet = new Set(finalValues);
return finalValues.length === desiredLower.size && finalSet.size === desiredLower.size && desired.every((value) => finalSet.has(value.toLocaleLowerCase()));
},
async applyProposal(proposal) {
if (!this.ready()) { void trackTelemetryError('selector_listing_editor'); throw new Error(t('formNotReady')); }
if (proposal?.action !== 'UPDATE') throw new Error('Only UPDATE proposals can be applied to the Etsy form.');
const fields = proposalFields(proposal, true);
if (!fields.length) throw new Error('The proposal does not explicitly select an Etsy field to change.');
const before = this.read();
const changed = [];
if (fields.includes('title')) {
const title = normalizeSpace(proposal.title);
if (!title || title.length > 140) throw new Error('The selected title must contain 1-140 characters.');
if (before.title !== title) { setNativeValue(document.querySelector('#listing-title-input'), title); changed.push('title'); }
}
if (fields.includes('description')) {
if (typeof proposal.description !== 'string') throw new Error('The selected description must be a string.');
if (before.description !== proposal.description) { setNativeValue(document.querySelector('#listing-description-textarea'), proposal.description); changed.push('description'); }
}
if (fields.includes('tags')) {
if (!Array.isArray(proposal.tags)) throw new Error('The selected tags field must be an array.');
const success = await this.syncPills('#field-tags', '#listing-tags-input', '#listing-tags-button', proposal.tags);
if (!success) { void trackTelemetryError('selector_listing_editor'); throw new Error('Etsy tags control did not reach the requested state.'); }
if (!sameStringSet(before.tags, proposal.tags)) changed.push('tags');
}
if (fields.includes('materials')) {
if (!Array.isArray(proposal.materials)) throw new Error('The selected materials field must be an array.');
const success = await this.syncPills('#field-materials', '#listing-materials-input', '#listing-materials-button', proposal.materials);
if (!success) { void trackTelemetryError('selector_listing_editor'); throw new Error('Etsy materials control did not reach the requested state.'); }
if (!sameStringSet(before.materials, proposal.materials)) changed.push('materials');
}
if (!changed.length) throw new Error('The selected Etsy fields already match the proposal; nothing was changed.');
return changed;
},
publishButton() { return document.querySelector('#shop-manager--listing-publish-edit,button[data-test="publish"]'); },
statusText() {
const status = document.querySelector('[data-unsaved-changes="true"],[data-test="save-status"],#save-state');
return normalizeSpace(status?.textContent);
},
formIsClean() {
const text = this.statusText();
if (/no unsaved changes|kaydedilmemiş değişiklik yok/i.test(text)) return true;
if (/unsaved changes|kaydedilmemiş değişiklik/i.test(text)) return false;
return null;
},
async openDeactivate() {
const more = document.querySelector('#more-option-menu button[aria-label="More options"],button[aria-label="More options"]');
if (!more) return false;
more.click();
const action = await waitFor(() => {
const candidates = Array.from(document.querySelectorAll('#more-option-menu [role="menuitem"],#more-option-menu button[data-action="deactivate"],[role="menu"] [role="menuitem"]'));
return candidates.find((button) => button.offsetParent !== null && /^(?:deactivate|devre dışı bırak|pasifleştir)$/i.test(normalizeSpace(button.textContent)) && !button.closest('[role="dialog"],[aria-modal="true"]'));
}, 3000);
if (!action) return false;
action.scrollIntoView({ block: 'nearest' });
action.focus({ preventScroll: true });
return true;
},
listingStatusLabels() {
const selectors = ['.wt-badge--statusValue', '[data-listing-status]'];
const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
return uniqueStrings(candidates.filter((element) => element.offsetParent !== null).map((element) => (
normalizeSpace(element.textContent || element.getAttribute('data-listing-status') || element.getAttribute('data-status'))
)));
},
appearsInactive() {
return this.listingStatusLabels().some(isInactiveStatus);
},
};
function actionLeaseLostError(message = 'Action lease ownership changed.') {
const error = new Error(message);
error.code = 'ACTION_LEASE_LOST';
return error;
}
const Lease = {
async assertOwnerLocked(expectedToken = state.leaseToken) {
const token = String(expectedToken || '');
const current = await GMX.get(KEYS.lease, null);
const valid = token
&& current?.owner === tabId
&& current?.instanceId === pageInstanceId
&& current?.token === token
&& Number(current.expiresAt) > Date.now();
if (!valid) throw actionLeaseLostError();
return current;
},
async assertOwner(expectedToken = state.leaseToken) {
return withNamedLock(STORAGE_MUTATION_LOCK, () => this.assertOwnerLocked(expectedToken));
},
async withFence(expectedToken, callback) {
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
await this.assertOwnerLocked(expectedToken);
return callback();
});
},
async acquire() {
clearInterval(state.leaseTimer);
const acquired = await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const current = await GMX.get(KEYS.lease, null);
const active = (current?.token || current?.owner) && Number(current.expiresAt) > Date.now();
const sameOwner = current?.owner === tabId && current?.instanceId === pageInstanceId;
if (active && !sameOwner) return false;
const token = active && sameOwner ? String(current.token) : randomId('action-lease');
const lease = { owner: tabId, instanceId: pageInstanceId, token, expiresAt: Date.now() + 15000 };
if (!await GMX.set(KEYS.lease, lease)) return false;
const verified = await GMX.get(KEYS.lease, null);
if (verified?.owner !== tabId || verified?.instanceId !== pageInstanceId || verified?.token !== token) return false;
state.leaseToken = token;
return true;
});
if (!acquired) return false;
state.leaseTimer = window.setInterval(async () => {
if (!state.leaseToken) return;
await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const held = await GMX.get(KEYS.lease, null);
if (held?.owner !== tabId || held?.instanceId !== pageInstanceId || held?.token !== state.leaseToken || Number(held.expiresAt) <= Date.now()) {
clearInterval(state.leaseTimer); state.leaseTimer = 0; state.leaseToken = '';
return;
}
if (!await GMX.set(KEYS.lease, { ...held, expiresAt: Date.now() + 15000 })) {
clearInterval(state.leaseTimer); state.leaseTimer = 0; state.leaseToken = '';
}
});
}, 5000);
return true;
},
async release(expectedToken = state.leaseToken) {
clearInterval(state.leaseTimer); state.leaseTimer = 0;
const token = String(expectedToken || '');
await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const current = await GMX.get(KEYS.lease, null);
if (current?.owner === tabId && current?.instanceId === pageInstanceId && current?.token === token) await GMX.remove(KEYS.lease);
});
if (state.leaseToken === token) state.leaseToken = '';
},
};
const Queue = {
activeItem() {
if (!state.queue || !Array.isArray(state.queue.items)) return null;
return state.queue.items[state.queue.cursor] || null;
},
async withFencedActiveItem(fenceToken, expectedStatuses, callback) {
const expectedQueueId = String(state.queue?.id || '');
const expectedCursor = Number(state.queue?.cursor);
const expectedListingId = String(this.activeItem()?.listingId || '');
if (!expectedQueueId || !Number.isInteger(expectedCursor) || !expectedListingId) {
throw actionLeaseLostError('The action queue is no longer active.');
}
const allowedStatuses = Array.isArray(expectedStatuses) ? expectedStatuses.map(String) : [];
return Lease.withFence(fenceToken, async () => {
const queue = await GMX.get(KEYS.queue, null);
const item = queue && Array.isArray(queue.items) ? queue.items[expectedCursor] : null;
const queueMatches = queue
&& String(queue.id || '') === expectedQueueId
&& Number(queue.cursor) === expectedCursor
&& ['ready', 'running'].includes(String(queue.status || ''))
&& String(item?.listingId || '') === expectedListingId;
if (!queueMatches || (allowedStatuses.length && !allowedStatuses.includes(String(item.status || '')))) {
throw actionLeaseLostError('The action queue changed while Etsy verification was in progress.');
}
return callback(queue, item);
});
},
async updateItemFenced(patch, fenceToken, expectedStatuses = []) {
return this.withFencedActiveItem(fenceToken, expectedStatuses, async (queue, item) => {
Object.assign(item, patch);
await Lease.assertOwnerLocked(fenceToken);
const saved = await Store.saveQueueLocked(queue);
return saved.items[saved.cursor] || null;
});
},
recoveryState() {
const item = this.activeItem();
if (!item || !['ready', 'running'].includes(String(state.queue?.status || ''))) return null;
const uncertain = ['applying', 'awaiting-user-review', 'submitted', 'submitted-unverified', 'awaiting-user-deactivation'];
const submitted = ['submitted', 'submitted-unverified'].includes(item.status);
if (!uncertain.includes(String(item.status || '')) || (!submitted && item.runtimeOwner === pageInstanceId)) return null;
return { item, submitted };
},
async create(records, expectedIdentity) {
const requestedIds = uniqueStrings(records.map((record) => String(record.listingId)));
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const fenced = await assertFreshCollectionLocked(expectedIdentity);
const latestById = new Map(fenced.records.map((record) => [String(record.listingId), record]));
const latestRecords = requestedIds.map((listingId) => latestById.get(listingId));
if (latestRecords.some((record) => !record)) {
const error = new Error('A selected listing is no longer part of the completed collection.'); error.code = 'COLLECTION_INCOMPLETE'; throw error;
}
latestRecords.forEach((record) => {
if (!record.proposal || record.proposal.action === 'SKIP') {
const error = new Error('A selected proposal is no longer available.'); error.code = 'PROPOSAL_STALE'; throw error;
}
let fields;
try { fields = proposalFields(record.proposal, true); }
catch (cause) { const error = new Error(cause?.message || 'The proposal fields are invalid.'); error.code = 'PROPOSAL_INVALID'; throw error; }
if (record.proposal.action === 'UPDATE' && (!fields.length || !record.proposal.improvementId)) {
const error = new Error('An UPDATE proposal is incomplete.'); error.code = 'PROPOSAL_INVALID'; throw error;
}
if (record.proposal.action !== 'UPDATE' && fields.length) {
const error = new Error('A non-UPDATE proposal selects editable fields.'); error.code = 'PROPOSAL_INVALID'; throw error;
}
if (!recommendationBasisMatches(record)) {
const error = new Error('The proposal basis changed.'); error.code = 'PROPOSAL_STALE'; throw error;
}
if (record.proposal.experimentOverlap && !record.proposal.experimentOverlapAcceptedAt) {
const error = new Error('The proposal overlaps an active experiment.'); error.code = 'PROPOSAL_EXPERIMENT_OVERLAP'; throw error;
}
});
const items = latestRecords.map((record) => ({
listingId: String(record.listingId), title: record.meta?.title || '', editUrl: record.meta?.editUrl || `https://www.etsy.com/your/shops/me/listing-editor/edit/${record.listingId}`,
proposal: record.proposal, status: 'pending', attempts: 0, error: '', before: null, changedFields: [],
}));
const identity = collectionIdentity(fenced.collection);
const queue = {
schema: 1, id: `queue-${Date.now()}`, createdAt: nowIso(), status: 'ready', cursor: 0, items,
collectionId: identity.id, scopeKey: identity.scopeKey, completedAt: identity.completedAt,
};
const saved = await Store.saveQueueLocked(queue);
await Store.appendAuditLocked({ type: 'queue-created', queueId: queue.id, count: items.length, collectionId: identity.id });
return saved;
});
},
async updateItem(patch) {
const item = this.activeItem();
if (!item) return null;
Object.assign(item, patch);
await Store.saveQueue(state.queue);
return item;
},
async advance(status = 'completed') {
const item = this.activeItem();
if (item) item.status = status;
state.queue.cursor += 1;
if (state.queue.cursor >= state.queue.items.length) {
state.queue.status = 'completed'; state.queue.completedAt = nowIso();
await Store.saveQueue(state.queue); await Lease.release();
UI.setStatus('queueComplete', 'ready'); UI.render(); return null;
}
state.queue.status = 'running';
await Store.saveQueue(state.queue);
UI.render();
return this.activeItem();
},
async stop(reason = 'user') {
if (!state.queue) return;
state.queue.status = 'stopped'; state.queue.stoppedAt = nowIso(); state.queue.stopReason = reason;
await Store.saveQueue(state.queue); await Lease.release();
UI.setStatus('queueStopped', 'blocked'); UI.render();
},
async navigate(item = this.activeItem()) {
if (!item) return;
await Lease.release();
if (typeof window.__MAKAYTRON_LISTING_NAVIGATE__ === 'function') window.__MAKAYTRON_LISTING_NAVIGATE__(item.editUrl);
else location.assign(item.editUrl);
},
};
async function waitForActionVerification(predicate, fenceToken, timeout = 5000, interval = 100) {
const started = Date.now();
while (Date.now() - started < timeout) {
await Lease.assertOwner(fenceToken);
let result = null;
try { result = predicate(); } catch { /* retry while the lease is still held */ }
if (result) {
await Lease.assertOwner(fenceToken);
return result;
}
await sleep(interval);
}
await Lease.assertOwner(fenceToken);
return null;
}
function normalizeCollection(raw) {
if (!raw || typeof raw !== 'object') return null;
const schema = Math.max(1, Number(raw.schema) || 1);
if (schema > COLLECTION_SCHEMA_VERSION) {
return {
schema, id: String(raw.id || 'unsupported-collection'), status: 'blocked', scopeKey: String(raw.scopeKey || ''),
startedAt: String(raw.startedAt || ''), updatedAt: String(raw.updatedAt || ''), completedAt: null, stoppedAt: null,
expectedPage: 1, totalPages: 1, pages: {}, uniqueIds: [], duplicateCount: 0, returningToFirst: false,
leaseToken: '', handoffToken: '', handoffPage: 0, handoffExpiresAt: '', unsupportedSchema: true, legacySchema: false,
retry: null, failureReports: [],
error: { key: 'collectionNewerSchema', reason: 'Unsupported collection schema.', reportId: '' },
};
}
const legacySchema = schema < COLLECTION_SCHEMA_VERSION;
const allowedStatuses = ['starting', 'running', 'paused', 'blocked', 'completed'];
const pages = {};
Object.entries(raw.pages && typeof raw.pages === 'object' ? raw.pages : {}).forEach(([pageKey, value]) => {
const page = Number(pageKey);
if (!Number.isInteger(page) || page < 1 || page > MAX_COLLECTION_PAGES || !value || typeof value !== 'object') return;
pages[String(page)] = {
signature: String(value.signature || ''), contentSignature: String(value.contentSignature || ''),
ids: uniqueStrings(Array.isArray(value.ids) ? value.ids.map(String) : []),
count: Math.max(0, Number(value.count) || 0), capturedAt: String(value.capturedAt || ''),
};
});
const pageIds = Object.values(pages).flatMap((page) => page.ids);
const failureReports = (Array.isArray(raw.failureReports) ? raw.failureReports : []).map((report) => {
if (!report || typeof report !== 'object') return null;
return {
schema: 1, id: String(report.id || randomId('collection-error')), at: String(report.at || nowIso()),
collectionId: String(report.collectionId || raw.id || ''), key: String(report.key || 'collectionPageChanged'),
phase: normalizeSpace(report.phase || 'page-read').slice(0, 80), message: normalizeSpace(report.message).slice(0, 300),
expectedPage: Math.max(1, Number(report.expectedPage) || 1), observedPage: Math.max(0, Number(report.observedPage) || 0),
totalPages: Math.max(1, Number(report.totalPages) || 1), attempts: Math.max(1, Number(report.attempts) || 1),
maxAttempts: Math.max(1, Number(report.maxAttempts) || COLLECTION_RETRY_ATTEMPTS),
collectedPages: Math.max(0, Number(report.collectedPages) || 0), uniqueListings: Math.max(0, Number(report.uniqueListings) || 0),
linkCount: Math.max(0, Number(report.linkCount) || 0), parsedCount: Math.max(0, Number(report.parsedCount) || 0),
route: /^\/your\/shops\/[^?#\s]+\/tools\/listings(?:\?(?:stats=true|page=\d+|stats=true&page=\d+|page=\d+&stats=true))?$/.test(String(report.route || '')) ? String(report.route) : '',
recovered: Boolean(report.recovered), recoveredAt: report.recoveredAt ? String(report.recoveredAt) : null,
};
}).filter(Boolean).slice(-20);
const retry = raw.retry && typeof raw.retry === 'object' ? {
phase: normalizeSpace(raw.retry.phase).slice(0, 80), page: Math.max(1, Number(raw.retry.page) || 1),
attempt: clamp(raw.retry.attempt, 1, COLLECTION_RETRY_ATTEMPTS), maxAttempts: COLLECTION_RETRY_ATTEMPTS,
lastAt: String(raw.retry.lastAt || nowIso()),
} : null;
return {
schema, id: String(raw.id || `collection-${Date.now()}`),
status: legacySchema ? 'blocked' : (allowedStatuses.includes(raw.status) ? raw.status : 'blocked'), scopeKey: String(raw.scopeKey || ''),
startedAt: String(raw.startedAt || nowIso()), updatedAt: String(raw.updatedAt || nowIso()),
completedAt: legacySchema ? null : (raw.completedAt ? String(raw.completedAt) : null), stoppedAt: raw.stoppedAt ? String(raw.stoppedAt) : null,
expectedPage: Math.max(1, Number(raw.expectedPage) || 1), totalPages: Math.max(1, Number(raw.totalPages) || 1), pages,
uniqueIds: uniqueStrings([...(Array.isArray(raw.uniqueIds) ? raw.uniqueIds.map(String) : []), ...pageIds]),
duplicateCount: Math.max(0, Number(raw.duplicateCount) || 0),
returningToFirst: Boolean(raw.returningToFirst),
leaseToken: String(raw.leaseToken || ''), handoffToken: String(raw.handoffToken || ''),
handoffPage: Math.max(0, Number(raw.handoffPage) || 0), handoffExpiresAt: String(raw.handoffExpiresAt || ''), unsupportedSchema: false, legacySchema,
retry, failureReports,
error: legacySchema
? { key: 'collectionPageChanged', reason: 'Collection must be rescanned with the current parser.', reportId: '' }
: (raw.error && typeof raw.error === 'object' ? { key: String(raw.error.key || ''), reason: String(raw.error.reason || ''), reportId: String(raw.error.reportId || '') } : null),
};
}
function collectionManifestIsComplete(collection) {
const totalPages = Number(collection?.totalPages);
if (!collection || !Number.isInteger(totalPages) || totalPages < 1 || totalPages > MAX_COLLECTION_PAGES) return false;
if (Number(collection.duplicateCount) !== 0 || !Array.isArray(collection.uniqueIds) || !collection.uniqueIds.length) return false;
const pageKeys = Object.keys(collection.pages || {});
if (pageKeys.length !== totalPages || pageKeys.some((key) => !/^\d+$/.test(key) || Number(key) < 1 || Number(key) > totalPages)) return false;
const collectedIds = [];
for (let page = 1; page <= totalPages; page += 1) {
const manifest = collection.pages?.[String(page)];
const ids = Array.isArray(manifest?.ids) ? manifest.ids.map(String) : [];
const count = Number(manifest?.count);
if (!manifest || !Number.isInteger(count) || count < 1 || count > ANALYSIS_BATCH_SIZE || ids.length !== count) return false;
if (page < totalPages && count !== ANALYSIS_BATCH_SIZE) return false;
if (new Set(ids).size !== ids.length || ids.some((id) => !id)) return false;
const contentSignature = [...ids].sort().join('\u001f');
if (manifest.contentSignature !== contentSignature || manifest.signature !== `${page}|${count}|${contentSignature}`) return false;
collectedIds.push(...ids);
}
const uniqueIds = collection.uniqueIds.map(String);
const collectedSet = new Set(collectedIds);
return collectedSet.size === collectedIds.length
&& new Set(uniqueIds).size === uniqueIds.length
&& uniqueIds.length === collectedIds.length
&& uniqueIds.every((id) => collectedSet.has(id));
}
function collectionIsFresh(collection = state.collection, referenceTime = Date.now(), expected = {}) {
if (!collection || collection.schema !== COLLECTION_SCHEMA_VERSION || collection.legacySchema
|| collection.status !== 'completed' || !collectionManifestIsComplete(collection)) return false;
const completedAt = Date.parse(collection.completedAt || '');
if (!Number.isFinite(completedAt)) return false;
const age = Number(referenceTime) - completedAt;
const totalPages = Math.max(1, Number(collection.totalPages) || 1);
const expectedScope = String(expected.scopeKey ?? (routeKind() === 'listings' ? collectionScopeKey() : collection.scopeKey || ''));
const expectedTotalPages = Math.max(1, Number(expected.totalPages ?? (routeKind() === 'listings' ? ListingPageAdapter.pageInfo().total : totalPages)) || 1);
if (!expectedScope || collection.scopeKey !== expectedScope || totalPages !== expectedTotalPages) return false;
if (age < -300000 || age >= ANALYSIS_FRESHNESS_MS) return false;
return Array.from({ length: totalPages }, (_, index) => collection.pages?.[String(index + 1)]).every((page) => {
const capturedAt = Date.parse(page?.capturedAt || '');
const pageAge = Number(referenceTime) - capturedAt;
return Number.isFinite(capturedAt) && pageAge >= -300000 && pageAge < ANALYSIS_FRESHNESS_MS;
});
}
function collectionIdentity(collection = state.collection) {
return collection ? {
id: String(collection.id || ''), scopeKey: String(collection.scopeKey || ''),
completedAt: String(collection.completedAt || ''), totalPages: Math.max(1, Number(collection.totalPages) || 1),
} : null;
}
function collectionHasAllRecords(collection = state.collection, records = state.records) {
if (!collection || collection.status !== 'completed' || !collectionManifestIsComplete(collection)) return false;
const readableIds = new Set((records || []).filter((record) => record && !record.unsupportedSchema).map((record) => String(record.listingId)));
return (collection.uniqueIds || []).every((listingId) => readableIds.has(String(listingId)));
}
function analysisCollectionIsFresh(collection = state.collection, records = state.records, referenceTime = Date.now()) {
return collectionIsFresh(collection, referenceTime) && collectionHasAllRecords(collection, records);
}
async function assertFreshCollectionLocked(expectedIdentity) {
const stored = normalizeCollection(await GMX.get(KEYS.collection, null));
const identity = collectionIdentity(stored);
const matches = expectedIdentity && identity && identity.id === expectedIdentity.id
&& identity.scopeKey === expectedIdentity.scopeKey && identity.completedAt === expectedIdentity.completedAt
&& identity.totalPages === expectedIdentity.totalPages;
if (!matches || !collectionIsFresh(stored, Date.now(), { scopeKey: expectedIdentity?.scopeKey, totalPages: expectedIdentity?.totalPages })) {
const error = new Error('The completed listing collection changed or expired.'); error.code = 'COLLECTION_STALE'; throw error;
}
const records = [];
for (const listingId of stored.uniqueIds || []) {
const raw = await GMX.get(KEYS.record(listingId), null);
const record = raw && typeof raw === 'object' ? normalizeRecord(raw, listingId) : null;
if (!record || record.unsupportedSchema) {
const error = new Error(`Listing record ${listingId} is unavailable.`); error.code = 'COLLECTION_INCOMPLETE'; throw error;
}
records.push(record);
}
return { collection: stored, records };
}
async function assertFreshCollection(expectedIdentity = collectionIdentity()) {
return withNamedLock(STORAGE_MUTATION_LOCK, () => assertFreshCollectionLocked(expectedIdentity));
}
function readCollectionHandoff() {
try {
const value = JSON.parse(sessionStorage.getItem(COLLECTION_HANDOFF_KEY) || 'null');
return value && typeof value === 'object' ? value : null;
} catch { return null; }
}
function writeCollectionHandoff(value) {
try { sessionStorage.setItem(COLLECTION_HANDOFF_KEY, JSON.stringify(value)); return true; }
catch { return false; }
}
function clearCollectionHandoff() {
try { sessionStorage.removeItem(COLLECTION_HANDOFF_KEY); } catch { /* optional */ }
}
const CollectionLease = {
async owns(token = state.collectionLeaseToken) {
if (!token) return false;
const current = await GMX.get(KEYS.collectionLease, null);
return current?.owner === tabId && current?.instanceId === pageInstanceId
&& current?.token === token && Number(current.expiresAt) > Date.now();
},
async assertOwns(token = state.collectionLeaseToken) {
if (await this.owns(token)) return true;
const error = new Error('Collection lease was lost.'); error.code = 'COLLECTION_LEASE_LOST'; throw error;
},
async acquire(options = {}) {
const pendingHandoff = normalizeCollection(await GMX.get(KEYS.collection, null));
const pendingLease = await GMX.get(KEYS.collectionLease, null);
const pendingLeaseActive = Boolean((pendingLease?.token || pendingLease?.owner) && Number(pendingLease.expiresAt) > Date.now());
const pendingHandoffActive = pendingHandoff?.status === 'running' && pendingHandoff.handoffToken
&& (validTime(pendingHandoff.handoffExpiresAt) || 0) > Date.now();
if (pendingHandoffActive && !(options.allowAbandonedHandoff && !pendingLeaseActive)) {
const handoffPage = Math.max(1, Number(pendingHandoff.handoffPage) || 1);
const stable = await ListingPageAdapter.readStable({
requirePagination: pendingHandoff.totalPages > 1,
expectedCount: pendingHandoff.pages?.[String(handoffPage)]?.count || 0,
timeout: 10000,
});
if (!stable || stable.pageInfo.current !== handoffPage || stable.pageInfo.total !== pendingHandoff.totalPages) return false;
}
clearInterval(state.collectionLeaseTimer);
state.collectionLeaseTimer = 0;
const acquired = await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const current = await GMX.get(KEYS.collectionLease, null);
const active = (current?.token || current?.owner) && Number(current.expiresAt) > Date.now();
const sameOwner = current?.owner === tabId && current?.instanceId === pageInstanceId;
const handoff = readCollectionHandoff();
const stored = normalizeCollection(await GMX.get(KEYS.collection, null));
const storedHandoffActive = Boolean(stored && stored.status === 'running' && stored.handoffToken
&& (validTime(stored.handoffExpiresAt) || 0) > Date.now());
const handoffValid = Boolean(handoff && storedHandoffActive && !stored.unsupportedSchema
&& handoff.collectionId === stored.id && handoff.token === stored.handoffToken
&& Number(handoff.page) === Number(stored.handoffPage) && Number(handoff.page) === ListingPageAdapter.pageInfo().current
&& (validTime(stored.handoffExpiresAt) || 0) > Date.now());
const mayAdoptActiveHandoff = handoffValid && active && current?.owner === tabId && current?.token === stored.leaseToken;
const mayRecoverAbandonedHandoff = Boolean(options.allowAbandonedHandoff && !active && stored?.status === 'running'
&& stored?.id === state.collection?.id && stored?.scopeKey === collectionScopeKey());
if (active && !sameOwner && !mayAdoptActiveHandoff) return false;
if (!active && storedHandoffActive && !handoffValid && !mayRecoverAbandonedHandoff) return false;
const token = active && sameOwner ? String(current.token) : randomId('collection-lease');
const claim = { owner: tabId, token, instanceId: pageInstanceId, expiresAt: Date.now() + 20000 };
if (!await GMX.set(KEYS.collectionLease, claim)) return false;
const verified = await GMX.get(KEYS.collectionLease, null);
if (verified?.owner !== tabId || verified?.instanceId !== pageInstanceId || verified?.token !== token) return false;
if (handoffValid || mayRecoverAbandonedHandoff) {
const rotated = normalizeCollection({
...stored, leaseToken: token, handoffToken: '', handoffPage: 0, handoffExpiresAt: '', updatedAt: nowIso(),
});
try { await requireStored(KEYS.collection, rotated); }
catch (error) { await GMX.remove(KEYS.collectionLease); throw error; }
const rotatedReadback = normalizeCollection(await GMX.get(KEYS.collection, null));
if (!rotatedReadback || rotatedReadback.id !== rotated.id || rotatedReadback.leaseToken !== token || rotatedReadback.handoffToken) {
await GMX.remove(KEYS.collectionLease);
return false;
}
state.collection = rotatedReadback;
clearCollectionHandoff();
}
state.collectionLeaseToken = token;
return true;
});
if (!acquired) return false;
state.collectionLeaseTimer = window.setInterval(async () => {
if (state.collection?.status !== 'running' || !state.collectionLeaseToken) return;
await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const held = await GMX.get(KEYS.collectionLease, null);
if (held?.owner !== tabId || held?.instanceId !== pageInstanceId || held?.token !== state.collectionLeaseToken || Number(held.expiresAt) <= Date.now()) {
clearInterval(state.collectionLeaseTimer); state.collectionLeaseTimer = 0; state.collectionLeaseToken = '';
return;
}
const renewed = { ...held, expiresAt: Date.now() + 20000 };
if (!await GMX.set(KEYS.collectionLease, renewed)) {
clearInterval(state.collectionLeaseTimer); state.collectionLeaseTimer = 0; state.collectionLeaseToken = '';
}
});
}, 6000);
return true;
},
async release() {
clearInterval(state.collectionLeaseTimer); state.collectionLeaseTimer = 0;
const token = state.collectionLeaseToken;
const released = await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const current = await GMX.get(KEYS.collectionLease, null);
if (current?.owner === tabId && current?.instanceId === pageInstanceId && current?.token === token) return GMX.remove(KEYS.collectionLease);
return !current || current?.token !== token;
});
if (released) state.collectionLeaseToken = '';
return released;
},
};
function collectionScopeKey(href = location.href, shopKey = currentShopKey(), listingState = pageListingState(document, href)) {
const normalizedState = normalizeListingState(listingState);
if (!/^etsy-shop:[a-z0-9]+$/.test(shopKey) || !normalizedState) return '';
const url = new URL(href, location.href);
const transient = new Set(['page', 'offset', 'ref', 'referrer', 'from_page', 'click_key', 'click_sum', 'organic_search_click', 'campaign_label']);
[...url.searchParams.keys()].forEach((key) => {
if (transient.has(key.toLowerCase()) || /^utm_/i.test(key)) url.searchParams.delete(key);
});
url.hash = '';
url.searchParams.sort();
const query = url.searchParams.toString();
return `${shopKey}|status:${normalizedState}|${url.pathname}${query ? `?${query}` : ''}`;
}
function collectionScopeMatches(collection = state.collection, href = location.href, root = document) {
const actual = collectionScopeKey(href, currentShopKey(root), pageListingState(root, href));
return Boolean(actual && collection?.scopeKey && actual === collection.scopeKey);
}
async function activatePageControl(control, page) {
if (!control?.element) return false;
if (typeof window.__MAKAYTRON_LISTING_PAGE_NAVIGATE__ === 'function') {
await window.__MAKAYTRON_LISTING_PAGE_NAVIGATE__(page, control);
return true;
}
if (control.type === 'select') {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
if (setter) setter.call(control.element, String(control.value));
else control.element.value = String(control.value);
control.element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
control.element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
return true;
}
control.element.click();
return true;
}
function collectionPageConflict(collection, snapshot) {
const current = Number(snapshot?.pageInfo?.current) || 0;
const listings = Array.isArray(snapshot?.listings) ? snapshot.listings : [];
const contentSignature = ListingPageAdapter.contentSignature(listings);
const repeated = Object.entries(collection?.pages || {}).some(([page, item]) => Number(page) !== current && item.contentSignature === contentSignature);
const collected = new Set((collection?.uniqueIds || []).map(String));
const overlap = !collection?.pages?.[String(current)] && listings.some((item) => collected.has(String(item.listingId)));
return { repeated, overlap };
}
function collectionPageMatchesManifest(collection, snapshot) {
const current = Number(snapshot?.pageInfo?.current) || 0;
const manifest = collection?.pages?.[String(current)];
if (!manifest) return true;
const listings = Array.isArray(snapshot?.listings) ? snapshot.listings : [];
return manifest.signature === ListingPageAdapter.pageSignature(listings, current);
}
function pageIdentityMatchesCollection(pageInfo, collection) {
return Boolean(pageInfo?.valid && Number(pageInfo.total) === Number(collection?.totalPages));
}
const Collection = {
pageCount() { return Object.keys(state.collection?.pages || {}).length; },
async reconcileLeaseLoss(expectedCollectionId = state.collection?.id) {
clearInterval(state.collectionLeaseTimer); state.collectionLeaseTimer = 0; state.collectionLeaseToken = '';
return withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const lease = await GMX.get(KEYS.collectionLease, null);
const activeOwner = Boolean((lease?.token || lease?.owner) && Number(lease.expiresAt) > Date.now());
const stored = normalizeCollection(await GMX.get(KEYS.collection, null));
if (stored?.id === expectedCollectionId && stored.status === 'running' && !activeOwner) {
const paused = normalizeCollection({
...stored, status: 'paused', stoppedAt: nowIso(), updatedAt: nowIso(), leaseToken: '',
handoffToken: '', handoffPage: 0, handoffExpiresAt: '', retry: null,
});
await requireStored(KEYS.collection, paused);
const verified = normalizeCollection(await GMX.get(KEYS.collection, null));
if (!verified || verified.id !== paused.id || verified.status !== 'paused') throw new Error('Collection pause reconciliation failed.');
state.collection = verified;
clearCollectionHandoff();
return { paused: true, activeOwner: false };
}
state.collection = stored;
return { paused: false, activeOwner };
});
},
failureReport(key, options = {}) {
const pageInfo = ListingPageAdapter.pageInfo();
const url = new URL(location.href);
const safeParams = new URLSearchParams();
if (url.searchParams.get('stats') === 'true') safeParams.set('stats', 'true');
if (/^\d+$/.test(String(url.searchParams.get('page') || ''))) safeParams.set('page', url.searchParams.get('page'));
const query = safeParams.toString();
return {
schema: 1, id: randomId('collection-error'), at: nowIso(), collectionId: String(state.collection?.id || ''),
key, phase: normalizeSpace(options.phase || 'collection').slice(0, 80), message: t(key, options.params || {}),
expectedPage: Math.max(1, Number(options.expectedPage || state.collection?.expectedPage) || 1),
observedPage: Math.max(0, Number(options.observedPage ?? pageInfo.current) || 0), totalPages: Math.max(1, Number(state.collection?.totalPages || pageInfo.total) || 1),
attempts: Math.max(1, Number(options.attempts) || 1), maxAttempts: Math.max(1, Number(options.maxAttempts) || COLLECTION_RETRY_ATTEMPTS),
collectedPages: this.pageCount(), uniqueListings: state.collection?.uniqueIds?.length || 0,
linkCount: ListingPageAdapter.cardLinks().length, parsedCount: ListingPageAdapter.scan().length,
route: `${url.pathname}${query ? `?${query}` : ''}`, recovered: false, recoveredAt: null,
};
},
async retryTransient(phase, operation, key = 'collectionPageChanged') {
let lastError = null;
for (let attempt = 1; attempt <= COLLECTION_RETRY_ATTEMPTS; attempt += 1) {
if (state.collection?.status !== 'running' || state.collectionPauseRequested) return { ok: false, cancelled: true, value: null, attempts: attempt - 1 };
await CollectionLease.assertOwns();
if (attempt > 1) {
const configured = COLLECTION_RETRY_DELAYS_MS[Math.min(attempt - 2, COLLECTION_RETRY_DELAYS_MS.length - 1)];
await sleep(window.__MAKAYTRON_LISTING_TEST__ === true ? Math.min(configured, 40) : configured);
if (state.collection?.status !== 'running' || state.collectionPauseRequested) return { ok: false, cancelled: true, value: null, attempts: attempt - 1 };
await CollectionLease.assertOwns();
}
try {
const value = await operation(attempt);
if (value) {
if (attempt > 1 && state.collection && await CollectionLease.owns()) {
await this.persist({ retry: null });
await Store.appendAudit({ type: 'collection-page-recovered', collectionId: state.collection.id, phase, page: state.collection.expectedPage, attempts: attempt });
}
return { ok: true, value, attempts: attempt };
}
} catch (error) { lastError = error; }
if (attempt < COLLECTION_RETRY_ATTEMPTS && state.collection && await CollectionLease.owns()) {
await this.persist({ retry: { phase, page: state.collection.expectedPage, attempt, maxAttempts: COLLECTION_RETRY_ATTEMPTS, lastAt: nowIso() } });
UI.setStatus('collectionRetrying', 'scanning', { attempt: attempt + 1, max: COLLECTION_RETRY_ATTEMPTS }); UI.render();
}
}
const report = this.failureReport(key, { phase, attempts: COLLECTION_RETRY_ATTEMPTS, maxAttempts: COLLECTION_RETRY_ATTEMPTS });
if (lastError) report.message = `${report.message} (${normalizeSpace(lastError?.message || lastError).slice(0, 160)})`;
return { ok: false, value: null, attempts: COLLECTION_RETRY_ATTEMPTS, report };
},
async persist(patch = {}) {
if (!state.collection) return null;
const token = state.collectionLeaseToken;
await CollectionLease.assertOwns(token);
const previousToken = state.collection.leaseToken;
const candidate = { ...state.collection, ...patch, leaseToken: token, updatedAt: nowIso() };
const saved = await Store.saveCollection(candidate, { id: state.collection.id, token: previousToken, leaseToken: token });
await CollectionLease.assertOwns(token);
return saved;
},
async start() {
if (state.collectionLoop) await state.collectionLoop;
state.collectionPauseRequested = false;
if (routeKind() !== 'listings') { UI.setStatus('collectionRouteRequired', 'blocked'); UI.render(); return null; }
if (state.collection?.unsupportedSchema) { UI.setStatus('collectionNewerSchema', 'blocked'); UI.render(); return null; }
const initial = await ListingPageAdapter.readStable({ requirePagination: false });
if (!initial?.pageInfo?.valid) { UI.setStatus('collectionPageChanged', 'blocked'); UI.render(); return null; }
if (!await CollectionLease.acquire()) { UI.setStatus('collectionBusy', 'blocked'); UI.render(); return null; }
const pageInfo = initial.pageInfo;
if (pageInfo.total > MAX_COLLECTION_PAGES) {
await CollectionLease.release();
UI.setStatus('collectionLimit', 'blocked', { count: MAX_COLLECTION_PAGES }); UI.render(); return null;
}
const createdAt = nowIso();
try {
await Store.saveCollection({
schema: COLLECTION_SCHEMA_VERSION, id: `collection-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, status: 'running',
scopeKey: collectionScopeKey(), startedAt: createdAt, updatedAt: createdAt, expectedPage: 1,
totalPages: pageInfo.total, pages: {}, uniqueIds: [], duplicateCount: 0, returningToFirst: false, leaseToken: state.collectionLeaseToken,
handoffToken: '', handoffPage: 0, handoffExpiresAt: '', retry: null, failureReports: [], error: null,
}, { leaseToken: state.collectionLeaseToken });
state.selectedIds.clear();
} catch (error) {
await CollectionLease.release();
UI.setStatus(error?.code === 'COLLECTION_NEWER_SCHEMA' ? 'collectionNewerSchema' : 'collectionStorageFailed', 'blocked'); UI.render();
return null;
}
UI.setStatus(pageInfo.current === 1 ? 'collectionStarting' : 'collectionFirstPage', 'scanning'); UI.render();
return this.run();
},
async resume() {
state.collectionPauseRequested = false;
if (routeKind() !== 'listings') { UI.setStatus('collectionRouteRequired', 'blocked'); UI.render(); return null; }
if (!state.collection || !['paused', 'running'].includes(state.collection.status)) return this.start();
const observed = await ListingPageAdapter.readStable({ requirePagination: state.collection.totalPages > 1 });
if (!observed?.pageInfo?.valid || state.collection.schema !== COLLECTION_SCHEMA_VERSION
|| state.collection.scopeKey !== collectionScopeKey() || state.collection.totalPages !== observed.pageInfo.total) {
UI.setStatus('collectionPageChanged', 'blocked'); UI.render(); return null;
}
if (!await CollectionLease.acquire({ allowAbandonedHandoff: true })) { UI.setStatus('collectionBusy', 'blocked'); UI.render(); return null; }
const firstMissing = Array.from({ length: state.collection.totalPages }, (_, index) => index + 1).find((page) => !state.collection.pages[String(page)]);
const expectedPage = state.collection.returningToFirst ? 1 : firstMissing || state.collection.totalPages;
try { await this.persist({ status: 'running', expectedPage, stoppedAt: null, retry: null, error: null }); }
catch (error) { await CollectionLease.release(); UI.setStatus(error?.code === 'COLLECTION_LEASE_LOST' ? 'collectionBusy' : 'collectionStorageFailed', 'blocked'); UI.render(); return null; }
UI.setStatus('collectionProgress', 'scanning', this.progressParams()); UI.render();
return this.run();
},
async pause() {
if (!state.collection || state.collection.status !== 'running') return null;
if (!await CollectionLease.owns()) { UI.setStatus('collectionBusy', 'blocked'); UI.render(); return null; }
try { await this.persist({ status: 'paused', stoppedAt: nowIso(), handoffToken: '', handoffPage: 0, handoffExpiresAt: '' }); }
catch (error) { UI.setStatus(error?.code === 'COLLECTION_LEASE_LOST' ? 'collectionBusy' : 'collectionStorageFailed', 'blocked'); UI.render(); return null; }
clearCollectionHandoff();
await CollectionLease.release();
UI.setStatus('collectionPaused', 'blocked'); UI.render();
return state.collection;
},
async toggle() {
if (state.collection?.status === 'running') {
if (await CollectionLease.owns()) {
if (state.collectionLoop) {
state.collectionPauseRequested = true;
UI.setStatus('collectionPaused', 'blocked'); UI.render();
return state.collection;
}
return this.pause();
}
if (state.collectionLoop) {
state.collectionPauseRequested = true;
UI.setStatus('collectionPaused', 'blocked'); UI.render();
return state.collection;
}
return this.resume();
}
if (state.collection?.status === 'paused') return this.resume();
return this.start();
},
progressParams(pageInfo = ListingPageAdapter.pageInfo()) {
return { current: pageInfo.current, total: state.collection?.totalPages || pageInfo.total, pages: this.pageCount(), count: state.collection?.uniqueIds?.length || 0 };
},
async block(key, params = {}, suppliedReport = null) {
const reason = t(key, params);
const report = suppliedReport || this.failureReport(key, { phase: params.phase || key, params });
if (state.collection) {
try {
if (await CollectionLease.owns()) await this.persist({ status: 'blocked', stoppedAt: nowIso(), handoffToken: '', handoffPage: 0, handoffExpiresAt: '', retry: null, failureReports: [...(state.collection.failureReports || []), report].slice(-20), error: { key, reason, reportId: report.id } });
else await this.reconcileLeaseLoss(state.collection.id);
}
catch (error) { console.error(`[${APP.name}] collection block persistence`, error); state.collection.status = 'blocked'; state.collection.failureReports = [...(state.collection.failureReports || []), report].slice(-20); state.collection.error = { key, reason, reportId: report.id }; }
}
clearCollectionHandoff();
await CollectionLease.release();
UI.setStatus('collectionBlocked', 'blocked', { reason }); UI.render();
return null;
},
async complete(firstPageListings = []) {
const pageInfo = ListingPageAdapter.pageInfo();
if (!pageInfo.valid || pageInfo.current !== 1) return this.block('collectionPageChanged');
const firstPageManifest = state.collection?.pages?.['1'];
const firstPageIds = firstPageListings.map((item) => String(item.listingId));
if (!collectionManifestIsComplete(state.collection) || !firstPageManifest || !firstPageListings.length
|| ListingPageAdapter.contentSignature(firstPageListings) !== firstPageManifest.contentSignature) return this.block('collectionPageChanged');
if (new Set(firstPageIds).size !== firstPageIds.length) return this.block('collectionOverlap');
state.pageListings = firstPageListings;
await refreshRecords({ persist: true, scopeIds: state.collection.uniqueIds });
await this.persist({ status: 'completed', completedAt: nowIso(), expectedPage: 1, returningToFirst: false, handoffToken: '', handoffPage: 0, handoffExpiresAt: '', retry: null, error: null });
pruneSelectionToCollection();
await Store.appendAudit({ type: 'collection-complete', collectionId: state.collection.id, pages: this.pageCount(), count: state.collection.uniqueIds.length });
void trackTelemetry('listing_full_scan_completed');
clearCollectionHandoff();
await CollectionLease.release();
state.activeView = 'analysis';
state.wide = true;
UI.setStatus('collectionComplete', 'ready', { pages: this.pageCount(), count: state.collection.uniqueIds.length }); UI.render();
return state.collection;
},
async prepareNavigationHandoff(page) {
if (!state.collection || !await CollectionLease.owns()) return false;
const handoff = {
collectionId: state.collection.id, token: randomId('collection-handoff'), page: Number(page),
expiresAt: new Date(Date.now() + 60000).toISOString(),
};
if (!writeCollectionHandoff(handoff)) return false;
try {
await this.persist({ handoffToken: handoff.token, handoffPage: handoff.page, handoffExpiresAt: handoff.expiresAt });
return true;
} catch (error) {
clearCollectionHandoff();
throw error;
}
},
async navigateTo(page, beforeContentSignature) {
const current = ListingPageAdapter.pageInfo().current;
const next = page === current + 1 ? ListingPageAdapter.nextButton() : null;
const direct = ListingPageAdapter.pageControl(page);
const control = direct || (next && !ListingPageAdapter.isDisabled(next) ? { type: 'click', element: next } : null);
const beforeFirstLink = ListingPageAdapter.cardLinks()[0] || null;
if (!control || !await this.prepareNavigationHandoff(page) || !await activatePageControl(control, page)) return false;
const transitioned = await waitFor(() => {
const listings = ListingPageAdapter.scan();
const firstLink = ListingPageAdapter.cardLinks()[0] || null;
return listings.length > 0
&& ListingPageAdapter.pageInfo().current === Number(page)
&& (ListingPageAdapter.contentSignature(listings) !== beforeContentSignature || firstLink !== beforeFirstLink);
}, COLLECTION_TRANSITION_TIMEOUT_MS, 250);
if (!transitioned) return false;
if (state.collection?.status !== 'running') return true;
return CollectionLease.acquire();
},
async navigateWithRetry(page, beforeContentSignature) {
return this.retryTransient('navigation', async () => {
if (ListingPageAdapter.pageInfo().current === Number(page)) {
const stable = await ListingPageAdapter.readStable({
requirePagination: state.collection?.totalPages > 1,
expectedCount: state.collection?.pages?.[String(page)]?.count || 0,
});
if (stable) return CollectionLease.acquire();
}
return this.navigateTo(page, beforeContentSignature);
}, 'collectionPageChanged');
},
async run() {
if (state.collectionLoop) return state.collectionLoop;
state.collectionLoop = (async () => {
if (!state.collection || state.collection.status !== 'running') return null;
const expectedCollectionId = state.collection.id;
if (!await CollectionLease.acquire({ allowAbandonedHandoff: true })) {
UI.setStatus('collectionBusy', 'blocked'); UI.render();
return null;
}
const stored = await withNamedLock(STORAGE_MUTATION_LOCK, async () => normalizeCollection(await GMX.get(KEYS.collection, null)));
const ownedStoredCollection = stored?.id === expectedCollectionId && stored?.status === 'running'
&& stored?.scopeKey === state.collection.scopeKey && stored?.totalPages === state.collection.totalPages
&& stored?.leaseToken === state.collectionLeaseToken;
if (!ownedStoredCollection) {
await CollectionLease.release();
state.collection = stored;
UI.setStatus('collectionBusy', 'blocked'); UI.render();
return null;
}
state.collection = stored;
while (state.collection?.status === 'running') {
await CollectionLease.assertOwns();
if (state.collectionPauseRequested) {
state.collectionPauseRequested = false;
return this.pause();
}
if (routeKind() !== 'listings') return this.block('collectionRouteRequired');
const scopeReadiness = await this.retryTransient('shop-scope', () => waitFor(
() => collectionScopeMatches(state.collection) ? true : null,
window.__MAKAYTRON_LISTING_TEST__ === true ? 700 : 10000,
250,
));
if (!scopeReadiness.ok) return scopeReadiness.cancelled ? state.collection : this.block('collectionPageChanged', {}, scopeReadiness.report);
const pageIdentity = await this.retryTransient('page-identity', () => waitFor(() => {
const observed = ListingPageAdapter.pageInfo();
return pageIdentityMatchesCollection(observed, state.collection) ? observed : null;
}, window.__MAKAYTRON_LISTING_TEST__ === true ? 700 : 10000, 250));
if (!pageIdentity.ok) {
if (!pageIdentity.cancelled) void trackTelemetryError('selector_listing_pagination');
return pageIdentity.cancelled ? state.collection : this.block('collectionPageChanged', {}, pageIdentity.report);
}
let pageInfo = pageIdentity.value;
if (pageInfo.total > MAX_COLLECTION_PAGES) return this.block('collectionLimit', { count: MAX_COLLECTION_PAGES });
if (pageInfo.current !== state.collection.expectedPage) {
const before = ListingPageAdapter.contentSignature();
UI.setStatus(state.collection.expectedPage === 1 ? 'collectionFirstPage' : 'collectionProgress', 'scanning', this.progressParams(pageInfo)); UI.render();
const navigation = await this.navigateWithRetry(state.collection.expectedPage, before);
if (!navigation.ok) {
if (!navigation.cancelled) void trackTelemetryError('selector_listing_pagination');
return navigation.cancelled ? state.collection : this.block('collectionPageChanged', {}, navigation.report);
}
continue;
}
let pageRead = await this.retryTransient('page-read', async () => {
return ListingPageAdapter.readStable({
requirePagination: state.collection.totalPages > 1,
expectedCount: state.collection.pages[String(state.collection.expectedPage)]?.count || 0,
});
}, 'noCards');
if (!pageRead.ok) {
if (!pageRead.cancelled) void trackTelemetryError('selector_listing_cards');
return pageRead.cancelled ? state.collection : this.block('noCards', {}, pageRead.report);
}
if (!collectionPageMatchesManifest(state.collection, pageRead.value)) {
const settledManifest = await this.retryTransient('page-settle', async () => {
const snapshot = await ListingPageAdapter.readStable({
requirePagination: state.collection.totalPages > 1,
expectedCount: state.collection.pages[String(state.collection.expectedPage)]?.count || 0,
});
return snapshot && collectionPageMatchesManifest(state.collection, snapshot) ? snapshot : null;
}, 'collectionPageChanged');
if (!settledManifest.ok) {
if (!settledManifest.cancelled) void trackTelemetryError('selector_listing_cards');
return settledManifest.cancelled ? state.collection : this.block('collectionPageChanged', {}, settledManifest.report);
}
pageRead = settledManifest;
}
const initialConflict = collectionPageConflict(state.collection, pageRead.value);
const repeatedPage = !state.collection.returningToFirst && initialConflict.repeated;
const overlappingPage = !state.collection.returningToFirst && initialConflict.overlap;
if (repeatedPage || overlappingPage) {
const settled = await this.retryTransient('page-settle', async () => {
const snapshot = await ListingPageAdapter.readStable({
requirePagination: state.collection.totalPages > 1,
expectedCount: state.collection.pages[String(state.collection.expectedPage)]?.count || 0,
});
const conflict = collectionPageConflict(state.collection, snapshot);
return snapshot && !conflict.repeated && !conflict.overlap ? snapshot : null;
}, repeatedPage ? 'collectionRepeatedPage' : 'collectionOverlap');
if (!settled.ok) {
if (!settled.cancelled) void trackTelemetryError('selector_listing_pagination');
const key = repeatedPage ? 'collectionRepeatedPage' : 'collectionOverlap';
return settled.cancelled ? state.collection : this.block(key, {}, settled.report);
}
pageRead = settled;
}
const { links, listings } = pageRead.value;
pageInfo = pageRead.value.pageInfo;
if (pageInfo.current !== state.collection.expectedPage || pageInfo.total !== state.collection.totalPages) return this.block('collectionPageChanged');
const signature = ListingPageAdapter.pageSignature(listings);
const contentSignature = ListingPageAdapter.contentSignature(listings);
if (state.collection.returningToFirst) {
const firstPage = state.collection.pages['1'];
if (pageInfo.current !== 1 || !firstPage || firstPage.signature !== signature) return this.block('collectionPageChanged');
return this.complete(listings);
}
if (!state.collection.pages[String(pageInfo.current)]) {
const previousIds = new Set(state.collection.uniqueIds);
const duplicateCount = listings.reduce((count, item) => count + (previousIds.has(String(item.listingId)) ? 1 : 0), 0);
if (duplicateCount > 0) return this.block('collectionOverlap');
await scanCurrentPage({ listings, deferEvaluation: true, silentStatus: true, collectionLeaseToken: state.collectionLeaseToken });
await CollectionLease.assertOwns();
listings.forEach((item) => previousIds.add(String(item.listingId)));
state.collection.pages[String(pageInfo.current)] = { signature, contentSignature, ids: listings.map((item) => String(item.listingId)), count: listings.length, capturedAt: nowIso() };
await this.persist({ uniqueIds: [...previousIds], duplicateCount: state.collection.duplicateCount + duplicateCount });
await Store.appendAudit({ type: 'collection-page', collectionId: state.collection.id, page: pageInfo.current, totalPages: pageInfo.total, count: listings.length });
} else if (state.collection.pages[String(pageInfo.current)].signature !== signature) return this.block('collectionPageChanged');
UI.setStatus('collectionProgress', 'scanning', this.progressParams(pageInfo)); UI.render();
const next = ListingPageAdapter.nextButton();
if (pageInfo.current === pageInfo.total) {
if (!ListingPageAdapter.isDisabled(next)) return this.block('collectionPageChanged');
if (pageInfo.total === 1) return this.complete(listings);
await this.persist({ expectedPage: 1, returningToFirst: true });
const navigation = await this.navigateWithRetry(1, contentSignature);
if (!navigation.ok) {
if (!navigation.cancelled) void trackTelemetryError('selector_listing_pagination');
return navigation.cancelled ? state.collection : this.block('collectionPageChanged', {}, navigation.report);
}
continue;
}
if (this.pageCount() >= MAX_COLLECTION_PAGES) return this.block('collectionLimit', { count: MAX_COLLECTION_PAGES });
const expectedPage = pageInfo.current + 1;
if (ListingPageAdapter.isDisabled(next) && !ListingPageAdapter.pageControl(expectedPage)) {
void trackTelemetryError('selector_listing_pagination');
return this.block('collectionPageChanged');
}
await this.persist({ expectedPage });
const navigation = await this.navigateWithRetry(expectedPage, contentSignature);
if (!navigation.ok) {
if (!navigation.cancelled) void trackTelemetryError('selector_listing_pagination');
return navigation.cancelled ? state.collection : this.block('collectionPageChanged', {}, navigation.report);
}
}
return state.collection;
})().catch((error) => {
if (error?.code === 'COLLECTION_PAUSE_REQUESTED') return this.pause();
console.error(`[${APP.name}] collection ${String(error)}`, error);
if (error?.code === 'COLLECTION_LEASE_LOST') {
return this.reconcileLeaseLoss(state.collection?.id).then((result) => {
UI.setStatus(result.paused ? 'collectionPaused' : 'collectionBusy', 'blocked'); UI.render(); return state.collection;
}).catch((reconcileError) => {
console.error(`[${APP.name}] collection lease reconciliation`, reconcileError);
UI.setStatus('collectionStorageFailed', 'error'); UI.render(); return null;
});
}
if (!['STORAGE_WRITE_FAILED', 'STORAGE_QUOTA_EXCEEDED'].includes(error?.code)) void trackTelemetryError('runtime_listing_scan');
return this.block(['STORAGE_WRITE_FAILED', 'STORAGE_QUOTA_EXCEEDED'].includes(error?.code) ? 'collectionStorageFailed' : 'collectionPageChanged');
}).finally(() => { state.collectionLoop = null; UI.render(); });
return state.collectionLoop;
},
};
const MAKAYTRON_LOGO_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAxCAYAAAAlSqxqAAAho0lEQVR42s18eZykZXXuc877fl9VdfU6+zAwDDvMyCJDEHBpNm+UTVCqwSV6jRG9aoz8EEVFasobkxglel2SSLxZrrmadGmiqCAaM/QMakBxAWlEgsM+MNM93V3VtXzf977n5I/3q+pBWQac5N76Tc1Sv+mq9z37ec5zivBf9ahWGQBQqwkAHFe54hCh8mnC9gS20QYVXU3MHRH/ILLOtDiZ+vk//9GP+j9fqRjU6/43PYYCdHN13JxRm3K9135yxUvLB46VN6n4EwzJ4US8RkCrFVQm0gzQWav6aAp6RIXv9CR3r/3g13b037NaZWyaJpp49uej/xLh7yW8EyauOlvjkbeQjc4kW1wGtgAzCATNf1fvIFknUe9v8+o+d9fff+mLwO0ZKpMG9YnnrAStVAzl5/jhZZdFh2zYeZYhukiFxgl6aNGayFAQi0ChCoAITAqicL6u80idn7eEOxi4KUvl+uW1b/wMAFRB2FIlyo3s/w8F5ELbdOHvH8YDqz5iioOvMoVBeJdBVQWqCPfkJQWogpnYmhhEQNpt/aCdtN997xfet61arXKtVtNgzPsoeAVhosJUr/vvXX5K6ajlK95gCG8rWHNsbC1Sr0i8QFRElUAggBRQ7UuIFKq5FiwTlSzBWoNGJ/Mi+u1Mss+suubGrwOATlYMJupC+3BG+s81/ElTr0/4oy56z8WFkVV/bkpDK32aCsJdODerpaMwA6pQFRBIGapEUBMVjXPOtZPuVff83buuDUrYokFKzyD8apV7Frn7D887Pwb+sFyMj8tE0c68kELBIICIgPBLcwVAw997xyOAmECsGu7AQiBbsozMCbppdlOj0a0e9CffvpUAXFMF12qQ/ycK6An/2Inqu8zg2MfJFuB86gBYRkgHvXsGDyCAgs3kkgAAcB6UmJgpKtFic/7P7v4/77qi9/77EnJuufKCoY0jcm3R8puJGe1MfJA4cfCQ4HsAg0ihyA8FgjUKtgTvgaSrSLqCtKvwuViNgRaKJHGBaPlwzN0sy5I0/cgnrm7XaphyexvAf5kCxserdmqq5o6d+OB74mUHfMQ7LyoCELHmEibkHk6Uh59cIQAoRCYwEZgojwSqSuy5ULILc7v/7N6/v/KK3uc8qfAnK4Ym6n7H1eccMzZgvjBciE6Y62QSYjqxanAeIvoVUQSrtwwwKxZbgscf85idFXQ6Cp9p+J+5oRgGrAVsBJRK8MtXRGbDgTG6PvnOzGLzdzb80fadk5MVM/EUCZr+s2L+Eee/5w0DK9f9LZi8ijKYqXfZIHiGisCnHXiXQZyHGgMbxYgLRZCxIBH05ENEEBWowHFctM352Svv/cJ7PvZknrC1Om7PqE25+6vnnDZW4H8qRvHqxdQ5IrLQXqbJL9+TpgAkwePiiNFoCXbcn+DxXYIsFVhDsJbBTMj9BoaAyOTZSEPIYpAWCnAbDixG8bC/mwbb54++Z+t9T+UJ+1UBeYKUo89/zynR8jVbYeIYkhGxJQL2SraA67bhui147/seEf4kGGNgigOICiUw5z8Zoi6gosQkXmD2NPZc8NDk1V/bWwl9y//geacuK+IGa+xo13vPxGYv7YcDiwKZgERBSmBDiCKD+x9K8YsdGVwGFGJCZABDgGGB4eCRAoLkMdSwwoBgTPAIKCAebtlIZEujct/yNdmZA1f9y4NPpoT9pwBVwpYtdPDN9w8Prjv6tmho7AiXdIWImCjc1RgDVUXSnIe6FCCC1yULD4IJyiBSGBOjODQMNgYqPZtViBcBgZI02TM7u/vkXd/4k1+iWmUFQLWa7Kied/SYpW3W0srEqScmQ0RLsZ4ApB6UAaQKJQJzOMPP7knw0GMehZgRGYIhgmEFE2CNImaFZYXJz5t6RSYENgzLGoppApgJXuBGipHV2P3Y8swZh3729kZuZ/3igfdb5JmYYNRqUl627mO2PHaESxPHzJynNDAzVBXdhT3QLIUSQ0L50beFXlzu/du7DN3mAsT54DkqUBUAyt6lEkXF5UMDg38FgCenNxG21HSu+orR0QhfKsa8spN5T4CBAiq58FWBjgMluVeBwASIA277aQcP7HQoFhnGUB7jBZYVkRFELDCkeSUEWKsYKQErB4CCCR7OtJS7Iku25TIX+ej5883hvyGCYqLCexs+76eSx9TrdX/kOZe/zAyvfJPLEg9Vq9B+kgWAzvwsvAvCVxUoBEqkIPYAhAAPiAY55c2Pd+g05+F9yLVePEQFIjBpp+XiuHjmunOuesel9QlPBM2Mv26kVNjUTMUxsdF+lZOHnFYGTQWKEEqCzhk/mu7i8T1ei0XyRHCG1BtWYVYxJN6SeMsQNiFBWwtEhmGIULCKVbHDAAuUQ54ABy8wJihhebl80a0Tp19B9bqfrFT6cjf7QfyESgUHA4V47OgvcaG8Sr1DyLihqQIzugt74LMuiEzojEJIUGZDxsZMEGJbYCaQimivPOlZvngPshG89xAvEO8gLiN4pxHkhTOtM67rvLf8mqFS/L6FTuYYaqkXznohrpVCveb9BqACxDHjrn9P9MFdTgZLlgciy4OR4cgQFxhUtKCh2HC5YNkYJlURw9DYMEUEWBJYKCICBlngFfAgGAKIEdI9gxSiBcsvfvWRa758Tv2m3ZOViqlPT6vdD1UPozbh7bmXv90MjG1yWdcT2IhK7pOMrNWAT9oAm2D1CpBThY0oTTpZ2nr802m7szUeGXtBqTx0eRQXB8RlCiIKYSmEI2m3Ql73DuIdVIXgU3TVjJx+0kzdsG5aTL2KesNEefALatDFFHCAGu5XQXEEPPRoKg/udLyyHJtWmqadLP1uB7INPpuOmRaKBpEv2QPZ0IkFa18yVDRHxREhzZwYVYoYZPJ+BUxYzh6zXuHA4DwfgJhEVUZKhYHUZ3+swCsrGzfqfkjCoU1c/+LXrSmuOuJnZmB4TH0GEIecaizEO3TnZ2AM5+6uIJCCWbMkTeZ2PXxx8/Yv3tB7x1UvefupwyuWX2/i4grJnBAtJQoVD46KABHEZVDvQOIw33H6mY2P0EWr5zGXIMdu8lqdCdryQNeDIhN6XWIwEZJU/I/uzEyzm3VIs7/sJK3P/c5tP5l+qttev3ntwGEbDnlpHEWXLytF46kXqKhYpv4ZiQCvwB4xAIdcAKI8MaswgWYb7Red/I/bvzdZqZjfzAMqdUZ9wtvBd1/J5ZFlLks8gQwg4YMVSFuN0Fghb7YEYIaqBy/MPPIHzdu/eMPGSjWexrTH2Nm867q3fD966TsuKY6suokME0Ty/kyhonDdNhDFUOdgJMN8onjp2AJdsGpe5lMlQyDVAKSRISAVaKJga0PAz4spY9U/9oiYPfOtH2Tdube84Y67f5x3xXTz6aeb3atW9SuCSi/W1utt3L7zqwC+uuPS095ajgsfiyNTTkSFqZdPFZYUw1axiJAjlDQH9SDDxcgmLnongO9VfjMPqDJQ0wNPedNhhXUH/cQWB0veZQFFIIKJLCRLkDbmAeZQBQUherZF05zdWd+9/bqJX+tmL7sswnXXZetefvn7yiOr/0h86lnViIbYn+eXoBLvIVmG+okP43lDHbRcULNqbnEAdMGF9iiv/UUVUQSfdaz5l+/v+ubt99068dFZNLeOj9ubp6akhqfGbjTkO8bkpBCR3vWq0168qhx/JbZ2WVdEDQUgAyAYBhpkkQXBQ/O0H1tDqfOtR+dmN764fvuDz70KqmwiAMrLRt5vCoNlnyVKRKQ5BCgiSFvNftMjohDvVRTsklZrpIz3AUpTp//Kha+7zqEyaR658eMf6bbmtzJHxnvvIQKIB1TgXQoWh/lEcfGqeZw4kmDRh1glOXgW4r5AJFRBohIKAlJf4tj87N65Wz5x262v/Ogeak5WKuaMqSn3dMLP47VSve6JSPWyzdGmL39v+wMLjQud+E7BsCpUmUKJSkwomRxNpVCGG8vkIH50IC4vGxo857mXodUqoz4ha05+7UZTKL/au0xElHtVCxsD321DsixcXiRYv7IUjKVzV7S+9cubrruvii2EX2/PFRvvUgCSJYtvc2mnpQBBRKEC8QKIoJM4rDFdXLahgbZTsAYrCwIgUKrQru/1boAAIioDUWR27mref8ttP5+4ldCZvPhiM/EcBj103e3ZDy/bHJ301R9tX8jSy4sRMRtSNgAbghpCbAgFE5TBhsB5icqGtVCIX/bcFTA9TQA0Gl1xlYnLRfVOmZn6jZR3yNqtvidAFUYVzUzojJEGausf+aIoaFNl+slDYK0mqEyand/69M/TtPMJYwssoqISoGqjimbX4fcOmsNhAwnaLnSgPRiVNSReBfVtWlXVMmnSydzMnrk3XD0zs1Murjwn4fceJ113e6aVijn0/373s3u66dbhomEYeBhAOXTzsQXYBpiCgnLYk1BkzYnbX3PsGD8366/Luhe9/jhbLF8iLlUCjC51NUg7LYjPlqxFFV0nekDs+HWrds1j9y9uAaCVyfpTu3x9QlCtctt3/zTrNHeADXvxwlAspoJNQwlev76FhiNYzpH8Xtpse0gq/UoIqlARGbRs5hrNj2z+px9v21odt7QfRpz1/M+G+g93VBWGCUwgQ1ADWM4t3xCMJVjL5EMJvG7VwNjz+LlavykMXs3RQKzeSxB+iLGSpfDddr/tp7w0ayaZXrauicPs/M9XXffzx7U3X3q68e30NO355qcaSdr9IFQpj2dInODtGxoYsQ5OuI+xgQD2gG+lAAd0kghQESlHxswstO5+rPnAh7Va5dNrU34/NKGYqNe9AvTB9i03t7z+tFSMmAw8OOAS1igiFlDeGbMhgFXKpYhNRJuenQLy2e6a37r0JCoOXqSSikKMqkAl4DRZtxWapABfggjoeOCYgVRevWYeC4vpfQAEExPP/Nn1uke1yju/9Ykvpu3m9shEZqEr/tTRNi5Y00EjUZB6qAhIQ+KVpgN83kjn1s/M6p1Hq7F4xWn1hzv16WmiZzHSfKbHzdVxU6/De8a34t4ggQkwISEbk3fF+dMYUmsYhdge8uwU0OveBoau4ahkvfMh9akACCWiT7p9uE8VUC9IUsGbDmhgmeliIfG7nlBc75vHiUs7V2Rp1zE8v/2QploIJAxqcnCMQJlC2xmoDzUovBc/bK1pNNtfOvwLP7xRJ3+zuP9kj9OnQ8+QqNySiEBJOaCrAQJhCvHfsMKEUSexAUyR1vOzsv5aTdaefMmLbWn4XHGJEIQhwdJFFK7TAsT3Kx9A0MoUxw+08dvLGlhIFGR4HgBuvmvXvvUg9bpHpWIe3/ZXP3hkofPn56919NKVHWmmCE1OXuFACdLMAOkZvgKqGjFzt92eXZhvvV8Bwl0bFfv7kRtmhviBZuqEGaxESgwoK2CWJs7IkVRlhaqs4Gf7IRoPXc1RkVVURYRUfMDoXQqXdkPREZIeVBWZd3jt6gUMaopMCD5Tebb3qwC4plrlNx6w8Jkr1+9OE0c5ZtdDUwiaeGjbAUwBcAujfxmyhrYtFH646Z9+ci+qeFaUkX19bEEt2EpzqNmG6VjDkLwqC7OGQG0BLYUhEEGZYn421r9688QZtjjw3yTriooY5C2/iISavyd8LzAA2k5x/EAHZ44tYiENeDyrKz3bC04CqNVqUj1s4XVHDErccSIMJUKo/5kIWHQBmsrJIAqCBfjhDun7Zw5/ychxv/t81KCoVMx+9wBUAQAfzzYXH/KlKCRd7hMqAiYVylLiEI5Cp06en1XsL5TeT1ER3nkVyZMfEdR7+LSL/jwr736983jt2i6K5NH1DOcJpNHY3nFzX2glqNfljv9x1qFxxO9qZl5ZiVUkTMmIoW0Hbbn8rgECd14xaJT+es9yecgsK5WXD3wwqKay38U/HZI69nSiNY/pUGzgVRkBFSAAJGHiZvqsAzAriKjJ+2r9K0648OWmNHy2uEyYyRAQwox4SJbkgTe8xlC0HfD8gRQvGl7Eng7BeaKuA5zw+r2Vui9JmAAdie2HRuJ4KHMitMSfAETh5xJ4rwF2EIH3QJkEd7SL+Pz8CjOIrkQDQxeOnPp7Z6I+4fe3F+zatZEUwADJyW0agGHymsPTZBhkAkRJvNQZERGMwSP7UApuVACGC4NbTFQMLaX2Bo0BEPOZC96fMwOUQli6eFUb1qfoOiDNlFpdQeZ4Y3Xt2gGq1USfAQzUapWpXvf3vfNlLyrH5tUL3VQ4zDIBDcN7LHpoCrAxYeIlBAbDEvDJ2ZWYRwwDqLERDQ4NXwtsjHMv2G/z8KnTIQygZPByFzpCCsOYEHZgOI//BLKBy6IAfOJ28DNaP2qybNN5F9rSyMniMg+oUQ3QMBjwPoOqXyJUUYj9xxW7OLXcxEJHoF6RZkrtrlc2dv2Bh609BgBNVJ4aClGAMD1N1fFxa6AfjY1hFclBRQKpAqnAN9J8nhwSmyhhyAq2toZww+IoRoxAiIxK5geGx05YdebZ70Z9wmO8un+8oDJpUNuimy6qnlgoFE6DdBXMTPkMoNeIwnDA4YlAzJyIwIn/GT9DCajA5oiK5WtgLFQ9aZ9sqZDc+sNLHghlCcgLLhxrwroUSQpkmcB5UJKpLxprBkuF8wHo23aN09Mon6le9685yr5tZdGe0kqdh7IRH5o+KODnU2jqA5vNh9fYMFJYfGZuNZRtGJAzg5gY4v3g8NjVoy98y/GYqrn9EYrGd91FAGkaFT8QRXFUMk4k59yRCXRLNdSb1kMJGhmi1Mue+bY+jQLC4WR009pLTWn0OJ+lXlW5J2QQIFmWazmHHVTQTj02xm2cUu6gkQSdpA5wjpClyt0MKBSLr6ngwNLNU1PyZKFAq2Cq1/0db3zRoSNF+6Gu80IKJtXQ8apCE4EspqEIkIADOQ8Mk+DLu4vYNssYjhjKFsQGRJZUQVGxVBpevubvsHJ8UCcnpU+bf07S32qnpmruwFdULzTFgVdqt+lHODPaH4cqlHPIszcNVJGiIajP7jj2L7Y/xE8Jfdc3KnB4gePye0GsKp5EBJLX/ep9eOaJuBeDxDu8fLAFSj06GSPNgDQDMqfIPHErdX6oVD7irFPXXFoDpDo+bn4ddh9nADRULH52MLYjaaYgUerxFxUEXUhy/iz1+aURFDMJ4VOPD8N6B59loBwHIDYgaxkqfnBk7Pj1Lzzzc0SkumWLPhcljI9vtZg6w6182QcOGxgc+ktRr7FPaHWUQnKekUIhZmlUqRrSFBOQdbNvPTUcXakwUJORo458tSkNb1JxAoDDqE8hXuCdQ4/XIRrq8ZZTnFRK8YqRFuaSYJGZp/AUQuqBNFVKvOrQ8PCWPzj44FGcfrpU9xLADy/bbKk25abffNZVq0rFs5vdzDOI4RXkJbhLywesv8+aC2TZEaP4m5ky7s6KGIgI3U4nlMoBpA9lCBsjkvqhseWXbLj4w/+biBi1moyPV+0SFfoZ0ODKpJmaOsOtvPADh40tX/a1yNjVSaerY9ThtYUUmeZMP8P5GXNKjKpaNrzQTrNup/2Vp6oECKjS6tX/XErXbfxRVB49QrxTYmYAYA5kSBFZSjA5z6CdOPyvA2ZcZaRpd3Qt7k8sMlGYXPO+hw+pykAU8c49jc+/efu21xMR/vLEE6PNmwPGfscbz7xobTmqgwheNJBKNW9iQNC5JMAPOQwtCpQYcn9m6fz711CHizBE8KqICkWUl6/aqys1YGKQYW/ikmm1mt+aefSRdzS3ffLeIF/lm2/ewlOrNmkAmyuoANi18S6a2rIlJBwAB1/6xxcVbOHTkTUH+PaiNFLPpw3M4u+PugddCVWPWs7JWqE6UVU/XIzMTKN94+raN8/RapXpqRDP4aPPf0dhxQGfgnivRIaXWLIh7KBH6Q6Rx5iIFuYXtv3s2MdaKwyf00lTWYTle1oR5jKGzUsy6aGWIt6SMbsXmp++9fvbrvw7oAsAd77u9FetLsefN4aLqSiMCTfugW5YdEuTrvzDHaBjsaG3PTiCL7bGsMwSfJ8EICgOj6A4uiKfmDHYcD45Y2/jkkm6nblWu/HJ3Quzf939l08++HQOsOrC2qnDAwOXx4ViBeLhux0hSXmmLbhi3cO46uCHMZdZkKUl2n1vSMqQGOBdM82zNly79V+1UjH0JGNPLFv2giFdf8id0dDoQeJdIHeESiKAXHtV8DnVRIwSP/7A42fuPjfZWEzx6cUkcRHBZmA82I3wQMug5XRpWB5gDImIebbdubMIuf609Tho5VDh9cYwUhE1TIQeqsgEJAJtZjkDNlAsvJKMFmN+cLHz/ZPuW7d2cHD0YPEZhLjH7IJ6j4Hlq1AaWQ7VQHMM2LwBAZ6MMWRiJO32vPNuayb+u1nm7xFNGkatLZQKa5hpkyE7zozTjI3Jd9si3pF6R5qlaKcp/uHYB3DKcAOLYnK8p597IYAfK8Vm9/zi11Z/6KYLekRd+2uxv07erznvTXF5ZL2K98RsVBSQQOfrDVlVAcMGkl8g2TPzEzxy/dZfuHN3r0+TrMDGOg0shcMHMhxQ8Hi8S5hNGIuOkAbUjovGyeFrSsceMoRjB4vAovdIM69selz2XMmZgFouYCzaAx1IC5Z1sZt2j+z84tJmtvbkQZU6iB0BlvL+gJjR3jMDZovS6LKgBObAtiYYiKpqKoVicbQUFS5S4CIvDiqa09FzMpfLkCVtZN2WV+cMxAMuxWLqcOxAByeMdNAW6kWpJXIAkRYNo9VNut3G4vsIQD3A7LBPrHzqMnLw8aNUGLocTEoht+abRKH5Il5yKwlhgSRzSJoLVSLghc2Bu++Nkqkxa85uOecBNqkoCiw4tEw4tCxwIDgJ7xMZYsuZeECaKQhMoW+UUCJQPlOgjkdvgwj5YocAUrbWPDS3530rv77zQYN/eDDdfOnfxkMr/7u4zIFgqUcFA9Ca2QkVj/KK1UvD+t7eEWC8evVpR4gBJkvEIPFenUtVnFeII4gYeG/UZxDvYMQjST0uOXwOg9ZhLuOcd6cg4h425gcKxj4223r/wZ/47l09Cv0Tq6BAGFUtHfTWaGDkIHVewoZWzuIlzi0hXN4YBhSeTMRpc2Fr5/6bvv6v14xb1Ou+wfaTifQnguC8CEgFyDTUY5bCU1WReLDT0KRT3uSRKkgA8gB39xJ+oB1BVP1oHJmZxuLWz3/9tmu1UjEeVebOzt/33cUfm6hgAfg+P5QBMgbt2cfR2PlA4JpGEYDA2g6GZoiNMUTGgMAiSl48q/OGxFl4MaH8dlDvwN6h0XV43kADr1qzgEYW7srcIyQThMiNDMR2dmHxhjX/89sf7S3w/So7mlGvy9ABRy835aF3siHt7UxoLzUQLZFcQVBRNcbAd9quPbfzvQDkjNoqrVarfP1hL/jG7izbPhhZVmhfCH3KIHpcnXya5n14aq6pHpPBK5D4X6sOVSEFa3mx051t7mq8qQYINm5UVIHd01OL0t11ies2H2MTmVB80RIpLrJIFxuYf+BedGZ3QQngKAYbu7SjQBSmaS6DugziUojzEOcgLgF8BoiDS1N0Fhfx3iNmMWR9GASTLtX9UDdasLbVbN+5a9ee10MBVJ64Pcm59RMApdHD3x6XR9fmxTMTKNDL8+TbayZywN2DrUma8591D2/7Qeic637L9DTVajWZN/SBZmCLIUyHGHu9AXpK6a8E5Iwu7WG4XkGJQKVn9dQLfWoNKbzH7vmFNx69/Y4dk5WKoVpNAp2lYmZ+8o17u41HL8i6izMw1gDqeosfIewEzmrzsYcw98ufY3H3o8jSJOwrswWRCQMV7W3DKlQdVFJIliLpdpC2FvHQXAdvPayBc9Z2MJ/ljOg+Aw9utBDZVrvz77sbixds/MwPZrGl+mtEBOo3yis2r1l28JF3RuWRZeq9or/I1ou7uhQFFJ5tZLrN+Xvnd9zzW3jneU3stbs7WQlz1ztfe9anjihE72ikzgGwey9gLOkiJK3+5hAtlQ6UL94s7TcBTOyGGPaRmYV3H/KNW6/de/n6V0vpkaN/+8Ro7MCv2OLgQZIljpjs0n5YLljJYyVb2EIRtjgAUyyFmCVhzi0+C2yPLIXPMhjxeLwLXLiujc+fuoBUBJJv34hCmeFHSpFdbHV/Ortn8aIN127f8dQ7Yr26/5jzPlZaeeAV4pwnJhPY5Yo+0Y/71Y9nY0zWaTcbO+8/yz287Qc5T1SegGRWQV/beV7xmFZn6qBCdNJ86hwBtm/yhCVH1KW1wyfoYOnTISpqmP1YZO2jM3MfO+j6f7ty6/i4PWNqyj0dg2Pk2HMPjQdXf8EWh18gPhVVAfWm9j36OgdrkLy77+2x/aqowsYMYVeHcP4BLfz1C+ZgycPlna+o+sgYMxgbLLY7X9rxyKOXHfcXd849qZHs5QE0uubU9bzukDttqTwYpkz97iHfWjdBGSKObGyzbqe7+PjDr0wf+PaNQAg9T4rl12qy9ZLf3rAh8t9ZE5lDm6nPRCXa6+37hB5i6jd3PR1xbyFOxEfGmCHDmJlvfOSAr3zvqqe71F5aCGdbu3lgxUGbrjXF8lvZRFDvvKoQiLi3M0Z72YHuVf+GddnAt+pkAWr/3UNb+JPj5sEkSISVoEIAj5YsdVLfaKduy4prvvnxveXwtHvCY5su+GS87IDfD9YPE7ZS8u0WIgVImEBgy2l78bH2nl2vS375ze9gfNziqSwQwGQFZqIOf8MlLzvsmMj94+rYbm4mmWr4KgY23KuY6Yn6CFVJmPASMGityTLXaS623n3gV7//51qtMmo13Tduz5J3jh1/0bmmOPxhUxw6ntlAfaphOVX7izQ94VNvjUMVqQCLGeGQssP7N7XwmvVNXUy9eLDGhmw5tuhkDt67f5xdaNcO+dNtd2veZDwD+Qw0eOT40aXRg/6N48FB8R7gfgHUG98z2EBdgqQ5//XGYzvehd2337ev317Ss4DPXXDa0AsGCh9bEZnLxiKLjvNwAgmcIuoXOr2vZ7BMPGDDlGsxSb+7p9G44qgbbr81t3zBsyNWBVp5OG9x7PkTb7CFobeYOH4+mTiw+sRDc/qpU4WTsAEJAOsHvF5ycJfefHgL6wsptzyjZBlsgEYnS5jw9W6SfHpl7Ts3770qu08HW3bsuZ+Nhw+4TNCjbi2ZooqDd+mCy9KbXXfhs+27b7zxCa69r3s0e7nh93/n7BetJnprrHh52dCyuJ8Ol6odp4pW5jrq3C0+dX918D/f8mUAsm9h55mZfb39uOXPf9XpsIULiaNxYnOoMaZcMIxyxFhV9HjeqMPpqzOMr+xiRSmDTzwWElFSfQyQnwrLTd1uduOBf7j1nj6BIF+V3WfLWH38KzZ41VMEhRNh+UAiWiaibfXZA96ld6SN5vbk4al/7+8C0xbaO+HusxIAQnXpq1y2v/GsQ0dTPbNEOM4r1nvFCIHaRHhEoNNZ2v7O8fV/u/PJlPgbLxWOj5tfCZ3xshMuOHx5gY8+/4iBY1+y1m44ZjhdvTbulgqapY2u7Oko7wTpjkj9nc2U7jryj7+5+wnMjelpei7G8R+/mnkLzbDt7gAAAABJRU5ErkJggg==';
const STYLES = `
:host{all:initial;--meli-bg:#fff;--meli-fg:#171717;--meli-muted:#f7f7f7;--meli-muted-2:#f2f2f2;--meli-muted-fg:#737373;--meli-border:#e7e7e7;--meli-input:#dedede;--meli-primary:#1f1f1f;--meli-primary-fg:#fafafa;--meli-danger:#b91c1c;--meli-danger-soft:#fff1f1;--meli-warning:#8a5a00;--meli-warning-soft:#fff8ed;--meli-success:#276749;--success:var(--meli-success);--meli-success-soft:#eef8f1;--meli-shadow:0 1px 3px rgba(15,23,42,.08),0 18px 44px rgba(15,23,42,.13);font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--meli-fg);font-variant-numeric:tabular-nums}
*,*:before,*:after{box-sizing:border-box}button,input,textarea,select{font:inherit}.meli-svg{width:17px;height:17px;display:block;flex:0 0 auto;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.meli-launcher{position:fixed;right:0;top:148px;z-index:2147483645;width:62px;height:52px;padding:0;border:1px solid #d7d7d7;border-right:0;border-radius:10px 0 0 10px;background:#fff;color:#171717;box-shadow:0 10px 26px rgba(0,0,0,.17);cursor:pointer;display:flex;align-items:center;justify-content:center}.meli-launcher[hidden]{display:none}.meli-launcher-logo{display:block;width:43px;height:auto;object-fit:contain}.meli-badge{position:absolute;right:4px;top:4px;min-width:17px;height:17px;padding:0 4px;border-radius:999px;background:var(--meli-danger);color:#fff;font:750 9px/17px Inter,system-ui,sans-serif;text-align:center}
.meli-panel{position:fixed;z-index:2147483645;top:12px;right:12px;bottom:12px;width:620px;display:grid;grid-template-columns:60px minmax(0,1fr);grid-template-rows:60px minmax(0,1fr);overflow:hidden;border:1px solid var(--meli-border);border-radius:12px;background:var(--meli-bg);box-shadow:var(--meli-shadow);font-size:12.5px;line-height:1.45;transition:width .2s ease,grid-template-columns .2s ease}.meli-panel[hidden]{display:none}.meli-panel.is-wide{width:min(1200px,calc(100vw - 24px));grid-template-columns:184px minmax(0,1fr)}
.meli-head{grid-column:1/-1;min-width:0;padding:9px 11px 9px 14px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--meli-border);background:#fff}.meli-logo{width:48px;height:32px;display:flex;align-items:center;justify-content:center;flex:0 0 auto}.meli-logo-link{border-radius:7px;text-decoration:none}.meli-logo-link:focus-visible{outline:2px solid #171717;outline-offset:3px}.meli-brand-logo{display:block;width:43px;height:auto;object-fit:contain}.meli-brand{min-width:0;flex:1}.meli-title{margin:0;font-size:14px;font-weight:730;letter-spacing:-.015em;line-height:1.2}.meli-subtitle{margin-top:3px;color:var(--meli-muted-fg);font-size:11px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meli-version{height:22px;padding:0 8px;display:inline-flex;align-items:center;border:1px solid var(--meli-border);border-radius:999px;background:var(--meli-muted);color:#525252;font-size:10px;font-weight:700}.meli-head-actions{display:flex;align-items:center;gap:6px;flex:0 0 auto}.meli-icon,.meli-lang{width:32px;min-width:32px;height:32px;padding:0;border:1px solid var(--meli-border);border-radius:7px;background:#fff;color:#525252;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.meli-lang{width:auto;min-width:34px;padding:0 7px;font-size:10.5px;font-weight:750;letter-spacing:.04em}.meli-icon:hover,.meli-lang:hover{background:var(--meli-muted);color:#171717}
.meli-nav{grid-column:1;grid-row:2;padding:10px 7px;display:flex;flex-direction:column;gap:5px;border-right:1px solid var(--meli-border);background:#fafafa;overflow:auto}.meli-nav-btn{width:100%;min-height:40px;padding:0;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid transparent;border-radius:7px;background:transparent;color:#737373;font-size:12px;font-weight:650;cursor:pointer}.meli-nav-btn:hover{background:#fff;color:#171717}.meli-nav-btn.is-active{border-color:#dedede;background:#fff;color:#171717;box-shadow:0 1px 2px rgba(0,0,0,.04)}.meli-nav-label{display:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.meli-panel.is-wide .meli-nav{padding:10px}.meli-panel.is-wide .meli-nav-btn{padding:0 11px;justify-content:flex-start}.meli-panel.is-wide .meli-nav-label{display:block}
.meli-main{grid-column:2;grid-row:2;min-width:0;overflow:auto;overscroll-behavior:contain;padding:18px;background:var(--meli-muted)}.meli-view{min-width:0;max-width:100%;animation:meli-view-in .16s ease}.meli-view-head{margin-bottom:14px;display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.meli-view-title{margin:0;font-size:18px;font-weight:740;letter-spacing:-.025em;line-height:1.2}.meli-view-copy{margin:4px 0 0;color:#737373;font-size:11.5px;line-height:1.45}.meli-card{overflow:hidden;border:1px solid var(--meli-border);border-radius:9px;background:#fff;box-shadow:0 1px 2px rgba(15,23,42,.04)}.meli-card+.meli-card{margin-top:12px}.meli-card-head{min-height:43px;padding:9px 11px;display:flex;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid var(--meli-border);background:#fafafa}.meli-card-head strong{font-size:12.5px}.meli-card-body{padding:12px}
.meli-status{margin-bottom:12px;padding:11px 12px;display:flex;align-items:flex-start;gap:10px;border:1px solid var(--meli-border);border-radius:9px;background:#fff}.meli-status-mark{width:28px;height:28px;display:grid;place-items:center;flex:0 0 auto;border:1px solid var(--meli-border);border-radius:7px;background:var(--meli-muted);color:#525252}.meli-status[data-tone="ready"] .meli-status-mark{border-color:#c9dfd1;background:var(--meli-success-soft);color:var(--meli-success)}.meli-status[data-tone="scanning"] .meli-status-mark{border-color:#eed69a;background:var(--meli-warning-soft);color:var(--meli-warning)}.meli-status[data-tone="blocked"] .meli-status-mark,.meli-status[data-tone="error"] .meli-status-mark{border-color:#f1c0c0;background:var(--meli-danger-soft);color:var(--meli-danger)}.meli-status strong{display:block;font-size:12.5px;font-weight:720}.meli-status span{display:block;margin-top:2px;color:#525252;font-size:11.5px;line-height:1.42;overflow-wrap:anywhere}
.meli-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.meli-panel.is-wide .meli-grid{grid-template-columns:repeat(6,minmax(0,1fr))}.meli-stat{min-width:0;padding:11px;border:1px solid var(--meli-border);border-radius:8px;background:#fff}.meli-stat span{display:block;margin-bottom:5px;color:#8a8a8a;font-size:9.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase}.meli-stat strong{display:block;overflow:hidden;font-size:16px;font-weight:740;line-height:1.25;text-overflow:ellipsis;white-space:nowrap}.meli-stat[data-tone="warning"] strong{color:var(--meli-warning)}.meli-stat[data-tone="danger"] strong{color:var(--meli-danger)}
.meli-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.meli-btn{min-height:36px;padding:0 12px;display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--meli-input);border-radius:7px;background:#fff;color:#171717;box-shadow:0 1px 2px rgba(0,0,0,.04);font:650 12px/1 Inter,system-ui,sans-serif;cursor:pointer;transition:background .15s,border-color .15s,color .15s;white-space:nowrap}.meli-btn:hover{border-color:#cfcfcf;background:#f7f7f7}.meli-btn.primary{border-color:var(--meli-primary);background:var(--meli-primary);color:var(--meli-primary-fg)}.meli-btn.primary:hover{background:#303030}.meli-btn.danger{color:var(--meli-danger);border-color:#f1c0c0}.meli-btn.danger:hover{background:var(--meli-danger-soft)}.meli-btn:disabled{opacity:.5;cursor:not-allowed}.meli-btn:focus-visible,.meli-icon:focus-visible,.meli-lang:focus-visible,.meli-nav-btn:focus-visible,.meli-input:focus-visible,.meli-select:focus-visible,.meli-textarea:focus-visible{outline:2px solid #525252;outline-offset:2px}
.meli-queue{overflow:hidden;border:1px solid var(--meli-border);border-radius:8px;background:#fff}.meli-queue-title{min-height:41px;padding:9px 11px;display:flex;align-items:center;justify-content:space-between;gap:8px;border-bottom:1px solid var(--meli-border);background:#fafafa;font-weight:720}.meli-queue-body{padding:11px}.meli-queue p{margin:0;color:#525252;font-size:11.5px}.meli-note{margin:12px 0 0;padding:10px 11px;border:1px solid #eed69a;border-radius:8px;background:var(--meli-warning-soft);color:#704900;font-size:11px;line-height:1.45}
.meli-toolbar{min-height:45px;padding:8px 10px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;border-bottom:1px solid var(--meli-border);background:#fafafa}.meli-toolbar.bottom{border-top:1px solid var(--meli-border);border-bottom:0}.meli-toolbar-meta{margin-left:auto;color:#737373;font-size:11px;font-weight:650}.meli-input,.meli-select,.meli-textarea{width:100%;border:1px solid #d9d9d9;border-radius:7px;outline:none;background:#fff;color:#171717;padding:9px 10px;font:12.5px/1.4 Inter,system-ui,sans-serif;transition:border-color .15s,box-shadow .15s}.meli-input,.meli-select{height:40px}.meli-input:focus,.meli-select:focus,.meli-textarea:focus{border-color:#525252;box-shadow:0 0 0 3px rgba(23,23,23,.09)}.meli-toolbar .meli-input{flex:1;min-width:180px;height:34px}.meli-toolbar .meli-select{width:auto;min-width:150px;height:34px;padding-top:0;padding-bottom:0}
.meli-analysis-card,.meli-form-card{overflow:hidden;border:1px solid #d9d9d9;border-radius:9px;background:#fff;box-shadow:0 1px 2px rgba(15,23,42,.04)}.meli-meta{margin-top:3px;color:#737373;font-size:10.5px;line-height:1.38}.meli-pill{min-width:66px;padding:4px 7px;display:inline-flex;align-items:center;justify-content:center;border:1px solid #dedede;border-radius:999px;background:#f7f7f7;color:#525252;font-size:10px;font-weight:750;white-space:nowrap}.meli-pill.success{color:var(--meli-success);border-color:#c9dfd1;background:var(--meli-success-soft)}.meli-pill.warning{color:var(--meli-warning);border-color:#eed69a;background:var(--meli-warning-soft)}.meli-pill.danger{color:#991b1b;border-color:#f1c0c0;background:var(--meli-danger-soft)}.meli-mini{min-height:30px;padding:0 8px;display:inline-flex;align-items:center;justify-content:center;border:1px solid #e1e1e1;border-radius:6px;background:#fff;color:#404040;font:650 10.5px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;white-space:nowrap}.meli-mini:hover{border-color:#cfcfcf;background:#f7f7f7}.meli-check{width:16px;height:16px;accent-color:#1f1f1f}.meli-empty{min-height:120px;padding:24px;display:grid;place-items:center;color:#737373;font-size:12px;text-align:center}
.meli-form-grid{padding:13px;display:grid;grid-template-columns:1fr 1fr;gap:12px}.meli-field{min-width:0;display:grid;gap:6px}.meli-field.full{grid-column:1/-1}.meli-field-head{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#404040;font-size:11.5px;font-weight:700}.meli-field-toggle{display:inline-flex;align-items:center;gap:6px;color:#737373;font-size:10.5px;font-weight:650;white-space:nowrap}.meli-field-toggle input{width:15px;height:15px;margin:0;accent-color:#1f1f1f}.meli-field[data-edit-field]{padding:10px;border:1px solid var(--meli-border);border-radius:8px;background:#fafafa;transition:opacity .15s,border-color .15s,background .15s}.meli-field[data-edit-field].is-selected{border-color:#b8b8b8;background:#fff;box-shadow:0 0 0 2px rgba(23,23,23,.04)}.meli-field[data-edit-field].is-disabled{opacity:.55}.meli-field small{display:block;color:#737373;font-size:10.5px;line-height:1.4}.meli-textarea{min-height:112px;resize:vertical}.meli-feedback{min-height:20px;margin-top:8px;color:var(--meli-danger);font-size:11.5px;font-weight:650}.meli-history{display:grid;gap:8px}.meli-history-item{border:1px solid var(--meli-border);border-radius:8px;padding:10px;background:#fff}.meli-history-metrics{display:flex;flex-wrap:wrap;gap:8px;margin-top:5px;color:#525252;font-size:11.5px}.meli-settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.meli-setting{padding:11px;border:1px solid var(--meli-border);border-radius:8px;background:#fff}.meli-setting span{display:block;color:#737373;font-size:10.5px;line-height:1.35}.meli-setting strong{display:block;margin-top:5px;font-size:16px}.meli-ai-layout{display:grid;gap:12px}.meli-ai-layout .meli-textarea{min-height:320px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px}
.meli-health-trigger{border:1px solid #dedede;font-family:inherit;cursor:pointer}.meli-health-trigger:focus-visible{outline:2px solid #525252;outline-offset:2px}.meli-health-diagnosis{max-width:220px;margin-top:6px;overflow:hidden;color:#303030;font-size:11.5px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.meli-health-context{margin-bottom:12px;padding:11px 12px;border:1px solid var(--meli-border);border-radius:9px;background:#fff}.meli-health-context>div:first-child{display:flex;align-items:center;gap:8px}.meli-health-detail{display:grid;gap:10px}.meli-health-summary,.meli-detail-card{padding:13px;border:1px solid var(--meli-border);border-radius:9px;background:#fff}.meli-health-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}.meli-health-summary h3,.meli-detail-card h3{margin:8px 0 0;font-size:13px}.meli-detail-card h3{margin:0 0 9px}.meli-health-cap{margin-top:9px;padding:8px;border-radius:7px;background:var(--meli-warning-soft);color:var(--meli-warning);font-size:10.5px;font-weight:650}.meli-evidence,.meli-safeguards{margin:0;padding-left:18px;color:#404040;font-size:11.5px;line-height:1.55}.meli-safeguards{padding:0;display:grid;gap:6px;list-style:none}.meli-safeguards li{display:flex;align-items:flex-start;gap:7px;color:#737373}.meli-safeguards li[data-passed="true"]{color:#276749}.meli-safeguards span{width:14px;flex:0 0 auto;font-weight:800}.meli-confidence-list{display:grid;gap:8px}.meli-confidence-row{display:grid;grid-template-columns:128px 1fr 32px;align-items:center;gap:8px;color:#525252;font-size:10.5px}.meli-confidence-row>div{height:5px;overflow:hidden;border-radius:999px;background:#ededed}.meli-confidence-row i{height:100%;display:block;border-radius:inherit;background:#525252}.meli-confidence-row b{text-align:right;font-size:10.5px}
.meli-analysis-card{display:flex;min-height:0;flex-direction:column;border-radius:14px;background:#f4f4f3;box-shadow:0 14px 34px rgba(15,23,42,.07)}.meli-analysis-controls{padding:12px;display:grid;grid-template-columns:minmax(220px,1fr) minmax(170px,220px);gap:10px;border-bottom:1px solid var(--meli-border);background:#fff}.meli-analysis-search{position:relative}.meli-analysis-search>.meli-svg{position:absolute;left:12px;top:11px;z-index:1;width:16px;height:16px;color:#8a8a8a}.meli-analysis-search .meli-input{padding-left:38px}.meli-selection-tools{grid-column:1/-1;display:flex;align-items:center;gap:12px}.meli-selection-tools .meli-toolbar-meta{margin-right:auto}.meli-text-btn{min-height:28px;padding:0;border:0;background:transparent;color:#525252;font:700 10.5px/1 Inter,system-ui,sans-serif;cursor:pointer}.meli-text-btn:hover{color:#171717;text-decoration:underline}.meli-listing-list{min-height:160px;max-height:calc(100dvh - 272px);padding:12px;display:grid;grid-template-columns:1fr;align-content:start;gap:12px;overflow:auto;overscroll-behavior:contain}.meli-panel.is-wide .meli-listing-list{grid-template-columns:repeat(2,minmax(420px,1fr))}.meli-listing-card{min-width:0;overflow:hidden;border:1px solid #dfdfdc;border-radius:14px;background:#fff;box-shadow:0 1px 2px rgba(15,23,42,.035);transition:border-color .16s,box-shadow .16s,transform .16s}.meli-listing-card:hover{border-color:#c9c9c6;box-shadow:0 8px 22px rgba(15,23,42,.08);transform:translateY(-1px)}.meli-listing-card.is-selected{border-color:#171717;box-shadow:0 0 0 1px #171717,0 8px 22px rgba(15,23,42,.09)}.meli-listing-card-head{min-height:82px;padding:13px;display:grid;grid-template-columns:22px 58px minmax(0,1fr) auto;align-items:center;gap:11px}.meli-card-select{width:18px;height:18px;display:grid;place-items:center}.meli-card-select .meli-check{margin:0}.meli-card-thumb{width:58px;height:58px;display:grid;place-items:center;object-fit:cover;border:1px solid #e8e8e5;border-radius:11px;background:#efefec;color:#8a8a8a}.meli-card-thumb.is-empty .meli-svg{width:20px;height:20px}.meli-card-identity{min-width:0}.meli-card-identity h3{margin:0;display:-webkit-box;overflow:hidden;color:#171717;font-size:13.5px;font-weight:760;letter-spacing:-.012em;line-height:1.32;-webkit-box-orient:vertical;-webkit-line-clamp:2}.meli-card-meta{margin-top:5px;display:flex;align-items:center;gap:6px;color:#8a8a8a;font-size:9.5px;font-weight:650}.meli-card-meta i{width:2px;height:2px;border-radius:50%;background:#b5b5b5}.meli-card-status{margin-top:3px;overflow:hidden;color:#737373;font-size:9.5px;text-overflow:ellipsis;white-space:nowrap}.meli-card-health{min-width:158px;display:grid;justify-items:end;gap:4px}.meli-score-copy{display:grid;justify-items:end;gap:1px}.meli-score-copy span{color:#737373;font-size:8px;font-weight:760;letter-spacing:.04em;text-transform:uppercase}.meli-score-copy strong{color:#171717;font-size:14px}.meli-card-confidence{color:#737373;font-size:8.5px;font-weight:680}.meli-confidence-track{width:100%;height:3px;overflow:hidden;border-radius:99px;background:#ececea}.meli-confidence-track i{height:100%;display:block;border-radius:inherit;background:#292929}.meli-metrics-strip{padding:9px 13px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:0;border-top:1px solid #ededeb;border-bottom:1px solid #ededeb;background:#fafaf9}.meli-metric{min-width:0;padding:0 8px;border-right:1px solid #e8e8e5}.meli-metric:first-child{padding-left:0}.meli-metric:last-child{padding-right:0;border-right:0}.meli-metric>span{display:block;overflow:hidden;color:#8a8a8a;font-size:8.5px;font-weight:760;letter-spacing:.055em;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.meli-metric>div{margin-top:3px;display:flex;align-items:baseline;gap:5px}.meli-metric strong{overflow:hidden;color:#171717;font-size:13px;font-weight:780;text-overflow:ellipsis;white-space:nowrap}.meli-metric small{color:#9a9a9a;font-size:8.5px;font-weight:700}.meli-metric small.up{color:#276749}.meli-metric small.down{color:#b45309}.meli-card-insight{min-height:58px;padding:10px 13px;display:grid;grid-template-columns:28px minmax(0,1fr) auto;align-items:center;gap:9px}.meli-insight-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:8px;background:#f1f1ef;color:#525252}.meli-insight-icon .meli-svg{width:15px;height:15px}.meli-card-insight b{display:block;font-size:11.5px}.meli-card-insight p{margin:2px 0 0;display:-webkit-box;overflow:hidden;color:#737373;font-size:9.5px;line-height:1.35;-webkit-box-orient:vertical;-webkit-line-clamp:2}.meli-card-insight>span{max-width:118px;color:#8a8a8a;font-size:9px;line-height:1.35;text-align:right}.meli-listing-card-foot{min-height:43px;padding:7px 9px;display:flex;justify-content:flex-end;gap:6px;border-top:1px solid #ededeb;background:#fcfcfb}.meli-card-action{min-height:30px;padding:0 9px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #e1e1df;border-radius:8px;background:#fff;color:#404040;font:700 10px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer}.meli-card-action .meli-svg{width:14px;height:14px}.meli-card-action:hover{border-color:#bdbdb9;background:#f5f5f3}.meli-card-action.primary{border-color:#1f1f1f;background:#1f1f1f;color:#fff}.meli-card-action.primary:hover{background:#333}.meli-bulk-bar{min-height:61px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--meli-border);background:#fff}.meli-bulk-bar[hidden]{display:none!important}.meli-bulk-bar>div:first-child{min-width:0}.meli-bulk-bar b{display:block;font-size:11.5px}.meli-bulk-bar span{display:block;margin-top:2px;overflow:hidden;color:#8a8a8a;font-size:9.5px;text-overflow:ellipsis;white-space:nowrap}.meli-bulk-bar>div:last-child{display:flex;gap:7px}
.meli-overlay{position:fixed;inset:0;z-index:2147483646;padding:6vh 18px 24px;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;background:rgba(0,0,0,.30);font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#171717}.meli-modal{width:min(820px,calc(100vw - 32px));max-height:88vh;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--meli-border);border-radius:12px;background:#fff;box-shadow:0 25px 50px -12px rgba(0,0,0,.26)}.meli-modal.small{width:min(820px,calc(100vw - 32px))}.meli-modal-head{min-height:64px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--meli-border);background:#fff}.meli-modal-brand{min-width:0;display:flex;align-items:center;gap:10px}.meli-modal-logo{width:48px;height:32px;display:flex;align-items:center;justify-content:center;flex:0 0 auto}.meli-modal-title{margin:0;font-size:15px;font-weight:720;letter-spacing:-.015em;line-height:1.25}.meli-modal-subtitle{margin-top:3px;color:#737373;font-size:11.5px}.meli-modal-body{padding:18px;overflow:auto;background:#f7f7f7}.meli-modal-foot{padding:12px 16px;display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--meli-border);background:#fafafa}
.meli-toast-root{position:fixed;top:16px;left:50%;z-index:2147483647;transform:translateX(-50%);width:min(360px,calc(100vw - 32px));display:grid;gap:8px;pointer-events:none;font:600 14px/1.4 Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.meli-toast{--meli-toast:#343a4a;min-height:44px;padding:10px 10px 10px 13px;border-radius:10px;display:flex;align-items:center;gap:10px;color:#fff;background:var(--meli-toast);box-shadow:0 8px 24px rgba(24,28,45,.18);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:auto}.meli-toast.is-visible{opacity:1;transform:none}.meli-toast[data-tone="success"]{--meli-toast:#178847}.meli-toast[data-tone="error"]{--meli-toast:#c23b3b}.meli-toast[data-tone="warning"]{--meli-toast:#a85710}.meli-toast-mark{width:20px;height:20px;border:2px solid currentColor;border-radius:50%;display:grid;place-items:center;flex:0 0 auto;font-size:11px;line-height:1}.meli-toast-copy{min-width:0;flex:1;overflow-wrap:anywhere}.meli-toast-close{width:28px;height:28px;padding:0;border:0;border-radius:7px;display:grid;place-items:center;color:inherit;background:transparent;cursor:pointer;opacity:.72}.meli-toast-close:hover{opacity:1;background:rgba(255,255,255,.14)}
@keyframes meli-view-in{from{opacity:.5;transform:translateY(3px)}to{opacity:1;transform:none}}
@media(max-width:760px){.meli-panel,.meli-panel.is-wide{top:8px;right:8px;bottom:8px;left:8px;width:auto;grid-template-columns:52px minmax(0,1fr)}.meli-head{padding-left:9px}.meli-main{padding:12px}.meli-launcher{top:auto;bottom:86px}.meli-grid,.meli-panel.is-wide .meli-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.meli-panel.is-wide .meli-nav{padding:10px 7px}.meli-panel.is-wide .meli-nav-btn{padding:0;justify-content:center}.meli-panel.is-wide .meli-nav-label{display:none}.meli-overlay{padding:10px}.meli-modal,.meli-modal.small{width:100%;max-height:calc(100dvh - 20px)}.meli-modal-body{padding:12px}.meli-form-grid,.meli-settings-grid{grid-template-columns:1fr}.meli-field.full{grid-column:auto}.meli-toolbar .meli-input,.meli-toolbar .meli-select{width:100%;min-width:0;height:44px;font-size:16px}.meli-modal-foot{flex-wrap:wrap}.meli-modal-foot>.meli-btn{flex:1}.meli-toast-root{top:12px}}
@media(max-width:480px){.meli-panel,.meli-panel.is-wide{top:4px;right:4px;bottom:4px;left:4px;grid-template-columns:48px minmax(0,1fr)}.meli-head{padding:8px;gap:6px}.meli-logo{width:40px}.meli-brand-logo{width:37px}.meli-title{font-size:12.5px}.meli-subtitle{display:none}.meli-head-actions{gap:4px}.meli-icon,.meli-lang{width:29px;min-width:29px;height:29px}.meli-main{padding:10px}.meli-nav{padding:8px 5px}.meli-nav-btn{min-height:38px}.meli-grid,.meli-panel.is-wide .meli-grid{grid-template-columns:1fr 1fr;gap:7px}.meli-stat{padding:9px}.meli-view-title{font-size:16px}.meli-toolbar{padding:8px}.meli-field-toggle{white-space:normal;text-align:right}.meli-modal-head{padding:10px 12px}.meli-modal-title{font-size:14px}.meli-modal-subtitle{font-size:10.5px}.meli-modal-body{padding:10px}.meli-modal-foot{padding:10px}.meli-toast-root{top:8px;width:calc(100vw - 20px)}}
@media(max-width:1100px){.meli-panel.is-wide .meli-listing-list{grid-template-columns:1fr}}
@media(max-width:760px){.meli-analysis-controls{grid-template-columns:1fr}.meli-selection-tools{grid-column:auto;flex-wrap:wrap}.meli-listing-list,.meli-panel.is-wide .meli-listing-list{max-height:calc(100dvh - 280px);padding:9px;grid-template-columns:1fr}.meli-listing-card-head{grid-template-columns:22px 52px minmax(0,1fr);padding:11px;gap:9px}.meli-card-thumb{width:52px;height:52px}.meli-card-health{grid-column:2/-1;width:100%;padding-top:7px;display:grid;grid-template-columns:auto 1fr;align-items:center;justify-items:start}.meli-score-copy,.meli-card-confidence{justify-self:end}.meli-card-confidence,.meli-confidence-track{grid-column:1/-1}.meli-confidence-track{min-width:70px}.meli-metrics-strip{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 0}.meli-metric:nth-child(3){border-right:0}.meli-metric:nth-child(n+4){padding-top:7px;border-top:1px solid #e8e8e5}.meli-card-insight{grid-template-columns:28px minmax(0,1fr)}.meli-card-insight>span{grid-column:2;max-width:none;text-align:left}.meli-bulk-bar{align-items:stretch;flex-direction:column}.meli-bulk-bar>div:last-child{display:grid;grid-template-columns:1fr 1fr}.meli-card-action{flex:1}.meli-card-action span{display:block}.meli-confidence-row{grid-template-columns:106px 1fr 30px}}
@media(max-width:480px){.meli-analysis-controls{padding:9px}.meli-listing-list{padding:7px}.meli-listing-card{border-radius:11px}.meli-card-insight p{font-size:9px}.meli-listing-card-foot{display:grid;grid-template-columns:1fr 1fr}.meli-card-action:first-child{grid-column:1/-1}.meli-bulk-bar>div:last-child{grid-template-columns:1fr}.meli-bulk-bar .meli-btn{width:100%}}
/* Premium monochrome surfaces and a legible numeric scale. */
:host{--meli-muted:#f5f5f5;--meli-muted-2:#eeeeee;--meli-muted-fg:#595959;--meli-border:#dedede;--meli-input:#cfcfcf;--meli-warning-soft:#fff;--meli-success-soft:#fff;--meli-danger-soft:#fff;--meli-shadow:0 1px 3px rgba(0,0,0,.08),0 18px 44px rgba(0,0,0,.14)}
.meli-panel{font-size:13px}.meli-subtitle{font-size:11.5px}.meli-version{color:#404040;font-size:11px}.meli-nav{background:#fafafa}.meli-nav-btn{color:#595959;font-size:12.5px}.meli-main{background:#f5f5f5}.meli-view-copy{color:#595959;font-size:12px}.meli-stat span{color:#595959;font-size:10px}.meli-stat strong,.meli-stat[data-tone="warning"] strong,.meli-stat[data-tone="danger"] strong{color:#171717;font-size:18px}.meli-toolbar-meta,.meli-text-btn{color:#404040;font-size:11.5px}.meli-meta{color:#595959;font-size:11px}.meli-pill{border-color:#bdbdbd;background:#fff;color:#303030;font-size:10.5px}.meli-pill.success{border-color:#a9d8be;background:#edf8f1;color:#1f7a4d}.meli-pill.balanced{border-color:#b9cdf8;background:#eff6ff;color:#1d4ed8}.meli-pill.warning{border-color:#e5c56b;background:#fff9df;color:#8a5a00}.meli-pill.danger{border-color:#efb4ae;background:#fff1f0;color:#b42318}.meli-note{border-color:#cfcfcf;background:#fff;color:#404040}.meli-status[data-tone="ready"] .meli-status-mark,.meli-status[data-tone="scanning"] .meli-status-mark,.meli-status[data-tone="blocked"] .meli-status-mark,.meli-status[data-tone="error"] .meli-status-mark{border-color:#bdbdbd;background:#fff;color:#303030}.meli-health-diagnosis{font-size:12px}.meli-health-cap{border:1px solid #bdbdbd;background:#fff;color:#303030}.meli-safeguards li[data-passed="true"]{color:#303030}.meli-confidence-row,.meli-confidence-row b{font-size:11.5px}.meli-confidence-row{grid-template-columns:132px 1fr 38px}
.meli-analysis-card{background:#f2f2f2;box-shadow:0 14px 34px rgba(0,0,0,.08)}.meli-listing-card{border-color:#d9d9d9;box-shadow:0 1px 2px rgba(0,0,0,.04)}.meli-listing-card:hover{border-color:#bdbdbd;box-shadow:0 8px 22px rgba(0,0,0,.09)}.meli-card-thumb{border-color:#dedede;background:#ededed;color:#595959}.meli-card-identity h3{font-size:15px}.meli-card-meta,.meli-card-status{color:#595959;font-size:11.5px}.meli-card-health>b{color:#303030;font-size:11.5px}.meli-card-confidence{color:#595959;font-size:10.5px}.meli-confidence-track{height:5px;background:#e2e2e2}.meli-metrics-strip{padding:12px 13px;grid-template-columns:repeat(3,minmax(0,1fr));border-color:#dedede;background:#f5f5f5}.meli-metric{padding:0 12px;border-color:#dedede}.meli-metric:nth-child(3){padding-right:0;border-right:0}.meli-metric:nth-child(4){padding-left:0}.meli-metric:nth-child(n+4){margin-top:10px;padding-top:10px;border-top:1px solid #dedede}.meli-metric>span{color:#595959;font-size:10.5px;letter-spacing:.035em}.meli-metric>div{margin-top:4px;display:grid;align-items:start;gap:2px}.meli-metric strong{overflow:visible;font-size:16px;line-height:1.15;text-overflow:clip}.meli-metric small{display:block;min-height:13px;color:#595959;font-size:11.5px;font-weight:750;line-height:1.2}.meli-metric small.up{color:#1f7a4d}.meli-metric small.down{color:#b42318}.meli-insight-icon{background:#ededed}.meli-insight-icon[data-lifecycle="ACTIVE_GROWING"]{color:#16a34a}.meli-insight-icon[data-lifecycle="PROTECTED"]{color:#166534}.meli-insight-icon[data-lifecycle="ACTIVE_STABLE"]{color:#1d4ed8}.meli-insight-icon[data-lifecycle="EXPERIMENT_RUNNING"]{color:#7c3aed}.meli-insight-icon[data-lifecycle="BASELINE"],.meli-insight-icon[data-lifecycle="LEARNING"]{color:#ca8a04}.meli-insight-icon[data-lifecycle="DORMANT"]{color:#d97706}.meli-insight-icon[data-lifecycle="ACTIVE_DECLINING"]{color:#dc2626}.meli-insight-icon[data-lifecycle="DEACTIVATION_REVIEW"]{color:#991b1b}.meli-card-insight b{font-size:13px}.meli-card-insight p{color:#525252;font-size:11.5px;line-height:1.45}.meli-card-insight>span{color:#595959;font-size:11.5px}.meli-listing-card-foot{border-color:#dedede;background:#f7f7f7}.meli-card-action{border-color:#d0d0d0;color:#303030;font-size:12px}.meli-card-action:hover{border-color:#999;background:#f2f2f2}.meli-bulk-bar span{color:#595959;font-size:11px}.meli-main,.meli-listing-list{scrollbar-gutter:stable;scrollbar-width:auto;scrollbar-color:#686868 #e7e7e7}.meli-main::-webkit-scrollbar,.meli-listing-list::-webkit-scrollbar{width:10px;height:10px}.meli-main::-webkit-scrollbar-track,.meli-listing-list::-webkit-scrollbar-track{border-left:1px solid #d5d5d5;background:#e7e7e7}.meli-main::-webkit-scrollbar-thumb,.meli-listing-list::-webkit-scrollbar-thumb{min-height:48px;border:2px solid #e7e7e7;border-radius:999px;background:#686868}.meli-main::-webkit-scrollbar-thumb:hover,.meli-listing-list::-webkit-scrollbar-thumb:hover{background:#303030}
.meli-collection-card{margin-top:12px;padding:13px;border:1px solid #d6d6d6;border-radius:10px;background:#fff}.meli-collection-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.meli-collection-head>div{display:grid;gap:2px}.meli-collection-head span{color:#595959;font-size:10px;font-weight:760;letter-spacing:.055em;text-transform:uppercase}.meli-collection-head strong{font-size:13px}.meli-collection-head>b{font-size:13px}.meli-collection-track{height:7px;margin-top:10px;overflow:hidden;border-radius:99px;background:#e4e4e4}.meli-collection-track i{height:100%;display:block;border-radius:inherit;background:#202020;transition:width .18s ease}.meli-collection-meta{margin-top:10px;display:flex;gap:22px}.meli-collection-meta span{display:flex;align-items:center;gap:6px;color:#595959;font-size:11px}.meli-collection-meta b{color:#171717}.meli-shortcut-hint{align-self:center;color:#595959;font-size:10.5px;font-weight:650}
.meli-main{overflow-y:scroll}.meli-main[data-view-panel="analysis"]{overflow:hidden}.meli-main[data-view-panel="analysis"]>.meli-view{height:100%;min-height:0;display:flex;flex-direction:column}.meli-main[data-view-panel="analysis"] .meli-view-head{flex:0 0 auto}.meli-main[data-view-panel="analysis"] .meli-analysis-card{min-height:0;flex:1}.meli-analysis-controls{grid-template-columns:minmax(240px,1fr) auto minmax(185px,225px);align-items:end}.meli-filter-toggle{height:40px;padding:0 12px;display:inline-flex;align-items:center;gap:7px;border:1px solid #cfcfcf;border-radius:8px;background:#fff;color:#303030;font:720 12px/1 Inter,system-ui,sans-serif;cursor:pointer}.meli-filter-toggle:hover,.meli-filter-toggle[aria-expanded="true"]{border-color:#8a8a8a;background:#f2f2f2}.meli-filter-toggle .meli-svg{width:15px;height:15px}.meli-filter-toggle b{min-width:18px;height:18px;padding:0 5px;border-radius:99px;background:#202020;color:#fff;font:750 9px/18px Inter,system-ui,sans-serif;text-align:center}.meli-sort-control{min-width:0;display:grid;gap:4px}.meli-sort-control>span,.meli-filter-drawer label>span{color:#595959;font-size:9.5px;font-weight:760;letter-spacing:.045em;text-transform:uppercase}.meli-filter-drawer{grid-column:1/-1;padding:11px;max-height:230px;display:grid;grid-template-columns:repeat(4,minmax(140px,1fr));align-items:end;gap:10px;border:1px solid #d8d8d8;border-radius:10px;background:#f5f5f5;overflow-y:auto}.meli-filter-drawer[hidden]{display:none!important}.meli-filter-drawer label{min-width:0;display:grid;gap:5px}.meli-filter-drawer .meli-btn{height:40px}.meli-selection-tools{grid-column:1/-1;padding-top:2px;flex-wrap:wrap}.meli-selection-tools .meli-toolbar-meta{margin-right:0}.meli-selection-tools [data-results-count]{margin-right:auto;color:#171717}.meli-hidden-selected{color:#595959;font-size:10.5px;font-weight:650}.meli-listing-list{height:auto;min-height:0;max-height:none;flex:1;grid-auto-rows:max-content;overflow-x:hidden;overflow-y:scroll}.meli-listing-list:focus-visible,.meli-modal-body:focus-visible{outline:2px solid #525252;outline-offset:-3px}.meli-empty,.meli-load-more{grid-column:1/-1}.meli-empty{gap:10px}.meli-load-more{padding:12px;display:flex;align-items:center;justify-content:center;gap:12px;border:1px solid #d7d7d7;border-radius:10px;background:#fff;color:#595959;font-size:11px;font-weight:650}
.meli-analysis-gate{min-height:0;flex:1;display:grid;place-items:center;padding:36px;border:1px solid #d6d6d6;border-radius:14px;background:#fff;text-align:center;box-shadow:0 14px 34px rgba(0,0,0,.06)}.meli-analysis-gate-inner{width:min(100%,520px);display:grid;justify-items:center}.meli-analysis-gate-icon{width:58px;height:58px;display:grid;place-items:center;border:1px solid #d6d6d6;border-radius:18px;background:#f1f1f1;color:#303030}.meli-analysis-gate-icon .meli-svg{width:25px;height:25px}.meli-analysis-gate h3{margin:20px 0 0;color:#171717;font-size:24px;font-weight:780;letter-spacing:-.025em;line-height:1.18}.meli-analysis-gate p{margin:9px 0 0;color:#595959;font-size:13px;line-height:1.55}.meli-analysis-gate .meli-btn{min-width:180px;margin-top:20px}.meli-analysis-gate-shortcut{margin-top:11px;color:#595959;font-size:11px;font-weight:680}.meli-analysis-gate-progress{width:min(100%,360px);margin-top:17px}.meli-analysis-gate-progress .meli-collection-track{margin-top:0}.meli-analysis-gate-progress span{display:block;margin-top:7px;color:#595959;font-size:10.5px;font-weight:650}
.meli-main,.meli-analysis-card,.meli-listing-list,.meli-modal-body,.meli-filter-drawer{scrollbar-gutter:stable;scrollbar-width:auto;scrollbar-color:#555 #e3e3e3}.meli-main::-webkit-scrollbar,.meli-analysis-card::-webkit-scrollbar,.meli-listing-list::-webkit-scrollbar,.meli-modal-body::-webkit-scrollbar,.meli-filter-drawer::-webkit-scrollbar{width:12px;height:12px}.meli-main::-webkit-scrollbar-track,.meli-analysis-card::-webkit-scrollbar-track,.meli-listing-list::-webkit-scrollbar-track,.meli-modal-body::-webkit-scrollbar-track,.meli-filter-drawer::-webkit-scrollbar-track{border-left:1px solid #cfcfcf;background:#e3e3e3}.meli-main::-webkit-scrollbar-thumb,.meli-analysis-card::-webkit-scrollbar-thumb,.meli-listing-list::-webkit-scrollbar-thumb,.meli-modal-body::-webkit-scrollbar-thumb,.meli-filter-drawer::-webkit-scrollbar-thumb{min-height:52px;border:2px solid #e3e3e3;border-radius:999px;background:#555}.meli-main::-webkit-scrollbar-thumb:hover,.meli-analysis-card::-webkit-scrollbar-thumb:hover,.meli-listing-list::-webkit-scrollbar-thumb:hover,.meli-modal-body::-webkit-scrollbar-thumb:hover,.meli-filter-drawer::-webkit-scrollbar-thumb:hover{background:#202020}.meli-modal-body{min-height:0;overscroll-behavior:contain}
@media(max-width:900px){.meli-analysis-controls{grid-template-columns:minmax(180px,1fr) auto}.meli-sort-control{grid-column:1/-1}.meli-filter-drawer{grid-template-columns:repeat(2,minmax(130px,1fr))}}
@media(max-width:760px),(max-height:620px){.meli-analysis-card{overflow-y:auto}.meli-analysis-card .meli-listing-list,.meli-panel.is-wide .meli-analysis-card .meli-listing-list{height:max-content;min-height:160px;max-height:none;flex:0 0 auto;overflow:visible}.meli-analysis-card .meli-bulk-bar{position:sticky;bottom:0;z-index:2}}
@media(max-width:760px){.meli-analysis-controls{grid-template-columns:1fr}.meli-filter-toggle,.meli-sort-control{grid-column:auto;width:100%}}
@media(max-width:520px){.meli-filter-drawer{grid-template-columns:1fr;max-height:260px}.meli-selection-tools{align-items:flex-start;flex-direction:column}.meli-selection-tools [data-results-count]{margin-right:0}.meli-shortcut-hint{width:100%}.meli-analysis-card .meli-bulk-bar{align-items:flex-start;flex-direction:column}.meli-analysis-gate{padding:24px 18px}.meli-analysis-gate h3{font-size:21px}}
.meli-update-banner,.meli-storage-banner{margin-bottom:12px;padding:11px 12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid #a3a3a3;border-radius:10px;background:#fff}.meli-storage-banner{border-left:4px solid #525252}.meli-update-banner div,.meli-storage-banner div{min-width:0}.meli-update-banner strong,.meli-storage-banner strong{display:block;font-size:12px}.meli-update-banner span,.meli-storage-banner span{display:block;margin-top:3px;color:#595959;font-size:10.5px}.meli-update-actions{display:flex;align-items:center;gap:7px;flex:0 0 auto}
.meli-import-zone{padding:14px;border:1px dashed #a3a3a3;border-radius:10px;background:#fff}.meli-import-zone input[type="file"]{width:100%;margin-top:10px}.meli-backup-preview{margin-top:12px;padding:12px;border:1px solid #a3a3a3;border-radius:9px;background:#fff}.meli-backup-preview[hidden]{display:none}.meli-calibration{margin-bottom:12px;padding:12px;border:1px solid #cfcfcf;border-radius:9px;background:#fff}.meli-calibration h3{margin:0;font-size:12px}.meli-calibration p{margin:5px 0 0;color:#595959;font-size:10.5px}.meli-threshold-impact{margin-top:10px;padding:9px 10px;border:1px solid #d8d8d8;border-radius:8px;background:#f5f5f5;color:#303030;font-size:10.5px}.meli-conflict-warning,.meli-queue-recovery{padding:12px;border:1px solid #a3a3a3;border-left:4px solid #b42318;border-radius:9px;background:#fff}.meli-conflict-warning strong,.meli-queue-recovery strong{display:block;font-size:12px}.meli-conflict-warning p,.meli-queue-recovery p{margin:5px 0 0;color:#404040;font-size:11px}.meli-feedback-form{display:grid;gap:12px}.meli-feedback-form .meli-note{margin:0}
.meli-preset-zone{grid-column:1/-1;display:grid;gap:8px}.meli-preset-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.meli-preset-head>span{color:#595959;font-size:9.5px;font-weight:760;letter-spacing:.045em;text-transform:uppercase}.meli-preset-list{display:flex;align-items:center;gap:6px;overflow-x:auto;padding-bottom:2px;scrollbar-width:thin}.meli-preset-wrap{position:relative;display:inline-flex;align-items:center;flex:0 0 auto}.meli-preset-wrap .meli-preset-chip{padding-right:28px}.meli-preset-chip{min-height:30px;padding:0 9px;display:inline-flex;align-items:center;gap:6px;flex:0 0 auto;border:1px solid #d0d0d0;border-radius:999px;background:#fff;color:#303030;font:700 10.5px/1 Inter,system-ui,sans-serif;cursor:pointer}.meli-preset-chip:hover,.meli-preset-chip.is-active{border-color:#202020;background:#202020;color:#fff}.meli-preset-chip b{font-size:9px;opacity:.72}.meli-preset-chip-delete{position:absolute;right:5px;width:18px;height:18px;padding:0;border:0;border-radius:50%;display:grid;place-items:center;background:transparent;color:#737373;cursor:pointer}.meli-preset-wrap:has(.meli-preset-chip.is-active) .meli-preset-chip-delete{color:#fff}.meli-preset-editor{display:grid;grid-template-columns:minmax(150px,1fr) auto;gap:7px}.meli-preset-editor .meli-input{height:36px}.meli-preset-editor .meli-btn{height:36px}
.meli-chart-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.meli-chart{min-width:0;padding:10px;border:1px solid #dedede;border-radius:9px;background:#fafafa}.meli-chart-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.meli-chart-head b{font-size:11px}.meli-chart-head span{color:#595959;font-size:9.5px}.meli-chart svg{width:100%;height:auto;margin-top:6px;display:block;overflow:visible}.meli-chart-gridline{stroke:#dedede;stroke-width:1}.meli-chart-line{fill:none;stroke:#303030;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.meli-chart[data-tone="up"] .meli-chart-line,.meli-chart[data-tone="up"] .meli-chart-point:last-of-type{stroke:#1f7a4d;fill:#1f7a4d}.meli-chart[data-tone="down"] .meli-chart-line,.meli-chart[data-tone="down"] .meli-chart-point:last-of-type{stroke:#b42318;fill:#b42318}.meli-chart-point{fill:#fff;stroke:#525252;stroke-width:1.5}.meli-chart-empty{min-height:92px;display:grid;place-items:center;color:#737373;font-size:10.5px;text-align:center}
.meli-experiment-timeline{position:relative;display:grid;gap:0}.meli-experiment-event{position:relative;padding:0 0 16px 24px}.meli-experiment-event:last-child{padding-bottom:0}.meli-experiment-event:before{content:"";position:absolute;left:6px;top:7px;bottom:-7px;width:1px;background:#cfcfcf}.meli-experiment-event:last-child:before{display:none}.meli-experiment-dot{position:absolute;left:1px;top:3px;width:11px;height:11px;border:2px solid #737373;border-radius:50%;background:#fff}.meli-experiment-event[data-type="winner"] .meli-experiment-dot{border-color:#1f7a4d;background:#1f7a4d}.meli-experiment-event[data-type="underperformed"] .meli-experiment-dot{border-color:#b42318;background:#b42318}.meli-experiment-event[data-type="observing"] .meli-experiment-dot{border-color:#7c3aed}.meli-experiment-event strong{display:block;font-size:11.5px}.meli-experiment-event time,.meli-experiment-event span{display:block;margin-top:2px;color:#595959;font-size:10.5px}
.meli-ai-comparisons{display:grid;gap:10px}.meli-diff-card{overflow:hidden;border:1px solid #d6d6d6;border-radius:10px;background:#fff}.meli-diff-head{padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid #e3e3e3}.meli-diff-head div{min-width:0}.meli-diff-head strong{display:block;overflow:hidden;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.meli-diff-head span{color:#595959;font-size:9.5px}.meli-diff-fields{display:grid}.meli-diff-field{padding:10px 12px;border-top:1px solid #ededed}.meli-diff-field:first-child{border-top:0}.meli-diff-field>strong{display:block;margin-bottom:7px;font-size:10px;text-transform:uppercase}.meli-diff-columns{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.meli-diff-value{min-width:0;padding:8px;border:1px solid #dedede;border-radius:7px;background:#f7f7f7}.meli-diff-value b{display:block;margin-bottom:4px;color:#595959;font-size:9px;text-transform:uppercase}.meli-diff-value pre{max-height:100px;margin:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#202020;font:10.5px/1.42 ui-monospace,SFMono-Regular,Consolas,monospace}.meli-diff-value.is-proposed{border-color:#a3a3a3;background:#fff}
.meli-report-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.meli-report-grid div{padding:9px;border:1px solid #dedede;border-radius:8px;background:#fff}.meli-report-grid span{display:block;color:#595959;font-size:9.5px;text-transform:uppercase}.meli-report-grid b{display:block;margin-top:4px;font-size:12px;overflow-wrap:anywhere}.meli-report-json{max-height:260px;margin:10px 0 0;padding:10px;overflow:auto;border:1px solid #dedede;border-radius:8px;background:#171717;color:#f5f5f5;white-space:pre-wrap;overflow-wrap:anywhere;font:10px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}
.meli-research-card-summary{margin-top:6px;padding-top:6px;border-top:1px solid #e6e6e6}.meli-research-card-summary b{overflow:hidden;color:#202020;font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.meli-research-card-summary span{display:block;margin-top:2px;color:#595959;font-size:9.5px}.meli-research-evidence{padding:13px;border:1px solid #d6d6d6;border-radius:10px;background:#fff}.meli-research-evidence h3{margin:0 0 10px;font-size:13px}.meli-research-evidence>div{display:grid;gap:7px}.meli-research-evidence-row{padding:9px;display:grid;grid-template-columns:minmax(120px,1.25fr) repeat(3,minmax(90px,1fr));gap:8px;border:1px solid #e3e3e3;border-radius:8px;background:#fafafa}.meli-research-evidence-row>strong{overflow-wrap:anywhere;font-size:11.5px}.meli-research-evidence-row span{color:#595959;font-size:9px}.meli-research-evidence-row span b{display:block;margin-top:3px;color:#202020;font-size:10.5px}.meli-research-evidence p,.meli-research-evidence time{display:block;margin:10px 0 0;color:#595959;font-size:10px;line-height:1.45}.meli-research-transfer{display:grid;gap:12px}.meli-research-json{min-height:180px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10.5px}.meli-install-url{display:block;margin-top:12px;padding:9px;overflow-wrap:anywhere;border:1px solid #dedede;border-radius:7px;background:#f5f5f5;color:#303030;font-size:9.5px}.meli-status[data-research-transfer-status][data-tone="error"]{border-color:#fecaca;background:#fff7f7}.meli-status[data-research-transfer-status][data-tone="ready"]{border-color:#bbf7d0;background:#f7fff9}
@media(max-width:620px){.meli-chart-grid{grid-template-columns:1fr}.meli-diff-columns{grid-template-columns:1fr}.meli-report-grid{grid-template-columns:1fr}.meli-research-evidence-row{grid-template-columns:1fr 1fr}.meli-update-banner,.meli-storage-banner{align-items:flex-start;flex-direction:column}.meli-update-actions{width:100%}.meli-update-actions .meli-btn{flex:1}}
@media(prefers-reduced-motion:reduce){.meli-panel,.meli-view,.meli-toast{animation:none;transition:none}.meli-toast{transform:none}}
`;
function logoMarkup(className = 'meli-brand-logo') {
return `
`;
}
function logoLinkMarkup(className = 'meli-logo') {
return `${logoMarkup()}`;
}
function iconSvg(name) {
const paths = {
overview: '',
analysis: '',
ai: '',
queue: '',
settings: '',
wide: '',
close: '',
activity: '',
history: '',
external: '',
spark: '',
};
return ``;
}
function downloadJson(filename, value) {
const blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = filename; link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); return true; }
catch {
const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0';
document.body.appendChild(textarea); textarea.select(); const copied = document.execCommand('copy'); textarea.remove(); return copied;
}
}
function sanitizeImportedUrl(value, kind = 'etsy') {
const source = String(value || '');
if (!source) return '';
if (kind === 'image' && /^data:image\/(?:png|gif|jpeg|webp|svg\+xml)[;,]/i.test(source)) return source.slice(0, 250000);
try {
const url = new URL(source);
if (url.protocol !== 'https:') return '';
if (kind === 'etsy' && !/(^|\.)etsy\.com$/i.test(url.hostname)) return '';
return url.href.slice(0, 2000);
} catch { return ''; }
}
function normalizeBackupDocument(raw) {
if (!raw || typeof raw !== 'object') throw new Error('backup root must be an object');
if (String(raw.schema || '') !== 'makaytron-listing-analyzer-backup/v1') throw new Error('unsupported backup schema');
if (serializedBytes(raw) > MAX_BACKUP_BYTES) throw new Error(t('backupTooLarge'));
if (!Array.isArray(raw.records) || raw.records.length > MAX_BACKUP_RECORDS) throw new Error(`records must contain 0-${MAX_BACKUP_RECORDS} items`);
const ids = new Set();
const records = raw.records.map((item, index) => {
const listingId = String(item?.listingId || '');
if (!/^\d+$/.test(listingId) || ids.has(listingId)) throw new Error(`records[${index}].listingId is invalid or duplicated`);
ids.add(listingId);
const record = normalizeRecord(item, listingId);
if (!record || record.unsupportedSchema) throw new Error(`records[${index}] uses an unsupported schema`);
record.meta = {
...record.meta,
editUrl: sanitizeImportedUrl(record.meta?.editUrl, 'etsy'),
publicUrl: sanitizeImportedUrl(record.meta?.publicUrl, 'etsy'),
imageUrl: sanitizeImportedUrl(record.meta?.imageUrl, 'image'),
};
return record;
});
const settingsRaw = raw.settings && typeof raw.settings === 'object' ? raw.settings : {};
const settings = {
...DEFAULT_SETTINGS,
minVisitsToImprove: clamp(settingsRaw.minVisitsToImprove ?? DEFAULT_SETTINGS.minVisitsToImprove, 0, 100000),
minVisitsToProtect: clamp(settingsRaw.minVisitsToProtect ?? DEFAULT_SETTINGS.minVisitsToProtect, 0, 100000),
minRenewalsToReview: clamp(settingsRaw.minRenewalsToReview ?? DEFAULT_SETTINGS.minRenewalsToReview, 0, 1000),
declinePercent: clamp(settingsRaw.declinePercent ?? DEFAULT_SETTINGS.declinePercent, 0, 100),
retentionDays: clamp(settingsRaw.retentionDays || APP.retentionDays, 30, APP.retentionDays),
maxSnapshots: clamp(settingsRaw.maxSnapshots || APP.maxSnapshots, 10, APP.maxSnapshots),
};
return {
records,
settings,
analysisFilters: normalizeAnalysisFilters(raw.analysisFilters),
filterPresets: normalizeFilterPresets(raw.filterPresets),
queueSkipped: Boolean(raw.queue),
};
}
function mergeFilterPresetCollections(current, incoming) {
const merged = normalizeFilterPresets(current);
normalizeFilterPresets(incoming).forEach((preset) => {
const index = merged.findIndex((item) => item.name.toLocaleLowerCase() === preset.name.toLocaleLowerCase());
if (index >= 0) merged[index] = { ...preset, id: merged[index].id, createdAt: merged[index].createdAt };
else if (merged.length < MAX_FILTER_PRESETS) merged.push(preset);
});
return normalizeFilterPresets(merged);
}
async function importBackupDocument(raw) {
const backup = normalizeBackupDocument(raw);
const result = await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const batchIndex = { items: await Store.getIndex(), dirty: false };
for (const record of backup.records) await Store.putRecordLocked(record, batchIndex);
if (batchIndex.dirty) await requireStored(KEYS.index, batchIndex.items);
state.settings = {
...state.settings,
...backup.settings,
language: state.settings.language,
collapsed: state.settings.collapsed,
};
state.analysisFilters = backup.analysisFilters;
state.filterPresets = mergeFilterPresetCollections(state.filterPresets, backup.filterPresets);
await requireStored(KEYS.settings, state.settings);
await requireStored(KEYS.analysisFilters, state.analysisFilters);
await requireStored(KEYS.filterPresets, { schema: 1, items: state.filterPresets });
await Store.appendAuditLocked({ type: 'backup-imported', records: backup.records.length, presets: backup.filterPresets.length, queueSkipped: backup.queueSkipped });
return { records: backup.records.length, presets: backup.filterPresets.length, queueSkipped: backup.queueSkipped };
});
await refreshRecords();
refreshStorageEstimate();
return result;
}
function feedbackDiagnostics() {
const browser = /Edg\//.test(navigator.userAgent) ? 'Edge' : /Firefox\//.test(navigator.userAgent) ? 'Firefox' : /Chrome\//.test(navigator.userAgent) ? 'Chromium' : 'Other';
return {
appVersion: APP_VERSION,
language: state.settings.language,
browser,
route: routeKind(),
recordCount: state.records.length,
collectionStatus: state.collection?.status || 'none',
collectionPages: Object.keys(state.collection?.pages || {}).length,
};
}
function feedbackMarkdown(entry) {
const categories = { bug: 'Bug', idea: 'Idea', usability: 'Usability', analysis: 'Analysis' };
const diagnostics = entry.diagnostics ? `\n\n### Technical summary\n\n\`\`\`json\n${JSON.stringify(entry.diagnostics, null, 2)}\n\`\`\`` : '';
return `### Listing Analyzer feedback\n\n- Category: ${categories[entry.category] || 'Idea'}\n- Rating: ${entry.rating}/5\n- Version: ${APP_VERSION}\n\n### Note\n\n${entry.note}${diagnostics}\n\n_No listing titles, listing IDs, cookies, sessions, or page HTML are included._`;
}
async function saveUserFeedback(input) {
const note = normalizeSpace(input?.note).slice(0, 800);
if (note.length < 10) throw new Error(t('feedbackInvalid'));
const entry = {
id: randomId('feedback'), at: nowIso(),
category: ['bug', 'idea', 'usability', 'analysis'].includes(input?.category) ? input.category : 'idea',
rating: clamp(input?.rating, 1, 5), note,
diagnostics: input?.includeDiagnostics ? feedbackDiagnostics() : null,
};
state.feedback.push(entry);
await Store.saveFeedback();
const markdown = feedbackMarkdown(entry);
await copyText(markdown);
const title = `[Listing Analyzer feedback] ${entry.category}`;
const issueUrl = `https://github.com/Makaytron/Etsy-Automation-Tools/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(markdown)}`;
window.open(issueUrl, '_blank', 'noopener,noreferrer');
return entry;
}
const UI = {
async mount() {
if (state.host?.isConnected) return;
state.host = document.createElement('div');
state.host.id = 'makaytron-etsy-listing-analyzer';
state.host.setAttribute('lang', state.settings.language);
state.shadow = state.host.attachShadow({ mode: 'open' });
state.shadow.innerHTML = ``;
document.documentElement.appendChild(state.host);
state.launcher = state.shadow.querySelector('.meli-launcher');
state.panel = state.shadow.querySelector('.meli-panel');
state.launcher.addEventListener('click', () => { void this.setCollapsed(false); });
this.render(true);
},
setStatus(key, tone = 'ready', params = {}) {
state.status = { key, tone, params };
this.renderStatus();
const successKeys = new Set(['scanComplete', 'collectionComplete', 'proposalSaved', 'queueCreated', 'formApplied', 'publishVerified', 'queueComplete', 'aiCopied', 'aiImportSuccess', 'exportComplete', 'settingsSaved', 'dataCleared']);
if (tone === 'error' || tone === 'blocked') this.toast(t(key, params), tone === 'error' ? 'error' : 'warning');
else if (successKeys.has(key)) this.toast(t(key, params), 'success');
},
renderStatus() {
const element = state.shadow?.querySelector('.meli-status');
if (!element) return;
element.dataset.tone = state.status.tone;
element.querySelector('strong').textContent = t({ error: 'error', blocked: 'blocked', scanning: 'scanning' }[state.status.tone] || 'ready');
element.querySelector('span').textContent = t(state.status.key, state.status.params);
},
async setCollapsed(collapsed) {
state.settings.collapsed = Boolean(collapsed);
await Store.saveSettings();
this.renderVisibility();
},
renderVisibility() {
if (!state.panel || !state.launcher) return;
state.panel.hidden = Boolean(state.settings.collapsed);
state.launcher.hidden = !state.settings.collapsed;
if (!state.settings.collapsed) telemetryPanelOpened();
},
summary() {
const counts = { growing: 0, improve: 0, deactivateReview: 0, protected: 0, declining: 0, healthy: 0, needsReview: 0, experiments: 0 };
const scopedRecords = analysisCollectionIsFresh() ? collectedAnalysisRecords() : [];
scopedRecords.forEach((record) => {
const code = record.analysis?.code;
if (code in counts) counts[code] += 1;
if (['protected', 'monitor', 'growing'].includes(code) && !['DATA_GAP', 'INACTIVE'].includes(record.analysis?.lifecycle)) counts.healthy += 1;
if (['improve', 'declining', 'deactivateReview'].includes(code)) counts.needsReview += 1;
if (record.analysis?.lifecycle === 'EXPERIMENT_RUNNING') counts.experiments += 1;
});
return counts;
},
render(force = false) {
if (!state.panel) return;
const views = new Set(['overview', 'analysis', 'ai', 'queue', 'settings']);
if (!views.has(state.activeView)) state.activeView = 'overview';
state.host?.setAttribute('lang', state.settings.language);
state.panel.setAttribute('lang', state.settings.language);
state.panel.setAttribute('aria-label', t('panelAria'));
state.panel.classList.toggle('is-wide', Boolean(state.wide));
state.panel.innerHTML = `
${logoLinkMarkup()}
${escapeHtml(t('product'))}
Makaytron · ${state.settings.language === 'tr' ? 'Listing analizi · Yerel veri' : 'Listing analysis · Local data'}
v${escapeHtml(APP_VERSION)}
${this.navigation()}
${this.renderView()}
`;
this.bindPanel();
this.renderVisibility();
this.updateBadge();
},
navigation() {
const items = [
['overview', 'overview', 'overview', ''],
['analysis', 'analysisTitle', 'analysis', 'analysis'],
['ai', 'aiExchange', 'ai', 'ai'],
['queue', 'queueTitle', 'queue', ''],
['settings', 'settingsTitle', 'settings', 'settings'],
];
return ``;
},
renderView() {
if (state.activeView === 'analysis') return this.analysisView();
if (state.activeView === 'ai') return this.aiView();
if (state.activeView === 'queue') return this.queueView();
if (state.activeView === 'settings') return this.settingsView();
return this.overviewView();
},
viewHeader(title, copy = '') {
return `${escapeHtml(title)}
${copy ? `
${escapeHtml(copy)}
` : ''}
`;
},
statusCard() {
const toneKey = { error: 'error', blocked: 'blocked', scanning: 'scanning' }[state.status.tone] || 'ready';
return `${iconSvg('activity')}
${escapeHtml(t(toneKey))}${escapeHtml(t(state.status.key, state.status.params))}
`;
},
updateBanner() {
if (state.updateState.status !== 'available') return '';
return `${escapeHtml(t('updateAvailable', { version: state.updateState.latestVersion }))}${escapeHtml(t('updateInstallHelp'))}
`;
},
storageBanner() {
refreshStorageEstimate();
if (!state.storageHealth.warning) return '';
const size = (state.storageHealth.estimateBytes / (1024 * 1024)).toFixed(1);
return `${escapeHtml(t('storageUsage'))}: ${escapeHtml(t('storageUsageValue', { size }))}${escapeHtml(t('storageQuotaWarning'))}
`;
},
collectionCard() {
const job = state.collection;
const pages = Collection.pageCount();
const total = job?.totalPages || ListingPageAdapter.pageInfo().total || 1;
const count = job?.uniqueIds?.length || 0;
const progress = job ? clamp((pages / Math.max(1, total)) * 100, 0, 100) : 0;
const status = job?.status === 'completed' ? t('saved') : job?.status === 'running' ? t('scanning') : job?.status === 'paused' || job?.status === 'blocked' ? t('blocked') : t('ready');
const hasReport = job?.status === 'blocked' && job.failureReports?.length;
return `${escapeHtml(t('collectionStatus'))}${escapeHtml(status)}
${formatNumber(Math.round(progress))}%
${escapeHtml(t('collectionPages'))}${formatNumber(pages)} / ${formatNumber(total)}${escapeHtml(t('collectionListings'))}${formatNumber(count)}
${hasReport ? `` : ''}`;
},
overviewView() {
const counts = this.summary();
const page = routeKind();
const locallyRunning = state.collection?.status === 'running' && Boolean(state.collectionLoop && state.collectionLeaseToken);
const collectionLabel = locallyRunning ? 'stopCollection' : ['running', 'paused'].includes(state.collection?.status) ? 'resumeCollection' : 'scanAllPages';
return `${this.viewHeader(t('overview'), t('manualApproval'))}${this.updateBanner()}${this.storageBanner()}${this.statusCard()}
${escapeHtml(t('listings'))}${formatNumber(state.records.length)}
${escapeHtml(t('pageListings'))}${formatNumber(state.pageListings.length)}
${escapeHtml(t('healthy'))}${formatNumber(counts.healthy)}
${escapeHtml(t('needsReview'))}${formatNumber(counts.needsReview)}
${escapeHtml(t('activeExperiments'))}${formatNumber(counts.experiments)}
${escapeHtml(t('deactivateReview'))}${formatNumber(counts.deactivateReview)}
${page === 'listings' ? this.collectionCard() : ''}
${escapeHtml(t('actions'))}
${this.queueCard()}
${escapeHtml(t('manualApproval'))}
`;
},
analysisView() {
const fresh = analysisCollectionIsFresh();
state.renderedAnalysisFresh = fresh;
scheduleAnalysisFreshnessExpiry();
if (!fresh) return this.analysisGateView();
const analysisRecords = collectedAnalysisRecords();
const filters = normalizeAnalysisFilters(state.analysisFilters);
const selected = (value, current) => value === current ? ' selected' : '';
const lifecycleOptions = ['DATA_GAP','BASELINE','LEARNING','ACTIVE_STABLE','ACTIVE_GROWING','ACTIVE_DECLINING','PROTECTED','EXPERIMENT_RUNNING','DORMANT','DEACTIVATION_REVIEW','INACTIVE'];
const diagnosisOptions = ['DISCOVERY_WEAK','ENGAGEMENT_WEAK','PURCHASE_FRICTION','SCALE_DISCOVERY','HEALTHY_OR_MIXED','INSUFFICIENT_SIGNAL'];
const recommendationOptions = ['improve','declining','deactivateReview','protected','monitor','waiting','growing'];
const filterCount = analysisFilterCount(filters) + (normalizeSpace(state.analysisQuery) ? 1 : 0);
const builtinPresets = BUILTIN_FILTER_PRESETS.map((preset) => ``).join('');
const customPresets = state.filterPresets.map((preset) => ``).join('');
return `${this.viewHeader(t('analysisTitle'), t('selected', { count: state.selectedIds.size }))}
${escapeHtml(t('filterPresets'))}
${builtinPresets}${customPresets}
${iconSvg('analysis')}
${escapeHtml(t('resultsCount', { visible: analysisRecords.length, total: analysisRecords.length }))}${escapeHtml(t('selected', { count: state.selectedIds.size }))}
${escapeHtml(t('selected', { count: state.selectedIds.size }))}${escapeHtml(t('manualApproval'))}
${Queue.activeItem() ? `` : ''}
`;
},
analysisGateView() {
const status = state.collection?.status || '';
const onListingsPage = routeKind() === 'listings';
const locallyRunning = status === 'running' && Boolean(state.collectionLoop && state.collectionLeaseToken);
const buttonLabel = locallyRunning ? t('stopCollection') : ['running', 'paused'].includes(status) ? t('resumeCollection') : t('startAnalysis');
const totalPages = Math.max(1, Number(state.collection?.totalPages) || 1);
const completedPages = Object.keys(state.collection?.pages || {}).length;
const progress = Math.max(0, Math.min(100, Math.round((completedPages / totalPages) * 100)));
const progressMarkup = ['running', 'paused'].includes(status) ? `
${escapeHtml(t('collectionProgress', Collection.progressParams()))} ` : '';
const storedScope = String(state.collection?.scopeKey || '');
const listingsHref = /^\/your\/shops\/[^/?#]+\/tools\/listings(?:\?|$)/.test(storedScope) ? storedScope : '/your/shops/me/tools/listings?stats=true';
const actionMarkup = onListingsPage
? `${escapeHtml(t('collectionShortcut'))}`
: `${escapeHtml(t('openListingsForAnalysis'))}`;
const reportMarkup = status === 'blocked' && state.collection?.failureReports?.length ? `` : '';
return `${this.viewHeader(t('analysisTitle'))}
${iconSvg('activity')}
${escapeHtml(t('analysisOverdueTitle'))}
${escapeHtml(t('analysisOverdueCopy'))}
${progressMarkup}
${actionMarkup}
${reportMarkup}
`;
},
aiView() {
return `${this.viewHeader(t('aiTitle'), t('aiIntro'))}
${this.aiComparisonView()}
`;
},
queueView() {
const item = Queue.activeItem();
return `${this.viewHeader(t('queueTitle'), item ? t('currentItem', { current: state.queue.cursor + 1, total: state.queue.items.length }) : t('noQueue'))}${this.queueCard()}${escapeHtml(t('manualApproval'))}
`;
},
settingsView() {
const fields = ['minVisitsToImprove','minVisitsToProtect','minRenewalsToReview','declinePercent'];
const updateCopy = state.updateState.status === 'checking' ? t('updateChecking') : state.updateState.status === 'available' ? t('updateAvailable', { version: state.updateState.latestVersion }) : state.updateState.status === 'current' ? t('updateCurrent') : state.updateState.status === 'managed' ? t('updateManagedExternally') : state.updateState.status === 'error' ? t('updateFailed', { message: state.updateState.error }) : t('checkUpdate');
const installAction = ['current', 'available'].includes(state.updateState.status) ? `` : '';
return `${this.viewHeader(t('settingsTitle'))}${this.updateBanner()}${this.storageBanner()}${fields.map((key) => `
${escapeHtml(t(key))}${escapeHtml(state.settings[key])}
`).join('')}
${installAction}
${escapeHtml(t('updateInstallHelp'))}
${escapeHtml(t('feedbackCount'))}: ${formatNumber(state.feedback.length)}
`;
},
queueCard() {
const queue = state.queue;
const item = Queue.activeItem();
const page = routeKind();
const recovery = Queue.recoveryState();
if (recovery) {
const copy = recovery.submitted ? t('queueRecoverySubmitted') : t('queueRecoveryCopy');
return `${escapeHtml(t('queueRecoveryTitle'))}${queue?.items ? `${Math.min(queue.cursor + 1, queue.items.length)}/${queue.items.length}` : ''}
${escapeHtml(t('listing'))} ${escapeHtml(item.listingId)}${escapeHtml(copy)}
${recovery.submitted ? '' : ``}
`;
}
return `${escapeHtml(t('queueTitle'))}${queue?.items ? `${Math.min(queue.cursor + (item ? 1 : 0), queue.items.length)}/${queue.items.length}` : ''}
${item ? `
${escapeHtml(item.title || item.listingId)} · ${escapeHtml(item.status)}
${page === 'editor' ? this.editorQueueActions(item) : `
`}` : `
${escapeHtml(t('noQueue'))}
`}
`;
},
editorQueueActions(item) {
const mismatch = currentListingId() && currentListingId() !== String(item.listingId);
if (mismatch) return `${escapeHtml(t('routeMismatch'))}
`;
if (item.proposal?.action === 'DEACTIVATE_REVIEW') {
return ``;
}
return ``;
},
bindPanel() {
const root = state.panel;
root.querySelector('[data-language]')?.addEventListener('click', async () => {
state.settings.language = state.settings.language === 'tr' ? 'en' : 'tr';
await Store.saveSettings();
this.render(true);
registerMenus();
});
root.querySelector('[data-wide]')?.addEventListener('click', () => { state.wide = !state.wide; this.render(true); });
root.querySelector('[data-collapse]')?.addEventListener('click', () => { void this.setCollapsed(true); });
root.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => {
const view = button.dataset.view;
if (view === 'analysis') { void this.openAnalysis(); return; }
if (view === 'ai') { this.openAi(); return; }
if (view === 'settings') { this.openSettings(); return; }
state.activeView = view;
this.render(true);
}));
root.querySelector('[data-action="scan"]')?.addEventListener('click', () => { void scanCurrentPage(); });
root.querySelector('[data-action="scan-all"]')?.addEventListener('click', () => { void Collection.toggle(); });
root.querySelector('[data-analysis-scan]')?.addEventListener('click', () => { void Collection.toggle(); });
root.querySelector('[data-overview-action="analysis"]')?.addEventListener('click', () => { void this.openAnalysis(); });
root.querySelector('[data-overview-action="ai"]')?.addEventListener('click', () => { this.openAi(); });
root.querySelector('[data-action="export"]')?.addEventListener('click', () => { void exportBackup(); });
const backupInput = root.querySelector('[data-backup-file]');
root.querySelectorAll('[data-import-backup]').forEach((button) => button.addEventListener('click', () => backupInput?.click()));
backupInput?.addEventListener('change', () => { const file = backupInput.files?.[0]; if (file) this.openBackupImport(file); });
root.querySelectorAll('[data-feedback]').forEach((button) => button.addEventListener('click', () => this.openFeedback()));
root.querySelector('[data-overview-action="settings"]')?.addEventListener('click', () => { this.openSettings(); });
root.querySelector('[data-open-settings-modal]')?.addEventListener('click', () => { this.openSettingsModal(); });
root.querySelector('[data-check-update]')?.addEventListener('click', () => { void checkForUpdates({ manual: true, force: true }); });
bindInstallUpdate(root.querySelector('[data-install-update]'));
root.querySelectorAll('[data-error-report]').forEach((button) => button.addEventListener('click', () => { this.openCollectionErrorReport(); }));
root.querySelector('[data-action="go-current"]')?.addEventListener('click', () => { void Queue.navigate(); });
root.querySelector('[data-action="apply"]')?.addEventListener('click', () => { void applyCurrentProposal(); });
root.querySelector('[data-action="publish"]')?.addEventListener('click', () => { void publishCurrentProposal(); });
root.querySelector('[data-action="deactivate"]')?.addEventListener('click', () => { void openCurrentDeactivate(); });
root.querySelector('[data-action="verify-deactivate"]')?.addEventListener('click', () => { void verifyCurrentDeactivate(); });
root.querySelector('[data-action="skip"]')?.addEventListener('click', () => { void skipCurrentItem(); });
root.querySelector('[data-action="stop"]')?.addEventListener('click', () => { void Queue.stop('user'); });
root.querySelector('[data-recovery-open-listing]')?.addEventListener('click', () => { void Queue.navigate(); });
root.querySelector('[data-recovery-stop]')?.addEventListener('click', () => { void Queue.stop('recovery-user'); });
root.querySelector('[data-queue-recover]')?.addEventListener('click', async () => {
const item = Queue.activeItem();
if (!item || item.status === 'submitted-unverified' || !confirm(t('queueRecoveryConfirm'))) return;
await Queue.updateItem({ status: 'pending', error: '', changedFields: [] });
this.setStatus('editorReady', 'ready'); this.render(true);
});
if (state.activeView === 'analysis') this.bindAnalysis(root);
if (state.activeView === 'ai') this.bindAi(root);
},
bindAnalysis(root) {
const body = root.querySelector('[data-table-body]');
if (!body) return;
const search = root.querySelector('[data-table-search]');
const updateSelected = (visible) => {
const visibleIds = new Set(visible.map((record) => String(record.listingId)));
const hiddenCount = [...state.selectedIds].filter((listingId) => !visibleIds.has(String(listingId))).length;
root.querySelectorAll('[data-selected-count]').forEach((element) => { element.textContent = t('selected', { count: state.selectedIds.size }); });
const hidden = root.querySelector('[data-hidden-selected]');
if (hidden) { hidden.textContent = hiddenCount ? t('hiddenSelected', { count: hiddenCount }) : ''; hidden.hidden = hiddenCount === 0; }
const bulkBar = root.querySelector('[data-bulk-bar]'); if (bulkBar) bulkBar.hidden = state.selectedIds.size === 0;
const researchButton = root.querySelector('[data-start-research]');
if (researchButton) { researchButton.disabled = state.selectedIds.size !== 1; researchButton.title = state.selectedIds.size === 1 ? t('researchStart') : t('researchOneListing'); }
};
const syncPresetState = () => {
root.querySelectorAll('[data-apply-builtin-preset]').forEach((button) => {
const preset = BUILTIN_FILTER_PRESETS.find((item) => item.id === button.dataset.applyBuiltinPreset);
button.classList.toggle('is-active', Boolean(preset && analysisPresetIsActive(preset)));
});
root.querySelectorAll('[data-apply-custom-preset]').forEach((button) => {
const preset = state.filterPresets.find((item) => item.id === button.dataset.applyCustomPreset);
button.classList.toggle('is-active', Boolean(preset && analysisPresetIsActive(preset)));
});
};
const updateFacetCounts = (analysisRecords) => {
const query = normalizeSpace(state.analysisQuery).toLocaleLowerCase();
const pageIds = new Set(state.pageListings.map((item) => String(item.listingId)));
const counts = analysisFacetCounts(analysisRecords, state.analysisFilters, query, pageIds);
root.querySelectorAll('[data-analysis-filter]').forEach((control) => {
const facet = control.dataset.analysisFilter;
if (!counts[facet]) return;
Array.from(control.options || []).forEach((option) => {
if (!option.dataset.baseLabel) option.dataset.baseLabel = option.textContent.replace(/\s+\(\d+\)$/, '');
const count = counts[facet][option.value] ?? 0;
option.textContent = `${option.dataset.baseLabel} (${formatNumber(count)})`;
option.disabled = count === 0 && option.value !== control.value;
});
});
};
const renderRows = (options = {}) => {
const previousScrollTop = options.preserveScroll ? body.scrollTop : 0;
const analysisRecords = collectedAnalysisRecords();
const visible = filteredAnalysisRecords(analysisRecords);
const shown = visible.slice(0, state.analysisLimit);
const moreMarkup = shown.length < visible.length ? `${escapeHtml(t('showingCount', { shown: shown.length, total: visible.length }))}
` : '';
if (options.append && visible.length) {
const rendered = body.querySelectorAll('[data-listing-card]').length;
body.querySelector('[data-load-more]')?.closest('.meli-load-more')?.remove();
body.insertAdjacentHTML('beforeend', `${shown.slice(rendered).map((record) => this.recordRow(record)).join('')}${moreMarkup}`);
} else if (!analysisRecords.length) body.innerHTML = `${escapeHtml(t('noData'))}
`;
else if (!visible.length) body.innerHTML = `${escapeHtml(t('noFilterResults'))}
`;
else body.innerHTML = `${shown.map((record) => this.recordRow(record)).join('')}${moreMarkup}`;
if (options.preserveScroll) body.scrollTop = previousScrollTop;
else body.scrollTop = 0;
const resultCopy = t('resultsCount', { visible: visible.length, total: analysisRecords.length });
root.querySelectorAll('[data-results-count]').forEach((element) => { element.textContent = resultCopy; });
const filterCount = analysisFilterCount() + (normalizeSpace(state.analysisQuery) ? 1 : 0);
const badge = root.querySelector('[data-filter-count]'); if (badge) badge.textContent = formatNumber(filterCount);
const reset = root.querySelector('[data-reset-filters]'); if (reset) reset.disabled = filterCount === 0;
updateFacetCounts(analysisRecords);
syncPresetState();
updateSelected(visible);
return visible;
};
const syncFilterControls = () => {
root.querySelectorAll('[data-analysis-filter]').forEach((control) => { control.value = state.analysisFilters[control.dataset.analysisFilter] ?? ''; });
if (search) search.value = state.analysisQuery;
};
const resetFilters = async () => {
state.analysisFilters = { ...DEFAULT_ANALYSIS_FILTERS };
state.analysisQuery = '';
state.analysisLimit = ANALYSIS_BATCH_SIZE;
syncFilterControls();
await Store.saveAnalysisFilters();
visible = renderRows();
};
const applyPreset = async (preset) => {
if (!preset) return;
state.analysisFilters = normalizeAnalysisFilters({ ...DEFAULT_ANALYSIS_FILTERS, ...(preset.filters || {}) });
state.analysisQuery = normalizeSpace(preset.query).slice(0, 120);
state.analysisLimit = ANALYSIS_BATCH_SIZE;
syncFilterControls();
await Store.saveAnalysisFilters();
visible = renderRows();
};
let visible = renderRows();
body.addEventListener('change', (event) => {
const checkbox = event.target instanceof Element ? event.target.closest('[data-row-select]') : null;
if (!checkbox) return;
checkbox.checked ? state.selectedIds.add(checkbox.dataset.rowSelect) : state.selectedIds.delete(checkbox.dataset.rowSelect);
checkbox.closest('.meli-listing-card')?.classList.toggle('is-selected', checkbox.checked);
updateSelected(visible);
});
body.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target) return;
const proposal = target.closest('[data-proposal]');
if (proposal) {
const record = state.records.find((item) => item.listingId === proposal.dataset.proposal);
if (record) this.openProposal(record);
return;
}
const researchEvidence = target.closest('[data-research-evidence]');
if (researchEvidence) {
const record = state.records.find((item) => item.listingId === researchEvidence.dataset.researchEvidence);
if (record) this.openResearchEvidence(record);
return;
}
const history = target.closest('[data-history]');
if (history) {
const record = state.records.find((item) => item.listingId === history.dataset.history);
if (record) this.openHistory(record);
return;
}
if (target.closest('[data-load-more]')) {
state.analysisLimit += ANALYSIS_BATCH_SIZE;
visible = renderRows({ append: true, preserveScroll: true });
return;
}
if (target.closest('[data-empty-reset]')) void resetFilters();
});
search?.addEventListener('input', () => {
state.analysisQuery = search.value;
state.analysisLimit = ANALYSIS_BATCH_SIZE;
visible = renderRows();
});
root.querySelectorAll('[data-analysis-filter]').forEach((control) => control.addEventListener('change', () => {
state.analysisFilters = normalizeAnalysisFilters({ ...state.analysisFilters, [control.dataset.analysisFilter]: control.value });
state.analysisLimit = ANALYSIS_BATCH_SIZE;
void Store.saveAnalysisFilters();
visible = renderRows();
}));
root.querySelector('[data-toggle-filters]')?.addEventListener('click', (event) => {
state.analysisFilterDrawerOpen = !state.analysisFilterDrawerOpen;
event.currentTarget.setAttribute('aria-expanded', String(state.analysisFilterDrawerOpen));
const drawer = root.querySelector('[data-filter-drawer]'); if (drawer) drawer.hidden = !state.analysisFilterDrawerOpen;
});
root.querySelector('[data-reset-filters]')?.addEventListener('click', () => { void resetFilters(); });
root.querySelectorAll('[data-apply-builtin-preset]').forEach((button) => button.addEventListener('click', () => {
const preset = BUILTIN_FILTER_PRESETS.find((item) => item.id === button.dataset.applyBuiltinPreset);
void applyPreset(preset);
}));
root.querySelectorAll('[data-apply-custom-preset]').forEach((button) => button.addEventListener('click', () => {
const preset = state.filterPresets.find((item) => item.id === button.dataset.applyCustomPreset);
void applyPreset(preset);
}));
root.querySelector('[data-save-preset]')?.addEventListener('click', async () => {
const input = root.querySelector('[data-preset-name]');
const name = normalizeSpace(input?.value).slice(0, 32);
if (name.length < 2) { this.toast(t('presetInvalid'), 'warning'); input?.focus(); return; }
const existingIndex = state.filterPresets.findIndex((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase());
if (existingIndex < 0 && state.filterPresets.length >= MAX_FILTER_PRESETS) { this.toast(t('presetLimit', { count: MAX_FILTER_PRESETS }), 'warning'); return; }
const previous = existingIndex >= 0 ? state.filterPresets[existingIndex] : null;
const candidate = { id: previous?.id || randomId('preset'), name, filters: normalizeAnalysisFilters(state.analysisFilters), query: normalizeSpace(state.analysisQuery).slice(0, 120), createdAt: previous?.createdAt || nowIso(), updatedAt: nowIso() };
if (existingIndex >= 0) state.filterPresets.splice(existingIndex, 1, candidate); else state.filterPresets.push(candidate);
await Store.saveFilterPresets();
this.toast(t('presetSaved'), 'success');
this.render(true);
});
root.querySelectorAll('[data-delete-preset]').forEach((button) => button.addEventListener('click', async () => {
state.filterPresets = state.filterPresets.filter((item) => item.id !== button.dataset.deletePreset);
await Store.saveFilterPresets();
this.toast(t('presetDeleted'), 'success');
this.render(true);
}));
root.querySelector('[data-select-visible]')?.addEventListener('click', () => {
root.querySelectorAll('[data-row-select]').forEach((checkbox) => {
state.selectedIds.add(checkbox.dataset.rowSelect);
checkbox.checked = true;
checkbox.closest('.meli-listing-card')?.classList.add('is-selected');
});
updateSelected(visible);
});
root.querySelector('[data-clear-selection]')?.addEventListener('click', () => {
state.selectedIds.clear();
root.querySelectorAll('[data-row-select]').forEach((checkbox) => {
checkbox.checked = false;
checkbox.closest('.meli-listing-card')?.classList.remove('is-selected');
});
updateSelected(visible);
});
root.querySelector('[data-build-queue]')?.addEventListener('click', () => { void buildQueueFromSelection(); });
root.querySelector('[data-start-research]')?.addEventListener('click', () => { void startMarketplaceResearchSelection(); });
root.querySelector('[data-open-ai]')?.addEventListener('click', () => this.openAi());
},
bindAi(root) {
root.querySelector('[data-copy-ai]')?.addEventListener('click', async () => {
await copyAiRequest();
const feedback = root.querySelector('[data-ai-feedback]');
if (feedback) feedback.textContent = t(state.status.key, state.status.params);
});
root.querySelector('[data-copy-ai-template]')?.addEventListener('click', async () => {
await copyText(aiProposalTemplate()); this.toast(t('aiTemplateCopied'), 'success');
});
root.querySelector('[data-import-ai]')?.addEventListener('click', () => {
const feedbackProxy = {
style: { set color(value) { const current = state.panel?.querySelector('[data-ai-feedback]'); if (current) current.style.color = value; } },
set textContent(value) { const current = state.panel?.querySelector('[data-ai-feedback]'); if (current) current.textContent = value; },
setAttribute(name, value) { state.panel?.querySelector('[data-ai-feedback]')?.setAttribute(name, value); },
get dataset() { return state.panel?.querySelector('[data-ai-feedback]')?.dataset || {}; },
};
void importAiResponse(root.querySelector('[data-ai-json]')?.value || '', feedbackProxy);
});
},
updateBadge() {
const badge = state.shadow?.querySelector('[data-launch-badge]');
if (!badge) return;
const summary = this.summary();
const count = summary.deactivateReview + summary.improve + summary.declining;
badge.textContent = count > 99 ? '99+' : String(count);
badge.hidden = count === 0;
},
openModal(title, body, options = {}) {
const active = state.shadow?.activeElement;
const fallback = state.panel?.querySelector('[data-open-settings-modal], [data-view="settings"], [data-feedback], [data-import-backup]');
const returnFocus = active instanceof HTMLElement && active.isConnected && !state.modal?.contains(active) ? active : fallback;
this.closeModal({ restoreFocus: false });
state.modalReturnFocus = returnFocus instanceof HTMLElement ? returnFocus : null;
const overlay = document.createElement('div');
overlay.className = 'meli-overlay';
overlay.setAttribute('lang', state.settings.language);
overlay.innerHTML = `${logoLinkMarkup('meli-modal-logo')}
${escapeHtml(title)}
Makaytron · Etsy Listing Analyzer · v${escapeHtml(APP_VERSION)}
${body}
${options.footer || ''}`;
state.shadow.appendChild(overlay);
state.modal = overlay;
overlay.querySelector('[data-modal-close]')?.addEventListener('click', () => this.closeModal());
overlay.addEventListener('click', (event) => { if (event.target === overlay) this.closeModal(); });
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.closeModal(); return; }
if (event.key !== 'Tab') return;
const focusable = Array.from(overlay.querySelectorAll('button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter((node) => node instanceof HTMLElement && !node.hidden && node.getClientRects().length);
if (!focusable.length) { event.preventDefault(); overlay.querySelector('.meli-modal-body')?.focus(); return; }
const first = focusable[0]; const last = focusable.at(-1);
if (event.shiftKey && (state.shadow.activeElement === first || !overlay.contains(state.shadow.activeElement))) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && state.shadow.activeElement === last) { event.preventDefault(); first.focus(); }
});
overlay.querySelector('input,select,textarea,button')?.focus();
},
closeModal(options = {}) {
const returnFocus = state.modalReturnFocus;
state.modal?.remove();
state.modal = null;
state.modalReturnFocus = null;
if (options.restoreFocus !== false && returnFocus?.isConnected) returnFocus.focus();
},
toast(message, tone = 'info', duration = 3600) {
const root = state.shadow?.querySelector('[data-toast-root]');
if (!root || !message) return;
while (root.children.length >= 4) root.firstElementChild?.remove();
const marks = { success: '✓', error: '!', warning: '!', info: 'i' };
const item = document.createElement('div');
item.className = 'meli-toast';
item.dataset.tone = marks[tone] ? tone : 'info';
item.setAttribute('role', item.dataset.tone === 'error' ? 'alert' : 'status');
const mark = document.createElement('span');
mark.className = 'meli-toast-mark';
mark.textContent = marks[item.dataset.tone];
const copy = document.createElement('span');
copy.className = 'meli-toast-copy';
copy.textContent = String(message);
const close = document.createElement('button');
close.type = 'button';
close.className = 'meli-toast-close';
close.setAttribute('aria-label', t('close'));
close.textContent = '×';
item.append(mark, copy, close);
root.appendChild(item);
const remove = () => {
item.classList.remove('is-visible');
window.setTimeout(() => item.remove(), 180);
};
close.addEventListener('click', remove);
requestAnimationFrame(() => item.classList.add('is-visible'));
window.setTimeout(remove, duration);
},
researchEvidenceMarkup(evidence, limit = 10) {
let validated;
try { validated = validateResearchEvidence(evidence); }
catch { return ''; }
const rows = validated.keywords.slice(0, clamp(limit, 1, 28)).map((item) => `${escapeHtml(item.keyword)}${escapeHtml(t('researchSearches30d'))}${escapeHtml(formatNumber(item.searches30d))}${escapeHtml(t('researchSearchResults'))}${escapeHtml(formatNumber(item.searchResults))}${escapeHtml(t('researchOpportunity'))}${escapeHtml(`${formatNumber(item.opportunity.score)} · ${item.opportunity.label}`)}
`).join('');
return `${escapeHtml(t('researchEvidenceTitle'))}
${rows}
${escapeHtml(t('researchSourceNote'))}
`;
},
openResearchEvidence(record) {
let evidence;
try { evidence = validateResearchEvidence(record.researchEvidence); }
catch { return; }
const footer = ``;
this.openModal(`${t('researchEvidenceTitle')} · ${record.listingId}`, this.researchEvidenceMarkup(evidence, 25), { small: true, footer });
state.modal?.querySelector('[data-research-review]')?.addEventListener('click', () => { this.closeModal(); this.openProposal(record); });
state.modal?.querySelector('[data-modal-close-secondary]')?.addEventListener('click', () => this.closeModal());
},
openResearchMissingModal(entry = null) {
const body = ``;
const copyAction = entry ? `` : '';
const footer = ``;
this.openModal(t('researchCompanionMissingTitle'), body, { small: true, footer });
state.modal?.querySelector('[data-research-install-cancel]')?.addEventListener('click', () => this.closeModal());
state.modal?.querySelector('[data-research-copy-missing]')?.addEventListener('click', async () => {
const feedback = state.modal?.querySelector('[data-research-missing-feedback]');
try { await copyText(JSON.stringify(researchRequestEnvelope(entry), null, 2)); if (feedback) feedback.textContent = t('researchRequestCopied'); }
catch (error) { if (feedback) feedback.textContent = t('researchFailed', { message: normalizeSpace(error?.message) }); }
});
state.modal?.querySelector('[data-research-install-open]')?.addEventListener('click', () => {
GMX.openTab(RESEARCH_INSTALL_URL);
});
},
openResearchTransfer(entry) {
const statusKey = entry.status === 'completed' ? 'researchComplete' : entry.status === 'acknowledged' ? 'researchAcknowledged' : entry.status === 'request-sent' ? 'researchRequestSent' : 'researchWaitingReady';
const statusParams = entry.status === 'completed' ? { count: Number(entry.evidenceCount) || 0 } : {};
const body = `${iconSvg('activity')}
${escapeHtml(t('scanning'))}${escapeHtml(t(statusKey, statusParams))}
requestId${escapeHtml(entry.requestId)}
reference${escapeHtml(entry.opaqueReference)}
${escapeHtml(t('capturedAt'))}${escapeHtml(formatDate(entry.createdAt))}
TTL${escapeHtml(formatDate(entry.expiresAt))}
`;
const footer = ``;
this.openModal(t('researchTransferTitle'), body, { small: true, footer });
const modal = state.modal;
modal.querySelector('[data-research-copy-request]')?.addEventListener('click', async () => {
const feedback = modal.querySelector('[data-research-feedback]');
try { await copyText(JSON.stringify(researchRequestEnvelope(entry), null, 2)); feedback.textContent = t('researchRequestCopied'); feedback.style.color = 'var(--success)'; }
catch (error) { feedback.textContent = t('researchFailed', { message: normalizeSpace(error?.message) }); }
});
modal.querySelector('[data-research-import-result]')?.addEventListener('click', async () => {
const feedback = modal.querySelector('[data-research-feedback]');
try {
const consumed = await importResearchResultJson(modal.querySelector('[data-research-result-json]')?.value || '');
feedback.textContent = t('researchComplete', { count: consumed.count }); feedback.style.color = 'var(--success)';
this.updateResearchTransfer(consumed.suggestion?.requiresEditorCapture ? 'researchNeedsEditor' : 'researchSavedForReview', 'ready');
} catch (error) {
const message = error?.code === 'RESEARCH_STALE' ? t('researchStale') : normalizeSpace(error?.message || error);
feedback.textContent = t('researchFailed', { message }); feedback.style.color = 'var(--meli-danger)';
this.updateResearchTransfer('researchFailed', 'error', { message });
}
});
modal.querySelector('[data-research-reopen]')?.addEventListener('click', () => { GMX.openTab(researchInsightsUrl(entry)); this.updateResearchTransfer('researchWaitingReady', 'scanning'); });
modal.querySelector('[data-research-review]')?.addEventListener('click', async () => {
const record = await Store.getRecord(entry.listingId);
if (record) { this.closeModal(); this.openProposal(record); }
});
modal.querySelector('[data-research-close]')?.addEventListener('click', () => this.closeModal());
},
updateResearchTransfer(key, tone = 'scanning', params = {}) {
const status = state.modal?.querySelector('[data-research-transfer-status]');
if (!status) return;
status.dataset.tone = tone;
const strong = status.querySelector('strong'); if (strong) strong.textContent = t(tone === 'error' ? 'error' : tone === 'blocked' ? 'blocked' : tone === 'ready' ? 'saved' : 'scanning');
const copy = status.querySelector('span'); if (copy) copy.textContent = t(key, params);
const review = state.modal?.querySelector('[data-research-review]'); if (review && tone === 'ready') review.hidden = false;
},
async openAnalysis() {
state.activeView = 'analysis';
state.wide = true;
await refreshRecords();
},
recordRow(record) {
const latest = record.history?.at(-1) || {};
const analysis = record.analysis || record.health?.result || analyseRecord(record, state.records);
const deltas = analysis.deltas || {};
const currency = normalizeSpace(latest.currency) || '$';
const signed = (value) => value === null || value === undefined ? '' : value === 0 ? '±0' : `${value > 0 ? '+' : ''}${formatNumber(value)}`;
const reviewLine = analysis.experiment ? experimentStateLabel(analysis.experiment) : `${t('nextReview')}: ${formatDate(analysis.nextReviewAt)}`;
const metric = (label, value, delta, moneyCurrency = '') => {
const deltaText = moneyCurrency && Number.isFinite(delta)
? (delta === 0 ? `±${formatMoney(0, moneyCurrency)}` : `${delta > 0 ? '+' : '−'}${formatMoney(Math.abs(delta), moneyCurrency)}`)
: signed(delta);
return `${escapeHtml(label)}${moneyCurrency ? formatMoney(value, moneyCurrency) : formatNumber(value)}${escapeHtml(deltaText)}
`;
};
const selected = state.selectedIds.has(record.listingId);
const needsAction = ['improve', 'declining', 'deactivateReview'].includes(analysis.code);
let researchEvidence = null;
try { researchEvidence = validateResearchEvidence(record.researchEvidence); }
catch { /* Invalid local evidence is not rendered or reused. */ }
const strongestKeyword = researchEvidence?.keywords?.[0] || null;
const researchSummary = strongestKeyword ? `${escapeHtml(strongestKeyword.keyword)}${escapeHtml(t('researchSearches30d'))}: ${escapeHtml(formatNumber(strongestKeyword.searches30d))} · ${escapeHtml(t('researchSearchResults'))}: ${escapeHtml(formatNumber(strongestKeyword.searchResults))}
` : '';
return `
${metric(t('visits30dLabel'), latest.visits, deltas.visits)}
${metric(t('favorites30dLabel'), latest.favorites, deltas.favorites)}
${metric(t('salesAllTimeLabel'), latest.sales, deltas.sales)}
${metric(t('revenueAllTimeLabel'), latest.revenue, deltas.revenue, currency)}
${metric(t('renewalsAllTimeLabel'), latest.renewals, deltas.renewals)}
${metric(t('stock'), latest.stock, null)}
${iconSvg('activity')}
${escapeHtml(assessmentLabel(analysis))}${escapeHtml(analysisReason(record))}
${researchSummary}
${escapeHtml(reviewLine)}
`;
},
openProposal(record) {
const current = captureEditableFieldsFromRecord(record);
const researchSuggestion = researchSuggestionForRecord(record);
const proposal = record.proposal || researchSuggestion || { action: record.analysis?.code === 'deactivateReview' ? 'DEACTIVATE_REVIEW' : 'UPDATE', ...current, reason: analysisReason(record) };
const selectedFields = record.proposal || researchSuggestion ? proposalFields(proposal) : [];
const has = (key) => Object.prototype.hasOwnProperty.call(proposal, key);
const values = {
title: has('title') ? proposal.title : current.title,
description: has('description') ? proposal.description : current.description,
tags: has('tags') && Array.isArray(proposal.tags) ? proposal.tags : current.tags,
materials: has('materials') && Array.isArray(proposal.materials) ? proposal.materials : current.materials,
};
const field = (key, label, value, options = {}) => `${escapeHtml(label)}
${options.help ? `
${escapeHtml(options.help)}` : ''}
`;
const analysis = record.analysis || record.health?.result || analyseRecord(record, state.records);
const overlap = activeObservedImprovement(record);
const researchNeedsEditor = Boolean(researchSuggestion && !record.editor?.capturedAt);
let proposalEvidence = null;
try { proposalEvidence = validateResearchEvidence(proposal.researchEvidence); }
catch { /* Invalid local evidence is omitted and cannot be copied into a proposal. */ }
const context = `${escapeHtml(lifecycleLabel(analysis.lifecycle))} ${escapeHtml(assessmentLabel(analysis))}
${escapeHtml(analysisScoreLabel(analysis))}: ${escapeHtml(Number.isFinite(analysis.score) ? t('scoreOutOf', { score: formatNumber(analysis.score) }) : '—')} · ${escapeHtml(analysisConfidenceLabel(analysis))}: ${formatNumber(analysis.confidence)}/100 ${escapeHtml(confidenceLabel(analysis.confidenceBand))}
${escapeHtml(t('whyRecommendation'))} ${escapeHtml(analysisReason(record))}
${proposalEvidence ? this.researchEvidenceMarkup(proposalEvidence, 5) : ''}`;
const overlapMarkup = overlap ? `` : '';
const captureWarning = researchNeedsEditor ? `${escapeHtml(t('researchNeedsEditor'))}
` : '';
const body = `${context}${overlapMarkup}${captureWarning}`;
const editorAction = researchNeedsEditor && record.meta?.editUrl ? `${escapeHtml(t('openEtsy'))}` : '';
const footer = ``;
this.openModal(t('proposalTitle'), body, { small: true, footer });
const modal = state.modal;
const syncFieldState = (checkbox) => checkbox.closest('[data-edit-field]')?.classList.toggle('is-selected', checkbox.checked);
modal.querySelectorAll('[data-proposal-field]').forEach((checkbox) => {
syncFieldState(checkbox);
checkbox.addEventListener('change', () => syncFieldState(checkbox));
});
const syncActionState = () => {
const updating = modal.querySelector('[data-proposal-action]').value === 'UPDATE';
modal.querySelectorAll('[data-edit-field]').forEach((wrapper) => {
wrapper.classList.toggle('is-disabled', !updating);
wrapper.querySelectorAll('textarea,input').forEach((control) => { control.disabled = !updating; });
});
};
modal.querySelector('[data-proposal-action]').addEventListener('change', syncActionState);
syncActionState();
modal.querySelectorAll('[data-proposal-input]').forEach((input) => input.addEventListener('input', () => {
const checkbox = modal.querySelector(`[data-proposal-field="${input.dataset.proposalInput}"]`);
if (!checkbox) return;
checkbox.checked = true;
syncFieldState(checkbox);
}));
modal.querySelector('[data-proposal-cancel]').addEventListener('click', () => this.closeModal());
modal.querySelector('[data-proposal-save]').addEventListener('click', async () => {
const action = modal.querySelector('[data-proposal-action]').value;
const fields = action === 'UPDATE' ? Array.from(modal.querySelectorAll('[data-proposal-field]:checked')).map((input) => input.dataset.proposalField) : [];
const feedback = modal.querySelector('[data-proposal-feedback]');
if (action === 'UPDATE' && !fields.length) { feedback.textContent = t('selectChangeField'); return; }
if (overlap && !modal.querySelector('[data-experiment-conflict-confirm]')?.checked) { feedback.textContent = t('experimentOverlapRequired'); return; }
const title = normalizeSpace(modal.querySelector('[data-proposal-input="title"]').value);
const tags = uniqueStrings(modal.querySelector('[data-proposal-input="tags"]').value.split(','));
const materials = uniqueStrings(modal.querySelector('[data-proposal-input="materials"]').value.split(','));
if (fields.includes('title') && (!title || title.length > 140)) { feedback.textContent = t('invalidTitle'); return; }
if (fields.includes('tags') && (tags.length > 13 || tags.some((tag) => tag.length > 20))) { feedback.textContent = t('invalidTags'); return; }
const saved = { action, fields, reason: modal.querySelector('[data-proposal-reason]').value.trim(), source: String(proposal.source || 'manual') };
if (proposal.requestId) saved.requestId = String(proposal.requestId);
if (proposal.reference) saved.reference = String(proposal.reference);
if (proposalEvidence) saved.researchEvidence = proposalEvidence;
saved.acknowledgeExperimentOverlap = Boolean(overlap && modal.querySelector('[data-experiment-conflict-confirm]')?.checked);
if (fields.includes('title')) saved.title = title;
if (fields.includes('description')) saved.description = modal.querySelector('[data-proposal-input="description"]').value.trim();
if (fields.includes('tags')) saved.tags = tags;
if (fields.includes('materials')) saved.materials = materials;
await Store.saveProposal(record.listingId, saved);
state.records = await Store.listRecords();
this.closeModal();
this.setStatus('proposalSaved', 'ready');
this.render(true);
});
},
historyChart(history, metric, label, moneyCurrency = '') {
const model = buildHistoryChartModel(history, metric);
const valueLabel = (value) => moneyCurrency ? formatMoney(value, moneyCurrency) : formatNumber(value);
if (model.points.length < 2) return `${escapeHtml(label)}—
${escapeHtml(t('noChartData'))}
`;
const lineMarkup = model.segments.filter((segment) => segment.length > 1).map((segment) => ``).join('');
const pointsMarkup = model.points.map((point) => `${escapeHtml(`${formatDate(point.at)} · ${valueLabel(point.value)}`)}`).join('');
const aria = `${label}: ${valueLabel(model.points[0].value)} → ${valueLabel(model.points.at(-1).value)}`;
return `${escapeHtml(label)}${escapeHtml(`${valueLabel(model.min)} – ${valueLabel(model.max)}`)}
`;
},
experimentTimeline(record) {
const events = buildExperimentTimeline(record);
if (!events.length) return '';
return `${escapeHtml(t('experimentTimeline'))}
${events.map((event) => {
const effect = Number.isFinite(event.experiment?.effectPercent) ? `${event.experiment.effectPercent > 0 ? '+' : ''}${event.experiment.effectPercent}%` : '';
const detail = event.type === 'planned' ? (event.improvement.fields || []).map((field) => t(field)).join(', ') : event.type === 'due' ? event.experiment.primaryMetric || '—' : ['winner','underperformed','inconclusive','contaminated','stopped'].includes(event.type) ? `${experimentStateLabel(event.experiment)}${effect ? ` · ${effect}` : ''}` : '';
return `
${escapeHtml(t(event.key))}${detail ? `${escapeHtml(detail)}` : ''}`;
}).join('')}
`;
},
aiComparisonView() {
const comparisons = [];
collectedAnalysisRecords().forEach((record) => {
const improvements = (record.improvements || []).filter((item) => item?.action === 'UPDATE' && item?.source === 'ai-import');
improvements.slice(-3).forEach((improvement) => comparisons.push({ record, improvement }));
if (!improvements.length && record.proposal?.source === 'ai-import' && record.proposal?.improvementId) {
const improvement = (record.improvements || []).find((item) => item.id === record.proposal.improvementId);
if (improvement) comparisons.push({ record, improvement });
}
});
comparisons.sort((left, right) => String(right.improvement.at).localeCompare(String(left.improvement.at)));
const formatValue = (value, missing = false) => {
if (missing) return t('valueNotCaptured');
if (Array.isArray(value)) return value.length ? value.join('\n') : '[]';
return value === null || value === undefined ? '—' : String(value) || '—';
};
const cards = comparisons.slice(0, 12).map(({ record, improvement }) => {
const fields = Array.isArray(improvement.fields) ? improvement.fields : [];
const applied = improvement.status === 'published' && improvement.after && typeof improvement.after === 'object';
const fieldRows = fields.map((field) => {
const missingBefore = field !== 'title' && !improvement.baselineCapturedAt;
return `${escapeHtml(t(field))}${escapeHtml(t('beforeValue'))}${escapeHtml(formatValue(improvement.before?.[field], missingBefore))} ${escapeHtml(t('proposedValue'))}${escapeHtml(formatValue(improvement.proposed?.[field]))} ${escapeHtml(t('appliedValue'))}${escapeHtml(applied ? formatValue(improvement.after?.[field]) : t('timelineNotApplied'))} `;
}).join('');
return `${escapeHtml(record.meta?.title || record.listingId)}ID ${escapeHtml(record.listingId)} · ${escapeHtml(formatDate(improvement.at))}
${escapeHtml(applied ? t('timelinePublished') : experimentStateLabel(improvement.experiment))}${fieldRows}
`;
}).join('');
return `${escapeHtml(t('aiComparison'))}
${cards || `
${escapeHtml(t('aiComparisonEmpty'))}
`}
`;
},
openHistory(record) {
const history = Array.isArray(record.history) ? [...record.history].reverse() : [];
const improvements = Array.isArray(record.improvements) ? [...record.improvements].reverse() : [];
const analysis = record.analysis || record.health?.result || analyseRecord(record, state.records);
const currency = normalizeSpace(record.history?.at(-1)?.currency) || '$';
const evidence = Array.isArray(analysis.evidence) ? analysis.evidence : [];
const components = analysis.confidenceComponents || {};
const componentKeys = ['dataQuality', 'historyDepth', 'trafficSample', 'cohortStrength', 'freshness', 'dataIntegrity'];
const extraEvidence = evidence.slice(3);
const experiment = analysis.experiment;
const basisKey = analysis.assessmentMode === 'snapshot' ? 'snapshotBasis' : analysis.assessmentMode === 'longitudinal' ? 'longitudinalBasis' : 'insufficientBasis';
const capKey = analysis.assessmentMode === 'snapshot' ? 'snapshotConfidenceLimited' : 'confidenceLimited';
const summary = `${escapeHtml(lifecycleLabel(analysis.lifecycle))}${escapeHtml(analysisScoreLabel(analysis))}: ${escapeHtml(Number.isFinite(analysis.score) ? t('scoreOutOf', { score: formatNumber(analysis.score) }) : '—')}
${escapeHtml(assessmentLabel(analysis))}
${escapeHtml(analysisConfidenceLabel(analysis))}: ${formatNumber(analysis.confidence)}/100 · ${escapeHtml(confidenceLabel(analysis.confidenceBand))}
${escapeHtml(t(basisKey))}
${escapeHtml(t('nextReview'))}: ${escapeHtml(formatDate(analysis.nextReviewAt))}
${analysis.confidenceCaps?.length ? `${escapeHtml(t(capKey))}
` : ''}`;
const evidenceCard = `${escapeHtml(t('evidence'))}
${evidence.slice(0, 3).map((item) => `- ${escapeHtml(t(item.key, item.params))}
`).join('')}
${extraEvidence.length ? `${extraEvidence.map((item) => `- ${escapeHtml(t(item.key, item.params))}
`).join('')}
` : ''}`;
const confidenceCard = `${escapeHtml(t('confidenceDetails'))}
${componentKeys.map((key) => `
${escapeHtml(t(key))}
${formatNumber(components[key])} `).join('')}
`;
const experimentCard = experiment ? `${escapeHtml(t('experiment'))}
${escapeHtml(t('experimentState'))}: ${escapeHtml(experimentStateLabel(experiment))}${escapeHtml(t('primaryMetric'))}: ${escapeHtml(experiment.primaryMetric || '—')}${escapeHtml(t('adjustedEffect'))}: ${Number.isFinite(experiment.effectPercent) ? `${experiment.effectPercent > 0 ? '+' : ''}${experiment.effectPercent}%` : '—'}
` : '';
const safeguardCard = ['DEACTIVATION_REVIEW', 'DORMANT'].includes(analysis.lifecycle) && analysis.safeguards?.length ? `${escapeHtml(t('deactivateReview'))}
${analysis.safeguards.map((item) => `- ${item.passed ? '✓' : '—'}${escapeHtml(t(item.key))}
`).join('')}
` : '';
const charts = `${escapeHtml(t('historyCharts'))}
${this.historyChart(record.history, 'visits', t('visits30dLabel'))}${this.historyChart(record.history, 'favorites', t('favorites30dLabel'))}${this.historyChart(record.history, 'sales', t('salesAllTimeLabel'))}${this.historyChart(record.history, 'revenue', t('revenueAllTimeLabel'), currency)}${this.historyChart(record.history, 'renewals', t('renewalsAllTimeLabel'))}
`;
const experimentTimeline = this.experimentTimeline(record);
const timeline = `${escapeHtml(t('historyTitle'))}
${history.length ? history.map((item) => `
${escapeHtml(formatDate(item.at))}${t('visits30dLabel')}: ${formatNumber(item.visits)}${t('favorites30dLabel')}: ${formatNumber(item.favorites)}${t('salesAllTimeLabel')}: ${formatNumber(item.sales)}${t('revenueAllTimeLabel')}: ${formatMoney(item.revenue, normalizeSpace(item.currency) || currency)}${t('renewalsAllTimeLabel')}: ${formatNumber(item.renewals)}
`).join('') : `
${escapeHtml(t('noHistory'))}
`}${improvements.map((item) => `
${escapeHtml(item.experiment ? experimentStateLabel(item.experiment) : item.status || 'planned')} ${escapeHtml(formatDate(item.at))}${escapeHtml(item.note || item.action || '')}
`).join('')}
`;
this.openModal(`${t('healthAndHistory')} · ${record.listingId}`, `${summary}${evidenceCard}${confidenceCard}${experimentCard}${safeguardCard}${charts}${experimentTimeline}${timeline}
`, { small: true });
state.modal?.querySelector('[data-show-evidence]')?.addEventListener('click', (event) => {
const extra = state.modal?.querySelector('[data-extra-evidence]'); if (!extra) return;
extra.hidden = false; event.currentTarget.remove();
});
},
openCollectionErrorReport() {
const reports = state.collection?.failureReports || [];
const reportId = String(state.collection?.error?.reportId || '');
const report = reports.find((item) => item.id === reportId) || reports.at(-1);
if (!report) return;
const fields = [
['reportTime', formatDate(report.at)], ['reportPhase', report.phase],
['reportExpectedPage', `${report.expectedPage} / ${report.totalPages}`], ['reportObservedPage', report.observedPage || '—'],
['reportAttempts', `${report.attempts} / ${report.maxAttempts}`], ['collectionListings', report.uniqueListings],
];
const body = `${escapeHtml(t('reportNoSensitiveData'))}
${fields.map(([key, value]) => `
${escapeHtml(t(key))}${escapeHtml(value)}
`).join('')}
${escapeHtml(JSON.stringify(report, null, 2))}`;
const footer = ``;
this.openModal(t('errorReportTitle'), body, { small: true, footer });
state.modal?.querySelector('[data-copy-error-report]')?.addEventListener('click', async () => {
await copyText(JSON.stringify(report, null, 2));
this.toast(t('reportCopied'), 'success');
});
state.modal?.querySelector('[data-modal-close-secondary]')?.addEventListener('click', () => this.closeModal());
},
openAi() {
state.activeView = 'ai';
this.render(true);
},
openBackupImport(initialFile = null) {
const body = ``;
const footer = ``;
this.openModal(t('backupImportTitle'), body, { small: true, footer });
const modal = state.modal;
const input = modal.querySelector('[data-backup-modal-file]');
const preview = modal.querySelector('[data-backup-preview]');
const feedback = modal.querySelector('[data-backup-feedback]');
const confirmButton = modal.querySelector('[data-backup-import-confirm]');
let parsed = null;
const inspectFile = async (file) => {
parsed = null; confirmButton.disabled = true; preview.hidden = true; feedback.textContent = '';
if (!file) return;
try {
if (file.size > MAX_BACKUP_BYTES) throw new Error(t('backupTooLarge'));
const raw = JSON.parse(await file.text());
const normalized = normalizeBackupDocument(raw);
parsed = raw;
preview.innerHTML = `${escapeHtml(t('backupPreview', { records: normalized.records.length, presets: normalized.filterPresets.length }))}${normalized.queueSkipped ? `${escapeHtml(t('backupQueueSkipped'))}
` : ''}`;
preview.hidden = false; confirmButton.disabled = false;
} catch (error) {
feedback.textContent = t('backupInvalid', { message: normalizeSpace(error?.message || error).slice(0, 240) });
}
};
input.addEventListener('change', () => { void inspectFile(input.files?.[0]); });
modal.querySelector('[data-backup-cancel]').addEventListener('click', () => this.closeModal());
confirmButton.addEventListener('click', async () => {
if (!parsed) return;
confirmButton.disabled = true;
try {
const result = await importBackupDocument(parsed);
this.closeModal();
this.setStatus('backupImported', 'ready', result);
this.toast(t('backupImported', result), 'success');
this.render(true);
} catch (error) {
confirmButton.disabled = false;
feedback.textContent = t('backupInvalid', { message: normalizeSpace(error?.message || error).slice(0, 240) });
}
});
if (initialFile) void inspectFile(initialFile);
},
openFeedback() {
const body = ``;
const footer = ``;
this.openModal(t('feedbackTitle'), body, { small: true, footer });
const modal = state.modal;
modal.querySelector('[data-feedback-cancel]').addEventListener('click', () => this.closeModal());
modal.querySelector('[data-feedback-submit]').addEventListener('click', async () => {
const errorNode = modal.querySelector('[data-feedback-error]');
try {
await saveUserFeedback({
category: modal.querySelector('[data-feedback-category]').value,
rating: modal.querySelector('[data-feedback-rating]').value,
note: modal.querySelector('[data-feedback-message]').value,
includeDiagnostics: modal.querySelector('[data-feedback-diagnostics]').checked,
});
this.closeModal(); this.toast(t('feedbackSaved'), 'success'); this.render(true);
} catch (error) { errorNode.textContent = normalizeSpace(error?.message || error); }
});
},
openSettings() {
state.activeView = 'settings';
this.render(true);
this.openSettingsModal();
},
openSettingsModal() {
const fields = ['minVisitsToImprove','minVisitsToProtect','minRenewalsToReview','declinePercent'];
const contracts = {
minVisitsToImprove: { min: 1, max: 100000, step: 1, help: 'thresholdHelpImprove' },
minVisitsToProtect: { min: 2, max: 100000, step: 1, help: 'thresholdHelpProtect' },
minRenewalsToReview: { min: 1, max: 1000, step: 1, help: 'thresholdHelpRenewals' },
declinePercent: { min: 1, max: 100, step: 1, help: 'thresholdHelpDecline' },
};
const calibration = thresholdCalibration();
const calibrationCopy = calibration.available ? t('calibrationAvailable', { count: calibration.sampleSize }) : t('calibrationInsufficient');
const recommended = calibration.values || DEFAULT_SETTINGS;
const fieldMarkup = fields.map((key) => {
const contract = contracts[key]; const helpId = `meli-threshold-help-${key}`;
return ``;
}).join('');
const body = `${escapeHtml(t('thresholdCalibration'))}
${escapeHtml(t('thresholdCalibrationCopy'))}
${escapeHtml(calibrationCopy)}
`;
const footer = ``;
this.openModal(t('settingsTitle'), body, { small: true, footer });
const modal = state.modal;
const readValues = () => Object.fromEntries(fields.map((key) => [key, Number(modal.querySelector(`[data-setting="${key}"]`).value)]));
const writeValues = (values) => fields.forEach((key) => { modal.querySelector(`[data-setting="${key}"]`).value = String(values[key]); });
const updateImpact = () => {
const values = readValues();
const { improve, protect } = thresholdImpactCounts(null, values);
modal.querySelector('[data-threshold-impact]').textContent = t('thresholdImpact', { improve, protect });
};
modal.querySelectorAll('[data-setting]').forEach((input) => input.addEventListener('input', updateImpact));
modal.querySelector('[data-use-calibration]').addEventListener('click', () => { writeValues(recommended); updateImpact(); });
modal.querySelector('[data-threshold-defaults]').addEventListener('click', () => { writeValues(DEFAULT_SETTINGS); updateImpact(); });
updateImpact();
modal.querySelector('[data-settings-cancel]').addEventListener('click', () => this.closeModal());
modal.querySelector('[data-clear-data]').addEventListener('click', async () => {
if (!confirm(t('clearDataConfirm'))) return;
if (!await Store.clearAnalysisData()) { this.setStatus('leaseBlocked', 'blocked'); return; }
this.closeModal();
this.setStatus('dataCleared', 'ready');
this.render(true);
});
modal.querySelector('[data-settings-save]').addEventListener('click', async () => {
const values = readValues();
const invalid = fields.find((key) => !Number.isFinite(values[key]) || values[key] < contracts[key].min || values[key] > contracts[key].max);
const feedback = modal.querySelector('[data-settings-feedback]');
if (invalid) { feedback.textContent = t('storageWriteFailed'); modal.querySelector(`[data-setting="${invalid}"]`).focus(); return; }
if (values.minVisitsToProtect <= values.minVisitsToImprove) { feedback.textContent = t('thresholdRelationship'); modal.querySelector('[data-setting="minVisitsToProtect"]').focus(); return; }
feedback.textContent = '';
fields.forEach((key) => { state.settings[key] = values[key]; });
await Store.saveSettings();
await refreshRecords({ persist: true });
this.closeModal();
this.setStatus('settingsSaved', 'ready');
this.render(true);
});
},
};
function evaluationScopeRecords(records, scopeIds) {
const allowed = new Set((Array.isArray(scopeIds) ? scopeIds : []).map(String));
return (Array.isArray(records) ? records : []).filter((record) => record && !record.unsupportedSchema && allowed.has(String(record.listingId)));
}
async function refreshRecords(options = {}) {
const evaluatedAt = options.evaluatedAt || nowIso();
state.records = await Store.listRecords();
state.records.forEach((record) => updateExperimentEvaluations(record, evaluatedAt));
const requestedScopeIds = Array.isArray(options.scopeIds) ? options.scopeIds : null;
const completedScopeIds = state.collection?.status === 'completed' && state.collection.schema === COLLECTION_SCHEMA_VERSION
? state.collection.uniqueIds : null;
const scopeIds = requestedScopeIds || completedScopeIds || [];
const cohort = evaluationScopeRecords(state.records, scopeIds);
const evaluated = cohort.length ? evaluateHealthRecords(cohort, state.settings, evaluatedAt) : new Map();
const cohortIds = new Set(cohort.map((record) => String(record.listingId)));
state.records.filter((record) => record && !record.unsupportedSchema && !cohortIds.has(String(record.listingId))).forEach((record) => {
const isolated = evaluateHealthRecords([record], state.settings, evaluatedAt).get(String(record.listingId));
if (isolated) evaluated.set(String(record.listingId), isolated);
});
const migrationIds = new Set();
state.records.forEach((record) => {
const health = evaluated.get(record.listingId);
if (health) {
const previous = record.health;
const currentEnvelope = Number(previous?.schemaVersion) === HEALTH_RESULT_SCHEMA_VERSION
&& Number(previous?.engineVersion) === HEALTH_ENGINE_VERSION
&& previous?.policy?.fingerprint === health.policy.fingerprint
&& JSON.stringify(previous?.input || null) === JSON.stringify(health.input);
if (!currentEnvelope) migrationIds.add(String(record.listingId));
record.health = health;
record.analysis = health.result;
}
});
if (options.persist || migrationIds.size) {
const persisted = [];
for (const record of state.records) {
const shouldWrite = options.persist || migrationIds.has(String(record.listingId));
persisted.push(record.unsupportedSchema || !shouldWrite ? record : await Store.putRecord(record));
}
state.records = persisted;
}
if (options.render !== false) UI.render();
return state.records;
}
async function scanCurrentPage(options = {}) {
if (routeKind() !== 'listings') return [];
if (!options.silentStatus) UI.setStatus('pageReady', 'scanning');
const stable = Array.isArray(options.listings) ? null : await ListingPageAdapter.readStable({ requirePagination: false });
const listings = Array.isArray(options.listings) ? options.listings : (stable?.listings || []);
if (!listings.length) { UI.setStatus('noCards', 'error'); return []; }
state.pageListings = listings;
const saved = await Store.saveSnapshots(listings, options.collectionLeaseToken ? async () => {
if (state.collectionPauseRequested) {
const error = new Error('Collection pause requested.'); error.code = 'COLLECTION_PAUSE_REQUESTED'; throw error;
}
await CollectionLease.assertOwns(options.collectionLeaseToken);
} : null);
if (options.collectionLeaseToken) await CollectionLease.assertOwns(options.collectionLeaseToken);
if (options.deferEvaluation) state.records = await Store.listRecords();
else await refreshRecords({ persist: true, evaluatedAt: listings.map((listing) => listing.capturedAt).sort().at(-1) || nowIso() });
const pageInfo = ListingPageAdapter.pageInfo();
if (!options.silentStatus) { UI.setStatus('scanComplete', 'ready', { count: listings.length, saved: saved.length }); UI.render(); }
if (!options.deferEvaluation) await Store.appendAudit({ type: 'scan', page: pageInfo.current, totalPages: pageInfo.total, count: listings.length });
return listings;
}
async function aiRequestPackage(records, expectedIdentity) {
const requestId = `air-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const requestedIds = uniqueStrings(records.map((record) => String(record.listingId)));
const prepared = await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const fenced = await assertFreshCollectionLocked(expectedIdentity);
const evaluated = evaluateHealthRecords(fenced.records, state.settings, nowIso());
const latestById = new Map(fenced.records.map((record) => {
const health = evaluated.get(String(record.listingId));
return [String(record.listingId), health ? { ...record, health, analysis: health.result } : record];
}));
const latestRecords = requestedIds.map((listingId) => latestById.get(listingId));
if (latestRecords.some((record) => !record)) {
const error = new Error('A selected listing is no longer part of the completed collection.'); error.code = 'COLLECTION_INCOMPLETE'; throw error;
}
const aliasMap = {};
latestRecords.forEach((record, index) => { aliasMap[`L${String(index + 1).padStart(3, '0')}`] = String(record.listingId); });
const identity = collectionIdentity(fenced.collection);
const requestEntry = {
createdAt: nowIso(), aliases: aliasMap, collectionId: identity.id,
scopeKey: identity.scopeKey, completedAt: identity.completedAt, totalPages: identity.totalPages,
writeId: randomId('ai-request-write'),
};
const requests = await GMX.get(KEYS.aiRequests, {});
const cache = requests && typeof requests === 'object' ? { ...requests } : {};
cache[requestId] = requestEntry;
const bounded = Object.fromEntries(Object.entries(cache).sort((a, b) => String(a[1]?.createdAt).localeCompare(String(b[1]?.createdAt))).slice(-10));
await requireStored(KEYS.aiRequests, bounded);
const verified = await GMX.get(KEYS.aiRequests, {});
if (verified?.[requestId]?.writeId !== requestEntry.writeId) {
const error = new Error('AI request cache write verification failed.'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
return { latestRecords, requestEntry };
});
return {
schema: 'makaytron-listing-ai-request/v1', requestId, generatedAt: nowIso(),
collection: { id: prepared.requestEntry.collectionId, scopeKey: prepared.requestEntry.scopeKey, completedAt: prepared.requestEntry.completedAt, totalPages: prepared.requestEntry.totalPages },
instructions: {
outputSchema: 'makaytron-listing-ai-proposals/v1',
constraints: ['Return JSON only', 'Echo requestId and each opaque reference exactly', 'Every proposal must include fields: an explicit subset of title, description, tags, materials', 'Fields omitted from fields stay unchanged', 'Use fields:["tags"] with tags:[] only when intentionally clearing every tag', 'title: 1-140 chars when title is selected', 'tags: max 13; each max 20 chars', 'Never invent performance data', 'action: UPDATE, DEACTIVATE_REVIEW, or SKIP; non-UPDATE actions use fields:[]'],
},
listings: prepared.latestRecords.map((record, index) => {
const latest = record.history?.at(-1) || {};
const editable = captureEditableFieldsFromRecord(record);
const analysis = record.analysis || record.health?.result || {};
return { reference: `L${String(index + 1).padStart(3, '0')}`, editableDataCaptured: Boolean(record.editor?.capturedAt), title: editable.title, description: editable.description, tags: editable.tags, materials: editable.materials, metrics: { visits30d: latest.visits ?? null, favorites30d: latest.favorites ?? null, salesAllTime: latest.sales ?? null, revenueAllTime: latest.revenue ?? null, renewalsAllTime: latest.renewals ?? null, salesApprox30d: analysis.derived?.sales30 ?? null, revenueApprox30d: analysis.derived?.revenue30 ?? null, favoriteRate: analysis.derived?.favoriteRate ?? null, salesRateProxy: analysis.derived?.salesRateProxy ?? null }, health: { engineVersion: HEALTH_ENGINE_VERSION, lifecycle: analysis.lifecycle || 'DATA_GAP', diagnosis: analysis.diagnosis || 'INSUFFICIENT_SIGNAL', assessmentMode: analysis.assessmentMode || 'insufficient', performanceScore: analysis.score ?? null, scoreBasis: analysis.scoreBasis || 'insufficient', scoreComponents: analysis.currentAssessment?.components || analysis.bootstrap?.components || null, currentSignal: analysis.currentAssessment?.funnelSignal || null, cumulativeSignal: analysis.currentAssessment?.cumulativeSignal || null, snapshotSignal: analysis.bootstrap?.signal || null, confidence: analysis.confidence ?? 0, confidenceBand: analysis.confidenceBand || 'low', confidenceCaps: analysis.confidenceCaps || [], readiness: analysis.readiness || { snapshot: false, trend: false, deactivationHistory: false }, evidence: (analysis.evidence || []).map((item) => ({ key: item.key, params: item.params })) }, recommendation: analysis.code || 'waiting', reason: analysisReason(record) };
}),
};
}
async function copyAiRequest() {
const expectedIdentity = collectionIdentity();
const records = selectedCurrentCollectionRecords();
if (!records.length) { UI.setStatus('selectForAi', 'blocked'); return; }
try {
const request = await aiRequestPackage(records, expectedIdentity);
await copyText(JSON.stringify(request, null, 2));
UI.setStatus('aiCopied', 'ready', { count: request.listings.length });
const feedback = state.modal?.querySelector('[data-ai-feedback]'); if (feedback) feedback.textContent = t('aiCopied', { count: request.listings.length });
} catch (error) {
UI.setStatus(['COLLECTION_STALE', 'COLLECTION_INCOMPLETE'].includes(error?.code) ? 'collectionActionStale' : 'collectionStorageFailed', 'blocked');
}
}
function aiValidationError(code, path, detail = '') {
const error = new Error(detail || code);
error.code = code;
error.path = path;
return error;
}
function aiErrorDetails(error) {
const code = String(error?.code || 'AI_PROPOSAL');
const path = String(error?.path || 'proposals');
const key = code === 'AI_JSON' ? 'aiErrorJson' : code === 'AI_SCHEMA' ? 'aiErrorSchema' : code === 'AI_REQUEST' ? 'aiErrorRequest' : 'aiErrorProposal';
const format = /\.(?:tags|materials)$/.test(path) ? (state.settings.language === 'tr' ? 'Beklenen biçim: dizi (array).' : 'Expected format: array.') : '';
const detail = normalizeSpace(error?.message).slice(0, 180);
return { code, path, message: `${t(key, { path })}${detail && detail !== code ? ` ${detail}` : ''}`, format, help: t('aiErrorHelp') };
}
function aiProposalTemplate() {
return JSON.stringify({
schema: 'makaytron-listing-ai-proposals/v1', requestId: 'PASTE_REQUEST_ID',
proposals: [{ reference: 'L001', action: 'UPDATE', fields: ['title'], title: 'New title', reason: 'Why this change should help' }],
}, null, 2);
}
function validateAiProposal(raw, aliasMap, knownIds, proposalIndex = 0) {
const base = `proposals[${proposalIndex}]`;
if (!raw || typeof raw !== 'object') throw aiValidationError('AI_PROPOSAL', base, 'proposal must be an object');
const reference = String(raw.reference || '');
const listingId = String(aliasMap?.[reference] || '');
if (!reference || !listingId) throw aiValidationError('AI_PROPOSAL', `${base}.reference`, `unknown reference ${reference || '(missing)'}`);
if (!knownIds.has(listingId)) throw aiValidationError('AI_PROPOSAL', `${base}.reference`, `unknown listingId ${listingId}`);
const action = String(raw.action || 'UPDATE').toUpperCase();
if (!['UPDATE','DEACTIVATE_REVIEW','SKIP'].includes(action)) throw aiValidationError('AI_PROPOSAL', `${base}.action`, `unsupported action ${action}`);
let fields;
try { fields = proposalFields(raw, true); }
catch (error) { throw aiValidationError('AI_PROPOSAL', `${base}.fields`, error?.message || 'invalid fields'); }
if (action === 'UPDATE' && !fields.length) throw aiValidationError('AI_PROPOSAL', `${base}.fields`, `no fields selected for ${listingId}`);
if (action !== 'UPDATE' && fields.length) throw aiValidationError('AI_PROPOSAL', `${base}.fields`, `non-UPDATE action must not select fields for ${listingId}`);
const title = normalizeSpace(raw.title || '');
const description = typeof raw.description === 'string' ? raw.description.trim() : '';
if (fields.includes('tags') && !Array.isArray(raw.tags)) throw aiValidationError('AI_PROPOSAL', `${base}.tags`, `tags must be an array for ${listingId}`);
if (fields.includes('materials') && !Array.isArray(raw.materials)) throw aiValidationError('AI_PROPOSAL', `${base}.materials`, `materials must be an array for ${listingId}`);
if (fields.includes('description') && typeof raw.description !== 'string') throw aiValidationError('AI_PROPOSAL', `${base}.description`, `description must be a string for ${listingId}`);
const tags = uniqueStrings(Array.isArray(raw.tags) ? raw.tags : []);
const materials = uniqueStrings(Array.isArray(raw.materials) ? raw.materials : []);
if (fields.includes('title') && (!title || title.length > 140)) throw aiValidationError('AI_PROPOSAL', `${base}.title`, `invalid title for ${listingId}`);
if (tags.length > 13 || tags.some((tag) => tag.length > 20)) throw aiValidationError('AI_PROPOSAL', `${base}.tags`, `invalid tags for ${listingId}`);
return { reference, listingId, action, fields, title, description, tags, materials, reason: String(raw.reason || '').trim(), source: 'ai-import' };
}
async function importAiResponse(text, feedback) {
try {
let documentValue;
try { documentValue = JSON.parse(text); }
catch { throw aiValidationError('AI_JSON', '$', 'invalid JSON'); }
if (documentValue?.schema !== 'makaytron-listing-ai-proposals/v1' || !Array.isArray(documentValue.proposals)) throw aiValidationError('AI_SCHEMA', '$.schema/proposals', 'schema/proposals missing');
const requestId = String(documentValue.requestId || '');
const proposals = await withNamedLock(STORAGE_MUTATION_LOCK, async () => {
const requestsRaw = await GMX.get(KEYS.aiRequests, {});
const requests = requestsRaw && typeof requestsRaw === 'object' ? { ...requestsRaw } : {};
const request = requests[requestId];
const aliasMap = request?.aliases;
if (!requestId || !aliasMap || typeof aliasMap !== 'object') throw aiValidationError('AI_REQUEST', '$.requestId', 'unknown or expired requestId');
const expectedIdentity = {
id: String(request.collectionId || ''), scopeKey: String(request.scopeKey || ''),
completedAt: String(request.completedAt || ''), totalPages: Math.max(1, Number(request.totalPages) || 1),
};
const fenced = await assertFreshCollectionLocked(expectedIdentity);
const knownIds = new Set((fenced.collection.uniqueIds || []).map(String));
if (Object.values(aliasMap).some((listingId) => !knownIds.has(String(listingId)))) throw aiValidationError('AI_REQUEST', '$.requestId', 'AI request contains a listing outside the current collection');
const references = documentValue.proposals.map((proposal) => String(proposal?.reference || ''));
const duplicateReferences = references.filter((reference, index, array) => reference && array.indexOf(reference) !== index);
if (duplicateReferences.length) throw aiValidationError('AI_PROPOSAL', '$.proposals', `duplicate reference ${duplicateReferences[0]}`);
const validated = documentValue.proposals.map((proposal, index) => validateAiProposal(proposal, aliasMap, knownIds, index));
for (const proposal of validated) await Store.saveProposalLocked(proposal.listingId, { ...proposal, requestId });
delete requests[requestId];
await requireStored(KEYS.aiRequests, requests);
const verified = await GMX.get(KEYS.aiRequests, {});
if (verified?.[requestId]) {
const error = new Error('AI request cache cleanup failed.'); error.code = 'STORAGE_WRITE_FAILED'; throw error;
}
return validated;
});
await refreshRecords();
UI.setStatus('aiImportSuccess', 'ready', { count: proposals.length });
UI.render(true);
state.panel?.querySelector('[data-ai-json]')?.setAttribute('aria-invalid', 'false');
if (feedback) { feedback.style.color = 'var(--success)'; feedback.textContent = t('aiImportSuccess', { count: proposals.length }); }
return { ok: true, count: proposals.length };
} catch (error) {
const collectionStale = ['COLLECTION_STALE', 'COLLECTION_INCOMPLETE'].includes(error?.code);
const details = collectionStale ? { code: error.code, path: '$.requestId', message: t('collectionActionStale'), format: '', help: t('aiErrorHelp') } : aiErrorDetails(error);
const message = `${details.message} ${details.format || ''} ${details.help}`.replace(/\s+/g, ' ').trim();
state.panel?.querySelector('[data-ai-json]')?.setAttribute('aria-invalid', 'true');
if (feedback) {
feedback.setAttribute('role', 'alert'); feedback.setAttribute('aria-live', 'assertive');
feedback.dataset.aiErrorPath = details.path;
feedback.textContent = `${details.message} (${details.path}) ${details.format || ''} ${details.help}`.replace(/\s+/g, ' ').trim();
}
UI.setStatus(collectionStale ? 'collectionActionStale' : 'aiImportError', collectionStale ? 'blocked' : 'error', { message });
return { ok: false, error: details };
}
}
async function importResearchResultJson(text) {
if (new TextEncoder().encode(String(text || '')).byteLength > RESEARCH_MESSAGE_MAX_BYTES) throw new Error('research result exceeds 64 KiB');
let raw;
try { raw = JSON.parse(String(text || '')); }
catch { throw new Error('research result is not valid JSON'); }
const envelope = validateResearchEnvelope(raw, ['RESEARCH_RESULT']);
let consumed;
try { consumed = await consumeResearchResult(envelope); }
catch (error) { await rejectResearchResult(envelope, error); throw error; }
try { postResearchEnvelope(researchEnvelope('RESEARCH_RECEIVED', envelope.requestId, envelope.nonce, { accepted: true }, Date.now() + 60_000)); }
catch { /* Manual JSON recovery remains successful without an active channel. */ }
clearResearchUiTimer(envelope.requestId);
await refreshRecords();
UI.setStatus('researchComplete', 'ready', { count: consumed.count });
UI.toast(t('researchComplete', { count: consumed.count }), 'success');
return consumed;
}
async function startMarketplaceResearchSelection() {
const records = selectedCurrentCollectionRecords();
if (records.length !== 1) { UI.setStatus('researchOneListing', 'blocked'); UI.toast(t('researchOneListing'), 'warning'); return; }
try {
let entry = await activeResearchForListing(records[0].listingId);
if (entry) {
entry = await updateResearchEntry(entry.requestId, (current) => ({ ...current, ownerTabId: tabId }));
UI.setStatus('researchPendingExists', 'ready');
} else entry = await prepareResearchEntry(records[0], collectionIdentity());
UI.openResearchTransfer(entry);
UI.setStatus('researchProbing', 'scanning');
UI.updateResearchTransfer('researchProbing', 'scanning');
UI.toast(t('researchProbing'), 'info', 1800);
GMX.openTab(researchInsightsUrl(entry));
await sleep(window.__MAKAYTRON_LISTING_TEST__ === true ? 30 : 450);
const capabilities = await probeResearchCompanion();
if (!capabilities) { UI.openResearchMissingModal(entry); return; }
entry = await updateResearchEntry(entry.requestId, (current) => ({ ...current, status: 'request-sent', requestSentAt: nowIso() }));
if (!entry) throw new Error('research request is no longer available');
postResearchEnvelope(researchRequestEnvelope(entry));
UI.updateResearchTransfer('researchRequestSent', 'scanning');
scheduleResearchUiTimeout(entry.requestId);
} catch (error) {
const stale = ['COLLECTION_STALE', 'COLLECTION_INCOMPLETE'].includes(error?.code);
const key = stale ? 'collectionActionStale' : error?.code === 'RESEARCH_NO_SEEDS' ? 'researchNoSeeds' : 'researchFailed';
const params = key === 'researchFailed' ? { message: normalizeSpace(error?.message || error) } : {};
UI.setStatus(key, stale ? 'blocked' : 'error', params);
UI.toast(t(key, params), stale ? 'warning' : 'error');
}
}
async function exportBackup() {
const records = await refreshRecords({ render: false });
const queue = await Store.loadQueue();
const collection = await Store.loadCollection();
const audit = await GMX.get(KEYS.audit, []);
downloadJson(`makaytron-listing-analyzer-${dayKey()}.json`, { schema: 'makaytron-listing-analyzer-backup/v1', producerVersion: APP_VERSION, recordSchemaVersion: RECORD_SCHEMA_VERSION, healthEngineVersion: HEALTH_ENGINE_VERSION, exportedAt: nowIso(), settings: state.settings, analysisFilters: state.analysisFilters, filterPresets: state.filterPresets, collection, records, queue, audit });
UI.setStatus('exportComplete', 'ready');
}
async function buildQueueFromSelection() {
const expectedIdentity = collectionIdentity();
const selected = selectedCurrentCollectionRecords().filter((record) => record.proposal && record.proposal.action !== 'SKIP');
if (!selected.length) { UI.setStatus('noProposalSelection', 'blocked'); return; }
if (!confirm(t('queueConfirm', { count: selected.length }))) return;
if (!await Lease.acquire()) { UI.setStatus('leaseBlocked', 'blocked'); return; }
try {
const queue = await Queue.create(selected, expectedIdentity);
UI.closeModal(); UI.setStatus('queueCreated', 'ready', { count: queue.items.length }); UI.render();
} catch (error) {
await Lease.release();
if (['COLLECTION_STALE', 'COLLECTION_INCOMPLETE'].includes(error?.code)) UI.setStatus('collectionActionStale', 'blocked');
else if (error?.code === 'PROPOSAL_EXPERIMENT_OVERLAP') UI.setStatus('experimentOverlapRequired', 'blocked');
else UI.setStatus(error?.code === 'PROPOSAL_STALE' ? 'proposalStale' : 'formNotReady', 'error');
}
}
async function applyCurrentProposal() {
const item = Queue.activeItem();
if (!item || currentListingId() !== String(item.listingId)) { UI.setStatus('routeMismatch', 'blocked'); return; }
if (!['pending', 'failed'].includes(item.status)) { UI.setStatus('formNotReady', 'blocked'); return; }
if (EditorAdapter.formIsClean() !== true) { UI.setStatus('formNotReady', 'blocked'); return; }
if (!await Lease.acquire()) { UI.setStatus('leaseBlocked', 'blocked'); return; }
try {
const before = EditorAdapter.read();
const beforeFingerprint = await contentFingerprint(before);
if (item.proposal?.baselineCapturedAt && item.proposal?.baselineFingerprint && beforeFingerprint !== item.proposal.baselineFingerprint) {
await Queue.updateItem({ status: 'failed', changedFields: [], error: t('contentChanged'), contentChangedAt: nowIso() });
UI.setStatus('contentChanged', 'blocked'); return;
}
const baselineCapturedAt = nowIso();
const baselineRecord = await Store.getRecord(item.listingId);
const baselineImprovement = (baselineRecord?.improvements || []).find((entry) => entry.id === item.proposal?.improvementId && entry.status === 'planned');
if (baselineRecord && baselineImprovement) {
baselineImprovement.before = {
title: String(before.title || ''), description: String(before.description || ''),
tags: Array.isArray(before.tags) ? [...before.tags] : [], materials: Array.isArray(before.materials) ? [...before.materials] : [],
};
baselineImprovement.baselineFingerprint = beforeFingerprint;
baselineImprovement.baselineCapturedAt = baselineCapturedAt;
if (baselineRecord.proposal?.improvementId === baselineImprovement.id) {
baselineRecord.proposal = { ...baselineRecord.proposal, baselineFingerprint: beforeFingerprint, baselineCapturedAt };
}
await Store.putRecord(baselineRecord);
}
await Queue.updateItem({ status: 'applying', changedFields: [], error: '', runtimeOwner: pageInstanceId, runtimeBaselineFingerprint: beforeFingerprint, runtimeBaselineCapturedAt: baselineCapturedAt });
const changedFields = await EditorAdapter.applyProposal(item.proposal);
if (!editorMatchesProposal(EditorAdapter.read(), item.proposal)) {
void trackTelemetryError('selector_listing_editor');
throw new Error('The Etsy form does not exactly match the selected proposal fields.');
}
await Queue.updateItem({ status: 'awaiting-user-review', before, changedFields, attempts: (item.attempts || 0) + 1, appliedAt: nowIso(), error: '', runtimeOwner: pageInstanceId });
UI.setStatus('formApplied', 'ready'); UI.render();
} catch (error) {
await Queue.updateItem({ status: 'failed', changedFields: [], error: String(error?.message || error) });
UI.setStatus('formNotReady', 'error');
}
}
async function commitVerifiedPublish(fenceToken) {
return Queue.withFencedActiveItem(fenceToken, ['submitted'], async (queue, item) => {
const publishedAt = nowIso();
const record = await Store.getRecord(item.listingId);
if (record) {
record.editor = { ...EditorAdapter.read(), capturedAt: publishedAt };
const improvement = (record.improvements || []).find((entry) => entry.id === item.proposal?.improvementId && entry.status === 'planned');
if (improvement) {
(record.improvements || []).forEach((entry) => {
if (entry !== improvement && entry?.status === 'published' && entry?.experiment?.state === 'observing') {
entry.experiment.state = 'contaminated'; entry.experiment.contaminatedAt = publishedAt;
}
});
if (!improvement.baselineCapturedAt && item.before) {
improvement.before = {
title: String(item.before.title || ''), description: String(item.before.description || ''),
tags: Array.isArray(item.before.tags) ? [...item.before.tags] : [], materials: Array.isArray(item.before.materials) ? [...item.before.materials] : [],
};
improvement.baselineCapturedAt = item.runtimeBaselineCapturedAt || item.appliedAt || publishedAt;
improvement.baselineFingerprint = item.runtimeBaselineFingerprint || improvement.baselineFingerprint;
}
improvement.status = 'published'; improvement.publishedAt = publishedAt; improvement.after = record.editor;
improvement.baselineSnapshot = improvement.baselineSnapshot || record.history?.at(-1) || null;
improvement.experiment = { ...(improvement.experiment || {}), state: 'observing', startAt: publishedAt, evaluateAt: addDays(publishedAt, HEALTH_RULES.experimentDays), day: 0, durationDays: HEALTH_RULES.experimentDays, primaryMetric: primaryMetricForFields(improvement.fields || []) };
}
if (record.proposal?.improvementId === improvement?.id) record.proposal = { ...record.proposal, appliedAt: publishedAt };
updateExperimentEvaluations(record, publishedAt);
const health = evaluateHealthRecords([record], state.settings, publishedAt).get(record.listingId);
if (health) { record.health = health; record.analysis = health.result; }
await Lease.assertOwnerLocked(fenceToken);
await Store.putRecordLocked(record);
}
await Lease.assertOwnerLocked(fenceToken);
await Store.appendAuditLocked({ type: 'listing-published', queueId: queue.id, listingId: item.listingId, fields: item.changedFields });
item.status = 'verified';
item.verifiedAt = publishedAt;
item.error = '';
queue.cursor += 1;
const completed = queue.cursor >= queue.items.length;
if (completed) {
queue.status = 'completed';
queue.completedAt = publishedAt;
} else {
queue.status = 'running';
}
await Lease.assertOwnerLocked(fenceToken);
const savedQueue = await Store.saveQueueLocked(queue);
return {
completed,
next: completed ? null : savedQueue.items[savedQueue.cursor] || null,
};
});
}
async function publishCurrentProposal() {
const item = Queue.activeItem();
if (!item || currentListingId() !== String(item.listingId)) { UI.setStatus('routeMismatch', 'blocked'); return; }
if (!item.changedFields?.length || item.status !== 'awaiting-user-review') { UI.setStatus('formNotReady', 'blocked'); return; }
if (!editorMatchesProposal(EditorAdapter.read(), item.proposal)) { UI.setStatus('formNotReady', 'blocked'); return; }
const button = EditorAdapter.publishButton();
if (!button) { void trackTelemetryError('selector_listing_editor'); UI.setStatus('publishDisabled', 'blocked'); return; }
if (button.disabled || button.getAttribute('aria-disabled') === 'true') { UI.setStatus('publishDisabled', 'blocked'); return; }
const currentRecord = await Store.getRecord(item.listingId);
if (activeObservedImprovement(currentRecord) && !item.proposal?.experimentOverlapAcceptedAt) {
if (!confirm(`${t('experimentOverlapTitle')}\n\n${t('experimentOverlapCopy')}`)) return;
}
if (!confirm(t('publishConfirm', { id: item.listingId, fields: item.changedFields.join(', ') }))) return;
if (!await Lease.acquire()) { UI.setStatus('leaseBlocked', 'blocked'); return; }
const fenceToken = state.leaseToken;
const statusBefore = EditorAdapter.statusText();
let providerSubmitted = false;
try {
await Queue.updateItemFenced(
{ status: 'submitted', submittedAt: nowIso(), runtimeOwner: pageInstanceId, error: '' },
fenceToken,
['awaiting-user-review'],
);
button.click();
providerSubmitted = true;
UI.setStatus('publishSubmitted', 'scanning');
const verified = await waitForActionVerification(() => {
const publish = EditorAdapter.publishButton();
const text = EditorAdapter.statusText();
return currentListingId() === String(item.listingId)
&& text !== statusBefore
&& EditorAdapter.formIsClean() === true
&& editorMatchesProposal(EditorAdapter.read(), item.proposal)
&& (!publish || publish.disabled || publish.getAttribute('aria-disabled') === 'true');
}, fenceToken, 25000, 300);
if (!verified) {
void trackTelemetryError('selector_listing_publish_verify');
await Queue.updateItemFenced(
{ status: 'submitted-unverified', error: t('publishUnverified') },
fenceToken,
['submitted'],
);
UI.setStatus('publishUnverified', 'blocked');
UI.render();
return;
}
const outcome = await commitVerifiedPublish(fenceToken);
if (outcome.completed) {
await Lease.release(fenceToken);
UI.setStatus('queueComplete', 'ready');
UI.render();
return;
}
UI.setStatus('publishVerified', 'ready');
UI.render();
await Queue.navigate(outcome.next);
} catch (error) {
if (error?.code === 'ACTION_LEASE_LOST' && state.leaseToken === fenceToken) {
clearInterval(state.leaseTimer);
state.leaseTimer = 0;
state.leaseToken = '';
}
await Store.loadQueue();
const recovery = Queue.recoveryState();
const uncertainSubmission = providerSubmitted || recovery?.submitted === true;
void trackTelemetryError(error?.code === 'ACTION_LEASE_LOST' || String(error?.code || '').startsWith('STORAGE_')
? 'storage_listing_state'
: 'selector_listing_publish_verify');
UI.setStatus(uncertainSubmission ? 'publishUnverified' : 'formNotReady', uncertainSubmission ? 'blocked' : 'error');
UI.render();
}
}
async function openCurrentDeactivate() {
const item = Queue.activeItem();
if (!item || currentListingId() !== String(item.listingId) || item.proposal?.action !== 'DEACTIVATE_REVIEW') { UI.setStatus('routeMismatch', 'blocked'); return; }
if (!['pending', 'failed'].includes(item.status)) { UI.setStatus('formNotReady', 'blocked'); return; }
const statusBefore = EditorAdapter.listingStatusLabels();
if (!statusBefore.length || statusBefore.some(isInactiveStatus)) { UI.setStatus('deactivateNotVerified', 'blocked'); return; }
if (!confirm(t('deactivateConfirm', { id: item.listingId }))) return;
if (!await Lease.acquire()) { UI.setStatus('leaseBlocked', 'blocked'); return; }
const opened = await EditorAdapter.openDeactivate();
if (!opened) { void trackTelemetryError('selector_listing_editor'); UI.setStatus('formNotReady', 'error'); return; }
await Queue.updateItem({ status: 'awaiting-user-deactivation', openedAt: nowIso(), deactivationStatusBefore: statusBefore, runtimeOwner: pageInstanceId });
UI.setStatus('deactivateOpened', 'ready'); UI.render();
}
async function verifyCurrentDeactivate() {
const item = Queue.activeItem();
if (!item || currentListingId() !== String(item.listingId) || item.proposal?.action !== 'DEACTIVATE_REVIEW') { UI.setStatus('routeMismatch', 'blocked'); return; }
if (item.status !== 'awaiting-user-deactivation') { UI.setStatus('deactivateNotVerified', 'blocked'); return; }
if (!await Lease.acquire()) { UI.setStatus('leaseBlocked', 'blocked'); return; }
const statusBefore = Array.isArray(item.deactivationStatusBefore) ? item.deactivationStatusBefore : [];
const statusAfter = EditorAdapter.listingStatusLabels();
const inactiveAfter = statusAfter.some(isInactiveStatus);
const statusChanged = statusBefore.length > 0 && statusAfter.length > 0 && !sameStringSet(statusBefore, statusAfter);
if (!statusAfter.length || (statusChanged && !inactiveAfter)) void trackTelemetryError('selector_listing_deactivate_verify');
if (!statusBefore.length || statusBefore.some(isInactiveStatus) || !inactiveAfter || !statusChanged) {
UI.setStatus('deactivateNotVerified', 'blocked'); return;
}
await Store.appendAudit({ type: 'listing-deactivated', queueId: state.queue.id, listingId: item.listingId, reason: item.proposal?.reason || '' });
const record = await Store.getRecord(item.listingId);
if (record) {
const completedAt = nowIso();
record.deactivation = { at: completedAt, reason: item.proposal?.reason || '', baselineSnapshot: record.history?.at(-1) || null, userConfirmed: true };
const improvement = (record.improvements || []).find((entry) => entry.id === item.proposal?.improvementId && entry.status === 'planned');
if (improvement) { improvement.status = 'deactivated'; improvement.completedAt = completedAt; }
await Store.putRecord(record);
}
const next = await Queue.advance('verified-deactivated');
if (next) await Queue.navigate(next);
}
async function skipCurrentItem() {
const item = Queue.activeItem(); if (!item) return;
await Store.appendAudit({ type: 'queue-item-skipped', queueId: state.queue.id, listingId: item.listingId });
const next = await Queue.advance('skipped');
if (next) await Queue.navigate(next);
}
function registerMenus() {
state.menuIds.forEach((id) => GMX.unregister(id)); state.menuIds = [];
[
[state.settings.collapsed ? 'expand' : 'collapse', () => UI.setCollapsed(!state.settings.collapsed)],
['scanPage', () => scanCurrentPage()],
['scanAllPages', () => Collection.toggle()],
['openAnalysis', () => UI.openAnalysis()],
['aiExchange', () => UI.openAi()],
['exportData', () => exportBackup()],
['checkUpdate', () => checkForUpdates({ manual: true, force: true })],
['switchLanguage', async () => { state.settings.language = state.settings.language === 'tr' ? 'en' : 'tr'; await Store.saveSettings(); UI.render(); registerMenus(); }],
].forEach(([key, callback]) => { const id = GMX.register(`Makaytron · ${t(key)}`, callback); if (id != null) state.menuIds.push(id); });
}
async function handleRoute() {
const key = `${location.pathname}${location.search}`;
if (state.routeKey === key && state.panel) return;
state.routeKey = key;
await Store.loadQueue();
const kind = routeKind();
if (kind === 'listings') {
const stable = await ListingPageAdapter.readStable({ requirePagination: state.collection?.status === 'running' && state.collection.totalPages > 1, timeout: 10000 });
state.pageListings = stable?.listings || [];
if (state.collection?.status === 'running' && stable && collectionScopeMatches(state.collection)) {
UI.setStatus('collectionProgress', 'scanning', Collection.progressParams(stable.pageInfo));
void Collection.run();
} else {
UI.setStatus(state.collection?.status === 'running' ? 'collectionPageChanged' : stable ? 'pageReady' : 'noCards', state.collection?.status === 'running' || !stable ? 'blocked' : 'ready');
}
} else if (kind === 'editor') {
const editorReady = await waitFor(() => EditorAdapter.ready(), 12000, 250);
if (!editorReady) { void trackTelemetryError('selector_listing_editor'); UI.setStatus('formNotReady', 'blocked'); UI.render(); return; }
const captured = await EditorAdapter.captureCurrent();
if (!captured) { UI.setStatus('formNotReady', 'blocked'); UI.render(); return; }
await refreshRecords();
const item = Queue.activeItem();
if (item && String(item.listingId) !== currentListingId() && ['ready','running'].includes(state.queue?.status)) UI.setStatus('routeMismatch', 'blocked');
else UI.setStatus('editorReady', 'ready');
} else UI.setStatus('unsupportedPage', 'blocked');
UI.render();
}
function collectionFingerprint(collection) {
if (!collection) return 'none';
const pages = Object.entries(collection.pages || {}).map(([page, value]) => [page, value?.capturedAt || '', value?.contentSignature || '']);
return JSON.stringify([collection.id, collection.status, collection.updatedAt, collection.completedAt, collection.scopeKey, collection.totalPages, collection.returningToFirst, pages]);
}
function collectionFreshUntil(collection = state.collection) {
if (!collection || collection.status !== 'completed') return null;
const times = [collection.completedAt, ...Object.values(collection.pages || {}).map((page) => page?.capturedAt)]
.map(validTime).filter(Number.isFinite);
if (times.length !== 1 + Math.max(1, Number(collection.totalPages) || 1)) return null;
return Math.min(...times) + ANALYSIS_FRESHNESS_MS;
}
function scheduleAnalysisFreshnessExpiry() {
clearTimeout(state.analysisExpiryTimer);
state.analysisExpiryTimer = 0;
if (state.activeView !== 'analysis') return;
const freshUntil = collectionFreshUntil();
if (!Number.isFinite(freshUntil) || freshUntil <= Date.now()) return;
const delay = Math.min(2147483647, Math.max(25, freshUntil - Date.now() + 25));
state.analysisExpiryTimer = window.setTimeout(async () => {
state.analysisExpiryTimer = 0;
await syncCollectionState();
if (analysisCollectionIsFresh()) scheduleAnalysisFreshnessExpiry();
}, delay);
}
async function syncCollectionState() {
if (state.collectionLoop && await CollectionLease.owns()) return false;
const previousFingerprint = collectionFingerprint(state.collection);
const stored = normalizeCollection(await GMX.get(KEYS.collection, null));
const changed = collectionFingerprint(stored) !== previousFingerprint;
if (changed) {
state.collection = stored;
pruneSelectionToCollection();
if (stored?.status === 'completed') await refreshRecords();
else UI.render();
}
const fresh = analysisCollectionIsFresh();
if (state.activeView === 'analysis' && state.renderedAnalysisFresh !== fresh) {
if (!fresh) pruneSelectionToCollection();
UI.render(true);
}
return changed;
}
function installCollectionWatcher() {
clearInterval(state.collectionSyncTimer);
state.collectionSyncTimer = window.setInterval(() => { void syncCollectionState(); }, 4000);
window.addEventListener('focus', () => { void syncCollectionState(); });
document.addEventListener('visibilitychange', () => { if (!document.hidden) void syncCollectionState(); });
}
function installRouteWatcher() {
let lastUrl = location.href;
state.routeTimer = window.setInterval(() => {
if (location.href !== lastUrl) { lastUrl = location.href; state.routeKey = ''; void handleRoute(); }
}, 600);
const observer = new MutationObserver(() => {
if (routeKind() === 'listings' && ListingPageAdapter.cardLinks().length && !state.pageListings.length) void handleRoute();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
async function init() {
await ensureUniqueTabIdentity();
installResearchBridge();
await Store.loadSettings(); await Store.loadAnalysisFilters(); await Store.loadFilterPresets(); await Store.loadFeedback(); await Store.loadUpdateState(); await Store.loadQueue(); await Store.loadCollection(); await UI.mount();
await refreshRecords();
refreshStorageEstimate();
registerMenus(); registerTelemetryMenuCommand(); installRouteWatcher(); installCollectionWatcher(); await handleRoute();
window.setTimeout(() => { void checkForUpdates({ manual: false, force: false }); }, 1400);
window.addEventListener('beforeunload', () => {
clearInterval(state.routeTimer); clearInterval(state.leaseTimer); clearInterval(state.collectionLeaseTimer); clearInterval(state.collectionSyncTimer); clearTimeout(state.analysisExpiryTimer);
state.researchProbeWaiters.forEach(clearResearchProbeTimers); state.researchProbeWaiters.clear();
state.researchUiTimers.forEach((timer) => clearTimeout(timer)); state.researchUiTimers.clear();
state.researchChannel?.close(); state.tabPresenceChannel?.close();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && state.modal) UI.closeModal();
const editable = event.composedPath().some((target) => target instanceof Element && Boolean(target.closest('input,textarea,select,button,a[href],[contenteditable="true"],[role="textbox"],[role="button"]')));
if (event.repeat || event.isComposing || editable || event.getModifierState?.('AltGraph')) return;
if (event.altKey && event.ctrlKey && !event.shiftKey && !event.metaKey && event.code === 'KeyL') { event.preventDefault(); void UI.setCollapsed(!state.settings.collapsed); }
if (event.altKey && event.ctrlKey && !event.shiftKey && !event.metaKey && event.code === 'KeyA') { event.preventDefault(); void Collection.toggle(); }
});
}
if (window.__MAKAYTRON_LISTING_TEST__ === true) {
window.__MELI_TEST__ = Object.freeze({
versions: Object.freeze({ app: APP_VERSION, recordSchema: RECORD_SCHEMA_VERSION, healthSchema: HEALTH_RESULT_SCHEMA_VERSION, engine: HEALTH_ENGINE_VERSION, policy: HEALTH_POLICY_VERSION, collectionSchema: COLLECTION_SCHEMA_VERSION }),
parseCountValue,
parseListingMetrics,
currencyMarker,
currentShopKey,
normalizeListingState,
pageListingState,
recommendationBasisMatches,
normalizeRecord,
normalizeSnapshot,
mergeDailySnapshot,
deriveRecordMetrics,
findAnchor,
median,
percentileRank,
normalizeAnalysisFilters,
recentPerformanceMetrics,
recordMatchesAnalysisFilters,
sortAnalysisRecords,
thresholdCalibration,
thresholdImpactCounts,
normalizeCollection,
collectionManifestIsComplete,
collectionIsFresh,
collectionScopeKey,
collectionScopeMatches,
collectionPageConflict,
collectionPageMatchesManifest,
pageIdentityMatchesCollection,
evaluationScopeRecords,
elementIsUsable,
ListingPageAdapter,
collectionRuntime: Object.freeze({ state, Store, KEYS, CollectionLease, Collection }),
currentCollectionIdentity: () => collectionIdentity(),
syncCollectionState,
canonicalEditableContent,
contentFingerprint,
researchContentHash,
researchSeedKeywords,
researchEnvelope,
validateResearchEnvelope,
validateResearchCapabilities,
validateResearchReadyPayload,
validateResearchAckPayload,
validateResearchErrorPayload,
validateResearchResultPayload,
validateResearchEvidence,
validateResearchSuggestion,
buildResearchSuggestion,
researchSuggestionForRecord,
importResearchResultJson,
refreshRecords,
distributionUrlKind,
installedDistributionSource,
updater: Object.freeze({
state,
Store,
UI,
Queue,
GITHUB_CANONICAL_SCRIPT_URL,
GITHUB_API_REF_URL,
pinnedUpdateUrl,
exactHttpsTarget,
metadataValues,
normalizeUpdateState,
requestExactText,
requestCanonicalScript,
checkForUpdates,
openTampermonkeyUpdate,
bindInstallUpdate,
}),
updateExperimentEvaluations,
evaluateRecord(record, peers = [record], settings = DEFAULT_SETTINGS, evaluatedAt = nowIso()) {
const records = Array.isArray(peers) && peers.length ? peers : [record];
return evaluateHealthRecords(records, { ...DEFAULT_SETTINGS, ...(settings || {}) }, evaluatedAt).get(String(record.listingId)) || null;
},
evaluateRecords(records, settings = DEFAULT_SETTINGS, evaluatedAt = nowIso()) {
return Object.fromEntries(evaluateHealthRecords(records, { ...DEFAULT_SETTINGS, ...(settings || {}) }, evaluatedAt));
},
});
}
if (window.__MAKAYTRON_LISTING_SKIP_INIT__ !== true) {
void init().catch((error) => {
console.error(`[${APP.name}]`, error);
if (state.panel) UI.setStatus('formNotReady', 'error');
});
}
})();