Skip to content

feat(integration-devto): article scraping PoC #181

Description

@danielhe4rt

Plano: Script de Extração de Reactions History do dev.to

Context

Daniel quer coletar dados de "Reactions History" das páginas de stats dos seus artigos no dev.to para automação em massa. Atualmente, o dev.to não expõe esses dados via API pública — a única forma de acessá-los é pela página /stats de cada artigo, que requer autenticação. O script será executado manualmente no console do browser quando ele estiver logado.

Motivação: Coletar dados de reações de múltiplos posts para análise/dashboard.
Formato de saída: JSON estruturado.


Estrutura DOM Descoberta

div.crayons-card.p-4.overflow-auto
├── h2.crayons-subtitle.mb-2  →  "Reactions History"
└── div.fs-sm.py-2.flex.items-center  (× N entradas, ex: 222)
    ├── div.mr-3.relative
    │   ├── img.crayons-avatar.h-8.w-8          → avatar do usuário
    │   └── img.crayons-avatar.absolute...       → ícone da reação
    └── div.flex-1.flex.items-center.justify-between
        ├── div  →  "readinglist\n by Username"  (tipo + link do user)
        └── div.fs-xs  →  "Mar 19" ou "Dec 26 '25"  (data)

Tipos de Reação Mapeados

Tipo Ícone SVG
like sparkle-heart.svg
unicorn multi-unicorn.svg
readinglist save.svg
fire fire.svg
raised_hands raised-hands.svg
exploding_head exploding-head.svg

Formato de Datas

  • Ano corrente: "Mar 19"
  • Ano anterior: "Dec 26 '25"

Implementação

Arquivo a criar

Nenhum arquivo no projeto. O deliverable é um snippet JavaScript para console do browser.

Script — Funcionalidades

  1. Localizar a seção "Reactions History" pelo h2 com texto correspondente
  2. Iterar todas as entradas (div.fs-sm.py-2.flex.items-center)
  3. Extrair de cada entrada:
    • reactionType — texto antes do \n no primeiro div filho
    • username — texto do <a> no primeiro div filho
    • userProfileUrlhref do <a>
    • userAvatarUrlsrc do primeiro <img>
    • date — texto do div.fs-xs, normalizado para data completa
  4. Extrair metadados do artigo (título da página, URL atual)
  5. Montar JSON estruturado:
{
  "articleUrl": "https://dev.to/danielhe4rt/...",
  "articleSlug": "data-engineering-101-...",
  "extractedAt": "2026-03-18T...",
  "totalReactions": 222,
  "reactions": [
    {
      "type": "readinglist",
      "username": "Daniel Reis",
      "userProfileUrl": "https://dev.to/danielhe4rt",
      "userAvatarUrl": "https://media2.dev.to/...",
      "date": "Mar 19"
    }
  ],
  "summary": {
    "like": 45,
    "unicorn": 30,
    "readinglist": 80,
    "fire": 20,
    "raised_hands": 15,
    "exploding_head": 32
  }
}
  1. Copiar automaticamente para o clipboard via navigator.clipboard.writeText()
  2. Log no console com resumo (total, contagem por tipo)

Fluxo de uso para automação em massa

┌─────────────────────────────────┐
│  Abrir: /meu-post/stats        │
│  (logado no dev.to)             │
├─────────────────────────────────┤
│  Colar script no Console        │
│  → Extrai reactions             │
│  → Copia JSON pro clipboard     │
│  → Mostra resumo no console     │
├─────────────────────────────────┤
│  Repetir para cada artigo       │
│  ou salvar como snippet do      │
│  Chrome DevTools                │
└─────────────────────────────────┘

Expected Behavior

Happy Path

  • Given o usuário está logado no dev.to e na página /article-slug/stats
  • Then o script extrai todas as reações, monta JSON, copia pro clipboard e loga resumo

Edge Cases

  • Given a página não tem seção "Reactions History" (artigo sem reações)

  • Then o script loga mensagem de aviso e retorna JSON com reactions: []

  • Given data no formato do ano anterior (ex: "Dec 26 '25")

  • Then o script mantém a data como string original (sem tentar parsear, para evitar bugs de timezone)

  • Given o usuário não está na página de stats

  • Then o script loga erro informativo: "Execute este script na página /stats de um artigo do dev.to"


Script

(() => {
  // Validação: está na página certa?
  if (!window.location.pathname.endsWith('/stats')) {
    console.error('❌ Execute este script na página /stats de um artigo do dev.to');
    return;
  }

  // Encontrar a seção "Reactions History"
  const headers = document.querySelectorAll('h2');
  let rhHeader = null;
  headers.forEach(h => {
    if (h.textContent.trim() === 'Reactions History') rhHeader = h;
  });

  if (!rhHeader) {
    const result = {
      articleUrl: window.location.href.replace('/stats', ''),
      articleSlug: window.location.pathname.split('/').filter(Boolean).pop(),
      extractedAt: new Date().toISOString(),
      totalReactions: 0,
      reactions: [],
      summary: {}
    };
    console.warn('⚠️ Nenhuma seção "Reactions History" encontrada.');
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  const container = rhHeader.parentElement;
  const entries = container.querySelectorAll('.fs-sm.py-2.flex.items-center');

  const reactions = [];
  const summary = {};

  entries.forEach(entry => {
    const imgs = entry.querySelectorAll('img');
    const rightSide = entry.querySelector('.flex-1');
    if (!rightSide) return;

    const infoDivs = rightSide.children;

    // Tipo de reação
    const rawText = infoDivs[0]?.textContent.trim() || '';
    const reactionType = rawText.split('\n')[0].trim();

    // Usuário
    const link = infoDivs[0]?.querySelector('a');
    const username = link?.textContent.trim() || 'unknown';
    const userProfileUrl = link?.href || '';

    // Avatar
    const userAvatarUrl = imgs[0]?.src || '';

    // Data
    const date = infoDivs[1]?.textContent.trim() || '';

    reactions.push({
      type: reactionType,
      username,
      userProfileUrl,
      userAvatarUrl,
      date
    });

    // Contagem por tipo
    summary[reactionType] = (summary[reactionType] || 0) + 1;
  });

  const result = {
    articleUrl: window.location.href.replace('/stats', ''),
    articleSlug: window.location.pathname.split('/').filter(Boolean).pop(),
    extractedAt: new Date().toISOString(),
    totalReactions: reactions.length,
    reactions,
    summary
  };

  // Copiar para clipboard
  navigator.clipboard.writeText(JSON.stringify(result, null, 2)).then(() => {
    console.log('✅ JSON copiado para o clipboard!');
  }).catch(() => {
    console.warn('⚠️ Não foi possível copiar para o clipboard. JSON abaixo:');
  });

  // Log no console
  console.log(`📊 ${result.articleSlug}`);
  console.log(`   Total: ${result.totalReactions} reações`);
  Object.entries(result.summary).forEach(([type, count]) => {
    console.log(`   ${type}: ${count}`);
  });
  console.log(JSON.stringify(result, null, 2));

  return result;
})();

Verificação

  1. Abrir a página https://dev.to/danielhe4rt/data-engineering-101-a-real-beginners-approach-25a8/stats
  2. Colar o script no console
  3. Verificar que o JSON foi copiado para o clipboard (Ctrl+V em um editor)
  4. Confirmar que totalReactions bate com o número exibido na página
  5. Confirmar que o summary soma corretamente

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions