Как я собрал бесплатный сервис метаданных книг на Java и Spring Boot — с EAV/Pivot-схемой БД вместо ALTER TABLE на каждое новое поле, агрегацией нескольких открытых источников. Плюс обновлённый плагин для Obsidian. Читать далее
Уровень сложностиПростой
Время на прочтение13 мин
Охват и читатели6.9K
Кейс
Некоторое время назад я уже создавал форк плагина для obsidian и добавлял доработку для получения метаданных с литрес https://github.com/malexple/obsidian-book-search-plugin. Делал я это для знакомого, который в течении полугода говорил что у него не получается переделать плагин в chatGPT. Я подошел к этой проблеме с другой стороны и прикрутил тогда Литрес. Хотя его идея была немного другой. Но уже тогда я очень удивился что в пространстве СНГ нет какого-то бесплатного сервиса для получения метаданных книг. Я с этим сталкивался, когда пытался каталогизировать свою локальную хоть и небольшую библиотеку через Calibre. Мне если честно не очень нравится Calibre, а именно следующие моменты:
Большие кнопки и не удобный интерфейс
При создании бд он копирует файлы и переименовывает файлы в непонятные номера. Это лишнее место. А мне удобнее каталогизировать по человеческим описаниям папок Математика, Физика, Химия и т.д. То есть структура папок близкая к классификаторам ББК и УДК, если расшифровать номера.
Метаданные нужно вводить вручную. Есть плагины, но не все данные можно подтянуть с интернета или они в разных местах
В cвоё время я пользовался программой BookSeer. Автор: Марк Солтанович (Mark Soltanovich). Сейчас сайта автора нет, но интернет помнит все: https://web.archive.org/web/20090417003848/http://solsoft.narod.ru/ https://web.archive.org/web/20090327121058/http://www.msolt.nm.ru/News32.html

Программа супер простая. Супер маленькая и самое главное работает и по сей день. Отдельного внимания стоит функционал добавления файлов и сканирования


