image

Acesse bootcamps ilimitados e +750 cursos pra sempre

70
%OFF
Article image

DS

Davidson Silva10/06/2026 10:16
Compartilhe

Como construí uma extensão Chrome que transforma seu LinkedIn num currículo ATS em segundos

    Como construí uma extensão Chrome que transforma seu LinkedIn num currículo ATS em segundos

    Algumas semanas atrás, vi um post aqui no LinkedIn que viralizou. Centenas de pessoas nos comentários pedindo o modelo de currículo ATS que a autora tinha usado como exemplo. Ela não respondeu a ninguém.

    Aquilo ficou na minha cabeça. E virou código.

    O problema que quis resolver

    Sistemas ATS (Applicant Tracking Systems) são softwares usados por empresas para fazer a primeira triagem de currículos automaticamente — antes de qualquer olho humano ver o seu. Um currículo com tabelas, colunas, ícones decorativos ou fontes não-padrão simplesmente falha nessa triagem. O robô não consegue ler.

    O problema: a maioria das pessoas tem todo o seu histórico profissional já organizado no LinkedIn. Mas transformar isso num CV limpo, linear e legível por máquina ainda é um processo manual, chato e propenso a erros.

    A ideia foi simples: ler o perfil diretamente da página do LinkedIn e gerar o CV na hora. 100% local, sem mandar nada para servidor nenhum.

    A stack escolhida

    WXT + React + TypeScript

    O WXT é um framework moderno para desenvolvimento de extensões Chrome/Firefox. Ele resolve uma das maiores dores do desenvolvimento de extensões: o boilerplate do Manifest V3.

    Com WXT você trabalha com uma estrutura de projeto próxima ao Vite, com hot reload nativo durante o desenvolvimento, suporte a TypeScript out-of-the-box e abstração dos entry points da extensão (background, content scripts, side panel).

    // wxt.config.ts
    export default defineConfig({
    manifest: {
      name: 'LinkedIn CV Generator',
      permissions: ['sidePanel', 'storage', 'tabs'],
      host_permissions: ['https://www.linkedin.com/*'],
    },
    })
    

    Material UI v5 com MD3 tokens

    A interface usa MUI v5 com design tokens do Material Design 3 — o mesmo sistema visual do Android moderno. Isso garante consistência visual sem reinventar a roda para uma extensão onde a UI precisa ser compacta e funcional.

    Clean Architecture

    Esse foi o ponto mais importante da arquitetura. A extensão está dividida em quatro camadas:

    src/
    ├── domain/          # Entidades e interfaces (Profile, CVSection, JobMatch)
    ├── application/     # Use cases (ExtractProfile, GenerateCV, AnalyzeJobMatch)
    ├── infrastructure/  # Implementações concretas (LinkedInScraper, PDFExporter)
    └── presentation/    # Componentes React (SidePanel, CVPreview, MatchScore)
    

    Essa separação tem um benefício direto: o domínio não sabe nada sobre o DOM do LinkedIn. Se o LinkedIn mudar a estrutura HTML (o que acontece com frequência), só a camada de infraestrutura precisa ser atualizada. Os use cases e a UI não são tocados.

    O coração da extensão: o scraper do LinkedIn

    O maior desafio técnico foi extrair dados de forma confiável de uma página que não foi feita para isso.

    Content Script vs. Side Panel

    A extensão usa dois contextos diferentes:

    • Content Script — roda no contexto da página do LinkedIn, tem acesso ao DOM
    • Side Panel — roda em contexto isolado (como um iframe), não tem acesso direto ao DOM

    A comunicação entre eles usa a API de mensagens do Chrome:

    // Content script — lê o DOM e responde
    chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (message.type === 'EXTRACT_PROFILE') {
      const profile = LinkedInScraper.extract()
      sendResponse({ success: true, data: profile })
    }
    return true // mantém o canal aberto para resposta assíncrona
    })
    
    // Side panel — solicita a extração
    const response = await chrome.tabs.sendMessage(tabId, {
    type: 'EXTRACT_PROFILE'
    })
    

    Estratégia de scraping

    O LinkedIn renderiza conteúdo dinamicamente e usa class names gerados (ex: artdeco-entity-lockup__title). A estratégia adotada foi usar seletores semânticos e atributos de dados em vez de class names voláteis:

    // Frágil — vai quebrar quando o LinkedIn mudar o CSS
    document.querySelector('.pv-text-details__left-panel h1')
    
    // Mais estável — usa estrutura semântica
    document.querySelector('h1[data-anonymize="person-name"]') 
    ?? document.querySelector('section.pv-top-card h1')
    

    Para seções como experiências e formação, o scraper percorre listas de elementos com múltiplos seletores alternativos em cascata — se o primeiro falhar, tenta o próximo.

    Cache com chrome.storage

    Após a extração, o perfil é salvo localmente:

    await chrome.storage.local.set({
    cachedProfile: profile,
    extractedAt: Date.now(),
    })
    

    Isso evita que o usuário precise reabrir o perfil toda vez. A extensão exibe a data da última extração e oferece um botão "Extrair Novamente" quando o dado está em cache.

    Geração do currículo

    Template HTML puro

    O CV é gerado como HTML estático — sem frameworks, sem dependências. Isso garante que o arquivo exportado seja portátil e abra em qualquer browser.

    A estrutura segue as melhores práticas ATS:

    • Layout linear de uma coluna
    • Sem tabelas para estruturar o conteúdo
    • Sem imagens decorativas
    • Fonte system-safe (Arial, sem serifa)
    • Hierarquia semântica com <h1>, <h2>, <h3>
    function generateHTMLResume(profile: Profile, sections: SectionConfig): string {
    return `
      <!DOCTYPE html>
      <html lang="pt-BR">
      <head>
        <meta charset="UTF-8">
        <style>${ATS_SAFE_CSS}</style>
      </head>
      <body>
        <header>
          <h1>${profile.name}</h1>
          <p>${profile.headline}</p>
          ${profile.contact ? renderContact(profile.contact) : ''}
        </header>
        ${sections.experiences ? renderExperiences(profile.experiences) : ''}
        ${sections.education ? renderEducation(profile.education) : ''}
        ${sections.skills ? renderSkills(profile.skills) : ''}
        ${sections.certifications ? renderCertifications(profile.certifications) : ''}
        ${sections.languages ? renderLanguages(profile.languages) : ''}
      </body>
      </html>
    `
    }
    

    Exportação PDF

    O PDF é gerado via window.print() com uma media query @print otimizada — sem bibliotecas externas como jsPDF ou Puppeteer. Simples, leve e funciona em qualquer SO.

    function downloadAsPDF(htmlContent: string) {
    const printWindow = window.open('', '_blank')
    printWindow.document.write(htmlContent)
    printWindow.document.close()
    printWindow.print()
    }
    

    Job Match: compatibilidade com a vaga

    Essa foi a feature mais interessante de construir. Quando o usuário está numa página de vaga (linkedin.com/jobs/view/...), a extensão extrai automaticamente a descrição da vaga e compara com o perfil em cache.

    Algoritmo de matching por keywords

    O matching não usa LLM nem API externa. É um algoritmo de interseção de keywords categorizadas:

    function analyzeJobMatch(profile: Profile, jobDescription: string): MatchResult {
    const jobKeywords = extractKeywords(jobDescription)
    const profileKeywords = extractKeywords(profileToText(profile))
    
    const matched = jobKeywords.filter(k => profileKeywords.includes(k))
    const missing = jobKeywords.filter(k => !profileKeywords.includes(k))
    
    const score = Math.round((matched.length / jobKeywords.length) * 100)
    
    return {
      score,
      matched,
      missing,
      recommendations: generateRecommendations(missing),
    }
    }
    

    As keywords são categorizadas por tipo (linguagens, frameworks, ferramentas, soft skills) e as recomendações são geradas com base na categoria da keyword faltante:

    "Destaque sua experiência em linguagens de programação citando kotlin"

    Simples, direto, sem custo de API.

    Testes com Vitest

    A suíte de testes cobre principalmente os use cases e o domínio:

    // Teste do use case de extração
    describe('ExtractProfileUseCase', () => {
    it('should normalize empty skills to empty array', () => {
      const rawProfile = { ...mockProfile, skills: null }
      const result = extractProfileUseCase.execute(rawProfile)
      expect(result.skills).toEqual([])
    })
    })
    

    O scraper em si é testado com fixtures de HTML — snapshots de páginas reais do LinkedIn com dados anonimizados.

    O que aprendi

    1. Clean Architecture em extensões Chrome vale a pena — a separação de camadas salvou várias vezes quando o LinkedIn mudou o HTML e só o scraper precisou ser atualizado.

    2. WXT é genuinamente bom — se você ainda está escrevendo extensões na mão com manifest.json puro, experimente o WXT. O DX é muito superior.

    3. Não subestime o scraping defensivo — o LinkedIn é agressivo em mudar class names. Múltiplos seletores em cascata + testes com fixtures são obrigatórios.

    4. PDF via window.print() é subestimado — para casos de uso simples como um CV, evita uma dependência pesada e funciona perfeitamente.

    Instale e teste

    A extensão está disponível gratuitamente na Chrome Web Store, sem cadastro, sem API key, sem nenhum dado saindo do seu navegador.

    🔗 LinkedIn CV Generator — Chrome Web Store

    Se você tiver sugestões, comenta aqui. 👇

    Stack: WXT · React · TypeScript · Material UI v5 · Clean Architecture · Vitest

    Compartilhe
    Recomendados para você
    Bootcamp Corpay - Back-end do Zero a Prática
    GFT - Fundamentos de Cloud com AWS
    Bootcamp Bradesco - GenAI, Dados & Cyber
    Comentários (0)