Last active
June 22, 2025 16:14
-
-
Save schuhwerk/c81cdf22813ee121e1603a0d778be091 to your computer and use it in GitHub Desktop.
Userscript - Automatically detects recurring Google/DuckDuckGo/etc searches and prompts you to save them as flashcards in a GitHub-synced CrowdAnki JSON file. Features a settings menu, post-add cooldown, and Ctrl+Enter shortcut.
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 Search to Anki | |
// @namespace https://violentmonkey.github.io/ | |
// @version 3.0 | |
// @description Automatically detects recurring Google/DuckDuckGo/etc searches and prompts you to save them as flashcards in a GitHub-synced CrowdAnki JSON file. Features a settings menu, post-add cooldown, and Ctrl+Enter shortcut. | |
// @author Vitus Schuhwerk | |
// @homepageURL https://gist.github.com/schuhwerk/c81cdf22813ee121e1603a0d778be091 | |
// @updateURL https://gist.github.com/schuhwerk/c81cdf22813ee121e1603a0d778be091/raw | |
// @downloadURL https://gist.github.com/schuhwerk/c81cdf22813ee121e1603a0d778be091/raw | |
// @match https://www.google.com/search* | |
// @match https://duckduckgo.com/ | |
// @match https://www.bing.com/search* | |
// @match https://search.brave.com/search* | |
// @match https://www.ecosia.org/search* | |
// @grant GM_setValue | |
// @grant GM_getValue | |
// @grant GM_deleteValue | |
// @grant GM_registerMenuCommand | |
// @grant GM_xmlhttpRequest | |
// @grant GM_addStyle | |
// ==/UserScript== | |
(async function() { | |
'use strict'; | |
// ================================================================================= | |
// SETUP GUIDE | |
// ================================================================================= | |
// | |
// 1. PREREQUISITES: | |
// - ⚠️ Be careful who to trust with your data! This stores all your searches. | |
// - Violentmonkey Browser Extension (or another userscript manager). | |
// - A GitHub account. | |
// - A private GitHub repository to store your Anki deck file. | |
// - A GitHub Personal Access Token with 'repo' scope. | |
// (Go to GitHub > Settings > Developer settings > Personal access tokens > Tokens (classic) > Generate new token). | |
// - Anki with the CrowdAnki add-on installed. | |
// | |
// 2. INITIAL CONFIGURATION: | |
// - After installing this script, go to a supported search engine (like Google). | |
// - Click the Violentmonkey extension icon in your browser's toolbar. | |
// - Select "⚙️ Configure Settings" from the menu that appears. | |
// - Fill in the details prompted, including your GitHub username, repo name, token, | |
// and your Anki Note Model UUID (which you can get from a CrowdAnki export). | |
// | |
// 3. USAGE: | |
// - The script will run in the background. When it detects a search you've made | |
// multiple times, a dialog will appear. | |
// - Fill in the answer and click "Yes, Add to Anki" or press Ctrl+Enter. | |
// - To import into Anki, either clone your GitHub repo and use CrowdAnki to import | |
// from that local directory, or periodically download the JSON file and use | |
// CrowdAnki's "Import from disk" feature. | |
// | |
// ================================================================================= | |
// --- SCRIPT CONSTANTS --- | |
// Set this to true for verbose console logging (press F12 to see developer console). | |
const DEBUG = false; | |
// --- DEFAULT CONFIGURATION --- | |
// These are the initial defaults. Your settings are saved after the first configuration. | |
const defaultConfig = { | |
searchRepetitionThreshold: 3, | |
searchPeriodInMonths: 12, | |
fuzzinessThreshold: 0.8, | |
ankiCooldownInMonths: 6, | |
githubUsername: '', | |
githubRepo: '', | |
githubToken: '', | |
ankiJsonPath: 'deck.json', | |
gitCommitMessage: 'Add new Anki card from search', | |
note_model_uuid: 'YOUR_NOTE_MODEL_UUID', | |
}; | |
// This will hold the active configuration (defaults merged with user's saved settings). | |
let config = {}; | |
// --- LOGGER --- | |
// Prefixed logger that only prints when DEBUG is true. | |
const log = (...args) => { if (DEBUG) console.log('[SearchToAnki]', ...args); }; | |
const logError = (...args) => { console.error('[SearchToAnki]', ...args); }; | |
// --- CONFIGURATION LOADER & MENU --- | |
/** | |
* Loads configuration from GM storage, merging it with defaults. | |
*/ | |
const loadConfig = async () => { | |
const savedConfig = await GM_getValue('config', {}); | |
config = { ...defaultConfig, ...savedConfig }; | |
log('Configuration loaded:', config); | |
}; | |
/** | |
* Opens a series of prompts to guide the user through setting configuration. | |
*/ | |
const openConfigMenu = async () => { | |
const currentConfig = { ...defaultConfig, ...(await GM_getValue('config', {})) }; | |
const promptForValue = (key, text, isNumber = false, isFloat = false) => { | |
const result = prompt(text, currentConfig[key]); | |
if (result === null) return null; // User cancelled | |
if (isNumber) { | |
const num = isFloat ? parseFloat(result) : parseInt(result, 10); | |
return isNaN(num) ? currentConfig[key] : num; | |
} | |
return result; | |
}; | |
const values = { | |
githubUsername: promptForValue('githubUsername', 'Enter your GitHub Username:'), | |
githubRepo: promptForValue('githubRepo', 'Enter your GitHub Repository name:'), | |
githubToken: promptForValue('githubToken', 'Enter your GitHub Personal Access Token (with repo scope):'), | |
note_model_uuid: promptForValue('note_model_uuid', 'Enter your Anki Note Model UUID:'), | |
fuzzinessThreshold: promptForValue('fuzzinessThreshold', 'Enter fuzziness threshold (0.1 to 1.0):', true, true), | |
searchRepetitionThreshold: promptForValue('searchRepetitionThreshold', 'Enter search repetition threshold (e.g., 3):', true), | |
ankiCooldownInMonths: promptForValue('ankiCooldownInMonths', 'Enter cooldown in months after adding a card (e.g., 6):', true), | |
}; | |
if (Object.values(values).some(v => v === null)) return alert('Configuration cancelled.'); | |
await GM_setValue('config', { ...currentConfig, ...values }); | |
await loadConfig(); | |
alert('Configuration saved successfully!'); | |
}; | |
/** | |
* Clears all saved settings and trackers from storage. | |
*/ | |
const resetConfig = async () => { | |
if (confirm('Are you sure you want to reset all settings? This will also clear your cooldown tracker for already-added cards.')) { | |
await GM_deleteValue('config'); | |
await GM_deleteValue('addedAnkiCards'); | |
await loadConfig(); | |
alert('Configuration has been reset. Please reload the page.'); | |
} | |
}; | |
// Initialize the script by loading the config and setting up the menu. | |
await loadConfig(); | |
GM_registerMenuCommand('⚙️ Configure Settings', openConfigMenu); | |
GM_registerMenuCommand('🔥 Reset to Defaults', resetConfig); | |
// --- SUPPORTED SEARCH ENGINES & UI STYLES --- | |
// The script will activate on pages matching these search engines. | |
const searchEngines = [ | |
{ name: 'Google', host: 'google.com', queryParam: 'q' }, | |
{ name: 'DuckDuckGo', host: 'duckduckgo.com', queryParam: 'q' }, | |
{ name: 'Bing', host: 'bing.com', queryParam: 'q' }, | |
{ name: 'Brave', host: 'search.brave.com', queryParam: 'q' }, | |
{ name: 'Ecosia', host: 'ecosia.org', queryParam: 'q' } | |
]; | |
// Injects the CSS for the dialog box. It respects system light/dark modes. | |
GM_addStyle(` | |
:root{--bg-color:#fff;--text-color:#24292f;--field-bg-color:#f6f8fa;--field-text-color:#24292f;--border-color:#d0d7de;--primary-color:#0969da;--primary-text-color:#fff;--shadow-color:rgba(140,149,159,.3)} | |
@media (prefers-color-scheme:dark){:root{--bg-color:#1c2128;--text-color:#c9d1d9;--field-bg-color:#0d1117;--field-text-color:#c9d1d9;--border-color:#30363d;--primary-color:#238636;--shadow-color:rgba(0,0,0,.4)}} | |
@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}} | |
#anki-dialog{position:fixed;top:25px;right:25px;background-color:var(--bg-color);color:var(--text-color);border:1px solid var(--border-color);padding:1.5rem;z-index:10000;box-shadow:0 8px 24px var(--shadow-color);max-width:420px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;border-radius:12px;animation:fadeIn .3s ease-out;display:flex;flex-direction:column;gap:1rem} | |
#anki-dialog h3{margin:0;font-size:1.1rem;font-weight:600} | |
#anki-dialog p,#anki-dialog label{margin:0;font-size:.9rem;line-height:1.5} | |
#anki-dialog ul{max-height:150px;overflow-y:auto;padding:.75rem 1rem;border:1px solid var(--border-color);background-color:var(--field-bg-color);border-radius:8px;list-style-position:inside} | |
#anki-dialog li{margin-bottom:.5rem} | |
#anki-dialog li b{font-weight:500} | |
#anki-dialog .anki-form-group{display:flex;flex-direction:column;gap:.5rem} | |
#anki-dialog input,#anki-dialog textarea{width:100%;padding:8px 12px;background-color:var(--field-bg-color);color:var(--field-text-color);border:1px solid var(--border-color);border-radius:6px;box-sizing:border-box;transition:border-color .2s,box-shadow .2s} | |
#anki-dialog input:focus,#anki-dialog textarea:focus{outline:none;border-color:var(--primary-color);box-shadow:0 0 0 3px rgba(var(--primary-color),.3)} | |
#anki-dialog .anki-button-group{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.5rem} | |
#anki-dialog button{padding:5px 12px;font-size:.85rem;font-weight:500;border:1px solid var(--border-color);border-radius:6px;cursor:pointer;transition:background-color .2s;background-color:var(--field-bg-color);color:var(--text-color)} | |
#anki-dialog button:hover{border-color:var(--primary-color)} | |
#anki-dialog button#anki-yes{background-color:var(--primary-color);color:var(--primary-text-color);border-color:var(--primary-color)} | |
`); | |
// --- CORE LOGIC & HELPER FUNCTIONS --- | |
/** | |
* Creates a deterministic, SHA-1 based GUID from a string, formatted as a UUID. | |
* This ensures that identical cards will have identical GUIDs, preventing duplicates. | |
* @param {string} str The string to hash, typically the note's content. | |
* @returns {Promise<string>} A promise that resolves to the formatted GUID. | |
*/ | |
async function createGuid(str) { | |
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(str)); | |
const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); | |
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`; | |
} | |
/** | |
* Calculates the Levenshtein distance between two strings (a measure of difference). | |
*/ | |
const levenshtein = (a, b) => { const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(null)); for (let i = 0; i <= a.length; i += 1) { matrix[0][i] = i; } for (let j = 0; j <= b.length; j += 1) { matrix[j][0] = j; } for (let j = 1; j <= b.length; j += 1) { for (let i = 1; i <= a.length; i += 1) { const indicator = a[i - 1] === b[j - 1] ? 0 : 1; matrix[j][i] = Math.min(matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + indicator,); } } return matrix[b.length][a.length]; }; | |
/** | |
* Calculates a normalized similarity score (0 to 1) between two strings. | |
*/ | |
const calculateSimilarity = (a, b) => { if (!a || !b) return 0; const distance = levenshtein(a.toLowerCase(), b.toLowerCase()); const maxLength = Math.max(a.length, b.length); if (maxLength === 0) return 1; return 1 - (distance / maxLength); }; | |
/** | |
* Logs the search query and timestamp to persistent storage. | |
*/ | |
const logSearch = async (query) => { let searchHistory = await GM_getValue('searchHistory', {}); const now = new Date().toISOString(); const recentSearches = Object.keys(searchHistory).slice(-10); for (const pastQuery of recentSearches) { if (calculateSimilarity(query, pastQuery) > 0.95) { searchHistory[pastQuery].push(now); await GM_setValue('searchHistory', searchHistory); return; } } if (!searchHistory[query]) { searchHistory[query] = []; } searchHistory[query].push(now); await GM_setValue('searchHistory', searchHistory); }; | |
/** | |
* Finds all searches in history that are similar to the current query. | |
*/ | |
const getSimilarSearches = async (currentQuery) => { const searchHistory = await GM_getValue('searchHistory', {}); const similarSearches = []; let allTimestamps = []; for (const pastQuery in searchHistory) { if (calculateSimilarity(currentQuery, pastQuery) >= config.fuzzinessThreshold) { const timestamps = searchHistory[pastQuery]; similarSearches.push({ query: pastQuery, timestamps }); allTimestamps = allTimestamps.concat(timestamps); } } return { similarSearches, allTimestamps }; }; | |
/** | |
* Checks if the number of recent searches meets the configured threshold. | |
*/ | |
const isNthSearch = (searchTimestamps, n, periodInMonths) => { const now = new Date(); const periodStartDate = new Date(now.setMonth(now.getMonth() - periodInMonths)); const recentSearches = searchTimestamps.filter(ts => new Date(ts) >= periodStartDate); log(`Found ${recentSearches.length} similar searches in the last ${periodInMonths} months.`); return recentSearches.length >= n; }; | |
/** | |
* Checks if a similar query has already been added to Anki within the cooldown period. | |
*/ | |
const isRecentlyAdded = async (currentQuery) => { const addedCards = await GM_getValue('addedAnkiCards', {}); if (Object.keys(addedCards).length === 0) return false; const now = new Date(); const cooldownStartDate = new Date(new Date().setMonth(now.getMonth() - config.ankiCooldownInMonths)); for (const addedQuery in addedCards) { if (calculateSimilarity(currentQuery, addedQuery) >= config.fuzzinessThreshold) { const addedDate = new Date(addedCards[addedQuery]); if (addedDate >= cooldownStartDate) { log(`Query is similar to a recently added card ("${addedQuery}") from ${addedDate.toLocaleDateString()}. Cooldown is active.`); return true; } } } return false; }; | |
/** | |
* Creates and displays the dialog box to the user. | |
*/ | |
const showAnkiDialog = (currentQuery, similarSearchResults) => { | |
log('Showing Anki dialog.'); | |
const dialogId = 'anki-dialog'; | |
if (document.getElementById(dialogId)) return; | |
let pastSearchesList = similarSearchResults.map(result => `<li><b>${result.query}</b> (${result.timestamps.length} time(s))</li>`).join(''); | |
const dialogHTML = `<div id="${dialogId}"><h3>You've asked similar questions...</h3><ul>${pastSearchesList}</ul><div class="anki-form-group"><label for="anki-query">Do you want to add this to your Anki deck?</label><input type="text" id="anki-query" placeholder="Question / Front" value="${currentQuery}"></div><div class="anki-form-group"><label for="anki-answer">Answer:</label><textarea id="anki-answer" rows="4" placeholder="Answer / Back"></textarea></div><div class="anki-button-group"><button id="anki-yes">Yes, Add to Anki</button><button id="anki-no">No</button><button id="anki-next-time">Maybe Later</button><button id="anki-dont-ask-again">Don't Ask Again</button></div></div>`; | |
document.body.insertAdjacentHTML('beforeend', dialogHTML); | |
const dialogElement = document.getElementById(dialogId); | |
const yesButton = document.getElementById('anki-yes'); | |
yesButton.addEventListener('click', async () => { | |
const modifiedQuery = document.getElementById('anki-query').value; | |
const answer = document.getElementById('anki-answer').value; | |
if (modifiedQuery && answer) { | |
await addSearchToAnki(modifiedQuery, answer); | |
dialogElement.remove(); | |
} else { | |
alert('Please provide both a query and an answer.'); | |
} | |
}); | |
document.getElementById('anki-no').addEventListener('click', () => dialogElement.remove()); | |
document.getElementById('anki-next-time').addEventListener('click', () => dialogElement.remove()); | |
document.getElementById('anki-dont-ask-again').addEventListener('click', async () => { | |
let ignoredSearches = await GM_getValue('ignoredSearches', []); | |
ignoredSearches.push(currentQuery); | |
await GM_setValue('ignoredSearches', ignoredSearches); | |
dialogElement.remove(); | |
}); | |
// Add Ctrl+Enter shortcut listener for the dialog | |
dialogElement.addEventListener('keydown', (event) => { | |
if (event.key === 'Enter' && event.ctrlKey) { | |
log('Ctrl+Enter detected, submitting.'); | |
event.preventDefault(); // Prevent default action (e.g., adding a newline) | |
yesButton.click(); // Programmatically click the "Yes" button | |
} | |
}); | |
document.getElementById('anki-answer').focus(); | |
}; | |
// --- ANKI & GITHUB API FUNCTIONS --- | |
/** | |
* Creates an Anki Note object, including a deterministic GUID. | |
*/ | |
const createAnkiCard = async (query, answer) => { | |
const guid = await createGuid(query + answer); | |
return { | |
"__type__": "Note", | |
"data": "", | |
"fields": [query, answer], | |
"flags": 0, | |
"guid": guid, | |
"note_model_uuid": config.note_model_uuid, | |
"tags": ["from_search"] | |
}; | |
}; | |
/** | |
* Fetches a file from the configured GitHub repository. | |
*/ | |
const getGitRepoFile = (path) => { | |
const url = `https://api.github.com/repos/${config.githubUsername}/${config.githubRepo}/contents/${path}`; | |
log(`Fetching git file from: ${url}`); | |
return new Promise((resolve, reject) => GM_xmlhttpRequest({ | |
method: 'GET', url, headers: { 'Authorization': `token ${config.githubToken}`, 'Accept': 'application/vnd.github.v3+json' }, | |
onload: (r) => { | |
if (r.status === 200) { | |
const d = JSON.parse(r.responseText); | |
log('Fetch success (200). Found file with SHA:', d.sha); | |
try { | |
const decodedContent = decodeURIComponent(escape(atob(d.content))); | |
resolve({ content: decodedContent, sha: d.sha }); | |
} catch (e) { logError('Failed to decode file content.', e); reject('File decoding error'); } | |
} else if (r.status === 404) { | |
log('Fetch success (404). File does not exist yet.'); | |
resolve({ content: null, sha: null }); | |
} else { | |
logError(`Fetch failed. Status: ${r.status}`, r.responseText); | |
reject(`Get file error ${r.status}`); | |
} | |
}, | |
onerror: (e) => { logError('Fetch network error.', e); reject(e) } | |
})); | |
}; | |
/** | |
* Updates (or creates) a file in the configured GitHub repository. | |
*/ | |
const updateGitRepoFile = (path, content, sha) => { | |
const url = `https://api.github.com/repos/${config.githubUsername}/${config.githubRepo}/contents/${path}`; | |
log(`Updating git file at: ${url}`); | |
const base64Content = btoa(unescape(encodeURIComponent(content))); | |
const data = { message: config.gitCommitMessage, content: base64Content, sha }; | |
return new Promise((resolve, reject) => GM_xmlhttpRequest({ | |
method: 'PUT', url, headers: { 'Authorization': `token ${config.githubToken}`, 'Content-Type': 'application/json' }, data: JSON.stringify(data), | |
onload: (r) => { | |
if (r.status === 200 || r.status === 201) { log('Update success.', r.status); resolve(); } | |
else { logError(`Update failed. Status: ${r.status}`, r.responseText); reject(`Update file error ${r.status}`); } | |
}, | |
onerror: (e) => { logError('Update network error.', e); reject(e) } | |
})); | |
}; | |
/** | |
* The main function to orchestrate fetching the deck, adding a new card, and pushing the update. | |
*/ | |
const addSearchToAnki = async (query, answer) => { | |
if (!config.githubUsername || config.note_model_uuid.startsWith('YOUR_')) { | |
alert('GitHub or Anki UUID details are not configured. Please use the "⚙️ Configure Settings" menu.'); | |
return; | |
} | |
try { | |
const { content: existingContent, sha } = await getGitRepoFile(config.ankiJsonPath); | |
let ankiDeck; | |
if (existingContent) { | |
ankiDeck = JSON.parse(existingContent); | |
} else { | |
ankiDeck = { "__type__": "Deck", "children": [], "desc": "Deck created from Web Searches", "dyn": 0, "id": Date.now(), "name": config.githubRepo, "notes": [] }; | |
} | |
// Await the creation of the card since GUID generation is async | |
const newCard = await createAnkiCard(query, answer); | |
ankiDeck.notes.push(newCard); | |
await updateGitRepoFile(config.ankiJsonPath, JSON.stringify(ankiDeck, null, 2), sha); | |
const addedAnkiCards = await GM_getValue('addedAnkiCards', {}); | |
addedAnkiCards[query] = new Date().toISOString(); | |
await GM_setValue('addedAnkiCards', addedAnkiCards); | |
log(`Added "${query}" to the cooldown list.`); | |
alert('Card added to Anki deck in your GitHub repo!'); | |
} catch (error) { | |
logError('Failed to add card:', error); | |
alert('Failed to add card. Check the console for details (F12). Is your GitHub token correct?'); | |
} | |
}; | |
// --- MAIN EXECUTION --- | |
/** | |
* The main entry point of the script. | |
*/ | |
const main = async () => { | |
const currentHost = window.location.hostname.replace('www.', ''); | |
const currentEngine = searchEngines.find(engine => currentHost === engine.host); | |
if (!currentEngine) return; // Exit if not on a supported search engine | |
log(`Running on ${currentEngine.name}.`); | |
const searchParams = new URLSearchParams(window.location.search); | |
const query = searchParams.get(currentEngine.queryParam)?.trim(); | |
if (!query) { | |
log('No search query found in URL.'); | |
return; | |
} | |
log(`Detected query: "${query}"`); | |
// Run through the checks in order | |
await logSearch(query); | |
// 1. Is it manually ignored? | |
const ignoredSearches = await GM_getValue('ignoredSearches', []); | |
if (ignoredSearches.includes(query)) { | |
log('Query is on the manual ignore list. Aborting.'); | |
return; | |
} | |
// 2. Has a similar card been added recently (is it on cooldown)? | |
if (await isRecentlyAdded(query)) { | |
log('Query is on cooldown from a recent Anki add. Aborting.'); | |
return; | |
} | |
// 3. Has it been searched enough times to trigger the prompt? | |
const { similarSearches, allTimestamps } = await getSimilarSearches(query); | |
if (isNthSearch(allTimestamps, config.searchRepetitionThreshold, config.searchPeriodInMonths)) { | |
showAnkiDialog(query, similarSearches); | |
} else { | |
log('Repetition threshold not met. Not showing dialog.'); | |
} | |
}; | |
main(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment