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 @@ |
| 1 | venv |
| \ 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 | |
| 5 | Crumb is a local-only browser history tracker. It consists of two parts: a |
| 6 | WebExtension-based browser plugin and a local HTTP server. Crumb logs each |
| 7 | visited 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 | |
| 17 | Each time a page is loaded in the browser, the extension sends the page metadata |
| 18 | to the local server. The server appends this data to a structured log file. |
| 19 | |
| 20 | Data 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 | |
| 29 | The log is formatted as an Org-mode file, with one top-level heading per visit |
| 30 | and 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 | |
| 40 | 1. 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 | |
| 49 | 2. Load the extension into your browser: |
| 50 | |
| 51 | - Enable Developer Mode |
| 52 | - Load the =crumb_extension/= directory |
| 53 | |
| 54 | 3. Browse the web. Entries will appear in =~/.crumb/history.org= |
| 55 | |
| 56 | 4. 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 | |
| 79 | Screenshots 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 | |
| 100 | GPL 3.0 License. See LICENSE file. |
crumb_extension/background.js
added
+26
| @@ -0,0 +1,26 @@ |
| 1 | chrome.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 @@ |
| 1 | flask |
| \ 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 @@ |
| 1 | import sys |
| 2 | import os |
| 3 | |
| 4 | LOG_PATH = os.path.expanduser("~/.crumb/history.org") |
| 5 | |
| 6 | def 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 | |
| 23 | if __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 @@ |
| 1 | from flask import Flask, request, jsonify, make_response |
| 2 | import os |
| 3 | from datetime import datetime |
| 4 | |
| 5 | app = Flask(__name__) |
| 6 | LOG_PATH = os.path.expanduser("~/.crumb/history.org") |
| 7 | |
| 8 | os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) |
| 9 | |
| 10 | @app.after_request |
| 11 | def 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']) |
| 17 | def 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 | |
| 49 | if __name__ == '__main__': |
| 50 | app.run(port=3555) |
| \ No newline at end of file |