// ==UserScript==
// @name For Imhentai
// @namespace http://tampermonkey.net/
// @version 1.4
// @description 不翻墙下,更快加载 imhentai.xxx 的图片,并提供打包下载
// @author 水母
// @match https://imhentai.xxx/gallery/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// @require https://cdn.bootcdn.net/ajax/libs/jszip/3.10.1/jszip.min.js
// @require https://cdn.bootcdn.net/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 全局数据
let IS_INIT = false;
let IS_RUN = false;
let IS_DOWNLOADING = false;
// 页码序列 [cover.jpg, 1.jpg, ..., 30.jpg] : cover不计入页数; eg.总页数定为 30; 数组 [0] 定为 cover
let CURRENT_PAGE = 0; // 当前页码
let MAX_BROWSE_PAGE = 0; // 最大浏览的页码,只增
let PAGE_LOADED = 0; // 已加载的页码
let SCALE = 1; // 图片整体缩放
// 用户定义的下载页码区间
let UserCustemRange = {
min: 0,
max: 0,
page_loaded: 0,
};
// enum
const BtnID = {
runBtn: 'run',
previousBtn: 'pre',
nextBtn: 'next',
downloadBtn: 'down',
scaleUpBtn: 'sUp',
scaleResetBtn: 'sReset',
scaleDownBtn: 'sDown',
};
const BtnText = {
runBtn: '启动🥰',
previousBtn: '⫷',
nextBtn: '⫸',
downloadBtn: '下载🥵',
scaleUpBtn: '⇱',
scaleResetBtn: '↺◲',
scaleDownBtn: '⇲',
};
/**
* 异步 FileReader 加载完毕计算器
*/
const CounterForFileReader = {
is_lock: false,
count: 0,
/**
* 如果更新成功,返回 true
* @returns {boolean}
*/
update() {
if (this.is_lock) return false;
else {
this.is_lock = true;
this.count++;
this.is_lock = false;
return true;
}
},
};
/**
*
* @param {string} name_en
* @param {string} name_sub
* @param {number} page
* @param {string} root_url
* @param {ImgInfo[]} imgInfoList
* @param {string[]} types
*/
function BzData(
name_en = 'Null',
name_sub = 'Null',
page = 0,
root_url = '',
imgInfoList = [],
types = ['.jpg', '.png', '.gif', '.err']
) {
this.name_en = name_en;
this.name_sub = name_sub;
this.page = page;
this.root_url = root_url;
this.imgInfoList = imgInfoList;
this.types = types;
}
/**
*
* @param {string} imgName
* @param {string} imgAlt
* @param {string} imgUrl
* @param {string} imgType
* @param {number} width
* @param {number} height
* @param {number} SCALE
* @param {Image} imgObj
* @param {string} imageBase64
*/
function ImgInfo(
imgName,
imgAlt,
imgUrl = '',
imgType = '',
width = 0,
height = 0,
SCALE = 1,
imgObj = null,
imageBase64 = ''
) {
this.imgName = imgName;
this.imgAlt = !imgAlt ? imgName : imgAlt;
this.imgUrl = imgUrl;
this.imgType = imgType;
this.width = width;
this.height = height;
this.SCALE = SCALE;
this.imgObj = imgObj;
this.imageBase64 = imageBase64;
}
/**
* BzData 迭代器
* @param {BzData} bzData
*/
function* BzDataIterator(bzData) {
let index = 0;
while (index < bzData.imgInfoList.length) {
let imgInfo = bzData.imgInfoList[index];
yield [index++, bzData.root_url, imgInfo];
}
}
/**
* 漫画名去特殊字符处理
* @param {string} filename 文件名
* @return {string} 处理后的文件名
*/
function processFilename(filename) {
return filename
.replaceAll('\\', '-')
.replaceAll('/', '-')
.replaceAll(':', ':')
.replaceAll('*', '-')
.replaceAll('?', '?')
.replaceAll('"', '“')
.replaceAll('<', '《')
.replaceAll('>', '》')
.replaceAll('|', '~');
}
/**
* 判断图片 url 有效与否
* @returns {Promise<Image>}
*/
function verifyImgExists(imgUrl) {
return new Promise((resolve, reject) => {
let ImgObj = new Image();
ImgObj.src = imgUrl;
ImgObj.onload = () => resolve(ImgObj);
ImgObj.onerror = (rej) => reject(rej);
});
}
/**
* 为 ImgInfo 测试三种后缀
* @param {string} root_url
* @param {ImgInfo} imgInfo
* @param {string[]} [types=['.jpg', '.png', '.gif', '.err']]
*/
async function processImgInfoAsync(
root_url,
imgInfo,
types = ['.jpg', '.png', '.gif', '.err']
) {
// 测试三种后缀
for (let type of types) {
imgInfo.imgUrl = root_url + imgInfo.imgName + type;
imgInfo.imgType = type;
try {
let ImgObj = await verifyImgExists(imgInfo.imgUrl);
imgInfo.imgObj = ImgObj;
imgInfo.width = ImgObj.width;
imgInfo.height = ImgObj.height;
break;
} catch (e) {
continue; // 未测试最后一个,继续
}
}
}
/**
* 为所有图片生成正确后缀类型
* @param {BzDataIterator} bzDataIterator
*/
async function processImgAsync(bzDataIterator) {
for (let [index, root_url, imgInfo] of bzDataIterator) {
await processImgInfoAsync(root_url, imgInfo);
PAGE_LOADED = index;
}
}
/**
* 循环数据,直至所有图片的 imageBase64 加载完全
* @param {BzData} bzData
* @param {number} min
* @param {number} max
*/
const watchImgInfoAsync = async (bzData, min, max) => {
console.log('watchImgInfoAsync');
document.querySelector(`#${BtnID.downloadBtn}`).textContent = 'Waiting...';
let is_done = false;
let intervalID = setInterval(
(bzData, is_done) => {
// 检查是否加载完全
for (let index = max; index >= min; index--) {
if (bzData.imgInfoList[index].imageBase64 === '') break;
else is_done = true;
}
// 更新进度
let percentage = Math.round(
(CounterForFileReader.count / (max - min + 1)) * 100
);
document.querySelector(
`#${BtnID.downloadBtn}`
).textContent = `Loading ${percentage}%`;
if (is_done) {
// 下载开始
clearInterval(intervalID);
downloadZip(bzData, min, max);
}
},
1500,
bzData,
is_done
);
};
/**
* 获取图片的 base64 编码
* @param {BzData} bzData
* @param {number} min
* @param {number} max
*/
const getImageBase64Async = async (bzData, min, max) => {
for (let i = min; i <= max; i++) {
if (bzData.imgInfoList[i].imageBase64 !== '') continue;
try {
let reader = new FileReader();
reader.onloadend = function () {
bzData.imgInfoList[i].imageBase64 = reader.result;
// 持续,直至更新计数
let intervalID = setInterval(() => {
if (CounterForFileReader.update()) clearInterval(intervalID);
}, Math.round(Math.random() * 1000));
};
// 加载图片的 blob 类型数据
if (bzData.imgInfoList[i].imgType !== '.err') {
let imgBlob = await fetch(bzData.imgInfoList[i].imgUrl).then(
(respone) => respone.blob()
);
reader.readAsDataURL(imgBlob); // 将 blob 数据转换成 DataURL 数据
} else {
reader.readAsDataURL(new Blob()); // 空文件
}
} catch (e) {
console.error(e);
}
}
};
/**
* 批量下载图片
* @param {BzData} bzData 图像数据
* @param {number} min
* @param {number} max
*/
const downloadZip = async (bzData, min, max) => {
document.querySelector(`#${BtnID.downloadBtn}`).textContent = '打包';
const zip = new JSZip();
// 图片 url json 文件,去除 imageBase64 数据
let bzDataUser = {
...bzData,
imgInfoList: bzData.imgInfoList.map((imgInfo) => {
return { ...imgInfo, imageBase64: '' };
}),
};
let stringData = JSON.stringify(bzDataUser, null, 2);
zip.file(`${bzData.name_en}.json`, stringData);
// 图片 zip
const fileFolder = zip.folder(bzData.name_en); // 创建 bzData.name_en 文件夹
const fileList = [];
for (let i = min; i <= max; i++) {
let name = bzData.imgInfoList[i].imgName + bzData.imgInfoList[i].imgType;
let imageBase64 = bzData.imgInfoList[i].imageBase64.substring(22); // 截取 data:image/png;base64, 后的数据
fileList.push({ name: name, img: imageBase64 });
}
// 往 zip 中,添加每张图片数据
for (let imgFile of fileList) {
fileFolder.file(imgFile.name, imgFile.img, {
base64: true,
});
}
zip.generateAsync({ type: 'blob' }).then((content) => {
saveAs(content, bzData.name_en + '.zip');
});
// 按钮还原
document.querySelector(`#${BtnID.downloadBtn}`).textContent =
BtnText.downloadBtn;
IS_DOWNLOADING = false;
};
/**
* 数据初始化,获取漫画名、页数、图片的 url
*/
function initData() {
let bzData = new BzData();
let coverUrl;
// cover
bzData.imgInfoList.push(new ImgInfo('cover'));
const tag_div_main = document.querySelectorAll(
'body > div.overlay > div.container > div.row.gallery_first > div'
);
// 获取漫画名
bzData.name_en = tag_div_main[1].querySelector('h1').textContent;
bzData.name_sub = tag_div_main[1].querySelector('p.subtitle').textContent;
// 漫画名去特殊字符处理
if (bzData.name_sub !== '') {
bzData.name_sub = processFilename(bzData.name_sub);
}
if (bzData.name_en !== '') {
bzData.name_en = processFilename(bzData.name_en);
} else {
bzData.name_en = bzData.name_sub;
}
// 获取页数
let page_str = tag_div_main[1].querySelector('li.pages').textContent;
bzData.page = Number.parseInt(page_str.match(/Pages: ([0-9]*)/i)[1]);
// 图片序列的 url 前缀与封面 url 的相同,
// eg.封面 url=https://m7.imhentai.xxx/023/mnsiote3jg/cover.jpg
// eg.序列的 url=https://m7.imhentai.xxx/023/mnsiote3jg/
coverUrl = tag_div_main[0].querySelector('img').dataset.src;
bzData.root_url = coverUrl.slice(0, coverUrl.lastIndexOf('/') + 1);
// 图片序列的 url 生成,
// eg: https://m6.imhentai.xxx/021/fh5n1d304g/1.jpg
for (let p = 1; p <= bzData.page; p++) {
bzData.imgInfoList.push(new ImgInfo(p.toString())); // 图片名未编码,数字序列就行
}
let bzDataIterator = BzDataIterator(bzData);
// 初始化 cover 数据,让 CURRENT_PAGE 与 PAGE_LOADED 能够对齐
let [index, root_url, coverInfo] = bzDataIterator.next().value;
let ImgObj = new Image();
ImgObj.src = coverUrl;
ImgObj.onload = () => {
coverInfo.width = ImgObj.width;
coverInfo.height = ImgObj.height;
};
coverInfo.imgUrl = coverUrl;
coverInfo.imgType = coverUrl
.substring(coverUrl.lastIndexOf('.'))
.toLowerCase();
return [bzData, bzDataIterator];
}
/**
* 初始化组件
* @param {BzData} bzData
* @param {BzDataIterator} bzDataIterator
*/
function initComponents(bzData, bzDataIterator) {
// <img>
const newImg = document.createElement('img');
newImg.id = 'can-img';
newImg.style = `
-webkit-user-select: none;
margin:0 auto;
transition: background-color 300ms;
`;
// <input>
const changePageInput = document.createElement('input');
changePageInput.id = 'can-input';
changePageInput.type = 'number';
changePageInput.value = `${CURRENT_PAGE}`;
changePageInput.disabled = true;
changePageInput.style = `
width: 45%;height: 80%;
font-size:18px;text-align:center;
`;
// <label>
const pageLabel = document.createElement('label');
pageLabel.id = 'can-page';
pageLabel.style =
'width: 55%;height: 80%;font-size:18px;text-align:center;margin: 0;background-color: hsla(0, 0%, 90%, 90%);';
pageLabel.textContent =
PAGE_LOADED === bzData.page ? `${PAGE_LOADED}` : `${PAGE_LOADED}`;
// <button>
const runBtn = document.createElement('button');
const previousBtn = document.createElement('button');
const nextBtn = document.createElement('button');
const downloadBtn = document.createElement('button');
const scaleUpBtn = document.createElement('button');
const scaleResetBtn = document.createElement('button');
const scaleDownBtn = document.createElement('button');
runBtn.id = BtnID.runBtn;
previousBtn.id = BtnID.previousBtn;
nextBtn.id = BtnID.nextBtn;
downloadBtn.id = BtnID.downloadBtn;
scaleUpBtn.id = BtnID.scaleUpBtn;
scaleResetBtn.id = BtnID.scaleResetBtn;
scaleDownBtn.id = BtnID.scaleDownBtn;
runBtn.textContent = BtnText.runBtn;
previousBtn.textContent = BtnText.previousBtn;
nextBtn.textContent = BtnText.nextBtn;
downloadBtn.textContent = BtnText.downloadBtn;
scaleUpBtn.textContent = BtnText.scaleUpBtn;
scaleResetBtn.textContent = BtnText.scaleResetBtn;
scaleDownBtn.textContent = BtnText.scaleDownBtn;
runBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
// 异步加载图片信息
if (!IS_INIT) {
IS_INIT = true;
processImgAsync(bzDataIterator);
}
if (!IS_RUN) {
IS_RUN = true;
evt.target.textContent = `原页 ${bzData.page}`;
// 生效按钮
let btns = document
.querySelector('#can-app')
.querySelectorAll('button');
for (const btn of btns) {
btn.disabled = false;
}
let inputPage = document.querySelector('#can-input');
inputPage.disabled = false;
// 显示 新 <img>
let _newImg = document.querySelector('#can-div-img');
_newImg.style.display = 'block';
} else {
IS_RUN = false;
evt.target.textContent = BtnText.runBtn;
// 无效按钮
let btns = document
.querySelector('#can-app')
.querySelectorAll('button');
for (const btn of btns) {
btn.disabled = btn.id !== 'run' ? true : false;
}
let inputPage = document.querySelector('#can-input');
inputPage.disabled = true;
// 隐藏新 <img>
let _newImg = document.querySelector('#can-div-img');
_newImg.style.display = 'none';
}
});
previousBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
let imgInfo =
bzData.imgInfoList[
CURRENT_PAGE > 0 ? --CURRENT_PAGE : (CURRENT_PAGE = MAX_BROWSE_PAGE)
];
updateImgTag(imgInfo);
let inputPage = document.querySelector('#can-input');
let page_ = document.querySelector('#can-page');
inputPage.value = CURRENT_PAGE;
if (PAGE_LOADED !== bzData.page) {
let percentage = Number.parseInt((PAGE_LOADED / bzData.page) * 100);
if (percentage < 25) page_.textContent = `${PAGE_LOADED} ◕`;
else if (percentage < 50) page_.textContent = `${PAGE_LOADED} ◑`;
else if (percentage < 75) page_.textContent = `${PAGE_LOADED} ◔`;
else if (percentage < 100) page_.textContent = `${PAGE_LOADED} ☯`;
} else {
page_.textContent = `${PAGE_LOADED}`;
}
});
nextBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
let imgInfo =
bzData.imgInfoList[
CURRENT_PAGE < PAGE_LOADED
? ++CURRENT_PAGE
: (CURRENT_PAGE = PAGE_LOADED !== bzData.page ? CURRENT_PAGE : 0) // 完全加载完前不会 '溢出跳 0'
];
updateImgTag(imgInfo);
let inputPage = document.querySelector('#can-input');
let page_ = document.querySelector('#can-page');
inputPage.value = CURRENT_PAGE;
if (PAGE_LOADED !== bzData.page) {
let percentage = Number.parseInt((PAGE_LOADED / bzData.page) * 100);
if (percentage < 25) page_.textContent = `${PAGE_LOADED} ◕`;
else if (percentage < 50) page_.textContent = `${PAGE_LOADED} ◑`;
else if (percentage < 75) page_.textContent = `${PAGE_LOADED} ◔`;
else if (percentage < 100) page_.textContent = `${PAGE_LOADED} ☯`;
} else {
page_.textContent = `${PAGE_LOADED}`;
}
if (MAX_BROWSE_PAGE < CURRENT_PAGE) MAX_BROWSE_PAGE = CURRENT_PAGE;
});
downloadBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
// 打包 zip
if (!IS_DOWNLOADING) {
IS_DOWNLOADING = true;
UserCustemRange.page_loaded = PAGE_LOADED;
if (UserCustemRange.page_loaded !== bzData.page) {
let result = confirm(
`当前${UserCustemRange.page_loaded}页,图片未加载完全,是否继续?🤨`
);
if (!result) {
IS_DOWNLOADING = false;
return;
}
}
let result = prompt(
'选择下载页面区间,请使用 [英文符号 - ] 隔开😇',
`0-${UserCustemRange.page_loaded}`
);
if (result) {
let rangeRegExp = result.match(/^(\d+)-(\d+)$/);
if (rangeRegExp) {
UserCustemRange.min = Number.parseInt(rangeRegExp[1]);
UserCustemRange.max = Number.parseInt(rangeRegExp[2]);
// 处理意外输入
if (
!rangeRegExp ||
0 > UserCustemRange.min ||
UserCustemRange.min > UserCustemRange.max ||
UserCustemRange.max > UserCustemRange.page_loaded
) {
alert('无效输入😥');
IS_DOWNLOADING = false;
return;
}
} else {
alert('无效输入😥');
IS_DOWNLOADING = false;
return;
}
watchImgInfoAsync(bzData, UserCustemRange.min, UserCustemRange.max);
getImageBase64Async(bzData, UserCustemRange.min, UserCustemRange.max);
} else {
IS_DOWNLOADING = false;
}
}
});
scaleUpBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
SCALE += SCALE < 3 ? 0.1 : 0;
let imgInfo = bzData.imgInfoList[CURRENT_PAGE];
updateImgTag(imgInfo);
});
scaleResetBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
SCALE = 1;
let imgInfo = bzData.imgInfoList[CURRENT_PAGE];
updateImgTag(imgInfo);
});
scaleDownBtn.addEventListener('click', (evt) => {
evt.stopPropagation();
SCALE -= SCALE > 0.3 ? 0.1 : 0;
let imgInfo = bzData.imgInfoList[CURRENT_PAGE];
updateImgTag(imgInfo);
});
changePageInput.addEventListener('change', (evt) => {
evt.stopPropagation();
if (0 <= evt.target.value && evt.target.value <= bzData.page) {
CURRENT_PAGE = evt.target.value;
let imgInfo = bzData.imgInfoList[CURRENT_PAGE];
updateImgTag(imgInfo);
}
});
const app = document.createElement('div');
app.id = 'can-app';
app.style = `
font-size:20px; color:HotPink;
width:120px; height:200px; background-color:hsla(0, 0%, 90%, 50%);
display:flex; flex-direction:column; justify-content:space-between;
position:fixed; top:40%; z-index:1000002; transform:translateX(calc(-50% * var(--direction))) translateY(-50%);
`;
const div_tool = document.createElement('div');
div_tool.style = `
display:flex; flex-direction:column; justify-content:space-between;
height:160px; background-color:hsla(0, 0%, 90%, 50%);
`;
const div_scale = document.createElement('div');
div_scale.style = `
display:flex; flex-direction:row; justify-content:space-between;
`;
const div_page = document.createElement('div');
div_page.style = `
align-items:center;
display:flex; flex-direction:row; justify-content:space-between;
`;
app.appendChild(runBtn);
div_tool.appendChild(previousBtn);
div_tool.appendChild(nextBtn);
div_scale.appendChild(scaleUpBtn);
div_scale.appendChild(scaleResetBtn);
div_scale.appendChild(scaleDownBtn);
div_tool.appendChild(div_scale);
div_page.appendChild(changePageInput);
div_page.appendChild(pageLabel);
div_tool.appendChild(div_page);
div_tool.appendChild(downloadBtn);
app.appendChild(div_tool);
document.body.appendChild(app);
// 包裹 <img> 并悬浮居中
const div_img = document.createElement('div');
div_img.id = 'can-div-img';
// 粉色
div_img.style = `
display:none;
position: fixed;overflow: auto;
width: 80%;height: 100%;top: 0%;z-index: 1000001;
left: 0;right: 0;margin:0 auto;text-align: center;
background-color: hsla(338, 100%, 70%, 0.8);
`;
div_img.appendChild(newImg);
document.body.appendChild(div_img);
let btns = document.querySelector('#can-app').querySelectorAll('button');
for (const btn of btns) {
btn.style = 'font-size:20px; color:HotPink;';
btn.disabled = btn.id !== 'run' ? true : false;
}
}
// 更新 <img>
function updateImgTag(imgInfo) {
let div_img = document.querySelector('#can-div-img');
let newImg_ = document.querySelector('#can-img');
newImg_.src = imgInfo.imgUrl;
newImg_.alt = imgInfo.imgAlt;
if (imgInfo.imgType !== '.err') {
newImg_.width = imgInfo.width * SCALE;
newImg_.height = imgInfo.height * SCALE;
} else {
newImg_.style.removeProperty('width');
newImg_.style.removeProperty('height');
}
}
initComponents(...initData());
})();