Prepare documented WhatsApp injector with portable Python bundle

This commit is contained in:
Ops Agent committed 2026-10-11 07:57:48 +02:00
commit d2b621bce7
49 files changed
+5737

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
"""Export all local message rows to ZIP via the native WebView JavaScript bridge.
No CDP, UI automation, app restart, or phone history sync. The ZIP contains
messages.jsonl and manifest.json; media binaries are not downloaded.
"""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import time
import zipfile
from .history import expression
from .live import data_chunk_expression, run_math
def evaluate(execute, value, session):
"""Run the fixed async database probe and stream its result across the bridge."""
key = '__whatsapp_zip_' + session
literal = json.dumps(key)
start = '''(() => {
const key=__KEY__,id=__ID__,state={probe_id:id,state:'pending'};window[key]=state;
Promise.resolve(__VALUE__).then(value=>{
state.json=JSON.stringify(value);state.state='ready';
}).catch(error=>{state.state='error';state.error=String(error).slice(0,500);});
return {probe_id:id,state:'started'};
})()'''.replace('__KEY__', literal).replace('__ID__', json.dumps(session)).replace('__VALUE__', value)
poll = '''(() => {const v=window[__KEY__];return {probe_id:v?.probe_id,state:v?.state,error:v?.error,length:v?.json?.length};})()'''.replace('__KEY__', literal)
try:
initial = json.loads(execute(start, 'export batch startup'))
if initial.get('probe_id') != session or initial.get('state') != 'started':
raise ValueError('Export startup response mismatch')
deadline = time.monotonic() + 60
while True:
meta = json.loads(execute(poll, 'export batch poll'))
if meta.get('probe_id') != session:
raise ValueError('Export session mismatch')
if meta.get('state') == 'error':
raise RuntimeError(meta.get('error', 'JavaScript export failed'))
if meta.get('state') == 'ready':
break
if time.monotonic() >= deadline:
raise TimeoutError('JavaScript export batch timed out')
time.sleep(.05)
length = meta.get('length')
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
raise ValueError('Export batch exceeds 16 MiB transport bound; reduce --batch-size')
offset, parts = 0, []
while offset < length:
part = json.loads(execute(data_chunk_expression(key, offset), 'export batch chunk'))
following = part.get('next')
if (part.get('probe_id') != session or part.get('offset') != offset
or type(following) is not int or not offset < following <= length
or not isinstance(part.get('chunk'), str)
or len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following-offset):
raise ValueError('Export transport chunk mismatch')
parts.append(part['chunk'])
offset = following
return json.loads(''.join(parts))
finally:
execute('delete window[' + literal + ']', 'export JavaScript state cleanup')
def write_messages(read, archive, batch, redact):
"""Stream a bounded keyset scan, preserving a record for every row error."""
bounds = read({'mode': 'bounds'})
count, upper = bounds['count'], bounds['upper']
after, rows, errors, seen = None, 0, 0, set()
with archive.open('messages.jsonl', 'w', force_zip64=True) as stream:
while upper is not None and after != upper:
page = read({'mode': 'batch', 'upper': upper, 'after': after, 'batch': batch, 'redact': redact})
keys, messages = page['keys'], page['messages']
if not keys:
raise RuntimeError('History cursor ended before the initial upper bound')
if len(keys) != len(messages) or page['last'] != keys[-1] or page['last'] == after:
raise RuntimeError('Invalid or nonprogressing history page')
for key, message in zip(keys, messages):
if key in seen or message.get('id') != key:
raise RuntimeError('Duplicate key or mismatched history message')
seen.add(key)
stream.write((json.dumps(message, ensure_ascii=True) + '\n').encode('utf-8'))
rows += 1
errors += int('error' in message)
after = page['last']
print(json.dumps({'exported_rows': rows, 'initial_rows': count, 'row_errors': errors}), flush=True)
final = read({'mode': 'bounds'})
return {'rows': rows, 'row_errors': errors, 'initial_row_count': count,
'final_row_count': final['count'], 'initial_upper_key': upper,
'reached_initial_upper': after == upper, 'snapshot_atomic': False,
'count_matches_initial': rows == count}
def export_zip(output, pid=None, batch=100, redact=False):
output = Path(output).resolve()
if not 1 <= batch <= 500:
raise ValueError('Batch size must be 1..500')
partial = output.with_suffix(output.suffix + '.partial')
report_path = output.with_suffix(output.suffix + '.report.json')
if output.exists() or partial.exists() or report_path.exists():
raise ValueError('Output or partial/report exists; select a new --output')
output.parent.mkdir(parents=True, exist_ok=True)
summary = {'cdp': False, 'ui_automation': False, 'transport': 'native WebView ExecuteScriptAsync',
'source': 'WhatsApp JavaScript message table getter and decoder',
'scope': 'All local message rows up to initial upper primary key; no phone sync or media download',
'created_utc': dt.datetime.now(dt.timezone.utc).isoformat(), 'text_redacted': redact}
def operation(execute, session, bridge):
with zipfile.ZipFile(partial, 'x', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
summary.update(write_messages(lambda options: evaluate(execute, expression(options), session), archive, batch, redact))
bridge = run_math(pid, 'history-export', _operation=operation)
summary['bridge'] = bridge
summary['passed'] = bool(bridge['passed'] and summary.get('reached_initial_upper') and not summary.get('row_errors'))
summary['status'] = 'completed' if summary['passed'] else 'completed_with_errors' if bridge['passed'] else 'failed_partial_export'
try:
if bridge['passed']:
with zipfile.ZipFile(partial, 'a', compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr('manifest.json', json.dumps(summary, indent=2))
with zipfile.ZipFile(partial) as archive:
bad = archive.testzip()
if bad:
raise RuntimeError('ZIP integrity failed: ' + bad)
# Publish without overwriting an output created during the live scan.
os.link(partial, output)
partial.unlink()
summary['output'] = str(output)
else:
summary['partial_output'] = str(partial)
except Exception as error:
summary.update(passed=False, status='failed_partial_export', error=str(error), partial_output=str(partial))
report_path.write_text(json.dumps(summary, indent=2) + '\n', encoding='utf-8')
return summary
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=Path('exports') / ('whatsapp-' + dt.datetime.now().strftime('%Y%m%d-%H%M%S') + '.zip'))
parser.add_argument('--pid', type=int)
parser.add_argument('--batch-size', type=int, default=100)
parser.add_argument('--redact-text', action='store_true')
args = parser.parse_args(argv)
report = export_zip(args.output, args.pid, args.batch_size, args.redact_text)
print(json.dumps({k: report[k] for k in ('passed', 'status', 'rows', 'row_errors', 'output', 'partial_output', 'error') if k in report}))
return 0 if report['passed'] else 1