1. El test que hicimos
Sitio: msc.com/en/track-a-shipment.
Container: MEDU9730548 (BoL MEDUJ0738446, sale de MOIN, Costa Rica, llega a YOKOHAMA, Japón).
❌ curl / requests (HTTP plano)
POST /api/feature/tools/TrackingInfo
Content-Type: application/json
{"trackingType":"Container","trackingNumber":"MEDU9730548"}
→ HTTP 403 — Access Denied
Reference #18.6b841402.1780679402.2fb6d206
(Akamai Bot Manager, edgesuite.net)
El endpoint existe, la API existe, el JSON está buenísimo. Pero Akamai te tira 403 al toque. No es un tema de cookies ni de User-Agent: es un tema de fingerprint de TLS, fingerprint de HTTP/2, y ausencia de contexto JS. Tu cliente grita "soy un bot" antes de mandar la primera línea de HTTP.
✅ Camoufox (headless Firefox parcheado)
POST /api/feature/tools/TrackingInfo
( desde una sesión Camoufox con fingerprint real )
→ HTTP 200 — application/json
5.116 bytes
BoL: MEDUJ0738446
Pod ETA: 04/07/2026
Latest move: COLON, PA
8 eventos del viaje
Mismo endpoint, mismo body: anda. La diferencia es que el browser tiene la huella digital completa de un Firefox real (TLS, HTTP/2, canvas, fuentes, GPU, locales, timezone), y el motor tiene los parches anti-detect en C++, no en JavaScript.
Lo que devolvió (artifact real)
Screenshot completa de la página de resultados del tracking de
MEDU9730548, capturada el 5 de junio 2026 a las 19:07
hora de Europa Central. 1905×3302, full-page render, con el BoL
cargado y todos los eventos del viaje.
Screenshot
El JSON que devolvió el XHR
El endpoint POST /api/feature/tools/TrackingInfo tira
un JSON anidado con toda la info del container. Acá te dejo un
fragmento (el JSON completo está en la respuesta, pero es largo
de copiar literal):
{
"IsSuccess": true,
"Data": {
"TrackingNumber": "MEDU9730548",
"BillOfLadings": [{
"BillOfLadingNumber": "MEDUJ0738446",
"GeneralTrackingInfo": {
"ShippedFrom": "MOIN, CR",
"ShippedTo": "YOKOHAMA, JP",
"PortOfLoad": "MOIN, CR",
"PortOfDischarge": "YOKOHAMA, JP",
"Transshipments": ["CRISTOBAL, PA", "RODMAN, PA"],
"PriceCalculationDate": "24/05/2026"
},
"ContainersInfo": [{
"ContainerNumber": "MEDU9730548",
"ContainerType": "40' HIGH CUBE REEFER",
"LatestMove": "COLON, PA",
"PodEtaDate": "04/07/2026",
"Delivered": false,
"Events": [
// 8 eventos, del más viejo al más nuevo:
// 1. 22/05 PUERTO LIMON → "Empty to Shipper"
// 2. 22/05 MOIN CR → "Export received at CY"
// 3. 24/05 MOIN CR → "Export Loaded on Vessel" (MSC PASSION III)
// 4. 25/05 COLON PA → "Full Transshipment Discharged"
// 5. 08/06 COLON PA → "Full Intended Transshipment"
// 6. 08/06 RODMAN PA → "Estimated Time of Arrival"
// 7. 10/06 RODMAN PA → "Full Intended Transshipment" (RDO ACE)
// 8. 04/07 YOKOHAMA JP → "Estimated Time of Arrival" (ETA final)
]
}]
}]
}
}
2. Por qué curl no anda (lo que Akamai te cocina)
Cuando mandás una request a msc.com con curl, lo que
Akamai ve en los primeros 50 ms de la conexión es:
TLS fingerprint (JA3 / JA4)
Antes de mandarse el primer header HTTP, tu cliente hace un handshake TLS. En ese handshake va la lista de cipher suites, extensions, elliptic curves y protocolos ALPN. Cada cliente HTTP (curl, requests, urllib3, Chrome, Firefox) tiene su propio patrón único. Akamai tiene una base con todos los patrones conocidos y los cruza contra el User-Agent que mandaste. Si no matchea, es automático. Por eso cambiar el User-Agent no sirve de nada: el problema está abajo del nivel HTTP, en el handshake, y el header que mandás no influye.
HTTP/2 fingerprint
Casi todos los sitios modernos (MSC incluido) hablan HTTP/2. El frame de SETTINGS, el orden de los headers, los priority hints y el timing del WINDOW_UPDATE también forman un patrón. Cada library de HTTP los manda de forma distinta, y el patrón de curl/requests no corresponde a ningún browser real.
Perfil de ejecución de JavaScript
MSC carga un script de Akamai que corre cientos de micro-checks dentro de la página:
- ¿
navigator.webdriverdevuelvetrue? - ¿Existe
chrome.csi()? - ¿El hash del canvas es consistente?
- ¿Hay
window.__playwright__binding__,document.__selenium_unwrapped, o cualquier resto de automatización colgado?
Cada check es una señal. La suma de las señales es lo que decide si te tira captcha, te tira 403, o te deja pasar.
Consistencia interna del fingerprint
Aunque pases los checks anteriores, el fingerprint tiene que ser internamente consistente. Cosas que Akamai crucza:
- User-Agent dice "Windows 11 Chrome 124" pero WebGL reporta GPU Apple M1 → bloqueado.
- UA dice "Mac Safari" pero
navigator.platformesLinux x86_64→ bloqueado. - UA dice "iPhone" pero la pantalla es 1920×1080 → bloqueado.
- UA dice "Firefox" pero el engine muestra features de Chromium V8 → bloqueado.
La forma berreta de spoofear esto es tirar
Object.defineProperty(navigator, 'webdriver', {get: () => false})
en la página. No anda. El parche queda en el nivel
JavaScript, el engine real sigue tirando los valores viejos, y
los detectores se dan cuenta (descriptor del getter mal, valores
inconsistentes con el motor, etc.).
3. Por qué Camoufox sí anda
Camoufox es un build parcheado de Firefox. Los parches están en el core C++ del browser (Gecko), no en JavaScript que corre en la página. Esto es la clave: si parcheás el motor, no hay forma de detectarlo desde el nivel JS, porque en el nivel JS los valores ya están bien.
Fingerprint inyectado a nivel C++
Cuando arrancás Camoufox desde Python, el launcher genera un
fingerprint (UA, pantalla, GPU, fuentes, locale, timezone, WebRTC,
etc.) y se lo pasa al browser por variables de entorno. El
Firefox parcheado lee esos valores en los lugares correctos del
engine y los devuelve en todos lados: navigator.webdriver,
WebGL, canvas, history API, geometría de pantalla, enumeración
de fuentes, etc. No solo en el objeto JS, sino en los call sites
internos del browser que un detector agresivo revisa.
Y el fingerprint no es una combinación random: sale de BrowserForge, un generador que imita la distribución estadística de dispositivos reales. Tu sesión "Windows 11 Chrome" no parece un recorte de la combinación más popular: parece uno más dentro de la población real de usuarios Windows 11 Chrome.
El protocolo de automatización, escondido de la página
Playwright controla Firefox por Juggler, un protocolo de
automatización que corre en su propio módulo XPCOM adentro del
browser. Camoufox parchea Juggler: cuando la página llama a
document.querySelector, Juggler no inyecta nada de
JS que la página pueda detectar. No hay
window.__playwright__binding__, no hay
Runtime.evaluate, no hay chrome.csi().
Camoufox intercepta la llamada a Juggler, le devuelve a Playwright
lo que esperaba, y la página real no se entera. Ni siquiera
Object.getOwnPropertyDescriptor te lo pesca, porque
el parche está en C++ donde no hay objeto JS que inspeccionar.
Headless disfrazado de ventana
El headless default de Firefox tiene señales obvias (no hay puntero, las dimensiones de pantalla son raras). Camoufox parchea eso, y como fallback corre sobre un X server virtual adentro del contenedor para los casos donde headless filtra.
¿Cuál es la contra?
No es magia. Camoufox pasa ~90% de DataDome y 70–82% de Akamai Bot Manager en modo agresivo (benchmarks de abril/mayo 2026). El resto de los fallos suele ser:
- Señales de comportamiento — sin movimiento de mouse,
sin scroll, sin tiempo en la página. Camoufox tiene
humanize=Trueque agrega movimiento sintético de mouse; eso te lleva de ~100 a ~300 requests por sesión, no a infinito. - Profiling de ejecución JS — la config más agresiva de Akamai profilea el orden y timing de las llamadas a funciones JS durante el load. El Firefox parcheado de Camoufox se ve un toque distinto al vanilla ahí, y Akamai está actualizando los detectores. Esto es el gato y el ratón actual.
- Cold start — el primer launch en un contenedor fresco tarda ~90s (baja el fork de Firefox, verifica, compila bindings). Los siguientes son ~3-4s. Para un worker persistente está bárbaro, para serverless es un bajón.
4. Lo que probé y no anduvo
| Estrategia | Qué pasó | Por qué |
|---|---|---|
curl con replay del XHR |
HTTP 403 (Reference #18.6b841402.1780679402.2fb6d206) | JA3 de TLS no matchea ningún browser conocido, fingerprint HTTP/2 es libcurl, sin contexto JS para el script de Akamai |
Python requests |
Igual que curl | Mismo stack de TLS y UA, mismo destino |
| headless Chromium (Playwright default) | Muy probablemente 403 (no lo probé, pero es fama) | navigator.webdriver=true, artefactos de CDP, Chromium tiene sus propios quilombos de fingerprinting |
playwright-stealth (parches JS, no engine) |
~60% de acierto en DataDome, menos en Akamai (benchmarks) | El spoofing por JS es detectable: el descriptor del getter queda mal, Object.getOwnPropertyDescriptor lo pesca, los internals del engine siguen con los valores reales |
| nodo HTTP Request de n8n | 403 (mismo que requests) |
n8n usa axios por debajo; mismo fingerprint, sin browser |
5. El contrato mínimo del worker (para n8n u otro orquestador)
Si querés meterle un orquestador tipo n8n encima (cron, Google Sheet, Slack, lo que sea), el worker no tiene que ser nada raro. Es un mini servicio HTTP que mantiene una (o un pool chico de) sesiones de Camoufox, y expone un endpoint simple:
POST /track
Content-Type: application/json
{
"container": "MEDU9730548"
}
→ 200 OK
{
"container": "MEDU9730548",
"bill_of_lading": "MEDUJ0738446",
"shipped_from": "MOIN, CR",
"shipped_to": "YOKOHAMA, JP",
"port_of_load": "MOIN, CR",
"port_of_discharge": "YOKOHAMA, JP",
"transshipments": ["CRISTOBAL, PA", "RODMAN, PA"],
"pod_eta": "2026-07-04",
"latest_move": "COLON, PA",
"container_type": "40' HIGH CUBE REEFER",
"delivered": false,
"events": [
{ "date": "2026-05-22", "location": "PUERTO LIMON, CR", "description": "Empty to Shipper" },
{ "date": "2026-05-22", "location": "MOIN, CR", "description": "Export received at CY" },
{ "date": "2026-05-24", "location": "MOIN, CR", "description": "Export Loaded on Vessel", "vessel": "MSC PASSION III", "voyage": "PH620R" },
{ "date": "2026-05-25", "location": "COLON, PA", "description": "Full Transshipment Discharged" },
{ "date": "2026-06-08", "location": "COLON, PA", "description": "Full Intended Transshipment" },
{ "date": "2026-06-08", "location": "RODMAN, PA", "description": "Estimated Time of Arrival" },
{ "date": "2026-06-10", "location": "RODMAN, PA", "description": "Full Intended Transshipment", "vessel": "RDO ACE", "voyage": "2615W" },
{ "date": "2026-07-04", "location": "YOKOHAMA, JP", "description": "Estimated Time of Arrival", "vessel": "RDO ACE", "voyage": "2615W" }
]
}
→ 404 si el container no existe
→ 502 si MSC está caído
→ 429 si te está rate-limiteando (el worker debería backoffear)
El worker aplana la respuesta anidada de MSC en una estructura
plana que se usa re fácil de ahí en adelante. Y guarda el JSON
original de MSC adentro (campo raw) por si necesitás
algo específico que no esté en la versión plana.
En n8n, el workflow típico es: Cron cada N horas →
Read sheet con la lista de containers → Loop uno
por uno → HTTP Request al /track del worker
→ Code node (opcional) para elegir campos → Append
row a la historia / base / Slack. Se arma en 10 minutos en
el editor visual de n8n.
6. TL;DR
- curl y requests: 403 al toque, ni en pedo.
- n8n HTTP Request: 403, mismo problema (usa
axiospor debajo). - playwright-stealth: 60% en DataDome, menos en Akamai. Spoofear por JS no alcanza.
- headless Chromium default: probablemente 403,
navigator.webdriverdelata. - Camoufox (Firefox parcheado en C++): anda. Pasa el filtro de Akamai, devuelve JSON limpio.
- El endpoint
POST /api/feature/tools/TrackingInfoviene devolviendo el mismo formato desde 2024 al menos, así que la estructura del JSON es estable. - El límite real no es tecnológico, es de comportamiento: si te ponés a tirar 10.000 requests seguidas desde la misma sesión, eventualmente Akamai te pesca por el timing. Rotación de fingerprint + IP + tiempo entre requests es lo que mantiene la cosa viva en producción.
La pregunta que tiene que hacer el orquestador no es "cómo llamo a MSC", es "cómo llamo al worker que tiene la sesión de Camoufox abierta". Ahí ya no hay ningún misterio.