# Atlas - Guia de migracao de um projeto existente

> Documento para quem **ja tem** a raspagem feita e precisa converter para o
> formato Atlas. Se voce vai raspar do zero, use a especificacao principal:
> ela ja ensina a produzir o formato certo direto, sem etapa de conversao.

Contrato v1.0.

---

## O que muda em relacao a uma raspagem nova

Numa raspagem nova voce sobe os arquivos para o IDrive E2 **durante** a coleta.
Aqui os binarios ja existem em algum lugar - no seu servidor, no S3 de outro
projeto, ou apenas como URL da fonte original. Entao a ordem e:

1. Mapear o seu schema para o schema Atlas (no papel, antes de codar).
2. Definir como gerar `externalId` e `hash` a partir do que voce ja tem.
3. **Migrar os binarios** para o IDrive E2 e guardar as novas URLs.
4. Exportar o NDJSON.
5. Enviar uma amostra, corrigir, enviar o volume total.

O passo 3 costuma ser o mais demorado e e o que mais reprova importacao.
Comece por ele.

---

## Passo 1 - Mapa de campos

Monte esta tabela antes de escrever codigo. Ela e a especificacao da conversao.

| Campo Atlas | Obrigatorio | De onde vem no seu banco |
| --- | --- | --- |
| `externalId` | **Sim** | id/slug/url original do registro na fonte |
| `type` | **Sim** | mapeie para: ARTICLE, PRODUCT, POST, PROFILE, DOCUMENT, VIDEO, IMAGE, AUDIO, PLACE, EVENT, ORGANIZATION, PERSON, RECORD, OTHER |
| `title` | Nao | titulo/nome/assunto |
| `description` | Nao | resumo/corpo/descricao |
| `url` | Nao | link canonico do registro **na fonte** |
| `author` | Nao | autor/vendedor/perfil |
| `language` | Nao | ISO 639-1 (`pt`) |
| `country` | Nao | ISO 3166-1 alpha-2 (`BR`) |
| `publishedAt` | Nao | data de publicacao **na fonte**, nao a data da coleta |
| `raw` | **Sim** | a linha original inteira, sem normalizar |
| `metadata` | Nao | o que voce ja calculou (preco, contadores, coordenadas) |
| `hash` | **Sim** | calculado - veja o passo 2 |
| `tags` | Nao | categorias/labels que voce ja tinha |
| `files` | Nao | imagens/PDFs, **depois** de migrados para o E2 |

Regras que costumam pegar quem esta convertendo:

- **Nao invente `publishedAt`.** Se a sua tabela so tem `created_at` (data em
  que voce raspou), deixe `publishedAt` ausente. Data de coleta como data de
  publicacao cria um dado falso que ninguem depois consegue distinguir.
- **`url` e da fonte, nao do arquivo.** O link do arquivo vai em `files[].url`.
- **Campo que nao tem lugar vai para `metadata`.** Nao invente campo raiz: o
  validador aceita, mas emite aviso e descarta na gravacao.
- **`raw` nao e opcional.** Se voce descartou o payload original, guarde ao
  menos a linha completa do seu banco - e o que permite reprocessar depois sem
  raspar de novo.

---

## Passo 2 - externalId e hash

### externalId

Precisa ser **estavel entre execucoes** e **unico dentro do dataset**.

```js
// Bom: id da fonte
externalId: `ig:${row.instagram_id}`

// Bom: url canonica quando nao ha id
externalId: row.url.replace(/^https?:\/\//, '').slice(0, 512)

// Ruim: id autoincrement do SEU banco
externalId: String(row.id)   // muda se voce reprocessar do zero
```

Usar o id do seu proprio banco funciona **enquanto** aquele banco existir. Se
ele for recriado, os ids mudam e o Atlas trata tudo como registro novo,
duplicando a base. Prefira sempre o identificador da fonte.

### hash

SHA-256 do conteudo normalizado. Se voce nunca calculou hash, gere agora a
partir dos campos que definem o conteudo:

