Initial import of hire-me.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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).`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/&/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
@@ -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 n’est pas vérifié sur Resend (DNS d’envoi 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
@@ -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
@@ -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
@@ -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 d’abord 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
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user