Там было удобно сканировать, но все равно нет того чего хотелось.
Мне не хватает создание папок по ББК или УДК и на диске иметь осмысленные названия папок для технической литературы. У меня была попытка создать такую базу данных номеров и я писал программу для получения УДК номеров https://github.com/malexple/udk-site-parser. Описано в статье: Парсим сайт для получения УДК иерархии Но саму программу я не писал. То есть сканирование, поиск и распознавание и создание папок это отдельная большая задача. Идей как это все сделать много. Если будет время я создам такую программу.
https://www.youtube.com/watch?v=3kN6n9Rjldg&t=715s
Я понял что с метаданными нужно что-то делать. Нужен полностью открытый проект который сам пополняется метаданными книг, при этом не хранит ни картинки ни файлы. Только метаданные. А API возвращает только то что нужно. Но тут стают вопросы: Я добавлю метаданные книг, а потом захочется добавить новое поле в бд, а потом добавить метаданные журналов или комиксов. Каждый раз менять структуру таблиц не хочется. Я вспомнил про одну не очень популярную архитектуру бд, но она работает и избавляет от этой проблемы.
Pivot architecture databaseГде применяется данная модель создания таблиц. В облачных сервисах для построения многопользовательских или мультиарендных систем.
Суть моделиPivot (EAV - Entity-Attribute-Value) архитектура — это подход к хранению данных, при котором:
Метаданные отделены от данных: Структура объектов (таблиц) и полей (колонок) описывается в отдельных таблицах метаданных (mt_objects, mt_fields), а не в DDL базы данных.
Данные хранятся в универсальных колонках: Вместо создания отдельных колонок для каждого атрибута, используется набор универсальных колонок (value0…value49), которые могут хранить данные любого типа.
Pivot таблицы для оптимизации: Для обеспечения быстрой выборки данных создаются специализированные pivot-таблицы (mt_indexes), которые содержат типизированные индексы для полей, помеченных как is_indexed.
Компонент | Таблица в проекте | Назначение |
|---|---|---|
Objects Table | mt_objects | Хранит метаданные объектов (Book, Author) |
Fields Table | mt_fields | Описывает поля объектов и их маппинг на value0-49 |
Data Table | mt_data | Основное хранилище данных с flex columns |
Indexes Pivot | mt_indexes | Типизированные индексы для быстрого поиска |
History Table | mt_field_history | История изменений полей |
Без ALTER TABLE: Добавление новых полей — это просто INSERT в mt_fields, а не дорогостоящая миграция схемы БД
Zero downtime: Изменения метаданных не блокируют работу приложения
Динамическая схема: Разные объекты могут иметь совершенно разные наборы полей
Изоляция тенантов: Все данные разделены по org_id
Экономия ресурсов: Один экземпляр приложения обслуживает множество организаций
Кастомизация: Каждая организация может иметь свою уникальную схему данных
Типизированные индексы: mt_indexes позволяет быстро искать по индексированным полям без сканирования всех 50 value-колонок
Партиционирование: Данные физически разделены по org_id (partition pruning)
Кэширование метаданных: Метаданные можно кэшировать в памяти
История изменений: mt_field_history автоматически отслеживает изменения
Источники данных: sources_meta (JSONB) хранит информацию о происхождении данных
Уникальность и валидация: Ограничения на уровне приложения и БД
Подходит для:
SaaS-приложений с кастомизируемой схемой данных
Систем с динамическими атрибутами (каталоги товаров, CRM, метаданные)
Multi-tenant архитектур
Приложений, требующих частых изменений схемы
Не подходит для:
Высоконагруженных транзакционных систем с фиксированной схемой
Сценариев, требующих сложных JOIN между динамическими полями
Приложений с жесткими требованиями к производительности на больших объемах
Да в такой модели есть свои плюсы и минусы при большом количестве данных, но они есть и в стандартных моделях. Основным плюсом для меня это:
новая игрушка и попробавать такую архитектуру
возможность добавлять новые поля без перестарта приложения
возможность добавления новых объектов
Как это примерно происходит:
Добавить объект — это вставка одной строки:
INSERT INTO mtobjects (orgid, objname, label, plurallabel, iscustom, isactive)
VALUES (1, 'Publisher', 'Издательство', 'Издательства', TRUE, TRUE);
Добавить новое поле существующему объекту — тоже вставка, а не миграция, причём номер свободного слота можно вычислить автоматически, а не подбирать руками:
INSERT INTO mtfields (orgid, objid, fieldname, label, datatype, fieldnum, isindexed, isunique, isrequired, length)
SELECT o.orgid, o.objid, 'Website', 'Сайт издательства', 'url',
COALESCE((SELECT MAX(mf.fieldnum) FROM mtfields mf
WHERE mf.orgid = o.orgid AND mf.objid = o.objid), -1) + 1,
FALSE, FALSE, FALSE, 500
FROM mtobjects o
WHERE o.orgid = 1 AND o.objname = 'Publisher';
С этого момента новое поле сразу доступно через ?fields=Website в публичном API, участвует в resolveRequestedFields, а если пометить его isIndexed = TRUE то автоматически начнёт попадать в mtindexes при следующем сохранении записи через PivotIndexService.syncIndexes, без единой строчки нового Java-кода.
Заплатить за эту гибкость приходится тем, что каждое чтение поля идёт не через нормальную типизированную колонку, а через getValue(int slot) с ручным switch на 50 кейсов, и запросы вроде “показать все книги, у которых PublishedYear > 2020” требуют JOIN с mtindexes, а не прямого WHERE по mtdata. Схема бесконечно расширяема во время работы системы, но платим за это цену на каждом отдельном чтении. Для сервиса, где состав полей продолжает меняться (а в Book Metadata Service он будет меняется), этот большущий плюс.
Изначальная идея создать сервис который сам будет ходить в другие сервиса и у себя агрегировать метаданные книг. Я добавил общедоступные и бесплатные Open Library, Google Books, FantLab и даже LibGen и Sigla. Но это либо серая зона или скрабинг сайта, что на постоянной основе делать нельзя. Это добавил как опцию. Сервис можно скачать и запустить в docker, но по умолчанию выключены. И на общедоступном сайте они выключены.
Мне нельзя делать такие задачки. Потому что очень много идей и хочется все сразу реализовать. Изначально я добавил возможность получения метаданных объектов:
@RestController
@RequestMapping("/api/metadata")
@RequiredArgsConstructor
@Tag(name = "Metadata", description = "Метаданные объектов и полей для UI-конструктора")
public class MetadataController {
private final MetadataService metadataService;
private final ApiKeyService apiKeyService;
@Operation(summary = "Список объектов (Book, Author, Magazine...)")
@GetMapping("/objects")
public ResponseEntity<List<MtObject>> getObjects(
@RequestParam String apiKey,
@Parameter(example = "1") @RequestParam(defaultValue = "1") Integer orgId) {
Integer resolvedOrgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.ADMIN);
return ResponseEntity.ok(metadataService.getObjectsByOrg(resolvedOrgId));
}
@Operation(summary = "Поля объекта — источник для чекбоксов в UI-конструкторе")
@GetMapping("/objects/{objectName}/fields")
public ResponseEntity<List<FieldDto>> getFields(
@RequestParam String apiKey,
@Parameter(example = "1") @RequestParam(defaultValue = "1") Integer orgId,
@Parameter(example = "Book") @PathVariable String objectName) {
Integer resolvedOrgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.ADMIN);
MtObject object = metadataService.getObjectByName(resolvedOrgId, objectName);
List<FieldDto> dtos = metadataService.getFieldsByObject(resolvedOrgId, object.getObjId()).stream()
.map(this::toDto)
.toList();
return ResponseEntity.ok(dtos);
}
private FieldDto toDto(MtField f) {
return FieldDto.builder()
.fieldName(f.getFieldName())
.label(f.getLabel())
.dataType(f.getDataType())
.isIndexed(f.getIsIndexed())
.isRequired(f.getIsRequired())
.build();
}
}
И планировал потом расширять функционально и может к этому вернусь с доступом под админом. Но для публичного сервиса убрал пока полностью.
Потом добавлял ручное обогащение данных через LLM. Когда LLM сама ходит ищет книгу которую не нашли в Open Library, Google Books, FantLab. И такая запись сохраняется во временную таблицу llm_suggestions, а ты потом проверяешь и добавляешь в общую таблицу, если все правильно:
@RestController
@RequestMapping("/api/v1/enrichment")
@RequiredArgsConstructor
@Tag(name = "Enrichment", description = "Обогащение метаданных из открытых источников, LLM fallback и модерация предложений")
public class EnrichmentController {
private final ApiKeyService apiKeyService;
private final PublicSourcesEnrichmentService publicSourcesEnrichmentService;
private final BookQueryService bookQueryService;
@Operation(summary = "Найти книгу по ISBN (Open Library -> Google Books -> FantLab -> LibGen -> LLM fallback в очередь)")
@PostMapping("/public/by-isbn")
public ResponseEntity<Map<String, Object>> enrichPublicByIsbn(
@RequestParam String apiKey,
@RequestParam String isbn) {
Integer orgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.PARTNER_WRITE);
MtData result = publicSourcesEnrichmentService.enrichByIsbn(orgId, isbn);
return respond(orgId, result);
}
@Operation(summary = "Найти книгу по названию, когда ISBN неизвестен (LLM fallback тоже уходит в очередь на модерацию)")
@PostMapping("/public/by-title")
public ResponseEntity<Map<String, Object>> enrichPublicByTitle(
@RequestParam String apiKey,
@RequestParam String title) {
Integer orgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.PARTNER_WRITE);
MtData result = publicSourcesEnrichmentService.enrichByTitle(orgId, title);
return respond(orgId, result);
}
@Operation(summary = "Список предложений от LLM fallback, ожидающих модерации (по умолчанию status=pending)")
@GetMapping("/llm-suggestions")
public ResponseEntity<List<LlmSuggestion>> listSuggestions(
@RequestParam String apiKey,
@RequestParam(defaultValue = "pending") String status) {
Integer orgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.ADMIN);
return ResponseEntity.ok(publicSourcesEnrichmentService.listSuggestions(orgId, status));
}
@Operation(summary = "Принять предложение LLM — записать данные в mt_data")
@PostMapping("/llm-suggestions/{id}/approve")
public ResponseEntity<Map<String, Object>> approveSuggestion(
@RequestParam String apiKey,
@PathVariable Long id) {
Integer orgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.ADMIN);
MtData result = publicSourcesEnrichmentService.approveSuggestion(orgId, id);
var book = bookQueryService.getBookObject(orgId);
var fields = bookQueryService.resolveRequestedFields(orgId, book.getObjId(), null);
return ResponseEntity.ok(bookQueryService.toResponseMap(result, fields));
}
@Operation(summary = "Отклонить предложение LLM — без записи в mt_data")
@PostMapping("/llm-suggestions/{id}/reject")
public ResponseEntity<Map<String, Object>> rejectSuggestion(
@RequestParam String apiKey,
@PathVariable Long id) {
Integer orgId = apiKeyService.resolveOrgIdOrThrow(apiKey, ApiKeyScope.ADMIN);
publicSourcesEnrichmentService.rejectSuggestion(orgId, id);
return ResponseEntity.ok(Map.of("rejected", true, "id", id));
}
private ResponseEntity<Map<String, Object>> respond(Integer orgId, MtData result) {
if (result == null) {
return ResponseEntity.ok(Map.of(
"found", false,
"message", "Книга не найдена структурированными источниками. " +
"Если сработал LLM fallback — предложение в /llm-suggestions на модерацию, " +
"иначе — в очереди unresolved_lookup."
));
}
var book = bookQueryService.getBookObject(orgId);
var fields = bookQueryService.resolveRequestedFields(orgId, book.getObjId(), null);
return ResponseEntity.ok(bookQueryService.toResponseMap(result, fields));
}
}
Но тоже выпилил частично так как у меня не будет времени на просмотр таких записей. Ну и с LLM на самом деле есть момент того, что она страдает галюцинациями и надо все проверять. Почти что ручное добавление. Добавил проверки как мог даже со слабой LLM. Но это на будущее.
Остались только 4 API https://www.bookmetadata.ru/swagger-ui/index.html
В чем проблема LibGen и SiglaLibGen. Проблема LibGen обусловлена тем, что платформа работает на стыке международного права, принципов свободы информации и жесткого законодательства об авторском праве (Copyright Law).
Формально проект нарушает исключительные права правообладателей, но специфика его работы не позволяет однозначно квалифицировать его как классический «черный» преступный бизнес.
Я вроде добавил получение данных с этого ресурса, но выключил. Если будете запускать у себя включайте на свой страх и риск.
Sigla (sigla.ru, каталог НБ МГУ). Здесь вопрос был не в законности контента (это каталожные метаданные, а не файлы книг), а в этичности самого способа доступа — это HTML-скрапинг legacy JSP-сайта, рассчитанного на человека за браузером, а не на автоматические обращения. Тут я ограничил нагрузку на данный ресурс добавив троттлинг не быстрее одного запроса за 2 секунды, синхронизированный на уровне JVM, чтобы параллельные запросы к нашему сервису не превращались в залп по чужому серверу. Источник тоже выключен по умолчанию.
Как работает конвейер обогащения метаданных книгШаг 1: Триггер и первичный поискКогда в систему попадает новый ISBN или Название, сервис проверяет локальный кэш/БД (mt_data + mt_indexes). Если данных нет то запускается поиск по внешним сервисам.
List<MtData> records = bookQueryService.findByIndexedField(..., "ISBN", normalized);
if (records.isEmpty()) {
MtData enriched = enrichmentService.enrichByIsbn(defaultOrgId, normalized, false);
...
}
Публичный поиск (SearchController) вызывает enrichByIsbn(..., false) с allowLlmFallback=false, то есть LLM fallback для публичного API отключён принудительно, даже если llm.fallback.enabled=true в конфиге.
Пока по факту LLM не работает. После экспериментов поправлю чтобы можно было включать параметром, если сервис запускается локально.
Шаг 2: Последовательная цепочка пополнения метаданнымиСервис по очереди пробует опрашивать источники: Open Library → Google Books → FantLab → Sigla → LibGen → LLM fallback (последние три выключены по умолчанию). Как только один ответил успешно, весь его набор полей сохраняется с мета-информацией в JSONB-поле sources_meta таблицы mt_data, а остальные источники в эту итерацию уже не опрашиваются:
{
"2": {"source": "openlibrary", "updated_at": "2026-08-18T00:32:00Z", "confidence": 0.7}, // Title
"6": {"source": "fantlab", "updated_at": "2026-08-18T00:33:10Z", "confidence": 0.65}, // Publisher
"13": {"source": "googlebooks", "updated_at": "2026-08-18T00:31:45Z", "confidence": 0.7} // Description
}
JsonNode olBook = openLibraryClient.findByIsbn(isbn);
if (olBook != null) return persist(..., "openlibrary", 0.7);
JsonNode gbVolume = googleBooksClient.findByIsbn(isbn);
if (gbVolume != null) return persist(..., "googlebooks", 0.7);
// дальше fantlab (0.65) → sigla (0.6) → libgen (0.4) → llmfallback (0.3)
Как только один источник ответил успешно — метод сразу возвращается. Возможно потом добавлю многопоточный обход и логику разрешения конфликтов через confidence. Пока не придумал как определять confidence для каждого поля.
recordHistoryIfChanged пишет старое/новое значение в mt_field_history только для аудита — он не решает, оставлять старое значение или нет. По факту сейчас оставляем значения полей от первого истоника от которого получилось получить данные. Но можно сделать по confidence.
Если метаданные нашли то возвращается примерно такой ответ:
[
{
"guid": "2441145b-ddb1-4111-828e-c1e06ec75594",
"Title": "Каббала или квантовая физика",
"AuthorName": "Михаэль Лайтман",
"PublishedYear": 2024
},
{
"guid": "2d92aaf6-f0d3-4c0d-8579-54f540c45412",
"Title": "Квантовая физика для чайников",
"AuthorName": "Эндрю Циммерман Джонс",
"PublishedYear": 2026
},
{
"guid": "456d7571-4515-4252-b417-d2b3f570b295",
"Title": "Квантовая физика. Понятным языком",
"AuthorName": "Оксана Полякова",
"PublishedYear": 2025
},
{
"guid": "61886993-a479-46cc-9a64-573cf3200d5f",
"Title": "Качественное соответствие общей физике. Хроно-Квантовая физика",
"AuthorName": "Дмитрий Аскольдович Завьялов",
"PublishedYear": 2022
},
{
"guid": "647f0032-5bd5-4f26-9860-3689843577b6",
"Title": "Квантовая физика: От теории к технологиям будущего",
"AuthorName": "Александр Чехановски",
"PublishedYear": 2025
},
{
"guid": "9c80f1f0-a094-4c43-99ce-1dcd66221fc2",
"Title": "Квантовая физика. Знания, которые не займут много места",
"AuthorName": null,
"PublishedYear": 2022
}
]

Если нет просто пустой список [].
Поиск по ISBNGET /api/v1/search/isbn/{isbn}
Если книги нет в базе — сервис автоматически подтянет данные из Open Library, Google Books, FantLab и других источников и сохранит на будущее. Первый запрос по новому ISBN может занять несколько секунд, повторный — мгновенный.
curl "https://ваш-домен/api/v1/search/isbn/9785389143852"
[
{
"guid": "c1eac6ea-ecac-4206-9121-d030cfbc9ff2",
"Title": "Дорога",
"AuthorName": "Кормак Маккарти",
"Publisher": "Азбука",
"PublishedYear": 2018,
"ISBN": "9785389143852",
"CoverUrl": "https://covers.openlibrary.org/b/id/12196387-L.jpg"
}
]
Если книгу не удалось найти нигде — вернётся пустой массив [], а не ошибка.
GET /api/v1/search/title?q=...
Возвращает массив — по названию может найтись несколько изданий одной книги или несколько разных книг со сходным названием. Выбор нужного издания — на стороне вашего приложения.
curl "https://ваш-домен/api/v1/search/title?q=Мастер+и+Маргарита"
GET /api/v1/search?q=...
Сам определяет по контрольной сумме, похож ли запрос на ISBN, и вызывает нужный метод. Удобен, если в вашем приложении одно поле ввода на всё.
curl "https://ваш-домен/api/v1/search?q=9785171234567"
curl "https://ваш-домен/api/v1/search?q=Мастер+и+Маргарита"
По умолчанию возвращается полный набор публичных полей. Можно запросить только нужные — например, для автокомплита в UI достаточно названия и автора:
curl "https://ваш-домен/api/v1/search/title?q=дорога&fields=Title,AuthorName"
Доступные поля: Title, Subtitle, AuthorName, Publisher, PublishedYear, ISBN, ISBN13, CoverUrl, Genre, Description, Pages, Language, Series, SeriesNumber, Rating, Format, AgeRestriction. Неизвестные имена в fields просто игнорируются — опечатка не приведёт к ошибке, вернётся набор по умолчанию.
Я обновил плагин для обсидиан и теперь можно выбрать https://www.bookmetadata.ru


