futakuro-auto-thread

スレ本文に含まれるキーワードを設定から保存していた場合、ふたクロの「新着レスに自動スクロール」を自動クリックしスレが落ちるか1000に行ったら次スレを探して移動する

当前为 2023-01-09 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name futakuro-auto-thread
  3. // @namespace https://2chan.net/
  4. // @version 1.1.3
  5. // @description スレ本文に含まれるキーワードを設定から保存していた場合、ふたクロの「新着レスに自動スクロール」を自動クリックしスレが落ちるか1000に行ったら次スレを探して移動する
  6. // @author ame-chan
  7. // @match http://*.2chan.net/b/res/*
  8. // @match https://*.2chan.net/b/res/*
  9. // @icon https://www.2chan.net/favicon.ico
  10. // @grant GM.setValue
  11. // @grant GM.getValue
  12. // @license MIT
  13. // @noframes
  14. // ==/UserScript==
  15. (() => {
  16. 'use strict';
  17. const inlineStyle = `<style id="fat-style">
  18. .fat-dialog {
  19. position: fixed;
  20. right: 16px;
  21. bottom: 16px;
  22. padding: 8px 24px;
  23. max-width: 200px;
  24. line-height: 1.5;
  25. color: #fff;
  26. font-size: 1rem;
  27. background-color: #3e8ed0;
  28. border-radius: 6px;
  29. opacity: 1;
  30. transition: all 0.3s ease;
  31. transform: translateY(0px);
  32. z-index: 10000;
  33. }
  34. .fat-dialog.is-hidden {
  35. opacity: 0;
  36. transform: translateY(100px);
  37. }
  38. .fat-dialog.is-info {
  39. background-color: #3e8ed0;
  40. color: #fff;
  41. }
  42. .fat-dialog.is-danger {
  43. background-color: #f14668;
  44. color: #fff;
  45. }
  46. .fat-icon {
  47. position: fixed;
  48. right: 16px;
  49. bottom: 16px;
  50. padding: 8px;
  51. width: 24px;
  52. height: 24px;
  53. z-index: 9999;
  54. background-color: #fff;
  55. border-radius: 50%;
  56. box-shadow: 0 2px 10px rgb(0 0 0 / 30%);
  57. cursor: pointer;
  58. }
  59. .fat-icon::before {
  60. display: block;
  61. width: 24px;
  62. height: 24px;
  63. content: "";
  64. background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0'%3F%3E%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='100px' height='100px'%3E%3Cpath d='M47.16,21.221l-5.91-0.966c-0.346-1.186-0.819-2.326-1.411-3.405l3.45-4.917c0.279-0.397,0.231-0.938-0.112-1.282 l-3.889-3.887c-0.347-0.346-0.893-0.391-1.291-0.104l-4.843,3.481c-1.089-0.602-2.239-1.08-3.432-1.427l-1.031-5.886 C28.607,2.35,28.192,2,27.706,2h-5.5c-0.49,0-0.908,0.355-0.987,0.839l-0.956,5.854c-1.2,0.345-2.352,0.818-3.437,1.412l-4.83-3.45 c-0.399-0.285-0.942-0.239-1.289,0.106L6.82,10.648c-0.343,0.343-0.391,0.883-0.112,1.28l3.399,4.863 c-0.605,1.095-1.087,2.254-1.438,3.46l-5.831,0.971c-0.482,0.08-0.836,0.498-0.836,0.986v5.5c0,0.485,0.348,0.9,0.825,0.985 l5.831,1.034c0.349,1.203,0.831,2.362,1.438,3.46l-3.441,4.813c-0.284,0.397-0.239,0.942,0.106,1.289l3.888,3.891 c0.343,0.343,0.884,0.391,1.281,0.112l4.87-3.411c1.093,0.601,2.248,1.078,3.445,1.424l0.976,5.861C21.3,47.647,21.717,48,22.206,48 h5.5c0.485,0,0.9-0.348,0.984-0.825l1.045-5.89c1.199-0.353,2.348-0.833,3.43-1.435l4.905,3.441 c0.398,0.281,0.938,0.232,1.282-0.111l3.888-3.891c0.346-0.347,0.391-0.894,0.104-1.292l-3.498-4.857 c0.593-1.08,1.064-2.222,1.407-3.408l5.918-1.039c0.479-0.084,0.827-0.5,0.827-0.985v-5.5C47.999,21.718,47.644,21.3,47.16,21.221z M25,32c-3.866,0-7-3.134-7-7c0-3.866,3.134-7,7-7s7,3.134,7,7C32,28.866,28.866,32,25,32z'/%3E%3C/svg%3E");
  65. background-repeat: no-repeat;
  66. background-size: cover;
  67. transition: all 0.3s ease;
  68. transform: rotate(0deg);
  69. }
  70. .fat-icon:hover::before {
  71. transform: rotate(180deg);
  72. }
  73. .fat-settings {
  74. position: fixed;
  75. bottom: 72px;
  76. right: 16px;
  77. display: flex;
  78. flex-direction: column;
  79. padding: 16px;
  80. max-width: 80%;
  81. width: calc(350px - 32px);
  82. height: fit-content;
  83. color: #202020;
  84. background-color: #fff;
  85. border-radius: 6px;
  86. transition: transform 0.3s ease;
  87. transform: translateX(400px);
  88. z-index: 10001;
  89. }
  90. .fat-settings p {
  91. margin: 0;
  92. padding: 0;
  93. font-size: 16px;
  94. }
  95. .fat-settings p span {
  96. font-size: 13px;
  97. }
  98. .fat-settings textarea {
  99. margin-top: 8px;
  100. padding: 8px;
  101. height: 150px;
  102. max-height: 400px;
  103. min-height: 100px;
  104. line-height: 1.3;
  105. letter-spacing: 0.5px;
  106. font-weight: 400;
  107. font-family: Verdana;
  108. border-radius: 4px;
  109. border: 1px solid #ccc;
  110. resize: vertical;
  111. }
  112. .fat-settings button {
  113. margin-top: 16px;
  114. padding: 8px 16px;
  115. width: fit-content;
  116. color: #fff;
  117. font-size: 13px;
  118. border: 0px;
  119. border-radius: 4px;
  120. background-color: #00d1b2;
  121. appearance: none;
  122. cursor: pointer;
  123. }
  124. .fat-settings button:hover {
  125. filter: saturate(130%);
  126. }
  127. .fat-settings button:active {
  128. filter: saturate(150%);
  129. }
  130. .fat-settings.is-visible {
  131. transform: translateX(0);
  132. }
  133. </style>`;
  134. document.head.insertAdjacentHTML('beforeend', inlineStyle);
  135. const delay = (time = 500) => new Promise((resolve) => setTimeout(() => resolve(true), time));
  136. const getStorageValue = async () => {
  137. const defaultValue = '["twitch.tv/rtainjapan","horaro.org/raidrta"]';
  138. const storageValue = await GM.getValue('fat-condition');
  139. return JSON.parse(storageValue || defaultValue);
  140. };
  141. const setSetting = async () => {
  142. const value = (await getStorageValue()).join('\n');
  143. const toggleSetting = () => {
  144. const settingElm = document.querySelector('[data-fat="settings"]');
  145. settingElm?.classList.toggle('is-visible');
  146. };
  147. const saveSetting = async () => {
  148. const settingConditionElm = document.querySelector(`[data-fat="condition"]`);
  149. if (!settingConditionElm) return;
  150. const valueArray = settingConditionElm.value.split('\n').filter(Boolean);
  151. await GM.setValue('fat-condition', JSON.stringify(valueArray));
  152. const settingElm = document.querySelector('[data-fat="settings"]');
  153. settingElm?.classList.remove('is-visible');
  154. await delay(300);
  155. location.reload();
  156. };
  157. const iconHTML = `<div class="fat-icon" data-fat="icon"></div>`;
  158. const settingHTML = `<div class="fat-settings" data-fat="settings">
  159. <p>スレ本文に以下の文字列がある場合のみ動作。改行でOR判定<br><span>※デフォルト値のようにURLの一部等の固有のキーワードを設定しないと全く関係の無い次スレに遷移する場合があります</span></p>
  160. <textarea data-fat="condition" class="fat-settings-textarea">${value}</textarea>
  161. <button type="button" data-fat="save">条件を保存してリロード</button>
  162. </div>`;
  163. document.body.insertAdjacentHTML('afterbegin', iconHTML);
  164. document.body.insertAdjacentHTML('afterbegin', settingHTML);
  165. await delay(300);
  166. const settingIconElm = document.querySelector(`[data-fat="icon"]`);
  167. settingIconElm?.addEventListener('click', toggleSetting);
  168. const settingSaveElm = document.querySelector(`[data-fat="save"]`);
  169. settingSaveElm?.addEventListener('click', saveSetting);
  170. };
  171. const setDialog = async (dialogText, status) => {
  172. const html = `<div class="fat-dialog is-hidden is-${status}">${dialogText}</div>`;
  173. const dialogElm = document.querySelector('.fat-dialog');
  174. if (dialogElm) {
  175. dialogElm.remove();
  176. }
  177. document.body.insertAdjacentHTML('afterbegin', html);
  178. await delay(100);
  179. document.querySelector('.fat-dialog')?.classList.remove('is-hidden');
  180. };
  181. const getFutabaJson = async (path) => {
  182. const options = {
  183. method: 'GET',
  184. cache: 'no-cache',
  185. credentials: 'include',
  186. };
  187. const result = await fetch(path, options)
  188. .then((res) => {
  189. if (!res.ok) {
  190. throw new Error(res.statusText);
  191. }
  192. return res.arrayBuffer();
  193. })
  194. .catch((err) => {
  195. throw new Error(err);
  196. });
  197. try {
  198. const textDecoder = new TextDecoder('utf-8');
  199. const futabaJson = JSON.parse(textDecoder.decode(result));
  200. return futabaJson;
  201. } catch (e) {
  202. const textDecoder1 = new TextDecoder('Shift_JIS');
  203. const html = textDecoder1.decode(result);
  204. const parser = new DOMParser();
  205. const dom = parser.parseFromString(html, 'text/html');
  206. const bodyText = dom?.body?.textContent;
  207. if (bodyText) {
  208. console.log('json-error:', bodyText);
  209. setDialog(bodyText, 'danger');
  210. if (bodyText.includes('満員')) {
  211. await delay(20000);
  212. return {
  213. res: {},
  214. maxres: '',
  215. old: 0,
  216. };
  217. }
  218. }
  219. throw new Error(e);
  220. }
  221. };
  222. const autoMoveThreads = async (matchText, threadNo) => {
  223. const catalog = await getFutabaJson('/b/futaba.php?mode=json&sort=6');
  224. const threadKeys = Object.keys(catalog?.res || {});
  225. const targetKeyArr = [];
  226. for (const threadKey of threadKeys) {
  227. const threadNoNum = Number(threadNo);
  228. const threadKeyNum = Number(threadKey);
  229. // 見ていたスレッド or 今のスレッドより古いものは飛ばす
  230. if (threadNo === threadKey || threadNoNum > threadKeyNum) continue;
  231. try {
  232. const threadText = catalog?.res?.[threadKey].com ?? false;
  233. if (threadText && threadText.includes(matchText)) {
  234. targetKeyArr.push(threadKeyNum);
  235. }
  236. } catch (e) {
  237. throw new Error(e);
  238. }
  239. }
  240. if (targetKeyArr.length) {
  241. try {
  242. // スレ立て重複した場合はスレ立てが早い方(threadKeyが小さい方)を優先するのでMath.min
  243. const recentThreadKey = targetKeyArr.reduce((a, b) => Math.min(a, b));
  244. // 見ていたスレッドより古いスレッドしかないならfalse
  245. if (Number(threadNo) > recentThreadKey) {
  246. return Promise.resolve(false);
  247. }
  248. const threadStatus = await getFutabaJson(`/b/futaba.php?mode=json&res=${String(recentThreadKey)}`);
  249. const resCount = Object.keys(threadStatus?.res || {}).length;
  250. const isMin950 = resCount >= 0 && resCount < 950;
  251. const isNotMaxRes = threadStatus.maxres === '';
  252. const isNotOld = threadStatus.old === 0;
  253. // レス数が950未満、maxresが空、oldが0なら新規スレッドとみなす
  254. if (isMin950 && isNotMaxRes && isNotOld) {
  255. return Promise.resolve(`/b/res/${recentThreadKey}.htm`);
  256. }
  257. } catch (e1) {
  258. return Promise.resolve(false);
  259. }
  260. }
  261. return Promise.resolve(false);
  262. };
  263. const observeThreadEnd = async (matchText, threadNo) => {
  264. const sec = 1000;
  265. let count = 0;
  266. let fetchTimer = 0;
  267. let observeTimer = 0;
  268. let isRequestOK = false;
  269. let scrollEventHandler = () => {};
  270. let nextThreadCheckInterval = 10000;
  271. let observer = null;
  272. const checkThreadEnd = async () => {
  273. const resElms = document.querySelectorAll('.thre > div[style]:not([style*="clear"]), .thre > table[border]');
  274. const lastAddElm = resElms[resElms.length - 1];
  275. const lastElm = lastAddElm.querySelector('table:last-child') || lastAddElm;
  276. const resNo = lastElm?.querySelector('[data-sno]')?.getAttribute('data-sno');
  277. if (!resNo) return Promise.resolve(false);
  278. const path = `/b/futaba.php?mode=json&res=${threadNo}&start=${resNo}&end=${resNo}`;
  279. const threadStatus = await getFutabaJson(path);
  280. const resCount = Object.keys(threadStatus?.res || {}).length;
  281. if (threadStatus.maxres !== '' || (threadStatus.old === 1 && resCount >= 950)) {
  282. return Promise.resolve(true);
  283. }
  284. return Promise.resolve(false);
  285. };
  286. const getTime = () => {
  287. const zeroPadding = (num) => String(num).padStart(2, '0');
  288. const time = new Date();
  289. const hour = zeroPadding(time.getHours());
  290. const minutes = zeroPadding(time.getMinutes());
  291. const seconds = zeroPadding(time.getSeconds());
  292. return `${hour}:${minutes}:${seconds}`;
  293. };
  294. const updateCheckInterval = (interval) => {
  295. if (count > interval / sec) {
  296. interval = interval * 2;
  297. }
  298. return interval;
  299. };
  300. const tryMoveThreads = async () => {
  301. if (isRequestOK) return;
  302. isRequestOK = true;
  303. window.removeEventListener('scroll', scrollEventHandler);
  304. if (count >= 30) {
  305. setDialog('次スレッドは見つかりませんでした', 'danger');
  306. return;
  307. }
  308. count += 1;
  309. nextThreadCheckInterval = updateCheckInterval(nextThreadCheckInterval);
  310. const dialogText = `[${getTime()}] 次のスレッドを探しています...<br>${count}巡目(${
  311. nextThreadCheckInterval / sec
  312. }秒間隔)`;
  313. setDialog(dialogText, 'info');
  314. const result = await autoMoveThreads(matchText, threadNo);
  315. if (typeof result === 'string') {
  316. return (location.href = result);
  317. }
  318. await delay(nextThreadCheckInterval);
  319. isRequestOK = false;
  320. void tryMoveThreads();
  321. };
  322. const checkThreadEndExec = async (observer) => {
  323. const isThreadEnd = await checkThreadEnd();
  324. if (isThreadEnd) {
  325. if (observer) observer.disconnect();
  326. void tryMoveThreads();
  327. }
  328. };
  329. const resetTimer = () => {
  330. if (fetchTimer) {
  331. clearTimeout(fetchTimer);
  332. }
  333. if (observeTimer) {
  334. clearInterval(observeTimer);
  335. }
  336. };
  337. scrollEventHandler = () => {
  338. resetTimer();
  339. fetchTimer = setTimeout(() => checkThreadEndExec(observer), 6000);
  340. };
  341. const threadDown = document.querySelector('#thread_down');
  342. const observeCallback = () => {
  343. // スレが落ちたらfutakuroによって出現するID
  344. const threadDown = document.querySelector('#thread_down');
  345. resetTimer();
  346. if (threadDown !== null) {
  347. if (observer) observer.disconnect();
  348. void tryMoveThreads();
  349. } else {
  350. observeTimer = setInterval(() => checkThreadEndExec(observer), 10000);
  351. }
  352. };
  353. const resMenuElm = document.querySelector('#res_menu');
  354. await checkThreadEndExec();
  355. if (threadDown !== null) {
  356. resetTimer();
  357. void tryMoveThreads();
  358. } else if (resMenuElm !== null) {
  359. observer = new MutationObserver(observeCallback);
  360. observer.observe(resMenuElm, {
  361. childList: true,
  362. subtree: true,
  363. });
  364. }
  365. window.addEventListener('scroll', scrollEventHandler, {
  366. passive: false,
  367. });
  368. };
  369. const checkAutoLiveScroll = async () => {
  370. /** スレ本文の要素 */ const threadTopText = document.querySelector('#master')?.innerText;
  371. if (typeof threadTopText === 'undefined') return;
  372. /** 新着レスに自動スクロールチェックボックス(futakuro) */ let liveScrollCheckbox =
  373. document.querySelector('#autolive_scroll');
  374. while (liveScrollCheckbox === null) {
  375. await delay(1000);
  376. liveScrollCheckbox = document.querySelector('#autolive_scroll');
  377. }
  378. const hasBody = typeof threadTopText === 'string';
  379. const values = await getStorageValue();
  380. const matchTargetText = values.find((word) => threadTopText.includes(word));
  381. const threadNo = document.querySelector('[data-res]')?.getAttribute('data-res');
  382. if (liveScrollCheckbox !== null && !liveScrollCheckbox.checked && hasBody && matchTargetText) {
  383. liveScrollCheckbox.click();
  384. if (threadNo) {
  385. observeThreadEnd(matchTargetText, threadNo);
  386. }
  387. }
  388. };
  389. const callback = async (_, observer) => {
  390. const liveWindowElm = document.querySelector('#livewindow');
  391. if (liveWindowElm !== null) {
  392. await delay(1000);
  393. void checkAutoLiveScroll();
  394. observer.disconnect();
  395. }
  396. };
  397. setSetting();
  398. const observer = new MutationObserver(callback);
  399. const liveScrollCheckbox = document.querySelector('#autolive_scroll');
  400. if (liveScrollCheckbox === null) {
  401. observer.observe(document.body, {
  402. childList: true,
  403. });
  404. } else {
  405. void checkAutoLiveScroll();
  406. }
  407. })();