Восемь аниме-плееров рунета изнутри: шифр Цезаря, пустой параметр и домен, который увели

Я взял за основу популярную опенсорсную библиотеку парсеров аниме-плееров, запустил её — и получил от Kodik 500 Internal Server Error. Не «токен протух», не «IP заблокирован», а именно 500 на каждый запрос. Разбор занял вечер, а причина оказалась в одном символе: библиотека отправляла параметр ref пустым, а сервер требует его заполненным и раскодированным.
Дальше выяснилось, что это не единичная поломка, а норма жанра. Один плеер полгода назад переехал на другой формат страницы. Другой отвечает бесконечным редиректом на самого себя. У третьего домен просто увели — сейчас там индонезийский интернет-магазин, а не аниме. Четвёртый прячет ссылки в WebSocket из обфусцированного бандла на 600 КБ, и его я честно не осилил.
Под катом — разбор восьми плееров: как каждый отдаёт видео, где именно ломаются существующие парсеры и что из этого следует, если вы пишете что-то похожее. Библиотека получилась побочным продуктом и лежит в открытом доступе.
Что получилось
anime-dl-core — библиотека, которая превращает ссылку на embed в прямые ссылки на видео (HLS/DASH/MP4) с заголовками, без которых CDN не отдаст файл.
pip install anime-dl-core
import anime_dl_core as ap
result = ap.extract("https://aniboom.one/embed/9G1MJ6NMV8z?episode=1&translation=30")
print(result.qualities) # [360, 480, 720, 1080]
stream = result.best(kind="hls")
print(stream.url) # https://.../media\\_6.m3u8
print(stream.headers) # {'Referer': 'https://aniboom.one/', ...}
print(stream.ffmpeg_command("ep1.mp4")) # готовая команда ffmpeg
Плееры | Aniboom, CVH, Kodik, Sibnet, Animedia, AniLibria, VK Video, SovetRomantica |
Зависимости | одна — |
Python | 3.9+ |
Тесты | 86 офлайн на настоящих сохранённых ответах + 18 живых |
Код |
Библиотека сознательно занимается только плеерами. Она не ищет аниме, не ведёт каталог, ничего не скачивает сама и не обходит платные ограничения — читает ровно то же, что читает обычный веб-плеер в браузере.
Зачем это вообще
Аниме-сайты рунета устроены одинаково: сам сайт — это витрина (описание, оценки, список серий), а видео живёт в чужом плеере, который встраивается в <iframe>. Один и тот же тайтл на разных сайтах крутится через одни и те же четыре-пять плееров.
Значит, если нужен свой клиент — телевизор, торрент-качалка на NAS, оффлайн для поездки в поезде, экран без рекламы — задача сводится не к «спарсить сайт», а к «понять плеер». Плееров конечное число, и они, в отличие от витрин, живут годами.
Дальше — по одному.
Kodik: три шага, шифр Цезаря и тот самый пустой параметр
Kodik — самый распространённый плеер и самый защищённый из встреченных. Достать из него ссылку — это три запроса.
Шаг первый. Страница embed (https://kodikplayer.com/seria/<id>/<hash>/720p) содержит блок с подписями:
var urlParams = '{"d":"animego.org",
"d_sign":"45f22a7face01f12...:2609080656",
"pd":"kodikplayer.com",
"pd_sign":"31aa713fde164488...:2609080656",
"ref":"https%3A%2F%2Fanimego.org%2F",
"ref_sign":"f99556604d27d0ff...:2609080656"}';
var vInfo = {};
vInfo.type = 'seria';
vInfo.hash = '932d5da818729ec5ccc9be7968ee3717';
vInfo.id = '1304528';
Подписи одноразовые и с временной меткой — страницу и следующий запрос нужно делать подряд.
Шаг второй. Адрес ручки спрятан в скрипте плеера:
$.ajax({type:"POST", url:atob("L2Z0b3I="), cache:!1, dataType:"json", data:e, ...})
atob("L2Z0b3I=") → /ftor. Обфускация уровня «чтобы не нагуглили», но искать её нужно регуляркой по вызову atob, а не по смещению в файле: в исходной библиотеке адрес вырезался как js[js.find("$.ajax")+30 : js.find("cache:!1")-3], и любая правка минификатора это сломает.
Шаг третий — и вот здесь была поломка. POST на /ftor с подписями. Исходная библиотека отправляла:
params = {..., 'ref': '', 'ref_sign': urlParams['ref_sign'], ...}
Пустой ref — и сервер отвечает 500. Правильно так:
from urllib.parse import unquote
params = {..., 'ref': unquote(url_params['ref']), # 'https://animego.org/'
'ref_sign': url_params['ref_sign'], ...}
То есть ref нужно взять из urlParams и раскодировать из процентной кодировки: подпись ref_sign считается от раскодированного значения. Одна строка — и вместо 500 приходит json со ссылками.
Ссылки, правда, зашифрованы:
Tg9rjO91HK5hj2fdHOVsjq5rj20dlFVtkvDejO9pHPUdVLQeUhIgV2GbGENtUg00HODqTERtU2Qb...
Это base64, к которому применён шифр Цезаря по латинице. Сдвиг меняется — на момент разбора он был 18. Подбирается перебором 26 вариантов с проверкой результата:
def decode_kodik_url(value, known_shift=None):
for shift in ([known_shift] if known_shift is not None else []) + list(range(26)):
try:
decoded = b64decode_padded(caesar_shift(value, shift)).decode("utf-8")
except Exception:
continue
if decoded.startswith("//") or decoded.startswith("http"):
return decoded, shift
raise DecryptionError(...)
Найденный сдвиг кэшируется в объекте плеера, так что перебор происходит один раз за сессию. На выходе:
//cloud.solodcdn.com/useruploads/4203027f-.../858b661430c3...:2026090706/720.mp4:hls:manifest.m3u8
Отсюда же берётся прямой mp4 — достаточно отрезать :hls:manifest.m3u8. Проверил: отдаётся честный файл на 202 МБ.
Бонусом на странице лежат таймкоды опенинга и эндинга:
playerSettings.skipButton = parseSkipButton("0:30-1:50,22:55-24:05", "anime");
Что важно: токен API Kodik для всего этого не нужен. Токен нужен только чтобы искать по базе Kodik; чтобы достать видео по готовой ссылке на embed — нет.
Aniboom: всё в одном атрибуте
После Kodik — отдых. Страница embed содержит:
<video id="video" data-parameters="{"id":"QK8d1LbNX6l",
"dash":"{\"src\":\"https:\/\/.../o9hwpavtcccz7.mpd\"}",
"hls":"{\"src\":\"https:\/\/.../master.m3u8\"}",
"duration":"2971", "qualityVideo":1080, ...}">
Разэкранировать html-сущности, распарсить json, внутри hls и dash — снова json-строки. Дальше — мастер-плейлист, который я разворачиваю в отдельные качества:
#EXT-X-STREAM-INF:BANDWIDTH=593867,RESOLUTION=640x360,CODECS="avc1.640028,mp4a.40.2"
media_0.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1593867,RESOLUTION=1280x720,...
media_4.m3u8
Единственная тонкость — заголовки: без Referer: https://aniboom.one/ и Origin CDN отвечает отказом. Это общее место для всех плееров, и поэтому в моей библиотеке заголовки — не деталь реализации, а часть результата:
stream.headers # {'Referer': ..., 'Origin': ..., 'User-Agent': ...}
stream.ffmpeg_args("ep.mp4") # уже с -headers
CVH: открытое API и ключи, о которых не догадаешься
CVH (CdnVideoHub) — два открытых эндпоинта без всякой авторизации:
GET /api/v1/player/sv/playlist?pub=747&aggr=mali&id=51019 → все серии и озвучки
GET /api/v1/player/sv/video/<vkId> → ссылки на потоки
Плейлист приятно устроен: каждая запись — это {season, episode, voiceStudio, voiceType, vkId}, то есть сразу видно, у какой серии какие озвучки.
А вот ответ со ссылками содержит сюрприз:
"sources": {
"hlsUrl": "https://vd576.okcdn.ru/video.m3u8?...",
"dashUrl": "https://vd576.okcdn.ru/?...",
"mpegHighUrl": "...", "mpegFullHdUrl": "...", "mpegMediumUrl": "...",
"mpegLowUrl": "...", "mpegLowestUrl": "...", "mpegTinyUrl": "..."
}
Раздаёт видео CDN Одноклассников, отсюда и нейминг. Соответствие такое:
Ключ | Качество |
|---|---|
| 144p |
| 240p |
| 360p |
| 480p |
| 720p |
| 1080p |
Исходная библиотека искала ключи, начинающиеся с url (url360, url720 — старый формат ответа), и поэтому теряла все прямые mp4, оставляя только HLS. Классическая тихая поломка: ошибки нет, данные есть, а половины результата нет.
Sibnet: одна строчка и обязательный Referer
player.src([{src: "/v/2305c05076f006222ee9e551c08c7e03/2589828.mp4", type: "video/mp4"}]);
Относительный путь → https://video.sibnet.ru/v/...mp4, дальше 302 на реальный CDN:
Location: //dv97.sibnet.ru/25/89/82/2589828.mp4?st=I3P2Jhxp...&e=1788750000&noip=1
Подписанный адрес живёт недолго и привязан к IP, поэтому по умолчанию я возвращаю стабильную ссылку video.sibnet.ru/v/..., которая сама редиректит куда надо, а разворачивание редиректа сделал опцией.
VK Video: страница переехала, и парсеры об этом не знают
Все известные мне парсеры VK ищут на странице video_ext.php вот это:
var playerParams = {"params":[{"url240":"...","url720":"...","hls":"..."}]};
Такого там больше нет. Сейчас данные лежат в предзагруженном ответе внутреннего API:
window.cur = Object.assign(window.cur || {}, {
"apiPrefetchCache":[{
"method":"video.get",
"response":{"items":[{
"files":{
"mp4_144":"https://vkvd419.okcdn.ru/?...",
"mp4_720":"https://vkvd419.okcdn.ru/?...",
"hls_ondemand":"https://vkvd419.okcdn.ru/.../hls4\\_....m3u8",
"dash_ondemand":"https://vkvd419.okcdn.ru/.../dash4\\_....mpd"
},
"title":"...", "duration":1440, "image":[...]
}]}
}]
});
Нашёл я это тупым способом, который рекомендую: сохранить страницу в файл и посчитать, какие ключи вообще стоят рядом с https://:
keys = re.findall(r'"([a-zA-Z0-9_]+)":"https:\\/\\/[^"]{20,}"', text)
print(Counter(keys).most_common(30))
Достать json из середины страницы удобно через raw_decode — он читает ровно один объект и говорит, где остановился:
start = text.find("[", text.find('"apiPrefetchCache"'))
entries, _ = json.JSONDecoder().raw_decode(text[start:])
Полезные детали:
параметр
hashиз кода вставки нужен только для видео «доступно по ссылке»; для публичных достаточноoidиid;у трансляции в
filesтолькоhls— по этому и определяется live;страница видео (
vkvideo.ru/video-…) ничего полезного не предзагружает, работает именноvideo_ext.php.
Старый формат я оставил как запасной вариант: он ещё встречается на зеркалах.
SovetRomantica: сайта нет, домен увели
Здесь начинается детектив. Плеер SovetRomantica живёт по адресу sovetromantica.com/embed/episode_<id>_<серия>-<dubbed|subtitles>. Запрашиваю — приходит 394 КБ HTML, в которых нет ни аниме, ни плеера, зато есть интернет-магазин детских товаров на индонезийском.
Первая мысль — подмена DNS у провайдера. Проверяю сертификат:
cert = ssl_socket.getpeercert()
# subject: www.qris.sovetromantica.com
# SAN: *.sovetromantica.com, sovetromantica.com,
# www.panduan.sovetromantica.com, www.qris.sovetromantica.com
Сертификат настоящий, Let’s Encrypt, выписан на домен — вместе с поддоменами qris (это индонезийская платёжная система) и panduan («руководство»). Это не подмена: домен просто сменил владельца и ушёл под SEO-схему. CDN команды (scu*.sovetromantica.com) не резолвится вообще. На Boosty у команды висит сбор на перезапуск проекта.
Казалось бы — вычеркнуть плеер. Но разметку-то можно восстановить: у Wayback Machine есть CDX API, который умеет отдавать список сохранённых страниц по маске.
requests.get("https://web.archive.org/cdx/search/cdx", params={
"url": "sovetromantica.com/embed/*", "output": "json",
"filter": "statuscode:200", "from": "2023", "collapse": "urlkey"})
Восемьдесят пять сохранённых embed-страниц. Беру свежую (суффикс id_ в адресе отдаёт оригинал без обвязки архива) — и вот она, настоящая разметка:
var nextEpisode = 'https://sovetromantica.com/embed/episode\\_1073\\_2-dubbed';
var skips = [
{'start': 174.7996, 'end': 184.7996, 'skip_to': 262.9293},
{'start': 1316.7738, 'end': 1326.7738, 'skip_to': 1398.5638},
];
var config = {
"id":"sovetromantica_player",
"file":"https://scu2.sovetromantica.com/anime/1073_gekkan-shoujo-nozaki-kun/episodes/dubbed/episode_1/episode\\_1.m3u8",
"poster":"...", "thumbnails":"....vtt",
"title":"Gekkan Shoujo Nozaki-kun - Озвучка - Эпизод 1",
"points": points
};
Обратите внимание на config: это не валидный json — в конце стоит js-переменная points. Поэтому поля достаются по отдельности регулярками, а не одним json.loads.
Структура skips тоже небанальная: start–end — это когда показывать кнопку «пропустить», а skip_to — куда она перематывает. То есть пропускаемый фрагмент — это start → skip_to, и именно так я его и записываю.
Плеер в итоге в библиотеке есть, разбор проверен на настоящих архивных страницах, а домен вынесен в параметр:
with SovetRomanticaPlayer(base_url="https://новый-домен") as player:
result = player.extract("episode_1073_1-dubbed")
Когда команда вернётся — заработает без правок кода. А свежие релизы SovetRomantica сейчас выкладывает во ВКонтакте, и их разбирает плеер vk.
Мораль, которая мне дорого досталась: прежде чем чинить парсер, проверьте, что вы вообще на том сайте. Двести строк регулярок могут «не работать» просто потому, что за доменом теперь другой человек.
Animedia: бесконечный редирект и переезд
Ссылки вида online.animedia.tv/embed/14938/1/1 до сих пор раздают агрегаторы. Запрашиваю — requests падает с TooManyRedirects. Смотрю без автоперехода:
GET https://online.animedia.tv/embed/14938/1/1
301 → https://online.animedia.tv/embed/14938/1/1
Редирект на самого себя. Ни куки, ни User-Agent не помогают, с другой сети — то же самое. То есть это сломано у всех, а не у меня.
Живой сайт нашёлся перебором доменов: animedia.online редиректит на amd.online, и это работающая «Animedia Online». Плеер там теперь свой — aser.pro/vod/<id>, и внутри всё в одну строку:
var player = new Playerjs({
id: "videoplayer20182",
file: "https://aser.pro/content/stream/raskolotaya_bitvoj_sineva_nebes_5/001_20182/hls/index.m3u8"
});
Playerjs — популярный плеер, и его параметр file бывает трёх видов: одна ссылка, [720]url1,[360]url2 и json-плейлист со списком серий. Поддержал все три, потому что стоит это десять строк, а ломается иначе молча.
А ссылки на серии лежат на странице тайтла, по одной на эпизод:
<a data-vid="1" data-vlnk="https://aser.pro/vod/1067" href="...">1</a>
<a data-vid="2" data-vlnk="https://aser.pro/vod/1068" href="...">2</a>
Alloha: место, где я остановился
Alloha — крупный агрегатор с открытым API. По id Кинопоиска/IMDb/TMDb или по названию он отдаёт всё, что нужно:
{"status":"success","data":{
"name":"Атака титанов", "year":2013, "id_kp":749374,
"seasons":{"1":{"episodes":{"1":{"iframe":"https://...","translation":{...}}}}},
"iframe":"https://miyagi-as.stravers.live/?token\\_movie=...&token=..."}}
Открываю iframe — в html есть fileList с id файлов, качествами и озвучками, но ни одной ссылки на видео. Ищу дальше:
m3u8в html — нет;/api/...,/playlist?...,/get?...— все 404;в бандле плеера (600 КБ, обфускация со строковым массивом и hex-константами) находится вот это:
window.__ws_factory(''.concat(aS, ...).concat(encodeURIComponent(aO), '&v=2.1&t=').concat(Date.now()))
Ссылки приходят по WebSocket, адрес которого собирается внутри обфусцированного кода. Это не «сложно распарсить», это «нужно исполнять их js». Headless-браузер решил бы задачу, но добавил бы в библиотеку с одной зависимостью — Chromium.
Поэтому я остановился и написал это в README прямым текстом. Alloha в библиотеке есть, но как источник, а не плеер: она отдаёт метаданные, список сезонов, озвучек и готовую ссылку на iframe — вставить к себе на страницу или открыть в webview.
Отдельно скажу, почему считаю это правильным решением. Соблазн был написать «поддерживается» и отдавать iframe под видом потока. Но у библиотеки один контракт: Stream.url — это то, что можно скормить ffmpeg. Строчка «не смог, вот почему» в README честнее, чем метод, который возвращает не то, что обещает.
Ещё один невключённый — jut.su. Там в <source> теперь лежат заглушки:
<source src="https://gen.jut.su/templates/school/images/pixel.png?1080" res="1080"/>
Настоящие ссылки подгружаются динамически. Та же история: без исполнения js не обойтись.
Что в сухом остатке по защитам
Плеер | Как отдаёт ссылку | Барьер |
|---|---|---|
Aniboom | json в атрибуте тега | Referer/Origin |
CVH | открытое API | нет |
Kodik | POST с одноразовыми подписями | base64 + Цезарь, подписи, |
Sibnet | js-строка на странице | Referer, подписанный редирект |
Animedia |
| нет |
AniLibria | открытое API | нет |
VK | предзагруженный ответ API в html | формат меняется |
Alloha | WebSocket из обфусцированного бандла | не пройден |
Общая закономерность: чем крупнее сервис, тем меньше он полагается на обфускацию и тем больше — на подписи, время жизни и привязку к IP. Kodik шифрует ссылку шифром Цезаря (это не защита, а знак «сюда не ходи»), но подписи в urlParams живут минуты — и вот это уже настоящая защита, потому что делает бессмысленным кэширование.
Отсюда три вывода для всех, кто такое пишет:
Заголовки — часть результата. Ссылка без
Referer— это не ссылка. Возвращайте их вместе с url, иначе пользователь вашей библиотеки будет ловить 403 и думать, что вы что-то сломали.Ссылки живут минуты. Кэшировать их бессмысленно, получать нужно перед самым скачиванием.
Ошибка должна называть, чего не нашлось. «Не удалось найти urlParams на странице Kodik» чинится за минуту,
AttributeError: 'NoneType' object has no attribute 'group'— за вечер.
Как это устроено внутри
Библиотека собрана вокруг реестра плееров: extract() определяет плеер по домену ссылки и возвращает единый результат.
src/anime_dl_core/
registry.py реестр, extract() / extract_async()
base.py BasePlayer: клиенты, сопоставление ссылок, жизненный цикл
models.py Stream, PlayerResult, SkipSegment, StreamKind
http.py HttpClient (requests) и AsyncHttpClient (aiohttp) с общим Response
utils.py разбор html/js, m3u8, дешифровка ссылок Kodik
players/ по файлу на плеер
sources/ необязательные помощники (AnimeGO, Animedia, Alloha)
Свой плеер добавляется двадцатью строками:
@register
class MyPlayer(BasePlayer):
name = "myplayer"
domains = ("myplayer.example",)
playback_headers = {"Referer": "https://myplayer.example/"}
def extract(self, url, **kwargs):
page = self.client.get(url).raise_for_status()
playlist = search(r'file:"([^"]+\.m3u8)"', page.text, what="ссылку на m3u8")
return PlayerResult(player=self.name, source_url=url,
streams=[Stream(playlist, StreamKind.HLS, None,
dict(self.playback_headers))])
Синхронный и асинхронный режимы делают одни и те же шаги и вызывают одни и те же функции разбора — поэтому не расходятся в поведении. HTML разбирается регулярками: для десятка конкретных страниц это надёжнее, чем зависимость от bs4/lxml, и оставляет библиотеке ровно одну зависимость.
Тесты: фикстуры из настоящих ответов
Восемьдесят шесть офлайн-тестов гоняют разбор на реальных ответах плееров, сохранённых в tests/fixtures/. Не на выдуманных, а на тех самых страницах, с которых я снимал разбор, — включая настоящий зашифрованный ответ Kodik, так что дешифровка проверяется по-настоящему, а не на строке, которую я сам же и зашифровал.
Восемнадцать живых тестов ходят в интернет. В CI они не гоняются намеренно: живой тест падает, когда конкретный тайтл убрали с сайта или сервис недоступен из дата-центра GitHub, — это не регрессия библиотеки, а красный прогон на ровном месте. Запускаются отдельно:
ANIME_DL_CORE_LIVE=1 pytest -m live
Такое разделение я советую всем, кто пишет парсеры: офлайн-тесты отвечают на вопрос «не сломал ли я разбор», живые — на вопрос «не сломался ли сайт». Это разные вопросы, и смешивать их в одном прогоне — верный способ приучить себя игнорировать красный CI.
CI: sha256 в релизе и доверенная публикация
Раз уж библиотека публичная, релизы делает CI по тегу: собирает sdist и wheel, ставит собранное колесо в чистое окружение (пакет, который импортируется только из исходников, — это сломанный пакет), считает SHA-256 каждого файла и кладёт хеши вместе с коммитом в текст релиза.
Публикация на PyPI — через Trusted Publishing: PyPI принимает короткоживущий OIDC-токен, выданный конкретному репозиторию и конкретному workflow. Долгоживущего секрета не существует вовсе, а к файлам прикладываются attestations (PEP 740) — подтверждение, что колесо собрано этим workflow из этого коммита:
{"kind": "GitHub", "repository": "ialakey/anime-dl-core",
"workflow": "publish-pypi.yml", "environment": null}
Грабли, на которые я наступил: pending publisher на PyPI работает только для проектов, которых ещё нет. Если проект уже создан загрузкой по токену, доверенного издателя нужно добавлять на странице самого проекта (Manage → Publishing), иначе обмен токена падает с invalid-publisher: valid token, but no corresponding publisher — при абсолютно корректных claim’ах, что сбивает с толку.
Что дальше
Чего не хватает и что я, возможно, сделаю (или сделает кто-то — MIT):
Alloha через headless-браузер отдельным опциональным пакетом, чтобы ядро осталось с одной зависимостью.
Больше плееров. Формат добавления — один файл, десяток регулярок и фикстура из настоящего ответа.
Скачивание. Сейчас библиотека отдаёт ссылку и команду ffmpeg; загрузчик с докачкой и склейкой HLS — отдельный слой.
Если у вас есть публичный пример плеера, который я не поддержал (или поддержал, но он у вас не работает) — это самый полезный вид обратной связи: почти всегда это либо новый формат страницы, либо ещё один переехавший домен.
Дисклеймер
Библиотека не обходит платные ограничения и не ломает DRM: она читает ровно те данные, которые отдаёт браузеру обычный веб-плеер. Ответственность за использование — на пользователе; уважайте авторские права и правила сайтов.
Код: github.com/ialakey/anime-dl-core · Пакет: pypi.org/project/anime-dl-core · Лицензия MIT.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.