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
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()
|