Zdjęcia obiektów: gen_images.py + 40 zdjęć dla 8 z 12 ofert
Skrypt pobiera 3–5 zdjęć ze strony z pola `www` frontmattera: og:image, potem galeria z HTML-a. Odsiew: nazwa pliku (logo/ikona/mapa), waga >30 KB, proporcje, przezroczystość i płaskie tło (plakietki), odcisk percepcyjny (duplikaty z srcset). Na stronie marki z wieloma obiektami skrypt schodzi do podstrony obiektu i filtruje adresy po jego nazwie własnej — inaczej oba AlpenParksy dostałyby zdjęcia cudzych resortów. Mapowanie oferta→zdjęcia to konwencja katalogowa (pages/img/<id>/NN.jpg), pliki .md nietknięte. Bez zdjęć: booking.com i corones.it (blokada bota), apart-ideal-serfaus (404 pod adresem z frontmattera), dom-dla-grupy (koncept bez `www`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
534
gen_images.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
#!/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 — <img src|data-src|srcset>, <a href="...jpg">
|
||||
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/<id-oferty>/01.jpg, 02.jpg, ... — `<id-oferty>` 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, <img>, <a href=*.jpg>, 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"<!doctype", b"<html", b"<?xml")):
|
||||
# Serwer oddał stronę pod adresem .jpg — tak wygląda blokada bota.
|
||||
return False, "HTML zamiast obrazka (blokada scrapingu)", 0
|
||||
if len(raw) < MIN_BYTES:
|
||||
return False, f"{len(raw) / 1024:.0f} KB < {MIN_BYTES // 1024} KB", 0
|
||||
if not PILLOW:
|
||||
return True, "", 0
|
||||
try:
|
||||
with Image.open(io.BytesIO(raw)) as image:
|
||||
width, height = image.size
|
||||
transparent = _transparency(image)
|
||||
flat = _flatness(image)
|
||||
fingerprint = _ahash(image)
|
||||
except Exception as exc:
|
||||
return False, f"nieczytelny obraz ({type(exc).__name__})", 0
|
||||
if width < MIN_WIDTH:
|
||||
return False, f"szerokość {width}px < {MIN_WIDTH}px", 0
|
||||
ratio = width / height if height else 0
|
||||
if not MIN_RATIO <= ratio <= MAX_RATIO:
|
||||
return False, f"proporcje {ratio:.2f} poza [{MIN_RATIO}, {MAX_RATIO}]", 0
|
||||
# Logotypy i plakietki przechodzą filtr wagi i proporcji (bywają duże),
|
||||
# ale zdradza je kanał alfa i płaskie tło — zdjęcie ma jedno i drugie inne.
|
||||
if transparent > 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())
|
||||
BIN
pages/img/alpenparks-rehrenberg/01.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
pages/img/alpenparks-rehrenberg/02.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
pages/img/alpenparks-rehrenberg/03.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
pages/img/alpenparks-rehrenberg/04.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
pages/img/alpenparks-rehrenberg/05.jpg
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
pages/img/alpenparks-sonnleiten/01.jpg
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
pages/img/alpenparks-sonnleiten/02.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
pages/img/alpenparks-sonnleiten/03.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
pages/img/alpenparks-sonnleiten/04.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
pages/img/alpenparks-sonnleiten/05.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
pages/img/apartment-brandau-kappl/01.jpg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
pages/img/apartment-brandau-kappl/02.jpg
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
pages/img/apartment-brandau-kappl/03.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
pages/img/apartment-brandau-kappl/04.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
pages/img/apartment-brandau-kappl/05.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
pages/img/apartments-kappl/01.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
pages/img/apartments-kappl/02.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
pages/img/apartments-kappl/03.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
pages/img/apartments-kappl/04.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
pages/img/apartments-kappl/05.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
pages/img/glemm-lodge/01.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
pages/img/glemm-lodge/02.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
pages/img/glemm-lodge/03.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
pages/img/glemm-lodge/04.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
pages/img/glemm-lodge/05.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
pages/img/haeuslerhof-valdaora/01.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
pages/img/haeuslerhof-valdaora/02.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
pages/img/haeuslerhof-valdaora/03.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
pages/img/haeuslerhof-valdaora/04.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
pages/img/haus-central-ladis/01.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
pages/img/haus-central-ladis/02.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
pages/img/haus-central-ladis/03.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
pages/img/haus-central-ladis/04.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
pages/img/haus-central-ladis/05.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
pages/img/sportchalet-viehhofen/01.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
pages/img/sportchalet-viehhofen/02.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
pages/img/sportchalet-viehhofen/03.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
pages/img/sportchalet-viehhofen/04.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
pages/img/sportchalet-viehhofen/05.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |