IMDb - List Helper

Makes creating IMDb lists more efficient and convenient

当前为 2018-01-29 提交的版本,查看 最新版本

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