From 37285e09194c92230581fc5a28f2f4ea04e9ae33 Mon Sep 17 00:00:00 2001 From: hltav Date: Tue, 6 Oct 2026 20:41:56 -0300 Subject: [PATCH] =?UTF-8?q?feat(PAV-122):=20adiciona=20classifica=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20produto=20e=20product=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ABRIR-PRS-CANDIDATE.md | 1116 +++++++++++++++++ GUIA-LINEAR-GITHUB.md | 4 +- SCRAPER.md | 12 + scraper-go/internal/classifier/classifier.go | 85 +- .../classifier/classify_batch_test.go | 6 +- .../internal/classifier/product_test.go | 184 +++ scraper-go/internal/classifier/taxonomy.go | 50 + scraper-go/internal/keywords/generator.go | 77 +- scraper-go/internal/keywords/generator.json | 37 +- scraper-go/internal/keywords/keywords.json | 24 +- scraper-go/internal/keywords/product_test.go | 95 ++ scraper-go/internal/pipeline/pipeline.go | 11 +- scraper-go/internal/pipeline/product_test.go | 70 ++ 13 files changed, 1734 insertions(+), 37 deletions(-) create mode 100644 ABRIR-PRS-CANDIDATE.md create mode 100644 scraper-go/internal/classifier/product_test.go create mode 100644 scraper-go/internal/keywords/product_test.go create mode 100644 scraper-go/internal/pipeline/product_test.go diff --git a/ABRIR-PRS-CANDIDATE.md b/ABRIR-PRS-CANDIDATE.md new file mode 100644 index 00000000..e5a9388b --- /dev/null +++ b/ABRIR-PRS-CANDIDATE.md @@ -0,0 +1,1116 @@ +--- +name: abrir-pr-jobs-scraper +description: Use quando o usuario pedir para abrir PR, criar PR, enviar PR, submeter PR, fazer pull request, ou similar no projeto Jobs Scraper Global +--- + +# Abrir PR Padronizado — Jobs Scraper Global + +Skill para criar Pull Requests padronizados no projeto **Jobs Scraper Global**, integrando Git, GitHub e Linear e produzindo uma descrição técnica proporcional à complexidade da alteração. + +O objetivo desta skill é garantir que todo PR: + +- esteja vinculado corretamente à task do Linear; +- respeite o fluxo de fork do projeto; +- seja comparado contra a versão atual de `upstream/develop`; +- tenha validações compatíveis com os módulos realmente alterados; +- apresente uma descrição útil para revisão técnica; +- não dependa apenas das mensagens de commit para explicar a entrega; +- seja criado somente após preview e confirmação do usuário; +- preserve rastreabilidade, segurança e clareza operacional. + +--- + +## Sumário + +1. [Princípios gerais](#princípios-gerais) +2. [Fluxo resumido](#fluxo-resumido) +3. [Pré-requisitos](#pré-requisitos) +4. [Passo 0 — Verificar pré-requisitos](#passo-0--verificar-pré-requisitos) +5. [Passo 1 — Identificar a task do Linear](#passo-1--identificar-a-task-do-linear) +6. [Passo 2 — Buscar dados da task](#passo-2--buscar-dados-da-task) +7. [Passo 3 — Verificar Git, branch, commits e PR existente](#passo-3--verificar-git-branch-commits-e-pr-existente) +8. [Passo 4 — Analisar as alterações](#passo-4--analisar-as-alterações) +9. [Passo 5 — Executar testes e validações](#passo-5--executar-testes-e-validações) +10. [Passo 6 — Gerar a documentação do PR](#passo-6--gerar-a-documentação-do-pr) +11. [Passo 7 — Montar e mostrar o preview](#passo-7--montar-e-mostrar-o-preview) +12. [Passo 8 — Confirmar ou editar](#passo-8--confirmar-ou-editar) +13. [Passo 9 — Criar o PR](#passo-9--criar-o-pr) +14. [Regras para escrever a descrição](#regras-para-escrever-a-descrição) +15. [Como adaptar o PR à complexidade](#como-adaptar-o-pr-à-complexidade) +16. [Erros comuns](#erros-comuns) +17. [Modelo padrão de PR](#modelo-padrão-de-pr) +18. [Exemplo preenchido](#exemplo-preenchido) + +--- + +## Princípios gerais + +Ao executar esta skill, siga estes princípios: + +1. **Não invente informações.** + - Não marque testes como executados se não foram executados. + - Não declare impacto inexistente sem analisar o diff. + - Não invente arquivos, migrations, riscos, comandos ou resultados. + +2. **A descrição do PR deve refletir o estado final da implementação.** + - Não transforme apenas mensagens de commit em texto. + - Use task do Linear, diff, arquivos alterados, código final e resultados de validação. + +3. **A documentação deve ser proporcional à mudança.** + - Alterações pequenas devem gerar PRs concisos. + - Alterações arquiteturais, de infraestrutura, contratos, banco ou múltiplos módulos exigem mais contexto. + +4. **O usuário sempre vê o preview antes da criação.** + +5. **Testes falhando não bloqueiam automaticamente o PR.** + - Mostre os resultados com clareza. + - Explique se a falha aparenta estar relacionada ou não à alteração. + - O usuário decide se deseja prosseguir. + +6. **O PR deve partir do fork do desenvolvedor para o upstream.** + +7. **O identificador da task e o link do Linear não devem ser removidos quando a integração Linear ↔ GitHub depender deles.** + +--- + +## Fluxo resumido + +Ao abrir um PR: + +1. Verifique as ferramentas necessárias. +2. Identifique a branch atual. +3. Extraia o código `PAV-XX`, se existir. +4. Confirme a task com o usuário. +5. Consulte o Linear quando disponível. +6. Atualize a referência de `upstream/develop`. +7. Verifique commits à frente de `develop`. +8. Verifique se já existe PR aberto para a branch. +9. Analise o diff e identifique módulos afetados. +10. Execute as validações adequadas às alterações. +11. Gere a documentação do PR com base no estado final do código. +12. Monte o título e o corpo completos. +13. Mostre o preview. +14. Aguarde confirmação do usuário. +15. Faça push da branch, se necessário. +16. Crie o PR do fork para `Cla-Code-Community/candidate:develop`. +17. Retorne o link do PR criado. + +--- + +## Pré-requisitos + +Antes de iniciar, verifique as ferramentas relevantes. + +| Ferramenta | Como verificar | Se ausente | +| --- | --- | --- | +| Git | `git --version` | Informar que Git não está instalado e parar | +| GitHub CLI | `gh --version` | Informar que o GitHub CLI não está instalado e parar | +| Linear MCP | Verificar ferramentas `mcp__linear-server__*` | Tentar seguir com dados manuais da task, quando viável | +| npm | `npm --version` | Exigir somente se módulos Node forem afetados | +| Go | `go version` | Exigir somente se módulos Go forem afetados | + +### Regra de degradação do Linear + +A ausência do MCP do Linear não precisa bloquear automaticamente a geração do PR. + +Se o Linear não estiver disponível: + +1. informe claramente a limitação; +2. tente obter do usuário: + - código da task; + - título; + - URL; +3. prossiga somente se houver informação suficiente para manter a rastreabilidade. + +Se a integração automática Linear ↔ GitHub depender obrigatoriamente do link e ele não estiver disponível, informe isso no preview. + +--- + +## Passo 0 — Verificar pré-requisitos + +Execute as verificações básicas: + +```bash +git --version +gh --version +``` + +Descubra os módulos afetados antes de exigir ferramentas específicas como npm ou Go. + +Não bloqueie a execução por ausência de uma ferramenta que não seja necessária para os arquivos alterados. + +--- + +## Passo 1 — Identificar a task do Linear + +1. Obter a branch atual: + +```bash +git branch --show-current +``` + +1. Tentar extrair o padrão: + +```text +PAV-\d+ +``` + +A busca deve ser case insensitive. + +1. Se encontrar um código `PAV-XX`, confirmar com o usuário: + +> A task desenvolvida foi a **PAV-XX**? + +1. Se o usuário confirmar, usar o código. + +2. Se negar, pedir o código correto. + +3. Se não houver código na branch, perguntar: + +> Qual é o código da task no Linear? Ex.: PAV-42 + +Não assumir silenciosamente que o código encontrado na branch é o correto. + +--- + +## Passo 2 — Buscar dados da task + +Quando o MCP do Linear estiver disponível: + +1. usar `mcp__linear-server__get_issue`; +2. buscar a task pelo código; +3. extrair: + - identificador; + - título; + - URL; + - descrição, quando útil para compreender o objetivo; + - critérios de aceite, quando disponíveis. + +### Se a task não for encontrada + +Informar ao usuário. + +Permitir: + +- corrigir o código; +- informar título e URL manualmente; +- criar a task por outro fluxo, se houver skill própria para isso. + +Não inventar título ou URL. + +--- + +## Passo 3 — Verificar Git, branch, commits e PR existente + +> **Modelo de fork** +> +> O remote `origin` aponta para o fork do desenvolvedor. +> +> O remote `upstream` aponta para: +> +> `Cla-Code-Community/candidate` +> +> O PR deve ser criado do fork para o upstream. + +### 3.1 Identificar os remotes + +```bash +git remote -v +``` + +Confirme que: + +- `origin` é o fork; +- `upstream` é o repositório principal. + +### 3.2 Detectar o owner do fork + +Não use `gh repo view` para descobrir o owner do fork. + +Use: + +```bash +FORK_OWNER=$(git remote get-url origin | sed -E 's#.*[/:]([^/]+)/[^/]+(\.git)?$#\1#') +``` + +### 3.3 Atualizar `upstream/develop` + +```bash +git fetch upstream develop +``` + +### 3.4 Verificar commits à frente + +```bash +git log upstream/develop..HEAD --oneline +``` + +Se não houver commits: + +> Esta branch não tem commits à frente de `upstream/develop`. Não há mudanças para abrir PR. + +Parar a execução. + +### 3.5 Verificar PR existente + +```bash +gh pr list \ + --head "$FORK_OWNER:$(git branch --show-current)" \ + --repo Cla-Code-Community/candidate \ + --state open +``` + +Se já existir PR aberto: + +- mostrar número e link; +- perguntar se o usuário deseja atualizar o PR existente; +- não criar um PR duplicado sem confirmação explícita. + +--- + +## Passo 4 — Analisar as alterações + +Antes de escrever a descrição do PR, analise a implementação. + +### 4.1 Obter diff + +```bash +git diff upstream/develop..HEAD +``` + +Para visão resumida: + +```bash +git diff --stat upstream/develop..HEAD +``` + +Para lista de arquivos: + +```bash +git diff --name-status upstream/develop..HEAD +``` + +### 4.2 Obter commits + +```bash +git log upstream/develop..HEAD --oneline +``` + +### 4.3 Identificar o escopo real + +Determine: + +- quais módulos foram alterados; +- quais funcionalidades foram criadas ou corrigidas; +- se houve alteração de API ou contrato; +- se houve alteração de banco ou migration; +- se houve alteração de configuração; +- se houve alteração de infraestrutura; +- se houve alteração de segurança/autorização; +- se houve alteração de dados pessoais ou privacidade; +- se houve documentação atualizada; +- se existem riscos operacionais; +- se há necessidade de rollback especial. + +### 4.4 Não usar commits como única fonte + +As mensagens de commit ajudam a compreender o histórico, mas a descrição do PR deve refletir o **estado final do diff**. + +--- + +## Passo 5 — Executar testes e validações + +As validações devem ser escolhidas com base nos módulos afetados e nas instruções já existentes no repositório. + +### 5.1 Regra geral + +Antes de executar comandos, procure orientações em arquivos como: + +- `AGENTS.md`; +- `README.md`; +- `CONTRIBUTING.md`; +- documentação do módulo; +- `package.json`; +- Makefiles; +- scripts do projeto; +- workflows de CI; +- documentação específica do processor/backend/frontend. + +### 5.2 Não executar suítes irrelevantes por padrão + +Não rode automaticamente todos os testes do monorepo se: + +- a alteração estiver isolada; +- existirem testes focados suficientes; +- o repositório documentar uma validação específica. + +### 5.3 Exemplos de validação Node + +Quando aplicável: + +```bash +npm test +npm run typecheck +npm run build +npm run lint +``` + +Use somente scripts realmente existentes. + +### 5.4 Exemplos de validação Go + +Quando aplicável: + +```bash +go test ./... +go build ./... +go vet ./... +``` + +Use validações mais focadas quando a alteração estiver isolada e isso fizer sentido. + +### 5.5 Validações Git + +Quando aplicável: + +```bash +git diff --check +``` + +### 5.6 Consolidar resultados + +Exemplos: + +```text +Backend tests — PASS +Frontend typecheck — PASS +Processor tests — PASS +git diff --check — PASS +``` + +ou: + +```text +Backend tests — FAIL +5 testes falhando em módulo não relacionado à alteração +Frontend typecheck — PASS +``` + +### 5.7 Testes falhando + +Falhas não bloqueiam automaticamente a criação do PR. + +O preview deve mostrar: + +- comando executado; +- resultado; +- número de falhas, quando disponível; +- se há indícios de relação com a alteração; +- qualquer limitação de ambiente. + +Nunca apresentar teste não executado como `PASS`. + +--- + +## Passo 6 — Gerar a documentação do PR + +A descrição deve ser construída a partir de: + +- task do Linear; +- diff; +- commits; +- arquivos afetados; +- documentação do repositório; +- testes e validações executados; +- impactos observados. + +### 6.1 Objetivo + +Explique: + +- o problema resolvido; +- o resultado entregue; +- como a mudança se relaciona com a task. + +Evite descrição genérica como: + +> Ajustes diversos no sistema. + +Prefira: + +> Adiciona classificação de vagas por produto no processor e atualiza o pipeline para produzir a taxonomia esperada pela API. + +### 6.2 Resumo das alterações + +Liste mudanças concretas e verificáveis. + +Exemplo: + +```markdown +- Adiciona classificação de produto ao pipeline. +- Atualiza a taxonomia utilizada pelo classifier. +- Inclui testes focados para classificação e geração de keywords. +``` + +### 6.3 Arquivos ou módulos afetados + +Não é necessário listar todos os arquivos de um PR enorme. + +Priorize: + +- módulos; +- diretórios; +- arquivos arquiteturalmente importantes; +- contratos; +- configurações. + +### 6.4 Como testar + +Informe comandos realmente aplicáveis. + +Não invente comandos. + +### 6.5 Impactos + +Avalie somente o que fizer sentido: + +- banco/migrations; +- configuração; +- segurança; +- privacidade/LGPD; +- contratos/API; +- infraestrutura; +- documentação. + +### 6.6 Riscos + +Documente riscos reais da mudança. + +Não invente riscos apenas para preencher a seção. + +### 6.7 Rollback + +Para alteração simples, pode ser suficiente: + +> Reverter o commit/PR. + +Para mudanças com migration, contrato, infraestrutura ou dados, explique os passos necessários. + +### 6.8 Fora do escopo + +Use para explicitar itens próximos ao problema, mas deliberadamente não tratados nesta entrega. + +--- + +## Passo 7 — Montar e mostrar o preview + +> **Importante** +> +> O identificador `PAV-XX` no título e o link do Linear no corpo podem ser utilizados pela integração Linear ↔ GitHub para vincular o PR à task e movimentar o card automaticamente. +> +> Não remover essas referências quando forem necessárias à automação. + +### Título + +```text +PAV-XX: +``` + +### Target + +```text +Cla-Code-Community/candidate +base: develop +head: : +``` + +### Body + +Gerar o corpo conforme o [Modelo padrão de PR](#modelo-padrão-de-pr), adaptando as seções à complexidade da alteração. + +Mostrar o preview completo ao usuário. + +Perguntar: + +> O PR está correto? Confirma a criação? (s/n) + +--- + +## Passo 8 — Confirmar ou editar + +### Se o usuário confirmar + +Seguir para o Passo 9. + +### Se o usuário não confirmar + +1. perguntar o que deseja alterar; +2. ajustar somente o necessário; +3. gerar novo preview; +4. pedir confirmação novamente. + +Não criar o PR antes da confirmação. + +--- + +## Passo 9 — Criar o PR + +### 9.1 Verificar se a branch existe no fork + +```bash +git ls-remote --heads origin "$(git branch --show-current)" +``` + +Se não existir: + +> A branch ainda não foi enviada ao remote. Deseja fazer push agora? (s/n) + +Se confirmado: + +```bash +git push -u origin "$(git branch --show-current)" +``` + +Se negado, parar. + +### 9.2 Detectar owner do fork + +```bash +FORK_OWNER=$(git remote get-url origin | sed -E 's#.*[/:]([^/]+)/[^/]+(\.git)?$#\1#') +``` + +### 9.3 Criar o PR com GitHub CLI + +```bash +gh pr create \ + --repo Cla-Code-Community/candidate \ + --base develop \ + --head "$FORK_OWNER:$(git branch --show-current)" \ + --title "PAV-XX: " \ + --body "" +``` + +Para bodies grandes, prefira arquivo temporário quando apropriado: + +```bash +gh pr create \ + --repo Cla-Code-Community/candidate \ + --base develop \ + --head "$FORK_OWNER:$(git branch --show-current)" \ + --title "PAV-XX: " \ + --body-file /tmp/pr-body.md +``` + +### 9.4 Fallback se `gh pr create` falhar + +Erros típicos: + +```text +No commits between ... +Head sha can't be blank +Head ref must be a branch +``` + +Antes do fallback, valide a comparação diretamente na API: + +```bash +gh api \ + "repos/Cla-Code-Community/candidate/compare/develop...$FORK_OWNER:$(git branch --show-current)" \ + --jq '{status:.status, ahead_by:.ahead_by}' +``` + +Se retornar: + +```text +ahead_by > 0 +``` + +a branch está à frente de `develop` e o problema pode estar na montagem do `--head`. + +Criar via API REST. + +Exemplo conceitual: + +```bash +PAYLOAD=$(mktemp) + +cat > "$PAYLOAD" <", + "head": "$FORK_OWNER:$(git branch --show-current)", + "base": "develop", + "body": "" +} +JSON + +gh api \ + --method POST \ + repos/Cla-Code-Community/candidate/pulls \ + --input "$PAYLOAD" \ + --jq '{number:.number, url:.html_url}' + +rm -f "$PAYLOAD" +``` + +Ao montar JSON manualmente, garanta escaping correto do body. + +### 9.5 Concluir + +Retornar: + +- número do PR; +- título; +- base; +- head; +- URL. + +--- + +## Regras para escrever a descrição + +A descrição do PR deve ser proporcional à alteração. + +### Sempre fazer + +- relacionar a mudança com a task; +- analisar o diff final; +- explicar o resultado entregue; +- informar validações realmente executadas; +- destacar impactos relevantes; +- registrar limitações conhecidas; +- diferenciar claramente `PASS`, `FAIL` e `NOT RUN`. + +### Evitar + +- copiar mensagens de commit como descrição; +- escrever frases vagas; +- criar seções enormes sem conteúdo útil; +- preencher tudo com `N/A`; +- afirmar que não há impacto sem analisar; +- incluir secrets; +- incluir valores reais de `.env`; +- incluir tokens; +- incluir credenciais; +- incluir dados pessoais desnecessários. + +### Linguagem + +Use linguagem técnica, direta e revisável. + +Prefira: + +> Atualiza o classifier para produzir a categoria de produto antes da etapa de geração de keywords. + +Evite: + +> Foram feitas melhorias importantes no sistema para que tudo funcione melhor. + +--- + +## Como adaptar o PR à complexidade + +### PR pequeno + +Exemplos: + +- correção localizada; +- teste; +- ajuste de tipagem; +- pequena refatoração sem mudança de contrato. + +Seções normalmente suficientes: + +- Linear; +- Objetivo; +- Resumo das alterações; +- Como testar; +- Validação. + +### PR médio + +Exemplos: + +- feature em um módulo; +- mudança de comportamento; +- alteração em múltiplos arquivos relacionados. + +Adicionar quando relevante: + +- Arquivos/módulos afetados; +- Impactos; +- Riscos; +- Fora do escopo. + +### PR grande + +Exemplos: + +- arquitetura; +- infraestrutura; +- Docker; +- múltiplos serviços; +- API + processor; +- banco/migrations; +- autenticação/autorização; +- contratos; +- mudanças operacionais. + +Usar o modelo completo, incluindo: + +- objetivo; +- resumo; +- arquitetura, se necessário; +- módulos afetados; +- como testar; +- validações; +- banco/migrations; +- configuração; +- segurança; +- privacidade; +- documentação; +- riscos; +- rollback; +- fora do escopo; +- checklist. + +### Regra importante + +Seções sem relevância podem: + +- ser omitidas; ou +- ser resumidas em uma única linha. + +Não invente impacto para preencher template. + +--- + +## Erros comuns + +### Não atualizar `upstream/develop` + +Sempre execute: + +```bash +git fetch upstream develop +``` + +antes da comparação. + +### Não confirmar a task + +Mesmo que a branch contenha `PAV-XX`, confirme com o usuário. + +### Criar PR sem preview + +Nunca criar sem mostrar o conteúdo completo e receber confirmação. + +### Usar commits como descrição + +Commits explicam o histórico. + +O PR deve explicar a entrega final. + +### Rodar testes irrelevantes + +Escolha validações de acordo com os módulos afetados. + +### Bloquear automaticamente por teste falhando + +Mostre a falha e permita decisão consciente do usuário. + +### Marcar teste não executado como PASS + +Nunca. + +### Esquecer o modelo de fork + +O PR deve usar: + +```text +fork:branch -> Cla-Code-Community/candidate:develop +``` + +### Não usar `--head` + +No modelo de fork, informar explicitamente: + +```bash +--head "$FORK_OWNER:" +``` + +### Usar owner errado + +Derive o owner a partir de `origin`. + +Não dependa de `gh repo view` para isso. + +### Criar PR duplicado + +Verifique PR existente antes. + +### Incluir informações sensíveis + +Nunca incluir: + +- `.env` real; +- tokens; +- chaves; +- certificados; +- credenciais; +- secrets; +- dados pessoais desnecessários. + +--- + +## Modelo padrão de PR + +Use este modelo como referência canônica. + +Adapte as seções conforme a complexidade da alteração. + +### Título + +```text +PAV-XX: +``` + +### Corpo + +````markdown +## Linear + +Close PAV-XX + + + +## Objetivo + + + +## Resumo das alterações + +- +- +- + +## Arquivos / módulos afetados + +- `` +- `` + +## Arquitetura + + + +```text + +``` + +## Como testar + +```bash + +``` + +## Validação + +- [ ] Testes relevantes executados +- [ ] Build executado quando aplicável +- [ ] Typecheck executado quando aplicável +- [ ] Lint/vet executado quando aplicável +- [ ] `git diff --check` +- [ ] Documentação atualizada quando aplicável +- [ ] Nenhum segredo ou artefato sensível incluído + +### Resultados + +- `` — PASS / FAIL / NOT RUN +- `` — PASS / FAIL / NOT RUN + +## Impacto de banco / migrations + + + +## Impacto de configuração + + + +## Impacto de contratos / API + + + +## Impacto de segurança / privacidade + + + +## Impacto de documentação + + + +## Riscos + +- +- + +## Rollback + + + +## Fora do escopo + +- + +## Checklist + +- [ ] Task do Linear confirmada +- [ ] Diff revisado contra `upstream/develop` +- [ ] Testes/validações relevantes executados +- [ ] Nenhum segredo ou credencial incluído +- [ ] PR parte do fork correto +- [ ] Base do PR é `develop` +- [ ] Preview confirmado pelo usuário +```` + +--- + +## Exemplo preenchido + +> Exemplo ilustrativo. +> +> Os nomes abaixo servem apenas para demonstrar o nível de detalhe esperado. + +### Título + +```text +PAV-128: corrigir classificação de produto no processor +``` + +### Corpo + +````markdown +## Linear + +Close PAV-128 + +https://linear.app/exemplo/issue/PAV-128 + +## Objetivo + +Corrigir a classificação de produto no processor para que as vagas processadas sejam associadas à taxonomia esperada pela plataforma antes da geração de keywords. + +A alteração mantém o escopo no pipeline de processamento e não modifica autenticação ou gerenciamento de usuários. + +## Resumo das alterações + +- Atualiza a lógica de classificação de produto. +- Ajusta a taxonomia usada pelo classifier. +- Integra a classificação ao pipeline antes da geração de keywords. +- Adiciona testes focados para classifier, keywords e pipeline. +- Atualiza a documentação técnica relacionada ao processor. + +## Arquivos / módulos afetados + +- `scraper-go/internal/classifier/` +- `scraper-go/internal/keywords/` +- `scraper-go/internal/pipeline/` +- `SCRAPER.md` + +## Como testar + +```bash +cd scraper-go + +go test ./internal/classifier/... +go test ./internal/keywords/... +go test ./internal/pipeline/... +go test ./... +go build ./... +``` + +## Validação + +- [x] Testes focados executados +- [x] Suíte Go executada +- [x] Build Go executado +- [x] `git diff --check` +- [x] Documentação revisada +- [x] Nenhum segredo incluído + +### Resultados + +- `go test ./internal/classifier/...` — PASS +- `go test ./internal/keywords/...` — PASS +- `go test ./internal/pipeline/...` — PASS +- `go test ./...` — PASS +- `go build ./...` — PASS +- `git diff --check` — PASS + +## Impacto de banco / migrations + +Sem alteração de schema ou migrations. + +## Impacto de configuração + +Sem novas variáveis de ambiente. + +## Impacto de contratos / API + +Sem alteração de contrato público da API. + +A mudança ocorre no processamento interno das vagas. + +## Impacto de segurança / privacidade + +Sem impacto relevante identificado. + +A alteração não adiciona autenticação, autorização, coleta de dados pessoais ou novos secrets. + +## Impacto de documentação + +Atualiza `SCRAPER.md` para refletir o fluxo de classificação utilizado pelo processor. + +## Riscos + +- Mudanças futuras na taxonomia podem exigir atualização dos testes de classificação. +- Regras excessivamente genéricas podem aumentar falsos positivos de classificação. + +## Rollback + +Reverter o PR restaura a lógica anterior de classificação. + +Não há migration ou alteração de dados persistidos que exija rollback adicional. + +## Fora do escopo + +- Alterações de autenticação. +- Alterações no frontend. +- Mudanças no contrato público da API. + +## Checklist + +- [x] Task do Linear confirmada +- [x] Diff revisado contra `upstream/develop` +- [x] Testes/validações relevantes executados +- [x] Nenhum segredo ou credencial incluído +- [x] PR parte do fork correto +- [x] Base do PR é `develop` +- [x] Preview confirmado pelo usuário +```` + +--- + +## Resultado esperado da skill + +Ao concluir, a skill deve ter produzido: + +1. task confirmada; +2. contexto atualizado de `upstream/develop`; +3. branch validada; +4. diff analisado; +5. testes e validações documentados; +6. descrição proporcional à complexidade; +7. preview aprovado; +8. PR criado do fork correto; +9. link final retornado ao usuário. + +A skill deve priorizar **clareza, rastreabilidade, revisão técnica e segurança**, sem transformar PRs simples em documentos excessivamente burocráticos. diff --git a/GUIA-LINEAR-GITHUB.md b/GUIA-LINEAR-GITHUB.md index 626371d8..9454867d 100644 --- a/GUIA-LINEAR-GITHUB.md +++ b/GUIA-LINEAR-GITHUB.md @@ -157,7 +157,7 @@ Esta é a parte que economiza seu tempo: uma vez que o card está vinculado (se | Ação no GitHub | Estado do card no Linear | | ------------------------------------------------------ | ------------------------ | -| **Branch criado** com o identificador do card | → **In Progress** | +| **Branch criado** com o identificador do card | → **In Progress** | | **Pull Request aberto** e vinculado ao card | → **In Review** | | **PR mergeado** (na `develop`) | → **Done** | @@ -228,7 +228,7 @@ Sim. Basta que todos referenciem o mesmo identificador `PAV-XX`. O **merge**. Abrir o PR leva o card para **In Review**; é o merge que o conclui em **Done**. **Onde vejo o board do time?** -Board oficial do PAV: https://linear.app/tatame/team/PAV/all +Board oficial do PAV: --- diff --git a/SCRAPER.md b/SCRAPER.md index 97468b3f..95d4e6f5 100644 --- a/SCRAPER.md +++ b/SCRAPER.md @@ -232,6 +232,18 @@ O mecanismo é fail-closed: se o Valkey não confirmar a aquisição, nenhum ada O token proprietário nunca é gravado no estado operacional nem nos logs. Um `runId` independente identifica a execução para observabilidade sem expor a credencial usada pelos scripts de renovação e liberação. +### Classificação de Produto e Design de Produto + +`product` reconhece gestão, análise, estratégia e operações de produtos digitais; `product_design` reconhece design, pesquisa e conteúdo de experiência digital. Ambas exigem cargo reconhecido no **título**, com limites de palavra e normalização de acentos, pontuação e espaços. O título original não é alterado. `Product Designer`, `Lead Product Designer` e `Product Design Manager` têm precedência de `product_design`; `Product Manager` e `Product Operations` pertencem a `product`. + +Os termos fortes do título valem 4 pontos cada. Descrição, competências e ferramentas presentes nos campos existentes apenas complementam a confiança, com no máximo 3 pontos adicionais; não habilitam essas famílias sem evidência no título. As regras e pontuações das famílias técnicas existentes permanecem as mesmas. `Reasons` mantém o formato de lista de strings, registra `titulo: ` e, quando aplicável, uma exclusão curta. Nenhum campo `skills` foi acrescentado. + +Produção industrial (`production`/`produção`), marketing, vendas, suporte e cargos de engenharia não habilitam `product`. Design gráfico, motion, moda, industrial, interiores e marketing não habilitam `product_design`. Um cargo técnico não vira Produto por mencionar Product Manager na descrição. + +Os cargos diretos em português e inglês constam de `keywords.json`, `generator.json` e do fallback interno. São preservados sem gerar combinações como `Jira Product Manager`, `Figma Product Owner` ou `SQL UX Designer`. O limite continua sendo **200 combinações adicionais**; as seeds fornecidas são preservadas, normalizadas e deduplicadas. Providers `catalog` e `batch` continuam recebendo uma tarefa por fonte, e providers `keyword` uma tarefa por consulta. + +Nesta etapa, as novas famílias não emitem índices `scraper:jobs:family:product` ou `scraper:jobs:family:product_design`, nem aliases de keyword derivados desses nomes de família. Os cargos continuam pesquisáveis pelas keywords diretas, usando o contrato de indexação existente. Filtros, cache keys e schema permanecem inalterados. + ### Processamento em lotes após a coleta Depois da coleta, o pipeline processa vagas em etapas com filas limitadas: diff --git a/scraper-go/internal/classifier/classifier.go b/scraper-go/internal/classifier/classifier.go index 01a4777a..57fa1105 100644 --- a/scraper-go/internal/classifier/classifier.go +++ b/scraper-go/internal/classifier/classifier.go @@ -11,8 +11,10 @@ import ( ) type familyScore struct { - family string - score int + family string + score int + titlePriority int + evidence string } type sourceClassificationStats struct { @@ -39,7 +41,7 @@ func Classify(job domain.Job) domain.Classification { job.Description, }, " ")) - scores := scoreFamilies(text) + scores, exclusions := scoreFamilies(text, normalizeText(job.Title)) technologies := detectTechnologies(text) seniority := detectSeniority(text) @@ -61,11 +63,14 @@ func Classify(job domain.Job) domain.Classification { Seniority: seniority, InScope: false, Confidence: 0, - Reasons: []string{"nenhuma familia reconhecida"}, + Reasons: append([]string{"nenhuma familia reconhecida"}, exclusions...), } } sort.Slice(scores, func(i, j int) bool { + if scores[i].titlePriority != scores[j].titlePriority { + return scores[i].titlePriority > scores[j].titlePriority + } if scores[i].score == scores[j].score { return scores[i].family < scores[j].family } @@ -86,6 +91,9 @@ func Classify(job domain.Job) domain.Classification { confidence := math.Min(0.99, 0.35+(float64(primary.score)*0.08)) reason := "classificacao local por titulo descricao tecnologias" + if primary.evidence != "" { + reason = "titulo: " + primary.evidence + } if primary.score < 2 { reason = "score abaixo do minimo" } @@ -97,7 +105,7 @@ func Classify(job domain.Job) domain.Classification { Seniority: seniority, InScope: primary.score >= 2, Confidence: math.Round(confidence*100) / 100, - Reasons: []string{reason}, + Reasons: append([]string{reason}, exclusions...), } } @@ -318,34 +326,85 @@ func topCountLabels(values map[string]int, limit int) []string { return out } -func scoreFamilies(text string) []familyScore { +func scoreFamilies(text, title string) ([]familyScore, []string) { scores := make([]familyScore, 0, len(familyRules)) + var exclusions []string for _, rule := range familyRules { score := 0 + evidence := "" + strongText := text + if rule.TitleRequired { + strongText = title + blocked := false + for _, term := range rule.NegativeTerms { + if containsWholePhrase(title, term) { + if hasTitleFamilyContext(title, rule.Family) { + exclusions = append(exclusions, "exclusao "+rule.Family+": "+term) + } + blocked = true + break + } + } + if blocked { + continue + } + } for _, term := range rule.StrongTerms { - if containsTokenOrPhrase(text, term) { + var matches bool + if rule.TitleRequired { + matches = containsWholePhrase(strongText, term) + } else { + matches = containsTokenOrPhrase(strongText, term) + } + if matches { score += 4 + if rule.TitleRequired && evidence == "" { + evidence = term + } } } + if rule.TitleRequired && evidence == "" { + continue + } + complementary := 0 for _, term := range rule.TechnologyTerms { if containsTokenOrPhrase(text, term) { - score++ + complementary++ } } - for _, term := range rule.NegativeTerms { - if containsTokenOrPhrase(text, term) { - score -= 3 + if rule.TitleRequired { + complementary = min(complementary, 3) + } + score += complementary + if !rule.TitleRequired { + for _, term := range rule.NegativeTerms { + if containsTokenOrPhrase(text, term) { + score -= 3 + } } } if score > 0 { - scores = append(scores, familyScore{family: rule.Family, score: score}) + scores = append(scores, familyScore{family: rule.Family, score: score, titlePriority: rule.TitlePriority, evidence: evidence}) } } - return scores + return scores, exclusions +} + +// Both ends of multi-word titles must be whole words: product != production, +// product manager != product management, and designer != designership. +func containsWholePhrase(text, term string) bool { + return strings.Contains(" "+text+" ", " "+normalizeText(term)+" ") +} + +func hasTitleFamilyContext(title, family string) bool { + if family == "product" { + return containsAny(title, "product", "produto", "produtos", "production", "producao") + } + return containsAny(title, "designer", "design", "ux", "ui") } func detectTechnologies(text string) []string { diff --git a/scraper-go/internal/classifier/classify_batch_test.go b/scraper-go/internal/classifier/classify_batch_test.go index 1f4bebc9..5e871385 100644 --- a/scraper-go/internal/classifier/classify_batch_test.go +++ b/scraper-go/internal/classifier/classify_batch_test.go @@ -42,7 +42,7 @@ func TestClassifyJobsMatchesIndividualClassify(t *testing.T) { } } -func TestCurrentFamiliesUnchanged(t *testing.T) { +func TestCurrentFamiliesPreservedAndProductFamiliesAdded(t *testing.T) { got := make([]string, 0, len(familyRules)) for _, rule := range familyRules { got = append(got, rule.Family) @@ -60,7 +60,7 @@ func TestCurrentFamiliesUnchanged(t *testing.T) { "security", "leadership", "software", + "product", + "product_design", }, got) - assert.NotContains(t, got, "product") - assert.NotContains(t, got, "product_design") } diff --git a/scraper-go/internal/classifier/product_test.go b/scraper-go/internal/classifier/product_test.go new file mode 100644 index 00000000..ae8a41e9 --- /dev/null +++ b/scraper-go/internal/classifier/product_test.go @@ -0,0 +1,184 @@ +package classifier + +import ( + "strings" + "testing" + + "github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClassifyProductTitles(t *testing.T) { + titles := []string{ + "Product Manager", "Senior Product Manager", "Product Owner", "Technical Product Manager", + "Associate Product Manager", "Growth Product Manager", "Product Analyst", "Product Operations Analyst", + "Product Operations", "Product Lead", "Head of Product", "Platform Product Manager", + "Data Product Manager", "AI Product Manager", "Gerente de Produto", "Analista de Produto", + "Especialista de Produto", "Líder de Produto", "Operações de Produto", "Diretor de Produto", + } + for _, title := range titles { + t.Run(title, func(t *testing.T) { + // A technical description must not override a recognized product title. + job := domain.Job{Title: title, Description: "APIs microservices backend java SQL React mobile data engineer DevOps"} + got := Classify(job) + assert.Equal(t, "product", got.PrimaryFamily) + assert.True(t, got.InScope) + require.NotEmpty(t, got.Reasons) + assert.True(t, strings.HasPrefix(got.Reasons[0], "titulo: ")) + assert.NotContains(t, got.Reasons[0], job.Description) + assert.Equal(t, title, job.Title) + assert.Equal(t, got, Classify(job)) + }) + } +} + +func TestClassifyProductDesignTitlesAndPrecedence(t *testing.T) { + titles := []string{ + "Product Designer", "Lead Product Designer", "Product Design Manager", "UX Designer", "UI Designer", + "UX/UI Designer", "UX Researcher", "UX Writer", "Content Designer", "Service Designer", + "Interaction Designer", "Design System Designer", "Designer de Produto", "Pesquisadora UX", + "Designer de Interação", "Product Designer / Product Manager", "Lead Product Designer (Product Owner)", + "Product Owner / UX Writer", + } + for _, title := range titles { + t.Run(title, func(t *testing.T) { + job := domain.Job{Title: title, Description: "Product Manager product strategy Jira SQL python APIs backend"} + got := Classify(job) + assert.Equal(t, "product_design", got.PrimaryFamily) + assert.True(t, got.InScope) + assert.True(t, strings.HasPrefix(got.Reasons[0], "titulo: ")) + assert.Equal(t, title, job.Title) + assert.Equal(t, got, Classify(job)) + }) + } +} + +func TestProductNormalizationAndWordBoundaries(t *testing.T) { + cases := []struct{ title, family string }{ + {" sENIOR PRODUCT-MANAGER (Remote) ", "product"}, + {"PRODUCT / OWNER", "product"}, + {"Gerente (de) Produto", "product"}, + {"LÍDER—DE—PRODUTO", "product"}, + {"OPERAÇÕES / DE / PRODUTO", "product"}, + {"UX/UI-DESIGNER", "product_design"}, + {"UI / UX Designer", "product_design"}, + {"Designer (de) Interação!", "product_design"}, + {"Product Managerial Assistant", "other"}, + {"Nonproduct Manager", "other"}, + {"Product Designership", "other"}, + {"Production Manager", "other"}, + } + for _, tc := range cases { + t.Run(tc.title, func(t *testing.T) { + got := Classify(domain.Job{Title: tc.title}) + assert.Equal(t, tc.family, got.PrimaryFamily) + assert.Equal(t, tc.family != "other", got.InScope) + }) + } +} + +func TestProductFalsePositivesKeepExistingTechnicalFamilies(t *testing.T) { + cases := []struct{ title, family string }{ + {"Production Manager", "other"}, {"Production Engineer", "other"}, {"Gerente de Produção", "other"}, + {"Product Marketing Manager", "other"}, {"Product Sales Manager", "other"}, + {"Product Support Engineer", "other"}, {"Product Engineer", "other"}, + {"Product Security Engineer", "security"}, {"Product Data Engineer", "data"}, + {"Backend Engineer, Product Platform", "backend"}, {"Software Engineer - Product", "software"}, + } + for _, tc := range cases { + t.Run(tc.title, func(t *testing.T) { + got := Classify(domain.Job{Title: tc.title, Description: "Product Manager Product Designer roadmap discovery Figma Jira"}) + assert.Equal(t, tc.family, got.PrimaryFamily) + assert.NotEqual(t, "product", got.PrimaryFamily) + assert.NotEqual(t, "product_design", got.PrimaryFamily) + require.Greater(t, len(got.Reasons), 1) + assert.Contains(t, strings.Join(got.Reasons, "; "), "exclusao product:") + }) + } +} + +func TestProductDesignFalsePositives(t *testing.T) { + titles := []string{ + "Graphic Designer", "Motion Designer", "Fashion Designer", "Industrial Designer", "Interior Designer", + "Marketing Designer", "Designer Gráfico", "Designer de Moda", "Graphic Designer / Product Designer", + } + for _, title := range titles { + t.Run(title, func(t *testing.T) { + got := Classify(domain.Job{Title: title, Description: "Product Designer UX Designer UX Researcher Figma Sketch prototyping"}) + assert.Equal(t, "other", got.PrimaryFamily) + assert.False(t, got.InScope) + assert.Contains(t, strings.Join(got.Reasons, "; "), "exclusao product_design:") + }) + } +} + +func TestProductDescriptionAndToolsCannotDefineFamily(t *testing.T) { + for _, title := range []string{"", "Consultant", "Analista", "Designer"} { + got := Classify(domain.Job{Title: title, Description: "Product Manager Product Designer UX Designer roadmap discovery Figma Jira"}) + assert.Equal(t, "other", got.PrimaryFamily, title) + assert.False(t, got.InScope, title) + } + for _, title := range []string{"Product", "Produto", "Design", "Jira", "Figma", "SQL"} { + got := Classify(domain.Job{Title: title, Description: "Jira Figma SQL Sketch Amplitude Mixpanel"}) + assert.NotEqual(t, "product", got.PrimaryFamily, title) + assert.NotEqual(t, "product_design", got.PrimaryFamily, title) + assert.False(t, got.InScope, title) + } + for _, tc := range []struct{ title, tools, family string }{ + {"Product Manager", "Jira SQL Amplitude", "product"}, + {"UX Designer", "Figma Sketch Adobe XD", "product_design"}, + } { + plain := Classify(domain.Job{Title: tc.title}) + supported := Classify(domain.Job{Title: tc.title, Description: tc.tools}) + assert.Equal(t, tc.family, plain.PrimaryFamily) + assert.Equal(t, plain.PrimaryFamily, supported.PrimaryFamily) + assert.Greater(t, supported.Confidence, plain.Confidence) + assert.Equal(t, plain.Reasons, supported.Reasons) + } +} + +func TestExistingFamiliesIgnoreProductContext(t *testing.T) { + cases := []struct{ title, family string }{ + {"Backend Engineer", "backend"}, {"Frontend Engineer", "frontend"}, {"Mobile Engineer", "mobile"}, + {"Fullstack Developer", "fullstack"}, {"Platform Engineer", "platform"}, {"DevOps Engineer", "devops"}, + {"Data Engineer", "data"}, {"Data Analyst - Product Strategy", "data"}, + {"QA Engineer", "qa"}, {"Security Engineer", "security"}, + {"Engineering Manager", "leadership"}, {"Software Engineer", "software"}, + } + for _, tc := range cases { + t.Run(tc.family, func(t *testing.T) { + plain := Classify(domain.Job{Title: tc.title}) + context := Classify(domain.Job{Title: tc.title, Description: "Work with the Product Manager on product discovery and product strategy."}) + assert.Equal(t, tc.family, context.PrimaryFamily) + assert.Equal(t, plain.Confidence, context.Confidence) + assert.Equal(t, plain.RelatedFamilies, context.RelatedFamilies) + assert.Equal(t, plain.InScope, context.InScope) + }) + } +} + +func TestProductFallbackAndOpeningBlocks(t *testing.T) { + for _, title := range []string{"", "Unknown Role", "Designer", "Product"} { + got := Classify(domain.Job{Title: title}) + assert.Equal(t, "other", got.PrimaryFamily) + assert.False(t, got.InScope) + assert.Equal(t, []string{"nenhuma familia reconhecida"}, got.Reasons) + } + for _, title := range []string{"Banco de Talentos - Product Manager", "Talent Pool - Product Designer"} { + got := Classify(domain.Job{Title: title}) + assert.Equal(t, "other", got.PrimaryFamily) + assert.False(t, got.InScope) + assert.Contains(t, got.Reasons[0], "vaga nao concreta") + } +} + +func TestProductBatchKeepsTitlesAndMatchesIndividualOutput(t *testing.T) { + jobs := []domain.Job{{Title: "Product Manager"}, {Title: "Designer de Interação"}, {Title: "Graphic Designer"}} + got := ClassifyJobs(jobs) + require.Len(t, got, 2) + for i := range got { + assert.Equal(t, jobs[i].Title, got[i].Title) + assert.Equal(t, Classify(jobs[i]), *got[i].Classification) + } +} diff --git a/scraper-go/internal/classifier/taxonomy.go b/scraper-go/internal/classifier/taxonomy.go index 52c843fc..17b5c9d8 100644 --- a/scraper-go/internal/classifier/taxonomy.go +++ b/scraper-go/internal/classifier/taxonomy.go @@ -5,6 +5,9 @@ type familyRule struct { StrongTerms []string TechnologyTerms []string NegativeTerms []string + // TitleRequired families cannot be inferred from descriptions or tools. + TitleRequired bool + TitlePriority int } var familyRules = []familyRule{ @@ -123,6 +126,53 @@ var familyRules = []familyRule{ "desenvolvedor de software", "application developer", }, }, + { + Family: "product", + TitleRequired: true, + TitlePriority: 1, + StrongTerms: []string{ + "product manager", "product owner", "product analyst", "product operations", + "product lead", "head of product", "product strategist", "product strategy manager", + "gerente de produto", "gerente de produtos", "analista de produto", "analista de produtos", + "especialista de produto", "especialista de produtos", "lider de produto", "lider de produtos", + "operacoes de produto", "operacoes de produtos", "diretor de produto", "diretor de produtos", + "estrategista de produto", "gestor de produto", "gestora de produto", + }, + TechnologyTerms: []string{ + "roadmap", "discovery", "product strategy", "product metrics", "estrategia de produto", + "metricas de produto", "jira", "sql", "amplitude", "mixpanel", + }, + NegativeTerms: []string{ + "production", "producao", "marketing", "sales", "vendas", "support", "suporte", + "engineer", "engineering", "developer", "engenheiro", "engenheira", "desenvolvedor", + "desenvolvedora", "designer", "design", + }, + }, + { + Family: "product_design", + TitleRequired: true, + TitlePriority: 2, + StrongTerms: []string{ + "product designer", "product design manager", "product design lead", + "ux designer", "ui designer", "ux ui designer", "ui ux designer", + "ux researcher", "ux writer", "content designer", "service designer", + "interaction designer", "design system designer", "design systems designer", + "designer de produto", "designer de produtos", "pesquisador ux", "pesquisadora ux", + "designer de interacao", "designer de servico", "designer de servicos", + "designer de conteudo", "pesquisador de experiencia do usuario", "pesquisadora de experiencia do usuario", + }, + TechnologyTerms: []string{ + "figma", "sketch", "adobe xd", "usability", "usabilidade", "user research", + "pesquisa com usuarios", "prototyping", "prototipacao", "design system", + }, + NegativeTerms: []string{ + "graphic designer", "motion designer", "fashion designer", "industrial designer", + "interior designer", "marketing designer", "designer grafico", "designer grafica", + "designer de moda", "designer industrial", "designer de interiores", "designer de marketing", + "production", "producao", "engineer", "developer", "engenheiro", "engenheira", + "desenvolvedor", "desenvolvedora", + }, + }, } var technologyAliases = map[string][]string{ diff --git a/scraper-go/internal/keywords/generator.go b/scraper-go/internal/keywords/generator.go index 7498c7fa..6d166f86 100644 --- a/scraper-go/internal/keywords/generator.go +++ b/scraper-go/internal/keywords/generator.go @@ -12,21 +12,23 @@ const maxGeneratedCombinations = 200 type keywordCategory string const ( - categoryBackend keywordCategory = "backend" - categoryFrontend keywordCategory = "frontend" - categoryFullstack keywordCategory = "fullstack" - categoryMobile keywordCategory = "mobile" - categoryData keywordCategory = "data" - categoryDevOps keywordCategory = "devops" - categoryPlatform keywordCategory = "platform" - categoryQA keywordCategory = "qa" - categorySecurity keywordCategory = "security" - categoryCRM keywordCategory = "crm" - categoryERP keywordCategory = "erp" - categoryIntegration keywordCategory = "integration" - categoryBlockchain keywordCategory = "blockchain" - categoryEmbedded keywordCategory = "embedded" - categoryGame keywordCategory = "game" + categoryBackend keywordCategory = "backend" + categoryFrontend keywordCategory = "frontend" + categoryFullstack keywordCategory = "fullstack" + categoryMobile keywordCategory = "mobile" + categoryData keywordCategory = "data" + categoryDevOps keywordCategory = "devops" + categoryPlatform keywordCategory = "platform" + categoryQA keywordCategory = "qa" + categorySecurity keywordCategory = "security" + categoryCRM keywordCategory = "crm" + categoryERP keywordCategory = "erp" + categoryIntegration keywordCategory = "integration" + categoryBlockchain keywordCategory = "blockchain" + categoryEmbedded keywordCategory = "embedded" + categoryGame keywordCategory = "game" + categoryProduct keywordCategory = "product" + categoryProductDesign keywordCategory = "product_design" ) type titleTerm struct { @@ -96,6 +98,41 @@ var defaultKeywordTitles = []titleTerm{ {name: "embedded engineer", categories: []keywordCategory{categoryEmbedded}}, {name: "firmware engineer", categories: []keywordCategory{categoryEmbedded}}, {name: "game developer", categories: []keywordCategory{categoryGame}}, + {name: "product manager", categories: []keywordCategory{categoryProduct}}, + {name: "senior product manager", categories: []keywordCategory{categoryProduct}}, + {name: "product owner", categories: []keywordCategory{categoryProduct}}, + {name: "technical product manager", categories: []keywordCategory{categoryProduct}}, + {name: "associate product manager", categories: []keywordCategory{categoryProduct}}, + {name: "growth product manager", categories: []keywordCategory{categoryProduct}}, + {name: "product analyst", categories: []keywordCategory{categoryProduct}}, + {name: "product operations analyst", categories: []keywordCategory{categoryProduct}}, + {name: "product operations", categories: []keywordCategory{categoryProduct}}, + {name: "product lead", categories: []keywordCategory{categoryProduct}}, + {name: "head of product", categories: []keywordCategory{categoryProduct}}, + {name: "platform product manager", categories: []keywordCategory{categoryProduct}}, + {name: "data product manager", categories: []keywordCategory{categoryProduct}}, + {name: "ai product manager", categories: []keywordCategory{categoryProduct}}, + {name: "gerente de produto", categories: []keywordCategory{categoryProduct}}, + {name: "analista de produto", categories: []keywordCategory{categoryProduct}}, + {name: "especialista de produto", categories: []keywordCategory{categoryProduct}}, + {name: "líder de produto", categories: []keywordCategory{categoryProduct}}, + {name: "operações de produto", categories: []keywordCategory{categoryProduct}}, + {name: "diretor de produto", categories: []keywordCategory{categoryProduct}}, + {name: "product designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "lead product designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "product design manager", categories: []keywordCategory{categoryProductDesign}}, + {name: "ux designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "ui designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "ux/ui designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "ux researcher", categories: []keywordCategory{categoryProductDesign}}, + {name: "ux writer", categories: []keywordCategory{categoryProductDesign}}, + {name: "content designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "service designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "interaction designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "design system designer", categories: []keywordCategory{categoryProductDesign}}, + {name: "designer de produto", categories: []keywordCategory{categoryProductDesign}}, + {name: "pesquisadora ux", categories: []keywordCategory{categoryProductDesign}}, + {name: "designer de interação", categories: []keywordCategory{categoryProductDesign}}, } var defaultKeywordTechnologies = []technologyTerm{ @@ -158,6 +195,10 @@ var defaultKeywordTechnologies = []technologyTerm{ } func GenerateSearchKeywords(raw []string) []string { + return generateSearchKeywords(raw, loadGeneratorConfig()) +} + +func generateSearchKeywords(raw []string, cfg generatorData) []string { base := NormalizeKeywords(raw) result := make([]string, 0, len(base)+maxGeneratedCombinations) seen := make(map[string]struct{}, len(base)+maxGeneratedCombinations) @@ -166,12 +207,16 @@ func GenerateSearchKeywords(raw []string) []string { addKeyword(&result, seen, keyword) } - cfg := loadGeneratorConfig() titles := matchingTitles(base, cfg.titles) technologies := matchingTechnologies(base, cfg.technologies) evaluatedCombinations := 0 for _, title := range titles { + // Product roles are direct queries, even if a custom generator assigns + // tools the same category. Never synthesize "Figma Product Owner". + if categoriesOverlap(title.categories, []keywordCategory{categoryProduct, categoryProductDesign}) { + continue + } for _, tech := range technologies { if !categoriesOverlap(title.categories, tech.categories) { continue diff --git a/scraper-go/internal/keywords/generator.json b/scraper-go/internal/keywords/generator.json index 09ae1685..02436b96 100644 --- a/scraper-go/internal/keywords/generator.json +++ b/scraper-go/internal/keywords/generator.json @@ -35,7 +35,42 @@ { "name": "blockchain developer", "categories": ["blockchain"] }, { "name": "embedded engineer", "categories": ["embedded"] }, { "name": "firmware engineer", "categories": ["embedded"] }, - { "name": "game developer", "categories": ["game"] } + { "name": "game developer", "categories": ["game"] }, + { "name": "product manager", "categories": ["product"] }, + { "name": "senior product manager", "categories": ["product"] }, + { "name": "product owner", "categories": ["product"] }, + { "name": "technical product manager", "categories": ["product"] }, + { "name": "associate product manager", "categories": ["product"] }, + { "name": "growth product manager", "categories": ["product"] }, + { "name": "product analyst", "categories": ["product"] }, + { "name": "product operations analyst", "categories": ["product"] }, + { "name": "product operations", "categories": ["product"] }, + { "name": "product lead", "categories": ["product"] }, + { "name": "head of product", "categories": ["product"] }, + { "name": "platform product manager", "categories": ["product"] }, + { "name": "data product manager", "categories": ["product"] }, + { "name": "ai product manager", "categories": ["product"] }, + { "name": "gerente de produto", "categories": ["product"] }, + { "name": "analista de produto", "categories": ["product"] }, + { "name": "especialista de produto", "categories": ["product"] }, + { "name": "líder de produto", "categories": ["product"] }, + { "name": "operações de produto", "categories": ["product"] }, + { "name": "diretor de produto", "categories": ["product"] }, + { "name": "product designer", "categories": ["product_design"] }, + { "name": "lead product designer", "categories": ["product_design"] }, + { "name": "product design manager", "categories": ["product_design"] }, + { "name": "ux designer", "categories": ["product_design"] }, + { "name": "ui designer", "categories": ["product_design"] }, + { "name": "ux/ui designer", "categories": ["product_design"] }, + { "name": "ux researcher", "categories": ["product_design"] }, + { "name": "ux writer", "categories": ["product_design"] }, + { "name": "content designer", "categories": ["product_design"] }, + { "name": "service designer", "categories": ["product_design"] }, + { "name": "interaction designer", "categories": ["product_design"] }, + { "name": "design system designer", "categories": ["product_design"] }, + { "name": "designer de produto", "categories": ["product_design"] }, + { "name": "pesquisadora ux", "categories": ["product_design"] }, + { "name": "designer de interação", "categories": ["product_design"] } ], "technologies": [ { "name": "react", "categories": ["frontend", "fullstack"] }, diff --git a/scraper-go/internal/keywords/keywords.json b/scraper-go/internal/keywords/keywords.json index 1ea28a82..112c69c3 100644 --- a/scraper-go/internal/keywords/keywords.json +++ b/scraper-go/internal/keywords/keywords.json @@ -222,6 +222,28 @@ "scrum", "kanban", "lean", - "xp" + "xp", + "senior product manager", + "associate product manager", + "growth product manager", + "product operations analyst", + "product operations", + "product lead", + "head of product", + "platform product manager", + "data product manager", + "ai product manager", + "gerente de produto", + "analista de produto", + "especialista de produto", + "líder de produto", + "operações de produto", + "diretor de produto", + "lead product designer", + "product design manager", + "content designer", + "designer de produto", + "pesquisadora ux", + "designer de interação" ] } diff --git a/scraper-go/internal/keywords/product_test.go b/scraper-go/internal/keywords/product_test.go new file mode 100644 index 00000000..b3463c8b --- /dev/null +++ b/scraper-go/internal/keywords/product_test.go @@ -0,0 +1,95 @@ +package keywords + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readProductGenerator(t *testing.T) generatorData { + t.Helper() + raw, err := os.ReadFile("generator.json") + require.NoError(t, err) + var file generatorFile + require.NoError(t, json.Unmarshal(raw, &file)) + var cfg generatorData + for _, term := range file.Titles { + cfg.titles = append(cfg.titles, titleTerm{name: term.Name, categories: term.Categories}) + } + for _, term := range file.Technologies { + cfg.technologies = append(cfg.technologies, technologyTerm{name: term.Name, categories: term.Categories}) + } + return cfg +} + +func TestProductKeywordsFileAndFallbackHaveSameDirectRoles(t *testing.T) { + file := readProductGenerator(t) + raw, err := os.ReadFile("keywords.json") + require.NoError(t, err) + var keywords keywordsFile + require.NoError(t, json.Unmarshal(raw, &keywords)) + seeds := NormalizeKeywords(keywords.Keywords) + require.Equal(t, len(keywords.Keywords), len(seeds), "seeds must already be normalized and unique") + var direct []string + for _, title := range file.titles { + if categoriesOverlap(title.categories, []keywordCategory{categoryProduct, categoryProductDesign}) { + direct = append(direct, title.name) + assert.Contains(t, seeds, title.name) + } + } + require.Len(t, direct, 35) + var fallback []string + for _, title := range defaultKeywordTitles { + if categoriesOverlap(title.categories, []keywordCategory{categoryProduct, categoryProductDesign}) { + fallback = append(fallback, title.name) + } + } + assert.Equal(t, direct, fallback) + assert.Contains(t, direct, "product operations analyst") + assert.Contains(t, direct, "gerente de produto") + assert.Contains(t, direct, "líder de produto") + assert.Contains(t, direct, "product design manager") + assert.Contains(t, direct, "designer de interação") +} + +func TestProductKeywordsStayDirectWithToolsInSameCategory(t *testing.T) { + file := readProductGenerator(t) + fallback := generatorData{titles: defaultKeywordTitles, technologies: defaultKeywordTechnologies} + for _, cfg := range []generatorData{file, fallback} { + // Even explicit category overlap must never synthesize tool+role titles. + cfg.technologies = append(append([]technologyTerm(nil), cfg.technologies...), + technologyTerm{name: "jira", categories: []keywordCategory{categoryProduct}}, + technologyTerm{name: "figma", categories: []keywordCategory{categoryProduct, categoryProductDesign}}, + technologyTerm{name: "sql", categories: []keywordCategory{categoryProductDesign}}) + input := []string{" Product Manager ", "product owner", "UX Designer", "gerente de produto", "designer de produto", "Jira", "Figma", "SQL", "PRODUCT MANAGER"} + got := generateSearchKeywords(input, cfg) + assert.Equal(t, NormalizeKeywords(input), got) + assert.NotContains(t, got, "jira product manager") + assert.NotContains(t, got, "figma product owner") + assert.NotContains(t, got, "sql ux designer") + assert.Equal(t, got, generateSearchKeywords(got, cfg)) + assert.Equal(t, got, generateSearchKeywords(input, cfg)) + } +} + +func TestProductKeywordsDoNotChangeTechnicalCombinationsOrLimit(t *testing.T) { + for _, cfg := range []generatorData{readProductGenerator(t), {titles: defaultKeywordTitles, technologies: defaultKeywordTechnologies}} { + technical := []string{"backend developer", "frontend developer", "go", "react"} + before := generateSearchKeywords(technical, cfg) + input := append(append([]string(nil), technical...), "product manager", "ux designer", "figma", "jira") + got := generateSearchKeywords(input, cfg) + assert.Equal(t, NormalizeKeywords(input), got[:len(input)]) + assert.Equal(t, before[len(technical):], got[len(input):]) + assert.Equal(t, got, generateSearchKeywords(input, cfg)) + assert.LessOrEqual(t, len(got), len(NormalizeKeywords(input))+maxGeneratedCombinations) + assert.Equal(t, len(got), len(NormalizeKeywords(got))) + for _, keyword := range got[len(input):] { + assert.False(t, strings.Contains(keyword, "product")) + assert.False(t, strings.Contains(keyword, "designer")) + } + } +} diff --git a/scraper-go/internal/pipeline/pipeline.go b/scraper-go/internal/pipeline/pipeline.go index 9ff5e7b9..eceb2dd4 100644 --- a/scraper-go/internal/pipeline/pipeline.go +++ b/scraper-go/internal/pipeline/pipeline.go @@ -409,7 +409,7 @@ func classificationIndexKeys(job domain.Job) []string { values := make([]string, 0, 1+len(classification.RelatedFamilies)+len(classification.Technologies)) - if classification.PrimaryFamily != "" { + if indexedClassificationFamily(classification.PrimaryFamily) { normalized := normalizeIndexValue(classification.PrimaryFamily) if normalized != "" { values = append(values, @@ -419,6 +419,9 @@ func classificationIndexKeys(job domain.Job) []string { } } for _, family := range classification.RelatedFamilies { + if !indexedClassificationFamily(family) { + continue + } normalized := normalizeIndexValue(family) if normalized != "" { values = append(values, @@ -446,6 +449,12 @@ func classificationIndexKeys(job domain.Job) []string { return uniqueStrings(values) } +// Product classification is enabled before its dedicated family filters/indexes. +// Direct title keywords remain searchable through the existing keyword path. +func indexedClassificationFamily(family string) bool { + return family != "" && family != "product" && family != "product_design" +} + func structuredIndexKeys(job domain.Job) []string { values := map[string]string{ "level": inferLevel(job), diff --git a/scraper-go/internal/pipeline/product_test.go b/scraper-go/internal/pipeline/product_test.go new file mode 100644 index 00000000..af9ad1c6 --- /dev/null +++ b/scraper-go/internal/pipeline/product_test.go @@ -0,0 +1,70 @@ +package pipeline + +import ( + "context" + "testing" + + "github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/classifier" + "github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain" + "github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/keywords" + "github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type productBatchAdapter struct { + schedulerTestAdapter + calls int + keywords []string +} + +func (a *productBatchAdapter) SearchBatch(_ context.Context, keywords []string, _ domain.ScrapeRequest) ([]domain.Job, error) { + a.calls++ + a.keywords = append([]string(nil), keywords...) + return nil, nil +} + +func TestProductKeywordsRespectDiscoveryModes(t *testing.T) { + seed := keywords.GenerateSearchKeywords([]string{"product manager", "ux designer", "gerente de produto", "figma"}) + catalog := &schedulerCatalogAdapter{schedulerTestAdapter: schedulerTestAdapter{provider: ports.ProviderGreenhouse, mode: ports.DiscoveryCatalog}} + batch := &productBatchAdapter{schedulerTestAdapter: schedulerTestAdapter{provider: ports.ProviderLinkedIn, mode: ports.DiscoveryBatch}} + adapters := []ports.JobSource{catalog, batch, schedulerTestAdapter{provider: ports.ProviderAdzuna}} + queue := make(chan adapterTask, len(seed)+2) + produceTasks(context.Background(), queue, adapters, seed, newProviderRunStats(adapters, nil)) + counts := map[ports.DiscoveryMode]int{} + var keywordOrder []string + for task := range queue { + counts[task.mode]++ + if task.mode == ports.DiscoveryKeyword { + keywordOrder = append(keywordOrder, task.keywords[0]) + } else { + assert.Equal(t, seed, task.keywords) + } + } + assert.Equal(t, map[ports.DiscoveryMode]int{ports.DiscoveryCatalog: 1, ports.DiscoveryBatch: 1, ports.DiscoveryKeyword: len(seed)}, counts) + assert.Equal(t, seed, keywordOrder) + _, err := Run(context.Background(), adapters, domain.ScrapeRequest{Keywords: seed, MaxConcurrency: 1}) + require.NoError(t, err) + assert.Equal(t, 1, catalog.calls) + assert.Equal(t, 1, batch.calls) + assert.Equal(t, seed, catalog.keywords) + assert.Equal(t, seed, batch.keywords) +} + +func TestProductClassificationDoesNotCreateFamilyIndexes(t *testing.T) { + for _, title := range []string{"Product Manager", "Product Designer"} { + job := domain.Job{Title: title, Description: "SQL"} + classification := classifier.Classify(job) + require.True(t, classification.InScope) + job.Classification = &classification + keys := classificationIndexKeys(job) + assert.NotContains(t, keys, "scraper:jobs:family:product") + assert.NotContains(t, keys, "scraper:jobs:family:product_design") + assert.NotContains(t, keys, "scraper:jobs:keyword:product") + assert.NotContains(t, keys, "scraper:jobs:keyword:product design") + assert.Contains(t, invertedIndexKeys(job, []string{title}), "scraper:jobs:keyword:"+normalizeIndexValue(title)) + } + job := domain.Job{Classification: &domain.Classification{PrimaryFamily: "backend", RelatedFamilies: []string{"product", "product_design"}, InScope: true}} + assert.Contains(t, classificationIndexKeys(job), "scraper:jobs:family:backend") + assert.NotContains(t, classificationIndexKeys(job), "scraper:jobs:family:product") +}