feat(sns): 增强朋友圈时间线/媒体获取与实时同步

- 新增 /api/sns/users:按发圈数统计联系人(支持 keyword/limit)
- 新增 /api/sns/realtime/sync_latest:WCDB 实时增量同步到解密库(append-only),并持久化 sync state
- 朋友圈媒体优先走“远程下载+解密”:图片支持 wcdb_decrypt_sns_image,视频/实况支持 ISAAC64(WeFlow 逻辑)
- 增加 WeFlow WASM keystream(Node) 优先 + Python ISAAC64 fallback,提升兼容性
- wcdb_api.dll 支持多路径自动发现/环境变量覆盖,并在状态信息中回传实际使用路径
This commit is contained in:
2977094657
2026-02-17 23:40:10 +08:00
parent 35a2266b1c
commit 6e127b2e32
5 changed files with 2706 additions and 108 deletions

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
"""ISAAC-64 PRNG (WeFlow compatible).
WeChat SNS live photo/video decryption uses a keystream generated by ISAAC-64 and
XORs the first 128KB of the mp4 file. WeFlow's implementation reverses the
generated byte array, so we mirror that behavior for compatibility.
"""
from typing import Any
_MASK_64 = 0xFFFFFFFFFFFFFFFF
def _u64(v: int) -> int:
return int(v) & _MASK_64
class Isaac64:
def __init__(self, seed: Any):
seed_text = str(seed).strip()
if not seed_text:
seed_val = 0
else:
try:
# WeFlow seeds with BigInt(seed), where seed is usually a decimal string.
seed_val = int(seed_text, 0)
except Exception:
seed_val = 0
self.mm = [_u64(0) for _ in range(256)]
self.aa = _u64(0)
self.bb = _u64(0)
self.cc = _u64(0)
self.randrsl = [_u64(0) for _ in range(256)]
self.randrsl[0] = _u64(seed_val)
self.randcnt = 0
self._init(True)
def _init(self, flag: bool) -> None:
a = b = c = d = e = f = g = h = _u64(0x9E3779B97F4A7C15)
def mix() -> tuple[int, int, int, int, int, int, int, int]:
nonlocal a, b, c, d, e, f, g, h
a = _u64(a - e)
f = _u64(f ^ (h >> 9))
h = _u64(h + a)
b = _u64(b - f)
g = _u64(g ^ _u64(a << 9))
a = _u64(a + b)
c = _u64(c - g)
h = _u64(h ^ (b >> 23))
b = _u64(b + c)
d = _u64(d - h)
a = _u64(a ^ _u64(c << 15))
c = _u64(c + d)
e = _u64(e - a)
b = _u64(b ^ (d >> 14))
d = _u64(d + e)
f = _u64(f - b)
c = _u64(c ^ _u64(e << 20))
e = _u64(e + f)
g = _u64(g - c)
d = _u64(d ^ (f >> 17))
f = _u64(f + g)
h = _u64(h - d)
e = _u64(e ^ _u64(g << 14))
g = _u64(g + h)
return a, b, c, d, e, f, g, h
for _ in range(4):
mix()
for i in range(0, 256, 8):
if flag:
a = _u64(a + self.randrsl[i])
b = _u64(b + self.randrsl[i + 1])
c = _u64(c + self.randrsl[i + 2])
d = _u64(d + self.randrsl[i + 3])
e = _u64(e + self.randrsl[i + 4])
f = _u64(f + self.randrsl[i + 5])
g = _u64(g + self.randrsl[i + 6])
h = _u64(h + self.randrsl[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
if flag:
for i in range(0, 256, 8):
a = _u64(a + self.mm[i])
b = _u64(b + self.mm[i + 1])
c = _u64(c + self.mm[i + 2])
d = _u64(d + self.mm[i + 3])
e = _u64(e + self.mm[i + 4])
f = _u64(f + self.mm[i + 5])
g = _u64(g + self.mm[i + 6])
h = _u64(h + self.mm[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
self._isaac64()
self.randcnt = 256
def _isaac64(self) -> None:
self.cc = _u64(self.cc + 1)
self.bb = _u64(self.bb + self.cc)
for i in range(256):
x = self.mm[i]
if (i & 3) == 0:
# aa ^= ~(aa << 21)
self.aa = _u64(self.aa ^ (_u64(self.aa << 21) ^ _MASK_64))
elif (i & 3) == 1:
self.aa = _u64(self.aa ^ (self.aa >> 5))
elif (i & 3) == 2:
self.aa = _u64(self.aa ^ _u64(self.aa << 12))
else:
self.aa = _u64(self.aa ^ (self.aa >> 33))
self.aa = _u64(self.mm[(i + 128) & 255] + self.aa)
y = _u64(self.mm[(x >> 3) & 255] + self.aa + self.bb)
self.mm[i] = y
self.bb = _u64(self.mm[(y >> 11) & 255] + x)
self.randrsl[i] = self.bb
def get_next(self) -> int:
if self.randcnt == 0:
self._isaac64()
self.randcnt = 256
idx = 256 - self.randcnt
self.randcnt -= 1
return _u64(self.randrsl[idx])
def generate_keystream(self, size: int) -> bytes:
"""Generate a keystream of `size` bytes (must be multiple of 8)."""
if size <= 0:
return b""
if size % 8 != 0:
raise ValueError("ISAAC64 keystream size must be multiple of 8 bytes.")
out = bytearray()
count = size // 8
for _ in range(count):
out.extend(int(self.get_next()).to_bytes(8, "little", signed=False))
# WeFlow reverses the entire byte array (Uint8Array.reverse()).
out.reverse()
return bytes(out)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,274 @@
"""SNS (Moments) realtime -> decrypted sqlite incremental sync.
Why:
- We can read the latest Moments via WCDB realtime, but the decrypted snapshot (`output/databases/{account}/sns.db`)
can lag behind or miss data (e.g. you viewed it when it was visible, then it became "only last 3 days").
- For export/offline browsing, we want to keep a local append-only cache of Moments that were visible at some point.
This module runs a lightweight background poller that watches db_storage/sns*.db mtime changes and triggers a cheap
incremental sync of the latest N Moments into the decrypted snapshot.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from fastapi import HTTPException
from .chat_helpers import _list_decrypted_accounts, _resolve_account_dir
from .logging_config import get_logger
from .wcdb_realtime import WCDB_REALTIME
logger = get_logger(__name__)
def _env_bool(name: str, default: bool) -> bool:
raw = str(os.environ.get(name, "") or "").strip().lower()
if not raw:
return default
return raw not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
raw = str(os.environ.get(name, "") or "").strip()
try:
v = int(raw)
except Exception:
v = int(default)
if v < min_v:
v = min_v
if v > max_v:
v = max_v
return v
def _mtime_ns(path: Path) -> int:
try:
st = path.stat()
m_ns = int(getattr(st, "st_mtime_ns", 0) or 0)
if m_ns <= 0:
m_ns = int(float(getattr(st, "st_mtime", 0.0) or 0.0) * 1_000_000_000)
return int(m_ns)
except Exception:
return 0
def _scan_sns_db_mtime_ns(db_storage_dir: Path) -> int:
"""Best-effort "latest mtime" signal for sns.db buckets."""
base = Path(db_storage_dir)
candidates: list[Path] = [
base / "sns" / "sns.db",
base / "sns" / "sns.db-wal",
base / "sns" / "sns.db-shm",
base / "sns.db",
base / "sns.db-wal",
base / "sns.db-shm",
]
max_ns = 0
for p in candidates:
v = _mtime_ns(p)
if v > max_ns:
max_ns = v
return int(max_ns)
@dataclass
class _AccountState:
last_mtime_ns: int = 0
due_at: float = 0.0
last_sync_end_at: float = 0.0
thread: Optional[threading.Thread] = None
class SnsRealtimeAutoSyncService:
def __init__(self) -> None:
self._enabled = _env_bool("WECHAT_TOOL_SNS_AUTOSYNC", True)
self._interval_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_INTERVAL_MS", 2000, min_v=500, max_v=60_000)
self._debounce_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_DEBOUNCE_MS", 800, min_v=0, max_v=60_000)
self._min_sync_interval_ms = _env_int(
"WECHAT_TOOL_SNS_AUTOSYNC_MIN_SYNC_INTERVAL_MS", 5000, min_v=0, max_v=300_000
)
self._workers = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_WORKERS", 1, min_v=1, max_v=4)
self._max_scan = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_MAX_SCAN", 200, min_v=20, max_v=2000)
self._mu = threading.Lock()
self._states: dict[str, _AccountState] = {}
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if not self._enabled:
logger.info("[sns-autosync] disabled by env WECHAT_TOOL_SNS_AUTOSYNC=0")
return
with self._mu:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
th = threading.Thread(target=self._run, name="sns-realtime-autosync", daemon=True)
self._thread = th
th.start()
logger.info(
"[sns-autosync] started interval_ms=%s debounce_ms=%s min_sync_interval_ms=%s max_scan=%s workers=%s",
int(self._interval_ms),
int(self._debounce_ms),
int(self._min_sync_interval_ms),
int(self._max_scan),
int(self._workers),
)
def stop(self) -> None:
self._stop.set()
with self._mu:
self._thread = None
def _run(self) -> None:
while not self._stop.is_set():
tick_t0 = time.perf_counter()
try:
self._tick()
except Exception:
logger.exception("[sns-autosync] tick failed")
elapsed_ms = (time.perf_counter() - tick_t0) * 1000.0
sleep_ms = max(200.0, float(self._interval_ms) - elapsed_ms)
self._stop.wait(timeout=sleep_ms / 1000.0)
def _tick(self) -> None:
accounts = _list_decrypted_accounts()
now = time.time()
if not accounts:
return
for acc in accounts:
if self._stop.is_set():
break
try:
account_dir = _resolve_account_dir(acc)
except HTTPException:
continue
except Exception:
continue
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
continue
db_storage_dir = Path(str(info.get("db_storage_dir") or "").strip())
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
continue
mtime_ns = _scan_sns_db_mtime_ns(db_storage_dir)
with self._mu:
st = self._states.setdefault(acc, _AccountState())
if mtime_ns and mtime_ns != st.last_mtime_ns:
st.last_mtime_ns = int(mtime_ns)
st.due_at = now + (float(self._debounce_ms) / 1000.0)
# Schedule daemon threads.
to_start: list[threading.Thread] = []
with self._mu:
keep = set(accounts)
for acc in list(self._states.keys()):
if acc not in keep:
self._states.pop(acc, None)
running = 0
for st in self._states.values():
th = st.thread
if th is not None and th.is_alive():
running += 1
elif th is not None and (not th.is_alive()):
st.thread = None
for acc, st in self._states.items():
if running >= int(self._workers):
break
if st.due_at <= 0 or st.due_at > now:
continue
if st.thread is not None and st.thread.is_alive():
continue
since = now - float(st.last_sync_end_at or 0.0)
min_interval = float(self._min_sync_interval_ms) / 1000.0
if min_interval > 0 and since < min_interval:
st.due_at = now + (min_interval - since)
continue
st.due_at = 0.0
th = threading.Thread(
target=self._sync_account_runner,
args=(acc,),
name=f"sns-autosync-{acc}",
daemon=True,
)
st.thread = th
to_start.append(th)
running += 1
for th in to_start:
if self._stop.is_set():
break
try:
th.start()
except Exception:
with self._mu:
for acc, st in self._states.items():
if st.thread is th:
st.thread = None
break
def _sync_account_runner(self, account: str) -> None:
account = str(account or "").strip()
try:
if self._stop.is_set() or (not account):
return
res = self._sync_account(account)
upserted = int((res or {}).get("upserted") or 0)
logger.info("[sns-autosync] sync done account=%s upserted=%s", account, upserted)
except Exception:
logger.exception("[sns-autosync] sync failed account=%s", account)
finally:
with self._mu:
st = self._states.get(account)
if st is not None:
st.thread = None
st.last_sync_end_at = time.time()
def _sync_account(self, account: str) -> dict[str, Any]:
account = str(account or "").strip()
if not account:
return {"status": "skipped", "reason": "missing account"}
try:
account_dir = _resolve_account_dir(account)
except Exception as e:
return {"status": "skipped", "reason": f"resolve account failed: {e}"}
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
return {"status": "skipped", "reason": "realtime not available"}
# Import lazily to avoid startup import ordering issues.
from .routers.sns import sync_sns_realtime_timeline_latest
try:
return sync_sns_realtime_timeline_latest(
account=account,
max_scan=int(self._max_scan),
force=0,
)
except HTTPException as e:
return {"status": "error", "error": str(e.detail or "")}
except Exception as e:
return {"status": "error", "error": str(e)}
SNS_REALTIME_AUTOSYNC = SnsRealtimeAutoSyncService()

View File

@@ -1,6 +1,8 @@
import ctypes import ctypes
import binascii
import json import json
import os import os
import re
import sys import sys
import threading import threading
import time import time
@@ -20,7 +22,51 @@ class WCDBRealtimeError(RuntimeError):
_NATIVE_DIR = Path(__file__).resolve().parent / "native" _NATIVE_DIR = Path(__file__).resolve().parent / "native"
_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll" _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _candidate_wcdb_api_dll_paths() -> list[Path]:
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
cands: list[Path] = []
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
if env:
cands.append(Path(env))
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
try:
repo_root = Path(__file__).resolve().parents[2]
except Exception:
repo_root = Path.cwd()
for p in [
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
_DEFAULT_WCDB_API_DLL,
]:
if p not in cands:
cands.append(p)
return cands
def _resolve_wcdb_api_dll_path() -> Path:
global _WCDB_API_DLL_SELECTED
if _WCDB_API_DLL_SELECTED is not None:
return _WCDB_API_DLL_SELECTED
for p in _candidate_wcdb_api_dll_paths():
try:
if p.exists() and p.is_file():
_WCDB_API_DLL_SELECTED = p
return p
except Exception:
continue
# Fall back to the default path even if it doesn't exist; caller will raise a clear error.
_WCDB_API_DLL_SELECTED = _DEFAULT_WCDB_API_DLL
return _WCDB_API_DLL_SELECTED
_lib_lock = threading.Lock() _lib_lock = threading.Lock()
_lib: Optional[ctypes.CDLL] = None _lib: Optional[ctypes.CDLL] = None
@@ -40,16 +86,18 @@ def _load_wcdb_lib() -> ctypes.CDLL:
if not _is_windows(): if not _is_windows():
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.") raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
if not _WCDB_API_DLL.exists(): wcdb_api_dll = _resolve_wcdb_api_dll_path()
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {_WCDB_API_DLL}") if not wcdb_api_dll.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {wcdb_api_dll}")
# Ensure dependent DLLs (e.g. WCDB.dll) can be found. # Ensure dependent DLLs (e.g. WCDB.dll) can be found.
try: try:
os.add_dll_directory(str(_NATIVE_DIR)) os.add_dll_directory(str(wcdb_api_dll.parent))
except Exception: except Exception:
pass pass
lib = ctypes.CDLL(str(_WCDB_API_DLL)) lib = ctypes.CDLL(str(wcdb_api_dll))
logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll)
# Signatures # Signatures
lib.wcdb_init.argtypes = [] lib.wcdb_init.argtypes = []
@@ -144,6 +192,19 @@ def _load_wcdb_lib() -> ctypes.CDLL:
# Older wcdb_api.dll may not expose this export. # Older wcdb_api.dll may not expose this export.
pass pass
# Optional (newer DLLs): wcdb_decrypt_sns_image(encrypted_data, len, key, out_hex)
# WeFlow uses this to decrypt Moments CDN images.
try:
lib.wcdb_decrypt_sns_image.argtypes = [
ctypes.c_void_p,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_void_p),
]
lib.wcdb_decrypt_sns_image.restype = ctypes.c_int32
except Exception:
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)] lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int lib.wcdb_get_logs.restype = ctypes.c_int
@@ -488,6 +549,63 @@ def get_sns_timeline(
return [] return []
def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
"""Decrypt Moments CDN image bytes using WCDB DLL (WeFlow compatible).
Notes:
- Requires a newer wcdb_api.dll export: wcdb_decrypt_sns_image.
- On failure, returns the original encrypted_data (best-effort behavior like WeFlow).
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_decrypt_sns_image", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.")
raw = bytes(encrypted_data or b"")
if not raw:
return b""
k = str(key or "").strip()
if not k:
return raw
out_ptr = ctypes.c_void_p()
buf = ctypes.create_string_buffer(raw, len(raw))
rc = 0
try:
rc = int(
fn(
ctypes.cast(buf, ctypes.c_void_p),
ctypes.c_int32(len(raw)),
k.encode("utf-8"),
ctypes.byref(out_ptr),
)
)
if rc != 0 or not out_ptr.value:
return raw
hex_bytes = ctypes.cast(out_ptr, ctypes.c_char_p).value or b""
if not hex_bytes:
return raw
# Defensive: keep only hex chars (some builds may include whitespace).
hex_clean = re.sub(rb"[^0-9a-fA-F]", b"", hex_bytes)
if not hex_clean:
return raw
try:
return binascii.unhexlify(hex_clean)
except Exception:
return raw
finally:
try:
if out_ptr.value:
lib.wcdb_free_string(ctypes.cast(out_ptr, ctypes.c_char_p))
except Exception:
pass
def shutdown() -> None: def shutdown() -> None:
global _initialized global _initialized
lib = _load_wcdb_lib() lib = _load_wcdb_lib()
@@ -573,11 +691,16 @@ class WCDBRealtimeManager:
except Exception as e: except Exception as e:
err = str(e) err = str(e)
dll_ok = _WCDB_API_DLL.exists() dll_path = _resolve_wcdb_api_dll_path()
try:
dll_ok = bool(dll_path.exists())
except Exception:
dll_ok = False
connected = self.is_connected(account) connected = self.is_connected(account)
return { return {
"account": account, "account": account,
"dll_present": bool(dll_ok), "dll_present": bool(dll_ok),
"wcdb_api_dll": str(dll_path),
"key_present": bool(key_ok), "key_present": bool(key_ok),
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "", "db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
"session_db_path": str(session_db_path) if session_db_path else "", "session_db_path": str(session_db_path) if session_db_path else "",

View File

@@ -0,0 +1,122 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
//
// Usage:
// node tools/weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} }, // keep stdout clean
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf) // copy view
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(size)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
// Match WeFlow worker logic: reverse the captured Uint8Array.
out.reverse()
process.stdout.write(out.toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()