Build a Searchable Job Dashboard

Scrape practice jobs into a searchable Flask dashboard with CSV export.

Introduction

30 Second Summary

Job listings are easy to browse until you need to compare dozens of them. Copying each detail by hand turns a quick search into repetitive work.

In this project, you will build Job Scout, a local Python dashboard that turns practice-page HTML into structured job records. You will use Flask to browse those records through flexible keyword and location filters.

What You'll Build

Your finished Job Scout dashboard turns a search for Python into a clean set of matching job cards in Google Chrome.

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

  • A searchable dashboard where every practice listing appears as a structured job card.
  • A flexible filter that finds partial keyword or location matches regardless of letter case.
  • Clear failure feedback when a search has no matches or the source page cannot be used.
  • Secret Mission: Add a route that returns the current filtered results as a CSV export.

Are there any prerequisites?

Comfort with Python is the only prerequisite. Basic HTML familiarity helps.

Before We Start

This is your moment to lock in what you are building before the hands-on work begins. Job Scout turns permitted practice job listings into a searchable local dashboard that you can use in Google Chrome.

Set Up the Windows Workspace

Your Job Scout plan now has a clear destination: a browser dashboard built from permitted practice listings. Shared Python packages can spill into unrelated projects when versions change.

A virtual environment gives job-scout its own package set. Inside Visual Studio Code, you will confirm Python compatibility before installing Flask, Requests, and Beautiful Soup.

In this step, get ready to:
  • Confirm that your Python runtime is version 3.10 or newer.
  • Inspect the practice page structure in Google Chrome.
  • Build an isolated environment with the three pinned packages.
Confirm Python and inspect the source

The project needs Python 3.10 or newer because every pinned dependency must support the runtime. You will confirm that compatibility before creating any files.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu.
  • Select Open Folder....
  • Select your Desktop in the folder picker.
  • Create an empty job-scout folder with the New Folder control.
  • Choose job-scout with Select Folder.

You should see JOB-SCOUT at the top of the Explorer sidebar. This confirms that future files and terminal commands belong to the correct folder.

  • Click View in the top menu.
  • Select Terminal.
  • Check your Python version by running this command:
python --version

What Does This Check Prove?

The --version option prints the Python version used by the python command. This check catches an incompatible runtime before package installation.

✔️ My Python version is compatible

Good. A result of Python 3.10 or newer can run every pinned dependency in this project.

  • Keep the integrated terminal open for the environment setup later in this step.

ⓧ I see an older version

Your current runtime is below Python 3.10. Update it before continuing so Requests can install successfully.

  • Open the official Python Install Manager instructions.
  • Download the Python Install Manager through the documented python.org option.
  • Double-click the downloaded installer file.
  • Select Install in the installer.
  • Return to the integrated terminal from earlier.
  • Repeat the Python version check from above.

ⓧ Command not found

Windows cannot currently find a Python runtime through the python command. The Python Install Manager provides the supported Windows installation route.

  • Open the official Python Install Manager instructions.
  • Download the Python Install Manager through the documented python.org option.
  • Double-click the downloaded installer file.
  • Select Install in the installer.
  • Return to the integrated terminal from earlier.
  • Repeat the Python version check from above.

Still Seeing an Incompatible Python Version?

Close the existing integrated terminal after installing Python. Open a fresh terminal so Visual Studio Code can detect the updated runtime.

Still stuck? Help me diagnose why Visual Studio Code still detects an incompatible Python version.

A browser displays the practice page from parsed HTML. The DOM reveals the exact elements that your scraper will target.

Google Chrome can connect each visible job field to its source element. This gives you a map of the page before you write the scraper.

  • Press the Windows key to open Windows search.
  • Type Google Chrome.
  • Press Enter to open Google Chrome.
  • Paste https://realpython.github.io/fake-jobs/ into the address bar.
  • Press Enter to load the practice page.
  • Right-click a visible job title.
  • Select Inspect.

Chrome selects the job title in the Elements panel. The highlighted node connects the visible text to the surrounding job card.

  • Locate the surrounding div element with the card-content class.
  • Identify the title, company, location, time, and Apply link inside that card.
  • Trace the card upward to the container with the ResultsContainer ID.

What Should the Job Card Contain?

  • The h2 element with class title holds the job title.
  • The h3 element with class company holds the company name.
  • The p element with class location holds the job location.
  • The time element holds the listing date.
  • The Apply link provides the practice listing address.

The ResultsContainer element groups the cards into one searchable area. Your scraper uses this structure to turn page content into consistent records.

Build the dependency boundary

A dependency file records the exact package versions that Job Scout expects. Reinstalling from this file produces the same starting point each time.

  • Select the Explorer icon in the Visual Studio Code Activity Bar.
  • Select New File... in the Explorer sidebar.
  • Enter requirements.txt as the file name.
  • Fill requirements.txt with the pinned dependencies by pasting this content:
Flask==3.1.3
requests==2.34.2
beautifulsoup4==4.15.0

What Does This File Control?

  • Flask 3.1.3 provides the local web application and routes.
  • Requests 2.34.2 downloads the practice page over HTTP.
  • Beautiful Soup 4.15.0 turns the downloaded HTML into elements your Python code can search.

The double equals signs pin each dependency to one version. That keeps package updates from changing the project underneath you.

  • Press Ctrl+S to save requirements.txt.
  • Confirm that requirements.txt appears directly inside job-scout in the Explorer sidebar.

✔️ Awesome, I've got everything!

Great. Your saved dependency file now gives the environment one exact package list to install.

ⓧ I'd like to double check the full code

Compare your saved requirements.txt file with this complete version.

Flask==3.1.3
requests==2.34.2
beautifulsoup4==4.15.0

Dependency File Looks Different?

Check that each package occupies its own line. Confirm that every version uses two equals signs.

Still stuck? Help me compare my requirements file with the expected package pins.

The virtual environment stores these packages inside job-scout. Your other Python projects keep their existing dependency sets.

  • Return to the integrated terminal from earlier.
  • Create the .venv environment by running this command:
py -3 -m venv .venv

What Does This Command Do?

