Гайд — свой модуль за 30 минут
Пошаговое руководство: с нуля до работающего модуля-источника на JS. В качестве примера — вымышленный сайт example-anime.com с JSON-API.
Что будем делать
- Поиск аниме по названию
- Список сезонов и эпизодов
- Переключатель озвучек
- Плеер с проксированием HLS-стрима
Шаг 1. Анализ исходника
Прежде чем писать код, разберитесь как устроен сайт. Типичные ситуации:
| Тип источника | Что делать |
|---|---|
| JSON REST API | Повезло — просто http.get + res.json() |
| HTML-страницы | Regex/строковый парсинг — Goja умеет RegExp как в JS |
| Playerjs iframe | Достать file: из HTML, распарсить как JSON или строку |
| JWPlayer / Kinescope | Обычно есть JSON-конфиг рядом с iframe |
| DDoS-Guard / Cloudflare | ⚠️ Нужен uTLS-fingerprint — JS-runtime пока не умеет. Оставляйте Go-балансер |
| Сайт с auth-cookie | Храните cookie в config.json, прокидывайте через headers.Cookie |
Откройте DevTools на сайте и посмотрите:
- Какой endpoint используется на странице поиска? Какой формат ответа?
- Как выглядит URL страницы тайтла? Какой JSON внутри?
- Где в HTML плеера находится реальный стрим (ищите
.m3u8,.mp4,file:)?
В нашем примере:
GET /api/search?q=naruto → [{id, title, year}, ...]
GET /api/title/{id} → {title, seasons: [{number, episodes: [...]}], voices: [...]}
GET /api/stream/{episodeId} → {m3u8, quality}Шаг 2. Каркас
Создайте папку modules/example-anime/:
mkdir -p modules/example-animemanifest.json
{
"id": "example_anime",
"name": "Example Anime",
"version": "0.1.0",
"author": "your-name",
"description": "Пример JS-модуля для гайда.",
"content_types": ["serial"],
"quality": "FHD",
"languages": ["uk"],
"anime": true,
"tags": ["tutorial"],
"config_schema": [
{
"key": "host",
"label": "API host",
"type": "string",
"default": "https://api.example-anime.com"
},
{
"key": "proxyStreams",
"label": "Проксировать стримы через /proxy",
"type": "bool",
"default": true,
"description": "Рекомендуется оставить включённым — обходит Origin/Referer-ограничения CDN"
}
]
}index.js (заготовка)
// Example Anime — гайдовый модуль.
var DEFAULTS = { host: 'https://api.example-anime.com', proxyStreams: true };
function cfg(inv, k) {
var v = inv.config && inv.config[k];
return (v === undefined || v === null || v === '') ? DEFAULTS[k] : v;
}
function handle(inv) {
return { type: 'movie', data: [] }; // пока пусто
}Положите оба файла в папку, откройте админку → Модули → должна появиться карточка «Example Anime». Если её нет — смотрите troubleshooting.
Шаг 3. Поиск
function apiGet(inv, path) {
var url = cfg(inv, 'host').replace(/\/+$/, '') + path;
var res = http.get(url, {
headers: { 'Accept': 'application/json' }
});
if (!res.ok) {
console.warn('example_anime: GET', path, 'status', res.status);
return null;
}
try { return res.json(); } catch (e) {
console.warn('example_anime: JSON parse failed', e.message);
return null;
}
}
function search(inv, query) {
if (!query) return [];
// Короткий кэш: одно и то же название в одной сессии не зовём повторно.
var key = 'search:' + query.toLowerCase();
var cached = cache.get(key);
if (cached !== undefined) return cached;
var data = apiGet(inv, '/api/search?q=' + util.urlencode(query));
var results = (data && data.results) || [];
cache.set(key, results, 300); // 5 минут
return results;
}Теперь добавим ветку «мне пришёл просто title, ещё без id» в handle:
function handle(inv) {
// checksearch — быстрый пинг: есть ли что-то по этому тайтлу
if (inv.checksearch) {
var r = search(inv, inv.query.title || inv.query.original_title || '');
return r.length
? { rch: true, type: 'movie', quality: manifest.quality || 'FHD' }
: { rch: false };
}
var title = (inv.query.title || inv.query.original_title || '').trim();
var id = (inv.query.id || '').trim();
if (!id) {
// Шаг поиска — возможно покажем список похожих
var results = search(inv, title);
if (!results.length) return { type: 'movie', data: [] };
if (results.length > 1) {
return {
type: 'similar',
data: results.map(function (r) {
return {
title: r.title + ' (' + r.year + ')',
url: inv.host + '/lite/example_anime?id=' + r.id +
'&title=' + util.urlencode(title)
};
})
};
}
id = results[0].id;
}
// Пока заглушка — завершим на следующем шаге
return { type: 'movie', data: [{ method: 'link', url: '#', name: 'ID=' + id }] };
}Проверка: в админке нажмите «Код» → вставьте изменения → «Сохранить и перезагрузить». Затем:
curl 'http://lampac:888/lite/example_anime?title=Naruto&checksearch=true'
# {"quality":"FHD","rch":true,"type":"movie"}
curl 'http://lampac:888/lite/example_anime?title=Naruto'
# { "type": "similar", "data": [{...}, {...}, ...] }Шаг 4. Детали тайтла — сезоны и озвучки
function getTitle(inv, id) {
var key = 'title:' + id;
var cached = cache.get(key);
if (cached !== undefined) return cached;
var data = apiGet(inv, '/api/title/' + id);
cache.set(key, data, 1800); // 30 минут
return data;
}Допишем ветку, где мы уже знаем id:
function handle(inv) {
// ... (предыдущий checksearch и поиск)
var detail = getTitle(inv, id);
if (!detail) return { type: 'movie', data: [] };
var voices = detail.voices || [];
var seasons = detail.seasons || [];
// Выбранные сезон/озвучка из query
var s = inv.query.s === '' || inv.query.s === undefined ? -1 : parseInt(inv.query.s, 10);
var t = inv.query.t === '' || inv.query.t === undefined ? 0 : parseInt(inv.query.t, 10);
// Если сезон не выбран и их больше одного — показать список сезонов
if (s === -1 && seasons.length > 1) {
return {
type: 'season',
data: seasons.map(function (se) {
return {
method: 'link',
id: se.number,
url: inv.host + '/lite/example_anime?id=' + id +
'&title=' + util.urlencode(title) + '&s=' + se.number,
name: se.number + ' сезон'
};
})
};
}
if (s === -1) s = (seasons[0] && seasons[0].number) || 1;
// Озвучки — переключатель
var voiceData = voices.map(function (v, i) {
return {
name: v.name,
active: i === t,
url: inv.host + '/lite/example_anime?id=' + id +
'&title=' + util.urlencode(title) + '&s=' + s + '&t=' + i
};
});
var seasonObj = seasons.filter(function (x) { return x.number === s; })[0] || seasons[0];
var episodes = seasonObj.episodes || [];
var data = episodes.map(function (ep) {
return {
method: 'call', // клиент ещё раз к нам, чтобы получить реальный URL стрима
url: inv.host + '/lite/example_anime/play?ep=' + ep.id +
'&t=' + voices[t].id + '&s=' + s + '&e=' + ep.number,
name: 'Эпизод ' + ep.number,
title: detail.title + ' — ' + ep.title,
s: s,
e: ep.number
};
});
return { type: 'episode', data: data, voice: voiceData };
}method: 'call' означает «клиент сходит на этот URL, получит JSON и распарсит url/stream». Полезно когда реальный URL дорого получать заранее — добываем его только при нажатии play.Шаг 5. Плеер — реальный стрим
Добавим отдельную ветку для /lite/example_anime/play:
function getStream(inv, episodeID, voiceID) {
var data = apiGet(inv, '/api/stream/' + episodeID + '?voice=' + voiceID);
if (!data || !data.m3u8) return null;
return data;
}
function maybeProxy(inv, u) {
return cfg(inv, 'proxyStreams') ? proxy.url(u, 'example_anime') : u;
}
function handle(inv) {
if (inv.checksearch) { /* ... */ }
// /lite/example_anime/play?ep=...&t=...&s=...&e=...
if (/\/play(\b|$)/.test(inv.path || '')) {
var epID = parseInt(inv.query.ep || 0, 10);
var voiceID = parseInt(inv.query.t || 0, 10);
if (!epID) return { rch: false };
var s = getStream(inv, epID, voiceID);
if (!s) return { rch: false };
var url = maybeProxy(inv, s.m3u8);
return {
method: 'play',
url: url,
stream: url,
title: 'Episode ' + inv.query.e + ' (S' + inv.query.s + ')',
quality: s.qualities ? s.qualities.reduce(function (acc, q) {
acc[q.label] = maybeProxy(inv, q.url); return acc;
}, {}) : undefined
};
}
// ... прежние ветки поиска / сезонов / эпизодов
}Готово — модуль умеет всё: искать, строить дерево сезонов, переключать озвучки, играть с качествами.
Шаг 6. Отладка и логи
Когда модуль не работает как ожидается:
1. Посмотрите логи в админке
Модули → карточка → Логи. Туда попадают все console.log/warn/error и ошибки runtime (taймауты, panic из Go-рантайма).
2. Добавьте отладку
function handle(inv) {
console.log('handle called', JSON.stringify(inv.query));
// ...
var results = search(inv, title);
console.log('search returned', results.length, 'results');
// ...
}3. Проверка через curl
# checksearch
curl 'http://localhost:888/lite/example_anime?title=Naruto&checksearch=true'
# просмотр «сырых» эпизодов
curl -s 'http://localhost:888/lite/example_anime?id=123&s=1' | jq4. Типичные ошибки
| Симптом | Причина | Лечение |
|---|---|---|
| Карточка не появилась в админке | Невалидный manifest.json, неправильный id |
Проверьте JSON и что ID подходит под [a-z][a-z0-9_]{1,31} |
Error: module "X" has parse error: SyntaxError |
JS не парсится | Смотрите карточку — там печатается ошибка с номером строки |
http.get возвращает {ok:false, status:0} |
Сеть/DNS/timeout | Проверьте хост, можно увеличить таймаут (глобально 30с) |
res.json() бросает исключение |
Ответ не-JSON (может быть HTML капча) | Логируйте res.text.slice(0,200) чтобы увидеть что пришло |
| 403 от CDN при проигрывании | Нужны Origin/Referer | Используйте proxy.urlWithHeaders |
| Lampa не показывает модуль в выдаче | checksearch вернул rch:false |
Проверьте что поиск работает (лог) |
Шаг 7. Упаковка и деплой
Поделиться модулем
Заархивируйте папку:
cd modules
zip -r example-anime.zip example-anime/Другой пользователь в админке → Модули → Установить → Загрузить архив — и у него появится ваш модуль.
Публикация на GitHub
your-repo/
modules/
example-anime/
manifest.json
index.js
README.md
LICENSEПользователь может установить прямо по URL на zip через Установить из URL.
repository в manifest.json — тогда в админке будет ссылка на исходники.Что дальше
- Многосерийные аниме с нестандартными озвучками — см. как устроен ashdi модуль — он парсит tree-структуру Playerjs
file:'[{title,folder:[{title,folder:[episodes]}]}]'. - Сайты с HTML-скрапингом — bamboo и eneyida показывают regex-парсинг.
- API с pagination — uafilmme реализует цикл
page → next_page.
Подсмотрите в исходниках украинских модулей — все 11 написаны по одному стилю и покрывают типичные паттерны.
Лимиты песочницы
Что нельзя сделать в JS-модуле:
- Нет
fs— модуль не читает/не пишет файлы (кроме того, что ему даётcache) - Нет
setTimeout/setInterval— только синхронныйutil.sleep - Нет
eval/Functionконструктора — хотя ES5-eval работает внутри Goja, он не рекомендуется - Нет cookiejar — если сайт нужен persistent cookies между запросами, сохраняйте их в
cacheвручную - Нет uTLS/Chrome fingerprint — DDoS-Guard и Cloudflare с JS-challenge обычно не пройдут
- Нет chromedp / headless browser — сайты с JS-рендерингом невозможны
Для всего перечисленного — пишите встроенный Go-балансер или оставляйте Go-версию как fallback.