Last active
February 3, 2026 23:06
-
-
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 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, | |
| }); | |
| })(); |
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 | |
| 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