● Zion Boggan
repos/Darwin/ingest/collect-browser-history.py
zionboggan.com ↗
318 lines · python
History for this file →
1
"""
2
Darwin BUILD-5 - Browser History Collector (standalone, invoked by ingest-all.sh)
3
 
4
Reads browser SQLite history DBs via Python-over-SSH.
5
Uses python3 (available on Windows via Git/standalone install) to:
6
  1. Copy the live DB to a temp path (readable even when browser is open).
7
  2. Query rows newer than the manifest watermark (stored in browser-watermarks.json).
8
  3. Emit normalized JSON records to stdout for downstream indexing.
9
 
10
Browsers supported:
11
  edge, chrome, brave - Chromium History SQLite (tables: urls, visits)
12
  firefox             - places.sqlite (tables: moz_places, moz_historyvisits)
13
 
14
Usage:
15
  python3 collect-browser-history.py --host HOST --user SSH_USER --browser BROWSER
16
  python3 collect-browser-history.py --host HOST --user zion --profile-user worky --browser brave
17
 
18
Browser path map (Windows):
19
  Edge:    C:\\\\Users\\\\<user>\\\\AppData\\\\Local\\\\Microsoft\\\\Edge\\\\User Data\\\\Default\\\\History
20
  Chrome:  C:\\\\Users\\\\<user>\\\\AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data\\\\Default\\\\History
21
  Brave:   C:\\\\Users\\\\<user>\\\\AppData\\\\Local\\\\BraveSoftware\\\\Brave-Browser\\\\User Data\\\\Default\\\\History
22
  Firefox: C:\\\\Users\\\\<user>\\\\AppData\\\\Roaming\\\\Mozilla\\\\Firefox\\\\Profiles\\\\<profile>\\\\places.sqlite
23
"""
24
 
25
import argparse
26
import base64
27
import json
28
import os
29
import subprocess
30
import sys
31
import time
32
 
33
WATERMARK_PATH = "/var/tmp/homelab-rag/browser-watermarks.json"
34
OUTPUT_DIR = "/var/tmp/homelab-rag/browser-history"
35
 
36
 
37
def load_watermarks():
38
    if os.path.exists(WATERMARK_PATH):
39
        with open(WATERMARK_PATH) as f:
40
            return json.load(f)
41
    return {}
42
 
43
 
44
def save_watermarks(wm):
45
    os.makedirs(os.path.dirname(WATERMARK_PATH), exist_ok=True)
46
    with open(WATERMARK_PATH, "w") as f:
47
        json.dump(wm, f, indent=2)
48
 
49
 
50
def run_python_via_ssh(host, ssh_user, py_script, connect_timeout=30, identity=None):
51
    """Run a Python script on a remote Windows host via SSH + python -c (stdin deploy).
52
 
53
    The script is sent via stdin to avoid command-line length limits.
54
    """
55
    target = f"{ssh_user}@{host}" if ssh_user else host
56
    ssh_args = [
57
        "ssh",
58
        "-o", "BatchMode=yes",
59
        "-o", f"ConnectTimeout={connect_timeout}",
60
        "-o", "StrictHostKeyChecking=accept-new",
61
    ]
62
    if identity:
63
        ssh_args += ["-i", identity]
64
    ssh_args += [target, "python -"]
65
    result = subprocess.run(
66
        ssh_args,
67
        input=py_script.encode("utf-8"),
68
        capture_output=True,
69
        timeout=120,
70
    )
71
    return result
72
 
73
 
74
CHROMIUM_PY_TEMPLATE = r"""
75
import sqlite3, json, shutil, tempfile, os, sys
76
 
77
payload = PAYLOAD_JSON
78
hist_path = payload["history_path"]
79
watermark_ms = int(payload["watermark_ms"])
80
source_id = payload["source_id"]
81
 
82
tmp = tempfile.mktemp(suffix=".db")
83
try:
84
    shutil.copy2(hist_path, tmp)
85
except Exception as e:
86
    print(json.dumps({"error": "copy_failed", "msg": str(e)}))
87
    sys.exit(0)
88
 
89
try:
90
    CHROMIUM_EPOCH_MS = 11644473600000
91
    watermark_chromium = (watermark_ms + CHROMIUM_EPOCH_MS) * 1000
92
 
93
    conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
94
    conn.row_factory = sqlite3.Row
95
    cur = conn.cursor()
96
    cur.execute(
97
        "SELECT u.url, u.title, v.visit_time "
98
        "FROM visits v JOIN urls u ON u.id=v.url "
99
        "WHERE v.visit_time > ? "
100
        "ORDER BY v.visit_time DESC LIMIT 50000",
101
        (watermark_chromium,)
102
    )
103
    records = []
104
    for row in cur.fetchall():
105
        chromium_us = int(row[2])
106
        unix_ms = chromium_us // 1000 - CHROMIUM_EPOCH_MS
107
        records.append({
108
            "url": row[0] or "",
109
            "title": row[1] or "",
110
            "visit_time_ms": unix_ms,
111
        })
112
    conn.close()
113
    print(json.dumps(records))
114
except Exception as e:
115
    print(json.dumps({"error": "sqlite_read_failed", "msg": str(e)}))
116
finally:
117
    try:
118
        os.unlink(tmp)
119
    except Exception:
120
        pass
121
"""
122
 
