Build an AI-Powered AWS Job Hunter

Search AWS jobs and auto-tailor your resume with Gemini AI.

Introduction

30 Second Summary

Applying for jobs means tailoring your resume for every single posting. Doing this manually is slow, repetitive, and easy to get wrong.

In this project, you will build a web app that searches for AWS DevOps and Solutions Architect jobs across global job boards. The app uses AI to analyze your resume and GitHub projects, then generates a tailored, ATS-optimized resume for any job you pick.

What You'll Build

A Streamlit web application that pulls live job postings via SerpApi, analyzes your resume and GitHub profile, and uses Google Gemini AI to generate tailored, ATS-optimized resumes with PDF export.

By the end of this project, you'll have:

  • A job search engine that fetches live AWS role postings from LinkedIn, Indeed, Glassdoor, and dozens more through a single API call.
  • A profile ingestion pipeline that extracts your resume text and fetches your top GitHub projects with stars, READMEs, and tech stacks.
  • An AI resume tailor powered by Google Gemini that generates job-specific resumes in the correct language, with ATS keyword scoring and a downloadable PDF.
  • Secret Mission: Integrate the Saramin Korean job board API as an additional data source for Korean-language job postings.

Are there any prerequisites?

You should be comfortable running basic terminal commands. Python experience is helpful but not required.

You'll also need a free SerpApi account and a free Google AI Studio account. You'll set both of these up in Step 1.

Before We Start

Before We Start...

Set Up Your Project and API Keys

Every project starts with a solid foundation. Before building any features, you need a dedicated folder, an isolated Python environment, and the right packages installed.

You also need API keys for the two services that power this tool. SerpApi pulls live job listings from Google Jobs. Google Gemini generates your tailored resumes.

In this step, get ready to:
  • Create your project folder, virtual environment, and install all Python packages.
  • Set up free accounts for SerpApi and Google Gemini.
  • Store your API keys securely and verify they work.
Create your project and install dependencies

Your project needs its own folder and a Python virtual environment. A virtual environment keeps this project's packages separate from your system Python, so nothing conflicts.

  • Open your terminal. Press the Super key to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir aws-job-hunter
cd aws-job-hunter
  • Set up a Python virtual environment and activate it by running these commands:
python3 -m venv venv
source venv/bin/activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Getting a 'No module named venv' error?

On some Ubuntu installations, you need to install the venv module separately.

  • Install it by running:
sudo apt install python3-venv -y
  • Then try creating the virtual environment again with the two commands above.

Help me fix my Python virtual environment setup on Ubuntu.

  • Install all the Python packages your project needs by running:
pip install streamlit serpapi google-genai PyPDF2 fpdf2 requests python-dotenv

What do these packages do?

  • streamlit builds your web app interface.
  • serpapi connects to SerpApi's Google Jobs search engine.
  • google-genai is the official SDK for Google's Gemini AI models.
  • PyPDF2 reads text from PDF resume files.
  • fpdf2 generates downloadable PDF files from the tailored resume.
  • requests makes HTTP calls to the GitHub API.
  • python-dotenv loads your API keys from a .env file.

You should see a Successfully installed message at the end of the output listing all seven packages.

Seeing installation errors?

Make sure your virtual environment is active. You should see (venv) in your terminal prompt.

If you see a permissions error, make sure you are NOT using sudo with pip. The virtual environment handles permissions for you.

Help me fix my pip install errors for this project.

  • Install Korean font support for PDF generation by running:
sudo apt install fonts-noto-cjk -y

Why install Korean fonts?

Your job hunter tool can find Korean-language job postings. When the AI generates a resume in Korean, the PDF generator needs these fonts to render Korean characters correctly.

Sign up for SerpApi and Google AI Studio

Your app needs API keys to access SerpApi for job search and Google AI Studio for AI resume generation. Both services have generous free tiers that cost nothing.

First, get your SerpApi key.

  • Open serpapi.com in your browser.
  • Click Register in the top navigation bar.
  • Create an account using Sign up with Google, Sign up with Github, or a manual email and password.
  • After signing in, you land on your dashboard.
  • Your API key is displayed on the dashboard page. Copy it and save it somewhere temporary, like a text file.

Next, get your Gemini API key.

  • Open aistudio.google.com in your browser.
  • Sign in with your Google account.
  • Click Get API key on the main page (or navigate to it from the left sidebar).
  • Click Create API key.
  • If prompted, select any Google Cloud project or let it create one for you automatically.
  • Copy the generated API key and save it alongside your SerpApi key.

Free tier limits

SerpApi gives you 250 free searches per month. Google Gemini provides free input and output tokens with no billing account required.

These free tiers are more than enough to build and test your project.

Why SerpApi instead of scraping job boards directly?

LinkedIn, Glassdoor, and Indeed all block or restrict direct scraping. SerpApi's Google Jobs engine aggregates listings from all these sources legally through Google's job search index.

One API call gives you results from dozens of job boards at once.

Having trouble creating a Gemini API key?

If you see a permission denied error, try clearing your browser cache and signing in again. Make sure you are using a personal Google account, not a managed workspace account.

For new users, Google AI Studio automatically creates a default project and API key after you accept the Terms of Service.

Help me troubleshoot my Google AI Studio API key creation issue.

Store your API keys and verify the setup

Your API keys should never be hardcoded directly in your Python files. Instead, you'll store them in a .env file and load them with a config module.

  • Open your project folder in Cursor by running this command from your terminal:
cursor .

Cursor command not found?

Open Cursor manually. Press the Super key, type Cursor, and press Enter. Then click File in the top menu, select Open Folder, and choose the aws-job-hunter folder on your Desktop.

  • Create a new file by pressing Ctrl+N.
  • Save it as .env by pressing Ctrl+S and entering .env as the filename.
  • Add the following to your .env file:
SERPAPI_KEY=your-serpapi-key-here
GEMINI_API_KEY=your-gemini-api-key-here
  • Replace your-serpapi-key-here with the SerpApi API key you copied earlier.
  • Replace your-gemini-api-key-here with the Gemini API key you copied earlier.
  • Save the file by pressing Ctrl+S.

What is a .env file?

A .env file stores sensitive configuration values like API keys outside your code. The python-dotenv package reads this file and makes the values available as environment variables.

This keeps your secrets out of your source code. If you ever share your project, the .env file stays private.

  • Create another new file by pressing Ctrl+N.
  • Save it as config.py.
  • Add the following code to config.py:
import os
from dotenv import load_dotenv

# Read the .env file and load its values into the environment
load_dotenv()

# Store API keys as variables for other modules to import
SERPAPI_KEY = os.getenv("SERPAPI_KEY")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")

What does this code do?

  • load_dotenv() reads your .env file and loads its key-value pairs into the environment.
  • os.getenv("SERPAPI_KEY") retrieves the SerpApi key from the environment and stores it in a variable.
  • Other modules in your project will import SERPAPI_KEY and GEMINI_API_KEY directly from this file.
  • Save config.py.

Time to verify everything works. You'll create a quick test script that calls both APIs.

  • Create one more new file and save it as test_setup.py.
  • Add the following code to test_setup.py:
import serpapi
import os
from google import genai
from config import SERPAPI_KEY, GEMINI_API_KEY

# Test SerpApi connection
print("Testing SerpApi...")
client = serpapi.Client(api_key=SERPAPI_KEY)
result = client.search({"engine": "google_jobs", "q": "test", "location": "United States"})
print(f"SerpApi works! Found {len(result.get('jobs_results', []))} jobs.")

# Test Gemini API connection
print("Testing Gemini API...")
os.environ["GEMINI_API_KEY"] = GEMINI_API_KEY
gemini_client = genai.Client()
response = gemini_client.models.generate_content(
    model="gemini-2.0-flash",
    contents="Say hello in one word.",
)
print(f"Gemini works! Response: {response.text}")

print("All set! Both APIs are working.")

