Voz save threads

Save your favorite threads

  1. // ==UserScript==
  2. // @name Voz save threads
  3. // @description Save your favorite threads
  4. // @namespace Violentmonkey Scripts
  5. // @match *://voz.vn/*
  6. // @version 0.1
  7. // @run-at document-idle
  8. // @license MIT
  9. // ==/UserScript==
  10.  
  11. const wT = 10; //in ms
  12. const getSync=true; //true de tai trang lan luot, false de lay tat ca cug luc
  13. const sleep = (ms) => new Promise((rs) => setTimeout(rs, ms));
  14.  
  15. async function unZip(data) { //return Blob, lấy text thì them await .text()
  16. let blob=new Blob([data]);
  17. const ds = new DecompressionStream("gzip");
  18. const decompressedStream = blob.stream().pipeThrough(ds);
  19. return await new Response(decompressedStream).blob();
  20. }
  21.  
  22. async function zip(data) { // return Blob
  23. let blob=new Blob([data]);
  24. const cs = new CompressionStream("gzip");
  25. const compressedStream = blob.stream().pipeThrough(cs);
  26. return await new Response(compressedStream).blob();
  27. }
  28.  
  29. HTMLElement.prototype.directText=function (){
  30. let el=this.cloneNode(true);
  31. while (el.children[0]) el.children[0].remove();
  32. return el.textContent;
  33. }
  34.  
  35. let threadId, dump;
  36.  
  37. class IDB {
  38. constructor(...args) { //use new IDB('databaseName',['storeName1','storeName2'....]) then IDB.close()right after to create database and Stores
  39. this.db = null;
  40. if (args.length>1) this.open(...args).then(this.close());
  41. }
  42.  
  43. typeOf = v => Object.prototype.toString.call(v).slice(8,-1);
  44.  
  45. async open(dbName = 'dbName', storeName = 'storeName', version = 1) {
  46. this.dbName = dbName;
  47. this.storeName = storeName;
  48. this.version = version;
  49.  
  50. return new Promise((resolve, reject) => {
  51. const request = indexedDB.open(dbName, version);
  52. request.onupgradeneeded = (event) => {
  53. this.db = event.target.result;
  54. if (this.typeOf(storeName)=='Array') storeName.forEach(s=>this.db.createObjectStore(s))
  55. else this.db.createObjectStore(storeName);
  56. resolve(true);
  57. };
  58.  
  59. request.onsuccess = (event) => {
  60. this.db = event.target.result;
  61. resolve(true);
  62. };
  63.  
  64. request.onerror = (e)=>{console.log(e); reject(e);}
  65. });
  66. }
  67.  
  68. async setItem(key, value) { return this._justDoIt('put', value, key);}
  69. async getItem(key) { return this._justDoIt('get', key);}
  70. async getAll() { return this._justDoIt('getAll');}
  71. async listAll() { return this._justDoIt('getAllKeys');}
  72. async deleteItem(key) { return this._justDoIt('delete', key);}
  73. async deleteAll() { return this._justDoIt('clear');}
  74.  
  75. _justDoIt(operation, key, value) {
  76. const store=this.db.transaction(this.storeName, 'readwrite').objectStore(this.storeName);
  77. return new Promise((resolve, reject) => {
  78. const request = store[operation](key, value);
  79. request.onsuccess = () => resolve(request.result);
  80. request.onerror = reject;
  81. });
  82. }
  83.  
  84. close() {
  85. if (this.db) {
  86. this.db.close();
  87. this.db = null;
  88. this.dbName = null;
  89. this.storeName = null;
  90. }
  91. }
  92. }
  93.  
  94. function convertContent(htmlStr) {
  95. dump=new DOMParser();
  96. let html=dump.parseFromString(htmlStr,'text/html');
  97.  
  98. html.querySelectorAll('[href]').forEach(el=> {
  99. let href=el.getAttribute('href');
  100. if (href.startsWith('/')) el.setAttribute('href','https://voz.vn'+ href);
  101. });
  102.  
  103. html.querySelectorAll('[src]').forEach(el=> {
  104. // if(el.tagName=='SCRIPT') return; //skip script;
  105. let src=el.getAttribute('src');
  106. if (src.startsWith('data:image')) el.setAttribute('src',el.getAttribute('data-src'));
  107. if (src.startsWith('/')) el.setAttribute('src','https://voz.vn'+ src);
  108. });
  109.  
  110. html.querySelectorAll('[srcset]').forEach(el=> {
  111. src=el.getAttribute('srcset').split(',').map(a=>{ if (a.startsWith('/')) return 'https://voz.vn'+a }).join(',');
  112. el.setAttribute('srcset',src);
  113. });
  114.  
  115. //Spoiler
  116. html.querySelectorAll('.bbCodeSpoiler-button,.bbCodeSpoiler-content').forEach(el=>el.classList.add('is-active'))
  117.  
  118. // Sửa link trang
  119. html.querySelectorAll('div.pageNav a').forEach(el=>{
  120. el.removeAttribute('href');
  121. });
  122.  
  123. htmlStr=new XMLSerializer().serializeToString(html);
  124. return htmlStr;
  125. }
  126.  
  127. dump =new IDB('VozSaveThreads',['Threads','Pages']);
  128.  
  129. async function saveThread() {
  130. let threadDB = new IDB(); await threadDB.open('VozSaveThreads','Threads');
  131. let pageDB = new IDB(); await pageDB.open('VozSaveThreads','Pages');
  132.  
  133. const maxPage = parseInt(document.querySelector("ul.pageNav-main>li:last-of-type>a").textContent);
  134. let title = document.querySelector("div.p-body-header > div.p-title > h1").directText();
  135. let thread = await threadDB.getItem(threadId);
  136. if (!thread) thread = { threadId, title, addTime: Date.now(), maxPage: 1};
  137.  
  138. async function saveContent(pageId) {
  139. let pageUrl = `https://voz.vn/t/${threadId}/${pageId}`;
  140. console.log(pageUrl);
  141. let res= await fetch(pageUrl);
  142. const data=await res.text();
  143. await pageDB.setItem(`${threadId}_${pageId.split('-')[1]}`, await zip(convertContent(data)));
  144. }
  145.  
  146. if (maxPage >= (thread.maxPage ?? 1)) {
  147. if(getSync) {
  148. for (let i = thread.maxPage??1; i <= maxPage; i++) {
  149. let pageUrl = `https://voz.vn/t/${threadId}/page-${i}`;
  150. console.log(pageUrl);
  151. let res= await fetch(pageUrl);
  152. if(!res.ok) return false;
  153. const data=await res.text();
  154. if (!data) return false;
  155. await pageDB.setItem(`${threadId}_${i}`, await zip(convertContent(data)));
  156. //Vi chay o content script nen o day cho dong bo
  157. thread.maxPage=i;
  158. await threadDB.setItem(threadId,thread)
  159. await sleep(wT);
  160. }
  161. } else {
  162. let run=[]
  163. for (let i = thread.maxPage??1; i <= maxPage; i++) run.push(saveContent('page-'+i));
  164. await Promise.all(run);
  165. thread.maxPage=maxPage;
  166. await threadDB.setItem(threadId,thread);
  167. }
  168. }
  169. threadDB.close();
  170. pageDB.close();
  171. }
  172.  
  173. (async function main() {
  174. //Create Show Saved Threads button
  175. dump = `<li><div class="p-navEl" style="cursor:pointer;"><div class="p-navEl-link">Saved Threads</div></div></li>`;
  176. document.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul").innerHTML += dump;
  177. document.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul>li>div.p-navEl>div.p-navEl-link").onclick=showSavedThreads;
  178.  
  179. //create SaveThread Button
  180. dump = location.href.match(/https:\/\/(?:.*\.)?voz.vn\/(f|t)\/[a-z\d\-]+.(\d+)\/?(page-(\d+))?/);
  181. let fOrT;
  182. if (dump) { fOrT = dump[1]; threadId = dump[2]; }
  183.  
  184. if (fOrT == "t") {
  185. const btnSave = document.createElement("a");
  186. btnSave.classList.add("pageNav-jump", "pageNav-jump--next");
  187. btnSave.textContent = "Save Thread";
  188. btnSave.onclick = saveThread;
  189. btnSave.style = "cursor:pointer;";
  190. document.querySelectorAll("ul.pageNav-main")
  191. .forEach((el, i) =>i == 0 ? el.parentElement.appendChild(btnSave) : ((dump = btnSave.cloneNode(true)), (dump.onclick = saveThread),el.parentElement.appendChild(dump)));
  192. }
  193. })();
  194.  
  195. async function showSavedThreads(latestFirst = true) {
  196. let html = `<html>
  197. <head>
  198. <title>Voz Saved Threads</title>
  199. </head>
  200. <body>
  201. <div id="list"></div>
  202. <div id="screen"></div>
  203. <style>
  204. :root { font-size: 1.25rem;}
  205. ul[page] {
  206. display: none;
  207. line-height: 1.7rem;
  208. }
  209. ul>input[type="radio"] {display: none;}
  210. ul:has(input[type="radio"]) {display: none;}
  211. ul:has(input[type="radio"]:checked) {display: block;}
  212. #options {border-radius: 2px; border: 1px solid black;margin: 3px;padding: 3px;}
  213. label>input[type="checkbox"] {display:none;}
  214. label:has(input[type="checkbox"])+div {display:none;}
  215. .page_number>span{cursor: pointer;}
  216. ul>li{cursor: pointer;}
  217. li>button {float:right;}
  218. .odd_line {background-color: rgb(237, 237, 235);}
  219. .even_line {background-color: rgb(251, 252, 245);}
  220. #screen {display:none;}
  221. #screen div.pageNav a{cursor: pointer;}
  222. </style>
  223. <script>
  224. class IDB {
  225. constructor(...args) { //use new IDB('databaseName',['storeName1','storeName2'....]) then IDB.close()right after to create database and Stores
  226. this.db = null;
  227. if (args.length>1) this.open(...args).then(this.close());
  228. }
  229. typeOf = v => Object.prototype.toString.call(v).slice(8,-1);
  230. async open(dbName = 'dbName', storeName = 'storeName', version = 1) {
  231. this.dbName = dbName;
  232. this.storeName = storeName;
  233. this.version = version;
  234.  
  235. return new Promise((resolve, reject) => {
  236. const request = indexedDB.open(dbName, version);
  237.  
  238. request.onupgradeneeded = (event) => {
  239. this.db = event.target.result;
  240. if (this.typeOf(storeName)=='Array') storeName.forEach(s=>this.db.createObjectStore(s))
  241. else this.db.createObjectStore(storeName);
  242. };
  243.  
  244. request.onsuccess = (event) => {
  245. this.db = event.target.result;
  246. resolve(true);
  247. };
  248. request.onerror = (e)=>{ console.log('Request Error: ',e); reject(e); }
  249. });
  250. }
  251.  
  252. async setItem(key, value) { return this._justDoIt('put', value, key);}
  253. async getItem(key) { return this._justDoIt('get', key);}
  254. async getAll() { return this._justDoIt('getAll');}
  255. async listAll() { return this._justDoIt('getAllKeys');}
  256. async deleteItem(key) { return this._justDoIt('delete', key);}
  257. async deleteAll() { return this._justDoIt('clear');}
  258.  
  259. _justDoIt(operation, key, value) {
  260. const store=this.db.transaction(this.storeName, 'readwrite').objectStore(this.storeName);
  261. return new Promise((resolve, reject) => {
  262. const request = store[operation](key, value);
  263. request.onsuccess = () => resolve(request.result);
  264. request.onerror = reject;
  265. });
  266. }
  267.  
  268. close() {
  269. if (this.db) {
  270. this.db.close();
  271. this.db = null;
  272. this.dbName = null;
  273. this.storeName = null;
  274. }
  275. }
  276. }
  277.  
  278. async function unZip(data) { //return Blob, lấy text thì them await .text()
  279. let blob=new Blob([data]);
  280. const ds = new DecompressionStream("gzip");
  281. const decompressedStream = blob.stream().pipeThrough(ds);
  282. return await new Response(decompressedStream).blob();
  283. }
  284.  
  285.  
  286. // let dump =new IDB('VozSaveThreads',['Threads','Pages']);
  287. let list = document.getElementById('list');
  288. let screen = document.getElementById('screen');
  289. const threadDB = new IDB();
  290. let pageDB = new IDB();
  291.  
  292. //Create topics list
  293. async function listTopics(tpp=15) {
  294. document.querySelector('head').insertAdjacentHTML('beforeend','<title>Voz Saved Threads</title>')
  295. const threads = await threadDB.getAll();
  296. let pageHeader='<div class="page_number"> Page: ';
  297. let ul='';
  298. for (let i=0; i<threads.length/tpp; i++){
  299. if(i==0) pageHeader+='<span style="font-weight:700"> '+(i+1)+' </span>';
  300. else pageHeader+='<span> '+(i+1)+' </span>';
  301.  
  302. ul+='<ul page=' + (i+1) + '><input type="radio" name="MyNameIsRadioButton" ' + (i==0?"checked":"") + '>'
  303. for (let j=0; j<tpp && i*tpp+j<threads.length; j++)
  304. ul+='<li class="'+ (j%2==0?'even_line':'odd_line') +'"><a threadid="'+ threads[i*tpp+j].threadId + '">'+ threads[i*tpp+j].title + '</a><button threadid="'+ threads[i*tpp+j].threadId+ '">Delete</button></li>';
  305. ul+='</ul>';
  306. }
  307. pageHeader+='</div>'
  308. list.innerHTML=pageHeader+ul+pageHeader;
  309.  
  310. /// Click page number
  311. list.querySelectorAll('div.page_number>span').forEach(span=>span.addEventListener('click',e=>{
  312. let pageNo=e.target.textContent.trim();
  313. list.querySelectorAll('div.page_number>span').forEach(el=> (el.textContent.trim()== pageNo) ? el.style.fontWeight='700':el.style.fontWeight='400');
  314. list.querySelector('ul[page="'+pageNo+'"]>input[type="radio"]').click();
  315. }))
  316.  
  317. //Click topics links
  318. list.querySelectorAll('li>a').forEach(el=>{el.addEventListener('click',async (e) =>await showPage(e.target.getAttribute('threadid')))});
  319.  
  320. //Delete button
  321. list.querySelectorAll('li>button').forEach(el=>{el.addEventListener('click',async (e) =>{
  322. let thread= await threadDB.getItem(e.target.getAttribute('threadid'));
  323. for (let i=1; i<=thread.maxPage; i++) await pageDB.deleteItem(thread.threadId+'_'+i);
  324. threadDB.deleteItem(thread.threadId);
  325. listTopics();
  326. }) });
  327.  
  328. //show it;
  329. list.style.display='block';
  330. screen.style.display='none';
  331. }
  332.  
  333. async function showPage(pageId) {
  334. pageId.split('_').length==1 ? pageId+='_1':pageId;
  335.  
  336. let pageContent= await (await unZip(await pageDB.getItem(pageId))).text();
  337.  
  338. screen.innerHTML=pageContent;
  339.  
  340. //Add "Saved Threads" on Menu
  341. dump = '<li><div class="p-navEl" style="cursor:pointer;"><div class="p-navEl-link">Saved Threads</div></div></li>';
  342. screen.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul").innerHTML += dump;
  343. screen.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul>li>div.p-navEl>div.p-navEl-link").onclick=()=>{
  344. screen.innerHTML='';
  345. list.style.display='block';
  346. screen.style.display='none';
  347. listTopics();}
  348.  
  349. //Page number click
  350. screen.querySelectorAll('ul.pageNav-main a:not([id])').forEach(el=> el.addEventListener('click',e=> {
  351. e.preventDefault();
  352. !isNaN(e.target.innerHTML) ? showPage(pageId.split('_')[0]+'_'+e.target.textContent.trim()):''
  353.  
  354. }));
  355.  
  356. //Goto page Click
  357. screen.querySelectorAll('ul.pageNav-main a[title="Go to page"]')?.forEach(el => el.addEventListener('click',e=>{
  358. let pageNo = prompt('Enter page number','1');
  359. if (isNaN(pageNo)) return;
  360. showPage(pageId.split('_')[0]+'_'+pageNo);
  361. })
  362. )
  363.  
  364. //Next Click
  365. screen.querySelectorAll('.pageNav-jump.pageNav-jump--next')?.forEach(el=>el.addEventListener('click',(e)=> {
  366. const [a,b]=pageId.split('_');
  367. showPage(a+'_'+ (parseInt(b)+1));
  368. } ));
  369.  
  370. //Next Click
  371. screen.querySelectorAll('.pageNav-jump.pageNav-jump--prev')?.forEach(el=>el.addEventListener('click',(e)=> {
  372. const [a,b]=pageId.split('_');
  373. showPage(a+'_'+ (parseInt(b)-1));
  374. } ));
  375.  
  376. //Show it
  377. document.querySelector('title')?.remove();
  378. list.style.display='none';
  379. screen.style.display='block';
  380. }
  381.  
  382. (async function main() {
  383. await pageDB.open('VozSaveThreads','Pages');
  384. await threadDB.open('VozSaveThreads','Threads');
  385. await listTopics();
  386. })();
  387. </script>
  388. </body>
  389. </html>`
  390.  
  391. window.open(URL.createObjectURL(new Blob([html],{type:'text/html'}),'_blank'));
  392. } //End ShowSavedThreads