Handle Audible series A/B layouts; add parsers & tests

Fix intermittent empty series pages by adding dedicated Cheerio parsers that handle Audible's dual (modern/legacy) /series/{asin} layouts. Adds src/lib/integrations/audible-series-parsers.ts (modern-first parsing from <adbl-product-row> JSON with legacy fallback, outsideCarousel exclusion, rating/description/count extraction and layout-drift warning), updates src/lib/integrations/audible-series.ts to orchestrate the new parsers, introduces tests/tests/integrations/audible-series-parsers.test.ts for layout parity, and updates documentation (documentation/integrations/audible.md and TABLEOFCONTENTS) describing the dual-layout behavior and the fix.
This commit is contained in:
kikootwo
2026-08-11 14:55:54 -04:00
parent af0a72b367
commit 5c977b8e17
5 changed files with 841 additions and 302 deletions
@@ -0,0 +1,324 @@
/**
* Component: Audible Series Page Parser Tests
* Documentation: documentation/integrations/audible.md
*
* Audible A/B-serves two series-page layouts. Fixtures below mirror the real
* markup of each. The parity tests are the point: both layouts must yield the
* same books, cover, rating and description.
*/
import * as cheerio from 'cheerio';
import { describe, expect, it } from 'vitest';
import {
parseSeriesBooks,
parseSeriesDescription,
parseSeriesPageSummary,
parseSimilarSeries,
} from '@/lib/integrations/audible-series-parsers';
import { getLanguageForRegion } from '@/lib/constants/language-config';
const LANG = getLanguageForRegion('us');
const parseBooks = ($: cheerio.CheerioAPI) =>
parseSeriesBooks($, LANG.scraping.authorPrefixes, LANG.scraping.narratorPrefixes, LANG);
// ---------------------------------------------------------------------------
// Fixture data — one single-narrator and one multi-narrator title
// ---------------------------------------------------------------------------
interface FixtureBook {
asin: string;
title: string;
narrators: string[];
duration: string;
minutes: number;
rating: number;
releaseDate: string;
}
const AUTHOR = 'J.T. Wright';
const AUTHOR_ASIN = 'B085Q425VB';
const DESCRIPTION = 'The Infinite World: ever-changing and ever growing.';
const TAGS = ['Epic Fantasy', 'Feel-Good'];
const COVER_SRC = 'https://m.media-amazon.com/images/I/51mVFLRVLTL._SL175_.jpg';
const COVER_EXPECTED = 'https://m.media-amazon.com/images/I/51mVFLRVLTL._SL500_.jpg';
const BOOKS: FixtureBook[] = [
{
asin: '177424599X',
title: 'The Land of the Undying Lord',
narrators: ['Tim Campbell'],
duration: '16 hrs and 17 mins',
minutes: 977,
rating: 4.8,
releaseDate: '2020-10-20',
},
{
asin: 'B09VW5NBLS',
title: 'Brambles and Thorns',
narrators: ['Tim Campbell', 'Andrea Parsneau'],
duration: '19 hrs and 31 mins',
minutes: 1171,
rating: 4.7,
releaseDate: '2022-04-12',
},
];
/** "Listeners also enjoyed" carousel — present in BOTH layouts. */
function similarSeriesCarousel(): string {
return `
<adbl-product-carousel id="SeriestoSeries">
<adbl-product-grid-item>
<div class="adbl-impression-emitted" data-asin="B0CGS1LPWJ"></div>
<adbl-collection-image><img src="https://m.media-amazon.com/images/I/other._SL175_.jpg" /></adbl-collection-image>
<adbl-metadata slot="title"><a href="/series/Hockey-Guys/B0CGS1LPWJ">Hockey Guys</a></adbl-metadata>
<adbl-metadata slot="child-count">3 titles</adbl-metadata>
</adbl-product-grid-item>
</adbl-product-carousel>`;
}
// ---------------------------------------------------------------------------
// Modern layout fixture (<adbl-product-row> + embedded JSON)
// ---------------------------------------------------------------------------
function modernRow(book: FixtureBook, index: number, metadataJson?: string): string {
const json = metadataJson ?? JSON.stringify({
authors: [{ name: AUTHOR, url: `/author/JT-Wright/${AUTHOR_ASIN}` }],
narrators: book.narrators.map(n => ({ name: n, url: `/search?searchNarrator=${encodeURIComponent(n)}` })),
duration: book.duration,
language: 'English',
releaseDate: book.releaseDate,
rating: { value: book.rating, count: 4831 },
});
return `
<adbl-style-scope>
<adbl-product-row variant="catalog" series-header="Book ${index + 1}" placement="base">
<a href="/pd/slug-audiobook/${book.asin}" slot="image">
<adbl-product-image><img src="${COVER_SRC}" alt="${book.title}" loading="lazy" /></adbl-product-image>
</a>
<h3 slot="title"><a href="/pd/slug-audiobook/${book.asin}">${book.title}</a></h3>
<h4 slot="subtitle">The Infinite World, Book ${index + 1}</h4>
<adbl-sample-button slot="sample-button" data-asin="${book.asin}"></adbl-sample-button>
<script type="application/json">${json}</script>
</adbl-product-row>
</adbl-style-scope>`;
}
function makeModernPage(rows: string = BOOKS.map((b, i) => modernRow(b, i)).join('')): string {
return `<html><body>
<adbl-metadata-group size="xl">
<adbl-metadata slot="title"><h1>The Infinite World</h1></adbl-metadata>
<span slot="child"> ${BOOKS.length} books in series </span>
<adbl-star-rating slot="rating" value="5" count="13156" aria-label="13,156 ratings">
<noscript>13,156 ratings</noscript>
</adbl-star-rating>
</adbl-metadata-group>
<div id="series-about">
<adbl-text-block lines="2">
<h3 slot="title">The Land of the Undying Lord Publisher's summary</h3>
<p>${DESCRIPTION}</p>
</adbl-text-block>
<adbl-chip-group>
${TAGS.map(t => `<adbl-chip href="/tag/theme/x" class="adbl_rec_tag related-tag">${t}</adbl-chip>`).join('')}
</adbl-chip-group>
</div>
<div id="series-titles">${rows}</div>
${similarSeriesCarousel()}
</body></html>`;
}
// ---------------------------------------------------------------------------
// Legacy layout fixture (<li class="productListItem">)
// ---------------------------------------------------------------------------
function legacyItem(book: FixtureBook): string {
const narratorLinks = book.narrators
.map(n => `<a href="/search?searchNarrator=${encodeURIComponent(n)}">${n}</a>`)
.join(', ');
return `
<li class="bc-list-item productListItem" data-asin="${book.asin}">
<div class="bc-container">
<img src="${COVER_SRC}" />
<h3 class="bc-heading"><a href="/pd/slug-audiobook/${book.asin}">${book.title}</a></h3>
<ul class="bc-list">
<li class="bc-list-item authorLabel"><a href="/author/JT-Wright/${AUTHOR_ASIN}">${AUTHOR}</a></li>
<li class="bc-list-item narratorLabel">Narrated by: ${narratorLinks}</li>
<li class="bc-list-item runtimeLabel">Length: ${book.duration}</li>
</ul>
<span class="ratingsLabel">${book.rating} out of 5</span>
</div>
</li>`;
}
function makeLegacyPage(items: string = BOOKS.map(legacyItem).join('')): string {
return `<html><body>
<h1>The Infinite World</h1>
<span class="bc-text">${BOOKS.length} books</span>
<div class="bc-review-stars" aria-label="5 out of 5 stars"></div>
<span class="series-rating bc-color-secondary">13,156 ratings</span>
<div class="bc-expander-content">${DESCRIPTION}</div>
${TAGS.map(t => `<adbl-chip href="/tag/theme/x" class="adbl_rec_tag related-tag">${t}</adbl-chip>`).join('')}
<ul class="bc-list">${items}</ul>
${similarSeriesCarousel()}
</body></html>`;
}
const modern$ = () => cheerio.load(makeModernPage());
const legacy$ = () => cheerio.load(makeLegacyPage());
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('parseSeriesBooks', () => {
it('parses books from the modern adbl-product-row layout', () => {
const books = parseBooks(modern$());
expect(books).toHaveLength(2);
expect(books[0]).toMatchObject({
asin: '177424599X',
title: 'The Land of the Undying Lord',
author: AUTHOR,
authorAsin: AUTHOR_ASIN,
narrator: 'Tim Campbell',
coverArtUrl: COVER_EXPECTED,
rating: 4.8,
durationMinutes: 977,
releaseDate: '2020-10-20',
language: 'English',
});
});
it('parses books from the legacy productListItem layout', () => {
const books = parseBooks(legacy$());
expect(books).toHaveLength(2);
expect(books[0]).toMatchObject({
asin: '177424599X',
title: 'The Land of the Undying Lord',
author: AUTHOR,
authorAsin: AUTHOR_ASIN,
narrator: 'Tim Campbell',
coverArtUrl: COVER_EXPECTED,
rating: 4.8,
durationMinutes: 977,
});
});
it('yields identical core book data for both layouts', () => {
const core = (b: Record<string, unknown>) => ({
asin: b.asin,
title: b.title,
author: b.author,
authorAsin: b.authorAsin,
narrator: b.narrator,
coverArtUrl: b.coverArtUrl,
rating: b.rating,
durationMinutes: b.durationMinutes,
});
expect(parseBooks(modern$()).map(core)).toEqual(parseBooks(legacy$()).map(core));
});
it('captures every narrator of a multi-narrator production in both layouts', () => {
expect(parseBooks(modern$())[1].narrator).toBe('Tim Campbell, Andrea Parsneau');
expect(parseBooks(legacy$())[1].narrator).toBe('Tim Campbell, Andrea Parsneau');
});
it('still returns title, asin and cover when a row JSON blob is malformed', () => {
const $ = cheerio.load(makeModernPage(modernRow(BOOKS[0], 0, '{not valid json')));
const books = parseBooks($);
expect(books).toHaveLength(1);
expect(books[0]).toMatchObject({
asin: '177424599X',
title: 'The Land of the Undying Lord',
coverArtUrl: COVER_EXPECTED,
});
expect(books[0].durationMinutes).toBeUndefined();
expect(books[0].narrator).toBeUndefined();
});
it('ignores product rows inside a similar-series carousel', () => {
const $ = cheerio.load(`<html><body>
<adbl-product-carousel id="SeriestoSeries">${modernRow(BOOKS[0], 0)}</adbl-product-carousel>
</body></html>`);
expect(parseBooks($)).toEqual([]);
});
it('returns an empty array when the page has no book rows', () => {
expect(parseBooks(cheerio.load('<html><body><h1>The Infinite World</h1></body></html>'))).toEqual([]);
});
});
describe('parseSeriesPageSummary', () => {
it('parses header fields from the modern layout', () => {
expect(parseSeriesPageSummary(modern$(), 'B08L6182J8')).toEqual({
asin: 'B08L6182J8',
title: 'The Infinite World',
bookCount: 2,
rating: 5,
ratingCount: 13156,
tags: TAGS,
coverArtUrl: COVER_EXPECTED,
});
});
it('parses header fields from the legacy layout', () => {
expect(parseSeriesPageSummary(legacy$(), 'B08L6182J8')).toEqual({
asin: 'B08L6182J8',
title: 'The Infinite World',
bookCount: 2,
rating: 5,
ratingCount: 13156,
tags: TAGS,
coverArtUrl: COVER_EXPECTED,
});
});
it('never takes the book count from the similar-series carousel', () => {
// The carousel advertises "3 titles" for a different series.
expect(parseSeriesPageSummary(modern$(), 'B08L6182J8').bookCount).toBe(2);
expect(parseSeriesPageSummary(legacy$(), 'B08L6182J8').bookCount).toBe(2);
});
it('falls back to counting rendered rows when no header count exists', () => {
const $ = cheerio.load(`<html><body>
<h1>The Infinite World</h1>
<div id="series-titles">${BOOKS.map((b, i) => modernRow(b, i)).join('')}</div>
</body></html>`);
expect(parseSeriesPageSummary($, 'B08L6182J8').bookCount).toBe(2);
});
});
describe('parseSeriesDescription', () => {
it('reads the description from the modern adbl-text-block', () => {
expect(parseSeriesDescription(modern$())).toBe(DESCRIPTION);
});
it('reads the description from the legacy expander content', () => {
expect(parseSeriesDescription(legacy$())).toBe(DESCRIPTION);
});
it('returns undefined when no description is present', () => {
expect(parseSeriesDescription(cheerio.load('<html><body><h1>x</h1></body></html>'))).toBeUndefined();
});
});
describe('parseSimilarSeries', () => {
it('parses the SeriestoSeries carousel in both layouts', () => {
const expected = [{
asin: 'B0CGS1LPWJ',
title: 'Hockey Guys',
bookCount: 3,
coverArtUrl: 'https://m.media-amazon.com/images/I/other._SL500_.jpg',
}];
expect(parseSimilarSeries(modern$())).toEqual(expected);
expect(parseSimilarSeries(legacy$())).toEqual(expected);
});
});