Amazon Video ASIN Display

Show unique ASINs for episodes and movies/seasons on Amazon Prime Video

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Amazon Video ASIN Display
// @namespace    [email protected]
// @version      0.4.0
// @description  Show unique ASINs for episodes and movies/seasons on Amazon Prime Video
// @author       ReiDoBrega
// @license      MIT
// @match        https://www.amazon.com/*
// @match        https://www.amazon.co.uk/*
// @match        https://www.amazon.de/*
// @match        https://www.amazon.co.jp/*
// @match        https://www.primevideo.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
    "use strict";

    // Add styles for ASIN display and pop-up
    let style = document.createElement("style");
    style.textContent = `
        // Modify your style.textContent by adding this rule:
        .x-asin-container ._3ra7oO {
            font-size: 0.2em;
            opacity: 0.75;
            margin-top: 2px;
        }
        .x-asin-item, .x-episode-asin {
            color: #1399FF; /* Blue color */
            cursor: pointer;
            margin: 5px 0;
        }
        .x-copy-popup {
            position: fixed;
            bottom: 20px;
            right: 20px;
            background-color: rgba(0, 0, 0, 0); /* Transparent background */
            color: #1399FF; /* Blue text */
            padding: 10px 20px;
            border-radius: 5px;
            font-family: Arial, sans-serif;
            font-size: 14px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0);
            z-index: 1000;
            animation: fadeInOut 2.5s ease-in-out;
        }
        @keyframes fadeOut {
            0% { opacity: 1; }
            100% { opacity: 0; }
        }
        .x-asin-display {
            font-size: 5px; /* Absolute size in pixels */
            opacity: 0.7;
            margin-top: 12px;
            cursor: pointer;
        }
    `;
    document.head.appendChild(style);

    // Store for captured episode data
    let capturedEpisodeData = [];

    // Flag to indicate if we've already processed episodes from API
    let episodesProcessed = false;

    // Function to extract ASIN from URL
    function extractASINFromURL() {
        const url = window.location.href;
        const asinRegex = /\/gp\/video\/detail\/([A-Z0-9]{10})/;
        const match = url.match(asinRegex);
        return match ? match[1] : null;
    }

    // Function to find and display unique ASINs
    function findUniqueASINs() {
        // Extract ASIN from URL first
        const urlASIN = extractASINFromURL();
        if (urlASIN) {
            return { urlASIN };
        }

        // Object to store one unique ASIN/ID for each type
        let uniqueIds = {};

        // List of ID patterns to find
        const idPatterns = [
            {
                name: 'titleID',
                regex: /"titleID":"([^"]+)"/
            },
            // {
            //     name: 'pageTypeId',
            //     regex: /pageTypeId: "([^"]+)"/
            // },
            // {
            //     name: 'pageTitleId',
            //     regex: /"pageTitleId":"([^"]+)"/
            // },
            // {
            //     name: 'catalogId',
            //     regex: /catalogId":"([^"]+)"/
            // }
        ];

        // Search through patterns
        idPatterns.forEach(pattern => {
            let match = document.body.innerHTML.match(pattern.regex);
            if (match && match[1]) {
                uniqueIds[pattern.name] = match[1];
            }
        });

        return uniqueIds;
    }

    // Function to find ASINs from JSON response
    function findUniqueASINsFromJSON(jsonData) {
        let uniqueIds = {};

        // Comprehensive search paths for ASINs
        const searchPaths = [
            { name: 'titleId', paths: [
                ['titleID'],
                ['page', 0, 'assembly', 'body', 0, 'args', 'titleID'],
                ['titleId'],
                ['detail', 'titleId'],
                ['data', 'titleId']
            ]},
        ];

        // Deep object traversal function
        function traverseObject(obj, paths) {
            for (let pathSet of paths) {
                try {
                    let value = obj;
                    for (let key of pathSet) {
                        value = value[key];
                        if (value === undefined) break;
                    }

                    if (value && typeof value === 'string' && value.trim() !== '') {
                        return value;
                    }
                } catch (e) {
                    // Silently ignore traversal errors
                }
            }
            return null;
        }

        // Search through all possible paths
        searchPaths.forEach(({ name, paths }) => {
            const value = traverseObject(jsonData, paths);
            if (value) {
                uniqueIds[name] = value;
                console.log(`[ASIN Display] Found ${name} in JSON: ${value}`);
            }
        });

        return uniqueIds;
    }

    // Function to extract episodes from JSON data
    function extractEpisodes(jsonData) {
        try {
            // Possible paths to episode data
            const episodePaths = [
                ['items'],
                ['widgets', 0, 'data', 'items'],
                ['widgets', 0, 'items'],
                ['data', 'widgets', 0, 'items'],
                ['page', 0, 'assembly', 'body', 0, 'items'],
                ['data', 'items']
            ];

            // Try each path
            for (const path of episodePaths) {
                let current = jsonData;
                let valid = true;

                // Navigate through the path
                for (const key of path) {
                    if (current && current[key] !== undefined) {
                        current = current[key];
                    } else {
                        valid = false;
                        break;
                    }
                }

                // If we found a valid path and it's an array of items
                if (valid && Array.isArray(current)) {
                    return current.filter(item =>
                        item &&
                        (item.titleId || item.id || item.episodeID || item.asin)
                    );
                }
            }
        } catch (e) {
            console.error("Error extracting episodes:", e);
        }
        return [];
    }

    // Function to add episode ASINs from captured API data
    function addAPIEpisodeASINs() {
        try {
            if (capturedEpisodeData.length === 0 || episodesProcessed) {
                return false;
            }

            console.log(`[ASIN Display] Processing ${capturedEpisodeData.length} captured episodes`);

            // Process and display episode ASINs
            capturedEpisodeData.forEach(episode => {
                const episodeId = episode.titleId || episode.id || episode.episodeID || episode.asin;
                const episodeNumber = episode.episodeNumber || episode.number;
                const seasonNumber = episode.seasonNumber;

                if (!episodeId) return;

                // Find episode element to attach ASIN to
                const selector = `[data-automation-id="ep-${episodeNumber}"], [id^="selector-${episodeId}"], [id^="av-episode-expand-toggle-${episodeId}"]`;
                let episodeElement = document.querySelector(selector);

                // If can't find by direct ID, try to find by episode number
                if (!episodeElement && episodeNumber) {
                    episodeElement = document.querySelector(`[data-automation-id*="ep-${episodeNumber}"]`);

                    // Try alternative approaches for finding episode elements
                    if (!episodeElement) {
                        // This will try to match elements that might contain the episode number visually
                        const possibleElements = [...document.querySelectorAll('[data-automation-id*="ep-"]')];
                        episodeElement = possibleElements.find(el => {
                            const text = el.textContent.trim();
                            return text.includes(`Episode ${episodeNumber}`) ||
                                  text.includes(`Ep. ${episodeNumber}`) ||
                                  text.match(new RegExp(`\\b${episodeNumber}\\b`));
                        });
                    }
                }

                // If we found an element to attach to
                if (episodeElement) {
                    // Skip if ASIN already added
                    if (episodeElement.parentNode.querySelector("._3ra7oO")) {
                        return;
                    }

                    // Create ASIN element
                    let asinEl = document.createElement("div");
                    asinEl.className = "_3ra7oO x-asin-display"; // Add your custom class alongside the original
                    asinEl.textContent = asin;
                    asinEl.addEventListener("click", () => copyToClipboard(asin));

                    // Insert ASIN element after the episode title
                    let epTitle = episodeElement.parentNode.querySelector("[data-automation-id^='ep-title']");
                    if (epTitle) {
                        epTitle.parentNode.insertBefore(asinEl, epTitle.nextSibling);
                    } else {
                        // If can't find specific title element, just append to the episode element's parent
                        episodeElement.parentNode.appendChild(asinEl);
                    }
                } else {
                    console.log(`[ASIN Display] Could not find element for episode ${episodeNumber} with ID ${episodeId}`);
                }
            });

            // Mark as processed to avoid duplicate processing
            episodesProcessed = true;

            return true; // API episode ASINs added successfully
        } catch (e) {
            console.error("ASIN Display - Error in addAPIEpisodeASINs:", e);
            return false; // Error occurred
        }
    }

    // Function to add episode ASINs using DOM
    function addEpisodeASINs() {
        try {
            document.querySelectorAll("[id^='selector-'], [id^='av-episode-expand-toggle-']").forEach(el => {
                // Skip if ASIN already added
                if (el.parentNode.querySelector("._3ra7oO")) {
                    return;
                }

                // Extract ASIN from the element ID
                let asin = el.id.replace(/^(?:selector|av-episode-expand-toggle)-/, "");

                // Create ASIN element
                let asinEl = document.createElement("div");
                asinEl.className = "_3ra7oO x-asin-display"; // Add your custom class alongside the original
                asinEl.textContent = asin;
                asinEl.addEventListener("click", () => copyToClipboard(asin));

                // Insert ASIN element after the episode title
                let epTitle = el.parentNode.querySelector("[data-automation-id^='ep-title']");
                if (epTitle) {
                    epTitle.parentNode.insertBefore(asinEl, epTitle.nextSibling);
                }
            });
            return true; // Episode ASINs added successfully
        } catch (e) {
            console.error("ASIN Display - Error in addEpisodeASINs:", e);
            return false; // Error occurred
        }
    }

    // Function to add ASIN display
    function addASINDisplay(uniqueIds = null) {
        try {
            // If no IDs provided, find them from HTML
            if (!uniqueIds) {
                uniqueIds = findUniqueASINs();
            }

            // Remove existing ASIN containers
            document.querySelectorAll(".x-asin-container").forEach(el => el.remove());

            // If no IDs found, return
            if (Object.keys(uniqueIds).length === 0) {
                console.log("ASIN Display: No ASINs found");
                return false;
            }

            // Create ASIN container
            let asinContainer = document.createElement("div");
            asinContainer.className = "x-asin-container";

            // Add each unique ID as a clickable element
            Object.entries(uniqueIds).forEach(([type, id]) => {
                let asinEl = document.createElement("div");
                asinEl.className = "_1jWggM v2uvTa fbl-btn _2Pw7le";
                asinEl.textContent = id;
                asinEl.addEventListener("click", () => copyToClipboard(id));
                asinContainer.appendChild(asinEl);
            });

            // Insert the ASIN container after the synopsis
            let after = document.querySelector(".dv-dp-node-synopsis, .av-synopsis");
            if (!after) {
                console.log("ASIN Display: Could not find element to insert after");
                return false;
            }

            after.parentNode.insertBefore(asinContainer, after.nextSibling);
            return true;
        } catch (e) {
            console.error("ASIN Display - Error in addASINDisplay:", e);
            return false;
        }
    }

    // Function to copy text to clipboard and show pop-up
    function copyToClipboard(text) {
        const input = document.createElement("textarea");
        input.value = text;
        document.body.appendChild(input);
        input.select();
        document.execCommand("copy");
        document.body.removeChild(input);

        // Show pop-up
        const popup = document.createElement("div");
        popup.className = "x-copy-popup";
        popup.textContent = `Copied: ${text}`;
        document.body.appendChild(popup);

        // Remove pop-up after 1.5 seconds
        setTimeout(() => {
            popup.remove();
        }, 1500);
    }

    // Intercept fetch requests for JSON responses
    const originalFetch = window.fetch;
    window.fetch = function(...args) {
        const [url] = args;
        const isString = typeof url === 'string';

        // Create a promise for the original fetch
        const fetchPromise = originalFetch.apply(this, args);

        // Check if this is a URL we're interested in
        if (isString &&
           ((url.includes('/detail/') && url.includes('primevideo.com')) ||
            (url.includes('api/getDetailWidgets')))) {

            // Process the response without blocking the original fetch
            fetchPromise.then(async response => {
                try {
                    // Only process JSON responses
                    const contentType = response.headers.get('content-type');
                    if (contentType?.includes('application/json')) {
                        // Clone the response to avoid consuming it
                        const clonedResponse = response.clone();
                        const jsonResponse = await clonedResponse.json();

                        // Find unique IDs from the response
                        const jsonIds = findUniqueASINsFromJSON(jsonResponse);

                        // For Detail API calls, extract episodes
                        if (url.includes('getDetailWidgets')) {
                            const episodes = extractEpisodes(jsonResponse);
                            if (episodes && episodes.length > 0) {
                                console.log(`[ASIN Display] Intercepted API response with ${episodes.length} episodes`);

                                // Store episode data for later use
                                capturedEpisodeData = episodes;

                                // Reset the processed flag to allow reprocessing on new data
                                episodesProcessed = false;

                                // Wait for the page to settle before updating
                                setTimeout(() => {
                                    addAPIEpisodeASINs();
                                }, 1000);
                            }
                        }

                        // Update ASIN display with any findings
                        if (Object.keys(jsonIds).length > 0) {
                            setTimeout(() => {
                                addASINDisplay(jsonIds);
                            }, 1000);
                        }
                    }
                } catch (error) {
                    console.error('[ASIN Display] Error processing fetch response:', error);
                }
            }).catch(error => {
                console.error('[ASIN Display] Error in fetch intercept:', error);
            });
        }

        // Return the original fetch promise so the page works normally
        return fetchPromise;
    };

    // Also intercept XHR requests to capture any non-fetch API calls
    const originalXHROpen = XMLHttpRequest.prototype.open;
    const originalXHRSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function(method, url, ...rest) {
        // Store the URL if it's a detail API call
        if (typeof url === 'string' && url.includes('api/getDetailWidgets')) {
            this._asinDisplayUrl = url;
        }
        return originalXHROpen.apply(this, [method, url, ...rest]);
    };

    XMLHttpRequest.prototype.send = function(...args) {
        if (this._asinDisplayUrl) {
            // Add a response handler
            this.addEventListener('load', function() {
                try {
                    if (this.responseType === 'json' ||
                        (this.getResponseHeader('content-type')?.includes('application/json'))) {

                        let jsonResponse;
                        if (this.responseType === 'json') {
                            jsonResponse = this.response;
                        } else {
                            jsonResponse = JSON.parse(this.responseText);
                        }

                        // Extract episodes and IDs
                        const episodes = extractEpisodes(jsonResponse);
                        if (episodes && episodes.length > 0) {
                            console.log(`[ASIN Display] Intercepted XHR with ${episodes.length} episodes`);
                            capturedEpisodeData = episodes;
                            episodesProcessed = false;

                            setTimeout(() => {
                                addAPIEpisodeASINs();
                            }, 1000);
                        }

                        // Update main ASIN display
                        const jsonIds = findUniqueASINsFromJSON(jsonResponse);
                        if (Object.keys(jsonIds).length > 0) {
                            setTimeout(() => {
                                addASINDisplay(jsonIds);
                            }, 1000);
                        }
                    }
                } catch (error) {
                    console.error('[ASIN Display] Error processing XHR response:', error);
                }
            });
        }
        return originalXHRSend.apply(this, args);
    };

    // Track the current URL
    let currentURL = window.location.href;

    // Function to update all ASINs
    function updateAllASINs() {
        // Display main ASINs
        addASINDisplay();

        // Try to add episode ASINs from DOM first
        addEpisodeASINs();

        // Try to add episode ASINs from captured API data
        addAPIEpisodeASINs();

        // Reset the episodesProcessed flag on page change
        episodesProcessed = false;
    }

    // Function to check for URL changes
    function checkForURLChange() {
        if (window.location.href !== currentURL) {
            currentURL = window.location.href;
            console.log("[ASIN Display] URL changed. Updating IDs...");

            // Clear captured data on page change
            capturedEpisodeData = [];
            episodesProcessed = false;

            // Wait for the page to settle before displaying ASINs
            setTimeout(() => {
                updateAllASINs();
            }, 1000);
        }
    }

    // Run the URL change checker every 500ms
    setInterval(checkForURLChange, 500);

    // Initial run after the page has fully loaded
    window.addEventListener("load", () => {
        setTimeout(() => {
            updateAllASINs();
        }, 1000);
    });

    // Additional MutationObserver to detect DOM changes that might indicate new episodes loaded
    const observer = new MutationObserver((mutations) => {
        // Look for mutations that might indicate new episode content
        const episodeContentChanged = mutations.some(mutation => {
            // Check if any added nodes contain episode selectors
            return Array.from(mutation.addedNodes).some(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    return node.querySelector?.('[id^="selector-"], [id^="av-episode-expand-toggle-"]') ||
                           node.id?.startsWith('selector-') ||
                           node.id?.startsWith('av-episode-expand-toggle-');
                }
                return false;
            });
        });

        if (episodeContentChanged) {
            console.log("[ASIN Display] Detected new episode content, updating ASINs...");
            setTimeout(() => {
                updateAllASINs();
            }, 1000);
        }
    });

    // Start observing the document body for episode content changes
    observer.observe(document.body, { childList: true, subtree: true });
})();