```js
const crypto = require('node:crypto');

function contentHash(row) {
  const canonical = JSON.stringify({
    externalId: row.externalId,
    type: row.type,
    title: row.title ?? null,
    description: row.description ?? null,
    url: row.url ?? null,
    publishedAt: row.publishedAt ?? null,
  });
  return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
}
```

Nao use o HTML cru como entrada: anuncio e timestamp mudam a cada visita e o
hash muda junto sem o conteudo ter mudado.

---

## Passo 3 - Migrar os binarios para o IDrive E2

Esta e a etapa que mais reprova entrega. **Nenhuma URL pode apontar para fora
do bucket oficial**, nem para a fonte original.

Layout obrigatorio:

```
atlas-scraping/
└── <slug-do-dataset>/
    ├── images/
    ├── documents/
    └── ...
```

Script de migracao com controle de retomada - importante, porque migrar
centenas de milhares de arquivos nao termina numa tacada so:

```js
const crypto = require('node:crypto');
const { S3Client, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');

const s3 = new S3Client({
  endpoint: process.env.IDRIVE_E2_ENDPOINT,
  region: process.env.IDRIVE_E2_REGION || 'us-east-1',
  forcePathStyle: true,
  credentials: {
    accessKeyId: process.env.IDRIVE_E2_ACCESS_KEY_ID,
    secretAccessKey: process.env.IDRIVE_E2_SECRET_ACCESS_KEY,
  },
});

const BUCKET = process.env.IDRIVE_E2_BUCKET;
const PROJECT = 'meu-projeto';          // slug combinado com o administrador
const BASE = 'https://SEU-ENDPOINT.idrivee2-XX.com/atlas-scraping';

async function migrarArquivo(urlOrigem, mimeType) {
  const resp = await fetch(urlOrigem);
  if (!resp.ok) throw new Error(`${resp.status} ao baixar ${urlOrigem}`);
  const buffer = Buffer.from(await resp.arrayBuffer());

  // Nome pelo hash do conteudo: reexecutar o script nao duplica nada.
  const hash = crypto.createHash('sha256').update(buffer).digest('hex');
  const ext = (mimeType.split('/')[1] || 'bin').split('+')[0];
  const key = `${PROJECT}/images/${hash.slice(0, 2)}/${hash}.${ext}`;

  // Retomada: se ja subiu numa execucao anterior, nao sobe de novo.
  try {
    await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
  } catch {
    await s3.send(new PutObjectCommand({
      Bucket: BUCKET, Key: key, Body: buffer, ContentType: mimeType,
    }));
  }

  return {
    url: `${BASE}/${key}`,
    type: 'IMAGE',
    mimeType,
    size: buffer.length,
    hash,
  };
}
```

Pontos que fazem diferenca:

- **Nome pelo hash do conteudo.** Reexecutar o script e idempotente, e o mesmo
  binario referenciado por varios registros vira um objeto so.
- **Subpasta pelos 2 primeiros caracteres do hash.** Evita diretorio com
  milhoes de objetos, que deixa listagem e backup lentos.
- **`size` e o tamanho real** do buffer, como numero. String reprova.
- **Content-Type correto** no upload. Errado gera aviso, nao erro, mas atrapalha
  quem consome a API depois.
- **Rode com concorrencia limitada** (8 a 16 downloads simultaneos). Mais que
  isso costuma tomar bloqueio da fonte no meio da migracao.

Guarde o mapa `url_original -> nova_url` numa tabela sua. Se a exportacao
falhar no meio, voce nao precisa baixar tudo de novo.

---

## Passo 4 - Exportar o NDJSON

Exporte em streaming, paginando pelo banco. `SELECT *` de uma tabela de
milhoes de linhas dentro de um `JSON.stringify` estoura a memoria.

