Cloudflare Workers

Un Worker delante de tu web: no tocas el servidor y funciona con cualquier hosting.

Markdown

Si tu dominio usa Cloudflare, un Worker en la ruta de tu sitio recibe todas las peticiones antes que tu servidor. Las de bots se las pide a Jazui; el resto las manda a tu servidor con fetch(request), como si el Worker no estuviera. Es la integración más sencilla y la que menos latencia añade.

Antes de empezar

  • El dominio en Cloudflare con el proxy activado (nube naranja).
  • Tu sitio verificado en Jazui y tu token.
  • El plan gratuito de Workers es suficiente para la mayoría de sitios.

Opción 1: desde el panel de Cloudflare

  1. En Cloudflare, ve a Workers y Pages › Crear › Crear Worker, ponle de nombre jazui y pulsa Implementar.
  2. Pulsa Editar código, borra lo que haya y pega este archivo completo (es la lógica común más el Worker, en uno solo):
worker.jsDescargar
// Generado por 3_Integrations/Sync-Integrations.ps1 (js/core.js + js/cloudflare-worker.js): no editar aquí.

// Núcleo común de las integraciones JavaScript de Jazui (Cloudflare Workers, Netlify, Vercel y Express).
// Solo usa la API Fetch estándar: funciona en Node 18+, Deno y los runtimes de borde.

const DEFAULT_API = 'https://api.jazui.com';
const DEFAULT_TIMEOUT_MS = 20000;

// Bots a los que se sirve el HTML renderizado (bots.txt; lo rellena Sync-Integrations.ps1)
const BOTS = [
  // BOTS:BEGIN
  'googlebot', 'google-inspectiontool', 'storebot-google', 'adsbot-google', 'bingbot', 'bingpreview', 'msnbot',
  'adidxbot', 'yandex', 'baiduspider', 'duckduckbot', 'slurp', 'applebot', 'seznambot', 'qwantbot', 'qwantify',
  'petalbot', 'sogou', 'naver', 'exabot', 'yetibot', '360spider', 'soso', 'gptbot', 'chatgpt', 'oai-searchbot',
  'claudebot', 'claude-user', 'claude-searchbot', 'anthropic-ai', 'perplexity', 'google-cloudvertexbot',
  'bytespider', 'ccbot', 'amazonbot', 'meta-externalagent', 'cohere-ai', 'mistralai-user', 'youbot',
  'duckassistbot', 'yibot', 'qwenbot', 'tongyibot', 'erniebot', 'yiyanbot', 'doubaobot', 'tiktokspider',
  'deepseekbot', 'pangubot', 'manus-user', 'kimi-agent', 'kimi-searchbot', 'kimi-user', 'kimibot', 'moonshotbot',
  'hunyuan', 'chatglm-spider', 'agi agent', 'ai2bot', 'ai2bot-deepresearcheval', 'ai2bot-dolma', 'aihitbot',
  'amazon-kendra', 'amazon-qbusiness', 'amazonbuyforme', 'amzn-searchbot', 'amzn-user', 'bedrockbot', 'novaact',
  'andibot', 'claude-web', 'apifybot', 'apifywebsitecontentcrawler', 'atlassian-bot', 'awario', 'bigsur.ai',
  'brightbot', 'buddybot', 'cartai agentic commerce', 'terracotta', 'cledarabot', 'cloudflare-autorag',
  'cloudflarebrowserrenderingcrawler', 'cohere-training-data-crawler', 'cragcrawler', 'crawlspace',
  'datenbank crawler', 'diffbot', 'diffbot-user', 'anomura', 'echobot bot', 'echoboxbot', 'elevenlabsbot',
  'elevenlabscrawler', 'exasearchbot', 'exauser', 'factset_spyderbot', 'firecrawlagent', 'geisthaus-pagefetcher',
  'cloudvertexbot', 'gemini-deep-research', 'google-agent', 'google-firebase', 'google-notebooklm',
  'googleagent-mariner', 'googleagent-urlcontext', 'googleother', 'googleother-image', 'googleother-video',
  'notebooklm', 'iaskbot', 'iaskspider', 'imagesiftbot', 'img2dataset', 'isscyberriskcrawler', 'kagi-fetcher',
  'keenablebot', 'klaviyoaibot', 'laiondownloader', 'linguee bot', 'linkupbot', 'aiwebindex',
  'meta-externalfetcher', 'meta-webindexer', 'facebookbot', 'azureai-searchbot', 'mistralai-index',
  'mistralai-training', 'mozilla-tabstack', 'nagetbot', 'netestate imprint crawler', 'icc-crawler', 'oai-adsbot',
  'panscient', 'panscient.com', 'shapbot', 'payhawk invoice fetching agent', 'phindbot', 'poggio-citations',
  'poseidon research crawler', 'qualifiedbot', 'wpbot', 'querit-searchbot', 'queritbot', 'quillbot',
  'quillbot.com', 'reflectionbot', 'cotoyogi', 'sbintuitionsbot', 'scrybot', 'sidetrade indexer bot', 'tavilybot',
  'agenttimes', 'thinkbot', 'timpibot', 'twinagent', 'velenpublicwebcrawler', 'wardbot', 'omgili', 'omgilibot',
  'grok bot', 'grokappandroid', 'grokbot', 'addsearchbot', 'agentdatabot', 'aranet-searchbot', 'bixelbot',
  'bravebot', 'channel3bot', 'friendlycrawler', 'henkbot', 'imagespider', 'kangaroo bot', 'kunatocrawler',
  'laion-huggingface-processor', 'linerbot', 'mycentralaiscraperbot', 'terra cotta', 'wrtnbot', 'zanistabot',
  'facebookexternalhit', 'facebot', 'twitterbot', 'linkedinbot', 'slackbot', 'whatsapp', 'telegrambot',
  'discordbot', 'pinterest', 'redditbot', 'skypeuripreview', 'embedly', 'quora link preview', 'showyoubot',
  'outbrain', 'developers.google.com/+/web/snippet', 'vkshare', 'tumblr', 'bitlybot', 'nuzzel', 'flipboard',
  'snapchat', 'viber', 'mattermost', 'bitrix link preview', 'xing-contenttabreceiver', 'ahrefsbot', 'semrushbot',
  'mj12bot', 'majestic-12', 'dotbot', 'rogerbot', 'screaming frog', 'sitebulb', 'serpstatbot', 'seokicks',
  'siteimprove', 'lighthouse', 'google page speed', 'gtmetrix', 'pingdom', 'uptimerobot', 'uptimebot',
  'w3c_validator', 'w3c-checklink', 'archive.org_bot', 'paperlibot', 'dataprovider.com', 'grapeshotcrawler',
  'zoominfobot', 'feedly', 'newsblur', 'inoreader', 'integration-test',
  // BOTS:END
];

// Archivos que nunca se renderizan (extensions.txt)
const STATIC_EXTENSIONS = [
  // EXTENSIONS:BEGIN
  'js', 'mjs', 'css', 'less', 'map', 'json', 'xml', 'rss', 'txt', 'webmanifest', 'ico', 'png', 'jpg', 'jpeg',
  'gif', 'webp', 'avif', 'svg', 'bmp', 'tif', 'tiff', 'psd', 'ai', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3',
  'm4a', 'wav', 'ogg', 'mp4', 'm4v', 'webm', 'avi', 'mov', 'mpg', 'mpeg', 'wmv', 'flv', 'swf', 'pdf', 'doc',
  'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'zip', 'gz', 'rar', '7z', 'iso', 'torrent', 'exe', 'dmg', 'apk',
  'wasm', 'dll', 'dat', 'blat',
  // EXTENSIONS:END
];

const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
// Patrón de la lista incluida; refreshBots lo sustituye por el del catálogo de Jazui (GET /v2/bots)
const BOT_PATTERN = new RegExp(BOTS.map(escapeRegex).join('|'), 'i');
const STATIC_PATTERN = new RegExp(`\\.(${STATIC_EXTENSIONS.join('|')})$`, 'i');

let botPattern = BOT_PATTERN;
let botsVersion = 'incluida';
let botsCheckedAt = 0;
let botsLoading = null;
const BOTS_REFRESH_MS = 24 * 60 * 60 * 1000;
const BOTS_RETRY_MS = 60 * 60 * 1000;

/** Patrón de bots en uso: el del catálogo si ya se descargó, si no el de la lista incluida. */
const currentBotPattern = () => botPattern;

/** Versión de la lista en uso ('incluida' hasta la primera descarga). */
const currentBotsVersion = () => botsVersion;

/**
 * Descarga la lista de bots del catálogo de Jazui (GET /v2/bots) si toca: al arrancar y cada 24 h; si falla se
 * sigue con la que hay y se reintenta en una hora. Nunca falla ni retrasa la petición: devuelve la promesa para
 * waitUntil (Cloudflare, Vercel, Netlify) o null si no toca. Se desactiva con remoteBots: false.
 */
function refreshBots({ api = DEFAULT_API, remoteBots = true } = {}) {
  const now = Date.now();
  if (!remoteBots || botsLoading || now - botsCheckedAt < BOTS_REFRESH_MS) return null;
  botsCheckedAt = now;
  botsLoading = fetch(`${(api || DEFAULT_API).replace(/\/+$/, '')}/v2/bots`, { signal: AbortSignal.timeout(5000) })
    .then((response) => (response.ok ? response.json() : null))
    .then((data) => {
      if (data && Array.isArray(data.bots) && data.bots.length > 0) {
        botPattern = new RegExp(data.bots.map(escapeRegex).join('|'), 'i');
        botsVersion = data.version || botsVersion;
      } else {
        botsCheckedAt = now - BOTS_REFRESH_MS + BOTS_RETRY_MS;
      }
    }, () => { botsCheckedAt = now - BOTS_REFRESH_MS + BOTS_RETRY_MS; })
    .finally(() => { botsLoading = null; });
  return botsLoading;
}

// Cabeceras de Jazui que se devuelven al bot
const PASS_HEADERS = ['content-type', 'location', 'etag', 'last-modified', 'x-jazui', 'x-jazui-cache'];

/**
 * Si la petición hay que pasarla por Jazui: GET/HEAD de un bot conocido (o con _escaped_fragment_, el esquema
 * antiguo de Google para las SPA) a una página, no a un archivo.
 */
function shouldRender({ method, url, userAgent, headers }) {
  if (method !== 'GET' && method !== 'HEAD') return false;
  if (headers && headers.get('x-jazui')) return false; // nunca a sí mismo
  const { pathname, search } = new URL(url);
  if (STATIC_PATTERN.test(pathname)) return false;
  return (!!userAgent && botPattern.test(userAgent)) || search.includes('_escaped_fragment_');
}

/**
 * Pide a Jazui el HTML de la URL. Devuelve la Response para el bot o null si hay que servir la página normal
 * (Jazui no respondió, se pasó del límite, el sitio no está verificado…): el bot nunca ve un error de Jazui.
 * Solo un 403 marcado con X-Jazui-Blocked (regla o bot de IA bloqueado) llega al bot.
 */
async function fetchRendered({ url, method = 'GET', userAgent, clientIp, ifNoneMatch, token, api = DEFAULT_API,
                                      siteUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
  if (!token) return null;
  let target = url;
  if (siteUrl) {
    // Dominio público del sitio cuando el servidor lo ve con otro (proxy, entorno de pruebas)
    const original = new URL(url);
    target = new URL(original.pathname + original.search, siteUrl).toString();
  }
  const query = new URLSearchParams({ url: target, botAgent: userAgent || '' });
  const headers = { Authorization: `Bearer ${token}` };
  if (clientIp) headers['X-Jazui-Client-IP'] = clientIp;
  if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;

  let response;
  try {
    response = await fetch(`${(api || DEFAULT_API).replace(/\/+$/, '')}/Render?${query}`, {
      headers,
      redirect: 'manual',
      signal: AbortSignal.timeout(timeoutMs),
    });
  } catch {
    return null;
  }

  const blocked = response.status === 403 && response.headers.get('x-jazui-blocked');
  if (!response.headers.get('x-jazui') && !blocked) {
    response.body?.cancel();
    return null;
  }
  const out = new Headers();
  for (const name of PASS_HEADERS) {
    const value = response.headers.get(name);
    if (value) out.set(name, value);
  }
  if (blocked) out.set('x-jazui-blocked', blocked);
  const empty = method === 'HEAD' || response.status === 304;
  if (empty) response.body?.cancel();
  return new Response(empty ? null : response.body, { status: response.status, headers: out });
}

// User-agents con pinta de bot: si no están en BOTS se avisa a Jazui sin enviarle la página (el mismo patrón que la API)
const UNKNOWN_BOT_PATTERN = /bot|crawl|spider|scrap|fetch|headless|preview|archiv|index/i;
const MAX_REPORTED = 1000;
const reported = new Set();
let reportedDay = '';

/**
 * Avisa a Jazui (POST /Render/Seen) de un user-agent con pinta de bot que no está en la lista, una vez al día por
 * user-agent y proceso, para que se pueda añadir al catálogo. No gasta renders y nunca falla: devuelve la promesa para
 * waitUntil (Cloudflare, Vercel) o null si no hay que avisar.
 */
function reportUnknownBot({ method, url, userAgent, token, api = DEFAULT_API, reportUnknownBots = true }) {
  if (!reportUnknownBots || !token || !userAgent || (method !== 'GET' && method !== 'HEAD')) return null;
  if (STATIC_PATTERN.test(new URL(url).pathname)) return null;
  if (!UNKNOWN_BOT_PATTERN.test(userAgent) || botPattern.test(userAgent)) return null;
  const today = new Date().toISOString().slice(0, 10);
  if (reportedDay !== today) {
    reported.clear();
    reportedDay = today;
  }
  if (reported.size >= MAX_REPORTED || reported.has(userAgent)) return null;
  reported.add(userAgent);
  return fetch(`${(api || DEFAULT_API).replace(/\/+$/, '')}/Render/Seen`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ userAgent, url }),
    signal: AbortSignal.timeout(5000),
  }).then((response) => { response.body?.cancel(); }, () => {});
}

/**
 * Atajo para los runtimes con Request estándar: la Response de Jazui o null. Con options.waitUntil (Cloudflare,
 * Vercel) la descarga de la lista de bots y el aviso de bots desconocidos siguen aunque la respuesta ya se haya enviado.
 */
function renderRequest(request, options, clientIp) {
  const refresh = refreshBots(options);
  if (refresh && options.waitUntil) options.waitUntil(refresh);
  const userAgent = request.headers.get('user-agent') || '';
  if (!shouldRender({ method: request.method, url: request.url, userAgent, headers: request.headers })) {
    const report = reportUnknownBot({ ...options, method: request.method, url: request.url, userAgent });
    if (report && options.waitUntil) options.waitUntil(report);
    return null;
  }
  return fetchRendered({
    ...options,
    url: request.url,
    method: request.method,
    userAgent,
    clientIp,
    ifNoneMatch: request.headers.get('if-none-match'),
  });
}

// Cloudflare Worker delante del sitio: los bots reciben el HTML de Jazui y el resto va al origen.
// Variables del Worker: JAZUI_TOKEN (secreto) y, opcionales, JAZUI_API y JAZUI_SITE_URL.

export default {
  async fetch(request, env, ctx) {
    const rendered = await renderRequest(request, {
      token: env.JAZUI_TOKEN,
      api: env.JAZUI_API,
      siteUrl: env.JAZUI_SITE_URL,
      waitUntil: (promise) => ctx.waitUntil(promise),
    }, request.headers.get('cf-connecting-ip'));
    return rendered ?? fetch(request);
  },
};
  1. Pulsa Implementar.
  2. En Configuración › Variables y secretos, añade JAZUI_TOKEN de tipo Secreto con tu token.
  3. En Configuración › Dominios y rutas › Añadir › Ruta, elige tu zona y escribe misitio.com/* (y www.misitio.com/* si usas www).

Opción 2: con Wrangler

Si prefieres tenerlo en un repositorio, descarga cloudflare-worker.js y core.js (los dos están más abajo), ponlos juntos con este wrangler.toml, guarda el token como secreto y publica:

Terminal
mi-worker/
├── wrangler.toml
├── cloudflare-worker.js
└── core.js
wrangler.toml
name = "jazui"
main = "cloudflare-worker.js"
compatibility_date = "2025-01-01"
routes = [{ pattern = "misitio.com/*", zone_name = "misitio.com" }]
Terminal
npx wrangler secret put JAZUI_TOKEN
npx wrangler deploy

Variables de entorno

VariableObligatoriaPara qué
JAZUI_TOKENSíTu token de la cuenta o del sitio. Guárdalo como secreto, nunca en el código.
JAZUI_APINoDirección de la API. Por defecto https://api.jazui.com.
JAZUI_SITE_URLNoTu dominio público (por ejemplo https://misitio.com) si la plataforma ve otro, como una dirección de vista previa: Jazui renderiza la página con ese dominio.

Sin JAZUI_TOKEN la integración no hace nada y toda la web se sirve normal.

Qué hace

El Worker llama a la lógica común con tu token y la IP del bot, que Cloudflare da en CF-Connecting-IP. Si Jazui devuelve la página, la entrega; si no, sigue con fetch(request) hacia tu servidor. La lista de bots y los avisos de bots desconocidos se hacen en segundo plano con ctx.waitUntil, sin retrasar la respuesta.

cloudflare-worker.jsDescargar
// Cloudflare Worker delante del sitio: los bots reciben el HTML de Jazui y el resto va al origen.
// Variables del Worker: JAZUI_TOKEN (secreto) y, opcionales, JAZUI_API y JAZUI_SITE_URL.
import { renderRequest } from './core.js';

export default {
  async fetch(request, env, ctx) {
    const rendered = await renderRequest(request, {
      token: env.JAZUI_TOKEN,
      api: env.JAZUI_API,
      siteUrl: env.JAZUI_SITE_URL,
      waitUntil: (promise) => ctx.waitUntil(promise),
    }, request.headers.get('cf-connecting-ip'));
    return rendered ?? fetch(request);
  },
};
core.jsDescargar
// Núcleo común de las integraciones JavaScript de Jazui (Cloudflare Workers, Netlify, Vercel y Express).
// Solo usa la API Fetch estándar: funciona en Node 18+, Deno y los runtimes de borde.

export const DEFAULT_API = 'https://api.jazui.com';
export const DEFAULT_TIMEOUT_MS = 20000;

// Bots a los que se sirve el HTML renderizado (bots.txt; lo rellena Sync-Integrations.ps1)
export const BOTS = [
  // BOTS:BEGIN
  'googlebot', 'google-inspectiontool', 'storebot-google', 'adsbot-google', 'bingbot', 'bingpreview', 'msnbot',
  'adidxbot', 'yandex', 'baiduspider', 'duckduckbot', 'slurp', 'applebot', 'seznambot', 'qwantbot', 'qwantify',
  'petalbot', 'sogou', 'naver', 'exabot', 'yetibot', '360spider', 'soso', 'gptbot', 'chatgpt', 'oai-searchbot',
  'claudebot', 'claude-user', 'claude-searchbot', 'anthropic-ai', 'perplexity', 'google-cloudvertexbot',
  'bytespider', 'ccbot', 'amazonbot', 'meta-externalagent', 'cohere-ai', 'mistralai-user', 'youbot',
  'duckassistbot', 'yibot', 'qwenbot', 'tongyibot', 'erniebot', 'yiyanbot', 'doubaobot', 'tiktokspider',
  'deepseekbot', 'pangubot', 'manus-user', 'kimi-agent', 'kimi-searchbot', 'kimi-user', 'kimibot', 'moonshotbot',
  'hunyuan', 'chatglm-spider', 'agi agent', 'ai2bot', 'ai2bot-deepresearcheval', 'ai2bot-dolma', 'aihitbot',
  'amazon-kendra', 'amazon-qbusiness', 'amazonbuyforme', 'amzn-searchbot', 'amzn-user', 'bedrockbot', 'novaact',
  'andibot', 'claude-web', 'apifybot', 'apifywebsitecontentcrawler', 'atlassian-bot', 'awario', 'bigsur.ai',
  'brightbot', 'buddybot', 'cartai agentic commerce', 'terracotta', 'cledarabot', 'cloudflare-autorag',
  'cloudflarebrowserrenderingcrawler', 'cohere-training-data-crawler', 'cragcrawler', 'crawlspace',
  'datenbank crawler', 'diffbot', 'diffbot-user', 'anomura', 'echobot bot', 'echoboxbot', 'elevenlabsbot',
  'elevenlabscrawler', 'exasearchbot', 'exauser', 'factset_spyderbot', 'firecrawlagent', 'geisthaus-pagefetcher',
  'cloudvertexbot', 'gemini-deep-research', 'google-agent', 'google-firebase', 'google-notebooklm',
  'googleagent-mariner', 'googleagent-urlcontext', 'googleother', 'googleother-image', 'googleother-video',
  'notebooklm', 'iaskbot', 'iaskspider', 'imagesiftbot', 'img2dataset', 'isscyberriskcrawler', 'kagi-fetcher',
  'keenablebot', 'klaviyoaibot', 'laiondownloader', 'linguee bot', 'linkupbot', 'aiwebindex',
  'meta-externalfetcher', 'meta-webindexer', 'facebookbot', 'azureai-searchbot', 'mistralai-index',
  'mistralai-training', 'mozilla-tabstack', 'nagetbot', 'netestate imprint crawler', 'icc-crawler', 'oai-adsbot',
  'panscient', 'panscient.com', 'shapbot', 'payhawk invoice fetching agent', 'phindbot', 'poggio-citations',
  'poseidon research crawler', 'qualifiedbot', 'wpbot', 'querit-searchbot', 'queritbot', 'quillbot',
  'quillbot.com', 'reflectionbot', 'cotoyogi', 'sbintuitionsbot', 'scrybot', 'sidetrade indexer bot', 'tavilybot',
  'agenttimes', 'thinkbot', 'timpibot', 'twinagent', 'velenpublicwebcrawler', 'wardbot', 'omgili', 'omgilibot',
  'grok bot', 'grokappandroid', 'grokbot', 'addsearchbot', 'agentdatabot', 'aranet-searchbot', 'bixelbot',
  'bravebot', 'channel3bot', 'friendlycrawler', 'henkbot', 'imagespider', 'kangaroo bot', 'kunatocrawler',
  'laion-huggingface-processor', 'linerbot', 'mycentralaiscraperbot', 'terra cotta', 'wrtnbot', 'zanistabot',
  'facebookexternalhit', 'facebot', 'twitterbot', 'linkedinbot', 'slackbot', 'whatsapp', 'telegrambot',
  'discordbot', 'pinterest', 'redditbot', 'skypeuripreview', 'embedly', 'quora link preview', 'showyoubot',
  'outbrain', 'developers.google.com/+/web/snippet', 'vkshare', 'tumblr', 'bitlybot', 'nuzzel', 'flipboard',
  'snapchat', 'viber', 'mattermost', 'bitrix link preview', 'xing-contenttabreceiver', 'ahrefsbot', 'semrushbot',
  'mj12bot', 'majestic-12', 'dotbot', 'rogerbot', 'screaming frog', 'sitebulb', 'serpstatbot', 'seokicks',
  'siteimprove', 'lighthouse', 'google page speed', 'gtmetrix', 'pingdom', 'uptimerobot', 'uptimebot',
  'w3c_validator', 'w3c-checklink', 'archive.org_bot', 'paperlibot', 'dataprovider.com', 'grapeshotcrawler',
  'zoominfobot', 'feedly', 'newsblur', 'inoreader', 'integration-test',
  // BOTS:END
];

// Archivos que nunca se renderizan (extensions.txt)
export const STATIC_EXTENSIONS = [
  // EXTENSIONS:BEGIN
  'js', 'mjs', 'css', 'less', 'map', 'json', 'xml', 'rss', 'txt', 'webmanifest', 'ico', 'png', 'jpg', 'jpeg',
  'gif', 'webp', 'avif', 'svg', 'bmp', 'tif', 'tiff', 'psd', 'ai', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3',
  'm4a', 'wav', 'ogg', 'mp4', 'm4v', 'webm', 'avi', 'mov', 'mpg', 'mpeg', 'wmv', 'flv', 'swf', 'pdf', 'doc',
  'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'zip', 'gz', 'rar', '7z', 'iso', 'torrent', 'exe', 'dmg', 'apk',
  'wasm', 'dll', 'dat', 'blat',
  // EXTENSIONS:END
];

const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
// Patrón de la lista incluida; refreshBots lo sustituye por el del catálogo de Jazui (GET /v2/bots)
export const BOT_PATTERN = new RegExp(BOTS.map(escapeRegex).join('|'), 'i');
export const STATIC_PATTERN = new RegExp(`\\.(${STATIC_EXTENSIONS.join('|')})$`, 'i');

let botPattern = BOT_PATTERN;
let botsVersion = 'incluida';
let botsCheckedAt = 0;
let botsLoading = null;
const BOTS_REFRESH_MS = 24 * 60 * 60 * 1000;
const BOTS_RETRY_MS = 60 * 60 * 1000;

/** Patrón de bots en uso: el del catálogo si ya se descargó, si no el de la lista incluida. */
export const currentBotPattern = () => botPattern;

/** Versión de la lista en uso ('incluida' hasta la primera descarga). */
export const currentBotsVersion = () => botsVersion;

/**
 * Descarga la lista de bots del catálogo de Jazui (GET /v2/bots) si toca: al arrancar y cada 24 h; si falla se
 * sigue con la que hay y se reintenta en una hora. Nunca falla ni retrasa la petición: devuelve la promesa para
 * waitUntil (Cloudflare, Vercel, Netlify) o null si no toca. Se desactiva con remoteBots: false.
 */
export function refreshBots({ api = DEFAULT_API, remoteBots = true } = {}) {
  const now = Date.now();
  if (!remoteBots || botsLoading || now - botsCheckedAt < BOTS_REFRESH_MS) return null;
  botsCheckedAt = now;
  botsLoading = fetch(`${(api || DEFAULT_API).replace(/\/+$/, '')}/v2/bots`, { signal: AbortSignal.timeout(5000) })
    .then((response) => (response.ok ? response.json() : null))
    .then((data) => {
      if (data && Array.isArray(data.bots) && data.bots.length > 0) {
        botPattern = new RegExp(data.bots.map(escapeRegex).join('|'), 'i');
        botsVersion = data.version || botsVersion;
      } else {
        botsCheckedAt = now - BOTS_REFRESH_MS + BOTS_RETRY_MS;
      }
    }, () => { botsCheckedAt = now - BOTS_REFRESH_MS + BOTS_RETRY_MS; })
    .finally(() => { botsLoading = null; });
  return botsLoading;
}

