Audible Search Hub

Add various search links to Audible (MyAnonaMouse, AudioBookBay, Mobilism, Goodreads, Anna's Archive, Z-Library & more)

目前為 2024-09-21 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Audible Search Hub
// @namespace    https://greasyfork.org/en/users/1370284
// @version      0.2.1
// @license      MIT
// @description  Add various search links to Audible (MyAnonaMouse, AudioBookBay, Mobilism, Goodreads, Anna's Archive, Z-Library & more)
// @match        https://*.audible.*/pd/*
// @match        https://*.audible.*/ac/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// ==/UserScript==

const sites = {
  mam: {
    label: '🐭 MAM',
    name: 'MyAnonaMouse',
    url: 'https://www.myanonamouse.net',
    searchBy: { title: true, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_mam');
      const url = new URL(`${baseUrl}/tor/browse.php`);
      url.searchParams.set('tor[text]', search);
      return url.href;
    }
  },
  abb: {
    label: '🎧 ABB',
    name: 'AudioBookBay',
    url: 'https://audiobookbay.lu',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_abb');
      const url = new URL(baseUrl);
      url.searchParams.set('s', search.toLowerCase());
      return url.href;
    }
  },
  mobilism: {
    label: '📱 Mobilism',
    name: 'Mobilism',
    url: 'https://forum.mobilism.org',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_mobilism');
      const url = new URL(`${baseUrl}/search.php`);
      url.searchParams.set('keywords', search);
      url.searchParams.set('sr', 'topics');
      url.searchParams.set('sf', 'titleonly');
      return url.href;
    }
  },
  goodreads: {
    label: '🔖 Goodreads',
    name: 'Goodreads',
    url: 'https://www.goodreads.com',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_goodreads');
      const url = new URL(`${baseUrl}/search`);
      url.searchParams.set('q', search);
      return url.href;
    }
  },
  anna: {
    label: '📚 Anna',
    name: "Anna's Archive",
    url: 'https://annas-archive.org',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_anna');
      const url = new URL(`${baseUrl}/search`);
      url.searchParams.set('q', search);
      url.searchParams.set('lang', 'en');
      return url.href;
    }
  },
  zlib: {
    label: '📕 zLib',
    name: 'Z-Library',
    url: 'https://z-lib.gs',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_zlib');
      const url = new URL(`${baseUrl}/s/${search}`);
      return url.href;
    }
  },
  libgen: {
    label: '📗 Libgen',
    name: 'Libgen',
    url: 'https://libgen.rs',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_libgen');
      const url = new URL(`${baseUrl}/search`);
      url.searchParams.set('req', search);
      return url.href;
    }
  },
  tgx: {
    label: '🌌 TGX',
    name: 'TorrentGalaxy',
    url: 'https://tgx.rs/torrents.php',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_tgx');
      const url = new URL(baseUrl);
      url.searchParams.set('search', search);
      return url.href;
    }
  },
  btdig: {
    label: '⛏️ BTDig',
    name: 'BTDig',
    url: 'https://btdig.com',
    searchBy: { title: false, titleAuthor: true },
    getLink: (search) => {
      const baseUrl = GM_config.get('url_btdig');
      const url = new URL(`${baseUrl}/search`);
      url.searchParams.set('q', search);
      return url.href;
    }
  }
};

const searchByFields = {
  title: {
    label: 't',
    description: 'title',
  },
  titleAuthor: {
    label: 't+a',
    description: 'title + author',
  },
};

function addSiteConfig(site) {
  return {
    [`section_${site}`]: {
      label: `-------------- ${sites[site].name} 👇🏻 --------------`,
      type: 'hidden'
    },
    [`enable_${site}`]: {
      label: 'Enable',
      type: 'checkbox',
      default: true
    },
    [`url_${site}`]: {
      label: 'URL',
      type: 'text',
      default: sites[site].url
    },
    [`enable_search_title_${site}`]: {
      label: 'Enable Search by Title',
      type: 'checkbox',
      default: sites[site].searchBy.title
    },
    [`enable_search_titleAuthor_${site}`]: {
      label: 'Enable Search by Title + Author',
      type: 'checkbox',
      default: sites[site].searchBy.titleAuthor
    },
  }
}

const perSiteFields = Object.keys(sites).reduce((acc, siteKey) => {
  return {
    ...acc,
    ...addSiteConfig(siteKey, sites[siteKey])
  };
}, {});

GM_config.init({
  id: 'audible-search-sites',
  title: 'Search Sites',
  fields: {
    open_in_new_tab: {
      label: 'Open Links in New Tab',
      type: 'checkbox',
      default: true
    },
    ...perSiteFields,
  }
});

