Skip to content

Instantly share code, notes, and snippets.

@0187773933
Last active January 19, 2026 02:14
Show Gist options
  • Select an option

  • Save 0187773933/61b0037349ad4840831f9bd1f97308a5 to your computer and use it in GitHub Desktop.

Select an option

Save 0187773933/61b0037349ad4840831f9bd1f97308a5 to your computer and use it in GitHub Desktop.
Zotero Already Saved / Exists Check
// ==UserScript==
// @name Google Scholar – Zotero Saved Highlighter
// @namespace local.zotero.scholar
// @version 0.4.0
// @description Highlight Google Scholar results already saved in Zotero (with caching)
// @match https://scholar.google.com/*
// @match https://scholar.google.com/scholar_labs/search/session/*
// @grant GM.xmlHttpRequest
// @connect 127.0.0.1
// ==/UserScript==
(() => {
"use strict";
console.warn("ZOTERO SCRIPT LOADED", location.href);
const API = "http://127.0.0.1:9371/exists";
const COLOR = "#ff9800";
/* =========================
* Cross-engine HTTP helper
* ========================= */
const httpRequest =
(typeof GM !== "undefined" && GM.xmlHttpRequest)
? GM.xmlHttpRequest
: GM_xmlhttpRequest;
if (!httpRequest) {
console.error("No GM HTTP API available");
return;
}
function postJSON(url, data) {
return new Promise((resolve, reject) => {
httpRequest({
method: "POST",
url,
headers: { "Content-Type": "application/json" },
data: JSON.stringify(data),
onload: (res) => {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
/* =========================
* Cache
* ========================= */
const zoteroCache = new Map();
// key -> boolean exists
function normalizeTitle(t) {
return t
.toLowerCase()
.replace(/[^\w\s]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function cacheKey({ doi, title }) {
if (doi) return `doi:${doi.toLowerCase()}`;
return `title:${normalizeTitle(title)}`;
}
/* =========================
* Heuristics
* ========================= */
function extractDOI(container) {
const text = container?.innerText || "";
const m = text.match(/10\.\d{4,9}\/[^\s"<>]+/i);
return m ? m[0] : null;
}
function collectScholarItems() {
const items = [];
document.querySelectorAll("div.gs_r").forEach((card, idx) => {
const h = card.querySelector("h3.gs_rt");
if (!h) return;
const title = h.innerText.trim();
if (!title) return;
const doi = extractDOI(card);
items.push({
id: `gs-${idx}`,
node: h,
title,
doi,
key: cacheKey({ title, doi }),
});
});
return items;
}
function highlight(node) {
node.style.background = COLOR;
node.style.padding = "2px 4px";
node.style.borderRadius = "4px";
node.title = "Already in Zotero";
}
/* =========================
* Zotero lookup + highlight
* ========================= */
async function checkZotero(items) {
if (!items.length) return;
// 1. Highlight immediately from cache
items.forEach(it => {
if (zoteroCache.get(it.key) === true) {
highlight(it.node);
}
});
// 2. Find uncached items
const toQuery = items.filter(it => !zoteroCache.has(it.key));
if (!toQuery.length) return;
const queries = toQuery.map(it => ({
id: it.id,
title: it.title,
doi: it.doi || undefined,
}));
console.warn("QUERYING ZOTERO (uncached only)", queries.length);
let data;
try {
data = await postJSON(API, { queries });
} catch (e) {
console.warn("Zotero exists server error", e);
return;
}
const existsMap = new Map(
(data.results || []).map(r => [r.id, r.exists])
);
// 3. Update cache + highlight
toQuery.forEach(it => {
const exists = !!existsMap.get(it.id);
zoteroCache.set(it.key, exists);
if (exists) highlight(it.node);
});
}
/* =========================
* Run + SPA-safe observer
* ========================= */
let lastRun = 0;
async function run() {
const now = Date.now();
if (now - lastRun < 800) return;
lastRun = now;
const items = collectScholarItems();
await checkZotero(items);
}
run();
new MutationObserver(run).observe(document.body, {
childList: true,
subtree: true,
});
})();
#!/usr/bin/env python3
import json
import sqlite3
import shutil
import tempfile
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
ZOTERO_DB = Path("/Users/morpheous/Zotero/zotero.sqlite")
HOST = "127.0.0.1"
PORT = 9371
def open_snapshot():
tmpdir = Path(tempfile.mkdtemp(prefix="zotero_db_"))
snap = tmpdir / "zotero.sqlite"
shutil.copy2(ZOTERO_DB, snap)
return sqlite3.connect(str(snap))
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/exists":
self.send_error(404)
return
try:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
queries = body.get("queries", [])
results = []
db = open_snapshot()
cur = db.cursor()
for q in queries:
exists = False
# DOI exact match
if q.get("doi"):
cur.execute(
"SELECT 1 FROM itemDataValues WHERE value = ? LIMIT 1",
(q["doi"],),
)
exists = cur.fetchone() is not None
# Title fallback (prefix heuristic)
if not exists and q.get("title"):
cur.execute(
"SELECT 1 FROM itemDataValues WHERE value LIKE ? LIMIT 1",
(q["title"][:80] + "%",),
)
exists = cur.fetchone() is not None
results.append({
"id": q.get("id"),
"exists": exists
})
db.close()
resp = json.dumps({"results": results}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
print(f"✔ served {len(results)} queries")
except Exception as e:
err = json.dumps({
"results": [],
"error": str(e)
}).encode()
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(err)))
self.end_headers()
self.wfile.write(err)
print("✖ server error:", e)
def log_message(self, *_):
return
if __name__ == "__main__":
print(f"Zotero exists server running on http://{HOST}:{PORT}")
HTTPServer((HOST, PORT), Handler).serve_forever()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment