Adds a bottom-right Auto Sync button. Clicks "Refresh account data" then syncs each character sequentially. Works in background tabs.
// ==UserScript==
// @name Completionism: Auto-sync button
// @namespace tm-completionism-sync
// @version 2.0.0
// @description Adds a bottom-right Auto Sync button. Clicks "Refresh account data" then syncs each character sequentially. Works in background tabs.
// @match https://completionism.com/*
// @match https://www.completionism.com/*
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const CFG = {
betweenClicksMs: 500,
stepTimeoutMs: 120000,
bootstrapTimeoutMs: 30000,
debug: true,
};
const log = (...args) => CFG.debug && console.log('[TM AutoSync]', ...args);
// Используем worker-based таймер чтобы не throttle'ился в фоновой вкладке
const workerBlob = new Blob([`
self.onmessage = function(e) {
const id = e.data.id;
setTimeout(() => self.postMessage({ id }), e.data.ms);
};
`], { type: 'application/javascript' });
const timerWorker = new Worker(URL.createObjectURL(workerBlob));
let sleepId = 0;
const sleepCallbacks = new Map();
timerWorker.onmessage = (e) => {
const cb = sleepCallbacks.get(e.data.id);
if (cb) {
sleepCallbacks.delete(e.data.id);
cb();
}
};
function sleep(ms) {
return new Promise((resolve) => {
const id = ++sleepId;
sleepCallbacks.set(id, resolve);
timerWorker.postMessage({ id, ms });
});
}
function isLoading(btn) {
if (!btn) return false;
if (btn.getAttribute('data-loading') === 'true') return true;
if (btn.disabled) return true;
const svg = btn.querySelector('svg');
if (svg && svg.getAttribute('data-animate') === 'true') return true;
return false;
}
async function waitFor(predicate, { timeoutMs = 30000, intervalMs = 200 } = {}) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
if (await predicate()) return true;
} catch (_) {}
await sleep(intervalMs);
}
return false;
}
function click(btn) {
btn.scrollIntoView({ block: 'center', inline: 'center' });
btn.click();
}
function findRefreshAccountButton() {
return Array.from(document.querySelectorAll('button'))
.find(b => (b.textContent || '').trim().startsWith('Refresh account data')) || null;
}
// Находим все строки персонажей и в каждой — кнопку синхронизации (первую кнопку с data-loading, которая НЕ содержит крестик "X")
function findCharacterRows() {
// Каждый персонаж в div'е с ссылкой на worldofwarcraft.com
const links = document.querySelectorAll('a[href*="worldofwarcraft.com"]');
const rows = [];
links.forEach(link => {
const row = link.closest('div[class]'); // ближайший контейнер строки
if (!row) return;
// Ищем все кнопки с data-loading в этой строке
const buttons = Array.from(row.querySelectorAll('button[data-loading]'));
// Кнопка синхронизации — первая, кнопка удаления — последняя (с иконкой X)
// Более надёжно: кнопка X содержит path с "m289.94" или текст крестика
const syncBtn = buttons.find(btn => {
const paths = Array.from(btn.querySelectorAll('svg path'));
// Кнопка удаления имеет path начинающийся с "m289.94 256 95-95"
const isRemove = paths.some(p => (p.getAttribute('d') || '').startsWith('m289.94'));
return !isRemove;
});
if (syncBtn) {
// Извлекаем имя
const nameSpan = row.querySelector('span');
const name = nameSpan ? nameSpan.textContent.trim() : 'Unknown';
const realmSpans = Array.from(row.querySelectorAll('span'));
const realm = realmSpans.length > 1 ? realmSpans[1].textContent.trim() : '';
rows.push({ btn: syncBtn, name, realm });
}
});
return rows;
}
async function waitStepDone(btn, label) {
// Сначала дождёмся что loading НАЧАЛСЯ (или уже true)
await sleep(150);
const ok = await waitFor(() => !isLoading(btn), { timeoutMs: CFG.stepTimeoutMs, intervalMs: 300 });
if (!ok) throw new Error(`Timeout waiting for "${label}" to finish.`);
}
let running = false;
let abortRequested = false;
async function runAutoSync() {
if (running) {
abortRequested = true;
log('Abort requested.');
toast('Stopping after current step…');
return;
}
running = true;
abortRequested = false;
setButtonState(true);
try {
// Ждём появления UI
const ready = await waitFor(() => {
return !!findRefreshAccountButton() || findCharacterRows().length > 0;
}, { timeoutMs: CFG.bootstrapTimeoutMs });
if (!ready) throw new Error('Не вижу модалку Characters. Открой её и нажми Auto Sync.');
// Шаг 1: Refresh account data
const refreshBtn = findRefreshAccountButton();
if (refreshBtn) {
log('Click: Refresh account data');
updateStatus('Refreshing account data…');
click(refreshBtn);
await waitStepDone(refreshBtn, 'Refresh account data');
await sleep(CFG.betweenClicksMs);
}
if (abortRequested) throw new Error('Aborted by user.');
// Шаг 2: Синхронизация каждого персонажа
let rows = findCharacterRows();
if (!rows.length) throw new Error('Не нашёл кнопки синхронизации персонажей.');
log(`Found ${rows.length} characters.`);
for (let i = 0; i < rows.length; i++) {
if (abortRequested) throw new Error('Aborted by user.');
// Пере-ищем каждый раз — DOM может обновиться
rows = findCharacterRows();
const row = rows[i];
if (!row) continue;
const label = `${row.name}${row.realm ? ' (' + row.realm + ')' : ''}`;
log(`[${i + 1}/${rows.length}] Syncing: ${label}`);
updateStatus(`Syncing ${i + 1}/${rows.length}: ${row.name}`);
click(row.btn);
await waitStepDone(row.btn, label);
await sleep(CFG.betweenClicksMs);
}
log('Done ✅');
toast('Auto Sync: DONE ✅');
} catch (err) {
console.error('[TM AutoSync] Error:', err);
toast(`Auto Sync: ${err.message || err}`);
} finally {
running = false;
abortRequested = false;
setButtonState(false);
}
}
// ---- UI ----
const BTN_ID = 'tm-completionism-autosync-btn';
const TOAST_ID = 'tm-completionism-autosync-toast';
function injectStyles() {
GM_addStyle(`
#${BTN_ID}{
position:fixed; right:18px; bottom:18px; z-index:999999;
padding:10px 14px; border-radius:12px;
border:1px solid rgba(255,255,255,.18);
background:rgba(20,20,28,.88); color:#fff;
font:600 14px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
cursor:pointer;
box-shadow:0 10px 30px rgba(0,0,0,.35);
backdrop-filter:blur(10px);
transition: background .15s;
}
#${BTN_ID}:hover{ background:rgba(40,40,55,.95); }
#${BTN_ID}[data-running="true"]{ border-color:rgba(255,120,80,.4); cursor:pointer; }
#${BTN_ID} .sub{ display:block; margin-top:4px;
font:500 11px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
opacity:.75;
}
#${TOAST_ID}{
position:fixed; right:18px; bottom:72px; z-index:999999;
max-width:360px; white-space:pre-line; padding:10px 12px;
border-radius:12px; border:1px solid rgba(255,255,255,.14);
background:rgba(0,0,0,.75); color:#fff;
font:500 12px/1.3 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
box-shadow:0 10px 30px rgba(0,0,0,.35); backdrop-filter:blur(10px);
opacity:0; transform:translateY(6px);
transition:opacity .18s ease,transform .18s ease;
pointer-events:none;
}
#${TOAST_ID}[data-show="true"]{ opacity:1; transform:translateY(0); }
`);
}
function ensureButton() {
if (document.getElementById(BTN_ID)) return;
const btn = document.createElement('button');
btn.id = BTN_ID;
btn.type = 'button';
btn.innerHTML = `Auto Sync<span class="sub">Open Characters modal first</span>`;
btn.addEventListener('click', runAutoSync);
document.body.appendChild(btn);
const toastEl = document.createElement('div');
toastEl.id = TOAST_ID;
document.body.appendChild(toastEl);
log('UI injected.');
}
function setButtonState(isRunning) {
const btn = document.getElementById(BTN_ID);
if (!btn) return;
btn.setAttribute('data-running', String(isRunning));
btn.innerHTML = isRunning
? `⏹ Stop<span class="sub">Click to abort</span>`
: `Auto Sync<span class="sub">Open Characters modal first</span>`;
}
function updateStatus(msg) {
const btn = document.getElementById(BTN_ID);
if (!btn || !running) return;
btn.innerHTML = `⏹ Stop<span class="sub">${msg}</span>`;
}
let toastTimer = null;
function toast(msg) {
const el = document.getElementById(TOAST_ID);
if (!el) return;
el.textContent = msg;
el.setAttribute('data-show', 'true');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.setAttribute('data-show', 'false'), 5000);
}
injectStyles();
ensureButton();
const mo = new MutationObserver(() => ensureButton());
mo.observe(document.documentElement, { childList: true, subtree: true });
})();