Source code for cata_log_hub.providers.penny

# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Cata-Log - the central hub for digital flyers
# Copyright (C) 2026 David Aderbauer & The Cata-Log Contributors
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.

import tempfile
from calendar import Day
from datetime import datetime, time, timedelta
from io import BytesIO
from typing import override

import pypdfium2
from pydantic import Field

from cata_log_hub.exceptions import PagesExhausted
from cata_log_hub.utils.page_numbers import PageNumber

from .base import Preview, Provider
from .regions import Germany


[docs] class Penny(Provider): """Provider class for Kaufland Wochenangebote catalog.""" uid = "penny-de" name = "Penny" description = "Penny Katalog" schedule = "0 3 * * *"
[docs] class Configuration(Provider.Configuration): markt_id: str = Field( description="""ID des Penny-Markts. Öffne penny.de und wähle deinen Markt aus. Öffne den Web-Inspektor und öffne den Netzwerktab. Öffne einen Onlinekatalog für deinen Markt. Es wird eine Datei market_... mit dem Typ json geladen. Die Zahl am Ende des Names dieser Datei ist die Markt-ID. """, )
url = "https://www.penny.de/angebote#handout" region = Germany first_page_number = 0 pdf_url_template = "https://penny-publish.blaetterkatalog.de/frontend/catalogs/{catalog_id}/2/pdf/complete.pdf" market_url_template = "https://www.penny.de/.rest/market/markets/market_{markt_id}" market_catalog_url_key = "flippingBookURL"
[docs] @override def _get_catalog_data(self) -> None: market_response = self._client.get( self.market_url_template.format( markt_id=self._configuration.markt_id, ) ) catalog_id = market_response.json()[self.market_catalog_url_key].rsplit( "=", maxsplit=1 )[1] pdf_catalog_response = self._client.get( self.pdf_url_template.format(catalog_id=catalog_id) ) self.temp_pdf_catalog_file = tempfile.NamedTemporaryFile( # noqa: SIM115 # needs to stay open until the class exits suffix="pdf" ) self.temp_pdf_catalog_file.write(pdf_catalog_response.content) self.pdf_catalog = pypdfium2.PdfDocument( # needs manual closing self.temp_pdf_catalog_file.name )
[docs] @override def _get_page(self, page_number: PageNumber) -> bytes: try: pdf_catalog_page = self.pdf_catalog.get_page(int(page_number)) except pypdfium2.PdfiumError as error: raise PagesExhausted from error image_data_io = BytesIO() with pdf_catalog_page.render(scale=2).to_pil() as image: image.save(image_data_io, format="WEBP") image_data_io.seek(0) return image_data_io.read()
[docs] @override def _get_valid_since(self) -> datetime: return datetime.combine( self._relevant_datetime - timedelta(days=self._relevant_datetime.weekday() - Day.MONDAY), time.min, self._relevant_datetime.tzinfo, )
[docs] @override def _get_valid_until(self) -> datetime: return self._get_valid_since() + timedelta(days=7)
[docs] @override def _cleanup(self) -> None: if hasattr(self, "pdf_catalog"): self.pdf_catalog.close() if hasattr(self, "temp_pdf_catalog_file"): self.temp_pdf_catalog_file.close()
[docs] class PennyPreview(Preview, Penny): """Provider class for Kaufland Wochenangebote preview catalog for next week.""" uid = Penny.uid + "-preview" name = Penny.name + "-Vorschau" description = Penny.description + " im nächsten Katalog"
[docs] @override def _get_preview_timedelta(self) -> timedelta: return timedelta(days=7)
market_catalog_url_key = "nextWeekFlippingBookURL"