GoodLib

Adds quick-search chips for Z-Lib, Anna's Archive, Gutenberg, and AudiobookBay on Goodreads, StoryGraph, and Hardcover book pages

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         GoodLib
// @namespace    https://greasyfork.org/users/KosherKale
// @version      1.3.1
// @icon         https://i.imgur.com/WpdBKjf.png
// @description  Adds quick-search chips for Z-Lib, Anna's Archive, Gutenberg, and AudiobookBay on Goodreads, StoryGraph, and Hardcover book pages
// @author       kosherkale
// @match        https://www.goodreads.com/book/*
// @match        https://app.thestorygraph.com/*
// @match        https://hardcover.app/*
// @license      MIT
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.openInTab
// @grant        GM.addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict'

  const SOURCES = [
    {
      key: 'zlib',
      chipLabel: 'Z-Lib',    chipGlyph: 'z',
      popupAvatar: 'Z',      popupName: 'Z-Lib',
      enabledKey: 'zlibEnabled', mirrorKey: 'zlibMirror',
      mirrors: ['z-lib.gl', 'z-lib.id', 'z-lib.fm', 'singlelogin.re']
    },
    {
      key: 'anna',
      chipLabel: "Anna's",   chipGlyph: 'A',
      popupAvatar: 'A',      popupName: "Anna's",
      enabledKey: 'annaEnabled', mirrorKey: 'annaMirror',
      mirrors: ['annas-archive.gd', 'annas-archive.se', 'annas-archive.li']
    },
    {
      key: 'libgen',
      chipLabel: 'LibGen',   chipGlyph: 'LG',
      popupAvatar: 'LG',     popupName: 'LibGen',
      enabledKey: 'libgenEnabled', mirrorKey: 'libgenMirror',
      mirrors: ['libgen.li', 'libgen.rs', 'libgen.st']
    },
    {
      key: 'gutenberg',
      chipLabel: 'Gutenberg', chipGlyph: 'PG',
      popupAvatar: 'PG',     popupName: 'Project Gutenberg',
      enabledKey: 'gutenbergEnabled', mirrorKey: 'gutenbergMirror',
      mirrors: ['gutenberg.org']
    },
    {
      key: 'audiobook',
      chipLabel: 'AudiobookBay', chipGlyph: 'AB',
      popupAvatar: 'AB',     popupName: 'AudiobookBay',
      enabledKey: 'audiobookEnabled', mirrorKey: 'audiobookMirror',
      mirrors: ['audiobookbay.lu', 'audiobookbay.is'],
      titleOnly: true
    }
  ];

  // Derived lookups all keyed by source key string
  const SOURCE_MAP     = Object.fromEntries(SOURCES.map(s => [s.key, s]));
  const SOURCE_ORDER   = SOURCES.map(s => s.key);
  const ENABLED_KEYS   = Object.fromEntries(SOURCES.map(s => [s.key, s.enabledKey]));
  const MIRROR_KEYS    = Object.fromEntries(SOURCES.map(s => [s.key, s.mirrorKey]));
  const MIRRORS        = Object.fromEntries(SOURCES.map(s => [s.key, s.mirrors]));

  // Mutable state declared before async calls
  const selectedMirror  = Object.fromEntries(SOURCES.map(s => [s.key, s.mirrors[0]]));
  const enabledBySource = Object.fromEntries(SOURCES.map(s => [s.key, true]));
  const lastStored      = Object.fromEntries(SOURCES.map(s => [s.key, null]));

  // CSS
  const css = `
  .goodlib-settings-button{display:inline-flex;align-items:center;justify-content:center;height:34px;width:34px;border-radius:50%;border:1px solid rgba(0,0,0,0.08);background:transparent;margin-left:8px;cursor:pointer;padding:4px}
  .goodlib-settings-button:hover{box-shadow:0 6px 18px rgba(0,0,0,0.08);transform:rotate(10deg)}
  .goodlib-settings-button--storygraph{height:auto;width:auto;border-radius:0;border:none;border-bottom:4px solid transparent;padding:0 4px;padding-top:4px;margin-left:0;font-size:0.75rem;font-weight:600;color:inherit}
  .goodlib-settings-button--storygraph:hover{box-shadow:none;transform:none;border-bottom-color:#0d7377}
  html.dark .goodlib-settings-button--storygraph:hover{border-bottom-color:#06b6d4}
  .goodlib-settings-button--hardcover{display:inline-flex;align-items:center;gap:0.5rem;height:auto;width:auto;border-radius:0.75rem;border:none;padding:0.5rem 1rem;background:rgba(209,213,219,0.3);color:inherit;font-size:0.875rem;font-weight:600;margin:0.5rem 0;cursor:pointer;backdrop-filter:blur(4px);transition:background 0.15s}
  .goodlib-settings-button--hardcover:hover{box-shadow:none;transform:none;background:rgba(209,213,219,0.5)}
  html.dark .goodlib-settings-button--hardcover{background:rgba(17,24,39,0.3)}
  html.dark .goodlib-settings-button--hardcover:hover{background:rgba(17,24,39,0.5)}
  .goodlib-chevron{transition:transform 0.15s ease-out;display:inline}
  .goodlib-settings-button--hardcover.goodlib-open .goodlib-chevron{transform:rotate(180deg)}
  .goodlib-settings-popup{position:absolute;z-index:100000;background:linear-gradient(180deg,#fff,#fbfdff);border:1px solid rgba(0,0,0,0.06);padding:10px;border-radius:10px;min-width:260px;box-shadow:0 12px 36px rgba(16,20,24,0.12);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif}
  .goodlib-card-header{display:flex;align-items:center;justify-content:space-between;padding:4px 6px 8px;border-bottom:1px solid rgba(0,0,0,0.04);margin-bottom:6px}
  .goodlib-card-title{font-weight:800;font-size:13px;color:#1e1e1e}
  .goodlib-row{display:flex;align-items:center;gap:10px;padding:7px 6px;border-radius:8px}
  .goodlib-row + .goodlib-row{border-top:1px solid rgba(0,0,0,0.04)}
  .goodlib-avatar{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:12px}
  .goodlib-avatar.zlib{background:#3b82f6}
  .goodlib-avatar.anna{background:#00d1b2}
  .goodlib-avatar.gutenberg{background:#ffb703;color:#111}
  .goodlib-avatar.audiobook{background:#6f42c1}
  .goodlib-avatar.libgen{background:#e63946}
  .goodlib-meta{flex:1;min-width:0}
  .goodlib-name{font-weight:700;font-size:13px;color:#111}
  .goodlib-domain{font-size:11px;color:#8b95a1;margin-top:2px}
  .goodlib-toggle{display:inline-flex;align-items:center}
  .goodlib-toggle input{display:none}
  .goodlib-toggle .slider{width:40px;height:22px;border-radius:20px;background:#e6eef9;position:relative;transition:all 0.2s}
  .goodlib-toggle .slider:before{content:'';position:absolute;left:2px;top:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,0.12);transition:all 0.2s}
  .goodlib-toggle input:checked + .slider{background:#cfe8ff}
  .goodlib-toggle input:checked + .slider:before{transform:translateX(18px)}
  .goodlib-toggle[data-source="zlib"] input:checked + .slider{background:#3b82f6}
  .goodlib-toggle[data-source="anna"] input:checked + .slider{background:#00d1b2}
  .goodlib-toggle[data-source="gutenberg"] input:checked + .slider{background:#ffb703}
  .goodlib-toggle[data-source="audiobook"] input:checked + .slider{background:#6f42c1}
  .goodlib-toggle[data-source="libgen"] input:checked + .slider{background:#e63946}
  .goodlib-domain-select{font-size:11px;color:#8b95a1;margin-top:2px;border:none;background:transparent;cursor:pointer;padding:0;-webkit-appearance:none;appearance:none;font-family:inherit;outline:none;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;display:block}
  .goodlib-domain-select:hover{color:#4b86d8}
  .goodlib-chip-wrap{display:inline-flex;align-items:center;gap:10px;margin-left:10px}
  .goodlib-chip{display:inline-flex;align-items:center;height:28px;padding:0 12px 0 8px;border:1.5px solid rgba(0,0,0,0.2);border-radius:14px;background:transparent;text-decoration:none;color:inherit;font-size:11px;font-weight:700;line-height:1;vertical-align:middle;cursor:pointer;white-space:nowrap;position:relative;z-index:10;pointer-events:auto;transition:transform 0.25s cubic-bezier(0.175,0.885,0.32,1.275),border-color 0.25s cubic-bezier(0.175,0.885,0.32,1.275),box-shadow 0.25s cubic-bezier(0.175,0.885,0.32,1.275);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;transform:scale(1) translateY(0)}
  [data-goodlib-site="goodreads"] .goodlib-chip{border:1px solid rgba(0,0,0,0.06);background:rgba(255,255,255,0.85);backdrop-filter:blur(8px);color:rgb(51,51,51);box-shadow:0 1px 2px rgba(0,0,0,0.04)}
  [data-goodlib-site="goodreads"] .goodlib-chip:hover{background:rgba(255,255,255,0.96);border-color:rgba(0,0,0,0.15);box-shadow:0 4px 12px rgba(0,0,0,0.08);transform:scale(1.06) translateY(-1px)}
  [data-goodlib-site="goodreads"] .goodlib-chip-label{color:rgb(51,51,51)}
  [data-goodlib-site="storygraph"] .goodlib-chip--zlib{border-color:#3b82f6}
  [data-goodlib-site="storygraph"] .goodlib-chip--anna{border-color:#00d1b2}
  [data-goodlib-site="storygraph"] .goodlib-chip--gutenberg{border-color:#ffb703}
  [data-goodlib-site="storygraph"] .goodlib-chip--audiobook{border-color:#6f42c1}
  [data-goodlib-site="storygraph"] .goodlib-chip--libgen{border-color:#e63946}
  [data-goodlib-site="storygraph"] .goodlib-chip:hover{background:rgba(128,128,128,0.07);box-shadow:0 4px 12px rgba(0,0,0,0.08);transform:scale(1.06) translateY(-1px)}
  .goodlib-chip-icon{display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:#3b82f6;margin-right:8px;font-size:9px;font-weight:800;color:#fff;flex-shrink:0;line-height:1}
  .goodlib-chip--anna .goodlib-chip-icon{background:#00d1b2}
  .goodlib-chip--gutenberg .goodlib-chip-icon{background:#ffdd57;color:#333}
  .goodlib-chip--libgen .goodlib-chip-icon{background:#e63946}
  .goodlib-chip--audiobook .goodlib-chip-icon{background:#6f42c1}
  .goodlib-chip-glyph{font-size:9px;font-weight:800;line-height:1;transform:translateY(-1px) rotate(0deg);transition:transform 0.3s}
  .goodlib-chip-glyph--wide{font-size:8px;transform:translateY(-0.5px) rotate(0deg)}
  .goodlib-chip:hover .goodlib-chip-glyph{transform:translateY(-1px) rotate(15deg)}
  .goodlib-chip:hover .goodlib-chip-glyph--wide{transform:translateY(-0.5px) rotate(15deg)}
  .goodlib-chip-label{font-size:11px;font-weight:700;color:inherit;line-height:1;letter-spacing:-0.2px}
  .goodlib-chip-wrap--block{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}
  html.dark .goodlib-settings-button--goodreads{background:#252535;border-color:rgba(255,255,255,0.1)}
  html.dark .goodlib-settings-button--goodreads svg path{fill:#bbb !important}
  [data-goodlib-site="hardcover"] .goodlib-chip--zlib{border-color:#3b82f6}
  [data-goodlib-site="hardcover"] .goodlib-chip--anna{border-color:#00d1b2}
  [data-goodlib-site="hardcover"] .goodlib-chip--gutenberg{border-color:#ffb703}
  [data-goodlib-site="hardcover"] .goodlib-chip--audiobook{border-color:#6f42c1}
  [data-goodlib-site="hardcover"] .goodlib-chip--libgen{border-color:#e63946}
  [data-goodlib-site="hardcover"] .goodlib-chip:hover{background:rgba(128,128,128,0.07);box-shadow:0 4px 12px rgba(0,0,0,0.08);transform:scale(1.06) translateY(-1px)}
  .goodlib-settings-popup.goodlib-dark{border-color:rgba(255,255,255,0.08)}
  .goodlib-settings-popup.goodlib-dark .goodlib-card-title{color:#eaeaea}
  .goodlib-settings-popup.goodlib-dark .goodlib-card-header{border-bottom-color:rgba(255,255,255,0.06)}
  .goodlib-settings-popup.goodlib-dark .goodlib-row + .goodlib-row{border-top-color:rgba(255,255,255,0.06)}
  .goodlib-settings-popup.goodlib-dark .goodlib-name{color:#e0e0e0}
  .goodlib-settings-popup.goodlib-dark .goodlib-domain{color:#6e7a8a}
  .goodlib-settings-popup.goodlib-dark .goodlib-toggle .slider{background:#363650}
  .goodlib-settings-popup.goodlib-dark .goodlib-domain-select{color:#6e7a8a}
  .goodlib-settings-popup.goodlib-dark .goodlib-domain-select:hover{color:#7aa6d8}
  /* ── Hardcover-native popup style ───────────────────────────────── */
  .goodlib-popup--hardcover{background:#fff;border:none;outline:2px solid #e5e7eb;padding:6px 0;border-radius:0.5rem;box-shadow:0 10px 15px -3px rgba(0,0,0,0.1),0 4px 6px -2px rgba(0,0,0,0.05);min-width:300px}
  .goodlib-popup--hardcover .goodlib-card-header{padding:8px 12px 6px;border-bottom:1px solid #f3f4f6;margin-bottom:2px}
  .goodlib-popup--hardcover .goodlib-card-title{color:#111827}
  .goodlib-popup--hardcover .goodlib-row{padding:6px 8px;margin:0 4px;border-radius:8px;border-top:none;transition:background 0.15s}
  .goodlib-popup--hardcover .goodlib-row + .goodlib-row{border-top:none}
  .goodlib-popup--hardcover .goodlib-row:hover{background:#f3f4f6}
  .goodlib-popup--hardcover .goodlib-avatar{width:40px;height:40px;border-radius:8px;background:#f3f4f6;transition:background 0.15s,color 0.15s}
  .goodlib-popup--hardcover .goodlib-avatar.zlib{color:#3b82f6}
  .goodlib-popup--hardcover .goodlib-avatar.anna{color:#00d1b2}
  .goodlib-popup--hardcover .goodlib-avatar.gutenberg{color:#d97706;background:#f3f4f6}
  .goodlib-popup--hardcover .goodlib-avatar.audiobook{color:#6f42c1}
  .goodlib-popup--hardcover .goodlib-avatar.libgen{color:#e63946}
  .goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.zlib{background:#3b82f6;color:#fff}
  .goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.anna{background:#00d1b2;color:#fff}
  .goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.gutenberg{background:#d97706;color:#fff}
  .goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.audiobook{background:#6f42c1;color:#fff}
  .goodlib-popup--hardcover .goodlib-row:hover .goodlib-avatar.libgen{background:#e63946;color:#fff}
  .goodlib-popup--hardcover .goodlib-name{color:#1f2937;font-size:0.875rem}
  .goodlib-popup--hardcover .goodlib-domain,.goodlib-popup--hardcover .goodlib-domain-select{color:#4b5563;font-size:0.75rem}
  .goodlib-popup--hardcover .goodlib-domain-select:hover{color:#3b82f6}
  .goodlib-popup-nip{position:absolute;top:-10px;width:20px;height:10px;pointer-events:none}
  .goodlib-popup-nip::before{content:'';position:absolute;bottom:0;left:0;width:0;height:0;border-left:10px solid transparent;border-right:10px solid transparent;border-bottom:10px solid #e5e7eb}
  .goodlib-popup-nip::after{content:'';position:absolute;bottom:0;left:2px;width:0;height:0;border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:8px solid #fff}
  /* Hardcover dark */
  .goodlib-popup--hardcover.goodlib-dark{background:#111827;outline-color:#374151}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-card-header{border-bottom-color:#1f2937}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-card-title{color:#e5e7eb}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-row:hover{background:#1f2937}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-avatar{background:#1f2937}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-name{color:#e5e7eb}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-domain,.goodlib-popup--hardcover.goodlib-dark .goodlib-domain-select{color:#9ca3af}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-domain-select:hover{color:#60a5fa}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-footer{border-top-color:#1f2937}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-footer-label{color:#9ca3af}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-lang-select{background:#1f2937;border:none;color:#d1d5db}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-toggle .slider{background:#374151}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-popup-nip::before{border-bottom-color:#374151}
  .goodlib-popup--hardcover.goodlib-dark .goodlib-popup-nip::after{border-bottom-color:#111827}
`;

  if (typeof GM !== 'undefined' && typeof GM.addStyle === 'function') {
    GM.addStyle(css);
  } else {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }

  const SITE = location.hostname.includes('thestorygraph') ? 'storygraph'
             : location.hostname.includes('hardcover') ? 'hardcover'
             : 'goodreads';
  document.documentElement.setAttribute('data-goodlib-site', SITE);

  const CHIP_ATTR      = 'data-goodlib-chip';
  const CHIP_CLASS     = 'goodlib-chip';
  const CHIPS_WRAP_ATTR = 'data-goodlib-chip-wrap';

  const titleSelectors = SITE === 'storygraph'
    ? ['h3[class*="font-bold"]', 'h3[class*="font-body"]', 'h3']
    : SITE === 'hardcover'
    ? ['h1.font-serif', 'h1[class*="font-serif"]', 'h1']
    : ["h1[data-testid='bookTitle']", 'h1.Text__title1', 'h1'];

  const authorSelectors = SITE === 'storygraph' || SITE === 'hardcover'
    ? ['a[href*="/authors/"]']
    : ["a[data-testid='name']", "[data-testid='authorName']", ".ContributorLinksList a.ContributorLink", 'a.ContributorLink', 'span.ContributorLink__name'];

  function getBookTitle() {
    for (const selector of titleSelectors) {
      const node = document.querySelector(selector);
      if (node instanceof HTMLElement && node.innerText.trim().length > 0) return node;
    }
    return null;
  }

  function normalizeText(value) {
    return value.replace(/\s+/g, ' ').trim();
  }

  function getCleanBookTitle(title) {
    const clone = title.cloneNode(true);
    const injectedWrap = clone.querySelector('[' + CHIPS_WRAP_ATTR + ']');
    if (injectedWrap) injectedWrap.remove();
    return normalizeText(clone.textContent || '');
  }

  function getPrimaryAuthor() {
    for (const selector of authorSelectors) {
      const node = document.querySelector(selector);
      if (!(node instanceof HTMLElement)) continue;
      const text = normalizeText(node.innerText);
      if (text.length > 0) return text;
    }
    return '';
  }

  function buildSearchQuery(title, author) {
    return [title, author].filter(Boolean).join(' ');
  }

  function removeChip() {
    document.querySelectorAll('[' + CHIP_ATTR + ']').forEach(chip => chip.remove());
    const wrap = document.querySelector('[' + CHIPS_WRAP_ATTR + ']');
    if (wrap && wrap.childElementCount === 0) wrap.remove();
  }

  function buildSourceUrl(source, query) {
    const qLower = String(query).toLowerCase();
    const encoded = encodeURIComponent(qLower);
    const mirror = selectedMirror[source] || MIRRORS[source][0];

    if (source === 'anna')      return `https://${mirror}/search?q=${encoded}`;
    if (source === 'gutenberg') return `https://www.${mirror}/ebooks/search/?query=${encoded}`;
    if (source === 'libgen')    return `https://${mirror}/index.php?req=${encoded}&lg_topic=libgen&open=0&view=simple&res=25&phrase=1&column=def`;
    if (source === 'audiobook') {
      const encodedPlus = encoded.replace(/%20/g, '+');
      return `https://${mirror}/?s=${encodedPlus}&cat=undefined%2Cundefined`;
    }
    return `https://${mirror}/s/${encoded}`;
  }

  function makeChip(source, searchQuery) {
    const src = SOURCE_MAP[source];
    const chip = document.createElement('span');
    chip.setAttribute(CHIP_ATTR, source);
    chip.className = `${CHIP_CLASS} ${CHIP_CLASS}--${source}`;
    chip.setAttribute('data-search-query', searchQuery);

    const glyphClass = src.chipGlyph.length > 1
      ? 'goodlib-chip-glyph goodlib-chip-glyph--wide'
      : 'goodlib-chip-glyph';

    const icon = document.createElement('span');
    icon.className = 'goodlib-chip-icon';
    const glyphNode = document.createElement('span');
    glyphNode.className = glyphClass;
    glyphNode.textContent = src.chipGlyph;
    icon.appendChild(glyphNode);

    const label = document.createElement('span');
    label.className = 'goodlib-chip-label';
    label.textContent = src.chipLabel;

    chip.replaceChildren(icon, label);
    chip.addEventListener('click', () => {
      const query = chip.getAttribute('data-search-query') || searchQuery;
      window.open(buildSourceUrl(source, query), '_blank', 'noopener,noreferrer');
    });

    return chip;
  }

  function injectChips(enabledBySource) {
    const title = getBookTitle();
    if (!title) return;
    const bookTitle = getCleanBookTitle(title);
    if (!bookTitle) return;
    const primaryAuthor = getPrimaryAuthor();
    const searchQuery = buildSearchQuery(bookTitle, primaryAuthor);

    let wrap;
    if (SITE === 'storygraph' || SITE === 'hardcover') {
      const anchor = SITE === 'storygraph'
        ? document.querySelector('p.text-sm.font-light.text-darkestGrey')
        : document.querySelector('div.mt-2.lg\\:mt-0');
      if (!anchor) return;
      const next = anchor.nextElementSibling;
      if (next instanceof HTMLElement && next.hasAttribute(CHIPS_WRAP_ATTR)) {
        wrap = next;
      } else {
        wrap = document.createElement('div');
        wrap.setAttribute(CHIPS_WRAP_ATTR, 'true');
        wrap.className = 'goodlib-chip-wrap goodlib-chip-wrap--block';
        anchor.parentNode.insertBefore(wrap, anchor.nextSibling);
      }
    } else {
      wrap = title.querySelector('[' + CHIPS_WRAP_ATTR + ']');
      if (!(wrap instanceof HTMLElement)) {
        wrap = document.createElement('span');
        wrap.setAttribute(CHIPS_WRAP_ATTR, 'true');
        wrap.className = 'goodlib-chip-wrap';
        title.appendChild(wrap);
      }
    }

    const orderedChips = [];
    for (const source of SOURCE_ORDER) {
      if (!enabledBySource[source]) continue;
      const src = SOURCE_MAP[source];
      const q = src.titleOnly ? bookTitle : searchQuery;
      let chip = wrap.querySelector('[' + CHIP_ATTR + '="' + source + '"]');
      if (!(chip instanceof HTMLElement)) chip = makeChip(source, q);
      chip.setAttribute('data-search-query', q);
      orderedChips.push(chip);
    }

    const currentOrder = Array.from(wrap.children).filter(
      node => node instanceof HTMLElement && node.hasAttribute(CHIP_ATTR)
    );
    const needsReorder =
      currentOrder.length !== orderedChips.length ||
      currentOrder.some((node, i) => node !== orderedChips[i]);
    if (needsReorder) wrap.replaceChildren(...orderedChips);
  }

  function isAnyEnabled() {
    return SOURCE_ORDER.some(s => enabledBySource[s]);
  }

  function syncChipToState() {
    if (!isAnyEnabled()) { removeChip(); return; }
    injectChips(enabledBySource);
  }

  // Initialize from GM storage
  async function initializeEnabledState() {
    try {
      for (const source of SOURCE_ORDER) {
        const val = (typeof GM !== 'undefined' && GM.getValue)
          ? await GM.getValue(ENABLED_KEYS[source], enabledBySource[source])
          : enabledBySource[source];
        enabledBySource[source] = typeof val === 'boolean' ? val : enabledBySource[source];
        lastStored[source] = enabledBySource[source];
      }
      for (const source of SOURCE_ORDER) {
        const val = (typeof GM !== 'undefined' && GM.getValue)
          ? await GM.getValue(MIRROR_KEYS[source], MIRRORS[source][0])
          : MIRRORS[source][0];
        if (typeof val === 'string' && MIRRORS[source].includes(val)) selectedMirror[source] = val;
      }
      syncChipToState();
    } catch (err) {
      console.error('GoodLib userscript: failed to read stored settings', err);
      syncChipToState();
    }
  }

  initializeEnabledState();

  let injectTimeout = null;
  const observer = new MutationObserver(() => {
    if (!isAnyEnabled()) return;
    if (injectTimeout) clearTimeout(injectTimeout);
    injectTimeout = setTimeout(() => injectChips(enabledBySource), 120);
  });
  observer.observe(document.body, { childList: true, subtree: true });

  async function pollStorage() {
    try {
      let changed = false;
      for (const source of SOURCE_ORDER) {
        const val = (typeof GM !== 'undefined' && GM.getValue)
          ? await GM.getValue(ENABLED_KEYS[source], enabledBySource[source])
          : enabledBySource[source];
        if (val !== lastStored[source]) {
          enabledBySource[source] = typeof val === 'boolean' ? val : enabledBySource[source];
          lastStored[source] = val;
          changed = true;
        }
      }
      if (changed) syncChipToState();
    } catch (err) {}
  }

  setInterval(pollStorage, 1500);

  const GEAR_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 12.94a7.14 7.14 0 0 0 0-1.88l2.03-1.57a.5.5 0 0 0 .12-.65l-1.93-3.34a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.73-.99l-.36-2.54A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.49.42l-.36 2.54c-.6.24-1.17.56-1.73.99l-2.39-.96a.5.5 0 0 0-.6.22L1.71 8.83a.5.5 0 0 0 .12.65L3.86 11.05a7.14 7.14 0 0 0 0 1.9L1.83 14.52a.5.5 0 0 0-.12.65l1.93 3.34c.14.24.44.34.69.22l2.39-.96c.56.43 1.13.75 1.73.99l.36 2.54A.5.5 0 0 0 10 22h4a.5.5 0 0 0 .49-.42l.36-2.54c.6-.24 1.17-.56 1.73-.99l2.39.96c.25.12.55.02.69-.22l1.93-3.34a.5.5 0 0 0-.12-.65l-2.03-1.58zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z" fill="currentColor"/></svg>';
  const CHEVRON_SVG = '<svg width="12" height="12" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="goodlib-chevron"><path d="M233.4 406.6a32.05 32.05 0 0 0 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/></svg>';

  const createSettingsButton = () => {
    const btn = document.createElement('button');
    btn.className = `goodlib-settings-button goodlib-settings-button--${SITE}`;
    btn.type = 'button';
    btn.title = 'GoodLib Settings';
    btn.innerHTML = SITE === 'hardcover'
      ? `${GEAR_SVG} GoodLib ${CHEVRON_SVG}`
      : GEAR_SVG;
    return btn;
  };

  const createPopup = () => {
    const popup = document.createElement('div');
    popup.className = 'goodlib-settings-popup' + (SITE === 'hardcover' ? ' goodlib-popup--hardcover' : '');

    if (SITE === 'hardcover') {
      const nip = document.createElement('div');
      nip.className = 'goodlib-popup-nip';
      popup.prepend(nip);
    }

    const header = document.createElement('div');
    header.className = 'goodlib-card-header';
    header.innerHTML = '<span class="goodlib-card-title">Good<span style="color:#f0a500">LIB</span></span>';
    popup.appendChild(header);

    for (const src of SOURCES) {
      const row = document.createElement('div');
      row.className = 'goodlib-row';

      const avatar = document.createElement('span');
      avatar.className = `goodlib-avatar ${src.key}`;
      avatar.textContent = src.popupAvatar;

      const meta = document.createElement('div');
      meta.className = 'goodlib-meta';
      const nameEl = document.createElement('div');
      nameEl.className = 'goodlib-name';
      nameEl.textContent = src.popupName;
      meta.appendChild(nameEl);

      if (src.mirrors.length > 1) {
        const domainSelect = document.createElement('select');
        domainSelect.className = 'goodlib-domain goodlib-domain-select';
        domainSelect.setAttribute('data-mirror-source', src.key);
        for (const m of src.mirrors) {
          const opt = document.createElement('option');
          opt.value = m;
          opt.textContent = m;
          if (m === selectedMirror[src.key]) opt.selected = true;
          domainSelect.appendChild(opt);
        }
        meta.appendChild(domainSelect);
      } else {
        const domainEl = document.createElement('div');
        domainEl.className = 'goodlib-domain';
        domainEl.textContent = selectedMirror[src.key];
        meta.appendChild(domainEl);
      }

      const toggle = document.createElement('label');
      toggle.className = 'goodlib-toggle';
      toggle.setAttribute('data-source', src.key);
      toggle.innerHTML = `<input type="checkbox" data-key="${src.enabledKey}"><span class="slider"></span>`;

      row.append(avatar, meta, toggle);
      popup.appendChild(row);
    }

    return popup;
  };

  const setStored = async (key, value) => {
    try {
      if (typeof GM !== 'undefined' && GM.setValue) await GM.setValue(key, value);
      else localStorage.setItem(key, JSON.stringify(value));
    } catch (e) {
      try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
    }
  };

  const getStored = async (key, fallback) => {
    try {
      if (typeof GM !== 'undefined' && GM.getValue) return await GM.getValue(key, fallback);
      const raw = localStorage.getItem(key);
      return raw === null ? fallback : JSON.parse(raw);
    } catch (e) { return fallback; }
  };

  function getPageBg() {
    let bg = getComputedStyle(document.body).backgroundColor;
    if (!bg || bg === 'rgba(0, 0, 0, 0)') bg = getComputedStyle(document.documentElement).backgroundColor;
    if (!bg || bg === 'rgba(0, 0, 0, 0)') bg = '#ffffff';
    return bg;
  }

  function isPageDark() {
    if (SITE === 'hardcover') return document.documentElement.classList.contains('dark');
    const bg = getPageBg();
    const m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
    if (!m) return window.matchMedia('(prefers-color-scheme: dark)').matches;
    const lum = (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) / 255;
    return lum < 0.5;
  }

  let settingsPopup = null;
  let hcHoverTimeout = null;
  const hcClearHover = () => { if (hcHoverTimeout) { clearTimeout(hcHoverTimeout); hcHoverTimeout = null; } };
  const hcScheduleClose = () => { hcClearHover(); hcHoverTimeout = setTimeout(closeSettings, 150); };

  const openSettings = async (btn) => {
    if (!settingsPopup) settingsPopup = createPopup();

    // Sync checkboxes
    for (const inp of settingsPopup.querySelectorAll('input[type=checkbox]')) {
      const key = inp.getAttribute('data-key');
      const src = SOURCES.find(s => s.enabledKey === key);
      const val = await getStored(key, enabledBySource[src.key]);
      inp.checked = !!val;
      inp.onchange = async () => {
        const next = inp.checked;
        await setStored(key, next);
        enabledBySource[src.key] = next;
        syncChipToState();
      };
    }

    // Sync mirror sites selections
    for (const sel of settingsPopup.querySelectorAll('.goodlib-domain-select')) {
      const source = sel.getAttribute('data-mirror-source');
      sel.value = selectedMirror[source];
      sel.onchange = async () => {
        selectedMirror[source] = sel.value;
        await setStored(MIRROR_KEYS[source], sel.value);
        syncChipToState();
      };
    }

    btn.classList.add('goodlib-open');
    if (SITE !== 'hardcover') settingsPopup.style.background = getPageBg();
    settingsPopup.classList.toggle('goodlib-dark', isPageDark());

    document.body.appendChild(settingsPopup);
    const rect = btn.getBoundingClientRect();
    const popupWidth = settingsPopup.offsetWidth;
    const popupLeft  = Math.max(8, rect.right + window.scrollX - popupWidth);
    settingsPopup.style.top  = (rect.bottom + window.scrollY + 8) + 'px';
    settingsPopup.style.left = popupLeft + 'px';
    if (SITE === 'hardcover') {
      const nipEl = settingsPopup.querySelector('.goodlib-popup-nip');
      if (nipEl) nipEl.style.left = Math.max(8, Math.round(popupWidth - rect.width / 2) - 10) + 'px';
    }

    const onEsc = (e) => { if (e.key === 'Escape') closeSettings(); };
    document.addEventListener('keydown', onEsc);
    if (SITE === 'hardcover') {
      settingsPopup.addEventListener('mouseenter', hcClearHover);
      settingsPopup.addEventListener('mouseleave', hcScheduleClose);
      settingsPopup._cleanup = () => document.removeEventListener('keydown', onEsc);
    } else {
      const onDocClick = (e) => { if (!settingsPopup.contains(e.target) && e.target !== btn) closeSettings(); };
      document.addEventListener('click', onDocClick);
      settingsPopup._cleanup = () => {
        document.removeEventListener('click', onDocClick);
        document.removeEventListener('keydown', onEsc);
      };
    }
  };

  const closeSettings = () => {
    if (!settingsPopup) return;
    if (settingsPopup._cleanup) settingsPopup._cleanup();
    settingsPopup.remove();
    settingsPopup = null;
    document.querySelector('.goodlib-settings-button')?.classList.remove('goodlib-open');
  };

  const tryInsertSettingsButton = () => {
    if (document.querySelector('.goodlib-settings-button')) return;
    const btn = createSettingsButton();
    if (SITE === 'hardcover') {
      btn.addEventListener('mouseenter', () => { hcClearHover(); if (!settingsPopup) openSettings(btn); });
      btn.addEventListener('mouseleave', hcScheduleClose);
    } else {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        settingsPopup ? closeSettings() : openSettings(btn);
      });
    }

    if (SITE === 'storygraph') {
      const signInLink = document.querySelector('a[href="/users/sign_in"]');
      if (!signInLink) return;
      signInLink.parentNode.insertBefore(btn, signInLink.nextSibling);
    } else if (SITE === 'hardcover') {
      const discoverBtn = Array.from(document.querySelectorAll('button')).find(
        b => b.textContent.trim().startsWith('Discover')
      );
      if (discoverBtn) {
        const wrapperDiv = discoverBtn.parentElement;
        const groupDiv   = wrapperDiv?.parentElement;
        if (groupDiv?.parentElement) {
          groupDiv.parentElement.insertBefore(btn, groupDiv.nextSibling);
          return;
        }
      }
      // fallback before Login
      const loginBtn = Array.from(document.querySelectorAll('button')).find(
        b => b.textContent.trim() === 'Login'
      );
      if (!loginBtn) return;
      loginBtn.parentNode.insertBefore(btn, loginBtn);
    } else {
      const searchContainer = document.querySelector('.Header__searchContainer')
        || document.querySelector('.HeaderSearch')
        || document.querySelector('.Header__contents');
      if (!searchContainer) return;
      if (searchContainer.parentNode) searchContainer.parentNode.insertBefore(btn, searchContainer.nextSibling);
    }
  };

  tryInsertSettingsButton();
  const headerObserver = new MutationObserver(() => tryInsertSettingsButton());
  headerObserver.observe(document.body, { childList: true, subtree: true });

  // SPA navigation detection for StoryGraph and Hardcover
  if (SITE === 'storygraph' || SITE === 'hardcover') {
    let lastPath = location.pathname;

    const onSpaNavigate = () => {
      if (location.pathname === lastPath) return;
      lastPath = location.pathname;
      [300, 700, 1500].forEach(delay => {
        setTimeout(() => syncChipToState(), delay);
        setTimeout(() => tryInsertSettingsButton(), delay);
      });
    };

    const _push = history.pushState.bind(history);
    history.pushState = (...a) => { _push(...a); onSpaNavigate(); };
    const _replace = history.replaceState.bind(history);
    history.replaceState = (...a) => { _replace(...a); onSpaNavigate(); };
    window.addEventListener('popstate', onSpaNavigate);
  }

})();