Gelbooru Robust Infinite Scroll + Save State

Надёжный автопейджер для классических страниц Gelbooru (pid, теги) + сохранение/восстановление позиции и загруженных постов.

// ==UserScript==
// @name         Gelbooru Robust Infinite Scroll + Save State
// @namespace    vm/gelbooru-autopager-robust-save
// @version      2.1
// @description  Надёжный автопейджер для классических страниц Gelbooru (pid, теги) + сохранение/восстановление позиции и загруженных постов.
// @match        https://gelbooru.com/index.php?page=post&s=list*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const PAGE_SIZE = 42;
  const AUTO_LOAD = true; // автоподгрузка при прокрутке
  const SCROLL_THRESHOLD_PX = 800;

  // ---- helpers для обнаружения контейнера и постов ----
  function findPostsContainer(doc = document) {
    const candidates = [
      'ul#post-list-posts',
      '#post-list-posts',
      '#posts',
      '.thumbnail-container',
      '.thumbs',
      '.post-list',
      '#post-list',
      '#content #posts',
      'div#post-list-posts',
      'div.thumb-list'
    ];
    for (const s of candidates) {
      const el = doc.querySelector(s);
      if (el) return el;
    }
    // fallback: найдём множество ссылок на просмотр поста и возьмём их общий контейнер
    const anchors = doc.querySelectorAll('a[href*="index.php?page=post&s=view&id="], a[href*="/post/show/"], a[href*="/post/"]');
    if (anchors.length) {
      let p = anchors[0].closest('ul,div,section') || document.body;
      return p;
    }
    return null;
  }

  function getPostNodes(root) {
    const trySelectors = [
      'ul#post-list-posts > li',
      '#post-list-posts > li',
      '#posts > article',
      '#posts > div',
      '.thumbs > li',
      '.thumbs > div',
      '.thumb',
      'article.post-preview',
      'li[id^="p"]',
      '#post-list-posts li',
      '.post-preview'
    ];
    for (const sel of trySelectors) {
      const nodes = root.querySelectorAll(sel);
      if (nodes && nodes.length) return Array.from(nodes);
    }
    // супер-фолбэк: взять элементы по ссылкам на просмотр поста
    const anchors = Array.from(root.querySelectorAll('a[href*="index.php?page=post&s=view&id="], a[href*="/post/show/"], a[href*="/post/"]'));
    if (anchors.length) {
      return anchors.map(a => a.closest('li') || a.closest('div') || a);
    }
    return [];
  }

  function extractPostId(node) {
    if (!node) return null;
    // data attributes
    const ds = node.dataset?.postId || node.dataset?.id || node.getAttribute('data-id');
    if (ds && /\d/.test(ds)) return String(ds).match(/\d+/)[0];

    // id attribute like "p123"
    if (node.id) {
      const m = node.id.match(/\d+/);
      if (m) return m[0];
    }

    // ссылки внутри
    const a = node.querySelector('a[href*="index.php?page=post&s=view&id="], a[href*="/post/show/"], a[href*="/post/"]');
    if (a && a.href) {
      const m1 = a.href.match(/id=(\d+)/);
      if (m1) return m1[1];
      const m2 = a.href.match(/post\/show\/(\d+)/);
      if (m2) return m2[1];
      const m3 = a.href.match(/\/post\/(\d+)/);
      if (m3) return m3[1];
    }

    // src миниатюры / пути с цифрами
    const img = node.querySelector('img[src], img[data-src]');
    if (img && img.src) {
      const m = img.src.match(/\/(\d+)[^\d]/);
      if (m) return m[1];
    }

    return null;
  }

  function absolutize(node, baseHref) {
    const base = new URL(baseHref, location.origin);
    // обычные ссылки
    node.querySelectorAll('a[href]').forEach(a => {
      try { a.href = new URL(a.getAttribute('href'), base).href; } catch(e) {}
    });
    // img: если lazy (data-src) — перенести
    node.querySelectorAll('img').forEach(img => {
      const ds = img.getAttribute('data-src') || img.getAttribute('data-original') || img.getAttribute('data-lazy-src');
      if (ds) {
        try { img.src = new URL(ds, base).href; } catch(e) {}
      } else if (img.getAttribute('src')) {
        try { img.src = new URL(img.getAttribute('src'), base).href; } catch(e) {}
      }
      // srcset
      const ss = img.getAttribute('srcset');
      if (ss) {
        const parts = ss.split(',').map(s => s.trim()).map(entry => {
          const [url, dpr] = entry.split(/\s+/);
          try { return new URL(url, base).href + (dpr ? ' ' + dpr : ''); } catch(e) { return entry; }
        });
        img.setAttribute('srcset', parts.join(', '));
      }
    });
  }

  // ---- начальная логика ----
  const container = findPostsContainer(document);
  if (!container) {
    console.warn('[Gelbooru Autopager] контейнер не найден — возможно нестандартная тема');
    return;
  }

  // сформируем ключ для localStorage: URL *без* pid (чтобы хранить по тегам/поиску)
  const curUrl = new URL(location.href);
  const paramsNoPid = new URLSearchParams(curUrl.searchParams);
  paramsNoPid.delete('pid');
  const STORAGE_KEY = 'gelbooru_autopager:' + curUrl.origin + curUrl.pathname + '?' + paramsNoPid.toString();

  // прочитаем pid из url (если есть)
  let pid = parseInt(curUrl.searchParams.get('pid') || '0', 10) || 0;
  let loading = false;
  let ended = false;

  // восстановление состояния (если есть)
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) {
      const saved = JSON.parse(raw);
      if (saved && saved.html && !isNaN(Number(saved.pid))) {
        // подменяем содержимое контейнера и восстанавливаем скролл
        try {
          container.innerHTML = saved.html;
          pid = Number(saved.pid);
          // небольшая задержка чтобы страница успела отрисоваться
          setTimeout(() => { window.scrollTo(0, saved.scroll || 0); }, 80);
          console.log('[Gelbooru Autopager] restored state, pid=', pid, ' scroll=', saved.scroll);
        } catch (e) {
          console.warn('[Gelbooru Autopager] restore failed', e);
        }
      }
    }
  } catch (e) {
    console.warn('[Gelbooru Autopager] error reading saved state', e);
  }

  // Собираем уже видимые id, чтобы не дублировать
  const initialPosts = getPostNodes(container);
  const seen = new Set(initialPosts.map(n => extractPostId(n)).filter(Boolean));
  console.log('[Gelbooru Autopager] found container with', initialPosts.length, 'items, seen=', seen.size);

  // UI: статус и кнопка
  const panel = document.createElement('div');
  panel.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:99999;background:rgba(0,0,0,0.6);color:#fff;padding:10px;border-radius:10px;font:13px/1.1 system-ui;box-shadow:0 6px 18px rgba(0,0,0,0.4)';
  const status = document.createElement('div');
  status.textContent = 'Autopager ready';
  status.style.marginBottom = '6px';
  const btn = document.createElement('button');
  btn.textContent = 'Load more';
  btn.style.cssText = 'cursor:pointer;border:none;padding:6px 10px;border-radius:8px;font-weight:600';
  const toggleAuto = document.createElement('button');
  toggleAuto.textContent = AUTO_LOAD ? 'Auto: ON' : 'Auto: OFF';
  toggleAuto.style.cssText = 'margin-left:6px;border-radius:8px;padding:6px 8px;border:none;background:#666;color:#fff';
  const clearBtn = document.createElement('button');
  clearBtn.textContent = 'Clear saved';
  clearBtn.style.cssText = 'margin-left:6px;border-radius:8px;padding:6px 8px;border:none;background:#b33;color:#fff';
  panel.appendChild(status);
  panel.appendChild(btn);
  panel.appendChild(toggleAuto);
  panel.appendChild(clearBtn);
  document.body.appendChild(panel);

  btn.addEventListener('click', () => loadNext());
  let autoEnabled = AUTO_LOAD;
  toggleAuto.addEventListener('click', () => { autoEnabled = !autoEnabled; toggleAuto.textContent = autoEnabled ? 'Auto: ON' : 'Auto: OFF'; });
  clearBtn.addEventListener('click', () => {
    localStorage.removeItem(STORAGE_KEY);
    status.textContent = 'Saved cleared';
    console.log('[Gelbooru Autopager] cleared saved state for', STORAGE_KEY);
  });

  // автоподгрузка
  if (AUTO_LOAD) window.addEventListener('scroll', onScroll, { passive: true });

  function onScroll() {
    if (!autoEnabled || loading || ended) return;
    const remain = document.documentElement.scrollHeight - (window.scrollY + window.innerHeight);
    if (remain < SCROLL_THRESHOLD_PX) loadNext();
    // регулярно сохраняем позицию/контент
    saveState();
  }

  async function loadNext() {
    if (loading || ended) return;
    loading = true;
    status.textContent = 'Loading...';
    pid += PAGE_SIZE;

    const next = new URL(location.href);
    next.searchParams.set('pid', String(pid));

    try {
      const resp = await fetch(next.href, { credentials: 'include' });
      if (!resp.ok) {
        console.error('[Gelbooru Autopager] HTTP', resp.status);
        status.textContent = 'HTTP ' + resp.status;
        loading = false;
        return;
      }
      const text = await resp.text();
      const doc = new DOMParser().parseFromString(text, 'text/html');

      // на странице может быть метка "no results"
      const newContainer = findPostsContainer(doc);
      const newNodes = newContainer ? getPostNodes(newContainer) : [];

      if (!newNodes || newNodes.length === 0) {
        // попробуем найти rel=next — если нет, значит конец
        const rel = doc.querySelector('a[rel="next"], link[rel="next"]');
        if (!rel) {
          ended = true;
          status.textContent = 'No more posts';
          console.log('[Gelbooru Autopager] no more posts.');
          window.removeEventListener('scroll', onScroll);
        } else {
          status.textContent = 'No items parsed from page';
        }
        loading = false;
        return;
      }

      let appended = 0;
      const frag = document.createDocumentFragment();
      for (const nd of newNodes) {
        const cloned = document.importNode(nd, true);
        const id = extractPostId(cloned);
        if (id && seen.has(id)) continue;
        if (id) seen.add(id);

        absolutize(cloned, next.href);
        frag.appendChild(cloned);
        appended++;
      }

      if (appended > 0) {
        // разделитель
        const sep = document.createElement('div');
        sep.textContent = `— page pid=${pid} —`;
        sep.style.cssText = 'text-align:center;margin:12px 0;opacity:.6;font-size:12px';
        container.appendChild(sep);
        container.appendChild(frag);
        status.textContent = `Loaded pid=${pid} (+${appended})`;

        // обновим URL без создания новой записи в истории (чтобы Back возвращал на нужный pid)
        try {
          history.replaceState(null, '', next.pathname + next.search);
        } catch (e) { /* ignore */ }

        // сохраним состояние после успешной загрузки
        saveState();
      } else {
        status.textContent = 'No new posts (duplicates)';
      }
      // small delay to let images start loading
      setTimeout(() => { loading = false; }, 300);
    } catch (err) {
      console.error('[Gelbooru Autopager] error', err);
      status.textContent = 'Error (see console)';
      loading = false;
    }
  }

  function saveState() {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify({
        html: container.innerHTML,
        pid: pid,
        scroll: window.scrollY,
        ts: Date.now()
      }));
    } catch (e) {
      console.warn('[Gelbooru Autopager] save failed', e);
    }
  }

  // если страница короткая — подгрузим сразу пару страниц
  setTimeout(() => {
    if (autoEnabled) onScroll();
  }, 900);
})();