123
FIREFOX_PY_TEMPLATE = r"""
124
import sqlite3, json, shutil, tempfile, os, sys
125
 
126
payload = PAYLOAD_JSON
127
db_path = payload["history_path"]
128
watermark_ms = int(payload["watermark_ms"])
129
source_id = payload["source_id"]
130
 
131
tmp = tempfile.mktemp(suffix=".sqlite")
132
try:
133
    shutil.copy2(db_path, tmp)
134
except Exception as e:
135
    print(json.dumps({"error": "copy_failed", "msg": str(e)}))
136
    sys.exit(0)
137
 
138
try:
139
    watermark_us = watermark_ms * 1000
140
 
141
    conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
142
    conn.row_factory = sqlite3.Row
143
    cur = conn.cursor()
144
    cur.execute(
145
        "SELECT p.url, p.title, h.visit_date "
146
        "FROM moz_historyvisits h JOIN moz_places p ON p.id=h.place_id "
147
        "WHERE h.visit_date > ? "
148
        "ORDER BY h.visit_date DESC LIMIT 50000",
149
        (watermark_us,)
150
    )
151
    records = []
152
    for row in cur.fetchall():
153
        visit_us = int(row[2])
154
        unix_ms = visit_us // 1000
155
        records.append({
156
            "url": row[0] or "",
157
            "title": row[1] or "",
158
            "visit_time_ms": unix_ms,
159
        })
160
    conn.close()
161
    print(json.dumps(records))
162
except Exception as e:
163
    print(json.dumps({"error": "sqlite_read_failed", "msg": str(e)}))
164
finally:
165
    try:
166
        os.unlink(tmp)
167
    except Exception:
168
        pass
169
"""
170
 
171
 
172
def collect_chromium(host, ssh_user, history_path, source_key, watermarks, identity=None):
173
    """Collect Chromium-based browser history via Python-over-SSH."""
174
    wm_ms = watermarks.get(source_key, 0)
175
    payload = json.dumps({
176
        "history_path": history_path,
177
        "watermark_ms": wm_ms,
178
        "source_id": source_key,
179
    })
180
    script = CHROMIUM_PY_TEMPLATE.replace("PAYLOAD_JSON", payload)
181
    result = run_python_via_ssh(host, ssh_user, script, identity=identity)
182
 
183
    if result.returncode != 0:
184
        print(f"[browser-history] SSH/python failed for {source_key}: {result.stderr[:300]}", file=sys.stderr)
185
        return []
186
 
187
    stdout = result.stdout.decode("utf-8", errors="replace").strip()
188
    if not stdout:
189
        print(f"[browser-history] Empty output for {source_key}", file=sys.stderr)
190
        return []
191
 
192
    try:
193
        rows = json.loads(stdout)
194
    except json.JSONDecodeError as e:
195
        print(f"[browser-history] JSON parse error for {source_key}: {e} | raw: {stdout[:200]}", file=sys.stderr)
196
        return []
197
 
198
    if isinstance(rows, dict) and "error" in rows:
199
        print(f"[browser-history] Remote error for {source_key}: {rows}", file=sys.stderr)
200
        return []
201
 
202
    return _process_rows(rows, source_key, wm_ms, watermarks)
203
 
204
 
205
def collect_firefox(host, ssh_user, profile_path, source_key, watermarks, identity=None):
206
    """Collect Firefox places.sqlite via Python-over-SSH."""
207
    wm_ms = watermarks.get(source_key, 0)
208
    payload = json.dumps({
209
        "history_path": profile_path,
210
        "watermark_ms": wm_ms,
211
        "source_id": source_key,
212
    })
213
    script = FIREFOX_PY_TEMPLATE.replace("PAYLOAD_JSON", payload)
214
    result = run_python_via_ssh(host, ssh_user, script, identity=identity)
215
 
216
    if result.returncode != 0:
