|
| 1 | +// Tracker deduplication is intentionally stricter than search ranking. A false |
| 2 | +// positive can delete a row or promote another role's status, so uncertain |
| 3 | +// titles remain distinct. |
| 4 | +const TOKEN_ALIASES = new Map([ |
| 5 | + ['backend', ['back', 'end']], |
| 6 | + ['dev', ['developer']], |
| 7 | + ['eng', ['engineer']], |
| 8 | + ['frontend', ['front', 'end']], |
| 9 | + ['fullstack', ['full', 'stack']], |
| 10 | + ['jr', ['junior']], |
| 11 | + ['mgr', ['manager']], |
| 12 | + ['ml', ['machine', 'learning']], |
| 13 | + ['programme', ['program']], |
| 14 | + ['snr', ['senior']], |
| 15 | + ['sr', ['senior']], |
| 16 | + ['sre', ['site', 'reliability', 'engineer']], |
| 17 | + ['swe', ['software', 'engineer']], |
| 18 | +]); |
| 19 | + |
| 20 | +const SENIORITY_TOKENS = new Set([ |
| 21 | + 'associate', |
| 22 | + 'distinguished', |
| 23 | + 'junior', |
| 24 | + 'lead', |
| 25 | + 'midlevel', |
| 26 | + 'principal', |
| 27 | + 'senior', |
| 28 | + 'staff', |
| 29 | +]); |
| 30 | +const ROLE_HEAD_TOKENS = new Set([ |
| 31 | + 'administrator', |
| 32 | + 'analyst', |
| 33 | + 'architect', |
| 34 | + 'attorney', |
| 35 | + 'consultant', |
| 36 | + 'coordinator', |
| 37 | + 'counsel', |
| 38 | + 'designer', |
| 39 | + 'developer', |
| 40 | + 'director', |
| 41 | + 'editor', |
| 42 | + 'engineer', |
| 43 | + 'executive', |
| 44 | + 'manager', |
| 45 | + 'officer', |
| 46 | + 'producer', |
| 47 | + 'recruiter', |
| 48 | + 'representative', |
| 49 | + 'researcher', |
| 50 | + 'scientist', |
| 51 | + 'specialist', |
| 52 | + 'strategist', |
| 53 | + 'technician', |
| 54 | + 'writer', |
| 55 | +]); |
| 56 | +const ROLE_FAMILY_PREFIX_TOKENS = new Set([ |
| 57 | + 'account', |
| 58 | + 'application', |
| 59 | + 'back', |
| 60 | + 'cloud', |
| 61 | + 'customer', |
| 62 | + 'data', |
| 63 | + 'end', |
| 64 | + 'front', |
| 65 | + 'full', |
| 66 | + 'general', |
| 67 | + 'infrastructure', |
| 68 | + 'learning', |
| 69 | + 'machine', |
| 70 | + 'mobile', |
| 71 | + 'platform', |
| 72 | + 'product', |
| 73 | + 'program', |
| 74 | + 'project', |
| 75 | + 'reliability', |
| 76 | + 'sales', |
| 77 | + 'security', |
| 78 | + 'site', |
| 79 | + 'software', |
| 80 | + 'solution', |
| 81 | + 'stack', |
| 82 | + 'success', |
| 83 | + 'system', |
| 84 | + 'technical', |
| 85 | + 'web', |
| 86 | +]); |
| 87 | + |
| 88 | +// This is deliberately an allowlist rather than a general stemmer. Substring |
| 89 | +// or broad suffix matching makes unrelated titles such as product/production |
| 90 | +// and data/database collide. |
| 91 | +const TOKEN_EQUIVALENTS = new Map([ |
| 92 | + ['developers', 'developer'], |
| 93 | + ['engineers', 'engineer'], |
| 94 | + ['managers', 'manager'], |
| 95 | + ['payments', 'payment'], |
| 96 | + ['platforms', 'platform'], |
| 97 | + ['products', 'product'], |
| 98 | + ['programmes', 'program'], |
| 99 | + ['programs', 'program'], |
| 100 | + ['projects', 'project'], |
| 101 | + ['services', 'service'], |
| 102 | + ['solutions', 'solution'], |
| 103 | + ['systems', 'system'], |
| 104 | +]); |
| 105 | + |
| 106 | +function normalizedRoleTokens(role) { |
| 107 | + const rawTokens = String(role || '') |
| 108 | + .normalize('NFKD') |
| 109 | + .replace(/[\u0300-\u036f]/g, '') |
| 110 | + .toLowerCase() |
| 111 | + .replace(/\bc\+\+(?=$|[^a-z0-9])/g, 'cplusplus') |
| 112 | + .replace(/\bc#(?=$|[^a-z0-9])/g, 'csharp') |
| 113 | + .replace(/\bf#(?=$|[^a-z0-9])/g, 'fsharp') |
| 114 | + .replace(/\.net\b/g, ' dotnet') |
| 115 | + .replace(/&/g, ' and ') |
| 116 | + .replace(/['\u2019]/g, '') |
| 117 | + .replace(/[^a-z0-9]+/g, ' ') |
| 118 | + .trim() |
| 119 | + .split(/\s+/) |
| 120 | + .filter(Boolean); |
| 121 | + const tokens = []; |
| 122 | + |
| 123 | + for (let index = 0; index < rawTokens.length; index++) { |
| 124 | + const token = rawTokens[index]; |
| 125 | + const next = rawTokens[index + 1]; |
| 126 | + if (token === 'entry' && next === 'level') { |
| 127 | + tokens.push('junior'); |
| 128 | + index++; |
| 129 | + continue; |
| 130 | + } |
| 131 | + if (token === 'mid' && next === 'level') { |
| 132 | + tokens.push('midlevel'); |
| 133 | + index++; |
| 134 | + continue; |
| 135 | + } |
| 136 | + |
| 137 | + tokens.push(...(TOKEN_ALIASES.get(token) || [token])); |
| 138 | + } |
| 139 | + |
| 140 | + for (let index = 0; index < tokens.length; index++) { |
| 141 | + tokens[index] = TOKEN_EQUIVALENTS.get(tokens[index]) || tokens[index]; |
| 142 | + } |
| 143 | + |
| 144 | + let seniorityLength = 0; |
| 145 | + while (seniorityLength < tokens.length && SENIORITY_TOKENS.has(tokens[seniorityLength])) { |
| 146 | + seniorityLength++; |
| 147 | + } |
| 148 | + |
| 149 | + if (seniorityLength > 0) { |
| 150 | + const tail = tokens.slice(seniorityLength); |
| 151 | + const headIndex = tail.findIndex((token) => ROLE_HEAD_TOKENS.has(token)); |
| 152 | + const clearlyPositional = headIndex >= 0 && tail |
| 153 | + .slice(0, headIndex) |
| 154 | + .every((token) => ROLE_FAMILY_PREFIX_TOKENS.has(token)); |
| 155 | + if (clearlyPositional) tokens.splice(0, seniorityLength); |
| 156 | + } |
| 157 | + |
| 158 | + return tokens; |
| 159 | +} |
| 160 | + |
| 161 | +export function trackerRoleIdentity(role) { |
| 162 | + const tokens = normalizedRoleTokens(role); |
| 163 | + return { |
| 164 | + key: tokens.join(':'), |
| 165 | + tokens, |
| 166 | + }; |
| 167 | +} |
| 168 | + |
| 169 | +export function trackerRolesMatch(leftRole, rightRole) { |
| 170 | + const left = normalizedRoleTokens(leftRole); |
| 171 | + const right = normalizedRoleTokens(rightRole); |
| 172 | + |
| 173 | + if (left.length === 0 || right.length === 0 || left.length !== right.length) { |
| 174 | + return false; |
| 175 | + } |
| 176 | + |
| 177 | + return left.every((token, index) => token === right[index]); |
| 178 | +} |
0 commit comments