// Cabeceras de Jazui que se devuelven al bot
const PASS_HEADERS = ['content-type', 'location', 'etag', 'last-modified', 'x-jazui', 'x-jazui-cache'];

/**
 * Si la petición hay que pasarla por Jazui: GET/HEAD de un bot conocido (o con _escaped_fragment_, el esquema
 * antiguo de Google para las SPA) a una página, no a un archivo.
 */
export function shouldRender({ method, url, userAgent, headers }) {
  if (method !== 'GET' && method !== 'HEAD') return false;
  if (headers && headers.get('x-jazui')) return false; // nunca a sí mismo
  const { pathname, search } = new URL(url);
  if (STATIC_PATTERN.test(pathname)) return false;
  return (!!userAgent && botPattern.test(userAgent)) || search.includes('_escaped_fragment_');
}

/**
 * Pide a Jazui el HTML de la URL. Devuelve la Response para el bot o null si hay que servir la página normal
 * (Jazui no respondió, se pasó del límite, el sitio no está verificado…): el bot nunca ve un error de Jazui.
 * Solo un 403 marcado con X-Jazui-Blocked (regla o bot de IA bloqueado) llega al bot.
 */
export async function fetchRendered({ url, method = 'GET', userAgent, clientIp, ifNoneMatch, token, api = DEFAULT_API,
                                      siteUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
  if (!token) return null;
  let target = url;
  if (siteUrl) {
    // Dominio público del sitio cuando el servidor lo ve con otro (proxy, entorno de pruebas)
    const original = new URL(url);
    target = new URL(original.pathname + original.search, siteUrl).toString();
  }
  const query = new URLSearchParams({ url: target, botAgent: userAgent || '' });
  const headers = { Authorization: `Bearer ${token}` };
  if (clientIp) headers['X-Jazui-Client-IP'] = clientIp;
  if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;

  let response;
  try {
    response = await fetch(`${(api || DEFAULT_API).replace(/\/+$/, '')}/Render?${query}`, {
      headers,
      redirect: 'manual',
      signal: AbortSignal.timeout(timeoutMs),
    });
  } catch {
    return null;
  }

  const blocked = response.status === 403 && response.headers.get('x-jazui-blocked');
  if (!response.headers.get('x-jazui') && !blocked) {
    response.body?.cancel();
    return null;
  }
  const out = new Headers();
  for (const name of PASS_HEADERS) {
    const value = response.headers.get(name);
    if (value) out.set(name, value);
  }
  if (blocked) out.set('x-jazui-blocked', blocked);
  const empty = method === 'HEAD' || response.status === 304;
  if (empty) response.body?.cancel();
  return new Response(empty ? null : response.body, { status: response.status, headers: out });
}

// User-agents con pinta de bot: si no están en BOTS se avisa a Jazui sin enviarle la página (el mismo patrón que la API)
export const UNKNOWN_BOT_PATTERN = /bot|crawl|spider|scrap|fetch|headless|preview|archiv|index/i;
const MAX_REPORTED = 1000;
const reported = new Set();
let reportedDay = '';

/**
 * Avisa a Jazui (POST /Render/Seen) de un user-agent con pinta de bot que no está en la lista, una vez al día por
 * user-agent y proceso, para que se pueda añadir al catálogo. No gasta renders y nunca falla: devuelve la promesa para
 * waitUntil (Cloudflare, Vercel) o null si no hay que avisar.
 */
export function reportUnknownBot({ method, url, userAgent, token, api = DEFAULT_API, reportUnknownBots = true }) {
  if (!reportUnknownBots || !token || !userAgent || (method !== 'GET' && method !== 'HEAD')) return null;
  if (STATIC_PATTERN.test(new URL(url).pathname)) return null;
  if (!UNKNOWN_BOT_PATTERN.test(userAgent) || botPattern.test(userAgent)) return null;
  const today = new Date().toISOString().slice(0, 10);
  if (reportedDay !== today) {
    reported.clear();
    reportedDay = today;
  }
  if (reported.size >= MAX_REPORTED || reported.has(userAgent)) return null;
  reported.add(userAgent);
  return fetch(`${(api || DEFAULT_API).replace(/\/+$/, '')}/Render/Seen`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ userAgent, url }),
    signal: AbortSignal.timeout(5000),
  }).then((response) => { response.body?.cancel(); }, () => {});
}

