krz/crumb

A local alternative to your browser's history. browser-history cli local-first privacy

Commit 8bbac67df8

8bbac67df8450b021914725a756a029667b8f585

Verified · cmc

cmc <hello@cleberg.net> · 2025-06-04 18:24 UTC

feat: initial commit

Layout: unified · split

.gitignore added +1
@@ -0,0 +1 @@
1venv\ No newline at end of file
.projectile added
README.org added +100
@@ -0,0 +1,100 @@
1#+TITLE: Crumb
2#+AUTHOR: Christian Cleberg
3#+OPTIONS: toc:nil
4
5Crumb is a local-only browser history tracker. It consists of two parts: a
6WebExtension-based browser plugin and a local HTTP server. Crumb logs each
7visited web page to a plaintext file in Org-mode format.
8
9* Components
10
11- Browser extension (WebExtension API, JavaScript)
12- Local HTTP listener (Python + Flask)
13- Plaintext log file (~/.crumb/history.log)
14
15* Functionality
16
17Each time a page is loaded in the browser, the extension sends the page metadata
18to the local server. The server appends this data to a structured log file.
19
20Data captured per page:
21
22- Page title
23- URL
24- Timestamp (UTC)
25- Hostname
26- Path
27- Optional: query string, tab ID, window ID, favicon URL
28
29The log is formatted as an Org-mode file, with one top-level heading per visit
30and standard =:PROPERTIES:= metadata.
31
32* Requirements
33
34- Python 3.x
35- Flask
36- A Chromium-based or Firefox browser with extension developer mode enabled
37
38* Usage
39
401. Start the local listener:
41
42 #+BEGIN_SRC shell
43 python3 -m venv venv
44 source ./venv/bin/activate
45 pip install -r requirements.txt
46 python server.py
47 #+END_SRC
48
492. Load the extension into your browser:
50
51 - Enable Developer Mode
52 - Load the =crumb_extension/= directory
53
543. Browse the web. Entries will appear in =~/.crumb/history.org=
55
564. Search the log using:
57
58 #+BEGIN_SRC shell
59 python search.py <term>
60 #+END_SRC
61
62* Example Log Entry
63
64#+BEGIN_SRC org
65* Example Domain
66:PROPERTIES:
67:URL: http://example.com
68:TIMESTAMP: 2025-06-04T20:31:01.123456
69:HOST: example.com
70:PATH: /
71:TAB: 143
72:WINDOW: 1
73:FAVICON: https://example.com/favicon.ico
74:END:
75#+END_SRC
76
77* Preview Images
78
79Screenshots of Crumb in use.
80
81#+CAPTION: Developer Console Output
82[[./screenshots/devtools.png]]
83
84#+CAPTION: Org-Mode History Log
85[[./screenshots/history.png]]
86
87#+CAPTION: Search Utility in Terminal
88[[./screenshots/search.png]]
89
90* Limitations
91
92- Browser extensions cannot write files directly; communication requires the
93 local server.
94- History is stored in plaintext and is not encrypted. You can bolt-on a
95 third-party encryption tool, if required.
96- Data is stored locally and is never transmitted externally.
97
98* License
99
100GPL 3.0 License. See LICENSE file.
crumb_extension/background.js added +26
@@ -0,0 +1,26 @@
1chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
2 if (changeInfo.status !== 'complete' || !tab.url.startsWith("http")) return;
3
4 const url = new URL(tab.url);
5
6 const payload = {
7 title: tab.title,
8 url: tab.url,
9 hostname: url.hostname,
10 path: url.pathname,
11 query: url.search,
12 tabId: tab.id,
13 windowId: tab.windowId,
14 favIconUrl: tab.favIconUrl || null
15 };
16
17 console.log("Crumb: Sending payload", payload);
18
19 fetch("http://localhost:3555", {
20 method: "POST",
21 headers: { "Content-Type": "application/json" },
22 body: JSON.stringify(payload)
23 }).catch(err => {
24 console.error("Crumb: Failed to reach server", err);
25 });
26});\ No newline at end of file
crumb_extension/manifest.json added +10
@@ -0,0 +1,10 @@
1{
2 "manifest_version": 3,
3 "name": "Crumb",
4 "version": "0.1",
5 "description": "Local history tracker that sends visit logs to a local server.",
6 "permissions": ["tabs", "history"],
7 "background": {
8 "service_worker": "background.js"
9 }
10}\ No newline at end of file
requirements.txt added +1
@@ -0,0 +1 @@
1flask\ No newline at end of file
screenshots/devtools.png added

Binary file not shown.

screenshots/history.png added

Binary file not shown.

screenshots/search.png added

Binary file not shown.

search.py added +27
@@ -0,0 +1,27 @@
1import sys
2import os
3
4LOG_PATH = os.path.expanduser("~/.crumb/history.org")
5
6def search_log(query):
7 if not os.path.exists(LOG_PATH):
8 print("No history file found.")
9 return
10
11 with open(LOG_PATH, "r") as f:
12 entries = f.read().split("* ")[1:] # split on org-mode headings
13
14 found = 0
15 for entry in entries:
16 if query.lower() in entry.lower():
17 print(f"* {entry.strip()}\n")
18 found += 1
19
20 if found == 0:
21 print("No matches found.")
22
23if __name__ == "__main__":
24 if len(sys.argv) < 2:
25 print("Usage: search_crumb.py <search term>")
26 else:
27 search_log(sys.argv[1])\ No newline at end of file
server.py added +50
@@ -0,0 +1,50 @@
1from flask import Flask, request, jsonify, make_response
2import os
3from datetime import datetime
4
5app = Flask(__name__)
6LOG_PATH = os.path.expanduser("~/.crumb/history.org")
7
8os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
9
10@app.after_request
11def add_cors_headers(response):
12 response.headers['Access-Control-Allow-Origin'] = '*'
13 response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
14 return response
15
16@app.route('/', methods=['POST', 'OPTIONS'])
17def log_visit():
18 if request.method == 'OPTIONS':
19 return '', 204
20
21 data = request.json
22 title = data.get("title", "No Title")
23 url = data.get("url", "No URL")
24 hostname = data.get("hostname", "")
25 path = data.get("path", "")
26 query = data.get("query", "")
27 tab_id = data.get("tabId", "")
28 window_id = data.get("windowId", "")
29 favicon = data.get("favIconUrl", "")
30 timestamp = datetime.utcnow().isoformat()
31
32 with open(LOG_PATH, "a") as f:
33 f.write(f"* {title}\n")
34 f.write(":PROPERTIES:\n")
35 f.write(f":URL: {url}\n")
36 f.write(f":TIMESTAMP: {timestamp}\n")
37 f.write(f":HOST: {hostname}\n")
38 f.write(f":PATH: {path}\n")
39 if query:
40 f.write(f":QUERY: {query}\n")
41 f.write(f":TAB: {tab_id}\n")
42 f.write(f":WINDOW: {window_id}\n")
43 if favicon:
44 f.write(f":FAVICON: {favicon}\n")
45 f.write(":END:\n\n")
46
47 return '', 204
48
49if __name__ == '__main__':
50 app.run(port=3555)\ No newline at end of file