@framers/agentos-ext-letterboxd 1.0.0 → 1.1.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.
package/manifest.json CHANGED
@@ -2,12 +2,12 @@
2
2
  "$schema": "https://agentos.sh/schemas/extension-manifest-v1.json",
3
3
  "id": "com.framers.media.letterboxd",
4
4
  "name": "Letterboxd Movie Lookup Extension",
5
- "version": "1.0.0",
5
+ "version": "1.1.0",
6
6
  "description": "Look up movie metadata, ratings, and reviews from Letterboxd via scraping — no API key required",
7
7
  "author": {
8
8
  "name": "Frame.dev",
9
9
  "email": "support@frame.dev",
10
- "url": "https://github.com/framersai"
10
+ "url": "https://github.com/framerslab"
11
11
  },
12
12
  "license": "MIT",
13
13
  "keywords": ["letterboxd", "movie", "film", "review", "media", "scraper"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@framers/agentos-ext-letterboxd",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Letterboxd movie lookup tool for AgentOS — scrapes film metadata, ratings, and reviews",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -38,29 +38,36 @@
38
38
  "license": "MIT",
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "https://github.com/framersai/agentos-extensions.git",
41
+ "url": "git+https://github.com/framerslab/agentos-extensions.git",
42
42
  "directory": "registry/curated/media/letterboxd"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public"
46
46
  },
47
47
  "peerDependencies": {
48
- "@framers/agentos": "^0.5.2"
48
+ "@framers/agentos": ">=0.10.40"
49
49
  },
50
50
  "devDependencies": {
51
- "@framers/agentos": "^0.6.6",
52
51
  "@types/node": "^20.12.12",
53
- "@vitest/coverage-v8": "^1.6.0",
52
+ "@vitest/coverage-v8": "^3.2.7",
54
53
  "rimraf": "^5.0.7",
55
54
  "typescript": "^5.4.5",
56
- "vitest": "^1.6.0"
55
+ "vitest": "^3.2.7",
56
+ "@framers/agentos": "^0.10.40"
57
57
  },
58
+ "homepage": "https://github.com/framerslab/agentos-extensions/tree/master/registry/curated/media/letterboxd#readme",
59
+ "bugs": {
60
+ "url": "https://github.com/framerslab/agentos-extensions/issues"
61
+ },
62
+ "files": [
63
+ "dist",
64
+ "manifest.json"
65
+ ],
58
66
  "scripts": {
59
67
  "build": "tsc",
60
- "test": "vitest run",
68
+ "test": "vitest run --passWithNoTests",
61
69
  "test:watch": "vitest",
62
70
  "test:coverage": "vitest run --coverage",
63
- "lint": "eslint src --ext .ts",
64
71
  "typecheck": "tsc --noEmit",
65
72
  "clean": "rimraf dist"
66
73
  }
