mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-09-01 05:08:38 +00:00
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:
@@ -48,6 +48,7 @@
|
|||||||
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
|
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||||
- **Dedup & works table (cross-ASIN identity)** → [integrations/audible.md](integrations/audible.md#dedup--works-table)
|
- **Dedup & works table (cross-ASIN identity)** → [integrations/audible.md](integrations/audible.md#dedup--works-table)
|
||||||
- **Multi-narrator capture in HTML scrapers** → [integrations/audible.md](integrations/audible.md#narrator-capture-in-html-scrapers)
|
- **Multi-narrator capture in HTML scrapers** → [integrations/audible.md](integrations/audible.md#narrator-capture-in-html-scrapers)
|
||||||
|
- **Series page dual layout (modern/legacy A/B)** → [integrations/audible.md](integrations/audible.md#series-page-dual-layout)
|
||||||
|
|
||||||
## E-book Support (First-Class)
|
## E-book Support (First-Class)
|
||||||
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||||
@@ -182,3 +183,4 @@
|
|||||||
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"Why is a series page blank / showing no books, then fine after a refresh?"** → [integrations/audible.md](integrations/audible.md#series-page-dual-layout) (Audible A/B-serves two layouts; parsers handle both)
|
||||||
|
|||||||
@@ -119,10 +119,32 @@ Configurable Audible region for accurate metadata matching across international
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Types: `src/lib/types/audible.ts`
|
- Types: `src/lib/types/audible.ts`
|
||||||
- Service: `src/lib/integrations/audible.service.ts`
|
- Service: `src/lib/integrations/audible.service.ts`
|
||||||
- Series (HTML): `src/lib/integrations/audible-series.ts`
|
- Series (HTML): `src/lib/integrations/audible-series.ts` (fetch/orchestration), `src/lib/integrations/audible-series-parsers.ts` (Cheerio parsing)
|
||||||
- Config: `src/lib/services/config.service.ts`
|
- Config: `src/lib/services/config.service.ts`
|
||||||
- API: `src/app/api/admin/settings/audible/route.ts`
|
- API: `src/app/api/admin/settings/audible/route.ts`
|
||||||
|
|
||||||
|
## Series Page Dual Layout
|
||||||
|
|
||||||
|
**Status:** ✅ Handled | Audible A/B-serves two different `/series/{asin}` layouts per request
|
||||||
|
|
||||||
|
Roughly 40% of requests return the **modern** layout, the rest **legacy**. The same URL flips between them request-to-request. All parsers in `audible-series-parsers.ts` try modern first, then fall back to legacy. The layouts are disjoint — a modern page contains zero legacy markers and vice versa.
|
||||||
|
|
||||||
|
| Field | Modern | Legacy |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| Books | `<adbl-product-row>` inside `#series-titles` | `<li class="productListItem">` / `.bc-list-item` |
|
||||||
|
| Book metadata | `<script type="application/json">` per row: `{authors[{name,url}], narrators[{name}], duration, language, releaseDate, rating{value,count}}` | scraped from `.authorLabel` / `searchNarrator=` anchors / `.runtimeLabel` / `.ratingsLabel` |
|
||||||
|
| Book count | `<span slot="child">4 books in series</span>` | `"4 books"` span text |
|
||||||
|
| Series rating | `<adbl-star-rating slot="rating" value count>` | `div.bc-review-stars[aria-label]` + `span.series-rating` |
|
||||||
|
| Description | `#series-about adbl-text-block` (read `<p>` only — `[slot="title"]` is a heading) | `.bc-expander-content` |
|
||||||
|
| Cover | first row's `adbl-product-image img` | `.productListItem img` |
|
||||||
|
| Tags, similar series | `adbl-chip.related-tag`, `adbl-product-carousel#SeriestoSeries` (identical in both) | same |
|
||||||
|
|
||||||
|
**Critical:** carousel content describes *other* series. `outsideCarousel()` excludes `adbl-product-carousel` descendants from book-count, rating, cover and row selection — the "Listeners also enjoyed" carousel carries its own `slot="child-count"` values.
|
||||||
|
|
||||||
|
Modern rows yield strictly richer data (`releaseDate`, `language`, real narrator arrays, author ASIN). `parseSeriesBooks()` returns legacy results only when zero modern rows parse.
|
||||||
|
|
||||||
|
`scrapeSeriesPage()` logs a warning when the header reports books but zero rows parse — that signals Audible changed its markup again.
|
||||||
|
|
||||||
## Unified Matching (`audiobook-matcher.ts`)
|
## Unified Matching (`audiobook-matcher.ts`)
|
||||||
|
|
||||||
**Status:** Production Ready (ASIN-Only Matching)
|
**Status:** Production Ready (ASIN-Only Matching)
|
||||||
@@ -287,6 +309,13 @@ interface AuthorBooksResult {
|
|||||||
|
|
||||||
## Fixed Issues
|
## Fixed Issues
|
||||||
|
|
||||||
|
**Series pages intermittently empty — blank cover, correct count, no books (2026-08-11)**
|
||||||
|
- **Problem:** Loading a series showed a blank cover and the right book count in the card, but an empty "Books in Series" table. Refreshing a few times fixed it. Logs showed `Series detail complete: "..." (0 books)` alternating with `(4 books)` for the same ASIN seconds apart.
|
||||||
|
- **Root cause:** Audible A/B-serves two different `/series/{asin}` layouts. `parseSeriesBooks` only matched the legacy `.productListItem` / `.bc-list-item` markup; the modern layout renders books as `<adbl-product-row>` web components and contains **zero** legacy classes. Probing one URL 12× returned the modern layout 5 times. The header (`h1`, book-count span) parses identically in both, which is why the count stayed correct while the list emptied. Same cause silently dropped `coverArtUrl`, `rating`, `ratingCount` and `description`.
|
||||||
|
- **Scope:** series pages only — `/search`, `/author/` and `/adblbestsellers` returned legacy markup on 6/6 probes each.
|
||||||
|
- **Fix:** Extracted all Cheerio parsing into `audible-series-parsers.ts` and made every parser modern-first with legacy fallback. Modern rows read their embedded JSON blob (structured narrators, author ASIN, duration, rating, releaseDate), so they carry more data than the legacy path. Added `outsideCarousel()` so similar-series carousel values can't be mistaken for this series', and a layout-drift warning when the header reports books but no rows parse.
|
||||||
|
- **Location:** `src/lib/integrations/audible-series-parsers.ts` (new); `src/lib/integrations/audible-series.ts` (now fetch/orchestration only); `tests/integrations/audible-series-parsers.test.ts` (new, dual-layout parity tests).
|
||||||
|
|
||||||
**Series-page duplicates not collapsing across user views (2026-05-14)**
|
**Series-page duplicates not collapsing across user views (2026-05-14)**
|
||||||
- **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
|
- **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
|
||||||
- **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
|
- **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* Component: Audible Series Page Parsers
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*
|
||||||
|
* Pure Cheerio parsers for Audible series pages.
|
||||||
|
*
|
||||||
|
* Audible A/B-serves two different layouts for /series/{asin}:
|
||||||
|
* modern - books in <adbl-product-row> web components inside #series-titles,
|
||||||
|
* each carrying a JSON metadata blob (authors, narrators, duration,
|
||||||
|
* rating, releaseDate). Contains zero legacy classes.
|
||||||
|
* legacy - books in <li class="productListItem"> inside .bc-list-item.
|
||||||
|
*
|
||||||
|
* The two layouts are disjoint, so every parser here tries modern first and
|
||||||
|
* falls back to legacy. Parsing only the legacy layout made series pages come
|
||||||
|
* back intermittently empty (blank cover + correct count + no books).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import type { AudibleAudiobook } from './audible.service';
|
||||||
|
import {
|
||||||
|
buildContainsSelector,
|
||||||
|
stripPrefixes,
|
||||||
|
type LanguageConfig,
|
||||||
|
} from '../constants/language-config';
|
||||||
|
import { parseRuntime } from '../utils/parse-runtime';
|
||||||
|
import { extractAllNarrators } from '../utils/extract-narrator';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SeriesSummary {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
bookCount: number;
|
||||||
|
rating?: number;
|
||||||
|
ratingCount?: number;
|
||||||
|
tags: string[];
|
||||||
|
coverArtUrl?: string;
|
||||||
|
audibleUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimilarSeries {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
bookCount?: number;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesDetail {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
bookCount: number;
|
||||||
|
rating?: number;
|
||||||
|
ratingCount?: number;
|
||||||
|
description?: string;
|
||||||
|
tags: string[];
|
||||||
|
books: AudibleAudiobook[];
|
||||||
|
similarSeries: SimilarSeries[];
|
||||||
|
audibleUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON metadata blob embedded in each modern <adbl-product-row>. */
|
||||||
|
interface AdblRowMetadata {
|
||||||
|
authors?: Array<{ name?: string; url?: string }>;
|
||||||
|
narrators?: Array<{ name?: string }>;
|
||||||
|
duration?: string;
|
||||||
|
language?: string;
|
||||||
|
releaseDate?: string;
|
||||||
|
rating?: { value?: number; count?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Upgrade an Audible image URL to the 500px variant. */
|
||||||
|
function toLargeCover(src?: string): string | undefined {
|
||||||
|
return src?.replace(/\._.*_\./, '._SL500_.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select elements outside any product carousel. Carousel entries describe
|
||||||
|
* *other* series ("Listeners also enjoyed"), so their counts, ratings and
|
||||||
|
* covers must never be mistaken for this series'.
|
||||||
|
*/
|
||||||
|
function outsideCarousel($: cheerio.CheerioAPI, selector: string) {
|
||||||
|
return $(selector).filter((_i, el) => $(el).closest('adbl-product-carousel').length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Book list parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse all books from a series page, preferring the modern layout.
|
||||||
|
* Falls back to the legacy product-list markup when no modern rows exist.
|
||||||
|
*/
|
||||||
|
export function parseSeriesBooks(
|
||||||
|
$: cheerio.CheerioAPI,
|
||||||
|
authorPrefixes: string[],
|
||||||
|
narratorPrefixes: string[],
|
||||||
|
langConfig: LanguageConfig
|
||||||
|
): AudibleAudiobook[] {
|
||||||
|
const modern = parseModernSeriesBooks($, langConfig);
|
||||||
|
if (modern.length > 0) return modern;
|
||||||
|
|
||||||
|
return parseLegacySeriesBooks($, authorPrefixes, narratorPrefixes, langConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely parse a row's embedded JSON metadata blob. */
|
||||||
|
function parseRowMetadata(json: string): AdblRowMetadata | null {
|
||||||
|
if (!json.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as AdblRowMetadata) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modern layout: <adbl-product-row> web components. Metadata comes from an
|
||||||
|
* embedded JSON blob, which is structured (real narrator arrays, author ASIN,
|
||||||
|
* release date) and needs no prefix stripping.
|
||||||
|
*/
|
||||||
|
function parseModernSeriesBooks(
|
||||||
|
$: cheerio.CheerioAPI,
|
||||||
|
langConfig: LanguageConfig
|
||||||
|
): AudibleAudiobook[] {
|
||||||
|
const books: AudibleAudiobook[] = [];
|
||||||
|
const seenAsins = new Set<string>();
|
||||||
|
|
||||||
|
outsideCarousel($, 'adbl-product-row').each((_index, element) => {
|
||||||
|
const $el = $(element);
|
||||||
|
|
||||||
|
const asin =
|
||||||
|
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('[data-asin]').first().attr('data-asin') ||
|
||||||
|
'';
|
||||||
|
if (!asin || seenAsins.has(asin)) return;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
$el.find('h3[slot="title"] a').first().text().trim() ||
|
||||||
|
$el.find('h3[slot="title"]').first().text().trim() ||
|
||||||
|
'';
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
seenAsins.add(asin);
|
||||||
|
|
||||||
|
const meta = parseRowMetadata($el.find('script[type="application/json"]').first().text());
|
||||||
|
|
||||||
|
// Match legacy behaviour: a single primary author, not a joined list.
|
||||||
|
const author = meta?.authors?.find(a => a?.name)?.name?.trim() || '';
|
||||||
|
const authorUrl = meta?.authors?.find(a => a?.url)?.url || '';
|
||||||
|
const authorAsin = authorUrl.match(/\/author\/[^/]+\/([A-Z0-9]{10})/)?.[1];
|
||||||
|
|
||||||
|
const narrator = (meta?.narrators || [])
|
||||||
|
.map(n => n?.name?.trim())
|
||||||
|
.filter((n): n is string => Boolean(n))
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const rating = typeof meta?.rating?.value === 'number' ? meta.rating.value : undefined;
|
||||||
|
|
||||||
|
books.push({
|
||||||
|
asin,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
authorAsin,
|
||||||
|
narrator: narrator || undefined,
|
||||||
|
coverArtUrl: toLargeCover($el.find('adbl-product-image img').first().attr('src')) || '',
|
||||||
|
rating,
|
||||||
|
durationMinutes: meta?.duration ? parseRuntime(meta.duration, langConfig) : undefined,
|
||||||
|
releaseDate: meta?.releaseDate || undefined,
|
||||||
|
language: meta?.language || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy layout: <li class="productListItem"> / .bc-list-item product rows. */
|
||||||
|
function parseLegacySeriesBooks(
|
||||||
|
$: cheerio.CheerioAPI,
|
||||||
|
authorPrefixes: string[],
|
||||||
|
narratorPrefixes: string[],
|
||||||
|
langConfig: LanguageConfig
|
||||||
|
): AudibleAudiobook[] {
|
||||||
|
const books: AudibleAudiobook[] = [];
|
||||||
|
const seenAsins = new Set<string>();
|
||||||
|
|
||||||
|
$('.productListItem, .bc-list-item').each((_index, element) => {
|
||||||
|
const $el = $(element);
|
||||||
|
|
||||||
|
// Extract ASIN
|
||||||
|
const bookAsin = $el.attr('data-asin') ||
|
||||||
|
$el.find('li').attr('data-asin') ||
|
||||||
|
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||||
|
|
||||||
|
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||||
|
seenAsins.add(bookAsin);
|
||||||
|
|
||||||
|
// Title: h3 a / .bc-heading a hold the real book title;
|
||||||
|
// h2 on series pages is the position label ("Book 1"), so try it last.
|
||||||
|
const title = $el.find('h3 a').first().text().trim() ||
|
||||||
|
$el.find('.bc-heading a').first().text().trim() ||
|
||||||
|
$el.find('h2 a').first().text().trim() ||
|
||||||
|
$el.find('h2').first().text().trim() ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
// Author
|
||||||
|
const authorLink = $el.find('a[href*="/author/"]').first();
|
||||||
|
const authorText = authorLink.text().trim() ||
|
||||||
|
$el.find('.authorLabel').text().trim() ||
|
||||||
|
'';
|
||||||
|
const authorHref = authorLink.attr('href') || '';
|
||||||
|
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
||||||
|
|
||||||
|
// Narrator — capture all narrator links (multi-narrator productions are common)
|
||||||
|
const narratorText = extractAllNarrators($, $el);
|
||||||
|
|
||||||
|
// Cover art
|
||||||
|
const coverArtUrl = toLargeCover($el.find('img').first().attr('src')) || '';
|
||||||
|
|
||||||
|
// Rating
|
||||||
|
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||||
|
$el.find('.a-icon-star span').first().text().trim();
|
||||||
|
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
||||||
|
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||||
|
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||||
|
const durationMinutes = parseRuntime(runtimeText, langConfig);
|
||||||
|
|
||||||
|
books.push({
|
||||||
|
asin: bookAsin,
|
||||||
|
title,
|
||||||
|
author: stripPrefixes(authorText, authorPrefixes),
|
||||||
|
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||||
|
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
||||||
|
coverArtUrl,
|
||||||
|
rating,
|
||||||
|
durationMinutes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page-level parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Parse summary fields from a series page's Cheerio document. */
|
||||||
|
export function parseSeriesPageSummary(
|
||||||
|
$: cheerio.CheerioAPI,
|
||||||
|
asin: string
|
||||||
|
): Omit<SeriesSummary, 'audibleUrl'> {
|
||||||
|
// Title - from h1
|
||||||
|
const title = $('h1').first().text().trim() || '';
|
||||||
|
|
||||||
|
const bookCount = parseSeriesBookCount($);
|
||||||
|
const { rating, ratingCount } = parseSeriesRating($);
|
||||||
|
|
||||||
|
// Tags/genres: primary from adbl-chip web components, fallback to legacy links
|
||||||
|
const tags: string[] = [];
|
||||||
|
const addTag = (text: string) => {
|
||||||
|
const tag = text.trim();
|
||||||
|
if (tag && tag.length >= 2 && tag.length <= 50 && !tags.includes(tag)) {
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Primary: adbl-chip.related-tag elements (modern Audible layout)
|
||||||
|
$('adbl-chip.related-tag').each((_i, el) => {
|
||||||
|
addTag($(el).text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback: legacy category and tag links
|
||||||
|
if (tags.length === 0) {
|
||||||
|
$('a[href*="/cat/"], a[href*="/tag/"]').each((_i, el) => {
|
||||||
|
addTag($(el).text());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover art from the first book image, modern layout first
|
||||||
|
const coverArtUrl =
|
||||||
|
toLargeCover(outsideCarousel($, 'adbl-product-row').find('adbl-product-image img').first().attr('src')) ||
|
||||||
|
toLargeCover($('.productListItem img, .bc-list-item img').first().attr('src')) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return { asin, title, bookCount, rating, ratingCount, tags: tags.slice(0, 5), coverArtUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract how many books the series contains, most specific source first. */
|
||||||
|
function parseSeriesBookCount($: cheerio.CheerioAPI): number {
|
||||||
|
// Modern: <span slot="child">4 books in series</span> in the page header
|
||||||
|
const childMatch = outsideCarousel($, 'span[slot="child"]').first().text().trim().match(/(\d+)/);
|
||||||
|
if (childMatch) return parseInt(childMatch[1]);
|
||||||
|
|
||||||
|
// Both layouts: <adbl-metadata slot="child-count">4 titles</adbl-metadata>
|
||||||
|
let fromMetadata = 0;
|
||||||
|
outsideCarousel($, 'adbl-metadata[slot="child-count"]').each((_i, el) => {
|
||||||
|
if (fromMetadata > 0) return false;
|
||||||
|
const match = $(el).text().trim().match(/(\d+)/);
|
||||||
|
if (match) fromMetadata = parseInt(match[1]);
|
||||||
|
});
|
||||||
|
if (fromMetadata > 0) return fromMetadata;
|
||||||
|
|
||||||
|
// Legacy: "X books/titles/Titel/libros/Bucher" text somewhere on the page
|
||||||
|
const countText = $('span:contains("book"), span:contains("title"), span:contains("Titel"), span:contains("libro"), span:contains("Buch"), span:contains("Bücher")')
|
||||||
|
.text().trim();
|
||||||
|
const countMatch = countText.match(/(\d+)\s*(books?|titles?|Titel|libros?|B(?:uch|ücher))/i);
|
||||||
|
if (countMatch) return parseInt(countMatch[1]);
|
||||||
|
|
||||||
|
// Last resort: count the rendered product rows
|
||||||
|
return outsideCarousel($, 'adbl-product-row').length ||
|
||||||
|
$('.productListItem, .bc-list-item[data-asin]').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract rating and rating count from a series page.
|
||||||
|
*
|
||||||
|
* Modern HTML uses:
|
||||||
|
* <adbl-star-rating slot="rating" value="5" count="13156" aria-label="13,156 ratings">
|
||||||
|
* Legacy HTML uses:
|
||||||
|
* <div aria-label="4.5 out of 5 stars" class="bc-review-stars ...">
|
||||||
|
* <span class="series-rating bc-color-secondary">8,704 ratings</span>
|
||||||
|
*/
|
||||||
|
function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCount?: number } {
|
||||||
|
// Modern: numeric attributes on the header star-rating component
|
||||||
|
const star = outsideCarousel($, 'adbl-star-rating[slot="rating"]').first();
|
||||||
|
if (star.length > 0) {
|
||||||
|
const value = parseFloat(star.attr('value') || '');
|
||||||
|
const count = parseInt((star.attr('count') || '').replace(/[.,]/g, ''));
|
||||||
|
const rating = Number.isFinite(value) ? value : undefined;
|
||||||
|
const ratingCount = Number.isFinite(count) ? count : undefined;
|
||||||
|
if (rating !== undefined || ratingCount !== undefined) return { rating, ratingCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
let rating: number | undefined;
|
||||||
|
let ratingCount: number | undefined;
|
||||||
|
|
||||||
|
// Legacy: aria-label on div.bc-review-stars (e.g. "4.5 out of 5 stars")
|
||||||
|
const starsDiv = $('div.bc-review-stars');
|
||||||
|
let ariaLabel = starsDiv.attr('aria-label') || '';
|
||||||
|
|
||||||
|
// Fallback: any element with aria-label containing rating pattern
|
||||||
|
if (!ariaLabel) {
|
||||||
|
const fallbackEl = $('[aria-label*="out of"], [aria-label*="von 5"], [aria-label*="de 5"]').first();
|
||||||
|
ariaLabel = fallbackEl.attr('aria-label') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract numeric rating from aria-label (handles "4.5 out of 5", "4,5 von 5", "4,5 de 5")
|
||||||
|
const ratingMatch = ariaLabel.match(/(\d+[.,]?\d*)\s*(?:out of|von|de)\s*5/i);
|
||||||
|
if (ratingMatch) {
|
||||||
|
rating = parseFloat(ratingMatch[1].replace(',', '.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating count from span.series-rating (e.g. "8,704 ratings")
|
||||||
|
const seriesRatingSpan = $('span.series-rating').first();
|
||||||
|
let countText = seriesRatingSpan.text().trim();
|
||||||
|
|
||||||
|
// Fallback: look in broader context for rating count text
|
||||||
|
if (!countText) {
|
||||||
|
const fallbackContainer = $('[class*="rating"], .ratingsLabel').first();
|
||||||
|
countText = fallbackContainer.text().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const countMatch = countText.match(/([\d,.]+)\s*(?:ratings?|Bewertungen?|calificaciones?)/i);
|
||||||
|
if (countMatch) {
|
||||||
|
ratingCount = parseInt(countMatch[1].replace(/[.,]/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rating, ratingCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the series description, modern layout first. */
|
||||||
|
export function parseSeriesDescription($: cheerio.CheerioAPI): string | undefined {
|
||||||
|
// Modern: <adbl-text-block> inside #series-about. Read only the paragraphs —
|
||||||
|
// its [slot="title"] child is a heading, not summary text.
|
||||||
|
const modern = $('#series-about adbl-text-block').first();
|
||||||
|
if (modern.length > 0) {
|
||||||
|
const text = modern.find('p').text().replace(/\s+/g, ' ').trim();
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = $('.bc-expander-content').first().text().trim() ||
|
||||||
|
$('[class*="productPublisherSummary"]').first().text().trim();
|
||||||
|
|
||||||
|
return legacy || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse similar series from the "Listeners also enjoyed" carousel.
|
||||||
|
*
|
||||||
|
* Real HTML uses web components:
|
||||||
|
* <adbl-product-carousel id="SeriestoSeries">
|
||||||
|
* <adbl-product-grid-item>
|
||||||
|
* <div class="adbl-impression-emitted" data-asin="B0CGS1LPWJ">
|
||||||
|
* <adbl-metadata slot="title"><a>Hockey Guys</a></adbl-metadata>
|
||||||
|
* <adbl-metadata slot="child-count">3 titles</adbl-metadata>
|
||||||
|
* </adbl-product-grid-item>
|
||||||
|
*/
|
||||||
|
export function parseSimilarSeries($: cheerio.CheerioAPI): SimilarSeries[] {
|
||||||
|
const similar: SimilarSeries[] = [];
|
||||||
|
const seenAsins = new Set<string>();
|
||||||
|
|
||||||
|
// Scope to the SeriestoSeries carousel to avoid picking up other series links
|
||||||
|
const carousel = $('adbl-product-carousel#SeriestoSeries');
|
||||||
|
if (carousel.length === 0) return similar;
|
||||||
|
|
||||||
|
carousel.find('adbl-product-grid-item').each((_i, el) => {
|
||||||
|
if (similar.length >= 15) return false;
|
||||||
|
|
||||||
|
const $el = $(el);
|
||||||
|
|
||||||
|
// Extract ASIN: prefer data-asin on impression div, fallback to series href
|
||||||
|
let asin = $el.find('.adbl-impression-emitted, .adbl-asin-impression').first().attr('data-asin') || '';
|
||||||
|
if (!asin) {
|
||||||
|
const seriesHref = $el.find('a[href*="/series/"]').first().attr('href') || '';
|
||||||
|
const hrefMatch = seriesHref.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
|
||||||
|
if (hrefMatch) asin = hrefMatch[1];
|
||||||
|
}
|
||||||
|
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) return;
|
||||||
|
if (seenAsins.has(asin)) return;
|
||||||
|
seenAsins.add(asin);
|
||||||
|
|
||||||
|
// Title from metadata slot
|
||||||
|
const title = $el.find('adbl-metadata[slot="title"] a').first().text().trim() ||
|
||||||
|
$el.find('adbl-metadata[slot="title"]').first().text().trim() || '';
|
||||||
|
if (!title || title.length > 200) return;
|
||||||
|
|
||||||
|
// Book count from child-count slot (e.g. "3 titles")
|
||||||
|
const countText = $el.find('adbl-metadata[slot="child-count"]').first().text().trim();
|
||||||
|
const countMatch = countText.match(/(\d+)/);
|
||||||
|
const bookCount = countMatch ? parseInt(countMatch[1]) : undefined;
|
||||||
|
|
||||||
|
// Cover image from adbl-collection-image
|
||||||
|
const coverArtUrl = toLargeCover($el.find('adbl-collection-image img').first().attr('src')) ||
|
||||||
|
toLargeCover($el.find('img').first().attr('src')) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
similar.push({ asin, title, bookCount, coverArtUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
return similar;
|
||||||
|
}
|
||||||
@@ -5,62 +5,39 @@
|
|||||||
* Standalone series scraping module. Uses the AudibleService fetch wrapper
|
* Standalone series scraping module. Uses the AudibleService fetch wrapper
|
||||||
* for HTTP requests and Cheerio for HTML parsing.
|
* for HTTP requests and Cheerio for HTML parsing.
|
||||||
* Kept separate from audible.service.ts to avoid bloating the main service.
|
* Kept separate from audible.service.ts to avoid bloating the main service.
|
||||||
|
*
|
||||||
|
* HTML parsing lives in audible-series-parsers.ts (Audible A/B-serves two
|
||||||
|
* different series-page layouts; the parsers handle both).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { getAudibleService, AudibleAudiobook } from './audible.service';
|
import { getAudibleService } from './audible.service';
|
||||||
import { AUDIBLE_REGIONS } from '../types/audible';
|
|
||||||
import {
|
import {
|
||||||
getLanguageForRegion,
|
getLanguageForRegion,
|
||||||
buildContainsSelector,
|
buildContainsSelector,
|
||||||
stripPrefixes,
|
|
||||||
type LanguageConfig,
|
|
||||||
} from '../constants/language-config';
|
} from '../constants/language-config';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
import { parseRuntime } from '../utils/parse-runtime';
|
|
||||||
import { randomDelay } from '../utils/scrape-resilience';
|
import { randomDelay } from '../utils/scrape-resilience';
|
||||||
import { extractAllNarrators } from '../utils/extract-narrator';
|
import {
|
||||||
|
parseSeriesBooks,
|
||||||
|
parseSeriesDescription,
|
||||||
|
parseSeriesPageSummary,
|
||||||
|
parseSimilarSeries,
|
||||||
|
type SeriesDetail,
|
||||||
|
type SeriesSummary,
|
||||||
|
} from './audible-series-parsers';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SeriesSummary,
|
||||||
|
SimilarSeries,
|
||||||
|
SeriesDetail,
|
||||||
|
} from './audible-series-parsers';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Audible.Series');
|
const logger = RMABLogger.create('Audible.Series');
|
||||||
|
|
||||||
const AUDIBLE_PAGE_SIZE = 50;
|
const AUDIBLE_PAGE_SIZE = 50;
|
||||||
const MAX_SERIES_RESULTS = 15;
|
const MAX_SERIES_RESULTS = 15;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface SeriesSummary {
|
|
||||||
asin: string;
|
|
||||||
title: string;
|
|
||||||
bookCount: number;
|
|
||||||
rating?: number;
|
|
||||||
ratingCount?: number;
|
|
||||||
tags: string[];
|
|
||||||
coverArtUrl?: string;
|
|
||||||
audibleUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimilarSeries {
|
|
||||||
asin: string;
|
|
||||||
title: string;
|
|
||||||
bookCount?: number;
|
|
||||||
coverArtUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeriesDetail {
|
|
||||||
asin: string;
|
|
||||||
title: string;
|
|
||||||
bookCount: number;
|
|
||||||
rating?: number;
|
|
||||||
ratingCount?: number;
|
|
||||||
description?: string;
|
|
||||||
tags: string[];
|
|
||||||
books: AudibleAudiobook[];
|
|
||||||
similarSeries: SimilarSeries[];
|
|
||||||
audibleUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Search: extract series links from Audible search results
|
// Search: extract series links from Audible search results
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -187,7 +164,7 @@ export async function searchForSeries(query: string): Promise<SeriesSummary[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Series page scraping (summary - for search results)
|
// Series page scraping
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,81 +189,6 @@ async function scrapeSeriesPageSummary(asin: string): Promise<Omit<SeriesSummary
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse summary fields from a series page's Cheerio document.
|
|
||||||
*/
|
|
||||||
function parseSeriesPageSummary(
|
|
||||||
$: cheerio.CheerioAPI,
|
|
||||||
asin: string
|
|
||||||
): Omit<SeriesSummary, 'audibleUrl'> {
|
|
||||||
// Title - from h1
|
|
||||||
const title = $('h1').first().text().trim() || '';
|
|
||||||
|
|
||||||
// Book count - multiple strategies, most specific first
|
|
||||||
let bookCount = 0;
|
|
||||||
|
|
||||||
// Primary: adbl-metadata[slot="child-count"] in the page header (NOT inside carousels)
|
|
||||||
// Filter out carousel items by excluding those inside adbl-product-carousel
|
|
||||||
$('adbl-metadata[slot="child-count"]').each((_i, el) => {
|
|
||||||
if (bookCount > 0) return false;
|
|
||||||
const $el = $(el);
|
|
||||||
// Skip if inside a carousel (those are similar-series counts)
|
|
||||||
if ($el.closest('adbl-product-carousel').length > 0) return;
|
|
||||||
const text = $el.text().trim();
|
|
||||||
const match = text.match(/(\d+)/);
|
|
||||||
if (match) bookCount = parseInt(match[1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Secondary: text matching in spans/headings for "X books/titles/Titel/libros/Bucher"
|
|
||||||
if (bookCount === 0) {
|
|
||||||
const countText = $('span:contains("book"), span:contains("title"), span:contains("Titel"), span:contains("libro"), span:contains("Buch"), span:contains("B\u00fccher")')
|
|
||||||
.text().trim();
|
|
||||||
const countMatch = countText.match(/(\d+)\s*(books?|titles?|Titel|libros?|B(?:uch|\u00fccher))/i);
|
|
||||||
if (countMatch) {
|
|
||||||
bookCount = parseInt(countMatch[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: count product items on the page
|
|
||||||
if (bookCount === 0) {
|
|
||||||
bookCount = $('.productListItem, .bc-list-item[data-asin]').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rating
|
|
||||||
const { rating, ratingCount } = parseSeriesRating($);
|
|
||||||
|
|
||||||
// Tags/genres: primary from adbl-chip web components, fallback to legacy links
|
|
||||||
const tags: string[] = [];
|
|
||||||
const addTag = (text: string) => {
|
|
||||||
const tag = text.trim();
|
|
||||||
if (tag && tag.length >= 2 && tag.length <= 50 && !tags.includes(tag)) {
|
|
||||||
tags.push(tag);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Primary: adbl-chip.related-tag elements (modern Audible layout)
|
|
||||||
$('adbl-chip.related-tag').each((_i, el) => {
|
|
||||||
addTag($(el).text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fallback: legacy category and tag links
|
|
||||||
if (tags.length === 0) {
|
|
||||||
$('a[href*="/cat/"], a[href*="/tag/"]').each((_i, el) => {
|
|
||||||
addTag($(el).text());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cover art from first book image
|
|
||||||
const coverArtUrl = $('.productListItem img, .bc-list-item img').first()
|
|
||||||
.attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
|
|
||||||
|
|
||||||
return { asin, title, bookCount, rating, ratingCount, tags: tags.slice(0, 5), coverArtUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Series page scraping (full detail)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrape a series page for full detail data including books and similar series.
|
* Scrape a series page for full detail data including books and similar series.
|
||||||
* Used by the detail API endpoint.
|
* Used by the detail API endpoint.
|
||||||
@@ -308,14 +210,23 @@ export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<
|
|||||||
// Parse summary fields
|
// Parse summary fields
|
||||||
const summary = parseSeriesPageSummary($, asin);
|
const summary = parseSeriesPageSummary($, asin);
|
||||||
|
|
||||||
// Description
|
const description = parseSeriesDescription($);
|
||||||
const description = $('.bc-expander-content').first().text().trim() ||
|
|
||||||
$('[class*="productPublisherSummary"]').first().text().trim() ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
// Parse all books from the series page
|
// Parse all books from the series page
|
||||||
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig);
|
const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig);
|
||||||
|
|
||||||
|
// Layout-drift detector: the header says the series has books but no rows
|
||||||
|
// parsed, which means Audible changed its markup again.
|
||||||
|
if (books.length === 0 && summary.bookCount > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Series ${asin} reports ${summary.bookCount} books but no book rows parsed - Audible layout may have changed`,
|
||||||
|
{
|
||||||
|
modernRows: $('adbl-product-row').length,
|
||||||
|
legacyRows: $('.productListItem, .bc-list-item').length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Use actual book count if we got more from scraping
|
// Use actual book count if we got more from scraping
|
||||||
const bookCount = Math.max(summary.bookCount, books.length);
|
const bookCount = Math.max(summary.bookCount, books.length);
|
||||||
|
|
||||||
@@ -350,183 +261,3 @@ export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Parsing helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract rating and rating count from a series page.
|
|
||||||
*
|
|
||||||
* Real HTML uses:
|
|
||||||
* <div aria-label="4.5 out of 5 stars" class="bc-review-stars ...">
|
|
||||||
* <span class="series-rating bc-color-secondary">8,704 ratings</span>
|
|
||||||
*/
|
|
||||||
function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCount?: number } {
|
|
||||||
let rating: number | undefined;
|
|
||||||
let ratingCount: number | undefined;
|
|
||||||
|
|
||||||
// Primary: aria-label on div.bc-review-stars (e.g. "4.5 out of 5 stars")
|
|
||||||
const starsDiv = $('div.bc-review-stars');
|
|
||||||
let ariaLabel = starsDiv.attr('aria-label') || '';
|
|
||||||
|
|
||||||
// Fallback: any element with aria-label containing rating pattern
|
|
||||||
if (!ariaLabel) {
|
|
||||||
const fallbackEl = $('[aria-label*="out of"], [aria-label*="von 5"], [aria-label*="de 5"]').first();
|
|
||||||
ariaLabel = fallbackEl.attr('aria-label') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract numeric rating from aria-label (handles "4.5 out of 5", "4,5 von 5", "4,5 de 5")
|
|
||||||
const ratingMatch = ariaLabel.match(/(\d+[.,]?\d*)\s*(?:out of|von|de)\s*5/i);
|
|
||||||
if (ratingMatch) {
|
|
||||||
rating = parseFloat(ratingMatch[1].replace(',', '.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rating count from span.series-rating (e.g. "8,704 ratings")
|
|
||||||
const seriesRatingSpan = $('span.series-rating').first();
|
|
||||||
let countText = seriesRatingSpan.text().trim();
|
|
||||||
|
|
||||||
// Fallback: look in broader context for rating count text
|
|
||||||
if (!countText) {
|
|
||||||
const fallbackContainer = $('[class*="rating"], .ratingsLabel').first();
|
|
||||||
countText = fallbackContainer.text().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const countMatch = countText.match(/([\d,.]+)\s*(?:ratings?|Bewertungen?|calificaciones?)/i);
|
|
||||||
if (countMatch) {
|
|
||||||
ratingCount = parseInt(countMatch[1].replace(/[.,]/g, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rating, ratingCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse all books from a series page's product list items.
|
|
||||||
*/
|
|
||||||
function parseSeriesBooks(
|
|
||||||
$: cheerio.CheerioAPI,
|
|
||||||
authorPrefixes: string[],
|
|
||||||
narratorPrefixes: string[],
|
|
||||||
langConfig: LanguageConfig
|
|
||||||
): AudibleAudiobook[] {
|
|
||||||
const books: AudibleAudiobook[] = [];
|
|
||||||
const seenAsins = new Set<string>();
|
|
||||||
|
|
||||||
$('.productListItem, .bc-list-item').each((_index, element) => {
|
|
||||||
const $el = $(element);
|
|
||||||
|
|
||||||
// Extract ASIN
|
|
||||||
const bookAsin = $el.attr('data-asin') ||
|
|
||||||
$el.find('li').attr('data-asin') ||
|
|
||||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
|
||||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
|
|
||||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
|
||||||
|
|
||||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
|
||||||
seenAsins.add(bookAsin);
|
|
||||||
|
|
||||||
// Title: h3 a / .bc-heading a hold the real book title;
|
|
||||||
// h2 on series pages is the position label ("Book 1"), so try it last.
|
|
||||||
const title = $el.find('h3 a').first().text().trim() ||
|
|
||||||
$el.find('.bc-heading a').first().text().trim() ||
|
|
||||||
$el.find('h2 a').first().text().trim() ||
|
|
||||||
$el.find('h2').first().text().trim() ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
// Author
|
|
||||||
const authorLink = $el.find('a[href*="/author/"]').first();
|
|
||||||
const authorText = authorLink.text().trim() ||
|
|
||||||
$el.find('.authorLabel').text().trim() ||
|
|
||||||
'';
|
|
||||||
const authorHref = authorLink.attr('href') || '';
|
|
||||||
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
|
||||||
|
|
||||||
// Narrator — capture all narrator links (multi-narrator productions are common)
|
|
||||||
const narratorText = extractAllNarrators($, $el);
|
|
||||||
|
|
||||||
// Cover art
|
|
||||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
|
||||||
|
|
||||||
// Rating
|
|
||||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
|
||||||
$el.find('.a-icon-star span').first().text().trim();
|
|
||||||
const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
|
|
||||||
const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
|
|
||||||
|
|
||||||
// Duration
|
|
||||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
|
||||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
|
||||||
const durationMinutes = parseRuntime(runtimeText, langConfig);
|
|
||||||
|
|
||||||
books.push({
|
|
||||||
asin: bookAsin,
|
|
||||||
title,
|
|
||||||
author: stripPrefixes(authorText, authorPrefixes),
|
|
||||||
authorAsin: authorAsinMatch?.[1] || undefined,
|
|
||||||
narrator: stripPrefixes(narratorText, narratorPrefixes),
|
|
||||||
coverArtUrl,
|
|
||||||
rating,
|
|
||||||
durationMinutes,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return books;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse similar series from the "Listeners also enjoyed" carousel.
|
|
||||||
*
|
|
||||||
* Real HTML uses web components:
|
|
||||||
* <adbl-product-carousel id="SeriestoSeries">
|
|
||||||
* <adbl-product-grid-item>
|
|
||||||
* <div class="adbl-impression-emitted" data-asin="B0CGS1LPWJ">
|
|
||||||
* <adbl-metadata slot="title"><a>Hockey Guys</a></adbl-metadata>
|
|
||||||
* <adbl-metadata slot="child-count">3 titles</adbl-metadata>
|
|
||||||
* </adbl-product-grid-item>
|
|
||||||
*/
|
|
||||||
function parseSimilarSeries($: cheerio.CheerioAPI): SimilarSeries[] {
|
|
||||||
const similar: SimilarSeries[] = [];
|
|
||||||
const seenAsins = new Set<string>();
|
|
||||||
|
|
||||||
// Scope to the SeriestoSeries carousel to avoid picking up other series links
|
|
||||||
const carousel = $('adbl-product-carousel#SeriestoSeries');
|
|
||||||
if (carousel.length === 0) return similar;
|
|
||||||
|
|
||||||
carousel.find('adbl-product-grid-item').each((_i, el) => {
|
|
||||||
if (similar.length >= 15) return false;
|
|
||||||
|
|
||||||
const $el = $(el);
|
|
||||||
|
|
||||||
// Extract ASIN: prefer data-asin on impression div, fallback to series href
|
|
||||||
let asin = $el.find('.adbl-impression-emitted, .adbl-asin-impression').first().attr('data-asin') || '';
|
|
||||||
if (!asin) {
|
|
||||||
const seriesHref = $el.find('a[href*="/series/"]').first().attr('href') || '';
|
|
||||||
const hrefMatch = seriesHref.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
|
|
||||||
if (hrefMatch) asin = hrefMatch[1];
|
|
||||||
}
|
|
||||||
if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) return;
|
|
||||||
if (seenAsins.has(asin)) return;
|
|
||||||
seenAsins.add(asin);
|
|
||||||
|
|
||||||
// Title from metadata slot
|
|
||||||
const title = $el.find('adbl-metadata[slot="title"] a').first().text().trim() ||
|
|
||||||
$el.find('adbl-metadata[slot="title"]').first().text().trim() || '';
|
|
||||||
if (!title || title.length > 200) return;
|
|
||||||
|
|
||||||
// Book count from child-count slot (e.g. "3 titles")
|
|
||||||
const countText = $el.find('adbl-metadata[slot="child-count"]').first().text().trim();
|
|
||||||
const countMatch = countText.match(/(\d+)/);
|
|
||||||
const bookCount = countMatch ? parseInt(countMatch[1]) : undefined;
|
|
||||||
|
|
||||||
// Cover image from adbl-collection-image
|
|
||||||
const coverArtUrl = $el.find('adbl-collection-image img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
|
|
||||||
$el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
similar.push({ asin, title, bookCount, coverArtUrl });
|
|
||||||
});
|
|
||||||
|
|
||||||
return similar;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user