Compare commits
10
Commits
286278c1ec
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e4480878 | ||
|
|
55d4f4a0d9 | ||
|
|
0de312edbd | ||
|
|
7ec91f384f | ||
|
|
367929e7d0 | ||
|
|
af1ee1c9b3 | ||
|
|
f8bfc4b5e3 | ||
|
|
3d0156a7d8 | ||
|
|
150e3dfbd5 | ||
|
|
86a092ecd3 |
@@ -1,3 +1,4 @@
|
||||
data/db
|
||||
vendor/
|
||||
build/
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": ["..."]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -12,38 +12,48 @@ import (
|
||||
)
|
||||
|
||||
type DeepSeekAnalyzer struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
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, "/"),
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
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
|
||||
|
||||
+36
-17
@@ -7,40 +7,49 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeminiAnalyzer struct {
|
||||
apiKey string
|
||||
client *http.Client
|
||||
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,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+116
-12
@@ -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
|
||||
LogLevel string
|
||||
LLMProvider string // "gemini", "deepseek", "mock"
|
||||
GeminiAPIKey string
|
||||
DeepSeekAPIKey string
|
||||
DeepSeekBaseURL string
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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,13 +4,14 @@ 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"`
|
||||
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
|
||||
HardKeywords []string `yaml:"hard_keywords"`
|
||||
AnalysisThreshold float64 `yaml:"analysis_threshold"`
|
||||
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
|
||||
}
|
||||
|
||||
func LoadFilterConfig(path string) (*FilterConfig, error) {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+74
-5
@@ -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)
|
||||
return err
|
||||
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, ¬null, &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, ¬null, &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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¬es, &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, ¬es, &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, ¬es, &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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert target %s: %w", t.Name, err)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package filter
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hireme/hireme/internal/db"
|
||||
"git.pengzhan.dev/haopengzhan/hireme/internal/db"
|
||||
)
|
||||
|
||||
type FilterResult struct {
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+55
-24
@@ -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
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+108
-34
@@ -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 & 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 & 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,14 +106,32 @@ 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 .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>
|
||||
{{ 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 }}" 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">
|
||||
🚫 Disqualified
|
||||
</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">
|
||||
✗ Not Qualified
|
||||
</span>
|
||||
<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 }}
|
||||
<span class="text-slate-500 text-[11px]">–</span>
|
||||
@@ -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 -->
|
||||
<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
|
||||
</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 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 }} • Ingested {{ formatDate .CrawledAt }}</p>
|
||||
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • 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>
|
||||
{{ 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>
|
||||
<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.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-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-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
|
||||
<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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
+180
-19
@@ -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 & 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 & 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);
|
||||
btn.classList.remove('text-indigo-400', 'border-indigo-500');
|
||||
btn.classList.add('text-slate-400', 'border-transparent');
|
||||
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);
|
||||
activeBtn.classList.remove('text-slate-400', 'border-transparent');
|
||||
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
|
||||
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 & 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 }}`
|
||||
|
||||
@@ -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 & 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 (≤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 (>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 (>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 }}`
|
||||
@@ -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">
|
||||
<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>
|
||||
<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 }}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+138
-43
@@ -9,38 +9,58 @@ 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
|
||||
isRunning atomic.Bool
|
||||
workerCount int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
db *db.DB
|
||||
analyzer analyzer.LLMAnalyzer
|
||||
filterPipe *filter.FilterPipeline
|
||||
analysisThreshold float64
|
||||
isRunning atomic.Bool
|
||||
workerCount int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
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,
|
||||
workerCount: numWorkers,
|
||||
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
|
||||
}
|
||||
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.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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "<p>Databricks is creating the world's most performant unified analytics engine.</p>",
|
||||
"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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: "",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "<p>Snowflake is the Data Cloud.</p>",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "<p>Stripe Infrastructure powers global financial networks.</p>",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user