allocine 0.1.0__py3-none-any.whl
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.
- allocine/__init__.py +56 -0
- allocine/api.py +132 -0
- allocine/cache.py +134 -0
- allocine/cli.py +153 -0
- allocine/client.py +241 -0
- allocine/models.py +233 -0
- allocine/nationalities.py +448 -0
- allocine/schedules.py +162 -0
- allocine-0.1.0.dist-info/METADATA +214 -0
- allocine-0.1.0.dist-info/RECORD +14 -0
- allocine-0.1.0.dist-info/WHEEL +5 -0
- allocine-0.1.0.dist-info/entry_points.txt +2 -0
- allocine-0.1.0.dist-info/licenses/LICENSE +22 -0
- allocine-0.1.0.dist-info/top_level.txt +1 -0
allocine/client.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
from datetime import date as Date
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from os import PathLike
|
|
7
|
+
|
|
8
|
+
import jmespath
|
|
9
|
+
from parsel import Selector
|
|
10
|
+
|
|
11
|
+
from allocine.api import AllocineApi
|
|
12
|
+
from allocine.cache import CacheOption
|
|
13
|
+
from allocine.models import MovieVersion, Showtime, Theater
|
|
14
|
+
|
|
15
|
+
DEFAULT_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Allocine:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
cache: CacheOption = True,
|
|
23
|
+
cache_dir: str | PathLike[str] | None = None,
|
|
24
|
+
):
|
|
25
|
+
self._client = AllocineApi(cache=cache, cache_dir=cache_dir)
|
|
26
|
+
|
|
27
|
+
def get_theater(self, theater_id: str) -> Theater:
|
|
28
|
+
resp = self._client.get_showtimelist_by_theater_id(theater_id=theater_id)
|
|
29
|
+
if not resp.get("results"):
|
|
30
|
+
raise ValueError(f"Theater not found. Is theater id {theater_id!r} correct?")
|
|
31
|
+
|
|
32
|
+
movie_id: int | None = jmespath.search("results[0].movie.internalId", resp)
|
|
33
|
+
assert movie_id is not None, "We need at least one showtime to get details about a theater"
|
|
34
|
+
|
|
35
|
+
return self._get_theater_details_from_movie_id(theater_id, movie_id)
|
|
36
|
+
|
|
37
|
+
def get_showtimes(
|
|
38
|
+
self,
|
|
39
|
+
theater_id: str,
|
|
40
|
+
from_date: Date | None = None,
|
|
41
|
+
to_date: Date | None = None,
|
|
42
|
+
) -> list[Showtime]:
|
|
43
|
+
today = Date.today()
|
|
44
|
+
from_date = max(from_date or today, today)
|
|
45
|
+
to_date = to_date or from_date
|
|
46
|
+
if from_date > to_date:
|
|
47
|
+
raise ValueError("from_date must be before or equal to to_date")
|
|
48
|
+
|
|
49
|
+
showtimes = []
|
|
50
|
+
requested_date = from_date
|
|
51
|
+
while requested_date <= to_date:
|
|
52
|
+
response = self._client.get_showtimelist_by_theater_id(
|
|
53
|
+
theater_id=theater_id,
|
|
54
|
+
date=requested_date,
|
|
55
|
+
)
|
|
56
|
+
day_showtimes = self._parse_showtimes(response.get("results") or [])
|
|
57
|
+
for page in range(2, (jmespath.search("pagination.totalPages", response) or 1) + 1):
|
|
58
|
+
response = self._client.get_showtimelist_by_theater_id(
|
|
59
|
+
theater_id=theater_id,
|
|
60
|
+
date=requested_date,
|
|
61
|
+
page=page,
|
|
62
|
+
)
|
|
63
|
+
day_showtimes.extend(self._parse_showtimes(response.get("results") or []))
|
|
64
|
+
|
|
65
|
+
showtimes.extend(showtime for showtime in day_showtimes if showtime.date == requested_date)
|
|
66
|
+
requested_date += timedelta(days=1)
|
|
67
|
+
|
|
68
|
+
return showtimes
|
|
69
|
+
|
|
70
|
+
def search_theaters(self, geocode: int | str) -> list[Theater]:
|
|
71
|
+
theaters = []
|
|
72
|
+
page = 1
|
|
73
|
+
while True:
|
|
74
|
+
selector = Selector(text=self._client.get_theaterlist_by_geocode(geocode=geocode, page=page))
|
|
75
|
+
theaters.extend(_parse_theaters(selector))
|
|
76
|
+
if not selector.css(".button-right:not(.button-disabled)"):
|
|
77
|
+
return theaters
|
|
78
|
+
page += 1
|
|
79
|
+
|
|
80
|
+
def clear_cache(self) -> int:
|
|
81
|
+
return self._client.clear_cache()
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self._client.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self):
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
def _get_theater_details_from_movie_id(self, theater_id: str, movie_id: int) -> Theater:
|
|
93
|
+
operation = self._client.get_showtimes_by_movie_and_theater_id(
|
|
94
|
+
movie_id=movie_id,
|
|
95
|
+
theater_id=theater_id,
|
|
96
|
+
)
|
|
97
|
+
raw_theater = operation["results"]["theater"]
|
|
98
|
+
if raw_theater is None:
|
|
99
|
+
return self._get_theater_details_from_page(theater_id)
|
|
100
|
+
|
|
101
|
+
location = raw_theater["location"]
|
|
102
|
+
return Theater(
|
|
103
|
+
theater_id=raw_theater["internalId"],
|
|
104
|
+
name=raw_theater["name"],
|
|
105
|
+
address=location["address"],
|
|
106
|
+
zipcode=location["zip"],
|
|
107
|
+
city=location["city"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _get_theater_details_from_page(self, theater_id: str) -> Theater:
|
|
111
|
+
selector = Selector(text=self._client.get_theater_page(theater_id))
|
|
112
|
+
for raw_data in selector.css('script[type="application/ld+json"]::text').getall():
|
|
113
|
+
theater_data = json.loads(raw_data)
|
|
114
|
+
if theater_data.get("@type") != "MovieTheater":
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
address = theater_data.get("address") or {}
|
|
118
|
+
return Theater(
|
|
119
|
+
theater_id=theater_id,
|
|
120
|
+
name=theater_data["name"],
|
|
121
|
+
address=address.get("streetAddress") or "",
|
|
122
|
+
zipcode=address.get("postalCode") or "",
|
|
123
|
+
city=address.get("addressLocality") or "",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
raise ValueError(f"Theater details not found for theater id {theater_id!r}")
|
|
127
|
+
|
|
128
|
+
def _parse_showtimes(self, raw_showtimes: list[dict]):
|
|
129
|
+
showtimes = []
|
|
130
|
+
for showtime_data in raw_showtimes:
|
|
131
|
+
raw_movie = showtime_data.get("movie")
|
|
132
|
+
if not raw_movie:
|
|
133
|
+
continue
|
|
134
|
+
duration_match = re.fullmatch(r"(?:(\d+)h)?\s*(?:(\d+)min)?", raw_movie.get("runtime") or "")
|
|
135
|
+
duration_obj = None
|
|
136
|
+
if duration_match and any(duration_match.groups()):
|
|
137
|
+
hours, minutes = duration_match.groups(default="0")
|
|
138
|
+
duration_obj = timedelta(hours=int(hours), minutes=int(minutes))
|
|
139
|
+
|
|
140
|
+
rating = jmespath.search("stats.userRating.score", raw_movie)
|
|
141
|
+
try:
|
|
142
|
+
rating = float(rating)
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
rating = None
|
|
145
|
+
|
|
146
|
+
countries = [country.get("localizedName") for country in raw_movie.get("countries") or []]
|
|
147
|
+
countries = [country for country in countries if country]
|
|
148
|
+
if year := jmespath.search("data.productionYear", raw_movie):
|
|
149
|
+
year = int(year)
|
|
150
|
+
|
|
151
|
+
directors = []
|
|
152
|
+
for credit in sorted(raw_movie.get("credits") or [], key=lambda item: item.get("rank") or 0):
|
|
153
|
+
if jmespath.search("position.name", credit) == "DIRECTOR":
|
|
154
|
+
if name := _person_name(credit.get("person")):
|
|
155
|
+
directors.append(name)
|
|
156
|
+
|
|
157
|
+
actors = []
|
|
158
|
+
cast = jmespath.search("cast.nodes", raw_movie) or []
|
|
159
|
+
for cast_member in sorted(cast, key=lambda item: item.get("rank") or 0):
|
|
160
|
+
person = (
|
|
161
|
+
cast_member.get("actor") or cast_member.get("originalVoiceActor") or cast_member.get("voiceActor")
|
|
162
|
+
)
|
|
163
|
+
if name := _person_name(person):
|
|
164
|
+
actors.append(name)
|
|
165
|
+
|
|
166
|
+
genres = ", ".join(genre["translate"] for genre in raw_movie.get("genres") or [] if genre.get("translate"))
|
|
167
|
+
for showtime_group in (showtime_data.get("showtimes") or {}).values():
|
|
168
|
+
for raw_showtime in showtime_group:
|
|
169
|
+
tags = raw_showtime.get("tags") or []
|
|
170
|
+
language = (
|
|
171
|
+
"Français"
|
|
172
|
+
if "Localization.Language.French" in tags
|
|
173
|
+
or raw_showtime.get("diffusionVersion") in {"DUBBED", "LOCAL"}
|
|
174
|
+
else "Version originale"
|
|
175
|
+
)
|
|
176
|
+
screen_formats = [
|
|
177
|
+
{"E_4DX": "4DX", "PLF": "PLF"}.get(value, value)
|
|
178
|
+
for value in raw_showtime.get("experience") or []
|
|
179
|
+
]
|
|
180
|
+
screen_formats.extend(
|
|
181
|
+
{"DIGITAL": "Numérique", "IMAX": "IMAX", "F_3D": "3D"}.get(value, value)
|
|
182
|
+
for value in raw_showtime.get("projection") or []
|
|
183
|
+
if value != "DIGITAL" or not screen_formats
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if synopsis := raw_movie.get("synopsis"):
|
|
187
|
+
synopsis = re.sub(r"<.*?>", "", synopsis)
|
|
188
|
+
synopsis = synopsis.replace("\xa0", " ") # Remove HTML tags (ex: <span>)
|
|
189
|
+
synopsis = unicodedata.normalize("NFKD", synopsis)
|
|
190
|
+
|
|
191
|
+
movie = MovieVersion(
|
|
192
|
+
movie_id=raw_movie["internalId"],
|
|
193
|
+
title=raw_movie.get("title"),
|
|
194
|
+
rating=rating,
|
|
195
|
+
language=language,
|
|
196
|
+
screen_format=" ".join(screen_formats) or "Numérique",
|
|
197
|
+
synopsis=synopsis,
|
|
198
|
+
original_title=raw_movie.get("originalTitle"),
|
|
199
|
+
year=year,
|
|
200
|
+
countries=countries,
|
|
201
|
+
genres=genres,
|
|
202
|
+
directors=", ".join(directors),
|
|
203
|
+
actors=", ".join(actors),
|
|
204
|
+
duration=duration_obj,
|
|
205
|
+
)
|
|
206
|
+
showtimes.append(
|
|
207
|
+
Showtime(
|
|
208
|
+
date_time=datetime.strptime(raw_showtime["startsAt"], DEFAULT_DATE_FORMAT),
|
|
209
|
+
movie=movie,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
return showtimes
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _person_name(person):
|
|
216
|
+
if not person:
|
|
217
|
+
return None
|
|
218
|
+
return " ".join(part for part in (person.get("firstName"), person.get("lastName")) if part)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_theaters(selector: Selector) -> list[Theater]:
|
|
222
|
+
theaters = []
|
|
223
|
+
for card in selector.css("div.theater-card"):
|
|
224
|
+
theater_json = card.css(".add-theater-anchor[data-theater]::attr(data-theater)").get()
|
|
225
|
+
if not theater_json:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
theater_data = json.loads(theater_json)
|
|
229
|
+
full_address = " ".join((card.css("address.address").xpath("string()").get() or "").split())
|
|
230
|
+
address_match = re.fullmatch(r"(.*)\s+(\d{5})\s+(.+)", full_address)
|
|
231
|
+
address, zipcode, city = address_match.groups() if address_match else (full_address, "", "")
|
|
232
|
+
theaters.append(
|
|
233
|
+
Theater(
|
|
234
|
+
theater_id=theater_data["id"],
|
|
235
|
+
name=theater_data["name"],
|
|
236
|
+
address=address,
|
|
237
|
+
zipcode=zipcode,
|
|
238
|
+
city=city,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return theaters
|
allocine/models.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import unicodedata
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date as Date
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from datetime import time as Time
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from allocine import nationalities
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Movie:
|
|
16
|
+
movie_id: int
|
|
17
|
+
title: str
|
|
18
|
+
original_title: str
|
|
19
|
+
rating: Optional[float]
|
|
20
|
+
duration: Optional[timedelta]
|
|
21
|
+
genres: str
|
|
22
|
+
countries: List[str]
|
|
23
|
+
directors: str
|
|
24
|
+
actors: str
|
|
25
|
+
synopsis: str
|
|
26
|
+
year: int
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def duration_str(self):
|
|
30
|
+
if self.duration is not None:
|
|
31
|
+
return _strfdelta(self.duration, "{hours:02d}h{minutes:02d}")
|
|
32
|
+
else:
|
|
33
|
+
return "HH:MM"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def duration_short_str(self) -> str:
|
|
37
|
+
if self.duration is not None:
|
|
38
|
+
return _strfdelta(self.duration, "{hours:d}h{minutes:02d}")
|
|
39
|
+
else:
|
|
40
|
+
return "NA"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def rating_str(self):
|
|
44
|
+
return "{0:.1f}".format(self.rating) if self.rating else ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def nationalities(self):
|
|
48
|
+
"""Return the nationality tuples, from the movie countries.
|
|
49
|
+
Example: if self.countries = ['France'] => [('français', 'française')]
|
|
50
|
+
"""
|
|
51
|
+
if self.countries:
|
|
52
|
+
nationality_tuples = []
|
|
53
|
+
for country_name in self.countries:
|
|
54
|
+
normalized_country_name = _strip_accents(country_name).lower()
|
|
55
|
+
country_code = nationalities.countries.get(normalized_country_name)
|
|
56
|
+
|
|
57
|
+
if country_code is not None:
|
|
58
|
+
nationality_tuples.append(nationalities.nationalities[country_code])
|
|
59
|
+
else:
|
|
60
|
+
logger.warning(f"Country {country_name!r} not found in nationalities")
|
|
61
|
+
nationality_tuples.append((f"de {country_name}", f"de {country_name}"))
|
|
62
|
+
return nationality_tuples
|
|
63
|
+
else:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def __str__(self):
|
|
67
|
+
return f"{self.title} [{self.movie_id}] ({self.duration_str})"
|
|
68
|
+
|
|
69
|
+
def __eq__(self, other):
|
|
70
|
+
return (self.movie_id) == (other.movie_id)
|
|
71
|
+
|
|
72
|
+
def __hash__(self):
|
|
73
|
+
"""This function allows us
|
|
74
|
+
to do a set(list_of_Movie_objects)"""
|
|
75
|
+
return hash(self.movie_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class MovieVersion(Movie):
|
|
80
|
+
language: str
|
|
81
|
+
screen_format: str
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def version(self):
|
|
85
|
+
version = "VF" if self.language == "Français" else "VOST"
|
|
86
|
+
if self.screen_format != "Numérique":
|
|
87
|
+
version += f" {self.screen_format}"
|
|
88
|
+
return version
|
|
89
|
+
|
|
90
|
+
def get_movie(self):
|
|
91
|
+
return Movie(
|
|
92
|
+
movie_id=self.movie_id,
|
|
93
|
+
title=self.title,
|
|
94
|
+
rating=self.rating,
|
|
95
|
+
duration=self.duration,
|
|
96
|
+
original_title=self.original_title,
|
|
97
|
+
year=self.year,
|
|
98
|
+
genres=self.genres,
|
|
99
|
+
countries=self.countries,
|
|
100
|
+
directors=self.directors,
|
|
101
|
+
actors=self.actors,
|
|
102
|
+
synopsis=self.synopsis,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def __str__(self):
|
|
106
|
+
movie_str = super().__str__()
|
|
107
|
+
return f"{movie_str} ({self.version})"
|
|
108
|
+
|
|
109
|
+
def __eq__(self, other):
|
|
110
|
+
return (self.movie_id, self.version) == (other.movie_id, other.version)
|
|
111
|
+
|
|
112
|
+
def __hash__(self):
|
|
113
|
+
"""This function allows us
|
|
114
|
+
to do a set(list_of_MovieVersion_objects)"""
|
|
115
|
+
return hash((self.movie_id, self.version))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Schedule:
|
|
120
|
+
date_time: datetime
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def date(self) -> Date:
|
|
124
|
+
return self.date_time.date()
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def hour(self) -> Time:
|
|
128
|
+
return self.date_time.time()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def hour_str(self) -> str:
|
|
132
|
+
return self.date_time.strftime("%H:%M")
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def hour_short_str(self) -> str:
|
|
136
|
+
return get_hour_short_str(self.hour)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def date_str(self) -> str:
|
|
140
|
+
return self.date_time.strftime("%d/%m/%Y %H:%M")
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def day_str(self) -> str:
|
|
144
|
+
return day_str(self.date)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def short_day_str(self) -> str:
|
|
148
|
+
return short_day_str(self.date)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_hour_short_str(hour: Time) -> str:
|
|
152
|
+
# Ex: 9h, 11h, 23h30
|
|
153
|
+
# Minus in '%-H' removes the leading 0
|
|
154
|
+
return hour.strftime("%-Hh%M").replace("h00", "h")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Showtime(Schedule):
|
|
159
|
+
movie: MovieVersion
|
|
160
|
+
|
|
161
|
+
def __str__(self):
|
|
162
|
+
return f"{self.date_str} : {self.movie}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def day_str(date: Date) -> str:
|
|
166
|
+
return to_french_weekday(date.weekday())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def to_french_weekday(weekday: int) -> str:
|
|
170
|
+
days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
|
171
|
+
return days[weekday]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_french_month(month_number: int) -> str:
|
|
175
|
+
months = [
|
|
176
|
+
"Janvier",
|
|
177
|
+
"Février",
|
|
178
|
+
"Mars",
|
|
179
|
+
"Avril",
|
|
180
|
+
"Mai",
|
|
181
|
+
"Juin",
|
|
182
|
+
"Juillet",
|
|
183
|
+
"Août",
|
|
184
|
+
"Septembre",
|
|
185
|
+
"Octobre",
|
|
186
|
+
"Novembre",
|
|
187
|
+
"Décembre",
|
|
188
|
+
]
|
|
189
|
+
return months[month_number - 1]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def to_french_short_weekday(weekday: int) -> str:
|
|
193
|
+
return to_french_weekday(weekday)[:3]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def short_day_str(date: Date) -> str:
|
|
197
|
+
return day_str(date)[:3]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class Theater:
|
|
202
|
+
theater_id: str
|
|
203
|
+
name: str
|
|
204
|
+
address: str
|
|
205
|
+
zipcode: str
|
|
206
|
+
city: str
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def address_str(self):
|
|
210
|
+
address_str = f"{self.address}, " if self.address else ""
|
|
211
|
+
address_str += f"{self.zipcode} {self.city}"
|
|
212
|
+
return address_str
|
|
213
|
+
|
|
214
|
+
def __eq__(self, other):
|
|
215
|
+
return (self.theater_id) == (other.theater_id)
|
|
216
|
+
|
|
217
|
+
def __hash__(self):
|
|
218
|
+
"""This function allows us to do a set(list_of_Theaters_objects)"""
|
|
219
|
+
return hash(self.theater_id)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _strfdelta(tdelta, fmt):
|
|
223
|
+
"""Format a timedelta object"""
|
|
224
|
+
# Thanks to https://stackoverflow.com/questions/8906926
|
|
225
|
+
d = {"days": tdelta.days}
|
|
226
|
+
d["hours"], rem = divmod(tdelta.seconds, 3600)
|
|
227
|
+
d["minutes"], d["seconds"] = divmod(rem, 60)
|
|
228
|
+
return fmt.format(**d)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _strip_accents(s):
|
|
232
|
+
# https://stackoverflow.com/a/518232/8748757
|
|
233
|
+
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
|