Цель: Самодостаточный документ. Имея только его, можно написать полнофункциональный плагин без доступа к исходному коду. Основан на анализе 27 нативных источников, изучении LuaSourceLoader.kt и реальном опыте отладки.
- Чеклист разработки
- Архитектура и жизненный цикл
- Критические правила LuaJ
- Анализ сайта — Decision Tree
- Структура файла плагина
- Глобальное Lua API — Полный справочник
- Cover-трансформации (обязательно читать)
- Реализация каталога и поиска
- Реализация деталей книги
- Реализация списка глав
- Реализация текста главы
- Настройки плагина (getSettingsSchema)
- Паттерны и сценарии
- Best Practices
- Антипаттерны и частые ошибки
- Отладка и логирование
- Регистрация плагина
- Шаг 1: Анализ сайта. Chrome DevTools (F12). URL каталога, пагинация, AJAX, кодировка.
- Шаг 2: Файл.
lang/source_id.lua - Шаг 3: Метаданные.
id,name,baseUrl,language,icon,version. - Шаг 4: URL-хелпер. Реализовать
absUrl()для resolve относительных ссылок. - Шаг 5: Cover-трансформация. Миниатюры → полный размер, прокси если нужно.
- Шаг 6: Каталог и поиск.
getCatalogList+getCatalogSearchсhasNext. - Шаг 7: Книга.
getBookTitle,getBookCoverImageUrl,getBookDescription. - Шаг 8: Главы.
getChapterList(oldest-first) +getChapterText. - Шаг 9: Чистка текста. Реклама, скрипты, навигация.
- Шаг 10 (опц.): Настройки.
getSettingsSchema()если плагин требует конфигурации. - Шаг 11: Регистрация.
index.yaml+ иконка.
Приложение загружает .lua через LuaJ. Скрипт выполняется — все top-level переменные и функции регистрируются в globals. Адаптер читает из globals по имени.
Важно: адаптер передаёт cover в UI без каких-либо трансформаций — coverImageUrl = table.get("cover").optjstring(""). Вся логика URL обложки целиком на стороне плагина.
1. Загрузка → globals["id"], globals["name"], globals["baseUrl"], ...
2. Каталог → getCatalogList(0), getCatalogList(1), ... пока hasNext=true
3. Поиск → getCatalogSearch(0, query), ...
4. Книга → getBookTitle(url) + getBookDescription(url) + getBookCoverImageUrl(url)
5. Главы → getChapterList(url) -- oldest-first
6. Чтение → приложение скачивает HTML → getChapterText(html, url)
ВАЖНО: второй аргумент url добавлен в v2 — используй его!
7. Обновления → getChapterListHash(url) -- любая строка меняющаяся при новых главах
8. Настройки → getSettingsSchema() -- схема UI (необязательно)
LuaJ = Lua 5.1. Нарушение →
LuaError: attempt to index ? (a nil value).
-- НЕВЕРНО — адаптер ищет функции в globals, return{} их туда не кладёт
return { getCatalogList = function(index) ... end }
-- ВЕРНО
function getCatalogList(index) ... endhtml_select / html_select_first возвращают таблицу. Поля — через ., Java-методы — через :.
-- ПОЛЯ (через точку):
el.text -- текст элемента
el.html -- внутренний HTML (передавать в html_select/html_text)
el.href -- абсолютный URL из href
el.src -- абсолютный URL из src
el.title -- атрибут title
el.class -- атрибут class
el.id -- атрибут id
-- МЕТОДЫ (через двоеточие, зарегистрированы из Java — РАБОТАЮТ):
el:attr("data-src") -- любой атрибут по имени
el:select("css selector") -- поиск внутри элемента → массив
el:get_text() -- = el.text
el:get_html() -- = el.html
el:remove() -- удалить из DOM (полезно внутри :select цикла)-- html_attr — удобная функция без нужды в объекте элемента
local val = html_attr(html_string, "css selector", "attr_name")
-- Возвращает "" если не найдено, НИКОГДА не nil-- НЕВЕРНО — e.text это Java-объект, не Lua string
local found = e.text:find("pattern")
-- ВЕРНО — сначала сохранить в переменную
local t = e.text
local found = t:find("pattern")
-- ВЕРНО — использовать API
local m = regex_match(e.text, "pattern")-- НЕВЕРНО
goto continue
-- ВЕРНО — условный блок
if condition then ... endlog_error("code=" .. tostring(r.code))-- regex_match возвращает массив ПОЛНЫХ совпадений всего паттерна:
local m = regex_match("/novel/12345/", "/(%d+)/")
-- m[1] = "/12345/" ← полное совпадение, НЕ capture group!
-- Для capture groups используй нативный Lua string.match:
local id = string.match("/novel/12345/", "/(%d+)/")
-- id = "12345" ← правильно
-- regex_match полезен когда нужны ВСЕ совпадения паттерна:
local nums = regex_match("1,2,3,4", "%d+")
-- nums[1]="1", nums[2]="2", nums[3]="3", nums[4]="4"-- ВЕРНО (v2) — второй аргумент url теперь всегда передаётся адаптером
function getChapterText(html, url)
-- используй url для API-запросов вместо парсинга из HTML
end
-- УСТАРЕВШИЙ стиль (работает, но url придётся искать самому)
function getChapterText(html)
local url = html_attr(html, "link[rel='canonical']", "href")
endКонтент:
- Чистый HTML →
http_get+html_select - JSON API →
http_get+json_parse - API + шифрование →
http_postк прокси илиaes_decrypt - Требует перевода →
google_translate+ постобработка
Пагинация каталога:
?page=1— большинство сайтов?offset=0&limit=20— API- URL-паттерн:
novels_0_0_1.htm
Список глав:
- Всё на странице → парсить HTML
- Paginated HTML (≤10 стр.) → цикл
?page=Nсsleep(300) - Paginated HTML (10+ стр.) →
http_get_batch— параллельная загрузка - AJAX GET → отдельный запрос с ID (WtrLab:
/api/chapters/{novelId}) - AJAX POST → WordPress
admin-ajax.php - JSON API → REST-эндпоинт с томами
Кодировка:
- Китайские сайты →
charset = "GBK"везде - GBK поиск →
url_encode_charset(query, "GBK")
Обложки:
- Миниатюра в каталоге → трансформировать URL
- Hotlink-защита → прокси wsrv.nl
-- ── Метаданные ────────────────────────────────────────────────────────────────
id = "source_id"
name = "Source Name"
version = "1.0.0"
baseUrl = "https://example.com/"
language = "en" -- ISO 639-1: en, ru, zh, es, de, fr, it, pl, id, tr
-- Для MTL: language = "MTL" → отображается как "MTL"
icon = "https://..."
-- ── URL-хелпер (рекомендуется всегда) ────────────────────────────────────────
local function absUrl(href)
if href == "" then return "" end
if string_starts_with(href, "http") then return href end
if string_starts_with(href, "//") then return "https:" .. href end
return url_resolve(baseUrl, href)
end
-- ── applyStandardContentTransforms (копировать в каждый плагин) ──────────────
local function applyStandardContentTransforms(text)
if not text or text == "" then return "" end
text = string_normalize(text)
local domain = baseUrl:gsub("https?://", ""):gsub("^www%.", ""):gsub("/$", "")
text = regex_replace(text, "(?i)" .. domain .. ".*?\\n", "")
text = regex_replace(text, "(?i)\\A[\\s\\p{Z}\\uFEFF]*((Глава\\s+\\d+|Chapter\\s+\\d+)[^\\n\\r]*[\\n\\r\\s]*)+", "")
text = regex_replace(text, "(?im)^\\s*(Перевод|Переводчик|Редакция|Редактор|Аннотация|Сайт|Источник|Студия)[:\\s][^\\n\\r]{0,70}(\\r?\\n|$)", "")
text = regex_replace(text, "(?im)^\\s*(Translator|Editor|Proofreader|Read\\s+(at|on|latest))[:\\s][^\\n\\r]{0,70}(\\r?\\n|$)", "")
text = string_trim(text)
return text
end
-- ── Cover-хелперы (по необходимости) ─────────────────────────────────────────
local function transformCover(coverUrl) ... end
-- ── Local вспомогательные функции ────────────────────────────────────────────
local function helper() ... end
-- ── Обязательные функции (top-level) ─────────────────────────────────────────
function getCatalogList(index) ... end
function getCatalogSearch(index, query) ... end
function getBookTitle(bookUrl) ... end
function getBookCoverImageUrl(bookUrl) ... end
function getBookDescription(bookUrl) ... end
function getChapterList(bookUrl) ... end
function getChapterText(html, url) ... end -- url — второй аргумент (v2)
-- ── Необязательные функции ───────────────────────────────────────────────────
function getChapterListHash(bookUrl) ... end
function getSettingsSchema() ... end -- настройки плагина (см. раздел 12)getCatalogList / getCatalogSearch:
return {
items = {
{ title = "Title", url = "https://...", cover = "https://..." },
},
hasNext = true
}getChapterList:
return {
{ title = "Chapter 1", url = "https://...", volume = "Vol.1" }, -- volume необязателен
}
-- Порядок: oldest → newestgetChapterText:
return "<p>Paragraph 1</p>\n<p>Paragraph 2</p>"
-- html_text() возвращает правильный формат автоматическиlocal r = http_get(url)
local r = http_get(url, { headers = { ["Referer"] = baseUrl }, charset = "GBK" })
-- r.success (bool), r.body (string), r.code (int)
local r = http_post(url, body, { headers = { ["Content-Type"] = "application/json" } })
local r = http_post(url, "key=val", {
charset = "GBK",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" }
})-- Параллельная загрузка нескольких URL (v4) ─────────────────────────────────── local results = http_get_batch(urls_table) -- urls_table — Lua-массив строк { "https://...", "https://...", ... } -- results — Lua-массив { success, body, code } в том же порядке что urls_table -- Запросы выполняются параллельно через OkHttp connection pool -- НЕ передавай headers/charset — только URL строки -- НЕ нужен sleep перед вызовом — запросы не блокируют друг друга
### HTML (Jsoup)
```lua
-- Массив элементов
local els = html_select(html_or_element, "css selector")
-- Первый или nil
local el = html_select_first(html_or_element, "css selector")
-- Атрибут без объекта (возвращает "" если нет)
local val = html_attr(html_string, "selector", "attr_name")
-- Текст с правильными абзацами
local text = html_text(html_or_element)
-- Удаление элементов → очищенный HTML
local cleaned = html_remove(html, "script", ".ads", "h3")
-- Парсинг → { text, html, title, body }
local doc = html_parse(html_string)
-- Поля элемента: el.text, el.html, el.href, el.src, el.title, el.class, el.id
-- Методы элемента: el:attr("name"), el:select("sel"), el:remove(), el:get_text()
CSS-селекторы (Jsoup):
.class, #id, tag, tag.class
a[href], img[src], meta[property='og:image']
div#catalog, ul#list
.parent > .child
li:nth-child(2), li:last-child
a:contains(Next)
.sm\\:text-lg -- экранирование : в классах
json_parse(str) -- string → lua table
json_stringify(val) -- lua value → string
url_encode(str) -- UTF-8
url_encode_charset(str, "GBK") -- нестандартная кодировка
url_resolve(base, relative) -- абсолютный URL
regex_match(text, pattern) -- массив ПОЛНЫХ совпадений
regex_replace(text, pattern, repl) -- замена (Kotlin Regex)
string.match(text, "(pattern)") -- нативный Lua, capture groups
string_trim(str)
string_normalize(str) -- NFKC Unicode
string_clean(str) -- normalize + collapse whitespace + trim (v4)
string_split(str, sep) -- → массив
string_starts_with(str, prefix)
string_ends_with(str, suffix)
unescape_unicode(str) -- \uXXXX → символыbase64_decode(str)
base64_encode(str)
aes_decrypt(b64, key, iv) -- AES/CBC/PKCS5
google_translate(text, sourceLang, targetLang [, origin])
-- sourceLang: "zh-CN", "en", "ru", etc.
-- targetLang: "ru", "en", "es", "de", "pl", "it", "fr", "id", "tr"
-- origin: ОБЯЗАТЕЛЬНО передавать baseUrl — без него API вернёт 400!
-- Пример: google_translate(html, "en", "ru", baseUrl)
-- Возвращает переведённый текст или оригинал при ошибке
-- ВАЖНО: принимает HTML с тегами <p>, возвращает HTML с тегами
get_preference(key) -- "" если нет
set_preference(key, value)
sleep(ms)
os_time() -- Unix timestamp мс
log_info("msg")
log_error("msg")Адаптер передаёт cover в UI без изменений. Вся логика — в плагине.
local function absUrl(href)
if href == "" then return "" end
if string_starts_with(href, "http") then return href end
if string_starts_with(href, "//") then return "https:" .. href end
return url_resolve(baseUrl, href)
end-- Jaomix: убрать -150x150
local function jaomixCover(url)
return regex_replace(url, "%-150x150", "")
end
-- Общий паттерн: убрать размер-суффикс
local function removeSizeSuffix(url)
return regex_replace(url, "%-%d+x%d+", "")
endlocal function weservProxy(coverUrl)
if coverUrl == "" then return "" end
if not string_starts_with(coverUrl, "http") then return coverUrl end
local stripped = regex_replace(coverUrl, "^https?://", "")
return "https://wsrv.nl/?url=" .. url_encode(stripped) .. "&https=1"
endМногие сайты используют lazy-loading — src пустой, реальный URL в data-src:
local cover = html_attr(card, "img", "src")
if cover == "" then cover = html_attr(card, "img", "data-src") end
cover = absUrl(cover)function getCatalogList(index)
local page = index + 1
local r = http_get(baseUrl .. "novels?page=" .. page)
if not r.success then return { items = {}, hasNext = false } end
local items = {}
for _, card in ipairs(html_select(r.body, ".novel-card")) do
local titleEl = html_select_first(card.html, "h3 a")
local imgEl = html_select_first(card.html, "img")
if titleEl then
table.insert(items, {
title = string_trim(titleEl.text),
url = absUrl(titleEl.href),
cover = imgEl and absUrl(imgEl.src) or ""
})
end
end
-- Предпочтительно: hasNext по наличию items, не по селектору
-- local nextEl = html_select_first(r.body, "a.next, .pagination .next")
return { items = items, hasNext = #items > 0 }
endfunction getCatalogSearch(index, query)
if index > 0 then return { items = {}, hasNext = false } end
local r = http_post(
baseUrl .. "modules/article/search.php",
"searchkey=" .. url_encode_charset(query, "GBK") .. "&searchtype=all",
{
charset = "GBK",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" }
}
)
if not r.success then return { items = {}, hasNext = false } end
-- парсинг...
endfunction getBookTitle(bookUrl)
local r = http_get(bookUrl)
if not r.success then return nil end
local el = html_select_first(r.body, "h1.title, h3.title")
if el then return string_trim(el.text) end
local og = html_attr(r.body, "meta[property='og:title']", "content")
if og ~= "" then return string_trim(og) end
return nil
end
function getBookCoverImageUrl(bookUrl)
local r = http_get(bookUrl)
if not r.success then return nil end
local url = html_attr(r.body, "meta[property='og:image']", "content")
if url ~= "" then return url end
local el = html_select_first(r.body, ".cover img, .book-cover img")
if el then return absUrl(el.src) end
return nil
end
function getBookDescription(bookUrl)
local r = http_get(bookUrl)
if not r.success then return nil end
local cleaned = html_remove(r.body, "script", ".ads")
local el = html_select_first(cleaned, ".description, .synopsis, .desc-text")
if el then return string_trim(el.text) end
return nil
end
function getChapterListHash(bookUrl)
local r = http_get(bookUrl)
if not r.success then return nil end
local el = html_select_first(r.body, ".chapter-list a:last-child")
if el then return el.href end
return nil
endfunction getChapterList(bookUrl)
local r = http_get(bookUrl)
if not r.success then return {} end
local chapters = {}
for _, a in ipairs(html_select(r.body, ".eplister li > a:not(.dlpdf)")) do
local chUrl = absUrl(a.href)
if chUrl ~= "" then
local titleEl = html_select_first(a.html, ".epl-title")
table.insert(chapters, {
title = titleEl and string_clean(titleEl.text) or string_clean(a.text),
url = chUrl
})
end
end
-- Разворот: сайт отдаёт newest-first → нужен oldest-first
local reversed = {}
for i = #chapters, 1, -1 do table.insert(reversed, chapters[i]) end
return reversed
endКогда страниц много, последовательный цикл слишком медленный.
http_get_batch загружает все страницы параллельно — скорость сопоставима с нативным KT:
function getChapterList(bookUrl)
local r = http_get(bookUrl)
if not r.success then return {} end
local maxPage = 1
local lastEl = html_select_first(r.body, "#list-chapter > ul:nth-child(3) > li.last > a")
if lastEl then
local p = string.match(lastEl.href, "[?&]page=(%d+)")
if p then maxPage = tonumber(p) or 1 end
end
-- Собираем URL страниц 2..maxPage (страница 1 уже загружена)
local pageUrls = {}
for page = 2, maxPage do
table.insert(pageUrls, bookUrl .. "?page=" .. tostring(page))
end
-- Параллельная загрузка — НЕ нужен sleep
local pageResults = {}
if #pageUrls > 0 then
pageResults = http_get_batch(pageUrls)
end
local chapters = {}
-- Страница 1 (уже есть)
for _, a in ipairs(html_select(r.body, "ul.list-chapter li a")) do
local chUrl = absUrl(a.href)
if chUrl ~= "" then
table.insert(chapters, { title = string_clean(a.text), url = chUrl })
end
end
-- Страницы 2..N (порядок гарантирован)
for _, pr in ipairs(pageResults) do
if pr.success then
for _, a in ipairs(html_select(pr.body, "ul.list-chapter li a")) do
local chUrl = absUrl(a.href)
if chUrl ~= "" then
table.insert(chapters, { title = string_clean(a.text), url = chUrl })
end
end
end
end
return chapters
endВажно:
http_get_batchпринимает только массив URL — без headers/charset. Порядок результатов гарантирован — соответствует порядку входного массива.
function getChapterList(bookUrl)
local novelId = string.match(bookUrl, "/novel/(%d+)/")
if not novelId then return {} end
local slug = string.match(bookUrl, "/novel/%d+/([^/?#]+)") or ""
sleep(300) -- rate limit
local r = http_get(baseUrl .. "api/chapters/" .. novelId, {
headers = { ["Referer"] = bookUrl }
})
if not r.success then return {} end
local data = json_parse(r.body)
if not data or not data.chapters then return {} end
local chapters = {}
for _, ch in ipairs(data.chapters) do
local order = ch.order or #chapters + 1
table.insert(chapters, {
title = tostring(order) .. ": " .. (ch.title or "Chapter " .. tostring(order)),
url = baseUrl .. "novel/" .. novelId .. "/" .. slug .. "/chapter-" .. tostring(order)
})
end
return chapters
endfunction getChapterList(bookUrl)
local r = http_get(bookUrl)
if not r.success then return {} end
local ogUrl = html_attr(r.body, "meta[property='og:url']", "content")
local novelId = string.match(ogUrl, "/([^/?#]+)/*$")
if not novelId then return {} end
local ar = http_get(baseUrl .. "ajax/chapter-archive?novelId=" .. novelId)
if not ar.success then return {} end
local chapters = {}
for _, a in ipairs(html_select(ar.body, "ul.list-chapter li a")) do
table.insert(chapters, { title = string_trim(a.text), url = absUrl(a.href) })
end
return chapters
endfunction getChapterList(bookUrl)
local r = http_get(bookUrl)
if not r.success then return {} end
local maxPage = math.max(1, #html_select(r.body, "select.page-select option"))
local chapters = {}
for page = maxPage, 1, -1 do
sleep(300)
local pr = http_post(baseUrl .. "wp-admin/admin-ajax.php",
"action=get_chapters&page=" .. page,
{ headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["X-Requested-With"] = "XMLHttpRequest",
["Referer"] = bookUrl
}}
)
if pr.success then
for _, a in ipairs(html_select(pr.body, "a[href]")) do
table.insert(chapters, { title = string_trim(a.text), url = absUrl(a.href) })
end
end
end
return chapters
endfunction getChapterList(bookUrl)
local slug = string.match(bookUrl, "/([^/?#]+)$")
if not slug then return {} end
local r = http_get("https://api.example.com/manga/" .. slug .. "/chapters")
if not r.success then return {} end
local data = json_parse(r.body)
local chapters = {}
for _, item in ipairs(data.data or {}) do
table.insert(chapters, {
title = "Глава " .. tostring(item.number) .. (item.name ~= "" and ": " .. item.name or ""),
url = baseUrl .. slug .. "/v" .. tostring(item.volume) .. "/c" .. tostring(item.number),
volume = "Том " .. tostring(item.volume)
})
end
-- Разворот newest→oldest
local reversed = {}
for i = #chapters, 1, -1 do table.insert(reversed, chapters[i]) end
return reversed
endfunction getChapterText(html, url)
local cleaned = html_remove(html, "script", "style", ".ads", "h3", ".chapter-warning", ".ad-insert")
local el = html_select_first(cleaned, "#chr-content")
if not el then return "" end
return applyStandardContentTransforms(html_text(el.html))
endКогда сайт отдаёт контент через JSON API а не через HTML страницу:
function getChapterText(html, chapterUrl)
-- Используем url напрямую (второй аргумент, не надо парсить из HTML)
if not chapterUrl or chapterUrl == "" then return "" end
local novelId = string.match(chapterUrl, "/novel/(%d+)/")
local chapterNo = tonumber(string.match(chapterUrl, "/chapter%-(%d+)")) or 1
local r = http_post(
baseUrl .. "api/reader/get",
json_stringify({
novel_id = novelId,
chapter_no = chapterNo,
mode = get_preference("plugin_mode") or "default"
}),
{ headers = {
["Content-Type"] = "application/json",
["Referer"] = chapterUrl,
["Origin"] = regex_replace(baseUrl, "/$", "")
}}
)
if not r.success then return "" end
local data = json_parse(r.body)
if not data then return "" end
-- Сборка абзацев из массива
local parts = {}
for _, para in ipairs(data.paragraphs or {}) do
if type(para) == "string" and para ~= "" then
table.insert(parts, "<p>" .. para .. "</p>")
end
end
return table.concat(parts, "\n")
end-- Перевод порциями по ~8000 символов
local function translateChunks(paragraphs, sourceLang, targetLang)
local result = {}
for i = 1, #paragraphs do result[i] = paragraphs[i] end
local MAX_CHARS = 8000
local chunks = {}
local ci, ch = {}, ""
for i, para in ipairs(paragraphs) do
local p = "<p>" .. para .. "</p>"
if ch ~= "" and #ch + #p > MAX_CHARS then
table.insert(chunks, { indices = ci, html = ch })
ci, ch = {}, ""
end
table.insert(ci, i)
ch = ch .. p
end
if ch ~= "" then table.insert(chunks, { indices = ci, html = ch }) end
for idx, chunk in ipairs(chunks) do
if idx > 1 then sleep(500) end
local translated = google_translate(chunk.html, sourceLang, targetLang, baseUrl) -- origin обязателен!
if translated and translated ~= chunk.html then
local tParas = {}
for _, el in ipairs(html_select(translated, "p")) do
local t = string_trim(el.text)
if t ~= "" then table.insert(tParas, t) end
end
local minSz = math.min(#tParas, #chunk.indices)
for pos = 1, minSz do
result[chunk.indices[pos]] = tParas[pos]
end
end
end
return result
end
function getChapterText(html, url)
-- ... получить paragraphs ...
local lang = get_preference("target_lang") -- "ru", "en", etc.
if lang and lang ~= "none" and lang ~= "" then
paragraphs = translateChunks(paragraphs, "zh-CN", lang)
end
local parts = {}
for _, p in ipairs(paragraphs) do
table.insert(parts, "<p>" .. p .. "</p>")
end
return table.concat(parts, "\n")
endfunction getChapterText(html, url)
local cleaned = html_remove(html, "script", ".ads")
local el = html_select_first(cleaned, ".content")
if not el then return "" end
local parts = { html_text(el.html) }
local current = cleaned
for _ = 1, 20 do
local nextEl = html_select_first(current, "a:contains(Next Part)")
if not nextEl then break end
local r = http_get(nextEl.href)
if not r.success then break end
current = html_remove(r.body, "script", ".ads")
local contentEl = html_select_first(current, ".content")
if contentEl then table.insert(parts, html_text(contentEl.html)) end
end
return table.concat(parts, "\n")
endПлагины могут объявить функцию getSettingsSchema(), которая описывает настраиваемые параметры. Адаптер автоматически рендерит нативный Material3 UI на основе этой схемы.
Настройки хранятся в SharedPreferences "lua_preferences". Используй get_preference(key) для чтения и set_preference(key, value) для записи. Ключи должны быть уникальными в пределах всех плагинов — рекомендуется префикс с id плагина: "wtrlab_mode", "ranobehub_lang".
| type | Поведение |
|---|---|
"select" |
2 варианта → кнопки бок о бок; 3+ вариантов → выпадающий список |
function getSettingsSchema()
return {
-- Виджет типа "select"
{
key = "pluginid_mode", -- ключ для get/set_preference
type = "select",
label = "Translation Mode", -- заголовок секции
current = get_preference("pluginid_mode") ~= ""
and get_preference("pluginid_mode") or "ai",
options = {
{ value = "ai", label = "AI (Enhanced)" },
{ value = "raw", label = "Raw (Web)" }
}
},
-- Второй виджет
{
key = "pluginid_lang",
type = "select",
label = "Translation Language",
current = get_preference("pluginid_lang") ~= ""
and get_preference("pluginid_lang") or "none",
options = {
{ value = "none", label = "No translation" },
{ value = "en", label = "English" },
{ value = "ru", label = "Russian" },
-- ...
}
}
}
endlocal PREF_MODE = "wtrlab_mode"
local PREF_LANG = "wtrlab_language"
local function getMode()
local v = get_preference(PREF_MODE)
return (v ~= "" and v) or "ai" -- значение по умолчанию
end
local function getLang()
local v = get_preference(PREF_LANG)
return (v ~= "" and v) or "none"
end
-- Использование в getChapterText:
function getChapterText(html, url)
local mode = getMode() -- "ai" или "raw"
local lang = getLang() -- "none", "ru", "en", ...
-- ...
endlocal PREF_MODE = "wtrlab_mode"
local PREF_LANG = "wtrlab_language"
local function getMode()
local v = get_preference(PREF_MODE)
return (v ~= "" and v) or "ai"
end
local function getLang()
local v = get_preference(PREF_LANG)
return (v ~= "" and v) or "none"
end
function getSettingsSchema()
return {
{
key = PREF_MODE,
type = "select",
label = "Translation Mode",
current = getMode(),
options = {
{ value = "ai", label = "AI (Enhanced)" },
{ value = "raw", label = "Raw (Web)" }
}
},
{
key = PREF_LANG,
type = "select",
label = "Translation Language",
current = getLang(),
options = {
{ value = "none", label = "No translation (original)" },
{ value = "en", label = "English" },
{ value = "es", label = "Spanish" },
{ value = "ru", label = "Russian" },
{ value = "de", label = "German" },
{ value = "id", label = "Indonesian" },
{ value = "tr", label = "Turkish" },
{ value = "pl", label = "Polish" },
{ value = "it", label = "Italian" },
{ value = "fr", label = "French" }
}
}
}
end1. createLuaSourceAdapter() вызывает parseLuaSettingsSchema(luaScript)
2. Если схема найдена → возвращает LuaSourceAdapterConfigurable (подкласс)
Если нет → возвращает обычный LuaSourceAdapter (без кнопки настроек)
3. LuaSourceAdapterConfigurable реализует SourceInterface.Configurable
4. UI находит кнопку настроек через стандартный `is SourceInterface.Configurable`
→ никаких изменений в UI-коде не нужно
5. LuaSettingsScreen рендерит нативные Material3 виджеты
6. При выборе пользователя → prefs.putString(key, value)
7. В следующем вызове getChapterText → get_preference(key) вернёт новое значение
Важно: Если плагин НЕ объявляет
getSettingsSchema()— кнопка настроек не появляется вообще. Только плагины с явной схемой получают UI настроек.
local reversed = {}
for i = #chapters, 1, -1 do table.insert(reversed, chapters[i]) end
return reversedlocal id = string.match(url, "/novel/(%d+)/")
local vol,ch = string.match(url, "/v(%d+)/c([%d%.]+)")
local slug = string.match(url, "/([^/]+)$")local jsonStr = string.match(r.body, "[^(]+%((.+)%)%s*$")
local data = json_parse(jsonStr)local bookId = string.match(r.body, "bookId%s*=%s*(%d+)")-- Паттерн WtrLab: тело начинается с "arr:" → отправить на прокси
local function decryptBody(rawBody)
if not string_starts_with(rawBody, "arr:") then return rawBody end
local r = http_post(
"https://my-proxy.fly.dev/decrypt",
json_stringify({ payload = rawBody }),
{ headers = { ["Content-Type"] = "application/json" } }
)
if not r.success then return rawBody end
local data = json_parse(r.body)
if type(data) == "table" and data[1] ~= nil then
return json_stringify(data) -- массив абзацев
end
if type(data) == "table" and data.body then
return json_stringify(data.body)
end
return rawBody
end-- glossary: { [0]="term0", [1]="term1", ... }
-- Маркеры в тексте: ※0⛬, ※0〓, ※1⛬, ...
local function applyGlossary(text, glossary)
for idx, term in pairs(glossary) do
text = text:gsub("※" .. tostring(idx) .. "⛬", term)
text = text:gsub("※" .. tostring(idx) .. "〓", term)
end
return text
endlocal r = http_get(url, { headers = { ["User-Agent"] = "Mozilla/5.0 (Linux; Android 12)" } })- Всегда
absUrl(href)для любых ссылок — никогда не возвращай относительные URL - Всегда
transformCover(url)— cover в адаптер идёт as-is без обработки tostring()для чисел:"page=" .. tostring(index)if el thenперед любым использованием результатаhtml_select_firsthtml_attrкогда нужен один атрибут без объекта элементаel:attr("name")когда объект элемента уже естьhtml_removeпередhtml_text— чистить до, а не послеstring_cleanдля заголовков и коротких строк — заменяет три вызова: normalize + collapse + trimstring_normalizeдля больших текстовых блоков передregex_replacesleep(300-500)только в последовательных циклах с запросамиhttp_get_batchкогда страниц глав 10+ — параллельно в разы быстрее чем цикл- Lazy-load обложки — всегда проверять
data-srcеслиsrcпустой string.matchс capture groups вместоregex_matchдля извлечения подстрокlocalфункции для хелперов- GBK везде:
http_get(url, {charset="GBK"}),url_encode_charset(q, "GBK") - Настройки: ключи вида
"{pluginid}_{key}"для избежания конфликтов - getChapterText: принимай оба аргумента
(html, url)—urlудобнее canonical - Перевод: используй chunking по 8000 символов +
sleep(500)между чанками - Прокси для шифрования:
http_postк внешнему сервису если API шифрует ответы google_translateтребуетorigin: всегда передавайbaseUrl4-м аргументом — без него API вернёт 400- Иконка: в
index.yaml— приоритетнее чемiconв Lua-скрипте. YAML исправит неверную иконку без обновления плагина hasNextчерез URL-индекс, не через селектор — если URL страницы строится по индексу (?page=N), используйhasNext = #items > 0вместо поиска кнопки.next. Селектор зависит от вёрстки и ненадёжен.
| Антипаттерн | Решение |
|---|---|
hasNext = html_select_first(r.body, ".next") ~= nil при URL-пагинации |
hasNext = #items > 0 |
return { getCatalogList = function() end } |
Top-level функции |
e.text:find(p) |
local t = e.text; t:find(p) |
regex_match(url, "/(%d+)/")[1] → "/123/" |
string.match(url, "/(%d+)/") → "123" |
Относительный URL в cover или url |
absUrl(href) |
| Миниатюра вместо полного cover | transformCover(url) |
"x=" .. r.code |
"x=" .. tostring(r.code) |
goto continue |
if ... end |
| Последовательный цикл для 10+ страниц глав | http_get_batch(urls) |
sleep перед http_get_batch |
Не нужен — запросы параллельны |
| Пустой cover при lazy-load | Проверять data-src если src == "" |
string_normalize + regex_replace("\\s+") + string_trim для заголовков |
string_clean(text) |
Запросы в цикле без sleep |
sleep(300) |
Текст без <p> |
Использовать html_text() |
| GBK сайт без charset | {charset = "GBK"} |
Игнорирование r.success |
if not r.success then return ... end |
get_preference без default |
(get_preference(k) ~= "" and get_preference(k)) or default |
| Конфликт ключей preferences между плагинами | Префикс "{id}_{key}" |
google_translate на огромном тексте |
Chunking по 8000 символов |
google_translate(text, src, tgt) без origin |
Всегда передавай baseUrl 4-м аргументом |
language = "MTL" показывает капсом |
Это нормально если LanguageCode.MTL.iso639_1 ≠ "MTL" — проверь значение в enum |
| Парсить URL из canonical вместо аргумента | Использовать второй аргумент url в getChapterText |
log_info("getCatalogList page=" .. tostring(index + 1))
log_error("http failed " .. tostring(r.code) .. " " .. url)Logcat тег: Lua:
| Ошибка | Причина |
|---|---|
attempt to index ? (a nil value) |
html_select_first вернул nil; функции в return{} |
attempt to concatenate number |
Нужен tostring() |
Compile error |
Незакрытый end, goto |
missing 'getCatalogList' |
Функция в return{}, не top-level |
| Пустой каталог | Неверный CSS-селектор |
| Кривые символы | charset="GBK", string_normalize() |
regex_match даёт "/123/" вместо "123" |
string.match(str, "/(%d+)/") |
| Сломанные обложки | Нет absUrl() или transformCover() |
get_preference вернул "" |
Добавить default: (v ~= "" and v) or default |
| Turnstile/CAPTCHA | error(chapterUrl) — сигнал для WebView |
| Пустой перевод | google_translate вернул оригинал при ошибке — скорее всего не передан origin |
| Кнопка настроек не появляется | Плагин не объявляет getSettingsSchema() или функция возвращает пустую таблицу |
| Настройки сбрасываются | Ключи конфликтуют с другим плагином — добавь префикс id: "wtrlab_mode" |
| Список глав грузится медленно (10+ стр.) | Используй http_get_batch вместо последовательного цикла |
repository/ en/wtrlab.lua ru/jaomix.lua zh/shuba69.lua en/index.yaml ru/index.yaml zh/index.yaml icons/wtrlab.png icons/jaomix.png icons/shuba69.png index.yaml
**`en/index.yaml`:**
```yaml
- id: wtrlab
name: WTR-LAB
version: "1.0.0"
language: MtL
icon: https://raw.githubusercontent.com/HnDK0/external-sources/main/icons/wtr-lab.png
codeUrl: https://raw.githubusercontent.com/HnDK0/external-sources/main/mtl/wtrlab.lua
Глобальный index.yaml:
sources:
- lang: en
index: https://raw.githubusercontent.com/.../en/index.yaml
- lang: MTL
index: https://raw.githubusercontent.com/.../mtl/index.yaml
- lang: ru
index: https://raw.githubusercontent.com/.../ru/index.yaml