Files
manjaro_kde_upload_tools/superbed_clipboard_upload.py

352 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:]))