Przejdź do głównej treści

Analizator Dopasowania CV

Grafika SVG 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?

  1. Wklejasz treść swojego CV
    (plain text, Markdown lub HTML – format nie ma znaczenia)
  2. Wklejasz treść ogłoszenia o pracę
  3. Definiujesz słowa kluczowe i ich wagi
    np.:

    php:3
    symfony:3
    mysql:2
    docker:1
  4. Wybierasz jeden z czterech trybów analizy
    • Podstawowy (exact match)
    • Częstotliwość wystąpień
    • Ważone wymagania
    • ATS – rygorystyczny
  5. 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)

  1. 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.
  2. Wysoki wynik ≠ gwarancja odpowiedzi
    Ostateczna decyzja zawsze należy do:
    • rekrutera
    • hiring managera
    • zespołu technicznego
  3. Niski wynik ≠ słabe CV
    Może oznaczać jedynie:
    • niedopasowany stack
    • brak słów kluczowych użytych w ogłoszeniu
    • inne nazewnictwo technologii
  4. Narzędzie nie ocenia talentu ani doświadczenia
    Aplikacja nie mierzy kompetencji, a jedynie zgodność treści.
  5. 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
Format: słowo:waga (jedna linia = jedno słowo kluczowe)
Tryb rygorystyczny symuluje ATS z twardymi wymaganiami „must have”.

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.
      24 stycznia 2026 3

      Kategorie

      Technologie

      Dziękujemy!
      ()

      Informacja o cookies

      Moja strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania. Nie używam ciasteczek w celach marketingowych ani analitycznych. Korzystając z mojej strony, wyrażasz zgodę na stosowanie tych plików. Możesz dowiedzieć się więcej w mojej polityce prywatności.