Amazon Video ASIN Display

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

  1. // ==UserScript==
  2. // @name Amazon Video ASIN Display
  3. // @namespace sac@libidgel.com
  4. // @version 0.4.0
  5. // @description Show unique ASINs for episodes and movies/seasons on Amazon Prime Video
  6. // @author ReiDoBrega
  7. // @license MIT
  8. // @match https://www.amazon.com/*
  9. // @match https://www.amazon.co.uk/*
  10. // @match https://www.amazon.de/*
  11. // @match https://www.amazon.co.jp/*
  12. // @match https://www.primevideo.com/*
  13. // @run-at document-idle
  14. // @grant none
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. "use strict";
  19.  
  20. // Add styles for ASIN display and pop-up
  21. let style = document.createElement("style");
  22. style.textContent = `
  23. // Modify your style.textContent by adding this rule:
  24. .x-asin-container ._3ra7oO {
  25. font-size: 0.2em;
  26. opacity: 0.75;
  27. margin-top: 2px;
  28. }
  29. .x-asin-item, .x-episode-asin {
  30. color: #1399FF; /* Blue color */
  31. cursor: pointer;
  32. margin: 5px 0;
  33. }
  34. .x-copy-popup {
  35. position: fixed;
  36. bottom: 20px;
  37. right: 20px;
  38. background-color: rgba(0, 0, 0, 0); /* Transparent background */
  39. color: #1399FF; /* Blue text */
  40. padding: 10px 20px;
  41. border-radius: 5px;
  42. font-family: Arial, sans-serif;
  43. font-size: 14px;
  44. box-shadow: 0 2px 10px rgba(0, 0, 0, 0);
  45. z-index: 1000;
  46. animation: fadeInOut 2.5s ease-in-out;
  47. }
  48. @keyframes fadeOut {
  49. 0% { opacity: 1; }
  50. 100% { opacity: 0; }
  51. }
  52. .x-asin-display {
  53. font-size: 5px; /* Absolute size in pixels */
  54. opacity: 0.7;
  55. margin-top: 12px;
  56. cursor: pointer;
  57. }
  58. `;
  59. document.head.appendChild(style);
  60.  
  61. // Store for captured episode data
  62. let capturedEpisodeData = [];
  63.  
  64. // Flag to indicate if we've already processed episodes from API
  65. let episodesProcessed = false;
  66.  
  67. // Function to extract ASIN from URL
  68. function extractASINFromURL() {
  69. const url = window.location.href;
  70. const asinRegex = /\/gp\/video\/detail\/([A-Z0-9]{10})/;
  71. const match = url.match(asinRegex);
  72. return match ? match[1] : null;
  73. }
  74.  
  75. // Function to find and display unique ASINs
  76. function findUniqueASINs() {
  77. // Extract ASIN from URL first
  78. const urlASIN = extractASINFromURL();
  79. if (urlASIN) {
  80. return { urlASIN };
  81. }
  82.  
  83. // Object to store one unique ASIN/ID for each type
  84. let uniqueIds = {};
  85.  
  86. // List of ID patterns to find
  87. const idPatterns = [
  88. {
  89. name: 'titleID',
  90. regex: /"titleID":"([^"]+)"/
  91. },
  92. // {
  93. // name: 'pageTypeId',
  94. // regex: /pageTypeId: "([^"]+)"/
  95. // },
  96. // {
  97. // name: 'pageTitleId',
  98. // regex: /"pageTitleId":"([^"]+)"/
  99. // },
  100. // {
  101. // name: 'catalogId',
  102. // regex: /catalogId":"([^"]+)"/
  103. // }
  104. ];
  105.  
  106. // Search through patterns
  107. idPatterns.forEach(pattern => {
  108. let match = document.body.innerHTML.match(pattern.regex);
  109. if (match && match[1]) {
  110. uniqueIds[pattern.name] = match[1];
  111. }
  112. });
  113.  
  114. return uniqueIds;
  115. }
  116.  
  117. // Function to find ASINs from JSON response
  118. function findUniqueASINsFromJSON(jsonData) {
  119. let uniqueIds = {};
  120.  
  121. // Comprehensive search paths for ASINs
  122. const searchPaths = [
  123. { name: 'titleId', paths: [
  124. ['titleID'],
  125. ['page', 0, 'assembly', 'body', 0, 'args', 'titleID'],
  126. ['titleId'],
  127. ['detail', 'titleId'],
  128. ['data', 'titleId']
  129. ]},
  130. ];
  131.  
  132. // Deep object traversal function
  133. function traverseObject(obj, paths) {
  134. for (let pathSet of paths) {
  135. try {
  136. let value = obj;
  137. for (let key of pathSet) {
  138. value = value[key];
  139. if (value === undefined) break;
  140. }
  141.  
  142. if (value && typeof value === 'string' && value.trim() !== '') {
  143. return value;
  144. }
  145. } catch (e) {
  146. // Silently ignore traversal errors
  147. }
  148. }
  149. return null;
  150. }
  151.  
  152. // Search through all possible paths
  153. searchPaths.forEach(({ name, paths }) => {
  154. const value = traverseObject(jsonData, paths);
  155. if (value) {
  156. uniqueIds[name] = value;
  157. console.log(`[ASIN Display] Found ${name} in JSON: ${value}`);
  158. }
  159. });
  160.  
  161. return uniqueIds;
  162. }
  163.  
  164. // Function to extract episodes from JSON data
  165. function extractEpisodes(jsonData) {
  166. try {
  167. // Possible paths to episode data
  168. const episodePaths = [
  169. ['items'],
  170. ['widgets', 0, 'data', 'items'],
  171. ['widgets', 0, 'items'],
  172. ['data', 'widgets', 0, 'items'],
  173. ['page', 0, 'assembly', 'body', 0, 'items'],
  174. ['data', 'items']
  175. ];
  176.  
  177. // Try each path
  178. for (const path of episodePaths) {
  179. let current = jsonData;
  180. let valid = true;
  181.  
  182. // Navigate through the path
  183. for (const key of path) {
  184. if (current && current[key] !== undefined) {
  185. current = current[key];
  186. } else {
  187. valid = false;
  188. break;
  189. }
  190. }
  191.  
  192. // If we found a valid path and it's an array of items
  193. if (valid && Array.isArray(current)) {
  194. return current.filter(item =>
  195. item &&
  196. (item.titleId || item.id || item.episodeID || item.asin)
  197. );
  198. }
  199. }
  200. } catch (e) {
  201. console.error("Error extracting episodes:", e);
  202. }
  203. return [];
  204. }
  205.  
  206. // Function to add episode ASINs from captured API data
  207. function addAPIEpisodeASINs() {
  208. try {
  209. if (capturedEpisodeData.length === 0 || episodesProcessed) {
  210. return false;
  211. }
  212.  
  213. console.log(`[ASIN Display] Processing ${capturedEpisodeData.length} captured episodes`);
  214.  
  215. // Process and display episode ASINs
  216. capturedEpisodeData.forEach(episode => {
  217. const episodeId = episode.titleId || episode.id || episode.episodeID || episode.asin;
  218. const episodeNumber = episode.episodeNumber || episode.number;
  219. const seasonNumber = episode.seasonNumber;
  220.  
  221. if (!episodeId) return;
  222.  
  223. // Find episode element to attach ASIN to
  224. const selector = `[data-automation-id="ep-${episodeNumber}"], [id^="selector-${episodeId}"], [id^="av-episode-expand-toggle-${episodeId}"]`;
  225. let episodeElement = document.querySelector(selector);
  226.  
  227. // If can't find by direct ID, try to find by episode number
  228. if (!episodeElement && episodeNumber) {
  229. episodeElement = document.querySelector(`[data-automation-id*="ep-${episodeNumber}"]`);
  230.  
  231. // Try alternative approaches for finding episode elements
  232. if (!episodeElement) {
  233. // This will try to match elements that might contain the episode number visually
  234. const possibleElements = [...document.querySelectorAll('[data-automation-id*="ep-"]')];
  235. episodeElement = possibleElements.find(el => {
  236. const text = el.textContent.trim();
  237. return text.includes(`Episode ${episodeNumber}`) ||
  238. text.includes(`Ep. ${episodeNumber}`) ||
  239. text.match(new RegExp(`\\b${episodeNumber}\\b`));
  240. });
  241. }
  242. }
  243.  
  244. // If we found an element to attach to
  245. if (episodeElement) {
  246. // Skip if ASIN already added
  247. if (episodeElement.parentNode.querySelector("._3ra7oO")) {
  248. return;
  249. }
  250.  
  251. // Create ASIN element
  252. let asinEl = document.createElement("div");
  253. asinEl.className = "_3ra7oO x-asin-display"; // Add your custom class alongside the original
  254. asinEl.textContent = asin;
  255. asinEl.addEventListener("click", () => copyToClipboard(asin));
  256.  
  257. // Insert ASIN element after the episode title
  258. let epTitle = episodeElement.parentNode.querySelector("[data-automation-id^='ep-title']");
  259. if (epTitle) {
  260. epTitle.parentNode.insertBefore(asinEl, epTitle.nextSibling);
  261. } else {
  262. // If can't find specific title element, just append to the episode element's parent
  263. episodeElement.parentNode.appendChild(asinEl);
  264. }
  265. } else {
  266. console.log(`[ASIN Display] Could not find element for episode ${episodeNumber} with ID ${episodeId}`);
  267. }
  268. });
  269.  
  270. // Mark as processed to avoid duplicate processing
  271. episodesProcessed = true;
  272.  
  273. return true; // API episode ASINs added successfully
  274. } catch (e) {
  275. console.error("ASIN Display - Error in addAPIEpisodeASINs:", e);
  276. return false; // Error occurred
  277. }
  278. }
  279.  
  280. // Function to add episode ASINs using DOM
  281. function addEpisodeASINs() {
  282. try {
  283. document.querySelectorAll("[id^='selector-'], [id^='av-episode-expand-toggle-']").forEach(el => {
  284. // Skip if ASIN already added
  285. if (el.parentNode.querySelector("._3ra7oO")) {
  286. return;
  287. }
  288.  
  289. // Extract ASIN from the element ID
  290. let asin = el.id.replace(/^(?:selector|av-episode-expand-toggle)-/, "");
  291.  
  292. // Create ASIN element
  293. let asinEl = document.createElement("div");
  294. asinEl.className = "_3ra7oO x-asin-display"; // Add your custom class alongside the original
  295. asinEl.textContent = asin;
  296. asinEl.addEventListener("click", () => copyToClipboard(asin));
  297.  
  298. // Insert ASIN element after the episode title
  299. let epTitle = el.parentNode.querySelector("[data-automation-id^='ep-title']");
  300. if (epTitle) {
  301. epTitle.parentNode.insertBefore(asinEl, epTitle.nextSibling);
  302. }
  303. });
  304. return true; // Episode ASINs added successfully
  305. } catch (e) {
  306. console.error("ASIN Display - Error in addEpisodeASINs:", e);
  307. return false; // Error occurred
  308. }
  309. }
  310.  
  311. // Function to add ASIN display
  312. function addASINDisplay(uniqueIds = null) {
  313. try {
  314. // If no IDs provided, find them from HTML
  315. if (!uniqueIds) {
  316. uniqueIds = findUniqueASINs();
  317. }
  318.  
  319. // Remove existing ASIN containers
  320. document.querySelectorAll(".x-asin-container").forEach(el => el.remove());
  321.  
  322. // If no IDs found, return
  323. if (Object.keys(uniqueIds).length === 0) {
  324. console.log("ASIN Display: No ASINs found");
  325. return false;
  326. }
  327.  
  328. // Create ASIN container
  329. let asinContainer = document.createElement("div");
  330. asinContainer.className = "x-asin-container";
  331.  
  332. // Add each unique ID as a clickable element
  333. Object.entries(uniqueIds).forEach(([type, id]) => {
  334. let asinEl = document.createElement("div");
  335. asinEl.className = "_1jWggM v2uvTa fbl-btn _2Pw7le";
  336. asinEl.textContent = id;
  337. asinEl.addEventListener("click", () => copyToClipboard(id));
  338. asinContainer.appendChild(asinEl);
  339. });
  340.  
  341. // Insert the ASIN container after the synopsis
  342. let after = document.querySelector(".dv-dp-node-synopsis, .av-synopsis");
  343. if (!after) {
  344. console.log("ASIN Display: Could not find element to insert after");
  345. return false;
  346. }
  347.  
  348. after.parentNode.insertBefore(asinContainer, after.nextSibling);
  349. return true;
  350. } catch (e) {
  351. console.error("ASIN Display - Error in addASINDisplay:", e);
  352. return false;
  353. }
  354. }
  355.  
  356. // Function to copy text to clipboard and show pop-up
  357. function copyToClipboard(text) {
  358. const input = document.createElement("textarea");
  359. input.value = text;
  360. document.body.appendChild(input);
  361. input.select();
  362. document.execCommand("copy");
  363. document.body.removeChild(input);
  364.  
  365. // Show pop-up
  366. const popup = document.createElement("div");
  367. popup.className = "x-copy-popup";
  368. popup.textContent = `Copied: ${text}`;
  369. document.body.appendChild(popup);
  370.  
  371. // Remove pop-up after 1.5 seconds
  372. setTimeout(() => {
  373. popup.remove();
  374. }, 1500);
  375. }
  376.  
  377. // Intercept fetch requests for JSON responses
  378. const originalFetch = window.fetch;
  379. window.fetch = function(...args) {
  380. const [url] = args;
  381. const isString = typeof url === 'string';
  382.  
  383. // Create a promise for the original fetch
  384. const fetchPromise = originalFetch.apply(this, args);
  385.  
  386. // Check if this is a URL we're interested in
  387. if (isString &&
  388. ((url.includes('/detail/') && url.includes('primevideo.com')) ||
  389. (url.includes('api/getDetailWidgets')))) {
  390.  
  391. // Process the response without blocking the original fetch
  392. fetchPromise.then(async response => {
  393. try {
  394. // Only process JSON responses
  395. const contentType = response.headers.get('content-type');
  396. if (contentType?.includes('application/json')) {
  397. // Clone the response to avoid consuming it
  398. const clonedResponse = response.clone();
  399. const jsonResponse = await clonedResponse.json();
  400.  
  401. // Find unique IDs from the response
  402. const jsonIds = findUniqueASINsFromJSON(jsonResponse);
  403.  
  404. // For Detail API calls, extract episodes
  405. if (url.includes('getDetailWidgets')) {
  406. const episodes = extractEpisodes(jsonResponse);
  407. if (episodes && episodes.length > 0) {
  408. console.log(`[ASIN Display] Intercepted API response with ${episodes.length} episodes`);
  409.  
  410. // Store episode data for later use
  411. capturedEpisodeData = episodes;
  412.  
  413. // Reset the processed flag to allow reprocessing on new data
  414. episodesProcessed = false;
  415.  
  416. // Wait for the page to settle before updating
  417. setTimeout(() => {
  418. addAPIEpisodeASINs();
  419. }, 1000);
  420. }
  421. }
  422.  
  423. // Update ASIN display with any findings
  424. if (Object.keys(jsonIds).length > 0) {
  425. setTimeout(() => {
  426. addASINDisplay(jsonIds);
  427. }, 1000);
  428. }
  429. }
  430. } catch (error) {
  431. console.error('[ASIN Display] Error processing fetch response:', error);
  432. }
  433. }).catch(error => {
  434. console.error('[ASIN Display] Error in fetch intercept:', error);
  435. });
  436. }
  437.  
  438. // Return the original fetch promise so the page works normally
  439. return fetchPromise;
  440. };
  441.  
  442. // Also intercept XHR requests to capture any non-fetch API calls
  443. const originalXHROpen = XMLHttpRequest.prototype.open;
  444. const originalXHRSend = XMLHttpRequest.prototype.send;
  445.  
  446. XMLHttpRequest.prototype.open = function(method, url, ...rest) {
  447. // Store the URL if it's a detail API call
  448. if (typeof url === 'string' && url.includes('api/getDetailWidgets')) {
  449. this._asinDisplayUrl = url;
  450. }
  451. return originalXHROpen.apply(this, [method, url, ...rest]);
  452. };
  453.  
  454. XMLHttpRequest.prototype.send = function(...args) {
  455. if (this._asinDisplayUrl) {
  456. // Add a response handler
  457. this.addEventListener('load', function() {
  458. try {
  459. if (this.responseType === 'json' ||
  460. (this.getResponseHeader('content-type')?.includes('application/json'))) {
  461.  
  462. let jsonResponse;
  463. if (this.responseType === 'json') {
  464. jsonResponse = this.response;
  465. } else {
  466. jsonResponse = JSON.parse(this.responseText);
  467. }
  468.  
  469. // Extract episodes and IDs
  470. const episodes = extractEpisodes(jsonResponse);
  471. if (episodes && episodes.length > 0) {
  472. console.log(`[ASIN Display] Intercepted XHR with ${episodes.length} episodes`);
  473. capturedEpisodeData = episodes;
  474. episodesProcessed = false;
  475.  
  476. setTimeout(() => {
  477. addAPIEpisodeASINs();
  478. }, 1000);
  479. }
  480.  
  481. // Update main ASIN display
  482. const jsonIds = findUniqueASINsFromJSON(jsonResponse);
  483. if (Object.keys(jsonIds).length > 0) {
  484. setTimeout(() => {
  485. addASINDisplay(jsonIds);
  486. }, 1000);
  487. }
  488. }
  489. } catch (error) {
  490. console.error('[ASIN Display] Error processing XHR response:', error);
  491. }
  492. });
  493. }
  494. return originalXHRSend.apply(this, args);
  495. };
  496.  
  497. // Track the current URL
  498. let currentURL = window.location.href;
  499.  
  500. // Function to update all ASINs
  501. function updateAllASINs() {
  502. // Display main ASINs
  503. addASINDisplay();
  504.  
  505. // Try to add episode ASINs from DOM first
  506. addEpisodeASINs();
  507.  
  508. // Try to add episode ASINs from captured API data
  509. addAPIEpisodeASINs();
  510.  
  511. // Reset the episodesProcessed flag on page change
  512. episodesProcessed = false;
  513. }
  514.  
  515. // Function to check for URL changes
  516. function checkForURLChange() {
  517. if (window.location.href !== currentURL) {
  518. currentURL = window.location.href;
  519. console.log("[ASIN Display] URL changed. Updating IDs...");
  520.  
  521. // Clear captured data on page change
  522. capturedEpisodeData = [];
  523. episodesProcessed = false;
  524.  
  525. // Wait for the page to settle before displaying ASINs
  526. setTimeout(() => {
  527. updateAllASINs();
  528. }, 1000);
  529. }
  530. }
  531.  
  532. // Run the URL change checker every 500ms
  533. setInterval(checkForURLChange, 500);
  534.  
  535. // Initial run after the page has fully loaded
  536. window.addEventListener("load", () => {
  537. setTimeout(() => {
  538. updateAllASINs();
  539. }, 1000);
  540. });
  541.  
  542. // Additional MutationObserver to detect DOM changes that might indicate new episodes loaded
  543. const observer = new MutationObserver((mutations) => {
  544. // Look for mutations that might indicate new episode content
  545. const episodeContentChanged = mutations.some(mutation => {
  546. // Check if any added nodes contain episode selectors
  547. return Array.from(mutation.addedNodes).some(node => {
  548. if (node.nodeType === Node.ELEMENT_NODE) {
  549. return node.querySelector?.('[id^="selector-"], [id^="av-episode-expand-toggle-"]') ||
  550. node.id?.startsWith('selector-') ||
  551. node.id?.startsWith('av-episode-expand-toggle-');
  552. }
  553. return false;
  554. });
  555. });
  556.  
  557. if (episodeContentChanged) {
  558. console.log("[ASIN Display] Detected new episode content, updating ASINs...");
  559. setTimeout(() => {
  560. updateAllASINs();
  561. }, 1000);
  562. }
  563. });
  564.  
  565. // Start observing the document body for episode content changes
  566. observer.observe(document.body, { childList: true, subtree: true });
  567. })();