What does this test do?

  • It imports your API keys from config.py to confirm the config module loads correctly.
  • It runs a test search on SerpApi's Google Jobs engine and prints the number of results found.
  • It sends a simple prompt to Gemini and prints the AI's response.
  • Save test_setup.py.
  • Open the integrated terminal in Cursor by pressing Ctrl+` (backtick).
  • Make sure your virtual environment is active. You should see (venv) in the prompt. If not, activate it by running:
source venv/bin/activate
  • Run the test script:
python test_setup.py

You should see output confirming both APIs are working. SerpApi prints the number of jobs found, and Gemini prints a short greeting response.

Seeing an error?

  • If you see ModuleNotFoundError, make sure your virtual environment is active and you installed all packages with pip install.
  • If you see Invalid API key for SerpApi, double-check your SERPAPI_KEY value in .env. Make sure there are no extra spaces around the = sign.
  • If Gemini returns an error, verify that your GEMINI_API_KEY in .env is correct and has no trailing whitespace.

Help me debug my test_setup.py API verification errors.

Once both tests pass, you can delete test_setup.py. It was only needed for verification.

✔️ Awesome, I've got everything!

Make sure you've saved both .env and config.py. These two files are the foundation for every module you build next.

ⓧ I'd like to double check the full code

Here is what your .env file should look like (with your actual API keys in place of the placeholders):

SERPAPI_KEY=your-serpapi-key-here
GEMINI_API_KEY=your-gemini-api-key-here

Here is what config.py should look like:

import os
from dotenv import load_dotenv

load_dotenv()

SERPAPI_KEY = os.getenv("SERPAPI_KEY")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")

Your environment is locked and loaded. Next up, you'll build the job search module that pulls live AWS job postings from dozens of job boards.

Build the Job Search Module

Your development environment is ready and both API keys are in place. Now it's time to build the first real piece of your job search tool.

Searching for AWS roles across LinkedIn, Indeed, Glassdoor, and other job boards manually takes hours. In this step, you'll create a Python module that pulls live job postings from all of these sources with a single function call using SerpApi.

In this step, get ready to:
  • Query the SerpApi Google Jobs API for AWS roles.
  • Parse job data into a clean, structured format.
  • Search multiple role variants with automatic deduplication.
Create the job search function

SerpApi's Google Jobs engine aggregates listings from LinkedIn, Indeed, Glassdoor, and dozens of other sources into a single API. Instead of scraping each board individually, you send one query and get results from across the web.

  • Create a new file called job_search.py in your project folder.
  • Add the following code to job_search.py:
import serpapi
from config import SERPAPI_KEY


def search_jobs(query, location="South Korea", gl="kr", hl="ko"):
    # Create a SerpApi client using your API key
    client = serpapi.Client(api_key=SERPAPI_KEY)

    # Send a search request to the Google Jobs engine
    results = client.search({
        "engine": "google_jobs",
        "q": query,
        "location": location,
        "gl": gl,
        "hl": hl,
    })

    # Extract the list of job postings from the response
    jobs = results.get("jobs_results", [])

    # Parse each job into a clean dictionary
    parsed = []
    for job in jobs:
        parsed.append({
            "title": job.get("title", ""),
            "company": job.get("company_name", ""),
            "location": job.get("location", ""),
            "description": job.get("description", ""),
            "source": job.get("via", ""),
            "extensions": job.get("extensions", []),
            "posted": job.get("detected_extensions", {}).get("posted_at", ""),
            "schedule": job.get("detected_extensions", {}).get("schedule_type", ""),
        })
    return parsed

What does this code do?

  • serpapi.Client() creates a client using your SerpApi API key loaded from config.py.
  • client.search() sends a query to the Google Jobs engine. The q parameter is your search term, location targets a geographic area, and gl/hl set the Google country and language codes.
  • The response comes back as a dictionary. jobs_results contains the list of job postings.
  • The for loop extracts the fields you care about from each raw job object and stores them in a clean dictionary.

Why SerpApi instead of scraping job boards?

LinkedIn, Indeed, and Glassdoor all block or restrict direct API access. LinkedIn's Jobs API is partner-only. Glassdoor's public API shut down in 2024.

SerpApi solves this by querying through Google Jobs search, which indexes listings from all these boards. One API call gives you results from dozens of sources.

  • Save job_search.py.
  • You won't see any output yet. The next substep tests the function.

Seeing an import error?

  • If you see ModuleNotFoundError: No module named 'serpapi', make sure your virtual environment is activated. Run pip list to confirm serpapi appears in the output.
  • If you see ModuleNotFoundError: No module named 'config', make sure config.py is in the same folder as job_search.py.
  • Help me fix my import error in job_search.py
Test the job search results

Your search_jobs() function queries the API and parses the response into a list of dictionaries. Each dictionary captures the key details from SerpApi's raw response in a cleaner format.

What fields are we extracting?

  • title and company come from title and company_name in the raw response.
  • source comes from the via field, which tells you where the listing was originally posted (e.g., "via LinkedIn").
  • posted and schedule are nested inside detected_extensions. SerpApi automatically extracts these from the job posting text.
  • extensions is a list of tags like salary range, "Full-time", or "No degree required".
  • Test your function by running this command:
python -c "from job_search import search_jobs; jobs = search_jobs('AWS DevOps Engineer'); print(len(jobs), 'jobs found')"

You should see output like 10 jobs found. The exact number varies based on available listings for your chosen location.

Each test uses a free search

This test uses 1 of your 250 free monthly SerpApi searches. You have plenty to work with, but keep it in mind during development.

Seeing 0 jobs or an error?

  • Check that your SERPAPI_KEY in .env matches the key from your SerpApi dashboard. Extra spaces or missing characters will cause authentication to fail.
  • If you get 0 results for "South Korea", try changing the location to United States to verify the API connection works. Some locations have fewer AWS-specific listings.
  • Help me debug my SerpApi job search returning 0 results or an error
Search multiple role variants with deduplication

A single search query like "AWS DevOps Engineer" only captures one slice of available roles. The same type of job might be listed under different titles like "Cloud Engineer AWS" or "Solutions Architect".

To catch more results, you'll add a function that searches for four different role variants and combines the results. It also removes duplicates so the same job doesn't appear twice.

  • In job_search.py, add this function below search_jobs():
def search_multiple_roles(location="South Korea", gl="kr", hl="ko"):
    # Define four AWS role variants to search
    queries = [
        "Junior AWS DevOps Engineer",
        "AWS Solutions Architect",
        "Junior Cloud Engineer AWS",
        "AWS DevOps Engineer",
    ]
    all_jobs = []
    seen = set()

    # Search each variant and deduplicate by (title, company)
    for query in queries:
        jobs = search_jobs(query, location, gl, hl)
        for job in jobs:
            key = (job["title"].lower().strip(), job["company"].lower().strip())
            if key not in seen:
                seen.add(key)
                all_jobs.append(job)
    return all_jobs

What does this code do?

  • queries is a list of four AWS role variants. Each one targets a slightly different job title to cast a wider net across job boards.
  • The function calls search_jobs() once per query variant and collects all the results.
  • seen is a set that tracks which (title, company) pairs have already been added. If the same job appears in multiple search results, it only gets included once.
  • Save job_search.py.
  • Test the multi-role search by running this command:
python -c "from job_search import search_multiple_roles; jobs = search_multiple_roles(); print(len(jobs), 'unique jobs found')"

You should see a higher number of unique jobs compared to the single query test, with duplicates automatically removed.

This test runs four searches

Because search_multiple_roles() calls search_jobs() four times (once per query variant), this test uses 4 of your 250 free monthly SerpApi searches.

Getting fewer results than expected?

  • Some locations have fewer AWS-specific listings. Try changing the location to United States with gl="us" and hl="en" to verify the function works with a larger job market.
  • If you see the same number as the single-query test, the other query variants may not have additional results for your chosen location. This is expected for smaller markets.
  • Help me understand why my multi-role job search has low results

✔️ Awesome, I've got everything!

Great. Double check you've saved job_search.py with both functions.

ⓧ I'd like to double check the full code

Here is the full job_search.py file. Compare it with yours and make sure everything matches.

import serpapi
from config import SERPAPI_KEY


def search_jobs(query, location="South Korea", gl="kr", hl="ko"):
    client = serpapi.Client(api_key=SERPAPI_KEY)
    results = client.search({
        "engine": "google_jobs",
        "q": query,
        "location": location,
        "gl": gl,
        "hl": hl,
    })
    jobs = results.get("jobs_results", [])
    parsed = []
    for job in jobs:
        parsed.append({
            "title": job.get("title", ""),
            "company": job.get("company_name", ""),
            "location": job.get("location", ""),
            "description": job.get("description", ""),
            "source": job.get("via", ""),
            "extensions": job.get("extensions", []),
            "posted": job.get("detected_extensions", {}).get("posted_at", ""),
            "schedule": job.get("detected_extensions", {}).get("schedule_type", ""),
        })
    return parsed


def search_multiple_roles(location="South Korea", gl="kr", hl="ko"):
    queries = [
        "Junior AWS DevOps Engineer",
        "AWS Solutions Architect",
        "Junior Cloud Engineer AWS",
        "AWS DevOps Engineer",
    ]
    all_jobs = []
    seen = set()
    for query in queries:
        jobs = search_jobs(query, location, gl, hl)
        for job in jobs:
            key = (job["title"].lower().strip(), job["company"].lower().strip())
            if key not in seen:
                seen.add(key)
                all_jobs.append(job)
    return all_jobs

Your job search module can now pull live AWS job listings from dozens of sources with a single function call. Next up, you'll build the profile ingestion module that turns your resume and GitHub projects into rich context for the AI.

Build the Profile Ingestion Module

Your job search module can now pull live listings from dozens of job boards. The next question is: what does the AI actually know about you? Without your resume and real project history, the AI has nothing to personalize.

In this step, you will build a profile ingestion module that reads your resume PDF and fetches your top GitHub projects. It pulls in star counts, README descriptions, and tech stacks so the AI has rich, real data to draw from when tailoring your resume.

In this step, get ready to:
  • Extract text from a resume PDF using PyPDF2.
  • Fetch and rank your top GitHub projects with README details.
  • Merge your resume and GitHub data into a single profile for the AI.
Extract text from resume PDFs

Your resume is a PDF file, which is not plain text. To use it as input for the AI, you need to extract the raw text from each page first.

  • Create a new file called profile_builder.py in your project folder.
  • Add the imports and PDF extraction function by pasting this code:
import requests
import base64
from PyPDF2 import PdfReader


def extract_pdf_text(pdf_file):
    # Read each page of the uploaded PDF
    reader = PdfReader(pdf_file)
    text = ""
    for page in reader.pages:
        page_text = page.extract_text()
        if page_text:
            text += page_text + "\n"
    # Return the combined text with extra whitespace removed
    return text.strip()

What does this code do?

PdfReader from PyPDF2 opens the PDF file and gives you access to its pages. The function loops through every page, extracts the text content, and joins it all together into one string.

Some PDF pages might not contain extractable text (like scanned images), so the if page_text: check skips those gracefully.

  • Save profile_builder.py.

You will not see a change yet. The next function fetches your GitHub data, which you can test from the terminal.

Fetch GitHub projects with README details

Listing repo names alone does not give the AI much to work with. By fetching each project's star count, primary language, topics, and README excerpt, the AI gets enough context to write a meaningful Projects section on your tailored resume.

You will build two functions. The first fetches a single repo's README. The second fetches all your repos, ranks them, and calls the first function for the top five.

  • In profile_builder.py, add the fetch_repo_readme() function below extract_pdf_text() by pasting this code:

def fetch_repo_readme(owner, repo_name):
    # Call the GitHub Contents API to get the README file
    url = f"https://api.github.com/repos/{owner}/{repo_name}/readme"
    response = requests.get(url)
    if response.status_code != 200:
        return ""
    data = response.json()
    # README content comes back as base64-encoded text
    content = data.get("content", "")
    try:
        decoded = base64.b64decode(content).decode("utf-8", errors="replace")
        return decoded[:1500]
    except Exception:
        return ""

What does this code do?

The GitHub Contents API returns README files as base64-encoded text, not plain text. This function calls the API at https://api.github.com/repos/{owner}/{repo}/readme, then decodes the response back into readable text.

The [:1500] slice keeps only the first 1500 characters. This prevents the AI prompt from getting too large while still capturing the most important project details.

  • Save profile_builder.py.

This helper is called by the main GitHub function you will write next.

  • Now add the fetch_github_profile() function below fetch_repo_readme() by pasting this code:

def fetch_github_profile(username):
    # Fetch up to 30 public repos, sorted by stars
    url = f"https://api.github.com/users/{username}/repos"
    params = {"sort": "stars", "direction": "desc", "per_page": 30}
    response = requests.get(url, params=params)
    if response.status_code != 200:
        return {"summary": "Could not fetch GitHub data.", "top_projects": []}

    repos = response.json()
    if not repos:
        return {"summary": "No public repositories found.", "top_projects": []}

    # Filter out forks and extract key metadata
    ranked = []
    for repo in repos:
        if repo.get("fork", False):
            continue
        stars = repo.get("stargazers_count", 0)
        desc = repo.get("description", "") or ""
        lang = repo.get("language", "") or ""
        topics = repo.get("topics", [])
        name = repo.get("name", "")
        ranked.append({
            "name": name,
            "description": desc,
            "stars": stars,
            "language": lang,
            "topics": topics,
            "url": repo.get("html_url", ""),
        })

    # Rank by stars and take the top 5
    ranked.sort(key=lambda r: r["stars"], reverse=True)
    top_repos = ranked[:5]

    # Fetch READMEs for each top repo to get rich descriptions
    top_projects = []
    all_languages = set()
    for repo in top_repos:
        readme_text = fetch_repo_readme(username, repo["name"])
        readme_summary = readme_text[:500].strip() if readme_text else "No README available."
        if repo["language"]:
            all_languages.add(repo["language"])
        top_projects.append({
            "name": repo["name"],
            "description": repo["description"],
            "stars": repo["stars"],
            "language": repo["language"],
            "topics": repo["topics"],
            "url": repo["url"],
            "readme_summary": readme_summary,
        })

    # Collect languages from remaining repos too
    for repo in ranked[5:]:
        if repo["language"]:
            all_languages.add(repo["language"])

    # Build a formatted text summary for the AI prompt
    summary_lines = ["TOP GITHUB PROJECTS (ranked by stars):"]
    for p in top_projects:
        line = f"\n### {p['name']} ({p['stars']} stars)"
        line += f"\nDescription: {p['description']}"
        line += f"\nPrimary language: {p['language']}"
        if p["topics"]:
            line += f"\nTopics: {', '.join(p['topics'])}"
        line += f"\nURL: {p['url']}"
        line += f"\nREADME excerpt:\n{p['readme_summary']}"
        summary_lines.append(line)

    summary_lines.append(f"\n\nAll languages across {len(ranked)} repos: {', '.join(sorted(all_languages))}")

    return {
        "summary": "\n".join(summary_lines),
        "top_projects": top_projects,
    }

What does this code do?

This function is the heart of the GitHub integration. Here is what each section handles:

  • It calls the GitHub REST API at https://api.github.com/users/{username}/repos to fetch up to 30 public repos, sorted by star count.
  • Forked repos are filtered out so only original work is included.
  • The top 5 repos by stars get their READMEs fetched via fetch_repo_readme(). This gives the AI detailed descriptions of your best projects.
  • A formatted text summary is built at the end. This is the block of text the AI will read when generating your tailored resume.

Why fetch READMEs instead of just repo names?

A repo name like cloud-infra-tool tells the AI very little. The README describes what the project does, what technologies it uses, and what problems it solves. This context lets the AI write resume bullet points that sound like real accomplishments, not generic filler.

  • Save profile_builder.py.
  • Test the GitHub fetching by running this command in your terminal:
python3 -c "from profile_builder import fetch_github_profile; r = fetch_github_profile('octocat'); print('Projects found:', len(r['top_projects']));"

You should see output like Projects found: 5 (the exact number depends on how many non-fork repos the octocat account has).

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Check that you saved profile_builder.py in the project root folder (the same folder where config.py and job_search.py live).
  • If you see a connection error, check that you have internet access. The GitHub API requires no authentication for this request, but it does need a network connection.

Help me debug my profile_builder.py GitHub fetching error.

GitHub API rate limits

Unauthenticated GitHub API requests are limited to 60 per hour. Each call to fetch_github_profile() uses about 6 requests (1 for the repo list + 5 for READMEs). This is plenty for development and normal use.

Merge everything into a complete profile

The AI needs one combined block of text containing your resume and GitHub data. This final function ties both data sources together into a single dictionary that the resume tailor will consume.

  • In profile_builder.py, add the build_full_profile() function below fetch_github_profile() by pasting this code:

def build_full_profile(resume_file, github_username):
    # Extract resume text if a file was uploaded
    resume_text = extract_pdf_text(resume_file) if resume_file else ""
    # Fetch GitHub data if a username was provided
    github_data = fetch_github_profile(github_username) if github_username else {"summary": "", "top_projects": []}
    # Return a dictionary with individual pieces and a combined text block
    return {
        "resume_text": resume_text,
        "github_summary": github_data["summary"],
        "github_projects": github_data["top_projects"],
        "combined": f"=== RESUME ===\n{resume_text}\n\n=== GITHUB PROFILE AND TOP PROJECTS ===\n{github_data['summary']}",
    }

What does this code do?

  • It calls extract_pdf_text() and fetch_github_profile() to gather both halves of your profile.
  • The combined key joins everything into one big string with clear section headers. This is the exact text that gets sent to the AI in the next step.
  • Both inputs are optional. If no resume file is uploaded, it returns an empty string. If no GitHub username is provided, it returns empty GitHub data.
  • Save profile_builder.py.

You will not see a visible change yet. The next step builds the AI resume tailor that consumes this profile data.

✔️ Awesome, I've got everything!

Great. Double check you have saved profile_builder.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete profile_builder.py file. Make sure yours matches:

import requests
import base64
from PyPDF2 import PdfReader


def extract_pdf_text(pdf_file):
    reader = PdfReader(pdf_file)
    text = ""
    for page in reader.pages:
        page_text = page.extract_text()
        if page_text:
            text += page_text + "\n"
    return text.strip()


def fetch_repo_readme(owner, repo_name):
    url = f"https://api.github.com/repos/{owner}/{repo_name}/readme"
    response = requests.get(url)
    if response.status_code != 200:
        return ""
    data = response.json()
    content = data.get("content", "")
    try:
        decoded = base64.b64decode(content).decode("utf-8", errors="replace")
        return decoded[:1500]
    except Exception:
        return ""


def fetch_github_profile(username):
    url = f"https://api.github.com/users/{username}/repos"
    params = {"sort": "stars", "direction": "desc", "per_page": 30}
    response = requests.get(url, params=params)
    if response.status_code != 200:
        return {"summary": "Could not fetch GitHub data.", "top_projects": []}

    repos = response.json()
    if not repos:
        return {"summary": "No public repositories found.", "top_projects": []}

    ranked = []
    for repo in repos:
        if repo.get("fork", False):
            continue
        stars = repo.get("stargazers_count", 0)
        desc = repo.get("description", "") or ""
        lang = repo.get("language", "") or ""
        topics = repo.get("topics", [])
        name = repo.get("name", "")
        ranked.append({
            "name": name,
            "description": desc,
            "stars": stars,
            "language": lang,
            "topics": topics,
            "url": repo.get("html_url", ""),
        })

    ranked.sort(key=lambda r: r["stars"], reverse=True)
    top_repos = ranked[:5]

    top_projects = []
    all_languages = set()
    for repo in top_repos:
        readme_text = fetch_repo_readme(username, repo["name"])
        readme_summary = readme_text[:500].strip() if readme_text else "No README available."
        if repo["language"]:
            all_languages.add(repo["language"])
        top_projects.append({
            "name": repo["name"],
            "description": repo["description"],
            "stars": repo["stars"],
            "language": repo["language"],
            "topics": repo["topics"],
            "url": repo["url"],
            "readme_summary": readme_summary,
        })

    for repo in ranked[5:]:
        if repo["language"]:
            all_languages.add(repo["language"])

    summary_lines = ["TOP GITHUB PROJECTS (ranked by stars):"]
    for p in top_projects:
        line = f"\n### {p['name']} ({p['stars']} stars)"
        line += f"\nDescription: {p['description']}"
        line += f"\nPrimary language: {p['language']}"
        if p["topics"]:
            line += f"\nTopics: {', '.join(p['topics'])}"
        line += f"\nURL: {p['url']}"
        line += f"\nREADME excerpt:\n{p['readme_summary']}"
        summary_lines.append(line)

    summary_lines.append(f"\n\nAll languages across {len(ranked)} repos: {', '.join(sorted(all_languages))}")

    return {
        "summary": "\n".join(summary_lines),
        "top_projects": top_projects,
    }


def build_full_profile(resume_file, github_username):
    resume_text = extract_pdf_text(resume_file) if resume_file else ""
    github_data = fetch_github_profile(github_username) if github_username else {"summary": "", "top_projects": []}
    return {
        "resume_text": resume_text,
        "github_summary": github_data["summary"],
        "github_projects": github_data["top_projects"],
        "combined": f"=== RESUME ===\n{resume_text}\n\n=== GITHUB PROFILE AND TOP PROJECTS ===\n{github_data['summary']}",
    }

Your profile ingestion pipeline is complete. It can read any resume PDF and pull in rich GitHub project data. Next up, you will build the AI resume tailor that takes this profile and a job description, then generates a tailored, ATS-optimized resume using Google Gemini.

Build the AI Resume Tailor

Your profile builder can now pull in your resume text and your top GitHub projects. The next challenge is turning all of that raw data into a polished, job-specific resume.

In this step, you will use the Google Gemini API to build an AI resume tailor. It detects the language of the job posting, then generates a tailored resume that matches the job description using only your real profile data.

In this step, get ready to:
  • Create resume_tailor.py with Google Gemini API integration.
  • Build a language detection function so the resume matches the job posting's language.
  • Build the core resume tailoring function with ATS optimization and a Projects section from GitHub.
Create the resume tailor file and Gemini client

The google-genai SDK is the official Python client for Gemini. You will use it to send prompts and receive generated text back from the gemini-2.0-flash model.

  • Create a new file called resume_tailor.py in your aws-job-hunter project folder.
  • Add the imports, model constant, and client helper by pasting this code into the file:
import os
from google import genai
from google.genai import types
from config import GEMINI_API_KEY

# Use the stable Gemini 2.0 Flash model
MODEL = "gemini-2.0-flash"


def _get_client():
    # Validate the API key before creating the client
    api_key = GEMINI_API_KEY
    if not api_key:
        raise ValueError("GEMINI_API_KEY is not set. Please check your .env file.")
    # genai.Client() reads the key from this environment variable
    os.environ["GEMINI_API_KEY"] = api_key
    return genai.Client()

What does this code do?

The imports pull in the official google-genai SDK and the types module you will use later for configuration.

The _get_client() helper validates that your Gemini API key exists, then sets it as an environment variable so genai.Client() can pick it up automatically. If the key is missing, it raises a clear error instead of failing silently later.

  • Save resume_tailor.py.

You won't see a change yet. The next chunk adds the first function that uses this client.

Why a helper function for the client?

Both functions in this file need a Gemini client. The _get_client() helper keeps the API key validation in one place so you do not repeat yourself.

Job postings come in different languages. A Korean job listing needs a Korean resume. An English listing needs English. Your AI needs to know which language to write in before it starts generating.

  • Add the detect_language() function below _get_client() by pasting this code:
def detect_language(text):
    client = _get_client()
    # Send the first 500 characters and ask Gemini to identify the language
    response = client.models.generate_content(
        model=MODEL,
        contents=f"What language is this text written in? Reply with ONLY the language name (e.g., 'Korean' or 'English'):\n\n{text[:500]}",
    )
    return response.text.strip()

What does this code do?

This function sends the first 500 characters of the job description to Gemini and asks it to identify the language. The prompt is strict: reply with ONLY the language name. This keeps the response clean (e.g. Korean or English) so you can pass it directly into the resume generation prompt.

  • Save resume_tailor.py.

You won't see a change yet. The next chunk uses this language detection result to tailor the full resume.

Seeing an import error?

If you see ModuleNotFoundError: No module named 'google', make sure your virtual environment is activated and that you installed google-genai in Step 1. The correct package is google-genai, not google-generativeai (which is deprecated).

Why isn't my google-genai import working?

Build the resume tailoring function

The core of this module is a function that combines the job description with your full profile data and sends it to Gemini with a detailed system instruction. The system instruction enforces rules like writing in the correct language, never fabricating experience, and including a Projects section drawn from your GitHub profile.

  • Add the tailor_resume() function below detect_language() by pasting this code:
def tailor_resume(job_description, profile_data):
    client = _get_client()
    # First, detect which language the job posting is written in
    language = detect_language(job_description)

    # Build detailed rules that guide how Gemini writes the resume
    system_instruction = f"""You are an expert resume writer and ATS optimization specialist.
Your task is to create a tailored resume based on the candidate's real profile data to match a specific job posting.

CRITICAL RULES:
1. Write the resume in {language} because the job posting is in {language}.
2. ONLY use skills, experiences, certifications, education, and projects that actually exist in the candidate's profile data. Do NOT fabricate or invent anything.
3. Reorder and rephrase experience bullets to highlight relevance to the job requirements.
4. Include a PROJECTS section that showcases the candidate's most relevant GitHub projects. For each project, include the project name, a brief description of what was built, the technologies used, and the star count if notable. Select projects that best demonstrate skills relevant to the job description.
5. Include a Skills section with keywords from the job description that the candidate actually possesses.
6. Use action verbs and quantify achievements where the data supports it.
7. Format the output with clear sections: SUMMARY, SKILLS, EXPERIENCE, PROJECTS, EDUCATION, CERTIFICATIONS (if applicable).
8. Keep the resume concise (1-2 pages worth of content).
9. Optimize for ATS by naturally including relevant keywords from the job description."""

    # Combine the job description and full candidate profile into one prompt
    prompt = f"""JOB DESCRIPTION:
{job_description}

CANDIDATE PROFILE DATA:
{profile_data['combined']}

Generate a tailored resume for this candidate that matches the job description. Follow all the rules in your instructions."""

    # Send to Gemini with the system instruction and a generous token limit
    response = client.models.generate_content(
        model=MODEL,
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=system_instruction,
            max_output_tokens=4096,
        ),
    )
    return {"resume_text": response.text, "language": language}

What does this code do?

  • detect_language(job_description) runs first to figure out if the resume should be in Korean, English, or another language.
  • The system_instruction is a set of strict rules Gemini must follow. Rule 2 prevents the AI from making up experience you do not have. Rule 4 tells it to highlight your real GitHub projects.
  • The prompt feeds in both the full job description and your combined profile data (resume text plus GitHub project summaries).
  • GenerateContentConfig sets max_output_tokens=4096 so Gemini has enough room to generate a full 1-2 page resume without cutting off mid-sentence.
  • The function returns a dictionary with the generated resume text and the detected language. Both values are used later by the PDF generator and the Streamlit app.
  • Save resume_tailor.py.

You won't see visual output yet. The Streamlit app in a later step will call this function and display the generated resume.

Seeing an API key error?

If you see ValueError: GEMINI_API_KEY is not set, double check that your .env file has your Gemini key and that config.py is loading it correctly. Make sure there are no extra spaces around the = sign in your .env file.

Why is my GEMINI_API_KEY not loading?

Review the error handling and configuration

Before moving on, take a look at the safeguards already built into your code. Good error handling prevents confusing failures when the app runs in the Streamlit UI later.

What safeguards are in place?

  • API key validation: _get_client() checks that GEMINI_API_KEY exists before making any API call. If the key is missing, it raises a ValueError with a clear message pointing to the .env file.
  • Output token limit: max_output_tokens=4096 ensures Gemini generates enough content for a full resume without cutting off. Resumes are typically 1-2 pages, which fits comfortably within 4096 tokens.
  • No-fabrication rule: Rule 2 in the system instruction explicitly tells Gemini to only use data that exists in the candidate's profile. This prevents the AI from inventing certifications or experience.

What is a system instruction?

A system instruction is a separate set of rules you pass to Gemini alongside the user's prompt. Unlike the prompt itself, the system instruction shapes the model's behavior for the entire conversation. Think of it as the AI's job description: it defines the role, the constraints, and the output format.

Separating rules (system instruction) from data (prompt) gives you tighter control over the output quality.

✔️ Awesome, I've got everything!

Double check you have saved resume_tailor.py.

ⓧ I'd like to double check the full code

Here is the complete resume_tailor.py file. Make sure your code matches this exactly.

import os
from google import genai
from google.genai import types
from config import GEMINI_API_KEY

MODEL = "gemini-2.0-flash"


def _get_client():
    api_key = GEMINI_API_KEY
    if not api_key:
        raise ValueError("GEMINI_API_KEY is not set. Please check your .env file.")
    os.environ["GEMINI_API_KEY"] = api_key
    return genai.Client()


def detect_language(text):
    client = _get_client()
    response = client.models.generate_content(
        model=MODEL,
        contents=f"What language is this text written in? Reply with ONLY the language name (e.g., 'Korean' or 'English'):\n\n{text[:500]}",
    )
    return response.text.strip()


def tailor_resume(job_description, profile_data):
    client = _get_client()
    language = detect_language(job_description)

    system_instruction = f"""You are an expert resume writer and ATS optimization specialist.
Your task is to create a tailored resume based on the candidate's real profile data to match a specific job posting.

CRITICAL RULES:
1. Write the resume in {language} because the job posting is in {language}.
2. ONLY use skills, experiences, certifications, education, and projects that actually exist in the candidate's profile data. Do NOT fabricate or invent anything.
3. Reorder and rephrase experience bullets to highlight relevance to the job requirements.
4. Include a PROJECTS section that showcases the candidate's most relevant GitHub projects. For each project, include the project name, a brief description of what was built, the technologies used, and the star count if notable. Select projects that best demonstrate skills relevant to the job description.
5. Include a Skills section with keywords from the job description that the candidate actually possesses.
6. Use action verbs and quantify achievements where the data supports it.
7. Format the output with clear sections: SUMMARY, SKILLS, EXPERIENCE, PROJECTS, EDUCATION, CERTIFICATIONS (if applicable).
8. Keep the resume concise (1-2 pages worth of content).
9. Optimize for ATS by naturally including relevant keywords from the job description."""

    prompt = f"""JOB DESCRIPTION:
{job_description}

CANDIDATE PROFILE DATA:
{profile_data['combined']}

Generate a tailored resume for this candidate that matches the job description. Follow all the rules in your instructions."""

    response = client.models.generate_content(
        model=MODEL,
        contents=prompt,
        config=types.GenerateContentConfig(
            system_instruction=system_instruction,
            max_output_tokens=4096,
        ),
    )
    return {"resume_text": response.text, "language": language}

Your AI resume tailor is ready. It can detect the language of any job posting and generate a full, structured resume using your real profile data. Next up, you will build an ATS keyword scorer and a PDF exporter so learners can download their tailored resume as a file.

Add ATS Scoring and PDF Export

Your AI resume tailor can now generate a customized resume for any job posting. The next question is: how well does that tailored resume actually match the job description?

In this step, you will build two utility modules. The first is a keyword scorer that measures how many ATS keywords from the job description actually appear in the tailored resume. The second is a PDF generator that exports the resume as a clean, downloadable file with Korean language support.

In this step, get ready to:
  • Create an ATS keyword scorer that measures resume-to-job-description match.
  • Build a PDF generator with Korean font support.
  • Verify the PDF export returns downloadable bytes for Streamlit.
Create the ATS keyword scorer

Most companies use Applicant Tracking Systems to filter resumes before a human ever reads them. These systems scan for specific keywords from the job description.

Your scorer will extract keywords from both the job description and the tailored resume, then calculate a match percentage. This gives the learner a concrete number to judge how well the AI did.

  • Create a new file called ats_scorer.py in your project folder.
  • Add the stop words set and keyword extraction function by pasting this code:
# Common English words to ignore when extracting keywords
STOP_WORDS = {
    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did", "will", "would", "could",
    "should", "may", "might", "shall", "can", "need", "dare", "ought",
    "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
    "as", "into", "through", "during", "before", "after", "above", "below",
    "between", "out", "off", "over", "under", "again", "further", "then",
    "once", "and", "but", "or", "nor", "not", "so", "yet", "both",
    "either", "neither", "each", "every", "all", "any", "few", "more",
    "most", "other", "some", "such", "no", "only", "own", "same", "than",
    "too", "very", "just", "because", "if", "when", "where", "how", "what",
    "which", "who", "whom", "this", "that", "these", "those", "i", "me",
    "my", "we", "our", "you", "your", "he", "him", "his", "she", "her",
    "it", "its", "they", "them", "their", "about", "up", "also",
}


def extract_keywords(text, min_length=2):
    # Lowercase and split the text into individual words
    words = text.lower().split()
    cleaned = []
    for word in words:
        # Keep only alphanumeric chars and common tech symbols like - _ . / + #
        word = "".join(c for c in word if c.isalnum() or c in "-_./+#")
        # Filter out short words and stop words
        if len(word) >= min_length and word not in STOP_WORDS:
            cleaned.append(word)
    return set(cleaned)

What does this code do?

STOP_WORDS is a set of common English words (like "the", "is", "and") that would appear in any text. Filtering these out keeps only the meaningful keywords that actually signal job requirements.

extract_keywords() lowercases the text, strips punctuation (while preserving tech-relevant characters like # and +), removes short words and stop words, then returns a set of unique keywords.

  • Save ats_scorer.py.

You won't see a change yet. The next function uses this to produce an actual score.

  • Below the extract_keywords function in ats_scorer.py, add the scoring function:
def score_resume(job_description, tailored_resume):
    # Extract keywords from both texts
    jd_keywords = extract_keywords(job_description)
    resume_keywords = extract_keywords(tailored_resume)

    # Handle edge case where job description has no extractable keywords
    if not jd_keywords:
        return {"score": 0, "matched": [], "missing": [], "total_jd_keywords": 0}

    # Find which job keywords appear in the resume and which are missing
    matched = jd_keywords.intersection(resume_keywords)
    missing = jd_keywords - resume_keywords
    # Calculate match percentage
    score = round((len(matched) / len(jd_keywords)) * 100, 1)

    return {
        "score": score,
        "matched": sorted(matched),
        "missing": sorted(missing),
        "total_jd_keywords": len(jd_keywords),
    }

What does this code do?

score_resume() extracts keywords from the job description and the tailored resume using the function you just wrote. It then computes the intersection (keywords that appear in both) and the difference (keywords that are missing from the resume).

The score is a simple percentage: how many of the job description's keywords did the resume cover? It also returns the matched and missing keyword lists so the learner can see exactly what was hit or missed.

  • Save ats_scorer.py.
  • Test the scorer by running this command in your terminal:
python -c "from ats_scorer import score_resume; result = score_resume('AWS DevOps engineer with Terraform and Kubernetes experience', 'Experienced DevOps engineer skilled in AWS and Terraform'); print(result)"

You should see a dictionary with a score percentage, a matched list, and a missing list printed to the terminal.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

Also check that your terminal is in the project folder where ats_scorer.py lives.

Help me debug a ModuleNotFoundError when importing ats_scorer

✔️ Awesome, I've got everything!

Make sure you have saved ats_scorer.py.

ⓧ I'd like to double check the full code

STOP_WORDS = {
    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did", "will", "would", "could",
    "should", "may", "might", "shall", "can", "need", "dare", "ought",
    "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
    "as", "into", "through", "during", "before", "after", "above", "below",
    "between", "out", "off", "over", "under", "again", "further", "then",
    "once", "and", "but", "or", "nor", "not", "so", "yet", "both",
    "either", "neither", "each", "every", "all", "any", "few", "more",
    "most", "other", "some", "such", "no", "only", "own", "same", "than",
    "too", "very", "just", "because", "if", "when", "where", "how", "what",
    "which", "who", "whom", "this", "that", "these", "those", "i", "me",
    "my", "we", "our", "you", "your", "he", "him", "his", "she", "her",
    "it", "its", "they", "them", "their", "about", "up", "also",
}


def extract_keywords(text, min_length=2):
    words = text.lower().split()
    cleaned = []
    for word in words:
        word = "".join(c for c in word if c.isalnum() or c in "-_./+#")
        if len(word) >= min_length and word not in STOP_WORDS:
            cleaned.append(word)
    return set(cleaned)


def score_resume(job_description, tailored_resume):
    jd_keywords = extract_keywords(job_description)
    resume_keywords = extract_keywords(tailored_resume)

    if not jd_keywords:
        return {"score": 0, "matched": [], "missing": [], "total_jd_keywords": 0}

    matched = jd_keywords.intersection(resume_keywords)
    missing = jd_keywords - resume_keywords
    score = round((len(matched) / len(jd_keywords)) * 100, 1)

    return {
        "score": score,
        "matched": sorted(matched),
        "missing": sorted(missing),
        "total_jd_keywords": len(jd_keywords),
    }
Build the PDF generator

The tailored resume is currently just text. To make it useful, you need to export it as a PDF the learner can download and submit to employers.

The PDF generator uses fpdf2 to create a clean, single-column layout. It also registers Noto CJK fonts so Korean-language resumes render correctly instead of showing blank squares.

  • Create a new file called pdf_generator.py in your project folder.
  • Paste the full PDF generator code:
from fpdf import FPDF
import io
import os

# Path to Korean CJK fonts installed via fonts-noto-cjk
CJK_FONT_DIR = "/usr/share/fonts/opentype/noto"


class ResumePDF(FPDF):
    """Custom PDF class with a page-number footer."""
    def header(self):
        pass

    def footer(self):
        self.set_y(-15)
        self.set_font("Helvetica", "I", 8)
        self.cell(0, 10, f"Page {self.page_no()}", align="C")


def generate_resume_pdf(resume_text, language="English"):
    pdf = ResumePDF()
    pdf.set_auto_page_break(auto=True, margin=20)
    pdf.add_page()

    # Register Korean fonts if the resume language is Korean
    if language.lower() == "korean":
        regular_path = os.path.join(CJK_FONT_DIR, "NotoSansCJK-Regular.ttc")
        bold_path = os.path.join(CJK_FONT_DIR, "NotoSansCJK-Bold.ttc")
        if os.path.exists(regular_path):
            pdf.add_font("NotoSans", "", regular_path, uni=True)
            pdf.add_font("NotoSans", "B", bold_path if os.path.exists(bold_path) else regular_path, uni=True)
            body_font = "NotoSans"
        else:
            body_font = "Helvetica"
    else:
        body_font = "Helvetica"

    # Process each line of the resume text
    lines = resume_text.split("\n")
    for line in lines:
        stripped = line.strip()
        if not stripped:
            pdf.ln(4)
            continue
        # All-caps lines are section headers (SUMMARY, SKILLS, etc.)
        if stripped.isupper() and len(stripped) > 2:
            pdf.set_font(body_font, "B", 13)
            pdf.cell(0, 8, stripped, ln=True)
            pdf.set_draw_color(50, 50, 50)
            pdf.line(10, pdf.get_y(), 200, pdf.get_y())
            pdf.ln(2)
        # Bullet points get regular font with indentation
        elif stripped.startswith("- ") or stripped.startswith("* "):
            pdf.set_font(body_font, "", 10)
            pdf.multi_cell(0, 5, f"  {stripped}")
        # Everything else is regular body text
        else:
            pdf.set_font(body_font, "", 10)
            pdf.multi_cell(0, 5, stripped)

    # Write the PDF to a bytes buffer for Streamlit to download
    buffer = io.BytesIO()
    pdf.output(buffer)
    buffer.seek(0)
    return buffer.getvalue()

What does this code do?

  • ResumePDF is a custom class extending FPDF. It overrides the footer to add a centered page number on every page.
  • The Korean font block checks if Noto CJK fonts exist on the system. If they do, it registers them so Korean characters render properly. If not, it falls back to Helvetica.
  • The line-processing loop detects all-caps text as section headers (like SUMMARY, SKILLS) and renders them in bold with an underline. Bullet points and regular text get standard formatting.
  • The final three lines write the PDF into a BytesIO buffer and return raw bytes. This is what lets Streamlit offer the PDF as a download button.
  • Save pdf_generator.py.

You won't see output yet. The next substep verifies the module works.

✔️ Awesome, I've got everything!

Make sure you have saved pdf_generator.py.

ⓧ I'd like to double check the full code

from fpdf import FPDF
import io
import os


CJK_FONT_DIR = "/usr/share/fonts/opentype/noto"


class ResumePDF(FPDF):
    def header(self):
        pass

    def footer(self):
        self.set_y(-15)
        self.set_font("Helvetica", "I", 8)
        self.cell(0, 10, f"Page {self.page_no()}", align="C")


def generate_resume_pdf(resume_text, language="English"):
    pdf = ResumePDF()
    pdf.set_auto_page_break(auto=True, margin=20)
    pdf.add_page()

    if language.lower() == "korean":
        regular_path = os.path.join(CJK_FONT_DIR, "NotoSansCJK-Regular.ttc")
        bold_path = os.path.join(CJK_FONT_DIR, "NotoSansCJK-Bold.ttc")
        if os.path.exists(regular_path):
            pdf.add_font("NotoSans", "", regular_path, uni=True)
            pdf.add_font("NotoSans", "B", bold_path if os.path.exists(bold_path) else regular_path, uni=True)
            body_font = "NotoSans"
        else:
            body_font = "Helvetica"
    else:
        body_font = "Helvetica"

    lines = resume_text.split("\n")
    for line in lines:
        stripped = line.strip()
        if not stripped:
            pdf.ln(4)
            continue
        if stripped.isupper() and len(stripped) > 2:
            pdf.set_font(body_font, "B", 13)
            pdf.cell(0, 8, stripped, ln=True)
            pdf.set_draw_color(50, 50, 50)
            pdf.line(10, pdf.get_y(), 200, pdf.get_y())
            pdf.ln(2)
        elif stripped.startswith("- ") or stripped.startswith("* "):
            pdf.set_font(body_font, "", 10)
            pdf.multi_cell(0, 5, f"  {stripped}")
        else:
            pdf.set_font(body_font, "", 10)
            pdf.multi_cell(0, 5, stripped)

    buffer = io.BytesIO()
    pdf.output(buffer)
    buffer.seek(0)
    return buffer.getvalue()
Verify the PDF export works

The generate_resume_pdf() function returns raw bytes from a BytesIO buffer. Streamlit's st.download_button() accepts raw bytes directly, so this return type is exactly what you need for the final app.

  • Confirm the PDF generator works by running this test command:
python -c "from pdf_generator import generate_resume_pdf; pdf = generate_resume_pdf('SUMMARY\nExperienced AWS engineer.\n\nSKILLS\n- Terraform\n- Kubernetes'); print(f'PDF generated: {len(pdf)} bytes')"

You should see output like PDF generated: 1234 bytes (the exact number will vary). A non-zero byte count confirms the PDF was created successfully.

Seeing an fpdf error or zero bytes?

Make sure fpdf2 is installed in your virtual environment. Run pip install fpdf2 if needed.

Check that your import says from fpdf import FPDF (not from fpdf2 import FPDF). The package name is fpdf2 but the import path is fpdf.

Help me debug a PDF generation error in my AWS Job Hunter project

What about Korean font support?

The Korean font path points to the Noto CJK fonts you installed in Step 1 with sudo apt install fonts-noto-cjk. If the font files exist at that path, Korean resumes will render correctly. If not, the generator gracefully falls back to Helvetica.

Both utility modules are ready. You have an ATS scorer that measures keyword coverage and a PDF generator that exports clean, downloadable resumes. Next up, you will wire everything together into a Streamlit web app with a full visual interface.

Build the Streamlit App

You now have six Python modules ready to go. Each one handles a distinct piece of the puzzle: job searching, profile building, resume tailoring, ATS scoring, and PDF generation.

In this step, you will create app.py, the Streamlit web app that ties all your modules together into a visual interface. Streamlit turns plain Python scripts into interactive web apps with just a few function calls.

In this step, get ready to:
  • Build a sidebar with job search controls and location filtering.
  • Display search results as expandable job cards with a selection button.
  • Add resume tailoring with AI-generated results, ATS scoring, and PDF download.
Create the app layout and search sidebar

Streamlit apps are single Python files that render top-to-bottom. You will start by setting up the page, importing your modules, and building the sidebar where users configure their job search.

  • Create a new file called app.py in your aws-job-hunter project folder.
  • Add the imports and page configuration by pasting this code at the top of app.py:
import streamlit as st
from job_search import search_jobs, search_multiple_roles
from profile_builder import build_full_profile
from resume_tailor import tailor_resume
from ats_scorer import score_resume
from pdf_generator import generate_resume_pdf

# Configure the page title, icon, and wide layout
st.set_page_config(page_title="AWS Job Hunter", page_icon="🎯", layout="wide")
st.title("🎯 AWS Job Hunter & Resume Tailor")
st.caption("Find AWS DevOps and Solutions Architect roles, then generate ATS-optimized resumes with AI")

What does this code do?

The first six imports bring in every module you built in the previous steps. Each one handles a specific job: searching, profile building, tailoring, scoring, and PDF export.

st.set_page_config() sets the browser tab title and switches the app to a wide layout. st.title() and st.caption() render the main heading and subtitle on the page.

The sidebar is where users will type their job search query and pick a location. Streamlit's st.sidebar context manager puts all its contents into a collapsible side panel.

  • Add the sidebar block below your page configuration code in app.py:
# Sidebar with search controls
with st.sidebar:
    st.header("🔍 Job Search")
    search_query = st.text_input("Job title / keywords", value="AWS DevOps Engineer")
    location_options = ["South Korea", "United States", "Remote", "Germany", "Japan", "Singapore", "Custom"]
    location_choice = st.selectbox("Location", location_options)
    if location_choice == "Custom":
        location = st.text_input("Enter location")
    else:
        location = location_choice

    # Map location names to SerpApi country and language codes
    gl_map = {
        "South Korea": "kr", "United States": "us", "Remote": "us",
        "Germany": "de", "Japan": "jp", "Singapore": "sg",
    }
    hl_map = {
        "South Korea": "ko", "United States": "en", "Remote": "en",
        "Germany": "de", "Japan": "ja", "Singapore": "en",
    }
    gl = gl_map.get(location_choice, "us")
    hl = hl_map.get(location_choice, "en")

    search_all = st.checkbox("Search multiple role variants", value=True)
    search_button = st.button("🔎 Search Jobs", type="primary", use_container_width=True)

What does this code do?

  • st.text_input() creates a text field with a default value of AWS DevOps Engineer.
  • st.selectbox() renders a dropdown for location. If the user picks Custom, a second text input appears so they can type any location.
  • gl_map and hl_map translate friendly location names into the two-letter country and language codes that SerpApi expects.
  • The search_all checkbox lets users search across multiple role variants at once, using the search_multiple_roles() function you built in Step 2.

Now you need the main area logic that fires when the user clicks Search Jobs and displays the results as expandable cards.

  • Add the search logic and results display below the sidebar block in app.py:
# Initialize session state so data persists across reruns
if "jobs" not in st.session_state:
    st.session_state.jobs = []
if "selected_job" not in st.session_state:
    st.session_state.selected_job = None

# Run the search when the button is clicked
if search_button:
    with st.spinner("Searching for jobs..."):
        if search_all:
            st.session_state.jobs = search_multiple_roles(location, gl, hl)
        else:
            st.session_state.jobs = search_jobs(search_query, location, gl, hl)
    st.success(f"Found {len(st.session_state.jobs)} jobs!")

# Display each job as an expandable card
if st.session_state.jobs:
    st.subheader(f"📋 Job Results ({len(st.session_state.jobs)} found)")
    for i, job in enumerate(st.session_state.jobs):
        with st.expander(f"**{job['title']}** at {job['company']} - {job['location']}"):
            st.write(f"**Source:** {job['source']}")
            if job["posted"]:
                st.write(f"**Posted:** {job['posted']}")
            if job["schedule"]:
                st.write(f"**Type:** {job['schedule']}")
            st.write(job["description"][:500] + "..." if len(job["description"]) > 500 else job["description"])
            if st.button(f"Select this job", key=f"select_{i}"):
                st.session_state.selected_job = job

Why do we need session state?

Streamlit reruns your entire script from top to bottom every time the user interacts with a widget. Without st.session_state, your search results would vanish every time someone clicked a button.

By storing jobs and selected_job in session state, the data survives across reruns. The st.expander() widget creates collapsible cards so users can browse job details without scrolling through a wall of text.

  • Save app.py.

Seeing a red error about imports?

  • Make sure all six module files (job_search.py, profile_builder.py, resume_tailor.py, ats_scorer.py, pdf_generator.py, config.py) are in the same folder as app.py.
  • Check that your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

Help me debug import errors in my Streamlit app

Add the resume tailoring section

When a user selects a job from the results, the app needs to show a second section where they can upload their resume, enter their GitHub username, and generate a tailored resume.

  • Add the resume tailoring section at the bottom of app.py, below the job results display code:
# Show the tailoring UI when a job is selected
if st.session_state.selected_job:
    st.divider()
    job = st.session_state.selected_job
    st.subheader(f"✨ Tailor Resume for: {job['title']} at {job['company']}")

    resume_file = st.file_uploader("Upload your Resume (PDF)", type=["pdf"], key="resume")
    github_username = st.text_input("GitHub Username")

    if st.button("🚀 Generate Tailored Resume", type="primary"):
        # Validate that both inputs are provided
        if not resume_file:
            st.error("Please upload your resume PDF.")
        elif not github_username:
            st.error("Please enter your GitHub username.")
        else:
            with st.spinner("Fetching your GitHub projects and generating tailored resume..."):
                # Build the combined profile from resume + GitHub
                profile = build_full_profile(resume_file, github_username)

                if profile["github_projects"]:
                    st.info(f"Found {len(profile['github_projects'])} top GitHub projects to include.")

                # Send everything to Gemini for tailoring
                result = tailor_resume(job["description"], profile)
                resume_text = result["resume_text"]
                language = result["language"]

                # Score the tailored resume against the job description
                ats_result = score_resume(job["description"], resume_text)

            # Display the tailored resume
            st.subheader("📄 Your Tailored Resume")
            st.text_area("Resume Preview", resume_text, height=400)

            # Display the ATS score with color coding
            st.subheader("📊 ATS Score")
            score = ats_result["score"]
            if score >= 70:
                st.success(f"ATS Match Score: {score}%")
            elif score >= 50:
                st.warning(f"ATS Match Score: {score}%")
            else:
                st.error(f"ATS Match Score: {score}%")

            # Show matched and missing keywords side by side
            col_m, col_miss = st.columns(2)
            with col_m:
                st.write(f"**Matched Keywords ({len(ats_result['matched'])}):**")
                st.write(", ".join(ats_result["matched"][:30]))
            with col_miss:
                st.write(f"**Missing Keywords ({len(ats_result['missing'])}):**")
                st.write(", ".join(ats_result["missing"][:20]))

            # Generate and offer the PDF download
            pdf_bytes = generate_resume_pdf(resume_text, language)
            st.download_button(
                label="📥 Download Resume PDF",
                data=pdf_bytes,
                file_name="tailored_resume.pdf",
                mime="application/pdf",
            )

What does this code do?

This section only appears when st.session_state.selected_job is set. It walks through a pipeline:

  • build_full_profile() combines the uploaded resume PDF with the user's top GitHub projects into one profile dictionary.
  • tailor_resume() sends the job description and profile to Gemini, which generates a resume in the job posting's language.
  • score_resume() compares keywords between the job description and the tailored resume. Scores above 70% show green, 50-69% show yellow, and below 50% show red.
  • generate_resume_pdf() converts the resume text into a downloadable PDF, with Korean font support when needed.
  • Save app.py.

Seeing a NameError for build_full_profile or tailor_resume?

  • Verify your import lines at the top of app.py match the function names exactly. For example, from profile_builder import build_full_profile.
  • Make sure profile_builder.py and resume_tailor.py are saved with no typos in the function definitions.

Help me fix a NameError in my Streamlit app

✔️ Awesome, I've got everything!

Double check you have saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file. Compare it with yours and fix any differences.

import streamlit as st
from job_search import search_jobs, search_multiple_roles
from profile_builder import build_full_profile
from resume_tailor import tailor_resume
from ats_scorer import score_resume
from pdf_generator import generate_resume_pdf

st.set_page_config(page_title="AWS Job Hunter", page_icon="🎯", layout="wide")
st.title("🎯 AWS Job Hunter & Resume Tailor")
st.caption("Find AWS DevOps and Solutions Architect roles, then generate ATS-optimized resumes with AI")

with st.sidebar:
    st.header("🔍 Job Search")
    search_query = st.text_input("Job title / keywords", value="AWS DevOps Engineer")
    location_options = ["South Korea", "United States", "Remote", "Germany", "Japan", "Singapore", "Custom"]
    location_choice = st.selectbox("Location", location_options)
    if location_choice == "Custom":
        location = st.text_input("Enter location")
    else:
        location = location_choice

    gl_map = {
        "South Korea": "kr", "United States": "us", "Remote": "us",
        "Germany": "de", "Japan": "jp", "Singapore": "sg",
    }
    hl_map = {
        "South Korea": "ko", "United States": "en", "Remote": "en",
        "Germany": "de", "Japan": "ja", "Singapore": "en",
    }
    gl = gl_map.get(location_choice, "us")
    hl = hl_map.get(location_choice, "en")

    search_all = st.checkbox("Search multiple role variants", value=True)
    search_button = st.button("🔎 Search Jobs", type="primary", use_container_width=True)

if "jobs" not in st.session_state:
    st.session_state.jobs = []
if "selected_job" not in st.session_state:
    st.session_state.selected_job = None

if search_button:
    with st.spinner("Searching for jobs..."):
        if search_all:
            st.session_state.jobs = search_multiple_roles(location, gl, hl)
        else:
            st.session_state.jobs = search_jobs(search_query, location, gl, hl)
    st.success(f"Found {len(st.session_state.jobs)} jobs!")

if st.session_state.jobs:
    st.subheader(f"📋 Job Results ({len(st.session_state.jobs)} found)")
    for i, job in enumerate(st.session_state.jobs):
        with st.expander(f"**{job['title']}** at {job['company']} - {job['location']}"):
            st.write(f"**Source:** {job['source']}")
            if job["posted"]:
                st.write(f"**Posted:** {job['posted']}")
            if job["schedule"]:
                st.write(f"**Type:** {job['schedule']}")
            st.write(job["description"][:500] + "..." if len(job["description"]) > 500 else job["description"])
            if st.button(f"Select this job", key=f"select_{i}"):
                st.session_state.selected_job = job

if st.session_state.selected_job:
    st.divider()
    job = st.session_state.selected_job
    st.subheader(f"✨ Tailor Resume for: {job['title']} at {job['company']}")

    resume_file = st.file_uploader("Upload your Resume (PDF)", type=["pdf"], key="resume")
    github_username = st.text_input("GitHub Username")

    if st.button("🚀 Generate Tailored Resume", type="primary"):
        if not resume_file:
            st.error("Please upload your resume PDF.")
        elif not github_username:
            st.error("Please enter your GitHub username.")
        else:
            with st.spinner("Fetching your GitHub projects and generating tailored resume..."):
                profile = build_full_profile(resume_file, github_username)

                if profile["github_projects"]:
                    st.info(f"Found {len(profile['github_projects'])} top GitHub projects to include.")

                result = tailor_resume(job["description"], profile)
                resume_text = result["resume_text"]
                language = result["language"]

                ats_result = score_resume(job["description"], resume_text)

            st.subheader("📄 Your Tailored Resume")
            st.text_area("Resume Preview", resume_text, height=400)

            st.subheader("📊 ATS Score")
            score = ats_result["score"]
            if score >= 70:
                st.success(f"ATS Match Score: {score}%")
            elif score >= 50:
                st.warning(f"ATS Match Score: {score}%")
            else:
                st.error(f"ATS Match Score: {score}%")

            col_m, col_miss = st.columns(2)
            with col_m:
                st.write(f"**Matched Keywords ({len(ats_result['matched'])}):**")
                st.write(", ".join(ats_result["matched"][:30]))
            with col_miss:
                st.write(f"**Missing Keywords ({len(ats_result['missing'])}):**")
                st.write(", ".join(ats_result["missing"][:20]))

            pdf_bytes = generate_resume_pdf(resume_text, language)
            st.download_button(
                label="📥 Download Resume PDF",
                data=pdf_bytes,
                file_name="tailored_resume.pdf",
                mime="application/pdf",
            )
Run and test the full workflow

Time to see everything working together. You will launch the Streamlit app, search for jobs, select one, and generate a tailored resume with your real profile data.

  • Make sure your virtual environment is activated, then start the app by running this command:
streamlit run app.py

What should I see?

Your terminal should display a local URL (usually http://localhost:8501). Your browser opens automatically to show the AWS Job Hunter app with a sidebar on the left and the main content area on the right.

App not opening in your browser?

  • Copy the URL from the terminal output and paste it directly into your browser.
  • If you see ModuleNotFoundError, check that your virtual environment is activated and that you installed all packages in Step 1.
  • If port 8501 is already in use, Streamlit will try the next port (8502, 8503, etc.). Check the terminal output for the correct URL.

Help me get my Streamlit app running

Now test the full workflow from search to PDF download.

  • In the sidebar, leave the default search query as AWS DevOps Engineer and pick a location from the dropdown.
  • Click Search Jobs.
  • Expand one of the job result cards and click Select this job.
  • Scroll down to the Tailor Resume section. Upload a resume PDF and type your GitHub username.
  • Click Generate Tailored Resume. This takes 15-30 seconds while it fetches your GitHub data and generates the resume with Gemini.

You should see the tailored resume text, a color-coded ATS score, two columns of matched and missing keywords, and a Download Resume PDF button.

  • Click Download Resume PDF and open the downloaded file to confirm it contains your tailored resume.

Search returns 0 jobs?

  • Open your .env file and verify your SERPAPI_KEY is correct with no extra spaces.
  • Check your SerpApi dashboard at serpapi.com to confirm you have free searches remaining (250 per month on the free tier).
  • Try a broader search query like DevOps Engineer or switch to United States for more results.

Help me debug why my job search returns no results

Your AI-powered job hunter is fully operational. In the Secret Mission, you will integrate the Saramin Korean job board API to add a second data source for Korean-language job postings.

Secret mission

Integrate Saramin Korean Job Board API

Your job search currently relies on SerpApi's Google Jobs aggregation. But what if you could tap directly into South Korea's largest job board? In this mission, you will integrate the Saramin (사람인) API to pull Korean-language job postings straight into your app.

Before you go

Clean Up Your Resources

This project runs entirely on your local machine using two free-tier API services. There are no ongoing cloud costs as long as you stay within the free tiers. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder with Python virtual environment and all source files.
  • SerpApi account and API key (250 free searches/month, stored in .env).
  • Google Gemini API key from Google AI Studio (free tier with free input/output tokens, stored in .env).
  • Installed system font package fonts-noto-cjk for Korean PDF rendering.
  • pip packages: streamlit, serpapi, google-genai, PyPDF2, fpdf2, requests, python-dotenv (installed inside the virtual environment).

Keep everything running

No action needed. Choose this if you are still actively building or plan to use the tool for upcoming job applications.

  • Your Streamlit app only runs when you start it from the terminal. It does not run in the background.
  • SerpApi gives you 250 free searches per month. No charges apply unless you upgrade your plan.
  • The Gemini API free tier provides free input and output tokens. No billing account is required.
  • Your API keys are stored locally in your .env file. They are not uploaded anywhere.

Pause - I'll come back to this later

Close your terminal to stop the Streamlit server. Nothing runs in the background, so there is nothing else to shut down.

  • Close the terminal window where streamlit run app.py is running. This stops the local server.
  • Your project folder, virtual environment, and .env file all stay on disk. You can pick up right where you left off.
  • To restart later, activate your virtual environment and run streamlit run app.py again.

Delete - I don't want to use this again

Remove all project files and revoke your API keys so they cannot be used again.

  • Delete the entire project folder from your Desktop.
  • Revoke your SerpApi key by logging in to serpapi.com, navigating to your API key settings, and deleting the key.
  • Revoke your Gemini API key by logging in to Google AI Studio at aistudio.google.com, navigating to your API keys, and deleting the key.
  • Optionally remove the system font package by running sudo apt remove fonts-noto-cjk in your terminal.

Mission accomplished

That's a Wrap!

Nice work! You just built an AI-powered job search and resume tailoring tool from scratch using Python, multiple APIs, and Google Gemini AI.

Here's what you accomplished:

  • Built a multi-API job search engine using SerpApi that pulls live AWS DevOps and Solutions Architect postings from dozens of job boards worldwide.
  • Created a profile ingestion pipeline that combines your resume PDF with enriched GitHub data, then used Google Gemini AI to generate tailored, ATS-optimized resumes in the correct language with a dedicated Projects section showcasing your best GitHub work.
  • Packaged everything into a Streamlit web app with ATS keyword scoring and downloadable PDF export.
  • Secret Mission: Integrated the Saramin Korean job board API for direct access to Korean job postings.

Ready to quiz yourself?