/**
 * Atajo para los runtimes con Request estándar: la Response de Jazui o null. Con options.waitUntil (Cloudflare,
 * Vercel) la descarga de la lista de bots y el aviso de bots desconocidos siguen aunque la respuesta ya se haya enviado.
 */
export function renderRequest(request, options, clientIp) {
  const refresh = refreshBots(options);
  if (refresh && options.waitUntil) options.waitUntil(refresh);
  const userAgent = request.headers.get('user-agent') || '';
  if (!shouldRender({ method: request.method, url: request.url, userAgent, headers: request.headers })) {
    const report = reportUnknownBot({ ...options, method: request.method, url: request.url, userAgent });
    if (report && options.waitUntil) options.waitUntil(report);
    return null;
  }
  return fetchRendered({
    ...options,
    url: request.url,
    method: request.method,
    userAgent,
    clientIp,
    ifNoneMatch: request.headers.get('if-none-match'),
  });
}

<p>Pide tu portada como si fueras un bot, con el agente de usuario de pruebas <code>integration-test</code>:</p><pre><code>curl -I -A "integration-test" https://misitio.com/</code></pre><p>La respuesta debe llevar <code>x-jazui: true</code> y <code>x-jazui-cache: MISS</code> la primera vez y <code>HIT</code> después. Sin agente de bot (<code>curl -I https://misitio.com/</code>) debe llegar tu web de siempre, sin <code>x-jazui</code>. En el panel, la petición aparece en <b>Tráfico de bots</b> y la página en <b>Páginas en caché</b>.</p>

Pide tu portada como si fueras un bot, con el agente de usuario de pruebas integration-test:

curl -I -A "integration-test" https://misitio.com/

La respuesta debe llevar x-jazui: true y x-jazui-cache: MISS la primera vez y HIT después. Sin agente de bot (curl -I https://misitio.com/) debe llegar tu web de siempre, sin x-jazui. En el panel, la petición aparece en Tráfico de bots y la página en Páginas en caché.

Si algo no funciona

  • No aparece x-jazui: comprueba que la ruta del Worker cubre la dirección que pruebas (con y sin www) y que JAZUI_TOKEN está guardado como secreto en ese Worker.
  • El registro DNS no tiene la nube naranja: sin el proxy de Cloudflare, el Worker no recibe las peticiones.
  • Tienes otro Worker en la misma ruta: solo se ejecuta uno por ruta; combina la lógica en uno o separa las rutas.
  • En Workers › jazui › Registros puedes ver en vivo cada petición y si hubo errores.