package/src/index.ts DELETED
@@ -1,86 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * Letterboxd Extension Pack — provides movie lookup capabilities for agents.
4
- *
5
- * Exposes a single `letterboxd_movie` tool that scrapes Letterboxd for film
6
- * metadata, aggregate ratings, director credits, genre tags, and a sample
7
- * of recent user reviews. No API key required.
8
- *
9
- * The tool supports an optional integration with the web-scraper extension's
10
- * recipe system: when a `web_scrape_recipe`-compatible tool reference is
11
- * supplied via `options.recipeTool`, the tool delegates scraping to the
12
- * recipe engine for more robust, tiered fetching. Otherwise it falls back
13
- * to a lightweight built-in `fetch()` scraper.
14
- *
15
- * @module @framers/agentos-ext-letterboxd
16
- */
17
-
18
- import { LetterboxdMovieTool } from './tools/letterboxdMovie.js';
19
-
20
- import type { RecipeToolLike } from './tools/letterboxdMovie.js';
21
-
22
- /**
23
- * Configuration options for the Letterboxd extension pack.
24
- */
25
- export interface LetterboxdExtensionOptions {
26
- /**
27
- * Optional reference to a `web_scrape_recipe`-compatible tool.
28
- * When provided, the `letterboxd_movie` tool attempts recipe-based
29
- * scraping before falling back to the built-in scraper.
30
- */
31
- recipeTool?: RecipeToolLike;
32
-
33
- /**
34
- * Descriptor priority for tool ordering.
35
- * Lower numbers are higher priority. Defaults to `50`.
36
- */
37
- priority?: number;
38
- }
39
-
40
- /**
41
- * Create the Letterboxd extension pack.
42
- *
43
- * @param context - Extension activation context provided by the AgentOS
44
- * runtime. Must contain an `options` property (or empty
45
- * object) and optionally a `logger` for lifecycle logging.
46
- * @returns An extension pack descriptor with a single `letterboxd_movie` tool.
47
- *
48
- * @example
49
- * ```ts
50
- * import { createExtensionPack } from '@framers/agentos-ext-letterboxd';
51
- *
52
- * const pack = createExtensionPack({ options: {} });
53
- * // pack.descriptors[0].payload is the LetterboxdMovieTool instance
54
- * ```
55
- */
56
- export function createExtensionPack(context: any) {
57
- const options = (context.options || {}) as LetterboxdExtensionOptions;
58
- const tool = new LetterboxdMovieTool(options.recipeTool);
59
-
60
- return {
61
- name: '@framers/agentos-ext-letterboxd',
62
- version: '1.0.0',
63
- descriptors: [
64
- {
65
- // IMPORTANT: ToolExecutor uses descriptor id as the lookup key for tool calls.
66
- // Keep it aligned with `tool.name`.
67
- id: tool.name,
68
- kind: 'tool' as const,
69
- priority: options.priority || 50,
70
- payload: tool,
71
- requiredSecrets: [],
72
- },
73
- ],
74
- onActivate: async () => context.logger?.info('Letterboxd Extension activated'),
75
- onDeactivate: async () => context.logger?.info('Letterboxd Extension deactivated'),
76
- };
77
- }
78
-
79
- export { LetterboxdMovieTool };
80
- export type {
81
- LetterboxdMovieInput,
82
- LetterboxdMovieOutput,
83
- LetterboxdReview,
84
- RecipeToolLike,
85
- } from './tools/letterboxdMovie.js';
86
- export default createExtensionPack;
@@ -1,702 +0,0 @@
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
- }
@@ -1,344 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * LetterboxdMovieTool — Unit Tests
4
- *
5
- * Verifies the tool's metadata (name, category, inputSchema), the built-in
6
- * fallback scraper with mocked fetch returning search results HTML and film
7
- * page HTML, and the `found: false` path when no search results match.
8
- *
9
- * All HTTP calls are mocked via `globalThis.fetch` — no real Letterboxd
10
- * requests are made.
11
- *
12
- * @module test/letterboxdMovie
13
- */
14
-
15
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
- import { LetterboxdMovieTool } from '../src/tools/letterboxdMovie.js';
17
-
18
- /* -------------------------------------------------------------------------- */
19
- /* Global fetch mock */
20
- /* -------------------------------------------------------------------------- */
21
-
22
- const mockFetch = vi.fn();
23
- vi.stubGlobal('fetch', mockFetch);
24
-
25
- beforeEach(() => {
26
- mockFetch.mockReset();
27
- });
28
-
29
- afterEach(() => {
30
- vi.restoreAllMocks();
31
- });
32
-
33
- /* -------------------------------------------------------------------------- */
34
- /* Minimal ToolExecutionContext stub */
35
- /* -------------------------------------------------------------------------- */
36
-
37
- /** Minimal context object satisfying the ToolExecutionContext interface. */
38
- const stubContext = {
39
- agentId: 'test-agent',
40
- conversationId: 'test-conversation',
41
- } as any;
42
-
43
- /* -------------------------------------------------------------------------- */
44
- /* HTML fixtures */
45
- /* -------------------------------------------------------------------------- */
46
-
47
- /**
48
- * Simulated Letterboxd search results page HTML containing a link to the
49
- * film "Arrival (2016)".
50
- */
51
- const SEARCH_RESULTS_HTML = `
52
- <!DOCTYPE html>
53
- <html>
54
- <head><title>Letterboxd - Search</title></head>
55
- <body>
56
- <ul class="results">
57
- <li class="search-result">
58
- <a href="/film/arrival-2016/" class="film-poster">
59
- <img src="https://letterboxd.com/poster.jpg" alt="Arrival">
60
- </a>
61
- <span class="film-title-wrapper">
62
- <a href="/film/arrival-2016/">Arrival</a>
63
- <small class="metadata">2016</small>
64
- </span>
65
- </li>
66
- <li class="search-result">
67
- <a href="/film/arrival-1996/" class="film-poster">
68
- <img src="https://letterboxd.com/poster2.jpg" alt="Arrival">
69
- </a>
70
- <span class="film-title-wrapper">
71
- <a href="/film/arrival-1996/">The Arrival</a>
72
- <small class="metadata">1996</small>
73
- </span>
74
- </li>
75
- </ul>
76
- </body>
77
- </html>
78
- `;
79
-
80
- /**
81
- * Simulated Letterboxd film page HTML for "Arrival (2016)".
82
- */
83
- const FILM_PAGE_HTML = `
84
- <!DOCTYPE html>
85
- <html>
86
- <head>
87
- <meta property="og:title" content="Arrival (2016)">
88
- <meta property="og:description" content="A linguist is recruited by the military to communicate with alien lifeforms.">
89
- <meta name="twitter:data1" content="Denis Villeneuve">
90
- <meta name="twitter:data2" content="4.0 out of 5">
91
- </head>
92
- <body>
93
- <section id="featured-film-header">
94
- <h1 class="headline-1 filmtitle">Arrival</h1>
95
- <small class="number"><a href="/films/year/2016/">2016</a></small>
96
- </section>
97
-
98
- <div class="text-sluglist">
99
- <a class="text-slug" href="/films/genre/drama/">Drama</a>
100
- <a class="text-slug" href="/films/genre/science-fiction/">Science Fiction</a>
101
- </div>
102
-
103
- <div class="truncate"><p>A linguist is recruited by the military to communicate with alien lifeforms.</p></div>
104
-
105
- <ul class="film-details">
106
- <li class="film-detail">
107
- <a class="context" href="/johndoe/film/arrival-2016/">johndoe</a>
108
- <span class="rating rated-large-8">★★★★</span>
109
- <div class="body-text -micro"><p>Absolutely breathtaking.</p></div>
110
- </li>
111
- <li class="film-detail">
112
- <a class="context" href="/janedoe/film/arrival-2016/">janedoe</a>
113
- <span class="rating rated-large-9">★★★★½</span>
114
- <div class="body-text -micro"><p>One of the best sci-fi films ever made.</p></div>
115
- </li>
116
- </ul>
117
-
118
- <section id="crew-credits">
119
- <a href="/director/denis-villeneuve/">Denis Villeneuve</a>
120
- </section>
121
- </body>
122
- </html>
123
- `;
124
-
125
- /** Search results HTML with no film links (empty results). */
126
- const EMPTY_SEARCH_HTML = `
127
- <!DOCTYPE html>
128
- <html>
129
- <head><title>Letterboxd - Search</title></head>
130
- <body>
131
- <div class="results">
132
- <p>There were no matches for your search term.</p>
133
- </div>
134
- </body>
135
- </html>
136
- `;
137
-
138
- /* -------------------------------------------------------------------------- */
139
- /* Tool metadata */
140
- /* -------------------------------------------------------------------------- */
141
-
142
- describe('LetterboxdMovieTool — metadata', () => {
143
- const tool = new LetterboxdMovieTool();
144
-
145
- it('should have the correct tool name', () => {
146
- expect(tool.name).toBe('letterboxd_movie');
147
- });
148
-
149
- it('should have the correct category', () => {
150
- expect(tool.category).toBe('media');
151
- });
152
-
153
- it('should define an inputSchema requiring "title"', () => {
154
- expect(tool.inputSchema).toBeDefined();
155
- expect(tool.inputSchema.type).toBe('object');
156
- expect(tool.inputSchema.properties).toBeDefined();
157
- expect((tool.inputSchema.properties as any).title).toBeDefined();
158
- expect((tool.inputSchema.properties as any).title.type).toBe('string');
159
- expect(tool.inputSchema.required).toContain('title');
160
- });
161
-
162
- it('should declare hasSideEffects as false', () => {
163
- expect(tool.hasSideEffects).toBe(false);
164
- });
165
-
166
- it('should have an id, displayName, and version', () => {
167
- expect(tool.id).toBe('letterboxd-movie-v1');
168
- expect(tool.displayName).toBe('Letterboxd Movie Lookup');
169
- expect(tool.version).toBe('1.0.0');
170
- });
171
- });
172
-
173
- /* -------------------------------------------------------------------------- */
174
- /* Successful film lookup */
175
- /* -------------------------------------------------------------------------- */
176
-
177
- describe('LetterboxdMovieTool — built-in fallback scraper', () => {
178
- const tool = new LetterboxdMovieTool();
179
-
180
- it('should return found: true with film data from mocked HTML', async () => {
181
- // First fetch: search results page
182
- mockFetch.mockResolvedValueOnce({
183
- ok: true,
184
- status: 200,
185
- text: async () => SEARCH_RESULTS_HTML,
186
- });
187
-
188
- // Second fetch: film detail page
189
- mockFetch.mockResolvedValueOnce({
190
- ok: true,
191
- status: 200,
192
- text: async () => FILM_PAGE_HTML,
193
- });
194
-
195
- const result = await tool.execute({ title: 'Arrival', year: 2016 }, stubContext);
196
-
197
- expect(result.success).toBe(true);
198
- expect(result.output).toBeDefined();
199
- expect(result.output!.found).toBe(true);
200
- expect(result.output!.title).toBe('Arrival');
201
- expect(result.output!.year).toBe('2016');
202
- expect(result.output!.rating).toBe('4.0');
203
- expect(result.output!.directors).toBe('Denis Villeneuve');
204
- expect(result.output!.genres).toContain('Drama');
205
- expect(result.output!.genres).toContain('Science Fiction');
206
- expect(result.output!.synopsis).toContain('linguist');
207
- });
208
-
209
- it('should extract user reviews from the film page', async () => {
210
- mockFetch.mockResolvedValueOnce({
211
- ok: true,
212
- status: 200,
213
- text: async () => SEARCH_RESULTS_HTML,
214
- });
215
- mockFetch.mockResolvedValueOnce({
216
- ok: true,
217
- status: 200,
218
- text: async () => FILM_PAGE_HTML,
219
- });
220
-
221
- const result = await tool.execute({ title: 'Arrival' }, stubContext);
222
-
223
- expect(result.output!.reviews).toBeDefined();
224
- expect(result.output!.reviews!.length).toBeGreaterThanOrEqual(1);
225
-
226
- const firstReview = result.output!.reviews![0]!;
227
- expect(firstReview.user).toBe('johndoe');
228
- expect(firstReview.text).toContain('breathtaking');
229
- });
230
- });
231
-
232
- /* -------------------------------------------------------------------------- */
233
- /* No search results */
234
- /* -------------------------------------------------------------------------- */
235
-
236
- describe('LetterboxdMovieTool — no results', () => {
237
- const tool = new LetterboxdMovieTool();
238
-
239
- it('should return found: false when no search results match and direct URL also fails', async () => {
240
- // Search returns empty results
241
- mockFetch.mockResolvedValueOnce({
242
- ok: true,
243
- status: 200,
244
- text: async () => EMPTY_SEARCH_HTML,
245
- });
246
-
247
- // Direct URL attempt also fails (404)
248
- mockFetch.mockResolvedValueOnce({
249
- ok: false,
250
- status: 404,
251
- text: async () => 'Not Found',
252
- });
253
-
254
- const result = await tool.execute({ title: 'xyznonexistent123' }, stubContext);
255
-
256
- expect(result.success).toBe(true);
257
- expect(result.output).toBeDefined();
258
- expect(result.output!.found).toBe(false);
259
- });
260
-
261
- it('should return an error when title is empty', async () => {
262
- const result = await tool.execute({ title: '' }, stubContext);
263
- expect(result.success).toBe(false);
264
- expect(result.error).toContain('title');
265
- });
266
-
267
- it('should return found: false with error message when search is blocked by Cloudflare', async () => {
268
- // Search returns 403 (Cloudflare blocks)
269
- mockFetch.mockResolvedValueOnce({
270
- ok: false,
271
- status: 403,
272
- text: async () => 'Forbidden',
273
- });
274
-
275
- // Direct URL attempt also fails
276
- mockFetch.mockResolvedValueOnce({
277
- ok: false,
278
- status: 404,
279
- text: async () => 'Not Found',
280
- });
281
-
282
- const result = await tool.execute({ title: 'Arrival' }, stubContext);
283
-
284
- expect(result.success).toBe(true);
285
- expect(result.output!.found).toBe(false);
286
- expect(result.error).toContain('Cloudflare');
287
- });
288
-
289
- it('should fall back to direct URL when search fails and return film data', async () => {
290
- // Search returns 403 (Cloudflare)
291
- mockFetch.mockResolvedValueOnce({
292
- ok: false,
293
- status: 403,
294
- text: async () => 'Forbidden',
295
- });
296
-
297
- // Direct URL attempt succeeds (film detail pages bypass Cloudflare)
298
- mockFetch.mockResolvedValueOnce({
299
- ok: true,
300
- status: 200,
301
- text: async () => FILM_PAGE_HTML,
302
- });
303
-
304
- const result = await tool.execute({ title: 'Arrival' }, stubContext);
305
-
306
- expect(result.success).toBe(true);
307
- expect(result.output).toBeDefined();
308
- expect(result.output!.found).toBe(true);
309
- expect(result.output!.title).toBe('Arrival');
310
- expect(result.output!.rating).toBe('4.0');
311
- });
312
- });
313
-
314
- /* -------------------------------------------------------------------------- */
315
- /* Argument validation */
316
- /* -------------------------------------------------------------------------- */
317
-
318
- describe('LetterboxdMovieTool — validateArgs', () => {
319
- const tool = new LetterboxdMovieTool();
320
-
321
- it('should pass validation with valid title', () => {
322
- const result = tool.validateArgs({ title: 'Arrival' });
323
- expect(result.isValid).toBe(true);
324
- expect(result.errors).toBeUndefined();
325
- });
326
-
327
- it('should fail validation when title is missing', () => {
328
- const result = tool.validateArgs({});
329
- expect(result.isValid).toBe(false);
330
- expect(result.errors).toBeDefined();
331
- expect(result.errors!.length).toBeGreaterThan(0);
332
- });
333
-
334
- it('should fail validation when year is not an integer', () => {
335
- const result = tool.validateArgs({ title: 'Arrival', year: 2016.5 });
336
- expect(result.isValid).toBe(false);
337
- expect(result.errors).toBeDefined();
338
- });
339
-
340
- it('should pass validation with title and integer year', () => {
341
- const result = tool.validateArgs({ title: 'Arrival', year: 2016 });
342
- expect(result.isValid).toBe(true);
343
- });
344
- });
package/tsconfig.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "lib": ["ES2022"],
6
- "outDir": "./dist",
7
- "rootDir": "./src",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "noEmitOnError": false,
12
- "forceConsistentCasingInFileNames": true,
13
- "declaration": true,
14
- "declarationMap": true,
15
- "sourceMap": true,
16
- "resolveJsonModule": true,
17
- "moduleResolution": "Bundler",
18
- "types": ["node"]
19
- },
20
- "include": ["src/**/*"],
21
- "exclude": ["node_modules", "dist", "test"]
22
- }