Last active
January 19, 2026 02:14
-
-
Save 0187773933/61b0037349ad4840831f9bd1f97308a5 to your computer and use it in GitHub Desktop.
Zotero Already Saved / Exists Check
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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, | |
| }); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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