161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Resolve Letterboxd poster image URLs for every film in a ratings.csv export.
|
|
|
|
Usage:
|
|
python fetch_posters.py ratings.csv [-o posters.json]
|
|
|
|
Writes a {"https://boxd.it/xxxx": "https://a.ltrbxd.com/..."} map. The output file
|
|
doubles as the cache: re-running after a new export only fetches the new rows.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
POSTER_RE = re.compile(r'https://a\.ltrbxd\.com/resized/film-poster/[^"\'\ <>]+')
|
|
|
|
# Some films serve their poster from /resized/sm/upload/ instead. Those pages also carry
|
|
# landscape stills, so match the poster crop shape and keep only the 2:3 images.
|
|
FALLBACK_RE = re.compile(r'https://a\.ltrbxd\.com/resized/[^"\'\ <>]*?-0-(\d+)-0-(\d+)-crop\.jpg(?:\?v=[0-9a-z]+)?')
|
|
|
|
# Letterboxd rejects the default Python-urllib agent with a 403.
|
|
USER_AGENT = (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
|
)
|
|
|
|
URI_COLUMN = "Letterboxd URI"
|
|
WORKERS = 4
|
|
REQUEST_DELAY = 0.15
|
|
TIMEOUT = 20
|
|
SAVE_EVERY = 20
|
|
|
|
print_lock = threading.Lock()
|
|
cache_lock = threading.Lock()
|
|
|
|
|
|
def read_uris(csv_path):
|
|
with open(csv_path, "r", encoding="utf-8-sig", newline="") as handle:
|
|
reader = csv.DictReader(handle)
|
|
if reader.fieldnames is None or URI_COLUMN not in reader.fieldnames:
|
|
sys.exit(
|
|
"'%s' has no '%s' column. Columns found: %s"
|
|
% (csv_path, URI_COLUMN, reader.fieldnames)
|
|
)
|
|
seen, uris = set(), []
|
|
for row in reader:
|
|
uri = (row.get(URI_COLUMN) or "").strip()
|
|
if uri and uri not in seen:
|
|
seen.add(uri)
|
|
uris.append(uri)
|
|
return uris
|
|
|
|
|
|
def load_cache(path):
|
|
if not os.path.exists(path):
|
|
return {}
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return data if isinstance(data, dict) else {}
|
|
except (OSError, ValueError) as error:
|
|
print("Could not read cache %s (%s), starting fresh." % (path, error))
|
|
return {}
|
|
|
|
|
|
def save_cache(path, cache):
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as handle:
|
|
json.dump(cache, handle, indent=1, sort_keys=True, ensure_ascii=False)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def find_poster(body):
|
|
match = POSTER_RE.search(body)
|
|
if match:
|
|
return match.group(0)
|
|
for match in FALLBACK_RE.finditer(body):
|
|
width, height = int(match.group(1)), int(match.group(2))
|
|
if width > 0 and abs(float(height) / width - 1.5) < 0.2:
|
|
return match.group(0)
|
|
return None
|
|
|
|
|
|
def fetch_poster(uri):
|
|
request = urllib.request.Request(uri, headers={"User-Agent": USER_AGENT})
|
|
for attempt in range(2):
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
|
body = response.read().decode("utf-8", "replace")
|
|
return find_poster(body)
|
|
except (urllib.error.URLError, OSError) as error:
|
|
if attempt == 1:
|
|
with print_lock:
|
|
print(" failed %s: %s" % (uri, error))
|
|
return None
|
|
time.sleep(1.0)
|
|
return None
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("csv_path", help="Letterboxd ratings.csv export")
|
|
parser.add_argument("-o", "--output", default=None, help="output json (default: posters.json next to the csv)")
|
|
parser.add_argument("--retry-failed", action="store_true", help="also retry URIs that previously had no poster")
|
|
args = parser.parse_args()
|
|
|
|
output = args.output or os.path.join(os.path.dirname(os.path.abspath(args.csv_path)), "posters.json")
|
|
|
|
uris = read_uris(args.csv_path)
|
|
cache = load_cache(output)
|
|
if args.retry_failed:
|
|
cache = {key: value for key, value in cache.items() if value}
|
|
|
|
pending = [uri for uri in uris if uri not in cache]
|
|
print("%d films, %d already cached, %d to fetch." % (len(uris), len(uris) - len(pending), len(pending)))
|
|
if not pending:
|
|
print("Nothing to do. %s is up to date." % output)
|
|
return
|
|
|
|
done = [0]
|
|
|
|
def worker(uri):
|
|
time.sleep(REQUEST_DELAY)
|
|
poster = fetch_poster(uri)
|
|
with cache_lock:
|
|
cache[uri] = poster
|
|
done[0] += 1
|
|
count = done[0]
|
|
if count % SAVE_EVERY == 0:
|
|
save_cache(output, cache)
|
|
with print_lock:
|
|
print(" %d/%d" % (count, len(pending)), end="\r", flush=True)
|
|
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
|
list(pool.map(worker, pending))
|
|
except KeyboardInterrupt:
|
|
print("\nInterrupted, saving what we have.")
|
|
finally:
|
|
with cache_lock:
|
|
save_cache(output, cache)
|
|
|
|
found = sum(1 for uri in uris if cache.get(uri))
|
|
missing = [uri for uri in uris if not cache.get(uri)]
|
|
print("\nWrote %s: %d of %d films have a poster." % (output, found, len(uris)))
|
|
if missing:
|
|
print("%d without a poster (they show as text cards): %s" % (len(missing), ", ".join(missing[:5])))
|
|
print("Re-run with --retry-failed to try them again.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|