diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..cccd93b --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,19 @@ +name: Smoke test + +# Corre después de cada deploy de producción en Vercel, una vez por día y a mano. +on: + deployment_status: + schedule: + - cron: "0 12 * * *" + workflow_dispatch: + +jobs: + smoke: + if: github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'Production') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: node scripts/smoke.mjs diff --git a/.gitignore b/.gitignore index 65c7f1c..0300a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,5 @@ build/ # Temporary files tmp/ temp/ +.vercel +.env* diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..134398a --- /dev/null +++ b/.vercelignore @@ -0,0 +1,5 @@ +node_modules +artifacts +cache +test +.env diff --git a/package-lock.json b/package-lock.json index 40d58ba..2ee7193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@lighthouse-web3/sdk": "^0.4.1", "@zama-fhe/relayer-sdk": "^0.1.2", + "ai": "^7.0.131", "axios": "^1.5.0", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -35,6 +36,63 @@ "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.107", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.107.tgz", + "integrity": "sha512-Z/e4lbywscQ86JHi+vgIE7paPzvCSypJFIQugADWpWZXGkQ3iZ6M08rqkjDNhj3y6Gi7O3A/sSL/Ib3LoVZv6w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.24", + "@ai-sdk/provider-utils": "5.0.56", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.24.tgz", + "integrity": "sha512-iztFqJTdxNN0ZzqZAAnVPMhRMaL1bfQFa3GIPAffWQ0+dP2FXIkyMD8kHeEI7EpB/C6TR45cqOkrS7YZZ1sATA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.56", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.56.tgz", + "integrity": "sha512-F7/U1YpVXEfSxRHbxScm0PVdr9lN6GaUWHnt8ws2UjuGeuxWHqCkV25dGpHAkpDHkuIXoC+aak/OqSylKSJmHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.24", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.29.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1821,6 +1879,12 @@ "antlr4ts": "^0.5.0-alpha.4" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2039,6 +2103,21 @@ "@types/node": "*" } }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/@zama-fhe/relayer-sdk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@zama-fhe/relayer-sdk/-/relayer-sdk-0.1.2.tgz", @@ -2187,6 +2266,23 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "7.0.131", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.131.tgz", + "integrity": "sha512-lOv32sfZ4T6yeFLvc9YyIBbaJH4lAhX4jYvRn6AmU7EePpL5VX3I7jgLFuX56lWaiKFi07rQ0I8+Jb2t0B0olg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.107", + "@ai-sdk/provider": "4.0.24", + "@ai-sdk/provider-utils": "5.0.56" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -4215,6 +4311,15 @@ "license": "MIT", "peer": true }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -5706,6 +5811,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -9372,6 +9483,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index e2b3013..1bd4fb1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "dependencies": { "@lighthouse-web3/sdk": "^0.4.1", "@zama-fhe/relayer-sdk": "^0.1.2", + "ai": "^7.0.131", "axios": "^1.5.0", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -63,4 +64,4 @@ "hardhat": "^2.22.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..fc4d762 --- /dev/null +++ b/public/index.html @@ -0,0 +1,602 @@ + + + + + +TrustBlock · ¿Es verdad lo que te llegó? + + + + + + + +
+
+ + + TrustBlock + + +
+
+ +
+
+

Verificación de noticias

+

¿Es verdad lo que te llegó?

+

Pegá el texto, el titular o el link. TrustBlock busca chequeos ya publicados por medios verificadores y revisa si otros medios cuentan lo mismo.

+ +
+ + +
+
+ Probá con: + + +
+ +
+

No guardamos lo que pegás. Tu historial queda solo en este navegador.

+ +
+
+ + + +
+
+

Imágenes

+

¿La foto es real o la hizo una IA?

+

Subí la imagen o pegá su link. Un modelo estima si fue generada con IA y te damos la búsqueda inversa para encontrar de dónde salió.

+
+
+
+
+ + +
+
+ + +
+
+ +
+ Elegí el detector + + + +

Los modelos "en tu dispositivo" se descargan una vez y quedan guardados en el navegador; la imagen no sale de tu equipo.

+
+ +
+

La imagen se achica en tu navegador y no la guardamos.

+ +
+ +
+ +
+ +
+

Dos fuentes, un resultado

+
+
+

