9 Commits
exo2 ... exo3

Author SHA1 Message Date
6992a0ca16 fix: format lint 2026-02-07 22:48:26 +01:00
74482af7f0 ajout: nouvelle classe pour données et testes 2026-02-07 20:36:09 +01:00
76017e3ea3 ajout(test_main): question 1 assertion si bien une liste 2026-02-07 19:02:19 +01:00
Loïc GUEZO
683582a253 Merge pull request #4 from guezoloic/exo1
Exo1
2026-02-06 21:26:10 +01:00
a81d5be5a9 ajout: commentaires et les requirements 2026-02-06 21:21:52 +01:00
2327974b6b modif(test_main): ajout fonction tests et exemples locaux 2026-02-06 21:10:46 +01:00
f02a23f032 modif(main.py): restructuration de la classe 2026-02-06 18:55:37 +01:00
DAHMANI chahrazad
e2317de748 Merge pull request #2 from guezoloic/exo1
correction: certains bugs sur get_json_data
2026-02-05 18:31:32 +01:00
76475d4a2a correction: certains bugs sur get_json_data 2026-02-05 18:21:50 +01:00
3 changed files with 266 additions and 67 deletions

202
main.py
View File

@@ -1,88 +1,166 @@
import requests from sys import stderr
from typing import Any, Dict from typing import cast
from bs4 import BeautifulSoup from requests import Response, Session
import json from bs4 import BeautifulSoup, Tag
from json import JSONDecodeError, loads
class ScraperData:
def __init__(self, data: dict[str, object]) -> None:
if not data:
raise ValueError("Données insuffisantes pour créer un ScraperData.")
self._data: dict[str, object] = data
def _getattributes(self) -> dict[str, object] | None:
current_data: object = self._data.get("attributes")
if isinstance(current_data, dict):
return cast(dict[str, object], current_data)
return None
def appellation(self) -> str | None:
current_value: dict[str, object] | None = self._getattributes()
if current_value is not None:
app_dict: dict[str, object] = cast(
dict[str, object], current_value.get("appellation")
)
if app_dict:
return cast(str, app_dict.get("value"))
return None
def getdata(self) -> dict[str, object]:
return self._data
class Scraper: class Scraper:
""" """
Scraper est une classe qui permet de gerer Scraper est une classe qui permet de gerer
de façon dynamique des requetes uniquement de façon dynamique des requetes uniquement
sur le serveur https de Millesina sur le serveur https de Millesima
""" """
def __init__(self): def __init__(self) -> None:
""" """
Initialise la session de scraping et récupère la page d'accueil. Initialise la session de scraping.
""" """
self._url: str = "https://www.millesima.fr/"
# Très utile pour éviter de renvoyer toujours les mêmes handshake # Très utile pour éviter de renvoyer toujours les mêmes handshake
# TCP et d'avoir toujours une connexion constante avec le server # TCP et d'avoir toujours une connexion constante avec le server
self._session: requests.Session = requests.Session() self._session: Session = Session()
self._url: str = "https://www.millesima.fr/" # Système de cache pour éviter de solliciter le serveur inutilement
self._soup = self.getsoup() self._latest_request: tuple[(str, Response | None)] = ("", None)
self._latest_soup: tuple[(str, BeautifulSoup | None)] = ("", None)
def _request( def _request(self, subdir: str) -> Response:
self, subdir: str, use_cache: bool = True
) -> requests.Response | requests.HTTPError:
""" """
Effectue une requête GET sur le serveur Millesima. Effectue une requête GET sur le serveur Millesima.
:param subdir: Le sous-répertoire ou chemin de l'URL (ex: "/vins").
:param use_cache: Si True, retourne la réponse précédente si l'URL est Args:
identique. subdir (str): Le sous-répertoire ou chemin de l'URL (ex: "/vins").
:return: requests.Response: L'objet réponse de la requête.
:rtype: requests.HTTPError: Si le serveur renvoie un code d'erreur Returns:
(4xx, 5xx). Response: L'objet réponse de la requête.
Raise:
HTTPError: Si le serveur renvoie un code d'erreur (4xx, 5xx).
""" """
target_url: str = self._url + subdir.lstrip("/")
response: Response = self._session.get(url=target_url, timeout=10)
response.raise_for_status()
return response
target_url: str = f"{self._url}{subdir.lstrip('/')}" def getresponse(self, subdir: str = "") -> Response:
# Éviter un max possible de faire des requetes au servers même
# en ayant un tunnel tcp avec le paramètre `use_cache` que si
# activer, va comparer l'url avec l'url précédant
if use_cache and hasattr(self, "_response") \
and self._response is not None:
if self._response.url == target_url:
return self._response
self._response: requests.Response = self._session.get(
target_url, timeout=10)
self._response.raise_for_status()
return self._response
def getsoup(self, subdir: str = "/") -> BeautifulSoup:
""" """
Récupère le contenu HTML d'une page et le transforme en objet Récupère la réponse d'une page, en utilisant le cache si possible.
BeautifulSoup.
:param subdir: Le chemin de la page. Si None, retourne la soupe Args:
actuelle. subdir (str, optional): Le chemin de la page.
:return: BeautifulSoup: L'objet parsé pour extraction de données.
:rtype: BeautifulSoup
"""
if subdir is not None:
self._request(subdir)
self._soup = BeautifulSoup(self._response.text, "html.parser")
return self._soup
def get_json_data(self) -> Dict[str, Any]: Returns:
Response: L'objet réponse (cache ou nouvelle requête).
Raise:
HTTPError: Si le serveur renvoie un code d'erreur (4xx, 5xx).
""" """
Extrait les données JSON contenues dans la balise __NEXT_DATA__ du rq_subdir, rq_response = self._latest_request
site.
if rq_response is not None and subdir == rq_subdir:
return rq_response
request: Response = self._request(subdir)
self._latest_request = (subdir, request)
return request
def getsoup(self, subdir: str = "") -> BeautifulSoup:
"""
Récupère le contenu HTML d'une page et le transforme en objet BeautifulSoup.
Args:
subdir (str, optional): Le chemin de la page.
Returns:
BeautifulSoup: L'objet parsé pour extraction de données.
Raise:
HTTPError: Si le serveur renvoie un code d'erreur (4xx, 5xx).
"""
rq_subdir, rq_soup = self._latest_soup
if rq_soup is not None and subdir == rq_subdir:
return rq_soup
soup: BeautifulSoup = BeautifulSoup(
markup=self.getresponse(subdir).text, features="html.parser"
)
self._latest_soup = (subdir, soup)
return soup
def getjsondata(self, subdir: str = "", id: str = "__NEXT_DATA__") -> ScraperData:
"""
Extrait les données JSON contenues dans la balise __NEXT_DATA__ du site.
Beaucoup de sites modernes (Next.js) stockent leur état initial dans Beaucoup de sites modernes (Next.js) stockent leur état initial dans
une balise <script> pour l'hydratation côté client. une balise <script> pour l'hydratation côté client.
:return Dict[str, Any]: Un dictionnaire contenant les props de la page, Args:
ou un dictionnaire vide en cas d'erreur ou subdir (str, optional): Le chemin de la page.
d'absence. id (str, optional): L'identifiant de la balise script (par défaut __NEXT_DATA__).
Raises:
HTTPError: Soulevée par `getresponse` si le serveur renvoie un code d'erreur (4xx, 5xx).
JSONDecodeError: Soulevée par `loads` si le contenu de la balise n'est pas un JSON valide.
ValueError: Soulevée manuellement si l'une des clés attendues (props, pageProps, etc.)
est absente de la structure JSON.
Returns:
dict[str, object]: Un dictionnaire contenant les données utiles
ou un dictionnaire vide en cas d'erreur.
""" """
script = self._soup.find("script", id="__NEXT_DATA__") soup: BeautifulSoup = self.getsoup(subdir)
if script and script.string: script: Tag | None = soup.find("script", id=id)
if isinstance(script, Tag) and script.string:
try: try:
data: dict[str, Any] = json.loads(script.string) current_data: object = loads(script.string)
for element in ['props', 'pageProps', 'initialReduxState', # tout le chemin à parcourir pour arriver au données
'product', 'content']: # (plein d'information inutile)
data.get(element) keys: list[str] = [
return data "props",
except json.decoder.JSONDecodeError: "pageProps",
pass "initialReduxState",
return {} "product",
"content",
]
for key in keys:
# si current_data est bien un dictionnaire et que la clé
# est bien dedans
if isinstance(current_data, dict) and key in current_data:
current_data: object = current_data[key]
else:
raise ValueError(f"Clé manquante dans le JSON : {key}")
if isinstance(current_data, dict):
return ScraperData(data=cast(dict[str, object], current_data))
except (JSONDecodeError, ValueError) as e:
print(f"Erreur lors de l'extraction JSON : {e}", file=stderr)
return ScraperData({})

