import re, time from datetime import datetime from modules import util from modules.util import Failed logger = util.logger sort_options = { "name": "by/name/", "popularity": "by/popular/", "newest": "by/newest/", "oldest": "by/oldest/", "updated": "" } user_sort_options = { "release_date_newest": "by/release/", "release_date_earliest": "by/release-earliest/", "name": "by/name/", "popularity": "by/popular/", "when_added_newest": "by/added/", "when_added_earliest": "by/added-earliest/", "when_rated_newest": "by/rated/", "when_rated_earliest": "by/rated-earliest/", "average_rating_highest": "by/rating/", "average_rating_lowest": "by/rating-lowest/", "user_rating_highest": "by/your-rating/", "user_rating_lowest": "by/your-rating-lowest/", "length_shortest": "by/shortest/", "length_longest": "by/longest/" } builders = ["letterboxd_list", "letterboxd_list_details", "letterboxd_user_films", "letterboxd_user_films_details", "letterboxd_user_reviews", "letterboxd_user_reviews_details"] base_url = "https://letterboxd.com" # Letterboxd migrated list/watchlist pages to a React-based "LazyPoster" # component in April 2026. Films are now nodes like: #
# The numeric film id is embedded in the data-postered-identifier JSON as # uid="film:"; extracting it here keeps existing letterboxd_map cache # rows (keyed by the same numeric id) compatible. _item_id_re = re.compile(r'"uid"\s*:\s*"film:(\d+)"') _item_year_re = re.compile(r"\((\d{4})\)\s*$") def _item_from_element(element): """Extract (letterboxd_id, slug, year) from a new-style LazyPoster div. Returns (None, None, None) if required fields are missing; callers skip those.""" slug = element.get("data-item-link") if not slug: # Fallback: some older pages use data-target-link on the same element. target = element.xpath("@data-target-link") slug = target[0] if target else None if not slug: return None, None, None letterboxd_id = None pid = element.get("data-postered-identifier") or "" if pid: m = _item_id_re.search(pid) if m: letterboxd_id = m.group(1) if not letterboxd_id: return None, None, None year = None name = element.get("data-item-name") or "" if name: m = _item_year_re.search(name) if m: year = int(m.group(1)) return letterboxd_id, slug, year class Letterboxd: def __init__(self, requests, cache=None): self.requests = requests self.cache = cache def _request(self, url, language, xpath=None): logger.trace(f"URL: {url}") response = self.requests.get_cloudscrape_html(url, language=language) return response.xpath(xpath) if xpath else response def _parse_page(self, list_url, language): if "ajax" not in list_url: list_url = list_url.replace("https://letterboxd.com/films", "https://letterboxd.com/films/ajax") response = self._request(list_url, language) # New LazyPoster markup (post-April-2026 migration) — see module-level # notes above. Ratings/notes are no longer nearby siblings on list and # watchlist pages, so those fields are left as None. letterboxd_elements = response.xpath("//div[@data-item-slug]") items = [] for letterboxd_element in letterboxd_elements: letterboxd_id, slug, year = _item_from_element(letterboxd_element) if not letterboxd_id or not slug: continue items.append((letterboxd_id, slug, year, None, None)) next_url = response.xpath("//a[@class='next']/@href") return items, next_url def _parse_list(self, list_url, limit, language): items, next_url = self._parse_page(list_url, language) while len(next_url) > 0: time.sleep(2) new_items, next_url = self._parse_page(f"{base_url}{next_url[0]}", language) items.extend(new_items) if limit and len(items) >= limit: return items[:limit] return items def _parse_user_page(self, page_url, language): # User pages don't use /ajax/ endpoints, use regular URL response = self._request(page_url, language) # New LazyPoster markup — see module-level notes. Rating/note data is # no longer carried on a sibling/parent of the item on list/watchlist # pages; when_added is best-effort from a nearby