Created
August 5, 2026 15:59
-
-
Save 0187773933/3f989896a3f929548fa4578db6ed9fea to your computer and use it in GitHub Desktop.
Amazon Prime Export Watch History
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (async () => { | |
| /** Configurable options */ | |
| const OPTION = { | |
| /** When true, prompt the user to continue when a partial-load warning is detected. Otherwise, continue silently */ | |
| interactive: true, | |
| /** When true, save the output as JSON. Otherwise, save it as CSV */ | |
| outputJson: false, | |
| /** The filename to use for the output file. The file extension will be added automatically */ | |
| outputFilename: `watch-history-unique-${Date.now()}`, | |
| }; | |
| /** Delimiters for the CSV file */ | |
| const DELIMITER = { | |
| string: '"', | |
| field: ',', | |
| record: '\n', | |
| json: '\t', | |
| }; | |
| /** Locale-specific strings */ | |
| const MSG = { | |
| column: { | |
| type: 'Type', | |
| title: 'Title', | |
| episodesWatched: 'Episodes Watched', | |
| dateWatched: 'Date Watched', | |
| }, | |
| value: { | |
| movie: 'Movie', | |
| series: 'Series', | |
| }, | |
| }; | |
| /** A map of unique watch history items keyed by identifier */ | |
| const watchHistoryItems = new Map(); | |
| /** Join an array of strings with double newlines */ | |
| const multiline = (...strs) => strs.join('\n\n'); | |
| /** Show a warning dialog the user can cancel; throws if they cancel (skipped when non-interactive) */ | |
| const warn = (msg) => { | |
| if (OPTION.interactive && !window.confirm(msg)) { | |
| throw new Error('User cancelled execution'); | |
| } | |
| }; | |
| /** Decode HTML entities in an input string (ex. """, """, etc.) */ | |
| const decodeHtmlEntities = (() => { | |
| const domParser = new DOMParser(); | |
| return (str) => domParser.parseFromString(str, 'text/html').documentElement.textContent; | |
| })(); | |
| /** Escape a value for CSV by converting it to a string, escaping delimiters, and wrapping in quotes */ | |
| const csvEscape = (value) => | |
| [ | |
| DELIMITER.string, | |
| String(value).replaceAll(DELIMITER.string, `${DELIMITER.string}${DELIMITER.string}`), | |
| DELIMITER.string, | |
| ].join(''); | |
| /** | |
| * Loop through the watch history items, deduping titles and tallying unique episodes per series. | |
| * Prime lists sections newest-first, so the first time a title is seen is its most recent watch; | |
| * we capture that section's own date label (never the epoch `time`, which is unreliable). | |
| */ | |
| const processWatchHistoryItems = (dateSections) => { | |
| for (const dateSection of dateSections) { | |
| const date = dateSection?.date ?? ''; | |
| for (const itemOfToday of dateSection.titles) { | |
| const title = decodeHtmlEntities(itemOfToday?.title?.text); | |
| const id = itemOfToday?.gti; | |
| const key = id || title; | |
| const isSeries = Array.isArray(itemOfToday.children) && itemOfToday.children.length > 0; | |
| if (isSeries) { | |
| let entry = watchHistoryItems.get(key); | |
| if (!entry) { | |
| entry = { type: MSG.value.series, title, episodes: new Set(), date }; | |
| watchHistoryItems.set(key, entry); | |
| } | |
| for (const episode of itemOfToday.children) { | |
| entry.episodes.add(episode?.gti || decodeHtmlEntities(episode?.title?.text)); | |
| } | |
| continue; | |
| } | |
| if (!watchHistoryItems.has(key)) { | |
| watchHistoryItems.set(key, { type: MSG.value.movie, title, episodes: null, date }); | |
| } | |
| } | |
| } | |
| }; | |
| /** Processes the watch history items from the given object. Returns the number of date sections found, otherwise 0 */ | |
| const processPotentialWatchHistoryResponse = (obj) => { | |
| const widgets = obj?.widgets; | |
| if (!Array.isArray(widgets)) return 0; | |
| let numOfItemsFound = 0; | |
| for (const widget of widgets) { | |
| if (widget?.widgetType !== 'watch-history') continue; | |
| const dateSections = widget?.content?.content?.titles; | |
| if (Array.isArray(dateSections)) { | |
| processWatchHistoryItems(dateSections); | |
| numOfItemsFound = dateSections.length; | |
| } | |
| } | |
| return numOfItemsFound; | |
| }; | |
| /** Search the page for a script containing inline watch history data and process it */ | |
| const findInlineWatchHistory = async () => { | |
| const scripts = Array.from(document.body.querySelectorAll('script[type="text/template"]')); | |
| let numOfItemsInJson = 0; | |
| for (const script of scripts) { | |
| const obj = JSON.parse(script.textContent.trim()); | |
| numOfItemsInJson = processPotentialWatchHistoryResponse(obj?.props); | |
| if (!numOfItemsInJson) { | |
| continue; | |
| } | |
| const numOfItemsOnPage = [...document.querySelectorAll('[data-automation-id^="wh-date"]')].length; | |
| if (numOfItemsOnPage > numOfItemsInJson) { | |
| warn( | |
| multiline( | |
| 'It looks like some watch history items have already been loaded. This can be caused by scrolling down the page before running the script.', | |
| 'To try again, click Cancel, reload the page, and run the script again.', | |
| 'Alternatively, you can click OK to continue, but some items may be missing from the output.', | |
| ), | |
| ); | |
| } | |
| break; | |
| } | |
| if (numOfItemsInJson) { | |
| return; | |
| } | |
| warn( | |
| multiline( | |
| 'No valid inline watch history found (this is probably a bug).', | |
| 'To try again, click Cancel, reload the page, and run the script again.', | |
| 'Alternatively, you can click OK to continue, but some items may be missing from the output.', | |
| ), | |
| ); | |
| }; | |
| /** Clone a response and inspect it */ | |
| const inspectResponse = async (response) => { | |
| const clonedResponse = response.clone(); | |
| const contentType = | |
| (clonedResponse.headers && clonedResponse.headers.get && clonedResponse.headers.get('content-type')) || ''; | |
| if (!contentType.includes('application/json')) return; | |
| const body = await clonedResponse.json(); | |
| processPotentialWatchHistoryResponse(body); | |
| }; | |
| /** Monkey-patch native fetch function so we can intercept responses and extract watch history data from them */ | |
| const patchFetchFn = () => { | |
| const originalFetchFn = window.fetch; | |
| window.fetch = async (...args) => { | |
| const response = await originalFetchFn(...args); | |
| // No need to wait for this to complete | |
| inspectResponse(response); | |
| return response; | |
| }; | |
| }; | |
| /** Force lazy loading of the watch history by scrolling to the bottom of the page */ | |
| const forceLoadWatchHistory = async () => | |
| new Promise((resolve) => { | |
| const autoScrollInterval = setInterval(() => { | |
| if (!document.body.querySelector('div[data-automation-id=activity-history-items] > div > noscript')) { | |
| clearInterval(autoScrollInterval); | |
| resolve(); | |
| } | |
| window.scrollTo(0, document.body.scrollHeight); | |
| }, 100); | |
| }); | |
| /** Flatten a stored map entry into an output row object */ | |
| const toRow = (item) => ({ | |
| type: item.type, | |
| title: item.title, | |
| episodesWatched: item.episodes ? item.episodes.size : '', | |
| dateWatched: item.date, | |
| }); | |
| /** Return output rows sorted movies-first, then series, alphabetized by title within each group */ | |
| const getSortedRows = () => { | |
| const typeOrder = { [MSG.value.movie]: 0, [MSG.value.series]: 1 }; | |
| return [...watchHistoryItems.values()] | |
| .map(toRow) | |
| .sort( | |
| (a, b) => | |
| typeOrder[a.type] - typeOrder[b.type] || | |
| a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }), | |
| ); | |
| }; | |
| /** Encode the unique watch history as CSV. Prefixed with a UTF-8 BOM so Excel/Numbers decode it as UTF-8 */ | |
| const encodeAsCsv = () => { | |
| const columnNames = Object.values(MSG.column).map(csvEscape); | |
| const rows = getSortedRows().map((item) => Object.values(item).map(csvEscape)); | |
| const body = [columnNames, ...rows].map((item) => item.join(DELIMITER.field)).join(DELIMITER.record); | |
| return ['csv', 'text/csv;charset=utf-8', `\uFEFF${body}`]; | |
| }; | |
| /** Encode the unique watch history as JSON */ | |
| const encodeAsJson = () => ['json', 'application/json', JSON.stringify(getSortedRows(), null, DELIMITER.json)]; | |
| /** Download the unique watch history as a CSV or JSON file */ | |
| const downloadFile = () => { | |
| const [extension, mimeType, data] = OPTION.outputJson ? encodeAsJson() : encodeAsCsv(); | |
| const blob = new Blob([data], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${OPTION.outputFilename}.${extension}`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| }; | |
| // Script entry point | |
| await findInlineWatchHistory(); | |
| patchFetchFn(); | |
| await forceLoadWatchHistory(); | |
| downloadFile(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment