aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlban Gruin2018-08-27 17:43:16 +0200
committerAlban Gruin2018-09-03 19:20:54 +0200
commitd02046f9255a07c4eb2bda9eb73d229cdb4f4a53 (patch)
treeab1b58efe7f519decaa3d0ce2d9b616b2a35ddc6
parent77b6ade4a7d465ca0fbc6b82950f3b54689d60e3 (diff)
parsers: parseur orienté objet avec une classe abstraite
Signed-off-by: Alban Gruin <alban at pa1ch dot fr>
-rw-r--r--management/commands/timetables.py35
-rw-r--r--management/parsers/abstractparser.py52
-rw-r--r--management/parsers/ups2017.py259
3 files changed, 205 insertions, 141 deletions
diff --git a/management/commands/timetables.py b/management/commands/timetables.py
index c148fed..c5159b7 100644
--- a/management/commands/timetables.py
+++ b/management/commands/timetables.py
@@ -13,9 +13,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+from importlib import import_module
+
import datetime
import traceback
+from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Min
@@ -23,8 +26,7 @@ from django.db.models import Min
from ...models import Course, Source
from ...utils import get_week, tz_now
-from ..parsers.ups2017 import get_events, get_update_date, get_weeks, \
- get_source
+DEFAULT_PARSER = "edt.management.parsers.ups2017"
def delete_courses_in_week(source, year, week, today):
@@ -34,8 +36,7 @@ def delete_courses_in_week(source, year, week, today):
@transaction.atomic
-def process_timetable_week(source, soup, weeks_in_soup, force,
- year=None, week=None):
+def process_timetable_week(source, force, parser, year=None, week=None):
if year is not None and week is not None:
begin, end = get_week(year, week)
@@ -69,7 +70,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force,
Min("last_update"))["last_update__min"]
# Date de mise à jour de Celcat, utilisée à des fins de statistiques
- new_update_date = get_update_date(soup)
+ new_update_date = parser.get_update_date()
# On ne fait pas la mise à jour si jamais la dernière date de MàJ
# est plus récente que celle indiquée par Celcat. Attention, le
@@ -88,7 +89,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force,
# Sinon, on efface tous les cours à partir de maintenant.
# Précisément, on prend la plus grande valeur entre la première semaine
# présente dans Celcat et maintenant.
- delete_from = min(weeks_in_soup.values())
+ delete_from = min(parser.weeks.values())
if not force:
# Si jamais on force la MàJ, on efface tout à partir de la
# première semaine
@@ -97,7 +98,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force,
# Tous les cours commençant sur la période traitée
# sont parsés, puis enregistrés dans la base de données.
- for course in get_events(source, soup, weeks_in_soup, today, year, week):
+ for course in parser.get_events(today, year, week):
course.save()
# On renseigne la date de mise à jour de Celcat, à des fins de statistiques
@@ -105,16 +106,16 @@ def process_timetable_week(source, soup, weeks_in_soup, force,
source.save()
-def process_timetable(source, force, year=None, weeks=None):
- soup = get_source(source.url)
- weeks_in_soup = get_weeks(soup)
+def process_timetable(source, force, parser_cls, year=None, weeks=None):
+ parser = parser_cls(source)
+ parser.get_source()
+ parser.get_weeks()
if year is not None and weeks is not None:
for week in weeks:
- process_timetable_week(source, soup, weeks_in_soup, force,
- year, week)
+ process_timetable_week(source, force, parser, year, week)
else:
- process_timetable_week(source, soup, weeks_in_soup, force)
+ process_timetable_week(source, force, parser)
class Command(BaseCommand):
@@ -129,9 +130,14 @@ class Command(BaseCommand):
nargs="+")
parser.add_argument("--year", type=int, nargs=1)
+ def __get_parser(self):
+ parser_module = getattr(settings, "CS_PARSER", DEFAULT_PARSER)
+ return getattr(import_module(parser_module), "Parser")
+
def handle(self, *args, **options):
year = None
errcount = 0
+ parser = self.__get_parser()
if options["all"]:
weeks = None
@@ -155,7 +161,8 @@ class Command(BaseCommand):
source.formatted_timetables))
try:
- process_timetable(source, options["force"], year, weeks)
+ process_timetable(source, options["force"], parser,
+ year, weeks)
except KeyboardInterrupt:
break
except Exception:
diff --git a/management/parsers/abstractparser.py b/management/parsers/abstractparser.py
new file mode 100644
index 0000000..8d55b6d
--- /dev/null
+++ b/management/parsers/abstractparser.py
@@ -0,0 +1,52 @@
+# Copyright (C) 2018 Alban Gruin
+#
+# celcatsanitizer is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# celcatsanitizer is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+
+import abc
+import requests
+
+import edt
+
+
+class AbstractParser(metaclass=abc.ABCMeta):
+ def __init__(self, source):
+ self.source = source
+ self.user_agent = "celcatsanitizer/" + edt.VERSION
+
+ def _make_request(self, url, user_agent=None, encoding="utf8", **kwargs):
+ user_agent = user_agent if user_agent is not None else self.user_agent
+
+ params = kwargs["params"] if "params" in kwargs else {}
+ headers = kwargs["headers"] if "headers" in kwargs else {}
+ headers["User-Agent"] = user_agent
+
+ req = requests.get(url, headers=headers, params=params)
+ req.encoding = encoding
+
+ return req
+
+ @abc.abstractmethod
+ def get_events(self):
+ pass
+
+ @abc.abstractmethod
+ def get_update_date(self):
+ pass
+
+ @abc.abstractmethod
+ def get_weeks(self):
+ pass
+
+ def get_source(self):
+ return self._make_request(self.source.url)
diff --git a/management/parsers/ups2017.py b/management/parsers/ups2017.py
index 8522793..99ce34d 100644
--- a/management/parsers/ups2017.py
+++ b/management/parsers/ups2017.py
@@ -20,9 +20,7 @@ from bs4 import BeautifulSoup
from django.utils import timezone
from ...models import Course, Group, Room
-
-import requests
-import edt
+from .abstractparser import AbstractParser
def add_time(date, time):
@@ -31,127 +29,134 @@ def add_time(date, time):
return date + delta
-def get_event(source, event, event_week, today):
- """Renvoie une classe Course à partir d’un événement récupéré par BS4"""
- # On récupère la date de l’évènement à partir de la semaine
- # et de la semaine référencée, puis l’heure de début et de fin
- date = event_week + datetime.timedelta(int(event.day.text))
- begin = add_time(date, event.starttime.text)
- end = add_time(date, event.endtime.text)
-
- # On ne traite pas le cours si il commence après le moment du traitement
- if today is not None and begin < today:
- return
-
- # Création de l’objet cours
- course = Course.objects.create(source=source, begin=begin, end=end)
-
- # On récupère les groupes concernés par les cours
- groups = [Group.objects.get_or_create(source=source,
- celcat_name=item.text)[0]
- for item in event.resources.group.find_all("item")]
- course.groups.add(*groups)
-
- # On récupère le champ « remarque »
- if event.notes is not None:
- course.notes = "\n".join(event.notes.find_all(text=True))
-
- # On récupère le champ « nom »
- if event.resources.module is not None:
- course.name = event.resources.module.item.text
- elif event.category is not None:
- # Il est possible qu’un cours n’ait pas de nom. Oui oui.
- # Qui sont les concepteurs de ce système ? Quels sont leurs
- # réseaux ?
- # Bref, dans ce cas, si le cours a un type, il devient son nom.
- course.type = event.category.text
- # Si il n’a pas de type (mais je ne pense pas que ça soit possible…),
- # il obtiendra une valeur par défaut définie à l’avance.
-
- # Récupération du type de cours
- if event.category is not None:
- course.type = event.category.text
-
- # Si un cours a une salle attribuée (oui, il est possible qu’il n’y
- # en ait pas… qui sont ils, leurs réseaux, tout ça…), on les insère
- # dans la base de données, et on les ajoute dans l’objet cours
- if event.resources.room is not None:
- rooms = [Room.objects.get_or_create(name=item.text)[0]
- for item in event.resources.room.find_all("item")]
- course.rooms.add(*rooms)
-
- return course
-
-
-def get_events(source, soup, weeks_in_soup, today, year=None, week=None):
- """Récupère tous les cours disponibles dans l’emploi du temps Celcat.
- Le traîtement se limitera à la semaine indiquée si il y en a une."""
- for event in soup.find_all("event"):
- event_week = weeks_in_soup[event.rawweeks.text]
- event_week_num = event_week.isocalendar()[1] # Numéro de semaine
-
- # On passe le traitement si la semaine de l’événement ne correspond pas
- # à la semaine passée, ou qu’il ne contient pas de groupe ou n’a pas de
- # date de début ou de fin.
- if (event_week_num == week and event_week.year == year or
- year is None or week is None) and \
- event.resources.group is not None and \
- event.starttime is not None and event.endtime is not None:
- course = get_event(source, event, event_week, today)
-
- # On renvoie le cours si il n’est pas nul
- if course is not None:
- yield course
-
-
-def get_update_date(soup):
- # Explication de la regex
- #
- # (\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)
- # (\d+) au moins un nombre
- # / un slash
- # (\d+) au moins un nombre
- # / un slash
- # (\d+) au moins un nombre
- # \s+ au moins un espace
- # (\d+) au moins un nombre
- # : un deux-points
- # (\d+) au moins un nombre
- # : un deux-points
- # (\d+) au moins un nombre
- datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)")
- search = datetime_regex.search(soup.footer.text)
- if search is None:
- return None
-
- day, month, year, hour, minute, second = [int(v) for v in search.groups()]
- date = datetime.datetime(year, month, day, hour, minute, second)
- return timezone.make_aware(date)
-
-
-def get_weeks(soup):
- # Les semaines sont référencées de manière assez… exotique
- # En gros, il y a une liste d’éléments span qui contiennent une sorte d’ID
- # de la semaine, formaté de la manière suivante :
- # NNNNNNNNNNNNNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN
- # Tous sont de la même longueur, contiennent 51 N et un seul Y.
- # Allez savoir pourquoi. Il se trouve dans la balise « alleventweeks ».
- # Un paramètre du span (« date ») représente la date de début.
- # Un cours contient donc un ID de semaine, puis le nombre de jours après le
- # début de cette semaine.
- weeks = {}
- for span in soup.find_all("span"): # Liste de toutes les semaines définies
- # On parse la date et on la fait correspondre à l’ID
- weeks[span.alleventweeks.text] = timezone.make_aware(
- datetime.datetime.strptime(span["date"], "%d/%m/%Y"))
-
- return weeks
-
-
-def get_source(url):
- user_agent = "celcatsanitizer/" + edt.VERSION
- req = requests.get(url, headers={"User-Agent": user_agent})
- req.encoding = "utf8"
-
- soup = BeautifulSoup(req.content, "html.parser")
- return soup
+class Parser(AbstractParser):
+ def __get_event(self, event, event_week, today):
+ """Renvoie une classe Course à partir d’un événement lu par BS4"""
+ # On récupère la date de l’évènement à partir de la semaine
+ # et de la semaine référencée, puis l’heure de début et de fin
+ date = event_week + datetime.timedelta(int(event.day.text))
+ begin = add_time(date, event.starttime.text)
+ end = add_time(date, event.endtime.text)
+
+ # On ne traite pas le cours si il commence après le moment du
+ # traitement
+ if today is not None and begin < today:
+ return
+
+ # Création de l’objet cours
+ course = Course.objects.create(source=self.source, begin=begin,
+ end=end)
+
+ # On récupère les groupes concernés par les cours
+ groups = [
+ Group.objects.get_or_create(
+ source=self.source, celcat_name=item.text
+ )[0]
+ for item in event.resources.group.find_all("item")
+ ]
+ course.groups.add(*groups)
+
+ # On récupère le champ « remarque »
+ if event.notes is not None:
+ course.notes = "\n".join(event.notes.find_all(text=True))
+
+ # On récupère le champ « nom »
+ if event.resources.module is not None:
+ course.name = event.resources.module.item.text
+ elif event.category is not None:
+ # Il est possible qu’un cours n’ait pas de nom. Dans ce
+ # cas, si le cours a un type, il devient son nom.
+ course.type = event.category.text
+ # Si il n’a pas de type, il obtiendra une valeur par
+ # défaut définie à l’avance.
+
+ # Récupération du type de cours
+ if event.category is not None:
+ course.type = event.category.text
+
+ # Si un cours a une salle attribuée, on les insère dans la
+ # base de données, et on les ajoute dans l’objet cours
+ if event.resources.room is not None:
+ rooms = [
+ Room.objects.get_or_create(name=item.text)[0]
+ for item in event.resources.room.find_all("item")
+ ]
+ course.rooms.add(*rooms)
+
+ return course
+
+ def get_events(self, today, year=None, week=None):
+ """Récupère tous les cours disponibles dans l’emploi du temps Celcat.
+ Le traîtement se limitera à la semaine indiquée si il y en a une."""
+ for event in self.soup.find_all("event"):
+ event_week = self.weeks[event.rawweeks.text]
+ event_week_num = event_week.isocalendar()[1] # Numéro de semaine
+
+ # On passe le traitement si la semaine de l’événement ne
+ # correspond pas à la semaine passée, ou qu’il ne contient
+ # pas de groupe ou n’a pas de date de début ou de fin.
+ if (
+ (
+ event_week_num == week
+ and event_week.year == year
+ or year is None
+ or week is None
+ )
+ and event.resources.group is not None
+ and event.starttime is not None
+ and event.endtime is not None
+ ):
+ course = self.__get_event(event, event_week, today)
+
+ # On renvoie le cours si il n’est pas nul
+ if course is not None:
+ yield course
+
+ def get_update_date(self):
+ # Explication de la regex
+ #
+ # (\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)
+ # (\d+) au moins un nombre
+ # / un slash
+ # (\d+) au moins un nombre
+ # / un slash
+ # (\d+) au moins un nombre
+ # \s+ au moins un espace
+ # (\d+) au moins un nombre
+ # : un deux-points
+ # (\d+) au moins un nombre
+ # : un deux-points
+ # (\d+) au moins un nombre
+ datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)")
+ search = datetime_regex.search(self.soup.footer.text)
+ if search is None:
+ return None
+
+ day, month, year, hour, minute, second = [
+ int(v) for v in search.groups()
+ ]
+ date = datetime.datetime(year, month, day, hour, minute, second)
+ return timezone.make_aware(date)
+
+ def get_weeks(self):
+ # Les semaines présentes dans l’emploi du temps sont toutes
+ # stockées dans un élément span. Il contient une chaîne de
+ # caractère qui correspond à une forme d’ID, et un champ date,
+ # qui correspond au lundi de cette semaine. Un cours contient
+ # un ID correspondant à une semaine, puis le nombre de jours
+ # après le début de cette semaine.
+ self.weeks = {}
+
+ # Liste de toutes les semaines définies
+ for span in self.soup.find_all("span"):
+ # On parse la date et on la fait correspondre à l’ID
+ self.weeks[span.alleventweeks.text] = timezone.make_aware(
+ datetime.datetime.strptime(span["date"], "%d/%m/%Y")
+ )
+
+ return self.weeks
+
+ def get_source(self):
+ req = super(Parser, self).get_source()
+ self.soup = BeautifulSoup(req.content, "html.parser")
+ return self.soup