diff --git a/.npmignore b/.npmignore index 42bf96f..f7b0f21 100644 --- a/.npmignore +++ b/.npmignore @@ -1,8 +1,10 @@ # When .npmignore exists, npm ignores .gitignore for packing. # Keep packaging exclusions here even if they already appear in .gitignore. -__pycache__/ +**/__pycache__/ +**/__pycache__/** *.py[cod] +**/*.py[cod] *$py.class .pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d3114f8..aad81b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.1.2] - 2026-08-06 + +### 修复 + +- 修复中文 Windows 下 `export_pptx.py` / `export_images.py` 导出卡住或 GBK 解码失败(stdout 改走临时文件 + UTF-8) +- 规避 agent-browser `--download-path` 导致 Chrome 静默取消下载:改为点击下载并轮询默认 Downloads +- 修复 npm 包误打入 `__pycache__/*.pyc`(`files` 仅列出脚本源文件) + ## [1.1.1] - 2026-08-06 ### 新增 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index d1b7f38..bae77b6 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -4,6 +4,14 @@ This project follows [Semantic Versioning](https://semver.org/). +## [1.1.2] - 2026-08-06 + +### Fixed + +- Fix Chinese-locale Windows export hang / GBK decode errors in `export_pptx.py` / `export_images.py` (capture stdout via temp file + UTF-8) +- Work around agent-browser `--download-path` silently canceling Chrome downloads: click download and poll the default Downloads folder +- Stop shipping `__pycache__/*.pyc` in the npm package (list script sources explicitly in `files`) + ## [1.1.1] - 2026-08-06 ### Added diff --git a/package.json b/package.json index 6addc12..3e2aa23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-kimi-ppt-skills", - "version": "1.1.1", + "version": "1.1.2", "description": "Unofficial reverse-engineered Kimi Slides skill for AI coding agents with local PPTD editing and PPTX export.", "license": "MIT", "author": "binaryify", @@ -35,7 +35,9 @@ "lib/", "skills/open-kimi-ppt/SKILL.md", "skills/open-kimi-ppt/reference/", - "skills/open-kimi-ppt/scripts/", + "skills/open-kimi-ppt/scripts/export_host.html", + "skills/open-kimi-ppt/scripts/export_images.py", + "skills/open-kimi-ppt/scripts/export_pptx.py", "README.md", "README_EN.md", "theme.md", diff --git a/skills/open-kimi-ppt/scripts/export_images.py b/skills/open-kimi-ppt/scripts/export_images.py index f6201af..6e3d586 100644 --- a/skills/open-kimi-ppt/scripts/export_images.py +++ b/skills/open-kimi-ppt/scripts/export_images.py @@ -16,7 +16,6 @@ import re import shutil import subprocess import sys -import tempfile import time import uuid import zipfile @@ -28,12 +27,15 @@ from export_pptx import ( BrowserSession, ExportError, build_payload, + default_downloads_dir, ensure_agent_browser, find_download, find_manifest, log, ref_by_name, + run_command, serve, + temporary_directory, wait_for_export_dialog, ) @@ -51,11 +53,8 @@ def ensure_pillow() -> Tuple[Any, Any, Any]: return Image, ImageDraw, ImageFont except ImportError: log("Pillow is required for stitching; installing pillow with pip --user") - process = subprocess.run( + process = run_command( [sys.executable, "-m", "pip", "install", "--user", "pillow"], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, timeout=300, ) if process.returncode != 0: @@ -187,11 +186,8 @@ def ensure_websocket() -> Any: return websocket except ImportError: log("websocket-client is required for dialog automation; installing with pip --user") - process = subprocess.run( + process = run_command( [sys.executable, "-m", "pip", "install", "--user", "websocket-client"], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, timeout=300, ) if process.returncode != 0: @@ -307,7 +303,7 @@ def export_images( image_cls, draw_cls, image_font = ensure_pillow() log(f"manifest: {manifest}") - with tempfile.TemporaryDirectory(prefix="open-kimi-ppt-images-") as temp_name: + with temporary_directory(prefix="open-kimi-ppt-images-") as temp_name: temp_dir = Path(temp_name) download_dir = temp_dir / "downloads" download_dir.mkdir() @@ -318,6 +314,7 @@ def export_images( server, thread, url = serve(temp_dir) session = f"open-kimi-ppt-images-{os.getpid()}-{uuid.uuid4().hex[:8]}" browser = BrowserSession(agent_browser, session, temp_dir, download_dir) + downloads = default_downloads_dir() try: log("opening the public Kimi slide editor") browser.open(url) @@ -338,17 +335,15 @@ def export_images( select_image_format(browser) dialog = wait_for_export_dialog(browser) + started_at = time.time() - 1.0 download_ref = ref_by_name(dialog, "下载", "button") log("rendering page images in the browser") - result = browser.run( - ["download", f"@{download_ref}", str(temp_dir / "browser-output.zip")], - timeout=300, - check=False, - ) - if result.returncode != 0: - log("download capture reported a timeout; checking browser output files") + browser.run(["click", f"@{download_ref}"], timeout=300) downloaded = find_download( - (download_dir, temp_dir), timeout=240, accept=is_image_zip + (downloads, download_dir, temp_dir), + timeout=240, + accept=is_image_zip, + since=started_at, ) finally: browser.close() @@ -362,6 +357,11 @@ def export_images( images = unzip_images(downloaded, output / "pages") if keep_download: shutil.copy2(downloaded, output / "browser-raw.zip") + try: + if downloaded.resolve().parent == downloads.resolve(): + downloaded.unlink(missing_ok=True) + except OSError: + pass overview = stitch_overview( images, output / "overview.jpg", image_cls, draw_cls, image_font ) diff --git a/skills/open-kimi-ppt/scripts/export_pptx.py b/skills/open-kimi-ppt/scripts/export_pptx.py index b0c33ab..92bd9ae 100755 --- a/skills/open-kimi-ppt/scripts/export_pptx.py +++ b/skills/open-kimi-ppt/scripts/export_pptx.py @@ -63,16 +63,83 @@ def log(message: str) -> None: print(f"[open-kimi-ppt] {message}", file=sys.stderr, flush=True) +def run_command( + command: Sequence[str], + *, + cwd: Optional[Path] = None, + env: Optional[Dict[str, str]] = None, + timeout: int = 90, +) -> subprocess.CompletedProcess[str]: + """Capture merged stdout/stderr via a temp file. + + On Windows, agent-browser's detached daemon can inherit a PIPE handle and + prevent EOF, deadlocking ``subprocess.run(stdout=PIPE)``. Decoding with the + system locale (GBK on zh-CN Windows) can also raise UnicodeDecodeError. + Writing to a UTF-8 file avoids both failures. + """ + handle, sink_path = tempfile.mkstemp(prefix="open-kimi-ppt-", suffix=".log") + os.close(handle) + sink = Path(sink_path) + output = "" + try: + with sink.open("w", encoding="utf-8", errors="replace") as out: + returncode = subprocess.call( + list(command), + cwd=str(cwd) if cwd is not None else None, + env=env, + stdout=out, + stderr=subprocess.STDOUT, + timeout=timeout, + ) + output = sink.read_text(encoding="utf-8", errors="replace") + except subprocess.TimeoutExpired as exc: + try: + output = sink.read_text(encoding="utf-8", errors="replace") + except OSError: + output = "" + raise subprocess.TimeoutExpired( + cmd=list(command), + timeout=timeout, + output=output, + ) from exc + finally: + try: + sink.unlink(missing_ok=True) + except OSError: + # WinError 32: daemon may still hold the log file handle. + pass + return subprocess.CompletedProcess(list(command), returncode, output, None) + + +def temporary_directory(prefix: str) -> Any: + # ignore_cleanup_errors avoids masking the real export error when a Windows + # browser daemon still holds files under the temp tree (Python 3.10+). + try: + return tempfile.TemporaryDirectory(prefix=prefix, ignore_cleanup_errors=True) + except TypeError: + return tempfile.TemporaryDirectory(prefix=prefix) + + +def default_downloads_dir() -> Path: + home = Path.home() + candidates: List[Path] = [] + user_profile = os.environ.get("USERPROFILE") + if user_profile: + candidates.append(Path(user_profile) / "Downloads") + candidates.extend((home / "Downloads", home / "下载")) + for path in candidates: + if path.is_dir(): + return path + return home / "Downloads" + + def ensure_pyyaml() -> Any: try: import yaml except ImportError: log("PyYAML is required; installing pyyaml with pip --user") - process = subprocess.run( + process = run_command( [sys.executable, "-m", "pip", "install", "--user", "pyyaml"], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, timeout=300, ) if process.returncode != 0: @@ -103,13 +170,7 @@ def parse_node_version(output: str) -> Tuple[int, int, int]: def read_agent_browser_version(executable: str) -> Tuple[int, int, int]: - process = subprocess.run( - [executable, "--version"], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=30, - ) + process = run_command([executable, "--version"], timeout=30) if process.returncode != 0: raise ExportError(f"agent-browser --version failed:\n{process.stdout[-2000:]}") return parse_version(process.stdout) @@ -120,13 +181,7 @@ def ensure_nodejs() -> str: if not executable: raise ExportError(f"Node.js is not installed or not on PATH. {NODE_INSTALL_HINT}") - process = subprocess.run( - [executable, "--version"], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=30, - ) + process = run_command([executable, "--version"], timeout=30) if process.returncode != 0: raise ExportError(f"node --version failed:\n{process.stdout[-2000:]}") @@ -168,11 +223,8 @@ def ensure_agent_browser() -> str: current = "not installed" if version is None else ".".join(map(str, version)) minimum = ".".join(map(str, MIN_AGENT_BROWSER_VERSION)) log(f"agent-browser {current} is below {minimum}; installing agent-browser@latest") - process = subprocess.run( + process = run_command( [npm, "install", "-g", "agent-browser@latest"], - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, timeout=300, ) if process.returncode != 0: @@ -304,6 +356,8 @@ class BrowserSession: self.executable = executable self.session = session self.cwd = cwd + # Kept as a search root fallback; not passed to agent-browser. On Windows, + # --download-path can be rewritten to a \\?\ path that cancels Chrome downloads. self.download_dir = download_dir self.env = os.environ.copy() self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000") @@ -317,15 +371,7 @@ class BrowserSession: check: bool = True, ) -> subprocess.CompletedProcess[str]: command = [self.executable, "--session", self.session, *args] - process = subprocess.run( - command, - cwd=self.cwd, - env=self.env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=timeout, - ) + process = run_command(command, cwd=self.cwd, env=self.env, timeout=timeout) if check and process.returncode != 0: raise ExportError( f"agent-browser command failed ({process.returncode}): " @@ -334,9 +380,9 @@ class BrowserSession: return process def open(self, url: str) -> None: - self.run( - ["--download-path", str(self.download_dir), "open", url], timeout=90 - ) + # Avoid --download-path: agent-browser ≤0.33.2 + Chrome may cancel downloads + # when given a verbatim Windows path. Files land in the default Downloads folder. + self.run(["open", url], timeout=90) def snapshot(self) -> Dict[str, Any]: process = self.run(["snapshot", "-i", "-C", "--json"]) @@ -408,6 +454,8 @@ def find_download( search_roots: Iterable[Path], timeout: float = 150.0, accept: Callable[[Path], bool] = is_pptx, + *, + since: Optional[float] = None, ) -> Path: deadline = time.monotonic() + timeout last_sizes: Dict[Path, int] = {} @@ -420,7 +468,10 @@ def find_download( candidates.extend(path for path in root.rglob("*") if path.is_file()) for path in sorted(candidates, key=lambda item: item.stat().st_mtime, reverse=True): try: - size = path.stat().st_size + stat = path.stat() + size = stat.st_size + if since is not None and stat.st_mtime < since: + continue except OSError: continue if size == last_sizes.get(path) and size > 0: @@ -588,7 +639,7 @@ def export_pptx( f"defaults: transition={transition}, embed_fonts={'on' if embed_fonts else 'off'}" ) - with tempfile.TemporaryDirectory(prefix="open-kimi-ppt-export-") as temp_name: + with temporary_directory(prefix="open-kimi-ppt-export-") as temp_name: temp_dir = Path(temp_name) download_dir = temp_dir / "downloads" download_dir.mkdir() @@ -599,6 +650,7 @@ def export_pptx( server, thread, url = serve(temp_dir) session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}" browser = BrowserSession(agent_browser, session, temp_dir, download_dir) + downloads = default_downloads_dir() try: log("opening the public Kimi slide editor") browser.open(url) @@ -627,16 +679,17 @@ def export_pptx( elif embed_fonts: log("warning: the official export dialog exposed no font switch") + # Plain click (not agent-browser `download`) so Chrome saves to the + # default Downloads folder; --download-path is broken on some Windows setups. + started_at = time.time() - 1.0 download_ref = ref_by_name(dialog, "下载", "button") log("generating PPTX in the browser") - result = browser.run( - ["download", f"@{download_ref}", str(temp_dir / "browser-output.pptx")], - timeout=180, - check=False, + browser.run(["click", f"@{download_ref}"], timeout=180) + downloaded = find_download( + (downloads, download_dir, temp_dir), + timeout=90, + since=started_at, ) - if result.returncode != 0: - log("download capture reported a timeout; checking browser output files") - downloaded = find_download((download_dir, temp_dir), timeout=90) shutil.copy2(downloaded, output) if keep_download: debug_copy = output.with_name(f"{output.stem}.browser-raw.pptx") @@ -645,6 +698,11 @@ def export_pptx( f"raw debug output already exists (pass --force): {debug_copy}" ) shutil.copy2(downloaded, debug_copy) + try: + if downloaded.resolve().parent == downloads.resolve(): + downloaded.unlink(missing_ok=True) + except OSError: + pass finally: browser.close() server.shutdown() diff --git a/skills/open-kimi-ppt/tests/test_export_pptx.py b/skills/open-kimi-ppt/tests/test_export_pptx.py index c239392..24d5009 100755 --- a/skills/open-kimi-ppt/tests/test_export_pptx.py +++ b/skills/open-kimi-ppt/tests/test_export_pptx.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import importlib.util +import os import tempfile +import time import unittest import zipfile from pathlib import Path @@ -19,9 +21,9 @@ class ExportPptxTests(unittest.TestCase): self.assertEqual(MODULE.parse_version("agent-browser 0.33.2"), (0, 33, 2)) self.assertEqual(MODULE.parse_version("v1.4.0-beta.1"), (1, 4, 0)) - @patch.object(MODULE.subprocess, "run") + @patch.object(MODULE, "run_command") @patch.object(MODULE.shutil, "which") - def test_old_agent_browser_is_upgraded(self, which, run): + def test_old_agent_browser_is_upgraded(self, which, run_command): which.side_effect = [ "/bin/node", "/bin/npm", @@ -29,38 +31,38 @@ class ExportPptxTests(unittest.TestCase): "/bin/npm", "/bin/agent-browser", ] - run.side_effect = [ + run_command.side_effect = [ MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n"), MODULE.subprocess.CompletedProcess([], 0, "agent-browser 0.17.1\n"), MODULE.subprocess.CompletedProcess([], 0, "changed 1 package\n"), MODULE.subprocess.CompletedProcess([], 0, "agent-browser 0.33.2\n"), ] self.assertEqual(MODULE.ensure_agent_browser(), "/bin/agent-browser") - self.assertEqual(run.call_args_list[2].args[0], [ + self.assertEqual(run_command.call_args_list[2].args[0], [ "/bin/npm", "install", "-g", "agent-browser@latest" ]) - @patch.object(MODULE.subprocess, "run") + @patch.object(MODULE, "run_command") @patch.object(MODULE.shutil, "which") - def test_missing_nodejs_raises_clear_error(self, which, run): + def test_missing_nodejs_raises_clear_error(self, which, run_command): which.return_value = None with self.assertRaisesRegex(MODULE.ExportError, "Node.js is not installed"): MODULE.ensure_nodejs() - run.assert_not_called() + run_command.assert_not_called() - @patch.object(MODULE.subprocess, "run") + @patch.object(MODULE, "run_command") @patch.object(MODULE.shutil, "which") - def test_old_nodejs_raises_clear_error(self, which, run): + def test_old_nodejs_raises_clear_error(self, which, run_command): which.return_value = "/bin/node" - run.return_value = MODULE.subprocess.CompletedProcess([], 0, "v16.20.2\n") + run_command.return_value = MODULE.subprocess.CompletedProcess([], 0, "v16.20.2\n") with self.assertRaisesRegex(MODULE.ExportError, "Node.js 18\\+ is required"): MODULE.ensure_nodejs() - @patch.object(MODULE.subprocess, "run") + @patch.object(MODULE, "run_command") @patch.object(MODULE.shutil, "which") - def test_missing_npm_raises_clear_error(self, which, run): + def test_missing_npm_raises_clear_error(self, which, run_command): which.side_effect = ["/bin/node", None] - run.return_value = MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n") + run_command.return_value = MODULE.subprocess.CompletedProcess([], 0, "v22.11.0\n") with self.assertRaisesRegex(MODULE.ExportError, "npm is not installed"): MODULE.ensure_nodejs() @@ -128,6 +130,53 @@ class ExportPptxTests(unittest.TestCase): slide = archive.read("ppt/slides/slide1.xml") self.assertIn(b"", slide) + @patch.object(MODULE.subprocess, "call", return_value=0) + def test_run_command_captures_utf8_via_temp_file(self, call): + def write_sink(*_args, **kwargs): + kwargs["stdout"].write("agent-browser 0.33.2\n") + return 0 + + call.side_effect = write_sink + process = MODULE.run_command(["agent-browser", "--version"], timeout=5) + self.assertEqual(process.returncode, 0) + self.assertIn("0.33.2", process.stdout) + self.assertEqual(call.call_args.kwargs["stderr"], MODULE.subprocess.STDOUT) + + def test_find_download_ignores_files_older_than_since(self): + with tempfile.TemporaryDirectory() as name: + root = Path(name) + old = root / "old.pptx" + new = root / "new.pptx" + for path in (old, new): + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "[Content_Types].xml", + '' + '', + ) + archive.writestr("ppt/presentation.xml", "") + + older = time.time() - 60 + os.utime(old, (older, older)) + since = time.time() - 5 + found = MODULE.find_download([root], timeout=2.0, since=since) + self.assertEqual(found.resolve(), new.resolve()) + + def test_browser_open_does_not_pass_download_path(self): + session = MODULE.BrowserSession( + "/bin/agent-browser", + "test-session", + Path("."), + Path("/tmp/downloads"), + ) + with patch.object(session, "run") as run: + session.open("http://127.0.0.1:9/export_host.html") + run.assert_called_once_with( + ["open", "http://127.0.0.1:9/export_host.html"], + timeout=90, + ) + if __name__ == "__main__": unittest.main()