Skip to content

Instantly share code, notes, and snippets.

@0187773933
Last active February 3, 2026 23:06
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 Zotero Saved Highlighter (Scholar + Web of Science + PubMed)
// @namespace local.zotero.multi
// @version 0.5.0
// @description Highlight items already saved in Zotero on Scholar, Web of Science, and PubMed
// @match https://scholar.google.com/*
// @match https://scholar.google.com/scholar_labs/search/session/*
// @match https://www-webofscience-com.ezproxy.libraries.wright.edu/wos/woscc/summary/*
// @match https://pubmed.ncbi.nlm.nih.gov/*
// @grant GM.xmlHttpRequest
// @connect 127.0.0.1
// ==/UserScript==
(() => {
"use strict";
console.warn("ZOTERO HIGHLIGHTER 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();
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)}`;
}
/* =========================
* Shared helpers
* ========================= */
function extractDOIFromText(text) {
const m = text.match(/10\.\d{4,9}\/[^\s"<>]+/i);
return m ? m[0] : null;
}
function highlight(node) {
node.style.background = COLOR;
node.style.padding = "2px 4px";
node.style.borderRadius = "4px";
node.title = "Already in Zotero";
}
/* =========================
* Site-specific collectors
* ========================= */
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 = extractDOIFromText(card.innerText || "");
items.push({
id: `gs-${idx}`,
node: h,
title,
doi,
key: cacheKey({ title, doi }),
});
});
return items;
}
function collectWebOfScienceItems() {
const items = [];
document
.querySelectorAll('a[data-ta="summary-record-title-link"]')
.forEach((a, idx) => {
const title = a.innerText.trim();
if (!title) return;
const record =
a.closest("div.ng-star-inserted") || a.parentElement;
const text = record?.innerText || "";
const doi = extractDOIFromText(text);
items.push({
id: `wos-${idx}`,
node: a,
title,
doi,
key: cacheKey({ title, doi }),
});
});
return items;
}
function collectPubMedItems() {
const items = [];
document
.querySelectorAll("a.docsum-title")
.forEach((a, idx) => {
const title = a.innerText.replace(/\s+/g, " ").trim();
if (!title) return;
const pmid =
a.getAttribute("data-article-id") ||
a.getAttribute("href")?.match(/\/(\d+)\//)?.[1] ||
null;
items.push({
id: `pm-${pmid || idx}`,
node: a,
title,
doi: null,
key: cacheKey({ title }),
});
});
return items;
}
function collectItemsBySite() {
const host = location.hostname;
if (host.includes("scholar.google.com")) {
return collectScholarItems();
}
if (host.includes("webofscience")) {
return collectWebOfScienceItems();
}
if (host.includes("pubmed.ncbi.nlm.nih.gov")) {
return collectPubMedItems();
}
return [];
}
/* =========================
* Zotero lookup + highlight
* ========================= */
async function checkZotero(items) {
if (!items.length) return;
items.forEach(it => {
if (zoteroCache.get(it.key) === true) {
highlight(it.node);
}
});
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("ZOTERO QUERY", 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])
);
toQuery.forEach(it => {
const exists = !!existsMap.get(it.id);
zoteroCache.set(it.key, exists);
if (exists) {
highlight(it.node);
}
});
}
/* =========================
* Run + SPA observer
* ========================= */
let lastRun = 0;
async function run() {
const now = Date.now();
if (now - lastRun < 800) return;
lastRun = now;
const items = collectItemsBySite();
await checkZotero(items);
}
run();
new MutationObserver(run).observe(document.body, {
childList: true,
subtree: true,
});
})();
#!/usr/bin/env python3
import json
import sqlite3
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_live_db():
uri = f"file:{ZOTERO_DB}?mode=ro&immutable=1"
return sqlite3.connect(uri, uri=True)
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 = []
with open_live_db() as db:
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"][:120] + "%",),
)
exists = cur.fetchone() is not None
results.append({
"id": q.get("id"),
"exists": exists
})
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 (live)")
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