-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathwatch-history-exporter-for-amazon-prime-video.js
More file actions
348 lines (275 loc) · 9.97 KB
/
watch-history-exporter-for-amazon-prime-video.js
File metadata and controls
348 lines (275 loc) · 9.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
(async () => {
/** Configurable options */
const OPTION = {
/** When true, prompt the user to continue when warning messages are displayed. Otherwise, continue automatically */
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-export-${Date.now()}`,
/** When true, format epoch ms into "yyyy-mm-dd hh:mm:ss.000". Otherwise, output the raw epoch milliseconds value */
formatDates: true,
};
/** Delimiters for the CSV file */
const DELIMITER = {
string: '"',
field: ',',
record: '\n',
json: '\t',
};
/** Locale-specific strings and functions */
const MSG = {
column: {
dateWatched: 'Date Watched',
type: 'Type',
title: 'Title',
episodeTitle: 'Episode Title',
gti: 'Global Title Identifier',
episodeGti: 'Episode Global Title Identifier',
path: 'Path',
episodePath: 'Episode Path',
imageUrl: 'Image URL',
},
value: {
movie: 'Movie',
series: 'Series',
},
};
/** A list of watch history items to be exported */
const watchHistoryItems = [];
/** Get the corresponding suffix for a log message given a continuation status */
const getLogSuffix = (() => {
const suffixMap = {
true: ' Continuing...',
false: ' Cancelling...',
undefined: '',
};
return (doContinue) => [doContinue, suffixMap[doContinue]];
})();
/** Print an informational message to the console */
const log = (() => {
const prefixArray = [
`%c[Watch History Exporter for Amazon Prime]`,
'color:#1399FF;background:#00050d;font-weight:bold;',
];
return (msg, logFn = console.info, showPrefix = true) => {
const [doContinue, suffix] = getLogSuffix(
(() => {
if (logFn !== console.warn) {
return undefined;
}
if (OPTION.interactive) {
return window.confirm(multiline(prefix, msg));
}
return true;
})(),
);
logFn(...(showPrefix ? prefixArray : []), `${msg}${suffix}`);
if (doContinue === false) {
throw new Error('User cancelled execution');
}
};
})();
/** Join an array of strings with double newlines */
const multiline = (...strs) => strs.join('\n\n');
/** 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('');
/** If `OPTION.formatDates` is true, format an epoch-milliseconds timestamp as "yyyy-mm-dd hh:mm:ss.sss", otherwise return the timestamp as a string */
const toDateTimeString = (ts) =>
OPTION.formatDates ? new Date(ts).toISOString().slice(0, -1).split('T').join(' ') : ts;
/** Loop through the watch history items and add them to the watchHistoryItems array */
const processWatchHistoryItems = (dateSections) => {
for (const dateSection of dateSections) {
const logMsgs = [`\t⤷ ${dateSection?.date}`];
for (const itemOfToday of dateSection.titles) {
const title = decodeHtmlEntities(itemOfToday?.title?.text);
const id = itemOfToday?.gti;
const path = itemOfToday?.title?.href;
const imageUrl = itemOfToday?.imageSrc;
if (Array.isArray(itemOfToday.children) && itemOfToday.children.length > 0) {
const type = MSG.value.series;
logMsgs.push(`\t\t⤷ [${type}] ${title}`);
for (const episode of itemOfToday.children) {
const episodeTitle = decodeHtmlEntities(episode?.title?.text);
logMsgs.push(`\t\t\t⤷ ${episodeTitle}`);
watchHistoryItems.push({
dateWatched: toDateTimeString(episode?.time),
type,
title,
episodeTitle,
id,
episodeId: episode?.gti,
path,
episodePath: episode?.title?.href,
imageUrl,
});
}
continue;
}
const type = MSG.value.movie;
logMsgs.push(`\t\t⤷ [${type}] ${title}`);
watchHistoryItems.push({
dateWatched: toDateTimeString(itemOfToday?.time),
type,
title,
episodeTitle: '',
id,
episodeId: '',
path,
episodePath: '',
imageUrl,
});
}
log(logMsgs.join('\n'), console.debug, false);
}
};
/** Processes the watch history items from the given object. Returns true if any watch-history widget was found and processed, otherwise false */
const processPotentialWatchHistoryResponse = (obj) => {
log('⏳ Processing response...');
const widgets = obj?.widgets;
if (!Array.isArray(widgets)) return false;
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;
}
}
log(`✅ Found ${numOfItemsFound} items`);
return numOfItemsFound;
};
/** Search the page for a script containing inline watch history data and process it */
const findInlineWatchHistory = async () => {
log('⏳ Loading inline watch history...');
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) {
log(
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.',
),
console.warn,
);
}
break;
}
if (numOfItemsInJson) {
return;
}
log(
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.',
),
console.warn,
);
};
/** 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 () => {
log('⏳ Loading more watch history items...');
return 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);
});
};
/** Print a message to the console encouraging users to sponsor my work */
const printSponsorMessage = () => {
const bannerStyle = 'border:2px solid hotpink;padding:8px 12px;border-radius:6px;font-weight:700;font-size:12px;';
const textStyle = 'font-size:12px;';
console.log(
'\n%c💖 If this script saved you some time or effort, please consider donating $1 to support my work. Thanks :)',
bannerStyle,
);
console.log(
[
'%c',
'👉 GitHub: https://github.com/sponsors/twocaretcat',
'👉 Patreon: https://patreon.com/twocaretcat',
'👉 More: https://johng.io/funding',
].join('\n'),
textStyle,
);
};
/** Encode the watch history as CSV */
const encodeAsCsv = () => {
const columnNames = Object.values(MSG.column).map(csvEscape);
const rows = watchHistoryItems.map((watchHistoryItem) => Object.values(watchHistoryItem).map(csvEscape));
return ['csv', 'text/csv', [columnNames, ...rows].map((item) => item.join(DELIMITER.field)).join(DELIMITER.record)];
};
/** Encode the watch history as JSON */
const encodeAsJson = () => ['json', 'application/json', JSON.stringify(watchHistoryItems, null, DELIMITER.json)];
/** Download the watch history as a CSV or JSON file */
const downloadFile = () => {
log(`⏳ Saving ${OPTION.outputJson ? 'JSON' : 'CSV'} file...`, console.group);
log(
'💡 If you are not prompted to save a file, make sure "Pop-ups and redirects" and "Automatic downloads" are enabled for www.primevideo.com in your browser.',
console.info,
false,
);
printSponsorMessage();
console.groupEnd();
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
log('🚀 Script started');
await findInlineWatchHistory();
patchFetchFn();
await forceLoadWatchHistory();
downloadFile();
log('🏁 Script finished');
})() && '';