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