GM_registerMenuCommand('Open Settings', () => {
  GM_config.open();
});

async function extractBookData(document2) {
  try {
    const acceptedTypes = ['Audiobook', 'Product', 'BreadcrumbList']
    const result = {}
    const ldJsonScripts = document2.querySelectorAll(
      'script[type="application/ld+json"]'
    )
    ldJsonScripts.forEach((script) => {
      try {
        const jsonLdData = JSON.parse(script.textContent?.trim() || '')
        const items = Array.isArray(jsonLdData) ? jsonLdData : [jsonLdData]
        items.forEach((item) => {
          if (acceptedTypes.includes(item['@type'])) {
            result[item['@type']] = { ...result[item['@type']], ...item }
          }
        })
      } catch (error) {
        console.error('Error parsing JSON-LD:', error)
      }
    })
    return result
  } catch (error) {
    console.error(`Error parsing data: `, error)
    return {}
  }
}

const waitForBookDataScripts = () =>
  new Promise((resolve, reject) => {
    const checkLdJson = async () => {
      const data = await extractBookData(document);
      if (data?.Audiobook) resolve(data);
    };

    const observer = new MutationObserver(async (mutationsList) => {
      mutationsList.forEach((mutation) =>
        mutation.addedNodes.forEach(async (node) => {
          if (node.nodeType === 1 && node.tagName === 'SCRIPT' && node.type === 'application/ld+json') {
            await checkLdJson();
          }
        })
      );
    });

    observer.observe(document, { childList: true, subtree: true });
    checkLdJson().then((data) => data.Audiobook && observer.disconnect() && resolve(data));
    setTimeout(() => {
      observer.disconnect();
      reject(new Error('Timeout: ld+json script not found'));
    }, 2000);
  });


const style = document.createElement('style');
style.textContent = `
    .custom-bc-tag {
      text-decoration: none;
      transition: background-color 0.2s ease;
    }
    .custom-bc-tag:hover {
      background-color: #f0f0f0;
      text-decoration: none;
    }
  `;
document.head.appendChild(style);



waitForBookDataScripts()
  .then((data) => {
    injectSearchLinks(data)
  })
  .catch((error) => {
    console.error('Error:', error.message)
  })


function createLink(text, href, title) {
  const link = document.createElement('a');
  link.href = href;
  link.textContent = text;
  link.target = GM_config.get('open_in_new_tab') ? '_blank' : '_self';
  link.target = '_blank';
  link.classList.add('bc-tag', 'bc-size-footnote', 'bc-tag-outline', 'bc-badge-tag', 'bc-badge', 'custom-bc-tag');
  link.style.whiteSpace = 'nowrap';
  link.title = title || text;
  return link;
};

function createLinksContainer() {
  const container = document.createElement('div');
  container.style.marginTop = '8px'
  container.style.display = 'flex'
  container.style.alignItems = 'center'
  container.style.flexWrap = 'wrap'
  container.style.gap = '4px'
  container.style.maxWidth = '340px'
  return container;
}

function decodeHtmlEntities (str) {
    if (str == null) {
      return ''
    }
    const parser = new DOMParser();
    const doc = parser.parseFromString(str, 'text/html');
    return doc.documentElement.textContent;
};

async function injectSearchLinks(data) {
  const title = decodeHtmlEntities(data.Audiobook?.name)
  const author = decodeHtmlEntities(data.Audiobook?.author?.at(0)?.name)
  const titleAuthor = `${title} ${author} `

  const authorLabelEl = document.querySelector('.authorLabel')
  const infoParentEl = authorLabelEl?.parentElement

  if (!infoParentEl) {
    console.warn("Can't find the parent element to inject links.")
    return
  }

  const linksContainer = createLinksContainer()

  Object.keys(sites).forEach((siteKey) => {
    if (GM_config.get(`enable_${siteKey}`)) {
      const { label, name, getLink } = sites[siteKey];

      const enabledSearchFields = Object.keys(searchByFields).filter((field) =>
        GM_config.get(`enable_search_${field}_${siteKey}`)
      );
      const isMultipleEnabled = enabledSearchFields.length > 1;

      enabledSearchFields.forEach((field) => {
        const { label: searchLabel, description } = searchByFields[field];

        const finalLabel = isMultipleEnabled ? `${label} (${searchLabel})` : label;

        const searchValue = field === 'titleAuthor' ? titleAuthor : title;
        const link = createLink(finalLabel, getLink(searchValue), `Search ${name} by ${description}`);
        linksContainer.append(link);
      });
    }
  });

  infoParentEl.parentElement.appendChild(linksContainer)
}