Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active June 22, 2025 16:14
Show Gist options
  • Save schuhwerk/c81cdf22813ee121e1603a0d778be091 to your computer and use it in GitHub Desktop.
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.
// ==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