Browse Source

feat: 添加更新日志处理与签名发布脚本

Hilda-Bot 3 days ago
parent
commit
5caf9a93ef

+ 10 - 1
.gitignore

@@ -14,4 +14,13 @@ output.xaml
 /Project/configs/local.*
 /Project/configs/local.*
 /.idea/*
 /.idea/*
 /local/*
 /local/*
-/Tools/*
+/Tools/*
+# Local translation packets and headless converter dependencies
+.translation-work/
+Actions/translation/node_modules/
+
+# AI instructions and prompts must remain local
+.translation-local/
+Actions/translation/README.md
+Actions/translation/prompts/
+Actions/translation/*.prompt.*

+ 15 - 0
Actions/translation/convert.mjs

@@ -0,0 +1,15 @@
+// Execute the pinned SPXX converter without executing any article scripts.
+import { build } from 'esbuild';
+import { JSDOM } from 'jsdom';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+const [source, htmlFile, url, output] = process.argv.slice(2);
+const entry = path.resolve(source, 'packages/userscript/src/sites/minecraft-net.ts');
+const code = await fs.readFile(entry, 'utf8');
+const result = await build({stdin:{contents:code+'\nexport { convertMCArticleToMarkdown };',resolveDir:path.dirname(entry),loader:'ts'},bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'headless-config',setup(b){b.onLoad({filter:/[/\\]config\.ts$/},()=>({contents:'export default {translator:"Hilda-Bot",bugCenter:{}};',loader:'js'}));}}]});
+const {convertMCArticleToMarkdown} = await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].text).toString('base64'));
+const doc = new JSDOM(await fs.readFile(htmlFile,'utf8'), {url}).window.document;
+if (!doc.querySelector('.MC_Layout_Article')) throw new Error('Minecraft.net article layout changed');
+const markdown = await convertMCArticleToMarkdown(doc,url,'Hilda-Bot');
+if (markdown.trim().length < 200) throw new Error('Empty/truncated article');
+await fs.writeFile(output,markdown);

+ 38 - 0
Actions/translation/gh_pr.py

@@ -0,0 +1,38 @@
+"""Create a pull request from the bot fork using GitHub CLI."""
+import argparse
+import json
+import subprocess
+from pathlib import Path
+
+UPSTREAM = 'Light-Beacon/PCL2-NewsHomepage'
+BOT = 'Hilda-Bot'
+
+
+def create_pr(branch, title, body_file):
+    login = subprocess.check_output(['gh', 'api', 'user', '--jq', '.login'], text=True, timeout=60).strip()
+    if login.casefold() != BOT.casefold():
+        raise RuntimeError(f'gh must authenticate as {BOT}; current account: {login}')
+    head = f'{BOT}:{branch}'
+    existing = json.loads(subprocess.check_output([
+        'gh', 'pr', 'list', '--repo', UPSTREAM, '--state', 'all',
+        '--head', branch, '--base', 'main', '--limit', '1000',
+        '--json', 'url,state,headRepositoryOwner'], text=True, timeout=60))
+    matches = [pr for pr in existing
+               if pr.get('headRepositoryOwner', {}).get('login', '').casefold() == BOT.casefold()]
+    if matches:
+        # Do not silently reopen rejected or merged contributions.
+        print(matches[0]['url'])
+        return matches[0]['url']
+    return subprocess.check_output([
+        'gh', 'pr', 'create', '--repo', UPSTREAM, '--base', 'main',
+        '--head', head, '--title', title, '--body-file', str(body_file.resolve())
+    ], text=True, timeout=60).strip()
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--branch', required=True)
+    parser.add_argument('--title', required=True)
+    parser.add_argument('--body-file', required=True, type=Path)
+    args = parser.parse_args()
+    print(create_pr(args.branch, args.title, args.body_file))

+ 961 - 0
Actions/translation/package-lock.json

@@ -0,0 +1,961 @@
+{
+  "name": "translation",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "esbuild": "0.25.12",
+        "jsdom": "26.1.0"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+      "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/css-calc": "^2.1.3",
+        "@csstools/css-color-parser": "^3.0.9",
+        "@csstools/css-parser-algorithms": "^3.0.4",
+        "@csstools/css-tokenizer": "^3.0.3",
+        "lru-cache": "^10.4.3"
+      }
+    },
+    "node_modules/@csstools/color-helpers": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+      "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+      "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+      "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^5.1.0",
+        "@csstools/css-calc": "^2.1.4"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+      "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+      "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/cssstyle": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+      "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^3.2.0",
+        "rrweb-cssom": "^0.8.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/data-urls": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+      "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "license": "MIT"
+    },
+    "node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+      "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-encoding": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsdom": {
+      "version": "26.1.0",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+      "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+      "license": "MIT",
+      "dependencies": {
+        "cssstyle": "^4.2.1",
+        "data-urls": "^5.0.0",
+        "decimal.js": "^10.5.0",
+        "html-encoding-sniffer": "^4.0.0",
+        "http-proxy-agent": "^7.0.2",
+        "https-proxy-agent": "^7.0.6",
+        "is-potential-custom-element-name": "^1.0.1",
+        "nwsapi": "^2.2.16",
+        "parse5": "^7.2.1",
+        "rrweb-cssom": "^0.8.0",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^5.1.1",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^7.0.0",
+        "whatwg-encoding": "^3.1.1",
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.1.1",
+        "ws": "^8.18.0",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "license": "ISC"
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/nwsapi": {
+      "version": "2.2.28",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
+      "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
+      "license": "MIT"
+    },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/rrweb-cssom": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+      "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "license": "MIT"
+    },
+    "node_modules/tldts": {
+      "version": "6.1.86",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+      "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^6.1.86"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "6.1.86",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+      "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+      "license": "MIT"
+    },
+    "node_modules/tough-cookie": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+      "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^6.1.32"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+      "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "14.2.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+      "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^5.1.0",
+        "webidl-conversions": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.22.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
+      "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "license": "MIT"
+    }
+  }
+}

+ 1 - 0
Actions/translation/package.json

@@ -0,0 +1 @@
+{"private":true,"type":"module","dependencies":{"esbuild":"0.25.12","jsdom":"26.1.0"}}

+ 153 - 0
Actions/translation/pipeline.py

@@ -0,0 +1,153 @@
+"""Prepare immutable translation packets; apply reviewed translations. No LLM API key needed."""
+import argparse
+import hashlib
+import json
+import re
+import subprocess
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from datetime import datetime, timezone, timedelta
+
+ROOT = Path(__file__).resolve().parents[2]
+LIB = ROOT / 'Project/libraries/Versions'
+WORK = ROOT / '.translation-work'
+SPXX_SHA = '6dde210a147062c565fae8f1b747e334f9f8f751'
+MANIFEST = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'
+
+
+def fetch(url):
+    req = urllib.request.Request(url, headers={'User-Agent': 'PCL2-NewsHomepage-Translation/1.0 ([email protected])'})
+    with urllib.request.urlopen(req, timeout=60) as response:
+        return response.read().decode('utf-8')
+
+
+def version_info(vid):
+    if re.fullmatch(r'\d+(?:\.\d+){1,2}', vid):
+        base, kind, slug = vid, 'release', 'java-edition-' + vid.replace('.', '-')
+    elif m := re.fullmatch(r'(\d+(?:\.\d+)+)-(snapshot|pre|rc)-?(\d+)', vid):
+        base, short, index = m.groups()
+        kind = {'snapshot':'snapshot','pre':'pre-release','rc':'release-candidate'}[short]
+        slug = base.replace('.', '-') + '-' + kind + '-' + index
+    else:
+        raise ValueError(f'Unsupported version: {vid}; explicit path mapping required')
+    major = '.'.join(base.split('.')[:2]) if base.startswith('1.') else base.split('.')[0]
+    return Path(major) / base / kind / (vid + '.md'), 'https://www.minecraft.net/en-us/article/minecraft-' + slug
+
+
+def digest(text):
+    return hashlib.sha256(text.encode()).hexdigest()
+
+
+def sync_after_detection():
+    """Fast-forward only; never discard local changes or merge divergent work."""
+    dirty = subprocess.check_output(['git', 'diff', 'HEAD', '--name-only'], cwd=ROOT, text=True)
+    if dirty.strip():
+        raise RuntimeError('Tracked local changes exist; use a clean checkout before pulling')
+    subprocess.run(['git', 'pull', '--ff-only',
+                    'https://github.com/Light-Beacon/PCL2-NewsHomepage.git', 'main'],
+                   cwd=ROOT, check=True)
+
+
+def prepare(days):
+    WORK.mkdir(exist_ok=True)
+    manifest = json.loads(fetch(MANIFEST))
+    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+    # Detect first, then pull before reading targets or preparing source packets.
+    pending = False
+    for v in manifest['versions']:
+        if datetime.fromisoformat(v['releaseTime'].replace('Z','+00:00')) < cutoff:
+            continue
+        if v['type'] not in ('release', 'snapshot'):
+            continue
+        try:
+            relative, _ = version_info(v['id'])
+        except ValueError:
+            continue
+        target = LIB / relative
+        if not target.exists() or re.search(r'^wip:\s*true\s*$', target.read_text(), re.M):
+            pending = True
+            break
+    if pending:
+        sync_after_detection()
+    packets, failures = [], []
+    for v in manifest['versions']:
+        if datetime.fromisoformat(v['releaseTime'].replace('Z','+00:00')) < cutoff:
+            continue
+        if v['type'] not in ('release','snapshot'):
+            continue
+        vid = v['id']
+        try:
+            relative, official = version_info(vid)
+            target = LIB / relative
+            old = target.read_text() if target.exists() else ''
+            if old and not re.search(r'^wip:\s*true\s*$', old, re.M):
+                continue  # Never overwrite existing completed/manual translations.
+            folder = WORK / vid
+            folder.mkdir(exist_ok=True)
+            html = fetch(official)
+            (folder/'official.html').write_text(html)
+            source = WORK/'spxx'
+            if not source.exists():
+                subprocess.run(['git','clone','https://github.com/Light-Beacon/SpxxNhpOptimized.git',str(source)],check=True)
+            subprocess.run(['git','-C',str(source),'checkout','--detach',SPXX_SHA],check=True)
+            subprocess.run(['node',str(Path(__file__).with_name('convert.mjs')),str(source),str(folder/'official.html'),official,str(folder/'source.md')],check=True)
+            title = 'Java版' + vid
+            api = 'https://zh.minecraft.wiki/api.php?' + urllib.parse.urlencode({'action':'parse','page':title,'prop':'wikitext','format':'json','redirects':1})
+            wiki = json.loads(fetch(api))
+            if 'error' in wiki:
+                raise RuntimeError('Chinese Wiki page unavailable; retry next run')
+            (folder/'wiki.json').write_text(json.dumps(wiki,ensure_ascii=False,indent=2))
+            details = json.loads(fetch(v['url']))
+            metadata = {'version':vid,'path':str(target.relative_to(ROOT)),'original_sha256':digest(old),'official_url':official,'wiki_url':'https://zh.minecraft.wiki/w/'+urllib.parse.quote(title),'wiki_revision':wiki['parse'].get('revid'),'server_jar':details.get('downloads',{}).get('server',{}).get('url'),'spxx_commit':SPXX_SHA}
+            (folder/'packet.json').write_text(json.dumps(metadata,ensure_ascii=False,indent=2))
+            packets.append(str(folder/'packet.json'))
+        except Exception as exc:
+            failures.append({'version':vid,'error':str(exc)})
+    result = {'packets':packets,'failures':failures}
+    (WORK/'queue.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
+    print(json.dumps(result,ensure_ascii=False,indent=2))
+    if failures:
+        raise SystemExit(1)
+
+
+def apply(packet, translation):
+    meta = json.loads(packet.read_text())
+    target = (ROOT/meta['path']).resolve()
+    if not target.is_relative_to(LIB.resolve()) or target.suffix != '.md':
+        raise ValueError('Invalid target')
+    old = target.read_text() if target.exists() else ''
+    if digest(old) != meta['original_sha256']:
+        raise ValueError('Target changed since preparation; prepare again')
+    body = translation.read_text().strip()
+    source = packet.with_name('source.md').read_text()
+    if len(body) < 200 or not re.search(r'[\u4e00-\u9fff]',body) or body.startswith('---'):
+        raise ValueError('Expected Chinese Markdown body without front matter')
+    if set(re.findall(r'MC-\d+',source)) - set(re.findall(r'MC-\d+',body)):
+        raise ValueError('Missing bug IDs')
+    if set(re.findall(r'https?://[^\s)<>]+',source)) - set(re.findall(r'https?://[^\s)<>]+',body)):
+        raise ValueError('Missing source links')
+    if old:
+        m = re.match(r'\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)',old,re.S)
+        if not m:
+            raise ValueError('Malformed front matter')
+        header = re.sub(r'^(?:wip|llm-transalte):[^\n]*(?:\n|$)','',m[1],flags=re.M).rstrip()
+    else:
+        header = 'version-image-link: null\nserver-jar: '+json.dumps(meta['server_jar'])+'\ntranslator: Hilda-Bot\ncats: '+json.dumps([target.parent.parent.name])
+    content = '---\n'+header+'\nllm-transalte: true\n---\n'+body+'\n\n'
+    content += f"<!-- 原文:{meta['official_url']};译名参考:{meta['wiki_url']}(revision {meta['wiki_revision']});SPXX {SPXX_SHA} -->\n"
+    target.parent.mkdir(parents=True,exist_ok=True)
+    temporary = target.with_suffix('.md.tmp')
+    temporary.write_text(content)
+    temporary.replace(target)
+    print(target.relative_to(ROOT))
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    sub = parser.add_subparsers(dest='command',required=True)
+    p = sub.add_parser('prepare'); p.add_argument('--days',type=int,default=14)
+    p = sub.add_parser('apply'); p.add_argument('packet',type=Path); p.add_argument('translation',type=Path)
+    args = parser.parse_args()
+    if args.command == 'prepare': prepare(args.days)
+    else: apply(args.packet,args.translation)

+ 34 - 0
Actions/translation/publish.py

@@ -0,0 +1,34 @@
+"""Generate a deterministic GitHub write plan for the connected Hilda-Bot account.
+The agent executes this plan through the GitHub connector; no PAT is stored locally.
+"""
+import argparse
+import json
+import subprocess
+from pathlib import Path
+from pipeline import ROOT, LIB
+
+
+def plan(paths, branch, title):
+    elements = []
+    for item in paths:
+        path = (ROOT/item).resolve()
+        if not path.is_relative_to(LIB.resolve()) or path.suffix != '.md':
+            raise ValueError('Only version Markdown files may be published')
+        content = path.read_text()
+        if '\nllm-transalte: true\n' not in content or '\nwip:' in content:
+            raise ValueError('Apply translation packet before publishing')
+        elements.append({'path':str(path.relative_to(ROOT)), 'mode':'100644','type':'blob','content':content})
+    if not elements:
+        raise ValueError('No translations')
+    base = subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()
+    tree = subprocess.check_output(['git','rev-parse','HEAD^{tree}'],cwd=ROOT,text=True).strip()
+    return {'repository_full_name':'Hilda-Bot/PCL2-NewsHomepage','pr_repository_full_name':'Light-Beacon/PCL2-NewsHomepage','head':'Hilda-Bot:'+branch,'account':'Hilda-Bot','email':'[email protected]','branch':branch,'base':'main','parent_sha':base,'base_tree_sha':tree,'tree_elements':elements,'message':title,'pr_title':title,'pr_body':'以 Minecraft.net 官方更新日志为原文、中文 Minecraft Wiki 为译名参考。使用固定版本 SPXX 转换,LLM 翻译;已校验源链接和 MC 漏洞编号,移除 wip 并标记 llm-transalte: true。\n\n请人工复核译文。'}
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--branch',required=True)
+    parser.add_argument('--title',required=True)
+    parser.add_argument('paths',nargs='+')
+    args = parser.parse_args()
+    print(json.dumps(plan(args.paths,args.branch,args.title),ensure_ascii=False,indent=2))

+ 51 - 0
Actions/translation/signed_push.py

@@ -0,0 +1,51 @@
+"""Commit version Markdown with a configured signing key, verify, then push."""
+import argparse
+import subprocess
+from pathlib import Path
+from pipeline import ROOT, LIB
+
+
+def output(args):
+    return subprocess.check_output(args, cwd=ROOT, text=True).strip()
+
+
+def publish(branch, message, paths):
+    if output(['gh', 'api', 'user', '--jq', '.login']).casefold() != 'hilda-bot':
+        raise RuntimeError('gh must authenticate as Hilda-Bot')
+    if output(['git', 'branch', '--show-current']) != branch:
+        raise RuntimeError('Check out the intended publishing branch first')
+    # Explicit configuration prevents accidental use of the personal default key.
+    if output(['git', 'config', '--local', '--get', 'user.email']) != '[email protected]':
+        raise RuntimeError('Configure repository-local [email protected] identity')
+    key = output(['git', 'config', '--local', '--get', 'user.signingkey'])
+    if not key:
+        raise RuntimeError('Configure a repository-local Hilda-Bot signing key')
+    if output(['git', 'diff', '--cached', '--name-only']):
+        raise RuntimeError('Index must be empty before publishing')
+    files = []
+    for item in paths:
+        path = (ROOT / item).resolve()
+        if not path.is_relative_to(LIB.resolve()) or path.suffix != '.md':
+            raise ValueError('Only version Markdown files may be published')
+        content = path.read_text()
+        if '\nllm-transalte: true\n' not in content or '\nwip:' in content:
+            raise ValueError('Apply the translation packet first')
+        files.append(str(path.relative_to(ROOT)))
+    if not files:
+        raise ValueError('No translations')
+    subprocess.run(['git', 'add', '--', *files], cwd=ROOT, check=True)
+    subprocess.run(['git', '-c', 'user.name=Hilda Bot', '-c', '[email protected]',
+                    'commit', '-S', '-m', message], cwd=ROOT, check=True)
+    subprocess.run(['git', 'verify-commit', 'HEAD'], cwd=ROOT, check=True)
+    subprocess.run(['git', '-c', 'credential.helper=', '-c', 'credential.helper=!gh auth git-credential',
+                    'push', 'https://github.com/Hilda-Bot/PCL2-NewsHomepage.git',
+                    f'HEAD:refs/heads/{branch}'], cwd=ROOT, check=True)
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--branch', required=True)
+    parser.add_argument('--message', required=True)
+    parser.add_argument('paths', nargs='+')
+    args = parser.parse_args()
+    publish(args.branch, args.message, args.paths)

+ 26 - 0
Actions/translation/test_gh_pr.py

@@ -0,0 +1,26 @@
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+from gh_pr import create_pr
+
+
+class GhPrTests(unittest.TestCase):
+    def test_wrong_account_stops_before_writes(self):
+        with patch('gh_pr.subprocess.check_output', return_value='Light-Beacon\n') as command:
+            with self.assertRaises(RuntimeError): create_pr('topic', 'title', Path('body.md'))
+            self.assertEqual(command.call_count, 1)
+
+    def test_existing_pr_is_reused(self):
+        with patch('gh_pr.subprocess.check_output', side_effect=['Hilda-Bot\n', '[{"url":"https://github.com/test/pull/1","headRepositoryOwner":{"login":"Hilda-Bot"}}]']) as command:
+            self.assertEqual(create_pr('topic', 'title', Path('body.md')), 'https://github.com/test/pull/1')
+            self.assertEqual(command.call_count, 2)
+
+    def test_create_with_explicit_head_and_body_file(self):
+        with patch('gh_pr.subprocess.check_output', side_effect=['Hilda-Bot\n', '[]', 'https://github.com/test/pull/2\n']) as command:
+            self.assertEqual(create_pr('topic', 'title', Path('body.md')), 'https://github.com/test/pull/2')
+            args = command.call_args.args[0]
+            self.assertIn('Hilda-Bot:topic', args)
+            self.assertIn('--body-file', args)
+
+
+if __name__ == '__main__': unittest.main()

+ 52 - 0
Actions/translation/test_pipeline.py

@@ -0,0 +1,52 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+import pipeline as p
+
+
+class PipelineTests(unittest.TestCase):
+    def test_pull_before_preparation(self):
+        with patch.object(p.subprocess, 'check_output', return_value=''), patch.object(p.subprocess, 'run') as run:
+            p.sync_after_detection()
+            self.assertEqual(run.call_args.args[0], ['git', 'pull', '--ff-only', 'https://github.com/Light-Beacon/PCL2-NewsHomepage.git', 'main'])
+        with patch.object(p.subprocess, 'check_output', return_value='local.md'), patch.object(p.subprocess, 'run') as run:
+            with self.assertRaises(RuntimeError): p.sync_after_detection()
+            run.assert_not_called()
+
+    def test_paths(self):
+        cases = {'26.3-snapshot-2':'26/26.3/snapshot/26.3-snapshot-2.md',
+                 '1.21.11-pre1':'1.21/1.21.11/pre-release/1.21.11-pre1.md',
+                 '26.1-rc-1':'26/26.1/release-candidate/26.1-rc-1.md',
+                 '1.21.11':'1.21/1.21.11/release/1.21.11.md'}
+        for vid, expected in cases.items():
+            self.assertEqual(str(p.version_info(vid)[0]),expected)
+        with self.assertRaises(ValueError): p.version_info('25w01a')
+        with self.assertRaises(ValueError): p.version_info('../evil')
+
+    def test_apply_preserves_metadata_and_detects_concurrency(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            root = Path(tmp).resolve(); lib = root/'Project/libraries/Versions'; lib.mkdir(parents=True)
+            target = lib/'26.md'
+            old = '---\nversion-image-link: original\nwip: true\ncats: [26]\n---\n'
+            target.write_text(old)
+            packet = root/'packet.json'
+            packet.write_text(json.dumps({'path':str(target.relative_to(root)),'original_sha256':p.digest(old),'official_url':'https://example.com','wiki_url':'https://wiki.example.com','wiki_revision':123}))
+            body = '## 新特性\n'+'- 完整中文翻译。\n'*30+'[MC-123](https://example.com)'
+            (root/'source.md').write_text('[MC-123](https://example.com)')
+            translation = root/'translation.md'; translation.write_text(body)
+            with patch.object(p,'ROOT',root),patch.object(p,'LIB',lib):
+                p.apply(packet,translation)
+                output = target.read_text()
+                self.assertIn('version-image-link: original',output)
+                self.assertNotIn('wip:',output)
+                self.assertEqual(output.count('llm-transalte: true'),1)
+                with self.assertRaises(ValueError): p.apply(packet,translation)
+                target.write_text(old)
+                translation.write_text('中文'*150)
+                with self.assertRaisesRegex(ValueError,'bug IDs'): p.apply(packet,translation)
+                self.assertEqual(target.read_text(),old)
+
+
+if __name__ == '__main__': unittest.main()