Files
ykxmz/_build/fix_features_bugs.py
T
xmz7891 b705216e4a ykxmz 1.5.0锛氫慨澶嶆墜琛ㄧ TLS 璇佷功鏍¢獙 + MTU 淇缃戠粶绋冲畾鎬?
琚帶绔?- 淇 MTU 闂锛氬す MSS + 璺緞 MTU 鎺㈡祴锛堝叕缃戝彲鐢ㄧ巼 48% -> 100%锛?- 澶嶇敤 HTTP 杩炴帴锛堝厤鎺夋瘡娆℃柊寤?TLS 鐨?30% 鎻℃墜澶辫触锛?- 姣忓垎閽熷洖澶存壘灞€鍩熺綉锛岃嚜鍔ㄥ垏鍥炲揩 400 鍊嶇殑璺緞
- 鐢婚潰蹇冭烦甯э細闈欐鏃剁渷娴侀噺锛屽鎴风浠嶇煡閬撴祦鏄椿鐨?- 閿佸睆鏃剁粰鍑哄彲鎿嶄綔鐨勬彁绀?
鎵嬭〃绔?- 鍐呯疆 GTS Root R4 / GlobalSign Root CA锛屼慨 SSLHandshakeException
- 杩炴帴閿欒缈昏瘧鎴愪汉璇濓紙璇佷功/鏃堕棿/瑙f瀽/瓒呮椂鍒嗗埆鎻愮ず锛?- 缁忎腑杞繛涓婂悗鐢ㄦ湇鍔″櫒鎶ョ殑鍦板潃鍒锋柊宸查厤瀵硅褰?- 鏂板灞€鍩熺綉杩炴帴椤甸潰銆佹湇鍔″櫒寤惰繜妫€娴?
鏈嶅姟鍣ㄧ
- 鏀寔 chunked 璇锋眰浣擄紙淇瘡 16 绉掍竴娆$殑杩炴帴閿欎贡涓庡ぇ闈㈢Н 502锛?- 缃戠粶璋冧紭鑴氭湰 net-tune.sh锛堝紑鏈鸿嚜鍔ㄥ簲鐢級
- 绠$悊椤碉細鎵归噺鍒犻櫎銆佺綉缁滀綋妫€銆佷笅杞藉尯
- 闅ч亾鏀?HTTP/2 + 鍙岃繛鎺?
鏂囨。
- 璁稿彲鍗忚 / 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭ワ紙浣滆€咃細灏忛害瀛愶級
2026-10-06 19:01:05 +08:00

636 lines
26 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""修 features.py 里审计出来的 bug。
安全(严重)
1. 删除到回收站用 PowerShell 拼路径 —— 文件名带单引号就能注入任意命令
2. 播放视频走 cmd /c start —— 路径带 & 或 | 就能执行任意命令
数据丢失(严重)
3. 记笔记前没有先 load —— 进程重启后第一次记笔记会把历史笔记全部抹掉
崩溃(应抛 ValueError 却抛别的)
4-9. rename / make_dir / copy / move / list_dir / zip_dir / folder_size
的 OSError 家族全部直接冒泡给用户
行为不对
10. 全屏功能在界面关闭时抛 RuntimeError;「关掉全屏内容」其实没关
11. 数字雨的打标签失效,画布条目无限增长
12. 网络速度显示的是开机以来累计流量,不是速度
13. 世界时钟写死时区偏移,忽略夏令时
14. on 参数传字符串 "false" 会被当成 True
15. 设置文件被改坏时,笔记/待办/收藏夹列表会崩
16. 同名复制/移动静默覆盖目标
17. 下载文件到手表返回 "READY:路径",但没人解析 → 功能名不符实
"""
from __future__ import annotations
import sys
from pathlib import Path
FEATURES = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
r"\lanremote\features.py")
FIXES = []
def fix(old, new, tag):
FIXES.append((old, new, tag))
# ======================================================================
# 1+2. 安全:不再把路径拼进 shell
# ======================================================================
fix(
''' def _do_delete_file(self, params) -> str:
"""删除到回收站,不做永久删除。"""
target = self.resolve_in_roots(str(params.get("path") or ""))
if not target.exists():
raise ValueError("文件不存在")
if IS_WINDOWS:
script = (f"Add-Type -AssemblyName Microsoft.VisualBasic;"
f"[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile("
f"'{target}', 'OnlyErrorDialogs', 'SendToRecycleBin')")
if target.is_dir():
script = (f"Add-Type -AssemblyName Microsoft.VisualBasic;"
f"[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory("
f"'{target}', 'OnlyErrorDialogs', 'SendToRecycleBin')")
code, out = _run(["powershell", "-NoProfile", "-Command", script])
if code != 0:
raise ValueError(f"删除失败: {out[:80]}")
else:
target.unlink()
return f"已删除到回收站:{target.name}"''',
''' def _recycle(self, target: Path) -> None:
"""删除到回收站。
之前是把路径用单引号拼进 PowerShell 脚本,文件名里带一个单引号
就能闭合字符串、注入任意命令(新建文件夹的清洗正则不删单引号,
所以远端两步就能造出这种文件名)。现在直接调 Windows 自己的
SHFileOperationW,不经过任何 shell,没有注入面。
"""
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [("hwnd", ctypes.c_void_p),
("wFunc", ctypes.c_uint),
("pFrom", ctypes.c_wchar_p),
("pTo", ctypes.c_wchar_p),
("fFlags", ctypes.c_ushort),
("fAnyOperationsAborted", ctypes.c_int),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", ctypes.c_wchar_p)]
FO_DELETE = 3
FOF_ALLOWUNDO = 0x0040
FOF_NOCONFIRMATION = 0x0010
FOF_SILENT = 0x0004
FOF_NOERRORUI = 0x0400
# pFrom 要双 \0 结尾
raw = str(target) + "\\0"
buffer = ctypes.create_unicode_buffer(len(raw) + 1)
buffer[:len(raw)] = raw
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
op.pFrom = ctypes.cast(buffer, ctypes.c_wchar_p)
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION
| FOF_SILENT | FOF_NOERRORUI)
result = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if result != 0:
raise ValueError(f"删除失败(系统错误码 {result})")
def _do_delete_file(self, params) -> str:
"""删除到回收站,不做永久删除。"""
target = self.resolve_in_roots(str(params.get("path") or ""))
if not target.exists():
raise ValueError("文件不存在")
try:
if IS_WINDOWS:
self._recycle(target)
else:
raise ValueError("仅支持 Windows")
except ValueError:
raise
except OSError as exc:
raise ValueError(f"删除失败: {exc}") from exc
return f"已删除到回收站:{target.name}"''',
"删除到回收站:改用 Windows API,不再拼 shell",
)
fix(
''' def _do_play_video(self, params) -> str:
target = self.resolve_in_roots(str(params.get("path") or ""))
if not target.is_file():
raise ValueError("视频文件不存在")
subprocess.Popen(["cmd", "/c", "start", "", str(target)],
creationflags=_NO_WINDOW)
return f"已用播放器打开 {target.name}"''',
''' def _do_play_video(self, params) -> str:
target = self.resolve_in_roots(str(params.get("path") or ""))
if not target.is_file():
raise ValueError("视频文件不存在")
# 之前走 cmd /c start,路径里的 & | ^ 会被 cmd 当成命令分隔符,
# 一个文件名就能变成任意命令执行。os.startfile 不经过 shell。
try:
os.startfile(str(target)) # noqa: S606
except OSError as exc:
raise ValueError(f"打不开: {exc}") from exc
return f"已用播放器打开 {target.name}"''',
"播放视频:改用 os.startfile,不再走 cmd",
)
# ======================================================================
# 3. 笔记:先 load 再写(否则会抹掉历史)
# ======================================================================
fix(
''' self._notes.insert(0, {"text": text[:2000],
"at": time.strftime("%m-%d %H:%M")})
del self._notes[200:]
self._persist("notes", self._notes)''',
''' # 必须先 load。__init__ 里 self._notes 是空的,
# 直接 insert 再 persist 会把历史笔记全抹掉(重启后第一次记笔记就中招)。
self._notes = self._load("notes")
self._notes.insert(0, {"text": text[:2000],
"at": time.strftime("%m-%d %H:%M")})
del self._notes[200:]
self._persist("notes", self._notes)''',
"记笔记:先读回来再写,不再抹掉历史",
)
# ======================================================================
# 4-6. 文件操作:OSError 一律转 ValueError + 边界检查
# ======================================================================
fix(
''' target = parent / name
target.mkdir(parents=True, exist_ok=True)
return f"已新建 {target}"''',
''' if not parent.is_dir():
raise ValueError("父目录不是文件夹")
target = parent / name
if target.exists():
raise ValueError("同名文件或文件夹已存在")
try:
target.mkdir(parents=True, exist_ok=False)
except OSError as exc:
raise ValueError(f"新建失败: {exc}") from exc
return f"已新建 {target}"''',
"新建文件夹:父目录校验 + 错误包装",
)
fix(
''' target = source.parent / name
if target.exists():
raise ValueError("同名文件已存在")
source.rename(target)
return f"已重命名为 {target.name}"''',
''' if not source.exists():
raise ValueError("文件不存在")
target = source.parent / name
if target.exists():
raise ValueError("同名文件已存在")
try:
source.rename(target)
except OSError as exc:
raise ValueError(f"重命名失败: {exc}") from exc
return f"已重命名为 {target.name}"''',
"重命名:源不存在检查 + 错误包装",
)
fix(
''' if not source.exists():
raise ValueError("源文件不存在")
if source.is_dir():
_shutil.copytree(source, target, dirs_exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
_shutil.copy2(source, target)
return f"已复制到 {target}"''',
''' if not source.exists():
raise ValueError("源文件不存在")
if source == target:
raise ValueError("源和目标是同一个位置")
if target.exists():
raise ValueError("目标已存在,不覆盖(先改名或删掉)")
try:
if source.is_dir():
_shutil.copytree(source, target)
else:
target.parent.mkdir(parents=True, exist_ok=True)
_shutil.copy2(source, target)
except OSError as exc:
raise ValueError(f"复制失败: {exc}") from exc
return f"已复制到 {target}"''',
"复制文件:不静默覆盖 + 错误包装",
)
fix(
''' if not source.exists():
raise ValueError("源文件不存在")
target.parent.mkdir(parents=True, exist_ok=True)
_shutil.move(str(source), str(target))
return f"已移动到 {target}"''',
''' if not source.exists():
raise ValueError("源文件不存在")
if source == target:
raise ValueError("源和目标是同一个位置")
if target.exists():
raise ValueError("目标已存在,不覆盖(先改名或删掉)")
try:
target.parent.mkdir(parents=True, exist_ok=True)
_shutil.move(str(source), str(target))
except OSError as exc:
raise ValueError(f"移动失败: {exc}") from exc
return f"已移动到 {target}"''',
"移动文件:不静默覆盖 + 错误包装",
)
fix(
''' entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))''',
''' try:
entries = sorted(target.iterdir(),
key=lambda p: (p.is_file(), p.name.lower()))
except OSError as exc:
raise ValueError(f"读不了这个文件夹: {exc}") from exc''',
"浏览文件夹:权限错误包装",
)
fix(
''' out = source.parent / f"{source.name}.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
for item in source.rglob("*"):
if item.is_file():
zf.write(item, item.relative_to(source.parent))
return f"已压缩为 {out.name}({out.stat().st_size // 1024} KB)"''',
''' out = source.parent / f"{source.name}.zip"
if out.exists():
raise ValueError("同名的 zip 已存在,先改名或删掉")
try:
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
for item in source.rglob("*"):
if item.is_file():
zf.write(item, item.relative_to(source.parent))
size_kb = out.stat().st_size // 1024
except OSError as exc:
raise ValueError(f"压缩失败: {exc}") from exc
return f"已压缩为 {out.name}({size_kb} KB)"''',
"压缩:不覆盖 + 错误包装",
)
fix(
''' total = 0
count = 0
for item in target.rglob("*"):
try:
if item.is_file():
total += item.stat().st_size
count += 1
except OSError:
continue
return f"{target.name}:{count} 个文件,共 {total / 1048576:.1f} MB"''',
''' total = 0
count = 0
deadline = time.time() + 15 # 大目录别把请求线程占死
try:
for item in target.rglob("*"):
if time.time() > deadline:
break
try:
if item.is_file():
total += item.stat().st_size
count += 1
except OSError:
continue
except OSError as exc:
raise ValueError(f"算不了: {exc}") from exc
return f"{target.name}:{count} 个文件,共 {total / 1048576:.1f} MB"''',
"文件夹大小:加超时 + 错误包装",
)
# ======================================================================
# 10. 全屏窗口:错误包装 + 能真的关掉
# ======================================================================
fix(
''' def _fullscreen_window(self, builder, seconds: float = 0.0):
"""在电脑上开一个全屏窗口。builder(win, close) 负责放内容。"""
def build():
import tkinter as tk
root = getattr(self.actions.ui, "root", None)
if root is None:
return
win = tk.Toplevel(root)
win.attributes("-fullscreen", True)
win.attributes("-topmost", True)
def close(_event=None):
try:
win.destroy()
except Exception: # noqa: BLE001
pass
builder(win, close)
win.bind("<Escape>", close)
win.bind("<Button-1>", close)
win.protocol("WM_DELETE_WINDOW", close)
if seconds > 0:
win.after(int(seconds * 1000), close)
self.actions.ui.ui_call(build)''',
''' def _close_fullscreen_windows(self) -> int:
"""关掉所有由本程序开的全屏窗口。"""
closed = 0
for closer in list(getattr(self, "_fullscreen_closers", [])):
try:
closer()
closed += 1
except Exception: # noqa: BLE001
pass
self._fullscreen_closers = []
return closed
def _fullscreen_window(self, builder, seconds: float = 0.0):
"""在电脑上开一个全屏窗口。builder(win, close) 负责放内容。
先关掉上一个:不然连着点两次「全屏大字」,窗口会一层层叠上去,
Esc 只能关最上面那个,下面的再也关不掉。
"""
self._close_fullscreen_windows()
def build():
import tkinter as tk
root = getattr(self.actions.ui, "root", None)
if root is None:
raise RuntimeError("界面不可用")
win = tk.Toplevel(root)
win.attributes("-fullscreen", True)
win.attributes("-topmost", True)
def close(_event=None):
try:
win.destroy()
except Exception: # noqa: BLE001
pass
try:
self._fullscreen_closers.remove(close)
except (ValueError, AttributeError):
pass
builder(win, close)
win.bind("<Escape>", close)
win.bind("<Button-1>", close)
win.protocol("WM_DELETE_WINDOW", close)
if seconds > 0:
win.after(int(seconds * 1000), close)
self._fullscreen_closers.append(close)
try:
self.actions.ui.ui_call(build)
except (RuntimeError, TimeoutError, AttributeError) as exc:
# 界面关了 / 忙超时 —— 转成用户看得懂的话,别把栈信息甩出去
raise ValueError(f"电脑上的界面不可用({exc})") from exc''',
"全屏窗口:先关旧的 + 错误转成人话",
)
fix(
''' def _do_big_text_clear(self, params) -> str:
return "按 Esc 或点一下就能关掉全屏窗口"''',
''' def _do_big_text_clear(self, params) -> str:
closed = self._close_fullscreen_windows()
return f"已关掉 {closed} 个全屏窗口" if closed else "当前没有全屏窗口"''',
"关掉全屏内容:真的关掉",
)
# ======================================================================
# 11. 数字雨:打上 tag,旧帧才会被擦掉
# ======================================================================
fix(
''' canvas.delete("fade")
for i, y in enumerate(drops):
char = random.choice(chars)
canvas.create_text(i * font_size + font_size // 2,
y * font_size, text=char,
fill="#00FF66",
font=("Consolas", font_size, "bold"))''',
''' canvas.delete("fade")
for i, y in enumerate(drops):
char = random.choice(chars)
canvas.create_text(i * font_size + font_size // 2,
y * font_size, text=char,
fill="#00FF66",
font=("Consolas", font_size, "bold"),
tags="fade")''',
"数字雨:补上 fade 标签(否则画布条目无限堆积)",
)
# ======================================================================
# 12. 网络速度:算差值,不再显示累计流量
# ======================================================================
fix(
''' def _do_network_speed(self, params) -> str:
script = ("Get-NetAdapterStatistics | Where-Object {$_.ReceivedBytes -gt 0} | "
"ForEach-Object { \\"$($_.Name): 收 $([math]::Round($_.ReceivedBytes/1MB,1))MB"
" 发 $([math]::Round($_.SentBytes/1MB,1))MB\\" }")
code, out = _run(["powershell", "-NoProfile", "-Command", script])
return out.replace("\\n", " | ")[:300] or "读不到"''',
''' def _do_network_speed(self, params) -> str:
"""真实速率:采两次求差,不是开机以来的累计流量。"""
script = ("(Get-NetAdapterStatistics | Measure-Object -Property "
"ReceivedBytes -Sum).Sum")
_code, out = _run(["powershell", "-NoProfile", "-Command", script])
digits = re.findall(r"\\d+", out or "")
now_bytes = int(digits[0]) if digits else 0
now = time.time()
last_bytes = getattr(self, "_net_last_bytes", None)
last_time = getattr(self, "_net_last_time", None)
self._net_last_bytes = now_bytes
self._net_last_time = now
if last_bytes is None or last_time is None or now <= last_time:
time.sleep(1.0)
_code, out = _run(["powershell", "-NoProfile", "-Command", script])
digits = re.findall(r"\\d+", out or "")
again = int(digits[0]) if digits else now_bytes
self._net_last_bytes = again
self._net_last_time = time.time()
return f"当前约 {(again - now_bytes) / 1048576:.2f} MB/s(采样 1 秒)"
elapsed = now - last_time
delta = max(0, now_bytes - last_bytes)
mbps = delta / elapsed / 1048576
return (f"下载 {mbps:.2f} MB/s({mbps * 8:.1f} Mbps)\\n"
f"这段时间共收了 {delta / 1048576:.1f} MB")''',
"网络速度:改成真实速率(两次采样求差)",
)
# ======================================================================
# 13. 世界时钟:用 zoneinfo,自动处理夏令时
# ======================================================================
fix(
''' def _do_world_clock(self, params) -> str:
now = time.time()
zones = [("北京", 8), ("伦敦", 0), ("纽约", -5), ("洛杉矶", -8),
("东京", 9), ("悉尼", 10)]
return "\\n".join(
f"{name} {time.strftime('%H:%M', time.gmtime(now + off * 3600))}"
for name, off in zones)''',
''' def _do_world_clock(self, params) -> str:
"""写死偏移会忽略夏令时(现在纽约/伦敦/悉尼都差 1 小时),改用 zoneinfo。"""
zones = [("北京", "Asia/Shanghai"), ("伦敦", "Europe/London"),
("纽约", "America/New_York"), ("洛杉矶", "America/Los_Angeles"),
("东京", "Asia/Tokyo"), ("悉尼", "Australia/Sydney")]
try:
from zoneinfo import ZoneInfo
import datetime as _dt
now = _dt.datetime.now(_dt.timezone.utc)
lines = []
for name, zone in zones:
local = now.astimezone(ZoneInfo(zone))
lines.append(f"{name} {local.strftime('%H:%M')}"
f"{'(夏令时)' if local.dst() else ''}")
return "\\n".join(lines)
except Exception: # noqa: BLE001
# 没有时区数据库时退回 UTC 偏移(可能差 1 小时)
now = time.time()
fallback = [("北京", 8), ("伦敦", 0), ("纽约", -5), ("洛杉矶", -8),
("东京", 9), ("悉尼", 10)]
return "\\n".join(
f"{n} {time.strftime('%H:%M', time.gmtime(now + o * 3600))}"
for n, o in fallback)''',
"世界时钟:用 zoneinfo 处理夏令时",
)
# ======================================================================
# 14. on 参数:字符串 "false" 不该被当成 True
# ======================================================================
fix(
''' def run(self, name: str, params: dict) -> str:''',
''' @staticmethod
def _truthy(value) -> bool:
"""字符串 "false"/"0"/"off" 不能被当成真 —— bool("false") 是 True。"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "on", "yes", "是", "开")
return bool(value)
def run(self, name: str, params: dict) -> str:''',
"新增 _truthy 辅助",
)
for _old, _new, _tag in [
("want_on = bool(params.get(\"on\"))", "want_on = self._truthy(params.get(\"on\"))",
"夜间模式用 _truthy"),
("want = bool(params.get(\"on\"))", "want = self._truthy(params.get(\"on\"))",
"任务栏隐藏用 _truthy"),
("want = 1 if params.get(\"on\") else 0", "want = 1 if self._truthy(params.get(\"on\")) else 0",
"时钟秒数用 _truthy"),
("want = 1 if params.get(\"on\") else 2", "want = 2 if not self._truthy(params.get(\"on\")) else 1",
"隐藏文件用 _truthy"),
]:
fix(_old, _new, _tag)
# ======================================================================
# 15. _load:元素类型校验(设置文件被改坏时不崩)
# ======================================================================
fix(
''' def _load(self, key: str):
try:
value = self.store.get(f"feat_{key}")
return value if isinstance(value, list) else []
except Exception: # noqa: BLE001
return []''',
''' def _load(self, key: str, item_type=None):
"""读持久化列表。
设置文件可能被手改坏、或是旧版本残留,所以元素类型也要校验 ——
否则 notes/todos/clip_fav 的列表渲染会抛 TypeError 崩掉。
"""
try:
value = self.store.get(f"feat_{key}")
except Exception: # noqa: BLE001
return []
if not isinstance(value, list):
return []
if item_type is None:
return value
return [item for item in value if isinstance(item, item_type)]''',
"_load 元素类型校验",
)
fix("self._notes = self._load(\"notes\")",
"self._notes = self._load(\"notes\", dict)",
"笔记读回来校验 dict")
fix("todos = self._load(\"todos\")",
"todos = self._load(\"todos\", dict)",
"待办读回来校验 dict")
fix("favs = self._load(\"clip_fav\")",
"favs = self._load(\"clip_fav\", str)",
"收藏夹读回来校验 str")
# ======================================================================
# 17. 下载文件到手表:改成手表端真正能拉的
# ======================================================================
fix(
''' add("download_file", "下载文件到手表", "input", "文件",
hint="完整路径", key="path")''',
''' add("download_file", "下载文件到手表", "file", "文件",
hint="完整路径", key="path")''',
"下载文件:标记成 file 类型",
)
fix(
''' if target.stat().st_size > 30 * 1024 * 1024:
raise ValueError("文件超过 30MB,太大")
return f"READY:{target}"''',
''' size = target.stat().st_size
if size > 30 * 1024 * 1024:
raise ValueError("文件超过 30MB,太大")
readable = (f"{size / 1048576:.1f} MB" if size > 1048576
else f"{max(1, size // 1024)} KB")
return f"{target.name}({readable}),正在传到手表…"'''
,
"下载文件:返回可读文案(拉取由手表端发起)",
)
def main() -> int:
text = FEATURES.read_text(encoding="utf-8")
applied, skipped = 0, []
for old, new, tag in FIXES:
if old not in text:
skipped.append(tag)
print(f" [跳过] {tag} —— 锚点没找到")
continue
if text.count(old) > 1:
print(f" [注意] {tag} 匹配 {text.count(old)} 处,只改第一处")
text = text.replace(old, new, 1)
applied += 1
print(f" {tag}: OK")
FEATURES.write_text(text, encoding="utf-8")
print(f"\n 应用 {applied} / {len(FIXES)}")
print("\n语法检查:")
import ast
try:
ast.parse(FEATURES.read_text(encoding="utf-8"))
print(" features.py: OK")
except SyntaxError as exc:
print(f" features.py: 语法错误 {exc}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())