"""Compilación y publicación repetible de Raps; nunca publica una etapa fallida.""" from __future__ import annotations import argparse import hashlib import inspect import json import os from pathlib import Path import re import shutil import subprocess import sys import time import zipfile ROOT = Path(__file__).resolve().parents[1] WORK = ROOT / 'build/publicacion' STATE = WORK / 'etapas.json' SUMMARY = WORK / 'resultado.json' PYTHON = sys.executable JAVA = Path('C:/Program Files/Eclipse Adoptium/jdk-17.0.20.8-hotspot/bin') SITES = Path.home() / '.codex/plugins/cache/openai-curated-remote/sites/0.1.75/scripts/site-workflow.mjs' # HUELLAS: una etapa sólo se reutiliza si sus entradas y salidas siguen iguales. def sha(path: Path) -> str: value = hashlib.sha256() with path.open('rb') as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b''): value.update(chunk) return value.hexdigest() def files(paths): result = [] for path in map(Path, paths): if path.is_dir(): result.extend(p for p in path.rglob('*') if p.is_file() and '__pycache__' not in p.parts) elif path.is_file(): result.append(path) else: raise FileNotFoundError(path) return sorted(set(result)) def digest(paths) -> str: return hashlib.sha256(json.dumps([(str(p), sha(p)) for p in files(paths)]).encode()).hexdigest() def write_json(path, value): path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + '.tmp') temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') temporary.replace(path) # HERRAMIENTAS: usar ejecutables reales evita los problemas de .cmd y comillas. def executable(name): found = shutil.which(name + '.exe') or shutil.which(name) if found and Path(found).suffix.lower() == '.cmd': match = re.search(r'"([^"\r\n]+\.exe)"', Path(found).read_text(encoding='utf-8')) if match and Path(match[1]).is_file(): return match[1] if not found: raise RuntimeError('No se encontró ' + name) return found def ps(script, *arguments): return [executable('pwsh'), '-NoProfile', '-File', str(ROOT / script), *map(str, arguments)] def build_sources(): return [ROOT / 'src', ROOT / 'build.gradle', ROOT / 'settings.gradle', ROOT / 'gradle.properties', ROOT / 'gradle/wrapper', ROOT / 'gradlew.bat', ROOT / 'compilar.ps1'] def server_test_sources(): backend = [p for p in (ROOT / 'src/main/java').rglob('*.java') if '/horror/client/' not in p.as_posix() and p.name != 'HostSkinAtlas.java'] infrastructure = [p for p in build_sources() if p != ROOT / 'src'] resources = [p for p in (ROOT / 'src/main/resources').rglob('*') if p.is_file() and '/textures/' not in p.as_posix() and '/sounds/' not in p.as_posix()] return backend + infrastructure + resources + [ROOT / 'config/raps'] def stop_owned(process): # Sólo terminar el árbol que inició este publicador, nunca Java/Chrome por nombre. if process.poll() is None: if os.name == 'nt': subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15) else: process.kill() process.wait(timeout=15) # EJECUCIÓN: límite total, registro por etapa y avance breve cada 30 segundos. def run(command, log, limit=600, cwd=ROOT, input_text=None): environment = os.environ.copy() git_bin = Path('C:/Program Files/Git/bin') if git_bin.exists(): environment['PATH'] = str(git_bin) + os.pathsep + environment.get('PATH', '') if any('site-workflow.mjs' in str(part) for part in command): # GNU tar de Git Bash interpreta C: como host remoto si no se fuerza ruta local. environment['TAR_OPTIONS'] = '--force-local' environment['PYTHONUNBUFFERED'] = '1' started = time.monotonic() next_notice = started + 30 check_failure_at = started + 1 game_tests = any('runGameTestServer' in str(part) for part in command) with Path(log).open('w', encoding='utf-8') as stream: process = subprocess.Popen(list(map(str, command)), cwd=cwd, env=environment, stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) try: if input_text is not None: process.stdin.write(input_text.encode('utf-8')) process.stdin.close() while process.poll() is None: now = time.monotonic() if now - started > limit: raise TimeoutError(f'La etapa excedió {limit}s. Registro: {log}') if game_tests and now >= check_failure_at: stream.flush() current = Path(log).read_text(encoding='utf-8', errors='replace') if re.search(r'\[minecraft/LogTestReporter\].* failed!', current): raise RuntimeError('Forge informó una prueba fallida; publicación detenida. Registro: ' + str(log)) check_failure_at = now + 1 if now >= next_notice: print(f' {Path(log).stem}: {int(now - started)}s', flush=True) next_notice = now + 30 time.sleep(.2) if process.returncode: tail = Path(log).read_text(encoding='utf-8', errors='replace').splitlines()[-12:] raise RuntimeError(f'Etapa fallida ({process.returncode}): {log}\n' + '\n'.join(tail)) finally: stop_owned(process) return time.monotonic() - started class Publisher: def __init__(self, force=False, work=WORK): self.work = work self.work.mkdir(parents=True, exist_ok=True) self.state_path = work / 'etapas.json' self.state = json.loads(self.state_path.read_text(encoding='utf-8')) if self.state_path.exists() else {} self.force = force self.done = [] def phase(self, name, command, inputs, outputs=(), limit=600, before=None, after=None): key = digest(inputs) invocation = list(map(str, command)) hooks = [inspect.getsource(hook) if hook is not None else None for hook in (before, after)] output_paths = list(map(Path, outputs)) previous = self.state.get(name, {}) if (not self.force and previous.get('inputs') == key and previous.get('command') == invocation and previous.get('hooks') == hooks and all(p.exists() for p in output_paths) and previous.get('outputs') == {str(p): sha(p) for p in output_paths}): print(name + ': reutilizado', flush=True) self.done.append({'stage': name, 'status': 'cached'}) return print(name + ': iniciando', flush=True) if before: before() seconds = run(command, self.work / (name + '.log'), limit) if after: after(self.work / (name + '.log')) if digest(inputs) != key: raise RuntimeError('Cambió una entrada durante ' + name + '; retomar con las fuentes estables.') self.state[name] = {'inputs': key, 'command': invocation, 'hooks': hooks, 'outputs': {str(p): sha(p) for p in output_paths}, 'seconds': round(seconds, 2), 'completed': time.strftime('%Y-%m-%dT%H:%M:%S')} write_json(self.state_path, self.state) self.done.append({'stage': name, 'status': 'passed', 'seconds': round(seconds, 2)}) print(name + ': completo', flush=True) # PRUEBAS DE SERVIDOR: conservar el mundo local anterior y exigir el resultado Forge. def prepare_test_world(): world = (ROOT / 'run/world').resolve() if world.parent != (ROOT / 'run').resolve(): raise RuntimeError('Ruta de mundo inesperada') if world.exists(): world.rename(world.with_name('world-before-release-' + time.strftime('%Y%m%d-%H%M%S'))) def check_game_tests(log): text = log.read_text(encoding='utf-8', errors='replace') match = re.search(r'All (\d+) required tests passed', text) if not match or 'BUILD SUCCESSFUL' not in text: raise RuntimeError('Forge no confirmó todas las pruebas requeridas: ' + str(log)) write_json(WORK / 'pruebas-servidor.json', {'passed': True, 'required_tests': int(match[1])}) # INSTALACIÓN: respaldar el JAR anterior y comparar el archivo copiado. def install_jar(): source = ROOT / 'build/libs/Raps.jar' with zipfile.ZipFile(source) as archive: if archive.testzip() is not None: raise RuntimeError('El JAR contiene una entrada dañada') for target in [ROOT / 'dist/Raps.jar', ROOT.parent / 'mods/Raps.jar']: target.parent.mkdir(parents=True, exist_ok=True) if target.exists() and sha(target) != sha(source): backup = ROOT / 'respaldos' / ('publicacion-' + time.strftime('%Y%m%d-%H%M%S')) / target.parent.name backup.mkdir(parents=True, exist_ok=True) shutil.copy2(target, backup / target.name) temporary = target.with_suffix('.jar.tmp') shutil.copy2(source, temporary) if sha(temporary) != sha(source): raise RuntimeError('Copia incorrecta: ' + str(target)) temporary.replace(target) return sha(source) # INFORME: documentación de la entrega tomada de los resultados, sin cifras fijas. def delivery_report(jar_hash, server_tests_skipped=False): def read(relative): path = ROOT / relative return json.loads(path.read_text(encoding='utf-8')) if path.exists() else {} server = read('build/publicacion/pruebas-servidor.json') motion = read('docs/parasito-movimiento-validacion.json') native = read('build/jar-native-audit/result.json') remote = read('build/publicacion/exaroton.json') remote_current = remote.get('status') == 'verified' and remote.get('sha256') == jar_hash release_state = json.loads(STATE.read_text(encoding='utf-8')) report = { 'created': release_state['compilar']['completed'], 'jar_sha256': jar_hash, 'jar_bytes': (ROOT / 'build/libs/Raps.jar').stat().st_size, 'rig_sha256': sha(ROOT / 'src/main/java/ar/com/companeros/horror/client/ParasiteRig.java'), 'model_sha256': sha(ROOT / 'src/main/java/ar/com/companeros/horror/client/ParasiteModel.java'), 'server_tests': {'status': 'skipped'} if server_tests_skipped else server, 'locomotion': motion, 'native_source_equivalence': native, 'exposed_skin': read('build/exposed-skin-audit/result.json'), 'continuity': read('build/motion-support-audit/current.json'), 'media': read('docs/parasito-preview-validacion.json'), 'voice_studio': read('docs/voice-studio/validacion.json'), 'exaroton': remote if remote_current else {'status': 'not_verified_for_this_jar'} } write_json(ROOT / 'docs/entrega-actual.json', report) count = server.get('required_tests', 'sin registro') tests = 'Pruebas de servidor omitidas en esta ejecución.' if server_tests_skipped else f'Forge confirmó {count} pruebas requeridas.' remote_text = ('El JAR de Exaroton fue descargado y su hash coincide con esta entrega. El servidor permanece apagado.' if remote_current else 'La instalación de Exaroton no está verificada para este JAR.') markdown = f'''# Entrega actual de Raps Generada el {report['created']} desde los resultados de esta compilación. JAR: **{report['jar_bytes']} bytes**, SHA256 `{jar_hash}`. {tests} La auditoría de locomoción comprobó {motion.get('checked_samples', 'sin registro')} muestras en {', '.join(motion.get('selected_modes', []))}. La equivalencia fuente/JAR verificó {native.get('native_cli', {}).get('native_frames_per_target', 'sin registro')} muestras nativas. Los informes completos están en [entrega-actual.json](entrega-actual.json). El acecho ya no usa un apoyo ficticio. Las palmas consultan colisiones reales; adquirir y abandonar el apoyo es gradual. El plegado mezcla orientaciones continuas de los brazos y conserva la posición final del arrastre. La revisión de contorsiones sigue requiriendo observación dentro del cliente; la auditoría de locomoción no afirma ausencia absoluta de intersecciones en todas las poses. Luna puede solicitar `auto`, `hunched`, `all_fours`, `crawl` y `wall_brace`. Las preferencias caducan y el entorno decide si son posibles. La cadencia, el presupuesto y el Gateway permanecen vigentes. El estudio local transforma grabaciones e incorpora síntesis Piper bajo demanda. El preset utiliza el tratamiento de la voz aprobado; aplicar el mismo filtro a dos voces no garantiza identidad vocal idéntica. El publicador instala el JAR local con respaldo y prepara las galerías. {remote_text} ''' (ROOT / 'docs/ENTREGA-ACTUAL.md').write_text(markdown, encoding='utf-8') def check_server_ready(server_tests_required=True): state = json.loads(STATE.read_text(encoding='utf-8')) jar = ROOT / 'build/libs/Raps.jar' required_stages = [('compilar', build_sources())] if server_tests_required: required_stages.append(('pruebas-servidor', server_test_sources())) for name, inputs in required_stages: stage = state.get(name, {}) if stage.get('inputs') != digest(inputs): raise RuntimeError('Las fuentes cambiaron después de ' + name) if any(not Path(p).is_file() or sha(Path(p)) != value for p, value in stage.get('outputs', {}).items()): raise RuntimeError('El artefacto cambió después de ' + name) audit = json.loads((ROOT / 'build/jar-native-audit/result.json').read_text(encoding='utf-8')) if not audit.get('passed') or audit.get('jar_sha256') != sha(jar): raise RuntimeError('Falta comprobar la equivalencia del JAR actual con las fuentes') print('Entrega actual compilada, probada y coincidente con su fuente.') # SITES: preparar el mismo paquete; la credencial sólo entra por stdin. def sites_package(): payload = json.loads(sys.stdin.readline()) credential = payload['credential'] hosting = json.loads((ROOT / 'galeria-chatgpt/.openai/hosting.json').read_text(encoding='utf-8')) project_id = hosting['project_id'] checkout = ROOT / 'galeria-chatgpt' command = [executable('node'), str(SITES), '--project-id', project_id] opening_log = WORK / 'sites-opening.log' run(command, opening_log, 180, checkout, json.dumps({'credential': credential}) + '\n') source = next(json.loads(line) for line in opening_log.read_text(encoding='utf-8').splitlines() if line.startswith('{') and 'checkout_path' in line) # Archivos de publicación fuera del checkout: nunca se añaden al commit del sitio. archive = WORK / 'galeria-chatgpt.tar.gz' publishing_log = WORK / 'sites-package.log' run(command, publishing_log, 600, checkout, json.dumps({'credential': credential, 'source': source, 'archivePath': archive.as_posix()}) + '\n') result = next(json.loads(line) for line in publishing_log.read_text(encoding='utf-8').splitlines() if line.startswith('{') and 'archive' in line) write_json(WORK / 'sites-paquete.json', result) print(json.dumps(result), flush=True) def self_test(): # El fixture usa sólo archivos temporales; no compila, instala ni publica. import tempfile with tempfile.TemporaryDirectory(prefix='raps-publicador-') as folder: base = Path(folder) entry, output = base / 'input.txt', base / 'output.txt' entry.write_text('uno', encoding='utf-8') publisher = Publisher(work=base) command = [PYTHON, '-c', 'from pathlib import Path; Path(r"' + str(output) + '").write_text("ok")'] publisher.phase('fixture', command, [entry], [output]) publisher.phase('fixture', command, [entry], [output]) assert publisher.done[-1]['status'] == 'cached' output.write_text('alterado', encoding='utf-8') publisher.phase('fixture', command, [entry], [output]) assert output.read_text() == 'ok' and publisher.done[-1]['status'] == 'passed' changed_command = [PYTHON, '-c', 'from pathlib import Path; Path(r"' + str(output) + '").write_text("nuevo")'] publisher.phase('fixture', changed_command, [entry], [output]) assert output.read_text() == 'nuevo' and publisher.done[-1]['status'] == 'passed' entry.write_text('dos', encoding='utf-8') publisher.phase('fixture', command, [entry], [output]) try: publisher.phase('fallo', [PYTHON, '-c', 'raise SystemExit(7)'], [entry]) except RuntimeError: pass else: raise AssertionError('No bloqueó una etapa fallida') assert 'fallo' not in publisher.state try: run([PYTHON, '-c', 'import time; time.sleep(10)'], base / 'timeout.log', .4) except TimeoutError: pass else: raise AssertionError('No aplicó el límite de tiempo') print('Autoprueba: caché, invalidación, fallo y tiempo límite aprobados.') # ENTREGA: las etapas posteriores nunca corren si una validación anterior falla. def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prepare-only', action='store_true') parser.add_argument('--force', action='store_true') parser.add_argument('--skip-server-tests', action='store_true') parser.add_argument('--self-test', action='store_true') parser.add_argument('--sites-package', action='store_true') parser.add_argument('--write-report', action='store_true') parser.add_argument('--server', action='store_true') parser.add_argument('--server-only', action='store_true') parser.add_argument('--check-server-ready', action='store_true') args = parser.parse_args() WORK.mkdir(parents=True, exist_ok=True) if args.self_test: self_test() return if args.sites_package: sites_package() return if args.check_server_ready: check_server_ready() return if args.server_only: run(ps('publicar-servidor-raps.ps1'), WORK / 'exaroton.log', 300) return if args.write_report: delivery_report(sha(ROOT / 'build/libs/Raps.jar'), args.skip_server_tests) return publisher = Publisher(args.force) lock = (WORK / 'publicador.lock').open('a+b') if os.name == 'nt': import msvcrt lock.seek(0) try: msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1) except OSError: raise RuntimeError('Ya hay un publicador activo') try: source = ROOT / 'src' jar = ROOT / 'build/libs/Raps.jar' # Estas herramientas viven fuera de build para sobrevivir a Gradle clean. required = ['docs/publicacion/verify-final-jar.py', 'docs/publicacion/JarRigExportAudit.java', 'docs/publicacion/run-exposed-skin-audit.py', 'docs/publicacion/ExposedSkinAudit.java', 'docs/publicacion/check-published-gallery.cjs'] for relative in required: if not (ROOT / relative).is_file(): raise RuntimeError('Falta una herramienta de publicación: ' + relative) build_inputs = build_sources() publisher.phase('compilar', ps('compilar.ps1', '-Tarea', 'build', '-SinRed'), build_inputs, [jar], 900) if not args.skip_server_tests: publisher.phase('pruebas-servidor', ps('compilar.ps1', '-Tarea', 'runGameTestServer', '-SinRed'), server_test_sources(), [WORK / 'pruebas-servidor.json'], 900, prepare_test_world, check_game_tests) rig = source / 'main/java/ar/com/companeros/horror/client/ParasiteRig.java' model = source / 'main/java/ar/com/companeros/horror/client/ParasiteModel.java' textures = source / 'main/resources/assets/companeros/textures/entity' # El auditor consume clases propias del visor, distintas de las de Gradle. preview_classes = ROOT / 'build/parasite-preview-classes' preview_classes.mkdir(parents=True, exist_ok=True) publisher.phase('compilar-visor', [JAVA / 'javac.exe', '-encoding', 'UTF-8', '-d', preview_classes, rig], [rig], [preview_classes / ('ar/com/companeros/horror/client/ParasiteRig' + name + '.class') for name in ['', '$Pose', '$Bone', '$Box', '$State', '$Bounds']], 90) publisher.phase('movimiento', [PYTHON, ROOT / 'docs/validate-parasite-preview.py', '--modes', 'stalk,crawl3,climb'], [rig, ROOT / 'docs/validate-parasite-preview.py'], [ROOT / 'docs/parasito-movimiento-validacion.json'], 240) native_audit = ROOT / 'docs/publicacion/verify-final-jar.py' publisher.phase('fuente-jar', [PYTHON, native_audit, '--jar', jar, '--source-only'], [rig, jar, native_audit, ROOT / 'docs/publicacion/JarRigExportAudit.java'], [ROOT / 'build/jar-native-audit/result.json'], 240) skin_audit = ROOT / 'docs/publicacion/run-exposed-skin-audit.py' publisher.phase('piel', [PYTHON, skin_audit], [source / 'main/java/ar/com/companeros/identity/HostSkinAtlas.java', skin_audit, ROOT / 'docs/publicacion/ExposedSkinAudit.java', ROOT / 'docs/skin-huesped-neon.png'], [ROOT / 'build/exposed-skin-audit/result.json'], 180) modes = ['idle', 'lore', 'chase', 'chase4', 'drag', 'crawl', 'crawl2', 'crawl3', 'crawl4', 'fold', 'turn', 'climb', 'attack', 'friendly'] media_outputs = [ROOT / 'docs' / ('parasito-' + mode + '.mp4') for mode in modes] media_outputs += [ROOT / 'docs/parasito-modelos-origen.json', ROOT / 'docs/parasito-modelos.png', ROOT / 'docs/modelos-3d/parasito-humanoide.obj', ROOT / 'docs/modelos-3d/parasito-aracnido.obj'] stills = ['rostro', 'evolucion', 'sensible', *['fase-' + str(n) for n in range(5)], *['prefab-' + material for material in ['obsidian', 'iron', 'diamond', 'copper']]] media_outputs += [ROOT / 'docs' / ('parasito-' + name + '.png') for name in stills] media_outputs += [ROOT / 'docs' / name for name in ['parasito-modelos.html', 'parasito-animaciones.mp4', 'parasito-chase.gif', 'parasito-lore.gif', 'parasito-encuentro-voz.mp4', 'parasito-encuentro-completo.mp4']] objects = ['parasito-humanoide', 'parasito-encuentro', 'parasito-aracnido', 'parasito-sensible', 'parasito-huesped-inicial'] media_outputs += [ROOT / 'docs/modelos-3d' / (name + '.' + ext) for name in objects for ext in ['obj', 'json']] media_outputs += [ROOT / 'docs/modelos-3d' / (name + '.' + ext) for name in ['parasite', 'parasite_initial', 'parasite_friendly'] for ext in ['mtl', 'png']] media_outputs += [ROOT / 'docs/modelos-3d' / ('parasito-prefab-' + name + '.obj') for name in ['obsidian', 'iron', 'diamond', 'copper']] publisher.phase('medios', [PYTHON, ROOT / 'docs/render-parasite-preview.py'], [rig, model, textures, ROOT / 'docs/render-parasite-preview.py', ROOT / 'docs/parasito-voz.mp3'], media_outputs, 1200) publisher.phase('corredor', [PYTHON, ROOT / 'docs/render-corridor-demo.py'], [rig, model, textures, ROOT / 'docs/render-corridor-demo.py', ROOT / 'docs/render-parasite-preview.py'], [ROOT / 'docs/parasito-crawl3-paredes-origen.json', ROOT / 'docs/parasito-crawl3-paredes.mp4'], 300) publisher.phase('escalada', [PYTHON, ROOT / 'docs/render-climbing-demo.py', '--expected-rig-sha256', sha(rig)], [rig, model, textures, ROOT / 'docs/render-climbing-demo.py', ROOT / 'docs/render-parasite-preview.py'], [ROOT / 'docs/parasito-climb-pared-origen.json', ROOT / 'docs/parasito-climb-pared.mp4'], 300) publisher.phase('validar-medios', [PYTHON, ROOT / 'docs/validate-parasite-assets.py'], [rig, model, ROOT / 'docs/validate-parasite-assets.py'] + [p for p in (ROOT / 'docs').glob('parasito-*') if p.name != 'parasito-preview-validacion.json'], [ROOT / 'docs/parasito-preview-validacion.json'], 300) # Revalidar antes de instalar: otro proceso pudo cambiar fuentes durante los renders. check_server_ready(not args.skip_server_tests) jar_hash = install_jar() if args.server: if args.prepare_only or args.skip_server_tests: raise RuntimeError('Publicar el servidor requiere una entrega con pruebas y publicación habilitadas.') run(ps('publicar-servidor-raps.ps1'), WORK / 'exaroton.log', 300) delivery_report(jar_hash, args.skip_server_tests) run(ps('publicar-visor.ps1', '-SoloPreparar'), WORK / 'preparar-web.log', 300) run(ps('preparar-galeria.ps1'), WORK / 'escritorio.log', 180) run(ps('preparar-auditoria-sites.ps1'), WORK / 'preparar-sites.log', 180) site_inputs = [ROOT / 'dist/visor'] if not args.prepare_only: wrangler = Path.home() / 'Desktop/Rapsodia/lista-azul-web/node_modules/wrangler/bin/wrangler.js' for branch in ['main', 'preview']: publisher.phase('cloudflare-' + branch, [executable('node'), wrangler, 'pages', 'deploy', ROOT / 'dist/visor', '--project-name', 'raps-huesped', '--branch', branch, '--commit-dirty=true'], site_inputs + [wrangler], limit=300) run([executable('node'), ROOT / 'docs/publicacion/check-published-gallery.cjs', '--cloudflare-only'], WORK / 'verificar-web.log', 300) result = {'status': 'prepared' if args.prepare_only else 'published', 'jar_sha256': jar_hash, 'jar': str(jar), 'server_tests': 'skipped' if args.skip_server_tests else 'passed', 'stages': publisher.done, 'sites': {'status': 'prepared-for-authenticated-package', 'command': [PYTHON, str(Path(__file__)), '--sites-package']}, 'urls': ['https://raps-huesped.pages.dev/?v=' + jar_hash[:8] + '#movimiento', 'https://preview.raps-huesped.pages.dev/?v=' + jar_hash[:8] + '#movimiento']} write_json(SUMMARY, result) print(json.dumps(result, ensure_ascii=False), flush=True) except Exception as error: write_json(SUMMARY, {'status': 'failed', 'error': str(error), 'completed_stages': publisher.done}) raise finally: lock.close() if __name__ == '__main__': main()