Comick Source Linker

Link Comick chapters to alternative sources

2025/11/16のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Comick Source Linker
// @namespace    http://github.com/GooglyBlox
// @version      1.6
// @description  Link Comick chapters to alternative sources
// @author       GooglyBlox
// @match        https://comick.dev/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      localhost
// @connect      comick-source-api.notaspider.dev
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    const API_BASE_URL = "https://comick-source-api.notaspider.dev";
    const STORAGE_KEY_PREFIX = "comick_sources_";
    const SETTINGS_STORAGE_KEY = "comick_source_linker_settings";

    let sourcesCache = null;
    const chapterListCache = {};
    const CACHE_DURATION = 5 * 60 * 1000;

    const error = (...args) => console.error("[Comick Source Linker]", ...args);

    const getSettings = () => {
      try {
        const data = GM_getValue(SETTINGS_STORAGE_KEY);
        if (data) {
          return JSON.parse(data);
        }
      } catch (err) {
        error("Failed to get settings:", err);
      }
      return { enabledSources: {} };
    };

    const saveSettings = (settings) => {
      try {
        GM_setValue(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
      } catch (err) {
        error("Failed to save settings:", err);
      }
    };

    const getCachedChapters = async (url, source, comicId) => {
      const cacheKey = `${comicId}_${source}`;
      const cached = chapterListCache[cacheKey];

      // Avoid hammering the API for chapter lists we've already fetched
      if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
        return cached.chapters;
      }

      const result = await getChapters(url, source);
      chapterListCache[cacheKey] = {
        chapters: result.chapters,
        timestamp: Date.now(),
      };

      return result.chapters;
    };

    const getFaviconUrl = (baseUrl) => {
      try {
        const url = new URL(baseUrl);
        return `https://www.google.com/s2/favicons?sz=32&domain_url=${url.origin}`;
      } catch {
        return null;
      }
    };

    const createLoadingSpinner = (size = "w-5 h-5") => {
      return `
            <svg class="animate-spin ${size} text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
            </svg>
          `;
    };

    const setButtonLoading = (button, isLoading, loadingText = "") => {
      if (isLoading) {
        button.disabled = true;
        button.dataset.originalHtml = button.innerHTML;
        button.innerHTML = `
              <span class="flex items-center justify-center gap-2">
                ${createLoadingSpinner("w-4 h-4")}
                ${loadingText}
              </span>
            `;
      } else {
        button.disabled = false;
        button.innerHTML = button.dataset.originalHtml || button.innerHTML;
        delete button.dataset.originalHtml;
      }
    };

    const getSourceInfo = (sourceName) => {
      if (!sourcesCache) return null;
      return sourcesCache.sources.find((s) => s.name === sourceName);
    };

    const getStoredSources = (comicId) => {
      try {
        const data = GM_getValue(STORAGE_KEY_PREFIX + comicId);
        return data ? JSON.parse(data) : null;
      } catch (err) {
        error("Failed to get stored sources:", err);
        return null;
      }
    };

    const setStoredSources = (comicId, sources) => {
      try {
        GM_setValue(STORAGE_KEY_PREFIX + comicId, JSON.stringify(sources));
      } catch (err) {
        error("Failed to store sources:", err);
      }
    };

    const apiRequest = (endpoint, method = "GET", data = null) => {
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: method,
          url: `${API_BASE_URL}${endpoint}`,
          headers: {
            "Content-Type": "application/json",
          },
          data: data ? JSON.stringify(data) : null,
          onload: (response) => {
            try {
              const result = JSON.parse(response.responseText);
              resolve(result);
            } catch {
              reject(new Error("Failed to parse response"));
            }
          },
          onerror: (err) => reject(err),
          ontimeout: () => reject(new Error("Request timeout")),
        });
      });
    };

    const getSources = () => apiRequest("/api/sources");
    const searchSources = (query, sources = "all") =>
      apiRequest("/api/search", "POST", { query, source: sources });
    const getChapters = (url, source) =>
      apiRequest("/api/chapters", "POST", { url, source });

    const isComicPage = () => {
      const path = window.location.pathname;
      const parts = path.split("/").filter((p) => p);
      return parts.length === 2 && parts[0] === "comic";
    };

    const isChapterPage = () => {
      const path = window.location.pathname;
      const parts = path.split("/").filter((p) => p);
      return parts.length === 3 && parts[0] === "comic";
    };

    const extractComicInfo = () => {
      const titleElement = document.querySelector("h1");
      const title = titleElement ? titleElement.textContent.trim() : null;

      const aliasesElement = document.querySelector(
        '.text-gray-500.dark\\:text-gray-400[style*="max-height"]'
      );
      const aliases = aliasesElement
        ? aliasesElement.textContent
            .split("•")
            .map((a) => a.trim())
            .filter((a) => a)
        : [];

      const comicId = window.location.pathname.split("/")[2];

      return { title, aliases, comicId };
    };

    const extractChapterNumber = (chapterElement) => {
      const titleElement = chapterElement.querySelector("span.font-bold");
      if (!titleElement) return null;

      const match = titleElement.textContent.match(/Ch\.\s*(\d+(?:\.\d+)?)/i);
      return match ? parseFloat(match[1]) : null;
    };

    const extractCurrentChapter = () => {
      const infoElement = document.querySelector(
        ".rounded-md.bg-gray-50.dark\\:bg-gray-900"
      );
      if (!infoElement) return null;

      const chapterElement = infoElement.querySelector("h3");
      if (!chapterElement) return null;

      const match = chapterElement.textContent.match(
        /Chapter\s+(\d+(?:\.\d+)?)/i
      );
      return match ? parseFloat(match[1]) : null;
    };

    const createSourceButton = () => {
      const button = document.createElement("button");
      button.type = "button";
      button.className =
        "flex-none w-12 h-12 btn px-2 py-2 inline-flex items-center rounded font-medium shadow-sm focus:outline-none focus:ring-2 rounded-md px-3 py-2 text-sm leading-4 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:ring-blue-500";
      button.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
              <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
            </svg>
          `;
      button.title = "Manage Alternative Sources";
      return button;
    };

    const createModal = (comicInfo, onClose) => {
      const overlay = document.createElement("div");
      overlay.className = "fixed inset-0 z-50 overflow-y-auto";
      overlay.style.cssText = "background-color: rgba(0, 0, 0, 0.5);";

      // Add custom scrollbar styles
      const style = document.createElement("style");
      style.textContent = `
        .comick-modal-scrollbar::-webkit-scrollbar {
          width: 8px;
          height: 8px;
        }
        .comick-modal-scrollbar::-webkit-scrollbar-track {
          background: rgba(0, 0, 0, 0.1);
          border-radius: 4px;
        }
        .comick-modal-scrollbar::-webkit-scrollbar-thumb {
          background: rgba(0, 0, 0, 0.3);
          border-radius: 4px;
        }
        .comick-modal-scrollbar::-webkit-scrollbar-thumb:hover {
          background: rgba(0, 0, 0, 0.5);
        }
        .dark .comick-modal-scrollbar::-webkit-scrollbar-track {
          background: rgba(255, 255, 255, 0.05);
        }
        .dark .comick-modal-scrollbar::-webkit-scrollbar-thumb {
          background: rgba(255, 255, 255, 0.2);
        }
        .dark .comick-modal-scrollbar::-webkit-scrollbar-thumb:hover {
          background: rgba(255, 255, 255, 0.3);
        }
      `;
      document.head.appendChild(style);

      const settings = getSettings();
      const availableSources = sourcesCache?.sources || [];

      // First time setup: enable all sources by default
      if (availableSources.length > 0 && Object.keys(settings.enabledSources).length === 0) {
        availableSources.forEach(source => {
          settings.enabledSources[source.name] = true;
        });
      }

      const scanlators = availableSources.filter(s => s.type === "scanlator");
      const aggregators = availableSources.filter(s => s.type === "aggregator");

      const createSourceHtml = (source) => `
        <label class="flex items-center gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg cursor-pointer border border-gray-200 dark:border-gray-600 transition-colors">
          <input type="checkbox" class="source-toggle w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" data-source="${source.name}" ${settings.enabledSources[source.name] !== false ? 'checked' : ''}>
          <div class="flex-1 min-w-0">
            <div class="font-medium text-gray-900 dark:text-gray-100 truncate">${source.name}</div>
            <div class="text-sm text-gray-500 dark:text-gray-400 truncate">${source.baseUrl}</div>
          </div>
        </label>
      `;

      overlay.innerHTML = `
            <div class="flex items-center justify-center min-h-screen p-4">
              <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
                <div id="modal-header" class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
                  <h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Alternative Sources</h2>
                  <div class="flex items-center gap-2">
                    <button class="settings-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" title="Settings">
                      <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
                      </svg>
                    </button>
                    <button class="close-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
                      <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                      </svg>
                    </button>
                  </div>
                </div>
                <div id="modal-content" class="p-6 flex-1 overflow-y-auto comick-modal-scrollbar">
                  <div id="search-view">
                    <div class="mb-4">
                      <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
                        Searching for: <strong>${comicInfo.title}</strong>
                      </p>
                      <div class="flex gap-2 items-center">
                        <label class="text-sm text-gray-600 dark:text-gray-400">Use alias:</label>
                        <select id="alias-selector" class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
                          <option value="${comicInfo.title}">${comicInfo.title}</option>
                          ${comicInfo.aliases.map(alias => `<option value="${alias}">${alias}</option>`).join("")}
                        </select>
                        <button id="search-btn" class="px-4 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded">
                          Search
                        </button>
                      </div>
                    </div>
                    <div id="search-status" class="mb-4 text-sm"></div>
                    <!-- Search Results Tabs -->
                    <div class="border-b border-gray-200 dark:border-gray-700 mb-4">
                      <nav class="flex -mb-px space-x-8" aria-label="Search Results">
                        <button type="button" class="search-results-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm border-blue-500 text-blue-600 dark:text-blue-400" data-tab="aggregators">
                          Aggregators
                        </button>
                        <button type="button" class="search-results-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" data-tab="scanlators">
                          Scanlators
                        </button>
                      </nav>
                    </div>
                    <!-- Tab Contents -->
                    <div id="search-results-aggregators" class="search-results-content overflow-y-auto max-h-96 comick-modal-scrollbar"></div>
                    <div id="search-results-scanlators" class="search-results-content overflow-y-auto max-h-96 hidden comick-modal-scrollbar"></div>
                  </div>
                  <div id="settings-view" class="hidden">
                    <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
                      Select which sources to search. Disabling unused sources can improve search performance.
                    </p>
                    <!-- Tab Navigation -->
                    <div class="border-b border-gray-200 dark:border-gray-700 mb-4">
                      <nav class="flex -mb-px space-x-8" aria-label="Tabs">
                        <button type="button" class="source-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm border-blue-500 text-blue-600 dark:text-blue-400" data-tab="all">
                          All Sources (${availableSources.length})
                        </button>
                        <button type="button" class="source-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" data-tab="aggregators">
                          Aggregators (${aggregators.length})
                        </button>
                        <button type="button" class="source-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" data-tab="scanlators">
                          Scanlators (${scanlators.length})
                        </button>
                      </nav>
                    </div>
                    <!-- Tab Content -->
                    <div id="tab-content-all" class="source-tab-content space-y-2 overflow-y-auto max-h-96 comick-modal-scrollbar">
                      ${availableSources.map(createSourceHtml).join("")}
                    </div>
                    <div id="tab-content-aggregators" class="source-tab-content hidden space-y-2 overflow-y-auto max-h-96 comick-modal-scrollbar">
                      ${aggregators.map(createSourceHtml).join("")}
                    </div>
                    <div id="tab-content-scanlators" class="source-tab-content hidden space-y-2 overflow-y-auto max-h-96 comick-modal-scrollbar">
                      ${scanlators.map(createSourceHtml).join("")}
                    </div>
                  </div>
                </div>
                <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
                  <button id="cancel-btn" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700">
                    Cancel
                  </button>
                  <button id="save-btn" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded">
                    Save Selected
                  </button>
                </div>
              </div>
            </div>
          `;

      const closeHandler = () => {
        onClose();
      };

      const modalHeader = overlay.querySelector("#modal-header");
      const searchView = overlay.querySelector("#search-view");
      const settingsView = overlay.querySelector("#settings-view");
      const settingsBtn = overlay.querySelector(".settings-btn");
      const saveBtn = overlay.querySelector("#save-btn");

      const showSettings = () => {
        searchView.classList.add("hidden");
        settingsView.classList.remove("hidden");

        modalHeader.innerHTML = `
          <div class="flex items-center gap-3">
            <button class="back-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
              </svg>
            </button>
            <h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Source Settings</h2>
          </div>
          <button class="close-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        `;

        saveBtn.textContent = "Save Settings";

        modalHeader.querySelector(".back-btn").addEventListener("click", showSearch);
        modalHeader.querySelector(".close-btn").addEventListener("click", closeHandler);

        const tabButtons = settingsView.querySelectorAll(".source-tab");
        const tabContents = settingsView.querySelectorAll(".source-tab-content");

        tabButtons.forEach(button => {
          button.addEventListener("click", () => {
            const targetTab = button.dataset.tab;

            tabButtons.forEach(btn => {
              if (btn === button) {
                btn.classList.remove("border-transparent", "text-gray-500", "dark:text-gray-400");
                btn.classList.add("border-blue-500", "text-blue-600", "dark:text-blue-400");
              } else {
                btn.classList.remove("border-blue-500", "text-blue-600", "dark:text-blue-400");
                btn.classList.add("border-transparent", "text-gray-500", "dark:text-gray-400");
              }
            });

            tabContents.forEach(content => {
              if (content.id === `tab-content-${targetTab}`) {
                content.classList.remove("hidden");
              } else {
                content.classList.add("hidden");
              }
            });
          });
        });
      };

      const showSearch = () => {
        searchView.classList.remove("hidden");
        settingsView.classList.add("hidden");

        modalHeader.innerHTML = `
          <h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Alternative Sources</h2>
          <div class="flex items-center gap-2">
            <button class="settings-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" title="Settings">
              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
              </svg>
            </button>
            <button class="close-btn text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>
          </div>
        `;

        saveBtn.textContent = "Save Selected";

        modalHeader.querySelector(".settings-btn").addEventListener("click", showSettings);
        modalHeader.querySelector(".close-btn").addEventListener("click", closeHandler);
      };

      overlay.querySelector(".close-btn").addEventListener("click", closeHandler);
      overlay.querySelector("#cancel-btn").addEventListener("click", closeHandler);
      overlay.addEventListener("click", (e) => {
        if (e.target === overlay) closeHandler();
      });

      settingsBtn.addEventListener("click", showSettings);

      const searchResultsTabs = overlay.querySelectorAll(".search-results-tab");
      const searchResultsContents = overlay.querySelectorAll(".search-results-content");

      searchResultsTabs.forEach(button => {
        button.addEventListener("click", () => {
          const targetTab = button.dataset.tab;

          searchResultsTabs.forEach(btn => {
            if (btn === button) {
              btn.classList.remove("border-transparent", "text-gray-500", "dark:text-gray-400");
              btn.classList.add("border-blue-500", "text-blue-600", "dark:text-blue-400");
            } else {
              btn.classList.remove("border-blue-500", "text-blue-600", "dark:text-blue-400");
              btn.classList.add("border-transparent", "text-gray-500", "dark:text-gray-400");
            }
          });

          searchResultsContents.forEach(content => {
            if (content.id === `search-results-${targetTab}`) {
              content.classList.remove("hidden");
            } else {
              content.classList.add("hidden");
            }
          });
        });
      });

      return overlay;
    };

    const displaySearchResults = (resultsContainer, sources) => {
      if (!sources || sources.length === 0) {
        resultsContainer.innerHTML =
          '<p class="text-gray-500">No results found</p>';
        return;
      }

      resultsContainer.innerHTML = sources
        .map((source) => {
          if (!source.results || source.results.length === 0) return "";

          return `
              <div class="mb-4 border border-gray-200 dark:border-gray-700 rounded p-3">
                <h3 class="font-semibold mb-2 text-gray-900 dark:text-gray-100">${
                  source.source
                }</h3>
                <div class="space-y-2">
                  ${source.results
                    .map(
                      (result) => `
                    <label class="flex items-start gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer">
                      <input type="checkbox" class="source-checkbox mt-1"
                        data-source="${source.source}"
                        data-url="${result.url}"
                        data-title="${result.title}">
                      <div class="flex-1">
                        <div class="font-medium text-gray-900 dark:text-gray-100">${result.title}</div>
                        <div class="text-sm text-gray-600 dark:text-gray-400">
                          Latest: Ch. ${result.latestChapter} | Updated: ${result.lastUpdated}
                        </div>
                        <div class="text-xs text-gray-500 dark:text-gray-500 mt-1">${result.url}</div>
                      </div>
                    </label>
                  `
                    )
                    .join("")}
                </div>
              </div>
            `;
        })
        .join("");
    };

    const handleComicPage = async () => {
      const comicInfo = extractComicInfo();
      if (!comicInfo.title) return;

      const buttonsContainer = document.querySelector(
        ".flex.items-center.w-full.md\\:max-w-md.xl\\:max-w-xl.space-x-3"
      );
      if (!buttonsContainer) return;

      if (!buttonsContainer.querySelector(".source-linker-btn")) {
        const sourceButton = createSourceButton();
        sourceButton.classList.add("source-linker-btn");
        buttonsContainer.appendChild(sourceButton);

        sourceButton.addEventListener("click", async () => {
          setButtonLoading(sourceButton, true);

          const currentComicInfo = extractComicInfo();
          const modal = createModal(currentComicInfo, () => {
            document.body.removeChild(modal);
            setButtonLoading(sourceButton, false);
          });
          document.body.appendChild(modal);

          const searchBtn = modal.querySelector("#search-btn");
          const aliasSelector = modal.querySelector("#alias-selector");
          const searchStatus = modal.querySelector("#search-status");
          const searchResultsScanlators = modal.querySelector("#search-results-scanlators");
          const searchResultsAggregators = modal.querySelector("#search-results-aggregators");
          const saveBtn = modal.querySelector("#save-btn");

          const storedSources = getStoredSources(currentComicInfo.comicId);

          const performSearch = async () => {
            const query = aliasSelector.value;
            setButtonLoading(searchBtn, true, "Searching...");

            const settings = getSettings();

            const scanlatorSources = (sourcesCache?.sources || []).filter(s => s.type === "scanlator");
            const aggregatorSources = (sourcesCache?.sources || []).filter(s => s.type === "aggregator");

            const enabledScanlators = scanlatorSources
              .filter(s => settings.enabledSources[s.name] !== false)
              .map(s => s.name);
            const enabledAggregators = aggregatorSources
              .filter(s => settings.enabledSources[s.name] !== false)
              .map(s => s.name);

            searchStatus.innerHTML =
              `<p class="text-blue-500">Searching ${enabledScanlators.length} scanlator(s) and ${enabledAggregators.length} aggregator(s)...</p>`;
            searchResultsScanlators.innerHTML = "";
            searchResultsAggregators.innerHTML = "";

            try {
              const [scanlatorsResults, aggregatorsResults] = await Promise.all([
                Promise.all(enabledScanlators.map(async (sourceName) => {
                  try {
                    const result = await searchSources(query, sourceName);
                    return {
                      source: sourceName,
                      results: result.results || []
                    };
                  } catch (err) {
                    error(`Failed to search ${sourceName}:`, err);
                    return {
                      source: sourceName,
                      results: [],
                      error: err instanceof Error ? err.message : "Search failed"
                    };
                  }
                })),
                Promise.all(enabledAggregators.map(async (sourceName) => {
                  try {
                    const result = await searchSources(query, sourceName);
                    return {
                      source: sourceName,
                      results: result.results || []
                    };
                  } catch (err) {
                    error(`Failed to search ${sourceName}:`, err);
                    return {
                      source: sourceName,
                      results: [],
                      error: err instanceof Error ? err.message : "Search failed"
                    };
                  }
                }))
              ]);

              searchStatus.innerHTML =
                '<p class="text-green-500">Search complete!</p>';
              displaySearchResults(searchResultsScanlators, scanlatorsResults);
              displaySearchResults(searchResultsAggregators, aggregatorsResults);

              if (storedSources) {
                storedSources.forEach((stored) => {
                  const checkboxScanlator = searchResultsScanlators.querySelector(
                    `input[data-source="${stored.source}"][data-url="${stored.url}"]`
                  );
                  const checkboxAggregator = searchResultsAggregators.querySelector(
                    `input[data-source="${stored.source}"][data-url="${stored.url}"]`
                  );
                  if (checkboxScanlator) checkboxScanlator.checked = true;
                  if (checkboxAggregator) checkboxAggregator.checked = true;
                });
              }
            } catch (err) {
              error("Search failed:", err);
              searchStatus.innerHTML =
                '<p class="text-red-500">Search failed. Please try again.</p>';
            } finally {
              setButtonLoading(searchBtn, false);
            }
          };

          searchBtn.addEventListener("click", performSearch);

          saveBtn.addEventListener("click", async () => {
            setButtonLoading(saveBtn, true, "Saving...");

            try {
              const settingsCheckboxes = modal.querySelectorAll(".source-toggle");
              const newSettings = { enabledSources: {} };

              settingsCheckboxes.forEach(checkbox => {
                newSettings.enabledSources[checkbox.dataset.source] = checkbox.checked;
              });

              saveSettings(newSettings);

              const checkboxesScanlators = searchResultsScanlators.querySelectorAll(".source-checkbox");
              const checkboxesAggregators = searchResultsAggregators.querySelectorAll(".source-checkbox");
              const allCheckboxes = [...checkboxesScanlators, ...checkboxesAggregators];

              const currentResults = Array.from(allCheckboxes).map((cb) => ({
                source: cb.dataset.source,
                url: cb.dataset.url,
                title: cb.dataset.title,
                checked: cb.checked,
              }));

              const existingStored = storedSources || [];

              // Build a set of current search results for quick lookup
              const currentResultKeys = new Set(
                currentResults.map((r) => `${r.source}:${r.url}`)
              );

              // Keep sources from previous searches that aren't in current results
              const sourcesToKeep = existingStored.filter(
                (stored) =>
                  !currentResultKeys.has(`${stored.source}:${stored.url}`)
              );

              const newlySelected = currentResults
                .filter((r) => r.checked)
                .map((r) => ({
                  source: r.source,
                  url: r.url,
                  title: r.title,
                }));

              const finalSources = [...sourcesToKeep, ...newlySelected];

              setStoredSources(currentComicInfo.comicId, finalSources);

              if (finalSources.length > 0) {
                await addChapterIcons(currentComicInfo.comicId, finalSources);
              }

              document.body.removeChild(modal);
              setButtonLoading(sourceButton, false);
            } catch (err) {
              error("Failed to save:", err);
              setButtonLoading(saveBtn, false);
            }
          });

          try {
            await performSearch();
          } finally {
            setButtonLoading(sourceButton, false);
          }
        });
      }

      const storedSources = getStoredSources(comicInfo.comicId);
      if (storedSources && storedSources.length > 0) {
        await addChapterIcons(comicInfo.comicId, storedSources);

        const chapterTable = document.querySelector("table.table-fixed tbody");
        if (chapterTable) {
          if (window.chapterListObserver) {
            window.chapterListObserver.disconnect();
          }

          let isUpdating = false;

          window.chapterListObserver = new MutationObserver((mutations) => {
            if (isUpdating) return;

            // Ignore changes to our own aggregate rows to avoid infinite loops
            const hasNonAggregateChanges = mutations.some(mutation => {
              return Array.from(mutation.addedNodes).some(node =>
                node.nodeType === 1 && !node.classList.contains('aggregate-chapter-row')
              ) || Array.from(mutation.removedNodes).some(node =>
                node.nodeType === 1 && !node.classList.contains('aggregate-chapter-row')
              );
            });

            if (hasNonAggregateChanges) {
              isUpdating = true;
              addChapterIcons(comicInfo.comicId, storedSources).finally(() => {
                isUpdating = false;
              });
            }
          });

          window.chapterListObserver.observe(chapterTable, {
            childList: true,
            subtree: false,
          });
        }
      }
    };

    const createAggregateChapterRow = (chapterNum, sourcesWithChapter) => {
      const firstSource = sourcesWithChapter[0];
      const lastUpdated = firstSource.chapter.lastUpdated || "Unknown";

      const iconsHtml = sourcesWithChapter
        .map(({ source, chapter }) => {
          const sourceInfo = getSourceInfo(source);
          const faviconUrl = sourceInfo ? getFaviconUrl(sourceInfo.baseUrl) : null;

          if (faviconUrl) {
            return `
              <a href="${chapter.url}" target="_blank"
                class="inline-block w-5 h-5 ml-1 hover:opacity-75"
                title="${source} - Ch. ${chapter.number}">
                <img src="${faviconUrl}" alt="${source}" class="w-full h-full rounded" />
              </a>
            `;
          } else {
            return `
              <a href="${chapter.url}" target="_blank"
                class="inline-block w-5 h-5 rounded-full bg-blue-500 text-white text-xs leading-5 text-center ml-1 hover:bg-blue-600"
                title="${source} - Ch. ${chapter.number}">
                ${source.charAt(0).toUpperCase()}
              </a>
            `;
          }
        })
        .join("");

      const tr = document.createElement("tr");
      tr.className = "group border-t dark:border-gray-600 border-gray-300 aggregate-chapter-row";
      tr.style.backgroundColor = "rgba(59, 130, 246, 0.05)";

      tr.innerHTML = `
        <td class="customclass1 left-0 break-all cursor-pointer flex items-center justify-between group">
          <div class="py-3 w-full link-effect-no-ring dark:visited:text-gray-500 visited:text-gray-400 text-black dark:text-gray-200 flex items-center justify-between active:bg-blue-500/30 hover:bg-gray-100 dark:hover:bg-gray-700">
            <div class="truncate">
              <i class="fi mr-2 fi-gb rounded"></i><span class="font-bold" title="Chapter ${chapterNum}">Ch. ${chapterNum}</span><span class="source-icons ml-2">${iconsHtml}</span>
            </div>
          </div>
        </td>
        <td class="text-gray-600 text-right dark:text-gray-400 text-xs md:text-sm lg:text-base">
          <time class="cursor-pointer">${lastUpdated}</time>
        </td>
        <td class="pl-3 xl:pl-8 text-right text-gray-600 dark:text-gray-400 text-xs md:text-sm lg:text-base flex items-center justify-between">
          <div class="flex">
            <div class="w-32 md:w-40 lg:w-48 xl:w-56 text-left truncate">
              <span class="text-gray-500 dark:text-gray-500">Aggregate Source</span>
            </div>
            <div></div>
          </div>
          <div></div>
        </td>
      `;

      return tr;
    };

    const isOnFirstPage = () => {
      const currentPageLink = document.querySelector('nav[aria-label="pagination"] a[aria-current="page"]');
      if (!currentPageLink) return true;

      const pageText = currentPageLink.textContent.trim();
      return pageText === "1";
    };

    const addChapterIcons = async (comicId, sources) => {
      const sourceChapters = {};

      for (const source of sources) {
        try {
          sourceChapters[source.source] = await getCachedChapters(
            source.url,
            source.source,
            comicId
          );
        } catch (err) {
          error(`Failed to fetch chapters for ${source.source}:`, err);
        }
      }

      const chapterRows = document.querySelectorAll("table.table-fixed tbody tr:not(.aggregate-chapter-row)");
      const existingChapterNumbers = new Set();

      chapterRows.forEach((row) => {
        const chapterNum = extractChapterNumber(row);
        if (chapterNum !== null) {
          existingChapterNumbers.add(chapterNum);
        }
      });

      const tbody = document.querySelector("table.table-fixed tbody");

      if (isOnFirstPage()) {
        const highestComickChapter = existingChapterNumbers.size > 0
          ? Math.max(...Array.from(existingChapterNumbers))
          : 0;

        const newChaptersMap = new Map();

        // Find chapters on alternative sources that aren't on Comick yet
        for (const [sourceName, chapters] of Object.entries(sourceChapters)) {
          for (const chapter of chapters) {
            if (chapter.number > highestComickChapter && !existingChapterNumbers.has(chapter.number)) {
              if (!newChaptersMap.has(chapter.number)) {
                newChaptersMap.set(chapter.number, []);
              }
              newChaptersMap.get(chapter.number).push({
                source: sourceName,
                chapter: chapter,
              });
            }
          }
        }

        if (tbody) {
          tbody.querySelectorAll(".aggregate-chapter-row").forEach(row => row.remove());

          const sortedNewChapters = Array.from(newChaptersMap.entries()).sort((a, b) => b[0] - a[0]);

          const firstChapterRow = tbody.querySelector("tr:not(.aggregate-chapter-row)");

          for (const [chapterNum, sourcesWithChapter] of sortedNewChapters) {
            const newRow = createAggregateChapterRow(chapterNum, sourcesWithChapter);
            if (firstChapterRow) {
              tbody.insertBefore(newRow, firstChapterRow);
            } else {
              tbody.appendChild(newRow);
            }
          }
        }
      } else {
        if (tbody) {
          tbody.querySelectorAll(".aggregate-chapter-row").forEach(row => row.remove());
        }
      }

      chapterRows.forEach((row) => {
        const chapterNum = extractChapterNumber(row);
        if (chapterNum === null) return;

        const availableSources = [];
        for (const [sourceName, chapters] of Object.entries(sourceChapters)) {
          // Fuzzy match chapter numbers (handles 1 vs 1.0, etc.)
          const matchingChapter = chapters.find(
            (ch) => Math.abs(ch.number - chapterNum) < 0.01
          );
          if (matchingChapter) {
            availableSources.push({
              source: sourceName,
              chapter: matchingChapter,
            });
          }
        }

        if (availableSources.length === 0) return;

        const titleCell = row.querySelector("td:first-child .truncate");
        if (!titleCell) return;

        const existingIcons = titleCell.querySelector(".source-icons");
        if (existingIcons) existingIcons.remove();

        const iconsContainer = document.createElement("span");
        iconsContainer.className = "source-icons ml-2";
        iconsContainer.innerHTML = availableSources
          .map(({ source, chapter }) => {
            const sourceInfo = getSourceInfo(source);
            const faviconUrl = sourceInfo
              ? getFaviconUrl(sourceInfo.baseUrl)
              : null;

            if (faviconUrl) {
              return `
                  <a href="${chapter.url}" target="_blank"
                    class="inline-block w-5 h-5 ml-1 hover:opacity-75"
                    title="${source} - Ch. ${chapter.number}">
                    <img src="${faviconUrl}" alt="${source}" class="w-full h-full rounded" />
                  </a>
                `;
            } else {
              return `
                  <a href="${chapter.url}" target="_blank"
                    class="inline-block w-5 h-5 rounded-full bg-blue-500 text-white text-xs leading-5 text-center ml-1 hover:bg-blue-600"
                    title="${source} - Ch. ${chapter.number}">
                    ${source.charAt(0).toUpperCase()}
                  </a>
                `;
            }
          })
          .join("");

        titleCell.appendChild(iconsContainer);
      });
    };

    const handleChapterPage = async () => {
      const pathParts = window.location.pathname.split("/");
      const comicId = pathParts[2];
      const currentChapter = extractCurrentChapter();

      if (!currentChapter) return;

      const storedSources = getStoredSources(comicId);
      if (!storedSources || storedSources.length === 0) return;

      const infoContainer = document.querySelector(
        ".rounded-md.bg-gray-50.dark\\:bg-gray-900"
      );
      if (!infoContainer) return;

      if (infoContainer.querySelector(".alt-source-links-container")) return;

      const linksContainer = document.createElement("div");
      linksContainer.className =
        "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 alt-source-links-container";
      linksContainer.innerHTML = `
            <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Read on alternative sources:</h4>
            <div id="alt-source-links" class="flex flex-wrap gap-2">
              <div class="flex items-center gap-2 text-sm text-gray-500">
                ${createLoadingSpinner("w-4 h-4")}
                <span>Loading alternative sources...</span>
              </div>
            </div>
          `;

      const buttonsDiv = infoContainer.querySelector(".flex.flex-col.gap-2");
      if (buttonsDiv) {
        buttonsDiv.parentNode.insertBefore(linksContainer, buttonsDiv);
      } else {
        infoContainer.appendChild(linksContainer);
      }

      const linksDiv = linksContainer.querySelector("#alt-source-links");
      const links = [];
      for (const source of storedSources) {
        try {
          const chapters = await getCachedChapters(
            source.url,
            source.source,
            comicId
          );
          const matchingChapter = chapters.find(
            (ch) => Math.abs(ch.number - currentChapter) < 0.01
          );

          if (matchingChapter) {
            links.push({
              source: source.source,
              url: matchingChapter.url,
              title: matchingChapter.title || `Chapter ${matchingChapter.number}`,
            });
          }
        } catch (err) {
          error(`Failed to fetch chapters for ${source.source}:`, err);
        }
      }

      if (links.length === 0) {
        linksDiv.innerHTML =
          '<p class="text-sm text-gray-500">No matching chapters found on alternative sources</p>';
        return;
      }

      linksDiv.innerHTML = links
        .map((link) => {
          const sourceInfo = getSourceInfo(link.source);
          const faviconUrl = sourceInfo
            ? getFaviconUrl(sourceInfo.baseUrl)
            : null;

          return `
              <a href="${link.url}" target="_blank" rel="noreferrer">
                <button type="button" class="rounded-md bg-blue-200 dark:bg-blue-900 px-2 py-1.5 text-sm font-medium text-blue-800 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 focus:ring-offset-blue-50 flex items-center cursor-pointer gap-2">
                  ${
                    faviconUrl
                      ? `<img src="${faviconUrl}" alt="${link.source}" class="w-4 h-4" />`
                      : ""
                  }
                  <span>${link.source}</span>
                  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon" class="w-4 h-4 shrink-0">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"></path>
                  </svg>
                </button>
              </a>
            `;
        })
        .join("");
    };

    let currentUrl = window.location.href;
    let isInitialized = false;

    const init = async () => {
      if (!sourcesCache) {
        try {
          sourcesCache = await getSources();
        } catch (err) {
          error("Failed to load sources:", err);
          alert(
            "Failed to connect to the API. Please make sure the API server is running at " +
              API_BASE_URL
          );
          return;
        }
      }

      isInitialized = true;
      handlePageLoad();
    };

    const handlePageLoad = () => {
      if (isComicPage()) {
        setTimeout(() => handleComicPage(), 500);
      } else if (isChapterPage()) {
        setTimeout(() => handleChapterPage(), 500);
      } else {
        if (window.chapterListObserver) {
          window.chapterListObserver.disconnect();
          window.chapterListObserver = null;
        }
      }
    };

    const setupUrlChangeDetection = () => {
      setInterval(() => {
        if (currentUrl !== window.location.href) {
          currentUrl = window.location.href;
          if (isInitialized) handlePageLoad();
        }
      }, 500);

      window.addEventListener("popstate", () => {
        if (isInitialized) handlePageLoad();
      });
    };

    const start = async () => {
      setupUrlChangeDetection();
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
      } else {
        await init();
      }
    };

    start();
  })();