Chequeos publicados

+

Verificaciones de medios como Chequeado, AFP Factual o Reuters, con su calificación y el link al chequeo original.

+ Google Fact Check · ClaimReview +
+
+

Cobertura en medios

+

Si otros medios cuentan lo mismo, desde cuándo y quiénes. Si nadie más lo publica, es una alerta.

+ Google News +
+
+
+ +
+

El método: SIFT

+
    +
  1. S · StopPará.Antes de compartir, notá qué te genera.
  2. +
  3. I · InvestigateInvestigá la fuente.¿Quién lo publicó y qué antecedentes tiene?
  4. +
  5. F · FindBuscá otra cobertura.¿Quién más está hablando de esto?
  6. +
  7. T · TraceVolvé al origen.La foto, la cita o el dato en su contexto original.
  8. +
+
+ +
+
+

Tus verificaciones

+ Guardadas en este navegador +
+ +
Todavía no verificaste nada en este navegador.
+
+
+ + + + + + diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..e3c140f --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,73 @@ +// Smoke test de TrustBlock contra un sitio publicado. +// node scripts/smoke.mjs -> https://trustblock.vercel.app +// BASE=http://localhost:3000 node scripts/smoke.mjs +const BASE = (process.env.BASE || 'https://trustblock.vercel.app').replace(/\/$/, ''); +let failed = 0; + +async function check(name, fn) { + const t0 = Date.now(); + try { + const detail = await fn(); + console.log(`ok ${name} (${Date.now() - t0} ms)${detail ? ` · ${detail}` : ''}`); + } catch (e) { + failed++; + console.log(`FALLA ${name}: ${e.message}`); + } +} +const expect = (cond, msg) => { if (!cond) throw new Error(msg); }; +const json = (path, init) => fetch(BASE + path, init).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); +const postJson = (path, data) => json(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data) }); + +await check('página principal', async () => { + const r = await fetch(`${BASE}/`); + const html = await r.text(); + expect(r.status === 200, `status ${r.status}`); + expect(html.includes('¿Es verdad lo que te llegó?'), 'no aparece el título'); + expect(html.includes('id="img-form"'), 'falta la sección de imágenes'); +}); + +await check('/health', async () => { + const r = await json('/health'); + expect(r.status === 200 && r.body?.status === 'OK', `status ${r.status}`); +}); + +await check('/api/verify rechaza textos cortos', async () => { + const r = await json('/api/verify?q=hola'); + expect(r.status === 400, `esperaba 400, vino ${r.status}`); +}); + +await check('/api/verify encuentra cobertura', async () => { + const q = encodeURIComponent('La foto del papa Francisco con una campera blanca es real'); + const r = await json(`/api/verify?q=${q}`); + expect(r.status === 200, `status ${r.status}`); + expect(r.body.coverage.status === 'ok', `cobertura: ${r.body.coverage.status} ${r.body.coverage.error || ''}`); + expect(r.body.coverage.items.length > 0, 'cobertura vacía'); + return `veredicto ${r.body.verdict}, ${r.body.coverage.items.length} notas, chequeos ${r.body.factChecks.status}`; +}); + +await check('/api/image/status', async () => { + const r = await json('/api/image/status'); + expect(r.status === 200 && typeof r.body?.roboflow === 'boolean', `status ${r.status}`); + return `roboflow ${r.body.roboflow ? `activo (${r.body.model})` : 'sin clave'}`; +}); + +await check('/api/image arma búsqueda inversa', async () => { + const r = await postJson('/api/image', { url: 'https://example.com/foto.jpg' }); + expect(r.status === 200, `status ${r.status}`); + expect(r.body.reverseSearch?.length === 3, 'faltan links de búsqueda inversa'); +}); + +await check('/api/image rechaza links peligrosos', async () => { + const r = await postJson('/api/image', { url: 'javascript:alert(1)' }); + expect(r.status === 400, `esperaba 400, vino ${r.status}`); +}); + +for (const id of ['amrita-detectly/detect-ai-image-v1', 'onnx-community/deepfake_vs_real_image_detection-ONNX']) { + await check(`modelo del navegador disponible: ${id}`, async () => { + const r = await fetch(`https://huggingface.co/${id}/resolve/main/onnx/model_quantized.onnx`, { method: 'HEAD', redirect: 'follow' }); + expect(r.ok, `status ${r.status}`); + }); +} + +console.log(failed ? `\n${failed} chequeo(s) fallaron` : '\nTodo OK'); +process.exit(failed ? 1 : 0); diff --git a/scripts/try-verify.js b/scripts/try-verify.js new file mode 100644 index 0000000..6a75f01 --- /dev/null +++ b/scripts/try-verify.js @@ -0,0 +1,24 @@ +// Prueba local del servicio de verificación: node scripts/try-verify.js "texto" +// Para clasificar con jev en local, AI_GATEWAY_API_KEY se toma de ~/.config/jev/env. +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const envFile = path.join(os.homedir(), '.config', 'jev', 'env'); +if (!process.env.AI_GATEWAY_API_KEY && fs.existsSync(envFile)) { + const m = fs.readFileSync(envFile, 'utf8').match(/AI_GATEWAY_API_KEY\s*=\s*(.+)/); + if (m) process.env.AI_GATEWAY_API_KEY = m[1].trim().replace(/^["']|["']$/g, ''); +} + +const { verify, keywords } = require('../src/services/verifyService'); + +(async () => { + const q = process.argv[2] || 'La foto del papa Francisco con una campera blanca es real'; + console.log('términos de búsqueda:', keywords(q, 8)); + const r = await verify(q); + console.log(JSON.stringify({ + verdict: r.verdict, + factChecks: { status: r.factChecks.status, n: r.factChecks.items.length, classifier: r.factChecks.classifier }, + coverage: { status: r.coverage.status, error: r.coverage.error, n: r.coverage.items.length, first: r.coverage.items.slice(0, 3) }, + }, null, 2)); +})(); diff --git a/src/index.js b/src/index.js index 9b830da..70d4726 100644 --- a/src/index.js +++ b/src/index.js @@ -33,6 +33,8 @@ const truthboardRoutes = require('./routes/truthboard'); const filecoinRoutes = require('./routes/filecoin'); const confidentialRoutes = require('./routes/confidential'); const flareRoutes = require('./routes/flare'); +const verifyRoutes = require('./routes/verify'); +const imageRoutes = require('./routes/image'); const app = express(); const PORT = process.env.PORT || 3000; @@ -53,6 +55,10 @@ if (trustProxy) { console.log(`🔧 Trust proxy deshabilitado para desarrollo local`); } +// Página pública (public/). En Vercel la sirve el CDN; esto es para correrla en local. +// Va antes de helmet porque su CSP bloquearía el script y las fuentes de la página. +app.use(express.static(require('path').join(__dirname, '../public'))); + // Middleware de seguridad app.use(helmet({ crossOriginEmbedderPolicy: false @@ -196,6 +202,10 @@ app.use('/api/truthboard', publishLimiter, truthboardRoutes); app.use('/api/filecoin', filecoinLimiter, filecoinRoutes); app.use('/api/confidential', confidentialLimiter, confidentialRoutes); app.use('/api/flare', validationLimiter, flareRoutes); +// Verificación abierta: chequeos publicados + cobertura en medios, sin cuenta. +app.use('/api/verify', searchLimiter, verifyRoutes); +// ¿Imagen generada con IA? Detector de Roboflow + búsqueda inversa. +app.use('/api/image', searchLimiter, imageRoutes); // Aplicar rate limiter específico para búsquedas después de las rutas news app.use('/api/news/search', searchLimiter); diff --git a/src/routes/image.js b/src/routes/image.js new file mode 100644 index 0000000..bde5a84 --- /dev/null +++ b/src/routes/image.js @@ -0,0 +1,39 @@ +const express = require('express'); +const { checkImage } = require('../services/imageCheckService'); + +const router = express.Router(); +// Vercel corta los cuerpos de más de 4,5 MB: la página achica la imagen antes de mandarla. +const MAX_BASE64 = 4 * 1024 * 1024; + +// GET /api/image/status: si el detector de Roboflow está activo (la página lo habilita o no). +router.get('/status', (req, res) => { + const on = Boolean(process.env.ROBOFLOW_API_KEY && process.env.ROBOFLOW_MODEL); + res.json({ roboflow: on, model: on ? process.env.ROBOFLOW_MODEL : null }); +}); + +// POST /api/image { image: "" } o { url: "https://..." } +router.post('/', async (req, res) => { + const { image, url } = req.body || {}; + let safeUrl = null; + if (url) { + try { + const u = new URL(String(url)); + if (!/^https?:$/.test(u.protocol)) throw new Error(); + safeUrl = u.href; + } catch { + return res.status(400).json({ error: 'El link de la imagen no es válido.' }); + } + } + const base64 = typeof image === 'string' ? image.replace(/^data:image\/[a-z+]+;base64,/, '') : null; + if (!safeUrl && !base64) return res.status(400).json({ error: 'Subí una imagen o pegá el link de una.' }); + if (base64 && base64.length > MAX_BASE64) return res.status(413).json({ error: 'La imagen es demasiado grande.' }); + try { + res.set('cache-control', 'no-store'); + res.json(await checkImage({ base64, url: safeUrl })); + } catch (error) { + console.error('Error analizando imagen:', error); + res.status(500).json({ error: 'No pudimos analizar la imagen ahora.' }); + } +}); + +module.exports = router; diff --git a/src/routes/verify.js b/src/routes/verify.js new file mode 100644 index 0000000..255da08 --- /dev/null +++ b/src/routes/verify.js @@ -0,0 +1,24 @@ +const express = require('express'); +const { verify } = require('../services/verifyService'); + +const router = express.Router(); + +// GET /api/verify?q=texto o link. Sin cuenta ni wallet. +router.get('/', async (req, res) => { + const q = String(req.query.q || '').trim(); + if (q.length < 8) { + return res.status(400).json({ error: 'Pegá un texto, titular o link de al menos 8 caracteres.' }); + } + if (q.length > 2000) { + return res.status(400).json({ error: 'El texto es demasiado largo: pegá el titular o la frase clave.' }); + } + try { + res.set('cache-control', 'no-store'); + res.json(await verify(q)); + } catch (error) { + console.error('Error verificando:', error); + res.status(500).json({ error: 'No pudimos verificar ahora. Probá de nuevo en unos segundos.' }); + } +}); + +module.exports = router; diff --git a/src/services/databaseService.js b/src/services/databaseService.js index 32114ea..2598f80 100644 --- a/src/services/databaseService.js +++ b/src/services/databaseService.js @@ -3,13 +3,16 @@ const path = require('path'); class DatabaseService { constructor() { - this.dbPath = path.join(__dirname, '../../data/database.json'); + // En Vercel el disco es de solo lectura: se trabaja sobre /tmp, sembrado con data/database.json. + this.seedPath = path.join(__dirname, '../../data/database.json'); + this.dbPath = process.env.VERCEL ? '/tmp/trustblock-database.json' : this.seedPath; this.data = null; } async initialize() { try { - const dbContent = await fs.readFile(this.dbPath, 'utf8'); + const dbContent = await fs.readFile(this.dbPath, 'utf8') + .catch(() => fs.readFile(this.seedPath, 'utf8')); this.data = JSON.parse(dbContent); console.log('✅ Base de datos inicializada'); } catch (error) { diff --git a/src/services/imageCheckService.js b/src/services/imageCheckService.js new file mode 100644 index 0000000..4e1dec1 --- /dev/null +++ b/src/services/imageCheckService.js @@ -0,0 +1,76 @@ +// ¿La imagen fue generada con IA? Pregunta a un modelo de clasificación de Roboflow +// (cualquiera de Roboflow Universe, configurable) y arma links de búsqueda inversa, +// que sirven aunque el detector no esté configurado o se equivoque. +// +// Variables: +// ROBOFLOW_API_KEY clave de la cuenta de Roboflow +// ROBOFLOW_MODEL modelo y versión, p. ej. "fnd-classifier/1" +// ROBOFLOW_AI_CLASSES etiquetas del modelo que significan "hecha con IA", +// separadas por coma (por defecto: ai,ai-generated,ai_generated,artificial,fake,generated,synthetic) + +const CLASSIFY_URL = 'https://classify.roboflow.com'; +const TIMEOUT_MS = 15000; +const DEFAULT_AI_CLASSES = 'ai,ai-generated,ai_generated,artificial,fake,generated,synthetic'; + +function aiClasses() { + return new Set((process.env.ROBOFLOW_AI_CLASSES || DEFAULT_AI_CLASSES) + .split(',').map((c) => c.trim().toLowerCase()).filter(Boolean)); +} + +/** Roboflow devuelve una lista (una sola clase) o un objeto por clase (multi-etiqueta). */ +function readPredictions(data) { + const p = data?.predictions; + if (Array.isArray(p)) return p.map((x) => ({ label: String(x.class), confidence: Number(x.confidence) })); + if (p && typeof p === 'object') { + return Object.entries(p).map(([label, v]) => ({ label, confidence: Number(v?.confidence ?? v) })); + } + if (data?.top) return [{ label: String(data.top), confidence: Number(data.confidence) }]; + return []; +} + +async function classify({ base64, url }) { + const key = process.env.ROBOFLOW_API_KEY; + const model = process.env.ROBOFLOW_MODEL; + if (!key || !model) return { status: 'no_configurado' }; + + const params = new URLSearchParams({ api_key: key }); + if (url) params.set('image', url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(`${CLASSIFY_URL}/${model}?${params}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: url ? undefined : base64, + signal: controller.signal, + }); + if (!res.ok) return { status: 'error', error: `Roboflow respondió ${res.status}` }; + const predictions = readPredictions(await res.json()).sort((a, b) => b.confidence - a.confidence); + if (predictions.length === 0) return { status: 'error', error: 'El modelo no devolvió clases' }; + const ai = aiClasses(); + const aiScore = predictions.filter((p) => ai.has(p.label.toLowerCase())) + .reduce((max, p) => Math.max(max, p.confidence), 0); + return { status: 'ok', model, aiProbability: Math.round(aiScore * 100) / 100, predictions }; + } catch (e) { + return { status: 'error', error: e.name === 'AbortError' ? 'Roboflow tardó demasiado' : e.message }; + } finally { + clearTimeout(timer); + } +} + +function reverseSearch(url) { + if (!url) return []; + const u = encodeURIComponent(url); + return [ + { name: 'Google Lens', url: `https://lens.google.com/uploadbyurl?url=${u}` }, + { name: 'TinEye', url: `https://tineye.com/search?url=${u}` }, + { name: 'Bing Visual Search', url: `https://www.bing.com/images/search?view=detailv2&iss=sbi&q=imgurl:${u}` }, + ]; +} + +async function checkImage({ base64, url }) { + const detector = await classify({ base64, url }); + return { detector, reverseSearch: reverseSearch(url), checkedAt: new Date().toISOString() }; +} + +module.exports = { checkImage, readPredictions }; diff --git a/src/services/verifyService.js b/src/services/verifyService.js new file mode 100644 index 0000000..333df8c --- /dev/null +++ b/src/services/verifyService.js @@ -0,0 +1,206 @@ +// Verificación sin cuenta: busca chequeos publicados (Google Fact Check, ClaimReview) +// y cobertura en medios (Google News), y resume un veredicto. +// La calificación de cada chequeo ("Falso", "Mostly false", "Engañoso"...) la clasifica +// el modelo typesafe-ai/jev por Vercel AI Gateway; si no responde, queda sin clasificar. + +const FACTCHECK_URL = 'https://factchecktools.googleapis.com/v1alpha1/claims:search'; +const GNEWS_URL = 'https://news.google.com/rss/search'; +const TIMEOUT_MS = 8000; +const VERDICTS = ['falso', 'enganoso', 'verdadero', 'sin_calificar']; + +// Palabras que no sirven como términos de búsqueda (no clasifican nada). +const STOPWORDS = new Set(('a al algo ante bajo cada como con contra cual cuando de del desde donde durante el ella ellos en entre era es esa ese eso esta este esto fue ha hay la las le les lo los mas más me mi muy no nos o otra otro para pero por porque que qué se ser si sí sin sobre su sus también tiene toda todo todos tu un una uno unos ya y ' + + 'about after also and are been but by for from has have into its just more not now of on or our out over said says than that the their them then there these they this was were what when which who will with would you your').split(' ')); + +async function fetchWithTimeout(url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function isUrl(text) { + return /^https?:\/\/\S+$/i.test(text.trim()); +} + +/** Si pegaron un link, usamos el título de la página como texto a buscar. */ +async function resolveInput(input) { + const text = input.trim(); + if (!isUrl(text)) return { query: text, source: null }; + const url = new URL(text); + const source = { url: url.href, domain: url.hostname.replace(/^www\./, '') }; + try { + const res = await fetchWithTimeout(url.href, { headers: { 'user-agent': 'TrustBlock/1.0 (+https://trustblock.vercel.app)' } }); + const html = (await res.text()).slice(0, 200000); + const og = html.match(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i); + const title = html.match(/]*>([^<]+)<\/title>/i); + const found = (og?.[1] || title?.[1] || '').replace(/\s+/g, ' ').trim(); + return { query: found || source.domain, source: { ...source, title: found || null } }; + } catch { + return { query: source.domain, source }; + } +} + +/** El buscador de noticias va mejor con palabras clave que con la frase entera: nos quedamos con las más informativas. */ +function keywords(text, max = 6) { + const words = text + .normalize('NFC') + .replace(/https?:\/\/\S+/g, ' ') + .split(/[^\p{L}\p{N}]+/u) + .filter((w) => w.length >= 4 && !STOPWORDS.has(w.toLowerCase())); + return [...new Set(words)].slice(0, max); +} + +async function searchFactChecks(query) { + const key = process.env.GOOGLE_FACTCHECK_API_KEY; + if (!key) return { status: 'no_configurado', items: [] }; + const params = new URLSearchParams({ query: query.slice(0, 500), pageSize: '10', key }); + try { + const res = await fetchWithTimeout(`${FACTCHECK_URL}?${params}`); + if (!res.ok) return { status: 'error', error: `Google Fact Check respondió ${res.status}`, items: [] }; + const data = await res.json(); + const items = (data.claims || []).flatMap((claim) => + (claim.claimReview || []).map((review) => ({ + claim: claim.text || null, + claimant: claim.claimant || null, + publisher: review.publisher?.name || review.publisher?.site || null, + rating: review.textualRating || null, + title: review.title || null, + url: review.url || null, + date: review.reviewDate || claim.claimDate || null, + language: review.languageCode || null, + }))); + return { status: 'ok', items }; + } catch (e) { + return { status: 'error', error: e.name === 'AbortError' ? 'Google Fact Check tardó demasiado' : e.message, items: [] }; + } +} + +function decodeEntities(text) { + return text + .replace(//gs, '$1') + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) + .replace(/"/g, '"').replace(/'|'/g, "'") + .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); +} + +// Cobertura: Google News (RSS, sin clave). GDELT quedó descartado: limita a una consulta +// cada 5 s por IP y en IPs compartidas (como las de Vercel) responde 429 casi siempre. +async function searchCoverage(query) { + const all = keywords(query, 8); + if (all.length === 0) return { status: 'sin_terminos', terms: all, items: [] }; + // Google News pide que aparezcan todas las palabras, y una sola rara (un verbo, una + // conjugación) deja la búsqueda en cero. Si pasa, se prueban en paralelo las combinaciones + // de 4 y después de 3 de las primeras 5 palabras, y gana la que trae más notas. + const first = await searchNews(all); + if (first.status !== 'ok' || first.items.length > 0) return first; + const base = all.slice(0, 5); + for (const size of [4, 3]) { + if (base.length <= size) continue; + const results = await Promise.all(combinations(base, size).map(searchNews)); + const best = results.filter((r) => r.status === 'ok').sort((a, b) => b.items.length - a.items.length)[0]; + if (best && best.items.length > 0) return best; + } + return first; +} + +function combinations(list, size, start = 0, picked = []) { + if (picked.length === size) return [picked]; + const out = []; + for (let i = start; i < list.length; i++) out.push(...combinations(list, size, i + 1, [...picked, list[i]])); + return out; +} + +async function searchNews(terms) { + const params = new URLSearchParams({ q: terms.join(' '), hl: 'es-419', gl: 'AR', ceid: 'AR:es-419' }); + try { + const res = await fetchWithTimeout(`${GNEWS_URL}?${params}`); + if (!res.ok) return { status: 'error', error: `Google News respondió ${res.status}`, terms, items: [] }; + const xml = await res.text(); + const items = [...xml.matchAll(/([\s\S]*?)<\/item>/g)].slice(0, 15).map(([, item]) => { + const field = (tag) => decodeEntities(item.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`))?.[1] || '').trim(); + const source = item.match(/([\s\S]*?)<\/source>/); + const outlet = source ? decodeEntities(source[2]).trim() : null; + const title = field('title'); + const date = field('pubDate'); + return { + title: outlet && title.endsWith(` - ${outlet}`) ? title.slice(0, -(outlet.length + 3)) : title, + url: field('link'), + outlet, + domain: source ? new URL(source[1]).hostname.replace(/^www\./, '') : null, + date: date ? new Date(date).toISOString().slice(0, 10) : null, + }; + }); + return { status: 'ok', terms, items }; + } catch (e) { + return { status: 'error', error: e.name === 'AbortError' ? 'Google News tardó demasiado' : e.message, terms, items: [] }; + } +} + +/** Una sola llamada a jev: una pregunta choice por calificación. */ +async function classifyRatings(ratings) { + if (ratings.length === 0) return { status: 'ok', verdicts: [] }; + const criteria = { + falso: 'La calificación dice que la afirmación es falsa, inventada, fabricada o generada artificialmente', + enganoso: 'Engañosa, sacada de contexto, exagerada, parcialmente cierta, mayormente falsa o sin evidencia', + verdadero: 'Verdadera, correcta o mayormente verdadera', + sin_calificar: 'No es una calificación de veracidad (sátira, explicación, opinión) o no se entiende', + }; + const questions = Object.fromEntries(ratings.map((r, i) => [`c${i}`, { + type: 'choice', + instructions: `¿Qué dice la calificación número ${i}?`, + criteria, + }])); + const state = ratings.map((r, i) => `Calificación ${i}: "${r || 'sin texto'}"`).join('\n'); + try { + const { experimental_evaluate: evaluate } = await import('ai'); + const result = await evaluate({ model: 'typesafe-ai/jev', state, questions }); + const answers = result.answers ?? result; + const verdicts = ratings.map((_, i) => { + const a = answers[`c${i}`]; + const value = typeof a === 'string' ? a : a?.value ?? a?.answer ?? a?.choice; + return VERDICTS.includes(value) ? value : 'sin_calificar'; + }); + return { status: 'ok', verdicts }; + } catch (e) { + return { status: 'error', error: e.message, verdicts: ratings.map(() => null) }; + } +} + +function summarize(factChecks, coverage) { + const counted = factChecks.items.map((i) => i.verdict).filter((v) => v && v !== 'sin_calificar'); + if (counted.length > 0) { + const tally = counted.reduce((acc, v) => ({ ...acc, [v]: (acc[v] || 0) + 1 }), {}); + const top = Object.entries(tally).sort((a, b) => b[1] - a[1]); + // Empate entre calificaciones distintas: lo más prudente es "engañoso". + const verdict = top.length > 1 && top[0][1] === top[1][1] ? 'enganoso' : top[0][0]; + return { verdict, basis: 'chequeos' }; + } + if (factChecks.items.length > 0) return { verdict: 'ver_chequeos', basis: 'chequeos' }; + if (coverage.status === 'ok' && coverage.items.length === 0) return { verdict: 'sin_cobertura', basis: 'cobertura' }; + // Si no se pudieron buscar chequeos, no decimos "no hay": mandamos a mirar la cobertura. + if (factChecks.status !== 'ok') return { verdict: 'ver_cobertura', basis: 'cobertura' }; + return { verdict: 'sin_chequeos', basis: 'cobertura' }; +} + +async function verify(input) { + const { query, source } = await resolveInput(input); + const [factChecks, coverage] = await Promise.all([searchFactChecks(query), searchCoverage(query)]); + const classification = await classifyRatings(factChecks.items.map((i) => i.rating)); + factChecks.items = factChecks.items.map((item, i) => ({ ...item, verdict: classification.verdicts[i] ?? null })); + if (classification.status === 'error') factChecks.classifier = { status: 'error', error: classification.error }; + return { + input: input.trim(), + query, + source, + ...summarize(factChecks, coverage), + factChecks, + coverage, + checkedAt: new Date().toISOString(), + }; +} + +module.exports = { verify, keywords }; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..55309a4 --- /dev/null +++ b/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/", "destination": "/index.html" }] +}