From 293f0aba53d16d1bbda39ad85180ec7208436125 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 8 May 2026 13:32:10 -0700 Subject: [PATCH] chore(release): v0.9.4 Messaging overhaul: expandable email timeline with lazy body loading, sticky compose bar replacing always-visible action buttons, layout height fixed to 100dvh. Accessibility fixes for contrast failures on orange/amber backgrounds. Theme-aware replacements for hardcoded colors in Interviews, References, and JobReview. Indeed alert parser, Oracle HCM scraper, manage.sh compose engine detection. --- CHANGELOG.md | 47 +++++++ dev-api.py | 75 +++++++++++ manage.sh | 71 +++++++--- scripts/db.py | 6 +- scripts/imap_sync.py | 103 +++++++++++++-- scripts/scrape_url.py | 70 +++++++++- tests/test_imap_sync.py | 67 ++++++++++ web/src/assets/theme.css | 3 + web/src/views/ContactsView.vue | 143 ++++++++++++++++++-- web/src/views/HomeView.vue | 21 ++- web/src/views/InterviewsView.vue | 10 +- web/src/views/JobReviewView.vue | 2 +- web/src/views/MessagingView.vue | 218 ++++++++++++++++++++++--------- web/src/views/ReferencesView.vue | 2 +- 14 files changed, 726 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abbf48..0b863c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.9.4] — 2026-05-08 + +### Added + +- **Messages view — expandable email timeline** — click any email item to lazy-load + and read the full body inline (HTML stripped to plain text via `DOMParser`). + Bodies are fetched on-demand via the new `GET /api/contacts/{id}` endpoint to avoid + loading 50KB+ email bodies on every page view. +- **Messages view — compose bar** — action buttons (Log call, Log note, Use template, + Draft reply with LLM, Call via Osprey) moved from the always-visible header into a + sticky bottom compose bar triggered by a + New toggle. Reduces visual clutter when + just reading the thread. +- **Home view — "Skip review" checkbox** — when adding jobs by URL, a checkbox (default + on) sends them directly to the Apply queue, bypassing Job Review. +- **ContactsView — sync status** — shows last completed sync time and a spinner when + an email sync is running. +- **imap_sync: Indeed alert parser** — `parse_indeed_alert()` extracts job title, + company, location, salary, and canonical URL from Indeed Job Alert digest emails. +- **scrape_url: Oracle HCM support** — Playwright-based scraper for Oracle HCM + CandidateExperience portals (React SPAs requiring JS execution). +- **manage.sh** — compose engine auto-detection (docker compose / podman compose / + podman-compose), `build` command, and cloud/demo stack shortcuts. +- **theme.css** — `--color-overlay` token for modal/dialog backdrops. + +### Fixed + +- **Messages view layout** — changed `height: 100%` to `height: 100dvh` with a mobile + override for the 56px tab bar. `height: 100%` was resolving to "shrink-wrap" because + `.app-main` has no explicit height; compose bar is now correctly pinned to the bottom. +- **Accessibility: danger button contrast** — `btn--danger` used `color: white` on + `--app-accent` (Talon Orange), yielding 2.8:1 contrast (fails WCAG AA 4.5:1 for + normal text). Fixed to `color: var(--app-accent-text)` (dark navy, 5.5:1). +- **Accessibility: warning badge contrast** — `tab-badge` in Job Review used `color: white` + on `--color-warning` (amber). Same fix applied. +- **Theme: Interviews signal banners** — hardcoded `rgba(245,158,11,…)` / `rgba(39,174,…)` + / `rgba(192,57,…)` replaced with `color-mix()` against `--color-warning/success/error`. +- **Theme: Interviews signal count** — `color: #e67e22` hardcode replaced with + `var(--app-accent)`. +- **Theme: References academic tag chip** — `color: #7c3aed` hardcode replaced with + `var(--status-synced)`; background uses `color-mix()` with the same token. +- **Theme: Interviews signal-move button** — `color: #fff` on `--color-primary` fails + in dark mode (light green bg); fixed to `var(--color-text-inverse)`. +- **Modal backdrops** — `rgba(0,0,0,0.5)` replaced with `var(--color-overlay)` for + theme consistency. + +--- + ## [0.9.3] — 2026-05-05 ### Added diff --git a/dev-api.py b/dev-api.py index 98bed9d..d8bfb56 100644 --- a/dev-api.py +++ b/dev-api.py @@ -114,6 +114,38 @@ app.include_router(_feedback_router, prefix="/api/feedback") _log = logging.getLogger("peregrine.session") +# ── Structured auth logging ─────────────────────────────────────────────────── +# Writes one JSON line per request to /devl/peregrine-logs/auth.log when in +# cloud mode. Rotates at 10 MB, keeps 5 files. Also logs to stdout in dev. +_AUTH_LOG_DIR = Path(os.environ.get("PEREGRINE_LOG_DIR", "/devl/peregrine-logs")) + +class _JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + if hasattr(record, "auth_event"): + payload.update(record.auth_event) + return json.dumps(payload) + +def _setup_auth_logging() -> None: + from logging.handlers import RotatingFileHandler + _AUTH_LOG_DIR.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + _AUTH_LOG_DIR / "auth.log", maxBytes=10 * 1024 * 1024, backupCount=5 + ) + handler.setFormatter(_JsonFormatter()) + handler.setLevel(logging.INFO) + _log.addHandler(handler) + _log.setLevel(logging.DEBUG) + +_setup_auth_logging() + +_seen_users: set[str] = set() # track first-access events within this process lifetime + def _demo_guard() -> None: """Raise 403 if running in demo mode. Call at the top of any write endpoint.""" @@ -158,6 +190,16 @@ def _resolve_cf_user_id(cookie_str: str) -> str | None: return None +def _auth_log(event: str, **kwargs) -> None: + """Emit a structured INFO log line to the auth logger.""" + record = logging.LogRecord( + name="peregrine.session", level=logging.INFO, + pathname="", lineno=0, msg=event, args=(), exc_info=None, + ) + record.auth_event = {"event": event, **kwargs} + _log.handle(record) + + @app.middleware("http") async def cloud_session_middleware(request: Request, call_next): """In cloud mode, resolve per-user staging.db from the X-CF-Session header.""" @@ -165,16 +207,36 @@ async def cloud_session_middleware(request: Request, call_next): cookie_header = request.headers.get("X-CF-Session", "") user_id = _resolve_cf_user_id(cookie_header) if user_id: + first_access = user_id not in _seen_users + if first_access: + _seen_users.add(user_id) user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db") if user_db not in _migrated_db_paths: from scripts.db_migrate import migrate_db migrate_db(Path(user_db)) _migrated_db_paths.add(user_db) + _auth_log( + "session_resolved", + user_id=user_id, + method=request.method, + path=request.url.path, + first_access=first_access, + ) token = _request_db.set(user_db) try: return await call_next(request) finally: _request_db.reset(token) + else: + # Only log failures on non-trivial paths (skip health checks / static assets) + if request.url.path.startswith("/api/"): + _auth_log( + "session_failed", + method=request.method, + path=request.url.path, + reason="no_user_id", + has_cookie=bool(cookie_header), + ) return await call_next(request) @@ -1677,6 +1739,16 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None, return {"total": total, "contacts": [dict(r) for r in rows]} +@app.get("/api/contacts/{contact_id}") +def get_contact(contact_id: int): + db = _get_db() + row = db.execute("SELECT * FROM job_contacts WHERE id = ?", (contact_id,)).fetchone() + db.close() + if not row: + raise HTTPException(status_code=404, detail="Contact not found") + return dict(row) + + # ── References ───────────────────────────────────────────────────────────────── class ReferencePayload(BaseModel): @@ -2112,6 +2184,7 @@ def bulk_purge_jobs(body: BulkPurgeBody): class AddJobsBody(BaseModel): urls: List[str] + skip_review: bool = True @app.post("/api/jobs/add", status_code=202) @@ -2123,6 +2196,7 @@ def add_jobs_by_url(body: AddJobsBody): from scripts.task_runner import submit_task db_path = _db_path() existing = get_existing_urls(db_path) + status = "approved" if body.skip_review else "pending" queued = 0 for raw_url in body.urls: url = canonicalize_url(raw_url.strip()) @@ -2132,6 +2206,7 @@ def add_jobs_by_url(body: AddJobsBody): "title": "Importing...", "company": "", "url": url, "source": "manual", "location": "", "description": "", "date_found": _dt.now().isoformat()[:10], + "status": status, }) if job_id: submit_task(db_path, "scrape_url", job_id) diff --git a/manage.sh b/manage.sh index 516816a..aaa48b2 100755 --- a/manage.sh +++ b/manage.sh @@ -15,6 +15,11 @@ cd "$SCRIPT_DIR" PROFILE="${PROFILE:-remote}" +# ── Compose engine detection ────────────────────────────────────────────────── +COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \ + && echo "docker compose" \ + || (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))" + # ── Usage ──────────────────────────────────────────────────────────────────── usage() { echo "" @@ -28,9 +33,10 @@ usage() { echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)" echo -e " ${GREEN}stop${NC} Stop all services" echo -e " ${GREEN}restart${NC} Restart all services" + echo -e " ${GREEN}build [service]${NC} Rebuild image(s) without restarting (default: api web)" echo -e " ${GREEN}status${NC} Show running containers" - echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)" - echo -e " ${GREEN}update${NC} Pull latest images + rebuild app" + echo -e " ${GREEN}logs [service]${NC} Tail logs (default: api)" + echo -e " ${GREEN}update${NC} Pull latest images + rebuild" echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env" echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing" echo -e " ${GREEN}test${NC} Run test suite" @@ -41,6 +47,12 @@ usage() { echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)" echo -e " ${GREEN}open${NC} Open the web UI in your browser" echo "" + echo -e " Cloud / demo commands:" + echo -e " ${GREEN}cloud-start${NC} Start the cloud stack (peregrine-cloud)" + echo -e " ${GREEN}cloud-restart${NC} Rebuild + restart the cloud stack" + echo -e " ${GREEN}demo-start${NC} Start the demo stack (peregrine-demo)" + echo -e " ${GREEN}demo-restart${NC} Rebuild + restart the demo stack" + echo "" echo " Profiles (set via --profile or PROFILE env var):" echo " remote API-only, no local inference (default)" echo " cpu Local Ollama inference on CPU" @@ -70,7 +82,7 @@ while [[ $# -gt 0 ]]; do esac done -SERVICE="${1:-app}" # used by `logs` command +SERVICE="${1:-api}" # used by `logs` command # ── Dependency guard ────────────────────────────────────────────────────────── # Commands that delegate to make; others (status, logs, update, open, setup) run fine without it. @@ -101,7 +113,7 @@ case "$CMD" in start) info "Starting Peregrine (PROFILE=${PROFILE})..." make start PROFILE="$PROFILE" - PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)" + PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)" success "Peregrine is up → http://localhost:${PORT}" ;; @@ -114,33 +126,30 @@ case "$CMD" in restart) info "Restarting (PROFILE=${PROFILE})..." make restart PROFILE="$PROFILE" - PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)" + PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)" success "Peregrine restarted → http://localhost:${PORT}" ;; status) - # Auto-detect compose engine same way Makefile does - COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \ - && echo "docker compose" \ - || (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))" $COMPOSE ps ;; logs) - COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \ - && echo "docker compose" \ - || (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))" info "Tailing logs for: ${SERVICE}" $COMPOSE logs -f "$SERVICE" ;; + build) + BUILD_SVC="$([[ "${SERVICE}" == "api" ]] && echo "api web" || echo "${SERVICE}")" + info "Building ${BUILD_SVC}..." + $COMPOSE build $BUILD_SVC + success "Build complete. Run './manage.sh restart' to apply." + ;; + update) - info "Pulling latest images and rebuilding app..." - COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \ - && echo "docker compose" \ - || (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))" + info "Pulling latest images and rebuilding..." $COMPOSE pull searxng ollama 2>/dev/null || true - $COMPOSE build app web + $COMPOSE build api web success "Update complete. Run './manage.sh restart' to apply." ;; @@ -167,7 +176,7 @@ case "$CMD" in ;; open) - PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)" + PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)" URL="http://localhost:${PORT}" info "Opening ${URL}" if command -v xdg-open &>/dev/null; then @@ -197,6 +206,32 @@ case "$CMD" in -v "${@:3}" ;; + cloud-start) + info "Starting cloud stack (peregrine-cloud)..." + $COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d + success "Cloud stack up → http://localhost:8508" + ;; + + cloud-restart) + info "Rebuilding + restarting cloud stack (peregrine-cloud)..." + $COMPOSE -f compose.cloud.yml --project-name peregrine-cloud build api web + $COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d + success "Cloud stack restarted → http://localhost:8508" + ;; + + demo-start) + info "Starting demo stack (peregrine-demo)..." + $COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d + success "Demo stack up → http://localhost:8504" + ;; + + demo-restart) + info "Rebuilding + restarting demo stack (peregrine-demo)..." + $COMPOSE -f compose.demo.yml --project-name peregrine-demo build api web + $COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d + success "Demo stack restarted → http://localhost:8504" + ;; + help|--help|-h) usage ;; diff --git a/scripts/db.py b/scripts/db.py index 6daf69e..e015a2b 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -234,10 +234,11 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]: return None conn = sqlite3.connect(db_path) try: + status = job.get("status", "pending") cursor = conn.execute( """INSERT INTO jobs - (title, company, url, source, location, is_remote, salary, description, date_found, date_posted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (title, company, url, source, location, is_remote, salary, description, date_found, date_posted, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( job.get("title", ""), job.get("company", ""), @@ -249,6 +250,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]: job.get("description", ""), job.get("date_found", ""), job.get("date_posted", "") or "", + status, ), ) conn.commit() diff --git a/scripts/imap_sync.py b/scripts/imap_sync.py index e900aed..ebe490d 100644 --- a/scripts/imap_sync.py +++ b/scripts/imap_sync.py @@ -392,6 +392,7 @@ def _has_todo_keyword(subject: str) -> bool: _LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com" +_INDEED_ALERT_SENDER = "jobalerts@indeed.com" # Social-proof / nav lines to skip when parsing alert blocks _ALERT_SKIP_PHRASES = { @@ -447,6 +448,75 @@ def parse_linkedin_alert(body: str) -> list[dict]: return jobs +def parse_indeed_alert(body: str) -> list[dict]: + """ + Parse the HTML body of an Indeed Job Alert email. + + Returns a list of dicts: {title, company, location, salary, url}. + URL is canonicalised to https://www.indeed.com/viewjob?jk= + (tracking parameters stripped). + """ + try: + from bs4 import BeautifulSoup as _BS + except ImportError: + return [] + + jobs: list[dict] = [] + soup = _BS(body, "html.parser") + + # Each job card is an wrapping a job title — Indeed uses several layouts + # across their email templates. We try two strategies: + # + # Strategy A (2023+ layout): blocks containing an with /viewjob?jk= + # Strategy B (older layout): blocks + # + # Both extract the canonical jk= key from the href. + + seen_jks: set[str] = set() + + for anchor in soup.find_all("a", href=True): + href: str = anchor["href"] + jk_m = re.search(r"[?&]jk=([a-z0-9]+)", href, re.IGNORECASE) + if not jk_m: + continue + jk = jk_m.group(1) + if jk in seen_jks: + continue + seen_jks.add(jk) + + title = anchor.get_text(separator=" ", strip=True) + if not title or len(title) < 3: + continue + + # Walk up to find the container cell/row and extract company + location + container = anchor.find_parent(["td", "tr", "div"]) + company = location = salary = "" + if container: + text_lines = [ + t.strip() for t in container.get_text(separator="\n").splitlines() + if t.strip() and t.strip().lower() != title.lower() + ] + if text_lines: + company = text_lines[0] + if len(text_lines) > 1: + location = text_lines[1] + # salary line often contains "$" or "/yr" + for line in text_lines[2:]: + if "$" in line or "/yr" in line.lower() or "/hour" in line.lower(): + salary = line + break + + jobs.append({ + "title": title, + "company": company, + "location": location, + "salary": salary, + "url": f"https://www.indeed.com/viewjob?jk={jk}", + }) + + return jobs + + def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path, active_jobs: list[dict], known_message_ids: set) -> int: @@ -558,20 +628,29 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict, if mid in known_message_ids: continue - # ── LinkedIn Job Alert digest — parse each card individually ────── - if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower(): - cards = parse_linkedin_alert(parsed["body"]) - for card in cards: + # ── Job alert digests — parse each card deterministically ─────── + from_lower = parsed["from_addr"].lower() + alert_cards: list[dict] = [] + alert_source = "" + if _LINKEDIN_ALERT_SENDER in from_lower: + alert_cards = parse_linkedin_alert(parsed["body"]) + alert_source = "linkedin" + elif _INDEED_ALERT_SENDER in from_lower: + alert_cards = parse_indeed_alert(parsed["body"]) + alert_source = "indeed" + + if alert_cards: + for card in alert_cards: if card["url"] in existing_urls: continue job_id = insert_job(db_path, { - "title": card["title"], - "company": card["company"], - "url": card["url"], - "source": "linkedin", - "location": card["location"], - "is_remote": 0, - "salary": "", + "title": card["title"], + "company": card["company"], + "url": card["url"], + "source": alert_source, + "location": card.get("location", ""), + "is_remote": 0, + "salary": card.get("salary", ""), "description": "", "date_found": datetime.now().isoformat()[:10], }) @@ -580,7 +659,7 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict, submit_task(db_path, "scrape_url", job_id) existing_urls.add(card["url"]) new_leads += 1 - print(f"[imap] LinkedIn alert → {card['company']} — {card['title']}") + print(f"[imap] {alert_source} alert → {card['company']} — {card['title']}") known_message_ids.add(mid) continue # skip normal LLM extraction path diff --git a/scripts/scrape_url.py b/scripts/scrape_url.py index ea55306..6c73db3 100644 --- a/scripts/scrape_url.py +++ b/scripts/scrape_url.py @@ -57,7 +57,7 @@ _TIMEOUT = 12 def _detect_board(url: str) -> str: - """Return 'linkedin', 'indeed', 'glassdoor', or 'generic'.""" + """Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'.""" url_lower = url.lower() if "linkedin.com" in url_lower: return "linkedin" @@ -67,6 +67,8 @@ def _detect_board(url: str) -> str: return "glassdoor" if "jobgether.com" in url_lower: return "jobgether" + if "oraclecloud.com" in url_lower and "hcmui" in url_lower: + return "oracle_hcm" return "generic" @@ -201,6 +203,70 @@ def _scrape_jobgether(url: str) -> dict: return {"company": company, "source": "jobgether"} if company else {} +def _scrape_oracle_hcm(url: str) -> dict: + """Scrape an Oracle HCM CandidateExperience job page via Playwright. + + Oracle HCM portals are React SPAs that require JS execution. The prospect + token in the URL path grants public access — no auth needed. + """ + try: + from playwright.sync_api import sync_playwright + except ImportError: + print("[scrape_url] Oracle HCM: Playwright not installed, falling back to generic") + return _scrape_generic(url) + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + ctx = browser.new_context(user_agent=_HEADERS["User-Agent"]) + page = ctx.new_page() + page.goto(url, timeout=30_000) + page.wait_for_load_state("networkidle", timeout=20_000) + + result = page.evaluate("""() => { + const sel = (s) => document.querySelector(s)?.textContent?.trim() || ''; + const selInner = (s) => document.querySelector(s)?.innerText?.trim() || ''; + + // Title: try known HCM selectors then fall back to first h1 + const title = sel('[class*="requisition-title"]') + || sel('[class*="JobTitle"]') + || sel('.job-title') + || sel('h1'); + + // Company: page header logo alt text, meta, or site-name span + const companyMeta = document.querySelector('meta[property="og:site_name"]') + ?.getAttribute('content') || ''; + const company = sel('[class*="company-name"]') + || sel('[class*="siteName"]') + || sel('[class*="site-name"]') + || companyMeta; + + // Location: job detail list items + const location = sel('[class*="job-location"]') + || sel('[data-testid*="location"]') + || sel('[class*="location"]'); + + // Description: main content div + const description = selInner('[class*="job-description"]') + || selInner('[class*="requisition-description"]') + || selInner('[class*="JobDescription"]') + || selInner('main article') + || selInner('main'); + + return { title, company, location, description }; + }""") + finally: + browser.close() + + result["source"] = "oracle_hcm" + return {k: v for k, v in result.items() if v} + + except Exception as exc: + print(f"[scrape_url] Oracle HCM Playwright error for {url}: {exc}") + return {} + + def _parse_json_ld_or_og(html: str) -> dict: """Extract job fields from JSON-LD structured data, then og: meta tags.""" soup = BeautifulSoup(html, "html.parser") @@ -278,6 +344,8 @@ def scrape_job_url(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict: fields = _scrape_glassdoor(url) elif board == "jobgether": fields = _scrape_jobgether(url) + elif board == "oracle_hcm": + fields = _scrape_oracle_hcm(url) else: fields = _scrape_generic(url) except requests.RequestException as exc: diff --git a/tests/test_imap_sync.py b/tests/test_imap_sync.py index 5bdc687..348da67 100644 --- a/tests/test_imap_sync.py +++ b/tests/test_imap_sync.py @@ -203,6 +203,73 @@ def test_parse_linkedin_alert_empty_body(): assert parse_linkedin_alert("No jobs here.") == [] +# ── Indeed alert parser ─────────────────────────────────────────────────────── + +_INDEED_ALERT_HTML = """ + + + + + + + + + + + +
+ + Senior Python Engineer + +
Acme Corp
San Francisco, CA
$130,000 - $160,000 a year +
+ + Staff Backend Engineer + +
Widgets Inc
Remote +
+ Duplicate link +
+ +""" + +def test_parse_indeed_alert_extracts_jobs(): + from scripts.imap_sync import parse_indeed_alert + jobs = parse_indeed_alert(_INDEED_ALERT_HTML) + assert len(jobs) == 2 + assert jobs[0]["title"] == "Senior Python Engineer" + assert jobs[0]["url"] == "https://www.indeed.com/viewjob?jk=abc123def456" + assert jobs[1]["title"] == "Staff Backend Engineer" + assert jobs[1]["url"] == "https://www.indeed.com/viewjob?jk=999zzzqqq111" + + +def test_parse_indeed_alert_strips_tracking_params(): + from scripts.imap_sync import parse_indeed_alert + jobs = parse_indeed_alert(_INDEED_ALERT_HTML) + for job in jobs: + assert "utm_source" not in job["url"] + assert "trk=" not in job["url"] + + +def test_parse_indeed_alert_deduplicates_jk(): + from scripts.imap_sync import parse_indeed_alert + jobs = parse_indeed_alert(_INDEED_ALERT_HTML) + urls = [j["url"] for j in jobs] + assert len(urls) == len(set(urls)) + + +def test_parse_indeed_alert_empty_body(): + from scripts.imap_sync import parse_indeed_alert + assert parse_indeed_alert("") == [] + assert parse_indeed_alert("No jobs here") == [] + + +def test_parse_indeed_alert_extracts_salary(): + from scripts.imap_sync import parse_indeed_alert + jobs = parse_indeed_alert(_INDEED_ALERT_HTML) + assert "$130,000" in jobs[0]["salary"] + + # ── _scan_unmatched_leads integration ───────────────────────────────────────── _ALERT_BODY = """\ diff --git a/web/src/assets/theme.css b/web/src/assets/theme.css index 6150a0c..214a645 100644 --- a/web/src/assets/theme.css +++ b/web/src/assets/theme.css @@ -63,6 +63,9 @@ --shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06); --shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06); + /* Overlay — modal/dialog scrim */ + --color-overlay: rgba(0, 0, 0, 0.5); + /* Transitions */ --transition: 200ms ease; --transition-slow: 400ms ease; diff --git a/web/src/views/ContactsView.vue b/web/src/views/ContactsView.vue index 5e53941..dc51c0f 100644 --- a/web/src/views/ContactsView.vue +++ b/web/src/views/ContactsView.vue @@ -1,5 +1,5 @@