// ==UserScript== // @name Rulate Helper: доход и отложка // @namespace Rasteo123/rulate-helper // @version 1.1.1 // @description Доход за сегодня рядом с балансом и колокольчик, когда отложка книги закончилась // @homepageURL https://github.com/Rasteo123/rulate-helper // @supportURL https://github.com/Rasteo123/rulate-helper/issues // @updateURL https://raw.githubusercontent.com/Rasteo123/rulate-helper/main/rulate-helper.user.js // @downloadURL https://raw.githubusercontent.com/Rasteo123/rulate-helper/main/rulate-helper.user.js // @match https://tl.rulate.ru/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; const api = {}; // ---- core: time & text ---- const MINUTE = 60 * 1000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const MSK_OFFSET = 3 * HOUR; const MONTHS = { янв: 0, фев: 1, мар: 2, апр: 3, май: 4, мая: 4, июн: 5, июл: 6, авг: 7, сен: 8, окт: 9, ноя: 10, дек: 11 }; const MONTH_LABELS = ['Янв', 'Фев', 'Мар', 'Апр', 'Мая', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек']; const SCHEDULE_RE = /(\d{1,2})\s+([А-Яа-яЁё]{3,})\.?\s+в\s+(\d{1,2})(?:\s*ч(?:\s*(\d{1,2})\s*м)?|:(\d{2}))/; const pad2 = (n) => String(n).padStart(2, '0'); const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); function mskToUtc(year, month, day, hour, minute) { return Date.UTC(year, month, day, hour, minute) - MSK_OFFSET; } function mskParts(ms) { const d = new Date(ms + MSK_OFFSET); return { year: d.getUTCFullYear(), month: d.getUTCMonth(), day: d.getUTCDate(), hour: d.getUTCHours(), minute: d.getUTCMinutes() }; } function mskDateKey(ms) { const p = mskParts(ms); return `${p.year}-${pad2(p.month + 1)}-${pad2(p.day)}`; } function mskTime(ms) { const p = mskParts(ms); return `${pad2(p.hour)}:${pad2(p.minute)}`; } // «12 Окт»; с nowMs год дописывается, если он не текущий: «12 Окт 2027». function mskDay(ms, nowMs) { const p = mskParts(ms); const label = `${p.day} ${MONTH_LABELS[p.month]}`; return nowMs != null && mskParts(nowMs).year !== p.year ? `${label} ${p.year}` : label; } function mskDayMonth(ms) { const p = mskParts(ms); return `${pad2(p.day)}.${pad2(p.month + 1)}`; } function parseScheduleText(text) { const m = SCHEDULE_RE.exec(String(text == null ? '' : text).replace(/\u00a0/g, ' ')); if (!m) return null; const month = MONTHS[m[2].slice(0, 3).toLowerCase()]; const day = Number(m[1]); const hour = Number(m[3]); const minute = Number(m[4] || m[5] || 0); if (month === undefined || day < 1 || day > 31 || hour > 23 || minute > 59) return null; return { day, month, hour, minute }; } // Года на Rulate нет. Первая дата — ближайшая глава отложки: обычно чуть впереди, // у застрявшей отложки — в прошлом. Ставим её в год, при котором она попадает // в окно «245 дней назад — 120 дней вперёд». Дальше по оглавлению год растёт, только // когда месяц откатился сильно (декабрь → январь), а не на шаг из-за главы с ручным временем. function resolveScheduleDates(parts, nowMs) { if (!parts.length) return []; const nowYear = mskParts(nowMs).year; const first = parts[0]; let year = nowYear; for (const y of [nowYear + 1, nowYear, nowYear - 1]) { const t = mskToUtc(y, first.month, first.day, first.hour, first.minute); if (t > nowMs - 245 * DAY && t <= nowMs + 120 * DAY) { year = y; break; } } let prevMonth = first.month; return parts.map((p) => { if (prevMonth - p.month > 6) year += 1; prevMonth = p.month; return mskToUtc(year, p.month, p.day, p.hour, p.minute); }); } function formatMoney(value) { const [int, frac] = Math.abs(value).toFixed(2).split('.'); return (value < 0 ? '-' : '') + int.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00a0') + '.' + frac; } function pluralRu(n, one, few, many) { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return many; if (b === 1) return one; if (b >= 2 && b <= 4) return few; return many; } Object.assign(api, { MINUTE, HOUR, DAY, clean, mskToUtc, mskParts, mskDateKey, mskTime, mskDay, mskDayMonth, parseScheduleText, resolveScheduleDates, formatMoney, pluralRu, }); // ---- core: page parsers ---- const USER_HREF = /^\/users\/(\d+)$/; function parseUid(doc) { for (const a of doc.querySelectorAll('.main-header-avatar_dropdown-menu a[href]')) { const m = USER_HREF.exec(a.getAttribute('href')); if (m) return Number(m[1]); } return null; } // Баланс RC из шапки: «6 993.75» → 6993.75. \s в JS ловит и неразрывный пробел. function parseBalance(doc) { const el = doc.querySelector('.main-header-balance span') || doc.querySelector('.main-header-avatar_dropdown-menu_payments-balance span'); if (!el) return null; const value = parseFloat(el.textContent.replace(/\s/g, '').replace(',', '.')); return Number.isFinite(value) ? value : null; } function parseBookPage(doc, nowMs) { const h1 = doc.querySelector('h1'); const stateDt = Array.from(doc.querySelectorAll('dl.info dt')).find((dt) => /Состояние перевода/.test(dt.textContent)); if (!h1 || !stateDt) { return { ok: false, reason: 'пришла не та страница: нет названия или «Состояния перевода»' }; } const stateDd = stateDt.nextElementSibling; const state = stateDd && stateDd.tagName === 'DD' ? clean(stateDd.textContent) : ''; const info = stateDt.parentElement; const ownerDd = Array.from(info.querySelectorAll('dd')).find((dd) => /владелец/.test(dd.textContent)); let ownerUid = null; for (const a of (ownerDd || info).querySelectorAll('a[href]')) { const m = USER_HREF.exec(a.getAttribute('href')); if (m) { ownerUid = Number(m[1]); break; } } const rows = Array.from(doc.querySelectorAll('tr.chapter_row')); const parts = []; for (const row of rows) { const cell = row.querySelector('td.t'); const icon = cell && cell.querySelector('i.icon-time'); if (!icon) { // Дата после названия, но без значка: Rulate поменял вёрстку, и конец // отложки уже не отличить от смены значка. Лучше сбой, чем ложная тревога. const tail = cell ? Array.from(cell.childNodes).filter((n) => n.nodeName !== 'A').map((n) => n.textContent).join('') : ''; if (SCHEDULE_RE.test(tail)) return { ok: false, reason: `вижу дату «${clean(tail)}» без значка отложки — вёрстка Rulate изменилась` }; continue; } let text = ''; for (let n = icon.nextSibling; n; n = n.nextSibling) text += n.textContent; const p = parseScheduleText(text); if (!p) return { ok: false, reason: `не понял дату «${clean(text)}»` }; parts.push(p); } const dates = resolveScheduleDates(parts, nowMs); return { ok: true, title: clean(h1.textContent), state, ownerUid, chapters: rows.length, stock: parts.length, lastAt: dates.length ? Math.max(...dates) : null, }; } function parseStatPage(doc) { const attr = (selector) => { const el = doc.querySelector(selector); return el ? el.getAttribute('value') || '' : ''; }; const active = (name) => { const el = doc.querySelector(`button.active[name="${name}"]`); return el ? Number(el.getAttribute('value')) : null; }; const start = attr('#custom_period input[name="start"]'); const end = attr('#custom_period input[name="end"]'); const books = attr('#custom_books input[name="books"]'); let period = active('period'); if (period === null && start && end) period = 3; let forBooks = active('for_books'); if (forBooks === null && books) forBooks = 2; const byInput = doc.querySelector('input[name="by"][checked]'); const by = byInput ? Number(byInput.getAttribute('value')) : null; let income = null; const table = doc.querySelector('table.tablesorter'); if (table) { const heads = Array.from(table.querySelectorAll('thead th')).map((th) => clean(th.textContent)); const col = heads.indexOf('Доход'); const total = Array.from(table.querySelectorAll('tr')).find((tr) => tr.cells[0] && clean(tr.cells[0].textContent) === 'Всего'); if (col >= 0 && total && total.cells[col]) { const value = parseFloat(total.cells[col].textContent.replace(/[\s\u00a0]/g, '').replace(',', '.')); if (Number.isFinite(value)) income = value; } } const bookIds = []; for (const a of doc.querySelectorAll('a[href]')) { const m = /^\/book\/(\d+)\/stat$/.exec(a.getAttribute('href')); if (m && !bookIds.includes(Number(m[1]))) bookIds.push(Number(m[1])); } return { period, forBooks, by, start, end, books, income, bookIds, balance: parseBalance(doc) }; } Object.assign(api, { parseUid, parseBalance, parseBookPage, parseStatPage }); // ---- core: decisions ---- const STATE_ACTIVE = 'В работе'; const TRACKED = ['stock', 'stalled']; function newRecord(id, title) { return { id, title: title || '', state: '', stock: 0, lastAt: null, status: 'new', checkAt: 0, checkedAt: 0, fails: 0, note: '' }; } function applyParse(rec, parsed, nowMs) { const watched = TRACKED.includes(rec.status) || rec.status === 'finished'; // У книги, за которой следили, оглавление опустеть не может: страница пришла не целиком. if (parsed.chapters === 0 && watched) return applyFailure(rec, 'оглавление пустое — страница пришла не целиком?', nowMs); const r = Object.assign({}, rec, { title: parsed.title || rec.title, state: parsed.state, stock: parsed.stock, checkedAt: nowMs, fails: 0, note: '', confirm: false, gone: false, }); // Новая книга без глав: nolib, скорее всего, ещё заливает. Не хороним её в done. if (parsed.chapters === 0 && rec.status === 'new') { return Object.assign(r, { checkAt: nowMs + DAY, note: 'глав пока нет — перечитаю через сутки' }); } if (parsed.stock > 0) { r.lastAt = parsed.lastAt; if (parsed.lastAt > nowMs) { r.status = 'stock'; r.checkAt = parsed.lastAt + MINUTE; } else if (nowMs - parsed.lastAt < HOUR) { r.status = 'stock'; r.checkAt = nowMs + 5 * MINUTE; } else { r.status = 'stalled'; r.checkAt = nowMs + HOUR; r.note = 'главы не вышли — таймер выключен?'; } return r; } // Отложка пропала раньше срока: перепроверим через 10 минут, прежде чем звать. if (TRACKED.includes(rec.status) && rec.lastAt > nowMs && !rec.confirm) { return Object.assign(r, { status: rec.status, stock: rec.stock, confirm: true, checkAt: nowMs + 10 * MINUTE, note: 'отложка пропала раньше срока — перепроверяю' }); } if (parsed.chapters > 0 && parsed.state === STATE_ACTIVE) { r.status = rec.status === 'dismissed' ? 'dismissed' : 'finished'; if (r.lastAt > nowMs) r.lastAt = nowMs; } else { r.status = 'done'; } // Закончившуюся книгу перечитываем раз в сутки: вдруг «Завершён» поставили не через форму. r.checkAt = r.status === 'finished' ? nowMs + DAY : 0; return r; } function applyFailure(rec, reason, nowMs) { const fails = (rec.fails || 0) + 1; return Object.assign({}, rec, { fails, note: reason, checkedAt: nowMs, // 30 минут, дальше вдвое дольше, но не реже раза в сутки: сломанная вёрстка не должна долбить сайт. checkAt: nowMs + Math.min(DAY, 30 * MINUTE * 2 ** (fails - 1)), status: fails >= 3 ? 'error' : rec.status, }); } // Метка crawl переживает слияние: задание обхода, слитое с заданием «новая книга» // (приоритет 1), всё равно считается в прогрессе обхода. function enqueue(queue, items) { const byId = new Map(queue.map((q) => [q.id, q])); for (const it of items) { const cur = byId.get(it.id); const notBefore = it.notBefore || 0; const next = cur ? { id: it.id, prio: Math.min(cur.prio, it.prio), notBefore: Math.max(cur.notBefore, notBefore) } : { id: it.id, prio: it.prio, notBefore }; if ((cur && cur.crawl) || it.crawl) next.crawl = true; byId.set(it.id, next); } return Array.from(byId.values()); } // Снять книгу с очереди. С nowMs — только наступившие задания, будущие остаются. function dequeue(queue, id, nowMs) { return queue.filter((q) => q.id !== id || (nowMs != null && q.notBefore > nowMs)); } function pickNext(queue, books, nowMs) { const ready = queue.filter((q) => q.notBefore <= nowMs).sort((a, b) => a.prio - b.prio || a.notBefore - b.notBefore); if (ready.length && ready[0].prio === 0) return ready[0].id; let due = null; for (const b of Object.values(books)) { if (b.checkAt && b.checkAt <= nowMs && (!due || b.checkAt < due.checkAt)) due = b; } if (due) return due.id; return ready.length ? ready[0].id : null; } function crawlProgress(crawl, queue) { if (!crawl || !crawl.total) return null; const left = queue.filter((q) => q.crawl).length; return left ? { done: crawl.total - left, total: crawl.total } : null; } function incomeStale(income, nowMs) { if (!income || income.value == null) return true; if (income.dateKey !== mskDateKey(nowMs)) return true; return nowMs - income.at > 15 * MINUTE; } function bellModel(books, crawl, queue, alert, rate, nowMs) { const list = Object.values(books); const finished = list.filter((b) => b.status === 'finished').sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0)); const soon = (b) => (b.lastAt == null ? Number.MAX_SAFE_INTEGER : b.lastAt); const tracking = list.filter((b) => TRACKED.includes(b.status) || b.status === 'error').sort((a, b) => soon(a) - soon(b)); const checkedAt = list.reduce((m, b) => Math.max(m, b.checkedAt || 0), 0); // alert — одно предупреждение или список (общее и про возврат статистики). const active = [].concat(alert || []).filter((a) => a && a.text && (!a.until || a.until > nowMs)); const alerts = active.map((a) => a.text); if (rate && rate.pausedUntil > nowMs && !active.some((a) => a.until === rate.pausedUntil)) { const day = mskDateKey(rate.pausedUntil) === mskDateKey(nowMs) ? '' : `${mskDay(rate.pausedUntil, nowMs)} `; alerts.push(`Rulate просит подождать до ${day}${mskTime(rate.pausedUntil)} МСК`); } const broken = list.filter((b) => b.status === 'error' && !b.gone).length; if (broken >= 3) alerts.push(`Не разбираю страницы ${broken} ${pluralRu(broken, 'книги', 'книг', 'книг')} — возможно, Rulate поменял вёрстку`); return { finished, tracking, stockCount: list.filter((b) => TRACKED.includes(b.status)).length, progress: crawlProgress(crawl, queue), checkedAt: checkedAt || null, alerts, }; } Object.assign(api, { newRecord, applyParse, applyFailure, enqueue, dequeue, pickNext, crawlProgress, incomeStale, bellModel, }); // ---- runtime: network ---- const RATE_WINDOW = MINUTE; const RATE_MAX = 8; const RATE_BURST = 3; const RATE_GAP = RATE_WINDOW / RATE_MAX; // Скрытую вкладку Chrome будит раз в минуту: метка должна пережить такой сон. const LOCK_TTL = 150 * 1000; const NO_RATE = { times: [], pausedUntil: 0 }; const LOCK_BUSY = { busy: true }; // Зависший запрос не должен держать блокировку статистики и ведущую вкладку. const FETCH_TIMEOUT = 90 * 1000; class RulateError extends Error { constructor(kind, message, extra) { super(message); this.kind = kind; Object.assign(this, extra || {}); } } function classifyResponse(status, text, retryAfter, nowMs) { if (status === 403 && /Вы забанены на сайте/i.test(text)) { return { kind: 'ban', text: clean(/Вы забанены на сайте[^<]{0,300}/i.exec(text)[0]) }; } if (status === 429 || (status === 403 && /слишком часто|слишком много|too many requests|rate limit/i.test(text))) { let wait = 10 * MINUTE; if (retryAfter) { const raw = String(retryAfter).trim(); const ms = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - nowMs; if (Number.isFinite(ms)) wait = Math.max(wait, ms); } return { kind: 'rate', until: nowMs + Math.min(wait, DAY) }; } if (status === 0 || status >= 500) return { kind: 'net' }; if (status >= 400) return { kind: 'http', status }; return { kind: 'ok' }; } // Не больше RATE_MAX запросов за минуту и не больше RATE_BURST подряд: // любые RATE_BURST + 1 запросов растягиваются минимум на RATE_BURST * RATE_GAP. function limiterDelay(rate, nowMs) { if (rate.pausedUntil && rate.pausedUntil > nowMs) return rate.pausedUntil - nowMs; const recent = rate.times.filter((t) => t > nowMs - RATE_WINDOW); let wait = 0; if (recent.length >= RATE_MAX) wait = Math.max(wait, recent[recent.length - RATE_MAX] + RATE_WINDOW - nowMs); if (recent.length >= RATE_BURST) wait = Math.max(wait, recent[recent.length - RATE_BURST] + RATE_BURST * RATE_GAP - nowMs); return Math.max(0, wait); } function limiterRecord(rate, nowMs) { return { times: rate.times.filter((t) => t > nowMs - RATE_WINDOW).concat(nowMs), pausedUntil: rate.pausedUntil || 0 }; } function createStore(gm, uid) { const key = (name) => `u${uid}:${name}`; const store = { get(name, fallback) { const value = gm.getValue(key(name)); return value === undefined || value === null ? fallback : value; }, set(name, value) { gm.setValue(key(name), value); }, update(name, fallback, fn) { const next = fn(store.get(name, fallback)); store.set(name, next); return next; }, onChange(name, cb) { if (gm.addValueChangeListener) gm.addValueChangeListener(key(name), (k, oldValue, newValue) => cb(newValue, oldValue)); }, }; return store; } async function request(ctx, url, opts) { const fields = opts && opts.fields; for (;;) { const rate = ctx.store.get('rate', NO_RATE); const wait = limiterDelay(rate, ctx.now()); if (wait <= 0) { ctx.store.set('rate', limiterRecord(rate, ctx.now())); break; } await ctx.sleep(Math.min(wait, MINUTE)); } // Пока вкладка ждала темпа, место ведущей могла забрать другая. if (ctx.isLeader && !ctx.isLeader()) throw new RulateError('lost', 'эта вкладка больше не ведущая'); const init = fields ? { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString() } : { credentials: 'include' }; const abort = typeof AbortController === 'function' ? new AbortController() : null; const timer = abort ? setTimeout(() => abort.abort(), ctx.fetchTimeout || FETCH_TIMEOUT) : null; if (abort) init.signal = abort.signal; let res; let text; try { res = await ctx.fetch(url, init); text = await res.text(); } catch (e) { throw new RulateError('net', 'нет связи с Rulate'); } finally { if (timer) clearTimeout(timer); } const verdict = classifyResponse(res.status, text, res.headers.get('Retry-After'), ctx.now()); if (verdict.kind === 'ban' || verdict.kind === 'rate') { const until = verdict.kind === 'ban' ? ctx.now() + DAY : verdict.until; ctx.store.update('rate', NO_RATE, (r) => Object.assign({}, r, { pausedUntil: until })); if (verdict.kind === 'ban') ctx.store.set('alert', { text: `Rulate: ${verdict.text}`, until }); throw new RulateError(verdict.kind, verdict.kind === 'ban' ? verdict.text : 'Rulate просит подождать'); } if (verdict.kind === 'net') throw new RulateError('net', `Rulate не отвечает (HTTP ${res.status})`); if (verdict.kind === 'http') throw new RulateError('http', `HTTP ${res.status}`, { status: res.status }); return { text, url: res.url || url }; } // Ведущая вкладка: свободное место, своя метка, устаревшая метка // или вкладка на виду против скрытой. function nextLock(lock, me, nowMs) { if (!lock || lock.tabId === me.tabId || nowMs - lock.at > LOCK_TTL || (me.visible && !lock.visible)) { return { tabId: me.tabId, visible: me.visible, at: nowMs }; } return lock; } Object.assign(api, { LOCK_BUSY, RulateError, classifyResponse, limiterDelay, limiterRecord, createStore, request, nextLock }); // ---- runtime: stat view ---- const RESTORE_FAILED = 'Не удалось вернуть ваш выбор на странице статистики — проверьте период вручную'; const RESTORE_ATTEMPTS = 3; // Эти ошибки не про статистику: пауза Rulate или смена ведущей вкладки. Возврат ждёт. const isPause = (e) => !!e && (e.kind === 'rate' || e.kind === 'ban' || e.kind === 'lost'); // Нужны: выбранный период, «Все» и «по переводам» — список книг есть только в этой разбивке. function planStatSwitch(stat, wantPeriod) { if (stat.period == null || stat.forBooks == null || stat.by == null) { return { error: 'не понял, что выбрано на странице статистики' }; } const target = { period: wantPeriod, forBooks: 0, by: 2 }; const switchFields = {}; if (stat.period !== wantPeriod) switchFields.period = String(wantPeriod); if (stat.forBooks !== 0) switchFields.for_books = '0'; if (stat.by !== 2) switchFields.by = '2'; if (!Object.keys(switchFields).length) return { ready: true, target }; const restoreFields = {}; if (switchFields.period) { restoreFields.period = String(stat.period); if (stat.period === 3) Object.assign(restoreFields, { start: stat.start, end: stat.end }); } if (switchFields.for_books) { restoreFields.for_books = String(stat.forBooks); if (stat.forBooks === 2) restoreFields.books = stat.books; } if (switchFields.by) restoreFields.by = String(stat.by); return { ready: false, target, switchFields, restoreFields }; } function statMatches(stat, want) { return stat.period === want.period && stat.forBooks === want.forBooks && stat.by === want.by && (want.period !== 3 || (stat.start === want.start && stat.end === want.end)) && (want.forBooks !== 2 || stat.books === want.books); } function splitFields(fields) { return [['period', 'start', 'end'], ['for_books', 'books'], ['by']] .map((keys) => keys.reduce((group, k) => (k in fields ? Object.assign(group, { [k]: fields[k] }) : group), {})) .filter((group) => Object.keys(group).length); } async function fetchStat(ctx, fields) { const res = await request(ctx, `/users/${ctx.uid}/stat`, fields ? { fields } : undefined); return parseStatPage(ctx.parse(res.text)); } // Сначала все поля одним POST; если сервер принял не всё — по группам. async function postStat(ctx, fields, want) { let view = await fetchStat(ctx, fields); if (statMatches(view, want)) return view; for (const group of splitFields(fields)) view = await fetchStat(ctx, group); return statMatches(view, want) ? view : null; } // Возвращает выбор пользователя. Сбой не бросает слот: повтор через 5 и 10 минут, // и только после третьей неудачи — предупреждение. async function restoreStat(ctx) { const pending = ctx.store.get('statRestore', null); if (!pending) return true; let ok = false; let offline = false; try { ok = !!(await postStat(ctx, pending.fields, pending.want)); } catch (e) { if (isPause(e)) throw e; offline = e.kind === 'net'; } if (ok) { ctx.store.set('statRestore', null); return true; } // Сеть моргнула — это не отказ Rulate, попытку не засчитываем, просто повторим. if (offline) { ctx.store.set('statRestore', Object.assign({}, pending, { nextAt: ctx.now() + 5 * MINUTE })); return false; } const attempts = (pending.attempts || 0) + 1; if (attempts >= RESTORE_ATTEMPTS) { ctx.store.set('statRestore', null); ctx.store.set('restoreAlert', { text: RESTORE_FAILED, until: ctx.now() + DAY }); } else { ctx.store.set('statRestore', Object.assign({}, pending, { attempts, nextAt: ctx.now() + attempts * 5 * MINUTE })); } return false; } async function withStatView(ctx, wantPeriod) { if (!(await restoreStat(ctx))) throw new RulateError('pending', 'статистика ещё не возвращена'); const before = await fetchStat(ctx); const plan = planStatSwitch(before, wantPeriod); if (plan.error) throw new RulateError('parse', plan.error); if (plan.ready) return before; ctx.store.set('statRestore', { fields: plan.restoreFields, want: before }); let view = null; let failure = null; try { view = await postStat(ctx, plan.switchFields, plan.target); } catch (e) { failure = e; } // На паузе от Rulate или после смены ведущей возврат доделает runOnce. if (!isPause(failure)) await restoreStat(ctx); if (failure) throw failure; if (!view) throw new RulateError('parse', 'Rulate не переключил статистику'); return view; } function addBooks(ctx, ids, prio) { const known = ctx.store.get('books', {}); const fresh = ids.filter((id) => !known[id]); if (!fresh.length) return; ctx.store.update('books', {}, (b) => { for (const id of fresh) if (!b[id]) b[id] = newRecord(id); return b; }); ctx.store.update('queue', [], (q) => enqueue(q, fresh.map((id) => ({ id, prio, notBefore: 0 })))); } async function refreshIncome(ctx) { try { const view = await withStatView(ctx, 1); if (view.income == null) throw new RulateError('parse', 'в статистике нет строки «Всего» или столбца «Доход»'); ctx.store.set('income', { value: view.income, dateKey: mskDateKey(ctx.now()), at: ctx.now(), error: null, failedAt: 0 }); noteBalance(ctx, view.balance, true); addBooks(ctx, view.bookIds, 1); return true; } catch (e) { if (e.kind === 'lost') throw e; ctx.store.update('income', null, (inc) => Object.assign({ value: null, dateKey: '', at: 0 }, inc, { error: e.message, failedAt: ctx.now() })); if (isPause(e)) throw e; return false; } } async function rescan(ctx) { const view = await withStatView(ctx, 0); noteBalance(ctx, view.balance); if (!view.bookIds.length) throw new RulateError('parse', 'в статистике за «Всё время» нет книг'); ctx.store.update('books', {}, (b) => { for (const id of view.bookIds) if (!b[id]) b[id] = newRecord(id); return b; }); ctx.store.update('queue', [], (q) => enqueue(q, view.bookIds.map((id) => ({ id, prio: 2, notBefore: 0, crawl: true })))); ctx.store.set('crawl', { total: view.bookIds.length, startedAt: ctx.now() }); } Object.assign(api, { planStatSwitch, statMatches, splitFields, fetchStat, postStat, restoreStat, withStatView, addBooks, refreshIncome, rescan, }); // ---- runtime: books & loop ---- // Из очереди снимается только то, что уже наступило: перечитывание после // сохранения формы (notBefore в будущем) должно дождаться своего часа. function saveBook(ctx, id, fn) { ctx.store.update('books', {}, (b) => { b[id] = fn(b[id] || newRecord(id)); return b; }); ctx.store.update('queue', [], (q) => dequeue(q, id, ctx.now())); } // Баланс в шапке поменялся — пришли деньги (или вы их потратили): доход пора обновить, // не дожидаясь 15 минут. silent — доход уже свежий на этот баланс, просить незачем. function noteBalance(ctx, value, silent) { if (value == null) return; const prev = ctx.store.get('balance', null); if (prev && prev.value === value) return; ctx.store.set('balance', { value, at: ctx.now() }); if (prev && !silent) ctx.store.update('tasks', {}, (t) => Object.assign({}, t, { balanceChanged: ctx.now() })); } async function readBook(ctx, id) { let res; try { res = await request(ctx, `/book/${id}`); } catch (e) { if (e.kind === 'net') { const retry = ctx.now() + 5 * MINUTE; ctx.store.update('queue', [], (q) => q.map((it) => (it.id === id ? Object.assign({}, it, { notBefore: retry }) : it))); ctx.store.update('books', {}, (b) => { if (b[id] && b[id].checkAt) b[id] = Object.assign({}, b[id], { checkAt: retry }); return b; }); return; } if (e.kind !== 'http') throw e; // 404/410 — книгу удалили: это не сломанная вёрстка, в общий ⚠ не считаем. const gone = e.status === 404 || e.status === 410; saveBook(ctx, id, (rec) => Object.assign(applyFailure(rec, `страница книги не открылась (${e.message})`, ctx.now()), { gone })); return; } const doc = ctx.parse(res.text); // Вышли из аккаунта или вошли другим: чужой вид страницы может не показать отложку. // Это беда всей сессии, а не книги: общая пауза, книга и очередь не трогаются. if (parseUid(doc) !== ctx.uid) { const until = ctx.now() + 30 * MINUTE; ctx.store.update('rate', NO_RATE, (r) => Object.assign({}, r, { pausedUntil: until })); ctx.store.set('alert', { text: `Rulate открылся не под вашим аккаунтом — вы вышли? Проверю снова в ${mskTime(until)} МСК`, until }); return; } noteBalance(ctx, parseBalance(doc)); const parsed = parseBookPage(doc, ctx.now()); saveBook(ctx, id, (rec) => (parsed.ok ? applyParse(rec, parsed, ctx.now()) : applyFailure(rec, parsed.reason, ctx.now()))); } // Статистику трогает только одна вкладка за раз. ctx.lock — navigator.locks с ifAvailable: // занято — сразу LOCK_BUSY. Без ctx.lock работа идёт как есть. function withLock(ctx, fn) { return ctx.lock ? ctx.lock(`rh-stat-u${ctx.uid}`, fn) : fn(); } // Что нужно сделать со статистикой. Пока висит возврат, остальное ждёт. function statJob(ctx, nowMs) { const pending = ctx.store.get('statRestore', null); if (pending) return (pending.nextAt || 0) <= nowMs ? 'restore' : null; const tasks = ctx.store.get('tasks', {}); const backoff = ctx.store.get('backoff', {}); const income = ctx.store.get('income', null); // Баланс поменялся: доход обновляем сразу, но не чаще раза в минуту и не во время паузы после сбоя. const balanceNews = tasks.balanceChanged && !(income && nowMs - income.at < MINUTE) && !(backoff.income > nowMs); if (tasks.refreshIncome || balanceNews || (incomeStale(income, nowMs) && !(backoff.income > nowMs))) return 'income'; if (tasks.rescan || (!ctx.store.get('crawl', null) && !(backoff.rescan > nowMs))) return 'rescan'; return null; } async function runStatJob(ctx, job) { if (job === 'restore') { await restoreStat(ctx); return; } if (job === 'income') { const started = ctx.now(); const ok = await refreshIncome(ctx); // Сбрасываем только просьбы, поданные до начала: новое изменение баланса, // замеченное другой вкладкой во время обновления, не теряется. ctx.store.update('tasks', {}, (t) => Object.assign({}, t, { refreshIncome: t.refreshIncome > started ? t.refreshIncome : 0, balanceChanged: t.balanceChanged > started ? t.balanceChanged : 0, })); ctx.store.update('backoff', {}, (b) => Object.assign({}, b, { income: ok ? 0 : ctx.now() + 5 * MINUTE })); return; } try { await rescan(ctx); ctx.store.update('backoff', {}, (b) => Object.assign({}, b, { rescan: 0 })); } catch (e) { if (isPause(e)) throw e; ctx.store.update('backoff', {}, (b) => Object.assign({}, b, { rescan: ctx.now() + 5 * MINUTE })); ctx.store.set('alert', { text: `Не удалось взять список книг: ${e.message}`, until: ctx.now() + 5 * MINUTE }); } ctx.store.update('tasks', {}, (t) => Object.assign({}, t, { rescan: 0 })); } // Одна единица работы ведущей вкладки. Порядок: пауза, статистика (возврат, // доход, список книг) под блокировкой, потом одна книга. async function runOnce(ctx) { const now = ctx.now(); if (ctx.store.get('rate', NO_RATE).pausedUntil > now) return 'paused'; const job = statJob(ctx, now); if (job && (await withLock(ctx, () => runStatJob(ctx, job))) !== LOCK_BUSY) return job; const id = pickNext(ctx.store.get('queue', []), ctx.store.get('books', {}), now); if (id == null) return 'idle'; await readBook(ctx, id); return 'book'; } Object.assign(api, { saveBook, noteBalance, readBook, runOnce }); // ---- ui ---- const GREEN = '#3fb950'; const GREY = '#8b949e'; const BELL_SVG = ''; const STYLE = [ // Доход стоит в самой строке баланса, сразу за суммой, и шрифт у него как у суммы. // Без position:absolute: строка не вылезает за край шапки и не мешает курсору на пути к меню профиля. '.rh-income{display:flex;align-items:center;gap:2px;margin-left:4px;white-space:nowrap;font-size:.68rem;font-weight:600;line-height:1.19;user-select:none;-webkit-user-select:none;cursor:default}', '.rh-income-refresh{border:0;background:none;padding:0 2px;cursor:pointer;color:inherit;font:13px/13px system-ui,sans-serif;opacity:.75}', '.rh-income-refresh:hover{opacity:1}', '.rh-spin{display:inline-block;animation:rh-spin 1s linear infinite}', '@keyframes rh-spin{to{transform:rotate(360deg)}}', '.rh-bell{position:relative}', '.rh-bell-btn{position:relative;width:38px;height:38px;border:0;background:none;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}', '.rh-bell-badge{position:absolute;top:3px;right:2px;min-width:15px;height:15px;padding:0 4px;border-radius:8px;background:#2ea043;color:#fff;font:600 10px/15px system-ui,sans-serif;text-align:center;box-sizing:border-box}', '.rh-bell-warn{position:absolute;bottom:2px;right:2px;font:11px/11px system-ui,sans-serif}', '.rh-panel{position:absolute;top:100%;right:0;width:360px;max-height:70vh;overflow:auto;z-index:1000;padding:8px 0;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.35);font:13px/1.35 system-ui,sans-serif;text-align:left}', '.rh-panel[hidden],.rh-bell-badge[hidden],.rh-bell-warn[hidden]{display:none}', '.rh-sec{padding:4px 12px;font-weight:600;opacity:.7;font-size:12px}', '.rh-sec-toggle{cursor:pointer;display:block;width:100%;border:0;background:none;color:inherit;text-align:left;font-family:inherit}', '.rh-item{display:flex;gap:8px;align-items:flex-start;padding:6px 12px}', '.rh-item-main{flex:1;min-width:0}', '.rh-item a{color:inherit;text-decoration:none;font-weight:600}', '.rh-item a:hover{text-decoration:underline}', '.rh-sub{opacity:.65;font-size:12px}', '.rh-x{border:0;background:none;color:inherit;opacity:.5;cursor:pointer;font-size:16px;line-height:16px;padding:0 2px}', '.rh-x:hover{opacity:1}', '.rh-empty{padding:6px 12px;opacity:.65}', '.rh-alert{padding:6px 12px;color:#d29922;font-size:12px}', '.rh-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 12px 2px;margin-top:4px;border-top:1px solid rgba(127,127,127,.25);font-size:12px}', '.rh-foot button{border:1px solid rgba(127,127,127,.4);background:none;color:inherit;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:12px;white-space:nowrap}', ].join('\n'); function injectStyle(doc) { if (doc.getElementById('rh-style')) return; const style = doc.createElement('style'); style.id = 'rh-style'; style.textContent = STYLE; (doc.head || doc.documentElement).appendChild(style); } function make(doc, tag, cls, text) { const el = doc.createElement(tag); if (cls) el.className = cls; if (text != null) el.textContent = text; return el; } function mountIncome(doc) { const balance = doc.querySelector('.main-header-balance'); if (!balance) return null; injectStyle(doc); const root = make(doc, 'div', 'rh-income'); const value = make(doc, 'span', 'rh-income-value'); const refresh = make(doc, 'button', 'rh-income-refresh', '↻'); refresh.type = 'button'; refresh.title = 'Обновить доход'; root.append(value, refresh); balance.appendChild(root); return { root, value, refresh }; } function renderIncome(ui, income, nowMs, busy) { const has = !!income && income.value != null; const stale = has && (!!income.error || income.dateKey !== mskDateKey(nowMs)); const sign = has && income.value < 0 ? '−' : '+'; ui.value.textContent = has ? `(${sign}${formatMoney(Math.abs(income.value))})` : (income && income.error ? '(?)' : '(…)'); ui.value.style.color = has && !stale && income.value > 0 ? GREEN : GREY; const lines = ['Доход за сегодня по статистике Rulate, с 00:00 МСК.']; if (has) lines.push(`Обновлено в ${mskTime(income.at)} МСК.`); if (stale) lines.push('Устарело.'); if (income && income.error) lines.push(`Ошибка: ${income.error}`); ui.value.title = lines.join('\n'); ui.refresh.classList.toggle('rh-spin', !!busy); } function mountBell(doc) { const menu = doc.querySelector('.main-header-right_menu'); if (!menu) return null; injectStyle(doc); const rulateBell = Array.from(menu.children) .find((el) => el.classList.contains('notifications') && el.querySelector('.main-header-notifications_dropdown-menu')) || null; const root = make(doc, 'div', 'header-item header-icon-item rh-bell'); const btn = make(doc, 'button', 'rh-bell-btn'); btn.type = 'button'; btn.innerHTML = BELL_SVG; btn.setAttribute('aria-label', 'Отложка'); btn.setAttribute('aria-expanded', 'false'); const badge = make(doc, 'span', 'rh-bell-badge'); badge.hidden = true; const warn = make(doc, 'span', 'rh-bell-warn', '⚠'); warn.hidden = true; btn.append(badge, warn); const panel = make(doc, 'div', 'rh-panel'); panel.hidden = true; root.append(btn, panel); const profile = Array.from(menu.children).find((el) => el.classList.contains('main-header-profile')); if (rulateBell) rulateBell.after(root); else if (profile) menu.insertBefore(root, profile); else menu.appendChild(root); return { root, btn, badge, warn, panel, rulateBell, collapsed: true, lastSig: '' }; } // Цвета берём у колокольчика Rulate: так список свой и в светлой, и в тёмной теме. function paintBell(ui) { const win = ui.root.ownerDocument.defaultView; const style = (el) => (el && win && win.getComputedStyle ? win.getComputedStyle(el) : null); const icon = style(ui.rulateBell && ui.rulateBell.querySelector('button')); const menu = style(ui.rulateBell && ui.rulateBell.querySelector('.main-header-notifications_dropdown-menu')); if (icon && icon.color) ui.btn.style.color = icon.color; const bg = menu && menu.backgroundColor; ui.panel.style.backgroundColor = bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)' ? bg : '#24292f'; ui.panel.style.color = menu && menu.color ? menu.color : '#e6edf3'; } function renderBell(ui, model, handlers, nowMs) { const n = model.finished.length; ui.badge.hidden = n === 0; ui.badge.textContent = String(n); ui.warn.hidden = model.alerts.length === 0; ui.btn.title = n ? `Отложка закончилась: ${n} ${pluralRu(n, 'книга', 'книги', 'книг')}` : `Отложка: слежу за ${model.stockCount} ${pluralRu(model.stockCount, 'книгой', 'книгами', 'книгами')}`; const sig = JSON.stringify([model, ui.collapsed]); if (ui.panel.hidden || sig === ui.lastSig) return; ui.lastSig = sig; const doc = ui.root.ownerDocument; const p = ui.panel; p.textContent = ''; p.append(make(doc, 'div', 'rh-sec', 'Отложка закончилась')); if (!n) p.append(make(doc, 'div', 'rh-empty', 'Пока ни одна отложка не закончилась')); for (const b of model.finished) { const item = make(doc, 'div', 'rh-item'); const main = make(doc, 'div', 'rh-item-main'); const link = make(doc, 'a', null, b.title || `Книга ${b.id}`); link.href = `/book/${b.id}/edit/info`; const sub = b.lastAt ? `последняя глава вышла ${mskDay(b.lastAt, nowMs)} в ${mskTime(b.lastAt)} МСК` : 'найдена при первом обходе'; main.append(link, make(doc, 'div', 'rh-sub', sub)); const x = make(doc, 'button', 'rh-x', '×'); x.type = 'button'; x.title = 'Убрать из списка'; x.addEventListener('click', () => handlers.dismiss(b.id)); item.append(main, x); p.append(item); } const head = make(doc, 'button', 'rh-sec rh-sec-toggle', `${ui.collapsed ? '▸' : '▾'} Ещё в отложке (${model.tracking.length})`); head.type = 'button'; head.setAttribute('aria-expanded', String(!ui.collapsed)); head.addEventListener('click', () => { ui.collapsed = !ui.collapsed; ui.refocus = true; handlers.rerender(); }); p.append(head); // Список пересобран — вернуть фокус на новый заголовок, чтобы клавиатура не теряла место. if (ui.refocus) { ui.refocus = false; head.focus(); } if (!ui.collapsed) { for (const b of model.tracking) { const item = make(doc, 'div', 'rh-item'); const main = make(doc, 'div', 'rh-item-main'); const link = make(doc, 'a', null, b.title || `Книга ${b.id}`); link.href = `/book/${b.id}`; const parts = []; if (b.status !== 'stock') parts.push(`⚠ ${b.note}`); if (b.lastAt) parts.push(`${b.stock} ${pluralRu(b.stock, 'глава', 'главы', 'глав')}, до ${mskDay(b.lastAt, nowMs)} ${mskTime(b.lastAt)} МСК`); main.append(link, make(doc, 'div', 'rh-sub', parts.join(' · '))); item.append(main); p.append(item); } } for (const text of model.alerts) p.append(make(doc, 'div', 'rh-alert', `⚠ ${text}`)); const foot = make(doc, 'div', 'rh-foot'); let status = 'Ещё не проверял'; if (model.progress) status = `Обход: ${model.progress.done} из ${model.progress.total}`; else if (model.checkedAt) status = `Проверено ${mskDayMonth(model.checkedAt)} в ${mskTime(model.checkedAt)} МСК`; const again = make(doc, 'button', null, 'Пересканировать всё'); again.type = 'button'; again.addEventListener('click', () => handlers.rescan()); foot.append(make(doc, 'span', null, status), again); p.append(foot); } function wireBell(ui, handlers) { const doc = ui.root.ownerDocument; const show = (open) => { ui.panel.hidden = !open; ui.btn.setAttribute('aria-expanded', String(open)); }; ui.btn.addEventListener('click', (ev) => { ev.stopPropagation(); show(ui.panel.hidden); if (!ui.panel.hidden) { paintBell(ui); ui.lastSig = ''; handlers.rerender(); } }); ui.panel.addEventListener('click', (ev) => ev.stopPropagation()); doc.addEventListener('click', () => show(false)); doc.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') show(false); }); } Object.assign(api, { injectStyle, mountIncome, renderIncome, mountBell, paintBell, renderBell, wireBell }); // ---- boot ---- function onBookPage(ctx, id) { const parsed = parseBookPage(ctx.doc, ctx.now()); if (!parsed.ok) return false; const known = ctx.store.get('books', {})[id]; if (!known && parsed.ownerUid !== ctx.uid) return false; saveBook(ctx, id, (rec) => applyParse(rec, parsed, ctx.now())); return true; } function onFormSend(form, cb) { form.addEventListener('submit', cb, true); for (const b of form.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')) { b.addEventListener('click', cb, true); } } function hookForms(ctx, path) { const later = () => ctx.now() + 10 * 1000; const edit = /^\/book\/(\d+)\/edit\/info\/?$/.exec(path); if (edit) { const id = Number(edit[1]); const form = ctx.doc.querySelector('form#form-edit'); if (form) onFormSend(form, () => ctx.store.update('queue', [], (q) => enqueue(q, [{ id, prio: 0, notBefore: later() }]))); return; } const mass = /^\/users\/(\d+)\/book_settings\/?$/.exec(path); if (!mass || Number(mass[1]) !== ctx.uid) return; const field = ctx.doc.querySelector('[name="BookSettingsTemplate[open_count]"]'); if (!field || !field.form) return; onFormSend(field.form, () => { const ids = Object.values(ctx.store.get('books', {})).filter((b) => TRACKED.includes(b.status)).map((b) => b.id); ctx.store.update('queue', [], (q) => enqueue(q, ids.map((id) => ({ id, prio: 0, notBefore: later() })))); }); } function isLeaderOf(ctx, me) { const lock = ctx.store.get('lock', null); return !!lock && lock.tabId === me.tabId; } // Такт ведущей вкладки: продлить свою метку или забрать устаревшую / у скрытой. function leaderBeat(ctx, me) { if (ctx.doc) me.visible = ctx.doc.visibilityState === 'visible'; const lock = nextLock(ctx.store.get('lock', null), me, ctx.now()); if (lock.tabId === me.tabId) ctx.store.set('lock', lock); return isLeaderOf(ctx, me); } function startLeaderLoop(ctx, me) { ctx.isLeader = () => isLeaderOf(ctx, me); const beat = () => leaderBeat(ctx, me); beat(); setInterval(beat, 10 * 1000); ctx.doc.addEventListener('visibilitychange', beat); ctx.win.addEventListener('pagehide', () => { if (ctx.isLeader()) ctx.store.set('lock', null); }); (async () => { for (;;) { let result = 'idle'; if (ctx.isLeader()) { try { result = await runOnce(ctx); } catch (e) { if (!isPause(e)) console.warn('[Rulate Helper]', e); } } await ctx.sleep(result === 'idle' || result === 'paused' ? 5000 : 1000); } })(); } function boot() { const doc = document; const uid = parseUid(doc); if (!uid) return; const gm = { getValue: GM_getValue, setValue: GM_setValue, addValueChangeListener: GM_addValueChangeListener }; const ctx = { uid, doc, win: window, store: createStore(gm, uid), now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), fetch: (url, opts) => fetch(url, opts), parse: (html) => new DOMParser().parseFromString(html, 'text/html'), lock: typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request ? (name, fn) => navigator.locks.request(name, { ifAvailable: true }, (held) => (held ? fn() : LOCK_BUSY)) : null, }; console.info('[Rulate Helper] запущен; блокировка между вкладками:', ctx.lock ? 'есть' : 'НЕТ'); // Без блокировки две вкладки могут одновременно переключать статистику. const noLock = ctx.lock ? null : { text: 'Браузер не дал блокировку между вкладками — держите Rulate открытым в одной вкладке' }; const path = location.pathname; noteBalance(ctx, parseBalance(doc)); const book = /^\/book\/(\d+)\/?$/.exec(path); if (book) onBookPage(ctx, Number(book[1])); hookForms(ctx, path); // Если Rulate переделает шапку, без значков, но слежка продолжится. const safely = (fn) => { try { return fn(doc); } catch (e) { console.warn('[Rulate Helper]', e); return null; } }; const incomeUi = safely(mountIncome); const bellUi = safely(mountBell); let busySince = 0; const handlers = { dismiss: (id) => ctx.store.update('books', {}, (b) => { if (b[id]) b[id] = Object.assign({}, b[id], { status: 'dismissed' }); return b; }), rescan: () => ctx.store.update('tasks', {}, (t) => Object.assign({}, t, { rescan: ctx.now() })), rerender: () => renderAll(), }; function renderAll() { const now = ctx.now(); const income = ctx.store.get('income', null); if (busySince && (now - busySince > MINUTE || (income && (income.at >= busySince || income.failedAt >= busySince)))) busySince = 0; if (incomeUi) renderIncome(incomeUi, income, now, busySince > 0); if (bellUi) { paintBell(bellUi); const model = bellModel(ctx.store.get('books', {}), ctx.store.get('crawl', null), ctx.store.get('queue', []), [ctx.store.get('alert', null), ctx.store.get('restoreAlert', null), noLock], ctx.store.get('rate', NO_RATE), now); renderBell(bellUi, model, handlers, now); } } if (incomeUi) { incomeUi.refresh.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); busySince = ctx.now(); ctx.store.update('tasks', {}, (t) => Object.assign({}, t, { refreshIncome: ctx.now() })); renderAll(); }); } if (bellUi) wireBell(bellUi, handlers); for (const id of ['header-theme-toggle', 'theme-toggle-menu']) { const el = doc.getElementById(id); if (el) el.addEventListener('click', () => setTimeout(renderAll, 300)); } for (const name of ['income', 'books', 'queue', 'crawl', 'alert', 'restoreAlert', 'rate']) ctx.store.onChange(name, renderAll); renderAll(); setInterval(renderAll, 30 * 1000); startLeaderLoop(ctx, { tabId: Math.random().toString(36).slice(2) + Date.now().toString(36), visible: doc.visibilityState === 'visible' }); } Object.assign(api, { onBookPage, onFormSend, hookForms, isLeaderOf, leaderBeat }); // ---- exports ---- if (typeof GM_getValue === 'undefined' && typeof module === 'object' && module && module.exports) { module.exports = api; return; } boot(); })();