Search the Ships (Enhanced UI v7)

Adds a beautifully designed button to book-related websites to search the current book title on various archives, with a centralized status indicator.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Search the Ships (Enhanced UI v7)
// @namespace    Violentmonkey Scripts
// @version      1.5
// @description  Adds a beautifully designed button to book-related websites to search the current book title on various archives, with a centralized status indicator.
// @author       Delaxy (UI by Gemini)
// @match        https://thegreatestbooks.org/*
// @match        https://www.goodreads.com/*
// @match        https://www.amazon.com/*
// @match        https://www.amazon.fr/*
// @match        https://www.amazon.de/*
// @match        https://www.amazon.co.uk/*
// @match        https://www.amazon.it/*
// @match        https://www.amazon.*/*
// @match        https://tastedive.com/books/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const searchSites = {
    "Z-Library": {
      queryKey: "",
      separator: "?",
      urls: [
        {
          name: "All Files",
          base: "https://z-lib.gd/s/",
          extra: "",
        },
        {
          name: "EPUBs",
          base: "https://z-lib.gd/s/",
          extra: "extensions[]=EPUB",
        },
        {
          name: "PDFs",
          base: "https://z-lib.gd/s/",
          extra: "extensions[]=PDF",
        },
      ],
    },
    "Anna's Archive": {
      queryKey: "q",
      separator: "&",
      urls: [
        {
          name: "All Files",
          base: "https://annas-archive.org/search",
          extra: "page=1&sort=",
        },
        {
          name: "EPUBs",
          base: "https://annas-archive.org/search",
          extra: "page=1&sort=&ext=epub",
        },
        {
          name: "PDFs",
          base: "https://annas-archive.org/search",
          extra: "page=1&sort=&ext=pdf",
        },
      ],
    },
    "Library Genesis": {
      queryKey: "req",
      separator: "&",
      urls: [
        {
          name: "Default Search",
          base: "https://libgen.li/index.php",
          extra:
            "lg_topic=libgen&open=0&view=simple&res=25&phrase=1&column=def",
        },
      ],
    },
    Mobilism: {
      queryKey: "keywords",
      separator: "&",
      urls: [
        {
          name: "Books Forum",
          base: "https://forum.mobilism.org/search.php",
          extra: "fid[]=120&sr=topics&sf=titleonly",
        },
      ],
    },
  };

  function getBookTitle() {
    let title = "";
    const hostname = window.location.hostname;

    // Goodreads
    if (hostname.includes("goodreads.com")) {
      // Books page is always in the goodreads.com/book/show section of the website
      const isBookPage = window.location.pathname.includes("/book/show/");
      if (isBookPage) {
        const el = document.querySelector('[data-testid="bookTitle"]');
        if (el) title = el.innerText.trim();
      }
    }
    // Amazon
    else if (hostname.includes("amazon.")) {
      // Amazon probably just needs the breadcrumb
      // The breadcrumb have either books or kindle store that indicates if it is a books page
      // I do plan of adding a seperate audiobook function so I just have to check if the breadcrumbs include audiobook
      const isProductPage =
        window.location.pathname.includes("/dp/") ||
        window.location.pathname.includes("/gp/product/");
      const breadcrumb = document.querySelector(
        "#wayfinding-breadcrumbs_feature_div",
      );
      const isBookCategory =
        breadcrumb &&
        (breadcrumb.innerText.includes("Books") ||
          breadcrumb.innerText.includes("Kindle Store"));
      // If both is true, we know for sure it is the book page and not home page or other pages
      if (isProductPage && isBookCategory) {
        const el =
          document.getElementById("productTitle") ||
          document.getElementById("bookTitle");
        if (el) title = el.innerText.trim();
      }
    }
    // The Greatest Books of All Time
    else if (hostname.includes("thegreatestbooks.org")) {
      // Pretty easy for this source since the book page is always in thegreatestbooks.org/books
      const isBookPage = window.location.pathname.includes("/books/");
      if (isBookPage) {
        const el = document.querySelector("h1 a.no-underline-link");
        if (el) title = el.textContent.trim();
      }
    }
    // Tastedive
    // The best way I found to check if we are in the books page is to check if the section that says "Similar books" is shown
    else if (hostname.includes("tastedive.com")) {
      // The element where similar books is located

      const similarBooksElement = document.querySelector(
        "h2.SectionHeader__Heading-sc-hhwqmu-1.djzdBM.RecommendedSection__StyledSectionHeader-sc-rgsodb-4.VUeGU",
      );
      const isBookPage =
        similarBooksElement.innerHTML.includes("Similar books");

      console.log(isBookPage);
    }

    if (!title) {
      return "";
    } else {
      return cleanTitle(title);
    }
  }

  function cleanTitle(title) {
    return title
      .replace(/\(Paperback\)/, "")
      .replace(/\(Hardcover\)/, "")
      .replace(/\(Kindle Edition\)/, "")
      .replace(/\(Audible Audio Edition\)/, "")
      .trim();
  }

  function constructSearchUrl(siteConfig, urlObject, encodedTitle) {
    let finalUrl;
    if (!siteConfig.queryKey) {
      finalUrl = `${urlObject.base}${encodedTitle}`;
      if (urlObject.extra) {
        finalUrl += `${siteConfig.separator}${urlObject.extra}`;
      }
    } else {
      finalUrl = `${urlObject.base}?${siteConfig.queryKey}=${encodedTitle}`;
      if (urlObject.extra) {
        finalUrl += `${siteConfig.separator}${urlObject.extra}`;
      }
    }
    return finalUrl;
  }

  function checkStatuses() {
    const statusElements = document.querySelectorAll(".sts-ship-status");
    statusElements.forEach((span) => {
      const url = span.dataset.url;
      if (!url || !span.classList.contains("sts-pending")) return;

      const baseUrl = new URL(url).origin;
      fetch(baseUrl, { method: "HEAD", mode: "no-cors" })
        .then(() => {
          span.classList.remove("sts-pending");
          span.classList.add("sts-online");
          span.title = "Online";
        })
        .catch(() => {
          span.classList.remove("sts-pending");
          span.classList.add("sts-offline");
          span.title = "Offline";
        });
    });
  }

  function createSearchButton() {
    const bookTitle = getBookTitle();
    if (!bookTitle) return;

    const encodedTitle = encodeURIComponent(bookTitle);

    const styles = `
            @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');

            .sts-container {
                position: fixed;
                bottom: 20px;
                right: 20px;
                z-index: 10000;
                font-family: 'Inter', sans-serif;
            }
            @media (max-width: 480px) {
                .sts-container {
                    right: 10px;
                    bottom: 10px;
                }
            }
            .sts-button {
                background: linear-gradient(135deg, #5A67D8 0%, #9F7AEA 100%);
                color: white;
                padding: 12px 20px;
                border: none;
                border-radius: 50px;
                cursor: pointer;
                font-size: 16px;
                font-weight: 600;
                box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
                transition: all 0.3s ease;
                outline: none;
                display: flex;
                align-items: center;
                gap: 8px;
            }
            .sts-button:hover {
                transform: translateY(-3px);
                box-shadow: 0 7px 25px rgba(0, 0, 0, 0.25);
            }
            .sts-button svg {
                width: 20px;
                height: 20px;
                fill: currentColor;
            }
            .sts-dropdown {
                position: absolute;
                bottom: calc(100% + 10px);
                left: 50%;
                background-color: rgba(255, 255, 255, 0.85);
                backdrop-filter: blur(10px);
                -webkit-backdrop-filter: blur(10px);
                border: 1px solid rgba(0, 0, 0, 0.1);
                border-radius: 12px;
                box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
                display: none;
                z-index: 10001;
                min-width: 220px;
                padding: 6px;
                box-sizing: border-box;
                opacity: 0;
                transform: translate(-50%, 10px);
                transition: opacity 0.2s ease, transform 0.2s ease;
                pointer-events: none;
            }
            .sts-dropdown.sts-visible {
                display: block;
                opacity: 1;
                transform: translate(-50%, 0);
                pointer-events: auto;
            }
            .sts-site-div {
                padding: 10px 15px;
                cursor: pointer;
                font-size: 15px;
                font-weight: 500;
                color: #333;
                transition: background-color 0.2s ease;
                border-radius: 8px;
                position: relative;
                /* --- MODIFIED --- */
                display: flex;
                justify-content: space-between;
                align-items: center;
            }
            .sts-site-div:hover {
                background-color: rgba(0, 0, 0, 0.05);
            }
            .sts-submenu {
                display: none;
                position: absolute;
                top: -6px;
                right: calc(100% + 8px);
                background-color: rgba(255, 255, 255, 0.85);
                backdrop-filter: blur(10px);
                -webkit-backdrop-filter: blur(10px);
                border: 1px solid rgba(0, 0, 0, 0.1);
                border-radius: 12px;
                box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
                min-width: 220px;
                z-index: 10002;
                padding: 6px;
                opacity: 0;
                transform: translateX(10px);
                transition: opacity 0.2s ease, transform 0.2s ease;
                pointer-events: none;
            }
            .sts-site-div:last-child > .sts-submenu {
                top: auto;
                bottom: -6px;
            }
            .sts-submenu.sts-submenu-visible {
                display: block;
                opacity: 1;
                transform: translateX(0);
                pointer-events: auto;
            }
            .sts-link {
                /* --- MODIFIED --- */
                display: block;
                padding: 8px 12px;
                color: #4A5568;
                text-decoration: none;
                font-size: 14px;
                border-radius: 6px;
                transition: background-color 0.2s ease, color 0.2s ease;
            }
            .sts-link:hover {
                background-color: rgba(90, 103, 216, 0.1);
                color: #5A67D8;
            }
            .sts-ship-status {
                width: 8px;
                height: 8px;
                border-radius: 50%;
                display: inline-block;
                margin-left: 10px;
                flex-shrink: 0;
            }
            .sts-pending { background-color: #9CA3AF; }
            .sts-online { background-color: #34D399; }
            .sts-offline { background-color: #F87171; }
        `;
    const styleSheet = document.createElement("style");
    styleSheet.innerText = styles;
    document.head.appendChild(styleSheet);

    const buttonContainer = document.createElement("div");
    buttonContainer.className = "sts-container";

    const button = document.createElement("button");
    button.className = "sts-button";
    button.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
            <span>Search the Ships</span>
        `;

    const dropdown = document.createElement("div");
    dropdown.className = "sts-dropdown";

    for (const siteName in searchSites) {
      const siteConfig = searchSites[siteName];
      const siteDiv = document.createElement("div");
      siteDiv.className = "sts-site-div";

      // --- NEW LOGIC ---
      // Create a separate span for the name to allow flexbox positioning.
      const nameSpan = document.createElement("span");
      nameSpan.innerText = siteName;
      siteDiv.appendChild(nameSpan);

      // Add the status indicator directly to the siteDiv.
      if (siteConfig.urls && siteConfig.urls.length > 0) {
        const statusSpan = document.createElement("span");
        statusSpan.className = "sts-ship-status sts-pending";
        // Use the base URL of the first link for the status check.
        statusSpan.dataset.url = siteConfig.urls[0].base;
        statusSpan.title = "Checking...";
        siteDiv.appendChild(statusSpan);
      }

      const subMenu = document.createElement("div");
      subMenu.className = "sts-submenu";

      siteConfig.urls.forEach((urlObject) => {
        const finalUrl = constructSearchUrl(
          siteConfig,
          urlObject,
          encodedTitle,
        );
        const link = document.createElement("a");
        link.href = finalUrl;
        link.target = "_blank";
        link.rel = "noopener noreferrer";
        link.className = "sts-link";
        link.innerText = urlObject.name;

        // --- REMOVED ---
        // The status span is no longer added to each link.

        subMenu.appendChild(link);
      });

      siteDiv.appendChild(subMenu);
      dropdown.appendChild(siteDiv);
    }

    button.appendChild(dropdown);
    buttonContainer.appendChild(button);
    document.body.appendChild(buttonContainer);

    let hideDropdownTimeout;
    buttonContainer.addEventListener("mouseenter", () => {
      clearTimeout(hideDropdownTimeout);
      if (!dropdown.classList.contains("sts-visible")) {
        dropdown.classList.add("sts-visible");
      }
    });
    buttonContainer.addEventListener("mouseleave", () => {
      hideDropdownTimeout = setTimeout(() => {
        dropdown.classList.remove("sts-visible");
      }, 300);
    });

    document.querySelectorAll(".sts-site-div").forEach((siteDiv) => {
      const subMenu = siteDiv.querySelector(".sts-submenu");
      let hideSubMenuTimeout;

      const show = () => {
        clearTimeout(hideSubMenuTimeout);
        document
          .querySelectorAll(".sts-submenu-visible")
          .forEach((visibleSubMenu) => {
            if (visibleSubMenu !== subMenu) {
              visibleSubMenu.classList.remove("sts-submenu-visible");
            }
          });
        subMenu.classList.add("sts-submenu-visible");
      };

      const hide = () => {
        hideSubMenuTimeout = setTimeout(() => {
          subMenu.classList.remove("sts-submenu-visible");
        }, 300);
      };

      siteDiv.addEventListener("mouseenter", show);
      siteDiv.addEventListener("mouseleave", hide);
      subMenu.addEventListener("mouseenter", show);
      subMenu.addEventListener("mouseleave", hide);
    });
    checkStatuses();
  }

  if (document.readyState === "loading") {
    window.addEventListener("DOMContentLoaded", createSearchButton);
  } else {
    createSearchButton();
  }
})();