// ==UserScript==
// @name zaimanhua漫画页转长图
// @namespace https://unlucky.ninja/
// @version 2025.9.17.0
// @description 打开某一话后点击标题右侧按钮生成长图(仅为用于熟人间分享),需要与网络请求修改类浏览器扩展配合
// @author UnluckyNinja
// @license MIT
// @match https://manhua.zaimanhua.com/*
// @connect images.zaimanhua.com
// @require https://code.jquery.com/jquery-3.7.1.slim.min.js
// @require https://update.gf.qytechs.cn/scripts/498113/1395364/waitForKeyElements_mirror.js
// @icon https://www.google.com/s2/favicons?sz=64&domain=idmzj.com
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
// consts
const store = {
canvas_width: GM_getValue('canvas_width', 800),
jpg_quality: GM_getValue('jpg_quality', 0.3),
blob_revoke_timeout: GM_getValue('blob_revoke_timeout', 30000),
}
// return tarB in tarA/tarB=srcA/srcB
function toRatioValue(srcA, srcB, tarA){
return tarA/srcA * srcB
}
function getTotalHeight(images, width = 800){
let totalH = 0
for (let image of images){
if (image instanceof HTMLImageElement) {
const w = image.naturalWidth
const h = image.naturalHeight
totalH += Math.ceil(toRatioValue(w, h, width))
} else if (image instanceof ImageBitmap) {
const w = image.width
const h = image.height
totalH += Math.ceil(toRatioValue(w, h, width))
} else {
console.error('[漫画页转长图] 无效的图片参数', image)
throw new Error('[漫画页转长图] 无效的图片参数')
}
}
return totalH
}
async function corsGetAllImages(urls) {
const images = []
for (const url of urls) {
const res = await GM.xmlHttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
});
const image = await createImageBitmap(res.response)
images.push(image)
}
return images
}
async function genLongImageBlob(){
const imageURLs = [...document.querySelectorAll(".comic_wraCon img").values()].slice(from.value-1, to.value).map(it=>it.src)
const images = await corsGetAllImages(imageURLs)
const canvas = document.createElement('canvas')
const totalHeight = getTotalHeight(images, store.canvas_width)
let targetWidth = store.canvas_width
if (totalHeight > 32767) {
let optimalWidth = Math.floor(32767 / totalHeight * store.canvas_width)
const conti = confirm(`高度超出生成上限: ${totalHeight} / 32767, 若继续生成,则图片宽度将只有:${optimalWidth.toFixed(0)}px,是否继续生成?`)
if (!conti) throw new Error('[漫画页转长图] 高度过大无法绘制')
targetWidth = optimalWidth
}
canvas.width = targetWidth
canvas.height = getTotalHeight(images, targetWidth)
const context = canvas.getContext('2d')
let curH = 0
// const promises = images.map(it=>it.decode())
// await Promise.all(promises)
for (let image of images){
let w, h
if (image instanceof HTMLImageElement) {
w = image.naturalWidth
h = image.naturalHeight
} else if (image instanceof ImageBitmap){
w = image.width
h = image.height
} else {
console.error('[漫画页转长图] 无效的图片参数', image)
throw new Error('[漫画页转长图] 无效的图片格式')
}
const newH = Math.ceil(toRatioValue(w, h, targetWidth))
context.drawImage(image, 0, curH, targetWidth, newH)
curH += newH
}
return new Promise((resolve)=>{
canvas.toBlob((blob)=>{
resolve(blob)
}, 'image/jpeg', store.jpg_quality)
})
}
let downloadButton
let previewButton
async function openImage(){
previewButton.innerText = '生成中...'
previewButton.disabled = true
try {
const blob = await genLongImageBlob()
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
setTimeout(()=>{
URL.revokeObjectURL(url)
}, store.blob_revoke_timeout)
} finally {
previewButton.innerText = '预览长图'
previewButton.disabled = false
}
}
async function downloadImage(){
downloadButton.innerText = '生成中...'
downloadButton.disabled = true
try {
const blob = await genLongImageBlob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const name = document.querySelector('.comic_wraCon h1').textContent
const chapter = document.querySelector('.comic_wraCon h1 ~ span').textContent
a.download = `${name}_${chapter}.jpg`
a.click()
//setTimeout(()=>{
URL.revokeObjectURL(url)
//}, 0)
} finally {
downloadButton.innerText = '下载长图'
downloadButton.disabled = false
}
}
let from
let to
function addButton(node){
const wrapper = document.createElement('span')
wrapper.style.position = 'relative'
const div = document.createElement('div')
div.style.display = 'flex'
div.style.position = 'absolute'
div.style.width = 'max-content'
div.style.height = '100%'
div.style.left = '100%'
div.style.top = '0'
previewButton = document.createElement('button')
previewButton.innerText = '预览长图'
previewButton.style.margin = '0 0.5rem'
previewButton.addEventListener('click', openImage)
div.append(previewButton)
downloadButton = document.createElement('button')
downloadButton.innerText = '下载长图'
//downloadButton.style.margin = '0 0.5rem'
downloadButton.addEventListener('click', downloadImage)
div.append(downloadButton)
// 前后页数跳过
from = document.createElement('input')
to = document.createElement('input')
from.value = 1
to.value = document.querySelectorAll(".comic_wraCon img").length
from.type = 'number'
to.type = 'number'
from.style.width = '40px'
from.style.marginLeft = '4px'
to.style.width = '40px'
to.style.marginLeft = '4px'
const dash = document.createElement('span')
dash.textContent = '-'
dash.style.display = 'flex'
dash.style.alignItems = 'center'
div.append(from)
div.append(dash)
div.append(to)
wrapper.append(div)
node[0].parentElement.append(wrapper)
console.log('[漫画页转长图] 控件已添加至',node[0])
}
waitForKeyElements('.comic_wraCon img', (images)=>{
if (!window.location.href.match(/https?:\/\/manhua.zaimanhua.com\/.*\/.+/)) {
console.info('[漫画页转长图] 非有效页面,跳过')
return
}
console.log(images)
// images[0].crossOrigin = 'anonymous'
})
waitForKeyElements('h1.hotrmtexth1', (nodes)=>{
if (!window.location.href.match(/https?:\/\/manhua.zaimanhua.com\/.*\/.+/)) {
console.info('[漫画页转长图] 非zaimanhua有效页面,跳过')
return
}
console.info('[漫画页转长图] zaimanhua页面,开始添加按钮')
addButton(nodes)
})
function registerMenuCommand(options){
const {
label,
inputLabel,
storeKey,
} = options
const isLabelFunction = typeof label === 'function'
function onMenuHandle(){
const value = promptForNumber({label: inputLabel, defaultValue: store[storeKey]})
if (value === null) return
store[storeKey] = value
GM_setValue(storeKey, value)
if (isLabelFunction) {
menu_id = GM_registerMenuCommand(label(), onMenuHandle, {
autoClose: false,
options: {
id: menu_id
}
});
}
}
let menu_id = GM_registerMenuCommand( isLabelFunction ? label() : label, onMenuHandle, {
autoClose: false,
});
}
registerMenuCommand({
label(){
return `修改图片宽度,当前:${store.canvas_width}px`
},
inputLabel: '输入新宽度',
storeKey: 'canvas_width',
});
registerMenuCommand({
label(){
return `修改图片质量,当前:${store.jpg_quality}`
},
inputLabel: '输入新质量(0~1)',
storeKey: 'jpg_quality',
});
registerMenuCommand({
label(){
return `修改预览过期时间,当前:${store.blob_revoke_timeout}ms`
},
inputLabel: '输入新时间(单位:毫秒)',
storeKey: 'blob_revoke_timeout',
});
})();
function promptForNumber(options = {}){
const {label = '请输入一个数字', defaultValue = undefined, type = 'int' } = options || {}
while(true){
const input = prompt(label, defaultValue);
if (input === null) {
return null
}
let value
try {
if (type === 'float') {
value = parseFloat(input)
} else {
value = parseInt(input)
}
} catch {
alert(`请输入有效的数字`)
continue
}
return value
}
}