Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
db1b9c2
fix: upgrade Levenshtein to >=0.26.0 for Python 3.13 compatibility
railway-app[bot] Apr 3, 2026
0fca2d4
Merge pull request #1 from emirsaffar-collab/railway/code-change-Zd4R04
emirsaffar-collab Apr 3, 2026
8d78168
feat: add web UI with FastAPI, async doc generation, WebSocket progre…
Copilot Apr 3, 2026
2ca3b04
fix: make web server work without selenium/openai, fix imports for li…
Copilot Apr 3, 2026
8d05fa6
fix: address CodeQL alert (case-insensitive regex) and review feedback
Copilot Apr 3, 2026
c656dce
fix: handle whitespace in closing script/style tags for robust HTML s…
Copilot Apr 3, 2026
f663667
Merge pull request #4 from emirsaffar-collab/copilot/integrate-web-ui…
emirsaffar-collab Apr 3, 2026
3ae213a
fix: add git to Dockerfile for pip git+ dependency install
Copilot Apr 3, 2026
fc1fca3
Merge pull request #5 from emirsaffar-collab/copilot/load-build-defin…
emirsaffar-collab Apr 3, 2026
8d0fe0a
fix: replace apt-key with gpg keyring for Google Chrome repo
railway-app[bot] Apr 3, 2026
bdad61f
Merge pull request #6 from emirsaffar-collab/railway/code-change-uTc4TA
emirsaffar-collab Apr 3, 2026
ddabe9b
feat: add work preferences and resume config API endpoints to web UI
Copilot Apr 3, 2026
32c92f6
feat: add tabbed UI with Resume and Settings panels
Copilot Apr 3, 2026
396f2e9
Add work preferences and resume config management to web UI
Copilot Apr 3, 2026
a66f1c0
Fix validation for missing keys, restore data files, fix status message
Copilot Apr 3, 2026
96b8101
Merge pull request #7 from emirsaffar-collab/copilot/check-web-ui-fun…
emirsaffar-collab Apr 3, 2026
70b224c
feat: add unified multi-platform job application bot with web UI
claude Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ share/python-wheels/
MANIFEST
chrome_profile/*
data_folder/output/*
data_folder/credentials.yaml
data_folder/applications.db
data_folder/cookies/
answers.json
# PyInstaller
# Usually these files are written by a python script from a template
Expand Down
31 changes: 31 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
FROM python:3.11-slim

# Install Chrome for PDF generation (optional, falls back to reportlab)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
wget \
gnupg2 \
&& wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends google-chrome-stable \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy requirements first for caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Create data directories
RUN mkdir -p data_folder/output log

# Expose port (Railway sets PORT env var)
EXPOSE 8080

# Run the web server
CMD ["python", "main.py", "web"]
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: python main.py web
17 changes: 11 additions & 6 deletions config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# In this file, you can set the configurations of the app.

import os
from src.utils.constants import DEBUG, ERROR, LLM_MODEL, OPENAI

#config related to logging must have prefix LOG_
LOG_LEVEL = 'ERROR'
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'ERROR')
LOG_SELENIUM_LEVEL = ERROR
LOG_TO_FILE = False
LOG_TO_CONSOLE = False
LOG_TO_FILE = os.environ.get('LOG_TO_FILE', 'false').lower() == 'true'
LOG_TO_CONSOLE = os.environ.get('LOG_TO_CONSOLE', 'false').lower() == 'true'

MINIMUM_WAIT_TIME_IN_SECONDS = 60

Expand All @@ -16,7 +17,11 @@
JOB_MAX_APPLICATIONS = 5
JOB_MIN_APPLICATIONS = 1

LLM_MODEL_TYPE = 'openai'
LLM_MODEL = 'gpt-4o-mini'
LLM_MODEL_TYPE = os.environ.get('LLM_MODEL_TYPE', 'claude')
LLM_MODEL = os.environ.get('LLM_MODEL', 'claude-sonnet-4-20250514')
# Only required for OLLAMA models
LLM_API_URL = ''
LLM_API_URL = os.environ.get('LLM_API_URL', '')

# Web server configuration
WEB_HOST = os.environ.get('WEB_HOST', '0.0.0.0')
WEB_PORT = int(os.environ.get('PORT', os.environ.get('WEB_PORT', '8080')))
23 changes: 23 additions & 0 deletions data_folder_example/credentials.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Platform login credentials for the Auto Apply bot.
# Copy this file to data_folder/credentials.yaml and fill in your details.
# IMPORTANT: Keep this file private — never commit it to version control.

linkedin:
email: "your.email@example.com"
password: "your_linkedin_password"

indeed:
email: "your.email@example.com"
password: "your_indeed_password"

glassdoor:
email: "your.email@example.com"
password: "your_glassdoor_password"

ziprecruiter:
email: "your.email@example.com"
password: "your_ziprecruiter_password"

dice:
email: "your.email@example.com"
password: "your_dice_password"
13 changes: 12 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import base64
import sys

# Check for web mode early, before importing CLI-specific dependencies
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "web":
import uvicorn
import config as cfg
from src.web.app import app

print(f"Starting AIHawk web server on {cfg.WEB_HOST}:{cfg.WEB_PORT}")
uvicorn.run(app, host=cfg.WEB_HOST, port=cfg.WEB_PORT)
sys.exit(0)

import base64
from pathlib import Path
import traceback
from typing import List, Optional, Tuple, Dict
Expand Down
14 changes: 14 additions & 0 deletions railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"startCommand": "python main.py web",
"healthcheckPath": "/api/health",
"healthcheckTimeout": 30,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}
10 changes: 8 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ langchain-ollama==0.1.3
langchain-openai==0.1.17
langchain-text-splitters==0.2.2
langsmith==0.1.93
Levenshtein==0.25.1
Levenshtein>=0.26.0
loguru==0.7.2
openai==1.37.1
pdfminer.six==20221105
Expand All @@ -28,4 +28,10 @@ webdriver-manager==4.0.2
pytest
pytest-mock
pytest-cov
undetected-chromedriver==3.5.5
undetected-chromedriver==3.5.5
fastapi>=0.110.0
uvicorn[standard]>=0.24.0
websockets>=12.0
inquirer
playwright>=1.44.0
aiosqlite>=0.20.0
5 changes: 5 additions & 0 deletions src/automation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Automation module: multi-platform job application bot."""
from src.automation.bot_manager import BotManager, BotConfig
from src.automation.application_tracker import ApplicationTracker

__all__ = ["BotManager", "BotConfig", "ApplicationTracker"]
217 changes: 217 additions & 0 deletions src/automation/application_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""SQLite-backed application history tracker."""
from __future__ import annotations

import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from src.logging import logger

DB_PATH = Path("data_folder/applications.db")

# Thread-local storage so each thread gets its own connection
_local = threading.local()


def _get_conn(db_path: Path | None = None) -> sqlite3.Connection:
path = str(db_path or DB_PATH)
if not hasattr(_local, "conns"):
_local.conns = {}
conn = _local.conns.get(path)
if conn is not None:
try:
conn.execute("SELECT 1")
return conn
except sqlite3.ProgrammingError:
pass
conn = sqlite3.connect(path, timeout=30)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=10000")
conn.row_factory = sqlite3.Row
_local.conns[path] = conn
return conn


class ApplicationTracker:
"""Track job applications in a local SQLite database.

Each discovered/applied job gets one row. The tracker prevents
duplicate applications and maintains a full history for the UI.
"""

def __init__(self, db_path: Path | None = None):
self.db_path = db_path or DB_PATH
self._init_db()

def _init_db(self) -> None:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = _get_conn(self.db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
company TEXT,
title TEXT,
url TEXT UNIQUE,
status TEXT DEFAULT 'pending',
score INTEGER,
score_reason TEXT,
applied_at TEXT,
discovered_at TEXT NOT NULL,
resume_path TEXT,
cover_path TEXT,
notes TEXT,
session_id TEXT
)
""")
conn.commit()
logger.debug("ApplicationTracker DB initialized at {}", self.db_path)

def record_discovered(
self,
platform: str,
company: str,
title: str,
url: str,
session_id: str = "",
) -> int | None:
"""Insert a newly discovered job. Returns row id, or None if duplicate."""
now = datetime.now(timezone.utc).isoformat()
conn = _get_conn(self.db_path)
try:
cur = conn.execute(
"""INSERT INTO applications
(platform, company, title, url, status, discovered_at, session_id)
VALUES (?, ?, ?, ?, 'discovered', ?, ?)""",
(platform, company, title, url, now, session_id),
)
conn.commit()
return cur.lastrowid
except sqlite3.IntegrityError:
return None # already in DB

def update_score(self, url: str, score: int, reason: str = "") -> None:
conn = _get_conn(self.db_path)
conn.execute(
"UPDATE applications SET score=?, score_reason=?, status='scored' WHERE url=?",
(score, reason, url),
)
conn.commit()

def mark_skipped(self, url: str, reason: str = "") -> None:
conn = _get_conn(self.db_path)
conn.execute(
"UPDATE applications SET status='skipped', notes=? WHERE url=?",
(reason, url),
)
conn.commit()

def mark_applied(self, url: str, resume_path: str = "", cover_path: str = "") -> None:
now = datetime.now(timezone.utc).isoformat()
conn = _get_conn(self.db_path)
conn.execute(
"""UPDATE applications
SET status='applied', applied_at=?, resume_path=?, cover_path=?
WHERE url=?""",
(now, resume_path, cover_path, url),
)
conn.commit()

def mark_failed(self, url: str, reason: str = "") -> None:
conn = _get_conn(self.db_path)
conn.execute(
"UPDATE applications SET status='failed', notes=? WHERE url=?",
(reason, url),
)
conn.commit()

def already_applied(self, company: str, title: str) -> bool:
"""True if we already applied to this company+title combo."""
conn = _get_conn(self.db_path)
row = conn.execute(
"""SELECT 1 FROM applications
WHERE company=? AND title=? AND status='applied'
LIMIT 1""",
(company, title),
).fetchone()
return row is not None

def url_seen(self, url: str) -> bool:
"""True if this URL is already in the database (any status)."""
conn = _get_conn(self.db_path)
row = conn.execute(
"SELECT 1 FROM applications WHERE url=? LIMIT 1", (url,)
).fetchone()
return row is not None

def get_applications(
self,
platform: str | None = None,
status: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[dict[str, Any]]:
"""Return applications filtered by platform / status."""
conn = _get_conn(self.db_path)
conditions = []
params: list = []
if platform:
conditions.append("platform=?")
params.append(platform)
if status:
conditions.append("status=?")
params.append(status)
where = "WHERE " + " AND ".join(conditions) if conditions else ""
rows = conn.execute(
f"SELECT * FROM applications {where} ORDER BY discovered_at DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
return [dict(row) for row in rows]

def get_application(self, app_id: int) -> dict[str, Any] | None:
conn = _get_conn(self.db_path)
row = conn.execute(
"SELECT * FROM applications WHERE id=?", (app_id,)
).fetchone()
return dict(row) if row else None

def get_stats(self) -> dict[str, Any]:
conn = _get_conn(self.db_path)
total = conn.execute("SELECT COUNT(*) FROM applications").fetchone()[0]
applied = conn.execute(
"SELECT COUNT(*) FROM applications WHERE status='applied'"
).fetchone()[0]
skipped = conn.execute(
"SELECT COUNT(*) FROM applications WHERE status='skipped'"
).fetchone()[0]
failed = conn.execute(
"SELECT COUNT(*) FROM applications WHERE status='failed'"
).fetchone()[0]
by_platform = conn.execute(
"SELECT platform, COUNT(*) FROM applications GROUP BY platform"
).fetchall()
return {
"total": total,
"applied": applied,
"skipped": skipped,
"failed": failed,
"by_platform": {row[0]: row[1] for row in by_platform},
}

def export_csv(self) -> str:
"""Return all applications as a CSV string."""
import csv
import io

conn = _get_conn(self.db_path)
rows = conn.execute("SELECT * FROM applications ORDER BY discovered_at DESC").fetchall()
if not rows:
return "id,platform,company,title,url,status,score,applied_at\n"
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
for row in rows:
writer.writerow(dict(row))
return output.getvalue()
Loading