The py launcher starts Python 3. The venv module creates an isolated environment inside the .venv folder.

  • Confirm that the .venv folder appears under job-scout in the Explorer sidebar.

Virtual Environment Folder Missing?

Confirm that the integrated terminal is open inside the job-scout folder. Check the terminal output for a Python launcher error.

Still stuck? Help me diagnose why the virtual environment was not created.

  • Activate the new environment by running this command:
.venv\Scripts\activate

What Does Activation Change?

Activation points Python package commands at the environment inside .venv. Packages installed next stay attached to this workspace.

That's the isolation boundary in place. Your terminal prompt should now begin with the environment name.

PowerShell Blocking Activation?

A PowerShell policy can block the activation script. Visual Studio Code can run the same project commands through Command Prompt.

  • Select the terminal profile dropdown in the terminal panel.
  • Choose Command Prompt.
  • Run the activation command from above in the new terminal.

Still stuck? Help me activate my Windows virtual environment from Command Prompt.

Finish the package setup

The active environment is ready to receive the versions recorded in requirements.txt. Installing from that file keeps the package set tied to the project artifact you just checked.

  • Install the pinned dependencies into the active environment by running this command:
py -m pip install -r requirements.txt

What Does This Installation Use?

The -r option tells pip to read package requirements from requirements.txt. Each pinned package is installed into the active environment.

The command returns to the prompt after installation. The output should report successful installation instead of a dependency compatibility failure.

Package Installation Failed?

Confirm that the terminal prompt shows the active environment. Check that requirements.txt is saved directly inside job-scout.

Still stuck? Help me diagnose my pinned package installation error.

Before you run the final check, which three pinned packages do you expect to find in the environment?

  • List the installed packages by running this command:
py -m pip list

What Does This Check Show?

The package list reports every dependency available to the active Python environment. It lets you compare the installed versions with requirements.txt before application development begins.

You should find Flask 3.1.3, requests 2.34.2, and beautifulsoup4 4.15.0 in the output. Other dependency rows are normal.

Your earlier version check should also show Python 3.10 or newer. Those two checks confirm that the Windows workspace matches the project's compatibility requirements.

Your Windows workspace is isolated and ready. Next, you will turn the inspected HTML structure into job records that Flask can display.

Turn Raw HTML Into Job Cards

Your Visual Studio Code workspace now has an isolated Python environment with every dependency ready. You can move from inspecting the practice page to collecting its listings.

A browser dashboard becomes useful after the source HTML has been converted into structured records. This step builds that fetch-parse-render pipeline.

Requests retrieves the page through an HTTP request. Beautiful Soup extracts each job card before Flask passes the records into a browser template.

In this step, get ready to:
  • Fetch the practice page and extract each job into a Python dictionary.
  • Connect the scraper to a Flask route and an HTML template.
  • Run the local dashboard and compare a rendered card with its source listing.
Fetch and parse the practice page

The inspected page contains repeated job cards inside ResultsContainer. Beautiful Soup can turn those elements into dictionaries with predictable fields.

Why Requests and Beautiful Soup?

The practice target serves static HTML. Requests can download that source directly.

Beautiful Soup provides focused DOM parsing without the setup required for browser automation. This keeps the project centred on extracting the page structure you inspected.

  • In the file sidebar of Visual Studio Code from earlier, create scraper.py inside the job-scout folder.
  • Paste the scraper configuration and required-field helper into scraper.py:
import requests
from bs4 import BeautifulSoup

URL = "https://realpython.github.io/fake-jobs/"
TIMEOUT_SECONDS = 10


class ScrapingError(RuntimeError):
    """Raised when the practice listings cannot be collected safely."""


def required_text(job_card, tag_name, class_name=None):
    if class_name:
        element = job_card.find(tag_name, class_=class_name)
    else:
        element = job_card.find(tag_name)

    if element is None:
        raise ScrapingError(
            f"The source page is missing an expected {tag_name} element."
        )

    return element.get_text(strip=True)

What does this scraper setup do?

  • The imports provide the page-downloading client and the HTML parser.
  • The URL points to the permitted practice page. The TIMEOUT_SECONDS value prevents a request from waiting indefinitely.
  • The ScrapingError class gives the application one clear error type for collection failures.
  • The required_text() helper finds an expected element. It raises a clear error when that element is missing.
  • Save scraper.py.
  • Confirm the file now shows the ScrapingError class above the required_text() function.

Seeing import or indentation warnings?

  • Confirm the selected Python environment is the workspace-local .venv environment from the previous step.
  • Check that the body of required_text() remains indented beneath the function definition.

Still stuck? Help me check the imports and indentation in my scraper setup.

The helper protects individual fields. The main scraper now needs to protect the network request and the wider page structure.

  • Add the remaining scrape_jobs() logic below required_text():
def scrape_jobs():
    try:
        response = requests.get(URL, timeout=TIMEOUT_SECONDS)
        response.raise_for_status()
    except requests.RequestException as error:
        raise ScrapingError(
            "The practice job page could not be downloaded. Try again shortly."
        ) from error

    soup = BeautifulSoup(response.content, "html.parser")
    results = soup.find(id="ResultsContainer")

    if results is None:
        raise ScrapingError(
            "The source page structure changed: ResultsContainer was not found."
        )

    jobs = []

    for job_card in results.find_all("div", class_="card-content"):
        apply_url = next(
            (
                link.get("href")
                for link in job_card.find_all("a")
                if link.get_text(strip=True) == "Apply" and link.get("href")
            ),
            None,
        )

        if apply_url is None:
            raise ScrapingError(
                "The source page structure changed: an Apply link was not found."
            )

        jobs.append(
            {
                "title": required_text(job_card, "h2", "title"),
                "company": required_text(job_card, "h3", "company"),
                "location": required_text(job_card, "p", "location"),
                "date": required_text(job_card, "time"),
                "apply_url": apply_url,
            }
        )

    if not jobs:
        raise ScrapingError("The source page returned no job cards.")

    return jobs

How does the scraping pipeline work?

  • The request uses a finite timeout. raise_for_status() turns unsuccessful HTTP responses into exceptions.
  • The parser receives response.content and searches for ResultsContainer.
  • The loop visits every card-content element. Each resulting dictionary holds the title, company, location, date, and listing URL.
  • The final checks prevent an empty or changed source page from looking like a successful scrape.
  • Save scraper.py.
  • Confirm the bottom of the file ends with return jobs.

Does the scraper structure look incomplete?

  • Check that scrape_jobs() begins at the left edge of the file.
  • Check that the for job_card loop remains inside scrape_jobs().
  • Confirm each field inside the job dictionary has a matching closing parenthesis or brace.

Need another pair of eyes? Help me find a structural problem in my scrape_jobs function.

✔️ Awesome, I've got everything!

Great. Your scraper now has the complete fetch and extraction pipeline saved in scraper.py.

ⓧ I'd like to double check the full code

Compare your complete scraper.py file with this reference:

import requests
from bs4 import BeautifulSoup

URL = "https://realpython.github.io/fake-jobs/"
TIMEOUT_SECONDS = 10


class ScrapingError(RuntimeError):
    """Raised when the practice listings cannot be collected safely."""


def required_text(job_card, tag_name, class_name=None):
    if class_name:
        element = job_card.find(tag_name, class_=class_name)
    else:
        element = job_card.find(tag_name)

    if element is None:
        raise ScrapingError(
            f"The source page is missing an expected {tag_name} element."
        )

    return element.get_text(strip=True)


def scrape_jobs():
    try:
        response = requests.get(URL, timeout=TIMEOUT_SECONDS)
        response.raise_for_status()
    except requests.RequestException as error:
        raise ScrapingError(
            "The practice job page could not be downloaded. Try again shortly."
        ) from error

    soup = BeautifulSoup(response.content, "html.parser")
    results = soup.find(id="ResultsContainer")

    if results is None:
        raise ScrapingError(
            "The source page structure changed: ResultsContainer was not found."
        )

    jobs = []

    for job_card in results.find_all("div", class_="card-content"):
        apply_url = next(
            (
                link.get("href")
                for link in job_card.find_all("a")
                if link.get_text(strip=True) == "Apply" and link.get("href")
            ),
            None,
        )

        if apply_url is None:
            raise ScrapingError(
                "The source page structure changed: an Apply link was not found."
            )

        jobs.append(
            {
                "title": required_text(job_card, "h2", "title"),
                "company": required_text(job_card, "h3", "company"),
                "location": required_text(job_card, "p", "location"),
                "date": required_text(job_card, "time"),
                "apply_url": apply_url,
            }
        )

    if not jobs:
        raise ScrapingError("The source page returned no job cards.")

    return jobs

What should match?

The reference contains the imports, configuration, custom error, field helper, network request, parsing loop, and final job-list return. Matching this structure gives Flask a consistent list of dictionaries to render.

Connect the scraper to Flask

The scraper produces Python data. A Flask route gives the browser a URL that turns that data into a rendered page.

  • In the file sidebar from earlier, create app.py inside the job-scout folder.
  • Paste the Flask application and root route into app.py:
from flask import Flask
from flask import render_template

from scraper import scrape_jobs

app = Flask(__name__)


@app.route("/")
def index():
    return render_template("index.html", jobs=scrape_jobs())

What does this route do?

  • The imports provide the Flask application object and template renderer. The scraper import makes the extracted jobs available to the web layer.
  • The @app.route("/") decorator connects the dashboard's root URL to index().
  • The route calls scrape_jobs() on each page load. It passes the returned list into the template as jobs.
  • Save app.py.
  • Confirm app.py appears beside scraper.py and requirements.txt in the job-scout folder.

Does the scraper import show a warning?

Confirm app.py and scraper.py are in the same job-scout folder. A nested or misspelled scraper file prevents the import from resolving.

Need help tracing it? Help me resolve the scraper import in app.py.

✔️ Awesome, I've got everything!

Your root route now sends scraped jobs to index.html.

ⓧ I'd like to double check the full code

Compare your complete app.py file with this reference:

from flask import Flask
from flask import render_template

from scraper import scrape_jobs

app = Flask(__name__)


@app.route("/")
def index():
    return render_template("index.html", jobs=scrape_jobs())

What should match?

The complete file imports Flask and scrape_jobs(). Its only route renders index.html with the collected jobs.

Flask looks for page templates inside a templates folder next to app.py. The template can loop over the dictionaries supplied by the route.

  • In the file sidebar, create a folder named templates inside job-scout.

You should see templates nested beneath the job-scout folder.

  • Inside templates, create index.html.
  • Paste the page structure and job-card loop into templates/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Job Scout</title>
</head>
<body>
  <main>
    <h1>Job Scout</h1>
    <p>{{ jobs|length }} jobs found</p>
    {% for job in jobs %}
      <article>
        <p>{{ job.date }}</p>
        <h2>{{ job.title }}</h2>
        <p>{{ job.company }}</p>
        <p>{{ job.location }}</p>
        <a href="{{ job.apply_url }}">View practice listing</a>
      </article>
    {% endfor %}
  </main>
</body>
</html>

How does the template render jobs?

  • The Jinja expression {{ jobs|length }} displays the number of dictionaries received from the route.
  • The template loop creates one article for each job.
  • Each article reads the dictionary's date, title, company, location, and apply_url values.
  • Save templates/index.html.
  • Confirm the final template line closes the html element.

Is the template file in the wrong place?

Check that the path is exactly templates/index.html inside job-scout. A template saved beside the folder cannot be discovered by Flask.

Still unsure? Help me check my Flask template folder structure.

✔️ Awesome, I've got everything!

Your template is saved inside templates and is ready to receive the scraped job list.

ⓧ I'd like to double check the full code

Compare your complete templates/index.html file with this reference:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Job Scout</title>
</head>
<body>
  <main>
    <h1>Job Scout</h1>
    <p>{{ jobs|length }} jobs found</p>
    {% for job in jobs %}
      <article>
        <p>{{ job.date }}</p>
        <h2>{{ job.title }}</h2>
        <p>{{ job.company }}</p>
        <p>{{ job.location }}</p>
        <a href="{{ job.apply_url }}">View practice listing</a>
      </article>
    {% endfor %}
  </main>
</body>
</html>

What should match?

