4 Commits
exo2 ... exo1

4 changed files with 178 additions and 82 deletions

134
main.py
View File

@@ -1,5 +1,5 @@
from sys import stderr
from typing import cast, Any, Dict, Optional
from typing import cast
from requests import Response, Session
from bs4 import BeautifulSoup, Tag
from json import JSONDecodeError, loads
@@ -13,37 +13,112 @@ class Scraper:
"""
def __init__(self) -> None:
"""
Initialise la session de scraping.
"""
self._url: str = "https://www.millesima.fr/"
# Très utile pour éviter de renvoyer toujours les mêmes handshake
# TCP et d'avoir toujours une connexion constante avec le server
self._session: Session = Session()
# Système de cache pour éviter de solliciter le serveur inutilement
self._latest_request: tuple[(str, Response | None)] = ("", None)
def _request(self, subdir: str) -> Response:
"""
Effectue une requête GET sur le serveur Millesima.
Args:
subdir (str): Le sous-répertoire ou chemin de l'URL (ex: "/vins").
Returns:
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
def getresponse(self, subdir: str = "") -> Response:
"""
Récupère la réponse d'une page, en utilisant le cache si possible.
Args:
subdir (str, optional): Le chemin de la page.
Returns:
Response: L'objet réponse (cache ou nouvelle requête).
Raise:
HTTPError: Si le serveur renvoie un code d'erreur (4xx, 5xx).
"""
rq_subdir, rq_response = self._latest_request
if rq_response is None or subdir != rq_subdir:
request: Response = self._request(subdir)
self._latest_request = (subdir, request)
return request
return rq_response
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).
"""
markup: str = self.getresponse(subdir).text
return BeautifulSoup(markup, features="html.parser")
def getjsondata(self, subdir: str = "", id: str = "__NEXT_DATA__") -> dict[str, object]:
def getjsondata(
self, subdir: str = "", id: str = "__NEXT_DATA__"
) -> dict[str, object]:
"""
Extrait les données JSON contenues dans la balise __NEXT_DATA__ du site.
Beaucoup de sites modernes (Next.js) stockent leur état initial dans
une balise <script> pour l'hydratation côté client.
Args:
subdir (str, optional): Le chemin de la page.
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.
"""
soup: BeautifulSoup = self.getsoup(subdir)
script: Tag | None = soup.find("script", id=id)
if isinstance(script, Tag) and script.string:
try:
current_data: object = loads(script.string)
keys: list[str] = ["props", "pageProps", "initialReduxState", "product", "content"]
# tout le chemin à parcourir pour arriver au données
# (plein d'information inutile)
keys: list[str] = [
"props",
"pageProps",
"initialReduxState",
"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 = current_data[key]
else:
@@ -55,56 +130,3 @@ class Scraper:
except (JSONDecodeError, ValueError) as e:
print(f"Erreur lors de l'extraction JSON : {e}", file=stderr)
return {}
def prix(self, subdir: str) -> float:
"""
Retourne le prix d'une bouteille (75cl).
Les données récupérées depuis le site contiennent plusieurs formats
de vente dans la liste "items" :
- bouteille seule si nbunit=1 et equivbtl=1
-> prix direct (format vendu à l'unité).
- caisse de plusieurs bouteilles si nbunit=1
-> prix direct (format vendu à l'unité).
- formats spéciaux (magnum, impériale, etc.)sinon
-> calcul du prix unitaire : offerPrice / (nbunit * equivbtl)
Chaque item possède notamment :
- offerPrice : prix total du format proposé
- nbunit : nombre d'unités dans le format
- equivbtl : équivalent en nombre de bouteilles standard (75cl)
"""
data = self.getjsondata(subdir)
items = data.get("items")
if not isinstance(items, list) or len(items) == 0:
raise ValueError("Aucun prix disponible (items vide).")
# 1) bouteille 75cl (nbunit=1 et equivbtl=1)
for item in items:
if not isinstance(item, dict):
continue
attrs = item.get("attributes", {})
nbunit = attrs.get("nbunit", {}).get("value")
equivbtl = attrs.get("equivbtl", {}).get("value")
if nbunit == "1" and equivbtl == "1":
p = item.get("offerPrice")
if isinstance(p, (int, float)):
return float(p)
# 2) calcul depuis caisse
for item in items:
if not isinstance(item, dict):
continue
p = item.get("offerPrice")
attrs = item.get("attributes", {})
nbunit = attrs.get("nbunit", {}).get("value")
equivbtl = attrs.get("equivbtl", {}).get("value")
if isinstance(p, (int, float)) and nbunit and equivbtl:
denom = float(nbunit) * float(equivbtl)
if denom > 0:
return round(float(p) / denom, 2)
raise ValueError("Impossible de trouver le prix unitaire.")

Binary file not shown.

View File

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

View File

@@ -1,35 +1,107 @@
import json
from json import dumps
from bs4 import Tag
import pytest
from requests_mock import Mocker
from main import Scraper
def test_json():
scraper = Scraper()
@pytest.fixture(autouse=True)
def mock_site():
with Mocker() as m:
m.get(
"https://www.millesima.fr/",
text="<html><body><h1>MILLESIMA</h1></body></html>",
)
data = scraper.getjsondata("/chateau-gloria-2016.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",
"longdesc": "<h2>Caractéristiques et conseils de dégustation du 5 Stelle Sfursat 2022 de Nino Negri</h2><p><strong>Dégustation</strong></p><p><em>Robe</em></p><p>La robe dévoile une couleur grenat d'intensité moyenne.</p><p><em>Nez</em></p><p>Le nez révèle des arômes singuliers de fruits mûrs accompagnés de notes d'épices douces.</p><p><em>Bouche</em></p><p>En bouche, ce vin séduit par son équilibre remarquable, sa richesse et son caractère corsé. La dégustation dévoile une concentration intense et vigoureuse, portée par un fond aristocratique de mûre bien mûre et d'épices. La finale se distingue par sa longueur et sa persistance.</p><p><strong>Accords mets et vins</strong></p><p>Ce vin de caractère accompagne parfaitement les viandes rouges braisées, le gibier en sauce ou encore les fromages affinés à pâte dure.</p><p><strong>Service et garde</strong></p><p>Le 5 Stelle Sfursat 2022 gagnera à être servi à une température comprise entre 16 et 18°C.</p><h2>Un Sforzato di Valtellina d'exception élaboré par la Maison Nino Negri</h2><p><strong>La propriété</strong></p><p>Fondée en 1897 par Nino Negri à Chiuro en Valteline, cette Maison lombarde représente aujourd'hui la plus importante cave de la région. Propriété du Gruppo Italiano Vini depuis 1986, elle cultive 38 hectares de vignobles en terrasses sur des pentes alpines aux sols granitiques et calcaires. Sous la houlette de l'œnologue Danilo Drocco, <a href=\"/producteur-nino-negri.html\">Nino Negri</a> perpétue l'excellence du nebbiolo valtelin, notamment à travers son emblématique Sforzato élaboré selon la méthode traditionnelle d'appassimento.</p><p><strong>Le vignoble</strong></p><p>Le 5 Stelle Sfursat est issu de l'appellation <a href=\"/sforzato-di-valtellina.html\">Sforzato di Valtellina</a> DOCG, territoire d'exception où le nebbiolo s'épanouit sur des terrasses alpines escarpées. Les raisins proviennent de vignobles implantés sur des pentes granitiques et calcaires, bénéficiant d'une exposition optimale permettant une maturation idéale du nebbiolo.</p><p><strong>Vinification et élevage</strong></p><p>Le 5 Stelle Sfursat 2022 est produit uniquement lors des saisons les plus favorables. Les raisins sont récoltés manuellement et disposés en couche unique dans des caisses de 4 kg. Ils sont ensuite soumis à un séchage naturel dans un grenier pendant environ trois mois avant la vinification, selon la méthode traditionnelle de l'appassimento. Ce processus permet aux baies de perdre près de 30 % de leur poids, concentrant ainsi les arômes et les sucres naturels.</p><p><strong>Cépage</strong></p><p>Ce <a href=\"/lombardie.html\">vin de Lombardie</a> est un 100 % nebbiolo</p>",
"image": "J4131_2022NM_c.png",
"seoKeyword": "nino-negri-5-stelle-sfursat-2022.html",
"title": "Nino Negri : 5 Stelle Sfursat 2022",
"metaDesc": "Nino Negri : 5 Stelle Sfursat 2022 : Vente en ligne, Grand vin d'origine garantie en provenance directe de la propriété - ✅ Qualité de stockage",
"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,
}
],
}
}
}
}
}
}
print("JSON récupéré :")
print(json.dumps(data, indent=4, ensure_ascii=False))
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)
assert isinstance(data, dict)
assert "items" in data
# on return m sans fermer le server qui simule la page
yield m
def test_prix():
scraper = Scraper()
try:
p = scraper.prix("/chateau-saint-pierre-2011.html")
print("Prix unitaire =", p)
assert isinstance(p, float)
assert p > 0
except ValueError:
# le vin n'est pas disponible à la vente
print("OK : aucun prix (vin indisponible, items vide)")
@pytest.fixture
def scraper() -> Scraper:
return Scraper()
if __name__ == "__main__":
test_json()
test_prix()
print("\nTous les tests terminés")
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 len(jsondata["items"]) > 0
assert jsondata["items"][0]["offerPrice"] == 390