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
+18
View File
@@ -0,0 +1,18 @@
# Resend — envoi des mails de démarchage
# La clé d'app peut rester « Sending access ».
# Pour enregistrer le domaine, il faut une clé Full access (temporaire).
RESEND_API_KEY=re_xxxxxxxx
RESEND_DOMAIN=delikesance.cloud
MAIL_FROM="Angelo Clauin <angelo@delikesance.cloud>"
MAIL_REPLY_TO=contact@delikesance.cloud
MAIL_INBOX=clauinangelo@proton.me
# Cloudflare — DNS + Email Routing
CLOUDFLARE_API_TOKEN=cfat_xxxxxxxx
CLOUDFLARE_ZONE_NAME=delikesance.cloud
CLOUDFLARE_ZONE_ID=
# Gemini — rédaction des mails (lite gratuit)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash-lite
GEMINI_MODEL_FALLBACKS=gemini-flash-lite-latest,gemini-3.5-flash-lite
+11
View File
@@ -0,0 +1,11 @@
.env
.env.*
!.env.example
result
result-*
node_modules
.direnv
data/matches.json
data/applications.json
data/email-cache.json
resume.pdf
+65
View File
@@ -0,0 +1,65 @@
# Profil candidat — source: data/resume.pdf (Angelo Clauin)
# Utilisé par l'outil de démarchage pour cibler entreprises et messages.
identity:
name: Angelo Clauin
title: Fullstack Software Engineer
email: clauinangelo@proton.me
location: Perpignan, France
work_mode: Full Remote
languages:
- { name: Français, level: native }
- { name: Anglais, level: professional } # documentation, revues, technique
positioning:
headline: Ingénieur Fullstack orienté produit — TypeScript, Vue/Nuxt, Node.js
seniority_signal: autodidacte depuis 8 ans, 12+ ans de pratique
pitch: |
Ingénieur Fullstack passionné et orienté produit, capable de concevoir et
déployer des applications web de bout en bout : PostgreSQL, UI réactive,
intégrations d'APIs critiques à fort volume transactionnel.
core_stack:
frontend: [Vue 3, Nuxt 4, Pinia, Vuetify 4, TypeScript, Vee-Validate, Zod]
backend: [Node.js, Express, REST, OpenAPI, microservices, SOAP/XML, NDC]
data: [PostgreSQL, MongoDB, Redis, Sequelize, Prisma, SQLite]
languages: [TypeScript, JavaScript, Go, Rust, Python, Dart, SQL, Bash]
quality: [Playwright, Mocha, Chai, Git, Docker, CI/CD]
current_role:
company: Metis Digital
period: Juil. 2025 aujourd'hui
domain: Travel tech B2B — Online Booking Tool
highlights:
- Intégrations NDC bout-en-bout (Vueling, Volotea, Air France/KLM)
- Flux shopping/pricing/booking, ancillaires, frais CB, BSP
- Agrégateur GDS (GOKYTE/Kyte), retry, normalisation d'erreurs
- Bugs critiques (duplication passagers, double facturation)
- Tests E2E Playwright data-driven + schémas Zod
projects:
- { name: hir, tags: [Go, C FFI, Node.js], note: moteur de rendu HML → PNG, API HTTP }
- { name: docusign-poc, tags: [Rust, Axum, OpenSSL], note: signature PAdES locale }
- { name: gazes-security, tags: [TypeScript, Prisma, PostgreSQL], note: automod Discord événementiel }
# Ciblage démarchage (à affiner avec l'outil)
targeting:
roles:
- Fullstack Software Engineer
- Frontend Engineer (Vue/Nuxt)
- Backend Engineer (Node.js / TypeScript)
- Software Engineer (product / travel tech)
must_have:
- Remote (full remote prioritaire)
nice_to_have:
- Vue / Nuxt en production
- TypeScript + Node.js
- APIs B2B, intégrations, fort volume
- Travel / fintech / product-led
avoid:
- Présentiel obligatoire (Perpignan)
- Stack uniquement Java/.NET/PHP sans TypeScript
geography:
primary: France / Europe remote
timezone: Europe/Paris
languages_ok: [fr, en]
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1788316716,
"narHash": "sha256-bc7rSpXIdn9QWGNqfWcPZWOhEVF8NoeAZkWq0XWnf/k=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+34
View File
@@ -0,0 +1,34 @@
{
description = "Démarchage emploi : TypeScript/Node, emails Resend depuis delikesance.cloud (DNS Cloudflare)";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
forEachSystem = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
formatter = forEachSystem (pkgs: pkgs.nixfmt-rfc-style);
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
name = "hire-me";
packages = with pkgs; [
nodejs_22
pnpm
jq
curl
];
};
});
};
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "hire-me",
"private": true,
"type": "module",
"description": "Trouve des entreprises qui recrutent un profil Fullstack TypeScript / Vue / Nuxt / Node, puis permet de les démarcher par mail.",
"scripts": {
"find": "node --experimental-strip-types src/find-companies.ts",
"enrich-emails": "node --experimental-strip-types src/enrich-emails.ts",
"dev": "node --experimental-strip-types --watch src/server.ts",
"start": "node --experimental-strip-types src/server.ts"
},
"engines": {
"node": ">=22"
}
}
+261
View File
@@ -0,0 +1,261 @@
const statusEl = document.querySelector("#status");
const listEl = document.querySelector("#list");
const metaEl = document.querySelector("#meta");
const dialog = document.querySelector("#preview");
const previewTitle = document.querySelector("#preview-title");
const previewFrom = document.querySelector("#preview-from");
const previewReply = document.querySelector("#preview-reply");
const previewAttach = document.querySelector("#preview-attach");
const previewTo = document.querySelector("#preview-to");
const previewSubject = document.querySelector("#preview-subject");
const previewText = document.querySelector("#preview-text");
const previewError = document.querySelector("#preview-error");
const previewSend = document.querySelector("#preview-send");
const previewRegen = document.querySelector("#preview-regen");
/** @type {{ offer: any, article: HTMLElement, button: HTMLButtonElement, input: HTMLInputElement, hint: HTMLElement | null, feedback: HTMLElement, language: string } | null} */
let active = null;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function sourceLabel(source) {
if (source === "website") return "trouvé sur le site";
if (source === "gemini") return "détecté via Gemini";
if (source === "guess") return "estimé (careers/recruiting@)";
if (source === "description") return "dans loffre";
if (source === "manual") return "saisi";
if (source === "cache") return "en cache";
return source || "recherche…";
}
function showPreviewError(message) {
if (!previewError) return;
previewError.hidden = !message;
previewError.textContent = message || "";
}
async function loadPreview(forceTo) {
if (!active) return;
const { offer, button, input, feedback } = active;
showPreviewError("");
button.disabled = true;
button.textContent = "Rédaction…";
feedback.hidden = false;
feedback.className = "feedback";
feedback.textContent = "Gemini rédige le mail…";
if (previewRegen) previewRegen.disabled = true;
if (previewSend) previewSend.disabled = true;
try {
const response = await fetch("/api/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: offer.id,
to: forceTo || input.value.trim() || undefined,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Impossible de préparer le mail");
active.language = data.language || "en";
previewTitle.textContent = `${data.company}${data.title}`;
previewFrom.textContent = data.from;
previewReply.textContent = data.replyTo;
previewAttach.textContent = data.attachment;
previewTo.value = data.to;
previewSubject.value = data.subject;
previewText.value = data.text;
input.value = data.to;
if (active.hint) active.hint.textContent = sourceLabel(data.emailSource);
feedback.hidden = true;
dialog?.showModal();
} catch (error) {
feedback.className = "feedback err";
feedback.textContent = error instanceof Error ? error.message : "Erreur";
} finally {
button.disabled = false;
button.textContent = "Postuler";
if (previewRegen) previewRegen.disabled = false;
if (previewSend) previewSend.disabled = false;
}
}
async function sendPreview() {
if (!active) return;
const { offer, article, button, input, hint, feedback } = active;
const to = previewTo.value.trim();
const subject = previewSubject.value.trim();
const text = previewText.value.trim();
showPreviewError("");
if (!to || !subject || !text) {
showPreviewError("Destinataire, objet et corps sont requis.");
return;
}
previewSend.disabled = true;
previewRegen.disabled = true;
previewSend.textContent = "Envoi…";
try {
const response = await fetch("/api/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: offer.id,
to,
subject,
text,
language: active.language,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Échec de candidature");
article.classList.add("applied");
button.classList.add("done");
button.textContent = "Envoyé";
button.disabled = true;
input.value = data.to;
input.disabled = true;
if (hint) hint.textContent = sourceLabel(data.emailSource);
feedback.hidden = false;
feedback.className = "feedback ok";
feedback.textContent = `Envoyé à ${data.to}${data.subject}`;
dialog?.close();
} catch (error) {
showPreviewError(error instanceof Error ? error.message : "Erreur");
} finally {
previewSend.disabled = false;
previewRegen.disabled = false;
previewSend.textContent = "Envoyer";
}
}
function renderOffer(offer) {
const applied = Boolean(offer.appliedAt);
const email = offer.emails[0] ?? "";
const article = document.createElement("article");
article.className = `offer${applied ? " applied" : ""}`;
article.dataset.id = offer.id;
article.innerHTML = `
<div class="offer-head">
<div>
<p class="company">${escapeHtml(offer.company)}</p>
<h2 class="title">${escapeHtml(offer.title)}</h2>
</div>
<span class="score">score ${escapeHtml(offer.score)}</span>
</div>
<p class="meta-row">
<span>${escapeHtml(offer.location)}</span>
<span>·</span>
<span>${escapeHtml(offer.source)}</span>
<span>·</span>
<a href="${escapeHtml(offer.url)}" target="_blank" rel="noreferrer">voir loffre</a>
</p>
<ul class="reasons">
${offer.reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<p class="excerpt">${escapeHtml(offer.excerpt)}</p>
<div class="actions">
<label>
Email destinataire
<input
type="email"
name="to"
value="${escapeHtml(email)}"
placeholder="${email ? "" : "Recherche auto en cours…"}"
${applied ? "disabled" : ""}
/>
<span class="email-hint">${
email
? escapeHtml(sourceLabel(offer.emailSource))
: "sera récupéré automatiquement au clic si besoin"
}</span>
</label>
<button type="button" ${applied ? "disabled" : ""}>
${applied ? "Déjà postulé" : "Postuler"}
</button>
</div>
<p class="feedback" hidden></p>
`;
const button = article.querySelector("button");
const input = article.querySelector('input[name="to"]');
const hint = article.querySelector(".email-hint");
const feedback = article.querySelector(".feedback");
button?.addEventListener("click", async () => {
if (!button || !input || !feedback) return;
active = { offer, article, button, input, hint, feedback, language: "en" };
await loadPreview(input.value.trim());
});
return article;
}
previewSend?.addEventListener("click", () => {
void sendPreview();
});
previewRegen?.addEventListener("click", () => {
void loadPreview(previewTo?.value.trim());
});
async function boot() {
try {
const response = await fetch("/api/offers");
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Impossible de charger les offres");
statusEl.hidden = true;
listEl.hidden = false;
metaEl.hidden = false;
const withEmail = data.offers.filter((o) => o.emails?.length).length;
metaEl.textContent = `${data.count} offre(s) — ${withEmail} email(s) trouvés. Postuler ouvre dabord un aperçu.`;
if (!data.offers.length) {
statusEl.hidden = false;
statusEl.textContent = "Aucune offre. Lance dabord `npm run find`.";
return;
}
for (const offer of data.offers) {
listEl.append(renderOffer(offer));
}
setTimeout(async () => {
try {
const again = await fetch("/api/offers");
const next = await again.json();
if (!again.ok) return;
for (const offer of next.offers) {
const article = listEl.querySelector(`[data-id="${offer.id}"]`);
const input = article?.querySelector('input[name="to"]');
const hint = article?.querySelector(".email-hint");
if (input && !input.disabled && offer.emails?.[0] && !input.value) {
input.value = offer.emails[0];
if (hint) hint.textContent = sourceLabel(offer.emailSource);
}
}
const filled = next.offers.filter((o) => o.emails?.length).length;
metaEl.textContent = `${next.count} offre(s) — ${filled} email(s) trouvés. Postuler ouvre dabord un aperçu.`;
} catch {
// ignore
}
}, 8000);
} catch (error) {
statusEl.classList.add("error");
statusEl.textContent = error instanceof Error ? error.message : "Erreur de chargement";
}
}
boot();
+75
View File
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hire-me — candidatures</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="bg"></div>
<main>
<header class="top">
<p class="brand">hire-me</p>
<h1>Offres alignées sur ton profil</h1>
<p class="lede">
<strong>Postuler</strong> prépare le mail (destinataire + texte Gemini + CV). Tu
previews, tu ajustes, puis tu envoies.
</p>
<p id="meta" class="meta" hidden></p>
</header>
<section id="status" class="status" aria-live="polite">Chargement des offres…</section>
<section id="list" class="list" hidden></section>
</main>
<dialog id="preview" class="preview">
<form method="dialog" id="preview-form" class="preview-sheet">
<header class="preview-top">
<div>
<p class="preview-kicker">Aperçu avant envoi</p>
<h2 id="preview-title">Candidature</h2>
</div>
<button type="submit" value="cancel" class="ghost" id="preview-close">Fermer</button>
</header>
<div class="preview-meta">
<p><span>De</span> <strong id="preview-from"></strong></p>
<p><span>Reply-To</span> <strong id="preview-reply"></strong></p>
<p><span>Pièce jointe</span> <strong id="preview-attach"></strong></p>
</div>
<label>
Destinataire
<input type="email" id="preview-to" required />
</label>
<label>
Objet
<input type="text" id="preview-subject" required />
</label>
<label>
Corps du mail
<textarea id="preview-text" rows="14" required></textarea>
</label>
<p id="preview-error" class="feedback err" hidden></p>
<footer class="preview-actions">
<button type="button" class="ghost" id="preview-regen">Regénérer</button>
<div class="preview-actions-right">
<button type="submit" value="cancel" class="ghost">Annuler</button>
<button type="button" id="preview-send">Envoyer</button>
</div>
</footer>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+407
View File
@@ -0,0 +1,407 @@
:root {
--ink: #14201a;
--muted: #5a6b62;
--line: rgba(20, 32, 26, 0.12);
--paper: #f3efe6;
--panel: rgba(255, 252, 246, 0.78);
--accent: #0f6b4c;
--accent-ink: #f7fff9;
--warn: #8a4b16;
--danger: #8f2d2d;
--ok: #1f6b3a;
--shadow: 0 18px 50px rgba(20, 32, 26, 0.08);
--radius: 18px;
--font: "DM Sans", system-ui, sans-serif;
--display: "Instrument Serif", Georgia, serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: var(--font);
color: var(--ink);
background: var(--paper);
line-height: 1.45;
}
.bg {
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(900px 480px at 12% -10%, rgba(15, 107, 76, 0.18), transparent 60%),
radial-gradient(700px 420px at 100% 0%, rgba(138, 75, 22, 0.12), transparent 55%),
linear-gradient(180deg, #f7f3ea 0%, #ebe4d6 100%);
}
main {
width: min(920px, calc(100% - 2rem));
margin: 0 auto;
padding: 2.5rem 0 4rem;
}
.top {
margin-bottom: 1.75rem;
}
.brand {
margin: 0 0 0.75rem;
font-size: 0.85rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0;
font-family: var(--display);
font-weight: 400;
font-size: clamp(2.2rem, 5vw, 3.4rem);
letter-spacing: -0.02em;
line-height: 1.05;
}
.lede {
margin: 0.85rem 0 0;
max-width: 38rem;
color: var(--muted);
font-size: 1.02rem;
}
.meta {
margin: 0.85rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.status {
padding: 1rem 1.15rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
backdrop-filter: blur(10px);
color: var(--muted);
}
.status.error {
color: var(--danger);
border-color: rgba(143, 45, 45, 0.25);
}
.list {
display: grid;
gap: 0.9rem;
}
.offer {
display: grid;
gap: 0.95rem;
padding: 1.15rem 1.2rem 1.2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
backdrop-filter: blur(10px);
box-shadow: var(--shadow);
transition: transform 160ms ease, border-color 160ms ease;
}
.offer:hover {
transform: translateY(-1px);
border-color: rgba(15, 107, 76, 0.28);
}
.offer.applied {
opacity: 0.72;
}
.offer-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.company {
margin: 0;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
.title {
margin: 0.25rem 0 0;
font-family: var(--display);
font-size: clamp(1.35rem, 2.5vw, 1.7rem);
line-height: 1.15;
font-weight: 400;
}
.score {
flex: none;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: rgba(15, 107, 76, 0.1);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.75rem;
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.meta-row a {
color: inherit;
}
.reasons {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.reasons li {
padding: 0.22rem 0.55rem;
border-radius: 999px;
background: rgba(20, 32, 26, 0.05);
color: var(--muted);
font-size: 0.78rem;
}
.excerpt {
margin: 0;
color: var(--ink);
font-size: 0.95rem;
}
.actions {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.65rem;
align-items: end;
}
label {
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.02em;
}
.email-hint {
font-size: 0.75rem;
font-weight: 500;
color: var(--muted);
opacity: 0.9;
}
input[type="email"] {
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
font: inherit;
}
input[type="email"]:focus {
outline: 2px solid rgba(15, 107, 76, 0.35);
border-color: transparent;
}
button {
appearance: none;
border: 0;
border-radius: 12px;
padding: 0.78rem 1.15rem;
background: var(--accent);
color: var(--accent-ink);
font: inherit;
font-weight: 650;
cursor: pointer;
transition: transform 140ms ease, opacity 140ms ease, background 140ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.done {
background: var(--ok);
}
.feedback {
margin: 0;
font-size: 0.88rem;
color: var(--muted);
}
.feedback.ok {
color: var(--ok);
}
.feedback.err {
color: var(--danger);
}
.preview {
border: 0;
padding: 0;
max-width: min(720px, calc(100vw - 1.5rem));
width: 100%;
background: transparent;
}
.preview::backdrop {
background: rgba(18, 28, 22, 0.45);
backdrop-filter: blur(6px);
}
.preview-sheet {
display: grid;
gap: 0.85rem;
margin: 0;
padding: 1.2rem 1.25rem 1.25rem;
border: 1px solid var(--line);
border-radius: 22px;
background: #fffcf6;
box-shadow: var(--shadow);
}
.preview-top {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.preview-kicker {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
.preview-top h2 {
margin: 0.2rem 0 0;
font-family: var(--display);
font-size: clamp(1.35rem, 3vw, 1.75rem);
font-weight: 400;
line-height: 1.15;
}
.preview-meta {
display: grid;
gap: 0.25rem;
padding: 0.75rem 0.85rem;
border-radius: 14px;
background: rgba(20, 32, 26, 0.04);
color: var(--muted);
font-size: 0.88rem;
}
.preview-meta p {
margin: 0;
}
.preview-meta span {
display: inline-block;
min-width: 5.5rem;
font-weight: 600;
}
.preview-sheet input[type="text"],
.preview-sheet input[type="email"],
.preview-sheet textarea {
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
font: inherit;
resize: vertical;
}
.preview-sheet textarea {
line-height: 1.5;
min-height: 220px;
}
.preview-sheet input:focus,
.preview-sheet textarea:focus {
outline: 2px solid rgba(15, 107, 76, 0.35);
border-color: transparent;
}
.preview-actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.preview-actions-right {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
button.ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
}
button.ghost:hover:not(:disabled) {
background: rgba(20, 32, 26, 0.04);
}
@media (max-width: 640px) {
.actions {
grid-template-columns: 1fr;
}
button {
width: 100%;
}
.preview-actions,
.preview-actions-right {
width: 100%;
}
.preview-actions-right button {
flex: 1;
}
}
+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[];
};