Initial import of hire-me.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
delikesance
2026-09-04 20:08:12 +00:00
co-authored by Cursor
commit 9c734470b3
23 changed files with 2463 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { extractEmails } from "./extract-emails.ts";
import { generateGeminiJson } from "./gemini-client.ts";
export type EmailHit = {
email: string;
source: "description" | "website" | "gemini" | "guess";
domain?: string;
};
export type EmailCacheEntry = {
company: string;
domain: string | null;
emails: string[];
source: EmailHit["source"] | "none";
updatedAt: string;
};
const USER_AGENT =
"hire-me/0.1 (personal job outreach; +https://delikesance.cloud)";
const IGNORE_EMAILS = [
/example\./i,
/exemple\./i,
/sentry\.io/i,
/wixpress/i,
/noreply/i,
/no-reply/i,
/donotreply/i,
/mailer-daemon/i,
/github\.com/i,
/amazonaws\.com/i,
/googleusercontent/i,
];
const PRIORITY_LOCAL = [
"recruiting",
"recrutement",
"careers",
"jobs",
"talent",
"hiring",
"hr",
"people",
"join",
"work",
"apply",
"candidature",
"emploi",
];
const SCRAPE_PATHS = [
"",
"/contact",
"/contact-us",
"/contacts",
"/about",
"/about-us",
"/careers",
"/jobs",
"/recruiting",
"/recruitment",
"/fr/contact",
"/en/contact",
"/fr/carriere",
"/fr/carrieres",
"/company",
];
function normalizeCompany(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, " ");
}
function isIgnoredEmail(email: string): boolean {
return IGNORE_EMAILS.some((re) => re.test(email));
}
function scoreEmail(email: string): number {
const local = email.split("@")[0] ?? "";
const idx = PRIORITY_LOCAL.findIndex((p) => local === p || local.startsWith(`${p}.`) || local.startsWith(`${p}-`));
if (idx >= 0) return 100 - idx;
if (local === "contact" || local === "hello" || local === "info" || local === "contato") return 40;
return 10;
}
function rankEmails(emails: string[]): string[] {
return [...new Set(emails.map((e) => e.toLowerCase()).filter((e) => !isIgnoredEmail(e)))].sort(
(a, b) => scoreEmail(b) - scoreEmail(a) || a.localeCompare(b),
);
}
function extractDomainsFromText(...texts: string[]): string[] {
const found = new Set<string>();
const re = /https?:\/\/(?:www\.)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
for (const text of texts) {
for (const match of text.matchAll(re)) {
const host = match[1]?.toLowerCase();
if (!host) continue;
if (
/himalayas\.app$|remotive\.|remoteok\.|jobicy\.|arbeitnow\.|linkedin\.|google\.|facebook\.|twitter\.|github\.com$/.test(
host,
)
) {
continue;
}
found.add(host);
}
}
return [...found];
}
async function fetchText(url: string, timeoutMs = 10000): Promise<string | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT, Accept: "text/html,application/json" },
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) return null;
return await response.text();
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
export async function resolveCompanyDomain(company: string): Promise<string | null> {
const url = `https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(company)}`;
try {
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
});
if (!response.ok) return null;
const suggestions = (await response.json()) as Array<{ name?: string; domain?: string }>;
if (!suggestions.length) return null;
const exact = suggestions.find(
(s) => s.name?.toLowerCase() === company.toLowerCase() && s.domain,
);
return (exact?.domain ?? suggestions[0]?.domain ?? null)?.toLowerCase() ?? null;
} catch {
return null;
}
}
async function scrapeDomainEmails(domain: string): Promise<string[]> {
const found = new Set<string>();
for (const path of SCRAPE_PATHS) {
const html = await fetchText(`https://${domain}${path}`);
if (!html) continue;
for (const email of extractEmails(html)) {
if (email.endsWith(`@${domain}`) || email.split("@")[1]?.endsWith(`.${domain}`)) {
found.add(email);
} else if (!isIgnoredEmail(email) && scoreEmail(email) >= 40) {
// keep high-priority recruiting emails even on related domains (e.g. .com.br)
found.add(email);
}
}
}
return rankEmails([...found]);
}
async function geminiRecruitingEmail(
company: string,
domain: string | null,
): Promise<string | null> {
const prompt = `Trouve l'adresse email publique de recrutement / candidatures pour "${company}"${domain ? ` (domaine: ${domain})` : ""}.
Réponds UNIQUEMENT en JSON: {"email":"string|null","confidence":0-1}
Règles strictes:
- N'invente pas.
- Si confiance < 0.7, email=null.
- Préfère careers@, jobs@, recruiting@, recrutement@, talent@, hiring@ connus et publics.
- L'email doit appartenir au domaine de l'entreprise si connu.`;
try {
const { text } = await generateGeminiJson(prompt, {
temperature: 0.1,
maxOutputTokens: 200,
});
const parsed = JSON.parse(text) as { email?: string | null; confidence?: number };
const email = parsed.email?.trim().toLowerCase() ?? null;
if (!email || !email.includes("@") || (parsed.confidence ?? 0) < 0.7) return null;
if (isIgnoredEmail(email)) return null;
if (domain) {
const host = email.split("@")[1] ?? "";
if (host !== domain && !host.endsWith(`.${domain}`) && !domain.endsWith(`.${host}`)) {
const base = domain.split(".").slice(-2).join(".");
if (!host.endsWith(base)) return null;
}
}
return email;
} catch {
return null;
}
}
export async function discoverEmails(input: {
company: string;
description?: string;
excerpt?: string;
url?: string;
}): Promise<{ emails: string[]; domain: string | null; source: EmailHit["source"] | "none" }> {
const fromText = rankEmails(
extractEmails(input.description ?? "", input.excerpt ?? "", input.url ?? ""),
);
if (fromText.length) {
return { emails: fromText, domain: fromText[0]!.split("@")[1] ?? null, source: "description" };
}
const fromLinks = extractDomainsFromText(input.description ?? "", input.excerpt ?? "");
const clearbitDomain = await resolveCompanyDomain(input.company);
const domain = clearbitDomain ?? fromLinks[0] ?? null;
if (domain) {
const scraped = await scrapeDomainEmails(domain);
if (scraped.length) {
return { emails: scraped, domain, source: "website" };
}
}
const gemini = await geminiRecruitingEmail(input.company, domain);
if (gemini) {
return { emails: [gemini], domain: domain ?? gemini.split("@")[1] ?? null, source: "gemini" };
}
if (domain) {
const guesses = rankEmails(
["recruiting", "recrutement", "careers", "jobs", "talent", "hiring", "hr", "contact", "hello"].map(
(local) => `${local}@${domain}`,
),
);
// Keep only the top recruiting-style guess as last-resort automatic target.
const top = guesses[0];
if (top && scoreEmail(top) >= 90) {
return { emails: [top], domain, source: "guess" };
}
}
return { emails: [], domain, source: "none" };
}
export async function loadEmailCache(root: string): Promise<Record<string, EmailCacheEntry>> {
try {
return JSON.parse(await readFile(join(root, "data", "email-cache.json"), "utf8")) as Record<
string,
EmailCacheEntry
>;
} catch {
return {};
}
}
async function saveCache(root: string, cache: Record<string, EmailCacheEntry>): Promise<void> {
await mkdir(join(root, "data"), { recursive: true });
await writeFile(join(root, "data", "email-cache.json"), `${JSON.stringify(cache, null, 2)}\n`);
}
let cacheLock: Promise<void> = Promise.resolve();
function withCacheLock<T>(fn: () => Promise<T>): Promise<T> {
const run = cacheLock.then(fn, fn);
cacheLock = run.then(
() => undefined,
() => undefined,
);
return run;
}
export async function getCachedEmails(
root: string,
company: string,
): Promise<EmailCacheEntry | null> {
const cache = await loadEmailCache(root);
return cache[normalizeCompany(company)] ?? null;
}
export async function enrichOfferEmail(
root: string,
offer: {
company: string;
description?: string;
excerpt?: string;
url?: string;
},
options: { force?: boolean } = {},
): Promise<EmailCacheEntry> {
const key = normalizeCompany(offer.company);
const existing = await getCachedEmails(root, offer.company);
if (
!options.force &&
existing &&
existing.emails.length > 0 &&
Date.now() - Date.parse(existing.updatedAt) < 7 * 24 * 60 * 60 * 1000
) {
return existing;
}
const discovered = await discoverEmails(offer);
const entry: EmailCacheEntry = {
company: offer.company,
domain: discovered.domain,
emails: discovered.emails,
source: discovered.source,
updatedAt: new Date().toISOString(),
};
return withCacheLock(async () => {
const cache = await loadEmailCache(root);
const latest = cache[key];
if (
!options.force &&
latest &&
latest.emails.length > 0 &&
Date.now() - Date.parse(latest.updatedAt) < 7 * 24 * 60 * 60 * 1000
) {
return latest;
}
cache[key] = entry;
await saveCache(root, cache);
return entry;
});
}
export async function enrichAllOffers(
root: string,
offers: Array<{ company: string; description?: string; excerpt?: string; url?: string }>,
): Promise<void> {
const seen = new Set<string>();
const unique = offers.filter((offer) => {
const key = normalizeCompany(offer.company);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const concurrency = 3;
let index = 0;
async function worker() {
while (index < unique.length) {
const current = unique[index++];
if (!current) break;
try {
const result = await enrichOfferEmail(root, current);
console.log(
`email ${current.company}: ${result.emails[0] ?? "(none)"} [${result.source}]`,
);
} catch (error) {
console.error(`email ${current.company}:`, error);
}
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
}
+18
View File
@@ -0,0 +1,18 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnv } from "./env.ts";
import { enrichAllOffers } from "./discover-email.ts";
import { loadOffers } from "./offers.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
loadEnv(root);
const offers = await loadOffers(root);
console.log(`Enrichissement de ${offers.length} offres…`);
await enrichAllOffers(root, offers);
const refreshed = await loadOffers(root);
const withEmail = refreshed.filter((o) => o.emails.length);
console.log(`${withEmail.length}/${refreshed.length} avec email`);
for (const offer of withEmail) {
console.log(`- ${offer.company}: ${offer.emails[0]} [${offer.emailSource}]`);
}
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
export function loadEnv(root = process.cwd()): void {
const path = resolve(root, ".env");
if (!existsSync(path)) return;
for (const line of readFileSync(path, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
export function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing env ${name}`);
return value;
}
+26
View File
@@ -0,0 +1,26 @@
const EMAIL_RE =
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
const IGNORE_EMAILS = new Set([
"example@example.com",
"email@example.com",
"you@example.com",
"noreply@himalayas.app",
"hello@remotive.io",
"hello@remotive.com",
"nom@exemple.fr",
]);
export function extractEmails(...texts: string[]): string[] {
const found = new Set<string>();
for (const text of texts) {
for (const match of text.match(EMAIL_RE) ?? []) {
const email = match.toLowerCase();
if (IGNORE_EMAILS.has(email)) continue;
if (email.endsWith(".png") || email.endsWith(".jpg")) continue;
if (email.includes("exemple.") || email.includes("example.")) continue;
found.add(email);
}
}
return [...found];
}
+62
View File
@@ -0,0 +1,62 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { matchJob } from "./match.ts";
import { fetchAllJobs } from "./sources.ts";
import type { CompanyMatch } from "./types.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
function groupByCompany(matches: ReturnType<typeof matchJob>[]): CompanyMatch[] {
const byCompany = new Map<string, CompanyMatch>();
for (const match of matches) {
if (!match) continue;
const key = match.job.company.trim();
const existing = byCompany.get(key);
if (!existing) {
byCompany.set(key, { company: key, score: match.score, jobs: [match] });
continue;
}
existing.jobs.push(match);
existing.score = Math.max(existing.score, match.score);
existing.jobs.sort((a, b) => b.score - a.score);
}
return [...byCompany.values()].sort((a, b) => b.score - a.score || a.company.localeCompare(b.company));
}
function printCompanies(companies: CompanyMatch[]): void {
console.log(`\n${companies.length} entreprises qui recrutent un profil proche (Vue/Nuxt, remote EU).\n`);
for (const [index, company] of companies.entries()) {
const top = company.jobs[0];
if (!top) continue;
console.log(`${String(index + 1).padStart(2, " ")}. ${company.company} (score ${company.score})`);
console.log(` ${top.job.title}`);
console.log(` ${top.job.location || "Remote"} · ${top.job.source}`);
console.log(` ${top.reasons.join(" · ")}`);
console.log(` ${top.job.url}`);
if (company.jobs.length > 1) {
console.log(` + ${company.jobs.length - 1} autre(s) offre(s)`);
}
console.log("");
}
}
const jobs = await fetchAllJobs();
const matches = jobs.map(matchJob).filter((match) => match !== null);
const companies = groupByCompany(matches);
printCompanies(companies);
const outDir = join(root, "data");
await mkdir(outDir, { recursive: true });
const payload = {
generatedAt: new Date().toISOString(),
profile: "Fullstack TypeScript · Vue/Nuxt · Node.js · full remote EU",
sources: ["himalayas", "remotive", "remoteok", "jobicy", "arbeitnow"],
scannedJobs: jobs.length,
matchedJobs: matches.length,
companies,
};
const outFile = join(outDir, "matches.json");
await writeFile(outFile, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`Écrit ${outFile} (${jobs.length} offres lues, ${matches.length} retenues).`);
+68
View File
@@ -0,0 +1,68 @@
import { requireEnv } from "./env.ts";
/** Preferred free lite model; Google may reject it for new keys. */
export const GEMINI_PREFERRED = "gemini-2.5-flash-lite";
/** Free replacements when 2.5-flash-lite is unavailable to the API key. */
export const GEMINI_FALLBACKS = [
"gemini-flash-lite-latest",
"gemini-3.5-flash-lite",
];
export function geminiModelChain(): string[] {
const preferred = process.env.GEMINI_MODEL?.trim() || GEMINI_PREFERRED;
const extra = (process.env.GEMINI_MODEL_FALLBACKS ?? "")
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const chain = [preferred, ...extra, ...GEMINI_FALLBACKS];
return [...new Set(chain)];
}
export async function generateGeminiJson(prompt: string, options?: {
temperature?: number;
maxOutputTokens?: number;
}): Promise<{ text: string; model: string }> {
const apiKey = requireEnv("GEMINI_API_KEY");
const models = geminiModelChain();
let lastError = "No Gemini model tried";
for (const model of models) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(apiKey)}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
temperature: options?.temperature ?? 0.4,
maxOutputTokens: options?.maxOutputTokens ?? 800,
responseMimeType: "application/json",
},
}),
});
if (response.ok) {
const data = (await response.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
};
const text =
data.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("") ?? "";
if (!text) {
lastError = `Empty Gemini response from ${model}`;
continue;
}
if (model !== models[0]) {
console.warn(`Gemini: ${models[0]} indisponible, fallback → ${model}`);
}
return { text, model };
}
const body = await response.text();
lastError = `Gemini ${response.status} (${model}): ${body.slice(0, 300)}`;
// Try next model on not-found / unavailable; fail fast on auth errors.
if (response.status === 401 || response.status === 403) throw new Error(lastError);
}
throw new Error(lastError);
}
+67
View File
@@ -0,0 +1,67 @@
import { generateGeminiJson } from "./gemini-client.ts";
import type { Offer } from "./offers.ts";
export type DraftEmail = {
subject: string;
text: string;
language: "fr" | "en";
};
const PROFILE_BRIEF = `
Candidat: Angelo Clauin
Titre: Fullstack Software Engineer
Localisation: Perpignan, France — full remote
Email contact: clauinangelo@proton.me
Expérience: autodidacte, 12+ ans de pratique
Stack principale: TypeScript, Vue 3, Nuxt 4, Pinia, Node.js, Express, PostgreSQL, Playwright, Zod, Docker
Poste actuel: Metis Digital (depuis juil. 2025) — plateforme B2B de réservation de voyages
Preuves fortes: intégrations NDC (Vueling, Volotea, AF/KLM), agrégateur GDS, ancillaires/frais, tests E2E Playwright
Langues: français natif, anglais professionnel
`.trim();
function extractJson(text: string): DraftEmail {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = (fenced?.[1] ?? text).trim();
const parsed = JSON.parse(raw) as Partial<DraftEmail>;
if (!parsed.subject || !parsed.text) {
throw new Error("Gemini response missing subject/text");
}
return {
subject: parsed.subject.trim(),
text: parsed.text.trim(),
language: parsed.language === "fr" ? "fr" : "en",
};
}
export async function draftApplicationEmail(offer: Offer): Promise<DraftEmail> {
const prompt = `Tu rédiges une candidature par email courte et convaincante.
${PROFILE_BRIEF}
Offre ciblée:
- Entreprise: ${offer.company}
- Poste: ${offer.title}
- Lieu: ${offer.location}
- Source: ${offer.source}
- Lien: ${offer.url}
- Extrait: ${offer.excerpt}
- Description (tronquée): ${offer.description.slice(0, 2500)}
Consignes:
- Choisis la langue (fr ou en) selon la langue de l'offre.
- Ton pro, direct, humain — pas de flatterie creuse, pas de formules IA ("I hope this email finds you well", "je me permets de").
- 120-180 mots max pour le corps.
- Mentionne 1-2 preuves concrètes du profil qui matchent l'offre (Vue/Nuxt/TS/Node/travel tech selon pertinence).
- Indique clairement full remote depuis la France / Europe.
- Le CV PDF est joint — dis-le brièvement.
- Signe: Angelo Clauin
- Ne invente pas d'expérience absente du profil.
- Réponds UNIQUEMENT avec un JSON valide:
{"subject":"...","text":"...","language":"fr"|"en"}`;
const { text } = await generateGeminiJson(prompt, {
temperature: 0.6,
maxOutputTokens: 800,
});
return extractJson(text);
}
+58
View File
@@ -0,0 +1,58 @@
const USER_AGENT =
"hire-me/0.1 (job search for personal outreach; +https://delikesance.cloud)";
export async function getJson<T>(url: string, attempts = 3): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": USER_AGENT,
},
});
if (response.ok) return (await response.json()) as T;
lastError = new Error(`${response.status} ${response.statusText} for ${url}`);
if (response.status === 429 || response.status >= 500) {
await sleep(400 * attempt);
continue;
}
throw lastError;
}
throw lastError ?? new Error(`Failed ${url}`);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function stripHtml(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&#x26;/g, "&")
.replace(/\s+/g, " ")
.trim();
}
export function locationsToText(
value: unknown,
): string {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object" && "name" in item) {
return String((item as { name: string }).name);
}
return "";
})
.filter(Boolean)
.join(", ");
}
return String(value);
}
+73
View File
@@ -0,0 +1,73 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { requireEnv } from "./env.ts";
import type { DraftEmail } from "./gemini.ts";
import type { Offer } from "./offers.ts";
export type SendResult = {
id: string;
to: string;
subject: string;
};
export async function sendApplicationEmail(options: {
root: string;
offer: Offer;
to: string;
draft: DraftEmail;
}): Promise<SendResult> {
const apiKey = requireEnv("RESEND_API_KEY");
const from =
process.env.MAIL_FROM?.trim() ||
`Angelo Clauin <angelo@${process.env.RESEND_DOMAIN?.trim() || "delikesance.cloud"}>`;
const fromAddress = from.includes("<") ? from : `Angelo Clauin <${from}>`;
const replyTo =
process.env.MAIL_REPLY_TO?.trim() || "contact@delikesance.cloud";
const resumePath = join(options.root, "resume.pdf");
const pdf = await readFile(resumePath);
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: fromAddress,
to: [options.to],
reply_to: replyTo,
subject: options.draft.subject,
text: options.draft.text,
attachments: [
{
filename: "Angelo_Clauin_Resume.pdf",
content: pdf.toString("base64"),
},
],
tags: [
{ name: "company", value: options.offer.company.slice(0, 40) },
{ name: "offer_id", value: options.offer.id },
],
}),
});
const body = (await response.json()) as { id?: string; message?: string; name?: string };
if (!response.ok) {
const message = body.message ?? JSON.stringify(body);
if (/domain is not verified/i.test(message)) {
throw new Error(
"Le domaine delikesance.cloud nest pas vérifié sur Resend (DNS denvoi incomplets : SPF/DKIM/MX). Termine la config sur https://resend.com/domains",
);
}
throw new Error(`Resend ${response.status}: ${message}`);
}
if (!body.id) throw new Error("Resend returned no id");
return {
id: body.id,
to: options.to,
subject: options.draft.subject,
};
}
+161
View File
@@ -0,0 +1,161 @@
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 };
}
+114
View File
@@ -0,0 +1,114 @@
import { createHash } from "node:crypto";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { CompanyMatch, Match, NormalizedJob } from "./types.ts";
import { loadEmailCache } from "./discover-email.ts";
import { extractEmails } from "./extract-emails.ts";
export type Offer = {
id: string;
score: number;
reasons: string[];
company: string;
title: string;
url: string;
location: string;
source: string;
tags: string[];
excerpt: string;
description: string;
emails: string[];
emailSource?: string;
domain?: string | null;
appliedAt?: string;
};
export type ApplicationRecord = {
id: string;
company: string;
title: string;
to: string;
subject: string;
appliedAt: string;
resendId?: string;
};
export { extractEmails };
export function jobId(job: Pick<NormalizedJob, "company" | "title" | "url">): string {
return createHash("sha1")
.update(`${job.company}|${job.title}|${job.url}`)
.digest("hex")
.slice(0, 12);
}
export async function loadOffers(root: string): Promise<Offer[]> {
const matchesPath = join(root, "data", "matches.json");
const raw = JSON.parse(await readFile(matchesPath, "utf8")) as {
companies: CompanyMatch[];
};
const applications = await loadApplications(root);
const applied = new Map(applications.map((a) => [a.id, a.appliedAt]));
const emailCache = await loadEmailCache(root);
const offers: Offer[] = [];
for (const company of raw.companies) {
for (const match of company.jobs) {
const offer = toOffer(match, applied.get(jobId(match.job)));
const cached = emailCache[offer.company.trim().toLowerCase().replace(/\s+/g, " ")];
if (cached?.emails.length) {
offer.emails = [...new Set([...cached.emails, ...offer.emails])];
offer.emailSource = cached.source;
offer.domain = cached.domain;
} else if (offer.emails.length) {
offer.emailSource = "description";
}
offers.push(offer);
}
}
return offers.sort((a, b) => b.score - a.score || a.company.localeCompare(b.company));
}
export function toOffer(match: Match, appliedAt?: string): Offer {
const { job, score, reasons } = match;
return {
id: jobId(job),
score,
reasons,
company: job.company,
title: job.title,
url: job.url,
location: job.location || "Remote",
source: job.source,
tags: job.tags.slice(0, 8),
excerpt: job.excerpt || job.description.slice(0, 280),
description: job.description,
emails: extractEmails(job.description, job.excerpt, job.url),
appliedAt,
};
}
export async function findOffer(root: string, id: string): Promise<Offer | null> {
const offers = await loadOffers(root);
return offers.find((offer) => offer.id === id) ?? null;
}
export async function loadApplications(root: string): Promise<ApplicationRecord[]> {
const path = join(root, "data", "applications.json");
try {
return JSON.parse(await readFile(path, "utf8")) as ApplicationRecord[];
} catch {
return [];
}
}
export async function saveApplication(
root: string,
record: ApplicationRecord,
): Promise<void> {
const path = join(root, "data", "applications.json");
await mkdir(join(root, "data"), { recursive: true });
const existing = await loadApplications(root);
const next = [record, ...existing.filter((item) => item.id !== record.id)];
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
}
+273
View File
@@ -0,0 +1,273 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnv } from "./env.ts";
import { draftApplicationEmail } from "./gemini.ts";
import { sendApplicationEmail } from "./mail.ts";
import { enrichAllOffers, enrichOfferEmail } from "./discover-email.ts";
import { findOffer, loadOffers, saveApplication } from "./offers.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
loadEnv(root);
const publicDir = join(root, "public");
const port = Number(process.env.PORT ?? 8787);
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
".json": "application/json; charset=utf-8",
};
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
function json(res: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(body);
}
function isEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
async function serveStatic(res: ServerResponse, pathname: string): Promise<void> {
const relative = pathname === "/" ? "/index.html" : pathname;
const filePath = join(publicDir, relative);
if (!filePath.startsWith(publicDir)) {
json(res, 403, { error: "Forbidden" });
return;
}
try {
const data = await readFile(filePath);
res.writeHead(200, {
"Content-Type": MIME[extname(filePath)] ?? "application/octet-stream",
});
res.end(data);
} catch {
json(res, 404, { error: "Not found" });
}
}
async function resolveRecipient(offerId: string, override?: string): Promise<{
offer: NonNullable<Awaited<ReturnType<typeof findOffer>>>;
to: string;
emailSource: string;
}> {
const offer = await findOffer(root, offerId);
if (!offer) throw Object.assign(new Error("Offre introuvable — relance npm run find"), { status: 404 });
if (override?.trim()) {
const to = override.trim().toLowerCase();
if (!isEmail(to)) throw Object.assign(new Error("Email destinataire invalide"), { status: 400 });
return { offer, to, emailSource: "manual" };
}
if (offer.emails[0]) {
return { offer, to: offer.emails[0], emailSource: offer.emailSource ?? "cache" };
}
const discovered = await enrichOfferEmail(root, offer, { force: true });
if (!discovered.emails[0]) {
throw Object.assign(
new Error("Impossible de trouver automatiquement un email destinataire pour cette entreprise"),
{ status: 422, emails: [], domain: discovered.domain },
);
}
offer.emails = discovered.emails;
offer.emailSource = discovered.source;
offer.domain = discovered.domain;
return { offer, to: discovered.emails[0], emailSource: discovered.source };
}
async function handlePreview(req: IncomingMessage, res: ServerResponse): Promise<void> {
const raw = await readBody(req);
const body = JSON.parse(raw || "{}") as { id?: string; to?: string };
if (!body.id) {
json(res, 400, { error: "id requis" });
return;
}
let resolved: Awaited<ReturnType<typeof resolveRecipient>>;
try {
resolved = await resolveRecipient(body.id, body.to);
} catch (error) {
const err = error as Error & { status?: number; emails?: string[]; domain?: string | null };
json(res, err.status ?? 500, {
error: err.message,
emails: err.emails,
domain: err.domain,
});
return;
}
const { offer, to, emailSource } = resolved;
if (offer.appliedAt) {
json(res, 409, { error: "Déjà postulé à cette offre", appliedAt: offer.appliedAt });
return;
}
const draft = await draftApplicationEmail(offer);
const from =
process.env.MAIL_FROM?.trim() ||
`Angelo Clauin <angelo@${process.env.RESEND_DOMAIN?.trim() || "delikesance.cloud"}>`;
const replyTo = process.env.MAIL_REPLY_TO?.trim() || "contact@delikesance.cloud";
json(res, 200, {
ok: true,
id: offer.id,
company: offer.company,
title: offer.title,
to,
emailSource,
from,
replyTo,
subject: draft.subject,
text: draft.text,
language: draft.language,
attachment: "Angelo_Clauin_Resume.pdf",
});
}
async function handleApply(req: IncomingMessage, res: ServerResponse): Promise<void> {
const raw = await readBody(req);
const body = JSON.parse(raw || "{}") as {
id?: string;
to?: string;
subject?: string;
text?: string;
language?: "fr" | "en";
};
if (!body.id) {
json(res, 400, { error: "id requis" });
return;
}
let resolved: Awaited<ReturnType<typeof resolveRecipient>>;
try {
resolved = await resolveRecipient(body.id, body.to);
} catch (error) {
const err = error as Error & { status?: number; emails?: string[]; domain?: string | null };
json(res, err.status ?? 500, {
error: err.message,
emails: err.emails,
domain: err.domain,
});
return;
}
const { offer, to, emailSource } = resolved;
if (offer.appliedAt) {
json(res, 409, { error: "Déjà postulé à cette offre", appliedAt: offer.appliedAt });
return;
}
const subject = body.subject?.trim();
const text = body.text?.trim();
if (!subject || !text) {
json(res, 400, { error: "subject et text requis — passe dabord par la preview" });
return;
}
const draft = {
subject,
text,
language: body.language === "fr" ? ("fr" as const) : ("en" as const),
};
const sent = await sendApplicationEmail({ root, offer, to, draft });
const appliedAt = new Date().toISOString();
await saveApplication(root, {
id: offer.id,
company: offer.company,
title: offer.title,
to,
subject: draft.subject,
appliedAt,
resendId: sent.id,
});
json(res, 200, {
ok: true,
appliedAt,
to,
emailSource,
subject: draft.subject,
preview: draft.text,
resendId: sent.id,
});
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const { pathname } = url;
if (req.method === "GET" && pathname === "/api/offers") {
const offers = await loadOffers(root);
json(res, 200, {
generatedAt: new Date().toISOString(),
count: offers.length,
offers: offers.map(({ description, ...rest }) => ({
...rest,
descriptionPreview: description.slice(0, 600),
})),
});
return;
}
if (req.method === "POST" && pathname === "/api/enrich-emails") {
const offers = await loadOffers(root);
await enrichAllOffers(root, offers);
const refreshed = await loadOffers(root);
json(res, 200, {
ok: true,
withEmail: refreshed.filter((o) => o.emails.length).length,
total: refreshed.length,
});
return;
}
if (req.method === "POST" && pathname === "/api/preview") {
await handlePreview(req, res);
return;
}
if (req.method === "POST" && pathname === "/api/apply") {
await handleApply(req, res);
return;
}
if (req.method === "GET") {
await serveStatic(res, pathname);
return;
}
json(res, 405, { error: "Method not allowed" });
} catch (error) {
console.error(error);
json(res, 500, {
error: error instanceof Error ? error.message : "Erreur serveur",
});
}
});
server.listen(port, () => {
console.log(`hire-me → http://127.0.0.1:${port}`);
loadOffers(root)
.then((offers) => {
console.log(`enrichissement emails de ${offers.length} offres…`);
return enrichAllOffers(root, offers);
})
.then(() => console.log("enrichissement emails terminé"))
.catch((error) => console.error("enrichissement emails:", error));
});
+222
View File
@@ -0,0 +1,222 @@
import { getJson, locationsToText, sleep, stripHtml } from "./http.ts";
import type { NormalizedJob } from "./types.ts";
const EU_QUERY_COUNTRIES = [
"France",
"Germany",
"Spain",
"Portugal",
"Netherlands",
"Belgium",
"United Kingdom",
];
type HimalayasJob = {
title: string;
excerpt?: string;
companyName: string;
applicationLink: string;
locationRestrictions?: unknown;
timezoneRestrictions?: number[];
categories?: string[];
description?: string;
employmentType?: string;
};
type HimalayasSearch = {
totalCount?: number;
jobs?: HimalayasJob[];
};
type RemotiveFeed = {
jobs?: Array<{
title: string;
company_name: string;
url: string;
candidate_required_location?: string;
tags?: string[];
description?: string;
job_type?: string;
}>;
};
type RemoteOkJob = {
position?: string;
company?: string;
url?: string;
location?: string;
tags?: string[];
description?: string;
};
type JobicyFeed = {
jobs?: Array<{
jobTitle: string;
companyName: string;
url: string;
jobGeo?: string;
jobIndustry?: string[];
jobExcerpt?: string;
jobDescription?: string;
}>;
};
type ArbeitnowFeed = {
data?: Array<{
title: string;
company_name: string;
url: string;
location?: string;
remote?: boolean;
tags?: string[];
description?: string;
}>;
};
async function himalayasSearch(q: string, country?: string): Promise<NormalizedJob[]> {
const jobs: NormalizedJob[] = [];
for (let page = 1; page <= 3; page += 1) {
const url = new URL("https://himalayas.app/jobs/api/search");
url.searchParams.set("q", q);
url.searchParams.set("page", String(page));
if (country) url.searchParams.set("country", country);
const data = await getJson<HimalayasSearch>(url.toString());
const batch = data.jobs ?? [];
if (batch.length === 0) break;
for (const job of batch) {
jobs.push({
source: "himalayas",
title: job.title,
company: job.companyName,
url: job.applicationLink,
location: locationsToText(job.locationRestrictions),
tags: job.categories ?? [],
excerpt: job.excerpt ?? "",
description: stripHtml(job.description ?? ""),
remote: true,
timezones: job.timezoneRestrictions ?? [],
});
}
if (batch.length < 10) break;
await sleep(120);
}
return jobs;
}
async function fromHimalayas(): Promise<NormalizedJob[]> {
const seen = new Set<string>();
const out: NormalizedJob[] = [];
const queries: Array<{ q: string; country?: string }> = [
{ q: "vue", country: "France" },
{ q: "nuxt", country: "France" },
{ q: "vue.js" },
{ q: "nuxt" },
];
for (const country of EU_QUERY_COUNTRIES) {
if (country === "France") continue;
queries.push({ q: "vue", country });
}
for (const query of queries) {
try {
const jobs = await himalayasSearch(query.q, query.country);
for (const job of jobs) {
const key = `${job.company}|${job.title}|${job.url}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(job);
}
} catch (error) {
console.error(`himalayas ${query.q} ${query.country ?? ""}:`, error);
}
await sleep(150);
}
return out;
}
async function fromRemotive(): Promise<NormalizedJob[]> {
const data = await getJson<RemotiveFeed>(
"https://remotive.com/api/remote-jobs?category=software-dev",
);
return (data.jobs ?? []).map((job) => ({
source: "remotive",
title: job.title,
company: job.company_name.trim(),
url: job.url,
location: job.candidate_required_location ?? "",
tags: job.tags ?? [],
excerpt: "",
description: stripHtml(job.description ?? ""),
remote: true,
timezones: [],
}));
}
async function fromRemoteOk(): Promise<NormalizedJob[]> {
const data = await getJson<unknown[]>("https://remoteok.com/api");
return data
.slice(1)
.map((raw) => raw as RemoteOkJob)
.filter((job) => job.position && job.company && job.url)
.map((job) => ({
source: "remoteok",
title: job.position ?? "",
company: job.company ?? "",
url: job.url ?? "",
location: job.location ?? "",
tags: job.tags ?? [],
excerpt: "",
description: stripHtml(job.description ?? ""),
remote: true,
timezones: [],
}));
}
async function fromJobicy(): Promise<NormalizedJob[]> {
const data = await getJson<JobicyFeed>(
"https://jobicy.com/api/v2/remote-jobs?count=100&tag=javascript",
);
return (data.jobs ?? []).map((job) => ({
source: "jobicy",
title: job.jobTitle,
company: job.companyName,
url: job.url,
location: job.jobGeo ?? "",
tags: job.jobIndustry ?? [],
excerpt: job.jobExcerpt ?? "",
description: stripHtml(job.jobDescription ?? ""),
remote: true,
timezones: [],
}));
}
async function fromArbeitnow(): Promise<NormalizedJob[]> {
const data = await getJson<ArbeitnowFeed>("https://www.arbeitnow.com/api/job-board-api");
return (data.data ?? []).map((job) => ({
source: "arbeitnow",
title: job.title,
company: job.company_name,
url: job.url,
location: job.location ?? "",
tags: job.tags ?? [],
excerpt: "",
description: stripHtml(job.description ?? ""),
remote: Boolean(job.remote),
timezones: [],
}));
}
export async function fetchAllJobs(): Promise<NormalizedJob[]> {
const settled = await Promise.allSettled([
fromHimalayas(),
fromRemotive(),
fromRemoteOk(),
fromJobicy(),
fromArbeitnow(),
]);
const jobs: NormalizedJob[] = [];
for (const result of settled) {
if (result.status === "fulfilled") jobs.push(...result.value);
else console.error(result.reason);
}
return jobs;
}
+24
View File
@@ -0,0 +1,24 @@
export type NormalizedJob = {
source: string;
title: string;
company: string;
url: string;
location: string;
tags: string[];
excerpt: string;
description: string;
remote: boolean;
timezones: number[];
};
export type Match = {
job: NormalizedJob;
score: number;
reasons: string[];
};
export type CompanyMatch = {
company: string;
score: number;
jobs: Match[];
};