// ==UserScript==
// @name ImmersionKit → Anki
// @namespace immersionkit_to_anki
// @version 1.1.0
// @description Add example images and audio from ImmersionKit's dictionary pages to your latest Anki note via AnkiConnect.
// @icon https://vitejs.dev/logo.svg
// @match https://www.immersionkit.com/dictionary*
// @connect apiv2.immersionkit.com
// @connect us-southeast-1.linodeobjects.com
// @connect 127.0.0.1
// @connect localhost
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
ANKI_CONNECT_URL: "http://127.0.0.1:8765",
ANKI_CONNECT_KEY: null,
IMAGE_FIELD_NAME: "Picture",
AUDIO_FIELD_NAME: "SentenceAudio",
EXAMPLE_INDEX: 0,
CONFIRM_OVERWRITE: true
};
var _GM_addStyle = (() => typeof GM_addStyle != "undefined" ? GM_addStyle : void 0)();
var _GM_getValue = (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
var _GM_registerMenuCommand = (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)();
var _GM_setValue = (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
var _GM_xmlhttpRequest = (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
function fetchExamples(keyword) {
return new Promise((resolve, reject) => {
const url = `https://apiv2.immersionkit.com/search?q=${encodeURIComponent(keyword)}`;
_GM_xmlhttpRequest({
method: "GET",
url,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data && Array.isArray(data.examples) && data.examples.length > 0) {
resolve(data.examples);
} else {
reject(new Error("No examples returned from ImmersionKit API"));
}
} catch (e) {
reject(new Error("Failed to parse ImmersionKit API response" + e));
}
},
onerror: () => reject(new Error("Failed to request ImmersionKit API"))
});
});
}
function buildMediaTargets(example, mediaType) {
const prefix = "https://us-southeast-1.linodeobjects.com/immersionkit/media";
let category = "";
if (example.id && typeof example.id === "string") {
const parts = example.id.split("_");
if (parts.length > 0) category = parts[0];
}
function toTitleCaseWords(s) {
return s.split(/\s+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
let rawTitle = typeof example.title === "string" ? example.title : "";
rawTitle = rawTitle.replace(/_/g, " ").replace(/\s+/g, " ").trim();
const titleFolder = toTitleCaseWords(rawTitle);
const encTitleFolder = encodeURIComponent(titleFolder);
const filename = mediaType === "picture" ? example.image || "" : example.sound || "";
const encFilename = encodeURIComponent(filename);
const directUrl = `${prefix}/${category}/${encTitleFolder}/media/${encFilename}`;
const rawPath = `media/${category}/${titleFolder}/media/${filename}`;
const apiUrl = `https://apiv2.immersionkit.com/download_media?path=${encodeURIComponent(rawPath)}`;
return { directUrl, apiUrl, filename };
}
function invokeAnkiConnect(action, params = {}) {
const payload = { action, version: 6, params };
if (CONFIG.ANKI_CONNECT_KEY) payload.key = CONFIG.ANKI_CONNECT_KEY;
const endpoints = [CONFIG.ANKI_CONNECT_URL, "http://localhost:8765"];
return new Promise((resolve, reject) => {
let tried = 0;
function tryNext() {
if (tried >= endpoints.length) {
reject(new Error("Failed to connect to AnkiConnect. Is Anki running?"));
return;
}
const url = endpoints[tried++];
_GM_xmlhttpRequest({
method: "POST",
url,
data: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data && typeof data === "object" && "error" in data) {
const ac = data;
if (ac.error) reject(new Error(ac.error));
else resolve(ac.result);
} else if (data && typeof data === "object" && "result" in data) {
resolve(data.result);
} else {
resolve(data);
}
} catch (e) {
reject(new Error("Failed to parse AnkiConnect response" + e));
}
},
onerror: tryNext
});
}
tryNext();
});
}
async function getMostRecentNoteId() {
const recentCards = await invokeAnkiConnect("findCards", { query: "added:1" });
if (!recentCards || recentCards.length === 0) throw new Error("No cards added in the last 24 hours");
const mostRecentCard = Math.max(...recentCards);
const noteIds = await invokeAnkiConnect("cardsToNotes", { cards: [mostRecentCard] });
const noteId = Array.isArray(noteIds) ? noteIds[0] : noteIds;
if (!noteId) throw new Error("Could not resolve card to note");
return noteId;
}
async function getNoteInfo(noteId) {
const noteInfoList = await invokeAnkiConnect("notesInfo", { notes: [noteId] });
const noteInfo = Array.isArray(noteInfoList) ? noteInfoList[0] : noteInfoList;
return noteInfo || null;
}
async function ensureFieldOnNote(noteId, fieldName) {
const noteInfoList = await invokeAnkiConnect("notesInfo", { notes: [noteId] });
const noteInfo = Array.isArray(noteInfoList) ? noteInfoList[0] : noteInfoList;
if (!noteInfo || !noteInfo.fields || !(fieldName in noteInfo.fields)) {
throw new Error(`Field “${fieldName}” does not exist on the latest note`);
}
}
async function attachMedia(noteId, mediaType, media, fieldName) {
const mediaObject = { url: media.url, filename: media.filename, fields: [fieldName] };
const noteUpdate = { id: noteId, fields: {} };
if (mediaType === "picture") noteUpdate.picture = [mediaObject];
else noteUpdate.audio = [mediaObject];
await invokeAnkiConnect("updateNoteFields", { note: noteUpdate });
}
const modalCss = '.anki-feedback-pending {\r\n opacity: .7;\r\n pointer-events: none;\r\n}\r\n\r\n.anki-feedback-success {\r\n color: #0a8f08 !important;\r\n}\r\n\r\n.anki-feedback-error {\r\n color: #c62828 !important;\r\n}\r\n\r\n.anki-modal-overlay {\r\n position: fixed;\r\n inset: 0;\r\n background: rgba(0, 0, 0, .45);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n z-index: 99999;\r\n}\r\n\r\n.anki-modal {\r\n background: #fff;\r\n border-radius: 10px;\r\n box-shadow: 0 10px 30px rgba(0, 0, 0, .2);\r\n width: min(560px, 92vw);\r\n max-height: 90vh;\r\n display: flex;\r\n flex-direction: column;\r\n overflow: hidden;\r\n font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;\r\n}\r\n\r\n.anki-modal header {\r\n padding: 14px 18px;\r\n border-bottom: 1px solid #eee;\r\n font-weight: 600;\r\n font-size: 15px;\r\n}\r\n\r\n.anki-modal main {\r\n padding: 16px 18px;\r\n overflow: auto;\r\n font-size: 14px;\r\n line-height: 1.5;\r\n color: #333;\r\n}\r\n\r\n.anki-modal footer {\r\n padding: 12px 18px;\r\n border-top: 1px solid #eee;\r\n display: flex;\r\n gap: 10px;\r\n justify-content: flex-end;\r\n background: #fafafa;\r\n}\r\n\r\n.anki-btn {\r\n appearance: none;\r\n border: 1px solid #ccc;\r\n background: #fff;\r\n border-radius: 8px;\r\n padding: 6px 12px;\r\n cursor: pointer;\r\n font-size: 14px;\r\n}\r\n\r\n.anki-btn.primary {\r\n background: #2563eb;\r\n border-color: #2563eb;\r\n color: #fff;\r\n}\r\n\r\n.anki-btn.danger {\r\n background: #c62828;\r\n border-color: #c62828;\r\n color: #fff;\r\n}\r\n\r\n.anki-form {\r\n display: grid;\r\n grid-template-columns: 130px 1fr;\r\n gap: 10px 12px;\r\n align-items: center;\r\n}\r\n\r\n.anki-form label {\r\n font-weight: 600;\r\n color: #444;\r\n}\r\n\r\n.anki-form input[type="text"],\r\n.anki-form input[type="number"],\r\n.anki-form input[type="password"] {\r\n width: 100%;\r\n padding: 8px 10px;\r\n border: 1px solid #ddd;\r\n border-radius: 8px;\r\n font-size: 14px;\r\n}\r\n\r\n.anki-form .row-span-2 {\r\n grid-column: 1 / -1\r\n}\r\n\r\n.anki-kv {\r\n display: grid;\r\n grid-template-columns: 120px 1fr;\r\n gap: 8px 10px;\r\n margin-bottom: 8px\r\n}\r\n\r\n.anki-kv .key {\r\n color: #555;\r\n font-weight: 600\r\n}\r\n\r\n.anki-pre {\r\n background: #f7f7f9;\r\n border: 1px solid #eee;\r\n border-radius: 8px;\r\n padding: 10px;\r\n max-height: 180px;\r\n overflow: auto;\r\n white-space: pre-wrap;\r\n word-break: break-word;\r\n}';
function setButtonState(el, state, text) {
if (!el) return;
const node = el;
if (!node.dataset.ankiOriginalText) {
node.dataset.ankiOriginalText = node.textContent || "";
}
node.classList.remove("anki-feedback-pending", "anki-feedback-success", "anki-feedback-error");
if (state === "idle") {
node.textContent = node.dataset.ankiOriginalText;
if (node.tagName === "BUTTON") node.disabled = false;
return;
}
if (state === "pending") {
node.classList.add("anki-feedback-pending");
if (node.tagName === "BUTTON") node.disabled = true;
node.textContent = text || "添加中…";
return;
}
if (state === "success") {
node.classList.add("anki-feedback-success");
if (node.tagName === "BUTTON") node.disabled = false;
node.textContent = text || "已添加";
return;
}
if (state === "error") {
node.classList.add("anki-feedback-error");
if (node.tagName === "BUTTON") node.disabled = false;
node.textContent = text || "添加失败";
}
}
function revertButtonState(el) {
if (!el) return;
const node = el;
node.classList.remove("anki-feedback-pending", "anki-feedback-success", "anki-feedback-error");
if (node.tagName === "BUTTON") node.disabled = false;
node.textContent = node.dataset.ankiOriginalText || node.textContent;
}
function getSuccessText(mediaType) {
return mediaType === "picture" ? "图片添加成功" : "音频添加成功";
}
function injectStyles() {
const css = modalCss;
if (typeof _GM_addStyle === "function") {
_GM_addStyle(css);
} else {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
}
function showModal(opts) {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "anki-modal-overlay";
const modal = document.createElement("div");
modal.className = "anki-modal";
modal.innerHTML = `
<header>${opts.title || ""}</header>
<main>${opts.html || ""}</main>
<footer>
${opts.allowCancel === false ? "" : `<button class="anki-btn">${opts.cancelText || "取消"}</button>`}
<button class="anki-btn ${opts.danger ? "danger" : "primary"}">${opts.confirmText || "确定"}</button>
</footer>
`;
overlay.appendChild(modal);
function cleanup() {
overlay.remove();
}
function onResolve(val) {
cleanup();
resolve(val);
}
overlay.addEventListener("click", (e) => {
if (e.target === overlay && opts.allowCancel !== false) onResolve(false);
});
const footer = modal.querySelector("footer");
const buttons = Array.from(footer.querySelectorAll("button"));
const cancelBtn = buttons.length === 2 ? buttons[0] : null;
const confirmBtn = buttons[buttons.length - 1];
if (cancelBtn) cancelBtn.addEventListener("click", () => onResolve(false));
confirmBtn.addEventListener("click", async () => {
if (opts.onConfirm) {
try {
const ok = await opts.onConfirm(modal);
if (ok === false) return;
} catch {
}
}
onResolve(true);
});
document.body.appendChild(overlay);
});
}
async function addMediaToAnkiForIndex(mediaType, exampleIndex, triggerEl) {
const url = new URL(window.location.href);
const keywordParam = url.searchParams.get("keyword");
if (!keywordParam) {
if (triggerEl) {
setButtonState(triggerEl, "error", "未检测到关键词");
setTimeout(() => revertButtonState(triggerEl), 2e3);
}
console.warn("Cannot determine keyword from URL");
return;
}
const keyword = decodeURIComponent(keywordParam);
try {
if (triggerEl) setButtonState(triggerEl, "pending", "添加中…");
const examples = await fetchExamples(keyword);
let index = Number.isFinite(exampleIndex) ? exampleIndex : 0;
if (index < 0) index = 0;
if (index >= examples.length) index = examples.length - 1;
const example = examples[index];
if (!example) throw new Error("No example available");
if (mediaType === "picture" && !example.image) throw new Error("Example has no image");
if (mediaType === "audio" && !example.sound) throw new Error("Example has no audio");
const { apiUrl, filename } = buildMediaTargets(example, mediaType);
const fieldName = mediaType === "picture" ? CONFIG.IMAGE_FIELD_NAME : CONFIG.AUDIO_FIELD_NAME;
const noteId = await getMostRecentNoteId();
await ensureFieldOnNote(noteId, fieldName);
if (CONFIG.CONFIRM_OVERWRITE) {
const info = await getNoteInfo(noteId);
const model = info?.modelName || "";
const existing = info?.fields?.[fieldName]?.value || "";
const hasExisting = typeof existing === "string" && existing.trim().length > 0;
const html = `
<div class="anki-kv"><div class="key">Note ID</div><div>${noteId}</div></div>
<div class="anki-kv"><div class="key">Note Type</div><div>${model || "未知"}</div></div>
<div class="anki-kv"><div class="key">字段</div><div>${fieldName}</div></div>
${hasExisting ? `<div class="anki-kv row-span-2"><div class="key">原有内容</div><div><div class="anki-pre">${escapeHtml(existing)}</div></div></div>` : ""}
<div class="anki-kv"><div class="key">将添加</div><div>${filename}</div></div>
`;
const proceed = await showModal({
title: hasExisting ? "覆盖字段内容?" : "添加媒体",
html,
confirmText: hasExisting ? "覆盖并添加" : "添加",
danger: hasExisting
});
if (!proceed) {
if (triggerEl) revertButtonState(triggerEl);
return;
}
}
await attachMedia(noteId, mediaType, { url: apiUrl, filename }, fieldName);
if (triggerEl) {
setButtonState(triggerEl, "success", getSuccessText(mediaType));
setTimeout(() => revertButtonState(triggerEl), 2e3);
}
} catch (err) {
if (triggerEl) {
setButtonState(triggerEl, "error", "添加失败");
setTimeout(() => revertButtonState(triggerEl), 2500);
}
console.error("Failed to add " + mediaType, err);
}
}
function addMediaToAnki(mediaType, triggerEl) {
return addMediaToAnkiForIndex(mediaType, CONFIG.EXAMPLE_INDEX, triggerEl);
}
function insertAnkiButtons() {
let attempts = 0;
const maxAttempts = 40;
const interval = setInterval(() => {
attempts++;
const desktopMenus = Array.from(
document.querySelectorAll("span.mobile.or.lower.hidden .ui.secondary.menu")
);
const mobileMenus = Array.from(
document.querySelectorAll("span.mobile.only .ui.secondary.menu")
);
const menus = desktopMenus.length > 0 ? desktopMenus : mobileMenus;
if (menus.length > 0) {
let createAnkiMenuItem = function(label, key, index, onClickFn) {
const a = document.createElement("a");
a.className = "item";
a.href = "#";
a.dataset.anki = key;
a.dataset.ankiIndex = String(index);
a.textContent = label;
a.addEventListener("click", (e) => {
e.preventDefault();
onClickFn(e.currentTarget, index);
});
return a;
};
menus.forEach((menuEl, idx) => {
if (!menuEl.querySelector('a.item[data-anki="image"]')) {
const imgItem = createAnkiMenuItem(
"Anki Image",
"image",
idx,
(el, i) => addMediaToAnkiForIndex("picture", i, el)
);
menuEl.appendChild(imgItem);
}
if (!menuEl.querySelector('a.item[data-anki="audio"]')) {
const audioItem = createAnkiMenuItem(
"Anki Audio",
"audio",
idx,
(el, i) => addMediaToAnkiForIndex("audio", i, el)
);
menuEl.appendChild(audioItem);
}
});
clearInterval(interval);
return;
}
const allButtons = Array.from(document.querySelectorAll("button, a, span, div"));
const imageButton = allButtons.find((el) => el.textContent && el.textContent.trim() === "Image");
const soundButton = allButtons.find((el) => el.textContent && el.textContent.trim() === "Sound");
if (imageButton || soundButton) {
let createAnkiBtn = function(label, key, onClickFn) {
const btn = document.createElement("button");
btn.textContent = label;
btn.style.marginLeft = "6px";
btn.style.padding = "4px 8px";
btn.style.fontSize = "90%";
btn.style.cursor = "pointer";
btn.dataset.anki = key;
btn.addEventListener("click", (e) => onClickFn(e.currentTarget));
return btn;
};
clearInterval(interval);
if (imageButton) {
const ankiImgBtn = createAnkiBtn("Anki Image", "image", (el) => addMediaToAnki("picture", el));
imageButton.parentNode?.insertBefore(ankiImgBtn, imageButton.nextSibling);
}
if (soundButton) {
const ankiSoundBtn = createAnkiBtn("Anki Audio", "audio", (el) => addMediaToAnki("audio", el));
soundButton.parentNode?.insertBefore(ankiSoundBtn, soundButton.nextSibling);
}
} else if (attempts >= maxAttempts) {
clearInterval(interval);
console.warn("ImmersionKit → Anki: Could not find Image/Sound buttons");
}
}, 500);
}
let stylesInjected = false;
function init() {
if (!stylesInjected) {
injectStyles();
stylesInjected = true;
}
setTimeout(insertAnkiButtons, 1e3);
}
function isDictionaryPage(u) {
const url = new URL(window.location.href);
return url.pathname.startsWith("/dictionary");
}
let lastInitializedHref = null;
function maybeInitForDictionary() {
if (!isDictionaryPage()) return;
const href = window.location.href;
if (href === lastInitializedHref) return;
lastInitializedHref = href;
init();
}
function startUserscript() {
const onReady = () => {
maybeInitForDictionary();
let lastHref = window.location.href;
window.addEventListener("popstate", maybeInitForDictionary);
window.addEventListener("hashchange", maybeInitForDictionary);
setInterval(() => {
const current = window.location.href;
if (current !== lastHref) {
lastHref = current;
maybeInitForDictionary();
}
}, 400);
};
if (document.readyState === "complete") onReady();
else window.addEventListener("load", onReady);
}
function escapeHtml(s) {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
function getSettings() {
return {
ankiUrl: _GM_getValue?.("ankiUrl") || CONFIG.ANKI_CONNECT_URL,
ankiKey: _GM_getValue?.("ankiKey") || (CONFIG.ANKI_CONNECT_KEY || ""),
imageField: _GM_getValue?.("imageField") || CONFIG.IMAGE_FIELD_NAME,
audioField: _GM_getValue?.("audioField") || CONFIG.AUDIO_FIELD_NAME,
exampleIndex: Number(_GM_getValue?.("exampleIndex") ?? CONFIG.EXAMPLE_INDEX) || 0,
confirmOverwrite: Boolean(_GM_getValue?.("confirmOverwrite") ?? CONFIG.CONFIRM_OVERWRITE)
};
}
function saveSettings(s) {
_GM_setValue?.("ankiUrl", s.ankiUrl.trim());
_GM_setValue?.("ankiKey", s.ankiKey.trim());
_GM_setValue?.("imageField", s.imageField.trim());
_GM_setValue?.("audioField", s.audioField.trim());
_GM_setValue?.("exampleIndex", Number.isFinite(s.exampleIndex) ? s.exampleIndex : 0);
_GM_setValue?.("confirmOverwrite", !!s.confirmOverwrite);
CONFIG.ANKI_CONNECT_URL = s.ankiUrl.trim() || CONFIG.ANKI_CONNECT_URL;
CONFIG.ANKI_CONNECT_KEY = s.ankiKey.trim() || null;
CONFIG.IMAGE_FIELD_NAME = s.imageField.trim() || CONFIG.IMAGE_FIELD_NAME;
CONFIG.AUDIO_FIELD_NAME = s.audioField.trim() || CONFIG.AUDIO_FIELD_NAME;
CONFIG.EXAMPLE_INDEX = Number.isFinite(s.exampleIndex) ? s.exampleIndex : CONFIG.EXAMPLE_INDEX;
CONFIG.CONFIRM_OVERWRITE = !!s.confirmOverwrite;
}
function renderSettingsHtml(s) {
return `
<form class="anki-form">
<label>AnkiConnect URL</label>
<input type="text" name="ankiUrl" value="${escapeHtml(s.ankiUrl)}" placeholder="http://127.0.0.1:8765" />
<label>AnkiConnect Key</label>
<input type="password" name="ankiKey" value="${escapeHtml(s.ankiKey)}" placeholder="(可选)" />
<label>图片字段名</label>
<input type="text" name="imageField" value="${escapeHtml(s.imageField)}" />
<label>音频字段名</label>
<input type="text" name="audioField" value="${escapeHtml(s.audioField)}" />
<label>示例索引</label>
<input type="number" name="exampleIndex" value="${String(s.exampleIndex)}" min="0" />
<label>覆盖前确认</label>
<input type="checkbox" name="confirmOverwrite" ${s.confirmOverwrite ? "checked" : ""} />
</form>
`;
}
function registerMenu() {
if (typeof _GM_registerMenuCommand !== "function") return;
_GM_registerMenuCommand("设置(ImmersionKit → Anki)", async () => {
const s = getSettings();
const html = renderSettingsHtml(s);
await showModal({
title: "设置(ImmersionKit → Anki)",
html,
confirmText: "保存",
onConfirm: (root) => {
const form = root.querySelector("form");
if (!form) return true;
const next = {
ankiUrl: form.querySelector('[name="ankiUrl"]')?.value || s.ankiUrl,
ankiKey: form.querySelector('[name="ankiKey"]')?.value || "",
imageField: form.querySelector('[name="imageField"]')?.value || s.imageField,
audioField: form.querySelector('[name="audioField"]')?.value || s.audioField,
exampleIndex: Number(form.querySelector('[name="exampleIndex"]')?.value || s.exampleIndex) || 0,
confirmOverwrite: !!form.querySelector('[name="confirmOverwrite"]')?.checked
};
saveSettings(next);
return true;
}
});
});
}
(function() {
startUserscript();
registerMenu();
})();
})();