Letterboxd to Markdown
If you’re a movie buff who writes reviews on Letterboxd and also maintains a personal website or blog, you might have wished for a way to showcase your reviews on your own platform. That’s exactly what this script does - it pulls your Letterboxd reviews through their RSS feed and converts them to markdown files that you can use in your static site generator of choice (Eleventy in my case!)
The Problem
I have been trying to find the perfect way to log my own movie reviews for ever. You can see in my CLI tool post that I even made it possible to grab metadata from the open movie database. The problem is this relies on me logging this, from my terminal, in an orderly fashion, which…
Meanwhile, Letterboxd is right there, all the time, on my phone when I get up from my seat at a theatre. I’ve gone through periods of logging well in letterboxd, but also years where I haven’t logged a thing.
a non technical problem with this project, is getting my letterboxd profile cleaned up and maybe a little more text on the reviews, where it seems like in some cases I didn’t even leave a score.
The Solution
I created a TypeScript script that:
- Fetches your Letterboxd RSS feed
- Parses the XML to extract review data
- Downloads the movie poster images
- Creates markdown files with proper frontmatter
- Only processes new reviews (skips existing ones)
The script is designed to be run periodically as part of your site’s build process, so it can automatically sync new reviews. You could also set it up to be part of a cron job in a GitHub action.
I have thought about this and I think the answer is a GitHub actions cron job to trigger a rebuild every 12 hours if data changes.
— Ezra Mechaber (@ezra.im) April 18, 2025 at 1:23 PM
The Code
Here’s the script that makes it all happen. I’ve added comments so you can understand each part:
/**
* Letterboxd to Markdown Converter
*
* This script fetches movie reviews from a Letterboxd RSS feed and converts them
* to markdown files for use in a static site or blog. It also downloads the movie
* poster images from the RSS feed.
*
* Run with: bun run scripts/sync_letterboxd.ts
*/
import fetch from 'node-fetch';
import fs from 'fs-extra';
import path from 'path';
import { Parser } from 'xml2js';
// Configuration constants
const RSS_URL = 'https://letterboxd.com/keithk/rss/';
const MOVIES_DIR = path.join(__dirname, '../src/db/movies');
const IMAGES_DIR = path.join(__dirname, '../src/assets/images/db/movies');
// TypeScript interfaces for data models
interface LetterboxdItem {
'letterboxd:filmTitle'?: string;
'letterboxd:filmYear'?: string;
'letterboxd:memberRating'?: string;
'letterboxd:watchedDate'?: string;
description?: string;
link?: string;
}
interface LetterboxdRSS {
rss: {
channel: {
item: LetterboxdItem | LetterboxdItem[];
};
};
}
/**
* Fetches the RSS feed from the given URL
*
* @param url - The RSS feed URL
* @returns The RSS feed content as text
* @throws Error if the fetch fails
*/
async function fetchRSS(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch RSS feed: ${res.status} ${res.statusText}`);
return res.text();
}
/**
* Parses the RSS XML content into a JavaScript object
*
* @param xml - The RSS feed content as text
* @returns Parsed RSS feed as a JavaScript object
*/
async function parseRSS(xml: string): Promise<LetterboxdRSS> {
const parser = new Parser({
explicitArray: false, // Don't return arrays for single elements
mergeAttrs: true // Merge attributes into the elements
});
return parser.parseStringPromise(xml) as Promise<LetterboxdRSS>;
}
/**
* Sanitizes a movie title to create a valid filename
*
* @param title - Movie title
* @param year - Release year (optional)
* @returns Sanitized filename with .md extension
*/
function sanitizeFilename(title: string, year?: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
.replace(/(^-|-$)/g, '') // Remove leading/trailing hyphens
+ (year ? `-${year}` : '') // Append year if available
+ '.md' // Add markdown extension
);
}
/**
* Checks if a file exists at the given path
*
* @param filepath - Path to check
* @returns Boolean indicating if file exists
*/
async function fileExists(filepath: string): Promise<boolean> {
try {
await fs.access(filepath);
return true;
} catch {
return false;
}
}
/**
* Downloads an image from a URL to the specified destination
*
* @param url - Image URL
* @param dest - Destination file path
* @returns Promise that resolves when download completes
* @throws Error if download fails
*/
async function downloadImage(url: string, dest: string): Promise<void> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download image: ${url} (${res.status} ${res.statusText})`);
// Ensure the directory exists
await fs.ensureDir(path.dirname(dest));
const stream = fs.createWriteStream(dest);
return new Promise<void>((resolve, reject) => {
// Check if body exists and is readable
if (!res.body) {
reject(new Error('Response body is null'));
return;
}
res.body.pipe(stream);
res.body.on('error', reject);
stream.on('finish', resolve);
});
}
/**
* Extracts the movie poster URL from the item description
*
* @param description - HTML description from RSS feed
* @returns Image URL or null if not found
*/
function extractImageUrl(description: string): string | null {
const match = description.match(/<img src=\"(.*?)\"/);
return match ? match[1] : null;
}
/**
* Extracts the text review from the HTML description
*
* @param description - HTML description from RSS feed
* @returns Plain text review without HTML tags
*/
function extractReview(description: string): string {
// Remove <img> tag and strip all HTML tags
return description
.replace(/<p><img[^>]+><\/p>/, '') // Remove poster image
.replace(/<[^>]+>/g, '') // Remove all HTML tags
.trim(); // Clean up whitespace
}
/**
* Main function to process the RSS feed and create markdown files
*/
async function main(): Promise<void> {
// Ensure directories exist
await fs.ensureDir(MOVIES_DIR);
await fs.ensureDir(IMAGES_DIR);
console.log('Fetching RSS feed from Letterboxd...');
const xml = await fetchRSS(RSS_URL);
console.log('Parsing RSS feed...');
const rss = await parseRSS(xml);
const items = rss.rss.channel.item;
if (!items) {
console.log('No items found in RSS feed.');
return;
}
// Convert to array if it's a single item
const itemsArray = Array.isArray(items) ? items : [items];
console.log(`Found ${itemsArray.length} reviews in the RSS feed.`);
// Counter for processed items
let processed = 0;
for (const item of itemsArray) {
const title = item['letterboxd:filmTitle'] || '';
const year = item['letterboxd:filmYear'] || '';
const rating = item['letterboxd:memberRating'] || '';
const watchedDate = item['letterboxd:watchedDate'] || '';
const review = extractReview(item.description || '');
const imageUrl = extractImageUrl(item.description || '');
// Generate filename and paths
const slug = sanitizeFilename(title, year);
const mdPath = path.join(MOVIES_DIR, slug);
// Extract image filename from URL
const imageFilename = imageUrl ? path.basename(imageUrl.split('?')[0]) : '';
const imageDest = imageUrl ? path.join(IMAGES_DIR, imageFilename) : '';
// Skip if file already exists (no overwriting)
if (await fileExists(mdPath)) {
console.log(`Skipping existing review: ${title} (${year})`);
continue;
}
// Download image if needed
if (imageUrl && !(await fileExists(imageDest))) {
try {
await downloadImage(imageUrl, imageDest);
console.log(`Downloaded image for: ${title} (${year})`);
} catch (e) {
console.error(`Failed to download image for ${title}:`, e instanceof Error ? e.message : e);
}
}
// Create markdown content with frontmatter
const mdContent = `---
title: "${title}"
headerSubtitle: "watching a movie"
emoji: "📸"
layout: db
date: ${watchedDate}
tags:
- movie
- review
rating: ${rating}
featuredImage: /assets/images/db/movies/${imageFilename}
---
${review}
<div class='import-letterboxd'><emoji>🔗</emoji>This review was imported from <a href='${item.link}'>Letterboxd</a>.</div>`;
// Write markdown file
await fs.writeFile(mdPath, mdContent, 'utf8');
console.log(`Created: ${mdPath}`);
processed++;
}
console.log(`Done! Processed ${processed} new movie reviews.`);
}
// Execute the script and handle errors
main().catch(e => {
console.error('Error in Letterboxd sync script:', e instanceof Error ? e.message : e);
process.exit(1);
});
How It Works
The script performs several steps to convert your Letterboxd reviews to markdown:
1. Fetching and Parsing the RSS Feed
The script starts by fetching your Letterboxd RSS feed and parsing the XML into a JavaScript object using the xml2js
library. The RSS feed contains all your recent activity on Letterboxd, including film ratings, reviews, lists, and more. It has a limit of 200 movies, so if you’ve done more than that… I’m not really sure. Maybe it’s time to start fresh!
2. Processing Each Review
For each review in the feed, the script:
- Extracts the film title, year, rating, and review text
- Sanitizes the title to create a valid filename
- Downloads the film poster image if it doesn’t already exist
- Creates a markdown file with appropriate frontmatter
3. Frontmatter Generation
The markdown files include frontmatter that can be used by static site generators like Eleventy or Astro. In this case it also includes some specifics for my blog.
The frontmatter includes:
- Movie title and year
- Your rating
- The date you watched the movie
- A path to the downloaded poster image
- Tags for categorization
4. Error Handling and Logging
The script includes error handling to ensure it doesn’t crash if one review fails to process. It logs its progress to the console so you can see what’s happening and troubleshoot any issues and run again.
Setup
To use this script in your own project:
Install the required dependencies:
bun add node-fetch fs-extra xml2js
Create a TypeScript file (e.g.,
sync_letterboxd.ts
) with the code aboveUpdate the
RSS_URL
constant with your own Letterboxd username:const RSS_URL = 'https://letterboxd.com/YOUR_USERNAME/rss/';
Update the paths to match your project structure:
const MOVIES_DIR = path.join(__dirname, '../src/content/movies'); const IMAGES_DIR = path.join(__dirname, '../public/images/movies');
Run the script:
bun run sync_letterboxd.ts
Consider adding it to your build process or setting up a cron job to run it periodically
Customization
You’ll probably need to change the script to fit your needs, but that shouldn’t be too hard:
- Frontmatter Format: Modify the
mdContent
string to change the frontmatter format to match your blog, you can see mine here (each blog post gets an emoji!) - File Organization: Adjust the directory paths to match your project structure
- Image Handling: Change how images are processed or stored
Conclusion
The beauty of this approach is that you maintain ownership of your content on your personal site while still taking advantage of the ease of Letterboxd. You can even go more in depth on your blog.
Feel free to adapt this script to your needs, and let me know if you make any cool improvements to it!