The reference shows the result count before the loop. Every loop iteration renders all five values from one scraped job dictionary.

Run the visible first version

The three layers are now connected. Starting the development server tests whether the request, parser, route, and template can complete one full page load.

Before you start the server, do you expect the browser to show raw HTML or structured job cards?

  • Start the local Flask development server from the activated terminal by running:
flask run

What does this command do?

Flask discovers the application in app.py and starts a local development server. The terminal remains occupied while that server handles browser requests.

You should see http://127.0.0.1:5000 in the terminal output. Press CTRL+C in that terminal when you need to stop the server.

  • Switch back to Google Chrome from earlier.
  • Enter http://127.0.0.1:5000/ in the address bar.

You should see the Job Scout heading followed by a job count and multiple cards. Each card shows a date, title, company, location, and practice-listing link.

  • Select one View practice listing link.
  • Compare the opened practice listing with its rendered Job Scout card.

The title, company, location, date, and link should agree with the source listing. That match proves the complete fetch-parse-render pipeline is working.

Seeing an error or an empty browser page?

  • Confirm the activated terminal still shows the .venv environment before the prompt.
  • Check that app.py and scraper.py are directly inside job-scout.
  • Check that the template path is exactly templates/index.html.

Need help diagnosing the result? Help me debug my first Job Scout page load.

That is your first working Job Scout dashboard. Next, you will add keyword search and see why exact matching misses realistic job titles.

Build a Filter That Fails

Your local Flask dashboard already turns the practice page into job cards in Google Chrome. The next goal is to let someone search those cards from the browser.

A complete-title comparison looks like a reasonable first filter. This step puts that assumption under pressure with a realistic search.

In this step, get ready to:
  • Add a keyword search form with a preserved submitted value.
  • Connect the keyword query parameter to exact title matching.
  • Test the exact-match behavior with Python.
Add a keyword search form

A GET form places its field values in the page URL as a query parameter. The template can reuse that value after each search.

  • Switch back to templates/index.html in the Visual Studio Code editor.
  • Inside <main>, replace everything from <h1>Job Scout</h1> through {% endfor %} with the code below:
    <h1>Job Scout</h1>
    <form method="get">
      <label>
        Keyword
        <input type="search" name="keyword" value="{{ keyword }}">
      </label>
      <button type="submit">Search jobs</button>
    </form>
    {% if jobs %}
      <p>{{ jobs|length }} matching jobs</p>
      {% for job in jobs %}
        <article>
          <p>{{ job.date }}</p>
          <h2>{{ job.title }}</h2>
          <p>{{ job.company }}</p>
          <p>{{ job.location }}</p>
          <a href="{{ job.apply_url }}">View practice listing</a>
        </article>
      {% endfor %}
    {% else %}
      <section>
        <h2>No jobs matched</h2>
        <p>Try a shorter keyword or clear one of the filters.</p>
      </section>
    {% endif %}

What does this template do?

  • The method="get" attribute submits the search value through the page URL.
  • The value="{{ keyword }}" attribute keeps the submitted keyword visible in the field.
  • The Jinja condition displays the count plus the job cards when results exist.
  • The {% else %} branch displays a clear message when the list is empty.
  • Save templates/index.html.
  • Return to the running dashboard in Google Chrome.
  • Refresh the page.

Good progress. Your dashboard now shows a keyword field above the complete job list.

Don't see the search form?

  • Check that the form sits inside the existing <main> element.
  • Confirm that every opening {% if %} block has the matching {% endif %} line.

Still stuck? Help me check my Job Scout search form.

✔️ Awesome, I've got everything!

Your saved template now shows the search form plus the complete job list.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Job Scout</title>
</head>
<body>
  <main>
    <h1>Job Scout</h1>
    <form method="get">
      <label>
        Keyword
        <input type="search" name="keyword" value="{{ keyword }}">
      </label>
      <button type="submit">Search jobs</button>
    </form>
    {% if jobs %}
      <p>{{ jobs|length }} matching jobs</p>
      {% for job in jobs %}
        <article>
          <p>{{ job.date }}</p>
          <h2>{{ job.title }}</h2>
          <p>{{ job.company }}</p>
          <p>{{ job.location }}</p>
          <a href="{{ job.apply_url }}">View practice listing</a>
        </article>
      {% endfor %}
    {% else %}
      <section>
        <h2>No jobs matched</h2>
        <p>Try a shorter keyword or clear one of the filters.</p>
      </section>
    {% endif %}
  </main>
</body>
</html>

How does the full template fit together?

This reference keeps the form before the conditional results section. It also places the job loop entirely inside the results branch.

Read and filter the query parameter

Flask exposes submitted query values through request. The route can clean the keyword before comparing it with each scraped title.

  • Switch to app.py in the Visual Studio Code editor.
  • Replace the contents of app.py with the code below:
from flask import Flask
from flask import render_template
from flask import request

from scraper import scrape_jobs

app = Flask(__name__)


@app.route("/")
def index():
    keyword = request.args.get("keyword", "").strip()
    jobs = scrape_jobs()

    if keyword:
        jobs = [
            job
            for job in jobs
            if job["title"].casefold() == keyword.casefold()
        ]

    return render_template("index.html", jobs=jobs, keyword=keyword)

What does this route do?

  • The request import gives the route access to values submitted through the browser.
  • The request.args.get("keyword", "").strip() expression reads the keyword while removing surrounding spaces.
  • The if keyword: condition leaves the full list untouched when the field is empty.
  • The two casefold() calls remove differences in letter case before the equality check.
  • Save app.py.
  • Press Ctrl+C in the integrated terminal that is running Flask.
  • Restart the local server by running this command:
flask run

What does this command do?

The command starts the development application defined in app.py. It makes the updated route available through the local dashboard URL.

You should see the local server address in the terminal without a Python traceback.

Server not starting?

  • Confirm that the terminal prompt still shows the activated virtual environment.
  • Check the indentation beneath def index(): against the reference code.
  • Confirm that the earlier server stopped before you restarted it.

Still stuck? Help me restart the updated Flask app.

✔️ Awesome, I've got everything!

Your updated route is running with the keyword query logic loaded.

ⓧ I'd like to double check the full code

from flask import Flask
from flask import render_template
from flask import request

from scraper import scrape_jobs

app = Flask(__name__)


@app.route("/")
def index():
    keyword = request.args.get("keyword", "").strip()
    jobs = scrape_jobs()

    if keyword:
        jobs = [
            job
            for job in jobs
            if job["title"].casefold() == keyword.casefold()
        ]

    return render_template("index.html", jobs=jobs, keyword=keyword)

How does the full route fit together?

This reference imports the request object before reading the keyword inside index(). The final template call passes both the filtered list and the submitted value to the page.

Observe the exact-match failure

The filter is wired into the browser. One search can now reveal whether complete-title equality fits the way people search for jobs.

Before you search, do you think exact title matching accepts Python as a useful query?

  • Return to the Job Scout dashboard in Google Chrome.
  • Refresh the page.
  • Enter Python in the Keyword field.
  • Click Search jobs.

You'll see the No jobs matched message with zero job cards. The submitted Python value remains visible in the search field.

That shortfall is the result we wanted. You have proved that exact equality misses useful matches hidden inside longer titles.

Why did Python return no cards?

The casefold() calls make the comparison case-insensitive. The == operator still requires the complete job title to equal Python.

A title such as Senior Python Developer contains the keyword without equalling it. Substring matching is the rule that fits this search behavior.

Your dashboard now demonstrates the exact-match limitation in the browser. Next, you'll replace that brittle rule with flexible keyword and location filtering.

Make Search Flexible and Resilient

That failed search did its job. You proved that exact equality misses useful listings when a title contains extra words.

A reliable Flask dashboard needs flexible filtering. It also needs a clear response when the source page cannot provide valid jobs.

In this step, get ready to:
  • Replace exact title equality with reusable keyword and location filters.
  • Show useful empty states and source failure messages in the dashboard.
  • Style the finished interface and verify every search path.
Replace exact matching with partial filters

A substring check accepts a search term anywhere inside a title. Applying casefold() to both values keeps the comparison case-insensitive.

The same helper can filter locations without duplicating the matching logic. Empty query values leave that part of the dataset unchanged.

  • Switch back to app.py in Visual Studio Code.
  • Select the existing scraper import and everything below it through app = Flask(__name__).
  • Replace the selected section with this error import and filtering helper:
from scraper import ScrapingError
from scraper import scrape_jobs

app = Flask(__name__)


def filter_jobs(jobs, keyword, location):
    keyword_query = keyword.casefold()
    location_query = location.casefold()

    return [
        job
        for job in jobs
        if (
            not keyword_query
            or keyword_query in job["title"].casefold()
        )
        and (
            not location_query
            or location_query in job["location"].casefold()
        )
    ]

What does this helper do?

  • The ScrapingError import gives the route access to the scraper's controlled failure type.
  • The two query variables hold case-insensitive versions of the submitted filters.
  • The list comprehension keeps jobs whose title contains the keyword.
  • The second condition keeps jobs whose location contains the location query.
  • Select the existing @app.route("/") section through its render_template() return statement.
  • Replace the selected route with this version:
@app.route("/")
def index():
    keyword = request.args.get("keyword", "").strip()
    location = request.args.get("location", "").strip()
    error_message = None

    try:
        jobs = filter_jobs(scrape_jobs(), keyword, location)
    except ScrapingError as error:
        jobs = []
        error_message = str(error)

    return render_template(
        "index.html",
        jobs=jobs,
        keyword=keyword,
        location=location,
        error_message=error_message,
    )

How does the route use the filters?

  • The route reads both query parameters and removes surrounding whitespace.
  • The try block sends scraped jobs through filter_jobs().
  • The except block converts a scraper failure into an empty list and a readable message.
  • The template receives every value needed to preserve filters and choose a visible state.
  • Save app.py.
  • Keep the Flask server from earlier running in the activated terminal.

Before you search again, do you think different capitalization or extra title words can still prevent a match?

  • Return to the Job Scout page in Google Chrome.
  • Refresh the page.
  • Enter Python in the keyword field.
  • Click Search jobs.

You should now see several matching cards. Titles containing Python inside a longer phrase remain in the results.

Still seeing zero Python jobs?

  • Check that the title condition uses in between keyword_query and the folded job title.
  • Confirm that index() calls filter_jobs().
  • Save app.py before refreshing the browser.

Still stuck? Help me debug my partial keyword filter.

✔️ Awesome, I've got everything!

Great. Your keyword search now finds partial title matches without caring about capitalization.

ⓧ I'd like to double check the full code

Compare your complete app.py file with this version.

from flask import Flask
from flask import render_template
from flask import request

from scraper import ScrapingError
from scraper import scrape_jobs

app = Flask(__name__)


def filter_jobs(jobs, keyword, location):
    keyword_query = keyword.casefold()
    location_query = location.casefold()

    return [
        job
        for job in jobs
        if (
            not keyword_query
            or keyword_query in job["title"].casefold()
        )
        and (
            not location_query
            or location_query in job["location"].casefold()
        )
    ]


@app.route("/")
def index():
    keyword = request.args.get("keyword", "").strip()
    location = request.args.get("location", "").strip()
    error_message = None

    try:
        jobs = filter_jobs(scrape_jobs(), keyword, location)
    except ScrapingError as error:
        jobs = []
        error_message = str(error)

    return render_template(
        "index.html",
        jobs=jobs,
        keyword=keyword,
        location=location,
        error_message=error_message,
    )
Show filters and source failures

The route now prepares three possible outcomes for the HTML template. The page can render jobs, an empty search result, or a source error.

Your scraper from earlier already wraps Requests failures and unexpected page structures in ScrapingError. The template needs a dedicated error branch so learners see that message.

  • Switch back to templates/index.html.
  • Replace the existing opening <main> tag and <h1> line with this header:
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">Python scraping project</p>
      <h1>Job Scout</h1>
      <p>Search structured listings collected from the Real Python practice site.</p>
    </header>

What does this header add?

  • The shell class gives the page a centered layout hook.
  • The hero section introduces the dashboard before the search controls.
  • The eyebrow text identifies this as a Python scraping project.
  • Save templates/index.html.
  • Refresh the dashboard in Google Chrome.

