A minimal clone of ai‑job-search using free‑tier tools
Welcome to another official UVF IT guide! In this quick‑fire tutorial you’ll build a tiny command‑line job‑search assistant that mimics the core of ai‑job-search, but runs entirely on your laptop with no paid APIs.
We’ll use Python, the open‑source requests library for scraping, SQLite for storage, and a free LLM endpoint (e.g., a local llama‑cpp server) to generate cover letters – all in under 45 minutes.
git installed.Create a fresh folder for the clone, set up an isolated Python virtual environment, and install the tiny dependencies we need. This gives us a clean workspace and guarantees the same library versions on any machine.
We’ll also add a simple run.sh helper so you can start the CLI with one command, keeping the workflow beginner‑friendly.
#!/usr/bin/env bash
# 1. Create project folder
mkdir -p ai_job_cli && cd ai_job_cli
# 2. Initialise virtual environment
python3 -m venv .venv
source .venv/bin/activate
# 3. Upgrade pip and install tiny deps
pip install --upgrade pip
pip install requests
# 4. Optional helper script
cat > run.sh <<'EOF'
#!/usr/bin/env bash
source .venv/bin/activate
python cli.py "$@"
EOF
chmod +x run.sh
# 5. Verify setup
python -c "import requests, sqlite3; print('Setup OK')"We’ll store each job posting together with the generated cover letter in a single SQLite table. SQLite lives in a single file, needs no server, and works perfectly for a minimal CLI tool.
The model.py module creates the database (if missing) and provides a tiny helper to insert or fetch records, keeping the rest of the code clean.
model.py
import sqlite3
from pathlib import Path
DB_PATH = Path('jobs.db')
def _connect():
"""Return a connection with row dict access."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Create the jobs table if it doesn't exist."""
with _connect() as conn:
conn.execute('''
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
company TEXT NOT NULL,
url TEXT NOT NULL,
cover_letter TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
def add_job(title: str, company: str, url: str, cover_letter: str | None = None):
"""Insert a new job posting; returns the new row id."""
with _connect() as conn:
cur = conn.execute(
'INSERT INTO jobs (title, company, url, cover_letter) VALUES (?,?,?,?)',
(title, company, url, cover_letter)
)
conn.commit()
return cur.lastrowid
def get_jobs():
"""Yield all stored jobs as dictionaries."""
with _connect() as conn:
for row in conn.execute('SELECT * FROM jobs ORDER BY fetched_at DESC'):
yield dict(row)
# Initialise on import so the table is ready for the CLI.
init_db()We’ll pull the first page of RemoteOK using its public JSON endpoint. Each job object contains the title, company, and a URL – exactly what we need for a minimal job board. The script parses the JSON, extracts the fields we care about, and stores them in the SQLite database created in Phase 2. This gives us a persistent list of opportunities to work with later.
scrape.py
import json, sqlite3, urllib.request
DB = 'jobs.db'
REMOTEOK_URL = 'https://remoteok.com/api'
def init_db():
conn = sqlite3.connect(DB)
cur = conn.cursor()
cur.execute('''CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
company TEXT NOT NULL,
url TEXT NOT NULL,
cover_letter TEXT)''')
conn.commit()
conn.close()
def fetch_jobs():
with urllib.request.urlopen(REMOTEOK_URL) as resp:
data = json.load(resp)
# RemoteOK returns a list where the first element is metadata; skip it
return [job for job in data[1:] if job.get('title')]
def store_jobs(jobs):
conn = sqlite3.connect(DB)
cur = conn.cursor()
for job in jobs:
cur.execute('INSERT INTO jobs (title, company, url) VALUES (?,?,?)',
(job['title'], job['company'], job['url']))
conn.commit()
conn.close()
if __name__ == '__main__':
init_db()
jobs = fetch_jobs()
store_jobs(jobs)
print(f'Stored {len(jobs)} jobs into {DB}')Now we’ll call a locally‑running llama‑cpp server (or any free LLM that accepts HTTP POST). The prompt is built from the job title and company, plus a short template that asks the model to produce a concise cover letter. The generated text is written back into the same SQLite row, so later steps can retrieve it easily.
cover.py
import json, sqlite3, urllib.request
DB = 'jobs.db'
LLM_ENDPOINT = 'http://127.0.0.1:8000/v1/completions' # llama-cpp server
PROMPT_TEMPLATE = (
"Write a brief cover letter for a {title} role at {company}. "
"Highlight relevant remote‑work experience and enthusiasm.")
def get_uncovered_jobs():
conn = sqlite3.connect(DB)
cur = conn.cursor()
cur.execute('SELECT id, title, company FROM jobs WHERE cover_letter IS NULL')
rows = cur.fetchall()
conn.close()
return rows
def generate_letter(title, company):
prompt = PROMPT_TEMPLATE.format(title=title, company=company)
payload = json.dumps({
'model': 'llama',
'prompt': prompt,
'max_tokens': 150,
'temperature': 0.7
}).encode('utf-8')
req = urllib.request.Request(LLM_ENDPOINT, data=payload,
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as resp:
result = json.load(resp)
return result.get('choices',[{'text':''}])[0]['text'].strip()
def store_letter(job_id, letter):
conn = sqlite3.connect(DB)
cur = conn.cursor()
cur.execute('UPDATE jobs SET cover_letter = ? WHERE id = ?', (letter, job_id))
conn.commit()
conn.close()
if __name__ == '__main__':
for job_id, title, company in get_uncovered_jobs():
letter = generate_letter(title, company)
store_letter(job_id, letter)
print(f'Generated cover letter for job {job_id}')The final script ties everything together: it runs the scraper, calls the LLM to fill missing cover letters, and then prints a readable list of jobs with their generated letters. Running python cli.py should finish without errors and show the stored data, confirming that each phase worked end‑to‑end.
cli.py
import subprocess, sqlite3
def run_phase(script):
subprocess.run(['python', script], check=True)
def show_jobs():
conn = sqlite3.connect('jobs.db')
cur = conn.cursor()
cur.execute('SELECT title, company, url, cover_letter FROM jobs')
for title, company, url, letter in cur.fetchall():
print(f'=== {title} @ {company} ===')
print(f'URL: {url}')
print('Cover Letter:')
print(letter if letter else '<< not generated >>')
print('-' * 40)
conn.close()
if __name__ == '__main__':
# Phase 3 – scrape jobs
run_phase('scrape.py')
# Phase 4 – generate cover letters
run_phase('cover.py')
# Verify output
show_jobs()| Check | Command / Why it matters |
|---|---|
| Database file created with job rows | ls -l jobs.db && sqlite3 jobs.db "SELECT COUNT(*) FROM jobs;" |
| Cover letters populated | python cli.py | grep -i 'Cover Letter' -A2 |
| Script exits with status 0 | python cli.py; echo $? |
Your local AI‑powered job‑application CLI is now functional. Experiment by adding more job sources or swapping the LLM endpoint for another free model. Happy hunting!