@framers/agentos-ext-letterboxd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,702 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Letterboxd Movie Lookup Tool — ITool implementation for Letterboxd scraping.
4
+ *
5
+ * Retrieves film metadata, aggregate ratings, director credits, genre tags,
6
+ * a synopsis, and a sample of recent reviews from Letterboxd. Uses a
7
+ * two-strategy approach:
8
+ *
9
+ * 1. **Recipe delegation** — if a `web_scrape_recipe` tool reference is
10
+ * provided at construction time, the tool delegates to the web-scraper
11
+ * extension's recipe engine for robust, tiered scraping.
12
+ * 2. **Built-in fallback** — a lightweight `fetch()`-based scraper that
13
+ * parses Letterboxd HTML via regex. No API key or headless browser
14
+ * required; works out of the box for most films.
15
+ *
16
+ * @module tools/letterboxdMovie
17
+ */
18
+
19
+ import type {
20
+ ITool,
21
+ ToolExecutionContext,
22
+ ToolExecutionResult,
23
+ JSONSchemaObject,
24
+ } from '@framers/agentos';
25
+
26
+ /* -------------------------------------------------------------------------- */
27
+ /* Public Types */
28
+ /* -------------------------------------------------------------------------- */
29
+
30
+ /**
31
+ * Input parameters for the `letterboxd_movie` tool.
32
+ */
33
+ export interface LetterboxdMovieInput {
34
+ /** Film title to search for on Letterboxd. */
35
+ title: string;
36
+
37
+ /**
38
+ * Optional release year to disambiguate titles.
39
+ * When provided, the tool favours search results whose year matches.
40
+ */
41
+ year?: number;
42
+ }
43
+
44
+ /**
45
+ * A single user review extracted from a Letterboxd film page.
46
+ */
47
+ export interface LetterboxdReview {
48
+ /** Letterboxd username of the reviewer. */
49
+ user: string;
50
+
51
+ /** Star rating string (e.g. "★★★★" or "★★★½"). */
52
+ rating: string;
53
+
54
+ /** Truncated review body text. */
55
+ text: string;
56
+ }
57
+
58
+ /**
59
+ * Output payload returned by the `letterboxd_movie` tool.
60
+ */
61
+ export interface LetterboxdMovieOutput {
62
+ /** Whether a matching film was found on Letterboxd. */
63
+ found: boolean;
64
+
65
+ /** Canonical Letterboxd URL for the film (present when `found` is `true`). */
66
+ url?: string;
67
+
68
+ /** Film title as displayed on Letterboxd. */
69
+ title?: string;
70
+
71
+ /** Release year string (e.g. "2016"). */
72
+ year?: string;
73
+
74
+ /** Aggregate Letterboxd rating string (e.g. "4.1"). */
75
+ rating?: string;
76
+
77
+ /** Comma-separated director names. */
78
+ directors?: string;
79
+
80
+ /** Comma-separated genre tags. */
81
+ genres?: string;
82
+
83
+ /** Short plot synopsis / tagline. */
84
+ synopsis?: string;
85
+
86
+ /** Sample of recent user reviews (up to 5). */
87
+ reviews?: LetterboxdReview[];
88
+ }
89
+
90
+ /* -------------------------------------------------------------------------- */
91
+ /* Minimal recipe-tool interface (avoids hard dep on web-scraper package) */
92
+ /* -------------------------------------------------------------------------- */
93
+
94
+ /**
95
+ * Minimal shape of a recipe-capable tool that can be injected at
96
+ * construction time. Mirrors the `execute` signature of
97
+ * {@link WebScrapeRecipeTool} without requiring an import dependency.
98
+ */
99
+ export interface RecipeToolLike {
100
+ execute(
101
+ args: { recipe: string; params: Record<string, string> },
102
+ context: ToolExecutionContext,
103
+ ): Promise<{ success: boolean; output?: any; error?: string }>;
104
+ }
105
+
106
+ /* -------------------------------------------------------------------------- */
107
+ /* Constants */
108
+ /* -------------------------------------------------------------------------- */
109
+
110
+ /** Base URL for Letterboxd film search. */
111
+ const SEARCH_URL = 'https://letterboxd.com/search/films';
112
+
113
+ /** Base URL for individual film pages. */
114
+ const FILM_BASE_URL = 'https://letterboxd.com/film';
115
+
116
+ /** Maximum number of reviews to extract from a film page. */
117
+ const MAX_REVIEWS = 5;
118
+
119
+ /* -------------------------------------------------------------------------- */
120
+ /* User-Agent helper */
121
+ /* -------------------------------------------------------------------------- */
122
+
123
+ /**
124
+ * Return a realistic desktop Chrome User-Agent string.
125
+ *
126
+ * Rather than pulling in the full {@link UserAgentPool} from the web-scraper
127
+ * extension, this helper generates a single plausible UA on the fly.
128
+ *
129
+ * @returns A User-Agent header value string.
130
+ */
131
+ function realisticUserAgent(): string {
132
+ const chromeVersion = 128 + Math.floor(Math.random() * 6); // 128-133
133
+ const platforms = [
134
+ 'Windows NT 10.0; Win64; x64',
135
+ 'Macintosh; Intel Mac OS X 10_15_7',
136
+ 'Macintosh; Intel Mac OS X 14_5',
137
+ 'X11; Linux x86_64',
138
+ ];
139
+ const platform = platforms[Math.floor(Math.random() * platforms.length)];
140
+ return (
141
+ `Mozilla/5.0 (${platform}) AppleWebKit/537.36 ` +
142
+ `(KHTML, like Gecko) Chrome/${chromeVersion}.0.0.0 Safari/537.36`
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Build a minimal set of browser-like request headers.
148
+ *
149
+ * @returns A header record suitable for `fetch()`.
150
+ */
151
+ function browserHeaders(): Record<string, string> {
152
+ return {
153
+ 'User-Agent': realisticUserAgent(),
154
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
155
+ 'Accept-Language': 'en-US,en;q=0.9',
156
+ 'Accept-Encoding': 'gzip, deflate, br',
157
+ 'Connection': 'keep-alive',
158
+ 'Upgrade-Insecure-Requests': '1',
159
+ };
160
+ }
161
+
162
+ /* -------------------------------------------------------------------------- */
163
+ /* HTML helpers */
164
+ /* -------------------------------------------------------------------------- */
165
+
166
+ /**
167
+ * Strip HTML tags from a string, collapse whitespace, and trim.
168
+ *
169
+ * @param html - Raw HTML fragment.
170
+ * @returns Plain text content.
171
+ */
172
+ function stripTags(html: string): string {
173
+ return html
174
+ .replace(/<[^>]*>/g, ' ')
175
+ .replace(/&amp;/g, '&')
176
+ .replace(/&lt;/g, '<')
177
+ .replace(/&gt;/g, '>')
178
+ .replace(/&quot;/g, '"')
179
+ .replace(/&#039;/g, "'")
180
+ .replace(/&nbsp;/g, ' ')
181
+ .replace(/\s+/g, ' ')
182
+ .trim();
183
+ }
184
+
185
+ /* -------------------------------------------------------------------------- */
186
+ /* LetterboxdMovieTool */
187
+ /* -------------------------------------------------------------------------- */
188
+
189
+ /**
190
+ * ITool implementation that looks up movie information on Letterboxd.
191
+ *
192
+ * Supports two execution paths:
193
+ * - **Recipe delegation**: when a `web_scrape_recipe` compatible tool is
194
+ * provided via the constructor, the tool delegates scraping to the
195
+ * web-scraper extension for more robust, tiered fetching.
196
+ * - **Built-in fallback**: a lightweight `fetch()` + regex scraper that
197
+ * works without external dependencies or API keys.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * const tool = new LetterboxdMovieTool();
202
+ * const result = await tool.execute({ title: 'Arrival', year: 2016 }, ctx);
203
+ * if (result.success && result.output?.found) {
204
+ * console.log(result.output.title, result.output.rating);
205
+ * }
206
+ * ```
207
+ */
208
+ export class LetterboxdMovieTool implements ITool<LetterboxdMovieInput, LetterboxdMovieOutput> {
209
+ readonly id = 'letterboxd-movie-v1';
210
+ readonly name = 'letterboxd_movie';
211
+ readonly displayName = 'Letterboxd Movie Lookup';
212
+ readonly description =
213
+ 'Look up a movie on Letterboxd by title (and optional year). Returns ' +
214
+ 'the film\'s metadata — title, year, aggregate rating, directors, genres, ' +
215
+ 'synopsis, and a sample of recent user reviews. No API key required.';
216
+ readonly category = 'media';
217
+ readonly version = '1.0.0';
218
+ readonly hasSideEffects = false;
219
+
220
+ readonly inputSchema: JSONSchemaObject = {
221
+ type: 'object',
222
+ properties: {
223
+ title: {
224
+ type: 'string',
225
+ description: 'Film title to search for on Letterboxd.',
226
+ },
227
+ year: {
228
+ type: 'integer',
229
+ description: 'Optional release year to disambiguate titles with the same name.',
230
+ },
231
+ },
232
+ required: ['title'],
233
+ };
234
+
235
+ readonly requiredCapabilities = ['capability:media_search'];
236
+
237
+ /**
238
+ * Optional reference to a recipe-capable scraping tool.
239
+ * When provided, the tool delegates to the web-scraper extension's
240
+ * recipe engine rather than using the built-in fallback scraper.
241
+ */
242
+ private readonly recipeTool: RecipeToolLike | undefined;
243
+
244
+ /**
245
+ * Create a new LetterboxdMovieTool.
246
+ *
247
+ * @param recipeTool - Optional reference to a `web_scrape_recipe`-compatible
248
+ * tool. When provided, the tool attempts recipe-based
249
+ * scraping before falling back to the built-in scraper.
250
+ */
251
+ constructor(recipeTool?: RecipeToolLike) {
252
+ this.recipeTool = recipeTool;
253
+ }
254
+
255
+ /**
256
+ * Execute a Letterboxd movie lookup.
257
+ *
258
+ * @param args - The film title (and optional year) to search for.
259
+ * @param context - Tool execution context provided by the AgentOS runtime.
260
+ * @returns A {@link ToolExecutionResult} wrapping a {@link LetterboxdMovieOutput}.
261
+ */
262
+ async execute(
263
+ args: LetterboxdMovieInput,
264
+ context: ToolExecutionContext,
265
+ ): Promise<ToolExecutionResult<LetterboxdMovieOutput>> {
266
+ const title = (args.title || '').trim();
267
+ if (!title) {
268
+ return { success: false, error: '`title` must be a non-empty string.' };
269
+ }
270
+
271
+ // Strategy 1: delegate to web_scrape_recipe if available
272
+ if (this.recipeTool) {
273
+ try {
274
+ const recipeResult = await this.executeViaRecipe(title, args.year, context);
275
+ if (recipeResult) {
276
+ return recipeResult;
277
+ }
278
+ // Recipe returned null — fall through to built-in scraper
279
+ } catch {
280
+ // Recipe failed — fall through to built-in scraper
281
+ }
282
+ }
283
+
284
+ // Strategy 2: built-in fetch + regex scraper
285
+ try {
286
+ return await this.executeViaFallback(title, args.year);
287
+ } catch (err: unknown) {
288
+ const msg = err instanceof Error ? err.message : String(err);
289
+ return { success: false, error: `Letterboxd lookup failed: ${msg}` };
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Custom argument validation.
295
+ *
296
+ * @param args - Raw arguments from the LLM.
297
+ * @returns Validation result with error details if invalid.
298
+ */
299
+ validateArgs(args: Record<string, unknown>): { isValid: boolean; errors?: string[] } {
300
+ const errors: string[] = [];
301
+
302
+ if (!args.title || typeof args.title !== 'string') {
303
+ errors.push('`title` must be a non-empty string.');
304
+ }
305
+
306
+ if (args.year !== undefined && (typeof args.year !== 'number' || !Number.isInteger(args.year))) {
307
+ errors.push('`year` must be an integer when provided.');
308
+ }
309
+
310
+ return { isValid: errors.length === 0, errors: errors.length > 0 ? errors : undefined };
311
+ }
312
+
313
+ /* -------------------------------------------------------------------------- */
314
+ /* Strategy 1: Recipe delegation */
315
+ /* -------------------------------------------------------------------------- */
316
+
317
+ /**
318
+ * Attempt to look up the film via the web-scraper recipe engine.
319
+ *
320
+ * Passes the title (and year if provided) to a `letterboxd-movie` recipe.
321
+ * Returns `null` if the recipe is not available or returns no useful data,
322
+ * signalling the caller to fall back to the built-in scraper.
323
+ *
324
+ * @param title - Film title to search for.
325
+ * @param year - Optional release year.
326
+ * @param context - Tool execution context for the delegated call.
327
+ * @returns A {@link ToolExecutionResult} if the recipe succeeded, or `null`
328
+ * to signal fallback.
329
+ */
330
+ private async executeViaRecipe(
331
+ title: string,
332
+ year: number | undefined,
333
+ context: ToolExecutionContext,
334
+ ): Promise<ToolExecutionResult<LetterboxdMovieOutput> | null> {
335
+ if (!this.recipeTool) return null;
336
+
337
+ const params: Record<string, string> = { query: title };
338
+ if (year !== undefined) {
339
+ params.year = String(year);
340
+ }
341
+
342
+ const result = await this.recipeTool.execute(
343
+ { recipe: 'letterboxd-movie', params },
344
+ context,
345
+ );
346
+
347
+ if (!result.success || !result.output?.data) {
348
+ return null;
349
+ }
350
+
351
+ const data = result.output.data as Record<string, any>;
352
+
353
+ // The recipe is expected to return structured data with known keys
354
+ const output: LetterboxdMovieOutput = {
355
+ found: true,
356
+ url: data.url as string | undefined,
357
+ title: data.title as string | undefined,
358
+ year: data.year as string | undefined,
359
+ rating: data.rating as string | undefined,
360
+ directors: data.directors as string | undefined,
361
+ genres: data.genres as string | undefined,
362
+ synopsis: data.synopsis as string | undefined,
363
+ reviews: Array.isArray(data.reviews) ? data.reviews : undefined,
364
+ };
365
+
366
+ return { success: true, output };
367
+ }
368
+
369
+ /* -------------------------------------------------------------------------- */
370
+ /* Strategy 2: Built-in fallback scraper */
371
+ /* -------------------------------------------------------------------------- */
372
+
373
+ /**
374
+ * Look up the film using the built-in `fetch()` + regex scraper.
375
+ *
376
+ * Performs up to three HTTP requests:
377
+ * 1. **Search** — `GET /search/films/{encodedTitle}/` to find the film slug.
378
+ * 2. **Direct URL** — if search fails (e.g. Cloudflare 403), tries
379
+ * `GET /film/{slug}/` where the slug is derived from the title.
380
+ * Film detail pages are typically not behind Cloudflare challenges.
381
+ * 3. **Film page** — `GET /film/{slug}/` to extract metadata and reviews.
382
+ *
383
+ * @param title - Film title to search for.
384
+ * @param year - Optional release year for disambiguation.
385
+ * @returns A {@link ToolExecutionResult} wrapping the extracted film data.
386
+ */
387
+ private async executeViaFallback(
388
+ title: string,
389
+ year?: number,
390
+ ): Promise<ToolExecutionResult<LetterboxdMovieOutput>> {
391
+ // Step 1: Search for the film slug
392
+ let slug = await this.searchFilmSlug(title, year);
393
+
394
+ // Step 2: If search failed (e.g. Cloudflare 403), try the direct film
395
+ // URL pattern — detail pages are typically not behind Cloudflare.
396
+ if (!slug) {
397
+ const directSlug = this.titleToSlug(title);
398
+ const directUrl = `${FILM_BASE_URL}/${directSlug}/`;
399
+ const directHtml = await this.fetchPage(directUrl);
400
+ if (directHtml) {
401
+ const output = this.parseFilmPage(directHtml, directUrl);
402
+ return { success: true, output };
403
+ }
404
+
405
+ return {
406
+ success: true,
407
+ output: { found: false },
408
+ error: 'Search blocked by Cloudflare — tried direct URL lookup',
409
+ };
410
+ }
411
+
412
+ // Step 3: Fetch and parse the film page
413
+ const filmUrl = `${FILM_BASE_URL}/${slug}/`;
414
+ const filmHtml = await this.fetchPage(filmUrl);
415
+ if (!filmHtml) {
416
+ return {
417
+ success: true,
418
+ output: { found: false },
419
+ };
420
+ }
421
+
422
+ const output = this.parseFilmPage(filmHtml, filmUrl);
423
+ return { success: true, output };
424
+ }
425
+
426
+ /**
427
+ * Derive a URL slug from a film title.
428
+ *
429
+ * Lowercases the title, replaces non-alphanumeric runs with hyphens,
430
+ * and strips leading/trailing hyphens.
431
+ *
432
+ * @example
433
+ * ```
434
+ * titleToSlug('The Dark Knight') // => 'the-dark-knight'
435
+ * titleToSlug('Parasite') // => 'parasite'
436
+ * ```
437
+ *
438
+ * @param title - Film title to slugify.
439
+ * @returns A URL-safe slug string.
440
+ */
441
+ private titleToSlug(title: string): string {
442
+ return title
443
+ .toLowerCase()
444
+ .replace(/[^a-z0-9]+/g, '-')
445
+ .replace(/^-|-$/g, '');
446
+ }
447
+
448
+ /**
449
+ * Search Letterboxd for a film and return the slug of the best match.
450
+ *
451
+ * Parses the search results HTML for film links, optionally filtering
452
+ * by year when provided.
453
+ *
454
+ * @param title - Film title query string.
455
+ * @param year - Optional year to prefer in search results.
456
+ * @returns The film slug (e.g. "arrival-2016") or `null` if not found.
457
+ */
458
+ private async searchFilmSlug(title: string, year?: number): Promise<string | null> {
459
+ const encoded = encodeURIComponent(title);
460
+ const searchUrl = `${SEARCH_URL}/${encoded}/`;
461
+ const html = await this.fetchPage(searchUrl);
462
+ if (!html) return null;
463
+
464
+ // Letterboxd search results contain links like /film/{slug}/
465
+ // within list items that also contain the year in a small tag
466
+ const resultPattern = /<a\s+[^>]*href="\/film\/([^/"]+)\/"[^>]*>/gi;
467
+ const slugs: Array<{ slug: string; index: number }> = [];
468
+ let match: RegExpExecArray | null;
469
+
470
+ while ((match = resultPattern.exec(html)) !== null) {
471
+ slugs.push({ slug: match[1]!, index: match.index });
472
+ }
473
+
474
+ if (slugs.length === 0) return null;
475
+
476
+ // If a year is provided, try to find a result whose surrounding
477
+ // HTML context mentions that year
478
+ if (year !== undefined) {
479
+ for (const { slug, index } of slugs) {
480
+ const contextWindow = html.slice(index, index + 500);
481
+ if (contextWindow.includes(String(year))) {
482
+ return slug;
483
+ }
484
+ }
485
+ }
486
+
487
+ // Default to first result
488
+ return slugs[0]!.slug;
489
+ }
490
+
491
+ /**
492
+ * Fetch a page from Letterboxd with realistic browser headers.
493
+ *
494
+ * Follows redirects and returns the response body as a string.
495
+ * Returns `null` on non-2xx status codes or network errors.
496
+ *
497
+ * @param url - Fully qualified URL to fetch.
498
+ * @returns HTML body string or `null` on failure.
499
+ */
500
+ private async fetchPage(url: string): Promise<string | null> {
501
+ try {
502
+ const response = await fetch(url, {
503
+ headers: browserHeaders(),
504
+ redirect: 'follow',
505
+ });
506
+
507
+ if (!response.ok) return null;
508
+ return await response.text();
509
+ } catch {
510
+ return null;
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Parse a Letterboxd film page HTML and extract structured metadata.
516
+ *
517
+ * Uses regex patterns to extract:
518
+ * - Title and year from the headline / head section
519
+ * - Aggregate rating from the rating histogram section
520
+ * - Director names from the crew section
521
+ * - Genre tags from the genre tab
522
+ * - Synopsis from the truncated description
523
+ * - A sample of recent user reviews
524
+ *
525
+ * @param html - Raw HTML of the film page.
526
+ * @param filmUrl - Canonical URL for inclusion in the output.
527
+ * @returns A {@link LetterboxdMovieOutput} with extracted data.
528
+ */
529
+ private parseFilmPage(html: string, filmUrl: string): LetterboxdMovieOutput {
530
+ const output: LetterboxdMovieOutput = {
531
+ found: true,
532
+ url: filmUrl,
533
+ };
534
+
535
+ // --- Title ---
536
+ // <meta property="og:title" content="Film Title (Year)">
537
+ const ogTitleMatch = html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/i);
538
+ if (ogTitleMatch) {
539
+ const ogTitle = ogTitleMatch[1]!;
540
+ // Split "Film Title (2016)" or just use raw
541
+ const titleYearMatch = ogTitle.match(/^(.+?)\s*\((\d{4})\)\s*$/);
542
+ if (titleYearMatch) {
543
+ output.title = stripTags(titleYearMatch[1]!);
544
+ output.year = titleYearMatch[2];
545
+ } else {
546
+ output.title = stripTags(ogTitle);
547
+ }
548
+ }
549
+
550
+ // Fallback: <h1 class="headline-1 ...">Title</h1>
551
+ if (!output.title) {
552
+ const h1Match = html.match(/<h1[^>]*class="[^"]*headline[^"]*"[^>]*>([^<]+)<\/h1>/i);
553
+ if (h1Match) {
554
+ output.title = stripTags(h1Match[1]!);
555
+ }
556
+ }
557
+
558
+ // --- Year (fallback) ---
559
+ // <small class="number"><a href="/films/year/2016/">2016</a></small>
560
+ if (!output.year) {
561
+ const yearMatch = html.match(/<a\s+href="\/films\/year\/(\d{4})\/"[^>]*>\d{4}<\/a>/i);
562
+ if (yearMatch) {
563
+ output.year = yearMatch[1];
564
+ }
565
+ }
566
+
567
+ // --- Aggregate rating ---
568
+ // <meta name="twitter:data2" content="X.XX out of 5">
569
+ const ratingMeta = html.match(/<meta\s+name="twitter:data2"\s+content="([\d.]+)\s+out\s+of\s+5"/i);
570
+ if (ratingMeta) {
571
+ output.rating = ratingMeta[1];
572
+ }
573
+
574
+ // Fallback: weighted average from histogram or schema.org
575
+ if (!output.rating) {
576
+ // <span class="average-rating">X.X</span>
577
+ const avgMatch = html.match(/<span[^>]*class="[^"]*average-rating[^"]*"[^>]*>([\d.]+)<\/span>/i);
578
+ if (avgMatch) {
579
+ output.rating = avgMatch[1];
580
+ }
581
+ }
582
+
583
+ // --- Directors ---
584
+ // <meta name="twitter:data1" content="Director Name, Director Name">
585
+ const directorMeta = html.match(/<meta\s+name="twitter:data1"\s+content="([^"]+)"/i);
586
+ if (directorMeta) {
587
+ output.directors = directorMeta[1];
588
+ }
589
+
590
+ // Fallback: <a href="/director/.../">Name</a> within crew block
591
+ if (!output.directors) {
592
+ const directorLinks: string[] = [];
593
+ const dirPattern = /<a\s+href="\/director\/[^/"]+\/"[^>]*>([^<]+)<\/a>/gi;
594
+ let dirMatch: RegExpExecArray | null;
595
+ while ((dirMatch = dirPattern.exec(html)) !== null) {
596
+ const name = stripTags(dirMatch[1]!);
597
+ if (name && !directorLinks.includes(name)) {
598
+ directorLinks.push(name);
599
+ }
600
+ }
601
+ if (directorLinks.length > 0) {
602
+ output.directors = directorLinks.join(', ');
603
+ }
604
+ }
605
+
606
+ // --- Genres ---
607
+ // <a class="text-slug" href="/films/genre/drama/">Drama</a>
608
+ const genreSet: string[] = [];
609
+ const genrePattern = /<a[^>]+href="\/films\/genre\/[^/"]+\/"[^>]*>([^<]+)<\/a>/gi;
610
+ let genreMatch: RegExpExecArray | null;
611
+ while ((genreMatch = genrePattern.exec(html)) !== null) {
612
+ const genre = stripTags(genreMatch[1]!);
613
+ if (genre && !genreSet.includes(genre)) {
614
+ genreSet.push(genre);
615
+ }
616
+ }
617
+ if (genreSet.length > 0) {
618
+ output.genres = genreSet.join(', ');
619
+ }
620
+
621
+ // --- Synopsis ---
622
+ // <meta property="og:description" content="...">
623
+ const ogDescMatch = html.match(/<meta\s+property="og:description"\s+content="([^"]+)"/i);
624
+ if (ogDescMatch) {
625
+ output.synopsis = stripTags(ogDescMatch[1]!);
626
+ }
627
+
628
+ // Fallback: <div class="truncate">...<p>...</p>...</div>
629
+ if (!output.synopsis) {
630
+ const truncateMatch = html.match(/<div[^>]*class="[^"]*truncate[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
631
+ if (truncateMatch) {
632
+ // Extract first <p> inside the truncate div
633
+ const pMatch = truncateMatch[1]!.match(/<p>([^<]+)<\/p>/i);
634
+ if (pMatch) {
635
+ output.synopsis = stripTags(pMatch[1]!);
636
+ }
637
+ }
638
+ }
639
+
640
+ // --- Reviews ---
641
+ output.reviews = this.parseReviews(html);
642
+
643
+ return output;
644
+ }
645
+
646
+ /**
647
+ * Extract a sample of user reviews from a Letterboxd film page.
648
+ *
649
+ * Looks for review blocks in the "popular reviews" or "recent reviews"
650
+ * section and extracts username, star rating, and review text.
651
+ *
652
+ * @param html - Raw HTML of the film page.
653
+ * @returns An array of up to {@link MAX_REVIEWS} parsed reviews.
654
+ */
655
+ private parseReviews(html: string): LetterboxdReview[] {
656
+ const reviews: LetterboxdReview[] = [];
657
+
658
+ // Reviews are in <li> blocks within a section that contains
659
+ // class="film-detail-content" or similar review containers.
660
+ // Each review typically has:
661
+ // - <a class="context" href="/USERNAME/...">USERNAME</a>
662
+ // - <span class="rating ...">★★★★</span>
663
+ // - <div class="body-text ..."><p>Review text</p></div>
664
+
665
+ // Find review blocks — they contain "attribution" or "film-detail" class
666
+ const reviewBlockPattern =
667
+ /<li[^>]*class="[^"]*film-detail[^"]*"[^>]*>([\s\S]*?)<\/li>/gi;
668
+
669
+ let blockMatch: RegExpExecArray | null;
670
+ while ((blockMatch = reviewBlockPattern.exec(html)) !== null && reviews.length < MAX_REVIEWS) {
671
+ const block = blockMatch[1]!;
672
+
673
+ // Username
674
+ const userMatch = block.match(
675
+ /<a[^>]*class="[^"]*context[^"]*"[^>]*href="\/([^/"]+)\/[^"]*"[^>]*>/i,
676
+ );
677
+ // Also try: <strong class="name">username</strong>
678
+ const userFallback = block.match(/<strong[^>]*class="[^"]*name[^"]*"[^>]*>([^<]+)<\/strong>/i);
679
+ const user = userMatch
680
+ ? userMatch[1]!
681
+ : userFallback
682
+ ? stripTags(userFallback[1]!)
683
+ : 'anonymous';
684
+
685
+ // Star rating — Letterboxd uses ★ and ½ characters
686
+ const ratingMatch = block.match(/<span[^>]*class="[^"]*rating[^"]*"[^>]*>([★½]+)<\/span>/i);
687
+ const rating = ratingMatch ? ratingMatch[1]! : '';
688
+
689
+ // Review text
690
+ const bodyMatch = block.match(
691
+ /<div[^>]*class="[^"]*body-text[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
692
+ );
693
+ const text = bodyMatch ? stripTags(bodyMatch[1]!).slice(0, 500) : '';
694
+
695
+ if (user !== 'anonymous' || text) {
696
+ reviews.push({ user, rating, text });
697
+ }
698
+ }
699
+
700
+ return reviews;
701
+ }
702
+ }