pipeline.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Prepare immutable translation packets; apply reviewed translations. No LLM API key needed."""
  2. import argparse
  3. import hashlib
  4. import json
  5. import re
  6. import subprocess
  7. import urllib.parse
  8. import urllib.request
  9. from pathlib import Path
  10. from datetime import datetime, timezone, timedelta
  11. ROOT = Path(__file__).resolve().parents[2]
  12. LIB = ROOT / 'Project/libraries/Versions'
  13. WORK = ROOT / '.translation-work'
  14. SPXX_SHA = '6dde210a147062c565fae8f1b747e334f9f8f751'
  15. MANIFEST = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'
  16. def fetch(url):
  17. req = urllib.request.Request(url, headers={'User-Agent': 'PCL2-NewsHomepage-Translation/1.0 ([email protected])'})
  18. with urllib.request.urlopen(req, timeout=60) as response:
  19. return response.read().decode('utf-8')
  20. def fetch_official(url):
  21. headers = {
  22. 'Upgrade-Insecure-Requests': '1',
  23. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/157.0.0.0 Safari/537.36 Edg/157.0.0.0',
  24. 'sec-ch-ua': '\"Not-A.Brand\";v=\"99\", \"Microsoft Edge\";v=\"157\", \"Chromium\";v=\"157\"',
  25. 'sec-ch-ua-mobile': '?0',
  26. 'sec-ch-ua-platform': '\"macOS\"',
  27. }
  28. command = ['curl', '--url', url, '--location', '--fail', '--silent', '--show-error',
  29. '--connect-timeout', '15', '--max-time', '45']
  30. for name, value in headers.items():
  31. command.extend(['-H', f'{name}: {value}'])
  32. html = subprocess.check_output(command, text=True, timeout=50)
  33. if 'MC_Layout_Article' not in html:
  34. raise RuntimeError('Official response is not a supported article page')
  35. return html
  36. def version_info(vid):
  37. if re.fullmatch(r'\d+(?:\.\d+){1,2}', vid):
  38. base, kind, slug = vid, 'release', 'java-edition-' + vid.replace('.', '-')
  39. elif m := re.fullmatch(r'(\d+(?:\.\d+)+)-(snapshot|pre|rc)-?(\d+)', vid):
  40. base, short, index = m.groups()
  41. kind = {'snapshot':'snapshot','pre':'pre-release','rc':'release-candidate'}[short]
  42. slug = base.replace('.', '-') + '-' + kind + '-' + index
  43. else:
  44. raise ValueError(f'Unsupported version: {vid}; explicit path mapping required')
  45. major = '.'.join(base.split('.')[:2]) if base.startswith('1.') else base.split('.')[0]
  46. return Path(major) / base / kind / (vid + '.md'), 'https://www.minecraft.net/en-us/article/minecraft-' + slug
  47. def digest(text):
  48. return hashlib.sha256(text.encode()).hexdigest()
  49. def sync_after_detection():
  50. """Fast-forward only; never discard local changes or merge divergent work."""
  51. dirty = subprocess.check_output(['git', 'diff', 'HEAD', '--name-only'], cwd=ROOT, text=True)
  52. if dirty.strip():
  53. raise RuntimeError('Tracked local changes exist; use a clean checkout before pulling')
  54. subprocess.run(['git', 'pull', '--ff-only',
  55. 'https://github.com/Light-Beacon/PCL2-NewsHomepage.git', 'main'],
  56. cwd=ROOT, check=True)
  57. def prepare(days):
  58. WORK.mkdir(exist_ok=True)
  59. manifest = json.loads(fetch(MANIFEST))
  60. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  61. # Detect first, then pull before reading targets or preparing source packets.
  62. pending = False
  63. for v in manifest['versions']:
  64. if datetime.fromisoformat(v['releaseTime'].replace('Z','+00:00')) < cutoff:
  65. continue
  66. if v['type'] not in ('release', 'snapshot'):
  67. continue
  68. try:
  69. relative, _ = version_info(v['id'])
  70. except ValueError:
  71. continue
  72. target = LIB / relative
  73. if not target.exists() or re.search(r'^wip:\s*true\s*$', target.read_text(), re.M):
  74. pending = True
  75. break
  76. if pending:
  77. sync_after_detection()
  78. packets, failures = [], []
  79. for v in manifest['versions']:
  80. if datetime.fromisoformat(v['releaseTime'].replace('Z','+00:00')) < cutoff:
  81. continue
  82. if v['type'] not in ('release','snapshot'):
  83. continue
  84. vid = v['id']
  85. try:
  86. relative, official = version_info(vid)
  87. target = LIB / relative
  88. old = target.read_text() if target.exists() else ''
  89. if old and not re.search(r'^wip:\s*true\s*$', old, re.M):
  90. continue # Never overwrite existing completed/manual translations.
  91. folder = WORK / vid
  92. folder.mkdir(exist_ok=True)
  93. html = fetch_official(official)
  94. (folder/'official.html').write_text(html)
  95. source = WORK/'spxx'
  96. if not source.exists():
  97. subprocess.run(['git','clone','https://github.com/Light-Beacon/SpxxNhpOptimized.git',str(source)],check=True)
  98. subprocess.run(['git','-C',str(source),'checkout','--detach',SPXX_SHA],check=True)
  99. subprocess.run(['node',str(Path(__file__).with_name('convert.mjs')),str(source),str(folder/'official.html'),official,str(folder/'source.md')],check=True)
  100. title = 'Java版' + vid
  101. api = 'https://zh.minecraft.wiki/api.php?' + urllib.parse.urlencode({'action':'parse','page':title,'prop':'wikitext|revid','format':'json','redirects':1})
  102. wiki = json.loads(fetch(api))
  103. if 'error' in wiki:
  104. raise RuntimeError('Chinese Wiki page unavailable; retry next run')
  105. (folder/'wiki.json').write_text(json.dumps(wiki,ensure_ascii=False,indent=2))
  106. details = json.loads(fetch(v['url']))
  107. 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}
  108. (folder/'packet.json').write_text(json.dumps(metadata,ensure_ascii=False,indent=2))
  109. packets.append(str(folder/'packet.json'))
  110. except Exception as exc:
  111. failures.append({'version':vid,'error':str(exc)})
  112. result = {'packets':packets,'failures':failures}
  113. (WORK/'queue.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
  114. print(json.dumps(result,ensure_ascii=False,indent=2))
  115. if failures:
  116. raise SystemExit(1)
  117. def apply(packet, translation):
  118. meta = json.loads(packet.read_text())
  119. target = (ROOT/meta['path']).resolve()
  120. if not target.is_relative_to(LIB.resolve()) or target.suffix != '.md':
  121. raise ValueError('Invalid target')
  122. old = target.read_text() if target.exists() else ''
  123. if digest(old) != meta['original_sha256']:
  124. raise ValueError('Target changed since preparation; prepare again')
  125. body = translation.read_text().strip()
  126. source = packet.with_name('source.md').read_text()
  127. if len(body) < 200 or not re.search(r'[\u4e00-\u9fff]',body) or body.startswith('---'):
  128. raise ValueError('Expected Chinese Markdown body without front matter')
  129. if set(re.findall(r'MC-\d+',source)) - set(re.findall(r'MC-\d+',body)):
  130. raise ValueError('Missing bug IDs')
  131. if set(re.findall(r'https?://[^\s)<>]+',source)) - set(re.findall(r'https?://[^\s)<>]+',body)):
  132. raise ValueError('Missing source links')
  133. if old:
  134. m = re.match(r'\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)',old,re.S)
  135. if not m:
  136. raise ValueError('Malformed front matter')
  137. header = re.sub(r'^(?:wip|llm-transalte):[^\n]*(?:\n|$)','',m[1],flags=re.M).rstrip()
  138. else:
  139. header = 'version-image-link: null\nserver-jar: '+json.dumps(meta['server_jar'])+'\ntranslator: Hilda-Bot\ncats: '+json.dumps([target.parent.parent.name])
  140. content = '---\n'+header+'\nllm-transalte: true\n---\n'+body+'\n\n'
  141. content += f"<!-- 原文:{meta['official_url']};译名参考:{meta['wiki_url']}(revision {meta['wiki_revision']});SPXX {SPXX_SHA} -->\n"
  142. target.parent.mkdir(parents=True,exist_ok=True)
  143. temporary = target.with_suffix('.md.tmp')
  144. temporary.write_text(content)
  145. temporary.replace(target)
  146. print(target.relative_to(ROOT))
  147. if __name__ == '__main__':
  148. parser = argparse.ArgumentParser(description=__doc__)
  149. sub = parser.add_subparsers(dest='command',required=True)
  150. p = sub.add_parser('prepare'); p.add_argument('--days',type=int,default=14)
  151. p = sub.add_parser('apply'); p.add_argument('packet',type=Path); p.add_argument('translation',type=Path)
  152. args = parser.parse_args()
  153. if args.command == 'prepare': prepare(args.days)
  154. else: apply(args.packet,args.translation)