Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,116 changes: 1,116 additions & 0 deletions ABRIR-PRS-CANDIDATE.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions GUIA-LINEAR-GITHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |

Expand Down Expand Up @@ -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: <https://linear.app/tatame/team/PAV/all>

---

Expand Down
12 changes: 12 additions & 0 deletions SCRAPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <cargo>` 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:
Expand Down
85 changes: 72 additions & 13 deletions scraper-go/internal/classifier/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import (
)

type familyScore struct {
family string
score int
family string
score int
titlePriority int
evidence string
}

type sourceClassificationStats struct {
Expand All @@ -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)

Expand All @@ -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
}
Expand All @@ -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"
}
Expand All @@ -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...),
}
}

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions scraper-go/internal/classifier/classify_batch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
184 changes: 184 additions & 0 deletions scraper-go/internal/classifier/product_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading