DEV Community

Louis Desclous
Louis Desclous

Posted on Fully Autonomous

From a folder of CVs to a ranked shortlist in about 80 lines of Python

Screening is the part of hiring that scales worst. Ten CVs for one role is an afternoon; a hundred is a week. This tutorial builds a small Python script that takes a folder of resumes and a job description, and prints a ranked shortlist with a reason for each position.

It uses the HireLayer recruiting APIs, which I build. Everything below runs on the free plan (50 credits a month, no card), and each successful call costs one credit.

What we are building

cvs/                     job.txt
 ├─ alice.pdf               │
 ├─ bob.docx                ▼
 └─ chloe.png        extract criteria ──┐
      │                                  │
      ▼                                  ▼
 parse each CV ──► resume text ──► match each candidate  ──► per-criterion evidence
                          │
                          └──────► rank all candidates  ──► ordered shortlist
Enter fullscreen mode Exit fullscreen mode

Four endpoints, all synchronous JSON (the reference is at hirelayer.co/api-docs, with an OpenAPI spec if you prefer generating a client):

Step Endpoint Returns
Parse a CV POST /api/v3/parser Structured profile + full text in info_resume.text
Job criteria POST /api/v1/jobs/extract-criteria matching_criteria[] with a weight from 1 to 3
Match POST /api/v1/matching/job-candidate score from 0 to 1 + one verdict per criterion
Rank POST /api/v1/matching/job-candidates/rank Up to 10 candidates ordered by rank

Setup

pip install requests
export HIRELAYER_API_KEY="your-key"   # from the dashboard after signing up
Enter fullscreen mode Exit fullscreen mode

1. Parse the resumes

The parser accepts PDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, JPG, PNG and BMP files under 4.5 MB, and detects the type from the content. do_not_store_data=true tells the API not to keep the file after parsing, which is what you want for a local script.

import os
from pathlib import Path

import requests

API = "https://hirelayer.co"
HEADERS = {"X-API-Key": os.environ["HIRELAYER_API_KEY"]}


def parse_resume(path: Path) -> dict:
    with path.open("rb") as f:
        r = requests.post(
            f"{API}/api/v3/parser",
            headers=HEADERS,
            files={"file": (path.name, f)},
            data={"do_not_store_data": "true"},
            timeout=120,
        )
    r.raise_for_status()
    return r.json()
Enter fullscreen mode Exit fullscreen mode

The response has the candidate profile (info_candidate), work_experiences, educations, languages, skills and the full text, including OCR for scanned or image CVs, in info_resume.text. That text is what the matching endpoints take.

2. Turn the job description into criteria

def extract_criteria(job_text: str) -> list[dict]:
    r = requests.post(
        f"{API}/api/v1/jobs/extract-criteria",
        headers=HEADERS,
        json={"job_text": job_text},
        timeout=60,
    )
    r.raise_for_status()
    return r.json()["matching_criteria"]
Enter fullscreen mode Exit fullscreen mode

A sentence like "React required. Fluent English required. Paris-based." comes back as three criteria, sorted by weight, each with id, label, weight (3 essential, 2 important, 1 nice to have), is_mandatory and a rationale. You can edit the list before matching: drop a criterion, change a weight, add your own with any id.

3. Score one candidate, criterion by criterion

def match(job_text: str, resume_text: str, criteria: list[dict]) -> dict:
    r = requests.post(
        f"{API}/api/v1/matching/job-candidate",
        headers=HEADERS,
        json={
            "job_text": job_text,
            "candidate_text": resume_text,
            "matching_criteria": criteria,
        },
        timeout=60,
    )
    r.raise_for_status()
    return r.json()
Enter fullscreen mode Exit fullscreen mode

Each entry of evaluated_criteria gets a match_status (ideal, potential or not_mentioned) and a match_explanation quoting the evidence, and score is the weighted average. This is the call to use when a recruiter needs to see why, not just a number. Note that the summary and explanations are currently written in French.

4. Rank the whole batch

Ranking compares candidates against each other for the same job, up to 10 per call:

def rank(job_text: str, resumes: dict[str, str]) -> list[dict]:
    r = requests.post(
        f"{API}/api/v1/matching/job-candidates/rank",
        headers=HEADERS,
        json={
            "job_text": job_text,
            "candidates": [
                {"id": cid, "candidate_text": text} for cid, text in resumes.items()
            ],
        },
        timeout=120,
    )
    r.raise_for_status()
    return r.json()["rankings"]
Enter fullscreen mode Exit fullscreen mode

Putting it together

def main(cv_dir: str = "cvs", job_file: str = "job.txt") -> None:
    job_text = Path(job_file).read_text()
    resumes = {
        p.stem: parse_resume(p)["info_resume"]["text"]
        for p in sorted(Path(cv_dir).iterdir())
        if p.is_file()
    }

    criteria = extract_criteria(job_text)
    print("Criteria:")
    for c in criteria:
        print(f"  [{c['weight']}] {c['label']}")

    rankings = rank(job_text, resumes)
    print("\nShortlist:")
    for row in rankings:
        print(f"  #{row['rank']} {row['candidate_id']}  ({row['score']:.2f})  {row['rationale']}")

    best = rankings[0]["candidate_id"]
    detail = match(job_text, resumes[best], criteria)
    print(f"\n{best}: {detail['score']:.2f}")
    for c in detail["evaluated_criteria"]:
        print(f"  {c['match_status']:>13}  {c['label']}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

For 8 CVs this costs 8 (parse) + 1 (criteria) + 1 (rank) + 1 (match) = 11 credits. In a real pipeline you would cache the parsed text so a CV is only parsed once, even if it is matched against several jobs.

Practical notes

  • Batches over 10. Rank takes 10 candidates per call. For larger pools, score everyone with Match first and send the top 10 to Rank.
  • Errors. Non-2xx responses carry a JSON error. Treat 429 as retryable with a backoff; a 401 means the X-API-Key header is missing or wrong (Authorization: Bearer is not accepted).
  • Skills. If you store profiles, POST /api/v1/skills/resolve maps free-text skills ("Pack Office (Word, Excel)", "React.js") to taxonomy IDs, which makes search and facets much easier than raw strings.

Using it from an AI assistant instead

If you would rather ask Claude, Cursor or VS Code to "rank the CVs in ~/candidates for this job", the same five endpoints are wrapped in an open-source MCP server: github.com/louisdesc/hirelayer-mcp.

Questions or edge cases you hit with real CVs are very welcome in the comments.

Top comments (0)