@framers/agentos-ext-letterboxd 1.0.0 → 1.1.1
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 +4 -4
- package/package.json +16 -9
- package/src/index.ts +0 -86
- package/src/tools/letterboxdMovie.ts +0 -702
- package/test/letterboxdMovie.spec.ts +0 -344
- package/tsconfig.json +0 -22
package/manifest.json
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
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.
|
|
5
|
+
"version": "1.1.1",
|
|
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/
|
|
10
|
+
"url": "https://github.com/framerslab"
|
|
11
11
|
},
|
|
12
|
-
"license": "
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
13
|
"keywords": ["letterboxd", "movie", "film", "review", "media", "scraper"],
|
|
14
|
-
"agentosVersion": "
|
|
14
|
+
"agentosVersion": ">=0.10.40",
|
|
15
15
|
"categories": ["media", "research"],
|
|
16
16
|
"extensions": [
|
|
17
17
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@framers/agentos-ext-letterboxd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -35,32 +35,39 @@
|
|
|
35
35
|
"url": "https://github.com/jddunn"
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
|
-
"license": "
|
|
38
|
+
"license": "Apache-2.0",
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
|
41
|
-
"url": "https://github.com/
|
|
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": "
|
|
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": "^
|
|
52
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
54
53
|
"rimraf": "^5.0.7",
|
|
55
54
|
"typescript": "^5.4.5",
|
|
56
|
-
"vitest": "^
|
|
55
|
+
"vitest": "^3.2.7",
|
|
56
|
+
"@framers/agentos": "^0.13.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(/&/g, '&')
|
|
176
|
-
.replace(/</g, '<')
|
|
177
|
-
.replace(/>/g, '>')
|
|
178
|
-
.replace(/"/g, '"')
|
|
179
|
-
.replace(/'/g, "'")
|
|
180
|
-
.replace(/ /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
|
-
}
|