352 lines
11 KiB
Python
352 lines
11 KiB
Python
#!/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="", help="剪贴板写回模板,默认 ")
|
||
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:]))
|