Content Explorer
¿Por qué hemos creado un Cloudflare Worker?
El Content Explorer necesita descargar el HTML de cualquier web a partir de una URL para poder analizar su contenido.
El problema es que el navegador no puede hacer directamente peticiones a cualquier dominio debido a las restricciones de CORS.
Por eso hemos creado un Cloudflare Worker que actúa como intermediario:
1
2
3
4
5
6
7
8
9
Content Explorer
↓
Cloudflare Worker
↓
Web que queremos analizar
↓
HTML
↓
Content Explorer
El Worker recibe la URL que queremos analizar, realiza la petición desde el servidor y devuelve el HTML al Content Explorer.
Además, aprovechamos el Worker para añadir medidas de seguridad y optimizaciones:
- 🔐 API Key para impedir el uso no autorizado.
- 🌐 CORS limitado a
jorgerosa.dev. - 🔗 Validación de URLs (
http/https). - ↪️ Seguimiento de redirecciones.
- ⏱️ Timeout para evitar peticiones bloqueadas.
- 📦 Límite de 10 MB por página.
- 🧾 Comprobación de que el recurso sea HTML.
- 📍 Devolución de la URL final después de las redirecciones.
- ⚡ Caché de Cloudflare para evitar descargar repetidamente las mismas páginas.
- 📊 Información y métricas para poder supervisar las peticiones.
En resumen:
El Worker es el proxy seguro entre Content Explorer y las webs que queremos analizar, permitiendo obtener su HTML sin que el navegador tenga que realizar directamente la petición al dominio externo.
En resumen
Hemos creado el Cloudflare Worker porque el navegador no puede descargar directamente el HTML de cualquier web debido a CORS.
El Worker actúa como un proxy:
Content Explorer → Worker → Web externa → HTML
Además, nos permite controlar la seguridad, validar las URLs, limitar recursos, seguir redirecciones y utilizar la caché de Cloudflare.
Funcionalidades de la v1
Rastreo
- ✅Analizar una única página.
- ✅Analizar sitio completo (crawler).
- ✅Barra de progreso.
- ✅Cancelar análisis.
- ✅Cola de URLs pendientes.
- ✅Evitar analizar la misma URL dos veces.
- ✅Solo enlaces internos.
- ✅Respetar robots.txt (opcional, lo podemos añadir después).
Extracción
- ✅Title
- ✅H1-H6
- ✅P
- ✅LI
- ✅A
- ✅BUTTON
- ✅LABEL
- ✅SPAN
- ✅BLOCKQUOTE
- ✅CAPTION
- ✅TD
- ✅TH
Configurable mediante checkboxes.
Filtros
- ✅Longitud mínima.
- ✅Ignorar duplicados.
- ✅Ignorar texto vacío.
- ✅Ignorar elementos ocultos.
- ✅Agrupar por página.
- ✅Buscar en tiempo real.
- ✅Filtrar por etiqueta.
- ✅Regex.
Resultados
- ✅Texto.
- ✅Etiqueta.
- ✅URL.
- ✅Selector CSS.
- ✅XPath.
- ✅Copiar cualquiera de ellos.
Exportar
- ✅JSON.
- ✅CSV.
- ✅Copiar JSON.
Resumen
- ✅Nº páginas.
- ✅Nº elementos.
- ✅Nº caracteres.
- ✅Nº palabras.
- ✅Nº duplicados.
- ✅Tiempo empleado.
SEO
- ✅H1 duplicados.
- ✅Páginas sin H1.
- ✅Más de un H1.
- ✅Titles duplicados.
- ✅Title >60 caracteres.
- ✅Title <20 caracteres.
- ✅H1 demasiado largo.
- ✅H1 vacío.
- ✅Botones sin texto.
- ✅Enlaces sin texto.
Interfaz
- ✅Tema JR Tools.
- ✅Cards plegables por página.
- ✅Barra de progreso.
- ✅Spinner.
- ✅Contadores.
- ✅Toast de “Copiado”.
Pasos de exrtacción
1
2
3
4
5
6
7
8
9
10
11
12
13
https://content-explorer.jorgerosa.dev
│
│ fetch()
▼
https://proxy.jorgerosa.dev/?url=https://cliente.com
│
▼
Cloudflare Worker
│
Descarga el HTML
│
▼
Devuelve el HTML con CORS habilitado
Creamos el Worker en Cloudflare
- Dentro de Cloudflare en Build > Compute > Workers and Pages.
- Aquí es donde esta la web y los workers, pueden estar ambas cosas, no es excluyente que se tenga la web y el worker.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const TIMEOUT = 10000; // 10 segundos
const CACHE_SECONDS = 3600; // 1 hora
const VERSION = "1.2.0";
export default {
async fetch(request, env, ctx) {
// -----------------------------
// METHOD
// -----------------------------
if (
request.method !== "GET" &&
request.method !== "OPTIONS"
) {
return json({
success: false,
error: "Method not allowed."
}, 405);
}
// -----------------------------
// CORS PREFLIGHT
// -----------------------------
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders()
});
}
// -----------------------------
// ORIGIN / REFERER
// -----------------------------
const origin =
request.headers.get("Origin") || "";
const referer =
request.headers.get("Referer") || "";
// Si existe Origin, debe ser nuestra web.
if (
origin &&
origin !== "https://jorgerosa.dev"
) {
return json({
success: false,
error: "Forbidden."
}, 403);
}
// Si existe Referer, también debe ser nuestra web.
if (
referer &&
!referer.startsWith("https://jorgerosa.dev/")
) {
return json({
success: false,
error: "Forbidden."
}, 403);
}
// -----------------------------
// API KEY
// -----------------------------
const apiKey =
request.headers.get("X-API-Key");
if (
!apiKey ||
apiKey !== env.API_KEY
) {
return json({
success: false,
error: "Unauthorized."
}, 401);
}
// -----------------------------
// REQUEST URL
// -----------------------------
const requestUrl =
new URL(request.url);
const target =
requestUrl.searchParams.get("url");
if (!target) {
return json({
success: false,
error: "Missing 'url' parameter."
}, 400);
}
// -----------------------------
// VALIDATE TARGET URL
// -----------------------------
let targetUrl;
try {
targetUrl = new URL(target);
if (
targetUrl.protocol !== "http:" &&
targetUrl.protocol !== "https:"
) {
throw new Error();
}
} catch {
return json({
success: false,
error: "Invalid URL. Only HTTP and HTTPS are allowed."
}, 400);
}
// -----------------------------
// CACHE KEY
// -----------------------------
//
// The cache key contains:
//
// target URL
// extraction options hash
//
// This means:
//
// URL + options A = cache A
// URL + options B = cache B
//
// The original query string is NOT used
// directly as the cache key.
//
// NOTE:
// cf.cacheKey is an Enterprise-only feature
// according to Cloudflare documentation.
//
// Therefore we encode our cache identity
// into the URL used by the subrequest instead.
//
// -----------------------------
// -----------------------------
// TIMEOUT
// -----------------------------
const controller =
new AbortController();
const timeout =
setTimeout(() => {
controller.abort();
}, TIMEOUT);
const start =
Date.now();
try {
// -----------------------------
// FETCH ORIGIN THROUGH
// CLOUDFLARE CACHE
// -----------------------------
const response = await fetch(
targetUrl.toString(),
{
redirect: "follow",
signal:
controller.signal,
headers: {
"User-Agent":
"JR Tools Content Explorer",
"Accept":
"text/html,application/xhtml+xml"
},
cf: {
cacheEverything: true,
cacheTtl: CACHE_SECONDS
}
}
);
clearTimeout(timeout);
// -----------------------------
// HTTP STATUS
// -----------------------------
if (!response.ok) {
return json({
success: false,
status: response.status,
error: response.statusText
}, response.status);
}
// -----------------------------
// CONTENT TYPE
// -----------------------------
const contentType =
response.headers.get(
"content-type"
) || "";
if (
!contentType
.toLowerCase()
.includes("text/html")
) {
return json({
success: false,
error:
"Only HTML pages are supported.",
contentType
}, 415);
}
// -----------------------------
// READ HTML
// -----------------------------
const html =
await response.text();
// -----------------------------
// SIZE LIMIT
// -----------------------------
if (
html.length > MAX_SIZE
) {
return json({
success: false,
error:
"Page exceeds 10 MB."
}, 413);
}
// -----------------------------
// RESPONSE TIME
// -----------------------------
const elapsed =
Date.now() - start;
// -----------------------------
// CLOUDFLARE CACHE STATUS
// -----------------------------
const cloudflareCacheStatus =
response.headers.get(
"CF-Cache-Status"
) || "UNKNOWN";
// -----------------------------
// RESPONSE HEADERS
// -----------------------------
const headers =
new Headers(
corsHeaders()
);
headers.set(
"Content-Type",
"text/html; charset=utf-8"
);
headers.set(
"Cache-Control",
`public, max-age=${CACHE_SECONDS}`
);
headers.set(
"X-Worker-Version",
VERSION
);
headers.set(
"X-Cache",
cloudflareCacheStatus
);
headers.set(
"X-Final-URL",
response.url
);
headers.set(
"X-Status",
response.status.toString()
);
headers.set(
"X-Content-Type",
contentType
);
headers.set(
"X-Content-Length",
html.length.toString()
);
headers.set(
"X-Response-Time",
`${elapsed} ms`
);
// -----------------------------
// RETURN HTML
// -----------------------------
return new Response(
html,
{
status: 200,
headers
}
);
} catch (e) {
clearTimeout(timeout);
// -----------------------------
// TIMEOUT
// -----------------------------
if (
e.name === "AbortError"
) {
return json({
success: false,
error:
"Request timeout."
}, 408);
}
// -----------------------------
// OTHER ERROR
// -----------------------------
return json({
success: false,
error: e.message
}, 500);
}
}
};
// ============================================================
// CORS
// ============================================================
function corsHeaders() {
return {
"Access-Control-Allow-Origin":
"https://jorgerosa.dev",
"Access-Control-Allow-Methods":
"GET, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, X-API-Key",
"Access-Control-Expose-Headers":
"CF-Cache-Status, X-Worker-Version, X-Cache, X-Final-URL, X-Status, X-Content-Type, X-Content-Length, X-Response-Time"
};
}
// ============================================================
// JSON RESPONSE
// ============================================================
function json(
data,
status = 200
) {
return new Response(
JSON.stringify(
data,
null,
2
),
{
status,
headers: {
...corsHeaders(),
"Content-Type":
"application/json"
}
}
);
}
¿Y para que no use cualquiera tu worker?
Para que no cualqueira emplee el worker desde su web y empiecen a generear tráfico que no sobrecargue el sistema y se sobrepase el límite gratuito hay que generar una API_KEY para que solo acepte peticiones de quien tiene la api, osea yo.
Para crearlo hay que ir a Workers & Pages → tu Worker → Settings → Variables and Secrets → API_KEY
Importante guardarlo bien porque una vez se haga el deploy del API KEY no se podrá volver a ver se podrá cambiar por otro
¿Cómo probarlo?
Desde la consola del navegador (F12) hay que poner este comando. Pero IMPORTANTE como hemos configurado en el código que sólo acepte peticiones desde la URL https://jorgerosa.dev hay que abrir la consola para probarlo desde esta URL.
Si por ejemplo abrimos la consola desde https://google.com devolverá esto:
Como vemos la petición ha sido rechazada y no devuelve nada.
El comando para probarlo es:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fetch("https://proxy.jorgerosa.dev/?url=https://jorgerosa.dev/", {
headers: {
"X-API-Key": "MI_API_KEY"
}
})
.then(async response => {
console.log("Status:", response.status);
console.log("Cloudflare:", response.headers.get("CF-Cache-Status"));
console.log("Worker:", response.headers.get("X-Worker-Version"));
console.log("Final URL:", response.headers.get("X-Final-URL"));
console.log("Content Type:", response.headers.get("X-Content-Type"));
console.log("Content Length:", response.headers.get("X-Content-Length"));
console.log("Response Time:", response.headers.get("X-Response-Time"));
console.log("Body:", await response.text());
});
¿Cómo ver qué hace Cloudflare?
Cloudflare dispone de Traces dentro de Workers → Observability → Traces, que permiten inspeccionar cómo se ha ejecutado una petición dentro de nuestro Worker.
Podemos ver, entre otras cosas:
- La petición que ha recibido el Worker.
- Las operaciones que ha realizado el Worker.
- Las peticiones (
fetch) que el Worker ha realizado a otros servidores. - El tiempo que ha tardado cada operación.
- El código de estado de las respuestas.
- Los diferentes spans que forman la ejecución.
Por ejemplo, en nuestro caso podemos ver:
1
2
GET https://proxy.jorgerosa.dev/
└── fetch → https://jorgerosa.dev/
Esto nos permite comprobar que el Worker ha recibido la petición y posteriormente ha realizado el fetch de la URL solicitada.
¿Se ha servido desde la caché?
Los Traces no son la mejor forma de comprobar si una subpetición ha sido servida desde la caché. Para esto podemos ir a:
Workers → Metrics → Subrequests
Ahí Cloudflare muestra las peticiones realizadas por el Worker y su Cache Rate.
Por ejemplo:
Un Cache Rate del 40 % significa que, de las 5 peticiones realizadas a ese host, aproximadamente 2 fueron servidas desde la caché y las otras 3 tuvieron que obtenerse de nuevo.
Por tanto:
- Traces → nos permite ver cómo se ha ejecutado la petición y qué operaciones ha realizado el Worker.
- Metrics → Subrequests → Cache Rate → nos permite comprobar qué porcentaje de las subpeticiones se han servido desde la caché.
Nota: La cabecera
CF-Cache-Statusque podamos consultar desde el navegador no tiene por qué indicar el estado de caché de la subpetición realizada por el Worker. En nuestro caso, la respuesta que recibe el navegador es una nuevaResponsecreada por el Worker. Para comprobar la caché de las subpeticiones utilizaremos Metrics → Subrequests → Cache Rate.
La idea será que las opciones se envíen al Worker mediante options, de forma que:
1
2
3
4
5
6
7
8
URL + opciones
↓
Worker
↓
optionsHash
↓
caché independiente
Por ejemplo, analizar:
https://jorgerosa.dev/
con:
H1 + H2 + P
generará una caché diferente que:
H1 + H2 + H3 + P + listas
Para hacer esto y que el worker admita variables en la URL tenemos que configurarlas en el Worker de Cloudflare.
Tenemos que poner esto:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const options =
getExtractionOptions();
const optionsParam =
encodeURIComponent(
JSON.stringify(options)
);
const response = await fetch(
`${WORKER_URL}?url=${encodeURIComponent(url)}&options=${optionsParam}`,
{
headers: {
"X-API-Key": API_KEY
}
}
);
Ahora una petición podría terminar siendo conceptualmente:
?url=https://jorgerosa.dev/&options={"tags":["h1","h2","p"],"ignoreShort":true,"minLength":20,"removeDuplicates":false}
No tienes que construir esa URL manualmente; encodeURIComponent() lo hace correctamente.