fix(metadata): split embedded subtitle out of long hardcover titles (#887)

* fix(metadata): split embedded subtitle out of long hardcover titles

Hardcover's subtitle field is legacy: it is fed by their data providers but
ignored and unmaintained, so librarians follow the convention of embedding
the subtitle in the title after a colon. Imports from search and from
editions without their own subtitle therefore stored the full
"Title: Subtitle" string as the title while the subtitle stayed empty.

Mirror Hardcover's own display rule, confirmed by their developers: split
the title on the first colon once it exceeds 60 characters. An API-provided
subtitle keeps precedence and leaves the title untouched, and the length
threshold protects short colon titles like "2001: A Space Odyssey" from
false splits.

Closes #867

* fix(metadata): trim hardcover titles that duplicate the api subtitle

Books like hardcover.app/books/quantitative-momentum carry the embedded
subtitle in the librarian-curated title and additionally expose the same
text through the legacy edition subtitle. The subtitle precedence rule
then skipped the split and the imported title kept the duplicated
subtitle. When the part after the first colon matches the API subtitle
(case-insensitive, trimmed) and the title is over the 60-character
threshold, trim the title and keep the API subtitle verbatim. Titles
whose subtitle genuinely differs stay untouched.

Ref #867

* refactor(metadata): tighten hardcover embedded-subtitle split helper

Narrow splitEmbeddedSubtitle to the non-optional title both call sites
already pass, which removes the unreachable `?? doc.title` fallbacks, and
rename the threshold constant so it no longer reads as an off-by-one:
titles up to 60 characters are left unsplit.

Treat a whitespace-only API subtitle as absent so it no longer blocks the
split, and cover two interactions that had none: the blank-subtitle case,
and a relevance query carrying the full combined title still ranking the
split candidate first.

---------

Co-authored-by: ohoeltke <hoeltke@oho-dt.de>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
Oliver
2026-08-06 00:33:05 +02:00
committed by GitHub
parent ac676520b8
commit 6b0f561c76
3 changed files with 234 additions and 4 deletions
@@ -98,6 +98,13 @@ describe('candidate-relevance', () => {
const result = filterAndRank(candidates, params);
expect(result).toHaveLength(0);
});
it('keeps a candidate whose embedded subtitle was split out by its provider mapper', () => {
const fullTitle = "Babel, or The Necessity of Violence: An Arcane History of the Oxford Translators' Revolution";
const candidates = [candidate('A Different Book Entirely'), candidate('Babel, or The Necessity of Violence')];
const result = filterAndRank(candidates, { title: fullTitle });
expect(result[0].title).toBe('Babel, or The Necessity of Violence');
});
});
describe('displayTitle fallback', () => {
@@ -143,6 +143,115 @@ describe('mapSearchDocument', () => {
const doc: HardcoverSearchDocument = { ...baseDocument, image: undefined };
expect(mapSearchDocument(doc).coverUrl).toBeUndefined();
});
describe('embedded subtitle split', () => {
const longColonTitle = "Babel, or The Necessity of Violence: An Arcane History of the Oxford Translators' Revolution";
it('splits a long colon title into title and subtitle when no subtitle is provided', () => {
const doc: HardcoverSearchDocument = { ...baseDocument, title: longColonTitle, subtitle: undefined };
const result = mapSearchDocument(doc);
expect(result.title).toBe('Babel, or The Necessity of Violence');
expect(result.subtitle).toBe("An Arcane History of the Oxford Translators' Revolution");
});
it('keeps the title untouched when the API provides a subtitle', () => {
const doc: HardcoverSearchDocument = { ...baseDocument, title: longColonTitle, subtitle: 'Provided Subtitle' };
const result = mapSearchDocument(doc);
expect(result.title).toBe(longColonTitle);
expect(result.subtitle).toBe('Provided Subtitle');
});
it('does not split short colon titles', () => {
const doc: HardcoverSearchDocument = { ...baseDocument, title: '2001: A Space Odyssey', subtitle: undefined };
const result = mapSearchDocument(doc);
expect(result.title).toBe('2001: A Space Odyssey');
expect(result.subtitle).toBeUndefined();
});
it('treats a blank API subtitle as absent and still splits', () => {
const doc: HardcoverSearchDocument = { ...baseDocument, title: longColonTitle, subtitle: ' ' };
const result = mapSearchDocument(doc);
expect(result.title).toBe('Babel, or The Necessity of Violence');
expect(result.subtitle).toBe("An Arcane History of the Oxford Translators' Revolution");
});
it('does not split long titles without a colon', () => {
const title = 'A'.repeat(70);
const doc: HardcoverSearchDocument = { ...baseDocument, title, subtitle: undefined };
const result = mapSearchDocument(doc);
expect(result.title).toBe(title);
expect(result.subtitle).toBeUndefined();
});
it('splits only titles longer than 60 characters', () => {
const at60 = `${'A'.repeat(30)}: ${'B'.repeat(28)}`;
const at61 = `${'A'.repeat(30)}: ${'B'.repeat(29)}`;
expect(mapSearchDocument({ ...baseDocument, title: at60, subtitle: undefined }).title).toBe(at60);
const result = mapSearchDocument({ ...baseDocument, title: at61, subtitle: undefined });
expect(result.title).toBe('A'.repeat(30));
expect(result.subtitle).toBe('B'.repeat(29));
});
it('splits at the first colon only', () => {
const doc: HardcoverSearchDocument = {
...baseDocument,
title: 'How to Do Nothing: Resisting the Attention Economy: A Field Guide',
subtitle: undefined,
};
const result = mapSearchDocument(doc);
expect(result.title).toBe('How to Do Nothing');
expect(result.subtitle).toBe('Resisting the Attention Economy: A Field Guide');
});
it('does not split when nothing follows the colon', () => {
const title = `${'A'.repeat(70)}:`;
const doc: HardcoverSearchDocument = { ...baseDocument, title, subtitle: undefined };
const result = mapSearchDocument(doc);
expect(result.title).toBe(title);
expect(result.subtitle).toBeUndefined();
});
it('does not split when the title starts with a colon', () => {
const title = `: ${'A'.repeat(70)}`;
const doc: HardcoverSearchDocument = { ...baseDocument, title, subtitle: undefined };
const result = mapSearchDocument(doc);
expect(result.title).toBe(title);
expect(result.subtitle).toBeUndefined();
});
it('trims the title when the API subtitle duplicates the part after the colon', () => {
const doc: HardcoverSearchDocument = {
...baseDocument,
title: "Quantitative Momentum: A Practitioner's Guide to Building a Momentum-Based Stock Selection System",
subtitle: "A Practitioner's Guide to Building a Momentum-Based Stock Selection System",
};
const result = mapSearchDocument(doc);
expect(result.title).toBe('Quantitative Momentum');
expect(result.subtitle).toBe("A Practitioner's Guide to Building a Momentum-Based Stock Selection System");
});
it('matches the duplicated subtitle case-insensitively and keeps the API value verbatim', () => {
const doc: HardcoverSearchDocument = {
...baseDocument,
title: "Quantitative Momentum: A Practitioner's Guide to Building a Momentum-Based Stock Selection System",
subtitle: "A PRACTITIONER'S GUIDE TO BUILDING A MOMENTUM-BASED STOCK SELECTION SYSTEM",
};
const result = mapSearchDocument(doc);
expect(result.title).toBe('Quantitative Momentum');
expect(result.subtitle).toBe("A PRACTITIONER'S GUIDE TO BUILDING A MOMENTUM-BASED STOCK SELECTION SYSTEM");
});
it('does not trim short colon titles even when the subtitle matches', () => {
const doc: HardcoverSearchDocument = {
...baseDocument,
title: '2001: A Space Odyssey',
subtitle: 'A Space Odyssey',
};
const result = mapSearchDocument(doc);
expect(result.title).toBe('2001: A Space Odyssey');
expect(result.subtitle).toBe('A Space Odyssey');
});
});
});
describe('mapBookWithEditions', () => {
@@ -403,6 +512,96 @@ describe('mapBookWithEditions', () => {
expect(results[1].isbn13).toBe('NOPAGES');
});
describe('embedded subtitle split', () => {
const longColonTitle = "Babel, or The Necessity of Violence: An Arcane History of the Oxford Translators' Revolution";
it('splits a long colon title when neither edition nor book has a subtitle', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
subtitle: undefined,
editions: [{ ...baseBook.editions![0], title: longColonTitle, subtitle: undefined }],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe('Babel, or The Necessity of Violence');
expect(result.subtitle).toBe("An Arcane History of the Oxford Translators' Revolution");
});
it('keeps the title untouched when the edition provides its own subtitle', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
subtitle: undefined,
editions: [{ ...baseBook.editions![0], title: longColonTitle, subtitle: 'Edition Subtitle' }],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe(longColonTitle);
expect(result.subtitle).toBe('Edition Subtitle');
});
it('keeps the title untouched when the book subtitle fallback applies', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
subtitle: 'Book Subtitle',
editions: [{ ...baseBook.editions![0], title: longColonTitle, subtitle: undefined }],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe(longColonTitle);
expect(result.subtitle).toBe('Book Subtitle');
});
it('splits the book-title fallback when the edition has no title', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
title: longColonTitle,
subtitle: undefined,
editions: [{ ...baseBook.editions![0], title: undefined, subtitle: undefined }],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe('Babel, or The Necessity of Violence');
expect(result.subtitle).toBe("An Arcane History of the Oxford Translators' Revolution");
});
it('does not split short colon titles', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
subtitle: undefined,
editions: [{ ...baseBook.editions![0], title: '2001: A Space Odyssey', subtitle: undefined }],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe('2001: A Space Odyssey');
expect(result.subtitle).toBeUndefined();
});
it('trims the title when the edition subtitle duplicates the part after the colon', () => {
const fullTitle = "Quantitative Momentum: A Practitioner's Guide to Building a Momentum-Based Stock Selection System";
const book: HardcoverBookWithEditions = {
...baseBook,
title: fullTitle,
subtitle: undefined,
editions: [
{
...baseBook.editions![0],
title: fullTitle,
subtitle: "A Practitioner's Guide to Building a Momentum-Based Stock Selection System",
},
],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe('Quantitative Momentum');
expect(result.subtitle).toBe("A Practitioner's Guide to Building a Momentum-Based Stock Selection System");
});
it('keeps the title untouched when the edition subtitle differs from the embedded one', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
subtitle: undefined,
editions: [{ ...baseBook.editions![0], title: longColonTitle, subtitle: 'A Completely Different Subtitle' }],
};
const [result] = mapBookWithEditions(book);
expect(result.title).toBe(longColonTitle);
expect(result.subtitle).toBe('A Completely Different Subtitle');
});
});
it('filters out contributors with no author name', () => {
const book: HardcoverBookWithEditions = {
...baseBook,
@@ -49,6 +49,28 @@ function resolveEditionPublishedDate(edition: HardcoverEdition, book: HardcoverB
return parseDate(edition.release_year, edition.release_date) ?? parseDate(book.release_year, book.release_date);
}
// Hardcover's subtitle field is legacy and unmaintained; librarians embed the
// subtitle in the title separated by a colon. Mirror Hardcover's own display
// rule (confirmed by their developers): split on the first colon once the
// title exceeds 60 characters. Short colon titles like "2001: A Space Odyssey"
// stay untouched. An API-provided subtitle is kept verbatim: it blocks the
// split unless it merely duplicates the embedded part, in which case the
// title is trimmed so the subtitle does not appear twice.
const MAX_UNSPLIT_TITLE_LENGTH = 60;
function splitEmbeddedSubtitle(title: string, subtitle: string | undefined): { title: string; subtitle: string | undefined } {
if (title.length <= MAX_UNSPLIT_TITLE_LENGTH) return { title, subtitle };
const colonIndex = title.indexOf(':');
if (colonIndex === -1) return { title, subtitle };
const baseTitle = title.slice(0, colonIndex).trim();
const embeddedSubtitle = title.slice(colonIndex + 1).trim();
if (!baseTitle || !embeddedSubtitle) return { title, subtitle };
const providedSubtitle = subtitle?.trim();
if (!providedSubtitle) return { title: baseTitle, subtitle: embeddedSubtitle };
if (embeddedSubtitle.toLowerCase() === providedSubtitle.toLowerCase()) return { title: baseTitle, subtitle };
return { title, subtitle };
}
function normalizeCommunityRating(value: number | undefined): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 5 ? value : undefined;
}
@@ -61,12 +83,13 @@ export function mapSearchDocument(doc: HardcoverSearchDocument): MetadataCandida
const { isbn10, isbn13 } = pickIsbn(doc.isbns);
const communityRating = normalizeCommunityRating(doc.rating);
const communityRatingCount = normalizeCommunityRatingCount(doc.ratings_count);
const { title, subtitle } = splitEmbeddedSubtitle(doc.title, doc.subtitle);
return {
provider: MetadataProviderKey.HARDCOVER,
providerId: doc.slug,
title: doc.title,
subtitle: doc.subtitle,
title,
subtitle,
description: doc.description,
authors: doc.author_names ?? [],
pageCount: doc.pages,
@@ -94,13 +117,14 @@ function mapEdition(edition: HardcoverEdition, book: HardcoverBookWithEditions):
const authors = editionAuthors.length > 0 ? editionAuthors : extractAuthorsFromContributors(book.cached_contributors);
const communityRating = normalizeCommunityRating(book.rating);
const communityRatingCount = normalizeCommunityRatingCount(book.ratings_count);
const { title, subtitle } = splitEmbeddedSubtitle(edition.title ?? book.title, edition.subtitle ?? book.subtitle);
return {
provider: MetadataProviderKey.HARDCOVER,
providerId: book.slug,
hardcoverEditionId: String(edition.id),
title: edition.title ?? book.title,
subtitle: edition.subtitle ?? book.subtitle,
title,
subtitle,
description: book.description,
authors,
publisher: edition.publisher?.name,