В Calibre не форкал плагин и не добавлял. Но если будут просьбы добавлю. Код генерировал в Perplexity через Claude Sonnet.
Плагин: https://github.com/malexple/obsidian-book-search-plugin
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Pivot grid без сторонних библиотек: кэш, производительность и связанные гриды | 0 | 5 | 30-06-2026 |
| 2 | Книга: «Автоматизация доставки API. Улучшаем скорость и качество с APIOps и OpenAPI» | 0 | 5.48 | 09-07-2026 |
| 3 | Платформа данных на минималках. Часть 2. Практика: разворачиваем и настраиваем каталог метаданных | 0 | 8.35 | 19-08-2026 |
| 4 | Оптимизация без AI: как я автоматизировал API-ручки и типы | -2 | 5 | 29-06-2026 |
| 5 | 2 года в Obsidian: мой workflow, архитектура заметок, плагины и синхронизация | 0 | 6.81 | 26-07-2026 |
| 6 | Как пишут базы данных на C#: RavenDb | 0 | 7.6 | 06-08-2026 |
| 7 | Избавляемся от потерянных событий в микросервисах — как я написал свой Spring Starter для Outbox/Inbox | 0 | 6.87 | 29-07-2026 |
| 8 | Убежище книги | 0 | 9.51 | 12-08-2026 |
| 9 | Как я искал себе читалку и придумал новый формат электронных книг для изучающих язык | 0 | 5 | 08-07-2026 |