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.

381 lines
14 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": "kakaotalk",
"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)")
# ── 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 KakaoTalk 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 KakaoTalk."""
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")
# ── 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)
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("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)
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, last_run, last_status, failures)
console.print(table)
if __name__ == "__main__":
app()