import os
import re
import threading
import queue
import time
from urllib.parse import urlparse, unquote
import tkinter as tk
from tkinter import ttk, messagebox
import requests
from PIL import Image, ImageOps
from io import BytesIO
APP_NAME = "ImageDownloader"
THUMB_W, THUMB_H = 310, 207
TIMEOUT = 25 # seconds
UA = "ImageDownloader/1.0 (+Tkinter; Kelvin workflow)"
def desktop_output_dir() -> str:
home = os.path.expanduser("~")
desktop = os.path.join(home, "Desktop")
out_dir = os.path.join(desktop, APP_NAME)
os.makedirs(out_dir, exist_ok=True)
return out_dir
def safe_filename(name: str, max_len: int = 120) -> str:
# Remove illegal filename characters across macOS/Windows, trim spaces/dots
name = name.strip().strip(".")
name = re.sub(r"[\\/:*?\"<>|\n\r\t]+", " ", name)
name = re.sub(r"\s+", " ", name).strip()
if not name:
name = "untitled"
if len(name) > max_len:
name = name[:max_len].rstrip()
return name
def filename_stem_from_url(url: str) -> str:
try:
p = urlparse(url)
path = unquote(p.path or "")
base = os.path.basename(path)
if not base:
return "image"
# remove query-like suffixes that somehow got into path
base = base.split("?")[0].split("#")[0]
stem, _ext = os.path.splitext(base)
return safe_filename(stem) if stem else "image"
except Exception:
return "image"
def extract_title_from_html(html: str) -> str | None:
# Light-weight
parsing to avoid extra dependency.
# Works for most normal pages.
m = re.search(r"]*>(.*?)", html, flags=re.IGNORECASE | re.DOTALL)
if not m:
return None
title = m.group(1)
# remove extra whitespace / entities (basic)
title = re.sub(r"\s+", " ", title).strip()
# handle a few common entities
title = title.replace("&", "&").replace("<", "<").replace(">", ">").replace(""", '"').replace("'", "'")
return title or None
def ensure_unique_path(path: str) -> str:
if not os.path.exists(path):
return path
root, ext = os.path.splitext(path)
i = 2
while True:
candidate = f"{root}-{i}{ext}"
if not os.path.exists(candidate):
return candidate
i += 1
def make_ok_thumbnail(img: Image.Image) -> Image.Image:
# Resize to exact 310x207 (stretch). If you later want "cover" crop or "contain", we can adjust.
resized = img.resize((THUMB_W, THUMB_H), Image.LANCZOS)
# Add 2px black border, then 1px white border
bordered = ImageOps.expand(resized, border=2, fill="black")
bordered = ImageOps.expand(bordered, border=1, fill="white")
return bordered
class ImageDownloaderApp(tk.Tk):
def __init__(self):
super().__init__()
self.title(APP_NAME)
self.geometry("860x560")
self.out_dir = desktop_output_dir()
self.msg_q: queue.Queue = queue.Queue()
self.worker_thread: threading.Thread | None = None
self.stop_flag = threading.Event()
self._build_ui()
self._poll_queue()
def _build_ui(self):
pad = 10
top = ttk.Frame(self)
top.pack(fill="x", padx=pad, pady=(pad, 6))
title = ttk.Label(top, text="貼上圖片或網頁網址(支援多行;一行一個網址)", font=("Helvetica", 14))
title.pack(anchor="w")
sub = ttk.Label(top, text=f"輸出資料夾:{self.out_dir}", foreground="#444")
sub.pack(anchor="w", pady=(4, 0))
mid = ttk.Frame(self)
mid.pack(fill="both", expand=True, padx=pad, pady=(0, 6))
self.txt = tk.Text(mid, height=10, wrap="none")
self.txt.pack(fill="both", expand=True, side="left")
scroll_y = ttk.Scrollbar(mid, orient="vertical", command=self.txt.yview)
scroll_y.pack(side="right", fill="y")
self.txt.configure(yscrollcommand=scroll_y.set)
controls = ttk.Frame(self)
controls.pack(fill="x", padx=pad, pady=(0, pad))
self.btn_start = ttk.Button(controls, text="開始下載", command=self.on_start)
self.btn_start.pack(side="left")
self.btn_clear = ttk.Button(controls, text="清空輸入", command=self.on_clear)
self.btn_clear.pack(side="left", padx=(8, 0))
self.btn_open = ttk.Button(controls, text="開啟輸出資料夾", command=self.on_open_folder)
self.btn_open.pack(side="left", padx=(8, 0))
self.progress = ttk.Progressbar(controls, mode="indeterminate")
self.progress.pack(side="right", fill="x", expand=True, padx=(10, 0))
bottom = ttk.Frame(self)
bottom.pack(fill="both", expand=False, padx=pad, pady=(0, pad))
self.lbl_last = ttk.Label(bottom, text="狀態:等待中")
self.lbl_last.pack(anchor="w")
self.log = tk.Text(bottom, height=12, wrap="word", state="disabled")
self.log.pack(fill="both", expand=True, pady=(6, 0))
def log_line(self, s: str):
self.log.configure(state="normal")
self.log.insert("end", s + "\n")
self.log.see("end")
self.log.configure(state="disabled")
def set_status(self, s: str):
self.lbl_last.configure(text=f"狀態:{s}")
def on_clear(self):
self.txt.delete("1.0", "end")
self.txt.focus_set()
def on_open_folder(self):
# macOS: open folder
try:
if os.name == "posix":
os.system(f'open "{self.out_dir}"')
else:
os.startfile(self.out_dir) # type: ignore[attr-defined]
except Exception as e:
messagebox.showerror("錯誤", f"無法開啟資料夾:{e}")
def on_start(self):
if self.worker_thread and self.worker_thread.is_alive():
messagebox.showinfo("進行中", "目前正在下載中,請稍候。")
return
raw = self.txt.get("1.0", "end").strip()
urls = [line.strip() for line in raw.splitlines() if line.strip()]
if not urls:
messagebox.showwarning("沒有網址", "請先貼上至少一個網址(每行一個)。")
return
# UI: start progress, disable button
self.btn_start.configure(state="disabled")
self.progress.start(10)
self.set_status("下載中…")
self.stop_flag.clear()
# Start worker
self.worker_thread = threading.Thread(target=self._worker, args=(urls,), daemon=True)
self.worker_thread.start()
def _worker(self, urls: list[str]):
session = requests.Session()
session.headers.update({"User-Agent": UA})
for idx, url in enumerate(urls, start=1):
if self.stop_flag.is_set():
break
self.msg_q.put(("status", f"處理第 {idx}/{len(urls)} 個:{url}"))
self.msg_q.put(("log", f"[{idx}/{len(urls)}] {url}"))
try:
result = self._process_one(session, url)
# result: dict with title_used, size, saved_paths
px = result["size"]
title_used = result["title"]
png_path = result["png_path"]
ok_path = result["ok_path"]
self.msg_q.put(("log", f" ? 尺寸:{px[0]} × {px[1]} px"))
self.msg_q.put(("log", f" ? 檔名:{title_used}"))
self.msg_q.put(("log", f" ? 原始PNG:{png_path}"))
self.msg_q.put(("log", f" ? OK縮圖:{ok_path}"))
self.msg_q.put(("status", f"完成:{px[0]}×{px[1]} px ;已輸出 2 個檔案"))
except Exception as e:
self.msg_q.put(("log", f" ? 失敗:{e}"))
self.msg_q.put(("status", f"失敗:{e}"))
# done
self.msg_q.put(("done", None))
def _process_one(self, session: requests.Session, url: str) -> dict:
# Step A: GET url
r = session.get(url, timeout=TIMEOUT)
r.raise_for_status()
ct = (r.headers.get("Content-Type") or "").lower()
title = None
img = None
if "text/html" in ct or (r.text and r.text.lstrip().startswith("<")):
# It's an HTML page: parse title
html = r.text
title = extract_title_from_html(html) or "untitled-page"
# If it is HTML, we don't know which image to download without extra rules.
# For your current workflow, you are providing image URLs directly most of the time.
# We'll raise a clear error to avoid silently doing the wrong thing.
raise RuntimeError("此網址看起來是網頁(HTML)。目前版本請貼「圖片檔網址」(jpg/png/webp)。之後需要我也可以加『從網頁抓第一張大圖/og:image』的功能。")
else:
# Assume image bytes
img = Image.open(BytesIO(r.content))
# Normalize mode for PNG (handles P, RGBA, etc.)
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
# Title fallback: image url filename stem
title = filename_stem_from_url(url)
title = safe_filename(title)
out_dir = self.out_dir
# Step B: Save original PNG
png_path = os.path.join(out_dir, f"{title}.png")
png_path = ensure_unique_path(png_path)
img.save(png_path, format="PNG")
# Step C: Create OK thumbnail and save
ok_img = make_ok_thumbnail(img)
ok_path = os.path.join(out_dir, f"OK-{os.path.splitext(os.path.basename(png_path))[0]}.png")
ok_path = ensure_unique_path(ok_path)
ok_img.save(ok_path, format="PNG")
return {
"title": os.path.splitext(os.path.basename(png_path))[0],
"size": img.size, # (w, h)
"png_path": png_path,
"ok_path": ok_path,
}
def _poll_queue(self):
try:
while True:
kind, payload = self.msg_q.get_nowait()
if kind == "log":
self.log_line(str(payload))
elif kind == "status":
self.set_status(str(payload))
elif kind == "done":
# Finish UI
self.progress.stop()
self.btn_start.configure(state="normal")
self.set_status("全部處理完成")
# Clear input box per your requirement
self.on_clear()
except queue.Empty:
pass
self.after(120, self._poll_queue)
if __name__ == "__main__":
try:
app = ImageDownloaderApp()
app.mainloop()
except Exception as e:
messagebox.showerror("啟動失敗", str(e))