You should see the new project description directly below the Job Scout heading.

Header text missing?

  • Confirm that the new header remains inside the opening <main class="shell"> element.
  • Check that each opening tag has a matching closing tag.

Need another pair of eyes? Help me check my Job Scout header markup.

  • Select the existing keyword <form> from its opening tag through its closing tag.
  • Replace the selected form with these keyword and location controls:
    <form class="filters" action="{{ url_for('index') }}" method="get">
      <label>
        Keyword
        <input
          type="search"
          name="keyword"
          value="{{ keyword }}"
          placeholder="Try Python or engineer"
        >
      </label>

      <label>
        Location
        <input
          type="search"
          name="location"
          value="{{ location }}"
          placeholder="Try AA or Port"
        >
      </label>

      <button type="submit">Search jobs</button>
      <a class="reset" href="{{ url_for('index') }}">Reset</a>
    </form>

How do the controls preserve a search?

  • The form sends both fields as URL query parameters.
  • The value attributes display the submitted filters after each request.
  • The reset link loads the root route without query parameters.
  • Save templates/index.html.
  • Refresh the dashboard in Google Chrome.

You should see separate Keyword and Location fields. You should also see Search jobs and Reset controls.

Location field not keeping its value?

  • Check that the field name is location.
  • Confirm that its value uses {{ location }}.
  • Verify that app.py passes location=location to the template.

Still stuck? Help me preserve my location query value.

The result area uses Jinja conditions to choose one visible state. Giving errors the first branch prevents a source failure from looking like an ordinary search with no matches.

  • Select the existing template section from {% if jobs %} through its matching {% endif %}.
  • Replace the selected section with these three display states:
    {% if error_message %}
      <section class="notice error" role="alert">
        <h2>Listings unavailable</h2>
        <p>{{ error_message }}</p>
      </section>
    {% elif jobs %}
      <p class="count">{{ jobs|length }} matching jobs</p>
      <section class="job-grid" aria-label="Job results">
        {% for job in jobs %}
          <article class="job-card">
            <p class="date">{{ job.date }}</p>
            <h2>{{ job.title }}</h2>
            <p class="company">{{ job.company }}</p>
            <p class="location">{{ job.location }}</p>
            <a href="{{ job.apply_url }}" target="_blank" rel="noreferrer">
              View practice listing
            </a>
          </article>
        {% endfor %}
      </section>
    {% else %}
      <section class="notice">
        <h2>No jobs matched</h2>
        <p>Try a shorter keyword or clear one of the filters.</p>
      </section>
    {% endif %}

How do the three states work?

  • A populated error_message displays the scraper's explanation inside an alert.
  • A non-empty jobs list displays the matching count and cards.
  • An empty list without a source error displays the no-match guidance.
  • Each listing link opens its synthetic practice page in a separate browser tab.
  • Save templates/index.html.
  • Refresh the dashboard in Google Chrome.
  • Enter zzzz in the Keyword field.
  • Click Search jobs.

You should see the No jobs matched state without a traceback or broken page.

  • Click Reset.

The full listing set should return. Both search fields should be empty.

Seeing a template error?

  • Confirm that the conditional begins with {% if error_message %}.
  • Check that the job loop closes before the final empty-state branch.
  • Verify that the entire conditional ends with {% endif %}.

Need help tracing the blocks? Help me debug my Jinja result states.

Style and verify the finished dashboard

The dashboard now works across successful searches and failures. CSS turns those states into a responsive interface with clear visual separation.

  • Return to the Explorer sidebar in Visual Studio Code.
  • Right-click the job-scout folder.
  • Select New Folder.
  • Enter static as the folder name.
  • Right-click the new static folder.
  • Select New File.
  • Enter style.css as the file name.

You should now see static/style.css in the Explorer sidebar.

  • Switch back to templates/index.html.
  • Add this stylesheet link directly below the <title>Job Scout</title> line:
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

How does Flask find the stylesheet?

The static endpoint builds a URL for static/style.css. The browser requests that file whenever it loads the dashboard.

  • Switch to static/style.css.
  • Add the page foundation by pasting this first section:
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: #172033;
  background: #eef3f8;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(145deg, #e8f0ff, #f8fbff 45%, #eaf8f2);
}

.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 64px;
}

.hero {
  margin-bottom: 24px;
}

What does the page foundation control?

  • The root rule sets the typeface and base colors.
  • The universal rule keeps element widths predictable when padding is added.
  • The body creates the full-page gradient.
  • The shell limits the content width and centers the dashboard.
  • Save templates/index.html.
  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.

You should see a pale gradient background with the dashboard centered inside a wide column.

Stylesheet not loading?

  • Confirm that style.css is inside the static folder.
  • Check that the stylesheet link sits inside the template's <head> section.
  • Save both edited files before refreshing.

Still unstyled? Help me find why Flask is not loading style.css.

  • Add the hero typography below the existing .hero rule:
.eyebrow {
  margin: 0 0 8px;
  color: #2563eb;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1 {
  margin: 0;
  font-size: clamp(2.4rem, 7vw, 4.8rem);
  line-height: 1;
}

.hero > p:last-child {
  max-width: 650px;
  color: #4b5870;
  font-size: 1.05rem;
}

What changes in the hero?

The eyebrow becomes a compact blue label. The main heading scales with the browser width while the description stays readable.

  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.

You should see a large Job Scout heading with a blue uppercase label above it.

Hero text still looks plain?

  • Check that the template uses class="eyebrow" on the project label.
  • Confirm that the description is the final paragraph inside <header class="hero">.

Need help matching the selectors? Help me debug my hero styles.

  • Add the filter panel layout below the hero styles:
.filters {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr)) auto auto;
  gap: 12px;
  align-items: end;
  padding: 18px;
  border: 1px solid #d9e2ef;
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.9);
  box-shadow: 0 16px 40px rgba(36, 56, 87, 0.08);
}

label {
  display: grid;
  gap: 7px;
  color: #34435d;
  font-size: 0.85rem;
  font-weight: 700;
}

input,
button,
.reset {
  min-height: 44px;
  border-radius: 10px;
  font: inherit;
}

How does the filter panel work?

  • The grid places both fields and both controls on one row when space allows.
  • The panel background separates the controls from the page.
  • The shared sizing gives inputs and controls a consistent height.
  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.

You should see the search controls inside a rounded white panel.

Filters not forming a panel?

  • Confirm that the form uses class="filters".
  • Check that the closing brace for every selector is present.

Still stuck? Help me debug the filter panel layout.

  • Add the input and control styles below the shared sizing rule:
input {
  width: 100%;
  border: 1px solid #bcc9db;
  padding: 0 12px;
  background: #ffffff;
}

button,
.reset {
  display: inline-grid;
  place-items: center;
  border: 0;
  padding: 0 18px;
  font-weight: 800;
  text-decoration: none;
  cursor: pointer;
}

button {
  color: #ffffff;
  background: #2563eb;
}

.reset {
  color: #25324a;
  background: #e7edf5;
}

How do the controls become distinct?

The search button uses blue to signal the primary action. The reset link uses a quieter background while retaining the same shape.

  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.

You should see a blue Search jobs button and a light Reset control aligned with the fields.

Reset still looks like a plain link?

  • Confirm that the reset link uses class="reset".
  • Check that .reset appears in both the shared control rule and its color rule.

Need help tracing the class? Help me style the Reset link like a control.

  • Add the result count and card grid styles below the control rules:
.count {
  margin: 24px 0 12px;
  color: #526078;
  font-weight: 700;
}

.job-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 16px;
}

.job-card,
.notice {
  border: 1px solid #d9e2ef;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(36, 56, 87, 0.07);
}

.job-card {
  display: flex;
  min-height: 240px;
  flex-direction: column;
  padding: 20px;
}

How does the result grid respond?

  • The grid creates as many card columns as the available width can hold.
  • Each card receives a white surface with a border and shadow.
  • The flexible column layout leaves room to align listing links consistently.
  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.

You should see the job results arranged as separate cards across a responsive grid.

Jobs still appearing in one plain column?

  • Confirm that the results section uses class="job-grid".
  • Check that each repeated article uses class="job-card".

Still stuck? Help me debug the responsive job grid.

  • Add the card text and notice spacing below the card layout:
.job-card h2 {
  margin: 6px 0 10px;
  font-size: 1.25rem;
}

.date,
.company,
.location {
  margin: 0 0 8px;
  color: #5b6880;
}

.date {
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.05em;
}

.job-card a {
  margin-top: auto;
  color: #1d4ed8;
  font-weight: 800;
}

.notice {
  margin-top: 24px;
  padding: 24px;
}

What changes inside each card?

  • The title receives stronger spacing and a readable size.
  • The metadata uses a quieter color to support the title.
  • The listing link stays at the bottom of cards with different text lengths.
  • Notice states receive enough space to remain easy to scan.
  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.

You should see consistent title spacing and muted job details. Every View practice listing link should sit near the bottom of its card.

Card details not styled?

  • Check that the template uses the date, company, and location classes.
  • Confirm that the listing link remains inside each job-card article.

Need help checking the card structure? Help me match my card classes to the CSS.

  • Finish the stylesheet with the notice heading, error state, and mobile layout:
.notice h2 {
  margin-top: 0;
}

.error {
  border-color: #efb7b7;
  background: #fff6f6;
}

@media (max-width: 760px) {
  .filters {
    grid-template-columns: 1fr;
  }
}

What does the final styling add?

  • The error state receives a pale red background and matching border.
  • The media query stacks the search controls when the browser is narrow.
  • Save static/style.css.
  • Refresh the dashboard in Google Chrome.
  • Narrow the browser window.

You should see the filter controls move into a single column when the page becomes narrow.

Controls overflowing on a narrow screen?

  • Check that the media query closes after the .filters rule.
  • Confirm that the mobile grid value is 1fr.

Still stuck? Help me debug the mobile filter layout.

✔️ Awesome, I've got everything!

Your template now contains both filters and every display state. Your stylesheet now covers the complete responsive dashboard.

ⓧ I'd like to double check the full code

Compare your complete templates/index.html file with this version.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Job Scout</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">Python scraping project</p>
      <h1>Job Scout</h1>
      <p>Search structured listings collected from the Real Python practice site.</p>
    </header>

    <form class="filters" action="{{ url_for('index') }}" method="get">
      <label>
        Keyword
        <input
          type="search"
          name="keyword"
          value="{{ keyword }}"
          placeholder="Try Python or engineer"
        >
      </label>

      <label>
        Location
        <input
          type="search"
          name="location"
          value="{{ location }}"
          placeholder="Try AA or Port"
        >
      </label>

      <button type="submit">Search jobs</button>
      <a class="reset" href="{{ url_for('index') }}">Reset</a>
    </form>

    {% if error_message %}
      <section class="notice error" role="alert">
        <h2>Listings unavailable</h2>
        <p>{{ error_message }}</p>
      </section>
    {% elif jobs %}
      <p class="count">{{ jobs|length }} matching jobs</p>
      <section class="job-grid" aria-label="Job results">
        {% for job in jobs %}
          <article class="job-card">
            <p class="date">{{ job.date }}</p>
            <h2>{{ job.title }}</h2>
            <p class="company">{{ job.company }}</p>
            <p class="location">{{ job.location }}</p>
            <a href="{{ job.apply_url }}" target="_blank" rel="noreferrer">
              View practice listing
            </a>
          </article>
        {% endfor %}
      </section>
    {% else %}
      <section class="notice">
        <h2>No jobs matched</h2>
        <p>Try a shorter keyword or clear one of the filters.</p>
      </section>
    {% endif %}
  </main>
</body>
</html>

Compare your complete static/style.css file with this version.

:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: #172033;
  background: #eef3f8;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(145deg, #e8f0ff, #f8fbff 45%, #eaf8f2);
}

.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 64px;
}

.hero {
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 8px;
  color: #2563eb;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1 {
  margin: 0;
  font-size: clamp(2.4rem, 7vw, 4.8rem);
  line-height: 1;
}

.hero > p:last-child {
  max-width: 650px;
  color: #4b5870;
  font-size: 1.05rem;
}

.filters {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr)) auto auto;
  gap: 12px;
  align-items: end;
  padding: 18px;
  border: 1px solid #d9e2ef;
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.9);
  box-shadow: 0 16px 40px rgba(36, 56, 87, 0.08);
}

label {
  display: grid;
  gap: 7px;
  color: #34435d;
  font-size: 0.85rem;
  font-weight: 700;
}

input,
button,
.reset {
  min-height: 44px;
  border-radius: 10px;
  font: inherit;
}

input {
  width: 100%;
  border: 1px solid #bcc9db;
  padding: 0 12px;
  background: #ffffff;
}

button,
.reset {
  display: inline-grid;
  place-items: center;
  border: 0;
  padding: 0 18px;
  font-weight: 800;
  text-decoration: none;
  cursor: pointer;
}

button {
  color: #ffffff;
  background: #2563eb;
}

.reset {
  color: #25324a;
  background: #e7edf5;
}

.count {
  margin: 24px 0 12px;
  color: #526078;
  font-weight: 700;
}

.job-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 16px;
}

.job-card,
.notice {
  border: 1px solid #d9e2ef;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(36, 56, 87, 0.07);
}

.job-card {
  display: flex;
  min-height: 240px;
  flex-direction: column;
  padding: 20px;
}

.job-card h2 {
  margin: 6px 0 10px;
  font-size: 1.25rem;
}

.date,
.company,
.location {
  margin: 0 0 8px;
  color: #5b6880;
}

.date {
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.05em;
}

.job-card a {
  margin-top: auto;
  color: #1d4ed8;
  font-weight: 800;
}

.notice {
  margin-top: 24px;
  padding: 24px;
}

.notice h2 {
  margin-top: 0;
}

.error {
  border-color: #efb7b7;
  background: #fff6f6;
}

@media (max-width: 760px) {
  .filters {
    grid-template-columns: 1fr;
  }
}

Before the final check, which searches do you expect to show cards and which search should show the empty state?

  • Return to the activated terminal from earlier.
  • Start the local development server with this command if the terminal prompt is available:
flask run

What should the server show?

Flask should provide the local address http://127.0.0.1:5000/. Keep the terminal process running while you test the dashboard.

  • Open http://127.0.0.1:5000/ in Google Chrome.
  • Enter Python in the Keyword field.
  • Click Search jobs.

You should see cards whose longer titles contain Python. The Keyword field should still display your submitted value.

  • Copy a short location fragment from one visible card.
  • Enter that fragment in the Location field.
  • Click Search jobs.

The results should narrow to cards that contain both the keyword and the location fragment.

  • Replace the Keyword value with zzzz.
  • Clear the Location field.
  • Click Search jobs.

You should see the clean No jobs matched state.

  • Click Reset.

You should see the complete listing set again with both filter fields empty.

  • Click one View practice listing link.

A separate browser tab should open the corresponding synthetic listing from the practice site.

Final checks not matching?

  • Confirm that the terminal still shows the Flask development server running.
  • Check that both query conditions in filter_jobs() use substring matching.
  • Verify that the Reset link calls url_for('index') without query values.

Still stuck? Help me diagnose my final Job Scout verification.

You turned the exact-match failure into a searchable dashboard with visible recovery paths. Job Scout now handles realistic queries and presents every outcome clearly.

Secret mission

Export the Filtered Jobs as CSV

Turn the dashboard's current search results into reusable CSV text. Your export route will preserve both active filters so Google Chrome shows the same records as the dashboard.

Clean Up Your Resources

Clean Up Your Resources

Your next choice is whether to keep Job Scout available, pause its local server, or delete its files. Everything runs locally, so keeping the project creates no ongoing cost.

Resources you used:

  • The running Flask development server for the dashboard plus its CSV export.
  • The local job-scout folder containing the application files plus the .venv virtual environment.

Keep everything running

No action needed. Choose this if you are still testing filters or building on the CSV export.

  • Your dashboard remains available while the Flask development server is running.
  • Your source code stays inside the job-scout folder.
  • Your pinned packages stay isolated inside the .venv virtual environment.

Job Scout remains ready for another local session without creating ongoing charges.

Pause - I'll come back to this later

Shut down the local server to free its port. Your project files remain ready for your next session.

  • Return to the integrated terminal that is running Flask.
  • Press Ctrl+C to stop the development server.
  • Refresh the Job Scout page in Google Chrome.

You should see that the local dashboard can no longer connect. This confirms the Flask process has stopped.

Your job-scout folder remains unchanged, so you can restart the dashboard when you return.

Delete - I don't want to use this again

Remove the local project when you no longer need its dashboard or CSV export.

Deleting the folder is permanent. This removes the source files plus the project-specific virtual environment.

  • Return to the integrated terminal that is running Flask.
  • Press Ctrl+C to stop the development server.
  • Click the trash can icon in the Visual Studio Code terminal panel to close that terminal session.
  • Select Command Prompt from the terminal profile menu to start a new terminal.
  • Delete the job-scout folder by running these commands:
cd ..
rmdir /s /q job-scout

What do these commands do?

The first line moves the terminal outside the project folder.

The second line permanently removes the job-scout folder plus everything stored inside it.

  • Check the Explorer sidebar in Visual Studio Code.

You should see that the job-scout files are no longer available. That is the cleanup complete: Job Scout no longer occupies local storage.

Folder still present?

A project file may still be open in another program. Close any open Job Scout files before trying the cleanup commands again.

Help me remove the local project folder.

Nice Work!

Nice Work!

You did it! Your local Job Scout dashboard now turns permitted practice listings into searchable job cards. It also exports the current filtered results as CSV text.

You've learned how to:

  • Build a local Flask dashboard that turns HTML from the Real Python practice site into consistent job cards.
  • Create case-insensitive partial-match filters for job titles. Apply the same matching behavior to locations. Preserve both query values after each search.
  • Add defensive error handling for download failures. Validate the expected page structure before rendering results. Surface each problem through a clear dashboard message.
  • Complete the optional Secret Mission by reusing the scraping and filtering pipeline in an /export route. Google Chrome now displays only the active matches as CSV text.

Ready to quiz yourself?