Good2Dou

Extract Goodreads metadata to help add a new subject to Douban

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         Good2Dou
// @namespace    http://tampermonkey.net/
// @version      1.12
// @description  Extract Goodreads metadata to help add a new subject to Douban
// @author       cccccc
// @match        *://*.goodreads.com/book/show/*
// @match        *://book.douban.com/new_subject*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      *
// @run-at       document-end
// @license MIT
// ==/UserScript==

(() => {
  'use strict';

  const STORAGE_KEY = 'douban_book_meta_global';

  const storage = {
    async get(key) {
      const v = GM_getValue(key);
      try {
        return v ? JSON.parse(v) : null;
      } catch (_e) {
        return v || null;
      }
    },
    async set(obj) {
      // store as JSON string to preserve objects
      for (const k of Object.keys(obj)) {
        const val = obj[k] === undefined ? null : obj[k];
        try {
          GM_setValue(k, JSON.stringify(val));
        } catch (e) {
          // fallback: store primitive as-is
          GM_setValue(k, val);
        }
      }
    }
  };

  const readStored = async () => storage.get(STORAGE_KEY);
  const saveGlobal = async meta => {
    if (!meta) return;
    await storage.set({ [STORAGE_KEY]: meta });
    console.info('Saved metadata globally:', meta);
  };

  async function extractGoodreadsMeta() {
    try {
      const nextDataEl = document.querySelector('#__NEXT_DATA__');
      if (!nextDataEl) return null;
      const json = JSON.parse(nextDataEl.textContent);
      const apollo = json?.props?.pageProps?.apolloState || {};
      const objects = { Book: [], Work: [], Series: [], Contributor: [] };
      for (const v of Object.values(apollo)) {
        const t = v.__typename;
        if (t && objects[t]) objects[t].push(v);
      }
      const b = objects.Book.find(x => x.title);
      if (!b) return null;

      const data = {};
      data.title = b.title || '';
      data.description = (b.description || '')
        .replace(/<br\s*\/?\>/gi, '\n')
        .replace(/<\/p>/gi, '\n')
        .replace(/<[^>]+>/g, '')
        .trim();
      data.author = objects.Contributor.filter(c => c.name).map(c => c.name);
      data.cover = b.imageUrl || '';
      data.pages = b.details?.numPages || null;
      data.binding = b.details?.format || '';
      data.format = normalizeFormat(data.binding);
      data.publisher = b.details?.publisher || '';
      data.isbn = b.details?.isbn13 || b.details?.isbn || null;

      const full_title = (data.title || '').trim();
      if (full_title.includes(':')) {
        const [left, ...rest] = full_title.split(':');
        data.main_title = left.trim();
        data.subtitle = rest.join(':').trim() || '';
      } else {
        data.main_title = full_title;
        data.subtitle = null;
      }

      const pubTime = b.details?.publicationTime || null;
      if (pubTime) {
        const dt = new Date(pubTime);
        data.pub_year = dt.getFullYear();
        data.pub_month = dt.getMonth() + 1;
        data.pub_day = dt.getDate();
      }

      await saveGlobal(data);
      console.log('Extracted Goodreads Metadata:', data);
      return data;
    } catch (e) {
      console.error('extractGoodreadsMeta error', e);
      return null;
    }
  }

  const normalizeFormat = binding => {
    if (!binding) return '';
    const lower = binding.toLowerCase();
    if (lower.includes('paperback')) return 'Paperback';
    if (lower.includes('hardcover') || lower.includes('hardback')) return 'Hardcover';
    if (lower.includes('ebook')) return 'eBook';
    if (lower.includes('audiobook')) return 'Audiobook';
    return binding;
  };

  async function saveCover() {
    const meta = await readStored();
    try {
      if (!meta || !meta.cover) {
        console.warn('No cover image found.');
        return null;
      }

      // use GM_xmlhttpRequest to avoid CORS when necessary
      return new Promise((resolve) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: meta.cover,
          responseType: 'blob',
          onload(res) {
            try {
              const blob = res.response;
              const reader = new FileReader();
              reader.onload = () => {
                const a = document.createElement('a');
                a.href = reader.result;
                a.download = `${meta.main_title}.jpg`;
                document.body.appendChild(a);
                a.click();
                a.remove();
                resolve(meta.cover);
              };
              reader.readAsDataURL(blob);
            } catch (e) {
              console.error('saveCover read error', e);
              resolve(null);
            }
          },
          onerror() { resolve(null); }
        });
      });
    } catch (e) {
      console.error('saveCover error', e);
      return null;
    }
  }

  async function parseAndRedirect() {
    try {
      const meta = await extractGoodreadsMeta();
      setTimeout(() => window.open('https://book.douban.com/new_subject', '_blank'), 300);
      return meta;
    } catch (e) {
      console.error('parseAndRedirect error', e);
      return null;
    }
  }

  // Small DOM helpers
  const setValue = (selector, value) => {
    if (!value) return false;
    const el = document.querySelector(selector);
    if (!el) return false;
    el.value = value;
    return true;
  };

  const setSelectValue = (selector, value) => {
    if (value == null) return false;
    const el = document.querySelector(selector);
    if (!el) return false;
    el.value = value;
    el.dispatchEvent(new Event('change'));
    return true;
  };

  const setTextarea = (selector, value) => {
    if (!value) return false;
    const el = document.querySelector(selector);
    if (!el) return false;
    el.value = value;
    return true;
  };

  async function fillDoubanPage1() {
    const meta = await readStored();
    if (!meta) return;
    setValue('#p_title', meta.title) || setValue('input[name="p_title"]', meta.title);
    setValue('#uid', meta.isbn) || setValue('input[name="p_uid"]', meta.isbn);
    console.info('Filled Douban page 1.');
  }

  async function fillDoubanPage2() {
    const meta = await readStored();
    if (!meta) return;

    const fieldMap = {
      '#p_2': 'main_title',
      '#p_42': 'subtitle',
      '#p_6': 'publisher',
      '#p_58_other': 'format',
      '#p_9': 'isbn',
      '#p_10': 'pages'
    };

    for (const [selector, metaKey] of Object.entries(fieldMap)) {
      if (meta[metaKey] != null) setValue(selector, meta[metaKey]);
    }

    setTextarea('textarea[name="p_3_other"]', meta.description);

    if (Array.isArray(meta.author)) {
        const addLink = document.querySelector("ul li:last-child .add");
        meta.author.forEach((name, i) => {
            const selector = `#p_5_${i}`;
            setValue(selector, name) || (addLink.click(), setValue(selector, name));
            
        });
    }

    setSelectValue('#p_7_selectYear', meta.pub_year);
    // chain month/day after a short delay to allow any dynamic select population
    setTimeout(() => setSelectValue('#p_7_selectMonth', meta.pub_month), 300);
    setTimeout(() => setSelectValue('#p_7_selectDay', meta.pub_day), 600);

    console.info('Filled Douban page 2.');
  }

  // Create toolbar element and attach appropriate buttons
  function createToolbarElement() {
    const toolbar = document.createElement('div');
    toolbar.id = 'douban-helper-toolbar';
    toolbar.style.zIndex = 9999;
    toolbar.style.position = 'relative';

    const makeButton = (label, cb, color = '#28a745') => {
      const b = document.createElement('button');
      b.textContent = label;
      b.type = 'button';
      b.style.cssText = `padding:6px 5px;margin:3px;border-radius:4px;border:none;background:${color};color:#fff;cursor:pointer;font-weight:300;`;
      b.onclick = cb;
      return b;
    };

    const host = location.hostname || '';
    if (/goodreads/i.test(host)) {
      toolbar.appendChild(makeButton('Add to Douban', parseAndRedirect));
    } else if (/douban\.com/i.test(host)) {
      const isPage1 = !!document.querySelector('#p_title');
      const isPage2 = !!document.querySelector('#p_2');
      const isPage3 = !!document.querySelector('input[name="img_submit"]');
      if (isPage1) toolbar.appendChild(makeButton('ISBN & Title', fillDoubanPage1));
      if (isPage2) toolbar.appendChild(makeButton('Autofill More', fillDoubanPage2));
      if (isPage3) toolbar.appendChild(makeButton('Download Cover', saveCover));
    }

    return toolbar;
  }

  // Ensure toolbar exists and re-insert if removed by site re-renders
  function ensureToolbar() {
    if (document.getElementById('douban-helper-toolbar')) return;

    const toolbar = createToolbarElement();
    const h1 = document.querySelector('h1');
    if (h1 && h1.parentNode) h1.insertAdjacentElement('afterend', toolbar);
    else document.body.appendChild(toolbar);
  }

  // Observe DOM mutations and re-insert toolbar if it's removed
  function installToolbarObserver() {
    if (window.__douban_toolbar_observer_installed) return;
    window.__douban_toolbar_observer_installed = true;

    const observer = new MutationObserver(() => {
      if (!document.getElementById('douban-helper-toolbar')) ensureToolbar();
    });
    observer.observe(document.documentElement || document.body, { childList: true, subtree: true });

    // listen for navigation events (single-page-app style)
    window.addEventListener('popstate', () => setTimeout(ensureToolbar, 200));
    window.addEventListener('pushstate', () => setTimeout(ensureToolbar, 200));
    // catch common libraries firing pushstate via history API
    const _pushState = history.pushState;
    history.pushState = function () { const r = _pushState.apply(this, arguments); window.dispatchEvent(new Event('pushstate')); return r; };
  }

  // Initialize toolbar resiliently
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => { ensureToolbar(); installToolbarObserver(); });
  } else {
    ensureToolbar();
    installToolbarObserver();
  }
})();