Node.js: moderando imagens com TensorFlow
Olá meus Unicórnios! 🦄✨
Todo site que aceita upload de imagem tem, mais cedo ou mais tarde, o mesmo problema: alguém vai mandar aquilo que você não quer hospedar. 😬 E moderar na mão não escala — nem que seja um site pequeno, ninguém quer abrir a pasta de uploads de manhã com medo do que vai ver.
A solução óbvia hoje é mandar a imagem para uma API de moderação de alguma nuvem. Funciona muito bem — e cobra por imagem, e leva a foto dos seus usuários para fora. Eu queria o contrário: rodar a classificação na minha própria máquina, sem enviar nada para lugar nenhum, sem conta em nuvem e sem contador girando.
Foi aí que montei esta API: um servidor Express que recebe a URL de uma imagem e devolve o que ela é, usando TensorFlow.js puro com o modelo do nsfwjs. E este artigo é a história dela — incluindo as duas coisas que quase me fizeram desistir na primeira tarde. 😅
🧠 Por que TensorFlow.js "puro" e não o tfjs-node
Essa foi a primeira decisão, e ela define o resto do projeto.
O TensorFlow.js no Node tem duas encarnações. O @tensorflow/tfjs-node é o rápido: usa o TensorFlow nativo em C++, com aceleração de verdade. O @tensorflow/tfjs é o "puro" — JavaScript do começo ao fim, o mesmo que roda no navegador.
O rápido parece a escolha óbvia. Só que ele é um módulo nativo, e módulo nativo no Windows significa a saga de sempre: Python, Visual Studio Build Tools, uma compilação que falha com um erro de 400 linhas e você lendo issue de 2019 no GitHub. 🙃
Eu escolhi o puro de propósito, e a lista de pré-requisitos do projeto virou isto aqui:
node --version # v18 ou superior. So isso.
Sem Python. Sem compilador. Sem build tools. Um npm install e roda — na minha máquina, no servidor, no Docker, em qualquer lugar. As dependências inteiras cabem em cinco linhas:
{
"dependencies": {
"@tensorflow/tfjs": "^4.22.0",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"nsfwjs": "^4.2.1",
"sharp": "^0.33.5"
}
}
O preço? Ele avisa, toda vez que sobe. Literalmente:
============================
Hi, looks like you are running TensorFlow.js in Node.js. To speed things up
dramatically, install our node backend, visit
https://github.com/tensorflow/tfjs-node for more details.
============================
"Speed things up dramatically". 😅 Dá até medo. Mas segura essa informação, porque lá na frente eu meço quanto custa de verdade — e a resposta me surpreendeu.
😤 A primeira mentira: "é só carregar o modelo do disco"
Essa foi a hora em que eu achei que ia ser rápido e não foi.
O nsfwjs tem uma função load() que, na documentação, resolve tudo. No navegador você aponta para uma URL e pronto. No Node, com o tfjs-node instalado, você aponta para file://caminho/do/modelo e também pronto.
Eu não tenho o tfjs-node. E aí descobri o detalhe cruel: o TensorFlow.js puro não tem IOHandler de sistema de arquivos. 😳
Faz todo sentido quando você para para pensar — o tfjs "puro" é o build do navegador, e navegador não lê disco. Quem implementa o file:// é justamente o pacote nativo que eu recusei. Só que a mensagem de erro não te conta isso; ela reclama de um handler que não existe e te deixa achando que errou o caminho do arquivo.
A saída é ler os arquivos você mesma e montar o handler na memória. O tfjs tem exatamente a peça para isso, o tf.io.fromMemory() — ele só quer os três pedaços que compõem um modelo: a topologia, as especificações dos pesos e os bytes dos pesos.
const fs = require('fs/promises');
const tf = require('@tensorflow/tfjs');
// Monta um IOHandler em memoria a partir do model.json + shards no disco.
async function fileSystemHandler(modelDir) {
const json = JSON.parse(await fs.readFile(`${modelDir}/model.json`, 'utf8'));
const specs = [];
const partes = [];
for (const grupo of json.weightsManifest) {
specs.push(...grupo.weights);
for (const arquivo of grupo.paths) {
const buf = await fs.readFile(`${modelDir}/${arquivo}`);
// Recorta so a regiao deste Buffer (ele pode compartilhar um pool maior).
partes.push(new Uint8Array(buf));
}
}
return tf.io.fromMemory({
modelTopology: json.modelTopology,
weightSpecs: specs,
weightData: Buffer.concat(partes).buffer,
});
}
Aquele comentário do meio é o bug que me pegou, e vale o parágrafo. 🙏
Um Buffer do Node não é dono do seu próprio ArrayBuffer: para buffers pequenos, o Node os aloca dentro de um pool compartilhado maior. Se você passar buf.buffer direto, entrega ao TensorFlow o pool inteiro — que contém os seus pesos e mais um monte de bytes de outra coisa qualquer, com os seus começando num offset arbitrário. O modelo carrega, não dá erro nenhum, e classifica com pesos lixo.
Por isso o new Uint8Array(buf): ele enxerga exatamente os bytes daquele Buffer — do offset dele, no comprimento dele — e o Buffer.concat() depois junta tudo numa área nova e contínua. É uma linha que parece paranoia e é a diferença entre um modelo que funciona e um que devolve números aleatórios com toda a confiança do mundo. 😅
Com o handler pronto, o resto é o que a documentação prometia — e repare que o modelo é carregado uma vez só, guardado num módulo:
const MODEL_SIZE = 224; // MobileNetV2 espera 224x224
let model = null;
async function loadModel() {
if (!model) {
const handler = await fileSystemHandler(MODEL_DIR);
const instance = new NSFWJS(handler, { size: MODEL_SIZE, type: 'layers' });
await instance.load();
model = instance;
}
return model;
}
Carregar o modelo a cada requisição seria um desastre — são 2,5 MB de pesos e quase um segundo de trabalho. O servidor carrega antes de abrir a porta, e é por isso que o /health devolve um modelLoaded: para o balanceador não mandar tráfego para um processo que ainda está acordando.
async function start() {
console.log('Loading nsfwjs model...');
await loadModel(); // primeiro carrega...
console.log('Model loaded.');
const app = createApp();
app.listen(config.PORT); // ...depois escuta.
}
📦 De onde vem o modelo (e por que ele está no repositório)
O nsfwjs traz o MobileNetV2 embutido no próprio pacote npm, mas num formato dele — minificado, com o binário dos pesos em base64 dentro de um arquivo .js. Não dá para apontar o TensorFlow para aquilo.
Então tem um script que roda uma vez só e extrai o modelo para o formato padrão do TensorFlow.js (model.json + shard .bin). O trecho que eu mais gosto dele é a conferência:
const shardBuf = Buffer.from(shardStr, 'base64');
// Quantos bytes cada peso ocupa: quantizado usa 1 ou 2, float32 usa 4.
function bytesDoPeso(w) {
const itens = w.shape.reduce((a, b) => a * b, 1);
if (w.quantization) {
return itens * (w.quantization.dtype === 'uint8' ? 1 : 2);
}
return itens * (w.dtype === 'float32' ? 4 : 1);
}
// Confere o tamanho decodificado contra o que o manifesto declara.
const esperado = modelJson.weightsManifest
.flatMap((grupo) => grupo.weights)
.reduce((total, w) => total + bytesDoPeso(w), 0);
if (shardBuf.length !== esperado) {
throw new Error(`Tamanho do shard nao bate: ${shardBuf.length}, esperado ${esperado}`);
}
Ele soma, peso a peso, quantos bytes o manifesto diz que existem e compara com quantos bytes realmente saíram do base64. Se não bater, ele explode ali — na extração, num script que eu rodo consciente — em vez de deixar o erro aparecer em produção como uma classificação estranha. Repare no w.quantization: o modelo é quantizado, então cada peso ocupa 1 ou 2 bytes em vez de 4, e quem esquecer disso calcula o tamanho errado.
O resultado fica versionado no repositório, 2,5 MB:
model/
group1-shard1of1.bin 2.619.461 bytes
model.json 117.932 bytes
São 2,5 MB que não mudam nunca, e o melhor: eles saem do próprio pacote que o npm install já baixou. Nenhum download de modelo de lugar nenhum — a API sobe offline, e a pasta modelo/ se regenera com um comando.
🖼️ O sharp faz mais do que decodificar
O modelo não come JPEG — ele come um tensor de números. Alguém precisa transformar os bytes da imagem numa matriz altura × largura × 3 canais, e esse alguém é o sharp.
Só que essa função tem quatro chamadas encadeadas, e nenhuma delas é decoração:
async function decodeToTensor(buffer) {
const cru = await sharp(buffer, { animated: false })
.rotate() // aplica a orientacao EXIF
.toColourspace('srgb') // normaliza o espaco de cor
.removeAlpha() // 4 canais viram 3
.raw()
.toBuffer({ resolveWithObject: true })
.catch((err) => {
throw new Error(`Formato de imagem invalido ou corrompido: ${err.message}`);
});
const { width, height, channels } = cru.info;
if (channels !== 3) {
throw new Error(`Esperava 3 canais apos decodificar, veio ${channels}`);
}
const tensor = tf.tensor3d(new Uint8Array(cru.data), [height, width, 3], 'int32');
return { tensor, width, height };
}
Vamos por partes, porque cada linha dessas evita um bug específico:
.rotate() sem argumento nenhum é a joia escondida. 💎 Chamado assim, vazio, ele não rotaciona um ângulo fixo: ele lê a orientação EXIF do arquivo e endireita a foto. Sem essa linha, foto de iPhone chega deitada no modelo — porque o iPhone grava a imagem na horizontal e anexa um "girar 90°" nos metadados. O seu olho nunca vê isso (o visualizador obedece o EXIF), mas o tensor cru, não. E um modelo de visão computacional classificando uma pessoa deitada de lado erra bem mais.
.removeAlpha() resolve o PNG. PNG tem 4 canais (RGB + transparência) e o MobileNetV2 quer 3. Sem isso, o tf.tensor3d recebe uma contagem de bytes que não fecha com a forma declarada e quebra — com uma mensagem sobre tamanho de tensor que não menciona transparência em momento algum. 🙄
A conferência channels !== 3 é o cinto de segurança: se alguma imagem exótica escapar (escala de cinza, CMYK), eu prefiro um erro claro a um tensor com a forma errada.
E de brinde vem o suporte a formato. Como quem decodifica é o sharp, a API aceita tudo que o sharp lê: JPEG, PNG, WebP, GIF, TIFF, SVG e — o que mais importa na prática — HEIC/HEIF, que é o formato que o iPhone manda por padrão.
Testei o caminho do erro com um arquivo .jpg que na verdade era texto. A parte final da mensagem vem do próprio sharp:
{
"error": "Formato de imagem invalido ou corrompido: Input buffer contains unsupported image format",
"elapsedMs": 64
}
64 ms para recusar. O sharp olha os primeiros bytes, não reconhece assinatura de formato nenhum e desiste — sem nunca acordar o TensorFlow.
🧹 O tensor que precisa ser liberado na mão
Esse é o tipo de coisa que só aparece depois que o serviço está no ar há um tempo. E é curto:
async function classify(buffer) {
const { tensor, width, height } = await decodeToTensor(buffer);
try {
const predictions = await model.classify(tensor);
return { predictions: sortPredictions(predictions), width, height };
} finally {
tensor.dispose(); // <-- este finally nao e opcional
}
}
Tensor do TensorFlow.js não é coletado pelo garbage collector do JavaScript. A memória dele vive fora do heap do V8, e o tf.tensor3d reserva um bloco que só é devolvido quando você chama dispose(). Esqueceu? Vazamento — e um dos silenciosos, porque o processo cresce devagarinho enquanto o heapUsed jura que está tudo bem.
Repare que o dispose() está num finally, não depois do classify. É de propósito: se a classificação lançar exceção, o tensor ainda precisa ser liberado. Um dispose() na linha de baixo do return vaza exatamente nos casos de erro — que são justamente os que se repetem quando alguma coisa vai mal em produção. 😅
Esse detalhe também explica uma escolha do log. Para medir memória, o projeto usa rss, e não heapUsed:
// RSS — e nao heapUsed — porque os tensores e o buffer da imagem vivem em
// memoria nativa, fora do heap do V8. O delta e sinalizado (+/-) porque o GC
// pode rodar no meio da requisicao e deixar o processo menor do que comecou.
function formatMem(startRss) {
const rss = process.memoryUsage().rss;
const delta = rss - startRss;
const sign = delta >= 0 ? '+' : '-';
return `mem=${mb(rss)}MB (${sign}${mb(Math.abs(delta))}MB)`;
}
Medir o heap aqui te daria uma linha reta bonita e mentirosa. 📈
🎯 As cinco classes — e o que elas realmente medem
Agora a parte que eu achei mais interessante do projeto todo, e que ninguém escreve na documentação.
O nsfwjs devolve cinco classes com as probabilidades somando 1: Neutral, Drawing, Sexy, Hentai e Porn. E a decisão de moderação sai de somas, não de uma classe só:
function isPorn(predictions, threshold) {
return probOf(predictions, 'Porn') + probOf(predictions, 'Hentai') >= threshold;
}
function isNSFW(predictions, threshold) {
return (
probOf(predictions, 'Porn') +
probOf(predictions, 'Hentai') +
probOf(predictions, 'Sexy') >=
threshold
);
}
Somar é essencial, e é fácil errar aqui. Uma imagem pode dar 35% Porn e 40% Hentai — nenhuma das duas passa de 60% sozinha, e olhando classe por classe você aprovaria uma imagem que o modelo está 75% convencido de que é pornografia. 😰 Por isso são dois critérios separados: isPorn (explícito) e isNSFW, que soma também o Sexy para quem quer ser mais conservador.
A descoberta: as classes medem o traço, não o assunto
Rodei a API contra as capas deste blog — ilustrações — e contra prints e fotos dos artigos antigos. Repare no que aconteceu.
Capa de um artigo sobre nobreak, ilustração de unicórnio:
{
"predictions": [
{ "className": "Drawing", "probability": 0.9929920434951782 },
{ "className": "Neutral", "probability": 0.003550437744706869 },
{ "className": "Hentai", "probability": 0.003429210279136896 },
{ "className": "Porn", "probability": 0.00002208616024290677 },
{ "className": "Sexy", "probability": 0.00000626325572739006 }
],
"dominant": { "className": "Drawing", "probability": 0.9929920434951782 },
"isPorn": false,
"isNSFW": false,
"image": { "width": 1280, "height": 853 },
"elapsedMs": 1533
}
Achou "Drawing" com 99,3% — perfeito. Mas olha quem está em terceiro lugar: Hentai, acima de Porn e de Sexy. Numa ilustração de unicórnio ao lado de um nobreak. 🤨
Agora a mesma API num print de tela do AWS CloudWatch:
{
"predictions": [
{ "className": "Neutral", "probability": 0.9333747029304504 },
{ "className": "Drawing", "probability": 0.064621202647686 },
{ "className": "Hentai", "probability": 0.0018375966465100646 },
{ "className": "Porn", "probability": 0.0000995250593405217 },
{ "className": "Sexy", "probability": 0.00006696546188322827 }
],
"dominant": { "className": "Neutral", "probability": 0.9333747029304504 },
"isPorn": false, "isNSFW": false,
"image": { "width": 1920, "height": 1280 }, "elapsedMs": 1098
}
E numa foto real, de um Arduino numa protoboard:
{
"predictions": [
{ "className": "Neutral", "probability": 0.9167398810386658 },
{ "className": "Drawing", "probability": 0.07601407170295715 },
{ "className": "Sexy", "probability": 0.0067705027759075165 },
{ "className": "Porn", "probability": 0.00030024044099263847 },
{ "className": "Hentai", "probability": 0.0001752842654241249 }
],
"dominant": { "className": "Neutral", "probability": 0.9167398810386658 },
"isPorn": false, "isNSFW": false,
"image": { "width": 1920, "height": 1280 }, "elapsedMs": 1115
}
Viu a inversão? Na foto do Arduino, o Hentai despencou para último lugar (0,017%) e quem subiu foi o Sexy (0,68%) — quarenta vezes mais alto que na ilustração.
As classes de trás não estão medindo o quanto a imagem é imprópria. Estão medindo o meio. Para o modelo, Hentai é "desenho impróprio" e Porn/Sexy são "foto imprópria" — então uma ilustração inocente distribui sua incerteza entre as classes de desenho, e uma foto inocente distribui entre as classes de foto. O ruído segue o traço, não o conteúdo.
Isso tem uma consequência bem prática: não monte alarme em cima de uma classe isolada. Se você logar "imagens com Hentai acima de zero", vai encher o relatório de capas de blog e ilustração de gatinho. É exatamente por isso que a decisão é uma soma com limiar — e por isso o limiar padrão é 0,6, não 0,01.
🔐 A guarda que eu desliguei — e o comentário que deixei no lugar
Aqui tem uma decisão que vai contra o manual, e eu quero contar direito porque é o tipo de coisa que a gente encontra num código alheio e não entende. 😅
Uma API que recebe URL do usuário e vai buscar é um convite ao SSRF: eu mando http://169.254.169.254/latest/meta-data/ (o endereço mágico de metadados da AWS) e o seu servidor busca por mim, de dentro da sua rede, e me conta o que achou. Por isso o projeto nasceu com um ssrfGuard.js completo, que bloqueia faixas privadas de IPv4 e IPv6.
Só que o meu caso de uso precisava classificar imagens de um serviço interno, em localhost. E aí a guarda foi desligada — com o motivo escrito no código:
const host = parsed.hostname;
// SECURITY: private/loopback/reserved IP rejection is DELIBERATELY DISABLED.
// The service owner explicitly opted to allow internal/localhost image hosts
// (e.g. http://localhost:4000/...) and accepted the SSRF risk. This includes
// reachability of internal services and cloud metadata (169.254.169.254). Do
// NOT re-enable range checks here without the owner's consent; if a safer
// posture is wanted, restore isPrivateIP() gated behind an env flag/allowlist.
if (net.isIP(host)) {
return [host];
}
Repare que a função isPrivateIP() continua ali, inteira e testada — com testes cobrindo faixa privada de IPv4, loopback, link-local, ULA de IPv6 e endereço IPv4 mapeado em IPv6. Ela só não é chamada.
Isso foi de propósito, e é o conselho que eu levo para outros projetos: quando desligar uma proteção, desligue a chamada, não apague o código. Quem chegar depois encontra a peça pronta, o comentário dizendo quem decidiu e o que foi aceito em troca, e os testes provando que ela funciona. Apagar teria transformado uma decisão consciente num buraco silencioso — e daqui a um ano ninguém saberia dizer se aquilo foi escolha ou esquecimento. 🙈
O que continua ligado, porque não custa nada:
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new SsrfError('Image URL must use http or https');
}
if (parsed.username || parsed.password) {
throw new SsrfError('Image URL must not contain credentials');
}
O bloqueio de credencial na URL vale a linha: https://usuario:senha@host/x.jpg é uma forma clássica de fazer o servidor autenticar em algum lugar por você.
As duas guardas que sobreviveram inteiras
Essas duas eu não desligaria por nada, e são as mais espertas do projeto.
Um: redirecionamento não é seguido.
response = await fetch(url, { signal: controller.signal, redirect: 'manual' });
// ...
if (response.status >= 300 && response.status < 400) {
throw new Error('Image URL must not redirect');
}
Porque validar a URL de entrada não serve de nada se ela responder 302 apontando para outro lugar. Eu passo https://meusite.com/foto.jpg, você valida e aprova, e o meu site responde "vai para http://169.254.169.254/". Todo o cuidado da validação evaporou. Recusar o 3xx corta esse caminho inteiro.
E olha que engraçado: essa guarda me pegou no meu próprio teste. Quando tentei classificar uma imagem apontando para o blog em produção:
{
"error": "Image URL must not redirect",
"elapsedMs": 169
}
Não era ataque nenhum — era o redirecionamento normal de canonicalização do site. 😄 Levei alguns segundos até cair a ficha, e achei um ótimo lembrete de que essa guarda é agressiva mesmo: se o seu servidor de imagens redireciona (de http para https, ou de domínio para CDN), você precisa passar a URL final.
Dois: o tamanho é contado enquanto baixa.
const chunks = [];
let total = 0;
for await (const chunk of response.body) {
total += chunk.byteLength;
if (total > maxBytes) {
controller.abort();
throw new Error(`Image too large: > ${maxBytes} bytes`);
}
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
Tem uma checagem do Content-Length antes, que rejeita cedo quando o servidor é honesto. Mas o Content-Length é informação que o outro lado escolhe mandar — pode vir ausente, pode vir mentindo "2 MB" e despejar 8 GB. Contar byte a byte e abortar no meio é a única versão que funciona contra um servidor hostil.
⏱️ Quanto custa: os números reais
Agora aquele aviso do começo, o "speed things up dramatically". Vamos medir. 🧪
Primeiro a subida do processo:
require(): 142ms
loadModel(): 849ms
RSS depois de carregar: 181.3MB
Menos de um segundo para ler 2,5 MB de pesos e montar o modelo. E as classificações, direto do log do servidor (formato: imagem, veredito, tempo, memória):
[predict] .../nodejs-lendo-e-controlando-o-nobreak-nhs... -> Drawing 99.3% | isPorn=false | 1533ms | mem=171.6MB (+66.9MB)
[analyze] .../jt-express-api-rastreando-encomendas... -> Drawing 99.9% | isPorn=false | 900ms | mem=169.9MB (+3.0MB)
[predict] .../home-assistant-gerenciando-tomadas-tuya... -> Drawing 99.3% | isPorn=false | 887ms | mem=174.0MB (+8.4MB)
[predict] .../aws-cloudwatch-etapa-2-criando-graficos... -> Neutral 93.3% | isPorn=false | 1098ms | mem=211.8MB (+41.1MB)
[predict] .../arduino-ligacao-com-potenciometro... -> Neutral 91.7% | isPorn=false | 1115ms | mem=212.1MB (+68.1MB)
Três coisas que esse bloco conta:
A primeira requisição é a mais cara — 1533 ms contra ~890 ms das seguintes com imagem do mesmo tamanho. É o aquecimento: o tfjs aloca os buffers internos das operações na primeira passada e reaproveita depois. Não meça desempenho na primeira chamada; ela mente para pior. 📊
Imagem maior custa mais: as de 1920×1280 ficaram na casa de 1100 ms, as de 1280×853 em ~890 ms. Todas são reduzidas para 224×224 antes do modelo, então essa diferença é quase toda decodificação — trabalho do sharp, não do TensorFlow. Se o seu gargalo for esse, reduzir a imagem antes de mandar resolve mais que trocar de backend.
O delta de memória pula bastante (+3 MB a +68 MB) e o RSS total sobe devagar, de 163 MB para 212 MB. Isso é normal e é o motivo do sinal +/- no formatador: o coletor de lixo roda quando quer, então uma requisição pode terminar com o processo menor do que começou. O que importa acompanhar é a tendência ao longo de horas, não o número de uma requisição.
E o veredito sobre o tfjs puro: cerca de 1 segundo por imagem em CPU, sem nenhum binário nativo. Para moderar upload de usuário isso é folgado — o gargalo real vai ser a rede, não o modelo. Se você precisa classificar milhares de imagens por minuto, aí sim o tfjs-node paga a saga da instalação. Para o resto de nós, o aviso do console é mais assustador do que o problema. 😌
🚪 A API pronta
Juntando tudo, o servidor é pequeno:
function createApp() {
const app = express();
app.use(express.json({ limit: '1mb' }));
app.get('/health', (req, res) => {
res.json({ status: 'ok', modelLoaded: isModelLoaded() });
});
const auth = makeAuth(config.AUTH_TOKEN);
app.post('/analyze', auth, analyzeHandler);
app.post('/predict', auth, predictHandler);
return app;
}
O /health fica fora da autenticação de propósito — monitoramento precisa alcançar ele sem segredo. Os dois endpoints de classificação exigem token, e a comparação é feita em tempo constante:
function safeEqual(a, b) {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
Um === comum sai fora no primeiro caractere diferente, e essa diferença de tempo — minúscula, mas mensurável — permite descobrir o token caractere por caractere. O timingSafeEqual sempre percorre tudo. (A conferência de tamanho antes é obrigatória: o timingSafeEqual lança exceção se os buffers tiverem comprimentos diferentes.)
Usando na prática:
curl -X POST http://localhost:3000/analyze \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"imageUrl":"https://exemplo.com/foto.jpg"}'
E os erros, todos capturados de verdade nos meus testes:
Sem token, o corpo é só isto, com HTTP 401:
{
"error": "Unauthorized"
}
Com token válido mas requisição malformada, vem HTTP 400 — e repare que o elapsedMs é zero, porque nada foi baixado:
{
"error": "Body must include a string \"imageUrl\"",
"elapsedMs": 0
}
E o mesmo 400 para um esquema que a guarda recusa:
{
"error": "Image URL must use http or https",
"elapsedMs": 0
}
O mapa de status é a parte que eu mais recomendo copiar, porque separa culpa de quem:
// SsrfError e problemas de formato da URL sao erro do cliente (400);
// o resto (timeout, HTTP da origem, imagem grande demais) e erro de gateway.
if (err instanceof SsrfError || /https|invalid image url|credential/i.test(err.message)) {
err.status = 400;
} else {
err.status = 502;
}
400 quando quem chamou mandou algo errado. 502 quando o problema foi buscar a imagem lá fora. 500 só quando a classificação em si falhou. Isso importa de verdade quando você está olhando o painel às duas da manhã: um pico de 400 é cliente mal-configurado, um pico de 502 é a internet ou a origem das imagens caindo, e um pico de 500 é você. 😅
📝 O log que serve para duas coisas
Cada requisição escreve uma linha no console e um registro estruturado em logs/AAAA-MM-DD.jsonl. E o detalhe importante está no comentário:
// Melhor esforco: nunca lancar dentro do caminho da requisicao — uma falha de
// log nao pode transformar uma analise bem-sucedida em erro.
function append(record) {
const now = new Date();
const entry = { timestamp: now.toISOString(), ...record };
fs.mkdir(LOG_DIR, { recursive: true }, (mkErr) => {
if (mkErr) {
console.error(`[analysisLog] could not create ${LOG_DIR}: ${mkErr.message}`);
return;
}
fs.appendFile(logFilePath(now), JSON.stringify(entry) + '\n', (err) => {
if (err) console.error(`[analysisLog] could not write log: ${err.message}`);
});
});
}
Tudo assíncrono e nenhum erro sobe. Disco cheio, permissão errada, pasta apagada — o log reclama no console e a requisição segue feliz. Seria muito bobo devolver 500 numa classificação que deu certo só porque não deu para anotar. 🤦♀️
O formato .jsonl (um JSON por linha) é escolha prática: dá para acompanhar com tail -f e processar depois sem carregar tudo na memória. É o formato para responder "quantas imagens foram bloqueadas semana passada?" — e para recalibrar o limiar, já que o registro guarda as cinco probabilidades e não só o veredito. Mudou de 0,6 para 0,5? Você consegue reprocessar o histórico e ver o que teria mudado, sem reclassificar imagem nenhuma.
🧪 Os testes rodam sem rede e sem modelo
São 26 testes, no runner nativo do Node (node --test, sem Jest, sem Mocha):
✔ rejects wrong token (0.2392ms)
✔ accepts correct Bearer token and calls next (0.1429ms)
✔ sortPredictions orders by probability desc (1.3251ms)
✔ sortPredictions does not mutate input (0.2644ms)
✔ isPorn true exactly at the boundary (0.1363ms)
✔ isPrivateIP flags IPv6 loopback/link-local/ULA/mapped (3.483ms)
✔ assertSafeUrl rejects credentials in url (0.1868ms)
✔ assertSafeUrl allows literal private IP host (SSRF range check disabled) (0.729ms)
ℹ tests 26
ℹ pass 26
ℹ fail 0
ℹ duration_ms 150.5487
150 milissegundos para a suíte inteira. Isso é possível porque nenhum teste carrega o modelo nem acessa a rede — eles cobrem a autenticação, a matemática dos limiares e a guarda de URL, que são justamente as partes onde um erro é silencioso.
Dois deles merecem menção. O isPorn true exactly at the boundary testa o valor exato do limiar — porque a diferença entre > e >= é o tipo de bug que passa despercebido para sempre. E o sortPredictions does not mutate input existe porque a função copia antes de ordenar:
function sortPredictions(predictions) {
return [...predictions].sort((a, b) => b.probability - a.probability);
}
O .sort() do JavaScript ordena no lugar. Sem aquele [...], ordenar para exibir bagunçaria o array que o isPorn vai ler depois. Funcionaria mesmo assim (a soma não liga para ordem), mas é uma armadilha esperando o próximo que mexer no código — e o teste é o que garante que ela não volte.
E o último da lista é meu favorito pelo nome: assertSafeUrl allows literal private IP host (SSRF range check disabled). O teste documenta a decisão. Se alguém religar a guarda sem ler o comentário, esse teste quebra e conta a história inteira. 🎯
✅ O que eu levo deste projeto
Se você for construir moderação de imagem em Node, o resumo é esse:
- O tfjs puro é suficiente para a maioria dos casos — ~1 s por imagem em CPU, zero dor de instalação. Só troque pelo nativo com número na mão.
- Sem
tfjs-nodenão existe carregar do disco: leia os arquivos e monte o handler comtf.io.fromMemory()— recortando oArrayBuffercomslice(). sharp().rotate()vazio conserta as fotos de celular deitadas, e.removeAlpha()conserta os PNGs.tensor.dispose()dentro de umfinally, sempre. E monitore RSS, não heap.- Decida por soma de classes com limiar, nunca por uma classe isolada — as classes de trás medem o traço da imagem, não o quanto ela é imprópria.
- Ao desligar uma proteção, deixe o código, o comentário e o teste. Decisão documentada não vira buraco esquecido.
E o melhor: nenhuma imagem sai da minha máquina, nenhuma fatura por requisição, e o modelo inteiro cabe em 2,5 MB. Para um problema que parecia exigir nuvem, ficou bem doméstico. 🏡✨
💾 O repositório
Está tudo lá, com uma página de demonstração — cole a URL de uma imagem e veja as cinco classes com as barras. O extrair-modelo.js gera os 2,5 MB a partir do pacote que o npm install já baixou:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.