IMDb - List Helper

Makes creating IMDb lists more efficient and convenient

  1. // ==UserScript==
  2. // @name IMDb - List Helper
  3. // @description Makes creating IMDb lists more efficient and convenient
  4. // @namespace imdb
  5. // @author themagician, monk-time
  6. // @include http://*imdb.com/list/*/edit
  7. // @include http://*imdb.com/list/*/edit?*
  8. // @include https://*imdb.com/list/*/edit
  9. // @include https://*imdb.com/list/*/edit?*
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/d3-dsv/1.0.8/d3-dsv.min.js
  11. // @icon http://www.imdb.com/favicon.ico
  12. // @version 3.1.1
  13. // ==/UserScript==
  14.  
  15. //
  16. // CHANGELOG
  17. //
  18. // 3.1.1
  19. // fixed: https caused the script not to load
  20. //
  21. // 3.1
  22. // fixed: 'Skip/retry' buttons failed to keep search results visible
  23. // changed: The script searches for both IDs or titles by default again
  24. // added: A more granular control for what is used for search
  25. //
  26. // 3.0.1
  27. // fixed: New IMDb search results layout
  28. // changed: Search only for regex matches by default
  29. // added: A checkbox to toggle search mode (matches only vs. matches or full string)
  30. //
  31. // 3.0
  32. // fixed: Search by movie title
  33. // fixed: No longer requires jQuery; jquery-csv is replaced with d3-dsv
  34. // fixed: Remove delay before auto-clicking on a search result
  35. // changed: Criticker score conversion: 0..10 -> 1, 11..20 -> 2, 91..100 -> 10
  36. // changed: Criticker importer requires .csv
  37. // changed: If the regex fails, try searching for the whole string
  38. //
  39. // 2.4.1
  40. // fixed: In some instances the script wasn't loaded (bad @include)
  41. // changed: Limit number of setTimeOut calls
  42. //
  43. // 2.4.0
  44. // fixed: IMDb changed layout
  45. //
  46. // 2.3.0
  47. // fixed: importing ratings works again
  48. //
  49. // 2.2.0
  50. // added: support for people
  51. //
  52. // 2.1.1
  53. // added: only show import form if ratings is selected
  54. //
  55. // 2.1
  56. // added: importers for imdb, rateyourmusic, criticker
  57. //
  58. // 2.0
  59. // added: import ratings
  60. // added: if regex doesn't match, skip entry
  61. //
  62. // 1.6.1.2
  63. // added: input text suggestion as a placeholder
  64. //
  65. // 1.6.1.1
  66. // fixed: some entries are skipped when adding imdb ids/urls
  67. //
  68.  
  69. /* global d3 */
  70.  
  71. 'use strict';
  72.  
  73. // milliseconds between each request
  74. const REQUEST_DELAY = 1000;
  75.  
  76. // ----- DOM ELEMENTS: STYLING, CREATION AND TRACKING -----
  77.  
  78. document.head.insertAdjacentHTML('beforeend', `<style>
  79. #ilh-ui {
  80. margin: 0 5% 5% 5%;
  81. padding: 10px;
  82. border: 1px solid #e8e8e8;
  83. }
  84.  
  85. #ilh-ui div {
  86. margin: 0.5em 0 0.75em
  87. }
  88.  
  89. #ilh-ui label {
  90. font-weight: normal;
  91. margin-right: 6px;
  92. }
  93.  
  94. #ilh-ui span,
  95. #ilh-ui label:first-child {
  96. font-weight: bold;
  97. }
  98.  
  99. #ilh-ui textarea {
  100. width: 100%;
  101. background-color: lightyellow;
  102. overflow: auto;
  103. }
  104.  
  105. #ilh-ui .ilh-block {
  106. display: flex;
  107. }
  108.  
  109. #ilh-ui .ilh-block input[type=text] {
  110. font-family: monospace;
  111. flex-grow: 1;
  112. }
  113. </style>`);
  114.  
  115. const searchModeHints = {
  116. auto: 'Input titles, IMDb URLs or IDs here and click Start. ' +
  117. 'If a line has an IMDb ID, it\'ll be auto-added to the list. ' +
  118. 'Otherwise the whole line will be searched for, ' +
  119. 'and you\'ll have to select the correct title manually.',
  120. imdbid: 'Input text containing IMDb IDs here and click Start. ' +
  121. 'IMDb IDs are extracted from lines (only one per line) and ' +
  122. 'auto-added to the list, skipping the rest.',
  123. line: 'Input titles or IMDb IDs here and click Start. ' +
  124. 'Whole lines are used for search, nothing is skipped.',
  125. regexp: 'Input text here and click Start. Only captured groups of regex matches ' +
  126. 'are used for search.',
  127. rating: 'Use the controls below to load data from a file or input text here and click Start. ' +
  128. 'Your rating and IMDb ID/title is extracted from each line with regex.',
  129. };
  130.  
  131. const uiHTML = `
  132. <div id="ilh-ui">
  133. <div class="ilh-block">
  134. <label>Import mode:</label>
  135. <input type="radio" id="ilh-mode-list" name="mode" value="list" checked>
  136. <label for="ilh-mode-list">List</label>
  137. <input type="radio" id="ilh-mode-ratings" name="mode" value="ratings">
  138. <label for="ilh-mode-ratings">Ratings</label>
  139. </div>
  140. <textarea id="ilh-film-list" rows="7" placeholder="${searchModeHints.auto}"></textarea>
  141. <div>
  142. <input type="button" value="Start" id="ilh-start">
  143. <input type="button" value="Skip" id="ilh-skip">
  144. <input type="button" value="Retry" id="ilh-retry">
  145. <span>Remaining: <span id="ilh-films-remaining">0</span></span>
  146. </div>
  147. <div class="ilh-block">
  148. <label for="ilh-current-film">Current:</label>
  149. <input type="text" id="ilh-current-film">
  150. </div>
  151. <div class="ilh-block" id="ilh-regexp-box" style="display: none">
  152. <label for="ilh-regexp">Regexp:</label>
  153. <input type="text" id="ilh-regexp">
  154. </div>
  155. <div id="ilh-search-mode-box">
  156. <label for="ilh-search-mode">Search mode:</label>
  157. <select name="searchmode" id="ilh-search-mode">
  158. <option value="auto" selected>Auto</option>
  159. <option value="imdbid">IMDb IDs</option>
  160. <option value="line">Line</option>
  161. <option value="regexp">Regexp</option>
  162. </select>
  163. </div>
  164. <div id="ilh-import" style="display: none">
  165. <label for="ilh-import-sel">Import .csv from:</label>
  166. <select name="import" id="ilh-import-sel">
  167. <option value="" selected disabled hidden>Select</option>
  168. <option value="imdb">IMDb</option>
  169. <option value="rym">RateYourMusic</option>
  170. <option value="criticker">Criticker</option>
  171. </select>
  172. <span>File:</span>
  173. <input type="file" id="ilh-file-import" disabled>
  174. </div>
  175. </div>`;
  176.  
  177. document.querySelector('div.lister-search').insertAdjacentHTML('afterend', uiHTML);
  178.  
  179. const innerIDs = [
  180. 'mode-list',
  181. 'mode-ratings',
  182. 'film-list',
  183. 'start',
  184. 'skip',
  185. 'retry',
  186. 'films-remaining',
  187. 'current-film',
  188. 'search-mode-box',
  189. 'search-mode',
  190. 'regexp-box',
  191. 'regexp',
  192. 'import',
  193. 'import-sel',
  194. 'file-import',
  195. ];
  196.  
  197. const camelCase = s => s.replace(/-[a-z]/g, m => m[1].toUpperCase());
  198.  
  199. // Main object for interacting with the script's UI; keys match element ids
  200. const ui = Object.assign(...innerIDs.map(id => ({
  201. [camelCase(id)]: document.getElementById(`ilh-${id}`),
  202. })));
  203.  
  204. ui.freezables = [ui.modeList, ui.modeRatings, ui.filmList, ui.start, ui.regexp, ui.searchMode];
  205. const elIMDbSearch = document.getElementById('add-to-list-search');
  206. const elIMDbResults = document.getElementById('add-to-list-search-results');
  207.  
  208. // ----- HANDLERS AND ACTIONS -----
  209.  
  210. const convertRating = n => Math.ceil(n / 10) || 1; // 0..100 -> 1..10
  211. // d3 skips a row if a row conversion function returns null
  212. const joinOrSkip = (...fields) => (fields.includes(undefined) ? null : fields.join(','));
  213. const rowConverters = {
  214. imdb: row => joinOrSkip(row['Your Rating'], row.Const),
  215. rym: row => joinOrSkip(row.Rating, row.Title),
  216. // .csv exported from Criticker have spaces between column names
  217. criticker: row => joinOrSkip(convertRating(+row.Score), row[' IMDB ID']),
  218. };
  219.  
  220. const prepareImport = e => {
  221. const isLegalParser = Boolean(rowConverters[e.target.value]); // in case of html-js mismatch
  222. ui.fileImport.disabled = !isLegalParser;
  223. };
  224.  
  225. const handleImport = e => {
  226. const format = ui.importSel.value;
  227. const reader = new FileReader();
  228. reader.onload = event => {
  229. const fileStr = event.target.result;
  230. ui.filmList.value = d3.csvParse(fileStr, rowConverters[format]).join('\n');
  231. };
  232.  
  233. const [file] = e.target.files;
  234. reader.readAsText(file);
  235. };
  236.  
  237. const RatingManager = {
  238. rating: 0,
  239. regex: /^([1-9]|10),(.*)$/i,
  240. match: (line, mode, regex) => regex.exec(line),
  241. processMatch: ([, rating, filmTitle], callback) => {
  242. RatingManager.rating = rating;
  243. callback(filmTitle);
  244. },
  245. afterClick: async (imdbID, callback) => {
  246. console.log(`RatingManager::afterClick: Rating ${imdbID}`);
  247. const moviePage = await fetch(
  248. `http://www.imdb.com/title/${imdbID}/`,
  249. { credentials: 'same-origin' },
  250. );
  251. const authHash = new DOMParser()
  252. .parseFromString(await moviePage.text(), 'text/html')
  253. .getElementById('star-rating-widget')
  254. .dataset.auth;
  255.  
  256. const params = {
  257. tconst: imdbID,
  258. rating: RatingManager.rating,
  259. auth: authHash,
  260. tracking_tag: 'list',
  261. };
  262.  
  263. const postResp = await fetch('http://www.imdb.com/ratings/_ajax/title', {
  264. method: 'POST',
  265. body: new URLSearchParams(params),
  266. credentials: 'same-origin',
  267. });
  268.  
  269. if (postResp.ok) {
  270. callback();
  271. } else {
  272. alert(`Rating failed. Status code ${postResp.status}`);
  273. }
  274. },
  275. };
  276.  
  277. const ListManager = {
  278. regex: /((?:tt|nm)\d+)/i, // IMDb IDs
  279. match: (line, mode, regex) => ({
  280. /* eslint-disable no-sparse-arrays */
  281. // 'auto' - search for an id (if a string has one) or for a full non-empty string
  282. auto: s => ListManager.regex.exec(s) || s && [, s],
  283. imdbid: s => ListManager.regex.exec(s),
  284. line: s => s && [, s],
  285. regexp: s => regex.exec(s),
  286. /* eslint-enable no-sparse-arrays */
  287. })[mode](line),
  288. processMatch: ([, filmTitle], callback) => callback(filmTitle),
  289. afterClick: (imdbID, callback) => callback(),
  290. };
  291.  
  292. const App = {
  293. manager: ListManager,
  294. films: [],
  295. regexObj: null,
  296. run: () => {
  297. // Set the default value for the 'Regexp' mode
  298. ui.regexp.value = App.manager.regex.source;
  299.  
  300. ui.importSel.addEventListener('change', prepareImport);
  301. ui.fileImport.addEventListener('change', handleImport);
  302.  
  303. ui.searchMode.addEventListener('change', () => {
  304. ui.regexpBox.style.display = ui.searchMode.value === 'regexp' ? '' : 'none';
  305. ui.filmList.placeholder = searchModeHints[ui.searchMode.value];
  306. });
  307.  
  308. ui.modeList.addEventListener('change', () => {
  309. App.manager = ListManager;
  310. ui.import.style.display = 'none';
  311. ui.regexp.value = App.manager.regex.source;
  312. ui.regexpBox.style.display = ui.searchMode.value === 'regexp' ? '' : 'none';
  313. ui.searchModeBox.style.display = '';
  314. ui.filmList.placeholder = searchModeHints[ui.searchMode.value];
  315. });
  316.  
  317. ui.modeRatings.addEventListener('change', () => {
  318. App.manager = RatingManager;
  319. ui.import.style.display = '';
  320. ui.regexp.value = App.manager.regex.source;
  321. ui.regexpBox.style.display = '';
  322. ui.searchModeBox.style.display = 'none';
  323. ui.filmList.placeholder = searchModeHints.rating;
  324. });
  325.  
  326. ui.start.addEventListener('click', () => {
  327. // This will be used only for ListManager's 'regexp' mode or RatingManager
  328. App.regexObj = new RegExp(ui.regexp.value, 'i');
  329.  
  330. // Disable relevant UI elements
  331. ui.freezables.forEach(el => {
  332. el.disabled = true;
  333. });
  334.  
  335. App.films = ui.filmList.value.trim().split('\n');
  336. App.handleNext();
  337. });
  338.  
  339. // When the search popup loses focus, IMDb will hide it after 300 ms,
  340. // so all button clicks that want to keep it visible need to be delayed
  341. ui.skip.addEventListener('click', () =>
  342. setTimeout(() => App.handleNext(), 350));
  343.  
  344. ui.retry.addEventListener('click', () =>
  345. setTimeout(() => elIMDbSearch.dispatchEvent(new Event('keydown')), 350));
  346. },
  347. handleNext: () => {
  348. if (App.films.length) {
  349. App.search(App.films.shift());
  350. } else { // if last film
  351. App.reset();
  352. }
  353. },
  354. reset: () => {
  355. App.films = [];
  356. App.regexObj = null;
  357.  
  358. ui.freezables.forEach(el => {
  359. el.disabled = false;
  360. });
  361.  
  362. ui.currentFilm.value = '';
  363. elIMDbSearch.value = '';
  364. },
  365. search: line => {
  366. line = line.trim();
  367. ui.currentFilm.value = line;
  368. ui.filmsRemaining.textContent = App.films.length;
  369. ui.filmList.value = App.films.join('\n');
  370.  
  371. const result = App.manager.match(line, ui.searchMode.value, App.regexObj);
  372. if (result) {
  373. App.manager.processMatch(result, filmTitle => {
  374. // Set imdb search input field to film title and trigger search
  375. elIMDbSearch.value = filmTitle;
  376. elIMDbSearch.dispatchEvent(new Event('keydown'));
  377. });
  378. } else {
  379. App.handleNext();
  380. }
  381. },
  382. };
  383.  
  384. // Handle clicks on search results by a user or the script
  385. elIMDbResults.addEventListener('click', e => {
  386. const imdbID = e.target.closest('a').dataset.const;
  387. if (!imdbID || !imdbID.startsWith('tt')) return;
  388. App.manager.afterClick(imdbID, () => {
  389. setTimeout(() => App.handleNext(), REQUEST_DELAY);
  390. });
  391. });
  392.  
  393. // Monitor for changes to the search result box.
  394. // If the search was for IMDb URL/ID, the only result is clicked automatically
  395. const mut = new MutationObserver(mutList => mutList.forEach(({ addedNodes }) => {
  396. if (!addedNodes.length || !/(nm|tt)\d{7}/i.test(ui.currentFilm.value)) return;
  397. addedNodes[0].click();
  398. }));
  399. mut.observe(elIMDbResults, { childList: true });
  400.  
  401. App.run();