```js
const fs = require('node:fs');

const out = fs.createWriteStream('entities.ndjson', { encoding: 'utf8' });
let total = 0;
let cursor = 0;

while (true) {
  const rows = await db.query(
    'SELECT * FROM meus_registros WHERE id > $1 ORDER BY id LIMIT 5000',
    [cursor],
  );
  if (rows.length === 0) break;

  for (const row of rows) {
    const entity = converter(row);          // o mapa do passo 1
    out.write(JSON.stringify(entity) + '\n');  // UMA linha por registro
    total++;
  }
  cursor = rows[rows.length - 1].id;
  console.log(`${total} exportados...`);
}
out.end();

fs.writeFileSync('manifest.json', JSON.stringify({
  contractVersion: '1.0',
  project: PROJECT,
  collectionType: 'WEBSITE',           // WEBSITE | API | RSS | PDF | SOCIAL | DATASET | OTHER
  entityCount: total,                  // precisa bater exatamente
  generatedAt: new Date().toISOString(),
  generatedBy: 'migracao@exemplo.com',
  notes: 'Migracao do banco legado xyz',
}, null, 2));
```

`entityCount` **precisa** ser o total real de linhas. Divergencia reprova a
importacao, e e justamente o sinal de exportacao interrompida no meio.

---

## Passo 5 - Amostra antes do volume total

```bash
head -n 100 entities.ndjson > amostra.ndjson
```

Envie a amostra primeiro. Descobrir um erro de mapeamento em 100 linhas custa
minutos; descobrir em 3 milhoes custa a migracao inteira.

Quando a amostra passar, gere o dump completo com o mesmo codigo.

---

## Erros mais comuns em migracao

| Sintoma | Causa quase sempre |
| --- | --- |
| `LINE_INVALID_JSON` em toda linha | Exportou JSON identado ou array `[...]` em vez de uma linha por objeto |
| `FIELD_TYPE` em `size` | O driver do banco devolveu numero como string; use `Number(...)` |
| `FIELD_TYPE` em `raw` | Coluna JSON veio como texto; faca `JSON.parse` antes |
| `FIELD_FORMAT` em `publishedAt` | Data como `DD/MM/YYYY` ou objeto `Date` sem `.toISOString()` |
| `FIELD_FORMAT` em `country` | Minusculo (`br`); precisa ser `BR` |
| `FILE_URL_WRONG_HOST` | Passo 3 nao foi feito - a URL ainda aponta para a fonte |
| `DUPLICATE_EXTERNAL_ID` | A tabela de origem tinha duplicatas que voce nunca notou |
| `MANIFEST_COUNT_MISMATCH` | `entityCount` chumbado ou exportacao interrompida |

Sobre `DUPLICATE_EXTERNAL_ID`: rode isto **no seu banco** antes de exportar.
Quase toda base legada tem duplicata escondida.

```sql
SELECT external_id, COUNT(*)
FROM meus_registros
GROUP BY external_id
HAVING COUNT(*) > 1;
```

---

## Checklist da migracao

- [ ] Mapa de campos escrito e revisado com o administrador
- [ ] Slug do dataset combinado (e a pasta no IDrive E2)
- [ ] `externalId` vem da fonte, nao do id do meu banco
- [ ] Duplicatas de `externalId` verificadas e resolvidas na origem
- [ ] `hash` calculado a partir do conteudo normalizado
- [ ] Todos os binarios migrados para `atlas-scraping/<slug>/`
- [ ] Mapa `url_original -> nova_url` guardado, caso precise reexportar
- [ ] `size` de cada arquivo e o tamanho real, como numero
- [ ] `raw` preenchido com a linha original
- [ ] `entityCount` do manifesto bate com `wc -l entities.ndjson`
- [ ] Amostra de 100 linhas importada com sucesso
- [ ] 5 registros conferidos manualmente contra a fonte

---

## Referencia rapida

**EntityType:** `ARTICLE`, `PRODUCT`, `POST`, `PROFILE`, `DOCUMENT`, `VIDEO`, `IMAGE`, `AUDIO`, `PLACE`, `EVENT`, `ORGANIZATION`, `PERSON`, `RECORD`, `OTHER`

**FileType:** `IMAGE`, `VIDEO`, `DOCUMENT`, `AUDIO`, `OTHER`

**collectionType:** `WEBSITE`, `API`, `RSS`, `PDF`, `SOCIAL`, `DATASET`, `OTHER`

Nenhum valor serve? Use `OTHER` e detalhe o tipo real em `metadata`.

A especificacao completa dos campos esta na documentacao principal, secao
"Referencia tecnica do contrato".