- // ==UserScript==
- // @name Voz save threads
- // @description Save your favorite threads
- // @namespace Violentmonkey Scripts
- // @match *://voz.vn/*
- // @version 0.1
- // @run-at document-idle
- // @license MIT
- // ==/UserScript==
-
- const wT = 10; //in ms
- const getSync=true; //true de tai trang lan luot, false de lay tat ca cug luc
- const sleep = (ms) => new Promise((rs) => setTimeout(rs, ms));
-
- async function unZip(data) { //return Blob, lấy text thì them await .text()
- let blob=new Blob([data]);
- const ds = new DecompressionStream("gzip");
- const decompressedStream = blob.stream().pipeThrough(ds);
- return await new Response(decompressedStream).blob();
- }
-
- async function zip(data) { // return Blob
- let blob=new Blob([data]);
- const cs = new CompressionStream("gzip");
- const compressedStream = blob.stream().pipeThrough(cs);
- return await new Response(compressedStream).blob();
- }
-
- HTMLElement.prototype.directText=function (){
- let el=this.cloneNode(true);
- while (el.children[0]) el.children[0].remove();
- return el.textContent;
- }
-
- let threadId, dump;
-
- class IDB {
- constructor(...args) { //use new IDB('databaseName',['storeName1','storeName2'....]) then IDB.close()right after to create database and Stores
- this.db = null;
- if (args.length>1) this.open(...args).then(this.close());
- }
-
- typeOf = v => Object.prototype.toString.call(v).slice(8,-1);
-
- async open(dbName = 'dbName', storeName = 'storeName', version = 1) {
- this.dbName = dbName;
- this.storeName = storeName;
- this.version = version;
-
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(dbName, version);
- request.onupgradeneeded = (event) => {
- this.db = event.target.result;
- if (this.typeOf(storeName)=='Array') storeName.forEach(s=>this.db.createObjectStore(s))
- else this.db.createObjectStore(storeName);
- resolve(true);
- };
-
- request.onsuccess = (event) => {
- this.db = event.target.result;
- resolve(true);
- };
-
- request.onerror = (e)=>{console.log(e); reject(e);}
- });
- }
-
- async setItem(key, value) { return this._justDoIt('put', value, key);}
- async getItem(key) { return this._justDoIt('get', key);}
- async getAll() { return this._justDoIt('getAll');}
- async listAll() { return this._justDoIt('getAllKeys');}
- async deleteItem(key) { return this._justDoIt('delete', key);}
- async deleteAll() { return this._justDoIt('clear');}
-
- _justDoIt(operation, key, value) {
- const store=this.db.transaction(this.storeName, 'readwrite').objectStore(this.storeName);
- return new Promise((resolve, reject) => {
- const request = store[operation](key, value);
- request.onsuccess = () => resolve(request.result);
- request.onerror = reject;
- });
- }
-
- close() {
- if (this.db) {
- this.db.close();
- this.db = null;
- this.dbName = null;
- this.storeName = null;
- }
- }
- }
-
- function convertContent(htmlStr) {
- dump=new DOMParser();
- let html=dump.parseFromString(htmlStr,'text/html');
-
- html.querySelectorAll('[href]').forEach(el=> {
- let href=el.getAttribute('href');
- if (href.startsWith('/')) el.setAttribute('href','https://voz.vn'+ href);
- });
-
- html.querySelectorAll('[src]').forEach(el=> {
- // if(el.tagName=='SCRIPT') return; //skip script;
- let src=el.getAttribute('src');
- if (src.startsWith('data:image')) el.setAttribute('src',el.getAttribute('data-src'));
- if (src.startsWith('/')) el.setAttribute('src','https://voz.vn'+ src);
- });
-
- html.querySelectorAll('[srcset]').forEach(el=> {
- src=el.getAttribute('srcset').split(',').map(a=>{ if (a.startsWith('/')) return 'https://voz.vn'+a }).join(',');
- el.setAttribute('srcset',src);
- });
-
- //Spoiler
- html.querySelectorAll('.bbCodeSpoiler-button,.bbCodeSpoiler-content').forEach(el=>el.classList.add('is-active'))
-
- // Sửa link trang
- html.querySelectorAll('div.pageNav a').forEach(el=>{
- el.removeAttribute('href');
- });
-
- htmlStr=new XMLSerializer().serializeToString(html);
- return htmlStr;
- }
-
- dump =new IDB('VozSaveThreads',['Threads','Pages']);
-
- async function saveThread() {
- let threadDB = new IDB(); await threadDB.open('VozSaveThreads','Threads');
- let pageDB = new IDB(); await pageDB.open('VozSaveThreads','Pages');
-
- const maxPage = parseInt(document.querySelector("ul.pageNav-main>li:last-of-type>a").textContent);
- let title = document.querySelector("div.p-body-header > div.p-title > h1").directText();
- let thread = await threadDB.getItem(threadId);
- if (!thread) thread = { threadId, title, addTime: Date.now(), maxPage: 1};
-
- async function saveContent(pageId) {
- let pageUrl = `https://voz.vn/t/${threadId}/${pageId}`;
- console.log(pageUrl);
- let res= await fetch(pageUrl);
- const data=await res.text();
- await pageDB.setItem(`${threadId}_${pageId.split('-')[1]}`, await zip(convertContent(data)));
- }
-
- if (maxPage >= (thread.maxPage ?? 1)) {
- if(getSync) {
- for (let i = thread.maxPage??1; i <= maxPage; i++) {
- let pageUrl = `https://voz.vn/t/${threadId}/page-${i}`;
- console.log(pageUrl);
- let res= await fetch(pageUrl);
- if(!res.ok) return false;
- const data=await res.text();
- if (!data) return false;
- await pageDB.setItem(`${threadId}_${i}`, await zip(convertContent(data)));
- //Vi chay o content script nen o day cho dong bo
- thread.maxPage=i;
- await threadDB.setItem(threadId,thread)
- await sleep(wT);
- }
- } else {
- let run=[]
- for (let i = thread.maxPage??1; i <= maxPage; i++) run.push(saveContent('page-'+i));
- await Promise.all(run);
- thread.maxPage=maxPage;
- await threadDB.setItem(threadId,thread);
- }
- }
- threadDB.close();
- pageDB.close();
- }
-
- (async function main() {
- //Create Show Saved Threads button
- dump = `<li><div class="p-navEl" style="cursor:pointer;"><div class="p-navEl-link">Saved Threads</div></div></li>`;
- document.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul").innerHTML += dump;
- document.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul>li>div.p-navEl>div.p-navEl-link").onclick=showSavedThreads;
-
- //create SaveThread Button
- dump = location.href.match(/https:\/\/(?:.*\.)?voz.vn\/(f|t)\/[a-z\d\-]+.(\d+)\/?(page-(\d+))?/);
- let fOrT;
- if (dump) { fOrT = dump[1]; threadId = dump[2]; }
-
- if (fOrT == "t") {
- const btnSave = document.createElement("a");
- btnSave.classList.add("pageNav-jump", "pageNav-jump--next");
- btnSave.textContent = "Save Thread";
- btnSave.onclick = saveThread;
- btnSave.style = "cursor:pointer;";
- document.querySelectorAll("ul.pageNav-main")
- .forEach((el, i) =>i == 0 ? el.parentElement.appendChild(btnSave) : ((dump = btnSave.cloneNode(true)), (dump.onclick = saveThread),el.parentElement.appendChild(dump)));
- }
- })();
-
- async function showSavedThreads(latestFirst = true) {
- let html = `<html>
- <head>
- <title>Voz Saved Threads</title>
- </head>
- <body>
- <div id="list"></div>
- <div id="screen"></div>
- <style>
- :root { font-size: 1.25rem;}
- ul[page] {
- display: none;
- line-height: 1.7rem;
- }
- ul>input[type="radio"] {display: none;}
- ul:has(input[type="radio"]) {display: none;}
- ul:has(input[type="radio"]:checked) {display: block;}
- #options {border-radius: 2px; border: 1px solid black;margin: 3px;padding: 3px;}
- label>input[type="checkbox"] {display:none;}
- label:has(input[type="checkbox"])+div {display:none;}
- .page_number>span{cursor: pointer;}
- ul>li{cursor: pointer;}
- li>button {float:right;}
- .odd_line {background-color: rgb(237, 237, 235);}
- .even_line {background-color: rgb(251, 252, 245);}
- #screen {display:none;}
- #screen div.pageNav a{cursor: pointer;}
- </style>
- <script>
- class IDB {
- constructor(...args) { //use new IDB('databaseName',['storeName1','storeName2'....]) then IDB.close()right after to create database and Stores
- this.db = null;
- if (args.length>1) this.open(...args).then(this.close());
- }
- typeOf = v => Object.prototype.toString.call(v).slice(8,-1);
- async open(dbName = 'dbName', storeName = 'storeName', version = 1) {
- this.dbName = dbName;
- this.storeName = storeName;
- this.version = version;
-
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(dbName, version);
-
- request.onupgradeneeded = (event) => {
- this.db = event.target.result;
- if (this.typeOf(storeName)=='Array') storeName.forEach(s=>this.db.createObjectStore(s))
- else this.db.createObjectStore(storeName);
- };
-
- request.onsuccess = (event) => {
- this.db = event.target.result;
- resolve(true);
- };
- request.onerror = (e)=>{ console.log('Request Error: ',e); reject(e); }
- });
- }
-
- async setItem(key, value) { return this._justDoIt('put', value, key);}
- async getItem(key) { return this._justDoIt('get', key);}
- async getAll() { return this._justDoIt('getAll');}
- async listAll() { return this._justDoIt('getAllKeys');}
- async deleteItem(key) { return this._justDoIt('delete', key);}
- async deleteAll() { return this._justDoIt('clear');}
-
- _justDoIt(operation, key, value) {
- const store=this.db.transaction(this.storeName, 'readwrite').objectStore(this.storeName);
- return new Promise((resolve, reject) => {
- const request = store[operation](key, value);
- request.onsuccess = () => resolve(request.result);
- request.onerror = reject;
- });
- }
-
- close() {
- if (this.db) {
- this.db.close();
- this.db = null;
- this.dbName = null;
- this.storeName = null;
- }
- }
- }
-
- async function unZip(data) { //return Blob, lấy text thì them await .text()
- let blob=new Blob([data]);
- const ds = new DecompressionStream("gzip");
- const decompressedStream = blob.stream().pipeThrough(ds);
- return await new Response(decompressedStream).blob();
- }
-
-
- // let dump =new IDB('VozSaveThreads',['Threads','Pages']);
- let list = document.getElementById('list');
- let screen = document.getElementById('screen');
- const threadDB = new IDB();
- let pageDB = new IDB();
-
- //Create topics list
- async function listTopics(tpp=15) {
- document.querySelector('head').insertAdjacentHTML('beforeend','<title>Voz Saved Threads</title>')
- const threads = await threadDB.getAll();
- let pageHeader='<div class="page_number"> Page: ';
- let ul='';
- for (let i=0; i<threads.length/tpp; i++){
- if(i==0) pageHeader+='<span style="font-weight:700"> '+(i+1)+' </span>';
- else pageHeader+='<span> '+(i+1)+' </span>';
-
- ul+='<ul page=' + (i+1) + '><input type="radio" name="MyNameIsRadioButton" ' + (i==0?"checked":"") + '>'
- for (let j=0; j<tpp && i*tpp+j<threads.length; j++)
- 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>';
- ul+='</ul>';
- }
- pageHeader+='</div>'
- list.innerHTML=pageHeader+ul+pageHeader;
-
- /// Click page number
- list.querySelectorAll('div.page_number>span').forEach(span=>span.addEventListener('click',e=>{
- let pageNo=e.target.textContent.trim();
- list.querySelectorAll('div.page_number>span').forEach(el=> (el.textContent.trim()== pageNo) ? el.style.fontWeight='700':el.style.fontWeight='400');
- list.querySelector('ul[page="'+pageNo+'"]>input[type="radio"]').click();
- }))
-
- //Click topics links
- list.querySelectorAll('li>a').forEach(el=>{el.addEventListener('click',async (e) =>await showPage(e.target.getAttribute('threadid')))});
-
- //Delete button
- list.querySelectorAll('li>button').forEach(el=>{el.addEventListener('click',async (e) =>{
- let thread= await threadDB.getItem(e.target.getAttribute('threadid'));
- for (let i=1; i<=thread.maxPage; i++) await pageDB.deleteItem(thread.threadId+'_'+i);
- threadDB.deleteItem(thread.threadId);
- listTopics();
- }) });
-
- //show it;
- list.style.display='block';
- screen.style.display='none';
- }
-
- async function showPage(pageId) {
- pageId.split('_').length==1 ? pageId+='_1':pageId;
-
- let pageContent= await (await unZip(await pageDB.getItem(pageId))).text();
-
- screen.innerHTML=pageContent;
-
- //Add "Saved Threads" on Menu
- dump = '<li><div class="p-navEl" style="cursor:pointer;"><div class="p-navEl-link">Saved Threads</div></div></li>';
- screen.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul").innerHTML += dump;
- screen.querySelector("div.p-navSticky>nav>div>div.p-nav-scroller>div>ul>li>div.p-navEl>div.p-navEl-link").onclick=()=>{
- screen.innerHTML='';
- list.style.display='block';
- screen.style.display='none';
- listTopics();}
-
- //Page number click
- screen.querySelectorAll('ul.pageNav-main a:not([id])').forEach(el=> el.addEventListener('click',e=> {
- e.preventDefault();
- !isNaN(e.target.innerHTML) ? showPage(pageId.split('_')[0]+'_'+e.target.textContent.trim()):''
-
- }));
-
- //Goto page Click
- screen.querySelectorAll('ul.pageNav-main a[title="Go to page"]')?.forEach(el => el.addEventListener('click',e=>{
- let pageNo = prompt('Enter page number','1');
- if (isNaN(pageNo)) return;
- showPage(pageId.split('_')[0]+'_'+pageNo);
- })
- )
-
- //Next Click
- screen.querySelectorAll('.pageNav-jump.pageNav-jump--next')?.forEach(el=>el.addEventListener('click',(e)=> {
- const [a,b]=pageId.split('_');
- showPage(a+'_'+ (parseInt(b)+1));
- } ));
-
- //Next Click
- screen.querySelectorAll('.pageNav-jump.pageNav-jump--prev')?.forEach(el=>el.addEventListener('click',(e)=> {
- const [a,b]=pageId.split('_');
- showPage(a+'_'+ (parseInt(b)-1));
- } ));
-
- //Show it
- document.querySelector('title')?.remove();
- list.style.display='none';
- screen.style.display='block';
- }
-
- (async function main() {
- await pageDB.open('VozSaveThreads','Pages');
- await threadDB.open('VozSaveThreads','Threads');
- await listTopics();
- })();
- </script>
- </body>
- </html>`
-
- window.open(URL.createObjectURL(new Blob([html],{type:'text/html'}),'_blank'));
- } //End ShowSavedThreads