🏠 Home 

PTT Imgur Fix

修正 Imgur 在 PTT 上的問題


Install this script?
  1. // ==UserScript==
  2. // @name PTT Imgur Fix
  3. // @description 修正 Imgur 在 PTT 上的問題
  4. // @namespace eight04.blogspot.com
  5. // @match https://www.ptt.cc/bbs/*.html
  6. // @match https://www.ptt.cc/man/*.html
  7. // @match https://term.ptt.cc/
  8. // @version 0.9.5
  9. // @author eight
  10. // @homepage https://github.com/eight04/ptt-imgur-fix
  11. // @supportURL https://github.com/eight04/ptt-imgur-fix/issues
  12. // @license MIT
  13. // @compatible firefox Tampermonkey, Violentmonkey, Greasemonkey 4.11+
  14. // @compatible chrome Tampermonkey, Violentmonkey
  15. // @run-at document-start
  16. // @grant GM_getValue
  17. // @grant GM.getValue
  18. // @grant GM_setValue
  19. // @grant GM.setValue
  20. // @grant GM_deleteValue
  21. // @grant GM.deleteValue
  22. // @grant GM_addValueChangeListener
  23. // @grant GM_registerMenuCommand
  24. // @grant GM.registerMenuCommand
  25. // @grant GM_xmlhttpRequest
  26. // @grant GM.xmlHttpRequest
  27. // @require https://greasyfork.org/scripts/371339-gm-webextpref/code/GM_webextPref.js?version=961539
  28. // @require https://cdnjs.cloudflare.com/ajax/libs/sentinel-js/0.0.7/sentinel.min.js
  29. // @connect imgur.com
  30. // ==/UserScript==
  31. /* global GM_webextPref sentinel */
  32. const request = typeof GM_xmlhttpRequest === "function" ? GM_xmlhttpRequest : GM.xmlHttpRequest;
  33. const pref = GM_webextPref({
  34. default: {
  35. term: true,
  36. embedYoutube: true,
  37. youtubeParameters: "",
  38. embedImage: true,
  39. embedAlbum: false,
  40. embedVideo: true,
  41. albumMaxSize: 5,
  42. imgurVideo: false,
  43. lazyLoad: true,
  44. maxWidth: "100%",
  45. maxHeight: "none",
  46. },
  47. body: [
  48. {
  49. key: "embedImage",
  50. label: "Embed image",
  51. type: "checkbox",
  52. },
  53. {
  54. key: "embedVideo",
  55. label: "Embed video",
  56. type: "checkbox",
  57. },
  58. {
  59. key: "embedAlbum",
  60. label: "Embed imgur album. The script would request imgur.com for album info",
  61. type: "checkbox",
  62. children: [
  63. {
  64. key: "albumMaxSize",
  65. label: "Maximum number of images to load for an album",
  66. type: "number"
  67. }
  68. ]
  69. },
  70. {
  71. key: "imgurVideo",
  72. label: "Embed imgur video instead of GIF. Reduce file size",
  73. type: "checkbox"
  74. },
  75. {
  76. key: "embedYoutube",
  77. label: "Embed youtube video",
  78. type: "checkbox",
  79. children: [
  80. {
  81. key: "youtubeParameters",
  82. label: "Youtube player parameters (e.g. rel=0&loop=1)",
  83. type: "text",
  84. default: ""
  85. }
  86. ]
  87. },
  88. {
  89. key: "lazyLoad",
  90. label: "Don't load images until scrolled into view",
  91. type: "checkbox"
  92. },
  93. {
  94. key: "maxWidth",
  95. label: "Maximum width of image",
  96. type: "text",
  97. },
  98. {
  99. key: "maxHeight",
  100. label: "Maximum height of image",
  101. type: "text",
  102. },
  103. ],
  104. navbar: false
  105. });
  106. const lazyLoader = (() => {
  107. const xo = new IntersectionObserver(onXoChange, {rootMargin: "30% 0px 30% 0px"});
  108. const elMap = new Map;
  109. pref.on('change', onPrefChange);
  110. return {add, clear};
  111. function clear() {
  112. for (const target of elMap.values()) {
  113. xo.unobserve(target.el);
  114. }
  115. elMap.clear();
  116. }
  117. function onPrefChange(changes) {
  118. if (changes.lazyLoad == null) return;
  119. if (changes.lazyLoad) {
  120. for (const target of elMap.values()) {
  121. xo.observe(target.el);
  122. }
  123. } else {
  124. xo.disconnect();
  125. for (const target of elMap.values()) {
  126. target.visible = true;
  127. loadTarget(target);
  128. showTarget(target);
  129. }
  130. }
  131. }
  132. function add(el) {
  133. if (elMap.has(el)) return;
  134. const target = {
  135. el,
  136. state: 'pause',
  137. visible: false,
  138. finalUrl: '',
  139. mask: null,
  140. width: 0,
  141. height: 0
  142. };
  143. elMap.set(el, target);
  144. el.classList.add('lazy-target');
  145. if (pref.get('lazyLoad')) {
  146. xo.observe(target.el);
  147. } else {
  148. target.visible = true;
  149. loadTarget(target);
  150. }
  151. }
  152. function onXoChange(entries) {
  153. for (const entry of entries) {
  154. const target = elMap.get(entry.target);
  155. if (!target) {
  156. // unobserved element
  157. continue;
  158. }
  159. if (entry.isIntersecting) {
  160. target.visible = true;
  161. loadTarget(target);
  162. showTarget(target);
  163. } else {
  164. target.visible = false;
  165. hideTarget(target);
  166. }
  167. }
  168. }
  169. async function loadTarget(target) {
  170. if (target.state !== 'pause') return;
  171. target.state = 'loading';
  172. try {
  173. if (target.el.tagName === 'IMG' || target.el.tagName === 'IFRAME') {
  174. setSrc(target.el, target.el.dataset.src);
  175. await loadMedia(target.el);
  176. target.finalUrl = target.el.dataset.src;
  177. } else if (target.el.tagName === 'VIDEO') {
  178. const r = await fetch(target.el.dataset.src, {
  179. referrerPolicy: "no-referrer"
  180. });
  181. const b = await r.blob();
  182. const finalUrl = URL.createObjectURL(b);
  183. target.finalUrl = finalUrl;
  184. target.el.src = finalUrl;
  185. await loadMedia(target.el);
  186. } else {
  187. throw new Error(`Invalid media: ${target.el.tagName}`);
  188. }
  189. target.state = 'complete';
  190. const {offsetWidth: w, offsetHeight: h} = target.el;
  191. target.el.style.aspectRatio = `${w} / ${h}`;
  192. if (target.visible) {
  193. showTarget(target, false);
  194. } else {
  195. hideTarget(target);
  196. }
  197. } catch (err) {
  198. console.error(err);
  199. target.state = 'pause';
  200. }
  201. }
  202. function loadMedia(el) {
  203. return new Promise((resolve, reject) => {
  204. el.classList.add('lazy-load-start');
  205. el.addEventListener('load', onLoad);
  206. el.addEventListener('loadeddata', onLoad);
  207. el.addEventListener('error', onError);
  208. function cleanup() {
  209. el.classList.add('lazy-load-end');
  210. el.removeEventListener('load', onLoad);
  211. el.removeEventListener('loadeddata', onLoad);
  212. el.removeEventListener('error', onError);
  213. }
  214. function onLoad() {
  215. resolve();
  216. cleanup();
  217. }
  218. function onError(e) {
  219. console.error(e);
  220. reject(new Error(`failed loading media: ${el.src}`));
  221. cleanup();
  222. }
  223. });
  224. }
  225. function showTarget(target, useSrc = true) {
  226. if (target.state !== 'complete' && target.state !== 'hidden') return;
  227. if (useSrc) {
  228. setSrc(target.el, target.finalUrl);
  229. loadMedia(target.el)
  230. .then(() => {
  231. if (target.el.style.width) {
  232. target.el.style.width = '';
  233. target.el.style.height = '';
  234. }
  235. });
  236. }
  237. target.state = 'shown';
  238. }
  239. function hideTarget(target) {
  240. if (target.state !== 'complete' && target.state !== 'shown') return;
  241. if (target.el.tagName === 'IFRAME') return;
  242. const {offsetWidth: w, offsetHeight: h} = target.el;
  243. if (w && h) {
  244. target.el.style.width = `${w}px`;
  245. // Waterfox
  246. // https://greasyfork.org/zh-TW/scripts/28264-ptt-imgur-fix/discussions/115795
  247. if (!CSS.supports("aspect-ratio", "1/1")) {
  248. target.el.style.height = `${h}px`;
  249. }
  250. }
  251. setSrc(target.el, 'about:blank');
  252. target.state = 'hidden';
  253. }
  254. })();
  255. document.addEventListener("beforescriptexecute", e => {
  256. var url = new URL(e.target.src, location.href);
  257. if (url.hostname.endsWith("imgur.com")) {
  258. e.preventDefault();
  259. }
  260. });
  261. Promise.all([
  262. pref.ready(),
  263. domReady()
  264. ])
  265. .then(init)
  266. .catch(console.error);
  267. function domReady() {
  268. return new Promise(resolve => {
  269. if (document.readyState !== "loading") {
  270. resolve();
  271. return;
  272. }
  273. document.addEventListener("DOMContentLoaded", resolve, {once: true});
  274. });
  275. }
  276. function createStyle(css) {
  277. const style = document.createElement("style");
  278. style.textContent = css;
  279. document.head.appendChild(style);
  280. }
  281. function init() {
  282. createStyle(`
  283. .ptt-imgur-fix {
  284. max-width: ${pref.get("maxWidth")};
  285. max-height: none;
  286. }
  287. .ptt-imgur-fix img,
  288. .ptt-imgur-fix video,
  289. .ptt-imgur-fix iframe {
  290. max-width: 100%;
  291. max-height: ${pref.get("maxHeight")};
  292. }
  293. .lazy-target:not(.lazy-load-end) {
  294. /* give them a size so that we don't load them all at once */
  295. min-height: 50vh;
  296. }
  297. span[type=bbsrow] .richcontent {
  298. display: flex;
  299. justify-content: center;
  300. .resize-container {
  301. flex-grow: 1;
  302. }
  303. iframe {
  304. aspect-ratio: 16 / 9;
  305. width: 100%;
  306. }
  307. }
  308. `)
  309. if (location.hostname === "term.ptt.cc") {
  310. if (pref.get("term")) {
  311. initTerm();
  312. }
  313. } else {
  314. initWeb();
  315. }
  316. }
  317. function initTerm() {
  318. const selector = "span[type=bbsrow] a:not(.embeded)";
  319. detectEasyReading({
  320. on: () => sentinel.on(selector, onLink),
  321. off: () => {
  322. sentinel.off(selector);
  323. lazyLoader.clear();
  324. }
  325. });
  326. function onLink(node) {
  327. node.classList.add("embeded");
  328. if (node.href) {
  329. const linkInfo = getLinkInfo(node);
  330. const bbsRowDiv = node.closest("span[type=bbsrow] > div");
  331. const hasDefaultContent = !bbsRowDiv.children[1].classList.contains("richcontent");
  332. if (linkInfo.embedable) {
  333. const richContent = createRichContent(linkInfo);
  334. if (!hasDefaultContent) {
  335. bbsRowDiv.appendChild(richContent);
  336. } else {
  337. bbsRowDiv.children[1].replaceWith(richContent);
  338. }
  339. } else if (hasDefaultContent) {
  340. // remove default content under links
  341. bbsRowDiv.children[1].innerHTML = "";
  342. }
  343. }
  344. }
  345. }
  346. function waitElement(selector) {
  347. return new Promise(resolve => {
  348. const id = setInterval(() => {
  349. const el = document.querySelector(selector);
  350. if (el) {
  351. clearInterval(id);
  352. resolve(el);
  353. }
  354. }, 1000);
  355. });
  356. }
  357. async function detectEasyReading({on, off}) {
  358. let state = false;
  359. const easyReadingLastRow = await waitElement("#easyReadingLastRow")
  360. // const easyReadingLastRow = document.querySelector("#easyReadingLastRow");
  361. const observer = new MutationObserver(onMutations);
  362. observer.observe(easyReadingLastRow, {attributes: true, attributeFilter: ["style"]});
  363. function onMutations() {
  364. const newState = easyReadingLastRow.style.display === "block";
  365. if (newState === state) {
  366. return;
  367. }
  368. if (newState) {
  369. on();
  370. } else {
  371. off();
  372. }
  373. state = newState;
  374. }
  375. }
  376. function initWeb() {
  377. // remove old .richcontent
  378. var rich = document.querySelectorAll("#main-content .richcontent");
  379. for (var node of rich) {
  380. node.parentNode.removeChild(node);
  381. }
  382. // embed links
  383. var links = document.querySelectorAll("#main-content a"),
  384. processed = new Set;
  385. for (var link of links) {
  386. if (processed.has(link) || !getLinkInfo(link).embedable) {
  387. continue;
  388. }
  389. var [links_, lineEnd] = findLinksInSameLine(link);
  390. links_.forEach(l => processed.add(l));
  391. for (const link of links_) {
  392. const linkInfo = getLinkInfo(link);
  393. if (!linkInfo.embedable) {
  394. continue;
  395. }
  396. const richContent = createRichContent(linkInfo);
  397. lineEnd.parentNode.insertBefore(richContent, lineEnd.nextSibling);
  398. lineEnd = richContent;
  399. }
  400. // createRichContent(links_, lineEnd);
  401. }
  402. }
  403. function findLinksInSameLine(node) {
  404. var links = [];
  405. while (node) {
  406. if (node.nodeName == "A") {
  407. links.push(node);
  408. node = node.nextSibling || node.parentNode.nextSibling;
  409. continue;
  410. }
  411. if (node.nodeType == Node.TEXT_NODE && node.nodeValue.includes("\n")) {
  412. return [links, findLineEnd(node)];
  413. }
  414. if (node.childNodes.length) {
  415. node = node.childNodes[0];
  416. continue;
  417. }
  418. if (node.nextSibling) {
  419. node = node.nextSibling;
  420. continue;
  421. }
  422. if (node.parentNode.id != "main-content") {
  423. node = node.parentNode.nextSibling;
  424. continue;
  425. }
  426. throw new Error("Invalid article, missing new line?");
  427. }
  428. }
  429. function findLineEnd(text) {
  430. var index = text.nodeValue.indexOf("\n");
  431. if (index == text.nodeValue.length - 1) {
  432. while (text.parentNode.id != "main-content") {
  433. text = text.parentNode;
  434. }
  435. return text;
  436. }
  437. var pre = document.createTextNode("");
  438. pre.nodeValue = text.nodeValue.slice(0, index + 1);
  439. text.nodeValue = text.nodeValue.slice(index + 1);
  440. text.parentNode.insertBefore(pre, text);
  441. return pre;
  442. }
  443. function createRichContent(linkInfo) {
  444. const richContent = document.createElement("div");
  445. richContent.className = "richcontent ptt-imgur-fix";
  446. const embed = createEmbed(linkInfo, richContent);
  447. if (typeof embed === "string") {
  448. richContent.innerHTML = embed;
  449. } else if (embed) {
  450. richContent.appendChild(embed);
  451. }
  452. const lazyTarget = richContent.querySelector("[data-src]");
  453. if (lazyTarget) {
  454. lazyLoader.add(lazyTarget);
  455. }
  456. return richContent;
  457. }
  458. function getLinkInfo(link) {
  459. return getUrlInfo(link.href);
  460. }
  461. function getUrlInfo(url) {
  462. var match;
  463. if ((match = url.match(/\/\/(?:[im]\.)?imgur\.com\/([a-z0-9]{2,})(\.[a-z0-9]{3,4})?/i)) && match[1] != "gallery") {
  464. return {
  465. type: "imgur",
  466. id: match[1],
  467. url: url,
  468. embedable: pref.get("embedImage"),
  469. extension: match[2] && match[2].toLowerCase()
  470. };
  471. }
  472. if ((match = url.match(/\/\/(?:[im]\.)?imgur\.com\/(?:a|gallery)\/([a-z0-9]{2,})/i))) {
  473. return {
  474. type: "imgur-album",
  475. id: match[1],
  476. url: url,
  477. embedable: pref.get("embedAlbum")
  478. };
  479. }
  480. if (
  481. (match = url.match(/youtube\.com\/watch?.*?v=([a-z0-9_-]{9,12})/i)) ||
  482. (match = url.match(/(?:youtu\.be|youtube\.com\/embed)\/([a-z0-9_-]{9,12})/i)) ||
  483. (match = url.match(/youtube\.com\/shorts\/([a-z0-9_-]{9,12})/i)) ||
  484. (match = url.match(/youtube\.com\/live\/([a-z0-9_-]{9,12})/i))
  485. ) {
  486. return {
  487. type: "youtube",
  488. id: match[1],
  489. url: url,
  490. embedable: pref.get("embedYoutube")
  491. };
  492. }
  493. if ((match = url.match(/\/\/pbs\.twimg\.com\/media\/([a-z0-9_-]+\.(?:jpg|png))/i))) {
  494. return {
  495. type: "twitter",
  496. id: match[1],
  497. url: url,
  498. embedable: pref.get("embedImage")
  499. };
  500. }
  501. if ((match = url.match(/\/\/pbs\.twimg\.com\/media\/([a-z0-9_-]+)\?.*format=([\w]+)/i))) {
  502. const ext = match[2] === "webp" ? ".jpg" : `.${match[2]}`;
  503. return {
  504. type: "twitter",
  505. id: `${match[1]}${ext}`,
  506. url: url,
  507. embedable: pref.get("embedImage")
  508. };
  509. }
  510. if (/^[^?#]+\.(?:jpg|png|gif|jpeg|webp|apng|avif|jfif|pjpeg|pjp|svg)(?:$|[?#])/i.test(url)) {
  511. return {
  512. type: "image",
  513. id: null,
  514. url: url,
  515. embedable: pref.get("embedImage")
  516. };
  517. }
  518. if (/.*\.(?:mp4|webm|ogg)(?:$|[?#])/i.test(url)) {
  519. return {
  520. type: "video",
  521. id: null,
  522. url: url,
  523. embedable: pref.get("embedVideo")
  524. };
  525. }
  526. return {
  527. type: "url",
  528. id: null,
  529. url: url,
  530. embedable: false
  531. };
  532. }
  533. function createEmbed(info, container) {
  534. if (info.type == "imgur") {
  535. let extension = info.extension || ".jpg";
  536. if (extension === ".gif" && pref.get("imgurVideo")) {
  537. extension = ".mp4";
  538. }
  539. if (extension === ".gifv") {
  540. extension = pref.get("imgurVideo") ? ".mp4" : ".gif";
  541. }
  542. const url = `//i.imgur.com/${info.id}${extension}`;
  543. if (extension !== ".mp4") {
  544. return `<img referrerpolicy="no-referrer" data-src="${url}">`;
  545. }
  546. const video = document.createElement("video");
  547. video.loop = true;
  548. video.autoplay = true;
  549. video.controls = true;
  550. video.dataset.src = url;
  551. video.muted = true;
  552. return video;
  553. }
  554. if (info.type == "youtube") {
  555. return `<div class="resize-container"><div class="resize-content"><iframe class="youtube-player" type="text/html" data-src="//www.youtube.com/embed/${info.id}?${mergeParams(new URL(info.url).search, pref.get("youtubeParameters"))}" frameborder="0" allowfullscreen></iframe></div></div>`;
  556. }
  557. if (info.type == "image") {
  558. return `<img referrerpolicy="no-referrer" data-src="${info.url}">`;
  559. }
  560. if (info.type == "video") {
  561. const video = document.createElement("video");
  562. video.controls = true;
  563. video.dataset.src = info.url;
  564. return video;
  565. }
  566. if (info.type == "twitter") {
  567. const image = new Image;
  568. const urls = [
  569. `//pbs.twimg.com/media/${info.id}:orig`,
  570. `//pbs.twimg.com/media/${info.id.replace(/\.jpg\b/, ".png")}:orig`,
  571. `//pbs.twimg.com/media/${info.id}:large`,
  572. `//pbs.twimg.com/media/${info.id}`,
  573. ];
  574. image.dataset.src = urls.shift();
  575. const onerror = function onerror() {
  576. if (!urls.length || !image.src.endsWith(image.dataset.src)) {
  577. // not loaded yet
  578. return;
  579. }
  580. const newUrl = urls.shift();
  581. image.dataset.src = newUrl;
  582. image.src = newUrl;
  583. };
  584. const onload = () => {
  585. image.removeEventListener("error", onerror);
  586. image.removeEventListener("load", onload);
  587. }
  588. image.addEventListener("error", onerror);
  589. image.addEventListener("load", onload);
  590. return image;
  591. }
  592. if (info.type == "imgur-album") {
  593. container.textContent = "Loading album...";
  594. request({
  595. method: "GET",
  596. url: `https://api.imgur.com/post/v1/albums/${info.id}?client_id=546c25a59c58ad7&include=media`,
  597. responseType: "json",
  598. onload(response) {
  599. if (response.status < 200 || response.status >= 300) {
  600. container.textContent = `${response.status} ${response.statusText}`;
  601. return;
  602. }
  603. container.textContent = "";
  604. const urls = response.response.media.map(m => m.url);
  605. let i = 0;
  606. const loadImages = (count = Infinity) => {
  607. const els = [];
  608. for (; i < urls.length && count--; i++) {
  609. els.push(createRichContent(getUrlInfo(urls[i])));
  610. }
  611. container.append(...els);
  612. };
  613. loadImages(pref.get("albumMaxSize"));
  614. if (i < urls.length) {
  615. const button = document.createElement("button");
  616. button.textContent = `Load all images (${urls.length - i} more)`;
  617. button.addEventListener('click', () => {
  618. button.remove();
  619. loadImages();
  620. });
  621. container.appendChild(button);
  622. }
  623. }
  624. });
  625. return;
  626. }
  627. throw new Error(`Invalid type: ${info.type}`);
  628. }
  629. function mergeParams(origSearch, userSearch) {
  630. const r###lt = new URLSearchParams();
  631. for (const [key, value] of new URLSearchParams(origSearch)) {
  632. if (key === "t") {
  633. r###lt.set("start", value);
  634. } else {
  635. r###lt.set(key, value);
  636. }
  637. }
  638. for (const [key, value] of new URLSearchParams(userSearch)) {
  639. r###lt.set(key, value);
  640. }
  641. return r###lt.toString();
  642. }
  643. function setSrc(el, url) {
  644. try {
  645. // https://github.com/eight04/ptt-imgur-fix/issues/22
  646. el.contentWindow.location.replace(url);
  647. } catch (err) {
  648. el.src = url;
  649. }
  650. }