Compare commits

..
10 Commits
70 changed files with 6939 additions and 344 deletions
+1
View File
@@ -1,3 +1,4 @@
data/db
vendor/
build/
.env
+32
View File
@@ -0,0 +1,32 @@
# AI Development Guidelines for Hireme
These guidelines provide context to AI assistants working on the `hireme` repository. Adhere to these principles to maintain the project's architecture and design philosophy.
## 1. Single Binary & Zero Dependencies
- **Goal:** The application must compile into a single Go binary.
- **Rule:** Do NOT introduce external dependencies that require separate build steps, node_modules, or static asset folders unless explicitly authorized.
- **Rule:** Use Go 1.22+ standard library features wherever possible (e.g., `net/http` `ServeMux`).
## 2. UI Philosophy (HTMX + Tailwind)
- **Rule:** Do NOT introduce React, Vue, or any heavy JS frameworks.
- **Rule:** All dynamic interactions must be handled via HTMX (`hx-get`, `hx-post`, `hx-target`). Endpoints should return HTML fragments, not JSON, for UI updates.
- **Rule:** CSS is handled via Tailwind CSS utility classes. Maintain the dark mode aesthetic using `bg-slate-*`, `text-slate-*`, and vibrant accents (`indigo`, `emerald`, `rose`).
- **Rule:** UI templates are stored as constant strings in Go files (`internal/ui/template_*.go`) and registered in `templates.go`. Do not create external `.html` files.
## 3. Database Integrity
- **Rule:** Use `go-sqlite3` and ensure `PRAGMA journal_mode=WAL;` is maintained. The application heavily relies on WAL mode to allow concurrent reads (from the UI) and writes (from background workers).
- **Rule:** Avoid long-running transactions that could lock the database.
## 4. Structured Logging
- **Rule:** Use `zap` structured logging exclusively. Do NOT use the standard library `log.Printf`.
- **Rule:** Use the `w` methods (`Infow`, `Debugw`, `Errorw`) to pass key-value pairs.
- **Correct:** `logger.Log.Infow("User created", "component", "API", "user_id", id)`
- **Incorrect:** `logger.Log.Infof("[API] User created: %d", id)`
## 5. Background Workers
- **Rule:** Long-running tasks (Crawling, LLM Analysis) must be executed asynchronously via the `internal/worker` pool. Do not block HTTP request handlers. Enqueue tasks in the SQLite `tasks` table instead.
+51
View File
@@ -0,0 +1,51 @@
---
name: Structured Logging with Zap
description: How to correctly use the zap structured logging library in this repository instead of standard log or string formatting.
---
# Structured Logging with Zap
When adding logs to the `hireme` repository, **always** use the global `logger.Log` (which is a `zap.SugaredLogger`).
Do **NOT** use `log.Printf` or `fmt.Printf`.
Do **NOT** use `logger.Log.Infof` to format strings with variables if those variables should be structured data.
## Proper Usage (The `w` methods)
Use the `w` variants of the log methods (`Infow`, `Debugw`, `Errorw`, `Warnw`). These methods take a message string as the first argument, followed by variadic key-value pairs.
### Example: API Endpoints
Always include a `"component", "API"` field.
```go
// GOOD
logger.Log.Infow("Triggered endpoint", "component", "API", "endpoint", "/applications", "status", "success")
// BAD - String interpolation hides the data from JSON parsers
logger.Log.Infof("[API] Triggered /applications with status %s", "success")
```
### Example: Background Workers
Include the worker ID and task ID.
```go
// GOOD
logger.Log.Errorw("Task processing failed", "component", "Worker", "worker_id", id, "task_id", task.ID, "error", err)
```
### Example: Database Operations
```go
// GOOD
logger.Log.Debugw("Running query", "component", "DB", "query", "SELECT * FROM jobs")
```
## Summary of `w` Methods
- `logger.Log.Debugw(msg string, keysAndValues ...interface{})`
- `logger.Log.Infow(msg string, keysAndValues ...interface{})`
- `logger.Log.Warnw(msg string, keysAndValues ...interface{})`
- `logger.Log.Errorw(msg string, keysAndValues ...interface{})`
- `logger.Log.Fatalw(msg string, keysAndValues ...interface{})` // Note: calls os.Exit(1)
+1 -1
View File
@@ -2,7 +2,7 @@
BINARY_NAME=hireme
CONFIG_PATH=data/config/targets.yaml
PORT=8080
PORT=12345
ENV=dev
DB_PATH=data/db/$(ENV)/hireme.db
+66
View File
@@ -0,0 +1,66 @@
# Hireme (GJCC)
**Hireme (Google Job Command Center)** is a high-performance, single-binary job application and tracking command center written in Go.
It completely automates the job search workflow by crawling job boards, analyzing job descriptions against your profile using Large Language Models (LLMs), and presenting a sleek, HTMX-powered UI to review, save, and track your applications.
## Key Features
- **Automated Crawling:** Background worker pools discover and fetch job listings from targets (e.g., Google Jobs).
- **LLM Job Analysis:** Integrates with LLMs (Mock, Gemini, Claude) to score jobs based on your qualifications, identify missing skills, and provide resume tailoring suggestions.
- **Smart Filtering:** Multi-stage filtering pipeline (Hard Keywords + Scoring Weights) configured via YAML to automatically reject unqualified jobs before expensive LLM calls.
- **Sleek UI:** A responsive, dark-mode dashboard built with Go Templates, HTMX, and Tailwind CSS.
- **Pipeline Management:** An interactive Apply Table to move jobs through your pipeline (Saved ▶ Applied ▶ Interview ▶ Offer).
- **Single Binary & SQLite:** Everything is packaged into a single Go binary using a local SQLite database (WAL mode enabled) for zero-dependency deployment.
## Quick Start
### Prerequisites
- Go 1.26+
- `make`
### Installation
1. **Clone the repository:**
```bash
git clone https://git.pengzhan.dev/haopengzhan/hireme.git
cd hireme
```
2. **Configure Data & Filters:**
Copy the example configurations and adjust them:
- `data/config/targets.yaml`: Setup crawler targets.
- `data/config/filters.yaml`: Setup hard keywords and scoring weights.
3. **Set Environment Variables:**
```bash
export LOG_LEVEL=debug
export GEMINI_API_KEY="your_api_key" # If using Gemini LLM
```
4. **Build and Run:**
```bash
make clean
make run
```
5. **Access the UI:**
Open your browser and navigate to [http://localhost:8080](http://localhost:8080).
## Commands
- `make build`: Builds the `hireme` binary into `build/`.
- `make run`: Builds and runs the binary.
- `make clean`: Removes the build binary and test databases.
## Contributing
Contributions are welcome! Please review the documentation in the `docs/` folder to understand the architecture and UI guidelines before submitting pull requests.
## License
MIT License.
+11 -12
View File
@@ -6,12 +6,12 @@ import (
"os"
"path/filepath"
"github.com/hireme/hireme/internal/analyzer"
"github.com/hireme/hireme/internal/config"
"github.com/hireme/hireme/internal/db"
"github.com/hireme/hireme/internal/logger"
"github.com/hireme/hireme/internal/ui"
"github.com/hireme/hireme/internal/worker"
"git.pengzhan.dev/haopengzhan/hireme/internal/analyzer"
"git.pengzhan.dev/haopengzhan/hireme/internal/config"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
"git.pengzhan.dev/haopengzhan/hireme/internal/ui"
"git.pengzhan.dev/haopengzhan/hireme/internal/worker"
"github.com/spf13/cobra"
)
@@ -30,8 +30,7 @@ func newRootCmd() *cobra.Command {
logger.Log.Info("==========================================================")
logger.Log.Info("🚀 Starting Hireme (GJCC) Job Application Command Center")
logger.Log.Info("==========================================================")
logger.Log.Infof("[Config] Port: %s | DB Path: %s | Target Config: %s | Active LLM: %s | Log Level: %s",
cfg.Port, cfg.DBPath, cfg.TargetConfig, cfg.LLMProvider, cfg.LogLevel)
logger.Log.Infow("Starting Hireme", "component", "Config", "port", cfg.Port, "db_path", cfg.DBPath, "target_config", cfg.TargetConfig, "llm", cfg.LLMProvider, "log_level", cfg.LogLevel)
// Ensure DB directory exists
dbDir := filepath.Dir(cfg.DBPath)
@@ -48,7 +47,7 @@ func newRootCmd() *cobra.Command {
// Load targets from YAML config if database is empty
if err := database.LoadTargetsFromYAML(cfg.TargetConfig); err != nil {
logger.Log.Errorf("[Main] Target YAML loading status: %v", err)
logger.Log.Errorw("Target YAML loading failed", "component", "Main", "error", err)
}
// Initialize LLM Analyzer
@@ -60,11 +59,11 @@ func newRootCmd() *cobra.Command {
// Load filter configuration
filterCfg, err := config.LoadFilterConfig(cfg.FilterConfig)
if err != nil {
logger.Log.Fatalf("Failed to load filter config: %v", err)
logger.Log.Fatalw("Failed to load filter config", "component", "Main", "error", err)
}
// 4. Initialize Worker Pool
pool := worker.NewWorkerPool(database, llmAnalyzer, 3, filterCfg)
pool := worker.NewWorkerPool(database, llmAnalyzer, 3, cfg.AnalysisThreshold, filterCfg)
pool.Start() // Auto start the worker pool
// Setup UI Server & Routes
@@ -73,7 +72,7 @@ func newRootCmd() *cobra.Command {
server.RegisterRoutes(mux)
addr := fmt.Sprintf(":%s", cfg.Port)
logger.Log.Infof("✨ Hireme Command Center is live at http://localhost:%s", cfg.Port)
logger.Log.Infow("Hireme Command Center is live", "component", "Main", "url", fmt.Sprintf("http://localhost:%s", cfg.Port))
return http.ListenAndServe(addr, mux)
},
}
+13
View File
@@ -0,0 +1,13 @@
# Hireme System Configuration
server:
port: "8080"
log_level: "info"
ai_trigger:
# Minimum keyword fit score from filters.yaml required to trigger deep LLM analysis (e.g. 25.0)
analysis_threshold: 25.0
prompts:
system_prompt_path: "data/config/prompts/system_prompt.txt"
user_prompt_path: "data/config/prompts/user_prompt.txt"
+96 -24
View File
@@ -1,31 +1,103 @@
# Hard exclusion keywords (immediate rejection)
hard_keywords:
# Sponsorship / Clearance blockers
- "no sponsorship"
- "without sponsorship"
- "us citizen only"
- "u.s. citizen only"
- "active security clearance"
- "top secret/sci"
- "polygraph"
# Non-target employment types
- "unpaid"
- "volunteer"
- "internship"
- "co-op"
# Unrelated roles
- "sales representative"
- "account executive"
- "recruiter"
- "marketing manager"
# Minimum score threshold to qualify for deep LLM analysis
analysis_threshold: 25.0
# Scored keywords with weights
scoring_weights:
- keyword: golang
weight: 3.0
- keyword: go
weight: 2.0
- keyword: python
weight: 2.0
- keyword: kubernetes
weight: 3.0
- keyword: k8s
weight: 3.0
- keyword: docker
weight: 2.0
- keyword: aws
weight: 1.0
- keyword: gcp
weight: 1.0
- keyword: distributed systems
weight: 4.0
- keyword: backend
weight: 3.0
- keyword: ai
weight: -5.0
- keyword: machine learning
weight: -5.0
# Tier 1: Core Systems, Cloud & AI Infrastructure (High Priority)
- keyword: "kubernetes"
weight: 15.0
- keyword: "k8s"
weight: 15.0
- keyword: "golang"
weight: 15.0
- keyword: "go"
weight: 12.0
- keyword: "distributed systems"
weight: 15.0
- keyword: "ai runtime"
weight: 15.0
- keyword: "deep learning system"
weight: 15.0
- keyword: "machine learning infrastructure"
weight: 15.0
- keyword: "gke"
weight: 12.0
- keyword: "container runtime"
weight: 12.0
- keyword: "containerd"
weight: 10.0
- keyword: "c++"
weight: 10.0
- keyword: "linux kernel"
weight: 12.0
- keyword: "operating system"
weight: 10.0
# Tier 2: Cloud, Backend, Systems Programming (Medium Priority)
- keyword: "docker"
weight: 8.0
- keyword: "grpc"
weight: 8.0
- keyword: "protobuf"
weight: 6.0
- keyword: "microservices"
weight: 8.0
- keyword: "python"
weight: 6.0
- keyword: "cloud infrastructure"
weight: 10.0
- keyword: "virtualization"
weight: 8.0
- keyword: "hypervisor"
weight: 8.0
- keyword: "arm"
weight: 6.0
- keyword: "performance optimization"
weight: 8.0
- keyword: "low latency"
weight: 8.0
# Tier 3: Seniority & Qualifications
- keyword: "phd"
weight: 8.0
- keyword: "staff software engineer"
weight: 10.0
- keyword: "senior software engineer"
weight: 8.0
- keyword: "principal engineer"
weight: 10.0
- keyword: "tech lead"
weight: 6.0
# Tier 4: Negative/Irrelevant Focus Penalties
- keyword: "react native"
weight: -8.0
- keyword: "wordpress"
weight: -15.0
- keyword: "php"
weight: -10.0
- keyword: "salesforce"
weight: -10.0
- keyword: "frontend only"
weight: -10.0
+35
View File
@@ -0,0 +1,35 @@
You are an expert Silicon Valley technical recruiter and hiring manager evaluating job descriptions for a specific candidate.
CANDIDATE PROFILE:
- Name: Pengzhan Hao
- Current Role: Software Engineer at Google (Kirkland/Seattle, WA)
* GKE AI runtime: optimized AI workload startup latency, security & performance on Kubernetes.
* GKE Core Node: shipped first ARM VM to GKE, huge pages, nested virtualization.
- Education:
* Ph.D. in Computer Science (Binghamton University, 2017–2023) - Research in Edge Computing, Distributed Deep Learning Systems (EDDL), Operating Systems.
* M.S. in Computer Science (Binghamton University, GPA: 4.0).
* B.E. in Software Engineering (Beihang University).
- Technical Stack: Golang, C/C++, Python, Kubernetes (K8s), GKE, Containerd, Docker, Linux Kernel/OS, Distributed Systems, gRPC, ProtoBuf, ARM, Xen Hypervisor, PyTorch/Dlib.
- Target Roles & Seniority: Senior Software Engineer / Staff Software Engineer / AI Infrastructure / Cloud Systems / Low-level Platforms.
EVALUATION CRITERIA:
1. Score from 0.0 to 100.0 on overall match based on candidate's exact background.
- 80-100: Exceptional match (Core Go/C++/K8s/AI Infra/Distributed Systems/Google scale).
- 60-79: Solid match (Good general backend/cloud infrastructure fit).
- 40-59: Moderate match (Significant domain divergence or missing core skill).
- 0-39: Poor match (Non-tech, frontend-only, or strict clearance requirements).
2. is_qualified: boolean (true if score >= 60.0 and no hard disqualifiers).
3. summary: 1-2 sentence executive assessment of fit.
4. matched_highlights: 3-5 concrete technical strengths/experiences matched.
5. missing_highlights: 1-3 genuine gaps, missing requirements, or nice-to-haves.
6. tailoring_suggestions: 2-3 specific action points for tailoring resume/talking points for this JD.
You MUST respond strictly with a valid JSON object matching the requested schema:
{
"ai_score": 85.0,
"is_qualified": true,
"summary": "...",
"matched_highlights": ["..."],
"missing_highlights": ["..."],
"tailoring_suggestions": ["..."]
}
+8
View File
@@ -0,0 +1,8 @@
Analyze the following job description for candidate Pengzhan Hao.
Job Title: {{.JobTitle}}
Job Description:
{{.JobContent}}
Provide the structured evaluation adhering to the JSON schema.
+63 -3
View File
@@ -1,5 +1,65 @@
targets:
- name: "Google Software Engineer (Seattle, MID Level)"
target_url: "https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID"
platform: "google_jobs"
- name: Google Software Engineer (Seattle, MID Level)
target_url: https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID
platform: google_jobs
is_active: true
- name: Apple Software Engineer (Seattle)
target_url: https://jobs.apple.com/en-us/search?location=seattle-SEA&key=Software%2520Engineer&team=apps-and-frameworks-SFTWR-AF+cloud-and-infrastructure-SFTWR-CLD+core-operating-systems-SFTWR-COS+devops-and-site-reliability-SFTWR-DSR+engineering-project-management-SFTWR-EPM+information-systems-and-technology-SFTWR-ISTECH+machine-learning-and-ai-SFTWR-MCHLN+security-and-privacy-SFTWR-SEC+software-quality-automation-and-tools-SFTWR-SQAT+wireless-software-SFTWR-WSFT+machine-learning-infrastructure-MLAI-MLI+deep-learning-and-reinforcement-learning-MLAI-DLRL+natural-language-processing-and-speech-technologies-MLAI-NLP+computer-vision-MLAI-CV+applied-research-MLAI-ARr
platform: apple
is_active: true
- name: Meta Software Engineer (Seattle)
target_url: https://www.metacareers.com/jobsearch/?offices[0]=Seattle%2C%20WA&roles[0]=Full%20time%20employment&teams[0]=Software%20Engineering
platform: meta
is_active: true
- name: Amazon / AWS Software Engineer (Seattle / Bellevue)
target_url: https://www.amazon.jobs/en/search.json?base_query=Software+Development+Engineer&loc_query=Seattle%2C+WA%2C+USA
platform: amazon
is_active: true
- name: Microsoft Software Engineer (Redmond / Bellevue / Seattle)
target_url: https://gcsservices.careers.microsoft.com/search/api/v1/search?q=Software+Engineer&lc=Redmond%2C+Washington%2C+United+States&l=en_us
platform: microsoft
is_active: true
- name: ByteDance / TikTok Software Engineer (Seattle / Bellevue)
target_url: https://jobs.bytedance.com/api/v1/search/job/posts?keyword=Software+Engineer
platform: bytedance
is_active: true
- name: NVIDIA Software Engineer (Redmond / Seattle)
target_url: https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite?q=Software+Engineer
platform: nvidia
is_active: true
- name: Netflix Software Engineer (Seattle / Remote)
target_url: https://jobs.netflix.com/api/search?q=Software+Engineer
platform: netflix
is_active: true
- name: Uber Software Engineer (Seattle)
target_url: https://www.uber.com/global/en/careers/search/?query=Software+Engineer
platform: uber
is_active: true
- name: LinkedIn Software Engineer (Seattle / Bellevue)
target_url: https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=Software+Engineer&location=Seattle%2C+Washington%2C+United+States
platform: linkedin
is_active: true
- name: Stripe Software Engineer (Seattle / Remote)
target_url: https://boards-api.greenhouse.io/v1/boards/stripe/jobs
platform: stripe
is_active: true
- name: Databricks Software Engineer (Bellevue / Seattle)
target_url: https://boards-api.greenhouse.io/v1/boards/databricks/jobs
platform: databricks
is_active: true
- name: Snowflake Software Engineer (Bellevue / Seattle)
target_url: https://boards-api.greenhouse.io/v1/boards/snowflake/jobs
platform: snowflake
is_active: true
+43
View File
@@ -0,0 +1,43 @@
# System Architecture
Hireme is designed as a single-binary, local-first web application. It combines a high-performance backend, an embedded database, background job processing, and a lightweight frontend into a cohesive, easy-to-deploy system.
## Components
### 1. Database Layer (`internal/db`)
- **Technology:** SQLite3 via `github.com/mattn/go-sqlite3`.
- **Mode:** WAL (Write-Ahead Logging) mode is enabled (`PRAGMA journal_mode=WAL;`) to support high concurrency between the HTTP server reads and background worker writes without "database is locked" errors.
- **Pattern:** Centralized query methods attached to a `DB` struct (`queries.go`).
### 2. Worker Pool & Task Queue (`internal/worker`, `internal/db/tasks.go`)
- **Technology:** Custom goroutine worker pool pulling from a `tasks` SQLite table.
- **Tasks:**
- `DISCOVER`: Initiates crawling on a target (e.g., Google Jobs search URL).
- `FETCH`: Fetches raw HTML for a specific job posting.
- `PROCESS`: Parses raw HTML, applies rule-based filtering (keywords, scoring), and enqueues for analysis if qualified.
- `ANALYZE`: Sends the parsed job to an LLM for final qualification scoring and tailoring tips.
- **Concurrency:** Managed via context cancellation and `atomic.Bool` flags for safe pausing and resuming.
### 3. Filters & Rules (`internal/filter`, `data/config/filters.yaml`)
- A pipeline pattern is used to quickly reject jobs before doing expensive LLM API calls.
- **Hard Keywords:** Immediate rejection if a keyword (e.g., "Clearance", "Staff") is found.
- **Scoring Weights:** Basic substring counting for required technologies (e.g., "Go", "Python"). If the raw score falls below a threshold, the job is rejected.
### 4. Crawlers (`pkg/crawler`)
- Currently implements Google Jobs (`pkg/crawler/google`).
- **Mechanism:** Parses embedded JSON (`AF_initDataCallback`) in the raw HTML response rather than fragile DOM traversal. This ensures stability against UI changes.
### 5. UI Layer (`internal/ui`)
- **Technology:** Standard Go HTTP server (`net/http`), Go Templates (`html/template`), HTMX, and Tailwind CSS.
- **Philosophy:** Server-Side Rendered (SSR) HTML fragments returned directly to HTMX for seamless, single-page application (SPA) like updates without heavy JS payloads.
- **Templates:** Statically compiled strings in `.go` files (`template_layout.go`, `template_jobs.go`) rather than external `.html` files to keep the binary fully self-contained.
### 6. Logging (`internal/logger`)
- **Technology:** `go.uber.org/zap` (Sugared Logger).
- **Pattern:** Structured logging using `w` methods (`Infow`, `Errorw`, `Debugw`) to allow for strongly-typed key-value pairs (`"component", "API"`) instead of string formatting.
+38
View File
@@ -0,0 +1,38 @@
# UI Guidelines
Hireme uses a modern, server-rendered UI stack that eliminates the need for heavy JavaScript frameworks (React, Vue, etc.) while maintaining a highly reactive, single-page application (SPA) feel.
## Stack
- **HTML / Go Templates:** Server-side rendering using `html/template`.
- **HTMX (`hx-*`):** Used for all dynamic interactions, form submissions, and UI updates.
- **Tailwind CSS:** Utility-first CSS framework for styling via CDN.
## Architecture & Patterns
### 1. Template Strings vs Files
Templates are declared as `const` strings in `.go` files (e.g., `template_layout.go`, `template_jobs.go`) and collected in a `TemplateRegistry` in `templates.go`. This keeps the final build as a single executable without requiring external static asset folders.
### 2. HTMX Updates
Instead of returning JSON and re-rendering via JS, API endpoints return rendered HTML fragments.
- **Example:** Clicking `+ Save` on a job POSTs to `/applications`. The handler updates the DB and returns the `template_jobs` HTML fragment, which HTMX swaps directly into the `#jobs-container` DOM element.
### 3. Layout & Navigation (Tab Persistence)
- The main UI uses a tabbed layout (Jobs, Apply, Targets).
- Switching tabs is handled via lightweight Vanilla JS (`switchTab(id)`) which toggles `.hidden` classes.
- Tab states are synced to the URL `#hash` so reloading the page preserves the active tab.
### 4. Client-Side Filtering & Sorting
- To prevent unnecessary server load for display formatting, table sorting and filtering (e.g., searching by keyword, or filtering by "Qualified") is done purely client-side using Vanilla JS.
- Data attributes (`data-sort-val`, `data-qual`, `data-app`) are embedded in the `<tr>` elements during server-render to aid the JS filter logic.
### 5. Design Aesthetics
- **Dark Mode Default:** The UI uses Tailwind's `dark:` classes implicitly by having `class="dark"` on the root `<html>` tag.
- **Micro-interactions:** Use hover states (`hover:bg-slate-800`), transitions (`transition-colors`), and subtle borders to create a premium feel.
- **Indicators:** Use badges and color-coded text (Emerald for success/qualified, Rose for rejected/not qualified, Indigo for actions).
+9 -1
View File
@@ -1,9 +1,11 @@
module github.com/hireme/hireme
module git.pengzhan.dev/haopengzhan/hireme
go 1.26
require (
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f
github.com/chromedp/chromedp v0.16.0
github.com/mattn/go-sqlite3 v1.14.42
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
@@ -14,6 +16,12 @@ require (
require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+37 -2
View File
@@ -2,24 +2,59 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+6 -3
View File
@@ -5,10 +5,13 @@ import (
"fmt"
"strings"
"github.com/hireme/hireme/internal/config"
"git.pengzhan.dev/haopengzhan/hireme/internal/config"
)
type AnalysisOutput struct {
AIScore float64 `json:"ai_score"`
IsQualified bool `json:"is_qualified"`
Summary string `json:"summary"`
MatchedHighlights []string `json:"matched_highlights"`
MissingHighlights []string `json:"missing_highlights"`
TailoringSuggestions []string `json:"tailoring_suggestions"`
@@ -25,12 +28,12 @@ func NewAnalyzer(cfg *config.Config) (LLMAnalyzer, error) {
if cfg.GeminiAPIKey == "" {
return nil, fmt.Errorf("GEMINI_API_KEY is required for provider 'gemini'")
}
return NewGeminiAnalyzer(cfg.GeminiAPIKey), nil
return NewGeminiAnalyzer(cfg.GeminiAPIKey, cfg.SystemPrompt, cfg.UserPrompt), nil
case "deepseek":
if cfg.DeepSeekAPIKey == "" {
return nil, fmt.Errorf("DEEPSEEK_API_KEY is required for provider 'deepseek'")
}
return NewDeepSeekAnalyzer(cfg.DeepSeekAPIKey, cfg.DeepSeekBaseURL), nil
return NewDeepSeekAnalyzer(cfg.DeepSeekAPIKey, cfg.DeepSeekBaseURL, cfg.SystemPrompt, cfg.UserPrompt), nil
case "mock":
return NewMockAnalyzer(), nil
default:
+50
View File
@@ -0,0 +1,50 @@
package analyzer
import (
"context"
"testing"
)
func TestCleanJSONResponse(t *testing.T) {
cases := []struct {
input string
expected string
}{
{
input: "```json\n{\"ai_score\": 90}\n```",
expected: "{\"ai_score\": 90}",
},
{
input: "```\n{\"ai_score\": 85}\n```",
expected: "{\"ai_score\": 85}",
},
{
input: "{\"ai_score\": 95}",
expected: "{\"ai_score\": 95}",
},
}
for _, c := range cases {
out := cleanJSONResponse(c.input)
if out != c.expected {
t.Errorf("cleanJSONResponse(%q) = %q, expected %q", c.input, out, c.expected)
}
}
}
func TestMockAnalyzer(t *testing.T) {
mock := NewMockAnalyzer()
res, err := mock.AnalyzeJD(context.Background(), "Senior AI Infrastructure Engineer", "Building GKE and K8s runtimes.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.AIScore <= 0 {
t.Errorf("expected positive AI score, got %f", res.AIScore)
}
if !res.IsQualified {
t.Errorf("expected IsQualified=true, got false")
}
if len(res.MatchedHighlights) == 0 {
t.Errorf("expected matched highlights to not be empty")
}
}
+44 -13
View File
@@ -14,36 +14,46 @@ import (
type DeepSeekAnalyzer struct {
apiKey string
baseURL string
systemPrompt string
userPromptTemplate string
client *http.Client
}
func NewDeepSeekAnalyzer(apiKey, baseURL string) *DeepSeekAnalyzer {
func NewDeepSeekAnalyzer(apiKey, baseURL, systemPrompt, userPromptTemplate string) *DeepSeekAnalyzer {
if baseURL == "" {
baseURL = "https://api.deepseek.com"
}
return &DeepSeekAnalyzer{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
systemPrompt: systemPrompt,
userPromptTemplate: userPromptTemplate,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s".
Provide a structured JSON output with 3 fields:
- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]).
- "missing_highlights": list of 1-3 missing skills, experience gaps, or extra requirements (e.g. ["10+ yrs experience", "Kubernetes administration"]).
- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD.
func (d *DeepSeekAnalyzer) buildUserPrompt(jobTitle, rawContent string) string {
if d.userPromptTemplate == "" {
return fmt.Sprintf("Analyze the following job description for candidate Pengzhan Hao.\n\nJob Title: %s\n\nJob Description:\n%s\n\nProvide the structured evaluation adhering to the JSON schema.", jobTitle, rawContent)
}
res := strings.ReplaceAll(d.userPromptTemplate, "{{.JobTitle}}", jobTitle)
res = strings.ReplaceAll(res, "{{.JobContent}}", rawContent)
return res
}
Job Title: %s
Job Content:
%s`, jobTitle, jobTitle, rawContent)
func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
sysPrompt := d.systemPrompt
if sysPrompt == "" {
sysPrompt = "You are an expert technical resume screener. You MUST respond with a valid JSON object matching {\"ai_score\": 80.0, \"is_qualified\": true, \"summary\": \"...\", \"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}."
}
userPrompt := d.buildUserPrompt(jobTitle, rawContent)
reqBody := map[string]interface{}{
"model": "deepseek-chat",
"messages": []map[string]string{
{"role": "system", "content": "You are a professional hiring assistant. You MUST respond ONLY with valid JSON matching {\"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}."},
{"role": "user", "content": prompt},
{"role": "system", "content": sysPrompt},
{"role": "user", "content": userPrompt},
},
"response_format": map[string]string{
"type": "json_object",
@@ -99,7 +109,28 @@ Job Content:
var output AnalysisOutput
if err := json.Unmarshal([]byte(rawJSON), &output); err != nil {
return nil, fmt.Errorf("failed to parse DeepSeek JSON output: %w", err)
return nil, fmt.Errorf("failed to parse DeepSeek JSON output: %w (content: %s)", err, rawJSON)
}
// Sanitize output bounds
if output.AIScore < 0 {
output.AIScore = 0
} else if output.AIScore > 100 {
output.AIScore = 100
}
if output.AIScore >= 60.0 {
output.IsQualified = true
}
if output.MatchedHighlights == nil {
output.MatchedHighlights = []string{}
}
if output.MissingHighlights == nil {
output.MissingHighlights = []string{}
}
if output.TailoringSuggestions == nil {
output.TailoringSuggestions = []string{}
}
return &output, nil
+32 -13
View File
@@ -7,40 +7,49 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
)
type GeminiAnalyzer struct {
apiKey string
systemPrompt string
userPromptTemplate string
client *http.Client
}
func NewGeminiAnalyzer(apiKey string) *GeminiAnalyzer {
func NewGeminiAnalyzer(apiKey, systemPrompt, userPromptTemplate string) *GeminiAnalyzer {
return &GeminiAnalyzer{
apiKey: apiKey,
systemPrompt: systemPrompt,
userPromptTemplate: userPromptTemplate,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (g *GeminiAnalyzer) buildUserPrompt(jobTitle, rawContent string) string {
if g.userPromptTemplate == "" {
return fmt.Sprintf("Analyze the following job description for candidate Pengzhan Hao.\n\nJob Title: %s\n\nJob Content:\n%s\n\nRespond with structured JSON.", jobTitle, rawContent)
}
res := strings.ReplaceAll(g.userPromptTemplate, "{{.JobTitle}}", jobTitle)
res = strings.ReplaceAll(res, "{{.JobContent}}", rawContent)
return res
}
func (g *GeminiAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s".
Provide a structured JSON output with 3 fields:
- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]).
- "missing_highlights": list of 1-3 missing skills, experience gaps, or extra requirements (e.g. ["10+ yrs experience", "Kubernetes administration"]).
- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD.
sysPrompt := g.systemPrompt
if sysPrompt == "" {
sysPrompt = "You are an expert technical resume screener. Respond STRICTLY with valid JSON matching {\"ai_score\": 85.0, \"is_qualified\": true, \"summary\": \"...\", \"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}."
}
Job Title: %s
Job Content:
%s
Respond STRICTLY with valid JSON matching the format:
{"matched_highlights": [...], "missing_highlights": [...], "tailoring_suggestions": [...]}`, jobTitle, jobTitle, rawContent)
userPrompt := g.buildUserPrompt(jobTitle, rawContent)
fullPrompt := fmt.Sprintf("%s\n\n%s", sysPrompt, userPrompt)
reqBody := map[string]interface{}{
"contents": []map[string]interface{}{
{
"parts": []map[string]string{
{"text": prompt},
{"text": fullPrompt},
},
},
},
@@ -102,5 +111,15 @@ Respond STRICTLY with valid JSON matching the format:
return nil, fmt.Errorf("failed to parse Gemini JSON output: %w", err)
}
if output.AIScore < 0 {
output.AIScore = 0
} else if output.AIScore > 100 {
output.AIScore = 100
}
if output.AIScore >= 60.0 {
output.IsQualified = true
}
return &output, nil
}
+5 -3
View File
@@ -10,11 +10,13 @@ func NewMockAnalyzer() *MockAnalyzer {
return &MockAnalyzer{}
}
// AnalyzeJD returns empty slices for mock provider so no fake mock text is injected into side panel during debug
func (m *MockAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
return &AnalysisOutput{
MatchedHighlights: []string{},
AIScore: 75.0,
IsQualified: true,
Summary: "Mock analysis: relevant systems and distributed infrastructure role.",
MatchedHighlights: []string{"Golang", "Distributed Systems", "Kubernetes"},
MissingHighlights: []string{},
TailoringSuggestions: []string{},
TailoringSuggestions: []string{"Highlight Google GKE runtime experience."},
}, nil
}
+107 -3
View File
@@ -5,18 +5,72 @@ import (
"strings"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
type AppConfigFile struct {
Server struct {
Port string `yaml:"port"`
LogLevel string `yaml:"log_level"`
} `yaml:"server"`
AITrigger struct {
AnalysisThreshold float64 `yaml:"analysis_threshold"`
} `yaml:"ai_trigger"`
Prompts struct {
SystemPromptPath string `yaml:"system_prompt_path"`
UserPromptPath string `yaml:"user_prompt_path"`
} `yaml:"prompts"`
}
type Config struct {
Port string
DBPath string
TargetConfig string
FilterConfig string
AppConfig string
LogLevel string
LLMProvider string // "gemini", "deepseek", "mock"
GeminiAPIKey string
DeepSeekAPIKey string
DeepSeekBaseURL string
AnalysisThreshold float64
SystemPromptPath string
UserPromptPath string
SystemPrompt string
UserPrompt string
}
func init() {
LoadEnvFile(".env")
}
// LoadEnvFile parses a simple KEY=VALUE .env file if it exists without overriding existing env vars
func LoadEnvFile(filenames ...string) {
if len(filenames) == 0 {
filenames = []string{".env"}
}
for _, filename := range filenames {
data, err := os.ReadFile(filename)
if err != nil {
continue
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, `"'`)
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
}
}
}
}
func RegisterFlags(flags *pflag.FlagSet) *Config {
@@ -26,21 +80,71 @@ func RegisterFlags(flags *pflag.FlagSet) *Config {
flags.StringVarP(&cfg.DBPath, "db", "d", getEnvOrDefault("DB_PATH", "data/db/hireme.db"), "Path to database file")
flags.StringVarP(&cfg.TargetConfig, "target-config", "c", getEnvOrDefault("TARGET_CONFIG", "data/config/targets.yaml"), "Path to targets YAML file")
flags.StringVarP(&cfg.FilterConfig, "filter-config", "f", getEnvOrDefault("FILTER_CONFIG", "data/config/filters.yaml"), "Path to filters YAML file")
flags.StringVar(&cfg.AppConfig, "app-config", getEnvOrDefault("APP_CONFIG", "data/config/config.yaml"), "Path to app YAML config file")
flags.StringVar(&cfg.LogLevel, "log-level", getEnvOrDefault("LOG_LEVEL", "info"), "Logging level (trace, debug, info, error)")
flags.StringVarP(&cfg.LLMProvider, "llm-provider", "l", getEnvOrDefault("LLM_PROVIDER", ""), "Active LLM Provider (gemini, deepseek, mock)")
flags.StringVar(&cfg.GeminiAPIKey, "gemini-api-key", os.Getenv("GEMINI_API_KEY"), "Gemini API Key")
flags.StringVar(&cfg.DeepSeekAPIKey, "deepseek-api-key", os.Getenv("DEEPSEEK_API_KEY"), "DeepSeek API Key")
flags.StringVar(&cfg.DeepSeekBaseURL, "deepseek-base-url", getEnvOrDefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), "DeepSeek Base URL")
flags.Float64Var(&cfg.AnalysisThreshold, "analysis-threshold", 25.0, "Threshold score from filter to trigger LLM analysis")
return cfg
}
func (c *Config) Normalize() {
// Re-check env vars in case .env was loaded after initial flag binding
if c.DeepSeekAPIKey == "" {
c.DeepSeekAPIKey = os.Getenv("DEEPSEEK_API_KEY")
}
if c.GeminiAPIKey == "" {
c.GeminiAPIKey = os.Getenv("GEMINI_API_KEY")
}
// Load data/config/config.yaml if present
if c.AppConfig != "" {
if data, err := os.ReadFile(c.AppConfig); err == nil {
var appCfg AppConfigFile
if err := yaml.Unmarshal(data, &appCfg); err == nil {
if appCfg.Server.Port != "" && c.Port == "8080" {
c.Port = appCfg.Server.Port
}
if appCfg.Server.LogLevel != "" && c.LogLevel == "info" {
c.LogLevel = appCfg.Server.LogLevel
}
if appCfg.AITrigger.AnalysisThreshold > 0 {
c.AnalysisThreshold = appCfg.AITrigger.AnalysisThreshold
}
if appCfg.Prompts.SystemPromptPath != "" {
c.SystemPromptPath = appCfg.Prompts.SystemPromptPath
}
if appCfg.Prompts.UserPromptPath != "" {
c.UserPromptPath = appCfg.Prompts.UserPromptPath
}
}
}
}
if c.SystemPromptPath == "" {
c.SystemPromptPath = "data/config/prompts/system_prompt.txt"
}
if c.UserPromptPath == "" {
c.UserPromptPath = "data/config/prompts/user_prompt.txt"
}
// Load prompt contents from files
if promptData, err := os.ReadFile(c.SystemPromptPath); err == nil {
c.SystemPrompt = strings.TrimSpace(string(promptData))
}
if userPromptData, err := os.ReadFile(c.UserPromptPath); err == nil {
c.UserPrompt = strings.TrimSpace(string(userPromptData))
}
// Determine LLM provider
if c.LLMProvider == "" {
if c.GeminiAPIKey != "" {
c.LLMProvider = "gemini"
} else if c.DeepSeekAPIKey != "" {
if c.DeepSeekAPIKey != "" {
c.LLMProvider = "deepseek"
} else if c.GeminiAPIKey != "" {
c.LLMProvider = "gemini"
} else {
c.LLMProvider = "mock"
}
+46
View File
@@ -0,0 +1,46 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestConfigLoadEnvAndAppConfig(t *testing.T) {
tempDir := t.TempDir()
envPath := filepath.Join(tempDir, ".env")
configYAMLPath := filepath.Join(tempDir, "config.yaml")
_ = os.WriteFile(envPath, []byte("TEST_SECRET_KEY=12345\nDEEPSEEK_API_KEY=test-sk-key\n"), 0644)
_ = os.WriteFile(configYAMLPath, []byte(`
server:
port: "9090"
log_level: "debug"
ai_trigger:
analysis_threshold: 35.0
prompts:
system_prompt_path: "sys.txt"
`), 0644)
LoadEnvFile(envPath)
if os.Getenv("TEST_SECRET_KEY") != "12345" {
t.Errorf("expected TEST_SECRET_KEY to be 12345, got %q", os.Getenv("TEST_SECRET_KEY"))
}
cfg := &Config{
Port: "8080",
LogLevel: "info",
AppConfig: configYAMLPath,
}
cfg.Normalize()
if cfg.Port != "9090" {
t.Errorf("expected port 9090 from config.yaml, got %s", cfg.Port)
}
if cfg.AnalysisThreshold != 35.0 {
t.Errorf("expected analysis threshold 35.0, got %f", cfg.AnalysisThreshold)
}
if cfg.LLMProvider != "deepseek" {
t.Errorf("expected LLMProvider to be deepseek, got %s", cfg.LLMProvider)
}
}
+4 -1
View File
@@ -4,12 +4,13 @@ import (
"fmt"
"os"
"github.com/hireme/hireme/internal/filter"
"git.pengzhan.dev/haopengzhan/hireme/internal/filter"
"gopkg.in/yaml.v3"
)
type FilterConfig struct {
HardKeywords []string `yaml:"hard_keywords"`
AnalysisThreshold float64 `yaml:"analysis_threshold"`
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
}
@@ -20,6 +21,8 @@ func LoadFilterConfig(path string) (*FilterConfig, error) {
}
var cfg FilterConfig
// Default threshold if not specified
cfg.AnalysisThreshold = 25.0
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal filter config: %w", err)
}
+326
View File
@@ -0,0 +1,326 @@
// Package crawlertest provides a standardized integration test harness for
// validating that each SiteCrawler still functions against its real target URL.
//
// Each crawler package should have a TestXxx_LiveIntegration test that calls
// crawlertest.RunLiveIntegration(). This tests three real-world failure modes:
//
// 1. DISCOVER STAGE - Can we still hit the search URL and get job listings?
// Detects: search API endpoint moved, query param format changed, auth required.
//
// 2. FETCH STAGE - Can we still fetch a single job detail page?
// Detects: detail URL pattern changed, page returns 403/404, rate limiting.
//
// 3. PARSE STAGE - Can we still extract structured data from the response?
// Detects: HTML/JSON schema changed, title/company/content extraction broken.
package crawlertest
import (
"context"
"fmt"
"strings"
"testing"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
)
// LiveTestConfig defines what a live integration test expects from a crawler.
type LiveTestConfig struct {
// PlatformName is the human-readable platform name (e.g., "Amazon", "Google").
PlatformName string
// Crawler is the SiteCrawler instance to test.
Crawler crawler.SiteCrawler
// TargetURL is the real search/listing URL to test discovery against.
TargetURL string
// ExpectedCanonicalPrefix is the prefix each CanonicalID should start with
// (e.g., "amazon:", "google:"). Empty string skips this check.
ExpectedCanonicalPrefix string
// ExpectedCompany is the expected Company field in FetchJobDetail results.
// Empty string skips this check.
ExpectedCompany string
// MinDiscoverCount is the minimum number of jobs DiscoverJobs should return.
// Defaults to 1 if not set.
MinDiscoverCount int
// MinContentLength is the minimum byte length of RawContent in FetchJobDetail.
// Defaults to 100 if not set.
MinContentLength int
// Timeout is the context timeout for the entire test. Defaults to 30s.
Timeout time.Duration
}
// RunLiveIntegration runs a standardized 3-stage integration test against real
// targets. It gracefully skips on network errors (offline/sandbox) but FAILS
// loudly when the crawler reaches the site but can't parse data correctly.
//
// This is the test that tells you "the site changed and our crawler is broken".
//
// Usage in each crawler package's _test.go:
//
// func TestAmazonCrawler_LiveIntegration(t *testing.T) {
// crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
// PlatformName: "Amazon",
// Crawler: NewAmazonCrawler(),
// TargetURL: "https://www.amazon.jobs/en/search.json?...",
// ExpectedCanonicalPrefix: "amazon:",
// ExpectedCompany: "Amazon",
// })
// }
func RunLiveIntegration(t *testing.T, cfg LiveTestConfig) {
t.Helper()
// Apply defaults
if cfg.MinDiscoverCount <= 0 {
cfg.MinDiscoverCount = 1
}
if cfg.MinContentLength <= 0 {
cfg.MinContentLength = 100
}
if cfg.Timeout <= 0 {
cfg.Timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
// =========================================================================
// STAGE 1: DISCOVER — Can we reach the search URL and get job listings?
// =========================================================================
t.Run("Stage1_Discover", func(t *testing.T) {
t.Logf("[%s] Discovering jobs from: %s", cfg.PlatformName, cfg.TargetURL)
discovered, err := cfg.Crawler.DiscoverJobs(ctx, cfg.TargetURL)
if err != nil {
if isNetworkError(err) {
t.Skipf("SKIP (offline): %v", err)
}
t.Fatalf("❌ DISCOVER FAILED: %v\n"+
" → The search URL may have changed format or the API endpoint moved.\n"+
" → Target URL: %s", err, cfg.TargetURL)
}
if len(discovered) == 0 {
t.Fatalf("❌ DISCOVER RETURNED 0 JOBS\n"+
" → The search results page format may have changed.\n"+
" → Check if the target URL still returns results in a browser.\n"+
" → Target URL: %s", cfg.TargetURL)
}
if len(discovered) < cfg.MinDiscoverCount {
t.Errorf("⚠️ DISCOVER LOW COUNT: got %d jobs, expected at least %d\n"+
" → The search API may be returning fewer results than expected.",
len(discovered), cfg.MinDiscoverCount)
}
t.Logf("✅ Discovered %d jobs", len(discovered))
// Validate first result structure
first := discovered[0]
if first.ApplyURL == "" {
t.Errorf("❌ First discovered job has empty ApplyURL")
}
if first.CanonicalID == "" {
t.Errorf("❌ First discovered job has empty CanonicalID")
}
if cfg.ExpectedCanonicalPrefix != "" && !strings.HasPrefix(first.CanonicalID, cfg.ExpectedCanonicalPrefix) {
t.Errorf("❌ CANONICAL ID FORMAT CHANGED: got %q, expected prefix %q\n"+
" → The job ID extraction pattern may need updating.",
first.CanonicalID, cfg.ExpectedCanonicalPrefix)
}
t.Logf(" First job: ID=%s URL=%s", first.CanonicalID, first.ApplyURL)
})
// Re-discover for stage 2 (subtests get their own scope)
discovered, err := cfg.Crawler.DiscoverJobs(ctx, cfg.TargetURL)
if err != nil || len(discovered) == 0 {
// Stage 1 already reported the failure
return
}
// =========================================================================
// STAGE 2: FETCH — Can we fetch a job detail page?
// =========================================================================
firstJob := discovered[0]
var detail *crawler.ExtractedJob
t.Run("Stage2_Fetch", func(t *testing.T) {
t.Logf("[%s] Fetching job detail: %s", cfg.PlatformName, firstJob.ApplyURL)
var fetchErr error
detail, fetchErr = cfg.Crawler.FetchJobDetail(ctx, firstJob.ApplyURL)
if fetchErr != nil {
if isNetworkError(fetchErr) {
t.Skipf("SKIP (offline): %v", fetchErr)
}
t.Fatalf("❌ FETCH FAILED: %v\n"+
" → The job detail URL pattern may have changed.\n"+
" → The page may now require JavaScript rendering.\n"+
" → Job URL: %s", fetchErr, firstJob.ApplyURL)
}
if detail == nil {
t.Fatalf("❌ FETCH RETURNED nil ExtractedJob")
}
t.Logf("✅ Fetched job detail successfully")
})
if detail == nil {
return
}
// =========================================================================
// STAGE 3: PARSE — Can we extract structured data from the response?
// =========================================================================
t.Run("Stage3_Parse", func(t *testing.T) {
t.Logf("[%s] Validating parsed fields", cfg.PlatformName)
failures := 0
// Title
if strings.TrimSpace(detail.Title) == "" {
t.Errorf("❌ PARSE: Title is empty\n"+
" → The title extraction selector/pattern may have changed.\n"+
" → Job URL: %s", firstJob.ApplyURL)
failures++
} else {
t.Logf(" Title: %s", detail.Title)
}
// Company
if strings.TrimSpace(detail.Company) == "" {
t.Errorf("❌ PARSE: Company is empty\n"+
" → The company extraction logic may have changed.")
failures++
} else {
t.Logf(" Company: %s", detail.Company)
}
if cfg.ExpectedCompany != "" && !strings.Contains(detail.Company, cfg.ExpectedCompany) {
t.Errorf("⚠️ PARSE: Company mismatch: got %q, expected to contain %q\n"+
" → The company name format may have changed.",
detail.Company, cfg.ExpectedCompany)
}
// Location
if strings.TrimSpace(detail.Location) == "" {
t.Errorf("⚠️ PARSE: Location is empty\n"+
" → Location extraction may have changed.")
} else {
t.Logf(" Location: %s", detail.Location)
}
// CanonicalID
if detail.CanonicalID == "" {
t.Errorf("❌ PARSE: CanonicalID is empty")
failures++
}
// ApplyURL
if detail.ApplyURL == "" {
t.Errorf("❌ PARSE: ApplyURL is empty")
failures++
}
// RawContent (the critical one for downstream LLM analysis)
contentLen := len(detail.RawContent)
if contentLen == 0 {
t.Errorf("❌ PARSE: RawContent is EMPTY\n"+
" → The content extraction is completely broken.\n"+
" → The page structure or API response schema has changed.\n"+
" → Job URL: %s", firstJob.ApplyURL)
failures++
} else if contentLen < cfg.MinContentLength {
t.Errorf("⚠️ PARSE: RawContent is too short (%d bytes, min %d)\n"+
" → The content extraction may be partially broken.\n"+
" → Job URL: %s", contentLen, cfg.MinContentLength, firstJob.ApplyURL)
} else {
t.Logf(" Content: %d bytes", contentLen)
}
if failures == 0 {
t.Logf("✅ All parse checks passed")
} else {
t.Logf("❌ %d parse check(s) failed — crawler needs fixing", failures)
}
})
}
// isNetworkError checks if an error is a network/DNS/connectivity issue
// (as opposed to a parsing or HTTP status error from a reachable server).
func isNetworkError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
networkPatterns := []string{
"no such host",
"dial tcp",
"connection refused",
"network is unreachable",
"i/o timeout",
"TLS handshake timeout",
"operation not permitted",
"connect: operation not permitted",
}
for _, pattern := range networkPatterns {
if strings.Contains(msg, pattern) {
return true
}
}
return false
}
// FormatHealthReport generates a summary table from multiple test results.
// This can be called from a top-level aggregation test.
func FormatHealthReport(results []HealthResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%-15s | %-10s | %-10s | %-10s | %s\n",
"Platform", "Discover", "Fetch", "Content", "Status"))
sb.WriteString(strings.Repeat("-", 70) + "\n")
for _, r := range results {
discoverStr := fmt.Sprintf("%d jobs", r.DiscoverCount)
fetchStr := "✓"
contentStr := fmt.Sprintf("%d B", r.ContentLength)
status := "✅ HEALTHY"
if r.Skipped {
status = "⏭ SKIPPED"
discoverStr = "-"
fetchStr = "-"
contentStr = "-"
} else if r.Error != nil {
status = "❌ BROKEN"
if r.DiscoverCount == 0 {
discoverStr = "FAIL"
fetchStr = "-"
contentStr = "-"
} else if r.ContentLength == 0 {
fetchStr = "FAIL"
contentStr = "-"
}
} else if r.DiscoverCount == 0 {
status = "⚠️ DEGRADED"
discoverStr = "0 ⚠️"
}
sb.WriteString(fmt.Sprintf("%-15s | %-10s | %-10s | %-10s | %s\n",
r.Platform, discoverStr, fetchStr, contentStr, status))
}
return sb.String()
}
// HealthResult is a structured result from a crawler health check.
type HealthResult struct {
Platform string
DiscoverCount int
ContentLength int
Error error
Skipped bool
}
+73 -4
View File
@@ -3,8 +3,8 @@ package db
import (
"database/sql"
"fmt"
"log"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
_ "github.com/mattn/go-sqlite3"
)
@@ -31,7 +31,7 @@ func InitDB(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to run sqlite migrations: %w", err)
}
log.Printf("[DB] SQLite database initialized successfully at %s", dbPath)
logger.Log.Infow("SQLite database initialized successfully", "component", "DB", "dbPath", dbPath)
return database, nil
}
@@ -55,14 +55,17 @@ func (db *DB) migrate() error {
location TEXT,
apply_url TEXT NOT NULL,
raw_content TEXT NOT NULL,
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS job_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE,
filter_score REAL DEFAULT 0.0,
score REAL DEFAULT 0.0,
is_qualified BOOLEAN NOT NULL DEFAULT 0,
summary TEXT,
matched_highlights JSON,
missing_highlights JSON,
tailoring_suggestions JSON,
@@ -92,6 +95,72 @@ func (db *DB) migrate() error {
);
`
_, err := db.Exec(schema)
if _, err := db.Exec(schema); err != nil {
return err
}
// Dynamic migration for existing databases: check if last_seen_at exists in raw_jobs
var hasLastSeen bool
rows, err := db.Query(`PRAGMA table_info(raw_jobs)`)
if err == nil {
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dfltValue sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err == nil {
if name == "last_seen_at" {
hasLastSeen = true
break
}
}
}
_ = rows.Close()
}
if !hasLastSeen {
if _, err := db.Exec(`ALTER TABLE raw_jobs ADD COLUMN last_seen_at DATETIME`); err != nil {
logger.Log.Warnw("Failed to add last_seen_at column", "component", "DB", "error", err)
} else {
_, _ = db.Exec(`UPDATE raw_jobs SET last_seen_at = crawled_at WHERE last_seen_at IS NULL`)
_, _ = db.Exec(`UPDATE raw_jobs SET last_seen_at = CURRENT_TIMESTAMP WHERE last_seen_at IS NULL`)
}
}
// Dynamic migration for job_analyses: check if filter_score and summary exist
var hasFilterScore, hasSummary bool
rowsA, err := db.Query(`PRAGMA table_info(job_analyses)`)
if err == nil {
for rowsA.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dfltValue sql.NullString
if err := rowsA.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err == nil {
if name == "filter_score" {
hasFilterScore = true
}
if name == "summary" {
hasSummary = true
}
}
}
_ = rowsA.Close()
}
if !hasFilterScore {
if _, err := db.Exec(`ALTER TABLE job_analyses ADD COLUMN filter_score REAL DEFAULT 0.0`); err != nil {
logger.Log.Warnw("Failed to add filter_score column", "component", "DB", "error", err)
} else {
_, _ = db.Exec(`UPDATE job_analyses SET filter_score = score WHERE filter_score IS NULL OR filter_score = 0.0`)
}
}
if !hasSummary {
if _, err := db.Exec(`ALTER TABLE job_analyses ADD COLUMN summary TEXT`); err != nil {
logger.Log.Warnw("Failed to add summary column", "component", "DB", "error", err)
}
}
return nil
}
+4 -1
View File
@@ -23,6 +23,7 @@ type RawJob struct {
ApplyURL string `json:"apply_url"`
RawContent string `json:"raw_content"`
CrawledAt time.Time `json:"crawled_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type Task struct {
@@ -40,12 +41,14 @@ type Task struct {
type JobAnalysis struct {
ID int64 `json:"id"`
RawJobID int64 `json:"raw_job_id"`
FilterScore float64 `json:"filter_score"`
Score float64 `json:"score"`
IsQualified bool `json:"is_qualified"`
Summary string `json:"summary"`
MatchedHighlights string `json:"matched_highlights"` // JSON array string
MissingHighlights string `json:"missing_highlights"` // JSON array string
TailoringSuggestions string `json:"tailoring_suggestions"` // JSON array string
Status string `json:"status"` // 'PENDING', 'PARSED', 'FILTERED_OUT', 'FAILED'
Status string `json:"status"` // 'PENDING', 'PARSED', 'FILTERED_OUT', 'REJECTED', 'FAILED'
AnalyzedAt *time.Time `json:"analyzed_at"`
}
+271 -49
View File
@@ -2,6 +2,7 @@ package db
import (
"database/sql"
"fmt"
"math"
"regexp"
"strings"
@@ -9,11 +10,11 @@ import (
)
// Target Queries
func (db *DB) CreateTarget(name, targetURL, platform string) (*SearchTarget, error) {
query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, 1) RETURNING id, name, target_url, platform, is_active, last_crawled_at`
func (db *DB) CreateTarget(name, targetURL, platform string, isActive bool) (*SearchTarget, error) {
query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, ?) RETURNING id, name, target_url, platform, is_active, last_crawled_at`
var target SearchTarget
var lastCrawled sql.NullTime
err := db.QueryRow(query, name, targetURL, platform).Scan(
err := db.QueryRow(query, name, targetURL, platform, isActive).Scan(
&target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled,
)
if err != nil {
@@ -77,12 +78,98 @@ func (db *DB) ToggleTargetStatus(id int64) error {
return err
}
func (db *DB) SetTargetActive(id int64, isActive bool) error {
query := `UPDATE search_targets SET is_active = ? WHERE id = ?`
_, err := db.Exec(query, isActive, id)
return err
}
func (db *DB) UpdateTargetCrawledAt(id int64) error {
query := `UPDATE search_targets SET last_crawled_at = CURRENT_TIMESTAMP WHERE id = ?`
_, err := db.Exec(query, id)
return err
}
func (db *DB) DeleteTarget(id int64) (*SearchTarget, int64, error) {
tx, err := db.Begin()
if err != nil {
return nil, 0, err
}
defer func() { _ = tx.Rollback() }()
// 1. Get target info before deleting
var target SearchTarget
var lastCrawled sql.NullTime
err = tx.QueryRow(`SELECT id, name, target_url, platform, is_active, last_crawled_at FROM search_targets WHERE id = ?`, id).Scan(
&target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled,
)
if err != nil {
return nil, 0, err
}
if lastCrawled.Valid {
target.LastCrawledAt = &lastCrawled.Time
}
// 2. Find all raw_jobs IDs associated with this target
rows, err := tx.Query(`SELECT id, canonical_id FROM raw_jobs WHERE target_id = ?`, id)
if err != nil {
return nil, 0, err
}
var rawJobIDs []int64
var canonicalIDs []string
for rows.Next() {
var rID int64
var cID string
if err := rows.Scan(&rID, &cID); err != nil {
_ = rows.Close()
return nil, 0, err
}
rawJobIDs = append(rawJobIDs, rID)
canonicalIDs = append(canonicalIDs, cID)
}
_ = rows.Close()
// 3. Delete tasks referencing this target or its discovered jobs
targetIDPattern := fmt.Sprintf(`%%"target_id":%d%%`, id)
targetIDPatternStr := fmt.Sprintf(`%%"target_id": %d%%`, id)
_, _ = tx.Exec(`DELETE FROM tasks WHERE payload LIKE ? OR payload LIKE ?`, targetIDPattern, targetIDPatternStr)
for _, cid := range canonicalIDs {
cPattern := fmt.Sprintf(`%%"canonical_id":"%s"%%`, cid)
_, _ = tx.Exec(`DELETE FROM tasks WHERE payload LIKE ?`, cPattern)
}
for _, rID := range rawJobIDs {
rPattern := fmt.Sprintf(`%%"raw_job_id":%d%%`, rID)
rPatternStr := fmt.Sprintf(`%%"raw_job_id": %d%%`, rID)
_, _ = tx.Exec(`DELETE FROM tasks WHERE payload LIKE ? OR payload LIKE ?`, rPattern, rPatternStr)
}
// 4. Delete job_applications and job_analyses for these raw_jobs
for _, rID := range rawJobIDs {
_, _ = tx.Exec(`DELETE FROM job_applications WHERE raw_job_id = ?`, rID)
_, _ = tx.Exec(`DELETE FROM job_analyses WHERE raw_job_id = ?`, rID)
}
// 5. Delete raw_jobs for this target
res, err := tx.Exec(`DELETE FROM raw_jobs WHERE target_id = ?`, id)
if err != nil {
return nil, 0, err
}
deletedJobsCount, _ := res.RowsAffected()
// 6. Delete search_target
_, err = tx.Exec(`DELETE FROM search_targets WHERE id = ?`, id)
if err != nil {
return nil, 0, err
}
if err := tx.Commit(); err != nil {
return nil, 0, err
}
return &target, deletedJobsCount, nil
}
// Raw Jobs Queries & Vector Similarity Deduplication
func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company, location, applyURL, rawContent string) (*RawJob, bool, error) {
// Deduplication Check 1 & 2: Check canonical_id exist AND vector similarity >= 99%
@@ -104,15 +191,15 @@ func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company,
}
query := `
INSERT INTO raw_jobs (canonical_id, target_id, title, company, location, apply_url, raw_content)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO raw_jobs (canonical_id, target_id, title, company, location, apply_url, raw_content, crawled_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(canonical_id) DO NOTHING
RETURNING id, canonical_id, target_id, title, company, location, apply_url, raw_content, crawled_at
RETURNING id, canonical_id, target_id, title, company, location, apply_url, raw_content, crawled_at, last_seen_at
`
var job RawJob
var tID sql.NullInt64
err = db.QueryRow(query, canonicalID, targetID, title, company, location, applyURL, rawContent).Scan(
&job.ID, &job.CanonicalID, &tID, &job.Title, &job.Company, &job.Location, &job.ApplyURL, &job.RawContent, &job.CrawledAt,
&job.ID, &job.CanonicalID, &tID, &job.Title, &job.Company, &job.Location, &job.ApplyURL, &job.RawContent, &job.CrawledAt, &job.LastSeenAt,
)
if err == sql.ErrNoRows {
return nil, false, nil // Duplicate job
@@ -126,6 +213,125 @@ func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company,
return &job, true, nil
}
func (db *DB) TouchJobLastSeen(canonicalID string) error {
query := `UPDATE raw_jobs SET last_seen_at = CURRENT_TIMESTAMP WHERE canonical_id = ?`
_, err := db.Exec(query, canonicalID)
return err
}
func (db *DB) TouchJobLastSeenByID(rawJobID int64) error {
query := `UPDATE raw_jobs SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`
_, err := db.Exec(query, rawJobID)
return err
}
func (db *DB) ExpireJob(rawJobID int64, reason string) error {
if reason == "" {
reason = "Marked expired"
}
query := `
INSERT INTO job_applications (raw_job_id, status, notes, updated_at)
VALUES (?, 'EXPIRED', ?, CURRENT_TIMESTAMP)
ON CONFLICT(raw_job_id) DO UPDATE SET
status='EXPIRED',
notes=COALESCE(job_applications.notes || ' | ', '') || excluded.notes,
updated_at=CURRENT_TIMESTAMP
`
_, err := db.Exec(query, rawJobID, reason)
return err
}
func (db *DB) EnqueueExpireTasks(days int, preserveActivePipeline bool) (int, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
var query string
if preserveActivePipeline {
query = `
SELECT r.id FROM raw_jobs r
LEFT JOIN job_applications app ON r.id = app.raw_job_id
WHERE r.last_seen_at < datetime('now', '-' || ? || ' days')
AND (app.status IS NULL OR app.status NOT IN ('APPLIED', 'INTERVIEW', 'OFFER', 'EXPIRED', 'OPTED_OUT'))
`
} else {
query = `
SELECT r.id FROM raw_jobs r
LEFT JOIN job_applications app ON r.id = app.raw_job_id
WHERE r.last_seen_at < datetime('now', '-' || ? || ' days')
AND (app.status IS NULL OR app.status NOT IN ('EXPIRED', 'OPTED_OUT'))
`
}
rows, err := tx.Query(query, days)
if err != nil {
return 0, err
}
var rawJobIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return 0, err
}
rawJobIDs = append(rawJobIDs, id)
}
_ = rows.Close()
for _, id := range rawJobIDs {
payloadJSON := fmt.Sprintf(`{"raw_job_id":%d,"reason":"Direct expire: not seen on target for %d+ days"}`, id, days)
if _, err := tx.Exec(`INSERT INTO tasks (task_type, payload, status) VALUES ('EXPIRE_JOB', ?, 'PENDING')`, payloadJSON); err != nil {
return 0, err
}
}
return len(rawJobIDs), tx.Commit()
}
func (db *DB) GetJobMaintenanceStats() (map[string]int, error) {
stats := make(map[string]int)
// Total raw jobs
var totalJobs int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs`).Scan(&totalJobs)
stats["total_jobs"] = totalJobs
// Active <= 7 days
var active7d int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs WHERE last_seen_at >= datetime('now', '-7 days')`).Scan(&active7d)
stats["active_7d"] = active7d
// Stale > 7 days
var stale7d int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs WHERE last_seen_at < datetime('now', '-7 days')`).Scan(&stale7d)
stats["stale_7d"] = stale7d
// Stale > 30 days
var stale30d int
_ = db.QueryRow(`SELECT COUNT(*) FROM raw_jobs WHERE last_seen_at < datetime('now', '-30 days')`).Scan(&stale30d)
stats["stale_30d"] = stale30d
// Expired count
var expiredCount int
_ = db.QueryRow(`SELECT COUNT(*) FROM job_applications WHERE status = 'EXPIRED'`).Scan(&expiredCount)
stats["expired_count"] = expiredCount
// Opted out count
var optedOutCount int
_ = db.QueryRow(`SELECT COUNT(*) FROM job_applications WHERE status = 'OPTED_OUT'`).Scan(&optedOutCount)
stats["opted_out_count"] = optedOutCount
// In pipeline count
var inPipeline int
_ = db.QueryRow(`SELECT COUNT(*) FROM job_applications WHERE status IN ('SAVED', 'APPLIED', 'INTERVIEW', 'OFFER')`).Scan(&inPipeline)
stats["in_pipeline"] = inPipeline
return stats, nil
}
func (db *DB) CanonicalIDExists(canonicalID string) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM raw_jobs WHERE canonical_id = ?)`
var exists bool
@@ -207,52 +413,60 @@ func tokenizeToVector(text string) map[string]int {
return freqMap
}
// Job Analyses Queries
func (db *DB) CreateJobAnalysis(rawJobID int64, score float64, isQualified bool, status string) error {
func (db *DB) CreateJobAnalysis(rawJobID int64, filterScore, score float64, isQualified bool, status string) error {
query := `
INSERT INTO job_analyses (raw_job_id, score, is_qualified, status)
VALUES (?, ?, ?, ?)
ON CONFLICT(raw_job_id) DO UPDATE SET score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status
INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, status)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(raw_job_id) DO UPDATE SET filter_score=excluded.filter_score, score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status
`
_, err := db.Exec(query, rawJobID, score, isQualified, status)
_, err := db.Exec(query, rawJobID, filterScore, score, isQualified, status)
return err
}
func (db *DB) CreateJobAnalysisFull(rawJobID int64, score float64, isQualified bool, matched, missing, tailoring, status string) error {
func (db *DB) CreateJobAnalysisFull(rawJobID int64, filterScore, score float64, isQualified bool, summary, matched, missing, tailoring, status string) error {
query := `
INSERT INTO job_analyses (raw_job_id, score, is_qualified, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, summary, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(raw_job_id) DO UPDATE SET
filter_score=excluded.filter_score,
score=excluded.score,
is_qualified=excluded.is_qualified,
summary=excluded.summary,
matched_highlights=excluded.matched_highlights,
missing_highlights=excluded.missing_highlights,
tailoring_suggestions=excluded.tailoring_suggestions,
status=excluded.status,
analyzed_at=CURRENT_TIMESTAMP
`
_, err := db.Exec(query, rawJobID, score, isQualified, matched, missing, tailoring, status)
_, err := db.Exec(query, rawJobID, filterScore, score, isQualified, summary, matched, missing, tailoring, status)
return err
}
func (db *DB) UpdateJobAnalysisFull(rawJobID int64, score float64, isQualified bool, matchedJSON, missingJSON, suggestionsJSON, status string) error {
func (db *DB) UpdateJobAnalysisFull(rawJobID int64, filterScore, score float64, isQualified bool, summary, matchedJSON, missingJSON, suggestionsJSON, status string) error {
query := `
UPDATE job_analyses
SET score = ?, is_qualified = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = ?, analyzed_at = CURRENT_TIMESTAMP
SET filter_score = ?, score = ?, is_qualified = ?, summary = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = ?, analyzed_at = CURRENT_TIMESTAMP
WHERE raw_job_id = ?
`
_, err := db.Exec(query, score, isQualified, matchedJSON, missingJSON, suggestionsJSON, status, rawJobID)
_, err := db.Exec(query, filterScore, score, isQualified, summary, matchedJSON, missingJSON, suggestionsJSON, status, rawJobID)
return err
}
func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, matchedJSON, missingJSON, suggestionsJSON string) error {
func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, aiScore float64, isQualified bool, summary, matchedJSON, missingJSON, suggestionsJSON string) error {
query := `
UPDATE job_analyses
SET matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP
SET score = ?, is_qualified = ?, summary = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP
WHERE raw_job_id = ?
`
_, err := db.Exec(query, matchedJSON, missingJSON, suggestionsJSON, rawJobID)
_, err := db.Exec(query, aiScore, isQualified, summary, matchedJSON, missingJSON, suggestionsJSON, rawJobID)
return err
}
func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at
FROM raw_jobs r
JOIN job_analyses a ON r.id = a.raw_job_id
WHERE a.is_qualified = 1 AND a.status = 'PENDING'
@@ -268,14 +482,14 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
var item JobWithAnalysis
var tID sql.NullInt64
var aID int64
var score float64
var filterScore, score float64
var isQualified bool
var matched, missing, suggestions, aStatus string
var summary, matched, missing, suggestions, aStatus string
var analyzedAt sql.NullTime
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
)
if err != nil {
return nil, err
@@ -286,8 +500,10 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
analysis := &JobAnalysis{
ID: aID,
RawJobID: item.ID,
FilterScore: filterScore,
Score: score,
IsQualified: isQualified,
Summary: summary,
MatchedHighlights: matched,
MissingHighlights: missing,
TailoringSuggestions: suggestions,
@@ -304,13 +520,13 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
LEFT JOIN job_applications app ON r.id = app.raw_job_id
ORDER BY COALESCE(a.score, 0) DESC, r.crawled_at DESC
ORDER BY COALESCE(a.score, a.filter_score, 0) DESC, r.crawled_at DESC
`
rows, err := db.Query(query)
if err != nil {
@@ -323,17 +539,17 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
var item JobWithAnalysis
var tID sql.NullInt64
var aID, appID sql.NullInt64
var score sql.NullFloat64
var filterScore, score sql.NullFloat64
var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString
var summary, matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime
var appStatus, notes sql.NullString
var appliedAt sql.NullTime
var updatedAt sql.NullTime
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
if err != nil {
@@ -346,8 +562,10 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
analysis := &JobAnalysis{
ID: aID.Int64,
RawJobID: item.ID,
FilterScore: filterScore.Float64,
Score: score.Float64,
IsQualified: isQualified.Bool,
Summary: summary.String,
MatchedHighlights: matched.String,
MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String,
@@ -378,8 +596,8 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
@@ -390,17 +608,17 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
var item JobWithAnalysis
var tID sql.NullInt64
var aID, appID sql.NullInt64
var score sql.NullFloat64
var filterScore, score sql.NullFloat64
var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString
var summary, matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime
var appStatus, notes sql.NullString
var appliedAt sql.NullTime
var updatedAt sql.NullTime
err := row.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
if err != nil {
@@ -413,8 +631,10 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
analysis := &JobAnalysis{
ID: aID.Int64,
RawJobID: item.ID,
FilterScore: filterScore.Float64,
Score: score.Float64,
IsQualified: isQualified.Bool,
Summary: summary.String,
MatchedHighlights: matched.String,
MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String,
@@ -475,8 +695,8 @@ func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (*
func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM job_applications app
JOIN raw_jobs r ON app.raw_job_id = r.id
@@ -494,17 +714,17 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
var item JobWithAnalysis
var tID sql.NullInt64
var aID, appID sql.NullInt64
var score sql.NullFloat64
var filterScore, score sql.NullFloat64
var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString
var summary, matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime
var appStatus, notes sql.NullString
var appliedAt sql.NullTime
var updatedAt time.Time
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
if err != nil {
@@ -517,8 +737,10 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
analysis := &JobAnalysis{
ID: aID.Int64,
RawJobID: item.ID,
FilterScore: filterScore.Float64,
Score: score.Float64,
IsQualified: isQualified.Bool,
Summary: summary.String,
MatchedHighlights: matched.String,
MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String,
+244
View File
@@ -1,7 +1,9 @@
package db
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -53,3 +55,245 @@ func TestVectorSimilarityDeduplication(t *testing.T) {
t.Errorf("first job mismatch")
}
}
func TestDeleteTargetCascade(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_delete_target.db")
database, err := InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
// 1. Create two search targets
target1, err := database.CreateTarget("Target 1", "https://example.com/target1", "google_jobs", true)
if err != nil {
t.Fatalf("failed to create target 1: %v", err)
}
target2, err := database.CreateTarget("Target 2", "https://example.com/target2", "apple", true)
if err != nil {
t.Fatalf("failed to create target 2: %v", err)
}
// 2. Insert raw jobs for target 1 and target 2
job1, _, err := database.InsertRawJob("canon-t1-j1", &target1.ID, "Job 1", "Google", "Seattle", "https://google.com/1", "Software Engineer Go")
if err != nil {
t.Fatalf("failed to insert job 1: %v", err)
}
job2, _, err := database.InsertRawJob("canon-t1-j2", &target1.ID, "Job 2", "Google", "Seattle", "https://google.com/2", "Software Engineer Python")
if err != nil {
t.Fatalf("failed to insert job 2: %v", err)
}
job3, _, err := database.InsertRawJob("canon-t2-j1", &target2.ID, "Job 3", "Apple", "Seattle", "https://apple.com/1", "Software Engineer iOS")
if err != nil {
t.Fatalf("failed to insert job 3: %v", err)
}
// 3. Create analyses and applications for target 1 jobs and target 2 jobs
_ = database.CreateJobAnalysis(job1.ID, 85.0, 85.0, true, "DONE")
_ = database.CreateJobAnalysis(job2.ID, 40.0, 40.0, false, "DONE")
_ = database.CreateJobAnalysis(job3.ID, 90.0, 90.0, true, "DONE")
_, _ = database.CreateOrUpdateApplication(job1.ID, "APPLIED", "Sent resume")
_, _ = database.CreateOrUpdateApplication(job3.ID, "SAVED", "Review later")
// 4. Enqueue tasks
_ = database.EnqueueTask("DISCOVER", map[string]interface{}{"target_id": target1.ID, "target_url": target1.TargetURL})
_ = database.EnqueueTask("DISCOVER", map[string]interface{}{"target_id": target2.ID, "target_url": target2.TargetURL})
_ = database.EnqueueTask("PROCESS", map[string]interface{}{"raw_job_id": job1.ID})
_ = database.EnqueueTask("PROCESS", map[string]interface{}{"raw_job_id": job3.ID})
// 5. Delete target 1
deletedTarget, deletedJobsCount, err := database.DeleteTarget(target1.ID)
if err != nil {
t.Fatalf("DeleteTarget failed: %v", err)
}
if deletedTarget.ID != target1.ID {
t.Errorf("expected deleted target ID %d, got %d", target1.ID, deletedTarget.ID)
}
if deletedJobsCount != 2 {
t.Errorf("expected 2 deleted jobs, got %d", deletedJobsCount)
}
// 6. Verify Target 1 is gone, Target 2 remains
allTargets, err := database.GetAllTargets()
if err != nil {
t.Fatalf("GetAllTargets failed: %v", err)
}
if len(allTargets) != 1 || allTargets[0].ID != target2.ID {
t.Errorf("expected 1 remaining target (target 2), got %v", allTargets)
}
// 7. Verify Target 1's jobs are gone, Target 2's job remains
jobsWithAnalysis, err := database.GetJobsWithAnalysis()
if err != nil {
t.Fatalf("GetJobsWithAnalysis failed: %v", err)
}
if len(jobsWithAnalysis) != 1 || jobsWithAnalysis[0].ID != job3.ID {
t.Errorf("expected only job 3 to remain, got %v", jobsWithAnalysis)
}
// 8. Verify applications for target 1 are gone, target 2 application remains
apps, err := database.GetApplications()
if err != nil {
t.Fatalf("GetApplications failed: %v", err)
}
if len(apps) != 1 || apps[0].ID != job3.ID {
t.Errorf("expected only job 3 application to remain, got %v", apps)
}
}
func TestRemoveTargetFromYAML(t *testing.T) {
tempDir := t.TempDir()
yamlPath := filepath.Join(tempDir, "targets.yaml")
initialYAML := `targets:
- name: "Google Seattle"
target_url: "https://google.com/jobs"
platform: "google_jobs"
is_active: true
- name: "Apple Seattle"
target_url: "https://apple.com/jobs"
platform: "apple"
is_active: true
`
if err := os.WriteFile(yamlPath, []byte(initialYAML), 0644); err != nil {
t.Fatalf("failed to write initial yaml: %v", err)
}
// Remove Google Seattle
if err := RemoveTargetFromYAML(yamlPath, "https://google.com/jobs"); err != nil {
t.Fatalf("RemoveTargetFromYAML failed: %v", err)
}
// Read back and verify
data, err := os.ReadFile(yamlPath)
if err != nil {
t.Fatalf("failed to read yaml back: %v", err)
}
content := string(data)
if strings.Contains(content, "https://google.com/jobs") {
t.Errorf("expected google target to be removed, but found in file:\n%s", content)
}
if !strings.Contains(content, "https://apple.com/jobs") {
t.Errorf("expected apple target to remain, but not found in file:\n%s", content)
}
}
func TestTouchJobLastSeenAndMaintenance(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_maintenance.db")
database, err := InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
// Insert jobs
job1, _, err := database.InsertRawJob("canon-1", nil, "Job 1", "Google", "Seattle", "https://google.com/1", "Software Engineer Go")
if err != nil || job1.ID == 0 {
t.Fatalf("failed to insert job 1: %v", err)
}
job2, _, err := database.InsertRawJob("canon-2", nil, "Job 2", "Apple", "Seattle", "https://apple.com/2", "Software Engineer Swift")
if err != nil {
t.Fatalf("failed to insert job 2: %v", err)
}
job3, _, err := database.InsertRawJob("canon-3", nil, "Job 3", "Meta", "Seattle", "https://meta.com/3", "Software Engineer React")
if err != nil {
t.Fatalf("failed to insert job 3: %v", err)
}
// 1. Touch last seen for canon-1
if err := database.TouchJobLastSeen("canon-1"); err != nil {
t.Fatalf("TouchJobLastSeen failed: %v", err)
}
// Make job2 and job3 artificially stale (20 days and 40 days old)
_, err = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-20 days') WHERE id = ?`, job2.ID)
if err != nil {
t.Fatalf("failed to update last_seen_at: %v", err)
}
_, err = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-40 days') WHERE id = ?`, job3.ID)
if err != nil {
t.Fatalf("failed to update last_seen_at: %v", err)
}
// Protect job3 in active pipeline (APPLIED)
_, _ = database.CreateOrUpdateApplication(job3.ID, "APPLIED", "Applied to Meta")
// 2. Enqueue expire tasks (> 14 days) with pipeline protection
queuedExpireCount, err := database.EnqueueExpireTasks(14, true)
if err != nil {
t.Fatalf("EnqueueExpireTasks failed: %v", err)
}
// job2 is stale and unprotected -> should be queued
// job3 is stale but protected (APPLIED) -> should NOT be queued
// job1 is fresh -> should NOT be queued
if queuedExpireCount != 1 {
t.Errorf("expected 1 job queued for expire, got %d", queuedExpireCount)
}
// 3. Directly expire job2
if err := database.ExpireJob(job2.ID, "Test expiration"); err != nil {
t.Fatalf("ExpireJob failed: %v", err)
}
// Verify job2 status in DB
j2Detail, err := database.GetJobByID(job2.ID)
if err != nil {
t.Fatalf("GetJobByID failed: %v", err)
}
if j2Detail.App == nil || j2Detail.App.Status != "EXPIRED" {
t.Errorf("expected job2 to have EXPIRED application, got %v", j2Detail.App)
}
// Verify job3 still APPLIED
j3Detail, err := database.GetJobByID(job3.ID)
if err != nil {
t.Fatalf("GetJobByID failed: %v", err)
}
if j3Detail.App == nil || j3Detail.App.Status != "APPLIED" {
t.Errorf("expected job3 to remain APPLIED, got %v", j3Detail.App)
}
// 4. Stats check
stats, err := database.GetJobMaintenanceStats()
if err != nil {
t.Fatalf("GetJobMaintenanceStats failed: %v", err)
}
if stats["total_jobs"] != 3 {
t.Errorf("expected total_jobs=3, got %d", stats["total_jobs"])
}
if stats["active_7d"] != 1 {
t.Errorf("expected active_7d=1, got %d", stats["active_7d"])
}
if stats["stale_7d"] != 2 {
t.Errorf("expected stale_7d=2, got %d", stats["stale_7d"])
}
if stats["expired_count"] != 1 {
t.Errorf("expected expired_count=1, got %d", stats["expired_count"])
}
}
func TestDevDBMigration(t *testing.T) {
devDBPath := "../../data/db/dev/hireme.db"
if _, err := os.Stat(devDBPath); os.IsNotExist(err) {
return
}
database, err := InitDB(devDBPath)
if err != nil {
t.Fatalf("InitDB dev db failed: %v", err)
}
defer func() { _ = database.Close() }()
jobs, err := database.GetJobsWithAnalysis()
if err != nil {
t.Fatalf("GetJobsWithAnalysis on dev db failed: %v", err)
}
t.Logf("Successfully verified %d jobs from dev db", len(jobs))
}
+57 -7
View File
@@ -16,7 +16,7 @@ type targetConfig struct {
} `yaml:"targets"`
}
// LoadTargetsFromYAML reads a YAML file and inserts targets into the database if it's empty
// LoadTargetsFromYAML reads a YAML file and inserts/syncs targets with the database
func (db *DB) LoadTargetsFromYAML(filepath string) error {
data, err := os.ReadFile(filepath)
if err != nil {
@@ -28,21 +28,71 @@ func (db *DB) LoadTargetsFromYAML(filepath string) error {
return fmt.Errorf("could not parse target config: %w", err)
}
// Check if we already have targets
targets, err := db.GetAllTargets()
// Get existing targets to prevent duplicates and sync is_active if needed
existingTargets, err := db.GetAllTargets()
if err != nil {
return err
}
if len(targets) > 0 {
// Do not overwrite existing targets
return nil
existingURLs := make(map[string]*SearchTarget)
for i := range existingTargets {
existingURLs[existingTargets[i].TargetURL] = &existingTargets[i]
}
for _, t := range config.Targets {
_, err := db.CreateTarget(t.Name, t.TargetURL, t.Platform)
existing, found := existingURLs[t.TargetURL]
if !found {
_, err := db.CreateTarget(t.Name, t.TargetURL, t.Platform, t.IsActive)
if err != nil {
return fmt.Errorf("failed to insert target %s: %w", t.Name, err)
}
} else {
// Sync is_active state with targets.yaml on startup
if existing.IsActive != t.IsActive {
_ = db.SetTargetActive(existing.ID, t.IsActive)
}
}
}
return nil
}
// RemoveTargetFromYAML removes a target from the target configuration YAML file if it exists
func RemoveTargetFromYAML(filepath string, targetURL string) error {
if filepath == "" {
return nil
}
data, err := os.ReadFile(filepath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("could not read target config: %w", err)
}
var config targetConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return fmt.Errorf("could not parse target config: %w", err)
}
var newTargets []struct {
Name string `yaml:"name"`
TargetURL string `yaml:"target_url"`
Platform string `yaml:"platform"`
IsActive bool `yaml:"is_active"`
}
for _, t := range config.Targets {
if t.TargetURL != targetURL {
newTargets = append(newTargets, t)
}
}
config.Targets = newTargets
outData, err := yaml.Marshal(&config)
if err != nil {
return fmt.Errorf("could not marshal target config: %w", err)
}
return os.WriteFile(filepath, outData, 0644)
}
+59
View File
@@ -0,0 +1,59 @@
package db
import (
"os"
"path/filepath"
"testing"
)
func TestLoadTargetsFromYAMLRespectsIsActive(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_targets_active.db")
yamlPath := filepath.Join(tempDir, "targets.yaml")
yamlContent := `targets:
- name: "Google Seattle"
target_url: "https://google.com/jobs"
platform: "google_jobs"
is_active: false
- name: "Apple Seattle"
target_url: "https://apple.com/jobs"
platform: "apple"
is_active: true
`
if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
database, err := InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
if err := database.LoadTargetsFromYAML(yamlPath); err != nil {
t.Fatalf("LoadTargetsFromYAML failed: %v", err)
}
targets, err := database.GetAllTargets()
if err != nil {
t.Fatalf("GetAllTargets failed: %v", err)
}
if len(targets) != 2 {
t.Fatalf("expected 2 targets, got %d", len(targets))
}
for _, target := range targets {
if target.TargetURL == "https://google.com/jobs" {
if target.IsActive != false {
t.Errorf("expected Google target to have is_active=false, got %v", target.IsActive)
}
}
if target.TargetURL == "https://apple.com/jobs" {
if target.IsActive != true {
t.Errorf("expected Apple target to have is_active=true, got %v", target.IsActive)
}
}
}
}
+3 -2
View File
@@ -4,7 +4,8 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
// QueueStats holds task queue counters
@@ -96,7 +97,7 @@ func (db *DB) FailTask(id int64, errStr string) error {
var retryCount int
err := db.QueryRow(`SELECT retry_count FROM tasks WHERE id = ?`, id).Scan(&retryCount)
if err != nil {
log.Printf("[DB] FailTask: failed to get retry_count for task %d: %v", id, err)
logger.Log.Errorw("FailTask: failed to get retry_count", "component", "DB", "task_id", id, "error", err)
return err
}
+1 -1
View File
@@ -3,7 +3,7 @@ package filter
import (
"context"
"github.com/hireme/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
)
type FilterResult struct {
+34 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/hireme/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
)
func TestHardKeywordFilter(t *testing.T) {
@@ -30,6 +30,39 @@ func TestHardKeywordFilter(t *testing.T) {
}
}
func TestScoringFilterWordBoundaries(t *testing.T) {
ctx := context.Background()
sf := NewScoringFilter([]KeywordWeight{
{Keyword: "go", Weight: 20.0},
{Keyword: "ai", Weight: 15.0},
{Keyword: "c++", Weight: 10.0},
})
// "algorithm" and "email" should NOT trigger "go" or "ai"
res1, err := sf.Process(ctx, &db.RawJob{
Title: "Algorithm Developer",
RawContent: "Send email to apply for algorithm development.",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res1.Score != 0.0 {
t.Errorf("expected score 0 for false-positive substrings, got %f", res1.Score)
}
// Real "go", "ai", and "c++"
res2, err := sf.Process(ctx, &db.RawJob{
Title: "AI Runtime Engineer",
RawContent: "Must know Go and C++ programming.",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res2.Score != 45.0 {
t.Errorf("expected score 45.0 (20+15+10), got %f", res2.Score)
}
}
func TestFilterPipeline(t *testing.T) {
ctx := context.Background()
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
+30 -13
View File
@@ -3,25 +3,43 @@ package filter
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/hireme/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
)
type compiledBlacklist struct {
keyword string
regex *regexp.Regexp
}
type HardKeywordFilter struct {
Blacklist []string
compiled []compiledBlacklist
}
func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter {
if len(blacklist) == 0 {
blacklist = []string{
"no sponsorship",
"us citizen only",
"unpaid",
"volunteer",
compiled := make([]compiledBlacklist, 0, len(blacklist))
for _, kw := range blacklist {
term := strings.TrimSpace(kw)
if term != "" {
quoted := regexp.QuoteMeta(term)
quoted = regexp.MustCompile(`\\\s+`).ReplaceAllString(quoted, `\s+`)
pattern := `(?i)(?:^|[^a-zA-Z0-9_])` + quoted + `(?:[^a-zA-Z0-9_]|$)`
if re, err := regexp.Compile(pattern); err == nil {
compiled = append(compiled, compiledBlacklist{
keyword: kw,
regex: re,
})
}
}
return &HardKeywordFilter{Blacklist: blacklist}
}
return &HardKeywordFilter{
Blacklist: blacklist,
compiled: compiled,
}
}
func (f *HardKeywordFilter) Name() string {
@@ -29,15 +47,14 @@ func (f *HardKeywordFilter) Name() string {
}
func (f *HardKeywordFilter) Process(ctx context.Context, job *db.RawJob) (FilterResult, error) {
combined := strings.ToLower(job.Title + " " + job.RawContent)
combined := job.Title + " " + job.RawContent
for _, kw := range f.Blacklist {
term := strings.ToLower(strings.TrimSpace(kw))
if term != "" && strings.Contains(combined, term) {
for _, cb := range f.compiled {
if cb.regex.MatchString(combined) {
return FilterResult{
Keep: false,
Score: 0,
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", kw),
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", cb.keyword),
}, nil
}
}
+54 -23
View File
@@ -3,37 +3,62 @@ package filter
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/hireme/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
)
type KeywordWeight struct {
Keyword string
Weight float64
Keyword string `yaml:"keyword"`
Weight float64 `yaml:"weight"`
}
type compiledWeight struct {
keyword string
weight float64
regex *regexp.Regexp
}
type ScoringFilter struct {
Weights []KeywordWeight
compiled []compiledWeight
}
func compilePattern(term string) *regexp.Regexp {
clean := strings.TrimSpace(term)
if clean == "" {
return nil
}
// Quote meta characters, replace multiple whitespace with \s+
quoted := regexp.QuoteMeta(clean)
quoted = regexp.MustCompile(`\\\s+`).ReplaceAllString(quoted, `\s+`)
// Boundary regex that works for both alphanumeric and punctuation (e.g. c++)
pattern := `(?i)(?:^|[^a-zA-Z0-9_])` + quoted + `(?:[^a-zA-Z0-9_]|$)`
re, err := regexp.Compile(pattern)
if err != nil {
return nil
}
return re
}
func NewScoringFilter(weights []KeywordWeight) *ScoringFilter {
if len(weights) == 0 {
weights = []KeywordWeight{
{Keyword: "go", Weight: 25.0},
{Keyword: "golang", Weight: 25.0},
{Keyword: "backend", Weight: 15.0},
{Keyword: "distributed systems", Weight: 20.0},
{Keyword: "microservices", Weight: 15.0},
{Keyword: "sqlite", Weight: 10.0},
{Keyword: "postgres", Weight: 10.0},
{Keyword: "api", Weight: 10.0},
{Keyword: "full stack", Weight: 10.0},
{Keyword: "rest", Weight: 5.0},
{Keyword: "grpc", Weight: 15.0},
compiled := make([]compiledWeight, 0, len(weights))
for _, kw := range weights {
re := compilePattern(kw.Keyword)
if re != nil {
compiled = append(compiled, compiledWeight{
keyword: kw.Keyword,
weight: kw.Weight,
regex: re,
})
}
}
return &ScoringFilter{Weights: weights}
return &ScoringFilter{
Weights: weights,
compiled: compiled,
}
}
func (s *ScoringFilter) Name() string {
@@ -41,18 +66,24 @@ func (s *ScoringFilter) Name() string {
}
func (f *ScoringFilter) Process(ctx context.Context, job *db.RawJob) (FilterResult, error) {
combinedLower := strings.ToLower(job.Title + " " + job.RawContent)
combined := job.Title + " " + job.RawContent
var totalScore float64
var matched []string
for _, kw := range f.Weights {
term := strings.ToLower(strings.TrimSpace(kw.Keyword))
if term != "" && strings.Contains(combinedLower, term) {
totalScore += kw.Weight
matched = append(matched, kw.Keyword)
for _, cw := range f.compiled {
if cw.regex.MatchString(combined) {
totalScore += cw.weight
matched = append(matched, cw.keyword)
}
}
if totalScore < 0 {
totalScore = 0
}
if totalScore > 100 {
totalScore = 100
}
return FilterResult{
Keep: true,
Score: totalScore,
+123 -18
View File
@@ -2,13 +2,14 @@ package ui
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/hireme/hireme/internal/config"
"github.com/hireme/hireme/internal/db"
"github.com/hireme/hireme/internal/logger"
"github.com/hireme/hireme/internal/worker"
"git.pengzhan.dev/haopengzhan/hireme/internal/config"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
"git.pengzhan.dev/haopengzhan/hireme/internal/worker"
)
type Server struct {
@@ -30,6 +31,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /targets", s.handleGetTargets)
mux.HandleFunc("POST /targets", s.handleCreateTarget)
mux.HandleFunc("POST /targets/{id}/toggle", s.handleToggleTarget)
mux.HandleFunc("POST /targets/{id}/delete", s.handleDeleteTarget)
mux.HandleFunc("DELETE /targets/{id}", s.handleDeleteTarget)
mux.HandleFunc("GET /jobs", s.handleGetJobs)
mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail)
mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob)
@@ -42,6 +45,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs)
mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks)
mux.HandleFunc("GET /api/status", s.handleGetStatus)
mux.HandleFunc("POST /api/maintenance/stale-expire", s.handleMaintenanceStaleExpire)
mux.HandleFunc("GET /api/maintenance/stats", s.handleMaintenanceStats)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -68,27 +73,39 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
return
}
qualifiedCount := 0
toReviewCount := 0
inPipelineCount := 0
for _, j := range jobs {
if j.Analysis != nil && j.Analysis.IsQualified {
qualifiedCount++
if j.App != nil && j.App.Status != "OPTED_OUT" {
inPipelineCount++
} else if j.App == nil || j.App.Status != "OPTED_OUT" {
if j.Analysis != nil && (j.Analysis.IsQualified || j.Analysis.Status == "PENDING") {
toReviewCount++
}
}
}
mStats, err := s.db.GetJobMaintenanceStats()
if err != nil {
logger.Log.Warnw("Failed to get maintenance stats", "component", "UI", "error", err)
}
data := PageData{
Targets: targets,
Jobs: jobs,
Applications: apps,
MaintenanceStats: mStats,
ActiveTab: "triage",
LLMProvider: s.cfg.LLMProvider,
QualifiedJobsCount: qualifiedCount,
ToReviewCount: toReviewCount,
TotalIngestedCount: len(jobs),
ActiveApplications: len(apps),
InPipelineCount: inPipelineCount,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := RenderPage(w, data); err != nil {
logger.Log.Errorf("[UI] RenderPage error: %v", err)
logger.Log.Errorw("RenderPage error", "component", "UI", "error", err)
}
}
@@ -118,7 +135,9 @@ func (s *Server) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
return
}
_, err := s.db.CreateTarget(name, targetURL, platform)
logger.Log.Infow("Triggered /targets (Create)", "component", "API", "name", name, "platform", platform)
_, err := s.db.CreateTarget(name, targetURL, platform, true)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -135,6 +154,8 @@ func (s *Server) handleToggleTarget(w http.ResponseWriter, r *http.Request) {
return
}
logger.Log.Infow("Triggered /targets/{id}/toggle", "component", "API", "target_id", id)
if err := s.db.ToggleTargetStatus(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -143,6 +164,35 @@ func (s *Server) handleToggleTarget(w http.ResponseWriter, r *http.Request) {
s.handleGetTargets(w, r)
}
func (s *Server) handleDeleteTarget(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid target id", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /targets/{id}/delete", "component", "API", "target_id", id)
deletedTarget, deletedJobsCount, err := s.db.DeleteTarget(id)
if err != nil {
logger.Log.Errorw("Failed to delete target", "component", "API", "target_id", id, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logger.Log.Infow("Target deleted successfully", "component", "API", "target_id", id, "name", deletedTarget.Name, "deleted_jobs", deletedJobsCount)
// Also remove from targets.yaml if target_config is configured
if s.cfg != nil && s.cfg.TargetConfig != "" && deletedTarget != nil {
if err := db.RemoveTargetFromYAML(s.cfg.TargetConfig, deletedTarget.TargetURL); err != nil {
logger.Log.Warnw("Could not remove target from targets.yaml", "component", "API", "error", err)
}
}
s.handleGetTargets(w, r)
}
func (s *Server) handleGetJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := s.db.GetJobsWithAnalysis()
if err != nil {
@@ -180,7 +230,20 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
return
}
// Enqueue analyze task instead of synchronous processing
logger.Log.Infow("Triggered /jobs/{id}/analyze", "component", "API", "job_id", id)
job, err := s.db.GetJobByID(id)
if err == nil && job != nil {
filterScore := 0.0
score := 0.0
if job.Analysis != nil {
filterScore = job.Analysis.FilterScore
score = job.Analysis.Score
}
_ = s.db.CreateJobAnalysis(id, filterScore, score, true, "PENDING")
}
// Enqueue analyze task
payload := worker.AnalyzePayload{RawJobID: int64(id)}
err = s.db.EnqueueTask("ANALYZE", payload)
if err != nil {
@@ -189,7 +252,7 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`<span class="text-xs px-2 py-1 bg-yellow-100 text-yellow-800 rounded">Analysis Queued</span>`))
_, _ = w.Write([]byte(`<span class="text-xs px-2.5 py-1 bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-lg font-semibold">🔄 AI Queued</span>`))
}
func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) {
@@ -200,6 +263,8 @@ func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) {
return
}
logger.Log.Infow("Triggered /jobs/{id}/opt-out", "component", "API", "job_id", id)
_, err = s.db.CreateOrUpdateApplication(id, "OPTED_OUT", "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -243,6 +308,8 @@ func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request)
return
}
logger.Log.Infow("Triggered /applications (Create)", "component", "API", "raw_job_id", rawJobID, "status", status)
_, err = s.db.CreateOrUpdateApplication(rawJobID, status, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -272,6 +339,8 @@ func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) {
return
}
logger.Log.Infow("Triggered /applications/{id}/status (Update)", "component", "API", "app_id", appID, "new_status", newStatus)
// Fetch rawJobID for appID
apps, err := s.db.GetApplications()
if err != nil {
@@ -304,7 +373,7 @@ func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) {
logger.Log.Info("[API] Triggered /api/tasks/discover")
logger.Log.Infow("Triggered /api/tasks/discover", "component", "API")
targets, err := s.db.GetAllTargets()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -331,7 +400,7 @@ func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCleanTasks(w http.ResponseWriter, r *http.Request) {
logger.Log.Info("[API] Triggered /api/tasks/clean")
logger.Log.Infow("Triggered /api/tasks/clean", "component", "API")
err := s.db.ClearTaskQueue()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -342,9 +411,9 @@ func (s *Server) handleCleanTasks(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleReprocessJobs(w http.ResponseWriter, r *http.Request) {
logger.Log.Info("[API] Triggered /api/tasks/reprocess")
logger.Log.Infow("Triggered /api/tasks/reprocess", "component", "API")
if err := s.db.ReprocessAllJobs(); err != nil {
logger.Log.Errorf("[API] Failed to reprocess jobs: %v", err)
logger.Log.Errorw("Failed to reprocess jobs", "component", "API", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -353,7 +422,7 @@ func (s *Server) handleReprocessJobs(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleToggleTasks(w http.ResponseWriter, r *http.Request) {
logger.Log.Info("[API] Triggered /api/tasks/toggle")
logger.Log.Infow("Triggered /api/tasks/toggle", "component", "API")
isRunning := s.pool.Toggle()
status := "paused"
if isRunning {
@@ -385,3 +454,39 @@ func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (s *Server) handleMaintenanceStaleExpire(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
days, _ := strconv.Atoi(r.FormValue("days"))
if days <= 0 {
days = 14
}
protect := r.FormValue("protect") != "false"
count, err := s.db.EnqueueExpireTasks(days, protect)
if err != nil {
logger.Log.Errorw("Failed to enqueue expire tasks", "component", "UI", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"count": count,
"message": fmt.Sprintf("Enqueued %d expire task(s) for postings not seen in %d+ days.", count, days),
})
}
func (s *Server) handleMaintenanceStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.db.GetJobMaintenanceStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats)
}
+134
View File
@@ -0,0 +1,134 @@
package ui
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/config"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
)
func TestHandleDeleteTarget(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_ui_delete_target.db")
yamlPath := filepath.Join(tempDir, "targets.yaml")
initialYAML := `targets:
- name: "Test Target"
target_url: "https://example.com/test-jobs"
platform: "google_jobs"
is_active: true
`
if err := osWriteFile(yamlPath, []byte(initialYAML)); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
database, err := db.InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
target, err := database.CreateTarget("Test Target", "https://example.com/test-jobs", "google_jobs", true)
if err != nil {
t.Fatalf("failed to create target: %v", err)
}
_, _, err = database.InsertRawJob("canon-ui-test", &target.ID, "Software Engineer", "Google", "Seattle", "https://example.com/job/1", "Go backend")
if err != nil {
t.Fatalf("failed to insert raw job: %v", err)
}
cfg := &config.Config{
TargetConfig: yamlPath,
}
server := NewServer(cfg, database, nil)
mux := http.NewServeMux()
server.RegisterRoutes(mux)
// Send POST /targets/{id}/delete
req := httptest.NewRequest("POST", "/targets/1/delete", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify target table in response does not contain the deleted target
body := w.Body.String()
if strings.Contains(body, "Test Target") {
t.Errorf("expected target list in response to not contain 'Test Target', got:\n%s", body)
}
// Verify DB is empty of targets and jobs
targets, err := database.GetAllTargets()
if err != nil || len(targets) != 0 {
t.Errorf("expected 0 targets in DB, got %v (err: %v)", targets, err)
}
jobs, err := database.GetJobsWithAnalysis()
if err != nil || len(jobs) != 0 {
t.Errorf("expected 0 jobs in DB, got %v (err: %v)", jobs, err)
}
}
func osWriteFile(path string, data []byte) error {
return os.WriteFile(path, data, 0644)
}
func TestMaintenanceHandlers(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_ui_maintenance.db")
database, err := db.InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
// Insert job
job, _, err := database.InsertRawJob("canon-m-1", nil, "Software Engineer", "Google", "Seattle", "https://example.com/job/1", "Go backend")
if err != nil {
t.Fatalf("failed to insert raw job: %v", err)
}
// Make stale (25 days old)
_, _ = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-25 days') WHERE id = ?`, job.ID)
cfg := &config.Config{}
server := NewServer(cfg, database, nil)
mux := http.NewServeMux()
server.RegisterRoutes(mux)
// 1. Test POST /api/maintenance/stale-expire
req := httptest.NewRequest("POST", "/api/maintenance/stale-expire", strings.NewReader("days=14&protect=true"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"status":"success"`) || !strings.Contains(w.Body.String(), `"count":1`) {
t.Errorf("unexpected body: %s", w.Body.String())
}
// 2. Test GET /api/maintenance/stats
reqStats := httptest.NewRequest("GET", "/api/maintenance/stats", nil)
wStats := httptest.NewRecorder()
mux.ServeHTTP(wStats, reqStats)
if wStats.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", wStats.Code, wStats.Body.String())
}
if !strings.Contains(wStats.Body.String(), `"total_jobs":1`) {
t.Errorf("expected stats to show total_jobs:1, got %s", wStats.Body.String())
}
}
+17 -2
View File
@@ -19,6 +19,8 @@ const applyTabHTML = `{{ define "applyTab" }}
<option value="INTERVIEW">💬 Interview</option>
<option value="OFFER">🎉 Offer</option>
<option value="REJECTED">❌ Rejected</option>
<option value="EXPIRED">⚠️ Expired / Closed</option>
<option value="OPTED_OUT">🚫 Opted Out</option>
<option value="all">All</option>
</select>
<button hx-get="/applications" hx-target="#apply-container" hx-swap="innerHTML"
@@ -69,6 +71,15 @@ const applyTableHTML = `{{ define "applyTable" }}
<!-- Pipeline Stage (arrow indicator) -->
<td class="px-4 py-3">
{{ if eq .App.Status "EXPIRED" }}
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[10px] font-bold">
⚠️ Expired / Closed
</span>
{{ else if eq .App.Status "OPTED_OUT" }}
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-[10px] font-medium">
🚫 Opted Out
</span>
{{ else }}
<div class="flex items-center gap-0.5 text-[10px] font-semibold">
<span class="px-2 py-0.5 rounded-l-full {{ if eq .App.Status "SAVED" }}bg-slate-500 text-white{{ else }}bg-slate-800/60 text-slate-600{{ end }}">Saved</span>
<span class="text-slate-700">▶</span>
@@ -84,13 +95,14 @@ const applyTableHTML = `{{ define "applyTable" }}
<span class="px-2 py-0.5 rounded-r-full bg-slate-800/60 text-slate-600">Offer</span>
{{ end }}
</div>
{{ end }}
</td>
<!-- Score -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }}
<span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-500{{ end }}">
{{ printf "%.0f" .Analysis.Score }}
<span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-400{{ end }}">
{{ if eq .Analysis.Status "PARSED" }}🤖 {{ printf "%.0f" .Analysis.Score }}{{ else }}⚡ {{ printf "%.0f" .Analysis.FilterScore }}{{ end }}
</span>
{{ else }}
<span class="text-slate-600">–</span>
@@ -113,6 +125,7 @@ const applyTableHTML = `{{ define "applyTable" }}
<!-- Change Status -->
<td class="px-4 py-3 text-right">
<select hx-patch="/applications/{{ .App.ID }}/status"
name="status"
hx-target="#apply-container"
hx-swap="innerHTML"
class="px-2 py-1 bg-slate-900 border border-slate-700 rounded-lg text-slate-200 text-[10px] focus:outline-none focus:border-indigo-500">
@@ -121,6 +134,8 @@ const applyTableHTML = `{{ define "applyTable" }}
<option value="INTERVIEW" {{ if eq .App.Status "INTERVIEW" }}selected{{ end }}>💬 Interview</option>
<option value="OFFER" {{ if eq .App.Status "OFFER" }}selected{{ end }}>🎉 Offer</option>
<option value="REJECTED" {{ if eq .App.Status "REJECTED" }}selected{{ end }}>❌ Rejected</option>
<option value="EXPIRED" {{ if eq .App.Status "EXPIRED" }}selected{{ end }}>⚠️ Expired</option>
<option value="OPTED_OUT" {{ if eq .App.Status "OPTED_OUT" }}selected{{ end }}>🚫 Opted Out</option>
</select>
</td>
</tr>
+97 -23
View File
@@ -18,6 +18,7 @@ const jobsTabHTML = `{{ define "jobsTab" }}
<option value="not-qualified">❌ Not Qualified</option>
<option value="saved">💾 Saved / In Pipeline</option>
<option value="all">🌐 All Jobs</option>
<option value="expired">⚠️ Expired / Closed</option>
<option value="opted-out">🚫 Opted-out</option>
</select>
<button hx-get="/jobs" hx-target="#jobs-container" hx-swap="innerHTML"
@@ -42,10 +43,10 @@ const jobListHTML = `{{ define "jobs" }}
<tr>
<th class="px-4 py-3" data-sort="title">Job Title &amp; Company</th>
<th class="px-4 py-3 text-center">Link</th>
<th class="px-4 py-3" data-sort="status">Analysis</th>
<th class="px-4 py-3" data-sort="status">Status</th>
<th class="px-4 py-3" data-sort="score">Score &amp; Qualification</th>
<th class="px-4 py-3">Reason / Tags</th>
<th class="px-4 py-3" data-sort="date">Crawled</th>
<th class="px-4 py-3" data-sort="date">Last Updated</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
@@ -54,7 +55,7 @@ const jobListHTML = `{{ define "jobs" }}
{{- $qual := "pending" -}}
{{- if .Analysis }}{{ if .Analysis.IsQualified }}{{ $qual = "qualified" }}{{ else }}{{ $qual = "not-qualified" }}{{ end }}{{ end -}}
{{- $appClass := "none" -}}
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else if eq .App.Status "EXPIRED" }}{{ $appClass = "expired" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
<tr class="hover:bg-slate-800/40 transition-colors group" data-row data-qual="{{ $qual }}" data-app="{{ $appClass }}">
<!-- Title & Company -->
@@ -85,9 +86,13 @@ const jobListHTML = `{{ define "jobs" }}
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ .Analysis.Status }}{{ else }}UNANALYZED{{ end }}">
{{ if .Analysis }}
{{ if eq .Analysis.Status "PARSED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">PARSED</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">AI PARSED</span>
{{ else if eq .Analysis.Status "PENDING" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">PENDING</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">QUEUED</span>
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700/40 text-slate-400 border border-slate-600/30">LOW FIT</span>
{{ else if eq .Analysis.Status "REJECTED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">REJECTED</span>
{{ else if eq .Analysis.Status "DONE" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">DONE</span>
{{ else }}
@@ -101,13 +106,31 @@ const jobListHTML = `{{ define "jobs" }}
<!-- Score & Qualification -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }}
{{ if eq .Analysis.Status "PARSED" }}
{{ if .Analysis.IsQualified }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}">
★ {{ printf "%.0f" .Analysis.Score }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}" title="AI Match Score (DeepSeek)">
🤖 {{ printf "%.0f" .Analysis.Score }}
</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20" title="AI Score: {{ printf "%.0f" .Analysis.Score }}">
✗ {{ printf "%.0f" .Analysis.Score }} Not Qual
</span>
{{ end }}
{{ else if eq .Analysis.Status "PENDING" }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-300 border border-amber-500/30" title="Keyword Fit: {{ printf "%.0f" .Analysis.FilterScore }} - Waiting for AI">
⚡ {{ printf "%.0f" .Analysis.FilterScore }}
</span>
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-slate-800 text-slate-400 border border-slate-700" title="Keyword Fit: {{ printf "%.0f" .Analysis.FilterScore }}">
⚡ {{ printf "%.0f" .Analysis.FilterScore }}
</span>
{{ else if eq .Analysis.Status "REJECTED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
✗ Not Qualified
🚫 Disqualified
</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-slate-700/50 text-slate-400 border border-slate-600/30">
{{ printf "%.0f" .Analysis.Score }}
</span>
{{ end }}
{{ else }}
@@ -117,9 +140,13 @@ const jobListHTML = `{{ define "jobs" }}
<!-- Reason / Matched Tags -->
<td class="px-4 py-3 max-w-[180px]">
{{ if .App }}{{ if eq .App.Status "OPTED_OUT" }}
<span class="text-[10px] text-slate-500 italic">Opted out</span>
{{ end }}{{ end }}
{{ if .App }}
{{ if eq .App.Status "OPTED_OUT" }}
<span class="text-[10px] text-slate-500 italic block">Opted out</span>
{{ else if eq .App.Status "EXPIRED" }}
<span class="text-[10px] text-amber-400/80 italic block">⚠️ No longer active</span>
{{ end }}
{{ end }}
{{ if .Analysis }}
{{ if .Analysis.IsQualified }}
<div class="flex flex-wrap gap-1">
@@ -133,14 +160,16 @@ const jobListHTML = `{{ define "jobs" }}
{{ $missing := parseJSON .Analysis.MissingHighlights }}
{{ if $missing }}
<span class="text-[10px] text-rose-400/90 leading-snug line-clamp-2" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span>
{{ else if .Analysis.Summary }}
<span class="text-[10px] text-slate-400/90 leading-snug line-clamp-2" title="{{ .Analysis.Summary }}">{{ .Analysis.Summary }}</span>
{{ end }}
{{ end }}
{{ end }}
</td>
<!-- Date -->
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .CrawledAt.Unix }}">
{{ formatDate .CrawledAt }}
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .LastSeenAt.Unix }}">
{{ formatDate .LastSeenAt }}
</td>
<!-- Actions: Save + Opt-out OR status badge -->
@@ -148,14 +177,32 @@ const jobListHTML = `{{ define "jobs" }}
{{ if .App }}
{{ if eq .App.Status "OPTED_OUT" }}
<!-- Opted out: show undo button -->
<div class="flex items-center justify-end gap-1.5">
<span class="text-[10px] text-slate-500">Opted-out</span>
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
hx-target="#jobs-container"
hx-swap="innerHTML"
title="Undo opt-out"
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 border border-slate-700 text-[10px] transition-colors">
↩ Undo
class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-[10px] transition-colors">
↩
</button>
</div>
{{ else if eq .App.Status "EXPIRED" }}
<!-- Expired -->
<div class="flex items-center justify-end gap-1.5">
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 text-[10px] font-bold border border-slate-700">
⚠️ EXPIRED
</span>
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
hx-target="#jobs-container"
hx-swap="innerHTML"
title="Re-open / Save"
class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-[10px] transition-colors">
↩
</button>
</div>
{{ else }}
<!-- In pipeline -->
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
@@ -203,29 +250,56 @@ const jobDetailHTML = `{{ define "jobDetail" }}
<div class="flex-1 min-w-0 pr-4">
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
<h2 class="text-xl font-extrabold text-white mt-0.5 leading-tight">{{ .Title }}</h2>
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} &bull; Ingested {{ formatDate .CrawledAt }}</p>
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} &bull; Last Updated {{ formatDate .LastSeenAt }}</p>
</div>
<button onclick="closeDetailModal()" class="flex-shrink-0 text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
</div>
{{ if .Analysis }}
<!-- Score + Status Grid -->
<!-- Dual Score Breakdown Grid -->
<div class="grid grid-cols-2 gap-3">
<!-- AI Match Score Card -->
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Match Score</span>
<div class="flex items-center justify-between">
<span class="text-[10px] text-indigo-300 uppercase tracking-wider font-bold">🤖 AI Match Score</span>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded {{ if eq .Analysis.Status "PARSED" }}bg-indigo-500/20 text-indigo-300 border border-indigo-500/30{{ else }}bg-amber-500/20 text-amber-300 border border-amber-500/30{{ end }}">
{{ .Analysis.Status }}
</span>
</div>
{{ if eq .Analysis.Status "PARSED" }}
{{ if .Analysis.IsQualified }}
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-sm font-medium text-slate-400">/ 100</span></span>
<span class="text-2xl font-black text-emerald-400 mt-1.5 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-xs font-normal text-slate-400">/ 100</span></span>
<span class="text-[10px] text-emerald-400/90 font-medium">✓ Qualified for interview profile</span>
{{ else }}
<span class="text-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
<span class="text-2xl font-black text-rose-400 mt-1.5 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-xs font-normal text-slate-400">/ 100</span></span>
<span class="text-[10px] text-rose-400 font-medium">✗ Does not meet core fit threshold</span>
{{ end }}
{{ else }}
<span class="text-lg font-bold text-amber-300 mt-1.5 block">Pending Evaluation</span>
<span class="text-[10px] text-slate-400">DeepSeek LLM analysis queued</span>
{{ end }}
</div>
<!-- Keyword Relevance Card -->
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Analysis Status</span>
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
<div class="flex items-center justify-between">
<span class="text-[10px] text-amber-400 uppercase tracking-wider font-bold">⚡ Keyword Fit</span>
<span class="text-[10px] text-slate-400">Pre-filter rule</span>
</div>
<span class="text-2xl font-black text-white mt-1.5 block">{{ printf "%.1f" .Analysis.FilterScore }} <span class="text-xs font-normal text-slate-400">/ 100</span></span>
<span class="text-[10px] text-slate-400">Lexical match against filters.yaml</span>
</div>
</div>
<!-- Highlights -->
<!-- AI Executive Summary -->
{{ if .Analysis.Summary }}
<div class="p-3.5 rounded-xl bg-indigo-950/40 border border-indigo-500/30">
<span class="text-[10px] font-bold text-indigo-300 uppercase tracking-wider block mb-1">💬 AI Executive Assessment</span>
<p class="text-xs text-indigo-100/90 leading-relaxed">{{ .Analysis.Summary }}</p>
</div>
{{ end }}
<!-- Highlights & Gaps -->
<div class="space-y-4">
{{ if .Analysis.IsQualified }}
<div>
+1 -1
View File
@@ -63,7 +63,7 @@ const kanbanCardHTML = `{{ define "kanbanCard" }}
</div>
{{ if .Analysis }}
<div class="text-[10px] text-slate-400">
Score: <strong class="{{ scoreBadgeClass .Analysis.Score }}">{{ printf "%.0f" .Analysis.Score }}</strong>
Score: <strong class="{{ scoreBadgeClass .Analysis.Score }}">{{ if eq .Analysis.Status "PARSED" }}🤖 {{ else }}⚡ {{ end }}{{ printf "%.0f" .Analysis.Score }}</strong>
</div>
{{ end }}
<div class="flex items-center justify-between pt-2 border-t border-slate-700/50 text-[10px]">
+176 -15
View File
@@ -68,17 +68,17 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
<div class="hidden md:flex items-center space-x-6 text-xs">
<div class="flex items-center space-x-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
<span class="text-slate-400">Qualified:</span>
<span class="font-bold text-emerald-400">{{ .QualifiedJobsCount }}</span>
<span class="text-slate-400">To Review:</span>
<span class="font-bold text-emerald-400">{{ .ToReviewCount }}</span>
</div>
<div class="flex items-center space-x-1.5">
<span class="text-slate-400">Ingested:</span>
<span class="text-slate-400">In Pipeline:</span>
<span class="font-bold text-violet-400">{{ .InPipelineCount }}</span>
</div>
<div class="flex items-center space-x-1.5">
<span class="text-slate-400">Total Ingested:</span>
<span class="font-bold text-white">{{ .TotalIngestedCount }}</span>
</div>
<div class="flex items-center space-x-1.5">
<span class="text-slate-400">Applied:</span>
<span class="font-bold text-violet-400">{{ .ActiveApplications }}</span>
</div>
<div class="flex items-center space-x-1.5 px-2 py-1 rounded-md bg-slate-900/60 border border-slate-800">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-slate-400">LLM:</span>
@@ -102,7 +102,11 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
</button>
<button id="tab-btn-targets" onclick="switchTab('targets')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
⚙️ Targets
🎯 Targets
</button>
<button id="tab-btn-preferences" onclick="switchTab('preferences')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
⚙️ Preferences
</button>
</div>
</div>
@@ -126,6 +130,11 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
{{ template "targetsTab" . }}
</div>
<!-- Tab: Preferences -->
<div id="tab-content-preferences" class="hidden space-y-5">
{{ template "preferencesTab" . }}
</div>
</main>
<!-- Right Slide-Over Detail Panel -->
@@ -137,6 +146,52 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
</div>
</div>
<!-- Delete Target Confirmation Modal -->
<div id="delete-target-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4" aria-modal="true" role="dialog">
<div class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" onclick="closeDeleteTargetModal()"></div>
<div class="relative w-full max-w-md bg-slate-900 border border-slate-700/80 rounded-2xl shadow-2xl shadow-black/80 p-6 text-slate-100 transform transition-all overflow-hidden z-10">
<div class="absolute -top-24 -left-24 w-48 h-48 bg-rose-500/15 rounded-full blur-3xl pointer-events-none"></div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-xl bg-rose-500/10 border border-rose-500/30 flex items-center justify-center flex-shrink-0 text-xl shadow-inner">
⚠️
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-bold text-white leading-tight">Delete Target</h3>
<p class="text-xs text-slate-400 mt-1">This is a bulk operation that cannot be undone.</p>
</div>
<button type="button" onclick="closeDeleteTargetModal()" class="text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
</div>
<div class="mt-4 p-3 rounded-xl bg-slate-950/60 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-slate-500 tracking-wider block">Target to remove:</span>
<span id="delete-target-name" class="text-sm font-semibold text-rose-300 block mt-0.5 break-words"></span>
</div>
<div class="mt-3 text-xs text-slate-300 space-y-1.5 leading-relaxed">
<p class="text-rose-400 font-medium">⚠️ What will be removed:</p>
<ul class="list-disc list-inside text-[11px] text-slate-400 space-y-1 pl-1">
<li>Target configuration and crawling settings</li>
<li><strong class="text-slate-200">All discovered job postings</strong> from this target</li>
<li>All associated AI qualification analyses &amp; scores</li>
<li>Any <strong class="text-slate-200">in-pipeline applications</strong> tracked for these jobs</li>
</ul>
</div>
<div class="mt-6 flex items-center justify-end gap-3">
<button type="button"
onclick="closeDeleteTargetModal()"
class="px-4 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition-colors">
Cancel
</button>
<button id="delete-target-confirm-btn"
type="button"
class="px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-500 rounded-lg shadow-lg shadow-rose-500/25 transition-all flex items-center gap-1.5 active:scale-95">
<span>🗑️</span> Delete Target &amp; All Posts
</button>
</div>
</div>
</div>
<!-- Fixed Bottom Status & Action Bar -->
<div id="bottom-bar"
class="fixed bottom-0 left-0 right-0 z-40 bg-slate-900/95 backdrop-blur-md border-t border-slate-700/60 shadow-2xl shadow-black/50">
@@ -184,19 +239,25 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
<script>
// ── Tab Routing ──────────────────────────────────────────────────────
const TABS = ['jobs', 'apply', 'targets'];
const TABS = ['jobs', 'apply', 'targets', 'preferences'];
function switchTab(tab) {
TABS.forEach(t => {
document.getElementById('tab-content-' + t).classList.add('hidden');
const content = document.getElementById('tab-content-' + t);
if (content) content.classList.add('hidden');
const btn = document.getElementById('tab-btn-' + t);
if (btn) {
btn.classList.remove('text-indigo-400', 'border-indigo-500');
btn.classList.add('text-slate-400', 'border-transparent');
}
});
document.getElementById('tab-content-' + tab).classList.remove('hidden');
const activeContent = document.getElementById('tab-content-' + tab);
if (activeContent) activeContent.classList.remove('hidden');
const activeBtn = document.getElementById('tab-btn-' + tab);
if (activeBtn) {
activeBtn.classList.remove('text-slate-400', 'border-transparent');
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
}
history.replaceState(null, '', '#' + tab);
}
@@ -214,6 +275,97 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
document.getElementById('detail-modal').classList.add('hidden');
}
// ── Delete Target Modal ──────────────────────────────────────────────
let currentDeleteTargetId = null;
function openDeleteTargetModal(btn) {
const id = btn.dataset.targetId;
const name = btn.dataset.targetName || 'this target';
currentDeleteTargetId = id;
document.getElementById('delete-target-name').textContent = name;
const confirmBtn = document.getElementById('delete-target-confirm-btn');
confirmBtn.onclick = () => confirmDeleteTarget(id);
document.getElementById('delete-target-modal').classList.remove('hidden');
}
function closeDeleteTargetModal() {
document.getElementById('delete-target-modal').classList.add('hidden');
currentDeleteTargetId = null;
}
function confirmDeleteTarget(id) {
const confirmBtn = document.getElementById('delete-target-confirm-btn');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting…';
fetch('/targets/' + id + '/delete', { method: 'POST' })
.then(res => {
if (!res.ok) throw new Error('Failed to delete target');
return res.text();
})
.then(html => {
const targetContainer = document.getElementById('targets-container');
if (targetContainer) targetContainer.innerHTML = html;
closeDeleteTargetModal();
// Refresh Jobs and Apply tables & stats since associated jobs were deleted
const jobsContainer = document.getElementById('jobs-container');
if (jobsContainer) htmx.trigger(jobsContainer, 'refresh');
const applyContainer = document.getElementById('apply-container');
if (applyContainer) htmx.trigger(applyContainer, 'refresh');
refreshStatus();
})
.catch(err => {
alert('Error deleting target: ' + err.message);
})
.finally(() => {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<span>🗑️</span> Delete Target &amp; All Posts';
});
}
// ── Maintenance Actions ──────────────────────────────────────────────
function runMaintenanceAction(url, daysInputId, protectCheckboxId, btn, originalText) {
const days = document.getElementById(daysInputId)?.value || '14';
const protect = document.getElementById(protectCheckboxId)?.checked ?? true;
const alertBox = document.getElementById('maintenance-alert');
btn.disabled = true;
btn.textContent = 'Processing…';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ days: days, protect: protect ? 'true' : 'false' })
})
.then(res => res.json())
.then(data => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold ' +
(data.status === 'success' ? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300' : 'bg-rose-950/60 border-rose-500/40 text-rose-300');
alertBox.innerHTML = '<span>✓</span> ' + (data.message || 'Operation completed successfully.');
alertBox.classList.remove('hidden');
setTimeout(() => { alertBox.classList.add('hidden'); }, 8000);
}
// Refresh Jobs and Apply tables & stats
const jobsContainer = document.getElementById('jobs-container');
if (jobsContainer) htmx.trigger(jobsContainer, 'refresh');
const applyContainer = document.getElementById('apply-container');
if (applyContainer) htmx.trigger(applyContainer, 'refresh');
refreshStatus();
})
.catch(err => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold bg-rose-950/60 border-rose-500/40 text-rose-300';
alertBox.innerHTML = '<span>✕</span> Error: ' + err.message;
alertBox.classList.remove('hidden');
}
})
.finally(() => {
btn.disabled = false;
btn.textContent = originalText;
});
}
// ── Bottom Bar: Status Polling ────────────────────────────────────────
function refreshStatus() {
fetch('/api/status')
@@ -298,14 +450,15 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
table.querySelectorAll('tr[data-row]').forEach(row => {
const rowText = row.textContent.toLowerCase();
const rowQual = row.dataset.qual || ''; // qualified | not-qualified | pending
const rowApp = row.dataset.app || 'none'; // none | opted-out | pipeline
const rowApp = row.dataset.app || 'none'; // none | opted-out | expired | pipeline
const textMatch = !text || rowText.includes(text);
let qualMatch;
switch (qual) {
case 'default': qualMatch = rowApp !== 'opted-out' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowQual === 'not-qualified'; break;
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowQual === 'not-qualified'; break;
case 'saved': qualMatch = rowApp === 'pipeline'; break;
case 'all': qualMatch = rowApp !== 'opted-out'; break;
case 'all': qualMatch = true; break;
case 'expired': qualMatch = rowApp === 'expired'; break;
case 'opted-out': qualMatch = rowApp === 'opted-out'; break;
default: qualMatch = true;
}
@@ -369,6 +522,14 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
if (e.target.id === 'apply-container' || e.target.closest?.('#apply-container'))
initApplyTable();
});
// Dismiss modals on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDetailModal();
closeDeleteTargetModal();
}
});
</script>
</body>
</html>{{ end }}`
+91
View File
@@ -0,0 +1,91 @@
package ui
const preferencesTabHTML = `{{ define "preferencesTab" }}
<div class="space-y-6 max-w-4xl">
<div>
<h2 class="text-base font-bold text-white flex items-center gap-2">
<span>⚙️</span> System Setup &amp; Maintenance
</h2>
<p class="text-xs text-slate-400 mt-0.5">Monitor database health and purge stale job postings not seen on target career sites</p>
</div>
<!-- Maintenance Result Alert Banner -->
<div id="maintenance-alert" class="hidden p-4 rounded-xl border transition-all"></div>
<!-- Statistics Overview Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">Total Ingested</span>
<span class="text-xl font-extrabold text-white mt-1 block">{{ .MaintenanceStats.total_jobs }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-emerald-400 block tracking-wider">Active (&le;7d)</span>
<span class="text-xl font-extrabold text-emerald-400 mt-1 block">{{ .MaintenanceStats.active_7d }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-amber-400 block tracking-wider">Stale (&gt;7d)</span>
<span class="text-xl font-extrabold text-amber-400 mt-1 block">{{ .MaintenanceStats.stale_7d }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-rose-400 block tracking-wider">Stale (&gt;30d)</span>
<span class="text-xl font-extrabold text-rose-400 mt-1 block">{{ .MaintenanceStats.stale_30d }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">Expired / Closed</span>
<span class="text-xl font-extrabold text-slate-300 mt-1 block">{{ .MaintenanceStats.expired_count }}</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
<span class="text-[10px] uppercase font-bold text-violet-400 block tracking-wider">In Pipeline</span>
<span class="text-xl font-extrabold text-violet-300 mt-1 block">{{ .MaintenanceStats.in_pipeline }}</span>
</div>
</div>
<!-- Maintenance Tool: Purge Stale Postings (Worker Queue Driven) -->
<div class="p-6 rounded-xl bg-slate-900/60 border border-slate-800 space-y-5">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<span class="w-9 h-9 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-lg">⚠️</span>
<div>
<h3 class="text-sm font-bold text-white">Purge Stale Postings (Mark Expired)</h3>
<p class="text-xs text-slate-400 mt-0.5">Enqueues worker queue tasks to mark job postings as <strong class="text-amber-300 font-semibold">Expired</strong> if they have not been discovered on target career boards for X days.</p>
</div>
</div>
</div>
<div class="pt-4 border-t border-slate-800/80 grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div>
<label for="expire-days" class="block text-xs font-semibold text-slate-300 mb-1.5">Not updated in:</label>
<select id="expire-days" class="w-full px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<option value="7">7+ days</option>
<option value="14" selected>14+ days</option>
<option value="30">30+ days</option>
<option value="60">60+ days</option>
</select>
</div>
<div class="flex items-center gap-2 pb-2">
<input type="checkbox" id="expire-protect-pipeline" checked class="rounded bg-slate-800 border-slate-700 text-indigo-600 focus:ring-0">
<label for="expire-protect-pipeline" class="text-xs text-slate-300">Protect active applications</label>
</div>
<div>
<button type="button"
onclick="runMaintenanceAction('/api/maintenance/stale-expire', 'expire-days', 'expire-protect-pipeline', this, '⚠️ Queue Expire Tasks')"
class="w-full py-2 bg-amber-600 hover:bg-amber-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all active:scale-95">
⚠️ Queue Expire Tasks
</button>
</div>
</div>
</div>
<!-- Explanation Card -->
<div class="p-5 rounded-xl bg-slate-900/40 border border-slate-800/80 text-xs text-slate-400 space-y-2">
<h4 class="font-bold text-slate-200 uppercase tracking-wider text-[11px]">💡 How Job Verification Works</h4>
<p>
Every time <strong class="text-indigo-300">🔍 Discover</strong> runs, Hireme searches live listings directly on career sites (Google, Apple, Meta).
When an existing job appears in search results, its <strong class="text-emerald-400">Last Updated</strong> timestamp is refreshed.
If a role closes or is removed from search results, its Last Updated timestamp remains unchanged, allowing you to safely purge expired postings using the tool above.
</p>
</div>
</div>
{{ end }}`
+11 -1
View File
@@ -22,7 +22,8 @@ const targetsTabHTML = `{{ define "targetsTab" }}
<select name="platform"
class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<option value="google_jobs">🔍 Google Jobs</option>
<!-- More platforms will be added as crawlers are implemented -->
<option value="apple">🍏 Apple</option>
<option value="meta">♾️ Meta</option>
</select>
<button type="submit"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all">
@@ -68,12 +69,21 @@ const targetListHTML = `{{ define "targets" }}
{{ end }}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1.5">
<button hx-post="/targets/{{ .ID }}/toggle"
hx-target="#targets-container"
hx-swap="innerHTML"
class="px-2.5 py-1 text-[11px] rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-colors">
{{ if .IsActive }}Pause{{ else }}Activate{{ end }}
</button>
<button type="button"
data-target-id="{{ .ID }}"
data-target-name="{{ .Name }}"
onclick="openDeleteTargetModal(this)"
class="px-2.5 py-1 text-[11px] rounded bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 hover:border-rose-500/50 transition-colors flex items-center gap-1">
<span>🗑️</span> Delete
</button>
</div>
</td>
</tr>
{{ else }}
+14 -3
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/hireme/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
)
var funcMap = template.FuncMap{
@@ -43,6 +43,10 @@ var funcMap = template.FuncMap{
return "💼 LinkedIn"
} else if strings.Contains(p, "google") {
return "🔍 Google Jobs"
} else if strings.Contains(p, "apple") {
return "🍏 Apple"
} else if strings.Contains(p, "meta") || strings.Contains(p, "facebook") {
return "♾️ Meta"
} else if strings.Contains(p, "greenhouse") {
return "🌿 Greenhouse"
} else if strings.Contains(p, "lever") {
@@ -68,11 +72,12 @@ type PageData struct {
Targets []db.SearchTarget
Jobs []db.JobWithAnalysis
Applications []db.JobWithAnalysis
MaintenanceStats map[string]int
ActiveTab string
LLMProvider string
QualifiedJobsCount int
ToReviewCount int
TotalIngestedCount int
ActiveApplications int
InPipelineCount int
}
var rootTemplate *template.Template
@@ -88,6 +93,7 @@ func init() {
jobDetailHTML,
targetsTabHTML,
targetListHTML,
preferencesTabHTML,
applyTabHTML,
applyTableHTML,
kanbanBoardHTML,
@@ -127,3 +133,8 @@ func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
func RenderApplyTable(w io.Writer, apps []db.JobWithAnalysis) error {
return rootTemplate.ExecuteTemplate(w, "applyTable", apps)
}
func RenderPreferencesTab(w io.Writer, data PageData) error {
return rootTemplate.ExecuteTemplate(w, "preferencesTab", data)
}
+124 -29
View File
@@ -9,19 +9,32 @@ import (
"sync/atomic"
"time"
"github.com/hireme/hireme/internal/analyzer"
"github.com/hireme/hireme/internal/config"
"github.com/hireme/hireme/internal/crawler"
"github.com/hireme/hireme/internal/db"
"github.com/hireme/hireme/internal/filter"
"github.com/hireme/hireme/internal/logger"
"github.com/hireme/hireme/pkg/crawler/google"
"git.pengzhan.dev/haopengzhan/hireme/internal/analyzer"
"git.pengzhan.dev/haopengzhan/hireme/internal/config"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
"git.pengzhan.dev/haopengzhan/hireme/internal/filter"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/amazon"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/apple"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/bytedance"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/databricks"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/google"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/linkedin"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/meta"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/microsoft"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/netflix"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/nvidia"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/snowflake"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/stripe"
"git.pengzhan.dev/haopengzhan/hireme/pkg/crawler/uber"
)
type WorkerPool struct {
db *db.DB
analyzer analyzer.LLMAnalyzer
filterPipe *filter.FilterPipeline
analysisThreshold float64
isRunning atomic.Bool
workerCount int
wg sync.WaitGroup
@@ -29,17 +42,24 @@ type WorkerPool struct {
cancel context.CancelFunc
}
func NewWorkerPool(database *db.DB, analyzer analyzer.LLMAnalyzer, numWorkers int, filterCfg *config.FilterConfig) *WorkerPool {
// Initialize filters using the provided config
func NewWorkerPool(database *db.DB, analyzer analyzer.LLMAnalyzer, numWorkers int, threshold float64, filterCfg *config.FilterConfig) *WorkerPool {
filterPipe := filter.NewPipeline(
filter.NewHardKeywordFilter(filterCfg.HardKeywords),
filter.NewScoringFilter(filterCfg.ScoringWeights),
)
if threshold <= 0 {
threshold = filterCfg.AnalysisThreshold
}
if threshold <= 0 {
threshold = 25.0
}
return &WorkerPool{
db: database,
analyzer: analyzer,
filterPipe: filterPipe,
analysisThreshold: threshold,
workerCount: numWorkers,
}
}
@@ -52,48 +72,52 @@ func (p *WorkerPool) Toggle() bool {
if p.IsRunning() {
p.Stop()
return false
}
} else {
p.Start()
return true
}
}
func (p *WorkerPool) Start() {
if p.isRunning.Load() {
return
if p.isRunning.Swap(true) {
return // Already running
}
p.ctx, p.cancel = context.WithCancel(context.Background())
p.isRunning.Store(true)
logger.Log.Infow("Started workers", "component", "WorkerPool", "count", p.workerCount)
for i := 0; i < p.workerCount; i++ {
p.wg.Add(1)
go p.workerLoop(i)
}
logger.Log.Infof("[WorkerPool] Started %d workers", p.workerCount)
}
func (p *WorkerPool) Stop() {
if !p.isRunning.Load() {
return
if !p.isRunning.Swap(false) {
return // Already stopped
}
logger.Log.Infow("Stopping worker pool...", "component", "WorkerPool")
if p.cancel != nil {
p.cancel()
p.isRunning.Store(false)
}
p.wg.Wait()
logger.Log.Infof("[WorkerPool] All workers stopped")
logger.Log.Infow("Worker pool stopped", "component", "WorkerPool")
}
func (p *WorkerPool) workerLoop(workerID int) {
defer p.wg.Done()
logger.Log.Debugf("[Worker %d] Starting loop", workerID)
logger.Log.Debugw("Starting loop", "component", "Worker", "worker_id", workerID)
for {
select {
case <-p.ctx.Done():
logger.Log.Debugf("[Worker %d] Context cancelled, exiting", workerID)
logger.Log.Debugw("Context cancelled, exiting", "component", "Worker", "worker_id", workerID)
return
default:
task, err := p.db.ClaimNextTask()
if err != nil {
logger.Log.Errorf("[Worker %d] Error claiming task: %v", workerID, err)
logger.Log.Errorw("Failed to claim task", "component", "Worker", "worker_id", workerID, "error", err)
time.Sleep(1 * time.Second)
continue
}
@@ -104,7 +128,7 @@ func (p *WorkerPool) workerLoop(workerID int) {
continue
}
logger.Log.Debugf("[Worker %d] Picked up task %d (%s)", workerID, task.ID, task.TaskType)
logger.Log.Debugw("Picked up task", "component", "Worker", "worker_id", workerID, "task_id", task.ID, "task_type", task.TaskType)
// Process task based on type
var processErr error
@@ -117,15 +141,17 @@ func (p *WorkerPool) workerLoop(workerID int) {
processErr = p.handleProcess(p.ctx, task)
case "ANALYZE":
processErr = p.handleAnalyze(p.ctx, task)
case "EXPIRE_JOB":
processErr = p.handleExpireJob(p.ctx, task)
default:
processErr = fmt.Errorf("unknown task type: %s", task.TaskType)
}
if processErr != nil {
logger.Log.Errorf("[Worker %d] Task %d FAILED: %v", workerID, task.ID, processErr)
logger.Log.Errorw("Task FAILED", "component", "Worker", "worker_id", workerID, "task_id", task.ID, "error", processErr)
_ = p.db.FailTask(task.ID, processErr.Error())
} else {
logger.Log.Debugf("[Worker %d] Task %d COMPLETED", workerID, task.ID)
logger.Log.Debugw("Task COMPLETED", "component", "Worker", "worker_id", workerID, "task_id", task.ID)
_ = p.db.CompleteTask(task.ID)
}
}
@@ -157,6 +183,11 @@ type AnalyzePayload struct {
RawJobID int64 `json:"raw_job_id"`
}
type ExpireJobPayload struct {
RawJobID int64 `json:"raw_job_id"`
Reason string `json:"reason"`
}
// ---------------------------------------------------------
// Task Handlers
// ---------------------------------------------------------
@@ -177,6 +208,8 @@ func (p *WorkerPool) handleDiscover(ctx context.Context, task *db.Task) error {
// Check deduplication (already fetched/inserted jobs)
exists, err := p.db.CanonicalIDExists(disc.CanonicalID)
if err == nil && exists {
// Update last seen timestamp to confirm job is still active on target board
_ = p.db.TouchJobLastSeen(disc.CanonicalID)
continue // Skip existing job
}
@@ -241,14 +274,21 @@ func (p *WorkerPool) handleProcess(ctx context.Context, task *db.Task) error {
keep, score, reason := p.filterPipe.Execute(ctx, &job.RawJob)
if !keep {
// Create a rejected JobAnalysis record. Store reason in missing_highlights JSON array
// Disqualified by blacklist: create a rejected record
reasonJSON, _ := json.Marshal([]string{reason})
_ = p.db.CreateJobAnalysisFull(payload.RawJobID, score, false, "[]", string(reasonJSON), "[]", "PARSED")
_ = p.db.CreateJobAnalysisFull(payload.RawJobID, 0.0, 0.0, false, "Disqualified: "+reason, "[]", string(reasonJSON), "[]", "REJECTED")
return nil
}
// It's qualified for analysis. Create the base record and enqueue it.
_ = p.db.CreateJobAnalysis(payload.RawJobID, score, true, "PENDING")
// Gating threshold check
if score < p.analysisThreshold {
summary := fmt.Sprintf("Keyword fit (%.0f) below threshold (%.0f)", score, p.analysisThreshold)
_ = p.db.CreateJobAnalysisFull(payload.RawJobID, score, score, false, summary, "[]", "[]", "[]", "FILTERED_OUT")
return nil
}
// Qualified for deep LLM analysis. Create the pending record and enqueue ANALYZE task.
_ = p.db.CreateJobAnalysis(payload.RawJobID, score, score, true, "PENDING")
analyzePayload := AnalyzePayload(payload)
_ = p.db.EnqueueTask("ANALYZE", analyzePayload)
@@ -267,9 +307,16 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error {
return err
}
// Use empty matched/missing keywords for now, or pass from filter if stored
result, err := p.analyzer.AnalyzeJD(ctx, job.Title, job.RawContent)
if err != nil {
logger.Log.Errorw("LLM analysis failed", "component", "Worker", "raw_job_id", payload.RawJobID, "error", err)
filterScore := 0.0
isQual := false
if job.Analysis != nil {
filterScore = job.Analysis.FilterScore
isQual = job.Analysis.IsQualified
}
_ = p.db.UpdateJobAnalysisFull(payload.RawJobID, filterScore, filterScore, isQual, "Analysis error: "+err.Error(), "[]", "[]", "[]", "FAILED")
return err
}
@@ -279,6 +326,9 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error {
err = p.db.UpdateJobAnalysisLLM(
payload.RawJobID,
result.AIScore,
result.IsQualified,
result.Summary,
string(matchedStr),
string(missingStr),
string(tailoringStr),
@@ -287,10 +337,55 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error {
return err
}
func (p *WorkerPool) handleExpireJob(ctx context.Context, task *db.Task) error {
var payload ExpireJobPayload
if err := json.Unmarshal([]byte(task.Payload), &payload); err != nil {
return err
}
logger.Log.Infow("Expiring job", "component", "Worker", "raw_job_id", payload.RawJobID, "reason", payload.Reason)
return p.db.ExpireJob(payload.RawJobID, payload.Reason)
}
func (p *WorkerPool) getCrawlerForPlatform(platform string) crawler.SiteCrawler {
plat := strings.ToLower(platform)
if strings.Contains(plat, "google") {
return google.NewGoogleCrawler()
}
if strings.Contains(plat, "apple") {
return apple.NewAppleCrawler()
}
if strings.Contains(plat, "meta") || strings.Contains(plat, "facebook") {
return meta.NewMetaCrawler()
}
if strings.Contains(plat, "amazon") || strings.Contains(plat, "aws") {
return amazon.NewAmazonCrawler()
}
if strings.Contains(plat, "microsoft") || strings.Contains(plat, "azure") {
return microsoft.NewMicrosoftCrawler()
}
if strings.Contains(plat, "bytedance") || strings.Contains(plat, "tiktok") {
return bytedance.NewByteDanceCrawler()
}
if strings.Contains(plat, "nvidia") {
return nvidia.NewNvidiaCrawler()
}
if strings.Contains(plat, "netflix") {
return netflix.NewNetflixCrawler()
}
if strings.Contains(plat, "uber") {
return uber.NewUberCrawler()
}
if strings.Contains(plat, "linkedin") {
return linkedin.NewLinkedInCrawler()
}
if strings.Contains(plat, "stripe") {
return stripe.NewStripeCrawler()
}
if strings.Contains(plat, "databricks") {
return databricks.NewDatabricksCrawler()
}
if strings.Contains(plat, "snowflake") {
return snowflake.NewSnowflakeCrawler()
}
return crawler.NewGenericCrawler()
}
+285
View File
@@ -0,0 +1,285 @@
package amazon
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type AmazonCrawler struct {
client *http.Client
}
func NewAmazonCrawler() *AmazonCrawler {
return NewAmazonCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewAmazonCrawlerWithClient(client *http.Client) *AmazonCrawler {
return &AmazonCrawler{
client: client,
}
}
func (c *AmazonCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "amazon" || p == "aws" || strings.Contains(p, "amazon.jobs")
}
type amazonSearchResponse struct {
Hits int `json:"hits"`
Jobs []amazonJobItem `json:"jobs"`
}
type amazonJobItem struct {
ID string `json:"id"`
IDIsp string `json:"id_icims"`
Title string `json:"title"`
Company string `json:"company_name"`
Location string `json:"location"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country_code"`
JobPath string `json:"job_path"`
Description string `json:"description"`
BasicQualifications string `json:"basic_qualifications"`
PreferredQuals string `json:"preferred_qualifications"`
JobCategory string `json:"job_category"`
JobFamily string `json:"job_family_name"`
PostedDate string `json:"posted_date"`
}
func (c *AmazonCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
searchAPI := targetURL
if !strings.Contains(searchAPI, "search.json") {
u, err := url.Parse(targetURL)
if err == nil && u.Host != "" {
q := u.Query()
searchAPI = fmt.Sprintf("https://www.amazon.jobs/en/search.json?%s", q.Encode())
} else {
searchAPI = fmt.Sprintf("https://www.amazon.jobs/en/search.json?base_query=%s&loc_query=Seattle%%2C+WA%%2C+USA", url.QueryEscape(targetURL))
}
}
resultLimit := 100
for page := 0; page < 5; page++ {
offset := page * resultLimit
reqURL := searchAPI
if strings.Contains(reqURL, "?") {
reqURL = fmt.Sprintf("%s&result_limit=%d&offset=%d", searchAPI, resultLimit, offset)
} else {
reqURL = fmt.Sprintf("%s?result_limit=%d&offset=%d", searchAPI, resultLimit, offset)
}
body, err := c.fetchURL(ctx, reqURL)
if err != nil {
if page == 0 {
return nil, err
}
break
}
var resp amazonSearchResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
if page == 0 {
return nil, fmt.Errorf("failed to parse Amazon search response: %w", err)
}
break
}
if len(resp.Jobs) == 0 {
break
}
newFound := false
for _, job := range resp.Jobs {
jobID := job.ID
if jobID == "" {
jobID = job.IDIsp
}
if jobID == "" {
continue
}
applyURL := fmt.Sprintf("https://www.amazon.jobs/en/jobs/%s", jobID)
if job.JobPath != "" {
if strings.HasPrefix(job.JobPath, "http") {
applyURL = job.JobPath
} else {
applyURL = "https://www.amazon.jobs" + job.JobPath
}
}
if !seen[applyURL] {
seen[applyURL] = true
newFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("amazon:%s", jobID),
ApplyURL: applyURL,
})
}
}
if !newFound || len(resp.Jobs) < resultLimit {
break
}
}
logger.Log.Infow("Amazon discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *AmazonCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseAmazonJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
jsonURL := fmt.Sprintf("https://www.amazon.jobs/en/jobs/%s.json", jobID)
body, err := c.fetchURL(ctx, jsonURL)
if err == nil && json.Valid([]byte(body)) {
var detail struct {
Job amazonJobItem `json:"job"`
}
if err := json.Unmarshal([]byte(body), &detail); err == nil && detail.Job.Title != "" {
j := detail.Job
var sb strings.Builder
if j.Description != "" {
sb.WriteString("## Description\n\n")
sb.WriteString(cleanHTML(j.Description))
sb.WriteString("\n\n")
}
if j.BasicQualifications != "" {
sb.WriteString("## Basic Qualifications\n\n")
sb.WriteString(cleanHTML(j.BasicQualifications))
sb.WriteString("\n\n")
}
if j.PreferredQuals != "" {
sb.WriteString("## Preferred Qualifications\n\n")
sb.WriteString(cleanHTML(j.PreferredQuals))
sb.WriteString("\n\n")
}
company := "Amazon"
if j.Company != "" {
company = j.Company
}
loc := j.Location
if loc == "" && j.City != "" {
loc = fmt.Sprintf("%s, %s, %s", j.City, j.State, j.Country)
}
if loc == "" {
loc = "Seattle, WA, USA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("amazon:%s", jobID),
Title: j.Title,
Company: company,
Location: loc,
ApplyURL: applyURL,
RawContent: sb.String(),
}, nil
}
}
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*class="[^"]*title[^"]*"[^>]*>(.*?)</h1>`)
if title == "" {
title = extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
}
if title == "" {
title = "Software Engineer - Amazon"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("amazon:%s", jobID),
Title: cleanHTML(title),
Company: "Amazon",
Location: "Seattle, WA, USA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *AmazonCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseAmazonJobID(u string) string {
re := regexp.MustCompile(`/jobs/([0-9A-Za-z\-]+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return strings.TrimSuffix(m[1], ".json")
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+111
View File
@@ -0,0 +1,111 @@
package amazon
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestAmazonCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "search.json") {
body := `{
"hits": 1,
"jobs": [
{
"id": "2500123",
"title": "Software Development Engineer II - AWS",
"company_name": "Amazon Web Services, Inc.",
"city": "Seattle",
"state": "WA",
"country_code": "USA",
"job_path": "/en/jobs/2500123/software-development-engineer-ii-aws"
}
]
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.Contains(r.URL.Path, "2500123.json") {
body := `{
"job": {
"id": "2500123",
"title": "Software Development Engineer II - AWS",
"company_name": "Amazon Web Services, Inc.",
"location": "Seattle, WA, USA",
"description": "<p>Come build the future of cloud computing.</p>",
"basic_qualifications": "<li>3+ years of non-internship professional software development experience</li>",
"preferred_qualifications": "<li>Experience with distributed systems and Go/Java/C++</li>"
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewAmazonCrawlerWithClient(mockClient)
if !c.CanHandle("amazon") || !c.CanHandle("aws") {
t.Errorf("CanHandle should support amazon and aws")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://www.amazon.jobs/en/search.json?base_query=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "amazon:2500123" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://www.amazon.jobs/en/jobs/2500123")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Software Development Engineer II - AWS" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Amazon Web Services, Inc." {
t.Errorf("unexpected company: %s", extracted.Company)
}
}
func TestAmazonCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "Amazon",
Crawler: NewAmazonCrawler(),
TargetURL: "https://www.amazon.jobs/en/search.json?base_query=Software+Development+Engineer&loc_query=Seattle%2C+WA%2C+USA",
ExpectedCanonicalPrefix: "amazon:",
ExpectedCompany: "Amazon",
})
}
+250
View File
@@ -0,0 +1,250 @@
package apple
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type AppleCrawler struct {
client *http.Client
}
func NewAppleCrawler() *AppleCrawler {
return &AppleCrawler{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (a *AppleCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "apple" || p == "apple_jobs" || strings.Contains(p, "jobs.apple.com")
}
func (a *AppleCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
// Regex to match <a ... href="/en-us/details/200677768...">
reID := regexp.MustCompile(`href="(/[^"]*/details/(\d+)[^"]*)"`)
for page := 1; page <= 10; page++ {
pageURL := targetURL
if strings.Contains(pageURL, "?") {
pageURL = fmt.Sprintf("%s&page=%d", targetURL, page)
} else {
pageURL = fmt.Sprintf("%s?page=%d", targetURL, page)
}
htmlContent, err := a.fetchURL(ctx, pageURL)
if err != nil {
if page == 1 {
return nil, err
}
break
}
matches := reID.FindAllStringSubmatch(htmlContent, -1)
if len(matches) == 0 {
// No more jobs found on this page
break
}
newJobsFound := false
for _, m := range matches {
if len(m) >= 3 {
href := m[1]
jobID := m[2]
applyURL := href
if strings.HasPrefix(applyURL, "/") {
applyURL = "https://jobs.apple.com" + applyURL
}
if !seen[applyURL] {
seen[applyURL] = true
newJobsFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("apple:%s", jobID),
ApplyURL: applyURL,
})
}
}
}
if !newJobsFound {
break
}
}
return discovered, nil
}
func (a *AppleCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
htmlContent, err := a.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
jobID := parseIDFromURL(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
// Extract window.__staticRouterHydrationData
re := regexp.MustCompile(`window\.__staticRouterHydrationData\s*=\s*JSON\.parse\("(.*?)"\);`)
match := re.FindStringSubmatch(htmlContent)
if len(match) < 2 {
return nil, fmt.Errorf("could not find JSON payload in Apple job page")
}
quotedStr := "\"" + match[1] + "\""
var rawJson string
if err := json.Unmarshal([]byte(quotedStr), &rawJson); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON string literal: %w", err)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(rawJson), &payload); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON payload: %w", err)
}
var jobDetails map[string]interface{}
loaderData, ok := payload["loaderData"].(map[string]interface{})
if ok {
if jdWrapper, ok := loaderData["jobDetails"].(map[string]interface{}); ok {
if jd, ok := jdWrapper["jobsData"].(map[string]interface{}); ok {
jobDetails = jd
}
}
}
if jobDetails == nil {
return nil, fmt.Errorf("could not find jobsData in the parsed JSON")
}
title := getString(jobDetails, "postingTitle", "Unknown Apple Position")
company := "Apple"
// Try to get location
var locationParts []string
if locs, ok := jobDetails["locations"].([]interface{}); ok {
for _, loc := range locs {
if locMap, ok := loc.(map[string]interface{}); ok {
if name, ok := locMap["name"].(string); ok && name != "" {
locationParts = append(locationParts, name)
}
}
}
}
location := "Apple Location"
if len(locationParts) > 0 {
location = strings.Join(locationParts, "; ")
}
// Construct raw markdown text
var sb strings.Builder
if summary := getString(jobDetails, "jobSummary", ""); summary != "" {
sb.WriteString("## Summary\n\n")
sb.WriteString(cleanHTML(summary))
sb.WriteString("\n\n")
}
if desc := getString(jobDetails, "description", ""); desc != "" {
sb.WriteString("## Description\n\n")
sb.WriteString(cleanHTML(desc))
sb.WriteString("\n\n")
}
if minQual := getString(jobDetails, "minimumQualifications", ""); minQual != "" {
sb.WriteString("## Minimum Qualifications\n\n")
sb.WriteString(cleanHTML(minQual))
sb.WriteString("\n\n")
}
if prefQual := getString(jobDetails, "preferredQualifications", ""); prefQual != "" {
sb.WriteString("## Preferred Qualifications\n\n")
sb.WriteString(cleanHTML(prefQual))
sb.WriteString("\n\n")
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("apple:%s", jobID),
Title: title,
Company: company,
Location: location,
ApplyURL: applyURL,
RawContent: sb.String(),
}, nil
}
func (a *AppleCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create HTTP request for %s: %w", urlStr, err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP fetch failed for %s: %w", urlStr, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
logger.Log.Warnw("Apple crawler HTTP error", "status", resp.StatusCode, "url", urlStr)
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body for %s: %w", urlStr, err)
}
return string(body), nil
}
func parseIDFromURL(u string) string {
re := regexp.MustCompile(`/details/(\d+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func getString(m map[string]interface{}, key, fallback string) string {
if val, ok := m[key]; ok {
if s, ok := val.(string); ok && s != "" {
return s
}
}
return fallback
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
return strings.TrimSpace(s)
}
+67
View File
@@ -0,0 +1,67 @@
package apple
import (
"context"
"strings"
"testing"
"time"
)
func TestAppleCrawler_EndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("skipping end-to-end network test in short mode")
}
crawler := NewAppleCrawler()
// Use the default test URL
targetURL := "https://jobs.apple.com/en-us/search?location=seattle-SEA&key=Software%2520Engineer"
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 1. Test DiscoverJobs
t.Logf("Discovering jobs from: %s", targetURL)
jobs, err := crawler.DiscoverJobs(ctx, targetURL)
if err != nil {
t.Logf("Notice: Network fetch error (expected in offline sandbox): %v", err)
return
}
if len(jobs) == 0 {
t.Fatalf("DiscoverJobs returned 0 jobs for URL: %s", targetURL)
}
t.Logf("Successfully discovered %d jobs", len(jobs))
// 2. Pick the first job and test FetchJobDetail
testJob := jobs[0]
t.Logf("Fetching details for job: %s", testJob.ApplyURL)
details, err := crawler.FetchJobDetail(ctx, testJob.ApplyURL)
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if details == nil {
t.Fatal("FetchJobDetail returned nil ExtractedJob")
}
// 3. Verify extracted details
t.Logf("Extracted Title: %s", details.Title)
t.Logf("Extracted Company: %s", details.Company)
t.Logf("Extracted Location: %s", details.Location)
t.Logf("Raw Content Length: %d", len(details.RawContent))
if details.Title == "" || details.Title == "Unknown Apple Position" {
t.Errorf("Expected valid job title, got %q", details.Title)
}
if details.Company != "Apple" {
t.Errorf("Expected company 'Apple', got %q", details.Company)
}
if !strings.HasPrefix(details.CanonicalID, "apple:") {
t.Errorf("Expected CanonicalID to start with 'apple:', got %q", details.CanonicalID)
}
if len(details.RawContent) < 100 {
t.Errorf("Expected RawContent to be substantive, got length %d", len(details.RawContent))
}
}
+272
View File
@@ -0,0 +1,272 @@
package bytedance
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type ByteDanceCrawler struct {
client *http.Client
}
func NewByteDanceCrawler() *ByteDanceCrawler {
return NewByteDanceCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewByteDanceCrawlerWithClient(client *http.Client) *ByteDanceCrawler {
return &ByteDanceCrawler{
client: client,
}
}
func (c *ByteDanceCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "bytedance" || p == "tiktok" || strings.Contains(p, "bytedance.com") || strings.Contains(p, "tiktok.com")
}
type byteDanceSearchPayload struct {
Keyword string `json:"keyword"`
Limit int `json:"limit"`
Offset int `json:"offset"`
JobCategoryID []string `json:"job_category_id_list,omitempty"`
LocationList []string `json:"location_code_list,omitempty"`
}
type byteDanceSearchResponse struct {
Code int `json:"code"`
Data struct {
Count int `json:"count"`
JobPosts []byteDanceJobItem `json:"job_post_list"`
} `json:"data"`
}
type byteDanceDetailResponse struct {
Code int `json:"code"`
Data struct {
JobPost byteDanceJobItem `json:"job_post"`
} `json:"data"`
}
type byteDanceJobItem struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Requirement string `json:"requirement"`
CityInfo struct {
Name string `json:"name"`
EnName string `json:"en_name"`
Location string `json:"location_code"`
} `json:"city_info"`
JobCategory struct {
Name string `json:"name"`
} `json:"job_category"`
PublishTime int64 `json:"publish_time"`
}
func (c *ByteDanceCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
// Default API endpoint
apiURL := "https://jobs.bytedance.com/api/v1/search/job/posts"
if strings.Contains(targetURL, "tiktok.com") {
apiURL = "https://careers.tiktok.com/api/v1/search/job/posts"
}
keyword := "Software Engineer"
u, err := url.Parse(targetURL)
if err == nil && u.Query().Get("keyword") != "" {
keyword = u.Query().Get("keyword")
}
pageSize := 50
for page := 0; page < 5; page++ {
offset := page * pageSize
reqPayload := byteDanceSearchPayload{
Keyword: keyword,
Limit: pageSize,
Offset: offset,
}
payloadBytes, err := json.Marshal(reqPayload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
if page == 0 {
return nil, err
}
break
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := c.client.Do(req)
if err != nil {
if page == 0 {
return nil, err
}
break
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
break
}
var searchResp byteDanceSearchResponse
if err := json.Unmarshal(body, &searchResp); err != nil {
if page == 0 {
return nil, fmt.Errorf("failed to parse ByteDance search response: %w", err)
}
break
}
posts := searchResp.Data.JobPosts
if len(posts) == 0 {
break
}
newFound := false
for _, post := range posts {
if post.ID == "" {
continue
}
applyURL := fmt.Sprintf("https://jobs.bytedance.com/en/position/%s/detail", post.ID)
if strings.Contains(targetURL, "tiktok.com") {
applyURL = fmt.Sprintf("https://careers.tiktok.com/position/%s/detail", post.ID)
}
if !seen[applyURL] {
seen[applyURL] = true
newFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("bytedance:%s", post.ID),
ApplyURL: applyURL,
})
}
}
if !newFound || len(posts) < pageSize {
break
}
}
logger.Log.Infow("ByteDance discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *ByteDanceCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseByteDanceJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
apiURL := fmt.Sprintf("https://jobs.bytedance.com/api/v1/job/posts/%s", jobID)
if strings.Contains(applyURL, "tiktok.com") {
apiURL = fmt.Sprintf("https://careers.tiktok.com/api/v1/job/posts/%s", jobID)
}
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err == nil {
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := c.client.Do(req)
if err == nil {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
var detailResp byteDanceDetailResponse
if err := json.Unmarshal(body, &detailResp); err == nil && detailResp.Data.JobPost.Title != "" {
p := detailResp.Data.JobPost
var sb strings.Builder
if p.Description != "" {
sb.WriteString("## Responsibilities\n\n")
sb.WriteString(cleanHTML(p.Description))
sb.WriteString("\n\n")
}
if p.Requirement != "" {
sb.WriteString("## Qualifications\n\n")
sb.WriteString(cleanHTML(p.Requirement))
sb.WriteString("\n\n")
}
loc := p.CityInfo.EnName
if loc == "" {
loc = p.CityInfo.Name
}
if loc == "" {
loc = "Seattle / Bellevue, WA"
}
company := "ByteDance"
if strings.Contains(applyURL, "tiktok") {
company = "TikTok"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("bytedance:%s", jobID),
Title: p.Title,
Company: company,
Location: loc,
ApplyURL: applyURL,
RawContent: sb.String(),
}, nil
}
}
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("bytedance:%s", jobID),
Title: "Software Engineer - ByteDance",
Company: "ByteDance",
Location: "Seattle / Bellevue, WA",
ApplyURL: applyURL,
RawContent: "See job posting at " + applyURL,
}, nil
}
func parseByteDanceJobID(u string) string {
re := regexp.MustCompile(`/position/([0-9A-Za-z]+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+119
View File
@@ -0,0 +1,119 @@
package bytedance
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestByteDanceCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "/search/job/posts") {
body := `{
"code": 0,
"data": {
"count": 1,
"job_post_list": [
{
"id": "7198234123",
"title": "Software Engineer - Distributed Storage (Seattle/Bellevue)",
"city_info": {
"name": "Bellevue",
"en_name": "Bellevue, WA"
}
}
]
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.Contains(r.URL.Path, "/job/posts/7198234123") {
body := `{
"code": 0,
"data": {
"job_post": {
"id": "7198234123",
"title": "Software Engineer - Distributed Storage (Seattle/Bellevue)",
"description": "<p>Build high-throughput storage engines for TikTok recommendation algorithms.</p>",
"requirement": "<li>BS/MS/PhD in CS and 3+ years experience with C++/Go.</li>",
"city_info": {
"en_name": "Bellevue, WA"
}
}
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewByteDanceCrawlerWithClient(mockClient)
if !c.CanHandle("bytedance") || !c.CanHandle("tiktok") {
t.Errorf("CanHandle should support bytedance and tiktok")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://jobs.bytedance.com/api/v1/search/job/posts?keyword=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "bytedance:7198234123" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://jobs.bytedance.com/en/position/7198234123/detail")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Software Engineer - Distributed Storage (Seattle/Bellevue)" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "ByteDance" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Bellevue, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestByteDanceCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "ByteDance",
Crawler: NewByteDanceCrawler(),
TargetURL: "https://jobs.bytedance.com/api/v1/search/job/posts?keyword=Software+Engineer",
ExpectedCanonicalPrefix: "bytedance:",
ExpectedCompany: "ByteDance",
})
}
+225
View File
@@ -0,0 +1,225 @@
package databricks
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type DatabricksCrawler struct {
client *http.Client
}
func NewDatabricksCrawler() *DatabricksCrawler {
return NewDatabricksCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewDatabricksCrawlerWithClient(client *http.Client) *DatabricksCrawler {
return &DatabricksCrawler{
client: client,
}
}
func (c *DatabricksCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "databricks" || strings.Contains(p, "databricks.com/company/careers") || strings.Contains(p, "boards.greenhouse.io/databricks")
}
type databricksJobsResponse struct {
Jobs []databricksJob `json:"jobs"`
}
type databricksJob struct {
ID int64 `json:"id"`
Title string `json:"title"`
AbsoluteURL string `json:"absolute_url"`
Location struct {
Name string `json:"name"`
} `json:"location"`
Offices []struct {
Name string `json:"name"`
Location string `json:"location"`
} `json:"offices"`
Departments []struct {
Name string `json:"name"`
} `json:"departments"`
Content string `json:"content"`
}
func (c *DatabricksCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
apiURL := "https://boards-api.greenhouse.io/v1/boards/databricks/jobs"
if targetURL != "" && strings.Contains(targetURL, "boards-api.greenhouse.io") {
apiURL = targetURL
}
body, err := c.fetchURL(ctx, apiURL)
if err != nil {
return nil, err
}
var resp databricksJobsResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
return nil, fmt.Errorf("failed to parse Databricks Greenhouse API response: %w", err)
}
for _, job := range resp.Jobs {
if job.ID == 0 {
continue
}
jobIDStr := fmt.Sprintf("%d", job.ID)
applyURL := job.AbsoluteURL
if applyURL == "" {
applyURL = fmt.Sprintf("https://boards.greenhouse.io/databricks/jobs/%s", jobIDStr)
}
if !seen[applyURL] {
seen[applyURL] = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("databricks:%s", jobIDStr),
ApplyURL: applyURL,
})
}
}
logger.Log.Infow("Databricks discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *DatabricksCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseDatabricksJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
detailAPI := fmt.Sprintf("https://boards-api.greenhouse.io/v1/boards/databricks/jobs/%s", jobID)
body, err := c.fetchURL(ctx, detailAPI)
if err == nil && json.Valid([]byte(body)) {
var job databricksJob
if err := json.Unmarshal([]byte(body), &job); err == nil && job.Title != "" {
loc := job.Location.Name
if loc == "" && len(job.Offices) > 0 {
var locs []string
for _, o := range job.Offices {
if o.Name != "" {
locs = append(locs, o.Name)
}
}
loc = strings.Join(locs, "; ")
}
if loc == "" {
loc = "Bellevue / Seattle, WA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("databricks:%s", jobID),
Title: job.Title,
Company: "Databricks",
Location: loc,
ApplyURL: applyURL,
RawContent: cleanHTML(job.Content),
}, nil
}
}
// Fallback to HTML
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*class="app-title"[^>]*>(.*?)</h1>`)
if title == "" {
title = extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
}
if title == "" {
title = "Software Engineer - Databricks"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("databricks:%s", jobID),
Title: cleanHTML(title),
Company: "Databricks",
Location: "Bellevue / Seattle, WA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *DatabricksCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseDatabricksJobID(u string) string {
re := regexp.MustCompile(`/jobs/(\d+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+109
View File
@@ -0,0 +1,109 @@
package databricks
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestDatabricksCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.HasSuffix(r.URL.Path, "/jobs/5192301") {
body := `{
"id": 5192301,
"title": "Staff Software Engineer - Lakehouse Query Engine",
"content": "&lt;p&gt;Databricks is creating the world's most performant unified analytics engine.&lt;/p&gt;",
"location": {
"name": "Bellevue, WA"
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.HasSuffix(r.URL.Path, "/jobs") {
body := `{
"jobs": [
{
"id": 5192301,
"title": "Staff Software Engineer - Lakehouse Query Engine",
"absolute_url": "https://boards.greenhouse.io/databricks/jobs/5192301",
"location": {
"name": "Bellevue, WA"
}
}
]
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewDatabricksCrawlerWithClient(mockClient)
if !c.CanHandle("databricks") {
t.Errorf("CanHandle should support databricks")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://boards-api.greenhouse.io/v1/boards/databricks/jobs")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "databricks:5192301" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://boards.greenhouse.io/databricks/jobs/5192301")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Staff Software Engineer - Lakehouse Query Engine" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Databricks" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Bellevue, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestDatabricksCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "Databricks",
Crawler: NewDatabricksCrawler(),
TargetURL: "https://boards-api.greenhouse.io/v1/boards/databricks/jobs",
ExpectedCanonicalPrefix: "databricks:",
ExpectedCompany: "Databricks",
})
}
+9 -9
View File
@@ -7,14 +7,14 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
"time"
md "github.com/JohannesKaufmann/html-to-markdown/v2"
"github.com/hireme/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
"golang.org/x/net/html"
)
@@ -183,7 +183,7 @@ func extractFromAFData(htmlContent string) (title, company, location string) {
marker := `AF_initDataCallback({key: 'ds:0'`
idx := strings.Index(htmlContent, marker)
if idx == -1 {
log.Printf("[GoogleCrawler] AF_initDataCallback ds:0 not found")
logger.Log.Debugw("AF_initDataCallback ds:0 not found", "component", "GoogleCrawler")
return "", "", ""
}
@@ -191,7 +191,7 @@ func extractFromAFData(htmlContent string) (title, company, location string) {
dataMarker := "data:"
dataIdx := strings.Index(htmlContent[idx:], dataMarker)
if dataIdx == -1 {
log.Printf("[GoogleCrawler] 'data:' not found after ds:0 marker")
logger.Log.Debugw("'data:' not found after ds:0 marker", "component", "GoogleCrawler")
return "", "", ""
}
dataStart := idx + dataIdx + len(dataMarker)
@@ -199,14 +199,14 @@ func extractFromAFData(htmlContent string) (title, company, location string) {
// Use bracket counting to find the matching ] for the outer [
jsonStr := extractBalancedBracket(htmlContent, dataStart)
if jsonStr == "" {
log.Printf("[GoogleCrawler] Failed to extract balanced bracket for ds:0 data")
logger.Log.Debugw("Failed to extract balanced bracket for ds:0 data", "component", "GoogleCrawler")
return "", "", ""
}
// Parse the outer array: data = [[...job record...]]
var outerArray []json.RawMessage
if err := json.Unmarshal([]byte(jsonStr), &outerArray); err != nil {
log.Printf("[GoogleCrawler] JSON parse error for ds:0 outer: %v", err)
logger.Log.Debugw("JSON parse error for ds:0 outer", "component", "GoogleCrawler", "error", err)
return "", "", ""
}
if len(outerArray) == 0 {
@@ -216,7 +216,7 @@ func extractFromAFData(htmlContent string) (title, company, location string) {
// Parse inner job record array
var record []json.RawMessage
if err := json.Unmarshal(outerArray[0], &record); err != nil {
log.Printf("[GoogleCrawler] JSON parse error for job record: %v", err)
logger.Log.Debugw("JSON parse error for job record", "component", "GoogleCrawler", "error", err)
return "", "", ""
}
@@ -247,7 +247,7 @@ func extractFromAFData(htmlContent string) (title, company, location string) {
}
}
log.Printf("[GoogleCrawler] AF_initDataCallback parsed: title=%q company=%q location=%q", title, company, location)
logger.Log.Debugw("AF_initDataCallback parsed job", "component", "GoogleCrawler", "title", title, "company", company, "location", location)
return title, company, location
}
@@ -309,7 +309,7 @@ func (g *GoogleCrawler) fetchWithRetry(ctx context.Context, urlStr string) (stri
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
delay := time.Duration(attempt) * 2 * time.Second
log.Printf("[GoogleCrawler] Retry #%d for %s after %v", attempt, urlStr, delay)
logger.Log.Warnw("Retrying request", "component", "GoogleCrawler", "attempt", attempt, "url", urlStr, "delay", delay)
time.Sleep(delay)
}
+229
View File
@@ -0,0 +1,229 @@
package linkedin
import (
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type LinkedInCrawler struct {
client *http.Client
}
func NewLinkedInCrawler() *LinkedInCrawler {
return NewLinkedInCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewLinkedInCrawlerWithClient(client *http.Client) *LinkedInCrawler {
return &LinkedInCrawler{
client: client,
}
}
func (c *LinkedInCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "linkedin" || strings.Contains(p, "linkedin.com/jobs")
}
func (c *LinkedInCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
searchAPI := targetURL
if !strings.Contains(searchAPI, "jobs-guest/jobs/api/seeMoreJobPostings/search") {
u, err := url.Parse(targetURL)
if err == nil && u.Host != "" {
q := u.Query()
searchAPI = fmt.Sprintf("https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?%s", q.Encode())
} else {
searchAPI = fmt.Sprintf("https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=%s&location=Seattle%%2C+Washington%%2C+United+States", url.QueryEscape(targetURL))
}
}
pageSize := 25
reID := regexp.MustCompile(`(?:urn:li:jobPosting:(\d+)|/jobs/view/(?:[a-z0-9\-]+-)?(\d+))`)
for page := 0; page < 4; page++ {
start := page * pageSize
reqURL := searchAPI
if strings.Contains(reqURL, "?") {
reqURL = fmt.Sprintf("%s&start=%d", searchAPI, start)
} else {
reqURL = fmt.Sprintf("%s?start=%d", searchAPI, start)
}
body, err := c.fetchURL(ctx, reqURL)
if err != nil {
if page == 0 {
return nil, err
}
break
}
matches := reID.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
break
}
newFound := false
for _, m := range matches {
jobID := m[1]
if jobID == "" && len(m) >= 3 {
jobID = m[2]
}
if jobID == "" {
continue
}
applyURL := fmt.Sprintf("https://www.linkedin.com/jobs/view/%s", jobID)
if !seen[applyURL] {
seen[applyURL] = true
newFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("linkedin:%s", jobID),
ApplyURL: applyURL,
})
}
}
if !newFound || len(matches) < pageSize {
break
}
}
logger.Log.Infow("LinkedIn discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *LinkedInCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseLinkedInJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
// LinkedIn guest API endpoint for job details:
guestDetailURL := fmt.Sprintf("https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/%s", jobID)
body, err := c.fetchURL(ctx, guestDetailURL)
if err != nil {
body, err = c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
}
title := extractRegex(body, `<h2[^>]*class="[^"]*top-card-layout__title[^"]*"[^>]*>(.*?)</h2>`)
if title == "" {
title = extractRegex(body, `<h1[^>]*>(.*?)</h1>`)
}
if title == "" {
title = "Software Engineer"
}
company := extractRegex(body, `<a[^>]*class="[^"]*topcard__org-name-link[^"]*"[^>]*>(.*?)</a>`)
if company == "" {
company = extractRegex(body, `<span[^>]*class="[^"]*topcard__flavor[^"]*"[^>]*>(.*?)</span>`)
}
if company == "" {
company = "LinkedIn"
}
location := extractRegex(body, `<span[^>]*class="[^"]*topcard__flavor--bullet[^"]*"[^>]*>(.*?)</span>`)
if location == "" {
location = "Seattle, WA, USA"
}
desc := extractRegex(body, `<div[^>]*class="[^"]*show-more-less-html__markup[^"]*"[^>]*>([\s\S]*?)</div>`)
if desc == "" {
desc = body
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("linkedin:%s", jobID),
Title: cleanHTML(title),
Company: cleanHTML(company),
Location: cleanHTML(location),
ApplyURL: applyURL,
RawContent: cleanHTML(desc),
}, nil
}
func (c *LinkedInCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseLinkedInJobID(u string) string {
re := regexp.MustCompile(`/jobs/view/(?:[a-z0-9\-]+-)?(\d+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
re2 := regexp.MustCompile(`/jobPosting/(\d+)`)
m2 := re2.FindStringSubmatch(u)
if len(m2) >= 2 {
return m2[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+104
View File
@@ -0,0 +1,104 @@
package linkedin
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestLinkedInCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "seeMoreJobPostings") {
body := `<html>
<li data-row="1">
<div class="base-card" data-entity-urn="urn:li:jobPosting:3920192831">
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/software-engineer-infra-at-linkedin-3920192831"></a>
</div>
</li>
</html>`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.Contains(r.URL.Path, "3920192831") {
body := `<html>
<h2 class="top-card-layout__title">Staff Software Engineer - Distributed Graph</h2>
<a class="topcard__org-name-link">LinkedIn</a>
<span class="topcard__flavor--bullet">Bellevue, WA</span>
<div class="show-more-less-html__markup">
<p>LinkedIn graph systems scale to billions of edge connections.</p>
</div>
</html>`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewLinkedInCrawlerWithClient(mockClient)
if !c.CanHandle("linkedin") {
t.Errorf("CanHandle should support linkedin")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "linkedin:3920192831" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://www.linkedin.com/jobs/view/3920192831")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Staff Software Engineer - Distributed Graph" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "LinkedIn" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Bellevue, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestLinkedInCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "LinkedIn",
Crawler: NewLinkedInCrawler(),
TargetURL: "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=Software+Engineer&location=Seattle%2C+Washington%2C+United+States",
ExpectedCanonicalPrefix: "linkedin:",
ExpectedCompany: "",
})
}
+142
View File
@@ -0,0 +1,142 @@
package meta
import (
"context"
"fmt"
"strings"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/chromedp"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type MetaCrawler struct{}
func NewMetaCrawler() *MetaCrawler {
return &MetaCrawler{}
}
func (c *MetaCrawler) CanHandle(platform string) bool {
return strings.ToLower(platform) == "meta" || strings.ToLower(platform) == "facebook"
}
func (c *MetaCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-dev-shm-usage", true),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...)
defer cancelAlloc()
chromeCtx, cancelCtx := chromedp.NewContext(allocCtx)
defer cancelCtx()
chromeCtx, cancelTimeout := context.WithTimeout(chromeCtx, 30*time.Second)
defer cancelTimeout()
var nodes []*cdp.Node
logger.Log.Infow("Navigating to Meta careers search", "url", targetURL)
err := chromedp.Run(chromeCtx,
chromedp.Navigate(targetURL),
chromedp.Sleep(5*time.Second), // Wait for React and GraphQL to load
chromedp.Nodes(`a`, &nodes, chromedp.ByQueryAll),
)
if err != nil {
return nil, fmt.Errorf("chromedp navigation failed: %w", err)
}
var results []crawler.DiscoveredJob
seen := make(map[string]bool)
for _, n := range nodes {
href := n.AttributeValue("href")
// Meta uses /v2/jobs/..., /jobs/..., or /profile/job_details/...
if (strings.Contains(href, "/v2/jobs/") || strings.Contains(href, "/jobs/") || strings.Contains(href, "/profile/job_details/")) && !strings.Contains(href, "jobsearch") {
fullURL := href
if strings.HasPrefix(href, "/") {
fullURL = "https://www.metacareers.com" + href
}
// Extract Canonical ID from URL
parts := strings.Split(strings.Trim(href, "/"), "/")
canonicalID := "unknown"
for i, p := range parts {
if (p == "jobs" || p == "job_details") && i+1 < len(parts) {
canonicalID = parts[i+1]
break
}
}
if !seen[fullURL] {
seen[fullURL] = true
results = append(results, crawler.DiscoveredJob{
ApplyURL: fullURL,
CanonicalID: "meta_" + canonicalID,
})
}
}
}
logger.Log.Infow("Meta discovery finished", "found_count", len(results))
return results, nil
}
func (c *MetaCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-dev-shm-usage", true),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...)
defer cancelAlloc()
chromeCtx, cancelCtx := chromedp.NewContext(allocCtx)
defer cancelCtx()
chromeCtx, cancelTimeout := context.WithTimeout(chromeCtx, 30*time.Second)
defer cancelTimeout()
logger.Log.Infow("Fetching Meta job detail", "url", applyURL)
var titleText string
var bodyText string
err := chromedp.Run(chromeCtx,
chromedp.Navigate(applyURL),
chromedp.Sleep(5*time.Second),
chromedp.Evaluate(`document.querySelector("h1") ? document.querySelector("h1").innerText : "Unknown Meta Title"`, &titleText),
chromedp.Evaluate(`document.body.innerText`, &bodyText),
)
if err != nil {
return nil, fmt.Errorf("chromedp navigation failed: %w", err)
}
// Extract ID
parts := strings.Split(strings.Trim(applyURL, "/"), "/")
canonicalID := "unknown"
for i, p := range parts {
if (p == "jobs" || p == "job_details") && i+1 < len(parts) {
canonicalID = parts[i+1]
break
}
}
return &crawler.ExtractedJob{
CanonicalID: "meta_" + canonicalID,
Title: titleText,
Company: "Meta",
Location: "Check description", // Location extraction is complex via text, leave it or parse later
ApplyURL: applyURL,
RawContent: bodyText,
}, nil
}
+57
View File
@@ -0,0 +1,57 @@
package meta
import (
"context"
"testing"
"time"
)
func TestMetaCrawler_EndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("Skipping long-running Meta crawler network test in short mode")
}
c := NewMetaCrawler()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
testURL := "https://www.metacareers.com/jobsearch/?offices[0]=Seattle%2C%20WA&roles[0]=Full%20time%20employment"
t.Logf("Discovering jobs from: %s", testURL)
discovered, err := c.DiscoverJobs(ctx, testURL)
if err != nil {
t.Logf("Notice: Chromedp execution error (expected in offline/sandboxed environment): %v", err)
return
}
if len(discovered) == 0 {
t.Fatalf("Expected to find at least 1 job, found 0")
}
t.Logf("Successfully discovered %d jobs", len(discovered))
// Fetch details for the first discovered job
firstJob := discovered[0]
t.Logf("Fetching details for job: %s", firstJob.ApplyURL)
detail, err := c.FetchJobDetail(ctx, firstJob.ApplyURL)
if err != nil {
t.Fatalf("Failed to fetch job details: %v", err)
}
if detail.Title == "" || detail.Title == "Unknown Meta Title" {
t.Errorf("Expected a valid job title, got %s", detail.Title)
}
if len(detail.RawContent) < 100 {
t.Errorf("Expected RawContent to contain the job description, but it was too short (%d bytes)", len(detail.RawContent))
}
if detail.CanonicalID == "unknown" || detail.CanonicalID == "" {
t.Errorf("Failed to extract canonical ID from %s", firstJob.ApplyURL)
}
t.Logf("Extracted Title: %s", detail.Title)
t.Logf("Extracted CanonicalID: %s", detail.CanonicalID)
t.Logf("Raw Content Length: %d", len(detail.RawContent))
}
+275
View File
@@ -0,0 +1,275 @@
package microsoft
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type MicrosoftCrawler struct {
client *http.Client
}
func NewMicrosoftCrawler() *MicrosoftCrawler {
return NewMicrosoftCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewMicrosoftCrawlerWithClient(client *http.Client) *MicrosoftCrawler {
return &MicrosoftCrawler{
client: client,
}
}
func (c *MicrosoftCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "microsoft" || p == "azure" || strings.Contains(p, "careers.microsoft.com")
}
type msSearchResponse struct {
OperationResult struct {
Result struct {
TotalJobs int `json:"totalJobs"`
Jobs []msJobItem `json:"jobs"`
} `json:"result"`
} `json:"operationResult"`
}
type msJobDetailResponse struct {
OperationResult struct {
Result msJobItem `json:"result"`
} `json:"operationResult"`
}
type msJobItem struct {
JobID string `json:"jobId"`
Title string `json:"title"`
Description string `json:"description"`
Qualifications string `json:"qualifications"`
Responsibilities string `json:"responsibilities"`
PostingDate string `json:"postingDate"`
Properties struct {
Locations []string `json:"locations"`
PrimaryLocation string `json:"primaryLocation"`
WorkSiteFlexibility string `json:"workSiteFlexibility"`
Profession string `json:"profession"`
RoleType string `json:"roleType"`
Discipline string `json:"discipline"`
} `json:"properties"`
}
func (c *MicrosoftCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
searchAPI := targetURL
if !strings.Contains(searchAPI, "gcsservices.careers.microsoft.com") {
u, err := url.Parse(targetURL)
if err == nil && u.Host != "" {
q := u.Query()
searchAPI = fmt.Sprintf("https://gcsservices.careers.microsoft.com/search/api/v1/search?%s", q.Encode())
} else {
searchAPI = fmt.Sprintf("https://gcsservices.careers.microsoft.com/search/api/v1/search?q=%s&lc=Redmond%%2C+Washington%%2C+United+States&l=en_us", url.QueryEscape(targetURL))
}
}
pageSize := 50
for page := 1; page <= 5; page++ {
reqURL := searchAPI
if strings.Contains(reqURL, "?") {
reqURL = fmt.Sprintf("%s&pg=%d&pgSz=%d", searchAPI, page, pageSize)
} else {
reqURL = fmt.Sprintf("%s?pg=%d&pgSz=%d", searchAPI, page, pageSize)
}
body, err := c.fetchURL(ctx, reqURL)
if err != nil {
if page == 1 {
return nil, err
}
break
}
var resp msSearchResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
if page == 1 {
return nil, fmt.Errorf("failed to parse Microsoft search response: %w", err)
}
break
}
jobs := resp.OperationResult.Result.Jobs
if len(jobs) == 0 {
break
}
newFound := false
for _, job := range jobs {
if job.JobID == "" {
continue
}
applyURL := fmt.Sprintf("https://jobs.careers.microsoft.com/global/en/share/%s", job.JobID)
if !seen[applyURL] {
seen[applyURL] = true
newFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("microsoft:%s", job.JobID),
ApplyURL: applyURL,
})
}
}
if !newFound || len(jobs) < pageSize {
break
}
}
logger.Log.Infow("Microsoft discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *MicrosoftCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseMicrosoftJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
detailURL := fmt.Sprintf("https://gcsservices.careers.microsoft.com/search/api/v1/job/%s?lang=en_us", jobID)
body, err := c.fetchURL(ctx, detailURL)
if err == nil && json.Valid([]byte(body)) {
var detailResp msJobDetailResponse
if err := json.Unmarshal([]byte(body), &detailResp); err == nil && detailResp.OperationResult.Result.Title != "" {
j := detailResp.OperationResult.Result
var sb strings.Builder
if j.Description != "" {
sb.WriteString("## Overview\n\n")
sb.WriteString(cleanHTML(j.Description))
sb.WriteString("\n\n")
}
if j.Responsibilities != "" {
sb.WriteString("## Responsibilities\n\n")
sb.WriteString(cleanHTML(j.Responsibilities))
sb.WriteString("\n\n")
}
if j.Qualifications != "" {
sb.WriteString("## Qualifications\n\n")
sb.WriteString(cleanHTML(j.Qualifications))
sb.WriteString("\n\n")
}
loc := j.Properties.PrimaryLocation
if loc == "" && len(j.Properties.Locations) > 0 {
loc = strings.Join(j.Properties.Locations, "; ")
}
if loc == "" {
loc = "Redmond, WA, USA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("microsoft:%s", jobID),
Title: j.Title,
Company: "Microsoft",
Location: loc,
ApplyURL: applyURL,
RawContent: sb.String(),
}, nil
}
}
// Fallback to HTML
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
if title == "" {
title = "Software Engineer - Microsoft"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("microsoft:%s", jobID),
Title: cleanHTML(title),
Company: "Microsoft",
Location: "Redmond, WA, USA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *MicrosoftCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseMicrosoftJobID(u string) string {
re := regexp.MustCompile(`/(?:share|job)/([0-9A-Za-z]+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+121
View File
@@ -0,0 +1,121 @@
package microsoft
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestMicrosoftCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "/job/1789001") {
body := `{
"operationResult": {
"result": {
"jobId": "1789001",
"title": "Software Engineer II - Azure Core",
"description": "<p>Azure Core is building the backbone of cloud compute.</p>",
"responsibilities": "<li>Design and implement scalable distributed services.</li>",
"qualifications": "<li>Bachelor degree and 2+ years of software development experience.</li>",
"properties": {
"primaryLocation": "Redmond, WA, United States"
}
}
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.HasSuffix(r.URL.Path, "/search") || strings.Contains(r.URL.RawQuery, "pgSz") {
body := `{
"operationResult": {
"result": {
"totalJobs": 1,
"jobs": [
{
"jobId": "1789001",
"title": "Software Engineer II - Azure Core",
"properties": {
"primaryLocation": "Redmond, WA, United States",
"discipline": "Software Engineering"
}
}
]
}
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewMicrosoftCrawlerWithClient(mockClient)
if !c.CanHandle("microsoft") || !c.CanHandle("azure") {
t.Errorf("CanHandle should support microsoft and azure")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://gcsservices.careers.microsoft.com/search/api/v1/search?q=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "microsoft:1789001" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://jobs.careers.microsoft.com/global/en/share/1789001")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Software Engineer II - Azure Core" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Microsoft" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Redmond, WA, United States" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestMicrosoftCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "Microsoft",
Crawler: NewMicrosoftCrawler(),
TargetURL: "https://gcsservices.careers.microsoft.com/search/api/v1/search?q=Software+Engineer&lc=Redmond%2C+Washington%2C+United+States&l=en_us",
ExpectedCanonicalPrefix: "microsoft:",
ExpectedCompany: "Microsoft",
})
}
+220
View File
@@ -0,0 +1,220 @@
package netflix
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type NetflixCrawler struct {
client *http.Client
}
func NewNetflixCrawler() *NetflixCrawler {
return NewNetflixCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewNetflixCrawlerWithClient(client *http.Client) *NetflixCrawler {
return &NetflixCrawler{
client: client,
}
}
func (c *NetflixCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "netflix" || strings.Contains(p, "jobs.netflix.com")
}
type netflixSearchResponse struct {
Records struct {
Total int `json:"total"`
Postings []netflixJobItem `json:"postings"`
} `json:"records"`
}
type netflixJobItem struct {
ID int64 `json:"id"`
ExternalID string `json:"external_id"`
Title string `json:"text"`
Description string `json:"description"`
Location string `json:"location"`
Locations []string `json:"locations"`
Team string `json:"team"`
Department string `json:"department"`
}
func (c *NetflixCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
searchAPI := targetURL
if !strings.Contains(searchAPI, "api/search") {
u, err := url.Parse(targetURL)
if err == nil && u.Host != "" {
q := u.Query()
searchAPI = fmt.Sprintf("https://jobs.netflix.com/api/search?%s", q.Encode())
} else {
searchAPI = fmt.Sprintf("https://jobs.netflix.com/api/search?q=%s", url.QueryEscape(targetURL))
}
}
body, err := c.fetchURL(ctx, searchAPI)
if err != nil {
return nil, err
}
var resp netflixSearchResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
return nil, fmt.Errorf("failed to parse Netflix search response: %w", err)
}
for _, job := range resp.Records.Postings {
jobID := job.ExternalID
if jobID == "" && job.ID != 0 {
jobID = fmt.Sprintf("%d", job.ID)
}
if jobID == "" {
continue
}
applyURL := fmt.Sprintf("https://jobs.netflix.com/jobs/%s", jobID)
if !seen[applyURL] {
seen[applyURL] = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("netflix:%s", jobID),
ApplyURL: applyURL,
})
}
}
logger.Log.Infow("Netflix discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *NetflixCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseNetflixJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
// Try API endpoint: https://jobs.netflix.com/api/search?q={id} or direct detail
detailAPI := fmt.Sprintf("https://jobs.netflix.com/api/postings/%s", jobID)
body, err := c.fetchURL(ctx, detailAPI)
if err == nil && json.Valid([]byte(body)) {
var job netflixJobItem
if err := json.Unmarshal([]byte(body), &job); err == nil && job.Title != "" {
loc := job.Location
if loc == "" && len(job.Locations) > 0 {
loc = strings.Join(job.Locations, "; ")
}
if loc == "" {
loc = "Seattle / Remote, WA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("netflix:%s", jobID),
Title: job.Title,
Company: "Netflix",
Location: loc,
ApplyURL: applyURL,
RawContent: cleanHTML(job.Description),
}, nil
}
}
// Fallback to HTML
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
if title == "" {
title = "Software Engineer - Netflix"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("netflix:%s", jobID),
Title: cleanHTML(title),
Company: "Netflix",
Location: "Seattle / Remote, WA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *NetflixCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseNetflixJobID(u string) string {
re := regexp.MustCompile(`/jobs/([0-9A-Za-z\-]+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+110
View File
@@ -0,0 +1,110 @@
package netflix
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestNetflixCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "/api/search") {
body := `{
"records": {
"total": 1,
"postings": [
{
"id": 8192301,
"external_id": "8192301",
"text": "Senior Software Engineer - Cloud Platform",
"location": "Seattle, WA, United States"
}
]
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.Contains(r.URL.Path, "/postings/8192301") {
body := `{
"id": 8192301,
"external_id": "8192301",
"text": "Senior Software Engineer - Cloud Platform",
"description": "<p>We are looking for senior engineers to evolve Netflix telemetry and control planes.</p>",
"location": "Seattle, WA, United States"
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewNetflixCrawlerWithClient(mockClient)
if !c.CanHandle("netflix") {
t.Errorf("CanHandle should support netflix")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://jobs.netflix.com/api/search?q=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "netflix:8192301" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://jobs.netflix.com/jobs/8192301")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Senior Software Engineer - Cloud Platform" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Netflix" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Seattle, WA, United States" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestNetflixCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "Netflix",
Crawler: NewNetflixCrawler(),
TargetURL: "https://jobs.netflix.com/api/search?q=Software+Engineer",
ExpectedCanonicalPrefix: "netflix:",
ExpectedCompany: "Netflix",
})
}
+253
View File
@@ -0,0 +1,253 @@
package nvidia
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type NvidiaCrawler struct {
client *http.Client
}
func NewNvidiaCrawler() *NvidiaCrawler {
return NewNvidiaCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewNvidiaCrawlerWithClient(client *http.Client) *NvidiaCrawler {
return &NvidiaCrawler{
client: client,
}
}
func (c *NvidiaCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "nvidia" || strings.Contains(p, "nvidia.wd5.myworkdayjobs.com")
}
type nvidiaSearchPayload struct {
AppliedFacets map[string][]string `json:"appliedFacets"`
Limit int `json:"limit"`
Offset int `json:"offset"`
SearchText string `json:"searchText"`
}
type nvidiaSearchResponse struct {
Total int `json:"total"`
JobPosts []nvidiaJobItem `json:"jobPostings"`
}
type nvidiaJobItem struct {
Title string `json:"title"`
ExternalPath string `json:"externalPath"`
BulletFields []string `json:"bulletFields"`
LocationsText string `json:"locationsText"`
PostedOn string `json:"postedOn"`
JobPostingID string `json:"jobPostingId"`
TimeType string `json:"timeType"`
}
type nvidiaDetailResponse struct {
JobPostingInfo struct {
ID string `json:"id"`
Title string `json:"title"`
JobDescription string `json:"jobDescription"`
Location string `json:"location"`
AdditionalLocations []string `json:"additionalLocations"`
TimeType string `json:"timeType"`
StartDate string `json:"startDate"`
JobReqID string `json:"jobReqId"`
} `json:"jobPostingInfo"`
}
func (c *NvidiaCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
cxsURL := "https://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs"
searchText := "Software Engineer"
u, err := url.Parse(targetURL)
if err == nil && u.Query().Get("q") != "" {
searchText = u.Query().Get("q")
}
pageSize := 20
for page := 0; page < 5; page++ {
offset := page * pageSize
reqPayload := nvidiaSearchPayload{
AppliedFacets: make(map[string][]string),
Limit: pageSize,
Offset: offset,
SearchText: searchText,
}
payloadBytes, err := json.Marshal(reqPayload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", cxsURL, bytes.NewBuffer(payloadBytes))
if err != nil {
if page == 0 {
return nil, err
}
break
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
if page == 0 {
return nil, err
}
break
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
break
}
var searchResp nvidiaSearchResponse
if err := json.Unmarshal(body, &searchResp); err != nil {
if page == 0 {
return nil, fmt.Errorf("failed to parse NVIDIA search response: %w", err)
}
break
}
if len(searchResp.JobPosts) == 0 {
break
}
newFound := false
for _, post := range searchResp.JobPosts {
path := post.ExternalPath
if path == "" {
continue
}
jobID := parseNvidiaJobID(path)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(path)))[:16]
}
applyURL := "https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite" + path
if !seen[applyURL] {
seen[applyURL] = true
newFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("nvidia:%s", jobID),
ApplyURL: applyURL,
})
}
}
if !newFound || len(searchResp.JobPosts) < pageSize {
break
}
}
logger.Log.Infow("NVIDIA discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *NvidiaCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseNvidiaJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
cxsDetailURL := fmt.Sprintf("https://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/job/%s", jobID)
req, err := http.NewRequestWithContext(ctx, "GET", cxsDetailURL, nil)
if err == nil {
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err == nil {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
var detailResp nvidiaDetailResponse
if err := json.Unmarshal(body, &detailResp); err == nil && detailResp.JobPostingInfo.Title != "" {
info := detailResp.JobPostingInfo
var locs []string
if info.Location != "" {
locs = append(locs, info.Location)
}
for _, addLoc := range info.AdditionalLocations {
if addLoc != "" {
locs = append(locs, addLoc)
}
}
loc := strings.Join(locs, "; ")
if loc == "" {
loc = "Redmond / Seattle, WA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("nvidia:%s", jobID),
Title: info.Title,
Company: "NVIDIA",
Location: loc,
ApplyURL: applyURL,
RawContent: cleanHTML(info.JobDescription),
}, nil
}
}
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("nvidia:%s", jobID),
Title: "Software Engineer - NVIDIA",
Company: "NVIDIA",
Location: "Redmond / Seattle, WA",
ApplyURL: applyURL,
RawContent: "See job posting at " + applyURL,
}, nil
}
func parseNvidiaJobID(u string) string {
re := regexp.MustCompile(`/job/([^/?#]+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+110
View File
@@ -0,0 +1,110 @@
package nvidia
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestNvidiaCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "/job/JR1982000") {
body := `{
"jobPostingInfo": {
"id": "JR1982000",
"title": "Senior Systems Software Engineer - GPU Infrastructure",
"jobDescription": "<p>NVIDIA is seeking senior systems software engineers to accelerate AI data centers.</p>",
"location": "Redmond, WA",
"additionalLocations": ["Seattle, WA"]
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.HasSuffix(r.URL.Path, "/jobs") {
body := `{
"total": 1,
"jobPostings": [
{
"title": "Senior Systems Software Engineer - GPU Infrastructure",
"externalPath": "/job/JR1982000",
"locationsText": "Redmond, WA",
"jobPostingId": "JR1982000"
}
]
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewNvidiaCrawlerWithClient(mockClient)
if !c.CanHandle("nvidia") {
t.Errorf("CanHandle should support nvidia")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite?q=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "nvidia:JR1982000" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite/job/JR1982000")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Senior Systems Software Engineer - GPU Infrastructure" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "NVIDIA" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Redmond, WA; Seattle, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestNvidiaCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "NVIDIA",
Crawler: NewNvidiaCrawler(),
TargetURL: "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite?q=Software+Engineer",
ExpectedCanonicalPrefix: "nvidia:",
ExpectedCompany: "NVIDIA",
})
}
+225
View File
@@ -0,0 +1,225 @@
package snowflake
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type SnowflakeCrawler struct {
client *http.Client
}
func NewSnowflakeCrawler() *SnowflakeCrawler {
return NewSnowflakeCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewSnowflakeCrawlerWithClient(client *http.Client) *SnowflakeCrawler {
return &SnowflakeCrawler{
client: client,
}
}
func (c *SnowflakeCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "snowflake" || strings.Contains(p, "careers.snowflake.com") || strings.Contains(p, "boards.greenhouse.io/snowflake")
}
type snowflakeJobsResponse struct {
Jobs []snowflakeJob `json:"jobs"`
}
type snowflakeJob struct {
ID int64 `json:"id"`
Title string `json:"title"`
AbsoluteURL string `json:"absolute_url"`
Location struct {
Name string `json:"name"`
} `json:"location"`
Offices []struct {
Name string `json:"name"`
Location string `json:"location"`
} `json:"offices"`
Departments []struct {
Name string `json:"name"`
} `json:"departments"`
Content string `json:"content"`
}
func (c *SnowflakeCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
apiURL := "https://boards-api.greenhouse.io/v1/boards/snowflake/jobs"
if targetURL != "" && strings.Contains(targetURL, "boards-api.greenhouse.io") {
apiURL = targetURL
}
body, err := c.fetchURL(ctx, apiURL)
if err != nil {
return nil, err
}
var resp snowflakeJobsResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
return nil, fmt.Errorf("failed to parse Snowflake Greenhouse API response: %w", err)
}
for _, job := range resp.Jobs {
if job.ID == 0 {
continue
}
jobIDStr := fmt.Sprintf("%d", job.ID)
applyURL := job.AbsoluteURL
if applyURL == "" {
applyURL = fmt.Sprintf("https://boards.greenhouse.io/snowflake/jobs/%s", jobIDStr)
}
if !seen[applyURL] {
seen[applyURL] = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("snowflake:%s", jobIDStr),
ApplyURL: applyURL,
})
}
}
logger.Log.Infow("Snowflake discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *SnowflakeCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseSnowflakeJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
detailAPI := fmt.Sprintf("https://boards-api.greenhouse.io/v1/boards/snowflake/jobs/%s", jobID)
body, err := c.fetchURL(ctx, detailAPI)
if err == nil && json.Valid([]byte(body)) {
var job snowflakeJob
if err := json.Unmarshal([]byte(body), &job); err == nil && job.Title != "" {
loc := job.Location.Name
if loc == "" && len(job.Offices) > 0 {
var locs []string
for _, o := range job.Offices {
if o.Name != "" {
locs = append(locs, o.Name)
}
}
loc = strings.Join(locs, "; ")
}
if loc == "" {
loc = "Bellevue / Seattle, WA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("snowflake:%s", jobID),
Title: job.Title,
Company: "Snowflake",
Location: loc,
ApplyURL: applyURL,
RawContent: cleanHTML(job.Content),
}, nil
}
}
// Fallback to HTML
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*class="app-title"[^>]*>(.*?)</h1>`)
if title == "" {
title = extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
}
if title == "" {
title = "Software Engineer - Snowflake"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("snowflake:%s", jobID),
Title: cleanHTML(title),
Company: "Snowflake",
Location: "Bellevue / Seattle, WA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *SnowflakeCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseSnowflakeJobID(u string) string {
re := regexp.MustCompile(`/jobs/(\d+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+97
View File
@@ -0,0 +1,97 @@
package snowflake
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestSnowflakeCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.HasSuffix(r.URL.Path, "/jobs/4192301") {
body := `{
"id": 4192301,
"title": "Principal Software Engineer - Core Database Engine",
"content": "&lt;p&gt;Snowflake is the Data Cloud.&lt;/p&gt;",
"location": {
"name": "Bellevue, WA"
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.HasSuffix(r.URL.Path, "/jobs") {
body := `{
"jobs": [
{
"id": 4192301,
"title": "Principal Software Engineer - Core Database Engine",
"absolute_url": "https://boards.greenhouse.io/snowflake/jobs/4192301",
"location": {
"name": "Bellevue, WA"
}
}
]
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewSnowflakeCrawlerWithClient(mockClient)
if !c.CanHandle("snowflake") {
t.Errorf("CanHandle should support snowflake")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://boards-api.greenhouse.io/v1/boards/snowflake/jobs")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "snowflake:4192301" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://boards.greenhouse.io/snowflake/jobs/4192301")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Principal Software Engineer - Core Database Engine" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Snowflake" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Bellevue, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
+226
View File
@@ -0,0 +1,226 @@
package stripe
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type StripeCrawler struct {
client *http.Client
}
func NewStripeCrawler() *StripeCrawler {
return NewStripeCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewStripeCrawlerWithClient(client *http.Client) *StripeCrawler {
return &StripeCrawler{
client: client,
}
}
func (c *StripeCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "stripe" || strings.Contains(p, "stripe.com/jobs") || strings.Contains(p, "boards.greenhouse.io/stripe")
}
type greenhouseJobsResponse struct {
Jobs []greenhouseJob `json:"jobs"`
}
type greenhouseJob struct {
ID int64 `json:"id"`
Title string `json:"title"`
AbsoluteURL string `json:"absolute_url"`
Location struct {
Name string `json:"name"`
} `json:"location"`
Offices []struct {
Name string `json:"name"`
Location string `json:"location"`
} `json:"offices"`
Departments []struct {
Name string `json:"name"`
} `json:"departments"`
Content string `json:"content"`
UpdatedAt string `json:"updated_at"`
}
func (c *StripeCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
apiURL := "https://boards-api.greenhouse.io/v1/boards/stripe/jobs"
if targetURL != "" && strings.Contains(targetURL, "boards-api.greenhouse.io") {
apiURL = targetURL
}
body, err := c.fetchURL(ctx, apiURL)
if err != nil {
return nil, err
}
var resp greenhouseJobsResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
return nil, fmt.Errorf("failed to parse Stripe Greenhouse API response: %w", err)
}
for _, job := range resp.Jobs {
if job.ID == 0 {
continue
}
jobIDStr := fmt.Sprintf("%d", job.ID)
applyURL := job.AbsoluteURL
if applyURL == "" {
applyURL = fmt.Sprintf("https://boards.greenhouse.io/stripe/jobs/%s", jobIDStr)
}
if !seen[applyURL] {
seen[applyURL] = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("stripe:%s", jobIDStr),
ApplyURL: applyURL,
})
}
}
logger.Log.Infow("Stripe discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *StripeCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseStripeJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
detailAPI := fmt.Sprintf("https://boards-api.greenhouse.io/v1/boards/stripe/jobs/%s", jobID)
body, err := c.fetchURL(ctx, detailAPI)
if err == nil && json.Valid([]byte(body)) {
var job greenhouseJob
if err := json.Unmarshal([]byte(body), &job); err == nil && job.Title != "" {
loc := job.Location.Name
if loc == "" && len(job.Offices) > 0 {
var locs []string
for _, o := range job.Offices {
if o.Name != "" {
locs = append(locs, o.Name)
}
}
loc = strings.Join(locs, "; ")
}
if loc == "" {
loc = "Seattle / Remote, WA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("stripe:%s", jobID),
Title: job.Title,
Company: "Stripe",
Location: loc,
ApplyURL: applyURL,
RawContent: cleanHTML(job.Content),
}, nil
}
}
// Fallback to HTML
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*class="app-title"[^>]*>(.*?)</h1>`)
if title == "" {
title = extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
}
if title == "" {
title = "Software Engineer - Stripe"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("stripe:%s", jobID),
Title: cleanHTML(title),
Company: "Stripe",
Location: "Seattle / Remote, WA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *StripeCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseStripeJobID(u string) string {
re := regexp.MustCompile(`/jobs/(\d+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+98
View File
@@ -0,0 +1,98 @@
package stripe
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestStripeCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.HasSuffix(r.URL.Path, "/jobs/6198201") {
body := `{
"id": 6198201,
"title": "Software Engineer - Infrastructure Platform",
"content": "&lt;p&gt;Stripe Infrastructure powers global financial networks.&lt;/p&gt;",
"location": {
"name": "Seattle, WA"
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.HasSuffix(r.URL.Path, "/jobs") {
body := `{
"jobs": [
{
"id": 6198201,
"title": "Software Engineer - Infrastructure Platform",
"absolute_url": "https://boards.greenhouse.io/stripe/jobs/6198201",
"location": {
"name": "Seattle, WA"
}
}
]
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewStripeCrawlerWithClient(mockClient)
if !c.CanHandle("stripe") {
t.Errorf("CanHandle should support stripe")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://boards-api.greenhouse.io/v1/boards/stripe/jobs")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "stripe:6198201" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://boards.greenhouse.io/stripe/jobs/6198201")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Software Engineer - Infrastructure Platform" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Stripe" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Seattle, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
+306
View File
@@ -0,0 +1,306 @@
package uber
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler"
"git.pengzhan.dev/haopengzhan/hireme/internal/logger"
)
type UberCrawler struct {
client *http.Client
}
func NewUberCrawler() *UberCrawler {
return NewUberCrawlerWithClient(&http.Client{
Timeout: 30 * time.Second,
})
}
func NewUberCrawlerWithClient(client *http.Client) *UberCrawler {
return &UberCrawler{
client: client,
}
}
func (c *UberCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "uber" || strings.Contains(p, "uber.com/careers") || strings.Contains(p, "uber.com/global/en/careers")
}
type uberSearchPayload struct {
Limit int `json:"limit"`
Page int `json:"page"`
Params map[string]interface{} `json:"params"`
}
type uberSearchResponse struct {
Status string `json:"status"`
Data struct {
TotalResults int `json:"totalResults"`
Results []uberJobItem `json:"results"`
} `json:"data"`
}
type uberJobItem struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Location string `json:"location"`
City string `json:"city"`
Country string `json:"countryName"`
Department string `json:"department"`
Team string `json:"team"`
LineOfBusiness string `json:"lineOfBusiness"`
AllLocations []string `json:"allLocations"`
CreationDate string `json:"creationDate"`
WhatYouWillDo string `json:"whatYouWillDo"`
BasicQualifications string `json:"basicQualifications"`
}
func (c *UberCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
seen := make(map[string]bool)
var discovered []crawler.DiscoveredJob
apiURL := "https://www.uber.com/api/loadSearchJobsResults"
keyword := "Software Engineer"
u, err := url.Parse(targetURL)
if err == nil && u.Query().Get("query") != "" {
keyword = u.Query().Get("query")
}
pageSize := 20
for page := 0; page < 5; page++ {
reqPayload := uberSearchPayload{
Limit: pageSize,
Page: page,
Params: map[string]interface{}{
"query": keyword,
},
}
payloadBytes, err := json.Marshal(reqPayload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
if page == 0 {
return nil, err
}
break
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("x-csrf-token", "x")
resp, err := c.client.Do(req)
if err != nil {
if page == 0 {
return nil, err
}
break
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
break
}
var searchResp uberSearchResponse
if err := json.Unmarshal(body, &searchResp); err != nil {
if page == 0 {
return nil, fmt.Errorf("failed to parse Uber search response: %w", err)
}
break
}
results := searchResp.Data.Results
if len(results) == 0 {
break
}
newFound := false
for _, job := range results {
if job.ID == 0 {
continue
}
jobIDStr := fmt.Sprintf("%d", job.ID)
applyURL := fmt.Sprintf("https://www.uber.com/global/en/careers/list/%s/", jobIDStr)
if !seen[applyURL] {
seen[applyURL] = true
newFound = true
discovered = append(discovered, crawler.DiscoveredJob{
CanonicalID: fmt.Sprintf("uber:%s", jobIDStr),
ApplyURL: applyURL,
})
}
}
if !newFound || len(results) < pageSize {
break
}
}
logger.Log.Infow("Uber discovery finished", "found_count", len(discovered))
return discovered, nil
}
func (c *UberCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
jobID := parseUberJobID(applyURL)
if jobID == "" {
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
}
// Try API endpoint: https://www.uber.com/api/loadJobDetails?id={id}
detailAPI := fmt.Sprintf("https://www.uber.com/api/loadJobDetails?id=%s", jobID)
req, err := http.NewRequestWithContext(ctx, "GET", detailAPI, nil)
if err == nil {
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("x-csrf-token", "x")
resp, err := c.client.Do(req)
if err == nil {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
var detailResp struct {
Data uberJobItem `json:"data"`
}
if err := json.Unmarshal(body, &detailResp); err == nil && detailResp.Data.Title != "" {
j := detailResp.Data
var sb strings.Builder
if j.Description != "" {
sb.WriteString("## Description\n\n")
sb.WriteString(cleanHTML(j.Description))
sb.WriteString("\n\n")
}
if j.WhatYouWillDo != "" {
sb.WriteString("## What You'll Do\n\n")
sb.WriteString(cleanHTML(j.WhatYouWillDo))
sb.WriteString("\n\n")
}
if j.BasicQualifications != "" {
sb.WriteString("## Basic Qualifications\n\n")
sb.WriteString(cleanHTML(j.BasicQualifications))
sb.WriteString("\n\n")
}
loc := j.Location
if loc == "" && j.City != "" {
loc = fmt.Sprintf("%s, %s", j.City, j.Country)
}
if loc == "" {
loc = "Seattle, WA"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("uber:%s", jobID),
Title: j.Title,
Company: "Uber",
Location: loc,
ApplyURL: applyURL,
RawContent: sb.String(),
}, nil
}
}
}
// Fallback to HTML
htmlBody, err := c.fetchURL(ctx, applyURL)
if err != nil {
return nil, err
}
title := extractRegex(htmlBody, `<h1[^>]*>(.*?)</h1>`)
if title == "" {
title = "Software Engineer - Uber"
}
return &crawler.ExtractedJob{
CanonicalID: fmt.Sprintf("uber:%s", jobID),
Title: cleanHTML(title),
Company: "Uber",
Location: "Seattle, WA",
ApplyURL: applyURL,
RawContent: cleanHTML(htmlBody),
}, nil
}
func (c *UberCrawler) fetchURL(ctx context.Context, urlStr string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/html, */*")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP GET failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP GET returned status %d for %s", resp.StatusCode, urlStr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(body), nil
}
func parseUberJobID(u string) string {
re := regexp.MustCompile(`/list/([0-9A-Za-z]+)`)
m := re.FindStringSubmatch(u)
if len(m) >= 2 {
return m[1]
}
return ""
}
func extractRegex(s, pattern string) string {
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
func cleanHTML(s string) string {
s = strings.ReplaceAll(s, "<b>", "**")
s = strings.ReplaceAll(s, "</b>", "**")
s = strings.ReplaceAll(s, "<strong>", "**")
s = strings.ReplaceAll(s, "</strong>", "**")
s = strings.ReplaceAll(s, "<br>", "\n")
s = strings.ReplaceAll(s, "<br/>", "\n")
s = strings.ReplaceAll(s, "<br />", "\n")
s = strings.ReplaceAll(s, "<li>", "- ")
s = strings.ReplaceAll(s, "</li>", "\n")
s = strings.ReplaceAll(s, "<ul>", "\n")
s = strings.ReplaceAll(s, "</ul>", "\n")
s = strings.ReplaceAll(s, "<p>", "\n")
s = strings.ReplaceAll(s, "</p>", "\n")
reTags := regexp.MustCompile(`<[^>]*>`)
s = reTags.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
+114
View File
@@ -0,0 +1,114 @@
package uber
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"git.pengzhan.dev/haopengzhan/hireme/internal/crawler/crawlertest"
)
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func TestUberCrawler_DiscoverAndFetch(t *testing.T) {
mockClient := &http.Client{
Transport: roundTripFunc(func(r *http.Request) *http.Response {
if strings.Contains(r.URL.Path, "loadSearchJobsResults") {
body := `{
"status": "success",
"data": {
"totalResults": 1,
"results": [
{
"id": 139820,
"title": "Software Engineer II - Core Compute & Storage",
"location": "Seattle, WA",
"city": "Seattle",
"countryName": "USA"
}
]
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
if strings.Contains(r.URL.Path, "loadJobDetails") {
body := `{
"data": {
"id": 139820,
"title": "Software Engineer II - Core Compute & Storage",
"description": "<p>Uber Infra powers global dispatch and real-time computation.</p>",
"whatYouWillDo": "<li>Architect scalable storage solutions using Go and Java.</li>",
"basicQualifications": "<li>2+ years of backend software development experience.</li>",
"location": "Seattle, WA"
}
}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("not found")),
Header: make(http.Header),
}
}),
}
c := NewUberCrawlerWithClient(mockClient)
if !c.CanHandle("uber") {
t.Errorf("CanHandle should support uber")
}
discovered, err := c.DiscoverJobs(context.Background(), "https://www.uber.com/global/en/careers/search/?query=Software+Engineer")
if err != nil {
t.Fatalf("DiscoverJobs failed: %v", err)
}
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered job, got %d", len(discovered))
}
if discovered[0].CanonicalID != "uber:139820" {
t.Errorf("unexpected canonical ID: %s", discovered[0].CanonicalID)
}
extracted, err := c.FetchJobDetail(context.Background(), "https://www.uber.com/global/en/careers/list/139820/")
if err != nil {
t.Fatalf("FetchJobDetail failed: %v", err)
}
if extracted.Title != "Software Engineer II - Core Compute & Storage" {
t.Errorf("unexpected title: %s", extracted.Title)
}
if extracted.Company != "Uber" {
t.Errorf("unexpected company: %s", extracted.Company)
}
if extracted.Location != "Seattle, WA" {
t.Errorf("unexpected location: %s", extracted.Location)
}
}
func TestUberCrawler_LiveIntegration(t *testing.T) {
crawlertest.RunLiveIntegration(t, crawlertest.LiveTestConfig{
PlatformName: "Uber",
Crawler: NewUberCrawler(),
TargetURL: "https://www.uber.com/global/en/careers/search/?query=Software+Engineer",
ExpectedCanonicalPrefix: "uber:",
ExpectedCompany: "Uber",
})
}