// ==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