Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions allocine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from allocine.api import AllocineApi
from allocine.client import DEFAULT_DATE_FORMAT, Allocine
from allocine.models import (
Location,
Movie,
MovieVersion,
Schedule,
Expand Down Expand Up @@ -35,6 +36,7 @@
"Allocine",
"AllocineApi",
"DEFAULT_DATE_FORMAT",
"Location",
"Movie",
"MovieVersion",
"Schedule",
Expand Down
18 changes: 16 additions & 2 deletions allocine/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,22 @@ def get_theater_page(self, theater_id: str) -> str:
url = f"https://www.allocine.fr/seance/salle_gen_csalle={theater_id}.html"
return self._get_text(url)

def get_theaterlist_by_geocode(self, geocode: int | str, page: int = 1) -> str:
url = f"{THEATERS_BASE_URL}/ville-{geocode}/"
def get_locationlist(self, department_geocode: int | None = None, page: int = 1) -> str:
if department_geocode is None:
return self._get_text("https://www.allocine.fr/salle/")
params = {"page": page} if page > 1 else None
return self._get_text(f"{THEATERS_BASE_URL}/departement-{department_geocode}/", params=params)

def get_city_locations(self, search: str) -> dict:
return self._get(f"https://www.allocine.fr/_/localization_city/{search}")

def get_theaterlist_by_geocode(
self,
geocode: int | str,
page: int = 1,
location_type: str = "ville",
) -> str:
url = f"{THEATERS_BASE_URL}/{location_type}-{geocode}/"
params = {"page": page} if page > 1 else None
return self._get_text(url, params=params)

Expand Down
266 changes: 263 additions & 3 deletions allocine/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import base64
import json
import re
import unicodedata
from collections import Counter
from datetime import date as Date
from datetime import datetime, timedelta
from os import PathLike
Expand All @@ -10,10 +12,113 @@

from allocine.api import AllocineApi
from allocine.cache import CacheOption
from allocine.models import MovieVersion, Showtime, Theater
from allocine.models import Location, MovieVersion, Showtime, Theater

DEFAULT_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"

DEPARTMENT_NAMES = {
"01": "Ain",
"02": "Aisne",
"03": "Allier",
"04": "Alpes-de-Haute-Provence",
"05": "Hautes-Alpes",
"06": "Alpes-Maritimes",
"07": "Ardèche",
"08": "Ardennes",
"09": "Ariège",
"10": "Aube",
"11": "Aude",
"12": "Aveyron",
"13": "Bouches-du-Rhône",
"14": "Calvados",
"15": "Cantal",
"16": "Charente",
"17": "Charente-Maritime",
"18": "Cher",
"19": "Corrèze",
"2A": "Corse-du-Sud",
"2B": "Haute-Corse",
"21": "Côte-d'Or",
"22": "Côtes-d'Armor",
"23": "Creuse",
"24": "Dordogne",
"25": "Doubs",
"26": "Drôme",
"27": "Eure",
"28": "Eure-et-Loir",
"29": "Finistère",
"30": "Gard",
"31": "Haute-Garonne",
"32": "Gers",
"33": "Gironde",
"34": "Hérault",
"35": "Ille-et-Vilaine",
"36": "Indre",
"37": "Indre-et-Loire",
"38": "Isère",
"39": "Jura",
"40": "Landes",
"41": "Loir-et-Cher",
"42": "Loire",
"43": "Haute-Loire",
"44": "Loire-Atlantique",
"45": "Loiret",
"46": "Lot",
"47": "Lot-et-Garonne",
"48": "Lozère",
"49": "Maine-et-Loire",
"50": "Manche",
"51": "Marne",
"52": "Haute-Marne",
"53": "Mayenne",
"54": "Meurthe-et-Moselle",
"55": "Meuse",
"56": "Morbihan",
"57": "Moselle",
"58": "Nièvre",
"59": "Nord",
"60": "Oise",
"61": "Orne",
"62": "Pas-de-Calais",
"63": "Puy-de-Dôme",
"64": "Pyrénées-Atlantiques",
"65": "Hautes-Pyrénées",
"66": "Pyrénées-Orientales",
"67": "Bas-Rhin",
"68": "Haut-Rhin",
"69": "Rhône",
"70": "Haute-Saône",
"71": "Saône-et-Loire",
"72": "Sarthe",
"73": "Savoie",
"74": "Haute-Savoie",
"75": "Paris",
"76": "Seine-Maritime",
"77": "Seine-et-Marne",
"78": "Yvelines",
"79": "Deux-Sèvres",
"80": "Somme",
"81": "Tarn",
"82": "Tarn-et-Garonne",
"83": "Var",
"84": "Vaucluse",
"85": "Vendée",
"86": "Vienne",
"87": "Haute-Vienne",
"88": "Vosges",
"89": "Yonne",
"90": "Territoire de Belfort",
"91": "Essonne",
"92": "Hauts-de-Seine",
"93": "Seine-Saint-Denis",
"94": "Val-de-Marne",
"95": "Val-d'Oise",
"971": "Guadeloupe",
"972": "Martinique",
"973": "Guyane",
"974": "La Réunion",
}


class Allocine:
def __init__(
Expand Down Expand Up @@ -67,16 +172,81 @@ def get_showtimes(

return showtimes

def search_theaters(self, geocode: int | str) -> list[Theater]:
def get_geocodes(self, dept: str | int | None = None) -> list[Location]:
if dept is None:
selector = Selector(text=self._client.get_locationlist())
return _parse_departments(selector)

department = self._find_department(dept)
if department.dept == "75":
return [
Location(
geocode=department.geocode,
name=department.name,
location_type="city",
dept=department.dept,
zipcode="75000",
)
]
locations = []
page = 1
while True:
selector = Selector(text=self._client.get_locationlist(department.geocode, page=page))
locations.extend(_parse_cities(selector, department.dept))
if not selector.css(".button-right:not(.button-disabled)"):
return list({location.geocode: location for location in locations}.values())
page += 1

def search_theaters(
self,
geocode: int | str | None = None,
dept: str | int | None = None,
zipcode: str | int | None = None,
) -> list[Theater]:
if sum(value is not None for value in (geocode, dept, zipcode)) != 1:
raise ValueError("Exactly one of geocode, dept, or zipcode must be provided")

location_type = "ville"
if dept is not None:
department = self._find_department(dept)
geocode = department.geocode
if department.dept != "75":
location_type = "departement"
elif zipcode is not None:
normalized_zipcode = str(zipcode).zfill(5)
response = self._client.get_city_locations(normalized_zipcode)
raw_cities = response.get("values", {}).get("cities", [])
location = next(
(item["node"] for item in raw_cities if item.get("node", {}).get("zip") == normalized_zipcode),
None,
)
if location is None:
raise ValueError(f"No location found for zipcode {normalized_zipcode!r}")
geocode = location["id"]

assert geocode is not None
theaters = []
page = 1
while True:
selector = Selector(text=self._client.get_theaterlist_by_geocode(geocode=geocode, page=page))
selector = Selector(
text=self._client.get_theaterlist_by_geocode(
geocode=geocode,
page=page,
location_type=location_type,
)
)
theaters.extend(_parse_theaters(selector))
if not selector.css(".button-right:not(.button-disabled)"):
return theaters
page += 1

def _find_department(self, dept: str | int) -> Location:
department_code = str(dept).upper().zfill(2)
department = next((item for item in self.get_geocodes() if item.dept == department_code), None)
if department is None:
raise ValueError(f"Department not found: {department_code!r}")
return department

def clear_cache(self) -> int:
return self._client.clear_cache()

Expand Down Expand Up @@ -218,6 +388,96 @@ def _person_name(person):
return " ".join(part for part in (person.get("firstName"), person.get("lastName")) if part)


def _normalize_location_name(name: str) -> str:
name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", " ", name.lower()).strip()


DEPARTMENT_CODES = {_normalize_location_name(name): code for code, name in DEPARTMENT_NAMES.items()}
DEPARTMENT_CODES["reunion"] = "974"
DEPARTMENT_CODES["alpes de haute provence"] = "04"
DEPARTMENT_CODES["cotes d armor"] = "22"
DEPARTMENT_CODES["cote d or"] = "21"
DEPARTMENT_CODES["pyrenees atlantiques"] = "64"
DEPARTMENT_CODES["l eure et loir"] = "28"


def _location_path(selector: Selector) -> str | None:
if path := selector.attrib.get("href"):
return path
for class_name in (selector.attrib.get("class") or "").split():
if not class_name.startswith("ACr"):
continue
try:
return base64.b64decode(class_name.replace("ACr", "")).decode()
except (ValueError, UnicodeDecodeError):
continue
return None


def _parse_departments(selector: Selector) -> list[Location]:
departments = []
for item in selector.css(".mdl-more-item"):
path = _location_path(item)
match = re.search(r"/departement-(\d+)/", path or "")
if not match:
continue
name = " ".join((item.xpath("string()").get() or "").split())
department_code = DEPARTMENT_CODES.get(_normalize_location_name(name))
if department_code is None:
continue
departments.append(
Location(
geocode=int(match.group(1)),
name=name,
location_type="department",
dept=department_code,
zipcode=None,
)
)
if not any(department.dept == "75" for department in departments):
for item in selector.css(".mdl-more-item"):
path = _location_path(item)
name = " ".join((item.xpath("string()").get() or "").split())
match = re.search(r"/ville-(\d+)/", path or "")
if match and name == "Paris":
departments.append(
Location(
geocode=int(match.group(1)),
name=name,
location_type="department",
dept="75",
zipcode=None,
)
)
break
return departments


def _parse_cities(selector: Selector, department_code: str) -> list[Location]:
cities = []
for group in selector.css(".gd-col-left > .hred"):
item = group.css(".titlebar-link")[0] if group.css(".titlebar-link") else None
path = _location_path(item) if item is not None else None
match = re.search(r"/ville-(\d+)/", path or "")
if item is None or not match:
continue
zipcodes = []
for raw_address in group.css("address.address").xpath("string()").getall():
if zipcode_match := re.search(r"\b\d{5}\b", raw_address):
zipcodes.append(zipcode_match.group())
cities.append(
Location(
geocode=int(match.group(1)),
name=" ".join((item.xpath("string()").get() or "").split()),
location_type="city",
dept=department_code,
zipcode=Counter(zipcodes).most_common(1)[0][0] if zipcodes else None,
)
)
return cities


def _parse_theaters(selector: Selector) -> list[Theater]:
theaters = []
for card in selector.css("div.theater-card"):
Expand Down
11 changes: 10 additions & 1 deletion allocine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import date as Date
from datetime import datetime, timedelta
from datetime import time as Time
from typing import List, Optional
from typing import List, Literal, Optional

from allocine import nationalities

Expand Down Expand Up @@ -197,6 +197,15 @@ def short_day_str(date: Date) -> str:
return day_str(date)[:3]


@dataclass
class Location:
geocode: int
name: str
location_type: Literal["department", "city"]
dept: str
zipcode: str | None


@dataclass
class Theater:
theater_id: str
Expand Down
Loading
Loading