commit 9c734470b30ff03db410fbf043b0af3315c4d874 Author: delikesance Date: Fri Sep 4 20:08:12 2026 +0000 Initial import of hire-me. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..272a77b --- /dev/null +++ b/.env.example @@ -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 " +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a064576 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/data/profile.yaml b/data/profile.yaml new file mode 100644 index 0000000..de010f1 --- /dev/null +++ b/data/profile.yaml @@ -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] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fa9e125 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..34e8fc5 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + }); + }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e2d74f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..307a513 --- /dev/null +++ b/public/app.js @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +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 l’offre"; + 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 = ` +
+
+

${escapeHtml(offer.company)}

+

${escapeHtml(offer.title)}

+
+ score ${escapeHtml(offer.score)} +
+

+ ${escapeHtml(offer.location)} + · + ${escapeHtml(offer.source)} + · + voir l’offre +

+
    + ${offer.reasons.map((reason) => `
  • ${escapeHtml(reason)}
  • `).join("")} +
+

${escapeHtml(offer.excerpt)}

+
+ + +
+ + `; + + 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 d’abord un aperçu.`; + + if (!data.offers.length) { + statusEl.hidden = false; + statusEl.textContent = "Aucune offre. Lance d’abord `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 d’abord un aperçu.`; + } catch { + // ignore + } + }, 8000); + } catch (error) { + statusEl.classList.add("error"); + statusEl.textContent = error instanceof Error ? error.message : "Erreur de chargement"; + } +} + +boot(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c460ab4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,75 @@ + + + + + + hire-me — candidatures + + + + + + +
+
+
+

hire-me

+

Offres alignées sur ton profil

+

+ Postuler prépare le mail (destinataire + texte Gemini + CV). Tu + previews, tu ajustes, puis tu envoies. +

+ +
+ +
Chargement des offres…
+ +
+ + +
+
+
+

Aperçu avant envoi

+

Candidature

+
+ +
+ +
+

De

+

Reply-To

+

Pièce jointe

+
+ + + + + + + +
+ +
+ + +
+
+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..a152963 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/src/discover-email.ts b/src/discover-email.ts new file mode 100644 index 0000000..c2e4433 --- /dev/null +++ b/src/discover-email.ts @@ -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(); + 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 { + 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 { + 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 { + const found = new Set(); + 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 { + 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> { + 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): Promise { + await mkdir(join(root, "data"), { recursive: true }); + await writeFile(join(root, "data", "email-cache.json"), `${JSON.stringify(cache, null, 2)}\n`); +} + +let cacheLock: Promise = Promise.resolve(); + +function withCacheLock(fn: () => Promise): Promise { + const run = cacheLock.then(fn, fn); + cacheLock = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +export async function getCachedEmails( + root: string, + company: string, +): Promise { + 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 { + 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 { + const seen = new Set(); + 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())); +} diff --git a/src/enrich-emails.ts b/src/enrich-emails.ts new file mode 100644 index 0000000..f7483a4 --- /dev/null +++ b/src/enrich-emails.ts @@ -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}]`); +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..3a835b7 --- /dev/null +++ b/src/env.ts @@ -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; +} diff --git a/src/extract-emails.ts b/src/extract-emails.ts new file mode 100644 index 0000000..650b132 --- /dev/null +++ b/src/extract-emails.ts @@ -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(); + 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]; +} diff --git a/src/find-companies.ts b/src/find-companies.ts new file mode 100644 index 0000000..e7b6ecb --- /dev/null +++ b/src/find-companies.ts @@ -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[]): CompanyMatch[] { + const byCompany = new Map(); + 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).`); diff --git a/src/gemini-client.ts b/src/gemini-client.ts new file mode 100644 index 0000000..c3c1dca --- /dev/null +++ b/src/gemini-client.ts @@ -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); +} diff --git a/src/gemini.ts b/src/gemini.ts new file mode 100644 index 0000000..23b819e --- /dev/null +++ b/src/gemini.ts @@ -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; + 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 { + 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); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..ecc9edb --- /dev/null +++ b/src/http.ts @@ -0,0 +1,58 @@ +const USER_AGENT = + "hire-me/0.1 (job search for personal outreach; +https://delikesance.cloud)"; + +export async function getJson(url: string, attempts = 3): Promise { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function stripHtml(html: string): string { + return html + .replace(//gi, " ") + .replace(//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); +} diff --git a/src/mail.ts b/src/mail.ts new file mode 100644 index 0000000..453cd90 --- /dev/null +++ b/src/mail.ts @@ -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 { + const apiKey = requireEnv("RESEND_API_KEY"); + const from = + process.env.MAIL_FROM?.trim() || + `Angelo Clauin `; + 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, + }; +} diff --git a/src/match.ts b/src/match.ts new file mode 100644 index 0000000..3590529 --- /dev/null +++ b/src/match.ts @@ -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 }; +} diff --git a/src/offers.ts b/src/offers.ts new file mode 100644 index 0000000..9672259 --- /dev/null +++ b/src/offers.ts @@ -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): string { + return createHash("sha1") + .update(`${job.company}|${job.title}|${job.url}`) + .digest("hex") + .slice(0, 12); +} + +export async function loadOffers(root: string): Promise { + 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 { + const offers = await loadOffers(root); + return offers.find((offer) => offer.id === id) ?? null; +} + +export async function loadApplications(root: string): Promise { + 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 { + 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`); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..2b6ab1a --- /dev/null +++ b/src/server.ts @@ -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 = { + ".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 { + 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 { + 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>>; + 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 { + 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>; + 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 `; + 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 { + 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>; + 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)); +}); diff --git a/src/sources.ts b/src/sources.ts new file mode 100644 index 0000000..bb23b33 --- /dev/null +++ b/src/sources.ts @@ -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 { + 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(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 { + const seen = new Set(); + 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 { + const data = await getJson( + "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 { + const data = await getJson("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 { + const data = await getJson( + "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 { + const data = await getJson("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 { + 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; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..97d984a --- /dev/null +++ b/src/types.ts @@ -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[]; +};