# -*- coding: utf-8 -*- r'''通过 Halo API 把文章直接写进去。 == 为什么要走 API == 用户试过复制粘贴,但 Halo 富文本编辑器老把内容吞进代码块。 与其来回折腾,不如**直接调 API 建文章** —— 一次到位, 而且以后想再发文章也能复用这个脚本。 == ⚠ 密码怎么处理 == 密码存在 `_build/.halo_password`(**已经加进 .gitignore**)。 脚本本身**不含任何密码** —— 从文件读。 这一点很重要:这个密码和用户的中转站管理密码**是同一个**, 写进仓库就等于全泄漏了。 == Halo 2.x 的建文章 API == POST /apis/api.console.halo.run/v1alpha1/posts Authorization: Basic base64(user:pass) Content-Type: application/json 请求体是 `PostRequest`: { "post": { <- Post 资源本身 "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", "metadata": {"name": "post-xxx"}, "spec": { "title": "...", "slug": "...", "publish": true, "publishTime": "...", "visible": "PUBLIC", "allowComment": true, "excerpt": {"autoGenerate": true, "raw": ""}, "categories": [], "tags": [], "htmlMetas": [] } }, "content": { <- 正文单独放 "raw": "...", "content": "...", "rawType": "markdown" } } **注意 `content` 要同时给 `raw`(源码)和 `content`(渲染后的 HTML)**。 `raw` 给 Markdown,`content` 给 HTML —— Halo 前端直接读 `content`。 两个都给的话,编辑器里也能正常显示源码。 ''' from __future__ import annotations import base64 import json import sys import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "_build")) from lssh import run # noqa: E402 PWFILE = ROOT / "_build" / ".halo_password" # ---------------------------------------------------------------- 文章正文 # 用 Markdown 写(API 的 raw 字段就是 Markdown),Halo 会渲染成网页。 # 内容只提官网,不提任何管理后台 —— 对外材料清洁度要求。 TITLE = "远控软件正式上线" SLUG = "remote-control-launch" EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕," "就能操作教室那台电脑:远程鼠标、投图到大屏、语音播报、" "静默自动更新。官网 yk.xxmxxs.top") MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。 ## 它是干什么的 很多场景下,人不在电脑前面,但需要操作那台电脑: - 上课时站在教室中间,想切个课件、放张图 - 坐在办公室,想看看教室那台电脑还开着没 - 手上拿着东西,腾不出手去碰鼠标键盘 **这个软件就是解决这个的。** 电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。 ## 能做什么 ### 远程鼠标控制 手表变成触摸板: - **拖动** —— 移动鼠标 - **单击** —— 左键 - **双击** —— 右键 - 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮 精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。 ### 一键显示桌面 快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。 需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。 ### 电脑端静默自动更新 这是最省心的一点: **手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。** - 全程**不弹任何窗口** - 中转地址、配对码、所有设置**一个都不变** - 更新完自动回来继续跑 不用再跑到电脑前手动装一遍。 ### 顶部快捷栏 任何页面顶上都有一条栏,**4 个格子全部可以自己定义**: | 可选动作 | 说明 | |---|---| | 传文件 | 从手表把文件发到电脑 | | 屏幕镜像 | 看电脑屏幕 | | 鼠标控制 | 进触摸板模式 | | 音效 | 让电脑放一声 | | 关机 | 锁屏 / 关机 / 重启 | | 语音播报 | 让电脑念一段字 | | 投图到电脑 | 手表选图,全屏投到大屏 | | 显示桌面 | 等于 Win + D | 在设置页里点一下就能换。 ### 投图到大屏 手表上选一张图,直接全屏显示在电脑(也就是投影)上。 上课讲题、展示作业、临时放张图,比插 U 盘快得多。 ### 语音播报 输入文字,电脑用系统自带的语音念出来。 「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。 ### 让电脑放个音效 内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。 ### 其他 - 剪贴板互传(手表和电脑之间) - 电源控制(锁屏、关机、重启) - 文件互传 - 静音、音量加减、播放控制 ## 几个设计上的取舍 ### 电脑端不弹任何窗口 屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。 这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。 ### 支持 Windows 7 老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。 ### 静默更新为什么要校验 自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。 所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。 ## 怎么装 **官网下载:** - **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事 - **手表端** —— 下载 APK 安装,输入刚才那个 6 位码 装完之后: 1. 手表连上电脑 2. 会先到一个选择页:**远控** / **静默更新被控端** 3. 点「远控」进控制页 ## 说在最后 这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。 后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。 **有问题、想要什么功能,欢迎留言。** """ def md_to_html(text: str) -> str: """把这份固定的 Markdown 转成 HTML。 ⚠ 这里**不是在写通用 Markdown 解析器** —— 那样要引第三方库, 而我们的正文是我自己写的、结构已知。用简单的正则按块处理就够了, 而且不会因为库的行为差异出意外。 支持:## / ### 标题、**粗体**、`代码`、- 列表、1. 列表、 表格、> 引用、 自动链接、空行分段。 """ import html as _html import re as _re def inline(s: str) -> str: # 先转义,避免正文里的 < > 破坏 HTML s = _html.escape(s, quote=False) s = _re.sub(r"\*\*(.+?)\*\*", r"\1", s) s = _re.sub(r"`([^`]+)`", r"\1", s) s = _re.sub(r"<(https?://[^&]+?)>", r'\1', s) return s out = [] lines = text.split("\n") i = 0 while i < len(lines): ln = lines[i] if not ln.strip(): i += 1 continue # 表格 if ln.startswith("|") and i + 1 < len(lines) and \ set(lines[i + 1].replace("|", "").strip()) <= set("-: "): head = [c.strip() for c in ln.strip("|").split("|")] i += 2 rows = [] while i < len(lines) and lines[i].startswith("|"): rows.append([c.strip() for c in lines[i].strip("|").split("|")]) i += 1 out.append("" + "".join("" % inline(c) for c in head) + "" + "".join("" + "".join("" % inline(c) for c in r) + "" for r in rows) + "
%s
%s
") continue # 标题 m = _re.match(r"^(#{2,4})\s+(.*)$", ln) if m: lvl = len(m.group(1)) out.append("%s" % (lvl, inline(m.group(2)), lvl)) i += 1 continue # 列表 if _re.match(r"^[-*]\s+", ln): items = [] while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]): items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i]))) i += 1 out.append("") continue if _re.match(r"^\d+\.\s+", ln): items = [] while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]): items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i]))) i += 1 out.append("
    " + "".join("
  1. %s
  2. " % x for x in items) + "
") continue # 引用 if ln.startswith(">"): out.append("
%s
" % inline(ln.lstrip("> ").strip())) i += 1 continue # 普通段落(连续的合成一段) para = [ln.strip()] i += 1 while i < len(lines) and lines[i].strip() and \ not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]): para.append(lines[i].strip()) i += 1 out.append("

%s

" % inline(" ".join(para))) return "\n".join(out) def main() -> int: print("=" * 62) print(" 通过 Halo API 发布文章") print("=" * 62) print() if not PWFILE.is_file(): print(" ✗ 找不到 %s" % PWFILE) return 1 pw = PWFILE.read_text(encoding="utf-8").strip() print(" 密码已从 %s 读到(长度 %d)" % (PWFILE.name, len(pw))) # 1) 先测认证 print() print("── 1. 测认证 ──") out = run(r''' curl -s -o /dev/null -w " basic auth 打 posts -> HTTP %{http_code}\n" \ --max-time 10 -u "xmz7891:__PW__" \ http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts curl -s -o /dev/null -w " basic auth 打 users/- -> HTTP %{http_code}\n" \ --max-time 10 -u "xmz7891:__PW__" \ http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/users/- '''.replace("__PW__", pw), timeout=200, quiet=True) print(out[1] if isinstance(out, tuple) else out) # 2) 看现在有几篇文章 print("── 2. 现在的文章 ──") out = run(r''' curl -s --max-time 10 -u "xmz7891:__PW__" \ "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \ | python3 -c " import json,sys try: d = json.load(sys.stdin) except Exception as e: print(' 解析失败:', e); sys.exit() items = d.get('items') or [] print(' 共', d.get('total', len(items)), '篇') for p in items: sp = p.get('spec', {}) print(' -', sp.get('title'), '| publish =', sp.get('publish'), '| name =', (p.get('metadata') or {}).get('name')) " '''.replace("__PW__", pw), timeout=200, quiet=True) print(out[1] if isinstance(out, tuple) else out) return 0 if __name__ == "__main__": sys.exit(main())