#!/usr/bin/env python3 """Pobiera zdjęcia obiektów noclegowych do katalogu pages/img/. Dla każdej oferty (`type: oferta*`) z polem `www` we frontmatterze skrypt otwiera stronę obiektu i wyciąga z niej 3–5 zdjęć: 1. og:image / twitter:image — zdjęcie, które obiekt sam wybrał na wizytówkę, 2. galeria ze strony — , oraz background-image w stylach inline. Kandydaci są odsiewani dwoma filtrami, w tej kolejności: po nazwie pliku (logotypy, ikony, mapy, piksele śledzące) i po samym pliku — waga > 30 KB oraz proporcje w przedziale krajobrazowym (patrz MIN_RATIO/MAX_RATIO, MIN_WIDTH). Co przejdzie, ląduje jako JPEG przeskalowany do MAX_WIDTH px szerokości, jeśli w systemie jest Pillow; bez Pillow zapisywany jest oryginał z własnym rozszerzeniem. Zapis: pages/img//01.jpg, 02.jpg, ... — `` to pole `id` z frontmattera. Mapowanie oferta → zdjęcia jest wyłącznie konwencją katalogową: pliki .md NIE są modyfikowane, tak samo jak w gen_pages.py i gen_viz.py. Katalog czyta potem gen_pages.py (galeria na karcie, miniatury w tabeli porównawczej, tło kafli regionów). Błąd jednej oferty (strona nie oddaje zdjęć, blokada scrapingu, timeout) nie przerywa reszty — trafia na listę braków wypisywaną na końcu. Znany przypadek: booking.com odrzuca automatyczne pobrania i taka oferta zostaje bez zdjęć. Uruchomienie: python3 gen_images.py # pomija oferty, które mają już zdjęcia python3 gen_images.py --force # pobiera wszystko od nowa python3 gen_images.py --only kristall-schattbergxpress """ from __future__ import annotations import argparse import io import re import ssl import sys import urllib.error import urllib.request from dataclasses import dataclass, field from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote, urljoin, urlsplit import yaml BUNDLE_ROOT = Path(__file__).resolve().parent IMG_DIR = BUNDLE_ROOT / "pages" / "img" WANTED = 5 # ile zdjęć chcemy na obiekt MIN_WANTED = 3 # poniżej tego oferta idzie na listę „niepełnych” MAX_CANDIDATES = 40 # ile adresów maksymalnie próbujemy pobrać MIN_BYTES = 30 * 1024 MIN_WIDTH = 600 MIN_RATIO = 0.85 # wysokość ≥ szerokości to zwykle baner/logo w pionie MAX_RATIO = 2.60 # a bardzo szerokie paski to nagłówki, nie zdjęcia MAX_TRANSPARENT = 0.03 # udział pikseli z alfą — powyżej to logo/plakietka MAX_FLAT = 0.35 # udział jednego koloru — powyżej to grafika, nie zdjęcie MAX_WIDTH = 1200 TIMEOUT = 20 USER_AGENT = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/126.0 Safari/537.36" ) IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp") _RETINA_RE = re.compile(r"@[234]x(?=\.[a-z]+$)") MAX_HASH_DISTANCE = 10 # dystans Hamminga, poniżej którego to to samo zdjęcie # Nazwy plików, które prawie nigdy nie są zdjęciem obiektu. NOISE_RE = re.compile( r"(logo|icon|favicon|sprite|pixel|tracking|placeholder|avatar|banner|" r"button|arrow|flag|map|karte|mappa|plan|piktogram|wetter|weather|" r"footer|header|badge|award|siegel|social|whatsapp|facebook|instagram|" r"partner|gutschein|zertifikat|prospekt|katalog)", re.I, ) try: from PIL import Image except ModuleNotFoundError: # pragma: no cover Image = None PILLOW = Image is not None # --- oferty z bundle'a ------------------------------------------------- @dataclass class Offer: concept_id: str offer_id: str name: str url: str operator: str = "" def load_offers() -> list[Offer]: offers: list[Offer] = [] for path in sorted(BUNDLE_ROOT.rglob("*.md")): if ".git" in path.parts or "pages" in path.parts: continue text = path.read_text(encoding="utf-8") if not text.startswith("---\n"): continue end = text.find("\n---", 3) if end == -1: continue frontmatter = yaml.safe_load(text[4:end]) or {} if not str(frontmatter.get("type", "")).startswith("oferta"): continue offers.append( Offer( concept_id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(), offer_id=str(frontmatter.get("id") or "").strip(), name=str(frontmatter.get("nazwa") or frontmatter.get("title") or ""), url=str(frontmatter.get("www") or "").strip(), operator=str(frontmatter.get("operator") or ""), ) ) return offers # --- pobieranie -------------------------------------------------------- _LAX_SSL = ssl.create_default_context() _LAX_SSL.check_hostname = False _LAX_SSL.verify_mode = ssl.CERT_NONE def fetch(url: str, referer: str = "") -> tuple[bytes, str]: """Zwraca (bajty, content-type). Rzuca OSError/HTTPError przy porażce.""" headers = { "User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "pl,en;q=0.8,de;q=0.6", } if referer: headers["Referer"] = referer request = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(request, timeout=TIMEOUT) as response: return response.read(), response.headers.get("Content-Type", "") except urllib.error.URLError as exc: # Część małych obiektów ma łańcuch certyfikatów niepełny — dla zdjęć # z publicznej strony to akceptowalne ryzyko, więc jedna próba obok. if isinstance(getattr(exc, "reason", None), ssl.SSLError): with urllib.request.urlopen( request, timeout=TIMEOUT, context=_LAX_SSL ) as response: return response.read(), response.headers.get("Content-Type", "") raise # --- wyciąganie adresów zdjęć ze strony -------------------------------- class ImageCollector(HTMLParser): """Zbiera kandydatów na zdjęcia: og:image, , , tła.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.meta: list[str] = [] self.gallery: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: data = {k.lower(): (v or "") for k, v in attrs} if tag == "meta": key = (data.get("property") or data.get("name") or "").lower() if key in {"og:image", "og:image:url", "og:image:secure_url", "twitter:image"}: self._add(self.meta, data.get("content", "")) elif tag == "img": for attribute in ("data-src", "data-lazy-src", "data-original", "src"): if data.get(attribute): self._add(self.gallery, data[attribute]) break for attribute in ("srcset", "data-srcset"): for part in data.get(attribute, "").split(","): url = part.strip().split(" ")[0] self._add(self.gallery, url) elif tag == "a": href = data.get("href", "") if href.split("?")[0].lower().endswith(IMAGE_EXTS): self._add(self.gallery, href) if "style" in data: for match in re.finditer(r"url\(['\"]?([^)'\"]+)", data["style"]): self._add(self.gallery, match.group(1)) def _add(self, bucket: list[str], url: str) -> None: url = (url or "").strip() if url and not url.startswith("data:") and url not in bucket: bucket.append(url) def candidates(page_url: str, markup: str, tokens: list[str] = ()) -> list[str]: collector = ImageCollector() try: collector.feed(markup) except Exception: # parser HTML-a nie może wywrócić całego przebiegu pass ordered: list[str] = [] seen: set[str] = set() for url in collector.meta + collector.gallery: absolute = urljoin(page_url, url) if not absolute.lower().startswith(("http://", "https://")): continue path = urlsplit(absolute).path if path.lower().endswith(".svg") or NOISE_RE.search(path): continue # To samo zdjęcie wraca w srcset w kilku gęstościach (@2x, @3x) # i z parametrami skalowania w query — liczy się raz. key = _RETINA_RE.sub("", unquote(path).lower()) if key in seen: continue seen.add(key) ordered.append(absolute) # Na stronach portalowych (marka z wieloma obiektami, katalog noclegów) # w tym samym HTML-u siedzą zdjęcia cudzych obiektów. Jeśli którykolwiek # adres niesie w ścieżce nazwę własną obiektu, bierzemy tylko takie — # a gdy żaden jej nie niesie, zostaje pełna lista (strona własna obiektu). if tokens: relevant = [ url for url in ordered if any(token in unquote(urlsplit(url).path).lower() for token in tokens) ] if relevant: ordered = relevant return ordered[:MAX_CANDIDATES] # Człony nazw, które nie identyfikują obiektu — są w co drugiej ofercie. GENERIC_TOKENS = { "apartment", "apartments", "appartement", "appartementhaus", "apart", "hotel", "house", "residence", "chalet", "lodge", "ferienwohnung", "wohnung", "central", "sport", "sportchalet", } def object_tokens(offer: Offer) -> list[str]: """Nazwy własne obiektu, po których poznamy jego zdjęcia na portalu. Odpadają człony rodzajowe („apartments”), nazwa operatora („AlpenParks”) i wszystko, co siedzi już w domenie — jeśli cała witryna należy do obiektu, filtrowanie po nazwie nie ma czego zawężać. """ host = urlsplit(offer.url).netloc.lower().replace("-", "").replace(".", "") brand = re.sub(r"[^a-z0-9]", "", (offer.operator or "").lower()) tokens = [] for part in re.split(r"[^a-z0-9]+", offer.offer_id.lower()): if len(part) < 5 or part in GENERIC_TOKENS: continue if part in host or (brand and part in brand): continue tokens.append(part) return tokens def object_page(page_url: str, markup: str, tokens: list[str]) -> str: """Podstrona obiektu wylinkowana ze strony głównej marki/portalu.""" if not tokens or urlsplit(page_url).path.strip("/"): return "" # weszliśmy już w konkretną podstronę, nie w korzeń for match in re.finditer(r'href=["\']([^"\'#]+)["\']', markup, re.I): target = urljoin(page_url, match.group(1)) if urlsplit(target).netloc != urlsplit(page_url).netloc: continue path = unquote(urlsplit(target).path).lower() if path.strip("/") and any(token in path for token in tokens): return target return "" def decode(raw: bytes, content_type: str) -> str: match = re.search(r"charset=([\w-]+)", content_type, re.I) if not match: match = re.search(rb'charset=["\']?([\w-]+)', raw[:4096], re.I) encoding = match.group(1).decode("ascii", "ignore") if match else "utf-8" else: encoding = match.group(1) try: return raw.decode(encoding, "replace") except LookupError: return raw.decode("utf-8", "replace") # --- zapis ------------------------------------------------------------- def acceptable(raw: bytes, seen: list[int] = ()) -> tuple[bool, str, int]: """(czy bierzemy, powód odrzucenia, odcisk percepcyjny).""" if raw[:1024].lstrip().lower().startswith((b" MAX_TRANSPARENT: return False, f"grafika z przezroczystością ({transparent:.0%})", 0 if flat > MAX_FLAT: return False, f"płaskie tło ({flat:.0%} jednego koloru) — grafika", 0 # To samo zdjęcie bywa na stronie pod kilkoma adresami i w kilku # rozdzielczościach — po odcisku poznajemy je niezależnie od pliku. for other in seen: if bin(fingerprint ^ other).count("1") <= MAX_HASH_DISTANCE: return False, "duplikat wcześniejszego zdjęcia", fingerprint return True, "", fingerprint def _ahash(image: "Image.Image") -> int: """Odcisk percepcyjny (average hash) — odporny na zmianę rozdzielczości.""" pixels = image.convert("L").resize((8, 8)).tobytes() average = sum(pixels) / len(pixels) return sum(1 << n for n, value in enumerate(pixels) if value > average) def _transparency(image: "Image.Image") -> float: if image.mode not in ("RGBA", "LA", "P") or ( image.mode == "P" and "transparency" not in image.info ): return 0.0 alpha = image.convert("RGBA").getchannel("A").resize((48, 48)) histogram = alpha.getcolors(256) or [] total = sum(count for count, _ in histogram) or 1 return sum(count for count, value in histogram if value < 250) / total def _flatness(image: "Image.Image") -> float: small = image.convert("RGB").resize((48, 48)) histogram = small.getcolors(48 * 48) if not histogram: # więcej kolorów niż pikseli próbki — na pewno zdjęcie return 0.0 total = sum(count for count, _ in histogram) or 1 return max(count for count, _ in histogram) / total def save(raw: bytes, directory: Path, index: int, source_url: str) -> Path: directory.mkdir(parents=True, exist_ok=True) if PILLOW: with Image.open(io.BytesIO(raw)) as image: image = image.convert("RGB") if image.width > MAX_WIDTH: height = round(image.height * MAX_WIDTH / image.width) image = image.resize((MAX_WIDTH, height), Image.LANCZOS) out = directory / f"{index:02d}.jpg" image.save(out, "JPEG", quality=82, optimize=True, progressive=True) return out suffix = Path(urlsplit(source_url).path).suffix.lower() out = directory / f"{index:02d}{suffix if suffix in IMAGE_EXTS else '.jpg'}" out.write_bytes(raw) return out # --- przebieg na ofertę ------------------------------------------------ @dataclass class Result: offer: Offer saved: list[Path] = field(default_factory=list) reason: str = "" source: str = "" # podstrona obiektu, jeśli weszliśmy głębiej niż `www` def collect(offer: Offer, force: bool) -> Result: result = Result(offer=offer) directory = IMG_DIR / offer.offer_id if not offer.offer_id: result.reason = "brak pola `id` we frontmatterze" return result if not offer.url: result.reason = "brak pola `www` (oferta bez konkretnego obiektu)" return result existing = sorted(directory.glob("*")) if directory.is_dir() else [] if existing and not force: result.saved = existing result.reason = "pominięte — zdjęcia już są (--force nadpisze)" return result try: raw, content_type = fetch(offer.url) except Exception as exc: result.reason = f"strona nie odpowiada: {_describe(exc)}" return result markup = decode(raw, content_type) source_url = offer.url tokens = object_tokens(offer) deeper = object_page(offer.url, markup, tokens) if deeper: try: raw, content_type = fetch(deeper) markup = decode(raw, content_type) source_url = deeper result.source = deeper except Exception: pass # zostajemy przy stronie głównej urls = candidates(source_url, markup, tokens) if not urls: result.reason = "strona nie zawiera kandydatów na zdjęcia (HTML bez galerii)" return result if force and directory.is_dir(): for old in directory.glob("*"): old.unlink() rejected: list[str] = [] hashes: list[int] = [] for url in urls: if len(result.saved) >= WANTED: break try: image_raw, image_type = fetch(url, referer=offer.url) except Exception as exc: rejected.append(f"{_short(url)}: {_describe(exc)}") continue if "image" not in image_type and not urlsplit(url).path.lower().endswith( IMAGE_EXTS ): rejected.append(f"{_short(url)}: nie obrazek ({image_type})") continue ok, why, fingerprint = acceptable(image_raw, hashes) if not ok: rejected.append(f"{_short(url)}: {why}") continue hashes.append(fingerprint) try: result.saved.append( save(image_raw, directory, len(result.saved) + 1, url) ) except Exception as exc: rejected.append(f"{_short(url)}: zapis — {_describe(exc)}") if not result.saved: head = "; ".join(rejected[:3]) or "brak przyczyny" result.reason = f"żaden kandydat nie przeszedł filtrów ({head})" elif len(result.saved) < MIN_WANTED: result.reason = f"tylko {len(result.saved)} zdj. (cel: {MIN_WANTED}–{WANTED})" return result def _describe(exc: Exception) -> str: if isinstance(exc, urllib.error.HTTPError): return f"HTTP {exc.code}" reason = getattr(exc, "reason", None) return f"{type(exc).__name__}: {reason or exc}" def _short(url: str) -> str: name = urlsplit(url).path.rsplit("/", 1)[-1] or urlsplit(url).netloc return name[:42] # --- main -------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--force", action="store_true", help="pobierz od nowa") parser.add_argument("--only", metavar="ID", help="tylko ta oferta (pole `id`)") args = parser.parse_args() offers = load_offers() if args.only: offers = [o for o in offers if o.offer_id == args.only] if not offers: print(f"Nie ma oferty o id={args.only}", file=sys.stderr) return 1 print(f"Pillow: {'jest — skalowanie do ' + str(MAX_WIDTH) + 'px' if PILLOW else 'BRAK — zapis oryginałów'}") print(f"Ofert do przetworzenia: {len(offers)}\n") results = [] for offer in offers: print(f"→ {offer.offer_id or offer.concept_id} ({offer.url or 'brak www'})") result = collect(offer, args.force) results.append(result) if result.saved: names = ", ".join(p.name for p in result.saved) print(f" {len(result.saved)} zdj.: {names}" + (f" [{result.reason}]" if result.reason else "")) else: print(f" ✗ {result.reason}") ok = [r for r in results if len(r.saved) >= MIN_WANTED] partial = [r for r in results if 0 < len(r.saved) < MIN_WANTED] missing = [r for r in results if not r.saved] print(f"\nZe zdjęciami ({len(ok)}):") for result in ok: print(f" + {result.offer.offer_id} — {len(result.saved)}") if partial: print(f"\nNiepełne ({len(partial)}):") for result in partial: print(f" ~ {result.offer.offer_id} — {result.reason}") if missing: print(f"\nBez zdjęć ({len(missing)}):") for result in missing: print(f" ! {result.offer.offer_id or result.offer.concept_id} — {result.reason}") total = sum(p.stat().st_size for p in IMG_DIR.rglob("*") if p.is_file()) print(f"\nRazem w {IMG_DIR.relative_to(BUNDLE_ROOT)}: {total / 1024 / 1024:.1f} MiB") print("Teraz: python3 gen_pages.py (wpina zdjęcia w karty i tabelę)") return 0 if __name__ == "__main__": raise SystemExit(main())