Гайд — свой модуль за 30 минут

Гайд — свой модуль за 30 минут

Пошаговое руководство: с нуля до работающего модуля-источника на JS. В качестве примера — вымышленный сайт example-anime.com с JSON-API.

Перед чтением ознакомьтесь с обзором системы — это краткая справка по 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 на сайте и посмотрите:

  1. Какой endpoint используется на странице поиска? Какой формат ответа?
  2. Как выглядит URL страницы тайтла? Какой JSON внутри?
  3. Где в 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-anime

manifest.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' | jq

4. Типичные ошибки

Симптом Причина Лечение
Карточка не появилась в админке Невалидный 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 с paginationuafilmme реализует цикл 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.