Analizator Dopasowania CV
Sprawdź swoje CV oczami systemu ATS
Ta aplikacja pozwala oszacować szanse przejścia wstępnej selekcji CV, symulując sposób działania systemów ATS (Applicant Tracking System), które są powszechnie wykorzystywane w procesach rekrutacyjnych.
Zamiast zgadywać, dlaczego Twoje CV nie dostaje odpowiedzi, możesz świadomie sprawdzić dopasowanie treści CV do konkretnego ogłoszenia o pracę.
Jak działa aplikacja?
- Wklejasz treść swojego CV
(plain text, Markdown lub HTML – format nie ma znaczenia) - Wklejasz treść ogłoszenia o pracę
-
Definiujesz słowa kluczowe i ich wagi
np.:php:3 symfony:3 mysql:2 docker:1 - Wybierasz jeden z czterech trybów analizy
- Podstawowy (exact match)
- Częstotliwość wystąpień
- Ważone wymagania
- ATS – rygorystyczny
- Aplikacja:
- analizuje występowanie słów kluczowych w CV i ogłoszeniu
- uwzględnia ich ważność (wagi)
- oblicza procentowe dopasowanie ATS
- pokazuje:
- znalezione słowa kluczowe
- brakujące elementy
Dzięki temu wiesz co dokładnie poprawić, zamiast edytować CV „na ślepo”.
Jak definiować słowa kluczowe i wagi
W polu „Słowa kluczowe i wagi” każda linia opisuje jedno słowo kluczowe lub frazę, którą chcesz uwzględnić w analizie dopasowania CV do ogłoszenia.
Format
słowo kluczowe:waga
Przykłady
php:3
php 8:3
symfony:3
laravel:2
javascript:3
react:3
mysql:2
git:2
Co oznacza waga?
Waga określa, jak ważne jest dane słowo kluczowe w końcowym wyniku dopasowania:
- 3 – technologia kluczowa / wymaganie podstawowe
- 2 – ważna, ale nie krytyczna
- 1 – mile widziana / dodatkowa
Im wyższa waga:
- tym większy wpływ na wynik procentowy
- tym większa strata punktów, jeśli słowo nie występuje w CV
Jak dobierać wagi w praktyce?
Wymagania „must have”
występują w ogłoszeniu wprost, często na początku listy
php:3
symfony:3
rest api:3
Wymagania „nice to have”
docker:2
ci/cd:2
Dodatki / atuty
aws:1
graphql:1
Ważne zasady
- Waga musi być liczbą całkowitą dodatnią
- Jeśli pominiesz wagę lub wpiszesz błędną wartość → zostanie przyjęta waga = 1
- Wielkość liter nie ma znaczenia
- Frazy wielowyrazowe są obsługiwane (np.
rest api,unit test)
Dobre praktyki
- Nie przypisuj wszystkim słowom wagi 3 — wynik przestanie być miarodajny
- Dopasuj wagi do konkretnego ogłoszenia
- Unikaj sztucznego „upychania” słów w CV — ATS to nie wszystko
Tryby analizy dopasowania CV do ogłoszenia
Aplikacja oferuje kilka trybów analizy, które w różny sposób symulują działanie systemów ATS (Applicant Tracking System). Każdy tryb odpowiada innemu „stylowi” oceny CV.
Podstawowy (Basic)
Najprostszy i najbardziej intuicyjny tryb.
- Sprawdza, czy dane słowo kluczowe występuje zarówno w CV, jak i w ogłoszeniu
- Nie bierze pod uwagę częstotliwości ani kontekstu
- Każde słowo liczy się tylko raz
Kiedy używać:
- szybka, orientacyjna ocena
- sprawdzenie, czy CV „łapie się” na wymagania
Jak myśli ATS:
„Czy kandydat w ogóle wspomina o wymaganej technologii?”
Częstotliwości (Frequency)
Tryb bardziej zbliżony do realnych ATS.
- Analizuje jak często dane słowo pojawia się w CV względem ogłoszenia
- Jeśli ogłoszenie mocno akcentuje daną technologię, a CV tylko ją wspomina – wynik będzie niższy
- Chroni przed tzw. keyword stuffing (spamowanie słowami)
Kiedy używać:
- gdy ogłoszenie jest długie i techniczne
- do dopracowania proporcji treści w CV
Jak myśli ATS:
„Jak ważna jest ta technologia w profilu kandydata?”
Ważony (Weighted)
Tryb strategiczny – wagi mają znaczenie.
- Każde słowo ma przypisaną wagę (np. core skill vs nice-to-have)
- Brak technologii o niskiej wadze boli mniej niż brak kluczowej
- Lepsze dopasowanie do realnych decyzji rekruterów
Kiedy używać:
- gdy świadomie ustawiasz priorytety technologii
- do symulacji „jak rekruter patrzy na CV”
Jak myśli ATS / rekruter:
„Brakuje drobiazgów, ale core skills się zgadzają.”
Rygorystyczny (Strict / ATS-Hardcore)
Najbardziej bezlitosny tryb.
- Brak kluczowych technologii (wysoka waga) aktywuje karę
- Nawet przy dobrym wyniku procentowym brak core skills może mocno obniżyć ocenę
- Symuluje ATS z filtrem „must-have”
Kiedy używać:
- przed aplikowaniem na oferty z dużą konkurencją
- jako test „czy CV w ogóle przejdzie pierwszy screening”
Jak myśli ATS:
„Brakuje wymaganych technologii → odrzucenie.”
Co oznacza wynik?
- 80–100% – wysokie dopasowanie, duża szansa przejścia ATS
- 60–79% – średnie dopasowanie, CV warto doprecyzować
- 40–59% – niskie dopasowanie, ryzyko odrzucenia
- < 40% – bardzo niskie dopasowanie, CV prawdopodobnie odpadnie na preselekcji
Wynik dotyczy dopasowania technicznego, nie oceny Twoich umiejętności.
Dla kogo jest to narzędzie?
- dla programistów i specjalistów IT
- dla osób szukających pierwszej pracy
- dla zmieniających stack technologiczny
- dla osób wysyłających CV przez formularze online
- dla tych, którzy chcą zrozumieć, jak działa ATS
Ważne informacje (disclaimery)
- To nie jest prawdziwy system ATS
Aplikacja symuluje podstawowy mechanizm scoringu na podstawie słów kluczowych. Każda firma i każdy ATS może działać inaczej. - Wysoki wynik ≠ gwarancja odpowiedzi
Ostateczna decyzja zawsze należy do:- rekrutera
- hiring managera
- zespołu technicznego
- Niski wynik ≠ słabe CV
Może oznaczać jedynie:- niedopasowany stack
- brak słów kluczowych użytych w ogłoszeniu
- inne nazewnictwo technologii
- Narzędzie nie ocenia talentu ani doświadczenia
Aplikacja nie mierzy kompetencji, a jedynie zgodność treści. - Wynik analizy jest orientacyjny i służy jako pomoc w optymalizacji treści CV. Aplikacja nie gwarantuje odpowiedzi od rekrutera ani zatrudnienia.
Prywatność
- aplikacja działa lokalnie w przeglądarce
- żadne dane nie są wysyłane na serwer
- Twoje CV nie jest zapisywane ani analizowane zewnętrznie
Jak korzystać najlepiej?
- analizuj każde ogłoszenie osobno
- dopasuj słowa kluczowe do konkretnej oferty
- poprawiaj CV świadomie, a nie przez „keyword stuffing”
- używaj narzędzia jako wsparcia, nie wyroczni
- zacznij od basic
- dopasuj proporcje w frequency
- ustal priorytety w weighted
- sprawdź ryzyko w strict
Wynik dopasowania
Dopasowanie ATS:
Znalezione słowa kluczowe
Brakujące słowa kluczowe
Kod po stronie przeglądarki
<style>
[data-bs-theme=golden] .text-success {
color: #17804f !important;
}
[data-bs-theme=golden] .text-danger {
color: #d13241 !important;
}
[data-bs-theme=twilight] .text-success {
color: #1eaa69 !important;
}
[data-bs-theme=twilight] .text-danger {
color: #ff4c65 !important;
}
[data-bs-theme=dark] .text-success {
color: #1da264 !important;
}
[data-bs-theme=dark] .text-danger {
color: #ff4359 !important;
}
</style>
<div id="app">
<div class="row">
<div class="col-md-6 mb-3">
<label for="cvText" class="form-label">Treść CV</label>
<textarea id="cvText" class="form-control" rows="12"
placeholder="Wklej tutaj treść CV (plain text / markdown / HTML)"></textarea>
</div>
<div class="col-md-6 mb-3">
<label for="jobText" class="form-label">Treść ogłoszenia</label>
<textarea id="jobText" class="form-control" rows="12"
placeholder="Wklej tutaj treść ogłoszenia o pracę"></textarea>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="keywordsInput" class="form-label">Słowa kluczowe i wagi</label>
<textarea id="keywordsInput" class="form-control" rows="8">
php:3
symfony:3
mysql:2
docker:1
</textarea>
<div class="form-text">
Format: słowo:waga (jedna linia = jedno słowo kluczowe)
</div>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="analysisMode" class="form-label">Tryb analizy</label>
<select id="analysisMode" class="form-select">
<option value="basic">Podstawowy (exact match)</option>
<option value="frequency">Częstotliwość wystąpień</option>
<option value="weighted">Ważone wymagania</option>
<option value="strict">ATS – rygorystyczny</option>
</select>
<small class="text-muted">
Tryb rygorystyczny symuluje ATS z twardymi wymaganiami „must have”.
</small>
</div>
</div>
<button class="btn btn-primary w-100 mb-4" onclick="analyze()">Analizuj dopasowanie</button>
<div id="result" class="d-none">
<h2 class="h4">Wynik dopasowania</h2>
<div class="progress mb-3">
<div id="progressBar" class="progress-bar" role="progressbar" aria-label="Score"></div>
</div>
<p>
<strong>Dopasowanie ATS:</strong>
<span id="scoreText"></span>
</p>
<div class="row">
<div class="col-md-6">
<h3 class="h5 text-success">Znalezione słowa kluczowe</h3>
<ul id="foundList"></ul>
</div>
<div class="col-md-6">
<h3 class="h5 text-danger">Brakujące słowa kluczowe</h3>
<ul id="missingList"></ul>
</div>
</div>
<div id="verdict"></div>
</div>
</div>
<script>
// --- Konfiguracja punktów kolorów (HUE) ---
// 100% -> success (zielony), 66% -> warning (żółty), 33% -> orange, 0% -> danger (czerwony)
const stops = [{
p: 100,
h: 140
}, // zielony
{
p: 66,
h: 50
},
{
p: 33,
h: 30
}, // pomarańcz
{
p: 0,
h: 0
} // czerwony
];
// Naprawa literówki: zamieniamy funkcję pomocniczą na stałą wartość 50 (żółty)
stops[1].h = 50; // żółty
const progressBar = document.getElementById('progressBar');
// Interpolacja hue między punktami (procent w [0..100])
function hueForPercent(pct) {
for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i],
b = stops[i + 1];
if (pct <= a.p && pct >= b.p) {
const span = a.p - b.p;
const t = span === 0 ? 0 : (a.p - pct) / span; // 0..1
return a.h + (b.h - a.h) * t;
}
}
return stops[stops.length - 1].h;
}
// HSL -> RGB (0..255)
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
s /= 100;
l /= 100;
if (s === 0) {
const v = Math.round(l * 255);
return [v, v, v];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hk = h / 360;
const t = [hk + 1 / 3, hk, hk - 1 / 3];
const rgb = t.map(tc => {
if (tc < 0) tc += 1;
if (tc > 1) tc -= 1;
if (tc < 1 / 6) return p + (q - p) * 6 * tc;
if (tc < 1 / 2) return q;
if (tc < 2 / 3) return p + (q - p) * (2 / 3 - tc) * 6;
return p;
});
return rgb.map(v => Math.round(v * 255));
}
// Relatywna luminancja zgodnie z WCAG
function relLuminance(r, g, b) {
const toLin = c => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b);
}
// Kontrast ratio między dwoma kolorami (rgb 0..255)
function contrastRatio(rgb1, rgb2) {
const L1 = relLuminance(rgb1[0], rgb1[1], rgb1[2]);
const L2 = relLuminance(rgb2[0], rgb2[1], rgb2[2]);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
// Zapewnij kontrast: dla danego (h,s,lStart) znajdź l w okolicy, by biały lub czarny miały ratio >= 4.5
function ensureWcag(h, s, lStart) {
const targetRatio = 4.5;
// Spróbuj najpierw bez zmiany l
for (const textRgb of [
[255, 255, 255],
[0, 0, 0]
]) {
const rgb = hslToRgb(h, s, lStart);
if (contrastRatio(rgb, textRgb) >= targetRatio) return {
l: lStart,
textRgb
};
}
// Jeśli nie, skanuj w górę i w dół od lStart w kroku 1
let best = null;
for (let delta = 1; delta <= 40; delta++) {
for (const dir of [1, -1]) {
const l = Math.min(90, Math.max(10, lStart + dir * delta));
const rgb = hslToRgb(h, s, l);
for (const textRgb of [
[255, 255, 255],
[0, 0, 0]
]) {
const ratio = contrastRatio(rgb, textRgb);
if (ratio >= targetRatio) return {
l,
textRgb
};
if (!best || ratio > best.ratio) best = {
l,
textRgb,
ratio
};
}
}
}
// Jeśli nic nie osiągnęło 4.5, zwróć najlepsze co mieliśmy
return {
l: best.l,
textRgb: best.textRgb
};
}
// Aktualizuj pasek na podstawie wartości (0..100)
function updateProgress(v) {
const pct = Math.max(0, Math.min(100, Math.round(v)));
const hue = hueForPercent(pct);
const saturation = 75; // %
const baseLightness = 47; // preferowana jasność
const wcag = ensureWcag(hue, saturation, baseLightness);
const finalL = wcag.l;
const textRgb = wcag.textRgb;
// Ustaw styl (używamy modern hsl bez przecinków)
progressBar.style.background = `hsl(${Math.round(hue)} ${saturation}% ${Math.round(finalL)}%)`;
progressBar.style.width = pct + '%';
progressBar.setAttribute('aria-valuenow', String(pct));
// Tekst: procent + ewent. aria-label
progressBar.textContent = pct + '%';
// Ustaw kolor tekstu na biały lub czarny wg znalezionego najlepszego
const textColor = (textRgb[0] === 255 && textRgb[1] === 255 && textRgb[2] === 255) ? '#fff' : '#000';
progressBar.style.color = textColor;
// Dla dodatkowego kontrastu używamy lekkiego cienia tekstu gdy tekst jest biały
if (textColor === '#fff') progressBar.style.textShadow = '0 1px 0 rgba(0,0,0,0.25)';
else progressBar.style.textShadow = 'none';
}
function parseKeywords(input) {
return input
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0 && line.includes(":"))
.map(line => {
const [term, weight] = line.split(":");
return {
term: term.trim().toLowerCase(),
weight: parseInt(weight.trim(), 10) || 1
};
});
}
function normalize(text) {
return text
.toLowerCase()
.replace(/<[^>]*>/g, " ")
.replace(/[^a-z0-9ąćęłńóśżź+\/\s]/gi, " ");
}
function analyze() {
const mode = document.getElementById("analysisMode").value;
switch (mode) {
case "frequency":
return analyzeFrequency(mode);
case "weighted":
return analyzeWeighted(mode);
case "strict":
return analyzeStrict(mode);
default:
return analyzeBasic(mode);
}
}
function analyzeBasic(mode) {
return analyzeTemplate(
(cvText, jobText, k) =>
cvText.includes(k.term) && jobText.includes(k.term),
0,
mode
);
}
function analyzeFrequency(mode) {
return analyzeTemplate((cvText, jobText, k) => {
const cvCount = countOccurrences(cvText, k.term);
const jobCount = countOccurrences(jobText, k.term);
return Math.min(cvCount / Math.max(jobCount, 1), 1);
}, 0, mode);
}
function countOccurrences(text, term) {
return text.split(term).length - 1;
}
function analyzeWeighted(mode) {
return analyzeTemplate((cvText, jobText, k) => {
if (!jobText.includes(k.term)) return 0;
if (!cvText.includes(k.term)) return 0;
return k.weight >= 3 ? 1 : 0.7;
}, 0, mode);
}
function analyzeStrict(mode) {
return analyzeTemplate(
(cvText, jobText, k) => {
if (k.weight >= 3 && !cvText.includes(k.term)) {
return -1; // sygnał kary
}
return cvText.includes(k.term) ? 1 : 0;
},
0,
mode
);
}
function getExpectedFactor(mode, k) {
switch (mode) {
case "basic":
return 1;
case "frequency":
return 1; // max to 100% pokrycia częstotliwości
case "weighted":
return k.weight >= 3 ? 1 : 0.7;
case "strict":
return 1; // ale penalty odejmie później
default:
return 1;
}
}
function analyzeTemplate(matchFn, pen = 0, mode = "basic") {
const cvText = normalize(document.getElementById("cvText").value);
const jobText = normalize(document.getElementById("jobText").value);
const KEYWORDS = parseKeywords(
document.getElementById("keywordsInput").value
);
let score = 0;
let maxScore = 0;
const found = [];
const missing = [];
let penalty = pen;
KEYWORDS.forEach(k => {
const expected = getExpectedFactor(mode, k);
maxScore += k.weight * expected;
const factor = matchFn(cvText, jobText, k);
if (factor === -1) {
penalty += 0.2;
missing.push(k.term + " (MUST-HAVE)");
return;
}
if (factor > 0) {
score += k.weight * factor;
found.push(`${k.term} (+${Math.round(k.weight * factor)})`);
} else {
missing.push(k.term);
}
});
score = Math.max(score - penalty * maxScore, 0);
const percent = Math.round((score / maxScore) * 100);
renderResult(percent, found, missing);
}
function renderResult(percent, found, missing) {
document.getElementById("result").classList.remove("d-none");
updateProgress(percent);
document.getElementById("scoreText").textContent = percent + "%";
document.getElementById("foundList").innerHTML =
found.map(f => `<li>${f}</li>`).join("");
document.getElementById("missingList").innerHTML =
missing.map(m => `<li>${m}</li>`).join("");
renderVerdict(percent);
}
function renderVerdict(percent) {
let verdict = "";
let css = "";
if (percent >= 80) {
verdict = '<i class="bi bi-check-circle"></i> Wysokie dopasowanie – duża szansa przejścia ATS.';
css = 'success';
} else if (percent >= 60) {
verdict = '<i class="bi bi-exclamation-triangle"></i> Średnie dopasowanie – warto poprawić CV.';
css = 'warning';
} else if (percent >= 40) {
verdict = '<i class="bi bi-exclamation-octagon"></i> Niskie dopasowanie – CV prawdopodobnie odpadnie.';
css = 'orange';
} else {
verdict = '<i class="bi bi-x-circle"></i> Bardzo niskie dopasowanie – brak kluczowych technologii.';
css = 'danger';
}
const verdictEl = document.getElementById("verdict");
verdictEl.innerHTML = verdict;
verdictEl.setAttribute("class", "mt-3 alert alert-" + css);
}
</script>
Kod po stronie serwera
Brak kodu serwera
Ta aplikacja działa wyłącznie w przeglądarce i nie korzysta z kodu po stronie serwera.
Licencja
## BSD-3-Clause License Agreement
BSD-3-Clause
Сopyright (c) 2026 Dariusz Rorat
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.