Text Sources

WebSocket

  1. Enable WebSocket support in your text extraction program
  2. Check the port where the WebSocket server is running
  3. Update the "WebSocket URL" field in the Kizuna settings to match this port
  4. The connection icon should now turn green and new lines will be tracked

Clipboard Inserter

  1. Enable your clipboard inserter extension in the texthooker page
  2. Text copied to your clipboard will be sent to the page

ッツ Reader Integration

  1. Install the Violentmonkey browser extension
  2. Add the following userscript:
// ==UserScript==
// @name         TTSU<->Kizuna
// @version      1.0
// @description  Stream visible text from TTSU to Kizuna using IntersectionObserver
// @match        https://reader.ttsu.app/*
// @match        https://kizuna-texthooker-ui.app/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    const SYNC_KEY = 'kizuna_bridge_universal';

    // --- HELPER: CLEAN TEXT ---
    function getCleanText(el) {
        if (!el) return "";
        const clone = el.cloneNode(true);
        // Remove rt tags (furigana) but keep the base text
        clone.querySelectorAll('rt').forEach(rt => rt.remove());
        // Remove rp tags (fallback parenthesis)
        clone.querySelectorAll('rp').forEach(rp => rp.remove());
        return clone.textContent.trim();
    }

    // --- INJECTOR FOR KIZUNA (RECEIVER) ---
    function injectKizunaReceiver() {
        const script = document.createElement('script');
        script.textContent = `
            window.addEventListener('message', (e) => {
                if (e.data && e.data.type === 'TTSU_TO_KIZUNA') {
                    window.dispatchEvent(new CustomEvent('kizuna:add-line', {
                        detail: { text: e.data.text }
                    }));
                }
            });
        `;
        (document.head || document.documentElement).appendChild(script);
    }

    // --- TTSU READER LOGIC (SENDER) ---
    if (window.location.hostname.includes('reader.ttsu.app')) {

        // A Set to remember what we have already sent to Kizuna so we don't duplicate
        const sentHistory = new Set();
        let transmissionBuffer = [];

        // A Set to keep track of what is currently on screen according to the browser
        const visibleElements = new Set();

        // 1. The Observer: The browser tells us what is on screen
        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    visibleElements.add(entry.target);
                } else {
                    visibleElements.delete(entry.target);
                }
            });
        }, {
            root: null, // viewport
            rootMargin: '0px',
            threshold: 0.5 // Element must be 50% visible to count (prevents partial lines at edges)
        });

        // 2. The Scanner: Finds new paragraphs and tells the Observer to watch them
        setInterval(() => {
            // Target the specific paragraphs based on your structure
            const paragraphs = document.querySelectorAll('.ttu-book-body-wrapper p');

            paragraphs.forEach(p => {
                // If we haven't started watching this specific <p> tag yet, watch it
                if (!p.getAttribute('data-bridge-observed')) {
                    p.setAttribute('data-bridge-observed', 'true');
                    observer.observe(p);
                }
            });
        }, 1000); // Check for new chapters/content every second

        // 3. The Sender: Periodically checks what is visible and sends it
        setInterval(() => {
            // We select again to ensure we process in DOM ORDER (Top to bottom)
            // This ensures text arrives in the correct reading order
            const allParagraphs = document.querySelectorAll('.ttu-book-body-wrapper p');

            allParagraphs.forEach(p => {
                // Logic:
                // 1. Is it currently flagged as visible by the Observer?
                // 2. Is there valid text?
                // 3. Have we sent this specific text string before?

                if (visibleElements.has(p)) {
                    const text = getCleanText(p);

                    if (text && text.length > 0 && !sentHistory.has(text)) {
                        sentHistory.add(text);
                        transmissionBuffer.push(text);
                        console.log("TTSU Bridge Queued:", text);
                    }
                }
            });

            // Send batch if data exists
            if (transmissionBuffer.length > 0) {
                GM_setValue(SYNC_KEY, { lines: [...transmissionBuffer], ts: Date.now() });
                transmissionBuffer = [];
            }
        }, 500); // Check for text to send every 500ms
    }

    // --- KIZUNA UI LOGIC (RECEIVER) ---
    if (window.location.hostname.includes('kizuna-texthooker-ui')) {
        injectKizunaReceiver();

        GM_addValueChangeListener(SYNC_KEY, (name, old, newVal, remote) => {
            if (remote && newVal && newVal.lines) {
                newVal.lines.forEach(text => {
                    window.postMessage({ type: 'TTSU_TO_KIZUNA', text: text }, "*");
                });
            }
        });
    }
})();
  1. Open both the ttsu reader and Kizuna texthooker page in separate tabs
  2. Increase Kizuna auto-pause timeout to 300 seconds in the settings
  3. Read your book as usual. The text will automatically be tracked in Kizuna

