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"<title[^>]*>(.*?)", 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))