AniLINK - Episode Link Extractor

Stream or download your favorite anime series effortlessly with AniLINK! Unlock the power to play any anime series directly in your preferred video player or download entire seasons in a single click using popular download managers like IDM. AniLINK generates direct download links for all episodes, conveniently sorted by quality. Elevate your anime-watching experience now!

当前为 2024-09-02 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name AniLINK - Episode Link Extractor
  3. // @namespace https://greasyfork.org/en/users/781076-jery-js
  4. // @version 4.3.0
  5. // @description Stream or download your favorite anime series effortlessly with AniLINK! Unlock the power to play any anime series directly in your preferred video player or download entire seasons in a single click using popular download managers like IDM. AniLINK generates direct download links for all episodes, conveniently sorted by quality. Elevate your anime-watching experience now!
  6. // @icon https://www.google.com/s2/favicons?domain=animepahe.ru
  7. // @author Jery
  8. // @license MIT
  9. // @match https://anitaku.*/*
  10. // @match https://anitaku.so/*
  11. // @match https://gogoanime.*/*
  12. // @match https://gogoanime3.co/*
  13. // @match https://gogoanime3.*/*
  14. // @match https://animepahe.*/play/*
  15. // @match https://animepahe.ru/play/*
  16. // @match https://animepahe.com/play/*
  17. // @match https://animepahe.org/play/*
  18. // @match https://yugenanime.*/anime/*/*/watch/
  19. // @match https://yugenanime.tv/anime/*/*/watch/
  20. // @match https://yugenanime.sx/anime/*/*/watch/
  21. // @grant GM_registerMenuCommand
  22. // @grant GM_addStyle
  23. // ==/UserScript==
  24.  
  25. class Episode {
  26. constructor(number, title, links, type, thumbnail) {
  27. this.number = number;
  28. this.title = title;
  29. this.links = links;
  30. this.type = type;
  31. this.thumbnail = thumbnail;
  32. this.name = `${this.title} - ${this.number}`;
  33. }
  34. }
  35.  
  36. const websites = [
  37. {
  38. name: 'GoGoAnime',
  39. url: ['anitaku.to/', 'gogoanime3.co/', 'gogoanime3', 'anitaku', 'gogoanime'],
  40. epLinks: '#episode_related > li > a',
  41. epTitle: '.title_name > h2',
  42. linkElems: '.cf-download > a',
  43. thumbnail: '.headnav_left > a > img',
  44. addStartButton: function() {
  45. const button = document.createElement('a');
  46. button.id = "AniLINK_startBtn";
  47. button.style.cssText = `cursor: pointer; background-color: #145132;`;
  48. button.innerHTML = '<i class="icongec-dowload"></i> Generate Download Links';
  49.  
  50. // Add the button to the page if user is logged in otherwise show placeholder
  51. if (document.querySelector('.cf-download')) {
  52. document.querySelector('.cf-download').appendChild(button);
  53. } else {
  54. const loginMessage = document.querySelector('.list_dowload > div > span');
  55. loginMessage.innerHTML = `<b style="color:#FFC119;">AniLINK:</b> Please <a href="/login.html" title="login"><u>log in</u></a> to be able to batch download animes.`;
  56. }
  57. return button;
  58. },
  59. extractEpisodes: async function (status) {
  60. status.textContent = 'Starting...';
  61. let episodes = {};
  62. const episodePromises = Array.from(document.querySelectorAll(this.epLinks)).map(async epLink => { try {
  63. const page = await fetchPage(epLink.href);
  64. const [, epTitle, epNumber] = page.querySelector(this.epTitle).textContent.match(/(.+?) Episode (\d+(?:\.\d+)?)/);
  65. const episodeTitle = `${epNumber.padStart(3, '0')} - ${epTitle}`;
  66. const thumbnail = page.querySelector(this.thumbnail).src;
  67. const links = [...page.querySelectorAll(this.linkElems)].reduce((obj, elem) => ({ ...obj, [elem.textContent.trim()]: elem.href }), {});
  68. status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, '0')}...`;
  69.  
  70. episodes[episodeTitle] = new Episode(epNumber.padStart(3, '0'), epTitle, links, 'mp4', thumbnail);
  71. } catch (e) { showToast(e) } });
  72. await Promise.all(episodePromises);
  73. return episodes;
  74. }
  75. },
  76. {
  77. name: 'YugenAnime',
  78. url: ['yugenanime.tv', 'yugenanime.sx'],
  79. epLinks: '.ep-card > a.ep-thumbnail',
  80. animeTitle: '.ani-info-ep .link h1',
  81. epTitle: 'div.col.col-w-65 > div.box > h1',
  82. thumbnail: 'a.ep-thumbnail img',
  83. addStartButton: function() {
  84. return document.querySelector(".content .navigation").appendChild(Object.assign(document.createElement('a'), { id: "AniLINK_startBtn", className: "link p-15", textContent: "Generate Download Links" }));
  85. },
  86. extractEpisodes: async function (status) {
  87. status.textContent = 'Getting list of episodes...';
  88. let episodes = {};
  89. const epLinks = Array.from(document.querySelectorAll(this.epLinks));
  90. const throttleLimit = 6; // Number of episodes to extract in parallel
  91.  
  92. for (let i = 0; i < epLinks.length; i += throttleLimit) {
  93. const chunk = epLinks.slice(i, i + throttleLimit);
  94. let episodePromises = chunk.map(async (epLink, index) => { try {
  95. status.textContent = `Loading ${epLink.pathname}`
  96. const page = await fetchPage(epLink.href);
  97.  
  98. const animeTitle = page.querySelector(this.animeTitle).textContent;
  99. const epNumber = epLink.href.match(/(\d+)\/?$/)[1];
  100. const epTitle = page.querySelector(this.epTitle).textContent.match(/^${epNumber} : (.+)$/) || animeTitle;
  101. const thumbnail = document.querySelectorAll(this.thumbnail)[index].src;
  102. const episodeTitle = `${epNumber.padStart(3, '0')} - ${animeTitle}` + (epTitle != animeTitle ? `- ${epTitle}` : '');
  103. status.textContent = `Extracting ${episodeTitle}...`;
  104. const links = await this._getVideoLinks(page, status, episodeTitle);
  105.  
  106. episodes[episodeTitle] = new Episode(epNumber.padStart(3, '0'), epTitle, links, 'mp4', thumbnail);
  107. } catch (e) { showToast(e) }});
  108. await Promise.all(episodePromises);
  109. }
  110. return episodes;
  111. },
  112. // BASED ON N-SUDY's anime_scrapper [https://github.com/N-SUDY/anime_scrapper/blob/80a3c985923a32116fef621050c5de56884a4794/scrape.py#L20]
  113. _getVideoLinks: async function (page, status, episodeTitle) {
  114. const embedLinkId = page.body.innerHTML.match(new RegExp(`src="//${page.domain}/e/(.*?)/"`))[1];
  115. const embedApiResponse = await fetch(`https://${page.domain}/api/embed/`, { method: 'POST', headers: {"X-Requested-With": "XMLHttpRequest"}, body: new URLSearchParams({ id: embedLinkId, ac: "0" }) });
  116. const json = await embedApiResponse.json();
  117. const m3u8GeneralLink = json.hls[0];
  118. status.textContent = `Parsing ${episodeTitle}...`;
  119. // Fetch the m3u8 file content
  120. const m3u8Response = await fetch(m3u8GeneralLink);
  121. const m3u8Text = await m3u8Response.text();
  122. // Parse the m3u8 file to extract different qualities
  123. const qualityMatches = m3u8Text.matchAll(/#EXT-X-STREAM-INF:.*RESOLUTION=\d+x\d+.*NAME="(\d+p)"\n(.*\.m3u8)/g);
  124. const links = {};
  125. for (const match of qualityMatches) {
  126. const [_, quality, m3u8File] = match;
  127. links[quality] = `${m3u8GeneralLink.slice(0, m3u8GeneralLink.lastIndexOf('/') + 1)}${m3u8File}`;
  128. }
  129. return links;
  130. }
  131. },
  132. {
  133. name: 'AnimePahe',
  134. url: ['animepahe.ru', 'animepahe.com', 'animepahe.org', 'animepahe'],
  135. epLinks: '.dropup.episode-menu .dropdown-item',
  136. epTitle: '.theatre-info > h1',
  137. linkElems: '#resolutionMenu > button',
  138. thumbnail: '.theatre-info > a > img',
  139. addStartButton: function() {
  140. GM_addStyle(`.theatre-settings .col-sm-3 { max-width: 20%; }`);
  141. document.querySelector("div.theatre-settings > div.row").innerHTML += `
  142. <div class="col-12 col-sm-3">
  143. <div class="dropup">
  144. <a class="btn btn-secondary btn-block" id="AniLINK_startBtn">
  145. Generate Download Links
  146. </a>
  147. </div>
  148. </div>
  149. `;
  150. return document.getElementById("AniLINK_startBtn");
  151. },
  152. extractEpisodes: async function (status) {
  153. status.textContent = 'Starting...';
  154. let episodes = {};
  155. const episodePromises = Array.from(document.querySelectorAll(this.epLinks)).map(async epLink => { try {
  156. const page = await fetchPage(epLink.href);
  157. if (page.querySelector(this.epTitle) == null) return;
  158. const [, epTitle, epNumber] = page.querySelector(this.epTitle).outerText.split(/Watch (.+) - (\d+(?:\.\d+)?) Online$/);
  159. const episodeTitle = `${epNumber.padStart(3, '0')} - ${epTitle}`;
  160. const thumbnail = page.querySelector(this.thumbnail).src;
  161. status.textContent = `Extracting ${epTitle} - ${epNumber.padStart(3, "0")}...`;
  162.  
  163. async function getVideoUrl(kwikUrl) {
  164. const response = await fetch(kwikUrl, { headers: { "Referer": "https://animepahe.com" } });
  165. const data = await response.text();
  166. return eval(/(eval)(\(f.*?)(\n<\/script>)/s.exec(data)[2].replace("eval", "")).match(/https.*?m3u8/)[0];
  167. }
  168. let links = {};
  169. for (const elm of [...page.querySelectorAll(this.linkElems)]) {
  170. links[elm.textContent] = await getVideoUrl(elm.getAttribute('data-src'));
  171. status.textContent = `Parsed ${episodeTitle}`;
  172. }
  173.  
  174. episodes[episodeTitle] = new Episode(epNumber.padStart(3, '0'), epTitle, links, 'm3u8', thumbnail);
  175. } catch (e) { showToast(e) } });
  176. await Promise.all(episodePromises);
  177. console.log(episodes);
  178. return episodes;
  179. },
  180. styles: `div#AniLINK_LinksContainer { font-size: 10px; } #Quality > b > div > ul {font-size: 16px;}`
  181. },
  182.  
  183. ];
  184.  
  185. /**
  186. * Fetches the HTML content of a given URL and parses it into a DOM object.
  187. *
  188. * @param {string} url - The URL of the page to fetch.
  189. * @returns {Promise<Document>} A promise that resolves to a DOM Document object.
  190. * @throws {Error} If the fetch operation fails.
  191. */
  192. async function fetchPage(url) {
  193. const response = await fetch(url);
  194. if (response.ok) {
  195. const page = (new DOMParser()).parseFromString(await response.text(), 'text/html');
  196. return page;
  197. } else {
  198. showToast(`Failed to fetch HTML for ${url} : ${response.status}`);
  199. throw new Error(`Failed to fetch HTML for ${url} : ${response.status}`);
  200. }
  201. }
  202.  
  203. GM_registerMenuCommand('Extract Episodes', extractEpisodes);
  204.  
  205. // initialize
  206. console.log('Initializing AniLINK...');
  207. const site = websites.find(site => site.url.some(url => window.location.href.includes(url)));
  208.  
  209. // attach button to page
  210. site.addStartButton().addEventListener('click', extractEpisodes);
  211.  
  212. // append site specific css styles
  213. document.body.style.cssText += (site.styles || '');
  214.  
  215. // This function creates an overlay on the page and displays a list of episodes extracted from a website.
  216. // The function is triggered by a user command registered with `GM_registerMenuCommand`.
  217. // The episode list is generated by calling the `extractEpisodes` method of a website object that matches the current URL.
  218. async function extractEpisodes() {
  219. // Restore last overlay if it exists
  220. if (document.getElementById("AniLINK_Overlay")) {
  221. document.getElementById("AniLINK_Overlay").style.display = "flex";
  222. return;
  223. }
  224.  
  225. // Create an overlay to cover the page
  226. const overlayDiv = document.createElement("div");
  227. overlayDiv.id = "AniLINK_Overlay";
  228. overlayDiv.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 999; display: flex; align-items: center; justify-content: center;";
  229. document.body.appendChild(overlayDiv);
  230. overlayDiv.onclick = event => linksContainer.contains(event.target) ? null : overlayDiv.style.display = "none";
  231.  
  232. // Create a form to display the Episodes list
  233. const linksContainer = document.createElement('div');
  234. linksContainer.id = "AniLINK_LinksContainer";
  235. linksContainer.style.cssText = "position:relative; height:70%; width:60%; color:cyan; background-color:#0b0b0b; overflow:auto; border: groove rgb(75, 81, 84); border-radius: 10px; padding: 10px 5px; resize: both; scrollbar-width: thin; scrollbar-color: cyan transparent; display: flex; justify-content: center; align-items: center;";
  236. overlayDiv.appendChild(linksContainer);
  237.  
  238. // Create a progress bar to display the progress of the episode extraction process
  239. const statusBar = document.createElement('span');
  240. statusBar.id = "AniLINK_StatusBar";
  241. statusBar.textContent = "Extracting Links..."
  242. statusBar.style.cssText = "background-color: #0b0b0b; color: cyan;";
  243. linksContainer.appendChild(statusBar);
  244.  
  245. // Extract episodes
  246. const episodes = await site.extractEpisodes(statusBar);
  247.  
  248. console.log(episodes);
  249.  
  250. // Get all links into format - {[qual1]:[ep1,2,3,4], [qual2]:[ep1,2,3,4], ...}
  251. const sortedEpisodes = Object.values(episodes).sort((a, b) => a.number - b.number);
  252. const sortedLinks = sortedEpisodes.reduce((acc, episode) => {
  253. for (let quality in episode.links) (acc[quality] ??= []).push(episode);
  254. return acc;
  255. }, {});
  256. console.log('sorted', sortedLinks);
  257.  
  258.  
  259. const qualityLinkLists = Object.entries(sortedLinks).map(([quality, episode]) => {
  260. const listOfLinks = episode.map(ep => {
  261. return `<li id="EpisodeLink" style="list-style-type: none;">
  262. <span style="user-select:none; color:cyan;">
  263. Ep ${ep.number.replace(/^0+/, '')}: </span>
  264. <a title="${ep.title.replace(/[<>:"/\\|?*]/g, '')}" download="${encodeURI(ep.name)}.${ep.type}" href="${ep.links[quality]}" style="color:#FFC119;">
  265. ${ep.links[quality]}</a>
  266. </li>`;
  267. }).join("");
  268.  
  269. return `<ol style="white-space: nowrap;">
  270. <span id="Quality" style="display:flex; justify-content:center; align-items:center;">
  271. <b style="color:#58FFA9; font-size:25px; cursor:pointer; user-select:none;">
  272. -------------------${quality}-------------------\n
  273. </b>
  274. </span>
  275. ${listOfLinks}
  276. </ol><br><br>`;
  277. });
  278.  
  279. // Update the linksContainer with the finally generated links under each quality option header
  280. linksContainer.style.cssText = "position:relative; height:70%; width:60%; color:cyan; background-color:#0b0b0b; overflow:auto; border: groove rgb(75, 81, 84); border-radius: 10px; padding: 10px 5px; resize: both; scrollbar-width: thin; scrollbar-color: cyan transparent;";
  281. linksContainer.innerHTML = qualityLinkLists.join("");
  282.  
  283. // Add hover event listeners to update link text on hover
  284. linksContainer.querySelectorAll('#EpisodeLink').forEach(element => {
  285. const episode = element.querySelector('a');
  286. const link = episode.href;
  287. const name = decodeURIComponent(episode.download);
  288. element.addEventListener('mouseenter', () => window.getSelection().isCollapsed && (episode.textContent = name));
  289. element.addEventListener('mouseleave', () => episode.textContent = decodeURIComponent(link));
  290. });
  291.  
  292. // Add hover event listeners to quality headers to transform them into speed dials
  293. document.querySelectorAll('#Quality b').forEach(header => {
  294. const style = `style="background-color: #00A651; padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer; user-select: none;"`
  295. const sdHTML = `
  296. <div style="display: flex; justify-content: center; padding: 10px;">
  297. <ul style="list-style: none; display: flex; gap: 10px;">
  298. <button type="button" ${style} id="AniLINK_selectLinks">Select</button>
  299. <button type="button" ${style} id="AniLINK_copyLinks">Copy</button>
  300. <button type="button" ${style} id="AniLINK_exportLinks">Export</button>
  301. <button type="button" ${style} id="AniLINK_playLinks">Play with VLC</button>
  302. </ul>
  303. </div>`
  304.  
  305. let headerHTML = header.innerHTML;
  306. header.parentElement.addEventListener('mouseenter', () => (header.innerHTML = sdHTML, attachBtnClickListeners()));
  307. header.parentElement.addEventListener('mouseleave', () => (header.innerHTML = headerHTML));
  308. });
  309.  
  310. // Attach click listeners to the speed dial buttons
  311. function attachBtnClickListeners() {
  312. const buttonIds = [
  313. { id: 'AniLINK_selectLinks', handler: onSelectBtnPressed },
  314. { id: 'AniLINK_copyLinks', handler: onCopyBtnClicked },
  315. { id: 'AniLINK_exportLinks', handler: onExportBtnClicked },
  316. { id: 'AniLINK_playLinks', handler: onPlayBtnClicked }
  317. ];
  318.  
  319. buttonIds.forEach(({ id, handler }) => {
  320. const button = document.querySelector(`#${id}`);
  321. button.addEventListener('click', () => handler(button));
  322. });
  323.  
  324. // Select Button click event handler
  325. function onSelectBtnPressed(it) {
  326. const links = it.closest('ol').querySelectorAll('li');
  327. const range = new Range();
  328. range.selectNodeContents(links[0]);
  329. range.setEndAfter(links[links.length - 1]);
  330. window.getSelection().removeAllRanges();
  331. window.getSelection().addRange(range);
  332. it.textContent = 'Selected!!';
  333. setTimeout(() => { it.textContent = 'Select'; }, 1000);
  334. }
  335.  
  336. // copySelectedLinks click event handler
  337. function onCopyBtnClicked(it) {
  338. const links = it.closest('ol').querySelectorAll('li');
  339. const string = [...links].map(link => link.children[1].href).join('\n');
  340. navigator.clipboard.writeText(string);
  341. it.textContent = 'Copied!!';
  342. setTimeout(() => { it.textContent = 'Copy'; }, 1000);
  343. }
  344.  
  345. // exportToPlaylist click event handler
  346. function onExportBtnClicked(it) {
  347. // Export all links under the quality header into a playlist file
  348. const links = it.closest('ol').querySelectorAll('li');
  349. let string = '#EXTM3U\n';
  350. links.forEach(link => {
  351. const episode = decodeURIComponent(link.children[1].download);
  352. string += `#EXTINF:-1,${episode}\n` + link.children[1].href + '\n';
  353. });
  354. const fileName = links[0].querySelector('a').title + '.m3u';
  355. const file = new Blob([string], { type: 'application/vnd.apple.mpegurl' });
  356. const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(file), download: fileName });
  357. a.click();
  358. it.textContent = 'Exported!!';
  359. setTimeout(() => { it.textContent = 'Export'; }, 1000);
  360. }
  361.  
  362. // PlayWithVLC click event handler
  363. function onPlayBtnClicked(it) {
  364. // Export all links under the quality header into a playlist file
  365. const links = it.closest('ol').querySelectorAll('li');
  366. let string = '#EXTM3U\n';
  367. links.forEach(link => {
  368. const episode = decodeURIComponent(link.children[1].download);
  369. string += `#EXTINF:-1,${episode}\n` + link.children[1].href + '\n';
  370. });
  371. const file = new Blob([string], { type: 'application/vnd.apple.mpegurl' });
  372. const fileUrl = URL.createObjectURL(file);
  373. window.open(fileUrl);
  374. it.textContent = 'Launching VLC!!';
  375. setTimeout(() => { it.textContent = 'Play with VLC'; }, 2000);
  376. alert("Due to browser limitations, there is a high possibility that this feature may not work correctly.\nIf the video does not automatically play, please utilize the export button and manually open the playlist file manually.");
  377. }
  378.  
  379. return {
  380. onSelectBtnPressed,
  381. onCopyBtnClicked,
  382. onExportBtnClicked,
  383. onPlayBtnClicked
  384. };
  385. }
  386. }
  387.  
  388. /***************************************************************
  389. * Display a simple toast message on the top right of the screen
  390. ***************************************************************/
  391. let toasts = [];
  392.  
  393. function showToast(message) {
  394. const maxToastHeight = window.innerHeight * 0.5;
  395. const toastHeight = 50; // Approximate height of each toast
  396. const maxToasts = Math.floor(maxToastHeight / toastHeight);
  397.  
  398. console.log(message);
  399.  
  400. // Create the new toast element
  401. const x = document.createElement("div");
  402. x.innerHTML = message;
  403. x.style.color = "#000";
  404. x.style.backgroundColor = "#fdba2f";
  405. x.style.borderRadius = "10px";
  406. x.style.padding = "10px";
  407. x.style.position = "fixed";
  408. x.style.top = `${toasts.length * toastHeight}px`;
  409. x.style.right = "5px";
  410. x.style.fontSize = "large";
  411. x.style.fontWeight = "bold";
  412. x.style.zIndex = "10000";
  413. x.style.display = "block";
  414. x.style.borderColor = "#565e64";
  415. x.style.transition = "right 2s ease-in-out, top 0.5s ease-in-out";
  416. document.body.appendChild(x);
  417.  
  418. // Add the new toast to the list
  419. toasts.push(x);
  420.  
  421. // Remove the toast after it slides out
  422. setTimeout(() => {
  423. x.style.right = "-1000px";
  424. }, 3000);
  425.  
  426. setTimeout(() => {
  427. x.style.display = "none";
  428. if (document.body.contains(x)) document.body.removeChild(x);
  429. toasts = toasts.filter(toast => toast !== x);
  430. // Move remaining toasts up
  431. toasts.forEach((toast, index) => {
  432. toast.style.top = `${index * toastHeight}px`;
  433. });
  434. }, 4000);
  435.  
  436. // Limit the number of toasts to maxToasts
  437. if (toasts.length > maxToasts) {
  438. const oldestToast = toasts.shift();
  439. document.body.removeChild(oldestToast);
  440. toasts.forEach((toast, index) => {
  441. toast.style.top = `${index * toastHeight}px`;
  442. });
  443. }
  444. }