2026-08-24 14:32:48 -04:00
|
|
|
import io
|
|
|
|
|
import json
|
2026-09-08 22:55:59 +05:30
|
|
|
import logging
|
2026-08-24 14:32:48 -04:00
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
2026-09-08 22:55:59 +05:30
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-08-24 14:32:48 -04:00
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# CONFIG
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
API_URL = "https://commons.wikimedia.org/w/api.php"
|
|
|
|
|
|
2026-08-25 22:09:50 -04:00
|
|
|
OUTPUT_FILE = Path("visual_search.jpg")
|
2026-08-25 22:14:13 -04:00
|
|
|
METADATA_FILE = Path("visual_search.json")
|
2026-08-24 14:32:48 -04:00
|
|
|
|
|
|
|
|
MAX_ATTEMPTS = 10
|
|
|
|
|
|
|
|
|
|
# Loose filtering
|
|
|
|
|
MIN_WIDTH = 300
|
|
|
|
|
MIN_HEIGHT = 300
|
|
|
|
|
|
|
|
|
|
# Maximum original file size we'll accept
|
|
|
|
|
MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
|
|
|
|
|
|
|
|
|
|
# Wikimedia will generate a thumbnail around this width
|
|
|
|
|
THUMBNAIL_WIDTH = 1280
|
|
|
|
|
|
|
|
|
|
# Normal delay between failed attempts
|
|
|
|
|
REQUEST_DELAY = 3
|
|
|
|
|
|
|
|
|
|
# Maximum rate-limit wait
|
|
|
|
|
MAX_BACKOFF = 300
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# HTTP SESSION
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
|
|
|
|
|
session.headers.update({
|
2026-09-07 10:13:24 -04:00
|
|
|
"User-Agent": (
|
|
|
|
|
"RandomVisualSearchImage/1.1 "
|
|
|
|
|
"(contact: 12345rfdz@gmail.com)"
|
|
|
|
|
)
|
2026-08-24 14:32:48 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# URL HELPERS
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
def clean_url(url):
|
2026-09-07 10:13:24 -04:00
|
|
|
"""Remove query parameters from Wikimedia URLs."""
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
parts = urlsplit(url)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
return urlunsplit((
|
|
|
|
|
parts.scheme,
|
|
|
|
|
parts.netloc,
|
|
|
|
|
parts.path,
|
|
|
|
|
"",
|
|
|
|
|
"",
|
|
|
|
|
))
|
2026-08-24 14:32:48 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# RATE LIMIT HANDLING
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
def wait_after_429(response, attempt):
|
2026-09-07 10:13:24 -04:00
|
|
|
"""Wait according to Wikimedia's Retry-After header."""
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
retry_after = response.headers.get("Retry-After")
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
if retry_after:
|
|
|
|
|
try:
|
|
|
|
|
wait_time = int(retry_after)
|
|
|
|
|
except ValueError:
|
|
|
|
|
wait_time = min(
|
|
|
|
|
2 ** attempt,
|
|
|
|
|
MAX_BACKOFF,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
wait_time = min(
|
|
|
|
|
2 ** attempt,
|
|
|
|
|
MAX_BACKOFF,
|
|
|
|
|
)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
wait_time = max(5, wait_time)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Rate limited. Waiting %d seconds...", wait_time)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
time.sleep(wait_time)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# DOWNLOAD
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
def download_image(url):
|
2026-09-07 10:13:24 -04:00
|
|
|
"""Download image bytes from Wikimedia."""
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
url = clean_url(url)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
try:
|
|
|
|
|
response = session.get(
|
|
|
|
|
url,
|
|
|
|
|
timeout=30,
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 23:55:04 +05:30
|
|
|
except (requests.RequestException, ConnectionResetError, OSError) as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Download failed: %s", e)
|
2026-09-07 10:13:24 -04:00
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
if response.status_code == 429:
|
|
|
|
|
wait_after_429(response, 1)
|
|
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
if response.status_code == 403:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Wikimedia returned 403 Forbidden.")
|
2026-09-07 10:13:24 -04:00
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
try:
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
except requests.RequestException as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("HTTP error: %s", e)
|
2026-09-07 10:13:24 -04:00
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
content_type = response.headers.get(
|
|
|
|
|
"Content-Type",
|
|
|
|
|
"",
|
|
|
|
|
).lower()
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
if not content_type.startswith("image/"):
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Not an image: %s", content_type)
|
2026-09-07 10:13:24 -04:00
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
if not response.content:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Downloaded image is empty.")
|
2026-09-07 10:13:24 -04:00
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
return response.content
|
2026-08-24 14:32:48 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-08-25 22:09:50 -04:00
|
|
|
# JPEG CONVERSION
|
2026-08-24 14:32:48 -04:00
|
|
|
# ============================================================
|
|
|
|
|
|
2026-08-25 22:09:50 -04:00
|
|
|
def convert_to_jpeg(image_data):
|
2026-09-07 10:13:24 -04:00
|
|
|
"""Convert downloaded image bytes to JPEG."""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with Image.open(
|
|
|
|
|
io.BytesIO(image_data)
|
|
|
|
|
) as image:
|
|
|
|
|
|
|
|
|
|
# JPEG does not support alpha (transparency).
|
|
|
|
|
# If the image has transparency (RGBA or LA), paste it over a white background.
|
|
|
|
|
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
|
|
|
|
background = Image.new("RGB", image.size, (255, 255, 255))
|
|
|
|
|
if image.mode == "P":
|
|
|
|
|
image = image.convert("RGBA")
|
|
|
|
|
background.paste(image, mask=image.split()[-1])
|
|
|
|
|
jpeg_image = background
|
|
|
|
|
else:
|
|
|
|
|
jpeg_image = image.convert("RGB")
|
|
|
|
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
|
|
|
|
jpeg_image.save(
|
|
|
|
|
output,
|
|
|
|
|
format="JPEG",
|
|
|
|
|
quality=90,
|
|
|
|
|
optimize=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("JPEG conversion failed: %s", e)
|
2026-09-07 10:13:24 -04:00
|
|
|
return None
|
2026-08-24 14:32:48 -04:00
|
|
|
|
|
|
|
|
|
2026-08-29 23:41:57 +05:30
|
|
|
def generate_fallback_image():
|
2026-09-07 23:55:04 +05:30
|
|
|
"""Generate a synthetic local JPEG image using PIL as a fallback."""
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.info("Generating synthetic local fallback image for visual search...")
|
2026-09-07 23:55:04 +05:30
|
|
|
from PIL import ImageDraw
|
|
|
|
|
import random
|
|
|
|
|
|
|
|
|
|
img = Image.new("RGB", (800, 600), color=(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200)))
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
|
for _ in range(10):
|
|
|
|
|
x0 = random.randint(0, 700)
|
|
|
|
|
y0 = random.randint(0, 500)
|
|
|
|
|
x1 = x0 + random.randint(50, 200)
|
|
|
|
|
y1 = y0 + random.randint(50, 200)
|
|
|
|
|
fill = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
|
|
|
|
draw.rectangle([x0, y0, x1, y1], fill=fill)
|
|
|
|
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
img.save(output, format="JPEG", quality=90)
|
|
|
|
|
jpeg_data = output.getvalue()
|
|
|
|
|
|
|
|
|
|
OUTPUT_FILE.write_bytes(jpeg_data)
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.info("Saved fallback image to %s", OUTPUT_FILE.absolute())
|
2026-09-07 23:55:04 +05:30
|
|
|
return {"title": "Fallback Synthetic Image", "width": 800, "height": 600}
|
2026-08-29 23:41:57 +05:30
|
|
|
|
|
|
|
|
|
2026-08-24 14:32:48 -04:00
|
|
|
# ============================================================
|
|
|
|
|
# RANDOM IMAGE
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
def get_random_image():
|
|
|
|
|
|
2026-09-07 10:13:24 -04:00
|
|
|
for attempt in range(
|
|
|
|
|
1,
|
|
|
|
|
MAX_ATTEMPTS + 1,
|
|
|
|
|
):
|
|
|
|
|
|
|
|
|
|
if attempt > 1:
|
|
|
|
|
time.sleep(REQUEST_DELAY)
|
|
|
|
|
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Attempt %d/%d", attempt, MAX_ATTEMPTS)
|
2026-09-07 10:13:24 -04:00
|
|
|
|
|
|
|
|
params = {
|
|
|
|
|
"action": "query",
|
|
|
|
|
"format": "json",
|
|
|
|
|
|
|
|
|
|
"generator": "random",
|
|
|
|
|
"grnnamespace": 6,
|
|
|
|
|
"grnlimit": 1,
|
|
|
|
|
|
|
|
|
|
"prop": "imageinfo",
|
|
|
|
|
|
|
|
|
|
"iiprop": (
|
|
|
|
|
"url|size|mime|dimensions"
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
"iiurlwidth": THUMBNAIL_WIDTH,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = session.get(
|
|
|
|
|
API_URL,
|
|
|
|
|
params=params,
|
|
|
|
|
timeout=20,
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-07 23:55:04 +05:30
|
|
|
except (requests.RequestException, ConnectionResetError, OSError) as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("API request failed: %s", e)
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if response.status_code == 429:
|
|
|
|
|
wait_after_429(
|
|
|
|
|
response,
|
|
|
|
|
attempt,
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
|
|
|
|
except (
|
|
|
|
|
requests.RequestException,
|
|
|
|
|
ValueError,
|
|
|
|
|
) as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("API error: %s", e)
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
pages = (
|
|
|
|
|
data
|
|
|
|
|
.get("query", {})
|
|
|
|
|
.get("pages", {})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not pages:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("No page returned.")
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
page = next(
|
|
|
|
|
iter(pages.values())
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
title = page.get(
|
|
|
|
|
"title",
|
|
|
|
|
"Unknown",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
imageinfo = page.get(
|
|
|
|
|
"imageinfo"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not imageinfo:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("No image information.")
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
info = imageinfo[0]
|
|
|
|
|
|
|
|
|
|
mime = info.get(
|
|
|
|
|
"mime",
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
width = info.get(
|
|
|
|
|
"width",
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
height = info.get(
|
|
|
|
|
"height",
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
size = info.get(
|
|
|
|
|
"size",
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
thumbnail_url = info.get(
|
|
|
|
|
"thumburl"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
original_url = info.get(
|
|
|
|
|
"url"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if mime not in {
|
|
|
|
|
"image/jpeg",
|
|
|
|
|
"image/png",
|
|
|
|
|
"image/webp",
|
|
|
|
|
}:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Skipping unsupported type: %s", mime)
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if width < MIN_WIDTH or height < MIN_HEIGHT:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Skipping small image: %dx%d", width, height)
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if size > MAX_FILE_SIZE:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Skipping large image: %.1f MB", size / 1024 / 1024)
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if not thumbnail_url:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("No thumbnail URL.")
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Found: %s (%dx%d)", title, width, height)
|
2026-09-07 10:13:24 -04:00
|
|
|
|
|
|
|
|
image_data = download_image(
|
|
|
|
|
thumbnail_url
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if image_data is None and original_url:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Thumbnail download failed, trying original URL...")
|
2026-09-07 10:13:24 -04:00
|
|
|
|
|
|
|
|
image_data = download_image(
|
|
|
|
|
original_url
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if image_data is None:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Couldn't download image.")
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.debug("Converting to JPEG...")
|
2026-09-07 10:13:24 -04:00
|
|
|
|
|
|
|
|
jpeg_data = convert_to_jpeg(
|
|
|
|
|
image_data
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if jpeg_data is None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
OUTPUT_FILE.write_bytes(
|
|
|
|
|
jpeg_data
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except OSError as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Couldn't save image: %s", e)
|
2026-09-07 10:13:24 -04:00
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
metadata = {
|
|
|
|
|
"title": title,
|
|
|
|
|
"source": "Wikimedia Commons",
|
|
|
|
|
"output_format": "JPEG",
|
|
|
|
|
|
|
|
|
|
"width": width,
|
|
|
|
|
"height": height,
|
|
|
|
|
|
|
|
|
|
"original_mime": mime,
|
|
|
|
|
|
|
|
|
|
"original_size": size,
|
|
|
|
|
|
|
|
|
|
"jpeg_size": len(
|
|
|
|
|
jpeg_data
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
"original_url": (
|
|
|
|
|
clean_url(original_url)
|
|
|
|
|
if original_url
|
|
|
|
|
else None
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
"thumbnail_url": (
|
|
|
|
|
clean_url(thumbnail_url)
|
|
|
|
|
if thumbnail_url
|
|
|
|
|
else None
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
METADATA_FILE.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
metadata,
|
|
|
|
|
indent=4,
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except OSError as e:
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Couldn't save metadata: %s", e)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Visual search image saved: %s (%s, %.1f KB, source: %s)",
|
|
|
|
|
OUTPUT_FILE.absolute(),
|
|
|
|
|
f"{width}x{height}",
|
|
|
|
|
len(jpeg_data) / 1024,
|
|
|
|
|
title,
|
2026-09-07 10:13:24 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return metadata
|
|
|
|
|
|
2026-09-08 22:55:59 +05:30
|
|
|
logger.warning("Could not download image from Wikimedia Commons. Generating local fallback image.")
|
2026-09-07 23:55:04 +05:30
|
|
|
return generate_fallback_image()
|
2026-08-29 23:41:57 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# MAIN
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-09-07 10:13:24 -04:00
|
|
|
get_random_image()
|