Useful Scripts

Automatic Visual Novel Screenshots for Anki

Automatically add visual novel screenshots to your Anki cards:

  1. Install the Violentmonkey browser extension
  2. Add the following userscript:
// ==UserScript==
// @name         Anki Auto Screenshot Capture
// @version      1.0
// @description  Automatically capture window screenshots when new Anki cards are created
// @match        https://kizuna-texthooker-ui.app/*
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @connect      localhost
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const ANKI_URL = "http://localhost:8765";
    const PICTURE_FIELD = "Picture";
    const POLL_INTERVAL = 1000; // Check every 1 second

    // Crop settings to remove window chrome (title bar, borders)
    const CROP_SETTINGS = {
        top: 32,      // Title bar height
        bottom: 8,    // Bottom border
        left: 8,      // Left border
        right: 8      // Right border
    };

    let currentNoteIds = new Set();
    let firstRun = true;
    let isCapturing = false;
    let captureStream = null;  // Keep the stream alive
    let captureVideo = null;   // Keep the video element
    let lastWindowDimensions = null;  // Track last captured window size

    // Start polling for new Anki cards
    startPolling();

    async function startPolling() {
        console.log('Started polling Anki for new cards...');

        while (true) {
            await checkForNewCards();
            await sleep(POLL_INTERVAL);
        }
    }

    async function checkForNewCards() {
        try {
            const updatedNoteIds = await getNoteIds();
            const newCardIds = difference(updatedNoteIds, currentNoteIds);

            if (newCardIds.size > 0 && !firstRun) {
                console.log('Detected new card in Anki! Capturing screenshot...');

                if (!isCapturing) {
                    isCapturing = true;
                    await captureAndAddToAnki();
                    isCapturing = false;
                }
            }

            firstRun = false;
            currentNoteIds = updatedNoteIds;

        } catch (error) {
            console.error('Error checking for new cards:', error);
        }
    }

    async function getNoteIds() {
        const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
        return new Set(noteIds);
    }

    function difference(setA, setB) {
        const diff = new Set();
        for (const elem of setA) {
            if (!setB.has(elem)) {
                diff.add(elem);
            }
        }
        return diff;
    }

    async function captureAndAddToAnki() {
        try {
            // Small delay to ensure card is fully created
            await sleep(500);

            // Capture the screenshot
            const webpDataUrl = await captureWindowScreenshot();

            // Get the latest Anki card
            const lastNote = await getLastAnkiCard();

            if (!lastNote || !lastNote.noteId) {
                showNotification('No Anki cards found for today', 'error');
                return;
            }

            // Check if card already has an image
            if (lastNote.fields[PICTURE_FIELD] && lastNote.fields[PICTURE_FIELD].value) {
                console.log('Latest card already has an image, skipping');
                return;
            }

            // Convert data URL to base64
            const base64Data = webpDataUrl.split(',')[1];

            // Generate filename
            const filename = `screenshot_${Date.now()}.webp`;

            // Store the image in Anki
            const storedFilename = await storeMediaFile(filename, base64Data);

            // Update the card with the image
            await updateNoteWithImage(lastNote.noteId, storedFilename);

            showNotification('✓ Screenshot added to Anki card!', 'success');
            console.log(`Updated Anki card ${lastNote.noteId} with screenshot`);

        } catch (error) {
            console.error('Error:', error);
            showNotification('Failed: ' + error.message, 'error');
        }
    }

    async function initializeCaptureStream() {
        try {
            // Request screen capture - user will be prompted ONCE to select window
            captureStream = await navigator.mediaDevices.getDisplayMedia({
                video: {
                    mediaSource: 'window'
                },
                audio: false
            });

            captureVideo = document.createElement('video');
            captureVideo.srcObject = captureStream;
            captureVideo.autoplay = true;

            await new Promise((resolve) => {
                captureVideo.onloadedmetadata = resolve;
            });

            console.log('Capture stream initialized and ready');

            // Show crop adjustment dialog
            const userCropSettings = await showCropAdjustmentDialog(captureVideo);

            // Update crop settings with user values
            CROP_SETTINGS.left = userCropSettings.left;
            CROP_SETTINGS.right = userCropSettings.right;
            CROP_SETTINGS.top = userCropSettings.top;
            CROP_SETTINGS.bottom = userCropSettings.bottom;

            // Store initial window dimensions
            lastWindowDimensions = {
                width: captureVideo.videoWidth,
                height: captureVideo.videoHeight
            };

            console.log('Crop settings updated:', CROP_SETTINGS);

            // Handle stream ending (e.g., user stops sharing)
            captureStream.getTracks()[0].addEventListener('ended', () => {
                console.log('Capture stream ended');
                captureStream = null;
                captureVideo = null;
                lastWindowDimensions = null;
            });

        } catch (error) {
            throw new Error('Screenshot capture cancelled or failed: ' + error.message);
        }
    }

    async function showCropAdjustmentDialog(video) {
        return new Promise(async (resolve) => {
            // Load saved crop settings from GM storage
            let savedSettings = null;
            try {
                const saved = await GM.getValue('ankiAutoCropSettings', null);
                if (saved) {
                    savedSettings = JSON.parse(saved);
                }
            } catch (e) {
                console.warn('Failed to load saved crop settings:', e);
            }

            // Capture current frame for preview
            const sourceCanvas = document.createElement('canvas');
            sourceCanvas.width = video.videoWidth;
            sourceCanvas.height = video.videoHeight;
            const sourceCtx = sourceCanvas.getContext('2d');
            sourceCtx.drawImage(video, 0, 0);

            // Create preview canvas for cropped result
            const previewCanvas = document.createElement('canvas');
            const previewCtx = previewCanvas.getContext('2d');

            // Create modal overlay
            const overlay = document.createElement('div');
            overlay.style.cssText = `
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background: rgba(0, 0, 0, 0.9);
                z-index: 999999;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                padding: 20px;
                box-sizing: border-box;
            `;

            // Create container
            const container = document.createElement('div');
            container.style.cssText = `
                background: #2b2b2b;
                border-radius: 12px;
                padding: 20px;
                max-width: 90vw;
                max-height: 90vh;
                display: flex;
                flex-direction: column;
                gap: 15px;
                box-sizing: border-box;
                overflow: hidden;
            `;

            // Title
            const title = document.createElement('h2');
            title.textContent = 'Adjust Screenshot Crop Area';
            title.style.cssText = `
                margin: 0;
                color: white;
                font-family: Arial, sans-serif;
                font-size: 20px;
                text-align: center;
                flex-shrink: 0;
            `;

            // Preview container with canvas
            const previewContainer = document.createElement('div');
            previewContainer.style.cssText = `
                position: relative;
                display: flex;
                align-items: center;
                justify-content: center;
                width: 100%;
                flex: 1;
                min-height: 0;
                overflow: hidden;
            `;

            // Style the preview canvas
            previewCanvas.style.cssText = `
                display: block;
                max-width: 100%;
                max-height: 100%;
                width: auto;
                height: auto;
                object-fit: contain;
                border: 1px solid #555;
            `;

            // Controls container
            const controlsContainer = document.createElement('div');
            controlsContainer.style.cssText = `
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 15px;
                color: white;
                font-family: Arial, sans-serif;
                font-size: 14px;
                flex-shrink: 0;
            `;

            // Current crop values - use saved settings if available
            let cropValues = savedSettings ? {
                left: savedSettings.left,
                right: savedSettings.right,
                top: savedSettings.top,
                bottom: savedSettings.bottom
            } : {
                left: CROP_SETTINGS.left,
                right: CROP_SETTINGS.right,
                top: CROP_SETTINGS.top,
                bottom: CROP_SETTINGS.bottom
            };

            // Function to update cropped preview
            function updateCroppedPreview() {
                // Calculate cropped dimensions
                const croppedWidth = sourceCanvas.width - cropValues.left - cropValues.right;
                const croppedHeight = sourceCanvas.height - cropValues.top - cropValues.bottom;

                // Resize preview canvas to match cropped dimensions
                previewCanvas.width = croppedWidth;
                previewCanvas.height = croppedHeight;

                // Draw the cropped portion from source canvas
                previewCtx.drawImage(
                    sourceCanvas,
                    cropValues.left,      // Source X
                    cropValues.top,       // Source Y
                    croppedWidth,         // Source width
                    croppedHeight,        // Source height
                    0,                    // Dest X
                    0,                    // Dest Y
                    croppedWidth,         // Dest width
                    croppedHeight         // Dest height
                );
            }

            // Create slider control
            function createSlider(label, initialValue, max, onChange) {
                const sliderContainer = document.createElement('div');
                sliderContainer.style.cssText = `
                    display: flex;
                    flex-direction: column;
                    gap: 8px;
                `;

                const labelContainer = document.createElement('div');
                labelContainer.style.cssText = `
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                `;

                const labelElement = document.createElement('label');
                labelElement.textContent = label;
                labelElement.style.fontWeight = 'bold';

                const valueDisplay = document.createElement('span');
                valueDisplay.textContent = initialValue + 'px';
                valueDisplay.style.cssText = `
                    color: #4CAF50;
                    font-family: monospace;
                `;

                const slider = document.createElement('input');
                slider.type = 'range';
                slider.min = '0';
                slider.max = max;
                slider.value = initialValue;
                slider.style.cssText = `
                    width: 100%;
                    cursor: pointer;
                `;

                const updateValue = (value) => {
                    // Clamp value between 0 and max
                    value = Math.max(0, Math.min(max, value));
                    slider.value = value;
                    valueDisplay.textContent = value + 'px';
                    onChange(value);
                    updateCroppedPreview();
                };

                slider.addEventListener('input', (e) => {
                    const value = parseInt(e.target.value);
                    valueDisplay.textContent = value + 'px';
                    onChange(value);
                    updateCroppedPreview();
                });

                // Button container
                const buttonContainer = document.createElement('div');
                buttonContainer.style.cssText = `
                    display: flex;
                    gap: 5px;
                    justify-content: center;
                `;

                // Decrement button
                const decrementButton = document.createElement('button');
                decrementButton.textContent = '−';
                decrementButton.style.cssText = `
                    padding: 6px 12px;
                    background: #444;
                    color: white;
                    border: none;
                    border-radius: 4px;
                    font-size: 18px;
                    font-weight: bold;
                    cursor: pointer;
                    font-family: Arial, sans-serif;
                    min-width: 40px;
                `;
                decrementButton.addEventListener('mouseover', () => {
                    decrementButton.style.background = '#555';
                });
                decrementButton.addEventListener('mouseout', () => {
                    decrementButton.style.background = '#444';
                });
                decrementButton.addEventListener('click', () => {
                    updateValue(parseInt(slider.value) - 1);
                });

                // Increment button
                const incrementButton = document.createElement('button');
                incrementButton.textContent = '+';
                incrementButton.style.cssText = `
                    padding: 6px 12px;
                    background: #444;
                    color: white;
                    border: none;
                    border-radius: 4px;
                    font-size: 18px;
                    font-weight: bold;
                    cursor: pointer;
                    font-family: Arial, sans-serif;
                    min-width: 40px;
                `;
                incrementButton.addEventListener('mouseover', () => {
                    incrementButton.style.background = '#555';
                });
                incrementButton.addEventListener('mouseout', () => {
                    incrementButton.style.background = '#444';
                });
                incrementButton.addEventListener('click', () => {
                    updateValue(parseInt(slider.value) + 1);
                });

                buttonContainer.appendChild(decrementButton);
                buttonContainer.appendChild(incrementButton);

                labelContainer.appendChild(labelElement);
                labelContainer.appendChild(valueDisplay);
                sliderContainer.appendChild(labelContainer);
                sliderContainer.appendChild(slider);
                sliderContainer.appendChild(buttonContainer);

                return sliderContainer;
            }

            // Create sliders
            const leftSlider = createSlider('Left', cropValues.left, Math.floor(video.videoWidth / 2), (value) => {
                cropValues.left = value;
            });

            const rightSlider = createSlider('Right', cropValues.right, Math.floor(video.videoWidth / 2), (value) => {
                cropValues.right = value;
            });

            const topSlider = createSlider('Top', cropValues.top, Math.floor(video.videoHeight / 2), (value) => {
                cropValues.top = value;
            });

            const bottomSlider = createSlider('Bottom', cropValues.bottom, Math.floor(video.videoHeight / 2), (value) => {
                cropValues.bottom = value;
            });

            // Add sliders to controls
            controlsContainer.appendChild(leftSlider);
            controlsContainer.appendChild(rightSlider);
            controlsContainer.appendChild(topSlider);
            controlsContainer.appendChild(bottomSlider);

            // Button container
            const buttonContainer = document.createElement('div');
            buttonContainer.style.cssText = `
                display: flex;
                gap: 10px;
                justify-content: center;
                flex-shrink: 0;
            `;

            // Confirm button
            const confirmButton = document.createElement('button');
            confirmButton.textContent = 'Confirm Crop Settings';
            confirmButton.style.cssText = `
                padding: 12px 24px;
                background: #4CAF50;
                color: white;
                border: none;
                border-radius: 6px;
                font-size: 16px;
                font-weight: bold;
                cursor: pointer;
                font-family: Arial, sans-serif;
            `;
            confirmButton.addEventListener('mouseover', () => {
                confirmButton.style.background = '#45a049';
            });
            confirmButton.addEventListener('mouseout', () => {
                confirmButton.style.background = '#4CAF50';
            });
            confirmButton.addEventListener('click', async () => {
                // Save crop settings to GM storage
                try {
                    await GM.setValue('ankiAutoCropSettings', JSON.stringify(cropValues));
                    console.log('Crop settings saved to GM storage:', cropValues);
                } catch (e) {
                    console.warn('Failed to save crop settings to GM storage:', e);
                }
                overlay.remove();
                resolve(cropValues);
            });

            // Reset button
            const resetButton = document.createElement('button');
            resetButton.textContent = 'Reset to Default';
            resetButton.style.cssText = `
                padding: 12px 24px;
                background: #666;
                color: white;
                border: none;
                border-radius: 6px;
                font-size: 16px;
                cursor: pointer;
                font-family: Arial, sans-serif;
            `;
            resetButton.addEventListener('mouseover', () => {
                resetButton.style.background = '#555';
            });
            resetButton.addEventListener('mouseout', () => {
                resetButton.style.background = '#666';
            });
            resetButton.addEventListener('click', () => {
                cropValues = { left: 32, right: 8, top: 32, bottom: 8 };
                // Update all sliders
                leftSlider.querySelector('input').value = cropValues.left;
                leftSlider.querySelector('span').textContent = cropValues.left + 'px';
                rightSlider.querySelector('input').value = cropValues.right;
                rightSlider.querySelector('span').textContent = cropValues.right + 'px';
                topSlider.querySelector('input').value = cropValues.top;
                topSlider.querySelector('span').textContent = cropValues.top + 'px';
                bottomSlider.querySelector('input').value = cropValues.bottom;
                bottomSlider.querySelector('span').textContent = cropValues.bottom + 'px';
                updateCroppedPreview();
            });

            // Assemble the dialog
            previewContainer.appendChild(previewCanvas);
            buttonContainer.appendChild(confirmButton);
            buttonContainer.appendChild(resetButton);
            container.appendChild(title);
            container.appendChild(previewContainer);
            container.appendChild(controlsContainer);
            container.appendChild(buttonContainer);
            overlay.appendChild(container);
            document.body.appendChild(overlay);

            // Initial preview update
            updateCroppedPreview();
        });
    }

    async function captureWindowScreenshot() {
        try {
            // Initialize stream on first capture
            if (!captureStream || !captureVideo) {
                await initializeCaptureStream();
            }

            // Check if window dimensions have changed
            const currentDimensions = {
                width: captureVideo.videoWidth,
                height: captureVideo.videoHeight
            };

            if (lastWindowDimensions &&
                (lastWindowDimensions.width !== currentDimensions.width ||
                 lastWindowDimensions.height !== currentDimensions.height)) {
                console.log('Window size changed, showing crop adjustment dialog');
                console.log('Previous:', lastWindowDimensions, 'Current:', currentDimensions);

                // Show crop adjustment dialog again with new dimensions
                const userCropSettings = await showCropAdjustmentDialog(captureVideo);

                // Update crop settings with user values
                CROP_SETTINGS.left = userCropSettings.left;
                CROP_SETTINGS.right = userCropSettings.right;
                CROP_SETTINGS.top = userCropSettings.top;
                CROP_SETTINGS.bottom = userCropSettings.bottom;

                console.log('Crop settings updated:', CROP_SETTINGS);
            }

            // Store current dimensions for next comparison
            lastWindowDimensions = currentDimensions;

            // Create canvas and capture current frame
            const canvas = document.createElement('canvas');
            canvas.width = captureVideo.videoWidth;
            canvas.height = captureVideo.videoHeight;

            const ctx = canvas.getContext('2d');
            ctx.drawImage(captureVideo, 0, 0);

            // Create a new canvas for the cropped version
            const croppedCanvas = document.createElement('canvas');
            const croppedWidth = canvas.width - CROP_SETTINGS.left - CROP_SETTINGS.right;
            const croppedHeight = canvas.height - CROP_SETTINGS.top - CROP_SETTINGS.bottom;

            croppedCanvas.width = croppedWidth;
            croppedCanvas.height = croppedHeight;

            const croppedCtx = croppedCanvas.getContext('2d');

            // Draw the cropped portion (removes window chrome)
            croppedCtx.drawImage(
                canvas,
                CROP_SETTINGS.left,           // Source X
                CROP_SETTINGS.top,            // Source Y
                croppedWidth,                 // Source width
                croppedHeight,                // Source height
                0,                            // Dest X
                0,                            // Dest Y
                croppedWidth,                 // Dest width
                croppedHeight                 // Dest height
            );

            // Convert to WebP with quality setting (0.9 = 90% quality)
            const webpDataUrl = croppedCanvas.toDataURL('image/png', 0.9);

            return webpDataUrl;

        } catch (error) {
            throw new Error('Screenshot capture cancelled or failed: ' + error.message);
        }
    }

    async function getLastAnkiCard() {
        const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });

        if (!noteIds || noteIds.length === 0) {
            return null;
        }

        const lastNoteId = noteIds[noteIds.length - 1];
        const notesInfo = await ankiInvoke('notesInfo', { notes: [lastNoteId] });

        return notesInfo[0];
    }

    async function storeMediaFile(filename, base64Data) {
        return await ankiInvoke('storeMediaFile', {
            filename: filename,
            data: base64Data
        });
    }

    async function updateNoteWithImage(noteId, filename) {
        const imageHtml = `<img src="${filename}">`;

        await ankiInvoke('updateNoteFields', {
            note: {
                id: noteId,
                fields: {
                    [PICTURE_FIELD]: imageHtml
                }
            }
        });
    }

    function ankiInvoke(action, params = {}) {
        return new Promise((resolve, reject) => {
            const payload = JSON.stringify({
                action: action,
                version: 6,
                params: params
            });

            GM_xmlhttpRequest({
                method: 'POST',
                url: ANKI_URL,
                headers: {
                    'Content-Type': 'application/json'
                },
                data: payload,
                onload: function(response) {
                    try {
                        const result = JSON.parse(response.responseText);

                        if (result.error) {
                            reject(new Error(result.error));
                        } else {
                            resolve(result.result);
                        }
                    } catch (e) {
                        reject(new Error('Failed to parse Anki response'));
                    }
                },
                onerror: function(error) {
                    reject(new Error('Failed to connect to Anki'));
                }
            });
        });
    }

    function showNotification(message, type = 'info') {
        const notification = document.createElement('div');

        const colors = {
            success: '#4CAF50',
            error: '#f44336',
            warning: '#ff9800',
            info: '#2196F3'
        };

        notification.textContent = message;
        notification.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            background: ${colors[type]};
            color: white;
            padding: 15px 20px;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0,0,0,0.3);
            z-index: 10001;
            font-family: Arial, sans-serif;
            font-size: 14px;
            font-weight: 500;
            max-width: 300px;
            animation: slideIn 0.3s ease-out;
        `;

        const style = document.createElement('style');
        style.textContent = `
            @keyframes slideIn {
                from {
                    transform: translateX(400px);
                    opacity: 0;
                }
                to {
                    transform: translateX(0);
                    opacity: 1;
                }
            }
        `;
        if (!document.querySelector('style[data-anki-notif]')) {
            style.setAttribute('data-anki-notif', 'true');
            document.head.appendChild(style);
        }

        document.body.appendChild(notification);

        setTimeout(() => {
            notification.style.transition = 'opacity 0.3s ease-out';
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 300);
        }, 3000);
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    console.log('Anki Auto Screenshot Capture loaded. Monitoring for new cards...');
})();
  1. Edit the field names in the script if necessary to match your Anki card template
  2. When mining a new word, the Kizuna tab will ask you to select a window
  3. Select your game window
  4. Adjust the cropping dimensions to match your game window area
  5. Screenshots will now automatically be added to newly mined cards