217
        print(f"[browser-history] SSH/python failed for {source_key}: {result.stderr[:300]}", file=sys.stderr)
218
        return []
219
 
220
    stdout = result.stdout.decode("utf-8", errors="replace").strip()
221
    if not stdout:
222
        print(f"[browser-history] Empty output for {source_key}", file=sys.stderr)
223
        return []
224
 
225
    try:
226
        rows = json.loads(stdout)
227
    except json.JSONDecodeError as e:
228
        print(f"[browser-history] JSON parse error for {source_key}: {e} | raw: {stdout[:200]}", file=sys.stderr)
229
        return []
230
 
231
    if isinstance(rows, dict) and "error" in rows:
232
        print(f"[browser-history] Remote error for {source_key}: {rows}", file=sys.stderr)
233
        return []
234
 
235
    return _process_rows(rows, source_key, wm_ms, watermarks)
236
 
237
 
238
def _process_rows(rows, source_key, wm_ms, watermarks):
239
    """Normalize rows and update watermark."""
240
    records = []
241
    max_ms = wm_ms
242
    for row in rows:
243
        if isinstance(row, dict) and "url" in row:
244
            visit_ms = int(row.get("visit_time_ms", 0))
245
            if visit_ms > max_ms:
246
                max_ms = visit_ms
247
            records.append({
248
                "source_id": source_key,
249
                "url": row["url"],
250
                "title": row.get("title", ""),
251
                "visit_time_ms": visit_ms,
252
                "collected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
253
            })
254
    if max_ms > wm_ms:
255
        watermarks[source_key] = max_ms
256
    return records
257
 
258
 
259
def main():
260
    parser = argparse.ArgumentParser(description="Darwin browser history collector")
261
    parser.add_argument("--host", required=True)
262
    parser.add_argument("--user", required=True, help="SSH login user")
263
    parser.add_argument("--profile-user", default=None,
264
                        help="Windows profile username (if different from SSH user, e.g. 'worky' vs ssh user 'zion')")
265
    parser.add_argument("--browser", required=True, choices=["edge", "chrome", "brave", "firefox"])
266
    parser.add_argument("--firefox-profile", default=None,
267
                        help="Firefox profile dir name (e.g. ojo1adxx.default-release)")
268
    parser.add_argument("--source-key", default=None,
269
                        help="Override source key (default: <host>-<profile_user>-<browser>)")
270
    parser.add_argument("--identity", default=os.path.expanduser("~/.ssh/termius_homelab"))
271
    parser.add_argument("--output", default=None, help="Output JSONL path (default: auto)")
272
    args = parser.parse_args()
273
 
274
    profile_user = args.profile_user or args.user
275
    source_key = args.source_key or f"{args.host}-{profile_user}-{args.browser}"
276
 
277
    watermarks = load_watermarks()
278
 
279
    user_dir = f"C:\\Users\\{profile_user}"
280
    browser_paths = {
281
        "edge": f"{user_dir}\\AppData\\Local\\Microsoft\\Edge\\User Data\\Default\\History",
282
        "chrome": f"{user_dir}\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\History",
283
        "brave": f"{user_dir}\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\Default\\History",
284
    }
285
 
286
    if args.browser in ("edge", "chrome", "brave"):
287
        history_path = browser_paths[args.browser]
288
        records = collect_chromium(
289
            args.host, args.user, history_path, source_key, watermarks,
290
            identity=args.identity
291
        )
292
    elif args.browser == "firefox":
293
        if args.firefox_profile:
294
            places_path = f"{user_dir}\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\{args.firefox_profile}\\places.sqlite"
295
        else:
296
            places_path = f"{user_dir}\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\ojo1adxx.default-release\\places.sqlite"
297
        records = collect_firefox(
298
            args.host, args.user, places_path, source_key, watermarks,
299
            identity=args.identity
300
        )
301
    else:
302
        print(f"[browser-history] Unsupported browser: {args.browser}", file=sys.stderr)
303
        records = []
304
 
305
    if records:
306
        os.makedirs(OUTPUT_DIR, exist_ok=True)
307
        out_path = args.output or os.path.join(OUTPUT_DIR, f"{source_key}.jsonl")
308
        with open(out_path, "a", encoding="utf-8") as f:
309
            for rec in records:
310
                f.write(json.dumps(rec) + "\n")
311
        print(f"[browser-history] Wrote {len(records)} records to {out_path}", file=sys.stderr)
312
        save_watermarks(watermarks)
313
    else:
314
        print(f"[browser-history] No new records for {source_key}", file=sys.stderr)
315
 
316
 
317
if __name__ == "__main__":
318
    main()