Add Chrome extension and Windows native host
This commit is contained in:
1 parent
6f2b238988
commit
f889c6129d
8 files changed
+201
No files matched your search
@@ -41,3 +41,17 @@ V `.env` nastavi `DISCORD_WEBHOOK_URL`. Najprej se zapis shrani v SQLite, nato s
|
|||||||
## Nastavitev razširitve
|
## Nastavitev razširitve
|
||||||
|
|
||||||
V `extension/config.js` nastavi `collectorUrl`, `apiToken` in `deviceId`. Token naj ostane v tej lokalni datoteki in naj ne konča v repozitoriju. `manifest.json` vsebuje široko dovoljenje za HTTP/HTTPS gostitelje zato, da lahko izbereš lasten API naslov; če želiš strožje dovoljenje, zamenjaj `host_permissions` samo s svojim HTTPS naslovom.
|
V `extension/config.js` nastavi `collectorUrl`, `apiToken` in `deviceId`. Token naj ostane v tej lokalni datoteki in naj ne konča v repozitoriju. `manifest.json` vsebuje široko dovoljenje za HTTP/HTTPS gostitelje zato, da lahko izbereš lasten API naslov; če želiš strožje dovoljenje, zamenjaj `host_permissions` samo s svojim HTTPS naslovom.
|
||||||
|
|
||||||
|
## Windows native arhiv (neobvezno)
|
||||||
|
|
||||||
|
Repozitorij vključuje tudi Chrome razširitev in Windows Native Messaging host za lokalni, append-only arhiv obiskov v SQLite.
|
||||||
|
|
||||||
|
1. V Chromu odpri `chrome://extensions`, vklopi **Developer mode**, klikni **Load unpacked** in izberi mapo `extension`.
|
||||||
|
2. Kopiraj 32-znakovni ID razširitve.
|
||||||
|
3. V PowerShellu iz korena repozitorija zaženi:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\install-native-host.ps1 -ExtensionId TUKAJ_PRILEPI_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Lokalni arhiv se privzeto shrani v `%USERPROFILE%\ChromeHistoryArchive\history.sqlite3`. Za pošiljanje obiskov tudi na API uredi lokalni `extension/config.js`; vanj ne zapisuj tokena v commitu.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Copy/edit locally after unpacking the extension. Do not commit a real token.
|
||||||
|
export const CONFIG = {
|
||||||
|
// Example: "https://history.example.com/v1/visits"
|
||||||
|
collectorUrl: "",
|
||||||
|
apiToken: "",
|
||||||
|
deviceId: "windows-chrome"
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Local History Archive",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Writes Chrome visits to a local, append-only archive via Native Messaging.",
|
||||||
|
"permissions": [
|
||||||
|
"history",
|
||||||
|
"nativeMessaging",
|
||||||
|
"storage",
|
||||||
|
"alarms"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "service-worker.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "Local History Archive"
|
||||||
|
},
|
||||||
|
"host_permissions": [
|
||||||
|
"https://*/*",
|
||||||
|
"http://*/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { CONFIG } from "./config.js";
|
||||||
|
|
||||||
|
const HOST = "si.komelt.local_history_archive";
|
||||||
|
const QUEUE_KEY = "pendingVisits";
|
||||||
|
const MAX_QUEUE = 2000;
|
||||||
|
|
||||||
|
async function queued() {
|
||||||
|
const { [QUEUE_KEY]: visits = [] } = await chrome.storage.local.get(QUEUE_KEY);
|
||||||
|
return visits;
|
||||||
|
}
|
||||||
|
async function persist(visits) {
|
||||||
|
await chrome.storage.local.set({ [QUEUE_KEY]: visits.slice(-MAX_QUEUE) });
|
||||||
|
}
|
||||||
|
async function archiveLocally(visits) {
|
||||||
|
if (!visits.length) return true;
|
||||||
|
try {
|
||||||
|
const reply = await chrome.runtime.sendNativeMessage(HOST, { type: "visits", visits });
|
||||||
|
return reply?.ok === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Local archive unavailable", error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function sendToCollector(visits) {
|
||||||
|
// Empty settings deliberately disable sending; the local SQLite archive still works.
|
||||||
|
if (!CONFIG.collectorUrl || !CONFIG.apiToken) return true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(CONFIG.collectorUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${CONFIG.apiToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ device_id: CONFIG.deviceId, visits })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Collector unavailable", error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function flush() {
|
||||||
|
const visits = await queued();
|
||||||
|
if (!visits.length) return;
|
||||||
|
const [localOk, remoteOk] = await Promise.all([archiveLocally(visits), sendToCollector(visits)]);
|
||||||
|
if (localOk && remoteOk) await persist([]);
|
||||||
|
}
|
||||||
|
chrome.history.onVisited.addListener(async (item) => {
|
||||||
|
const visits = await queued();
|
||||||
|
visits.push({
|
||||||
|
url: item.url, title: item.title || "",
|
||||||
|
visit_time: new Date(item.lastVisitTime || Date.now()).toISOString(),
|
||||||
|
transition: "observed", source: "chrome-extension"
|
||||||
|
});
|
||||||
|
await persist(visits);
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
chrome.runtime.onStartup.addListener(flush);
|
||||||
|
chrome.runtime.onInstalled.addListener(flush);
|
||||||
|
chrome.alarms.create("local-history-archive-flush", { periodInMinutes: 1 });
|
||||||
|
chrome.alarms.onAlarm.addListener(flush);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern('^[a-p]{32}$')]
|
||||||
|
[string]$ExtensionId,
|
||||||
|
[string]$ArchivePath = "$env:USERPROFILE\ChromeHistoryArchive"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$source = Join-Path $root 'native-host'
|
||||||
|
$target = Join-Path $env:LOCALAPPDATA 'LocalHistoryArchive'
|
||||||
|
|
||||||
|
if (-not (Get-Command py -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'Python 3 ni nameščen. Namesti ga z https://www.python.org/downloads/windows/ in označi možnost Add python.exe to PATH.'
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $target, $ArchivePath | Out-Null
|
||||||
|
Copy-Item "$source\history_archive_host.py" $target -Force
|
||||||
|
Copy-Item "$source\history_archive_host.bat" $target -Force
|
||||||
|
|
||||||
|
$manifest = (Get-Content "$source\si.komelt.local_history_archive.json.template" -Raw).Replace('C:\\LOCAL_HISTORY_ARCHIVE', ($target -replace '\\', '\\')).Replace('YOUR_EXTENSION_ID', $ExtensionId)
|
||||||
|
$manifestPath = Join-Path $target 'si.komelt.local_history_archive.json'
|
||||||
|
Set-Content -Path $manifestPath -Value $manifest -Encoding UTF8
|
||||||
|
|
||||||
|
[Environment]::SetEnvironmentVariable('LOCAL_HISTORY_ARCHIVE_DIR', $ArchivePath, 'User')
|
||||||
|
$regPath = 'HKCU:\Software\Google\Chrome\NativeMessagingHosts\si.komelt.local_history_archive'
|
||||||
|
New-Item -Path $regPath -Force | Out-Null
|
||||||
|
New-ItemProperty -Path $regPath -Name '(Default)' -Value $manifestPath -PropertyType String -Force | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Nameščeno. Arhiv bo v: $ArchivePath\history.sqlite3"
|
||||||
|
Write-Host 'Zapri in znova odpri Chrome, nato obišči poljubno stran za preizkus.'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@echo off
|
||||||
|
py -3 "%~dp0history_archive_host.py"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Chrome Native Messaging host: append browser visits to local SQLite."""
|
||||||
|
import json, os, sqlite3, struct, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ARCHIVE_DIR = Path(os.environ.get("LOCAL_HISTORY_ARCHIVE_DIR", Path.home() / "ChromeHistoryArchive"))
|
||||||
|
DB_PATH = ARCHIVE_DIR / "history.sqlite3"
|
||||||
|
|
||||||
|
def read_message():
|
||||||
|
raw = sys.stdin.buffer.read(4)
|
||||||
|
if not raw: return None
|
||||||
|
length = struct.unpack("=I", raw)[0]
|
||||||
|
if length > 20_000_000: raise ValueError("Message too large")
|
||||||
|
return json.loads(sys.stdin.buffer.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def send_message(value):
|
||||||
|
body = json.dumps(value, ensure_ascii=False).encode("utf-8")
|
||||||
|
sys.stdout.buffer.write(struct.pack("=I", len(body)))
|
||||||
|
sys.stdout.buffer.write(body)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
def db():
|
||||||
|
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("""CREATE TABLE IF NOT EXISTS visits (
|
||||||
|
id INTEGER PRIMARY KEY, url TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
|
||||||
|
visit_time TEXT NOT NULL, transition TEXT, source TEXT NOT NULL,
|
||||||
|
archived_at TEXT NOT NULL, UNIQUE(url, visit_time, source))""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_visits_time ON visits(visit_time)")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def handle(message):
|
||||||
|
if message.get("type") != "visits" or not isinstance(message.get("visits"), list):
|
||||||
|
return {"ok": False, "error": "Expected a visits message"}
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
rows = []
|
||||||
|
for visit in message["visits"]:
|
||||||
|
url, when = visit.get("url"), visit.get("visit_time")
|
||||||
|
if isinstance(url, str) and url and isinstance(when, str) and when:
|
||||||
|
rows.append((url, str(visit.get("title") or ""), when,
|
||||||
|
str(visit.get("transition") or "observed"),
|
||||||
|
str(visit.get("source") or "chrome-extension"), now))
|
||||||
|
with db() as conn:
|
||||||
|
conn.executemany("INSERT OR IGNORE INTO visits (url,title,visit_time,transition,source,archived_at) VALUES (?,?,?,?,?,?)", rows)
|
||||||
|
return {"ok": True, "archived": len(rows), "database": str(DB_PATH)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
while message := read_message():
|
||||||
|
try: send_message(handle(message))
|
||||||
|
except Exception as exc: send_message({"ok": False, "error": str(exc)})
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "si.komelt.local_history_archive",
|
||||||
|
"description": "Local Chrome History Archive native host",
|
||||||
|
"path": "C:\\LOCAL_HISTORY_ARCHIVE\\history_archive_host.bat",
|
||||||
|
"type": "stdio",
|
||||||
|
"allowed_origins": ["chrome-extension://YOUR_EXTENSION_ID/"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user