View File

@@ -1,2 +1,4 @@
requests>=2.32.5 requests>=2.32.5
beautifulsoup4>=4.14.3 requests-mock>=1.12.1
beautifulsoup4>=4.14.3

View File

@@ -1,6 +1,125 @@
from main import * from json import dumps
from bs4 import Tag
import pytest
from requests_mock import Mocker
from main import Scraper, ScraperData
scraper = Scraper()
def test_soup(): @pytest.fixture(autouse=True)
assert scraper.getsoup().find('h1').text[3:12] == "MILLESIMA" def mock_site():
with Mocker() as m:
m.get(
"https://www.millesima.fr/",
text="<html><body><h1>MILLESIMA</h1></body></html>",
)
json_data = {
"props": {
"pageProps": {
"initialReduxState": {
"product": {
"content": {
"_id": "J4131/22-11652",
"partnumber": "J4131/22",
"productName": "Nino Negri : 5 Stelle Sfursat 2022",
"productNameForSearch": "Nino Negri : 5 Stelle Sfursat 2022",
"storeId": "11652",
"seoKeyword": "nino-negri-5-stelle-sfursat-2022.html",
"title": "Nino Negri : 5 Stelle Sfursat 2022",
"items": [
{
"_id": "J4131/22/C/CC/6-11652",
"partnumber": "J4131/22/C/CC/6",
"taxRate": "H",
"listPrice": 390,
"offerPrice": 390,
"seoKeyword": "nino-negri-5-stelle-sfursat-2022-c-cc-6.html",
"shortdesc": "Un carton de 6 Bouteilles (75cl)",
"attributes": {
"promotion_o_n": {
"valueId": "0",
"name": "En promotion",
"value": "Non",
"sequence": 80,
"displayable": "false",
"type": "CHECKBOX",
"isSpirit": False,
},
"in_stock": {
"valueId": "L",
"name": "En stock",
"value": "Livrable",
"sequence": 65,
"displayable": "true",
"type": "CHECKBOX",
"isSpirit": False,
},
},
"stock": 12,
"availability": "2026-02-05",
"isCustomizable": False,
"gtin_cond": "",
"gtin_unit": "",
"stockOrigin": "EUR",
"isPrevSale": False,
}
],
"attributes": {
"appellation": {
"valueId": "433",
"name": "Appellation",
"value": "Sforzato di Valtellina",
"url": "sforzato-di-valtellina.html",
"isSpirit": False,
"groupIdentifier": "appellation_433",
},
},
}
}
}
}
}
}
html_product = f"""
<html>
<script id="__NEXT_DATA__" type="application/json">
{dumps(json_data)}
</script>
</body>
</html>
"""
m.get(
"https://www.millesima.fr/nino-negri-5-stelle-sfursat-2022.html",
text=html_product,
)
# on return m sans fermer le server qui simule la page
yield m
@pytest.fixture
def scraper() -> Scraper:
return Scraper()
def test_soup(scraper: Scraper):
h1: Tag | None = scraper.getsoup().find("h1")
assert isinstance(h1, Tag)
assert h1.text == "MILLESIMA"
# def test_getProductName(scraper: Scraper):
# jsondata = scraper.getjsondata("nino-negri-5-stelle-sfursat-2022.html")
# assert jsondata["productName"] == "Nino Negri : 5 Stelle Sfursat 2022"
# assert isinstance(jsondata["items"], list)
# assert len(jsondata["items"]) > 0
# assert jsondata["items"][0]["offerPrice"] == 390
def test_appellation(scraper: Scraper):
appellation: ScraperData = scraper.getjsondata(
"nino-negri-5-stelle-sfursat-2022.html"
)
assert appellation.appellation() == "Sforzato di Valtellina"