clementBE's picture
Update app.py
b1add53 verified
Raw History Blame Contribute Delete
36.4 kB
import os
import re
import io
import time
import math
import hashlib
import zipfile
import tempfile
from pathlib import Path
from datetime import datetime, timezone
from urllib.parse import urlparse, parse_qs, unquote, urljoin
import requests
from requests.adapters import HTTPAdapter
from concurrent.futures import ThreadPoolExecutor
from bs4 import BeautifulSoup
import pandas as pd
import numpy as np
from PIL import Image, ExifTags
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import gradio as gr
# ============================================================
# CONFIGURATION
# ============================================================
APP_TITLE = "Web & Flickr Image Downloader + Metadata"
MAX_IMAGES_HARD_LIMIT = 500
REQUEST_TIMEOUT = 30
IMAGE_TIMEOUT = 45
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/140.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
}
BASE_DIR = Path(tempfile.gettempdir()) / "gradio_web_image_downloader"
BASE_DIR.mkdir(parents=True, exist_ok=True)
MAX_WORKERS = 8 # parallel requests (raise for speed, lower if Flickr starts refusing)
session = requests.Session()
session.headers.update(HEADERS)
_adapter = HTTPAdapter(pool_connections=MAX_WORKERS * 2, pool_maxsize=MAX_WORKERS * 2)
session.mount("https://", _adapter)
session.mount("http://", _adapter)
EXIF_TAGS = dict(ExifTags.TAGS)
GPS_TAGS = dict(ExifTags.GPSTAGS)
FLICKR_PHOTO_RE = re.compile(r"flickr\.com/photos/([^/?#]+)/(?:with/)?(\d{6,})")
# ============================================================
# GENERIC HELPERS
# ============================================================
def clean_url(url):
url = str(url or "").strip()
if url and not url.startswith(("http://", "https://")):
url = "https://" + url
return url
def valid_http_url(url):
try:
p = urlparse(url)
return p.scheme in ("http", "https") and bool(p.netloc)
except Exception:
return False
def is_flickr_url(url):
try:
host = urlparse(url).netloc.lower()
return host == "flickr.com" or host.endswith(".flickr.com")
except Exception:
return False
def extract_flickr_photo_id(url):
"""Return (username, photo_id) for /photos/<user>/<id> or /photos/<user>/with/<id>."""
m = FLICKR_PHOTO_RE.search(url)
return (m.group(1), m.group(2)) if m else (None, None)
def safe_filename(value, default="image"):
value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(value or default)).strip(" .")
return (value or default)[:180]
def make_hash(value):
return hashlib.sha1(value.encode("utf-8", errors="ignore")).hexdigest()[:12]
CONTENT_TYPE_EXT = {
"image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png",
"image/webp": ".webp", "image/gif": ".gif", "image/bmp": ".bmp",
"image/tiff": ".tif", "image/avif": ".avif",
}
FORMAT_EXT = {
"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "webp": ".webp",
"gif": ".gif", "bmp": ".bmp", "tiff": ".tif", "avif": ".avif",
}
def make_item(**kw):
"""Uniform candidate record used by every source."""
item = {
"image_url": "", "page_url": "", "title": "", "owner": "",
"photo_id": "", "width": 0, "height": 0, "source": "",
"license": "", "date_taken": "", "date_uploaded": "",
"views": "", "tags": "", "description": "", "alt": "",
}
item.update(kw)
return item
# ============================================================
# DATE HELPERS
# ============================================================
def unix_to_str(value):
"""Unix seconds (int or str) -> 'YYYY-MM-DD HH:MM:SS' (UTC)."""
try:
return datetime.fromtimestamp(int(value), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return ""
def parse_date(value):
if value is None:
return pd.NaT
if isinstance(value, pd.Timestamp):
return value
if isinstance(value, str) and re.fullmatch(r"\d{9,13}", value.strip()):
value = int(value.strip()) # Flickr returns Unix timestamps as strings
if isinstance(value, (int, float)):
if value > 100000000000:
return pd.to_datetime(value, unit="ms", errors="coerce")
if value > 1000000000:
return pd.to_datetime(value, unit="s", errors="coerce")
value = str(value).strip()
if not value:
return pd.NaT
if re.match(r"^\d{4}:\d{2}:\d{2}", value): # EXIF format
dt = pd.to_datetime(value, format="%Y:%m:%d %H:%M:%S", errors="coerce")
if not pd.isna(dt):
return dt
dt = pd.to_datetime(value, errors="coerce")
if not pd.isna(dt) and getattr(dt, "tzinfo", None) is not None:
dt = dt.tz_convert("UTC").tz_localize(None)
return dt
def iso_or_empty(value):
dt = parse_date(value)
return "" if pd.isna(dt) else dt.strftime("%Y-%m-%d %H:%M:%S")
# ============================================================
# EXIF
# ============================================================
def _num(x):
"""Convert PIL rational / tuple to float."""
if isinstance(x, tuple) and len(x) == 2:
return float(x[0]) / float(x[1]) if x[1] else 0.0
return float(x)
def gps_to_decimal(value, ref=None):
try:
if not value or len(value) != 3:
return None
d, m, s = (_num(v) for v in value)
dec = d + m / 60 + s / 3600
return -dec if ref in ("S", "W") else dec
except Exception:
return None
def extract_exif_metadata(image):
md = {k: "" for k in [
"exif_make", "exif_model", "exif_software", "exif_datetime",
"exif_datetime_original", "exif_datetime_digitized", "exif_orientation",
"exif_lens_model", "exif_focal_length", "exif_iso", "exif_exposure_time",
"exif_f_number", "exif_gps_latitude", "exif_gps_longitude", "exif_gps_altitude",
]}
try:
exif = image.getexif()
if not exif:
return md
# Base IFD + Exif sub-IFD (where DateTimeOriginal, ISO, etc. actually live)
decoded = {EXIF_TAGS.get(k, str(k)): v for k, v in exif.items()}
try:
for k, v in exif.get_ifd(0x8769).items():
decoded[EXIF_TAGS.get(k, str(k))] = v
except Exception:
pass
g = lambda k: str(decoded.get(k, ""))
md.update({
"exif_make": g("Make"), "exif_model": g("Model"),
"exif_software": g("Software"), "exif_datetime": g("DateTime"),
"exif_datetime_original": g("DateTimeOriginal"),
"exif_datetime_digitized": g("DateTimeDigitized"),
"exif_orientation": g("Orientation"), "exif_lens_model": g("LensModel"),
"exif_iso": str(decoded.get("ISOSpeedRatings", decoded.get("PhotographicSensitivity", ""))),
"exif_exposure_time": g("ExposureTime"), "exif_f_number": g("FNumber"),
})
if decoded.get("FocalLength") is not None:
try:
md["exif_focal_length"] = round(_num(decoded["FocalLength"]), 2)
except Exception:
md["exif_focal_length"] = g("FocalLength")
try:
gps = {GPS_TAGS.get(k, str(k)): v for k, v in exif.get_ifd(0x8825).items()}
except Exception:
gps = {}
lat = gps_to_decimal(gps.get("GPSLatitude"), gps.get("GPSLatitudeRef"))
lon = gps_to_decimal(gps.get("GPSLongitude"), gps.get("GPSLongitudeRef"))
if lat is not None:
md["exif_gps_latitude"] = round(lat, 7)
if lon is not None:
md["exif_gps_longitude"] = round(lon, 7)
if gps.get("GPSAltitude") is not None:
try:
md["exif_gps_altitude"] = round(_num(gps["GPSAltitude"]), 2)
except Exception:
md["exif_gps_altitude"] = str(gps["GPSAltitude"])
except Exception:
pass
return md
# ============================================================
# FLICKR
# ============================================================
def flickr_photo_via_html(username, photo_id, page_url):
"""Read the publication date and the largest image from the public photo page."""
r = session.get(page_url, timeout=REQUEST_TIMEOUT)
r.raise_for_status()
html = r.text.replace("\\/", "/")
soup = BeautifulSoup(html, "html.parser")
date_uploaded = ""
for pattern, is_unix in [
(r'"datePosted"\s*:\s*"?(\d{9,10})"?', True),
(r'"date_?[Uu]ploaded"\s*:\s*"?(\d{9,10})"?', True),
(r'"datePublished"\s*:\s*"([^"]+)"', False),
]:
m = re.search(pattern, html)
if m:
date_uploaded = unix_to_str(m.group(1)) if is_unix else iso_or_empty(m.group(1))
if date_uploaded:
break
m = re.search(r'"dateTaken"\s*:\s*"([^"]+)"', html)
date_taken = m.group(1) if m else ""
rank = ["o", "k", "h", "b", "c", "z"]
found = {}
for m in re.finditer(
r'((?:https?:)?//live\.staticflickr\.com/\d+/' + re.escape(photo_id)
+ r'_[0-9a-f]+(?:_([a-z0-9]))?\.(?:jpg|png|gif))', html):
found[m.group(2) or "m"] = m.group(1)
image_url = next((found[k] for k in rank if k in found), None)
if not image_url and found:
image_url = list(found.values())[-1]
if not image_url:
og = soup.find("meta", attrs={"property": "og:image"})
image_url = og.get("content", "") if og else ""
if image_url.startswith("//"):
image_url = "https:" + image_url
if not image_url:
raise RuntimeError("Could not find the image on this Flickr page.")
og_title = soup.find("meta", attrs={"property": "og:title"})
return make_item(
image_url=image_url, page_url=page_url,
title=og_title.get("content", "") if og_title else "",
owner=username, photo_id=photo_id, source="Flickr HTML (photo page)",
date_taken=date_taken, date_uploaded=date_uploaded,
)
def flickr_photo_candidate(url):
username, photo_id = extract_flickr_photo_id(url)
if not photo_id:
return None
page_url = f"https://www.flickr.com/photos/{username}/{photo_id}/"
return flickr_photo_via_html(username, photo_id, page_url)
STREAM_STATS = {}
def flickr_stream_via_html(username, max_images, first, max_pages=100):
"""Read the photostream pages (newest first), then each photo page.
Collects a few spare IDs so failed photo pages can be replaced."""
stats = STREAM_STATS
stats.clear()
stats.update(pages=0, ids_per_page=[], failed=0, errors=[], stop="")
wanted = max_images * 2 # spare IDs replace photo pages that fail to load
def parse_ids(html):
html = html.replace("\\u002F", "/").replace("\\/", "/")
user_pat = re.escape(username).replace("@", "(?:@|%40)")
hits = [(m.start(), m.group(1))
for m in re.finditer(rf"/photos/{user_pat}/(\d{{8,}})", html, flags=re.I)]
hits += [(m.start(), m.group(1))
for m in re.finditer(r"live\.staticflickr\.com/\d+/(\d{8,})_[0-9a-f]+", html)]
if not hits:
hits = [(m.start(), m.group(1))
for m in re.finditer(r'"id"\s*:\s*"(\d{9,12})"', html)]
return list(dict.fromkeys(i for _, i in sorted(hits)))
base = f"https://www.flickr.com/photos/{username}/"
ids, page = [], 1
stats["detail"] = []
while len(ids) < wanted:
if page > max_pages:
stats["stop"] = f"page limit ({max_pages}) reached"
break
variants = [base] if page == 1 else [
f"{base}page{page}/", f"{base}page{page}", f"{base}?page={page}"]
new = []
for u in variants: # try URL forms until one gives photos we don't have yet
try:
r = session.get(u, timeout=REQUEST_TIMEOUT, allow_redirects=True)
status_code, final, size = r.status_code, r.url, len(r.text)
r.raise_for_status()
new = [i for i in parse_ids(r.text) if i not in ids]
stats["detail"].append(
f"{u} β†’ HTTP {status_code}, {size} bytes, {len(new)} new photos"
+ (f", redirected to {final}" if final.rstrip('/') != u.rstrip('/') else ""))
except Exception as exc:
stats["detail"].append(f"{u} β†’ {type(exc).__name__}: {exc}")
continue
if new:
break
stats["pages"] += 1
stats["ids_per_page"].append(len(new))
if not new:
stats["stop"] = f"page {page} gave no new photos with any URL form"
break
ids += new
page += 1
ids = ids[:wanted]
def fetch(pid):
if pid == first["photo_id"]:
return first
err = ""
for attempt in range(2):
try:
return flickr_photo_via_html(
username, pid, f"https://www.flickr.com/photos/{username}/{pid}/")
except Exception as exc:
err = f"{type(exc).__name__}: {exc}"
time.sleep(1 + attempt)
stats["failed"] += 1
if len(stats["errors"]) < 5:
stats["errors"].append(f"photo {pid}: {err}")
return None
# Fetch photo pages in batches; stop as soon as enough succeeded (failed ones are replaced).
items = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
for start in range(0, len(ids), MAX_WORKERS):
if len(items) >= max_images:
break
items += [it for it in pool.map(fetch, ids[start:start + MAX_WORKERS]) if it]
return items
def flickr_photo_and_stream(url, max_images, include_stream=True, max_pages=100):
"""Photo page -> the photographer's public photos, newest to oldest, up to max_images."""
first = flickr_photo_candidate(url)
if not first:
return None
if not include_stream:
return [first]
username, _ = extract_flickr_photo_id(url)
items = flickr_stream_via_html(username, max_images, first, max_pages)
items.sort(key=lambda s: s.get("date_uploaded", ""), reverse=True)
return items or [first]
# ============================================================
# GENERIC HTML
# ============================================================
def page_publication_date(soup, html):
"""Try common publication-date markers on ordinary web pages."""
for attrs in [
{"property": "article:published_time"}, {"name": "article:published_time"},
{"itemprop": "datePublished"}, {"name": "date"}, {"name": "pubdate"},
{"property": "og:published_time"},
]:
tag = soup.find("meta", attrs=attrs)
if tag and tag.get("content"):
d = iso_or_empty(tag["content"])
if d:
return d
m = re.search(r'"datePublished"\s*:\s*"([^"]+)"', html)
if m:
d = iso_or_empty(m.group(1))
if d:
return d
t = soup.find("time", attrs={"datetime": True})
return iso_or_empty(t["datetime"]) if t else ""
def extract_image_candidates_from_html(page_url, html):
soup = BeautifulSoup(html, "html.parser")
published = page_publication_date(soup, html)
raw = []
for img in soup.find_all("img"):
alt = img.get("alt", "")
for attr in ["src", "data-src", "data-original", "data-lazy-src", "data-image", "data-url"]:
if img.get(attr):
raw.append((img[attr], alt))
for attr in ["srcset", "data-srcset"]:
if img.get(attr):
for part in img[attr].split(","):
bits = part.strip().split()
if bits:
raw.append((bits[0], alt))
for source in soup.find_all("source"):
if source.get("srcset"):
for part in source["srcset"].split(","):
bits = part.strip().split()
if bits:
raw.append((bits[0], ""))
for meta in soup.find_all("meta", attrs={"property": "og:image"}):
if meta.get("content"):
raw.append((meta["content"], "og:image"))
out = []
for value, alt in raw:
value = value.strip()
if not value or value.startswith("data:"):
continue
absolute = urljoin(page_url, value)
if valid_http_url(absolute):
out.append(make_item(
image_url=absolute, page_url=page_url, source="HTML",
alt=alt, date_uploaded=published,
))
return out
def generic_page_images(page_url, max_images=50):
r = session.get(page_url, timeout=REQUEST_TIMEOUT, allow_redirects=True)
r.raise_for_status()
ctype = r.headers.get("Content-Type", "").lower()
if "html" not in ctype:
if ctype.startswith("image/"):
return [make_item(image_url=r.url, page_url=page_url, source="Direct image")]
raise RuntimeError(f"The page did not return HTML. Content-Type: {ctype}")
seen, unique = set(), []
for item in extract_image_candidates_from_html(r.url, r.text):
key = item["image_url"].split("#")[0]
if key in seen:
continue
seen.add(key)
unique.append(item)
if len(unique) >= max_images:
break
return unique
# ============================================================
# DOWNLOAD + METADATA
# ============================================================
def download_image(item, output_dir, index, min_width=0, min_height=0):
image_url = item["image_url"]
try:
r = session.get(image_url, timeout=IMAGE_TIMEOUT, stream=True, allow_redirects=True)
r.raise_for_status()
ctype = r.headers.get("Content-Type", "").lower()
if ctype and not ctype.startswith("image/") and "octet-stream" not in ctype:
return None, f"Not an image: {ctype}"
data = r.content
if not data:
return None, "Empty response"
try:
image = Image.open(io.BytesIO(data))
width, height = image.size
image_format = (image.format or "JPEG").lower()
except Exception as exc:
return None, f"Invalid image: {exc}"
if min_width and width < min_width:
return None, f"Too narrow: {width}px"
if min_height and height < min_height:
return None, f"Too short: {height}px"
exif_md = extract_exif_metadata(image)
ext = FORMAT_EXT.get(image_format) or CONTENT_TYPE_EXT.get(ctype.split(";")[0], ".jpg")
stem = Path(urlparse(image_url).path).stem
stem = safe_filename(stem, f"image_{index:05d}") if stem else f"image_{index:05d}"
filename = safe_filename(f"{index:05d}_{stem}{ext}", f"image_{index:05d}{ext}")
output_path = Path(output_dir) / filename
output_path.write_bytes(data)
# ---- Date selection: publication date first ----
published = item.get("date_uploaded", "")
taken = item.get("date_taken", "")
date_used, date_source = "", ""
for value, label in [
(published, "Publication date"),
(taken, "Flickr date taken"),
(exif_md["exif_datetime_original"], "EXIF DateTimeOriginal"),
(exif_md["exif_datetime"], "EXIF DateTime"),
]:
if value:
date_used, date_source = value, label
break
parsed = parse_date(date_used)
ok = not pd.isna(parsed)
record = dict(item)
record.update({
"filename": filename,
"local_file": str(output_path),
"final_url": r.url,
"width": width,
"height": height,
"aspect_ratio": round(width / height, 3) if height else "",
"format": image_format,
"file_size_bytes": len(data),
"file_size_mb": round(len(data) / 1024 / 1024, 3),
"sha256": hashlib.sha256(data).hexdigest(),
"date_published": iso_or_empty(published),
"date_used": date_used,
"date_source": date_source,
"date_parsed": parsed.strftime("%Y-%m-%d") if ok else "",
"week": parsed.strftime("%G-W%V") if ok else "",
"download_status": "OK",
})
record.update(exif_md)
return record, None
except Exception as exc:
return None, str(exc)
# ============================================================
# DATAFRAMES, PLOT, EXPORTS
# ============================================================
PREFERRED_COLUMNS = [
"filename", "title", "owner", "photo_id", "source",
"page_url", "image_url", "final_url",
"date_published", "date_used", "date_source", "date_parsed", "week",
"date_taken", "date_uploaded",
"width", "height", "aspect_ratio", "format", "file_size_bytes", "file_size_mb",
"license", "tags", "description", "views", "alt",
"exif_make", "exif_model", "exif_software",
"exif_datetime", "exif_datetime_original", "exif_datetime_digitized",
"exif_orientation", "exif_lens_model", "exif_focal_length", "exif_iso",
"exif_exposure_time", "exif_f_number",
"exif_gps_latitude", "exif_gps_longitude", "exif_gps_altitude",
"sha256", "download_status",
]
def records_to_dataframe(records):
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
existing = [c for c in PREFERRED_COLUMNS if c in df.columns]
rest = [c for c in df.columns if c not in existing and c != "local_file"]
return df[existing + rest]
def count_nonempty(df, col):
if df.empty or col not in df.columns:
return 0
return int(df[col].fillna("").astype(str).str.len().gt(0).sum())
def create_weekly_dataframe(df):
cols = ["week", "week_start", "number_of_pictures"]
if df.empty:
return pd.DataFrame(columns=cols)
temp = df.copy()
temp["_date"] = pd.to_datetime(temp["date_parsed"], errors="coerce")
temp = temp[temp["_date"].notna()].copy()
if temp.empty:
return pd.DataFrame(columns=cols)
temp["week_start"] = (temp["_date"] - pd.to_timedelta(temp["_date"].dt.weekday, unit="D")).dt.normalize()
counts = temp.groupby("week_start").size()
# Continuous timeline: weeks without pictures appear as 0
full = pd.date_range(counts.index.min(), counts.index.max(), freq="7D")
counts = counts.reindex(full, fill_value=0)
weekly = pd.DataFrame({
"week": [d.strftime("%G-W%V") for d in full],
"week_start": [d.strftime("%Y-%m-%d") for d in full],
"number_of_pictures": counts.values.astype(int),
})
return weekly
def create_weekly_plot(weekly_df, output_path):
if weekly_df.empty:
return None
from matplotlib.figure import Figure # no pyplot: safe inside Gradio worker threads
n = len(weekly_df)
fig = Figure(figsize=(max(10, min(24, n * 0.4)), 6))
ax = fig.subplots()
x = np.arange(n)
y = weekly_df["number_of_pictures"].astype(int).values
bars = ax.bar(x, y, color="#4C78A8")
labels = weekly_df["week"].tolist()
step = max(1, math.ceil(n / 30))
ax.set_xticks(x[::step])
ax.set_xticklabels(labels[::step], rotation=45, ha="right")
if n <= 40:
for b, v in zip(bars, y):
if v:
ax.annotate(str(v), (b.get_x() + b.get_width() / 2, v),
ha="center", va="bottom", fontsize=8)
ax.set_xlabel("Week (Monday start)")
ax.set_ylabel("Number of pictures")
ax.set_title("Number of pictures per week")
ax.grid(axis="y", alpha=0.25)
ax.set_ylim(0, max(1, y.max()) * 1.15)
fig.tight_layout()
fig.savefig(output_path, dpi=150)
return str(output_path)
def create_xlsx(df, weekly_df, output_path):
summary_df = pd.DataFrame([
{"metric": "Number of pictures", "value": len(df)},
{"metric": "Pictures with publication date", "value": count_nonempty(df, "date_published")},
{"metric": "Pictures with any usable date", "value": count_nonempty(df, "date_parsed")},
{"metric": "Pictures with camera EXIF", "value": count_nonempty(df, "exif_model")},
{"metric": "Pictures with GPS", "value": count_nonempty(df, "exif_gps_latitude")},
])
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="Metadata", index=False)
weekly_df.to_excel(writer, sheet_name="Weekly_counts", index=False)
summary_df.to_excel(writer, sheet_name="Summary", index=False)
for name in ["Metadata", "Weekly_counts", "Summary"]:
ws = writer.book[name]
ws.freeze_panes = "A2"
for col in ws.columns:
longest = max((len(str(c.value)) for c in col if c.value is not None), default=0)
ws.column_dimensions[col[0].column_letter].width = min(max(longest + 2, 10), 45)
def build_zip(records, zip_path):
"""ZIP with the pictures only (metadata/plot are separate downloads)."""
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as z:
for rec in records:
lf = rec.get("local_file")
if lf and os.path.exists(lf):
z.write(lf, arcname=rec.get("filename", Path(lf).name))
# ============================================================
# MAIN
# ============================================================
def empty_result(message):
return [], message, [], pd.DataFrame(), pd.DataFrame(), None, None, None, None
def find_images(url, max_images, min_width, min_height, remove_duplicates,
include_stream=True, max_pages=3):
url = clean_url(url)
if not url:
return empty_result("❌ Please enter a URL.")
if not valid_http_url(url):
return empty_result("❌ Invalid HTTP/HTTPS URL.")
max_images = min(int(max_images), MAX_IMAGES_HARD_LIMIT)
max_pages = max(1, int(max_pages or 1))
min_width = int(min_width or 0)
min_height = int(min_height or 0)
STREAM_STATS.clear()
job_dir = BASE_DIR / f"{int(time.time())}_{make_hash(url)}"
job_dir.mkdir(parents=True, exist_ok=True)
# ---- Find candidates ----
try:
photo_list = (
flickr_photo_and_stream(url, max_images, include_stream, max_pages)
if is_flickr_url(url) else None
)
if photo_list:
mode_message = "Flickr photo page + photostream (HTML mode, one request per photo)"
candidates = photo_list
else:
mode_message = "HTML webpage mode"
candidates = generic_page_images(url, max_images)
except Exception as exc:
return empty_result(f"❌ Could not retrieve the page.\n\n{type(exc).__name__}: {exc}")
if not candidates:
return empty_result("⚠️ No image candidates were found.")
# ---- URL deduplication ----
if remove_duplicates:
seen, unique = set(), []
for item in candidates:
key = item.get("image_url", "").split("#")[0]
if key not in seen:
seen.add(key)
unique.append(item)
candidates = unique
# ---- Download ----
candidates = candidates[: max_images + max(5, max_images // 5)]
downloaded, errors = [], []
def work(args):
index, item = args
return item, download_image(item, job_dir, index, min_width, min_height)
# Batches: stop as soon as we have max_images, and failed ones get replaced by spares.
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
for start in range(0, len(candidates), MAX_WORKERS):
if len(downloaded) >= max_images:
break
batch = list(enumerate(candidates[start:start + MAX_WORKERS], start=start + 1))
for item, (result, error) in pool.map(work, batch):
if result:
downloaded.append(result)
else:
errors.append({"url": item.get("image_url", ""), "error": error})
downloaded = downloaded[:max_images]
# ---- Exact duplicate detection ----
if remove_duplicates and downloaded:
seen_hashes, unique_dl = set(), []
for rec in downloaded:
if rec["sha256"] in seen_hashes:
try:
os.remove(rec["local_file"])
except Exception:
pass
continue
seen_hashes.add(rec["sha256"])
unique_dl.append(rec)
downloaded = unique_dl
# ---- Tables & files ----
df = records_to_dataframe(downloaded)
weekly_df = create_weekly_dataframe(df)
csv_path = job_dir / "metadata.csv"
xlsx_path = job_dir / "metadata.xlsx"
plot_path = job_dir / "weekly_picture_count.png"
df.to_csv(csv_path, index=False, encoding="utf-8-sig")
create_xlsx(df, weekly_df, xlsx_path)
plot_error = ""
try:
create_weekly_plot(weekly_df, plot_path)
except Exception as exc:
plot_error = f"{type(exc).__name__}: {exc}"
gallery_items = [
(r["local_file"], r.get("title") or r.get("filename"))
for r in downloaded if r.get("local_file")
]
lines = [
f"### {mode_message}", "",
f"**Candidates found:** {len(candidates)}",
f"**Images downloaded:** {len(downloaded)}",
f"**Failed downloads:** {len(errors)}", "",
f"**Images with publication date:** {count_nonempty(df, 'date_published')}",
f"**Images with any usable date:** {count_nonempty(df, 'date_parsed')}",
f"**Images with camera EXIF:** {count_nonempty(df, 'exif_model')}",
f"**Images with GPS EXIF:** {count_nonempty(df, 'exif_gps_latitude')}", "",
f"**Weeks represented:** {len(weekly_df)}",
]
if STREAM_STATS.get("pages") is not None and STREAM_STATS:
st = STREAM_STATS
lines += ["", "### Photostream diagnostics",
f"- Pages scraped: {st['pages']} (new photos per page: {st['ids_per_page']})",
f"- Photo pages that failed to load: {st['failed']}"]
if st.get("stop"):
lines.append(f"- Stopped because: {st['stop']}")
lines += [f"- {e}" for e in st["errors"][:3]]
lines += ["", "Requests made:"] + [f" - {d}" for d in st.get("detail", [])[:12]]
if len(downloaded) < max_images and photo_list:
lines += ["", f"⚠️ Got {len(downloaded)} of {max_images} requested. See diagnostics above."]
if plot_error:
lines += ["", f"⚠️ Weekly plot failed: {plot_error}"]
elif weekly_df.empty and downloaded:
lines += ["", "⚠️ No usable dates, so no weekly plot."]
if downloaded and count_nonempty(df, "date_published") == 0:
lines += ["", "⚠️ No publication date was found."]
if errors:
lines += ["", "### Download errors"] + [f"- {e['error']}" for e in errors[:10]]
return (
gallery_items,
"\n".join(lines),
downloaded,
df,
weekly_df,
str(plot_path) if plot_path.exists() else None,
str(xlsx_path),
str(csv_path),
str(plot_path) if plot_path.exists() else None,
)
def create_zip(items):
if not items:
return None, "❌ There are no downloaded images."
try:
job_dir = Path(items[0]["local_file"]).parent
zip_path = job_dir.parent / f"images_{time.strftime('%Y%m%d_%H%M%S')}.zip"
build_zip(items, zip_path)
size_mb = zip_path.stat().st_size / 1024 / 1024
return str(zip_path), (
f"### βœ… ZIP created\n\nImages: {len(items)}\n\nZIP size: {size_mb:.2f} MB\n\n"
"The ZIP contains the pictures only. Download the metadata and plot files separately above."
)
except Exception as exc:
return None, f"❌ ZIP creation failed: {exc}"
def clear_all():
# url, gallery, status, image_state, metadata, weekly_table, weekly_plot, xlsx, csv, plot_file, zip, zip_status
return "", [], "Ready.", [], pd.DataFrame(), pd.DataFrame(), None, None, None, None, None, ""
# ============================================================
# UI
# ============================================================
DESCRIPTION = """
## πŸ–ΌοΈ Web & Flickr Image Downloader
Download images from Flickr or ordinary webpages and extract metadata.
### Supported URLs
- **Flickr photo page:** `https://www.flickr.com/photos/<user>/with/<photo_id>`
- Any ordinary webpage or direct image URL
No Flickr API is used: everything is read from the public web pages.
### Date used for the weekly analysis (first available)
1. **Publication date** (Flickr upload date, or the page's published date on ordinary sites)
2. Flickr date taken
3. EXIF DateTimeOriginal
4. EXIF DateTime
Weeks start on Monday (ISO week numbering).
### Exports
Separate downloads: `metadata.xlsx`, `metadata.csv` and the weekly bar chart (PNG). The ZIP contains the pictures only.
"""
with gr.Blocks(title=APP_TITLE) as demo:
gr.Markdown("# πŸ–ΌοΈ Web & Flickr Image Downloader")
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column(scale=4):
url_input = gr.Textbox(
label="Webpage or Flickr URL",
placeholder="https://www.flickr.com/photos/snapnpiks0304/with/55555288566",
lines=2,
)
with gr.Column(scale=1):
max_images = gr.Slider(1, 500, value=50, step=1, label="Maximum images")
with gr.Row():
min_width = gr.Number(value=0, minimum=0, maximum=10000, step=100, label="Minimum width (px)")
min_height = gr.Number(value=0, minimum=0, maximum=10000, step=100, label="Minimum height (px)")
remove_duplicates = gr.Checkbox(value=True, label="Remove duplicates")
include_stream = gr.Checkbox(
value=True,
label="Photo page: also download the photographer's other photos",
)
max_pages = gr.Number(
value=3, minimum=1, maximum=100, step=1, precision=0,
label="Photostream pages to scrape (stops earlier if Maximum images is reached)",
)
with gr.Row():
find_button = gr.Button("πŸ”Ž Find & Analyze Images", variant="primary")
clear_button = gr.Button("🧹 Clear")
status = gr.Markdown("Ready.")
gallery = gr.Gallery(
label="Downloaded images", columns=5, rows=3,
height="auto", object_fit="contain", allow_preview=True,
)
image_state = gr.State([])
gr.Markdown("## πŸ“‹ Image metadata")
metadata_table = gr.Dataframe(value=pd.DataFrame(), interactive=False, wrap=True, label="Metadata")
gr.Markdown("## πŸ“… Pictures per week")
weekly_table = gr.Dataframe(value=pd.DataFrame(), interactive=False, label="Weekly counts")
weekly_plot = gr.Image(label="Number of pictures per week", type="filepath")
gr.Markdown("## πŸ“₯ Downloads")
with gr.Row():
xlsx_file = gr.File(label="Excel metadata")
csv_file = gr.File(label="CSV metadata")
plot_file = gr.File(label="Weekly plot (PNG)")
gr.Markdown("## πŸ“¦ Pictures ZIP")
with gr.Row():
zip_button = gr.Button("πŸ“¦ Create Pictures ZIP", variant="primary")
zip_file = gr.File(label="Pictures ZIP")
zip_status = gr.Markdown("")
find_button.click(
fn=find_images,
inputs=[url_input, max_images, min_width, min_height, remove_duplicates,
include_stream, max_pages],
outputs=[gallery, status, image_state, metadata_table, weekly_table,
weekly_plot, xlsx_file, csv_file, plot_file],
)
# The ZIP button reads the generated files straight from the file components,
# so no extra State objects (and no race condition) are needed.
zip_button.click(
fn=create_zip,
inputs=[image_state],
outputs=[zip_file, zip_status],
)
clear_button.click(
fn=clear_all,
inputs=[],
outputs=[url_input, gallery, status, image_state, metadata_table,
weekly_table, weekly_plot, xlsx_file, csv_file, plot_file, zip_file, zip_status],
)
if __name__ == "__main__":
demo.launch()