LitresBookDescription

The script adds a button to the site to generate the book description in the form of empty FB2 file

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name           LitresBookDescription
// @name:ru        LitresBookDescription
// @namespace      90h.yy.zz
// @version        0.1.2
// @author         Ox90
// @match          https://*.litres.ru/book/*
// @description    The script adds a button to the site to generate the book description in the form of empty FB2 file
// @description:ru Скрипт добавляет кнопку формирования описания книги в виде пустого fb2 файла
// @require        https://update.greasyfork.org/scripts/468831/1792771/HTML2FB2Lib.js
// @grant          GM.xmlHttpRequest
// @run-at         document-start
// @license        MIT
// ==/UserScript==

(function() {
  "use strict";

const PROGRAM_NAME = "LitresBookDescription";
const PROGRAM_VERSION = GM_info.script.version;

let mainButton = null;

function init() {
  // Активация внешних стилей: используются псевдоэлементы
  addStyles();
  // Первоначальное добавление кнопки
  addMainButton();
  // Запуск отслеживания страницы, чтобы добавить кнопку после динамического обновления страницы
  watchPage('#__next', addMainButton);
}

function watchPage(selector, handler) {
  const container = document.querySelector(selector);
  if (container) {
    (new MutationObserver(function(mutations, observer) {
      try {
        if (handler(container)) observer.disconnect();
      } catch (err) {
        console.error(err);
      }
    })).observe(container, { childList: true, subtree: true });
  }
}

function addMainButton() {
  const container = document.querySelector(
    '#main div[data-testid="book-card__wrapper"] div[data-analytics-id="book-characteristics"]'
  );
  if (!container) return;

  if (!mainButton) {
    mainButton = document.createElement('div');
    mainButton.classList.add('lbd-main-button');
    mainButton.innerHTML = '<div class="lbd-title"><span role="heading" aria-level="5">Метаданные книги: </span></div>' +
      '<div role="list"><span class="lbd-value" role="listitem"><a tabindex="0" class="lbd-start-link">fb2</a></span></div>' +
      '<div><span class="lbd-progress-message lbd-hidden">Ждите...</span></div>' +
      '<div><a tabindex="0" class="lbd-result-link lbd-hidden" download="bookDescription.fb2">Скачать</a></div>';
    mainButton.querySelector('.lbd-start-link').addEventListener('click', event => {
      event.preventDefault();
      extractDiscription();
    });
  }

  container.append(mainButton);

  return true;
}

function updateMainButton(content) {
  const list = mainButton.querySelector('div[role=list]');
  const prog = mainButton.querySelector('.lbd-progress-message');
  const link = mainButton.querySelector('.lbd-result-link');

  let target = null;
  if (!content) {
    target = list;
  } else if (content.startsWith('blob:')) {
      link.href = content;
      target = link;
  } else {
    prog.textContent = content;
    target = prog;
  }
  [ list, prog, link ].forEach(el => {
    if (el === target) {
      el.classList.remove('lbd-hidden');
    } else {
      el.classList.add('lbd-hidden');
    }
  });
  if (target != link && link.href) {
    URL.revokeObjectURL(link.href);
    link.href = '';
  }
}

async function extractDiscription() {
  updateMainButton("Ждите...");
  try {
    const bookData = await loadFromJson();
    await extractHtmlData(bookData);
    await makeBookFile(bookData);
  } catch (err) {
    updateMainButton('Ошибка: ' + err.message);
    if (!(err instanceof AppError)) {
      console.error(err);
    }
  }
}

async function loadFromJson() {
  const bdata = {};
  let found = false;
  let jdata = null;

  for (const node of Array.from(document.querySelectorAll('script[type="application/ld+json"]'))) {
    try {
      jdata = JSON.parse(node.textContent);
    } catch (err) {
    }

    if (jdata['@type'] === 'Book') {
      bdata.title = jdata.name && jdata.name.trim() || 'Без названия';
      if (!jdata.author) {
        bdata.authors = [ new FB2Author('Неизвестный') ];
      } else {
        bdata.authors = (jdata.author[0] ? jdata.author : [ jdata.author ]).map(a => new FB2Author(a.name));
      }
      if (jdata.isbn) bdata.isbn = jdata.isbn.trim();
      if (jdata.description) bdata.annotation = `<p>${jdata.description}</p>`;

      found = true;
      break;
    }
  }

  if (!found) throw new AppError('Не найден json+ld блок');
  console.log('Обнаружен блок json+ld');

  let el = document.getElementById('__NEXT_DATA__');
  if (!el) throw new AppError('Не найден элемент NEXT_DATA');

  try {
    jdata = JSON.parse(el.textContent);
  } catch (err) {
    throw new AppError('Неожиданный формат next_data');
  }
  console.log('Обнаружен блок next_data');

  if (!jdata.props || !jdata.props.pageProps || !jdata.props.pageProps.artIdOrSlug) {
    throw new AppError('Id книги не найден');
  }
  bdata.bookId = jdata.props.pageProps.artIdOrSlug;
  console.log('Id книги: ' + bdata.bookId);

  try {
    jdata = JSON.parse(jdata.props.pageProps.initialState);
  } catch (err) {
    throw new AppError('Неожиданный формат initialState');
  }
  console.log('initialState loaded');

  if (!jdata.rtkqApi || !jdata.rtkqApi.queries) {
    throw new AppError('Не найден объект queries');
  }
  jdata = jdata.rtkqApi.queries;

  const query = `getArtData({"artIdOrSlug":${bdata.bookId}})`;
  if (jdata[query] && jdata[query].data) {
    const htmlAnn = jdata[query].data.html_annotation;
    if (htmlAnn) {
      bdata.annotation = htmlAnn;
      console.log('Обнаружена HTML аннотация');
    }
  }

  return bdata;
}

async function extractHtmlData(bdata) {
  console.log('Анализ DOM дерева страницы');

  const bookEl = document.querySelector('[itemtype="https://schema.org/Book"]');
  if (!bookEl) throw new AppError('Не найден блок информации о книге');

  const properties = new Map();
  {
    const bookDt = bookEl.querySelector('div[data-testid="book-characteristics__wrapper"]');
    if (!bookDt) throw new AppError('Не найден блок характеристик книги');
    let itemEl = bookDt.firstElementChild;
    while (itemEl) {
      if (itemEl.children.length == 2) {
        const r = /\s*(.+)\s*:/.exec(itemEl.children[0].textContent);
        if (r) properties.set(r[1], itemEl.children[1]);
      }
      itemEl = itemEl.nextElementSibling;
    }
  }

  function handleMetadataItem(name, handler) {
    const el = properties.get(name);
    handler(el);
  }

  // Переводчик
  handleMetadataItem('Переводчик', (el) => {
    if (!el) return;
    bdata.translators = Array.from(el.querySelectorAll('a')).reduce((list, el) => {
      const trName = el.textContent.trim();
      if (trName) {
        const tr = new FB2Author(trName);
        tr.name = 'translator';
        list.push(tr);
      }
      return list;
    }, []);
  });

  // Ключевые слова
  bdata.keywords = Array.from(bookEl.querySelectorAll('div[data-testid="book-genres-and-tags__wrapper"] a')).reduce((list, el) => {
    const kw = el.textContent.trim();
    if (kw) list.push(kw);
    return list;
  }, []);

  // Жанры
  bdata.genres = new FB2GenreList(bdata.keywords);

  // Серия
  bdata.sequence = (() => {
    let el = bookEl.querySelector('div[data-testid="art__inSeries--title"] a');
    if (el) {
      let r = /«\s*(.+)\s*»/.exec(el.textContent);
      if (r) {
        const seq = { name: r[1] };
        el = el.parentElement.firstChild;
        if (el && el.nodeName === '#text') {
          r = /(\d+)\D+\d+/.exec(el.textContent);
          if (r) seq.number = Number(r[1]);
        }
        return seq;
      }
    }
  })();

  // Дата публикации книги
  handleMetadataItem('Дата выхода на Литрес', (el) => {
    if (!el) return;
    const r = /(\d+)\s+([\S]+)\s+(\d+)/.exec(el.textContent);
    if (r) {
      const month = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' ].indexOf(r[2]) + 1;
      if (month > 0) bdata.bookDate = new Date(`${r[3]}-${month}-${r[1]}`);
    }
  });

  // Ссылка на источник
  bdata.sourceURL = document.location.toString();

  // Обложка книги
  let el = bookEl.querySelector('div[data-testid="book-cover__wrapper"] img');
  if (el && el.src) {
    const img = new FB2Image(el.src);
    console.log('Загрузка изображения');
    await img.load();
    if (img.type === "image/webp") {
      console.log('Конвертация изображения');
      await img.convert("image/jpeg");
    }
    img.id = "cover" + img.suffix();
    bdata.coverpage = img;
  }

  // Правообладатель
  handleMetadataItem('Правообладатель', (el) => {
    if (!el) return;
    const val = el.textContent.trim();
    if (val) bdata.publisher = val;
  });
}

async function makeBookFile(bdata) {
  console.log('Формирование документа FB2');

  const doc = new FB2Document();
  doc.bookTitle = bdata.title;
  doc.bookAuthors = bdata.authors;
  if (bdata.translators && bdata.translators[0]) {
    doc.translator = bdata.translators[0];
  }
  doc.genres = bdata.genres;
  doc.keywords = bdata.keywords;
  doc.sequence = bdata.sequence;
  doc.bookDate = bdata.bookDate;
  doc.sourceURL = bdata.sourceURL;
  doc.coverpage = bdata.coverpage;
  doc.binaries.push(bdata.coverpage);
  doc.idPrefix = 'lbd_';
  doc.history.push("v1.0 - создание файла");

  if (bdata.annotation) {
    doc.bindParser('a', new FB2AnnotationParser());
    const fragment = document.createElement('div');
    fragment.innerHTML = bdata.annotation;
    await doc.parse('a', fragment, false);
    doc.bindParser();
  }

  doc.publishBookTitle = bdata.title;
  doc.isbn = bdata.isbn;
  doc.publisher = bdata.publisher;
  if (bdata.bookDate) doc.publishYear = bdata.bookDate.getFullYear();
  doc.publishSequence = bdata.sequence;

  if (doc.publisher === 'Автор') {
    if (!doc.genres.find(g => g.value === 'network_literature')) {
      doc.genres.push(new FB2Genre('network_literature'));
    }
  }

  // Фейковая глава для валидации
  const chapter = new FB2Chapter();
  chapter.children.push(new FB2EmptyLine());
  doc.chapters.push(chapter);

  // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt
  const urlObj = URL.createObjectURL(new Blob([ doc ], { type: 'application/octet-stream' }));
  updateMainButton(urlObj);
}

//-------------------------

class AppError extends Error {
}

//-------------------------

function addStyle(css) {
  const style = document.getElementById("lbd_styles") || (function() {
    const style = document.createElement('style');
    style.type = 'text/css';
    style.id = "lbd_styles";
    document.head.appendChild(style);
    return style;
  })();
  const sheet = style.sheet;
  sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}

function addStyles() {
  [
    '.lbd-main-button { font:normal 14px/20px var(--main-font,sans-serif); letter-spacing:.25px; display:flex; }',
    '.lbd-title { min-width:205px; color:#9d9c9f }',
    '.lbd-title span { display:flex; }',
    '.lbd-title span::after { display:block; flex-grow:1; content:""; border-bottom:1px solid #ebebeb; }',
    '.lbd-value { white-space:pre; }',
    '.lbd-start-link { color:var(--link,#3d3dc7); text-decoration:none; cursor:pointer; outline:none; transition:color 80ms ease-in-out; }',
    '.lbd-start-link:hover { color:rgba(61,61,199,.6); }',
    '.lbd-progress-message { color: rgb(157, 156, 159); }',
    '.lbd-hidden { display:none !important; }'
  ].forEach(s => addStyle(s));
}

// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  else init();

})();