Skip to content

[feature-request] DevTo Scrapping PoC #181

@danielhe4rt

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

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions