You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

480 lines
18 KiB
Python

"""gimme-job CLI entry point."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
app = typer.Typer(
name="gimme-job",
help="Local job posting aggregator — AI-assisted learning, non-AI runtime.",
add_completion=False,
)
console = Console()
# ── init ──────────────────────────────────────────────────────────────────────
@app.command()
def init(
force: bool = typer.Option(False, "--force", help="Re-initialize even if already set up"),
) -> None:
"""Initialize project directories, database, and config."""
from gimme_job.db.engine import init_db
from gimme_job.utils.paths import ensure_workspace_dirs, project_root, sites_dir
console.print(Panel("[bold cyan]gimme-job init[/bold cyan]", expand=False))
# Create workspace directories
ensure_workspace_dirs()
console.print("[green]✓[/green] Workspace directories created")
# Create sites/global.yaml if absent
global_yaml = sites_dir() / "global.yaml"
if not global_yaml.exists() or force:
_write_default_global_yaml(global_yaml)
console.print(f"[green]✓[/green] Created {global_yaml}")
else:
console.print(f"[dim] {global_yaml} already exists[/dim]")
# Copy .env.example -> .env if absent
env_example = project_root() / ".env.example"
env_file = project_root() / ".env"
if env_example.exists() and not env_file.exists():
import shutil
shutil.copy(env_example, env_file)
console.print(f"[green]✓[/green] Copied .env.example → .env (fill in your tokens)")
else:
console.print("[dim] .env already exists[/dim]")
# Initialize SQLite DB
init_db()
console.print("[green]✓[/green] SQLite database initialized")
# Preflight checks
_check_playwright()
_check_ollama()
_check_claude()
console.print(Panel("[bold green]Initialization complete.[/bold green]", expand=False))
def _write_default_global_yaml(path: Path) -> None:
from gimme_job.utils.json_io import write_yaml
data = {
"runtime": {
"timezone": "America/Phoenix",
"headless": True,
"profile_name": "JobAgent",
"slow_mo_ms": 0,
"default_timeout_ms": 15000,
"navigation_timeout_ms": 30000,
"max_pages_per_site": 3,
"min_delay_ms": 1200,
"max_delay_ms": 3500,
},
"search_defaults": {
"keywords": ["orthodontist"],
"location": "",
"remote": False,
"date_mode": "today_or_last_24h",
"sort": "relevance",
"max_items_per_site": 30,
},
"summarization": {
"ollama_base_url": "http://127.0.0.1:11434",
"model": "qwen3.5:9b",
"temperature": 0.1,
"max_input_items": 200,
},
"notification": {
"provider": "telegram",
"fallback_markdown": True,
},
}
write_yaml(path, data)
def _check_playwright() -> None:
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
_ = p.chromium
console.print("[green]✓[/green] Playwright available")
except Exception as e:
console.print(f"[yellow]![/yellow] Playwright check failed: {e}")
console.print(" Run: [bold]uv run playwright install chromium[/bold]")
def _check_ollama() -> None:
try:
import httpx
from gimme_job.config import load_global_config
cfg = load_global_config()
r = httpx.get(f"{cfg.summarization.ollama_base_url}/api/tags", timeout=3.0)
if r.status_code == 200:
console.print("[green]✓[/green] Ollama reachable")
else:
console.print(f"[yellow]![/yellow] Ollama returned {r.status_code}")
except Exception as e:
console.print(f"[yellow]![/yellow] Ollama not reachable: {e}")
console.print(" Make sure Ollama is running: [bold]ollama serve[/bold]")
def _check_claude() -> None:
import subprocess
try:
result = subprocess.run(
["claude", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
console.print(f"[green]✓[/green] Claude Code CLI: {result.stdout.strip()}")
else:
console.print("[yellow]![/yellow] Claude Code CLI not found (needed for learn/repair)")
except FileNotFoundError:
console.print("[yellow]![/yellow] Claude Code CLI not found (needed for learn/repair)")
# ── login ─────────────────────────────────────────────────────────────────────
@app.command()
def login() -> None:
"""Open the JobAgent browser to log in to sites manually.
Browse to any sites (LinkedIn, Indeed, Google, etc.), log in, then press Enter.
All cookies and localStorage are saved in the JobAgent profile and reused on
every subsequent run.
"""
from gimme_job.config import load_global_config
from gimme_job.runtime.browser import BrowserManager
cfg = load_global_config()
console.print(Panel("[bold cyan]gimme-job login[/bold cyan]", expand=False))
console.print(
"Opening JobAgent Chrome profile.\n"
"Log in to any sites you need, then press Enter here to close the browser.\n"
"All cookies and localStorage will be saved and reused on future runs.\n"
)
bm = BrowserManager(
profile_name=cfg.runtime.profile_name,
headless=False,
slow_mo=cfg.runtime.slow_mo_ms,
)
try:
bm.open_context()
bm.new_page()
input(" >> Press Enter when done... ")
finally:
bm.close()
console.print("[green]✓[/green] Browser closed. Session saved to JobAgent profile.")
# ── run ───────────────────────────────────────────────────────────────────────
@app.command()
def run(
site: Optional[str] = typer.Option(None, "--site", help="Run a single site only"),
dry_run: bool = typer.Option(False, "--dry-run", help="Extract but do not save or notify"),
skip_notify: bool = typer.Option(False, "--skip-notify", help="Skip Telegram notification"),
today_only: bool = typer.Option(False, "--today-only", help="Only process today's new items"),
limit_sites: Optional[int] = typer.Option(None, "--limit-sites", help="Max sites to run"),
) -> None:
"""Collect job postings from all enabled sites."""
from gimme_job.config import load_global_config
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.runtime.orchestrator import RunOrchestrator
cfg = load_global_config()
engine = get_engine()
factory = get_session_factory(engine)
orchestrator = RunOrchestrator(global_config=cfg, session_factory=factory)
summary = orchestrator.run_all(
site_filter=site,
dry_run=dry_run,
skip_notify=skip_notify,
limit_sites=limit_sites,
)
# Print summary table
table = Table(title="Run Summary", show_header=True)
table.add_column("Site", style="cyan")
table.add_column("Status")
table.add_column("Found", justify="right")
table.add_column("New", justify="right")
for r in summary.site_results:
status_color = {
"success": "green",
"partial": "yellow",
"failed": "red",
"repair_needed": "red",
}.get(r.status.value, "white")
table.add_row(
r.site_id,
f"[{status_color}]{r.status.value}[/{status_color}]",
str(r.items_found),
str(r.new_items),
)
console.print(table)
console.print(f"\nTotal: [bold]{summary.total_found}[/bold] found, [bold green]{summary.total_new}[/bold green] new")
if summary.summary_text:
console.print("\n[bold]Summary sent.[/bold]")
# ── learn ─────────────────────────────────────────────────────────────────────
@app.command()
def learn(
site_id: str = typer.Option(..., "--site-id", help="Site identifier (e.g. linkedin)"),
url: str = typer.Option(..., "--url", help="Job search results URL to learn from"),
profile_name: str = typer.Option("JobAgent", "--profile-name"),
headed: bool = typer.Option(True, "--headed/--headless"),
timeout_seconds: int = typer.Option(180, "--timeout-seconds"),
) -> None:
"""Learn a new job site adapter using Claude Code."""
from gimme_job.config import load_global_config
from gimme_job.runtime.claude_cli import ClaudeCodeClient
from gimme_job.runtime.learn import LearnMode
cfg = load_global_config()
client = ClaudeCodeClient()
mode = LearnMode(global_config=cfg, claude_client=client)
console.print(Panel(f"[bold cyan]Learning site: {site_id}[/bold cyan]", expand=False))
success = mode.learn_site(
site_id=site_id,
url=url,
profile_name=profile_name,
headed=headed,
timeout=timeout_seconds,
)
if success:
console.print(f"[green]✓[/green] Successfully learned [bold]{site_id}[/bold]")
else:
console.print(f"[red]✗[/red] Failed to learn [bold]{site_id}[/bold]")
raise typer.Exit(1)
# ── repair ────────────────────────────────────────────────────────────────────
@app.command()
def repair(
site: Optional[str] = typer.Argument(None, help="Site ID to repair"),
all_: bool = typer.Option(False, "--all", help="Repair all repair_needed sites"),
) -> None:
"""Repair a broken site adapter using Claude Code."""
from gimme_job.config import load_global_config, list_all_sites
from gimme_job.runtime.claude_cli import ClaudeCodeClient
from gimme_job.runtime.repair import RepairMode
if not site and not all_:
console.print("[red]Provide a site ID or --all[/red]")
raise typer.Exit(1)
cfg = load_global_config()
client = ClaudeCodeClient()
mode = RepairMode(global_config=cfg, claude_client=client)
sites_to_repair = list_all_sites() if all_ else [site]
for s in sites_to_repair:
console.print(f"[cyan]Repairing {s}...[/cyan]")
success = mode.repair_site(s)
if success:
console.print(f"[green]✓[/green] {s} repaired")
else:
console.print(f"[red]✗[/red] {s} repair failed")
# ── test ──────────────────────────────────────────────────────────────────────
@app.command()
def test(
site: str = typer.Argument(..., help="Site ID to test"),
) -> None:
"""Run smoke test for a site adapter."""
import subprocess
console.print(Panel(f"[bold cyan]Testing: {site}[/bold cyan]", expand=False))
result = subprocess.run(
["uv", "run", "pytest", f"tests/adapters/test_{site}.py", "-v"],
cwd=str(Path(__file__).parent.parent),
)
raise typer.Exit(result.returncode)
# ── notify ────────────────────────────────────────────────────────────────────
@app.command()
def notify(
today: bool = typer.Option(True, "--today/--no-today", help="Re-send today's digest"),
) -> None:
"""Re-send today's job summary via Telegram."""
from datetime import date
from gimme_job.config import load_global_config
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import JobPostingRepo, SummaryRepo
from gimme_job.runtime.notifier import NotificationDispatcher
cfg = load_global_config()
engine = get_engine()
factory = get_session_factory(engine)
run_date = date.today()
with factory() as session:
summary_repo = SummaryRepo()
summary = summary_repo.get_latest(session, run_date)
if summary:
text = summary.content
else:
# Build summary from today's postings
postings = JobPostingRepo().get_today_new(session, run_date)
if not postings:
console.print("[yellow]No new postings found for today.[/yellow]")
raise typer.Exit(0)
from gimme_job.runtime.summarizer import OllamaSummarizer
summarizer = OllamaSummarizer(
base_url=cfg.summarization.ollama_base_url,
model=cfg.summarization.model,
temperature=cfg.summarization.temperature,
)
text = summarizer.summarize(postings)
dispatcher = NotificationDispatcher(global_config=cfg, session_factory=factory)
ok = dispatcher.send(text, run_date)
if ok:
console.print("[green]✓[/green] Notification sent")
else:
console.print("[yellow]![/yellow] Notification failed — saved as markdown fallback")
# ── list ──────────────────────────────────────────────────────────────────────
@app.command(name="list")
def list_postings(
site: Optional[str] = typer.Option(None, "--site", help="Filter by site ID"),
today: bool = typer.Option(False, "--today", help="Only show today's new postings"),
limit: int = typer.Option(50, "--limit", help="Max rows to show"),
) -> None:
"""List collected job postings from the database."""
from datetime import date
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import JobPostingRepo
from gimme_job.models.db import JobPosting
from sqlalchemy import select
engine = get_engine()
factory = get_session_factory(engine)
table = Table(title="Job Postings", show_header=True, show_lines=False)
table.add_column("#", style="dim", justify="right", width=4)
table.add_column("Site", style="cyan", width=10)
table.add_column("Title", width=40)
table.add_column("Company", width=25)
table.add_column("Location", width=20)
table.add_column("Posted", width=12)
table.add_column("New", width=4)
with factory() as session:
q = select(JobPosting).order_by(JobPosting.first_seen_at.desc())
if site:
q = q.where(JobPosting.site_id == site)
if today:
q = q.where(JobPosting.run_date == date.today(), JobPosting.is_new == True)
q = q.limit(limit)
rows = list(session.execute(q).scalars().all())
for i, p in enumerate(rows, 1):
table.add_row(
str(i),
p.site_id,
p.title[:38] if p.title else "",
(p.company or "")[:23],
(p.location or "")[:18],
p.posted_text or "",
"[green]Y[/green]" if p.is_new else "",
)
console.print(table)
console.print(f"[dim]{len(rows)} rows (limit={limit})[/dim]")
# ── status ────────────────────────────────────────────────────────────────────
@app.command()
def status() -> None:
"""Show health status of all configured sites."""
from gimme_job.config import list_all_sites, load_site_manifest
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import SiteConfigRepo, SiteRunRepo
engine = get_engine()
factory = get_session_factory(engine)
from sqlalchemy import func, select
from gimme_job.models.db import JobPosting
table = Table(title="Site Status", show_header=True)
table.add_column("Site", style="cyan")
table.add_column("Enabled")
table.add_column("Repair?")
table.add_column("Stored", justify="right")
table.add_column("Last Run")
table.add_column("Last Status")
table.add_column("Failures", justify="right")
run_repo = SiteRunRepo()
config_repo = SiteConfigRepo()
with factory() as session:
for site_id in list_all_sites():
try:
manifest = load_site_manifest(site_id)
except Exception:
continue
config = session.get(__import__("gimme_job.models.db", fromlist=["SiteConfig"]).SiteConfig, site_id)
recent = run_repo.get_recent_runs(session, site_id, limit=1)
stored = session.execute(
select(func.count()).where(JobPosting.site_id == site_id)
).scalar() or 0
enabled = "[green]yes[/green]" if manifest.enabled else "[dim]no[/dim]"
repair = "[red]YES[/red]" if manifest.repair_needed else "[green]no[/green]"
last_run = recent[0].started_at.strftime("%m-%d %H:%M") if recent else "[dim]never[/dim]"
last_status = recent[0].status if recent else "[dim]-[/dim]"
failures = str(config.consecutive_failures) if config else "0"
table.add_row(site_id, enabled, repair, str(stored), last_run, last_status, failures)
console.print(table)
if __name__ == "__main__":
app()