Last active
June 28, 2025 16:21
-
-
Save FlyInk13/861c085663405bcaba9cd97f0f64047c to your computer and use it in GitHub Desktop.
Manga Translator userscript with ollama
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Manga Translator | |
// @namespace http://tampermonkey.net/ | |
// @version 2025-06-27 | |
// @description Select text block on image and you LLM translate it! | |
// @author Flyink13 | |
// @match https://w7.a-sign-of-affection.online/manga/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=googleusercontent.com | |
// @grant GM_xmlhttpRequest | |
// @connect * | |
// ==/UserScript== | |
(async function() { | |
'use strict'; | |
// Константы для ollama | |
const OLLAMA_ENDPOINT = 'http://localhost:11434/api/'; | |
const OLLAMA_MODEL_ID = "gemma3:12b" | |
const OLLAMA_PROMPT = `Переведи текст на русский язык, отвечай только переведенным текстом или словом ERROR`; | |
// Добавляем корневой элемент для выделения и переводов | |
const appRoot = document.createElement('div'); | |
appRoot.classList.add('mt-app-root'); | |
document.body.appendChild(appRoot); | |
// Добавляем стили | |
document.head.appendChild(document.createElement("style")).innerHTML = ` | |
.mt-text { | |
position: absolute; | |
box-shadow: black 0px 0px 1px 0px; | |
line-height: 1.1em; | |
-pointer-events: none; | |
background: rgb(255, 255, 255, 0.85); | |
min-height: fit-content; | |
font-weight: bold; | |
align-items: center; | |
border-radius: 3px; | |
font-weight: bold; | |
display: flex; | |
font-family: "Comic Sans MS", "Comic Sans", cursive; | |
text-align: center; | |
font-style: italic; | |
opacity: 0; | |
transition: opacity linear .5s; | |
overflow: hidden; | |
} | |
.mt-canvas { | |
position: absolute; | |
box-shadow: 0 0 1px 1px blue; | |
pointer-events: none; | |
background: rgba(0, 0, 255, 0.1); | |
text-align: center; | |
display: block; | |
transition: box-shadow linear .5s; | |
} | |
.mt-select[data-status="processing"] .mt-canvas { box-shadow: 0 0 1px 1px cyan; } | |
.mt-select[data-status="wait"] .mt-canvas { box-shadow: 0 0 1px 1px orange; } | |
.mt-select[data-status="error"] .mt-canvas { box-shadow: 0 0 1px 1px red; } | |
.mt-select[data-status="ok"] .mt-canvas { display: none; } | |
.mt-select[data-status="ok"] .mt-text { opacity: 1; } | |
.mt-select[data-status="ok"] .mt-text.mt-hidden { opacity: 0.0; } | |
`; | |
// Вычисляет размер текста чтобы максимально заполнить блок | |
function adjustFontSize(element, minFontSize = 5, maxFontSize = 50, step = 1) { | |
// Получаем размеры контейнера | |
const containerWidth = parseInt(element.style.width, 10); | |
const containerHeight = parseInt(element.style.height, 10); | |
// Сбрасываем размер шрифта | |
element.style.fontSize = minFontSize + 'px'; | |
// Функция для проверки переполнения | |
function isOverflowing(currentFontSize) { | |
console.log(element.scrollWidth > containerWidth, element.scrollHeight > containerHeight, { sw: element.scrollWidth, cw: containerWidth, sh: element.scrollHeight, ch: containerHeight, currentFontSize }) | |
return element.scrollWidth > containerWidth || element.scrollHeight > containerHeight; | |
} | |
let currentFontSize = minFontSize; | |
let lastGoodFontSize = minFontSize; | |
// Поиск оптимального размера шрифта | |
while (currentFontSize <= maxFontSize) { | |
element.style.fontSize = currentFontSize + 'px'; | |
if (isOverflowing(currentFontSize)) { | |
// Если текст переполняет контейнер, возвращаем последний удачный размер | |
element.style.fontSize = lastGoodFontSize + 'px'; | |
break; | |
} | |
lastGoodFontSize = currentFontSize; | |
currentFontSize += step; | |
} | |
return lastGoodFontSize; | |
} | |
// обертка над api расширения делающего запросы | |
const fetchCors = (url, opts) => { | |
return new Promise((resolve, reject) => { | |
GM_xmlhttpRequest({ | |
url: url, | |
onload: (response) => resolve(response.response), | |
onerror: (error) => reject(error), | |
data: opts.body, | |
...opts, | |
}); | |
}); | |
} | |
// Переводит бинарные данные в base64 | |
function blobToBase64(blob) { | |
return new Promise((resolve, _) => { | |
const reader = new FileReader(); | |
reader.onloadend = () => resolve(reader.result); | |
reader.readAsDataURL(blob); | |
}); | |
} | |
// Ходим в ollama (LLM) с куском картинки и просим перевод | |
const extractText = async (imgBase64) => { | |
const llmResponse = await fetchCors(OLLAMA_ENDPOINT + 'generate', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
responseType: 'json', | |
body: JSON.stringify({ | |
model: OLLAMA_MODEL_ID, | |
responseType: 'json', | |
prompt: OLLAMA_PROMPT, | |
images: [imgBase64.substring(22)], | |
stream: false | |
}), | |
}); | |
console.log(llmResponse); | |
if (!llmResponse.response) return; | |
return llmResponse.response; | |
} | |
// Создаем новое изображение | |
const createProxyImage = (imgBase64) => { | |
return new Promise((resolve, reject) => { | |
const proxyImage = new Image(); | |
proxyImage.onload = async () => resolve(proxyImage); | |
proxyImage.onerror = async () => reject(proxyImage); | |
proxyImage.src = imgBase64; | |
}) | |
} | |
// Функция добавляющая возможность выделения у картинки | |
const addSelector = (img) => { | |
// Отменяет стандартное действи и всплытие события | |
const preventDefault = (event) => { | |
event.preventDefault(); | |
event.stopPropagation(); | |
} | |
// Содаем обработчик события | |
img.onmousedown = (event) => { | |
// Выделение только основной клавишей мыши | |
if (event.button !== 0) return; | |
// Создаем canvas чтобы вырезать кусок картинки | |
const imgSelectEl = document.createElement('div'); | |
const canvas = document.createElement('canvas'); | |
const ctx = canvas.getContext('2d'); | |
// Блок для текста | |
const textEl = document.createElement('div'); | |
// Откуда начали выделение | |
const imgSelection = { | |
selection: true, | |
startY: event.pageY, | |
startX: event.pageX, | |
canvas, | |
selectionImage: img, | |
}; | |
textEl.onclick = () => { | |
textEl.classList.toggle('mt-hidden'); | |
} | |
// Обновление размера выделение с учетом того, что выделять могут в обратную сторону | |
const update = (el, x, y) => { | |
el.width = Math.abs(imgSelection.startX - x); | |
el.height = Math.abs(imgSelection.startY - y); | |
el.style.left = Math.min(imgSelection.startX, x) + 'px'; | |
el.style.top = Math.min(imgSelection.startY, y) + 'px'; | |
el.style.width = el.width + 'px'; | |
el.style.height = el.height + 'px'; | |
} | |
// Добавляем элементы на страницу и стили для них | |
textEl.classList.add('mt-text'); | |
canvas.classList.add('mt-canvas'); | |
imgSelectEl.classList.add('mt-select'); | |
imgSelectEl.appendChild(canvas); | |
imgSelectEl.appendChild(textEl); | |
appRoot.appendChild(imgSelectEl); | |
// Обнавляем размер и отменяем стандарное действие | |
update(canvas, event.pageX, event.pageY); | |
preventDefault(event); | |
img.onclick = preventDefault; | |
// Обработчик перемещения мыши | |
img.onmousemove = (event) => { | |
if (!imgSelection.selection) return; | |
update(canvas, event.pageX, event.pageY); | |
preventDefault(event); | |
} | |
// Обработчик отпускания мыши (законченного выделения) | |
img.onmouseup = async (event) => { | |
preventDefault(event); | |
imgSelection.selection = false; | |
// Обновляем размеры блоков | |
update(canvas, event.pageX, event.pageY); | |
update(textEl, event.pageX, event.pageY); | |
imgSelectEl.dataset.status = 'processing'; | |
// Расчитываем откуда и куда копируем | |
const sx = Math.min(imgSelection.startX, event.pageX) - img.offsetLeft; | |
const sy = Math.min(imgSelection.startY, event.pageY) - img.offsetTop; | |
const dw = Math.abs(imgSelection.startX - event.pageX); | |
const dh = Math.abs(imgSelection.startY - event.pageY); | |
// Вычисляем "zoom" картинки | |
const iratio = (img.clientWidth / img.naturalWidth); | |
// Скачиваем картинку и получаем ее base64, так как обработке будут мешать контентные политики сайта. | |
const imgData = await fetchCors(img.src, { responseType: 'blob' }); | |
const imgBase64 = await blobToBase64(imgData); | |
const proxyImage = await createProxyImage(imgBase64); | |
// Вставляем выделенный кусок картинки в canvas | |
ctx.drawImage(proxyImage, sx / iratio, sy / iratio, dw / iratio, dh / iratio, 0, 0, dw, dh); | |
// Получаем base64 выделенного куска картинки | |
const imageSrcBase64 = canvas.toDataURL("image/png"); | |
// Отпавляем в LLM | |
imgSelectEl.dataset.status = 'wait'; | |
const text = await extractText(imageSrcBase64).catch(e => "ERROR"); | |
// Если LLM не смог - выходим | |
if (text === "ERROR") { | |
imgSelectEl.dataset.status = 'error'; | |
return; | |
}; | |
// Если смог показываем блок с текстом, скрываем канвас и подгоняем размер текста | |
textEl.textContent = text; | |
imgSelectEl.dataset.status = 'ok'; | |
adjustFontSize(textEl); | |
} | |
} | |
} | |
// Ищем все страницы манги на сайте | |
document.querySelectorAll('.separator img').forEach(addSelector); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment