PTT Imgur Fix

修正 Imgur 在 PTT 上的問題

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址