Navegador oculto (apiVersion 6)¶
Algunos sitios nunca ponen la dirección del video en su HTML: un reproductor incrustado la arma en la
página con sus propios scripts, y la única forma de conocerla es ejecutar la página. Para esos casos,
Kino 0.9.50 deja que un plugin abra la página en una vista web oculta en el aparato y reciba las
peticiones de video que hizo la página: kino.browser.capture. Es el último recurso, no la primera
herramienta.
Prefiere kino.fetch
Prueba primero el camino barato: un kino.fetch de la página o del embed, y
la dirección leída de su HTML, su JSON o una variable de un script (kino.html.select
ayuda). Es más rápido (no hay página que arrancar ni reproductor que esperar), corre en el
kit de Node y nunca necesita la línea roja de consentimiento. Deja el navegador
oculto para los servidores que de verdad necesitan que la página corra.
Cuándo usarlo¶
- Un embed cuya dirección de video solo aparece cuando la página ejecuta sus propios scripts (un reproductor ofuscado, un token calculado en la página, una dirección que el reproductor pide después de cargar).
- Un sitio donde cada servidor de un capítulo es un embed distinto, algunos sencillos (usa
kino.fetch) y otros no (usa la captura solo para esos).
Cuándo no usarlo:
- Para leer una lista, una búsqueda o la página de un título:
kino.browser.capturesolo existe enresolvey devuelve peticiones de video, no HTML. (Kino 0.9.50 también traekino.browser.page, con"browser": "pages", para leer páginas; ver abajo.) - Para pasar un sitio que pide una persona. Kino nunca resuelve un captcha, y tu plugin tampoco puede: ver La regla dura.
El permiso, y lo que ve la persona¶
"apiVersion": 6,
"browser": true,
"streamHosts": "any"
"browser": truenecesita"apiVersion": 6; por debajo, el campo se ignora."browser": "pages"además permitekino.browser.page, con su propia línea roja. Cualquier otro valor se rechaza con "El campo \"browser\" debe ser true, false o \"pages\"".- La pantalla de consentimiento lo muestra en rojo: "Puede abrir páginas web ocultas para encontrar el video". Una actualización que lo agrega espera a que la persona apruebe de nuevo, como un host nuevo.
- Un plugin aprobado para esto tiene un límite de
resolvemás largo: 75 s en vez de 20 s, porque cada página puede tardar hasta 25 s. - La página que abres debe estar en un host al que llega tu
kino.fetch(tushosts), porhttps. Después la página puede cargar desde cualquier servidor público (el reproductor de un embed vive en hosts que no puedes listar de antemano), y el video que encuentra suele estar en uno de esos, así que un plugin que reproduce lo que captura necesita también"streamHosts": "any"(otra línea roja). - Mientras corre una captura, la persona no ve nada nuevo: el reproductor muestra su carga de siempre. La página nunca sale en pantalla.
Solo en un resolve que empezó la persona¶
kino.browser.capture funciona solo dentro de resolve, y solo en un resolve que empezó la persona:
le dio play, o empezó una descarga (incluida su elección entre tus copias). En cualquier otro lugar
-- search, home, episodes, o un resolve que Kino corre en segundo plano, como una revisión de
disponibilidad -- lanza not_allowed. Kino nunca resuelve por adelantado los canales en vivo de un
plugin con navegador mientras la persona hace zapping.
Hay una sola página a la vez en toda la app. Una captura que empieza con otra página abierta lanza
busy; la captura de una descarga nunca espera a otra página (recibe busy de una vez y esa copia se
salta).
El modelo de seguridad¶
La página corre en el aparato de la persona, así que Kino la encierra:
- Un proxy dentro de Kino. Todo el tráfico de la página pasa por un proxy en el loopback del aparato, abierto solo mientras corre esa captura y que solo le responde a la página de esa captura, con una credencial hecha para esa captura. El proxy se conecta solo a la dirección que revisó, así que un nombre no puede responder una dirección a la revisión y otra a la conexión (la IP revisada queda fija); la WebView misma no resuelve ningún nombre.
- La red de la casa, nunca. Toda petición de la página a un nombre local, a una dirección privada o
de loopback, o a un nombre público que resuelve dentro de la red de la casa recibe una respuesta
vacía. La página inicial misma debe ser pública y
https, o la captura lanzablocked. - Todos los métodos, la misma revisión.
POSTfunciona, la página sigue las redirecciones como en un navegador, y las conexiones WebSocket pasan por la misma revisión. WebRTC está apagado. - A dónde puede ir la página principal. La página de una captura puede navegar a cualquier sitio público en el nivel principal (la cadena de redirecciones de un embed salta de host a propósito): solo devuelve las peticiones de video que hizo la página y la última dirección de la página principal, nunca un documento. Una lectura de página es más estricta: su documento principal tiene que quedarse en tus hosts.
- Limpia cada vez. Cada página empieza sin cookies ni almacenamiento, y todo se borra al cerrarla.
Nada se comparte con
kino.cookies, con tus otras capturas ni con ningún otro plugin. - Nada sale. La página no puede abrir ventanas, descargar archivos, salir de http(s), leer archivos, pedir ubicación, cámara o micrófono, ni mostrar diálogos, y está oculta para los servicios de accesibilidad. El sonido va silenciado.
- Sin puente. No hay canal de la página a tu código: lo que recibes es solo lo que la página pidió.
- Un aparato cuya WebView no se puede apuntar a un proxy igual captura, con Kino haciendo él mismo cada
GET/HEAD(unPOSTentonces solo llega a una dirección IPv4 pública escrita como tal, y no hay WebSocket). Uno cuya WebView no puede correr scripts al inicio del documento, o que no tiene WebView (algunas cajas de TV), recibebrowser_unavailable.
La regla dura: Kino nunca resuelve un captcha¶
Una página que pide una persona termina la captura con blocked
Cuando la página muestra un CAPTCHA, ALTCHA, Turnstile, hCaptcha, una casilla de reCAPTCHA,
"Verify you are human" o "Confirme que es humano", la captura termina de inmediato con blocked.
Kino nunca intenta resolverlo, hacerle clic ni marcarlo, y tu plugin tampoco: nada de servicios que
resuelven captchas, trucos de huella del navegador ni bucles de reintento para desgastar la revisión.
Trata blocked como "este servidor no es para nosotros ahora" y pasa a tu siguiente servidor, o
falla con un error claro.
Cada autor es responsable de su propio plugin. Kino solo lista los plugins de la comunidad (su
búsqueda de la comunidad); no los recomienda ni los promociona. Si un plugin incumple las reglas
para plugins -- por ejemplo, usa userMessage para pedir plata, contraseñas o datos de contacto,
es malware o infringe los derechos de alguien --, Kino lo retira del índice de la comunidad con
community-blocklist.json (en la raíz de este repositorio), y cualquiera lo puede reportar
con la plantilla de issue "Reclamo / retiro de plugin". Una copia instalada sigue
instalada, su tarjeta dice "Retirado del índice de la comunidad." y no recibe más actualizaciones;
un fork necesita su propio reporte. Ver Reclamos y retiro de plugins.
kino.browser.capture(url, options?)¶
Abre url en la vista web oculta, deja correr la página (en silencio), le da play y responde con las
peticiones de video que hizo la página, primero los manifiestos (HLS/DASH) y después los MP4.
| opción | |
|---|---|
timeoutMs |
1 a 25000 ms; por defecto 18000. La captura termina en la primera petición de video (más 1 s para sus hermanas; los manifiestos van antes que los MP4 sin importar el orden en que llegaron) o aquí. |
headers |
Encabezados extra solo para la primera carga de la página (un Referer que un embed exige). Cookie, Host y similares se descartan. |
match |
Una expresión regular (sin distinguir mayúsculas, 1 a 500 caracteres) de lo que cuenta como el video; por defecto: .m3u8, .mpd, .mp4, master.txt, videoplayback, /hls/. |
autoplay |
Por defecto true: arranca cualquier video, hace clic unas veces en los botones de play/servidor de siempre y toca el centro de la página cada 2 s (un toque llega al reproductor de un embed de otro origen). Nunca toca una revisión humana: esa termina la captura. |
La respuesta es { media: [{ url, mime?, headers }], subtitles: [{ url }], finalUrl }, máximo 8 media
y 10 subtítulos.
Tiempos¶
- Cada captura:
timeoutMs, máximo 25 s (18 s por defecto). - Todo el
resolvede un plugin con navegador aprobado: 75 s, tus fetch y todas las capturas juntas. Caben dos o tres servidores; planea para el primero que responda, no para probarlos todos. timeoutquiere decir que la página no mostró ninguna petición de video a tiempo. Prueba el siguiente servidor.
Encabezados y cookies que debes devolver¶
La página nunca descarga una petición de video: Kino la retiene (la página recibe una respuesta
vacía), así que un token de un solo uso o atado a la sesión en su dirección sigue sin gastarse cuando el
reproductor lo pide. Los headers de cada media son los que llevaba la petición de la página (Referer,
Origin, User-Agent, Accept-Language, un encabezado con token…, máximo 12; nunca Range,
Accept-Encoding ni el paquete de la app) más las cookies de la página para esa dirección.
Devuélvelos como los headers del Stream y al reproductor le sirven lo mismo que le habrían servido a
la página. Sin ellos, la mayoría de estos servidores responden 403.
Un ejemplo completo¶
Una fuente cuya página de capítulo lista varios servidores, cada uno un embed. La lista se lee con
kino.fetch; un servidor se captura ya, y los demás se ofrecen como
copias perezosas con etiqueta que solo se capturan si la persona escoge una o
si la primera no se puede reproducir.
// kino-plugin.json: "apiVersion": 6, "browser": true, "streamHosts": "any",
// "hosts": ["example.com"], "capabilities": ["search", "episodes", "resolve"]
const BASE = "https://example.com";
async function listServers(episodeRef) { // barato: HTML plano, sin abrir página
const r = await kino.fetch(`${BASE}/episode/${episodeRef}`);
if (!r.ok) throw kino.error("unavailable", `episode ${r.status}`);
return kino.html.select(r.text, "li[data-embed]").map((li, i) => ({
id: String(i),
name: li.attrs.title || `Servidor ${i + 1}`,
lang: li.attrs["data-lang"] || "Latino",
}));
}
async function resolveServer(episodeRef, serverId) {
const page = await kino.browser.capture(`${BASE}/embed/${episodeRef}/${serverId}`, { timeoutMs: 18000 });
const [first, ...rest] = page.media; // primero HLS/DASH, después MP4
return {
url: first.url,
headers: first.headers, // Referer, User-Agent, Cookie...: devuélvelos
alternatives: rest.map((m) => ({ url: m.url, headers: m.headers })),
subtitles: page.subtitles.map((s) => ({ url: s.url, lang: "es" })),
};
}
export async function resolve(ref) {
// El ref propio de una copia perezosa: "<capítulo>|<servidor>". Resuelve solo ese servidor.
if (ref.includes("|")) {
const [episodeRef, serverId] = ref.split("|");
const { alternatives, ...stream } = await resolveServer(episodeRef, serverId);
return stream; // las alternatives de una copia se ignoran igual
}
const servers = await listServers(ref);
for (const [i, s] of servers.entries()) {
try {
const { alternatives, ...stream } = await resolveServer(ref, s.id);
return {
...stream,
label: `${s.lang} · ${s.name}`, // "Latino · Servidor 1"
alternatives: servers.filter((o) => o !== s).slice(0, 8)
.map((o) => ({ label: `${o.lang} · ${o.name}`, ref: `${ref}|${o.id}` })),
};
} catch (e) {
if (["blocked", "timeout"].includes(e.code) && i < 2) continue; // siguiente servidor, dentro de los 75 s
throw e;
}
}
throw kino.error("unavailable", "no server answered");
}
Fallas comunes¶
| Código | Qué pasó | Qué hacer |
|---|---|---|
blocked |
La página pidió una persona (captcha), o el host inicial no es tuyo, no es https o resuelve dentro de la red de la casa. | Pasa al siguiente servidor. Nunca reintentes el mismo en bucle, nunca intentes resolverlo. |
timeout |
La página no mostró ninguna petición de video en timeoutMs. |
Siguiente servidor; revisa match si el reproductor usa una dirección rara. |
busy |
Hay otra página oculta abierta (una en toda la app), o la captura de una descarga encontró una abierta. | Falla esta copia; Kino sigue con la próxima. No esperes en un bucle. |
browser_unavailable |
No hay WebView en este aparato (algunas cajas de TV), o la que hay no puede correr scripts al inicio del documento. Siempre en el kit de Node. | Deja un camino con kino.fetch para los servidores que lo permiten, para que esos aparatos (y el kit) igual reproduzcan algo. |
not_allowed |
Sin aprobar, fuera de resolve, o un resolve que nadie empezó. |
Llámalo solo desde resolve. |
invalid_request |
Una opción mala (timeoutMs fuera de rango, un match que no es una expresión válida…). |
Corrige la llamada. |
Para depurar: mientras el Modo debug de tu plugin está encendido ("debug": true
en el manifiesto solo lo deja así de entrada), las líneas de logcat de Kino sobre la página oculta
(etiqueta KinoPlugin/<id>) nombran los hosts y rutas que cargó; en una versión de producción con el
interruptor apagado nunca lo hacen. Ver Registro y telemetría.
kino.browser.page(url, options?): leer una página¶
También en Kino 0.9.50 (apiVersion 6), pero se pide por nombre:
"apiVersion": 6,
"browser": "pages"
"pages" incluye todo lo que da true (también kino.browser.capture). La línea roja entonces lo
dice: "Puede abrir páginas web ocultas para mostrar contenido y encontrar el video". "browser": true
sigue siendo solo captura, con la línea de antes, y su kino.browser.page responde not_allowed. Un
plugin que la persona aprobó con true vuelve a preguntar cuando su actualización dice "pages"
-- también en la pasada automática de actualizaciones después de actualizar Kino, y en otro aparato que
solo aprobó la línea de antes. Cualquier otro valor se rechaza: "El campo \"browser\" debe ser true,
false o \"pages\"".
Algunos sitios responden cada kino.fetch normal con su revisión automática de navegador (el "Just a
moment…" de Cloudflare). kino.browser.page carga url en la misma vista web oculta de la captura
-- misma regla del host inicial, mismo proxy y mismo rechazo de la red de la casa, cookies y
almacenamiento nuevos, una sola página a la vez en toda la app -- y devuelve el HTML de la página cuando
ya cargó, ya no es la página de revisión del sitio (el "Just a moment…" de Cloudflare, sus marcas
cf-chl) y coincide con waitFor.
Dónde. Desde search, home, browse, episodes, section o resolve, solo mientras la
persona está usando la app: su propia búsqueda, la fila de Inicio o la sección que abrió, una lista, un
título, su play (un resolve también para una descarga que ella empezó). Una llamada que Kino hace por
su cuenta recibe not_allowed: "Para ti" revisando sus sugerencias cuando termina un capítulo,
categories (siempre es una llamada propia de Kino, aunque la persona esté mirando Categorías), las
filas de Inicio que pide por adelantado al abrir, los capítulos nuevos, el resolve por adelantado del
zapping en vivo, la sincronización y la búsqueda de actualizaciones. También la lectura de una lista
mientras la app no está al frente.
categories no puede leer páginas
Versiones anteriores de 0.9.50 ponían categories entre los exports que pueden llamar
kino.browser.page; ya no. Arma tus mosaicos de Categorías con datos que ya tienes (un
kino.fetch, o lo que una lectura de página en home o section dejó en
kino.storage).
La página tiene que quedarse en tus hosts. No solo la dirección inicial: cada navegación del nivel
principal -- una redirección, un meta refresh, un script que cambia location, una redirección abierta
-- y el documento que finalmente se lee tienen que estar en un host al que llega tu kino.fetch, por
https. El primer salto que no lo está corta la lectura con blocked ("la página terminó en un host que
el plugin no declaró") y no vuelve ningún HTML. Los frames, scripts e imágenes dentro de la página sí
pueden cargar desde cualquier servidor público. Nada en la página se reproduce (el video necesita un
gesto que nunca llega), y la página oculta no recibe ningún toque ni tecla de la persona.
// Un sitio cuyo kino.fetch siempre responde el 403 "Just a moment…" de Cloudflare.
const BASE = "https://example.com";
async function read(url, waitFor) {
const r = await kino.fetch(url);
if (r.ok && !/just a moment|cf-chl/i.test(r.text)) return r.text; // primero el camino barato
const page = await kino.browser.page(url, { waitFor, timeoutMs: 12000 }); // dentro de los 15 s de search
return page.html;
}
export async function search(query) {
try {
const html = await read(`${BASE}/?s=${encodeURIComponent(query.q)}`, "class=\"item");
return kino.html.select(html, "article.item").map(/* … tus items … */);
} catch (e) {
// blocked: el sitio quiere una persona (un captcha) o nunca dejó entrar al aparato. Ríndete en silencio.
if (e.code === "blocked" || e.code === "busy" || e.code === "rate_limited") return [];
throw e;
}
}
| opción | |
|---|---|
timeoutMs |
1 a 25000 ms; por defecto 15000. Cuenta dentro del límite de tu propia llamada (search 15 s, tus otros fetch incluidos; home, browse, episodes, section 20 s; resolve 75 s para un plugin con navegador). Kino lo recorta a lo que queda de ese límite menos 1,5 s para que alcances a usar el HTML, así la lectura termina con su propio timeout en vez de cancelarse toda la llamada; igual, pasa unos 12000 en search y deja espacio para tus otros fetch. |
waitFor |
Una expresión regular de JavaScript (texto o RegExp, 1 a 500 caracteres, sin distinguir mayúsculas, contra el HTML dentro de la página; un RegExp conserva sus banderas m y s, las demás no cambian nada en una prueba): la página se devuelve solo cuando coincide. Sin ella, apenas carga la página y pasa su revisión. Úsala con páginas que llenan su lista con scripts. |
La respuesta es { html, finalUrl, status, truncated }: el doctype y el outerHTML del DOM después de
que corrieron los scripts de la página (máximo 2.000.000 caracteres, lo que acepta kino.html.select;
truncated es true cuando se cortó), la última dirección de la página principal y el estado HTTP de
su última carga.
En modo página Kino nunca toca la página. Ni clic, ni toque, ni tecla, ni scroll, ni ayuda de
autoplay. Así que la única revisión que puede pasar es una que se completa sola, como cuando una persona
abre el sitio: la revisión automática de Cloudflare suele hacerlo en unos segundos. Una página que pide
una persona termina la lectura de inmediato con blocked, y una página que sigue en su revisión cuando
se acaba timeoutMs también es blocked (el sitio no dejó entrar al aparato). No la reintentes en
bucle; pasa a otra fuente o no devuelvas nada.
Límites. Máximo 20 lecturas de página por minuto por plugin (rate_limited después: nunca se
martilla un sitio a través del navegador oculto). Cada lectura abre una página nueva, así que guarda lo
que leíste con kino.storage. La lectura de una lista espera hasta 8 s su turno
cuando hay otra página abierta (busy después), y la persona dándole play la termina.
Errores: browser_unavailable (sin WebView, y siempre en el kit de Node cuando la petición es válida y
el manifiesto dice "pages" -- antes el kit responde invalid_request y not_allowed como lo hace la
app; deja un camino con kino.fetch normal para que el kit pueda correr tu plugin), timeout (la
página no cargó, o waitFor nunca coincidió), blocked (una revisión que pide una persona, una revisión
que nunca pasó, un documento principal fuera de tus hosts; o el host inicial no es tuyo, no es https o
resuelve dentro de la red de la casa), busy, not_allowed (sin "browser": "pages" aprobado -- true es solo captura --, otra
función, o nadie está usando la app), rate_limited e invalid_request.
Un ejemplo real¶
Maratón (firmado, apiVersion 6, "browser": "pages") es un plugin hecho así:
lista los servidores e idiomas de cada capítulo con kino.fetch normal, reproduce el primero con
kino.browser.capture y ofrece los demás como copias perezosas con etiqueta
en el menú Servidor del reproductor, cada una capturada solo cuando la persona la escoge. Para todo lo
demás -- ajustes, sesiones, descargas, canales en vivo -- "Tu servidor"
(kinotvapp/kino-plugin-own-server) sigue siendo
el plugin de referencia completo.