Cap. 22 · Código
Worker 410 com liveness check
Execução
Deploy no Cloudflare
$ npm i -g wrangler$ wrangler login$ cp wrangler.toml.example wrangler.toml# edite account_id, WP_API e as rotas (routes) / edit account_id, WP_API and routes$ wrangler deployDefina env.WP_API (origem WordPress) para o liveness check funcionar. Sem ele, o Worker falha em segurança e repassa a requisição.
Código-fonte
Código do módulo
/** * worker.js — Cap. 22: "Aparecer ou Sumir" * * Cloudflare Worker (ES module) that returns HTTP 410 Gone at the edge for a * curated list of dead URL paths — BUT only after confirming with the origin * that the content is really gone (liveness check). * * Why 410 instead of 404? A 410 tells Google (and other crawlers) the resource * is permanently gone, so it is dropped from the index faster than a 404, which * is treated as "maybe temporary". Serving it at the edge means the origin is * never even hit for these paths once the verdict is cached. * * The dead-URL list is NOT hardcoded in this file: it is LOADED from the bundled * `urls-removidas.txt` (embedded below as URLS_REMOVIDAS_TXT and parsed at * startup by parseRemovedUrls). Edit the .txt, redeploy, and the Worker picks up * the new list — no code changes. Extra paths can still be appended at runtime * via env.GONE_PATHS (comma-separated). * * The safety net — LIVENESS CHECK: * Before serving 410, the Worker asks the origin (WordPress REST API) whether * the content is *really* gone. If WordPress unexpectedly answers 200, the page * was probably revived / republished, so we DO NOT serve 410 — we pass the * request through to origin. This prevents us from nuking content that came * back to life. The liveness result is cached briefly (via the Cache API) so we * don't hammer the origin on every request. * * Configuration comes from `env` (see wrangler.toml.example): * env.WP_API -> base URL of the WordPress REST API, * e.g. "https://origin.exemplo.com" (TODO: set this) * env.GONE_PATHS -> (optional) comma-separated extra paths to treat as gone. *//** * The removed-URL list, embedded verbatim from `urls-removidas.txt`. * Keeping it inline means the Worker bundle is fully self-contained (Workers * have no filesystem at runtime) while the .txt in the repo stays the single * editable source of truth — the two are shipped together in the same module. */const URLS_REMOVIDAS_TXT = `# urls-removidas.txt — Cap. 22# Uma URL removida por linha (apenas o path, sem domínio).# Linhas em branco e linhas iniciadas por "#" são ignoradas.# Comparação é case-insensitive e ignora a barra final.# TODO: troque estes exemplos pelas suas próprias URLs aposentadas./promo-black-friday-2019/produto/kit-antigo-descontinuado/blog/post-que-foi-despublicado/categoria/colecao-verao-2020/servicos/plano-legado/lp/campanha-encerrada/loja/estoque-liquidado/eventos/webinar-2021-03`;/** How long (seconds) to cache a liveness decision at the edge. */const LIVENESS_TTL_SECONDS = 300; // 5 minutes/** Normalises a pathname for comparison: lowercase, no trailing slash. */function normalisePath(pathname) { let p = pathname.toLowerCase(); if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1); return p;}/** * Parses the contents of `urls-removidas.txt` into a Set of normalised paths. * Ignores blank lines and comment lines (starting with "#"). This is the small * embedded parser that lets the .txt drive the Worker's behaviour. */function parseRemovedUrls(text) { const set = new Set(); for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; set.add(normalisePath(line)); } return set;}/** Dead paths loaded once from the bundled list at module init. */const DEFAULT_GONE_PATHS = parseRemovedUrls(URLS_REMOVIDAS_TXT);/** * Builds the Set of gone paths, merging the list parsed from the .txt with any * extra paths provided via env.GONE_PATHS (comma-separated). */function buildGoneSet(env) { const set = new Set(DEFAULT_GONE_PATHS); if (env && typeof env.GONE_PATHS === "string" && env.GONE_PATHS.trim()) { for (const raw of env.GONE_PATHS.split(",")) { const p = normalisePath(raw.trim()); if (p) set.add(p); } } return set;}/** The 410 response body + headers actually served to clients and crawlers. */function goneResponse() { const body = "<!doctype html><html lang=\"pt-BR\"><head><meta charset=\"utf-8\">" + "<title>410 - Conteúdo removido</title></head><body>" + "<h1>410 - Conteúdo removido permanentemente</h1>" + "<p>Esta página foi removida e não está mais disponível.</p>" + "</body></html>"; return new Response(body, { status: 410, statusText: "Gone", headers: { "Content-Type": "text/html; charset=UTF-8", // Let crawlers cache the "gone" verdict; adjust to taste. "Cache-Control": "public, max-age=3600", // Belt-and-suspenders: keep these out of the index even if rendered. "X-Robots-Tag": "noindex", }, });}/** * Liveness check: asks the origin WordPress REST API whether the content at * `pathname` still exists. Returns true if the content appears GONE (safe to * serve 410), false if it looks ALIVE (should pass through). * * Strategy: query the WP REST API search endpoint for the slug. If WordPress * returns 404 for the mapped resource, or the search yields no live match, we * treat the content as gone. If it returns 200 with a live match, it is alive. * * Results are cached at the edge for LIVENESS_TTL_SECONDS using the Cache API, * keyed by a synthetic URL, so repeated hits don't re-query the origin. * * On any error (origin unreachable, timeout, malformed response) we FAIL SAFE * by returning false (alive) — i.e. we do NOT serve 410 if we can't confirm the * content is gone. Better to serve the page than to wrongly 410 live content. */async function isContentGone(pathname, env, ctx) { // Without a configured origin we cannot verify; fail safe (treat as alive). if (!env || !env.WP_API) { // TODO: set env.WP_API in wrangler.toml so liveness checks can run. return false; } const cache = caches.default; // Synthetic cache key — never actually fetched, just used as a cache handle. const cacheKey = new Request( `https://liveness.internal/${encodeURIComponent(pathname)}`, { method: "GET" }, ); // 1) Try the edge cache first. const cached = await cache.match(cacheKey); if (cached) { const verdict = await cached.text(); return verdict === "gone"; } let gone; try { // The slug is the last path segment; used to search WordPress. const slug = pathname.split("/").filter(Boolean).pop() || ""; const apiUrl = `${env.WP_API.replace(/\/$/, "")}` + `/wp-json/wp/v2/search?search=${encodeURIComponent(slug)}&per_page=1`; const originResponse = await fetch(apiUrl, { headers: { Accept: "application/json" }, cf: { cacheTtl: 0 }, }); if (originResponse.status === 404) { // Endpoint/resource explicitly missing -> gone. gone = true; } else if (originResponse.status === 200) { const results = await originResponse.json(); // If WordPress returns a live match whose URL ends with our path, the // content is ALIVE (revived). Otherwise treat as gone. const alive = Array.isArray(results) && results.some((item) => { if (!item || typeof item.url !== "string") return false; try { return normalisePath(new URL(item.url).pathname) === normalisePath(pathname); } catch { return false; } }); gone = !alive; } else { // Any other status (5xx, etc.) is inconclusive -> fail safe as alive. gone = false; } } catch (error) { // Network/parse failure -> fail safe: do not 410 content we can't verify. // eslint-disable-next-line no-console console.error("liveness check failed:", error); gone = false; } // 2) Store the verdict in the edge cache for a short while. const verdictResponse = new Response(gone ? "gone" : "alive", { headers: { "Cache-Control": `max-age=${LIVENESS_TTL_SECONDS}` }, }); // waitUntil so caching doesn't delay the response to the user. if (ctx && typeof ctx.waitUntil === "function") { ctx.waitUntil(cache.put(cacheKey, verdictResponse.clone())); } return gone;}export default { /** * @param {Request} request * @param {Record<string, string>} env * @param {{ waitUntil: (p: Promise<unknown>) => void }} ctx */ async fetch(request, env, ctx) { const url = new URL(request.url); const path = normalisePath(url.pathname); const goneSet = buildGoneSet(env); // Not a curated dead path -> nothing to do, pass straight through. if (!goneSet.has(path)) { return fetch(request); } // Curated as dead. Confirm with the origin before serving 410. const gone = await isContentGone(url.pathname, env, ctx); if (gone) { return goneResponse(); } // Content unexpectedly alive (or unverifiable) -> pass through to origin // so we never accidentally 410 revived content. return fetch(request); },};# urls-removidas.txt — Cap. 22# Uma URL removida por linha (apenas o path, sem domínio).# Linhas em branco e linhas iniciadas por "#" são ignoradas.# Comparação é case-insensitive e ignora a barra final.# TODO: troque estes exemplos pelas suas próprias URLs aposentadas./promo-black-friday-2019/produto/kit-antigo-descontinuado/blog/post-que-foi-despublicado/categoria/colecao-verao-2020/servicos/plano-legado/lp/campanha-encerrada/loja/estoque-liquidado/eventos/webinar-2021-03# wrangler.toml.example — Cap. 22: "Aparecer ou Sumir"## Copy this file to `wrangler.toml` and fill in your own values.# Deploy with: npx wrangler deploy## Docs: https://developers.cloudflare.com/workers/wrangler/configuration/name = "worker-410-gone"main = "worker.js"compatibility_date = "2026-01-01"# TODO: set your Cloudflare account ID (Dashboard -> Workers -> right sidebar).account_id = "TODO_YOUR_CLOUDFLARE_ACCOUNT_ID"# Route(s) this Worker runs on.# TODO: replace with your real zone / hostname pattern.# routes = [# { pattern = "exemplo.com/*", zone_name = "exemplo.com" }# ][vars]# TODO: base URL of your WordPress origin (used by the liveness check).WP_API = "https://origin.exemplo.com"# TODO (optional): extra dead paths as a comma-separated list. These are merged# with the list parsed from urls-removidas.txt (embedded in worker.js).GONE_PATHS = "/promo-antiga,/outra-url-morta"Dúvidas
Perguntas frequentes
Por que 410 em vez de 404?
O 410 Gone diz ao Google que o conteúdo saiu de forma permanente, então ele é removido do índice mais rápido que um 404, que é tratado como algo possivelmente temporário. Servido na borda, a origem nem é consultada para esses paths.
O que é o liveness check e por que ele importa?
Antes de servir 410, o Worker pergunta à origem (WordPress REST API) se o conteúdo realmente sumiu. Se a origem responder 200 (página revivida), o Worker repassa a requisição em vez de derrubar conteúdo que voltou. Em qualquer erro ele falha em segurança e trata como vivo.
Como a lista de URLs mortas é carregada?
Ela vem do urls-removidas.txt, embutido no worker.js e interpretado por um parser pequeno (parseRemovedUrls). Edite o .txt, refaça o deploy e a lista muda sem tocar na lógica. Paths extras podem ser somados em runtime via env.GONE_PATHS.
Implementação
Prefere que eu implemente?
Conte o seu caso numa conversa e a gente define o escopo juntos.
Agendar conversa