#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Iterable from urllib.parse import urlparse, unquote import requests API_URL = "https://api.superbed.cn/upload" IMAGE_MIME_PREFERENCE = [ "image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff", ] MIME_EXT = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif", "image/bmp": ".bmp", "image/tiff": ".tiff", } class UploadError(RuntimeError): pass def run_command(cmd: list[str], *, input_bytes: bytes | None = None) -> subprocess.CompletedProcess: return subprocess.run( cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) def available_clipboard_types() -> list[str]: if shutil.which("wl-paste"): proc = run_command(["wl-paste", "--list-types"]) if proc.returncode != 0: raise UploadError(f"读取剪贴板类型失败: {proc.stderr.decode('utf-8', 'ignore').strip()}") return [line.strip() for line in proc.stdout.decode("utf-8", "ignore").splitlines() if line.strip()] if shutil.which("xclip"): # X11 fallback: xclip 很难完整列出所有目标类型,这里只做常见兜底 return IMAGE_MIME_PREFERENCE + ["text/uri-list"] raise UploadError("未找到 wl-paste 或 xclip,无法读取剪贴板。请先安装 wl-clipboard。") def clipboard_image_from_wayland() -> tuple[str, bytes] | None: types = available_clipboard_types() for mime in IMAGE_MIME_PREFERENCE: if mime not in types: continue proc = run_command(["wl-paste", "--type", mime]) if proc.returncode == 0 and proc.stdout: return mime, proc.stdout return None def clipboard_image_from_x11() -> tuple[str, bytes] | None: if not shutil.which("xclip"): return None proc = run_command(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]) if proc.returncode == 0 and proc.stdout: return "image/png", proc.stdout return None def parse_file_uri(uri: str) -> Path | None: parsed = urlparse(uri.strip()) if parsed.scheme != "file": return None path = unquote(parsed.path) if not path: return None p = Path(path) return p if p.exists() else None def clipboard_file_from_wayland() -> tuple[str, bytes, str] | None: types = available_clipboard_types() if "text/uri-list" not in types or not shutil.which("wl-paste"): return None proc = run_command(["wl-paste", "--type", "text/uri-list"]) if proc.returncode != 0 or not proc.stdout: return None for raw in proc.stdout.decode("utf-8", "ignore").splitlines(): raw = raw.strip() if not raw or raw.startswith("#"): continue file_path = parse_file_uri(raw) if not file_path: continue ext = file_path.suffix.lower() mime = next((k for k, v in MIME_EXT.items() if v == ext), None) if mime is None and ext == ".jpeg": mime = "image/jpeg" if mime is None: continue data = file_path.read_bytes() return mime, data, file_path.name return None def get_clipboard_image() -> tuple[str, bytes, str]: img = clipboard_image_from_wayland() if img is not None: mime, data = img return mime, data, f"clipboard{MIME_EXT.get(mime, '.png')}" img = clipboard_image_from_x11() if img is not None: mime, data = img return mime, data, f"clipboard{MIME_EXT.get(mime, '.png')}" file_clip = clipboard_file_from_wayland() if file_clip is not None: return file_clip raise UploadError("剪贴板里没有图片数据。请先复制截图或图片。") def build_form(args: argparse.Namespace) -> dict[str, str]: token = args.token or os.environ.get("SUPERBED_TOKEN", "").strip() if not token: raise UploadError("未提供 token。请设置环境变量 SUPERBED_TOKEN,或用 --token 传入。") form: dict[str, str] = {"token": token} if args.categories: form["categories"] = args.categories if args.filename: form["filename"] = args.filename if args.watermark is not None: form["watermark"] = "true" if args.watermark else "false" if args.compress is not None: form["compress"] = "true" if args.compress else "false" if args.webp is not None: form["webp"] = "true" if args.webp else "false" return form def upload_image(args: argparse.Namespace, mime: str, data: bytes, filename: str) -> str: form = build_form(args) upload_name = args.filename or filename files = {"file": (upload_name, data, mime)} try: resp = requests.post(API_URL, data=form, files=files, timeout=args.timeout) resp.raise_for_status() except requests.RequestException as exc: raise UploadError(f"网络请求失败: {exc}") from exc try: payload = resp.json() except json.JSONDecodeError as exc: raise UploadError(f"接口返回的不是合法 JSON: {resp.text[:200]}") from exc if payload.get("err") != 0: raise UploadError(str(payload.get("msg") or payload)) url = payload.get("url") if not url: raise UploadError(f"接口未返回 url: {payload}") return str(url) def write_clipboard_text(text: str) -> None: encoded = text.encode("utf-8") if shutil.which("wl-copy"): proc = run_command(["wl-copy", "--type", "text/plain;charset=utf-8"], input_bytes=encoded) if proc.returncode == 0: return if shutil.which("xclip"): proc = run_command(["xclip", "-selection", "clipboard"], input_bytes=encoded) if proc.returncode == 0: return raise UploadError("上传成功,但写回剪贴板失败:未找到 wl-copy 或 xclip。") def qml_toast(message: str, success: bool) -> bool: qmlscene = shutil.which("qmlscene6") or shutil.which("qmlscene") if not qmlscene: return False bg = "#166534" if success else "#991b1b" border = "#4ade80" if success else "#f87171" text = json.dumps(message, ensure_ascii=False) qml = f"""import QtQuick 2.15 import QtQuick.Window 2.15 Window {{ id: root visible: true color: "transparent" opacity: 0.0 flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus property string toastText: {text} width: Math.min(Screen.width * 0.88, Math.max(300, label.implicitWidth + 48)) height: Math.max(52, label.paintedHeight + 26) Rectangle {{ id: bubble anchors.fill: parent radius: 16 color: "{bg}" border.width: 1 border.color: "{border}" opacity: 0.84 antialiasing: true }} Text {{ id: label anchors.centerIn: parent width: root.width - 28 text: root.toastText wrapMode: Text.WrapAtWordBoundaryOrAnywhere horizontalAlignment: Text.AlignHCenter color: "white" font.pixelSize: 14 font.bold: true }} Component.onCompleted: {{ x = Math.round((Screen.width - width) / 2) y = Math.round(Screen.height - height - Math.max(48, Screen.height * 0.08)) fadeAnim.start() }} SequentialAnimation {{ id: fadeAnim running: false NumberAnimation {{ target: root property: "opacity" from: 0.0 to: 1.0 duration: 180 easing.type: Easing.OutCubic }} PauseAnimation {{ duration: 1800 }} NumberAnimation {{ target: root property: "opacity" from: 1.0 to: 0.0 duration: 260 easing.type: Easing.InCubic }} onStopped: Qt.quit() }} }} """ tmp = None try: tmp = tempfile.NamedTemporaryFile("w", suffix=".qml", delete=False, encoding="utf-8") tmp.write(qml) tmp.flush() tmp.close() subprocess.Popen( [qmlscene, tmp.name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) return True except Exception: return False def notify_send(message: str, success: bool) -> bool: if not shutil.which("notify-send"): return False title = "✅ 图片上传成功" if success else "❌ 图片上传失败" icon = "dialog-information" if success else "dialog-error" try: subprocess.Popen( ["notify-send", "-a", "Superbed Upload", "-i", icon, title, message], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) return True except Exception: return False def show_toast(message: str, success: bool) -> None: prefix = "✅" if success else "❌" decorated_message = f"{prefix} {message}" if qml_toast(decorated_message, success): return notify_send(decorated_message, success) def parse_args(argv: Iterable[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="上传剪贴板图片到聚合图床,并把 Markdown 链接写回剪贴板。") parser.add_argument("--token", help="图床 token;不传则读取环境变量 SUPERBED_TOKEN") parser.add_argument("--categories", help="相册,多个用英文逗号分隔") parser.add_argument("--filename", help="自定义上传文件名") parser.add_argument("--timeout", type=int, default=30, help="上传超时秒数,默认 30") parser.add_argument("--markdown-template", default="![]({url})", help="剪贴板写回模板,默认 ![]({url})") parser.add_argument("--watermark", dest="watermark", action="store_true", help="强制开启水印") parser.add_argument("--no-watermark", dest="watermark", action="store_false", help="强制关闭水印") parser.set_defaults(watermark=None) parser.add_argument("--compress", dest="compress", action="store_true", help="强制开启压缩") parser.add_argument("--no-compress", dest="compress", action="store_false", help="强制关闭压缩") parser.set_defaults(compress=None) parser.add_argument("--webp", dest="webp", action="store_true", help="强制转为 webp") parser.add_argument("--no-webp", dest="webp", action="store_false", help="强制关闭 webp") parser.set_defaults(webp=None) return parser.parse_args(list(argv)) def main(argv: Iterable[str]) -> int: args = parse_args(argv) try: mime, data, filename = get_clipboard_image() url = upload_image(args, mime, data, filename) markdown = args.markdown_template.format(url=url, filename=filename) write_clipboard_text(markdown) print(markdown) show_toast("图片上传成功,Markdown 已复制到剪贴板", True) return 0 except UploadError as exc: msg = f"图片上传失败,原因:{exc}" print(msg, file=sys.stderr) show_toast(msg, False) return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))