143 lines
7.8 KiB
Python
143 lines
7.8 KiB
Python
"""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
|