162 lines
4.3 KiB
TypeScript
162 lines
4.3 KiB
TypeScript
import type { Match, NormalizedJob } from "./types.ts";
|
|
|
|
const EU_COUNTRIES = [
|
|
"france",
|
|
"germany",
|
|
"spain",
|
|
"italy",
|
|
"portugal",
|
|
"netherlands",
|
|
"belgium",
|
|
"luxembourg",
|
|
"austria",
|
|
"switzerland",
|
|
"ireland",
|
|
"united kingdom",
|
|
"sweden",
|
|
"norway",
|
|
"denmark",
|
|
"finland",
|
|
"poland",
|
|
"czech",
|
|
"czechia",
|
|
"romania",
|
|
"hungary",
|
|
"greece",
|
|
"croatia",
|
|
"slovenia",
|
|
"slovakia",
|
|
"estonia",
|
|
"latvia",
|
|
"lithuania",
|
|
"bulgaria",
|
|
"europe",
|
|
"european",
|
|
"emea",
|
|
];
|
|
|
|
const AGENCY_DENY = [
|
|
"lemon.io",
|
|
"g2i",
|
|
"distantjob",
|
|
"talent sam",
|
|
"kdci",
|
|
"2am.tech",
|
|
"photon",
|
|
"turing",
|
|
"toptal",
|
|
"andela",
|
|
"telus digital",
|
|
"a.team",
|
|
"white cloak",
|
|
"creative chaos",
|
|
"dacodes",
|
|
"coalition technologies",
|
|
"jobs for humanity",
|
|
"jobsforlebanon",
|
|
"consulting1x1",
|
|
];
|
|
|
|
const JUNIOR = /\b(intern|internship|trainee|stage\b|apprentice|junior|entry-level|entry level|software engineer i\b)\b/i;
|
|
const WRONG_ROLE =
|
|
/\b(qa engineer|quality assurance|copywriter|marketing|sales|account executive|customer support|support engineer|devops|data engineer|scraping engineer|documentation engineer|ux designer|head of engineering|game developer)\b/i;
|
|
const WRONG_STACK =
|
|
/\b(reactjs|react\.js|\breact\b|next\.js|angular|ruby|rails|django|\.net|asp\.net|spring ?boot|\bjava\b|\bphp\b|laravel|symfony|kotlin|golang|flutter)\b/i;
|
|
const VUE = /\bvue(?:\.?js)?\b/i;
|
|
const NUXT = /\bnuxt(?:\.?js)?\b/i;
|
|
|
|
function tokens(location: string): string[] {
|
|
const parts = location
|
|
.toLowerCase()
|
|
.split(/[,/;|]+/)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
if (parts.length === 0 && location.trim()) return [location.toLowerCase().trim()];
|
|
return parts;
|
|
}
|
|
|
|
function isEuToken(token: string): boolean {
|
|
if (token === "uk" || token === "gb" || token === "eu") return true;
|
|
return EU_COUNTRIES.some((country) => token === country || token.startsWith(`${country} `));
|
|
}
|
|
|
|
function geoEligible(job: NormalizedJob): { ok: boolean; reason?: string; bonus: number } {
|
|
const loc = job.location.toLowerCase().trim();
|
|
if (!loc || loc === "worldwide" || loc === "anywhere" || loc === "remote") {
|
|
return { ok: true, bonus: 1, reason: "remote worldwide" };
|
|
}
|
|
|
|
const parts = tokens(job.location);
|
|
const euParts = parts.filter(isEuToken);
|
|
if (euParts.length === 0) return { ok: false, bonus: 0 };
|
|
|
|
const france = parts.some((part) => part === "france" || part.includes("france"));
|
|
return {
|
|
ok: true,
|
|
bonus: france ? 6 : 3,
|
|
reason: france ? "France" : "Europe",
|
|
};
|
|
}
|
|
|
|
function timezoneOk(job: NormalizedJob): boolean {
|
|
if (job.timezones.length === 0) return true;
|
|
return job.timezones.some((tz) => tz >= 0 && tz <= 3);
|
|
}
|
|
|
|
export function matchJob(job: NormalizedJob): Match | null {
|
|
const company = job.company.toLowerCase();
|
|
if (AGENCY_DENY.some((name) => company.includes(name))) return null;
|
|
|
|
const title = job.title;
|
|
if (JUNIOR.test(title) || WRONG_ROLE.test(title)) return null;
|
|
if (/\b(hybrid|présentiel|on-?site|short term|fluent ukrainian)\b/i.test(title)) return null;
|
|
if (!job.remote) return null;
|
|
if (!timezoneOk(job)) return null;
|
|
|
|
const geo = geoEligible(job);
|
|
if (!geo.ok) return null;
|
|
|
|
const vueTitle = VUE.test(title);
|
|
const nuxtTitle = NUXT.test(title) || /\bsidebase\b/i.test(title);
|
|
const conflictingTitle = WRONG_STACK.test(title) && !vueTitle && !nuxtTitle;
|
|
|
|
if (conflictingTitle) return null;
|
|
if (!vueTitle && !nuxtTitle) return null;
|
|
|
|
const body = `${job.excerpt} ${job.description}`.toLowerCase().slice(0, 4000);
|
|
const reasons: string[] = [];
|
|
let score = geo.bonus;
|
|
if (geo.reason) reasons.push(geo.reason);
|
|
|
|
if (nuxtTitle) {
|
|
score += 8;
|
|
reasons.push("Nuxt");
|
|
}
|
|
if (vueTitle) {
|
|
score += 7;
|
|
reasons.push("Vue");
|
|
}
|
|
if (/\btypescript\b/i.test(`${title} ${job.tags.join(" ")} ${body}`)) {
|
|
score += 2;
|
|
reasons.push("TypeScript");
|
|
}
|
|
if (/\bnode(?:\.?js)?\b/i.test(`${title} ${job.tags.join(" ")} ${body}`)) {
|
|
score += 2;
|
|
reasons.push("Node.js");
|
|
}
|
|
if (/\bfull[\s-]?stack\b/i.test(title)) {
|
|
score += 2;
|
|
reasons.push("Fullstack");
|
|
} else if (/\bfront[\s-]?end\b/i.test(title)) {
|
|
score += 1;
|
|
reasons.push("Frontend");
|
|
}
|
|
if (/\b(travel|booking|ndc|gds|fintech|b2b)\b/i.test(`${title} ${body}`)) {
|
|
score += 2;
|
|
reasons.push("domaine proche");
|
|
}
|
|
|
|
if (score < 8) return null;
|
|
return { job, score, reasons };
|
|
}
|