Files

144 lines
6.9 KiB
Python

"""Storage inventory and saved message export analysis."""
import argparse
import collections
import json
from pathlib import Path
from .common import BASE, PROFILE, normalized
def messages(path):
with path.open(encoding='utf-8-sig') as stream:
if path.suffix.lower() == '.jsonl':
for number, line in enumerate(stream, 1):
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError as ex:
raise ValueError(f'Invalid JSON at line {number}: {ex}') from ex
if not isinstance(row, dict):
raise ValueError(f'Line {number} is not a message object')
yield row
else:
data = json.load(stream)
if isinstance(data, list):
rows = data
elif isinstance(data, dict):
rows = next((data[key] for key in ('messages', 'models', 'results')
if isinstance(data.get(key), list)), None)
if rows is None:
raise ValueError('Expected messages, models or results array')
else:
raise ValueError('Expected an array or a message report')
for row in rows:
if not isinstance(row, dict):
raise ValueError('Message array contains a non-object value')
yield row
def content(row):
return '\n'.join(row[key] for key in ('indexed_text', 'body', 'caption', 'text')
if isinstance(row.get(key), str))
def inventory(profile):
entries = []
for pattern in ('IndexedDB/*whatsapp*', 'Local Storage/leveldb', 'Session Storage'):
for folder in profile.glob(pattern):
files = []
for file in folder.iterdir() if folder.is_dir() else [folder]:
try:
if file.is_file():
files.append({'name': file.name, 'bytes': file.stat().st_size})
except OSError:
continue
entries.append({'path': str(folder), 'files': files})
return {'profile': str(profile), 'exists': profile.exists(), 'storage_inventory': entries,
'coverage': 'File names and sizes only; no decrypted messages.'}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
inv = sub.add_parser('inventory', help='List local Desktop storage files and sizes')
inv.add_argument('--profile', type=Path, default=PROFILE)
for name in ('stats', 'search'):
cmd = sub.add_parser(name, help='Analyze an existing JSON/JSONL message export')
cmd.add_argument('input', type=Path)
cmd.add_argument('--output', type=Path)
if name == 'search':
cmd.add_argument('query')
cmd.add_argument('--chat-id')
cmd.add_argument('--limit', type=int, default=100)
cmd.add_argument('--redact-text', action='store_true')
sub.add_parser('webview-test', help='Harmless JavaScript test in a separate local host')
live = sub.add_parser('whatsapp-test', help='Run eight fixed arithmetic checks in the running Desktop instance')
live.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
live.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-arithmetic-result.json')
search = sub.add_parser('whatsapp-search', help='Use the running app native message search through WebView2')
search.add_argument('query')
search.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
search.add_argument('--limit', type=int, default=50)
search.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-message-search.json')
args = parser.parse_args(argv)
if args.command == 'whatsapp-test':
from .live import main as run_live
return run_live(['--suite', 'math'] + (['--pid', str(args.pid)] if args.pid else []) + ['--output', str(args.output)])
if args.command == 'whatsapp-search':
if not args.query.strip() or len(args.query)>200 or not 1<=args.limit<=50:
parser.error('Search requires 1..200 characters and --limit 1..50')
from .native_search import run_live_probe
return run_live_probe(args.pid, args.output, args.query, args.limit)
if args.command == 'webview-test':
from .webview import run_webview_test
return run_webview_test()
if args.command == 'inventory':
result = inventory(args.profile)
elif args.command == 'stats':
count = with_text = 0
kinds = collections.Counter()
chats = set()
for row in messages(args.input):
count += 1
with_text += bool(content(row))
kinds[str(row.get('type', 'unknown'))] += 1
chat = row.get('chatId', row.get('chat_id'))
if chat is not None:
chats.add(str(chat))
result = {'source': str(args.input), 'messages': count, 'messages_with_text': with_text,
'chats': len(chats), 'types': dict(kinds), 'scope': 'Saved export only'}
else:
if not args.query.strip():
parser.error('query must not be empty')
if args.limit < 1:
parser.error('--limit must be positive')
found = []
total = 0
for row in messages(args.input):
chat = row.get('chatId', row.get('chat_id'))
if args.chat_id is not None and str(chat) != args.chat_id:
continue
text = content(row)
if normalized(args.query) not in normalized(text):
continue
total += 1
if len(found) >= args.limit:
continue
item = dict(row)
if args.redact_text:
# Allowlist metadata so nested fields cannot leak message content.
item = {key: value for key, value in row.items()
if key in ('id', 'chatId', 'chat_id', 'timestamp', 'type', 'nativeRowID', 'native_rowid')
and isinstance(value, (str, int, float, bool, type(None)))}
item['text_length'] = len(text)
found.append(item)
result = {'source': str(args.input), 'matches': total, 'returned': len(found),
'results': found, 'scope': 'Literal search of saved export only'}
serialized = json.dumps(result, ensure_ascii=False, indent=2)
output = getattr(args, 'output', None)
if output:
if output.resolve() == args.input.resolve():
parser.error('output must differ from input')
output.write_text(serialized + '\n', encoding='utf-8')
print(json.dumps({'output': str(output), 'command': args.command}))
else:
print(serialized)
return 0