refactor: rewrite in Python (#7)

* refactor: rewrite destVersionForMac in python; remove redundant code & files; new workflow parameter: Force create release using latest WeChat dmg;

* misc: update README file;
This commit is contained in:
baiiylu
2026-02-12 21:39:14 +00:00
committed by GitHub
parent cee5e1d36a
commit 09f7e5317b
7 changed files with 514 additions and 341 deletions

View File

@@ -0,0 +1,491 @@
#!/usr/bin/env python3
import datetime
import hashlib
import html.parser
import os
import plistlib
import re
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
WEBSITE_URL = "https://mac.weixin.qq.com/?t=mac&lang=zh_CN"
BASE_DIR = Path.cwd() / "WeChatMac"
TEMP_DIR = BASE_DIR / "temp"
class DownloadLinkParser(html.parser.HTMLParser):
"""
从微信 Mac 官方网站的 HTML 中解析下载链接。
"""
def __init__(self) -> None:
super().__init__()
self.link = ""
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag != "a" or self.link:
return
attrs_dict = {key: value or "" for key, value in attrs}
classes = attrs_dict.get("class", "").split()
if "download-button" in classes:
self.link = attrs_dict.get("href", "").strip()
def run(
cmd: list[str], check: bool = True, capture: bool = True
) -> subprocess.CompletedProcess:
"""
运行子进程命令的辅助函数,默认捕获输出并检查返回码。
Args:
cmd (list[str]): 要执行的命令列表。
check (bool, optional): 是否检查命令返回码。默认为 True。
capture (bool, optional): 是否捕获命令输出。默认为 True。
Returns:
subprocess.CompletedProcess: 子进程执行结果。
"""
return subprocess.run(
cmd,
check=check,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.STDOUT if capture else None,
)
def log(message: str) -> None:
"""
打印日志信息并立即刷新输出缓冲区。
Args:
message (str): 要打印的日志信息。
"""
print(message, flush=True)
def fetch_download_link() -> str:
"""
从微信 Mac 官方网站获取最新的下载链接
Raises:
RuntimeError: 如果在网站上未找到下载链接
Returns:
str: 下载链接 URL
"""
# Fetch HTML and extract the first download link on the page.
with urllib.request.urlopen(WEBSITE_URL, timeout=30) as response:
html = response.read().decode("utf-8", errors="replace")
parser = DownloadLinkParser()
parser.feed(html)
if not parser.link:
raise RuntimeError("Download link not found on website.")
return parser.link
def fetch_head_metadata(url: str) -> dict[str, str]:
"""
使用 HEAD 请求从直接文件链接读取元数据。
Args:
url (str): 文件的直接下载链接
Returns:
dict[str, str]: 从 HEAD 响应中提取的元数据字典,键为小写字符串,值为对应的响应头值
"""
# Use HEAD request to read metadata from the direct file link.
request = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(request, timeout=30) as response:
return {key.lower(): value.strip() for key, value in response.headers.items()}
def download_with_retry(url: str, dest: Path) -> None:
"""
下载软件包
Args:
url (str): 文件的直接下载链接
dest (Path): 目标文件路径
"""
# Keep temp files under current working directory for debugging.
dest.parent.mkdir(parents=True, exist_ok=True)
attempts = 2
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
try:
run(
[
"wget",
"--quiet",
"--tries",
"5",
"--waitretry",
"5",
"--retry-connrefused",
"--timeout",
"30",
url,
"-O",
str(dest),
]
)
return
except Exception as exc:
last_error = exc
if attempt < attempts:
log(f"Download failed (attempt {attempt}). Waiting before retry...")
time.sleep(10)
if last_error:
raise last_error
def mount_dmg(dmg_path: Path) -> str:
"""
挂载目标 dmg 镜像到本地
Args:
dmg_path (Path): dmg 镜像文件路径
Raises:
RuntimeError: 如果挂载失败或未找到挂载点
Returns:
str: 挂载点路径
"""
# Mount DMG and capture the mount point under /Volumes.
result = run(["hdiutil", "attach", str(dmg_path), "-nobrowse"])
matches = re.findall(r"(/Volumes/[^\n]+)", result.stdout)
if not matches:
raise RuntimeError("Failed to mount DMG.")
return matches[-1].strip()
def detach_dmg(mount_dir: str) -> None:
"""
解除挂载对应目录
Args:
mount_dir (str): 挂载点路径
"""
run(["hdiutil", "detach", mount_dir], check=False)
def get_tag_from_plist(mount_dir: str) -> str:
"""
解析 Info.plist 构建 Tag 标签
如果 WeChatBundleVersion 存在则直接使用,否则使用 CFBundleShortVersionString 和 CFBundleVersion 组合的形式。
Args:
mount_dir (str): 挂载路径
Raises:
RuntimeError: 如果 Info.plist 文件未找到
RuntimeError: 如果 CFBundleShortVersionString 未找到
RuntimeError: 如果 CFBundleVersion 未找到
Returns:
str: 构建标签
"""
# Info.plist lives inside the mounted WeChat.app bundle.
info_plist = Path(mount_dir) / "WeChat.app" / "Contents" / "Info.plist"
if not info_plist.exists():
raise RuntimeError("Info.plist not found in mounted volume.")
with info_plist.open("rb") as handle:
data = plistlib.load(handle)
short_version = str(data.get("CFBundleShortVersionString", "")).strip()
build = str(data.get("CFBundleVersion", "")).strip()
version = str(data.get("WeChatBundleVersion", "")).strip()
if not short_version:
raise RuntimeError("CFBundleShortVersionString not found.")
if not build:
raise RuntimeError("CFBundleVersion not found.")
if version:
tag = version
else:
tag = f"{short_version}+build.{build}"
return tag
def compute_sha256(file_path: Path) -> str:
"""
计算文件的 sha256
Args:
file_path (Path): 文件路径
Returns:
str: 文件的 sha256 值
"""
digest = hashlib.sha256()
with file_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def parse_release_body(body: str) -> dict[str, str]:
"""
从 GitHub release 的 body 文本中解析出键值对信息,返回一个字典。
Args:
body (str): GitHub release 的 body 文本,预期包含多行 "Key: Value" 格式的内容。
Returns:
dict[str, str]: 从 body 中解析出的键值对字典.
"""
info: dict[str, str] = {}
for line in body.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
info[key.lstrip("- ").strip()] = value.strip()
return info
def get_latest_release_info() -> dict[str, str]:
"""
获取最新 GitHub release 的信息,返回一个包含键值对的字典。
Returns:
dict[str, str]: 包含最新 release 信息的字典
"""
result = run(
["gh", "release", "view", "--json", "body", "--jq", ".body"], check=False
)
if result.returncode != 0 or not result.stdout:
return {}
return parse_release_body(result.stdout)
def tag_exists(tag: str) -> bool:
"""
检查指定的 Git 标签是否已经存在于远程仓库中。
Args:
tag (str): Git 标签名称
Returns:
bool: 指定的 Git 标签是否存在
"""
result = run(["gh", "release", "view", tag, "--json", "tagName"], check=False)
return result.returncode == 0
def build_release_notes(
tag: str,
download_link: str,
remote_md5: str,
sha256_sum: str,
remote_size: str,
remote_last_modified: str,
) -> str:
"""
构建 Release 发布信息
Args:
tag (str): 构建版本号
download_link (str): 下载链接
remote_md5 (str): 远端md5的值
sha256_sum (str): sha256的值
remote_size (str): 文件大小
remote_last_modified (str): 最后修改时间
Returns:
str: 最终的发布信息
"""
lines = [
"WeChat for Mac automatic release",
"",
"Download and integrity details are below.",
"",
"Release details",
f"- DestVersion: {tag}",
"",
"Source and checksums",
f"- DownloadFrom: {download_link}",
f"- Md5: {remote_md5}",
f"- Sha256: {sha256_sum}",
]
if remote_size:
lines.append(f"- ContentLength: {remote_size}")
if remote_last_modified:
lines.append(f"- LastModified: {remote_last_modified}")
return "\n".join(lines) + "\n"
def write_sha_file(
sha_file: Path,
tag: str,
download_link: str,
sha256_sum: str,
remote_md5: str,
remote_size: str,
remote_last_modified: str,
) -> None:
"""
写入 SHA 文件
Args:
sha_file (Path): SHA 文件路径
tag (str): 构建标签
download_link (str): 下载链接
sha256_sum (str): sha256的值
remote_md5 (str): 远端md5的值
remote_size (str): 文件大小
remote_last_modified (str): 最后修改时间
"""
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
lines = [
f"DestVersion: {tag}",
f"Md5: {remote_md5}",
f"Sha256: {sha256_sum}",
]
if remote_size:
lines.append(f"ContentLength: {remote_size}")
if remote_last_modified:
lines.append(f"LastModified: {remote_last_modified}")
lines.extend(
[
f"UpdateTime: {timestamp} (UTC)",
f"DownloadFrom: {download_link}",
]
)
sha_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
BASE_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
mount_dir = ""
try:
force_release = os.environ.get("FORCE_RELEASE", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
log(f"Force release: {'true' if force_release else 'false'}")
# Step 1: resolve download link from website.
log("Resolving download link from website...")
download_link = fetch_download_link()
log(f"Download link: {download_link}")
# Step 2: read metadata from HEAD response.
log("Fetching HEAD metadata...")
headers = fetch_head_metadata(download_link)
remote_md5 = headers.get("x-cos-meta-md5", "")
remote_size = headers.get("content-length", "")
remote_last_modified = headers.get("last-modified", "")
log(
"HEAD metadata: "
f"md5={remote_md5 or 'n/a'}, "
f"size={remote_size or 'n/a'}, "
f"last_modified={remote_last_modified or 'n/a'}"
)
# Step 3: compare with latest release by MD5 to avoid downloads.
log("Fetching latest GitHub release info...")
latest_info = get_latest_release_info()
latest_md5 = latest_info.get("Md5", "")
latest_sha256 = latest_info.get("Sha256", "")
log(
"Latest release: "
f"md5={latest_md5 or 'n/a'}, "
f"sha256={latest_sha256 or 'n/a'}"
)
if remote_md5 and latest_md5 and remote_md5 == latest_md5:
if force_release:
log("MD5 matches latest release, but force release is enabled.")
else:
log("No new version detected by MD5. Skipping download.")
return 0
# Step 4: download DMG with retry.
log("Downloading DMG...")
dmg_path = TEMP_DIR / "WeChatMac.dmg"
download_with_retry(download_link, dmg_path)
log(f"Downloaded DMG to {dmg_path}")
# Step 5: mount DMG and read plist values.
log("Mounting DMG and reading Info.plist...")
mount_dir = mount_dmg(dmg_path)
tag = get_tag_from_plist(mount_dir)
detach_dmg(mount_dir)
mount_dir = ""
log(f"Detected tag: {tag}")
# Step 6: prepare release assets in workspace.
log("Preparing release assets...")
version_dir = BASE_DIR / tag
version_dir.mkdir(parents=True, exist_ok=True)
final_dmg = version_dir / f"WeChatMac-{tag}.dmg"
shutil.copy2(dmg_path, final_dmg)
sha256_sum = compute_sha256(final_dmg)
log(f"Computed SHA256: {sha256_sum}")
sha_file = version_dir / f"WeChatMac-{tag}.dmg.sha256"
write_sha_file(
sha_file,
tag,
download_link,
sha256_sum,
remote_md5,
remote_size,
remote_last_modified,
)
if not latest_md5 and latest_sha256 and sha256_sum == latest_sha256:
if force_release:
log("SHA256 matches latest release, but force release is enabled.")
else:
log("No new version detected by SHA256. Skipping release.")
return 0
if not latest_md5:
log("Latest release has no MD5, used SHA256 fallback check.")
if tag_exists(tag):
suffix = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d")
tag = f"{tag}_{suffix}"
log(f"Release tag: {tag}")
title = f"Wechat For Mac {tag}"
notes_content = build_release_notes(
tag,
download_link,
remote_md5,
sha256_sum,
remote_size,
remote_last_modified,
)
notes_file = TEMP_DIR / "release_notes.txt"
notes_file.write_text(notes_content, encoding="utf-8")
log(f"Release notes written to {notes_file}")
# Step 7: publish release with assets and notes.
log("Creating GitHub release...")
run(
[
"gh",
"release",
"create",
tag,
str(final_dmg),
str(sha_file),
"-F",
str(notes_file),
"-t",
title,
]
)
log("GitHub release created.")
return 0
finally:
# Always detach and cleanup to keep workspace tidy.
if mount_dir:
detach_dmg(mount_dir)
shutil.rmtree(BASE_DIR, ignore_errors=True)
log("Cleanup completed.")
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)

View File

@@ -1,221 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
# ====================================================
# 配置变量
# ====================================================
TEMP_PATH="WeChatMac/temp"
WEBSITE_URL="https://mac.weixin.qq.com/?t=mac&lang=zh_CN"
DOWNLOAD_LINK=""
# ====================================================
# 函数定义
# ====================================================
# 打印分隔线
print_separator() {
printf '%*s\n' 60 | tr ' ' '#'
}
# 彩色输出函数
echo_color() {
local color="$1"
shift
local message="$*"
case "$color" in
yellow)
echo -e "\033[1;33m$message\033[0m"
;;
red)
echo -e "\033[1;31m$message\033[0m" >&2
;;
green)
echo -e "\033[1;32m$message\033[0m"
;;
*)
echo "$message"
;;
esac
}
# 安装依赖项
install_depends() {
print_separator
echo_color "yellow" "Installing dependencies: wget, curl, git, gh, shasum, pup"
print_separator
brew install wget curl git gh pup
}
# 下载 WeChat DMG
download_wechat() {
DOWNLOAD_LINK=$(curl -s "$WEBSITE_URL" | pup 'a.download-button:nth-of-type(1) attr{href}')
print_separator
echo_color "yellow" "Downloading the newest WeChatMac..."
print_separator
mkdir -p "$TEMP_PATH"
wget -q "$DOWNLOAD_LINK" -O "${TEMP_PATH}/WeChatMac.dmg"
if [ "$?" -ne 0 ]; then
echo_color "red" "Download Failed, please check your network!"
clean_data 1
fi
}
# 从 Info.plist 提取版本信息
get_version() {
print_separator
echo_color "yellow" "Extracting version from DMG (macOS)..."
print_separator
# 挂载 dmg
MOUNT_DIR=$(hdiutil attach "${TEMP_PATH}/WeChatMac.dmg" -nobrowse | sed -n 's/^.*\(\/Volumes\/.*\)$/\1/p' | tail -n1)
if [ -z "$MOUNT_DIR" ]; then
echo_color "red" "Failed to mount DMG!"
clean_data 1
fi
# 定位 Info.plist
# INFO_PLIST=$(find "${MOUNT_DIR}" -type f -name "Info.plist" | head -n 1)
INFO_PLIST="${MOUNT_DIR}/WeChat.app/Contents/Info.plist"
if [ ! -f "$INFO_PLIST" ]; then
echo_color "red" "Info.plist not found in mounted volume!"
hdiutil detach "$MOUNT_DIR"
clean_data 1
fi
# 使用 grep 和 sed 提取版本号
VERSION=$(grep -A1 '<key>CFBundleShortVersionString</key>' "$INFO_PLIST" | grep '<string>' | sed -E 's/.*<string>([^<]+)<\/string>.*/\1/')
# 使用 grep 和 sed 提取构建版本号
BUILD_VERSION=$(grep -A1 '<key>CFBundleVersion</key>' "$INFO_PLIST" | grep '<string>' | sed -E 's/.*<string>([^<]+)<\/string>.*/\1/')
# 卸载 dmg
hdiutil detach "$MOUNT_DIR"
if [ -z "$VERSION" ]; then
echo_color "red" "Version information not found in Info.plist!"
clean_data 1
fi
echo "Version: $VERSION"
}
# 计算 SHA256
compute_sha256() {
local file_path="$1"
shasum -a 256 "$file_path" | awk '{print $1}'
}
# 准备提交(复制 DMG 并创建 .sha256 文件)
prepare_commit() {
print_separator
echo_color "yellow" "Preparing to commit new version..."
print_separator
VERSION_DIR="WeChatMac/$VERSION"
mkdir -p "$VERSION_DIR"
cp "${TEMP_PATH}/WeChatMac.dmg" "$VERSION_DIR/WeChatMac-$VERSION.dmg"
NOW_SUM256=$(compute_sha256 "$VERSION_DIR/WeChatMac-$VERSION.dmg")
cat > "$VERSION_DIR/WeChatMac-$VERSION.dmg.sha256" <<EOF
DestVersion: $VERSION
DestBuild: $BUILD_VERSION
Sha256: $NOW_SUM256
UpdateTime: $(date -u '+%Y-%m-%d %H:%M:%S') (UTC)
DownloadFrom: $DOWNLOAD_LINK
EOF
echo "SHA256: $NOW_SUM256"
}
# 获取最新的 GitHub Release 信息
get_latest_release_info() {
print_separator
echo_color "yellow" "Getting latest GitHub release info..."
print_separator
LATEST_BODY=$(gh release view --json body --jq ".body" || true)
if [ -z "$LATEST_BODY" ]; then
LATEST_SUM256=""
LATEST_VERSION=""
else
LATEST_SUM256=$(echo "$LATEST_BODY" | grep 'Sha256:' | awk -F': ' '{print $2}')
LATEST_VERSION=$(echo "$LATEST_BODY" | grep 'DestVersion:' | awk -F': ' '{print $2}')
fi
echo "Latest Version: $LATEST_VERSION"
echo "Latest SHA256: $LATEST_SUM256"
}
# 创建新的 GitHub Release
create_release() {
print_separator
echo_color "yellow" "Creating new GitHub release..."
print_separator
if [ "$VERSION" = "$LATEST_VERSION" ]; then
VERSION_TAG="${VERSION}_$(date -u '+%Y%m%d')"
else
VERSION_TAG="$VERSION"
fi
gh release create "v$VERSION_TAG" "WeChatMac/$VERSION/WeChatMac-$VERSION.dmg" -F "WeChatMac/$VERSION/WeChatMac-$VERSION.dmg.sha256" -t "Wechat For Mac v$VERSION_TAG"
}
# 清理临时数据并退出
clean_data() {
print_separator
echo_color "yellow" "Cleaning runtime and exiting..."
print_separator
rm -rf "WeChatMac"
exit "$1"
}
# ====================================================
# 主流程
# ====================================================
main() {
# 创建临时目录
mkdir -p "$TEMP_PATH"
# 安装依赖项
install_depends
# 下载 WeChat DMG
download_wechat
# 提取版本信息
get_version
# 准备提交(复制 DMG 并创建 .sha256 文件)
prepare_commit
# 获取最新的 GitHub Release 信息
get_latest_release_info
# 比较 SHA256 值
if [ "$NOW_SUM256" = "$LATEST_SUM256" ] && [ -n "$LATEST_SUM256" ]; then
echo_color "green" "This is the newest Version!"
clean_data 0
fi
# 创建新的 GitHub Release
create_release
# 清理临时数据并退出
clean_data 0
}
# 执行主流程
main

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
if [ -z $GHTOKEN ]; then
>&2 echo -e "\033[1;31mMissing Github Token(GHTOKEN)! Please get a BotToken from 'Github Settings->Developer settings->Personal access tokens' and set it in Repo Secrect\033[0m"
exit 1
fi
if [ -z $BOTTOKEN ]; then
>&2 echo -e "\033[1;31mMissing Bot Token(BOTTOKEN)! Please get a BotToken from @Botfather on Telegram and set it in Repo Secrect\033[0m"
exit 2
fi
if [ -z $CHATIDS ]; then
>&2 echo -e "\033[1;31mMissing ChatIds(CHATIDS)! Please get ChatId from @GroupIDbot on Telegram Chats(Muti chatids split with comma ',') and set it in Repo Environment Values\033[0m"
exit 2
fi
function login_gh() {
printf "#%.0s" {1..60}
echo
echo -e "## \033[1;33mLogin to github to use github-cli...\033[0m"
printf "#%.0s" {1..60}
echo
echo $GHTOKEN > WeChatSetup/temp/GHTOKEN
gh auth login --with-token < WeChatSetup/temp/GHTOKEN
if [ "$?" -ne 0 ]; then
>&2 echo -e "\033[1;31mLogin Failed, please check your network or token!\033[0m"
clean_data 1
fi
rm -rfv WeChatSetup/temp/GHTOKEN
}
### https://kodango.com/sed-and-awk-notes-part-5
## start=${1:-""} means as follows in general
## if ($1) then
## start=$1
## else
## start=""
## end
function join_lines() {
local delim=${1:-,}
sed 'H;$!d;${x;s/^\n//;s/\n/\'$delim'/g}'
}
function clean_data() {
printf "#%.0s" {1..60}
echo
echo -e "## \033[1;33mClean runtime and exit...\033[0m"
printf "#%.0s" {1..60}
echo
rm -rfv WeChatSetup/*
exit $1
}
function main() {
temp_path="WeChatSetup/temp"
mkdir -p ${temp_path}
login_gh
gh release view --json body --jq ".body" > ${temp_path}/release.info
release_info=`awk '!/^$|Sha256/ { $1="*"$1"*";sub("UpdateTime", "CheckTime"); if ( match($2, /https?:\/\/([\w\.\/:])*/) ) $2="[Url]("$2")"; print $0 }' ${temp_path}/release.info | join_lines '%0A' | sed 's/ /%20/g'`
dest_version=`awk '/DestVersion/ { print $2 }' ${temp_path}/release.info`
release_info="$release_info%0A%0A*NotifyFrom:*%20[Github](https://github.com/tom-snow/wechat-windows-versions/releases/tag/v$dest_version)"
echo $CHATIDS | sed 's/,/\n/g' > ${temp_path}/chat_ids
# while IFS="" read -r chatid || [ -n "$chatid" ]
while IFS="" read -r chatid
do
api_link="https://api.telegram.org/bot$BOTTOKEN/sendMessage?chat_id=$chatid&text=*New%20WeChat%20Windows%20Version!!*%0A%0A$release_info&parse_mode=Markdown&disable_web_page_preview=true"
curl -s -o /dev/null $api_link
done < ${temp_path}/chat_ids
gh auth logout --hostname github.com | echo "y"
clean_data 0
}
main