feat: add PySide6 clipboard uploader with Nuitka release tooling
This commit is contained in:
351
superbed_clipboard_upload.py
Normal file
351
superbed_clipboard_upload.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user