// ==UserScript== // @name Makaytron Etsy Keyword & Market Analyzer // @name:tr Makaytron Etsy Keyword & Market Analyzer // @name:en Makaytron Etsy Keyword & Market Analyzer // @version 1.0.3 // @description Etsy Marketplace Insights verilerini sayfada analiz edin ve Listing Analyzer ile güvenli araştırma sonuçları paylaşın. // @description:tr Etsy Marketplace Insights verilerini sayfada analiz edin ve Listing Analyzer ile güvenli araştırma sonuçları paylaşın. // @description:en Analyze Etsy Marketplace Insights data in-page and securely share research results with Listing Analyzer. // @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-keyword-market-analyzer // @supportURL https://github.com/Makaytron/Etsy-Automation-Tools/issues // @updateURL https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/scripts/etsy-keyword-market-analyzer/Makaytron-Etsy-Keyword-Market-Analyzer.user.js // @downloadURL https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/scripts/etsy-keyword-market-analyzer/Makaytron-Etsy-Keyword-Market-Analyzer.user.js // @match https://www.etsy.com/your/shops/*/marketplace-insights* // @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 // @connect raw.githubusercontent.com // @connect api.github.com // @connect sjwibgcflufmzaorlwqe.supabase.co // @noframes // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const APP_VERSION = '1.0.3'; 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-keyword-market-analyzer'; const TELEMETRY_ALLOWED_EVENTS = new Set(['script_opened', 'keyword_research_completed']); const TELEMETRY_ALLOWED_ERROR_CODES = new Set(['selector_keyword_result_scope', 'selector_keyword_results', 'selector_keyword_navigation', 'runtime_research_capture', 'runtime_research_parse', 'runtime_research_queue', 'storage_research_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.
Privacy
`; 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

Privacy policy
`; 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 APP = Object.freeze({ name: 'Makaytron Etsy Keyword & Market Analyzer', shortName: 'Etsy Keyword & Market Analyzer', storagePrefix: 'ekma:v1', }); const GITHUB_SCRIPT_PATH = 'scripts/etsy-keyword-market-analyzer/Makaytron-Etsy-Keyword-Market-Analyzer.user.js'; const GITHUB_RAW_REPOSITORY_URL = 'https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools'; const GITHUB_RAW_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 MAKAYTRON_LOGO_URL = 'https://raw.githubusercontent.com/Makaytron/Etsy-Automation-Tools/main/assets/makaytron-logo.png'; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const UPDATE_CHECK_TIMEOUT_MS = 12 * 1000; const UPDATE_CHECK_STALE_MS = UPDATE_CHECK_TIMEOUT_MS * 2; const CHANNEL_NAME = 'makaytron:etsy-keyword-market-analyzer:v1'; const ENVELOPE_SCHEMA = 'makaytron-etsy-keyword-market-analyzer-envelope/v1'; const REQUEST_SCHEMA = 'makaytron-listing-research-request/v1'; const RESULT_SCHEMA = 'makaytron-listing-research-result/v1'; const MESSAGE_TYPES = Object.freeze([ 'PROBE', 'CAPABILITIES', 'RESEARCH_READY', 'RESEARCH_REQUEST', 'RESEARCH_ACK', 'RESEARCH_RESULT', 'RESEARCH_RECEIVED', 'ERROR', ]); const MAX_MESSAGE_BYTES = 64 * 1024; const MESSAGE_TTL_MS = 10 * 60 * 1000; const MAX_MESSAGE_TTL_MS = 30 * 60 * 1000; const REQUEST_TIMEOUT_MS = 10 * 60 * 1000; const DOM_RESULT_TIMEOUT_MS = 30 * 1000; const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MAX_SEED_KEYWORDS_DEFAULT = 1; const MAX_SEED_KEYWORDS_CAP = 3; const MAX_RELATED_KEYWORDS = 25; const MAX_KEYWORD_LENGTH = 80; const MAX_CACHE_ENTRIES = 80; const MAX_CAPTURE_ENTRIES = 100; const MAX_QUEUE_ENTRIES = 30; const MAX_EXPORT_BYTES = 2 * 1024 * 1024; const OBSERVER_DEBOUNCE_MS = 250; const SUPPORTED_ROUTE = /^\/your\/shops\/[^/]+\/marketplace-insights(?:\/search)?\/?$/; const QUEUE_TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000; const RESULT_HISTORY_TTL_MS = CACHE_TTL_MS; const MAX_RESULT_ENVELOPES = 30; const LEASE_SCHEMA = 'makaytron-keyword-market-analyzer-processor-lease/v1'; const LEASE_TTL_MS = 5_000; const LEASE_RENEW_MS = 1_500; const LEASE_SETTLE_MS = 60; const TAB_PRESENCE_SETTLE_MS = 90; const KEYS = Object.freeze({ queue: `${APP.storagePrefix}:research-queue`, cache: `${APP.storagePrefix}:research-cache`, captures: `${APP.storagePrefix}:captures`, settings: `${APP.storagePrefix}:settings`, update: `${APP.storagePrefix}:update-state`, lease: `${APP.storagePrefix}:processor-lease`, results: `${APP.storagePrefix}:result-envelopes`, }); const CAPABILITIES = Object.freeze({ version: APP_VERSION, standalone: true, maxSeedKeywords: MAX_SEED_KEYWORDS_CAP, maxRelatedKeywords: MAX_RELATED_KEYWORDS, cacheTtlDays: 7, networkAccess: false, }); const TEXT_ALIASES = Object.freeze({ searches: Object.freeze(['searches', 'aramalar']), searchResults: Object.freeze(['search results', 'arama sonuçları']), similarSearchTerms: Object.freeze(['similar search terms', 'benzer arama terimleri']), yourSearch: Object.freeze(['your search', 'aramanız']), }); const I18N = Object.freeze({ tr: Object.freeze({ launcher: 'Etsy Keyword & Market Analyzer panelini aç', panelTitle: 'Etsy Keyword & Market Analyzer', keyword: 'Anahtar kelime', search: 'Etsy Insights’ta ara', capture: 'Bu sayfayı analiz et ve kaydet', export: 'JSON dışa aktar', researchJson: 'Listing Analyzer araştırma zarfı (JSON)', researchJsonHelp: 'Listing Analyzer yoksa tam RESEARCH_REQUEST zarfını buraya yapıştırın. Sonuç tam RESEARCH_RESULT zarfı olarak kopyalanabilir veya indirilebilir.', importRequest: 'Araştırma talebini içe aktar', copyResult: 'Sonuç zarfını kopyala', downloadResult: 'Sonuç zarfını indir', importAccepted: 'Araştırma talebi doğrulandı ve kuyruğa alındı: {requestId}', importDelegated: 'Araştırma talebi bu sekmeden işlemci sekmesine aktarıldı: {requestId}', importRejected: 'Araştırma zarfı reddedildi: {message}', resultCopied: 'Tam RESEARCH_RESULT zarfı panoya kopyalandı.', resultDownloaded: 'Tam RESEARCH_RESULT zarfı indirildi.', resultMissing: 'Dışa aktarılabilecek geçerli bir RESEARCH_RESULT zarfı yok.', clipboardError: 'Panoya erişilemedi; sonuç zarfı JSON alanına yazıldı.', cancel: 'Aktif araştırmayı iptal et', close: 'Kapat', saved: 'Kaydedilen araştırma', ready: 'Hazır. Etsy verileri yalnız açık sayfanın DOM yapısından okunur.', noResult: 'Bu sayfada tamamlanmış Marketplace Insights sonucu bulunamadı.', captured: 'Analiz kaydedildi.', invalidKeyword: 'Geçerli bir anahtar kelime yazın.', queueEmpty: 'İptal edilecek aktif araştırma yok.', queueCancelled: 'Aktif araştırma iptal edildi.', clearData: 'Yerel verileri temizle', clearDataConfirm: 'Kaydedilmiş araştırmalar, sonuç zarfları, 7 günlük cache ve araştırma kuyruğu silinsin mi? Dil tercihiniz korunur.', clearDataDone: 'Yerel araştırma verileri temizlendi. Dil tercihi korundu.', clearDataError: 'Yerel veriler doğrulanarak temizlenemedi.', researchProgress: 'Listing araştırması: seed {current}/{total} · {keyword}', researchSent: 'Araştırma sonucu Analyzer’a gönderildi: {count} anahtar kelime.', researchFailed: 'Araştırma tamamlanamadı: {message}', exportTooLarge: 'Dışa aktarma 2 MiB güvenlik sınırını aşıyor.', checkUpdate: 'Şimdi güncelleme denetle', checkingUpdate: 'Güncelleme denetleniyor…', updateCurrent: 'v{version} güncel.', updateAvailable: 'Yeni sürüm hazır: v{version}', updateError: 'Güncelleme denetlenemedi; ana analiz çalışmaya devam eder.', updateExternal: 'Kurulum kaynağınızın güncelleme mekanizması kullanılıyor.', installUpdate: 'Güncelleme sayfasını aç', installConfirm: 'Canonical Makaytron userscript kurulum sayfası açılsın mı? Son onay userscript yöneticinizde verilir.', updateBusy: 'Aktif araştırma varken güncelleme açılamaz.', installSource: 'Kurulum kaynağı', sourceGithub: 'Canonical GitHub', sourceExternal: 'Userscript yöneticisi / diğer', sourceUnknown: 'Bilinmiyor', searches30d: '30 günlük Etsy araması', searchResults: 'Etsy arama sonucu (rekabet göstergesi)', opportunity: 'Makaytron türetilmiş fırsat metriği', trend: '7 günlük değişim', searchesShort: 'Arama', resultsShort: 'Sonuç', opportunityShort: 'Fırsat', trendShort: 'Trend', capturedAt: 'Yakalama', low: 'Düşük', medium: 'Orta', high: 'Yüksek', }), en: Object.freeze({ launcher: 'Open Etsy Keyword & Market Analyzer', panelTitle: 'Etsy Keyword & Market Analyzer', keyword: 'Keyword', search: 'Search in Etsy Insights', capture: 'Analyze and save this page', export: 'Export JSON', researchJson: 'Listing Analyzer research envelope (JSON)', researchJsonHelp: 'When Listing Analyzer is unavailable, paste a complete RESEARCH_REQUEST envelope here. The complete RESEARCH_RESULT envelope can be copied or downloaded.', importRequest: 'Import research request', copyResult: 'Copy result envelope', downloadResult: 'Download result envelope', importAccepted: 'Research request validated and queued: {requestId}', importDelegated: 'Research request relayed from this tab to the processor tab: {requestId}', importRejected: 'Research envelope rejected: {message}', resultCopied: 'Complete RESEARCH_RESULT envelope copied to the clipboard.', resultDownloaded: 'Complete RESEARCH_RESULT envelope downloaded.', resultMissing: 'There is no valid RESEARCH_RESULT envelope to export.', clipboardError: 'Clipboard access failed; the result envelope was written into the JSON field.', cancel: 'Cancel active research', close: 'Close', saved: 'Saved research', ready: 'Ready. Etsy data is read only from the open page DOM.', noResult: 'No completed Marketplace Insights result was found on this page.', captured: 'Analysis saved.', invalidKeyword: 'Enter a valid keyword.', queueEmpty: 'There is no active research to cancel.', queueCancelled: 'Active research cancelled.', clearData: 'Clear local data', clearDataConfirm: 'Delete saved research, result envelopes, the seven-day cache, and research queue? Your language preference is preserved.', clearDataDone: 'Local research data cleared. Language preference was preserved.', clearDataError: 'Local data could not be cleared and verified.', researchProgress: 'Listing research: seed {current}/{total} · {keyword}', researchSent: 'Research result sent to Analyzer: {count} keywords.', researchFailed: 'Research could not be completed: {message}', exportTooLarge: 'Export exceeds the 2 MiB safety limit.', checkUpdate: 'Check for updates now', checkingUpdate: 'Checking for updates…', updateCurrent: 'v{version} is current.', updateAvailable: 'New version available: v{version}', updateError: 'Update check failed; core analysis remains available.', updateExternal: 'Your installation source manages updates.', installUpdate: 'Open update page', installConfirm: 'Open the canonical Makaytron userscript installation page? Your userscript manager owns the final confirmation.', updateBusy: 'An update cannot be opened while research is active.', installSource: 'Installation source', sourceGithub: 'Canonical GitHub', sourceExternal: 'Userscript manager / other', sourceUnknown: 'Unknown', searches30d: 'Etsy searches over 30 days', searchResults: 'Etsy search results (competition indicator)', opportunity: 'Makaytron-derived opportunity metric', trend: '7-day change', searchesShort: 'Search', resultsShort: 'Results', opportunityShort: 'Score', trendShort: 'Trend', capturedAt: 'Captured', low: 'Low', medium: 'Medium', high: 'High', }), }); const Runtime = { channel: null, processing: false, observer: null, observerTimer: 0, ui: null, tabId: '', pageInstanceId: `ekma-page:${Date.now()}:${randomToken(8)}`, identityReady: null, presenceChannel: null, leaseToken: '', leaseRenewTimer: 0, leaseTakeoverTimer: 0, queueProcessTimer: 0, navigationPending: false, }; let language = String(document.documentElement.lang || '').toLowerCase().startsWith('tr') ? 'tr' : 'en'; const t = (key) => I18N[language][key] || I18N.en[key] || key; function tf(key, values = {}) { return Object.entries(values).reduce( (output, [name, value]) => output.replaceAll(`{${name}}`, String(value)), t(key), ); } function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); } function hasExactKeys(value, required, optional = []) { if (!isPlainObject(value)) return false; const allowed = new Set([...required, ...optional]); const keys = Object.keys(value); return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) && keys.every((key) => allowed.has(key)); } function jsonByteSize(value) { try { return new TextEncoder().encode(JSON.stringify(value)).byteLength; } catch { return Infinity; } } function normalizeText(value) { return String(value ?? '').replace(/\s+/g, ' ').trim(); } function normalizeKeyword(value) { return normalizeText(value).toLocaleLowerCase('en-US'); } function validIdentifier(value, min = 8, max = 128) { return typeof value === 'string' && value.length >= min && value.length <= max && /^[A-Za-z0-9._:-]+$/.test(value); } function validNonce(value) { return typeof value === 'string' && value.length >= 16 && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value); } function randomToken(bytes = 16) { const values = new Uint8Array(bytes); crypto.getRandomValues(values); return Array.from(values, (value) => value.toString(16).padStart(2, '0')).join(''); } function freshContext(prefix = 'ekma') { return { requestId: `${prefix}:${Date.now()}:${randomToken(6)}`, nonce: randomToken(16), }; } function validateEnvelope(value, now = Date.now()) { const required = ['schema', 'type', 'requestId', 'nonce', 'sentAt', 'expiresAt', 'sender', 'payload']; if (!hasExactKeys(value, required)) return { ok: false, code: 'INVALID_ENVELOPE_KEYS' }; if (jsonByteSize(value) > MAX_MESSAGE_BYTES) return { ok: false, code: 'MESSAGE_TOO_LARGE' }; if (value.schema !== ENVELOPE_SCHEMA) return { ok: false, code: 'INVALID_ENVELOPE_SCHEMA' }; if (!MESSAGE_TYPES.includes(value.type)) return { ok: false, code: 'INVALID_MESSAGE_TYPE' }; if (!validIdentifier(value.requestId)) return { ok: false, code: 'INVALID_REQUEST_ID' }; if (!validNonce(value.nonce)) return { ok: false, code: 'INVALID_NONCE' }; if (!Number.isInteger(value.sentAt) || !Number.isInteger(value.expiresAt)) { return { ok: false, code: 'INVALID_MESSAGE_TIME' }; } if (value.sender !== 'listing-analyzer' && value.sender !== 'keyword-market-analyzer') { return { ok: false, code: 'INVALID_SENDER' }; } if (value.sentAt > now + 30_000 || value.sentAt < now - MAX_MESSAGE_TTL_MS) { return { ok: false, code: 'MESSAGE_TIME_OUT_OF_RANGE' }; } if (value.expiresAt <= now || value.expiresAt <= value.sentAt || value.expiresAt - value.sentAt > MAX_MESSAGE_TTL_MS) { return { ok: false, code: 'MESSAGE_EXPIRED' }; } if (!isPlainObject(value.payload)) return { ok: false, code: 'INVALID_PAYLOAD' }; return { ok: true, value }; } function validateResearchRequest(payload) { const required = ['schema', 'opaqueReference', 'contentHash', 'title', 'tags', 'seedKeywords', 'maxSeeds']; if (!hasExactKeys(payload, required)) return { ok: false, code: 'INVALID_REQUEST_KEYS' }; if (payload.schema !== REQUEST_SCHEMA) return { ok: false, code: 'INVALID_REQUEST_SCHEMA' }; if (!validIdentifier(payload.opaqueReference, 1, 128)) return { ok: false, code: 'INVALID_OPAQUE_REFERENCE' }; if (typeof payload.contentHash !== 'string' || !/^[a-f0-9]{64}$/.test(payload.contentHash)) { return { ok: false, code: 'INVALID_CONTENT_HASH' }; } if (typeof payload.title !== 'string' || payload.title.length > 140) { return { ok: false, code: 'INVALID_TITLE' }; } if (!Array.isArray(payload.tags) || payload.tags.length > 13 || payload.tags.some((tag) => typeof tag !== 'string' || !normalizeText(tag) || tag.length > 20)) { return { ok: false, code: 'INVALID_TAGS' }; } if (!Array.isArray(payload.seedKeywords) || payload.seedKeywords.length < 1 || payload.seedKeywords.length > MAX_SEED_KEYWORDS_CAP || payload.seedKeywords.some((keyword) => typeof keyword !== 'string' || !normalizeText(keyword) || keyword.length > MAX_KEYWORD_LENGTH)) { return { ok: false, code: 'INVALID_SEED_KEYWORDS' }; } if (!Number.isInteger(payload.maxSeeds) || payload.maxSeeds < 1 || payload.maxSeeds > MAX_SEED_KEYWORDS_CAP || payload.seedKeywords.length > payload.maxSeeds) { return { ok: false, code: 'INVALID_MAX_SEEDS' }; } const normalizedSeeds = payload.seedKeywords.map(normalizeText); if (new Set(normalizedSeeds.map(normalizeKeyword)).size !== normalizedSeeds.length) { return { ok: false, code: 'DUPLICATE_SEED_KEYWORDS' }; } return { ok: true, value: { schema: REQUEST_SCHEMA, opaqueReference: payload.opaqueReference, contentHash: payload.contentHash, title: normalizeText(payload.title), tags: payload.tags.map(normalizeText), seedKeywords: normalizedSeeds, maxSeeds: payload.maxSeeds, }, }; } function makeEnvelope(type, context, payload) { const now = Date.now(); const envelope = { schema: ENVELOPE_SCHEMA, type, requestId: context.requestId, nonce: context.nonce, sentAt: now, expiresAt: now + MESSAGE_TTL_MS, sender: 'keyword-market-analyzer', payload, }; if (!validateEnvelope(envelope, now).ok || jsonByteSize(envelope) > MAX_MESSAGE_BYTES) return null; return envelope; } function sendEnvelope(envelope) { if (!Runtime.channel) return false; if (!validateEnvelope(envelope).ok || envelope.sender !== 'keyword-market-analyzer') return false; Runtime.channel.postMessage(envelope); return true; } function postMessage(type, context, payload) { const envelope = makeEnvelope(type, context, payload); return envelope && sendEnvelope(envelope) ? envelope : null; } function queueTime(value) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function normalizeQueueRecords(value, now = Date.now()) { const source = Array.isArray(value) ? value.filter(isPlainObject) : []; const terminalStatuses = new Set(['completed', 'cancelled', 'failed']); const liveStatuses = new Set(['queued', 'running', 'awaiting-receipt']); const live = []; const terminal = []; for (const raw of source) { if (!validIdentifier(raw.requestId) || !validNonce(raw.nonce)) continue; const item = { ...raw }; if (liveStatuses.has(item.status)) { const deadlineAt = queueTime(item.deadlineAt); if (deadlineAt === null || deadlineAt <= now) { item.status = 'failed'; item.updatedAt = now; item.terminalAt = now; item.error = { code: raw.status === 'awaiting-receipt' ? 'RECEIPT_TIMEOUT' : 'REQUEST_TIMEOUT', message: raw.status === 'awaiting-receipt' ? 'Research result receipt expired.' : 'Research request exceeded its time limit.', retryable: raw.status !== 'awaiting-receipt', }; delete item.result; delete item.resultEnvelope; item.seedResults = []; terminal.push(item); } else { live.push(item); } continue; } if (!terminalStatuses.has(item.status)) continue; const terminalAt = queueTime(item.terminalAt) ?? queueTime(item.updatedAt); if (terminalAt === null || now - terminalAt > QUEUE_TERMINAL_RETENTION_MS) continue; terminal.push(item); } const boundedLive = live.slice(0, MAX_QUEUE_ENTRIES); const terminalCapacity = Math.max(0, MAX_QUEUE_ENTRIES - boundedLive.length); const boundedTerminal = terminalCapacity > 0 ? terminal.slice(-Math.min(10, terminalCapacity)) : []; const items = [...boundedTerminal, ...boundedLive]; return { items, changed: !Array.isArray(value) || JSON.stringify(items) !== JSON.stringify(value), }; } function researchStorageStateError() { const error = new Error('Local research state storage failed.'); error.telemetryCode = 'storage_research_state'; return error; } const Store = { async get(key, fallback) { try { return await GM.getValue(key, fallback); } catch { void trackTelemetryError('storage_research_state'); throw researchStorageStateError(); } }, async set(key, value) { try { await GM.setValue(key, value); return value; } catch { void trackTelemetryError('storage_research_state'); throw researchStorageStateError(); } }, async delete(key) { try { await GM.deleteValue(key); const sentinel = `ekma-deleted:${randomToken(8)}`; if (await GM.getValue(key, sentinel) !== sentinel) throw new Error('STORAGE_DELETE_READBACK_FAILED'); return true; } catch { void trackTelemetryError('storage_research_state'); throw researchStorageStateError(); } }, async queue() { const value = await this.get(KEYS.queue, []); const normalized = normalizeQueueRecords(value); if (normalized.changed) await this.set(KEYS.queue, normalized.items); return normalized.items; }, async saveQueue(queue) { const normalized = normalizeQueueRecords(queue); return this.set(KEYS.queue, normalized.items); }, async captures() { const value = await this.get(KEYS.captures, []); return Array.isArray(value) ? value.filter(isPlainObject).slice(-MAX_CAPTURE_ENTRIES) : []; }, async cache() { const now = Date.now(); const value = await this.get(KEYS.cache, []); const pruned = Array.isArray(value) ? value.filter((entry) => isPlainObject(entry) && Number.isFinite(entry.savedAt) && now - entry.savedAt <= CACHE_TTL_MS && isPlainObject(entry.result)).slice(-MAX_CACHE_ENTRIES) : []; if (!Array.isArray(value) || pruned.length !== value.length) await this.set(KEYS.cache, pruned); return pruned; }, async resultEnvelopes() { const now = Date.now(); const value = await this.get(KEYS.results, []); const pruned = Array.isArray(value) ? value.filter((entry) => isPlainObject(entry) && Number.isFinite(entry.savedAt) && now - entry.savedAt <= RESULT_HISTORY_TTL_MS && validateEnvelope(entry.envelope, now).ok && entry.envelope.type === 'RESEARCH_RESULT' && entry.envelope.sender === 'keyword-market-analyzer').slice(-MAX_RESULT_ENVELOPES) : []; if (!Array.isArray(value) || JSON.stringify(pruned) !== JSON.stringify(value)) await this.set(KEYS.results, pruned); return pruned; }, async saveResultEnvelope(envelope) { const checked = validateEnvelope(envelope); if (!checked.ok || envelope.type !== 'RESEARCH_RESULT' || envelope.sender !== 'keyword-market-analyzer') { throw new Error('INVALID_RESULT_ENVELOPE'); } const values = (await this.resultEnvelopes()).filter((entry) => !( entry.envelope.requestId === envelope.requestId && entry.envelope.nonce === envelope.nonce )); values.push({ savedAt: Date.now(), envelope }); await this.set(KEYS.results, values.slice(-MAX_RESULT_ENVELOPES)); return envelope; }, async removeResultEnvelope(requestId, nonce) { const values = (await this.resultEnvelopes()).filter((entry) => !( entry.envelope.requestId === requestId && entry.envelope.nonce === nonce )); await this.set(KEYS.results, values); }, }; function delay(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function requestQueueProcessing(delayMs = 0) { window.clearTimeout(Runtime.queueProcessTimer); Runtime.queueProcessTimer = window.setTimeout(() => { Runtime.queueProcessTimer = 0; if (Runtime.processing) { requestQueueProcessing(100); return; } void processQueue(); }, Math.max(0, delayMs)); } function persistTabIdentity() { let state = null; try { state = isPlainObject(history.state) ? history.state : {}; } catch { state = {}; } try { history.replaceState({ ...state, __makaytronEkmaTabId: Runtime.tabId }, document.title, location.href); } catch { /* The instance-bound lease remains safe when history state is unavailable. */ } } function ensureTabIdentity() { let state = null; try { state = isPlainObject(history.state) ? history.state : {}; } catch { state = {}; } const stored = typeof state.__makaytronEkmaTabId === 'string' ? state.__makaytronEkmaTabId : ''; Runtime.tabId = validIdentifier(stored, 12, 160) ? stored : `ekma-tab:${Date.now()}:${randomToken(8)}`; persistTabIdentity(); return Runtime.tabId; } async function ensureUniqueTabIdentity() { if (Runtime.identityReady) return Runtime.identityReady; Runtime.identityReady = (async () => { ensureTabIdentity(); if (typeof BroadcastChannel !== 'function') return Runtime.tabId; let channel = null; try { channel = new BroadcastChannel(`${APP.storagePrefix}:tab-presence`); const probeId = `ekma-probe:${Date.now()}:${randomToken(8)}`; let duplicate = false; channel.addEventListener('message', (event) => { const message = event.data; if (!isPlainObject(message) || message.instanceId === Runtime.pageInstanceId || message.tabId !== Runtime.tabId || !validIdentifier(message.instanceId, 12, 160)) return; if (message.type === 'probe' && validIdentifier(message.probeId, 12, 160)) { channel.postMessage({ type: 'present', tabId: Runtime.tabId, probeId: message.probeId, instanceId: Runtime.pageInstanceId, }); } else if (message.type === 'present' && message.probeId === probeId) { duplicate = true; } }); channel.postMessage({ type: 'probe', tabId: Runtime.tabId, probeId, instanceId: Runtime.pageInstanceId, }); await delay(TAB_PRESENCE_SETTLE_MS); if (duplicate) { Runtime.tabId = `ekma-tab:${Date.now()}:${randomToken(8)}`; persistTabIdentity(); } Runtime.presenceChannel = channel; } catch { try { channel?.close(); } catch { /* optional collision-hardening channel */ } } return Runtime.tabId; })(); return Runtime.identityReady; } function normalizeLease(value, now = Date.now()) { if (!isPlainObject(value) || value.schema !== LEASE_SCHEMA || !validIdentifier(value.ownerId, 12, 160) || !validIdentifier(value.instanceId, 12, 160) || !validNonce(value.token) || !Number.isInteger(value.acquiredAt) || !Number.isInteger(value.renewedAt) || !Number.isInteger(value.expiresAt) || value.expiresAt <= value.renewedAt || value.expiresAt - value.renewedAt > LEASE_TTL_MS + 250) return null; return { ...value, expired: value.expiresAt <= now }; } async function withLeaseMutation(callback) { if (navigator.locks?.request) { return navigator.locks.request(`${APP.storagePrefix}:processor-lease-mutation`, { mode: 'exclusive' }, callback); } return callback(); } const Lease = { async current() { return normalizeLease(await Store.get(KEYS.lease, null)); }, clearTimers() { window.clearTimeout(Runtime.leaseRenewTimer); window.clearTimeout(Runtime.leaseTakeoverTimer); Runtime.leaseRenewTimer = 0; Runtime.leaseTakeoverTimer = 0; }, scheduleRenewal() { window.clearTimeout(Runtime.leaseRenewTimer); if (!Runtime.leaseToken) return; Runtime.leaseRenewTimer = window.setTimeout(async () => { const renewed = await this.renew(); if (renewed) this.scheduleRenewal(); }, LEASE_RENEW_MS); }, scheduleTakeover(expiresAt) { if (Runtime.leaseTakeoverTimer) return; const delayMs = Math.max(100, Math.min(LEASE_TTL_MS + 250, Number(expiresAt || Date.now()) - Date.now() + 75)); Runtime.leaseTakeoverTimer = window.setTimeout(() => { Runtime.leaseTakeoverTimer = 0; requestQueueProcessing(); }, delayMs); }, async acquire() { await ensureUniqueTabIdentity(); const outcome = await withLeaseMutation(async () => { const now = Date.now(); const current = normalizeLease(await Store.get(KEYS.lease, null), now); const sameInstance = current && current.ownerId === Runtime.tabId && current.instanceId === Runtime.pageInstanceId; if (current && !current.expired && !sameInstance) { return { acquired: false, expiresAt: current.expiresAt }; } const token = sameInstance && !current.expired ? current.token : randomToken(16); const candidate = { schema: LEASE_SCHEMA, ownerId: Runtime.tabId, instanceId: Runtime.pageInstanceId, token, acquiredAt: sameInstance ? current.acquiredAt : now, renewedAt: now, expiresAt: now + LEASE_TTL_MS, }; await Store.set(KEYS.lease, candidate); await delay(LEASE_SETTLE_MS); const verified = normalizeLease(await Store.get(KEYS.lease, null)); if (!verified || verified.expired || verified.ownerId !== candidate.ownerId || verified.instanceId !== candidate.instanceId || verified.token !== token) { return { acquired: false, expiresAt: verified?.expiresAt || now + LEASE_TTL_MS }; } return { acquired: true, token, expiresAt: verified.expiresAt }; }); if (!outcome.acquired) { Runtime.leaseToken = ''; this.scheduleTakeover(outcome.expiresAt); return false; } Runtime.leaseToken = outcome.token; this.scheduleRenewal(); return true; }, async owns() { if (!Runtime.tabId || !Runtime.leaseToken) return false; const current = normalizeLease(await Store.get(KEYS.lease, null)); return Boolean(current && !current.expired && current.ownerId === Runtime.tabId && current.instanceId === Runtime.pageInstanceId && current.token === Runtime.leaseToken); }, async assertOwner() { if (await this.owns()) return true; const error = new Error('PROCESSOR_LEASE_LOST'); error.code = 'PROCESSOR_LEASE_LOST'; throw error; }, async renew() { if (!Runtime.tabId || !Runtime.leaseToken) return false; const renewed = await withLeaseMutation(async () => { const current = normalizeLease(await Store.get(KEYS.lease, null)); if (!current || current.expired || current.ownerId !== Runtime.tabId || current.instanceId !== Runtime.pageInstanceId || current.token !== Runtime.leaseToken) return false; const now = Date.now(); const next = { ...current, renewedAt: now, expiresAt: now + LEASE_TTL_MS }; delete next.expired; await Store.set(KEYS.lease, next); const verified = normalizeLease(await Store.get(KEYS.lease, null)); return Boolean(verified && !verified.expired && verified.ownerId === Runtime.tabId && verified.instanceId === Runtime.pageInstanceId && verified.token === Runtime.leaseToken); }); if (!renewed) { Runtime.leaseToken = ''; window.clearTimeout(Runtime.leaseRenewTimer); Runtime.leaseRenewTimer = 0; } return renewed; }, async release() { this.clearTimers(); if (!Runtime.tabId || !Runtime.leaseToken) return false; const token = Runtime.leaseToken; const released = await withLeaseMutation(async () => { const current = normalizeLease(await Store.get(KEYS.lease, null)); if (!current || current.ownerId !== Runtime.tabId || current.instanceId !== Runtime.pageInstanceId || current.token !== token) return false; await Store.delete(KEYS.lease); return !normalizeLease(await Store.get(KEYS.lease, null)); }); if (released) Runtime.leaseToken = ''; return released; }, }; function installationSource() { let info = null; try { info = GM?.info || globalThis.GM_info || null; } catch { info = null; } const script = isPlainObject(info?.script) ? info.script : {}; const candidates = [script.downloadURL, script.updateURL].filter((value) => typeof value === 'string' && value); const allCanonical = candidates.length > 0 && candidates.every((value) => { try { const candidate = new URL(value); const canonical = new URL(GITHUB_RAW_SCRIPT_URL); return candidate.protocol === 'https:' && candidate.protocol === canonical.protocol && candidate.hostname === canonical.hostname && candidate.port === canonical.port && candidate.pathname === canonical.pathname && candidate.search === canonical.search && candidate.hash === canonical.hash && !candidate.username && !candidate.password; } catch { return false; } }); if (allCanonical) return { kind: 'github', labelKey: 'sourceGithub' }; if (candidates.length || normalizeText(info?.scriptHandler)) return { kind: 'external', labelKey: 'sourceExternal' }; return { kind: 'unknown', labelKey: 'sourceUnknown' }; } function compareSemver(left, right) { const parse = (value) => { const match = String(value || '').match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); return match ? match.slice(1).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 normalizeUpdateState(value) { const source = isPlainObject(value) ? value : {}; const commitSha = /^[a-f0-9]{40}$/.test(String(source.commitSha || '')) ? String(source.commitSha) : ''; const installUrl = commitSha ? pinnedUpdateUrl(commitSha) : ''; const hasVerifiedInstall = Boolean(installUrl && source.installUrl === installUrl); const requestedStatus = ['idle', 'checking', 'current', 'available', 'error', 'external'].includes(source.status) ? source.status : 'idle'; return { status: ['current', 'available'].includes(requestedStatus) && !hasVerifiedInstall ? 'idle' : requestedStatus, latestVersion: typeof source.latestVersion === 'string' ? source.latestVersion : '', checkedAt: Number.isFinite(source.checkedAt) ? source.checkedAt : 0, error: typeof source.error === 'string' ? source.error.slice(0, 300) : '', commitSha: hasVerifiedInstall ? commitSha : '', installUrl: hasVerifiedInstall ? installUrl : '', }; } function requestExactText(url, accept = 'text/plain') { return new Promise((resolve, reject) => { let settled = false; const finish = (callback, value) => { if (settled) return; settled = true; callback(value); }; try { GM.xmlHttpRequest({ method: 'GET', url, anonymous: true, timeout: UPDATE_CHECK_TIMEOUT_MS, headers: { Accept: accept, 'Cache-Control': 'no-cache' }, onload: (response) => { try { if (Number(response.status) !== 200) throw new Error(`UPDATE_HTTP_${response.status}`); if (!response.finalUrl) throw new Error('UPDATE_FINAL_URL_MISSING'); if (!exactHttpsTarget(response.finalUrl, url)) throw new Error('UPDATE_REDIRECT_REJECTED'); const source = String(response.responseText || ''); if (!source || source.length > 2_000_000) throw new Error('UPDATE_SOURCE_SIZE_INVALID'); finish(resolve, source); } catch (error) { finish(reject, error); } }, onerror: () => finish(reject, new Error('UPDATE_NETWORK_ERROR')), ontimeout: () => finish(reject, new Error('UPDATE_TIMEOUT')), }); } 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('UPDATE_COMMIT_RESPONSE_INVALID'); } const commitSha = /^[a-f0-9]{40}$/.test(String(ref?.sha || '')) ? String(ref.sha) : ''; if (!commitSha) throw new Error('UPDATE_COMMIT_INVALID'); const installUrl = pinnedUpdateUrl(commitSha); const source = await requestExactText(installUrl, 'text/plain'); if (!/^\/\/ ==UserScript==[\r\n]/.test(source)) throw new Error('UPDATE_METADATA_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('UPDATE_NAMESPACE_INVALID'); } if (names.length !== 1 || names[0] !== 'Makaytron Etsy Keyword & Market Analyzer') { throw new Error('UPDATE_PRODUCT_INVALID'); } if (updateUrls.length !== 1 || downloadUrls.length !== 1 || updateUrls[0] !== GITHUB_RAW_SCRIPT_URL || downloadUrls[0] !== GITHUB_RAW_SCRIPT_URL) { throw new Error('UPDATE_DISTRIBUTION_INVALID'); } const version = versions.length === 1 && /^\d+\.\d+\.\d+$/.test(versions[0]) ? versions[0] : ''; if (!version || compareSemver(version, APP_VERSION) === null) throw new Error('UPDATE_VERSION_INVALID'); return { version, commitSha, installUrl }; } async function updateState() { return normalizeUpdateState(await Store.get(KEYS.update, null)); } async function saveUpdateState(value) { const normalized = normalizeUpdateState(value); await Store.set(KEYS.update, normalized); renderUpdateState(normalized); return normalized; } async function checkForUpdates(options = {}) { const manual = options.manual === true; const force = manual || options.force === true; const source = installationSource(); const current = await updateState(); const checkingAge = Date.now() - current.checkedAt; const checkingIsFresh = current.status === 'checking' && current.checkedAt > 0 && checkingAge >= 0 && checkingAge < UPDATE_CHECK_STALE_MS; if (checkingIsFresh && !force) return current; if (source.kind === 'external') { const next = await saveUpdateState({ status: 'external', latestVersion: '', checkedAt: Date.now(), error: '' }); if (manual) UI.status(t('updateExternal')); return next; } if (source.kind === 'unknown' && !manual) return current; const age = Date.now() - current.checkedAt; if (!force && current.checkedAt && age >= 0 && age < UPDATE_CHECK_INTERVAL_MS) { renderUpdateState(current); return current; } await saveUpdateState({ ...current, status: 'checking', checkedAt: Date.now(), error: '' }); if (manual) UI.status(t('checkingUpdate')); try { const remote = await requestCanonicalScript(); if (compareSemver(remote.version, APP_VERSION) === null) throw new Error('UPDATE_VERSION_INVALID'); const available = compareSemver(remote.version, APP_VERSION) > 0; const next = await saveUpdateState({ status: available ? 'available' : 'current', latestVersion: remote.version, checkedAt: Date.now(), error: '', commitSha: remote.commitSha, installUrl: remote.installUrl, }); if (manual) UI.status(available ? tf('updateAvailable', { version: remote.version }) : tf('updateCurrent', { version: APP_VERSION })); return next; } catch (error) { const next = await saveUpdateState({ status: 'error', latestVersion: '', checkedAt: Date.now(), error: String(error?.message || error).slice(0, 300), commitSha: '', installUrl: '', }); if (manual) UI.status(t('updateError')); return next; } } function renderUpdateState(state) { if (!Runtime.ui) return; const normalized = normalizeUpdateState(state); Runtime.ui.version.textContent = normalized.status === 'available' ? `v${APP_VERSION} → v${normalized.latestVersion}` : `v${APP_VERSION}`; Runtime.ui.updateBanner.hidden = !['available', 'error', 'external'].includes(normalized.status); Runtime.ui.updateBanner.textContent = normalized.status === 'available' ? tf('updateAvailable', { version: normalized.latestVersion }) : normalized.status === 'external' ? t('updateExternal') : normalized.status === 'error' ? t('updateError') : ''; Runtime.ui.installUpdate.hidden = normalized.status !== 'available'; Runtime.ui.checkUpdate.disabled = normalized.status === 'checking'; Runtime.ui.source.textContent = `${t('installSource')}: ${t(installationSource().labelKey)}`; } async function openUpdatePage() { const queue = await Store.queue(); if (queue.some((item) => ['queued', 'running', 'awaiting-receipt'].includes(item.status))) { UI.status(t('updateBusy')); return false; } const current = await updateState(); const installUrl = pinnedUpdateUrl(current.commitSha); if (current.status !== 'available' || !installUrl || current.installUrl !== installUrl) { UI.status(t('updateError')); return false; } if (!window.confirm(t('installConfirm'))) return false; try { if (typeof GM.openInTab === 'function') { GM.openInTab(installUrl, { active: true, insert: true, setParent: true }); } else { const anchor = document.createElement('a'); anchor.href = installUrl; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; document.documentElement.appendChild(anchor); anchor.click(); anchor.remove(); } return true; } catch { UI.status(t('updateError')); return false; } } function bindInstallUpdate(button) { if (!button || typeof button.addEventListener !== 'function') return false; button.addEventListener('click', () => { void openUpdatePage(); }); return true; } function parseCompactNumber(value) { const original = normalizeText(value); if (!original) return null; const match = original.match(/([-+]?\d[\d.,\s]*)\s*(k|m|b|bn|bin|mn|mio|million|milyon)?\b/i); if (!match) return null; let numeric = match[1].replace(/\s/g, ''); const suffixRaw = match[2] || ''; const suffix = suffixRaw.toLowerCase(); const both = numeric.includes(',') && numeric.includes('.'); if (both) { const decimal = numeric.lastIndexOf(',') > numeric.lastIndexOf('.') ? ',' : '.'; const thousands = decimal === ',' ? '.' : ','; numeric = numeric.split(thousands).join('').replace(decimal, '.'); } else if (numeric.includes(',')) { const tail = numeric.length - numeric.lastIndexOf(',') - 1; numeric = tail === 3 && !suffix ? numeric.replace(/,/g, '') : numeric.replace(',', '.'); } else if (numeric.includes('.')) { const tail = numeric.length - numeric.lastIndexOf('.') - 1; if (tail === 3 && !suffix && (numeric.match(/\./g) || []).length === 1) numeric = numeric.replace('.', ''); } const base = Number(numeric); if (!Number.isFinite(base) || base < 0) return null; let multiplier = 1; if (suffix === 'k' || suffix === 'bin') multiplier = 1_000; else if (suffix === 'm' || suffix === 'mn' || suffix === 'mio' || suffix === 'million' || suffix === 'milyon') multiplier = 1_000_000; else if (suffix === 'b' || suffix === 'bn') { multiplier = language === 'tr' && suffixRaw === 'B' ? 1_000 : 1_000_000_000; } return Math.round(base * multiplier); } function parsePercent(value) { const match = normalizeText(value).match(/([+-]?\d+(?:[.,]\d+)?)\s*%/); if (!match) return null; const parsed = Number(match[1].replace(',', '.')); return Number.isFinite(parsed) ? parsed : null; } function opportunityFor(searches30d, searchResults) { const demand = Math.min(1, Math.log10(Math.max(0, searches30d) + 1) / 5); const ratio = Math.max(0, searches30d) / Math.max(1, searchResults); const efficiency = Math.min(1, Math.log10(1 + (ratio * 1_000)) / 3); const score = Math.max(0, Math.min(100, Math.round((demand * 0.55 + efficiency * 0.45) * 100))); const label = score >= 65 ? 'high' : score >= 35 ? 'medium' : 'low'; return { score, label, metric: 'makaytron-derived', }; } function exactText(element) { return normalizeText(element?.textContent).toLocaleLowerCase('en-US'); } function matchesAlias(value, aliases) { const normalized = normalizeText(value).toLocaleLowerCase('en-US'); return aliases.includes(normalized); } function isVisibleResultElement(element) { if (!element || !element.isConnected) return false; for (let current = element; current && current.nodeType === 1; current = current.parentElement) { if (current.hidden || current.getAttribute('aria-hidden') === 'true') return false; if (typeof getComputedStyle === 'function') { const style = getComputedStyle(current); if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0') return false; } } return true; } function textWithoutEkmaInline(element) { if (!element) return ''; const clone = element.cloneNode(true); clone.querySelectorAll?.('[data-ekma-inline]').forEach((node) => node.remove()); return normalizeText(clone.textContent); } function valueFromMetricLabel(aliases, scope) { if (!scope || !isVisibleResultElement(scope)) return null; const labels = Array.from(scope.querySelectorAll('h1,h2,h3,h4,h5,h6,span,div')); for (const label of labels) { if (!isVisibleResultElement(label) || label.closest('table,[data-ekma-inline]') || !matchesAlias(label.textContent, aliases)) continue; const container = label.parentElement; if (!container || !scope.contains(container) || !isVisibleResultElement(container)) continue; const preferred = container.querySelector('.wt-text-title-larger'); const preferredValue = isVisibleResultElement(preferred) && !preferred?.closest('[data-ekma-inline]') ? parseCompactNumber(preferred.textContent) : null; if (preferredValue !== null) return { value: preferredValue, container }; for (const child of Array.from(container.children)) { if (child === label || child.closest('[data-ekma-inline]') || !isVisibleResultElement(child)) continue; const parsed = parseCompactNumber(child.textContent); if (parsed !== null) return { value: parsed, container }; } } return null; } function queryKeyword() { const url = new URL(location.href); const fromUrl = normalizeText(url.searchParams.get('query')); if (fromUrl) return fromUrl.slice(0, MAX_KEYWORD_LENGTH); const input = document.querySelector('input[name="query"], input[type="search"]'); return normalizeText(input?.value).slice(0, MAX_KEYWORD_LENGTH); } function findMainKeywordAnchor(keyword) { const wanted = normalizeKeyword(keyword); const explicit = Array.from(document.querySelectorAll('[data-ekma-main-keyword]')) .find((element) => isVisibleResultElement(element) && normalizeKeyword(element.textContent) === wanted); if (explicit) return explicit; const candidates = Array.from(document.querySelectorAll('span,div,h1,h2,h3,h4,h5')) .filter((element) => isVisibleResultElement(element) && !element.closest('[data-ekma-inline]') && normalizeKeyword(element.textContent) === wanted); return candidates.find((element) => { let ancestor = element.parentElement; for (let depth = 0; ancestor && depth < 5; depth += 1, ancestor = ancestor.parentElement) { if (!isVisibleResultElement(ancestor)) continue; const content = exactText(ancestor); if (TEXT_ALIASES.yourSearch.some((alias) => content.includes(alias))) return true; } return false; }) || null; } function resultScopeForAnchor(anchor) { const main = anchor?.closest('main,[role="main"]'); if (!main || !isVisibleResultElement(main)) return null; const declaredScope = anchor.closest('[data-ekma-result-scope]'); if (declaredScope && declaredScope !== main && isVisibleResultElement(declaredScope)) { const searches = valueFromMetricLabel(TEXT_ALIASES.searches, declaredScope); const results = valueFromMetricLabel(TEXT_ALIASES.searchResults, declaredScope); if (searches && results) return { scope: declaredScope, searches, results }; } for (let scope = anchor.parentElement; scope && scope !== main; scope = scope.parentElement) { if (!isVisibleResultElement(scope)) continue; const searches = valueFromMetricLabel(TEXT_ALIASES.searches, scope); const results = valueFromMetricLabel(TEXT_ALIASES.searchResults, scope); if (searches && results) return { scope, searches, results }; } return null; } function similarTermsTable(scope) { if (!scope || !isVisibleResultElement(scope)) return null; return Array.from(scope.querySelectorAll('table')).find((table) => { if (!isVisibleResultElement(table)) return false; const headers = Array.from(table.querySelectorAll('thead th,thead td')); return headers.some((header) => isVisibleResultElement(header) && matchesAlias(header.textContent, TEXT_ALIASES.similarSearchTerms)); }) || null; } function captureRelatedTerms(scope) { const table = similarTermsTable(scope); if (!table) return []; const values = []; for (const row of Array.from(table.querySelectorAll('tbody tr')).slice(0, MAX_RELATED_KEYWORDS)) { if (!isVisibleResultElement(row)) continue; const cells = Array.from(row.children).filter((cell) => cell.matches('th,td')); if (cells.length < 3 || cells.some((cell) => !isVisibleResultElement(cell))) continue; const keywordTarget = Array.from(cells[0].querySelectorAll('[data-ekma-keyword],[role="button"]')) .find((target) => !target.closest('[data-ekma-inline]')); const keyword = keywordTarget ? normalizeText(keywordTarget.textContent) : textWithoutEkmaInline(cells[0]); const searches30d = parseCompactNumber(textWithoutEkmaInline(cells[1])); const searchResults = parseCompactNumber(textWithoutEkmaInline(cells[2])); if (!keyword || keyword.length > MAX_KEYWORD_LENGTH || searches30d === null || searchResults === null) continue; values.push({ keyword, searches30d, searchResults, opportunity: opportunityFor(searches30d, searchResults), _anchor: cells[0], }); } return values; } function captureCurrentPage() { const keyword = queryKeyword(); if (!keyword) return null; const anchor = findMainKeywordAnchor(keyword); if (!anchor) return null; const result = resultScopeForAnchor(anchor); if (!result) return null; const { scope: resultScope, searches, results } = result; const percentCandidates = Array.from(searches.container.querySelectorAll('span,button,div')); let trend7dPercent = null; for (const candidate of percentCandidates) { if (!isVisibleResultElement(candidate) || candidate.closest('[data-ekma-inline]')) continue; trend7dPercent = parsePercent(candidate.textContent); if (trend7dPercent !== null) break; } const main = { keyword, searches30d: searches.value, searchResults: results.value, opportunity: opportunityFor(searches.value, results.value), _anchor: anchor, }; if (trend7dPercent !== null) main.trend7dPercent = trend7dPercent; return { schema: 'makaytron-marketplace-insights-capture/v1', capturedAt: new Date().toISOString(), source: 'etsy-marketplace-insights-dom', main, related: captureRelatedTerms(resultScope), }; } function publicKeyword(keyword) { const output = { keyword: keyword.keyword, searches30d: keyword.searches30d, searchResults: keyword.searchResults, trend7dPercent: Number.isFinite(keyword.trend7dPercent) ? keyword.trend7dPercent : null, opportunity: { score: keyword.opportunity.score, label: keyword.opportunity.label, metric: 'makaytron-derived', }, }; return output; } function publicCapture(capture) { return { schema: capture.schema, capturedAt: capture.capturedAt, source: capture.source, main: publicKeyword(capture.main), related: capture.related.slice(0, MAX_RELATED_KEYWORDS).map(publicKeyword), }; } function keywordDomId(keyword) { let hash = 2166136261; for (const char of normalizeKeyword(keyword)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); } function formatNumber(value) { return new Intl.NumberFormat(language === 'tr' ? 'tr-TR' : 'en-US', { maximumFractionDigits: 0 }).format(value); } function formatCompactNumber(value) { return new Intl.NumberFormat(language === 'tr' ? 'tr-TR' : 'en-US', { notation: 'compact', maximumFractionDigits: 1, }).format(value); } function ensureInlineStyles() { if (document.getElementById('ekma-inline-styles')) return; const style = document.createElement('style'); style.id = 'ekma-inline-styles'; style.textContent = ` .ekma-inline{display:flex;align-items:center;flex-wrap:wrap;gap:4px 7px;width:max-content;max-width:100%;margin-top:6px;padding:5px 7px;box-sizing:border-box;border:1px solid #d8d8d8;border-radius:7px;background:#fafafa;color:#171717;font:600 10.5px/1.25 Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-variant-numeric:tabular-nums;box-shadow:0 1px 2px rgba(0,0,0,.035)} .ekma-inline__brand{width:18px;height:18px;display:inline-grid;place-items:center;flex:0 0 auto;border:1px solid #cfcfcf;border-radius:5px;background:#fff;color:#171717;font-size:9px;font-weight:800;line-height:1} .ekma-inline__grid{display:flex;align-items:center;flex-wrap:wrap;gap:3px 8px;min-width:0} .ekma-inline__metric{display:inline-flex;align-items:baseline;gap:4px;min-width:0;color:#595959;white-space:nowrap} .ekma-inline__metric b{color:#737373;font-size:8.5px;font-weight:760;letter-spacing:.035em;text-transform:uppercase} .ekma-inline__metric strong{color:#171717;font-size:10.5px;font-weight:780} .ekma-inline__metric--opportunity{padding:2px 6px;border:1px solid #cfcfcf;border-radius:999px;background:#fff} .ekma-inline__metric--opportunity[data-tone="high"]{border-color:#a9d8be;background:#edf8f1}.ekma-inline__metric--opportunity[data-tone="high"] b,.ekma-inline__metric--opportunity[data-tone="high"] strong{color:#1f7a4d} .ekma-inline__metric--opportunity[data-tone="medium"]{border-color:#e5c56b;background:#fff9df}.ekma-inline__metric--opportunity[data-tone="medium"] b,.ekma-inline__metric--opportunity[data-tone="medium"] strong{color:#8a5a00} .ekma-inline__metric--opportunity[data-tone="low"]{border-color:#efb4ae;background:#fff1f0}.ekma-inline__metric--opportunity[data-tone="low"] b,.ekma-inline__metric--opportunity[data-tone="low"] strong{color:#b42318} .ekma-inline__metric--trend[data-tone="up"] strong{color:#1f7a4d}.ekma-inline__metric--trend[data-tone="down"] strong{color:#b42318} .ekma-inline__note{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important} @media(max-width:640px){.ekma-inline{width:100%}.ekma-inline__grid{gap:4px 9px}} `; document.head.appendChild(style); } function renderInlineKeyword(keyword, anchor, capturedAt) { if (!anchor || !anchor.isConnected) return; const container = anchor.matches('th,td') ? anchor : (anchor.closest('[data-ekma-main-keyword-cell],[class*="wt-grid__item"]') || anchor.parentElement || anchor); const id = keywordDomId(keyword.keyword); let inline = null; for (const existing of Array.from(container.querySelectorAll(':scope > [data-ekma-inline]'))) { if (!inline && existing.dataset.ekmaInline === id) inline = existing; else existing.remove(); } if (!inline) { inline = document.createElement('div'); inline.className = 'ekma-inline'; inline.dataset.ekmaInline = id; container.appendChild(inline); } const fingerprint = JSON.stringify(publicKeyword(keyword)); if (inline.dataset.ekmaFingerprint === fingerprint) return; inline.dataset.ekmaFingerprint = fingerprint; inline.replaceChildren(); const brand = document.createElement('div'); brand.className = 'ekma-inline__brand'; brand.textContent = 'M'; brand.title = 'Makaytron'; brand.setAttribute('aria-label', 'Makaytron'); const grid = document.createElement('div'); grid.className = 'ekma-inline__grid'; const metrics = [ { key: 'searches', label: t('searchesShort'), fullLabel: t('searches30d'), value: formatCompactNumber(keyword.searches30d), fullValue: formatNumber(keyword.searches30d), }, { key: 'results', label: t('resultsShort'), fullLabel: t('searchResults'), value: formatCompactNumber(keyword.searchResults), fullValue: formatNumber(keyword.searchResults), }, { key: 'opportunity', label: t('opportunityShort'), fullLabel: t('opportunity'), value: `${keyword.opportunity.score} · ${t(keyword.opportunity.label)}`, fullValue: `${keyword.opportunity.score}/100 · ${t(keyword.opportunity.label)}`, tone: keyword.opportunity.label, }, ]; metrics.push({ key: 'trend', label: t('trendShort'), fullLabel: t('trend'), value: Number.isFinite(keyword.trend7dPercent) ? `${keyword.trend7dPercent > 0 ? '+' : ''}${keyword.trend7dPercent}%` : '—', fullValue: Number.isFinite(keyword.trend7dPercent) ? `${keyword.trend7dPercent > 0 ? '+' : ''}${keyword.trend7dPercent}%` : '—', tone: !Number.isFinite(keyword.trend7dPercent) ? 'neutral' : (keyword.trend7dPercent > 0 ? 'up' : keyword.trend7dPercent < 0 ? 'down' : 'neutral'), }); for (const metric of metrics) { const item = document.createElement('span'); item.className = `ekma-inline__metric ekma-inline__metric--${metric.key}`; if (metric.tone) item.dataset.tone = metric.tone; item.title = `${metric.fullLabel}: ${metric.fullValue}`; item.setAttribute('aria-label', item.title); const strong = document.createElement('b'); strong.textContent = metric.label; const value = document.createElement('strong'); value.textContent = metric.value; item.append(strong, value); grid.appendChild(item); } const note = document.createElement('time'); note.className = 'ekma-inline__note'; note.dateTime = capturedAt; note.textContent = `${t('capturedAt')}: ${new Date(capturedAt).toLocaleString(language === 'tr' ? 'tr-TR' : 'en-US')}`; inline.dataset.searches30d = String(keyword.searches30d); inline.dataset.searchResults = String(keyword.searchResults); inline.dataset.opportunityScore = String(keyword.opportunity.score); inline.dataset.trend7dPercent = Number.isFinite(keyword.trend7dPercent) ? String(keyword.trend7dPercent) : ''; inline.setAttribute('role', 'group'); inline.setAttribute('aria-label', ['Makaytron', ...metrics.map((metric) => `${metric.fullLabel}: ${metric.fullValue}`)].join('. ')); inline.append(brand, grid, note); } function renderCapture(capture) { if (!capture) return false; ensureInlineStyles(); renderInlineKeyword(capture.main, capture.main._anchor, capture.capturedAt); capture.related.forEach((keyword) => renderInlineKeyword(keyword, keyword._anchor, capture.capturedAt)); return true; } async function saveCapture(capture) { const clean = publicCapture(capture); const captures = await Store.captures(); const key = `${normalizeKeyword(clean.main.keyword)}:${clean.capturedAt.slice(0, 10)}`; const next = captures.filter((entry) => entry.key !== key); next.push({ key, savedAt: Date.now(), capture: clean }); await Store.set(KEYS.captures, next.slice(-MAX_CAPTURE_ENTRIES)); return clean; } function cacheKey(request) { return `${request.contentHash}:${request.seedKeywords.map(normalizeKeyword).join('|')}`; } async function cachedResult(request) { const key = cacheKey(request); const cache = await Store.cache(); return cache.find((entry) => entry.key === key)?.result || null; } async function saveCachedResult(request, result) { const key = cacheKey(request); const cache = (await Store.cache()).filter((entry) => entry.key !== key); cache.push({ key, savedAt: Date.now(), result }); await Store.set(KEYS.cache, cache.slice(-MAX_CACHE_ENTRIES)); } function marketplaceUrl(keyword, _context = null, trigger = 'makaytron_keyword_analyzer') { const shopMatch = location.pathname.match(/^\/your\/shops\/([^/]+)\/marketplace-insights(?:\/|$)/); const shop = shopMatch ? shopMatch[1] : 'me'; const url = new URL(`/your/shops/${encodeURIComponent(shop)}/marketplace-insights/search`, location.origin); url.searchParams.set('search_trigger', trigger); url.searchParams.set('query', normalizeText(keyword)); return url.href; } function navigateTo(url) { Runtime.navigationPending = true; if (typeof window.__EKMA_TEST_NAVIGATE__ === 'function') { window.__EKMA_TEST_NAVIGATE__(url); return; } try { const state = isPlainObject(history.state) ? history.state : {}; history.pushState({ ...state, __makaytronEkmaTabId: Runtime.tabId }, '', url); location.reload(); } catch { location.assign(url); } } function waitForCapture(expectedKeyword, timeoutMs = DOM_RESULT_TIMEOUT_MS) { return new Promise((resolve, reject) => { const wanted = normalizeKeyword(expectedKeyword); const tryCapture = () => { const capture = captureCurrentPage(); if (!capture || normalizeKeyword(capture.main.keyword) !== wanted) return null; const anchor = capture.main._anchor; if (!anchor || normalizeKeyword(anchor.textContent) !== wanted) return null; return capture; }; const scope = document.querySelector('main') || document.body || document.documentElement; if (!scope) { const error = new Error('RESULT_SCOPE_MISSING'); error.telemetryCode = 'selector_keyword_result_scope'; reject(error); return; } let timer = 0; let poller = 0; let settled = false; let candidate = null; let candidateFingerprint = ''; let candidateSince = 0; let lastMutationAt = Date.now(); const fingerprint = (capture) => JSON.stringify({ keyword: normalizeKeyword(capture.main.keyword), searches30d: capture.main.searches30d, searchResults: capture.main.searchResults, trend7dPercent: Number.isFinite(capture.main.trend7dPercent) ? capture.main.trend7dPercent : null, related: capture.related.map((keyword) => [ normalizeKeyword(keyword.keyword), keyword.searches30d, keyword.searchResults, Number.isFinite(keyword.trend7dPercent) ? keyword.trend7dPercent : null, ]), }); const sample = () => { const capture = tryCapture(); if (!capture) { candidate = null; candidateFingerprint = ''; candidateSince = 0; return null; } const nextFingerprint = fingerprint(capture); if (nextFingerprint !== candidateFingerprint) { candidateFingerprint = nextFingerprint; candidateSince = Date.now(); } candidate = capture; return capture; }; const finish = (capture) => { if (settled) return; settled = true; clearTimeout(timer); clearInterval(poller); observer.disconnect(); resolve(capture); }; const maybeFinish = () => { const capture = sample(); const now = Date.now(); if (capture && candidateSince > 0 && now - candidateSince >= OBSERVER_DEBOUNCE_MS && now - lastMutationAt >= OBSERVER_DEBOUNCE_MS) finish(candidate); }; const observer = new MutationObserver(() => { lastMutationAt = Date.now(); sample(); }); observer.observe(scope, { childList: true, subtree: true, characterData: true }); sample(); poller = window.setInterval(maybeFinish, 50); timer = window.setTimeout(() => { maybeFinish(); if (settled) return; clearInterval(poller); observer.disconnect(); const resultScopePresent = !!document.querySelector('main'); const error = new Error(resultScopePresent ? 'RESULT_DOM_TIMEOUT' : 'RESULT_SCOPE_MISSING'); error.telemetryCode = resultScopePresent ? 'selector_keyword_results' : 'selector_keyword_result_scope'; reject(error); }, timeoutMs); }); } function resultFromItem(item) { const deduped = new Map(); for (const capture of item.seedResults) { for (const keyword of [capture.main, ...capture.related]) { const key = normalizeKeyword(keyword.keyword); const existing = deduped.get(key); if (!existing || keyword.searches30d > existing.searches30d) deduped.set(key, publicKeyword(keyword)); } } const seedOrder = new Map(item.payload.seedKeywords.map((keyword, index) => [normalizeKeyword(keyword), index])); const ranked = Array.from(deduped.values()).sort((left, right) => { const leftSeed = seedOrder.has(normalizeKeyword(left.keyword)) ? seedOrder.get(normalizeKeyword(left.keyword)) : 999; const rightSeed = seedOrder.has(normalizeKeyword(right.keyword)) ? seedOrder.get(normalizeKeyword(right.keyword)) : 999; return leftSeed - rightSeed || right.opportunity.score - left.opportunity.score || right.searches30d - left.searches30d; }); const seeds = ranked.filter((keyword) => seedOrder.has(normalizeKeyword(keyword.keyword))); const related = ranked.filter((keyword) => !seedOrder.has(normalizeKeyword(keyword.keyword))).slice(0, MAX_RELATED_KEYWORDS); const keywords = [...seeds, ...related]; return { schema: RESULT_SCHEMA, opaqueReference: item.payload.opaqueReference, contentHash: item.payload.contentHash, capturedAt: new Date().toISOString(), source: 'etsy-marketplace-insights-dom', keywords, }; } function resultEnvelopeForItem(item, result) { const envelope = makeEnvelope('RESEARCH_RESULT', item, result); if (!envelope) return null; const deadlineAt = queueTime(item.deadlineAt); if (deadlineAt !== null && deadlineAt < envelope.expiresAt) envelope.expiresAt = deadlineAt; return validateEnvelope(envelope).ok ? envelope : null; } async function failQueueItem(item, code, message, retryable = false) { await Lease.assertOwner(); const queue = await Store.queue(); const target = queue.find((entry) => entry.requestId === item.requestId && entry.nonce === item.nonce); if (!target || ['completed', 'cancelled', 'failed'].includes(target.status)) return false; const now = Date.now(); const safeMessage = String(message || code).slice(0, 300); target.status = code === 'CANCELLED' ? 'cancelled' : 'failed'; target.updatedAt = now; target.terminalAt = now; target.error = { code, message: safeMessage, retryable: Boolean(retryable) }; delete target.result; delete target.resultEnvelope; target.seedResults = []; await Lease.assertOwner(); await Store.saveQueue(queue); await Lease.assertOwner(); await Store.removeResultEnvelope(target.requestId, target.nonce); await Lease.assertOwner(); postMessage('ERROR', target, { code, message: safeMessage, retryable: Boolean(retryable) }); UI.status(tf('researchFailed', { message: safeMessage.slice(0, 160) })); return true; } async function processQueue() { if (Runtime.processing) return false; if (!await Lease.acquire()) return false; Runtime.processing = true; let activeItem = null; let activeTelemetryCode = ''; try { await Lease.assertOwner(); const rawQueue = await Store.get(KEYS.queue, []); const now = Date.now(); const newlyExpired = Array.isArray(rawQueue) ? rawQueue.filter((entry) => isPlainObject(entry) && ['queued', 'running', 'awaiting-receipt'].includes(entry.status) && queueTime(entry.deadlineAt) !== null && queueTime(entry.deadlineAt) <= now) : []; const queue = await Store.queue(); for (const expired of newlyExpired) { const awaitingReceipt = expired.status === 'awaiting-receipt'; postMessage('ERROR', expired, { code: awaitingReceipt ? 'RECEIPT_TIMEOUT' : 'REQUEST_TIMEOUT', message: awaitingReceipt ? 'Research result receipt expired.' : 'Research request exceeded its time limit.', retryable: !awaitingReceipt, }); } let item = queue.find((entry) => entry.status === 'running') || queue.find((entry) => entry.status === 'queued'); if (!item) return true; activeItem = item; item.status = 'running'; item.updatedAt = Date.now(); await Lease.assertOwner(); await Store.saveQueue(queue); const seedIndex = Number.isInteger(item.seedIndex) ? item.seedIndex : 0; const expectedKeyword = item.payload.seedKeywords[seedIndex]; if (!expectedKeyword) { let result; try { result = resultFromItem(item); } catch (error) { activeTelemetryCode = 'runtime_research_parse'; throw error; } const resultEnvelope = resultEnvelopeForItem(item, result); if (!resultEnvelope) { const error = new Error('RESULT_ENVELOPE_INVALID'); error.telemetryCode = 'runtime_research_parse'; throw error; } await Lease.assertOwner(); await saveCachedResult(item.payload, result); const latest = await Store.queue(); const target = latest.find((entry) => entry.requestId === item.requestId && entry.nonce === item.nonce); if (!target || target.status !== 'running') return true; target.status = 'awaiting-receipt'; target.result = result; target.resultEnvelope = resultEnvelope; target.updatedAt = Date.now(); await Lease.assertOwner(); await Store.saveQueue(latest); await Lease.assertOwner(); await Store.saveResultEnvelope(resultEnvelope); void trackTelemetry('keyword_research_completed'); await Lease.assertOwner(); sendEnvelope(resultEnvelope); UI.status(tf('researchSent', { count: result.keywords.length })); return true; } UI.status(tf('researchProgress', { current: seedIndex + 1, total: item.payload.seedKeywords.length, keyword: expectedKeyword, })); if (normalizeKeyword(queryKeyword()) !== normalizeKeyword(expectedKeyword)) { item.navigationAttempts = Number(item.navigationAttempts || 0) + 1; if (item.navigationAttempts > 2) { void trackTelemetryError('selector_keyword_navigation'); await failQueueItem(item, 'NAVIGATION_LOOP', 'Marketplace Insights navigation could not reach the requested keyword.', true); return true; } await Lease.assertOwner(); await Store.saveQueue(queue); await Lease.assertOwner(); navigateTo(marketplaceUrl(expectedKeyword, item, 'makaytron_listing_analyzer')); return true; } const capture = await waitForCapture(expectedKeyword); try { renderCapture(capture); } catch (error) { activeTelemetryCode = 'runtime_research_capture'; throw error; } await Lease.assertOwner(); const current = await Store.queue(); item = current.find((entry) => entry.requestId === item.requestId && entry.nonce === item.nonce); if (!item || item.status !== 'running') return true; item.seedResults = Array.isArray(item.seedResults) ? item.seedResults : []; try { item.seedResults[seedIndex] = publicCapture(capture); } catch (error) { activeTelemetryCode = 'runtime_research_parse'; throw error; } item.seedIndex = seedIndex + 1; item.navigationAttempts = 0; item.updatedAt = Date.now(); await Lease.assertOwner(); await Store.saveQueue(current); } catch (error) { if (error?.code === 'PROCESSOR_LEASE_LOST' || error?.message === 'PROCESSOR_LEASE_LOST') { const current = await Lease.current(); Lease.scheduleTakeover(current?.expiresAt); } else if (await Lease.owns()) { const telemetryCode = TELEMETRY_ALLOWED_ERROR_CODES.has(activeTelemetryCode) ? activeTelemetryCode : TELEMETRY_ALLOWED_ERROR_CODES.has(error?.telemetryCode) ? error.telemetryCode : 'runtime_research_queue'; void trackTelemetryError(telemetryCode); if (activeItem) { await failQueueItem( activeItem, 'RESEARCH_FAILED', `Marketplace Insights result could not be captured (${String(error?.message || 'unknown').slice(0, 120)}).`, true, ); } } } finally { Runtime.processing = false; } if (Runtime.navigationPending || !await Lease.owns()) return true; const remaining = await Store.queue(); if (remaining.some((entry) => ['queued', 'running'].includes(entry.status))) return processQueue(); return true; } async function handleProbe(envelope) { if (!hasExactKeys(envelope.payload, ['wants']) || !Array.isArray(envelope.payload.wants) || envelope.payload.wants.some((item) => typeof item !== 'string')) return; if (!await Lease.acquire()) return false; await Lease.assertOwner(); postMessage('CAPABILITIES', envelope, CAPABILITIES); const queue = await Store.queue(); const busy = queue.some((item) => ['queued', 'running'].includes(item.status)); postMessage('RESEARCH_READY', envelope, { state: busy ? 'busy' : 'ready', ...(busy ? { activeRequestId: queue.find((item) => ['queued', 'running'].includes(item.status))?.requestId } : {}), }); let changed = false; for (const item of queue.filter((entry) => entry.status === 'awaiting-receipt' && isPlainObject(entry.result))) { const resultEnvelope = resultEnvelopeForItem(item, item.result); if (!resultEnvelope) continue; item.resultEnvelope = resultEnvelope; item.updatedAt = Date.now(); changed = true; await Lease.assertOwner(); await Store.saveResultEnvelope(resultEnvelope); await Lease.assertOwner(); sendEnvelope(resultEnvelope); } if (changed) { await Lease.assertOwner(); await Store.saveQueue(queue); } return true; } async function handleResearchRequest(envelope) { if (!await Lease.acquire()) return false; await Lease.assertOwner(); const validated = validateResearchRequest(envelope.payload); if (!validated.ok) { postMessage('ERROR', envelope, { code: validated.code, message: 'Research request validation failed.', retryable: false }); return true; } const queue = await Store.queue(); const sameId = queue.find((item) => item.requestId === envelope.requestId); if (sameId) { if (sameId.nonce !== envelope.nonce) { postMessage('ERROR', envelope, { code: 'REQUEST_ID_CONFLICT', message: 'Request ID is already bound to another nonce.', retryable: false }); return true; } postMessage('RESEARCH_ACK', envelope, { accepted: true, queuePosition: Math.max(0, queue.filter((item) => ['queued', 'running'].includes(item.status)).indexOf(sameId)), }); if (sameId.status === 'awaiting-receipt' && isPlainObject(sameId.result)) { const resultEnvelope = resultEnvelopeForItem(sameId, sameId.result); if (resultEnvelope) { sameId.resultEnvelope = resultEnvelope; sameId.updatedAt = Date.now(); await Lease.assertOwner(); await Store.saveQueue(queue); await Lease.assertOwner(); await Store.saveResultEnvelope(resultEnvelope); await Lease.assertOwner(); sendEnvelope(resultEnvelope); } } return true; } if (queue.filter((item) => ['queued', 'running', 'awaiting-receipt'].includes(item.status)).length >= MAX_QUEUE_ENTRIES) { postMessage('ERROR', envelope, { code: 'QUEUE_FULL', message: 'Research queue reached its bounded capacity.', retryable: true }); return true; } const cached = await cachedResult(validated.value); const now = Date.now(); const item = { requestId: envelope.requestId, nonce: envelope.nonce, status: cached ? 'awaiting-receipt' : 'queued', payload: validated.value, createdAt: now, updatedAt: now, deadlineAt: Math.min(envelope.expiresAt, now + REQUEST_TIMEOUT_MS), seedIndex: 0, seedResults: [], navigationAttempts: 0, ...(cached ? { result: cached } : {}), }; const cachedEnvelope = cached ? resultEnvelopeForItem(item, cached) : null; if (cached && !cachedEnvelope) { postMessage('ERROR', envelope, { code: 'RESULT_ENVELOPE_INVALID', message: 'Cached research result could not be safely enveloped.', retryable: true }); return true; } if (cachedEnvelope) item.resultEnvelope = cachedEnvelope; queue.push(item); await Lease.assertOwner(); await Store.saveQueue(queue); if (cachedEnvelope) { await Lease.assertOwner(); await Store.saveResultEnvelope(cachedEnvelope); } const position = queue.filter((entry) => ['queued', 'running'].includes(entry.status)).findIndex((entry) => entry.requestId === item.requestId); postMessage('RESEARCH_ACK', item, { accepted: true, queuePosition: Math.max(0, position) }); if (cachedEnvelope) { await Lease.assertOwner(); sendEnvelope(cachedEnvelope); } else requestQueueProcessing(); return true; } async function handleResearchReceived(envelope) { if (!hasExactKeys(envelope.payload, ['accepted']) || envelope.payload.accepted !== true) return; if (!await Lease.acquire()) return false; const queue = await Store.queue(); const item = queue.find((entry) => entry.requestId === envelope.requestId && entry.nonce === envelope.nonce); if (!item || item.status !== 'awaiting-receipt') return true; const now = Date.now(); item.status = 'completed'; delete item.result; delete item.resultEnvelope; item.seedResults = []; item.updatedAt = now; item.terminalAt = now; await Lease.assertOwner(); await Store.saveQueue(queue); void trackTelemetry('keyword_research_completed'); requestQueueProcessing(); return true; } async function handleRemoteError(envelope) { if (!hasExactKeys(envelope.payload, ['code', 'message', 'retryable'])) return; if (!['CANCELLED', 'RESULT_REJECTED'].includes(envelope.payload.code) || typeof envelope.payload.message !== 'string' || typeof envelope.payload.retryable !== 'boolean') return; if (!await Lease.acquire()) return false; const queue = await Store.queue(); const item = queue.find((entry) => entry.requestId === envelope.requestId && entry.nonce === envelope.nonce); const isRejected = envelope.payload.code === 'RESULT_REJECTED'; if (!item || (isRejected ? item.status !== 'awaiting-receipt' : !['queued', 'running', 'awaiting-receipt'].includes(item.status))) return true; const now = Date.now(); item.status = isRejected ? 'failed' : 'cancelled'; item.updatedAt = now; item.terminalAt = now; item.error = { code: envelope.payload.code, message: envelope.payload.message.slice(0, 300), retryable: false, }; delete item.result; delete item.resultEnvelope; item.seedResults = []; await Lease.assertOwner(); await Store.saveQueue(queue); await Lease.assertOwner(); await Store.removeResultEnvelope(item.requestId, item.nonce); requestQueueProcessing(); return true; } async function onChannelMessage(event) { const checked = validateEnvelope(event.data); if (!checked.ok) return; const envelope = checked.value; if (envelope.sender !== 'listing-analyzer') return; if (envelope.type === 'PROBE') await handleProbe(envelope); else if (envelope.type === 'RESEARCH_REQUEST') await handleResearchRequest(envelope); else if (envelope.type === 'RESEARCH_RECEIVED') await handleResearchReceived(envelope); else if (envelope.type === 'ERROR') await handleRemoteError(envelope); } function startChannel() { if (typeof BroadcastChannel !== 'function') return false; try { Runtime.channel = new BroadcastChannel(CHANNEL_NAME); Runtime.channel.addEventListener('message', (event) => { void onChannelMessage(event); }); return true; } catch { Runtime.channel = null; return false; } } async function cancelActiveResearch() { if (!await Lease.acquire()) return false; const queue = await Store.queue(); const item = queue.find((entry) => ['queued', 'running', 'awaiting-receipt'].includes(entry.status)); if (!item) return false; const now = Date.now(); item.status = 'cancelled'; item.updatedAt = now; item.terminalAt = now; delete item.result; delete item.resultEnvelope; item.seedResults = []; await Lease.assertOwner(); await Store.saveQueue(queue); await Lease.assertOwner(); await Store.removeResultEnvelope(item.requestId, item.nonce); await Lease.assertOwner(); postMessage('ERROR', item, { code: 'CANCELLED', message: 'Research was cancelled by the user.', retryable: false }); requestQueueProcessing(); return true; } async function importResearchEnvelopeText(text) { const source = typeof text === 'string' ? text.trim() : ''; if (!source) throw new Error('EMPTY_RESEARCH_ENVELOPE'); if (new TextEncoder().encode(source).byteLength > MAX_MESSAGE_BYTES) throw new Error('MESSAGE_TOO_LARGE'); let parsed; try { parsed = JSON.parse(source); } catch { throw new Error('INVALID_JSON'); } const checked = validateEnvelope(parsed); if (!checked.ok) throw new Error(checked.code); const envelope = checked.value; if (envelope.type !== 'RESEARCH_REQUEST') throw new Error('IMPORT_REQUIRES_RESEARCH_REQUEST'); if (envelope.sender !== 'listing-analyzer') throw new Error('IMPORT_REQUIRES_LISTING_ANALYZER_SENDER'); const request = validateResearchRequest(envelope.payload); if (!request.ok) throw new Error(request.code); const handled = await handleResearchRequest(envelope); if (handled) return { accepted: true, delegated: false, requestId: envelope.requestId }; if (!Runtime.channel) throw new Error('PROCESSOR_TAB_BUSY'); Runtime.channel.postMessage(envelope); return { accepted: true, delegated: true, requestId: envelope.requestId }; } async function exportResearchResultEnvelope(requestId = '') { const entries = await Store.resultEnvelopes(); const match = [...entries].reverse().find((entry) => !requestId || entry.envelope.requestId === requestId); return match ? JSON.parse(JSON.stringify(match.envelope)) : null; } function writeResearchJsonField(value) { if (!Runtime.ui?.researchJson) return false; Runtime.ui.researchJson.value = value; Runtime.ui.researchJson.focus(); Runtime.ui.researchJson.select(); return true; } async function copyResearchResultEnvelope(requestId = '') { const envelope = await exportResearchResultEnvelope(requestId); if (!envelope) return { ok: false, fallback: false }; const body = JSON.stringify(envelope, null, 2); try { if (!navigator.clipboard?.writeText) throw new Error('CLIPBOARD_UNAVAILABLE'); await navigator.clipboard.writeText(body); return { ok: true, fallback: false }; } catch { writeResearchJsonField(body); try { document.execCommand?.('copy'); } catch { /* The selected field remains accessible. */ } return { ok: true, fallback: true }; } } async function downloadResearchResultEnvelope(requestId = '') { const envelope = await exportResearchResultEnvelope(requestId); if (!envelope) return false; const safeRequestId = envelope.requestId.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80); return downloadJson(envelope, `makaytron-etsy-research-result-${safeRequestId}.json`); } async function clearLocalData() { if (!window.confirm(t('clearDataConfirm'))) return false; const queue = await Store.queue(); for (const item of queue.filter((entry) => ['queued', 'running', 'awaiting-receipt'].includes(entry.status))) { postMessage('ERROR', item, { code: 'CANCELLED', message: 'Local research data was cleared by the user.', retryable: false }); } try { Lease.clearTimers(); Runtime.leaseToken = ''; await Store.delete(KEYS.lease); await Promise.all([ Store.delete(KEYS.captures), Store.delete(KEYS.cache), Store.delete(KEYS.queue), Store.delete(KEYS.results), ]); return true; } catch { return false; } } function downloadJson(value, filename = `makaytron-etsy-keyword-research-${new Date().toISOString().slice(0, 10)}.json`) { const body = JSON.stringify(value, null, 2); if (new TextEncoder().encode(body).byteLength > MAX_EXPORT_BYTES) return false; const blob = new Blob([body], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); return true; } const UI = { mount() { if (document.getElementById('makaytron-etsy-keyword-market-analyzer')) return; const host = document.createElement('div'); host.id = 'makaytron-etsy-keyword-market-analyzer'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` `; document.body.appendChild(host); Runtime.ui = { host, shadow, launcher: shadow.querySelector('.launcher'), panel: shadow.querySelector('.panel'), input: shadow.querySelector('input'), researchJson: shadow.querySelector('[data-research-json]'), status: shadow.querySelector('.status'), count: shadow.querySelector('.count'), version: shadow.querySelector('.version'), updateBanner: shadow.querySelector('.update-banner'), checkUpdate: shadow.querySelector('[data-action="check-update"]'), installUpdate: shadow.querySelector('[data-action="install-update"]'), source: shadow.querySelector('.source'), }; Runtime.ui.input.value = queryKeyword(); Runtime.ui.launcher.addEventListener('click', () => this.toggle()); shadow.querySelector('.close').addEventListener('click', () => this.toggle(false)); shadow.querySelectorAll('[data-language]').forEach((button) => button.addEventListener('click', () => { void this.setLanguage(button.dataset.language); })); shadow.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !Runtime.ui.panel.hidden) { event.preventDefault(); this.toggle(false); } }); shadow.querySelector('[data-action="search"]').addEventListener('click', () => { const keyword = normalizeText(Runtime.ui.input.value); if (!keyword) return this.status(t('invalidKeyword')); navigateTo(marketplaceUrl(keyword)); }); shadow.querySelector('[data-action="capture"]').addEventListener('click', () => { void this.captureAndSave(); }); shadow.querySelector('[data-action="export"]').addEventListener('click', () => { void this.export(); }); shadow.querySelector('[data-action="import-request"]').addEventListener('click', async () => { try { const outcome = await importResearchEnvelopeText(Runtime.ui.researchJson.value); this.status(tf(outcome.delegated ? 'importDelegated' : 'importAccepted', { requestId: outcome.requestId })); } catch (error) { this.status(tf('importRejected', { message: String(error?.message || 'INVALID_ENVELOPE').slice(0, 120) })); } await this.refreshCount(); }); shadow.querySelector('[data-action="export-result"]').addEventListener('click', async () => { const outcome = await copyResearchResultEnvelope(); this.status(!outcome.ok ? t('resultMissing') : t(outcome.fallback ? 'clipboardError' : 'resultCopied')); }); shadow.querySelector('[data-action="download-result"]').addEventListener('click', async () => { this.status(await downloadResearchResultEnvelope() ? t('resultDownloaded') : t('resultMissing')); }); shadow.querySelector('[data-action="cancel"]').addEventListener('click', async () => { this.status(await cancelActiveResearch() ? t('queueCancelled') : t('queueEmpty')); await this.refreshCount(); }); shadow.querySelector('[data-action="clear-data"]').addEventListener('click', async () => { this.status(await clearLocalData() ? t('clearDataDone') : t('clearDataError')); await this.refreshCount(); }); Runtime.ui.checkUpdate.addEventListener('click', () => { void checkForUpdates({ manual: true, force: true }); }); bindInstallUpdate(Runtime.ui.installUpdate); void this.refreshCount(); void updateState().then(renderUpdateState); }, toggle(force) { if (!Runtime.ui) return; const open = force ?? Runtime.ui.panel.hidden; Runtime.ui.panel.hidden = !open; if (open) { telemetryPanelOpened(); Runtime.ui.input.focus(); } else Runtime.ui.launcher.focus(); }, async setLanguage(next) { if (!['tr', 'en'].includes(next) || next === language) return; language = next; await Store.set(KEYS.settings, { language }); Runtime.ui?.host.remove(); Runtime.ui = null; document.querySelectorAll('[data-ekma-inline]').forEach((element) => element.remove()); this.mount(); const capture = captureCurrentPage(); if (capture) renderCapture(capture); this.toggle(true); }, status(message) { if (Runtime.ui) Runtime.ui.status.textContent = message; }, async captureAndSave(timeoutMs = DOM_RESULT_TIMEOUT_MS) { const expectedKeyword = queryKeyword(); if (!expectedKeyword) { this.status(t('noResult')); return false; } try { const capture = await waitForCapture(expectedKeyword, timeoutMs); renderCapture(capture); await saveCapture(capture); void trackTelemetry('keyword_research_completed'); this.status(t('captured')); await this.refreshCount(); return true; } catch (error) { const telemetryCode = TELEMETRY_ALLOWED_ERROR_CODES.has(error?.telemetryCode) ? error.telemetryCode : 'runtime_research_capture'; void trackTelemetryError(telemetryCode); this.status(t('noResult')); return false; } }, async export() { const captures = await Store.captures(); const downloaded = downloadJson({ schema: 'makaytron-keyword-market-analyzer-export/v1', exportedAt: new Date().toISOString(), app: { name: APP.name, version: APP_VERSION }, captures: captures.map((entry) => entry.capture).filter(isPlainObject), }); if (!downloaded) this.status(t('exportTooLarge')); }, async refreshCount() { if (!Runtime.ui) return; const captures = await Store.captures(); Runtime.ui.count.textContent = `${t('saved')}: ${captures.length}`; }, }; function installDomObserver() { Runtime.observer?.disconnect(); const scope = document.querySelector('main') || document.body; if (!scope) return; Runtime.observer = new MutationObserver(() => { window.clearTimeout(Runtime.observerTimer); Runtime.observerTimer = window.setTimeout(() => { const capture = captureCurrentPage(); if (capture) renderCapture(capture); }, OBSERVER_DEBOUNCE_MS); }); Runtime.observer.observe(scope, { childList: true, subtree: true, characterData: true }); } function registerMenuCommands() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand(t('launcher'), () => UI.toggle(true)); GM_registerMenuCommand(t('capture'), () => { void UI.captureAndSave(); }); GM_registerMenuCommand(t('export'), () => { void UI.export(); }); GM_registerMenuCommand(t('importRequest'), () => { UI.toggle(true); Runtime.ui?.researchJson.focus(); }); GM_registerMenuCommand(t('copyResult'), async () => { const outcome = await copyResearchResultEnvelope(); UI.status(!outcome.ok ? t('resultMissing') : t(outcome.fallback ? 'clipboardError' : 'resultCopied')); }); GM_registerMenuCommand(t('clearData'), async () => { UI.status(await clearLocalData() ? t('clearDataDone') : t('clearDataError')); await UI.refreshCount(); }); GM_registerMenuCommand(t('checkUpdate'), () => { void checkForUpdates({ manual: true, force: true }); }); } async function boot() { if (!SUPPORTED_ROUTE.test(location.pathname)) return; const settings = await Store.get(KEYS.settings, null); if (isPlainObject(settings) && ['tr', 'en'].includes(settings.language)) language = settings.language; await ensureUniqueTabIdentity(); UI.mount(); const capture = captureCurrentPage(); if (capture) renderCapture(capture); installDomObserver(); registerMenuCommands(); registerTelemetryMenuCommand(); startChannel(); await Store.cache(); await processQueue(); if (!window.__EKMA_DISABLE_AUTO_UPDATE__) { window.setTimeout(() => { void checkForUpdates({ manual: false, force: false }); }, 1500); } } if (window.__EKMA_SKIP_INIT__) { window.__EKMA_TEST__ = { APP, APP_VERSION, CHANNEL_NAME, ENVELOPE_SCHEMA, REQUEST_SCHEMA, RESULT_SCHEMA, CAPABILITIES, GITHUB_RAW_SCRIPT_URL, GITHUB_API_REF_URL, UPDATE_CHECK_INTERVAL_MS, UPDATE_CHECK_STALE_MS, KEYS, Contract: { validateEnvelope, validateResearchRequest, makeEnvelope }, Store, Lease, ensureUniqueTabIdentity, UI, Runtime, parseCompactNumber, parsePercent, opportunityFor, captureCurrentPage, publicCapture, renderCapture, waitForCapture, saveCapture, marketplaceUrl, handleResearchRequest, handleResearchReceived, handleRemoteError, onChannelMessage, processQueue, pruneQueue: () => Store.queue(), importResearchEnvelopeText, exportResearchResultEnvelope, copyResearchResultEnvelope, downloadResearchResultEnvelope, cancelActiveResearch, clearLocalData, installationSource, compareSemver, pinnedUpdateUrl, exactHttpsTarget, metadataValues, normalizeUpdateState, requestExactText, requestCanonicalScript, checkForUpdates, openUpdatePage, bindInstallUpdate, boot, }; } else { void boot(); } })();