Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ docker compose up -d --build # → http://127.0.0.1:53487/
l'issue #43 ; `logo_url` n'est plus saisi mais déduit du favicon, issue #50),
`Candidature` (cœur
du suivi, étapes de progression + `motif_cloture` = clôture ; `cv` = CV joint,
issue #49), `StatusHistory`,
issue #49 ; `localisation` = zone géographique de l'offre, issue #52),
`StatusHistory`,
`Reminder`, `Interview`, `Contact`, `ApiToken`, `CV` (avec analyse IA des
informations principales — champs `analysis`/`analyzed_at`/… , issue #44 ;
`actif` = archivage, issue #48),
`actif` = archivage, issue #48 ; `par_defaut` = CV dont l'adresse sert d'origine
aux trajets, issue #52),
`AIConfig` (singleton de
config du coaching IA, clé Gemini chiffrée — issue #33). Énumérations
`TextChoices` : `Source`, `Canal`, `Statut`, `MotifCloture` (certaines avec icône
Expand All @@ -68,6 +70,12 @@ emoji dans le libellé pour les menus).
- Logos dérivés du favicon : `tracking/logos.py` (`favicon_service_url`, stdlib
uniquement) ; chargé par défaut à l'enregistrement d'un site (issue #27).
- Géométrie du donut de stats : `tracking/statistics.py`.
- Temps de trajet (issue #52) : la fiche candidature géocode la `localisation`
de l'offre et l'adresse du **CV par défaut** (`CV.par_defaut`,
`CV.home_location`) via Nominatim, puis calcule un itinéraire **routier** par
OSRM côté client (aucune clé). Un lien 🚆 renvoie vers Google Maps pour le
calcul en transport en commun. Le plugin Chrome récupère la zone géographique
via `jobLocation` (schema.org) puis sélecteurs DOM.
- Coaching IA (issues #33, #34, #39) : `tracking/ai.py` (clients REST stdlib pour
**Gemini, Mistral, OpenAI/ChatGPT, Anthropic/Claude, Perplexity** ;
`generate(..., provider=...)` aiguille — OpenAI/Mistral/Perplexity partagent le
Expand Down
131 changes: 131 additions & 0 deletions chrome-extension/GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Guide pas à pas — Enregistrer une annonce LinkedIn dans CandiTrack

Objectif : depuis une offre d'emploi affichée sur LinkedIn, créer la candidature
correspondante dans CandiTrack **en un clic** grâce à l'extension Chrome.

> ℹ️ L'extension ne se connecte pas à LinkedIn et n'utilise aucune API LinkedIn.
> Elle lit simplement les informations de **la page que vous avez ouverte** (titre du
> poste, entreprise, URL) et les envoie à **votre** instance CandiTrack.

---

## Étape 0 — Prérequis (à faire une seule fois)

### 0.1 Démarrer le backend avec un jeton
Dans un terminal, à la racine du projet :
```powershell
.venv\Scripts\Activate.ps1
```
Si `CANDITRACK_API_TOKEN` n'est pas encore dans votre `.env`, générez-en un :
```powershell
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
Copiez la valeur dans `.env` :
```
CANDITRACK_API_TOKEN=la_valeur_generee
```
Puis lancez le serveur :
```powershell
python manage.py runserver
```
Laissez ce terminal ouvert. CandiTrack tourne sur **http://127.0.0.1:8000/**.

### 0.2 Installer l'extension
1. Ouvrez **`chrome://extensions`** dans Chrome.
2. Activez le **Mode développeur** (interrupteur en haut à droite).
3. Cliquez **Charger l'extension non empaquetée**.
4. Sélectionnez le dossier **`chrome-extension/`** du projet.
5. L'extension **CandiTrack** apparaît. Cliquez sur l'icône 🧩 (puzzle) de Chrome puis
sur l'épingle à côté de CandiTrack pour **l'afficher** dans la barre d'outils.

### 0.3 Configurer l'extension
1. Clic **droit** sur l'icône CandiTrack → **Options**.
2. **URL du backend** : `http://127.0.0.1:8000`
3. **Jeton API** : collez la **même valeur** que `CANDITRACK_API_TOKEN` de votre `.env`.
4. Cliquez **Enregistrer** → vous devez voir « Enregistré ✓ ».

✅ Tout est prêt. Les étapes suivantes sont à refaire pour **chaque** annonce.

---

## Étape 1 — Ouvrir l'annonce sur LinkedIn

1. Allez sur **https://www.linkedin.com/jobs/** et lancez une recherche (mot-clé + lieu).
2. Dans les résultats, **cliquez sur une offre** : son détail s'affiche à droite.
3. **Important** : ouvrez l'offre sur sa **page dédiée** pour une extraction fiable.
- soit cliquez sur le titre de l'offre / le bouton qui ouvre la page complète,
- l'URL doit ressembler à `https://www.linkedin.com/jobs/view/4039xxxxxx/`.
4. Attendez que la page soit **entièrement chargée** (titre du poste + nom de l'entreprise visibles).

---

## Étape 2 — Ouvrir l'extension et vérifier le pré-remplissage

1. Cliquez sur l'icône **CandiTrack** dans la barre d'outils.
2. Une petite **popup** s'ouvre et tente de remplir automatiquement :
- **Entreprise** → ex. « ACME France »
- **URL de l'offre** → l'adresse `.../jobs/view/...`
- **Localisation** → la zone géographique de l'offre (ex. « Lyon, France »)
- **Source** → affichée en bas : `linkedin`

> ℹ️ Le plugin **ne capture volontairement pas l'intitulé du poste ni la date d'envoi** :
> vous les compléterez dans CandiTrack une fois la candidature réellement envoyée.
> Si l'entreprise est vide ou inexacte (LinkedIn change parfois sa mise en page),
> **corrigez-la** dans la popup. L'URL, elle, est toujours correcte.

---

## Étape 3 — Ajouter la candidature

1. Vérifiez/ajustez les champs.
2. Cliquez **Ajouter**.
3. La popup affiche **« Ajouté ✓ »** avec un lien **voir**.
- Le bouton se réactive et un message d'erreur s'affiche si quelque chose ne va pas
(voir Dépannage).

---

## Étape 4 — Vérifier dans CandiTrack

1. Ouvrez (ou rafraîchissez) **http://127.0.0.1:8000/**.
2. La nouvelle candidature apparaît dans la liste :
- **Libellé** = le nom de l'entreprise
- **Source** = LinkedIn, **URL de l'offre** cliquable
- **Poste** et **date d'envoi** restent **vides** (à compléter)
3. Cliquez sur son libellé pour ouvrir le détail et compléter le poste, la date d'envoi,
les étapes, les dates d'entretien, etc.

🎉 L'annonce LinkedIn est enregistrée.

---

## Dépannage

| Symptôme | Cause probable | Solution |
|---|---|---|
| « Configurez l'URL et le jeton… » | Options non remplies | Refaire l'**étape 0.3** |
| « Erreur : unauthorized » (401) | Jeton de l'extension ≠ `CANDITRACK_API_TOKEN` du `.env` | Recopier exactement le jeton, **Enregistrer**, réessayer |
| « Erreur réseau… » | Serveur arrêté ou mauvaise URL backend | Vérifier que `runserver` tourne et que l'URL est `http://127.0.0.1:8000` |
| Entreprise vide dans la popup | Page pas finie de charger, ou structure LinkedIn différente | Recharger l'annonce, rouvrir la popup, sinon **saisir à la main** |
| Rien ne se passe au clic sur l'icône | Vous n'êtes pas sur un onglet de page web (ex. `chrome://`) | Ouvrir l'extension depuis l'**onglet de l'annonce** |
| Modifs de l'extension non prises en compte | Cache de l'extension | `chrome://extensions` → bouton **⟳** sur la carte CandiTrack |

### Tester sans LinkedIn (vérifier que la chaîne fonctionne)
Sur n'importe quelle page d'offre (Indeed, Welcome to the Jungle…), la même procédure
s'applique. Pour un test purement backend, voir la section 3 de **`../TESTING.md`**
(`Invoke-RestMethod` vers `/api/candidatures/`).

---

## Notes et limites

- **Page dédiée recommandée** : sur la page de recherche LinkedIn (`/jobs/search/`),
plusieurs offres coexistent ; ouvrez l'offre sur sa page `/jobs/view/...` pour que
l'entreprise et le poste soient extraits de manière fiable.
- **Pas de connexion requise côté extension** : elle lit la page visible. Si l'offre n'est
visible que connecté à LinkedIn, restez connecté dans votre onglet — l'extension lit
alors ce que **vous** voyez.
- **Doublons** : cliquer « Ajouter » deux fois crée deux candidatures. Supprimez l'éventuel
doublon depuis CandiTrack.
- **Autre machine / hôte** : si votre backend n'est pas en local, ajoutez son URL dans
`host_permissions` du `manifest.json`, puis rechargez l'extension.
2 changes: 2 additions & 0 deletions chrome-extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ <h1>Ajouter à CandiTrack</h1>
<input id="entreprise" type="text">
<label for="url">URL de l'offre</label>
<input id="url" type="url">
<label for="localisation">Localisation</label>
<input id="localisation" type="text">
<div class="row">
<button id="add">Ajouter</button>
<span class="muted">Source : <span id="source">—</span></span>
Expand Down
71 changes: 65 additions & 6 deletions chrome-extension/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,22 @@ function extractFromPage() {
return el ? (el.textContent || "").trim() : "";
}

// Compose a readable location string from a schema.org PostalAddress (issue #52).
function formatAddress(addr) {
if (!addr) return "";
if (typeof addr === "string") return addr;
if (Array.isArray(addr)) return formatAddress(addr[0]);
if (addr["@type"] === "Place" && addr.address) return formatAddress(addr.address);
var parts = [addr.streetAddress, addr.postalCode, addr.addressLocality,
addr.addressRegion, addr.addressCountry];
return parts
.map(function (p) { return p && typeof p === "object" ? (p.name || "") : (p || ""); })
.filter(function (p) { return p; })
.join(", ");
}

var entreprise = "";
var localisation = "";

// 1) schema.org JobPosting (JSON-LD) — most reliable when present.
var scripts = document.querySelectorAll('script[type="application/ld+json"]');
Expand All @@ -34,7 +49,12 @@ function extractFromPage() {
if (isJob && node.hiringOrganization) {
var org = node.hiringOrganization;
var name = typeof org === "string" ? org : org.name;
if (name) { entreprise = String(name); break; }
if (name) { entreprise = String(name); }
}
// Zone géographique de l'offre (issue #52).
if (isJob && !localisation && node.jobLocation) {
var loc = Array.isArray(node.jobLocation) ? node.jobLocation[0] : node.jobLocation;
if (loc) localisation = formatAddress(loc.address || loc);
}
}
} catch (e) { /* ignore malformed JSON-LD */ }
Expand All @@ -43,10 +63,11 @@ function extractFromPage() {
// 2) Site-specific DOM selectors (pages without JSON-LD, e.g. LinkedIn signed-in).
if (!entreprise) {
var selectors = [
".topcard__org-name-link", // LinkedIn (déconnecté)
".topcard__flavor", // LinkedIn (déconnecté, variante)
".job-details-jobs-unified-top-card__company-name", // LinkedIn (connecté)
".jobs-unified-top-card__company-name", // LinkedIn (ancienne UI)
".topcard__org-name-link", // LinkedIn (déconnecté)
".topcard__flavor", // LinkedIn (déconnecté, variante)
".job-details-jobs-unified-top-card__company-name a", // LinkedIn (connecté, lien société)
".job-details-jobs-unified-top-card__company-name", // LinkedIn (connecté)
".jobs-unified-top-card__company-name", // LinkedIn (ancienne UI)
'[data-testid="inlineHeader-companyName"]', // Indeed
'[data-testid="company-name"]', // Indeed (variante)
".jobsearch-CompanyInfoContainer a", // Indeed (ancienne UI)
Expand All @@ -57,14 +78,50 @@ function extractFromPage() {
}
}

// 2bis) Sélecteurs DOM pour la localisation (issue #52).
if (!localisation) {
var locSelectors = [
".topcard__flavor--bullet", // LinkedIn (déconnecté)
".job-details-jobs-unified-top-card__bullet", // LinkedIn (ancienne UI connectée)
'[data-testid="inlineHeader-companyLocation"]', // Indeed
'[data-testid="job-location"]', // Indeed (variante)
'[data-testid="jobsearch-JobInfoHeader-companyLocation"]', // Indeed (ancienne UI)
".location", // génériques (APEC, Monster…)
];
for (var m = 0; m < locSelectors.length && !localisation; m++) {
localisation = text(locSelectors[m]);
}
}

// 2ter) LinkedIn (UI connectée récente, vues /jobs/view et /jobs/collections) :
// la localisation est le 1er segment (avant « · ») du conteneur de description
// sous le titre, mêlée à la date de publication et au nombre de candidats.
if (!localisation) {
var descSelectors = [
".job-details-jobs-unified-top-card__primary-description-container",
".job-details-jobs-unified-top-card__tertiary-description-container",
".jobs-unified-top-card__primary-description",
];
for (var p = 0; p < descSelectors.length && !localisation; p++) {
var raw = text(descSelectors[p]);
if (!raw) continue;
var segs = raw.split(/[·•|]/)
.map(function (s) { return s.replace(/\s+/g, " ").trim(); })
.filter(function (s) { return s; });
// Ignore un éventuel 1er segment qui répète le nom de l'entreprise.
if (segs.length && entreprise && segs[0] === entreprise) segs.shift();
if (segs.length) localisation = segs[0];
}
}

// 3) Open Graph — but ignore generic job-board names (LinkedIn, Indeed…).
if (!entreprise) {
var og = meta("og:site_name");
var generic = /linkedin|indeed|monster|cadr|apec|france.?travail|p[oô]le.?emploi|welcome to the jungle|glassdoor|hellowork/i;
if (og && !generic.test(og)) entreprise = og;
}

return { url: location.href, entreprise: entreprise.trim() };
return { url: location.href, entreprise: entreprise.trim(), localisation: localisation.trim() };
}

function sourceFromUrl(url) {
Expand Down Expand Up @@ -117,6 +174,7 @@ document.addEventListener("DOMContentLoaded", function () {
var data = results[0].result || {};
if (data.entreprise) document.getElementById("entreprise").value = data.entreprise;
if (data.url) document.getElementById("url").value = data.url;
if (data.localisation) document.getElementById("localisation").value = data.localisation;
}
);
});
Expand All @@ -131,6 +189,7 @@ document.addEventListener("DOMContentLoaded", function () {
var payload = {
url: document.getElementById("url").value.trim(),
entreprise: document.getElementById("entreprise").value.trim(),
localisation: document.getElementById("localisation").value.trim(),
source: current.source
};
btn.disabled = true;
Expand Down
36 changes: 36 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@
.row-actions a, .row-actions form { margin-right:0.5rem; }
.linklike { background:none; border:none; padding:0; color:var(--accent); cursor:pointer;
font:inherit; text-decoration:underline; }
/* Actions de ligne sous forme d'icônes (issue #52). */
.act-row { display:flex; gap:0.35rem; align-items:center; flex-wrap:nowrap; white-space:nowrap; }
.act-row form { margin:0; display:inline-flex; }
.icon-btn, .icon-btn:visited, .icon-btn:hover { display:inline-flex; align-items:center;
justify-content:center; width:34px; height:34px; padding:0; border:1px solid var(--border);
border-radius:var(--radius-sm); background:var(--card); color:var(--text); cursor:pointer;
font-size:1rem; line-height:1; text-decoration:none; box-shadow:var(--shadow);
transition:transform .12s ease, filter .15s, background .15s, border-color .15s; }
.icon-btn:hover { transform:translateY(-1px); background:var(--chip-bg); box-shadow:var(--shadow-lg); }
.icon-btn:active { transform:translateY(0); }
.icon-btn.danger:hover { background:rgba(224,88,75,0.16); border-color:#e0584b; }
/* Étoile « CV par défaut » (issue #52) : on/off graphique. */
.icon-btn.star { filter:grayscale(1); opacity:0.55; }
.icon-btn.star:hover { filter:none; opacity:1; }
.icon-btn.star.is-default { filter:none; opacity:1; border-color:var(--accent);
background:var(--accent-soft); }
/* Progress bars / steps, shared across pages */
.bar-track { display:block; background:var(--track); border-radius:6px; height:0.85rem; overflow:hidden; }
.bar-fill { display:block; height:100%; background:var(--accent); border-radius:6px; min-width:2px; }
Expand Down Expand Up @@ -193,6 +209,19 @@
.closed-row { opacity:0.55; }
.closed-row td { background:rgba(224,88,75,0.06); }
.src-logo { height:16px; width:16px; object-fit:contain; vertical-align:middle; margin-right:0.35rem; border-radius:3px; }
/* Logo + libellé de la source sur une seule ligne (issue #52). */
.src-cell { display:inline-flex; align-items:center; white-space:nowrap; }
/* Menu déroulant (téléchargement d'un CV par format, issue #52). */
.dd { position:relative; display:inline-block; }
.dd > summary { list-style:none; cursor:pointer; }
.dd > summary::-webkit-details-marker { display:none; }
.dd-menu { position:absolute; z-index:40; top:calc(100% + 4px); left:0; min-width:200px;
background:var(--card); border:1px solid var(--border); border-radius:var(--radius-sm);
box-shadow:var(--shadow-lg); padding:0.3rem; }
.dd-menu a { display:block; padding:0.42rem 0.6rem; border-radius:var(--radius-sm); color:var(--text);
text-decoration:none; font-size:0.9rem; white-space:nowrap; }
.dd-menu a:hover { background:var(--chip-bg); }
.dd-sep { height:1px; background:var(--border); margin:0.3rem 0; }
/* Toasts (issue #13) */
.toast-container { position:fixed; top:1rem; right:1rem; z-index:1500;
display:flex; flex-direction:column; gap:0.5rem; max-width:360px; }
Expand Down Expand Up @@ -321,6 +350,13 @@
<script>
var root = document.documentElement;

// Ferme les menus déroulants ouverts au clic à l'extérieur (issue #52).
document.addEventListener("click", function (e) {
document.querySelectorAll("details.dd[open]").forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute("open");
});
});

// Sidebar rétractable (issue #35) : état desktop persistant + tiroir mobile.
(function () {
var toggle = document.getElementById("sidebar-toggle");
Expand Down
1 change: 1 addition & 0 deletions templates/tracking/_candidature_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
</div>
{% include "tracking/_field.html" with field=form.cv %}
{% include "tracking/_field.html" with field=form.url_offre %}
{% include "tracking/_field.html" with field=form.localisation %}
<div class="grid2">
{% include "tracking/_field.html" with field=form.date_envoi %}
{% include "tracking/_field.html" with field=form.canal_envoi %}
Expand Down
17 changes: 17 additions & 0 deletions templates/tracking/_cv_download.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% comment %}
Menu déroulant de téléchargement d'un CV par format (issue #52).
Attend `cv` et, pour les exports structurés, `export_formats` (EXPORT_LABELS).
{% endcomment %}
<details class="dd">
<summary class="btn secondary">⬇️ Télécharger ▾</summary>
<div class="dd-menu">
<a href="{{ cv.file.url }}" target="_blank" rel="noopener">📄 Fichier original</a>
{% if cv.is_analyzed %}
<a href="{% url 'tracking:cv_print' cv.pk %}" target="_blank" rel="noopener">🖨️ PDF professionnel</a>
<div class="dd-sep"></div>
{% for fmt, label in export_formats.items %}
<a href="{% url 'tracking:cv_export' cv.pk fmt %}">🧩 {{ label }}</a>
{% endfor %}
{% endif %}
</div>
</details>
Loading
Loading