feat: initialize project structure with SQLite database, crawler framework, and LLM-based JD analysis integration
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
data/db
|
||||||
|
vendor/
|
||||||
|
build/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.PHONY: all build run test clean
|
||||||
|
|
||||||
|
BINARY_NAME=hireme
|
||||||
|
DB_PATH=data/db/hireme.db
|
||||||
|
CONFIG_PATH=data/config/targets.yaml
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build:
|
||||||
|
@mkdir -p build data/db data/config
|
||||||
|
GOSUMDB=off go build -o build/$(BINARY_NAME) ./cmd/hireme
|
||||||
|
|
||||||
|
run: build
|
||||||
|
@mkdir -p data/db data/config
|
||||||
|
./build/$(BINARY_NAME) --port $(PORT) --db $(DB_PATH) --target-config $(CONFIG_PATH)
|
||||||
|
|
||||||
|
test:
|
||||||
|
GOSUMDB=off go test -v ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f build/$(BINARY_NAME)
|
||||||
|
rm -rf data/db/*.db
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"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/service"
|
||||||
|
"github.com/hireme/hireme/internal/ui"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
|
func newRootCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "hireme",
|
||||||
|
Short: "Hireme (GJCC) - High Performance Job Application Command Center",
|
||||||
|
Long: `Hireme is a high-performance single-binary job application and tracking command center written in Go.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
log.Println("==========================================================")
|
||||||
|
log.Println("🚀 Starting Hireme (GJCC) Job Application Command Center")
|
||||||
|
log.Println("==========================================================")
|
||||||
|
log.Printf("[Config] Port: %s | DB Path: %s | Target Config: %s | Active LLM: %s",
|
||||||
|
cfg.Port, cfg.DBPath, cfg.TargetConfig, cfg.LLMProvider)
|
||||||
|
|
||||||
|
// Ensure DB directory exists
|
||||||
|
dbDir := filepath.Dir(cfg.DBPath)
|
||||||
|
if dbDir != "." && dbDir != "" {
|
||||||
|
_ = os.MkdirAll(dbDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Database
|
||||||
|
database, err := db.InitDB(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fatal DB error: %w", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Initialize LLM Analyzer
|
||||||
|
llmAnalyzer, err := analyzer.NewAnalyzer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fatal Analyzer setup error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Pipeline Service
|
||||||
|
pipeline := service.NewPipelineService(database, llmAnalyzer)
|
||||||
|
|
||||||
|
// Load targets from YAML config if database is empty
|
||||||
|
if err := pipeline.LoadTargetsFromYAML(cfg.TargetConfig); err != nil {
|
||||||
|
log.Printf("[Main] Target YAML loading status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup UI Server & Routes
|
||||||
|
server := ui.NewServer(cfg, database, pipeline)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||||
|
log.Printf("✨ Hireme Command Center is live at http://localhost:%s", cfg.Port)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = config.RegisterFlags(cmd.Flags())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rootCmd := newRootCmd()
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hireme/hireme/internal/analyzer"
|
||||||
|
"github.com/hireme/hireme/internal/config"
|
||||||
|
"github.com/hireme/hireme/internal/db"
|
||||||
|
"github.com/hireme/hireme/internal/service"
|
||||||
|
"github.com/hireme/hireme/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFullApplicationIntegration(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tempDir, "test_hireme.db")
|
||||||
|
yamlPath := filepath.Join(tempDir, "test_targets.yaml")
|
||||||
|
|
||||||
|
yamlContent := `targets:
|
||||||
|
- name: "Google Software Engineer (Seattle)"
|
||||||
|
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
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test targets.yaml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Port: "8080",
|
||||||
|
DBPath: dbPath,
|
||||||
|
TargetConfig: yamlPath,
|
||||||
|
LLMProvider: "mock",
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := db.InitDB(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitDB failed: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
llmAnalyzer, err := analyzer.NewAnalyzer(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAnalyzer failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline := service.NewPipelineService(database, llmAnalyzer)
|
||||||
|
|
||||||
|
// Verify target loading from YAML into SQLite
|
||||||
|
if err := pipeline.LoadTargetsFromYAML(cfg.TargetConfig); err != nil {
|
||||||
|
t.Fatalf("LoadTargetsFromYAML failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := database.GetAllTargets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllTargets failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(targets) != 1 {
|
||||||
|
t.Errorf("expected 1 target from YAML, got %d", len(targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify jobs start empty on clean SQLite DB startup (0 jobs)
|
||||||
|
jobs, err := database.GetJobsWithAnalysis()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetJobsWithAnalysis failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobs) != 0 {
|
||||||
|
t.Errorf("expected 0 jobs on clean startup, got %d", len(jobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup UI Server & routes
|
||||||
|
server := ui.NewServer(cfg, database, pipeline)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Test GET /
|
||||||
|
reqRoot := httptest.NewRequest("GET", "/", nil)
|
||||||
|
wRoot := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(wRoot, reqRoot)
|
||||||
|
|
||||||
|
if wRoot.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 OK for GET /, got %d", wRoot.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(wRoot.Body.String(), "Hireme") {
|
||||||
|
t.Errorf("expected homepage HTML to contain 'Hireme'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GET /targets
|
||||||
|
reqTargets := httptest.NewRequest("GET", "/targets", nil)
|
||||||
|
wTargets := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(wTargets, reqTargets)
|
||||||
|
|
||||||
|
if wTargets.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 OK for GET /targets, got %d", wTargets.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GET /jobs
|
||||||
|
reqJobs := httptest.NewRequest("GET", "/jobs", nil)
|
||||||
|
wJobs := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(wJobs, reqJobs)
|
||||||
|
|
||||||
|
if wJobs.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 OK for GET /jobs, got %d", wJobs.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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"
|
||||||
|
is_active: true
|
||||||
|
- name: "DeepMind"
|
||||||
|
target_url: "https://www.google.com/about/careers/applications/jobs/results?company=DeepMind"
|
||||||
|
platform: "google_jobs"
|
||||||
|
is_active: true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module github.com/hireme/hireme
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.42
|
||||||
|
github.com/spf13/cobra v1.9.1
|
||||||
|
github.com/spf13/pflag v1.0.6
|
||||||
|
golang.org/x/net v0.52.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
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=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
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=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hireme/hireme/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnalysisOutput struct {
|
||||||
|
MatchedHighlights []string `json:"matched_highlights"`
|
||||||
|
MissingHighlights []string `json:"missing_highlights"`
|
||||||
|
TailoringSuggestions []string `json:"tailoring_suggestions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMAnalyzer interface {
|
||||||
|
AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAnalyzer(cfg *config.Config) (LLMAnalyzer, error) {
|
||||||
|
provider := strings.ToLower(cfg.LLMProvider)
|
||||||
|
switch provider {
|
||||||
|
case "gemini":
|
||||||
|
if cfg.GeminiAPIKey == "" {
|
||||||
|
return nil, fmt.Errorf("GEMINI_API_KEY is required for provider 'gemini'")
|
||||||
|
}
|
||||||
|
return NewGeminiAnalyzer(cfg.GeminiAPIKey), 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
|
||||||
|
case "mock":
|
||||||
|
return NewMockAnalyzer(), nil
|
||||||
|
default:
|
||||||
|
return NewMockAnalyzer(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeepSeekAnalyzer struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeepSeekAnalyzer(apiKey, baseURL string) *DeepSeekAnalyzer {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.deepseek.com"
|
||||||
|
}
|
||||||
|
return &DeepSeekAnalyzer{
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
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.
|
||||||
|
|
||||||
|
Job Title: %s
|
||||||
|
Job Content:
|
||||||
|
%s`, jobTitle, 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},
|
||||||
|
},
|
||||||
|
"response_format": map[string]string{
|
||||||
|
"type": "json_object",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal DeepSeek payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/chat/completions", d.baseURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create DeepSeek request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.apiKey))
|
||||||
|
|
||||||
|
resp, err := d.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("DeepSeek API call failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read DeepSeek response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("DeepSeek API error (status %d): %s", resp.StatusCode, string(respBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var dsResp struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respBytes, &dsResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode DeepSeek envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dsResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty choices in DeepSeek response")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawJSON := cleanJSONResponse(dsResp.Choices[0].Message.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 &output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanJSONResponse(raw string) string {
|
||||||
|
cleaned := strings.TrimSpace(raw)
|
||||||
|
if strings.HasPrefix(cleaned, "```json") {
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, "```json")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "```")
|
||||||
|
} else if strings.HasPrefix(cleaned, "```") {
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, "```")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "```")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeminiAnalyzer struct {
|
||||||
|
apiKey string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGeminiAnalyzer(apiKey string) *GeminiAnalyzer {
|
||||||
|
return &GeminiAnalyzer{
|
||||||
|
apiKey: apiKey,
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Job Title: %s
|
||||||
|
Job Content:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Respond STRICTLY with valid JSON matching the format:
|
||||||
|
{"matched_highlights": [...], "missing_highlights": [...], "tailoring_suggestions": [...]}`, jobTitle, jobTitle, rawContent)
|
||||||
|
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"contents": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"parts": []map[string]string{
|
||||||
|
{"text": prompt},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"generationConfig": map[string]interface{}{
|
||||||
|
"response_mime_type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal Gemini payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=%s", g.apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create Gemini HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := g.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Gemini API call failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read Gemini response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Gemini API error (status %d): %s", resp.StatusCode, string(respBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var geminiResp struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respBytes, &geminiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode Gemini envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(geminiResp.Candidates) == 0 || len(geminiResp.Candidates[0].Content.Parts) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty candidates in Gemini response")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawJSON := geminiResp.Candidates[0].Content.Parts[0].Text
|
||||||
|
rawJSON = cleanJSONResponse(rawJSON)
|
||||||
|
|
||||||
|
var output AnalysisOutput
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &output); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse Gemini JSON output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &output, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockAnalyzer struct{}
|
||||||
|
|
||||||
|
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{},
|
||||||
|
MissingHighlights: []string{},
|
||||||
|
TailoringSuggestions: []string{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBPath string
|
||||||
|
TargetConfig string
|
||||||
|
LLMProvider string // "gemini", "deepseek", "mock"
|
||||||
|
GeminiAPIKey string
|
||||||
|
DeepSeekAPIKey string
|
||||||
|
DeepSeekBaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterFlags(flags *pflag.FlagSet) *Config {
|
||||||
|
cfg := &Config{}
|
||||||
|
|
||||||
|
flags.StringVarP(&cfg.Port, "port", "p", getEnvOrDefault("PORT", "8080"), "HTTP server port")
|
||||||
|
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.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")
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Normalize() {
|
||||||
|
if c.LLMProvider == "" {
|
||||||
|
if c.GeminiAPIKey != "" {
|
||||||
|
c.LLMProvider = "gemini"
|
||||||
|
} else if c.DeepSeekAPIKey != "" {
|
||||||
|
c.LLMProvider = "deepseek"
|
||||||
|
} else {
|
||||||
|
c.LLMProvider = "mock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.LLMProvider = strings.ToLower(c.LLMProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package crawler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscoveredJob struct {
|
||||||
|
CanonicalID string
|
||||||
|
ApplyURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractedJob struct {
|
||||||
|
CanonicalID string
|
||||||
|
Title string
|
||||||
|
Company string
|
||||||
|
Location string
|
||||||
|
ApplyURL string
|
||||||
|
RawContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateCanonicalID(company, title, applyURL string) string {
|
||||||
|
data := fmt.Sprintf("%s|%s|%s", company, title, applyURL)
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteCrawler interface {
|
||||||
|
CanHandle(platform string) bool
|
||||||
|
DiscoverJobs(ctx context.Context, targetURL string) ([]DiscoveredJob, error)
|
||||||
|
FetchJobDetail(ctx context.Context, applyURL string) (*ExtractedJob, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package crawler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenericCrawler struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGenericCrawler() *GenericCrawler {
|
||||||
|
return &GenericCrawler{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GenericCrawler) CanHandle(platform string) bool {
|
||||||
|
p := strings.ToLower(platform)
|
||||||
|
return p == "generic" || p == "greenhouse" || p == "lever" || p == "linkedin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GenericCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]DiscoveredJob, error) {
|
||||||
|
cid := GenerateCanonicalID(extractCompanyFromURL(targetURL), "Generic Job", targetURL)
|
||||||
|
return []DiscoveredJob{
|
||||||
|
{
|
||||||
|
CanonicalID: cid,
|
||||||
|
ApplyURL: targetURL,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GenericCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*ExtractedJob, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", applyURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP status error %d for URL %s", resp.StatusCode, applyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawText := ExtractTextFromHTML(string(body))
|
||||||
|
title := ExtractTitleFromHTML(string(body))
|
||||||
|
if title == "" {
|
||||||
|
title = "Job Opportunity"
|
||||||
|
}
|
||||||
|
|
||||||
|
company := extractCompanyFromURL(applyURL)
|
||||||
|
cid := GenerateCanonicalID(company, title, applyURL)
|
||||||
|
|
||||||
|
return &ExtractedJob{
|
||||||
|
CanonicalID: cid,
|
||||||
|
Title: title,
|
||||||
|
Company: company,
|
||||||
|
Location: "Remote / Listed Location",
|
||||||
|
ApplyURL: applyURL,
|
||||||
|
RawContent: rawText,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractTitleFromHTML(htmlContent string) string {
|
||||||
|
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var title string
|
||||||
|
var f func(*html.Node)
|
||||||
|
f = func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
|
||||||
|
title = strings.TrimSpace(n.FirstChild.Data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
f(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f(doc)
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractTextFromHTML(htmlContent string) string {
|
||||||
|
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||||
|
if err != nil {
|
||||||
|
return htmlContent
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
var f func(*html.Node)
|
||||||
|
f = func(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
txt := strings.TrimSpace(n.Data)
|
||||||
|
if txt != "" {
|
||||||
|
b.WriteString(txt)
|
||||||
|
b.WriteString(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
f(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f(doc)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCompanyFromURL(u string) string {
|
||||||
|
if strings.Contains(u, "google") {
|
||||||
|
return "Google"
|
||||||
|
} else if strings.Contains(u, "linkedin") {
|
||||||
|
return "LinkedIn Network"
|
||||||
|
} else if strings.Contains(u, "greenhouse") {
|
||||||
|
return "Greenhouse Client"
|
||||||
|
} else if strings.Contains(u, "lever") {
|
||||||
|
return "Lever Client"
|
||||||
|
}
|
||||||
|
return "Tech Corp"
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitDB(dbPath string) (*DB, error) {
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "data/db/hireme.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database := &DB{DB: conn}
|
||||||
|
if err := database.migrate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to run sqlite migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[DB] SQLite database initialized successfully at %s", dbPath)
|
||||||
|
return database, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) migrate() error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS search_targets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
last_crawled_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS raw_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
canonical_id TEXT UNIQUE NOT NULL,
|
||||||
|
target_id INTEGER REFERENCES search_targets(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
company TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
apply_url TEXT NOT NULL,
|
||||||
|
raw_content TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
crawled_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,
|
||||||
|
score REAL DEFAULT 0.0,
|
||||||
|
is_qualified BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
matched_highlights JSON,
|
||||||
|
missing_highlights JSON,
|
||||||
|
tailoring_suggestions JSON,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
analyzed_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS job_applications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'SAVED',
|
||||||
|
notes TEXT,
|
||||||
|
applied_at DATETIME,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := db.Exec(schema)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchTarget struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
Platform string `json:"platform"` // 'linkedin', 'google_jobs', 'greenhouse', 'lever'
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
LastCrawledAt *time.Time `json:"last_crawled_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawJob struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CanonicalID string `json:"canonical_id"` // SHA256(company + title + applyURL)
|
||||||
|
TargetID *int64 `json:"target_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
ApplyURL string `json:"apply_url"`
|
||||||
|
RawContent string `json:"raw_content"`
|
||||||
|
Status string `json:"status"` // 'PENDING', 'PROCESSED', 'FAILED', 'DUPLICATE'
|
||||||
|
CrawledAt time.Time `json:"crawled_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobAnalysis struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RawJobID int64 `json:"raw_job_id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
IsQualified bool `json:"is_qualified"`
|
||||||
|
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'
|
||||||
|
AnalyzedAt *time.Time `json:"analyzed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobApplication struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RawJobID int64 `json:"raw_job_id"`
|
||||||
|
Status string `json:"status"` // 'SAVED', 'APPLIED', 'INTERVIEW', 'REJECTED', 'OFFER'
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
AppliedAt *time.Time `json:"applied_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobWithAnalysis combines RawJob and JobAnalysis for UI rendering
|
||||||
|
type JobWithAnalysis struct {
|
||||||
|
RawJob
|
||||||
|
Analysis *JobAnalysis `json:"analysis"`
|
||||||
|
App *JobApplication `json:"application"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
var target SearchTarget
|
||||||
|
var lastCrawled sql.NullTime
|
||||||
|
err := db.QueryRow(query, name, targetURL, platform).Scan(
|
||||||
|
&target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastCrawled.Valid {
|
||||||
|
target.LastCrawledAt = &lastCrawled.Time
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetActiveTargets() ([]SearchTarget, error) {
|
||||||
|
query := `SELECT id, name, target_url, platform, is_active, last_crawled_at FROM search_targets WHERE is_active = 1`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var targets []SearchTarget
|
||||||
|
for rows.Next() {
|
||||||
|
var t SearchTarget
|
||||||
|
var lastCrawled sql.NullTime
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.TargetURL, &t.Platform, &t.IsActive, &lastCrawled); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastCrawled.Valid {
|
||||||
|
t.LastCrawledAt = &lastCrawled.Time
|
||||||
|
}
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
return targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAllTargets() ([]SearchTarget, error) {
|
||||||
|
query := `SELECT id, name, target_url, platform, is_active, last_crawled_at FROM search_targets ORDER BY id DESC`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var targets []SearchTarget
|
||||||
|
for rows.Next() {
|
||||||
|
var t SearchTarget
|
||||||
|
var lastCrawled sql.NullTime
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.TargetURL, &t.Platform, &t.IsActive, &lastCrawled); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastCrawled.Valid {
|
||||||
|
t.LastCrawledAt = &lastCrawled.Time
|
||||||
|
}
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
return targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ToggleTargetStatus(id int64) error {
|
||||||
|
query := `UPDATE search_targets SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END WHERE id = ?`
|
||||||
|
_, err := db.Exec(query, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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%
|
||||||
|
existingJobs, err := db.getAllRawJobsForDeduplication()
|
||||||
|
if err == nil {
|
||||||
|
for _, existing := range existingJobs {
|
||||||
|
if existing.CanonicalID == canonicalID {
|
||||||
|
return nil, false, nil // Duplicate Canonical ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vector Cosine Similarity check on raw_content (run only for substantial bodies >100 chars)
|
||||||
|
if len(existing.RawContent) >= 100 && len(rawContent) >= 100 {
|
||||||
|
similarity := CalculateCosineSimilarity(existing.RawContent, rawContent)
|
||||||
|
if similarity >= 0.99 {
|
||||||
|
return nil, false, nil // Content >= 99% vector match -> Deduplicate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO raw_jobs (canonical_id, target_id, title, company, location, apply_url, raw_content, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING')
|
||||||
|
ON CONFLICT(canonical_id) DO NOTHING
|
||||||
|
RETURNING id, canonical_id, target_id, title, company, location, apply_url, raw_content, status, crawled_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.Status, &job.CrawledAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, false, nil // Duplicate job
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if tID.Valid {
|
||||||
|
job.TargetID = &tID.Int64
|
||||||
|
}
|
||||||
|
return &job, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CanonicalIDExists(canonicalID string) (bool, error) {
|
||||||
|
query := `SELECT EXISTS(SELECT 1 FROM raw_jobs WHERE canonical_id = ?)`
|
||||||
|
var exists bool
|
||||||
|
err := db.QueryRow(query, canonicalID).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) getAllRawJobsForDeduplication() ([]RawJob, error) {
|
||||||
|
query := `SELECT id, canonical_id, raw_content FROM raw_jobs`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var jobs []RawJob
|
||||||
|
for rows.Next() {
|
||||||
|
var j RawJob
|
||||||
|
if err := rows.Scan(&j.ID, &j.CanonicalID, &j.RawContent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateCosineSimilarity tokenizes text into term frequency vectors and returns cosine similarity in range [0.0, 1.0]
|
||||||
|
func CalculateCosineSimilarity(text1, text2 string) float64 {
|
||||||
|
vec1 := tokenizeToVector(text1)
|
||||||
|
vec2 := tokenizeToVector(text2)
|
||||||
|
|
||||||
|
if len(vec1) == 0 || len(vec2) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var dotProduct float64
|
||||||
|
for term, freq1 := range vec1 {
|
||||||
|
if freq2, exists := vec2[term]; exists {
|
||||||
|
dotProduct += float64(freq1 * freq2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var norm1 float64
|
||||||
|
for _, freq := range vec1 {
|
||||||
|
norm1 += float64(freq * freq)
|
||||||
|
}
|
||||||
|
norm1 = math.Sqrt(norm1)
|
||||||
|
|
||||||
|
var norm2 float64
|
||||||
|
for _, freq := range vec2 {
|
||||||
|
norm2 += float64(freq * freq)
|
||||||
|
}
|
||||||
|
norm2 = math.Sqrt(norm2)
|
||||||
|
|
||||||
|
if norm1 == 0 || norm2 == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return dotProduct / (norm1 * norm2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wordRegex = regexp.MustCompile(`[a-zA-Z0-9]+`)
|
||||||
|
|
||||||
|
var stopWords = map[string]bool{
|
||||||
|
"the": true, "and": true, "for": true, "with": true, "this": true,
|
||||||
|
"that": true, "from": true, "are": true, "was": true, "were": true,
|
||||||
|
"google": true, "position": true, "apply": true, "url": true, "location": true,
|
||||||
|
"seattle": true, "usa": true, "job": true, "http": true, "https": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenizeToVector(text string) map[string]int {
|
||||||
|
words := wordRegex.FindAllString(strings.ToLower(text), -1)
|
||||||
|
freqMap := make(map[string]int)
|
||||||
|
for _, w := range words {
|
||||||
|
if len(w) > 2 && !stopWords[w] {
|
||||||
|
freqMap[w]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return freqMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetPendingRawJobs() ([]RawJob, error) {
|
||||||
|
query := `SELECT id, canonical_id, target_id, title, company, location, apply_url, raw_content, status, crawled_at FROM raw_jobs WHERE status = 'PENDING'`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var jobs []RawJob
|
||||||
|
for rows.Next() {
|
||||||
|
var j RawJob
|
||||||
|
var tID sql.NullInt64
|
||||||
|
if err := rows.Scan(&j.ID, &j.CanonicalID, &tID, &j.Title, &j.Company, &j.Location, &j.ApplyURL, &j.RawContent, &j.Status, &j.CrawledAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tID.Valid {
|
||||||
|
j.TargetID = &tID.Int64
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateRawJobStatus(id int64, status string) error {
|
||||||
|
query := `UPDATE raw_jobs SET status = ? WHERE id = ?`
|
||||||
|
_, err := db.Exec(query, status, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job Analyses Queries
|
||||||
|
func (db *DB) CreateJobAnalysis(rawJobID int64, 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
|
||||||
|
`
|
||||||
|
_, err := db.Exec(query, rawJobID, score, isQualified, status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, matchedJSON, missingJSON, suggestionsJSON string) error {
|
||||||
|
query := `
|
||||||
|
UPDATE job_analyses
|
||||||
|
SET matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE raw_job_id = ?
|
||||||
|
`
|
||||||
|
_, err := db.Exec(query, 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.status, 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
|
||||||
|
FROM raw_jobs r
|
||||||
|
JOIN job_analyses a ON r.id = a.raw_job_id
|
||||||
|
WHERE a.is_qualified = 1 AND a.status = 'PENDING'
|
||||||
|
`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []JobWithAnalysis
|
||||||
|
for rows.Next() {
|
||||||
|
var item JobWithAnalysis
|
||||||
|
var tID sql.NullInt64
|
||||||
|
var aID int64
|
||||||
|
var score float64
|
||||||
|
var isQualified bool
|
||||||
|
var 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.Status, &item.CrawledAt,
|
||||||
|
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tID.Valid {
|
||||||
|
item.TargetID = &tID.Int64
|
||||||
|
}
|
||||||
|
analysis := &JobAnalysis{
|
||||||
|
ID: aID,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Score: score,
|
||||||
|
IsQualified: isQualified,
|
||||||
|
MatchedHighlights: matched,
|
||||||
|
MissingHighlights: missing,
|
||||||
|
TailoringSuggestions: suggestions,
|
||||||
|
Status: aStatus,
|
||||||
|
}
|
||||||
|
if analyzedAt.Valid {
|
||||||
|
analysis.AnalyzedAt = &analyzedAt.Time
|
||||||
|
}
|
||||||
|
item.Analysis = analysis
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.status, 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,
|
||||||
|
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
|
||||||
|
`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []JobWithAnalysis
|
||||||
|
for rows.Next() {
|
||||||
|
var item JobWithAnalysis
|
||||||
|
var tID sql.NullInt64
|
||||||
|
var aID, appID sql.NullInt64
|
||||||
|
var score sql.NullFloat64
|
||||||
|
var isQualified sql.NullBool
|
||||||
|
var 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.Status, &item.CrawledAt,
|
||||||
|
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||||
|
&appID, &appStatus, ¬es, &appliedAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tID.Valid {
|
||||||
|
item.TargetID = &tID.Int64
|
||||||
|
}
|
||||||
|
if aID.Valid {
|
||||||
|
analysis := &JobAnalysis{
|
||||||
|
ID: aID.Int64,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Score: score.Float64,
|
||||||
|
IsQualified: isQualified.Bool,
|
||||||
|
MatchedHighlights: matched.String,
|
||||||
|
MissingHighlights: missing.String,
|
||||||
|
TailoringSuggestions: suggestions.String,
|
||||||
|
Status: aStatus.String,
|
||||||
|
}
|
||||||
|
if analyzedAt.Valid {
|
||||||
|
analysis.AnalyzedAt = &analyzedAt.Time
|
||||||
|
}
|
||||||
|
item.Analysis = analysis
|
||||||
|
}
|
||||||
|
if appID.Valid {
|
||||||
|
app := &JobApplication{
|
||||||
|
ID: appID.Int64,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Status: appStatus.String,
|
||||||
|
Notes: notes.String,
|
||||||
|
UpdatedAt: updatedAt.Time,
|
||||||
|
}
|
||||||
|
if appliedAt.Valid {
|
||||||
|
app.AppliedAt = &appliedAt.Time
|
||||||
|
}
|
||||||
|
item.App = app
|
||||||
|
}
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.status, 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,
|
||||||
|
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
|
||||||
|
WHERE r.id = ?
|
||||||
|
`
|
||||||
|
row := db.QueryRow(query, rawJobID)
|
||||||
|
var item JobWithAnalysis
|
||||||
|
var tID sql.NullInt64
|
||||||
|
var aID, appID sql.NullInt64
|
||||||
|
var score sql.NullFloat64
|
||||||
|
var isQualified sql.NullBool
|
||||||
|
var 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.Status, &item.CrawledAt,
|
||||||
|
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||||
|
&appID, &appStatus, ¬es, &appliedAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tID.Valid {
|
||||||
|
item.TargetID = &tID.Int64
|
||||||
|
}
|
||||||
|
if aID.Valid {
|
||||||
|
analysis := &JobAnalysis{
|
||||||
|
ID: aID.Int64,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Score: score.Float64,
|
||||||
|
IsQualified: isQualified.Bool,
|
||||||
|
MatchedHighlights: matched.String,
|
||||||
|
MissingHighlights: missing.String,
|
||||||
|
TailoringSuggestions: suggestions.String,
|
||||||
|
Status: aStatus.String,
|
||||||
|
}
|
||||||
|
if analyzedAt.Valid {
|
||||||
|
analysis.AnalyzedAt = &analyzedAt.Time
|
||||||
|
}
|
||||||
|
item.Analysis = analysis
|
||||||
|
}
|
||||||
|
if appID.Valid {
|
||||||
|
app := &JobApplication{
|
||||||
|
ID: appID.Int64,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Status: appStatus.String,
|
||||||
|
Notes: notes.String,
|
||||||
|
UpdatedAt: updatedAt.Time,
|
||||||
|
}
|
||||||
|
if appliedAt.Valid {
|
||||||
|
app.AppliedAt = &appliedAt.Time
|
||||||
|
}
|
||||||
|
item.App = app
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applications Queries
|
||||||
|
func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (*JobApplication, error) {
|
||||||
|
now := time.Now()
|
||||||
|
var appliedAt *time.Time
|
||||||
|
if status == "APPLIED" {
|
||||||
|
appliedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO job_applications (raw_job_id, status, notes, applied_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(raw_job_id) DO UPDATE SET
|
||||||
|
status=excluded.status,
|
||||||
|
notes=COALESCE(excluded.notes, job_applications.notes),
|
||||||
|
applied_at=COALESCE(excluded.applied_at, job_applications.applied_at),
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, raw_job_id, status, notes, applied_at, updated_at
|
||||||
|
`
|
||||||
|
var app JobApplication
|
||||||
|
var appAt sql.NullTime
|
||||||
|
err := db.QueryRow(query, rawJobID, status, notes, appliedAt).Scan(
|
||||||
|
&app.ID, &app.RawJobID, &app.Status, &app.Notes, &appAt, &app.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if appAt.Valid {
|
||||||
|
app.AppliedAt = &appAt.Time
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.status, 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,
|
||||||
|
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
|
||||||
|
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
|
||||||
|
ORDER BY app.updated_at DESC
|
||||||
|
`
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []JobWithAnalysis
|
||||||
|
for rows.Next() {
|
||||||
|
var item JobWithAnalysis
|
||||||
|
var tID sql.NullInt64
|
||||||
|
var aID, appID sql.NullInt64
|
||||||
|
var score sql.NullFloat64
|
||||||
|
var isQualified sql.NullBool
|
||||||
|
var 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.Status, &item.CrawledAt,
|
||||||
|
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||||
|
&appID, &appStatus, ¬es, &appliedAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tID.Valid {
|
||||||
|
item.TargetID = &tID.Int64
|
||||||
|
}
|
||||||
|
if aID.Valid {
|
||||||
|
analysis := &JobAnalysis{
|
||||||
|
ID: aID.Int64,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Score: score.Float64,
|
||||||
|
IsQualified: isQualified.Bool,
|
||||||
|
MatchedHighlights: matched.String,
|
||||||
|
MissingHighlights: missing.String,
|
||||||
|
TailoringSuggestions: suggestions.String,
|
||||||
|
Status: aStatus.String,
|
||||||
|
}
|
||||||
|
if analyzedAt.Valid {
|
||||||
|
analysis.AnalyzedAt = &analyzedAt.Time
|
||||||
|
}
|
||||||
|
item.Analysis = analysis
|
||||||
|
}
|
||||||
|
app := &JobApplication{
|
||||||
|
ID: appID.Int64,
|
||||||
|
RawJobID: item.ID,
|
||||||
|
Status: appStatus.String,
|
||||||
|
Notes: notes.String,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
}
|
||||||
|
if appliedAt.Valid {
|
||||||
|
app.AppliedAt = &appliedAt.Time
|
||||||
|
}
|
||||||
|
item.App = app
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVectorSimilarityDeduplication(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tempDir, "test_dedup.db")
|
||||||
|
|
||||||
|
database, err := InitDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init db: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
contentA := "Senior Software Engineer - Go Distributed Systems. Experience with microservices, gRPC, and SQLite database pipelines."
|
||||||
|
contentSimilar := "Senior Software Engineer - Go Distributed Systems. Experience with microservices, gRPC, and SQLite database pipelines."
|
||||||
|
contentDifferent := "Marketing Specialist - Social Media Campaign Manager. Experience with SEO, Google Ads, and brand strategy."
|
||||||
|
|
||||||
|
// 1. Insert job A
|
||||||
|
jobA, inserted1, err := database.InsertRawJob("canonical-1", nil, "Software Engineer", "Google", "Seattle", "https://google.com/job/1", contentA)
|
||||||
|
if err != nil || !inserted1 {
|
||||||
|
t.Fatalf("expected job A to be inserted: err=%v, inserted=%v", err, inserted1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insert job with identical/99%+ similar content -> should be deduplicated!
|
||||||
|
_, inserted2, err := database.InsertRawJob("canonical-2-different-id", nil, "Learn more about Software Engineer", "Google", "Seattle", "https://google.com/job/1-dup", contentSimilar)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if inserted2 {
|
||||||
|
t.Errorf("expected 99%% similar content job to be deduplicated (inserted=false), got inserted=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Insert job with distinct content -> should insert normally!
|
||||||
|
_, inserted3, err := database.InsertRawJob("canonical-3", nil, "Marketing Specialist", "Google", "Seattle", "https://google.com/job/3", contentDifferent)
|
||||||
|
if err != nil || !inserted3 {
|
||||||
|
t.Fatalf("expected distinct job to insert normally: err=%v, inserted=%v", err, inserted3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only 2 raw jobs exist in DB
|
||||||
|
allJobs, err := database.GetPendingRawJobs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingRawJobs failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(allJobs) != 2 {
|
||||||
|
t.Errorf("expected 2 raw jobs in DB after deduplication, got %d", len(allJobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if allJobs[0].ID != jobA.ID {
|
||||||
|
t.Errorf("first job mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type FilterResult struct {
|
||||||
|
Keep bool
|
||||||
|
Score float64
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobFilter interface {
|
||||||
|
Name() string
|
||||||
|
Process(ctx context.Context, title, rawContent string) (FilterResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterPipeline struct {
|
||||||
|
filters []JobFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPipeline(filters ...JobFilter) *FilterPipeline {
|
||||||
|
return &FilterPipeline{filters: filters}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FilterPipeline) Execute(ctx context.Context, title, rawContent string) (bool, float64) {
|
||||||
|
var totalScore float64
|
||||||
|
for _, f := range p.filters {
|
||||||
|
res, err := f.Process(ctx, title, rawContent)
|
||||||
|
if err != nil || !res.Keep {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
totalScore += res.Score
|
||||||
|
}
|
||||||
|
return true, totalScore
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHardKeywordFilter(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fk := NewHardKeywordFilter([]string{"DevOps", "No Sponsorship"})
|
||||||
|
|
||||||
|
// Should fail due to DevOps
|
||||||
|
res1, err := fk.Process(ctx, "Senior DevOps Engineer", "We build pipelines.")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res1.Keep {
|
||||||
|
t.Errorf("expected Keep=false for DevOps, got true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should pass
|
||||||
|
res2, err := fk.Process(ctx, "Senior Backend Engineer", "We build scalable APIs in Go.")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !res2.Keep {
|
||||||
|
t.Errorf("expected Keep=true for Backend Engineer, got false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterPipeline(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
|
||||||
|
scoringFilter := NewScoringFilter([]KeywordWeight{
|
||||||
|
{Keyword: "Go", Weight: 30.0},
|
||||||
|
{Keyword: "Backend", Weight: 20.0},
|
||||||
|
})
|
||||||
|
|
||||||
|
pipeline := NewPipeline(hardFilter, scoringFilter)
|
||||||
|
|
||||||
|
// Test job 1: Unpaid job -> rejected by hard filter
|
||||||
|
keep1, score1 := pipeline.Execute(ctx, "Backend Intern", "Unpaid position working with Go.")
|
||||||
|
if keep1 {
|
||||||
|
t.Errorf("expected job 1 to be filtered out, got keep=true")
|
||||||
|
}
|
||||||
|
if score1 != 0 {
|
||||||
|
t.Errorf("expected score 0 for rejected job, got %f", score1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test job 2: Qualified job -> passes both filters and accumulates score
|
||||||
|
keep2, score2 := pipeline.Execute(ctx, "Senior Go Backend Engineer", "Building high throughput services.")
|
||||||
|
if !keep2 {
|
||||||
|
t.Errorf("expected job 2 to pass filter pipeline, got keep=false")
|
||||||
|
}
|
||||||
|
if score2 != 50.0 {
|
||||||
|
t.Errorf("expected score 50.0 (Go + Backend), got %f", score2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HardKeywordFilter struct {
|
||||||
|
Blacklist []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter {
|
||||||
|
if len(blacklist) == 0 {
|
||||||
|
blacklist = []string{
|
||||||
|
"no sponsorship",
|
||||||
|
"us citizen only",
|
||||||
|
"unpaid",
|
||||||
|
"volunteer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &HardKeywordFilter{Blacklist: blacklist}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *HardKeywordFilter) Name() string {
|
||||||
|
return "HardKeywordFilter"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *HardKeywordFilter) Process(ctx context.Context, title, rawContent string) (FilterResult, error) {
|
||||||
|
combined := strings.ToLower(title + " " + rawContent)
|
||||||
|
|
||||||
|
for _, kw := range f.Blacklist {
|
||||||
|
term := strings.ToLower(strings.TrimSpace(kw))
|
||||||
|
if term != "" && strings.Contains(combined, term) {
|
||||||
|
return FilterResult{
|
||||||
|
Keep: false,
|
||||||
|
Score: 0,
|
||||||
|
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", kw),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilterResult{
|
||||||
|
Keep: true,
|
||||||
|
Score: 0,
|
||||||
|
Reason: "Passed hard keyword check",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KeywordWeight struct {
|
||||||
|
Keyword string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoringFilter struct {
|
||||||
|
Weights []KeywordWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ScoringFilter{Weights: weights}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScoringFilter) Name() string {
|
||||||
|
return "ScoringFilter"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScoringFilter) Process(ctx context.Context, title, rawContent string) (FilterResult, error) {
|
||||||
|
combined := strings.ToLower(title + " " + rawContent)
|
||||||
|
var totalScore float64
|
||||||
|
var matched []string
|
||||||
|
|
||||||
|
for _, kw := range s.Weights {
|
||||||
|
term := strings.ToLower(strings.TrimSpace(kw.Keyword))
|
||||||
|
if term != "" && strings.Contains(combined, term) {
|
||||||
|
totalScore += kw.Weight
|
||||||
|
matched = append(matched, kw.Keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilterResult{
|
||||||
|
Keep: true,
|
||||||
|
Score: totalScore,
|
||||||
|
Reason: fmt.Sprintf("Matched %d keywords: %v", len(matched), matched),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hireme/hireme/internal/analyzer"
|
||||||
|
"github.com/hireme/hireme/internal/crawler"
|
||||||
|
"github.com/hireme/hireme/internal/db"
|
||||||
|
"github.com/hireme/hireme/internal/filter"
|
||||||
|
"github.com/hireme/hireme/pkg/crawler/google"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TargetConfigYAML struct {
|
||||||
|
Targets []struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
TargetURL string `yaml:"target_url"`
|
||||||
|
Platform string `yaml:"platform"`
|
||||||
|
IsActive bool `yaml:"is_active"`
|
||||||
|
} `yaml:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PipelineService struct {
|
||||||
|
db *db.DB
|
||||||
|
crawlers []crawler.SiteCrawler
|
||||||
|
filterPipe *filter.FilterPipeline
|
||||||
|
llmAnalyzer analyzer.LLMAnalyzer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPipelineService(database *db.DB, llmAnalyzer analyzer.LLMAnalyzer) *PipelineService {
|
||||||
|
hardFilter := filter.NewHardKeywordFilter(nil)
|
||||||
|
scoringFilter := filter.NewScoringFilter(nil)
|
||||||
|
pipeline := filter.NewPipeline(hardFilter, scoringFilter)
|
||||||
|
|
||||||
|
crawlers := []crawler.SiteCrawler{
|
||||||
|
google.NewGoogleCrawler(),
|
||||||
|
crawler.NewGenericCrawler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PipelineService{
|
||||||
|
db: database,
|
||||||
|
crawlers: crawlers,
|
||||||
|
filterPipe: pipeline,
|
||||||
|
llmAnalyzer: llmAnalyzer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTargetsFromYAML loads search targets from YAML configuration file if DB targets table is empty
|
||||||
|
func (s *PipelineService) LoadTargetsFromYAML(yamlPath string) error {
|
||||||
|
targets, err := s.db.GetAllTargets()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(targets) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not read targets YAML config at %s: %w", yamlPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configData TargetConfigYAML
|
||||||
|
if err := yaml.Unmarshal(data, &configData); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse YAML targets config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range configData.Targets {
|
||||||
|
_, err := s.db.CreateTarget(t.Name, t.TargetURL, t.Platform)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] Error loading target %s: %v", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Pipeline] Loaded %d search targets from YAML config (%s)", len(configData.Targets), yamlPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PipelineService) getCrawlerForPlatform(platform string) crawler.SiteCrawler {
|
||||||
|
for _, c := range s.crawlers {
|
||||||
|
if c.CanHandle(platform) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.crawlers[len(s.crawlers)-1] // fallback to generic
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPipeline executes Stage 1 (Ingestion) and Stage 2 (Filtering & LLM Enrichment)
|
||||||
|
func (s *PipelineService) RunPipeline(ctx context.Context) (int, int, error) {
|
||||||
|
log.Println("[Pipeline] Starting ingestion & processing run...")
|
||||||
|
|
||||||
|
// 1. Fetch active targets
|
||||||
|
targets, err := s.db.GetActiveTargets()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get active targets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newJobsCount := 0
|
||||||
|
for _, target := range targets {
|
||||||
|
log.Printf("[Pipeline] Discovering jobs for target #%d: %s (%s)", target.ID, target.Name, target.TargetURL)
|
||||||
|
c := s.getCrawlerForPlatform(target.Platform)
|
||||||
|
discovered, err := c.DiscoverJobs(ctx, target.TargetURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] DiscoverJobs error for target #%d: %v", target.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Pipeline] Discovered %d potential job links for target #%d", len(discovered), target.ID)
|
||||||
|
|
||||||
|
for _, disc := range discovered {
|
||||||
|
// Check DB deduplication first
|
||||||
|
exists, err := s.db.CanonicalIDExists(disc.CanonicalID)
|
||||||
|
if err == nil && exists {
|
||||||
|
continue // Skip existing job link!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch & parse detail page for new job only
|
||||||
|
extracted, err := c.FetchJobDetail(ctx, disc.ApplyURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] FetchJobDetail error for %s: %v", disc.ApplyURL, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tID := target.ID
|
||||||
|
_, inserted, err := s.db.InsertRawJob(extracted.CanonicalID, &tID, extracted.Title, extracted.Company, extracted.Location, extracted.ApplyURL, extracted.RawContent)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] Error inserting job %s: %v", extracted.Title, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inserted {
|
||||||
|
newJobsCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polite delay to prevent HTTP 429 rate-limiting from Google
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.db.UpdateTargetCrawledAt(target.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Execute Stage 2 Filter Pipeline on pending raw_jobs
|
||||||
|
pendingJobs, err := s.db.GetPendingRawJobs()
|
||||||
|
if err != nil {
|
||||||
|
return newJobsCount, 0, fmt.Errorf("failed to fetch pending jobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedCount := 0
|
||||||
|
for _, job := range pendingJobs {
|
||||||
|
keep, score := s.filterPipe.Execute(ctx, job.Title, job.RawContent)
|
||||||
|
|
||||||
|
if !keep {
|
||||||
|
log.Printf("[Pipeline] Job #%d (%s) FILTERED OUT by keyword pipeline", job.ID, job.Title)
|
||||||
|
_ = s.db.CreateJobAnalysis(job.ID, 0, false, "FILTERED_OUT")
|
||||||
|
_ = s.db.UpdateRawJobStatus(job.ID, "PROCESSED")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Pipeline] Job #%d (%s) PASSED filter with score %.2f", job.ID, job.Title, score)
|
||||||
|
_ = s.db.CreateJobAnalysis(job.ID, score, true, "PENDING")
|
||||||
|
_ = s.db.UpdateRawJobStatus(job.ID, "PROCESSED")
|
||||||
|
qualifiedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Trigger LLM Analysis on qualified PENDING analyses
|
||||||
|
pendingAnalyses, err := s.db.GetPendingQualifiedAnalyses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] Error fetching pending qualified analyses: %v", err)
|
||||||
|
return newJobsCount, qualifiedCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range pendingAnalyses {
|
||||||
|
log.Printf("[Pipeline] Triggering LLM analysis for Job #%d (%s @ %s)...", item.ID, item.Title, item.Company)
|
||||||
|
output, err := s.llmAnalyzer.AnalyzeJD(ctx, item.Title, item.RawContent)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] LLM analysis failed for job #%d: %v", item.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedJSON, _ := json.Marshal(output.MatchedHighlights)
|
||||||
|
missingJSON, _ := json.Marshal(output.MissingHighlights)
|
||||||
|
suggestionsJSON, _ := json.Marshal(output.TailoringSuggestions)
|
||||||
|
|
||||||
|
err = s.db.UpdateJobAnalysisLLM(item.ID, string(matchedJSON), string(missingJSON), string(suggestionsJSON))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Pipeline] Failed to save LLM analysis for job #%d: %v", item.ID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[Pipeline] Successfully cached LLM analysis for Job #%d", item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newJobsCount, qualifiedCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeJobByID manually triggers LLM analysis on a single job
|
||||||
|
func (s *PipelineService) AnalyzeJobByID(ctx context.Context, jobID int64) (*db.JobWithAnalysis, error) {
|
||||||
|
jobWithAnalysis, err := s.db.GetJobByID(jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := s.llmAnalyzer.AnalyzeJD(ctx, jobWithAnalysis.Title, jobWithAnalysis.RawContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LLM analysis failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedJSON, _ := json.Marshal(output.MatchedHighlights)
|
||||||
|
missingJSON, _ := json.Marshal(output.MissingHighlights)
|
||||||
|
suggestionsJSON, _ := json.Marshal(output.TailoringSuggestions)
|
||||||
|
|
||||||
|
if jobWithAnalysis.Analysis == nil {
|
||||||
|
_, score := s.filterPipe.Execute(ctx, jobWithAnalysis.Title, jobWithAnalysis.RawContent)
|
||||||
|
_ = s.db.CreateJobAnalysis(jobID, score, true, "PENDING")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.db.UpdateJobAnalysisLLM(jobID, string(matchedJSON), string(missingJSON), string(suggestionsJSON))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.GetJobByID(jobID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hireme/hireme/internal/config"
|
||||||
|
"github.com/hireme/hireme/internal/db"
|
||||||
|
"github.com/hireme/hireme/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
db *db.DB
|
||||||
|
pipeline *service.PipelineService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cfg *config.Config, database *db.DB, pipeline *service.PipelineService) *Server {
|
||||||
|
return &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
db: database,
|
||||||
|
pipeline: pipeline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /", s.handleIndex)
|
||||||
|
mux.HandleFunc("GET /targets", s.handleGetTargets)
|
||||||
|
mux.HandleFunc("POST /targets", s.handleCreateTarget)
|
||||||
|
mux.HandleFunc("POST /targets/{id}/toggle", s.handleToggleTarget)
|
||||||
|
mux.HandleFunc("GET /jobs", s.handleGetJobs)
|
||||||
|
mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail)
|
||||||
|
mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob)
|
||||||
|
mux.HandleFunc("POST /applications", s.handleCreateApplication)
|
||||||
|
mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus)
|
||||||
|
mux.HandleFunc("POST /pipeline/run", s.handleRunPipeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := s.db.GetAllTargets()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := s.db.GetJobsWithAnalysis()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, err := s.db.GetApplications()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifiedCount := 0
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j.Analysis != nil && j.Analysis.IsQualified {
|
||||||
|
qualifiedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := PageData{
|
||||||
|
Targets: targets,
|
||||||
|
Jobs: jobs,
|
||||||
|
Applications: apps,
|
||||||
|
ActiveTab: "triage",
|
||||||
|
LLMProvider: s.cfg.LLMProvider,
|
||||||
|
QualifiedJobsCount: qualifiedCount,
|
||||||
|
TotalIngestedCount: len(jobs),
|
||||||
|
ActiveApplications: len(apps),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := RenderPage(w, data); err != nil {
|
||||||
|
log.Printf("[UI] RenderPage error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTargets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targets, err := s.db.GetAllTargets()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderTargetList(w, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
targetURL := r.FormValue("target_url")
|
||||||
|
platform := r.FormValue("platform")
|
||||||
|
|
||||||
|
if name == "" || targetURL == "" {
|
||||||
|
http.Error(w, "name and target_url are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.CreateTarget(name, targetURL, platform)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleGetTargets(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToggleTarget(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
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.ToggleTargetStatus(id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleGetTargets(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobs, err := s.db.GetJobsWithAnalysis()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderJobList(w, jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetJobDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid job id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := s.db.GetJobByID(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "job not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderJobDetail(w, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid job id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := s.pipeline.AnalyzeJobByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderJobDetail(w, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawJobIDStr := r.FormValue("raw_job_id")
|
||||||
|
status := r.FormValue("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "SAVED"
|
||||||
|
}
|
||||||
|
|
||||||
|
rawJobID, err := strconv.ParseInt(rawJobIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid raw_job_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.CreateOrUpdateApplication(rawJobID, status, "")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(r.Header.Get("HX-Target"), "kanban") {
|
||||||
|
apps, _ := s.db.GetApplications()
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderKanbanBoard(w, apps)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleGetJobs(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
appID, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid app id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newStatus := r.FormValue("status")
|
||||||
|
if newStatus == "" {
|
||||||
|
http.Error(w, "status required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch rawJobID for appID
|
||||||
|
apps, err := s.db.GetApplications()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRawJobID int64 = -1
|
||||||
|
for _, a := range apps {
|
||||||
|
if a.App != nil && a.App.ID == appID {
|
||||||
|
targetRawJobID = a.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetRawJobID == -1 {
|
||||||
|
http.Error(w, "application not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.CreateOrUpdateApplication(targetRawJobID, newStatus, "")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedApps, _ := s.db.GetApplications()
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = RenderKanbanBoard(w, updatedApps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRunPipeline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _, err := s.pipeline.RunPipeline(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Server] Pipeline run error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleGetJobs(w, r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hireme/hireme/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var funcMap = template.FuncMap{
|
||||||
|
"formatTime": func(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return "Never"
|
||||||
|
}
|
||||||
|
return t.Format("Jan 02, 2006 15:04")
|
||||||
|
},
|
||||||
|
"formatDate": func(t time.Time) string {
|
||||||
|
return t.Format("Jan 02, 2006")
|
||||||
|
},
|
||||||
|
"parseJSON": func(jsonStr string) []string {
|
||||||
|
if jsonStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
_ = json.Unmarshal([]byte(jsonStr), &list)
|
||||||
|
return list
|
||||||
|
},
|
||||||
|
"scoreBadgeClass": func(score float64) string {
|
||||||
|
if score >= 50 {
|
||||||
|
return "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||||
|
} else if score >= 20 {
|
||||||
|
return "bg-amber-500/20 text-amber-400 border-amber-500/30"
|
||||||
|
}
|
||||||
|
return "bg-slate-700/50 text-slate-400 border-slate-600/30"
|
||||||
|
},
|
||||||
|
"platformIcon": func(platform string) string {
|
||||||
|
p := strings.ToLower(platform)
|
||||||
|
if strings.Contains(p, "linkedin") {
|
||||||
|
return "💼 LinkedIn"
|
||||||
|
} else if strings.Contains(p, "google") {
|
||||||
|
return "🔍 Google Jobs"
|
||||||
|
} else if strings.Contains(p, "greenhouse") {
|
||||||
|
return "🌿 Greenhouse"
|
||||||
|
} else if strings.Contains(p, "lever") {
|
||||||
|
return "⚙️ Lever"
|
||||||
|
}
|
||||||
|
return "🌐 Web Target"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageData struct {
|
||||||
|
Targets []db.SearchTarget
|
||||||
|
Jobs []db.JobWithAnalysis
|
||||||
|
Applications []db.JobWithAnalysis
|
||||||
|
ActiveTab string
|
||||||
|
LLMProvider string
|
||||||
|
QualifiedJobsCount int
|
||||||
|
TotalIngestedCount int
|
||||||
|
ActiveApplications int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootTemplate *template.Template
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
rootTemplate = template.New("root").Funcs(funcMap)
|
||||||
|
rootTemplate, err = rootTemplate.Parse(mainLayoutHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
rootTemplate, err = rootTemplate.Parse(targetListHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
rootTemplate, err = rootTemplate.Parse(jobListHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
rootTemplate, err = rootTemplate.Parse(jobDetailHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
rootTemplate, err = rootTemplate.Parse(kanbanBoardHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
rootTemplate, err = rootTemplate.Parse(kanbanCardHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderPage(w io.Writer, data PageData) error {
|
||||||
|
return rootTemplate.ExecuteTemplate(w, "page", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderTargetList(w io.Writer, targets []db.SearchTarget) error {
|
||||||
|
return rootTemplate.ExecuteTemplate(w, "targets", targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderJobList(w io.Writer, jobs []db.JobWithAnalysis) error {
|
||||||
|
return rootTemplate.ExecuteTemplate(w, "jobs", jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderJobDetail(w io.Writer, job *db.JobWithAnalysis) error {
|
||||||
|
return rootTemplate.ExecuteTemplate(w, "jobDetail", job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
|
||||||
|
return rootTemplate.ExecuteTemplate(w, "kanban", apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Hireme | Job Command Center</title>
|
||||||
|
<!-- Tailwind CSS CDN -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- HTMX -->
|
||||||
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eef2ff',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.6); }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(99, 102, 241, 0.3); border-radius: 4px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(99, 102, 241, 0.6); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white">
|
||||||
|
|
||||||
|
<!-- Top Navigation Header -->
|
||||||
|
<header class="sticky top-0 z-30 glass-panel border-b border-slate-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
||||||
|
<span class="text-xl font-black text-white">H</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold bg-gradient-to-r from-white via-indigo-200 to-indigo-400 bg-clip-text text-transparent">Hireme</h1>
|
||||||
|
<p class="text-xs text-slate-400 font-medium">Job Application Command Center</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header Quick Stats & LLM Indicator -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="hidden md:flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-slate-900/60 border border-slate-800 text-xs">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
<span class="text-slate-400">LLM Provider:</span>
|
||||||
|
<span class="font-semibold text-indigo-300 uppercase">{{ .LLMProvider }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button hx-post="/pipeline/run"
|
||||||
|
hx-target="#jobs-container"
|
||||||
|
hx-indicator="#run-spinner"
|
||||||
|
class="relative inline-flex items-center justify-center px-4 py-2 text-xs font-semibold text-white transition-all bg-indigo-600 rounded-lg shadow-md hover:bg-indigo-500 active:scale-95 shadow-indigo-500/25">
|
||||||
|
<svg id="run-spinner" class="htmx-indicator animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>⚡ Run Pipeline</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Workspace Container -->
|
||||||
|
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||||
|
|
||||||
|
<!-- Metrics Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
||||||
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-indigo-500/10 rounded-full blur-xl group-hover:bg-indigo-500/20 transition-all"></div>
|
||||||
|
<p class="text-xs font-medium text-slate-400">Total Search Targets</p>
|
||||||
|
<h3 class="text-2xl font-bold text-white mt-1">{{ len .Targets }}</h3>
|
||||||
|
<span class="text-[10px] text-slate-500 mt-2 block">Active Crawler Pipelines</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
||||||
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-emerald-500/10 rounded-full blur-xl group-hover:bg-emerald-500/20 transition-all"></div>
|
||||||
|
<p class="text-xs font-medium text-slate-400">Qualified Jobs Matched</p>
|
||||||
|
<h3 class="text-2xl font-bold text-emerald-400 mt-1">{{ .QualifiedJobsCount }}</h3>
|
||||||
|
<span class="text-[10px] text-emerald-500/80 mt-2 block">Passed Keyword & Score Filter</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
||||||
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-amber-500/10 rounded-full blur-xl group-hover:bg-amber-500/20 transition-all"></div>
|
||||||
|
<p class="text-xs font-medium text-slate-400">Total Ingested Postings</p>
|
||||||
|
<h3 class="text-2xl font-bold text-amber-400 mt-1">{{ .TotalIngestedCount }}</h3>
|
||||||
|
<span class="text-[10px] text-slate-500 mt-2 block">Deduplicated Raw Crawls</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
|
||||||
|
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-violet-500/10 rounded-full blur-xl group-hover:bg-violet-500/20 transition-all"></div>
|
||||||
|
<p class="text-xs font-medium text-slate-400">Active Applications</p>
|
||||||
|
<h3 class="text-2xl font-bold text-violet-400 mt-1">{{ .ActiveApplications }}</h3>
|
||||||
|
<span class="text-[10px] text-slate-500 mt-2 block">Tracked in Kanban Pipeline</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabbed View Container -->
|
||||||
|
<div class="glass-panel rounded-2xl border border-slate-800 overflow-hidden shadow-xl">
|
||||||
|
<!-- Tabs Bar -->
|
||||||
|
<div class="flex items-center border-b border-slate-800 px-6 pt-4 space-x-6">
|
||||||
|
<button onclick="switchTab('triage')" id="tab-btn-triage"
|
||||||
|
class="pb-3 text-sm font-semibold text-indigo-400 border-b-2 border-indigo-500 transition-colors">
|
||||||
|
🎯 Job Triage & Matches
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('kanban')" id="tab-btn-kanban"
|
||||||
|
class="pb-3 text-sm font-semibold text-slate-400 border-b-2 border-transparent hover:text-slate-200 transition-colors">
|
||||||
|
📋 Application Kanban
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('targets')" id="tab-btn-targets"
|
||||||
|
class="pb-3 text-sm font-semibold text-slate-400 border-b-2 border-transparent hover:text-slate-200 transition-colors">
|
||||||
|
⚙️ Crawler Targets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content 1: Job Triage List -->
|
||||||
|
<div id="tab-content-triage" class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold text-white">Qualified Job Opportunities</h2>
|
||||||
|
<p class="text-xs text-slate-400">Ranked by rule score + cached LLM skill gap analysis</p>
|
||||||
|
</div>
|
||||||
|
<button hx-get="/jobs" hx-target="#jobs-container" class="text-xs text-indigo-400 hover:underline">
|
||||||
|
🔄 Refresh List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="jobs-container">
|
||||||
|
{{ template "jobs" .Jobs }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content 2: Application Kanban -->
|
||||||
|
<div id="tab-content-kanban" class="p-6 hidden">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold text-white">Job Application Tracking</h2>
|
||||||
|
<p class="text-xs text-slate-400">Manage saved, applied, interviewing, and offer states</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="kanban-container">
|
||||||
|
{{ template "kanban" .Applications }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content 3: Target Configurations -->
|
||||||
|
<div id="tab-content-targets" class="p-6 hidden space-y-6">
|
||||||
|
<!-- Add Target Form -->
|
||||||
|
<div class="p-5 rounded-xl bg-slate-900/60 border border-slate-800">
|
||||||
|
<h3 class="text-sm font-bold text-white mb-3">Add Target Search URL</h3>
|
||||||
|
<form hx-post="/targets" hx-target="#targets-container" class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<input type="text" name="name" placeholder="Target Name (e.g. Go Backend SF)" required
|
||||||
|
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">
|
||||||
|
<input type="url" name="target_url" placeholder="Target URL" required
|
||||||
|
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">
|
||||||
|
<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="linkedin">LinkedIn</option>
|
||||||
|
<option value="google_jobs">Google Jobs</option>
|
||||||
|
<option value="greenhouse">Greenhouse</option>
|
||||||
|
<option value="lever">Lever</option>
|
||||||
|
<option value="generic">Generic Web</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">
|
||||||
|
+ Add Target
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Targets Table -->
|
||||||
|
<div id="targets-container">
|
||||||
|
{{ template "targets" .Targets }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Slide-Over Detail Modal -->
|
||||||
|
<div id="detail-modal" class="fixed inset-0 z-50 hidden bg-slate-950/80 backdrop-blur-sm flex justify-end">
|
||||||
|
<div id="detail-modal-body" class="w-full max-w-2xl bg-slate-900 border-l border-slate-800 h-full p-6 overflow-y-auto custom-scrollbar shadow-2xl">
|
||||||
|
<!-- Dynamic HTMX content loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Switching Script -->
|
||||||
|
<script>
|
||||||
|
function switchTab(tab) {
|
||||||
|
['triage', 'kanban', 'targets'].forEach(t => {
|
||||||
|
document.getElementById('tab-content-' + t).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');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tab-content-' + tab).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');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailModal() {
|
||||||
|
document.getElementById('detail-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>{{ end }}`
|
||||||
|
|
||||||
|
const targetListHTML = `{{ define "targets" }}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
||||||
|
<table class="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3">Platform</th>
|
||||||
|
<th class="px-4 py-3">Name</th>
|
||||||
|
<th class="px-4 py-3">Target URL</th>
|
||||||
|
<th class="px-4 py-3">Last Crawled</th>
|
||||||
|
<th class="px-4 py-3">Status</th>
|
||||||
|
<th class="px-4 py-3 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
||||||
|
{{ range . }}
|
||||||
|
<tr class="hover:bg-slate-800/40 transition-colors">
|
||||||
|
<td class="px-4 py-3 font-medium text-indigo-300">{{ platformIcon .Platform }}</td>
|
||||||
|
<td class="px-4 py-3 font-semibold text-white">{{ .Name }}</td>
|
||||||
|
<td class="px-4 py-3 max-w-xs truncate text-slate-400">
|
||||||
|
<a href="{{ .TargetURL }}" target="_blank" class="hover:underline text-indigo-400">{{ .TargetURL }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-slate-400">{{ formatTime .LastCrawledAt }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{{ if .IsActive }}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">Active</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-slate-700/50 text-slate-400 border border-slate-600/30">Paused</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button hx-post="/targets/{{ .ID }}/toggle"
|
||||||
|
hx-target="#targets-container"
|
||||||
|
class="px-2.5 py-1 text-[11px] rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700">
|
||||||
|
Toggle
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-8 text-center text-slate-500">No search targets configured yet.</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>{{ end }}`
|
||||||
|
|
||||||
|
const jobListHTML = `{{ define "jobs" }}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
||||||
|
<table class="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3">Job Title & Company</th>
|
||||||
|
<th class="px-4 py-3 text-center">Original Link</th>
|
||||||
|
<th class="px-4 py-3">Analysis Status</th>
|
||||||
|
<th class="px-4 py-3">Score & Qualification</th>
|
||||||
|
<th class="px-4 py-3">Keyword Tags</th>
|
||||||
|
<th class="px-4 py-3">Crawled Date</th>
|
||||||
|
<th class="px-4 py-3 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
||||||
|
{{ range . }}
|
||||||
|
<tr class="hover:bg-slate-800/40 transition-colors group">
|
||||||
|
<!-- Job Title & Company -->
|
||||||
|
<td class="px-4 py-3 font-medium">
|
||||||
|
<button hx-get="/jobs/{{ .ID }}"
|
||||||
|
hx-target="#detail-modal-body"
|
||||||
|
onclick="document.getElementById('detail-modal').classList.remove('hidden')"
|
||||||
|
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline transition-colors block">
|
||||||
|
{{ .Title }}
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center space-x-2 mt-0.5 text-[11px] text-slate-400">
|
||||||
|
<span class="font-semibold text-indigo-400">{{ .Company }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>📍 {{ .Location }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Original Link -->
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<a href="{{ .ApplyURL }}" target="_blank" title="Open External Job Posting"
|
||||||
|
class="inline-flex items-center justify-center w-7 h-7 rounded bg-slate-800 hover:bg-slate-700 text-indigo-400 hover:text-indigo-300 border border-slate-700 transition-colors">
|
||||||
|
🔗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Analysis Status (JobAnalysis.Status) -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{{ 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>
|
||||||
|
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
|
||||||
|
<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">
|
||||||
|
FILTERED_OUT
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<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">
|
||||||
|
{{ .Analysis.Status }}
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-slate-800 text-slate-400 border border-slate-700">
|
||||||
|
UNANALYZED
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Score & Qualification -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{{ 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 }} Qualified
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||||
|
Not Qualified
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-500 text-[11px]">-</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Keyword Tags -->
|
||||||
|
<td class="px-4 py-3 max-w-xs">
|
||||||
|
{{ if .Analysis }}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{{ range parseJSON .Analysis.MatchedHighlights }}
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 text-[10px] font-medium border border-emerald-500/20">
|
||||||
|
{{ . }}
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-500 text-[10px] italic">No tags</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-500 text-[10px] italic">No tags</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Crawled Date -->
|
||||||
|
<td class="px-4 py-3 text-slate-400 text-[11px]">
|
||||||
|
{{ formatDate .CrawledAt }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{{ if .App }}
|
||||||
|
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
|
||||||
|
{{ .App.Status }}
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<button hx-post="/applications"
|
||||||
|
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||||||
|
hx-target="#jobs-container"
|
||||||
|
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-[11px] font-medium transition-colors">
|
||||||
|
+ Save
|
||||||
|
</button>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-8 text-center text-slate-500">
|
||||||
|
No job postings found yet. Click <strong>⚡ Run Pipeline</strong> to ingest jobs.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>{{ end }}`
|
||||||
|
|
||||||
|
const jobDetailHTML = `{{ define "jobDetail" }}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
|
||||||
|
<h2 class="text-xl font-extrabold text-white mt-1">{{ .Title }}</h2>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • Ingested {{ formatDate .CrawledAt }}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeDetailModal()" class="text-slate-400 hover:text-white text-lg p-1">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Analysis }}
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<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 Relevance Score</span>
|
||||||
|
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} / 100</span>
|
||||||
|
</div>
|
||||||
|
<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">LLM Status</span>
|
||||||
|
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matched & Missing Highlights -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">✓ Matched Technical Highlights</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{{ range parseJSON .Analysis.MatchedHighlights }}
|
||||||
|
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-300 text-xs font-semibold border border-emerald-500/30">
|
||||||
|
{{ . }}
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-rose-400 uppercase tracking-wider mb-2">! Skill & Experience Gaps</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{{ range parseJSON .Analysis.MissingHighlights }}
|
||||||
|
<span class="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-300 text-xs font-semibold border border-rose-500/30">
|
||||||
|
{{ . }}
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">💡 Resume Tailoring Action Points</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{{ range parseJSON .Analysis.TailoringSuggestions }}
|
||||||
|
<li class="p-3 rounded-lg bg-slate-800/80 border border-slate-700/60 text-xs text-slate-200 leading-relaxed">
|
||||||
|
• {{ . }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<!-- Raw Content snippet -->
|
||||||
|
<div class="border-t border-slate-800 pt-4">
|
||||||
|
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Job Description Payload</h4>
|
||||||
|
<div class="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-300 max-h-60 overflow-y-auto leading-relaxed custom-scrollbar whitespace-pre-wrap">
|
||||||
|
{{ .RawContent }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Footer -->
|
||||||
|
<div class="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||||
|
<a href="{{ .ApplyURL }}" target="_blank" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-bold text-white rounded-lg shadow-md transition-all">
|
||||||
|
Apply External ↗
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button hx-post="/jobs/{{ .ID }}/analyze"
|
||||||
|
hx-target="#detail-modal-body"
|
||||||
|
class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 rounded-lg border border-slate-700">
|
||||||
|
🔄 Re-run LLM
|
||||||
|
</button>
|
||||||
|
<button hx-post="/applications"
|
||||||
|
hx-vals='{"raw_job_id": {{ .ID }}, "status": "APPLIED"}'
|
||||||
|
hx-target="#kanban-container"
|
||||||
|
onclick="switchTab('kanban'); closeDetailModal();"
|
||||||
|
class="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white rounded-lg shadow-md">
|
||||||
|
+ Mark Applied
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>{{ end }}`
|
||||||
|
|
||||||
|
const kanbanBoardHTML = `{{ define "kanban" }}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- SAVED COLUMN -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
||||||
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
||||||
|
<h3 class="text-xs font-bold text-slate-300 uppercase">💾 Saved</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 flex-1">
|
||||||
|
{{ range . }}
|
||||||
|
{{ if eq .App.Status "SAVED" }}
|
||||||
|
{{ template "kanbanCard" . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- APPLIED COLUMN -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
||||||
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
||||||
|
<h3 class="text-xs font-bold text-indigo-400 uppercase">🚀 Applied</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 flex-1">
|
||||||
|
{{ range . }}
|
||||||
|
{{ if eq .App.Status "APPLIED" }}
|
||||||
|
{{ template "kanbanCard" . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INTERVIEW COLUMN -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
||||||
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
||||||
|
<h3 class="text-xs font-bold text-amber-400 uppercase">💬 Interview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 flex-1">
|
||||||
|
{{ range . }}
|
||||||
|
{{ if eq .App.Status "INTERVIEW" }}
|
||||||
|
{{ template "kanbanCard" . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OFFER / CLOSED COLUMN -->
|
||||||
|
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
|
||||||
|
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
|
||||||
|
<h3 class="text-xs font-bold text-emerald-400 uppercase">🎉 Offer / Closed</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 flex-1">
|
||||||
|
{{ range . }}
|
||||||
|
{{ if or (eq .App.Status "OFFER") (eq .App.Status "REJECTED") }}
|
||||||
|
{{ template "kanbanCard" . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>{{ end }}`
|
||||||
|
|
||||||
|
const kanbanCardHTML = `
|
||||||
|
{{ define "kanbanCard" }}
|
||||||
|
<div class="p-4 rounded-xl bg-slate-800/80 border border-slate-700/60 shadow-md space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-[10px] font-bold text-indigo-300 uppercase">{{ .Company }}</span>
|
||||||
|
<h4 class="text-xs font-bold text-white mt-0.5">{{ .Title }}</h4>
|
||||||
|
<span class="text-[10px] text-slate-400 mt-1 block">📍 {{ .Location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-2 border-t border-slate-700/50 text-[10px]">
|
||||||
|
<span class="text-slate-400">Status: <strong class="text-white">{{ .App.Status }}</strong></span>
|
||||||
|
|
||||||
|
<select hx-patch="/applications/{{ .App.ID }}/status"
|
||||||
|
hx-target="#kanban-container"
|
||||||
|
class="px-1.5 py-0.5 bg-slate-900 border border-slate-700 rounded text-slate-200 text-[10px]">
|
||||||
|
<option value="SAVED" {{ if eq .App.Status "SAVED" }}selected{{ end }}>Saved</option>
|
||||||
|
<option value="APPLIED" {{ if eq .App.Status "APPLIED" }}selected{{ end }}>Applied</option>
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}`
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package google
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hireme/hireme/internal/crawler"
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultTestTargetURL = "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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GoogleCrawler struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGoogleCrawler() *GoogleCrawler {
|
||||||
|
return &GoogleCrawler{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GoogleCrawler) CanHandle(platform string) bool {
|
||||||
|
p := strings.ToLower(platform)
|
||||||
|
return p == "google_jobs" || p == "google" || strings.Contains(p, "google.com/about/careers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverJobs finds all job links from search result pages (paginated).
|
||||||
|
func (g *GoogleCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]crawler.DiscoveredJob, error) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var discovered []crawler.DiscoveredJob
|
||||||
|
|
||||||
|
for page := 1; page <= 5; page++ {
|
||||||
|
pageURL := targetURL
|
||||||
|
if strings.Contains(pageURL, "?") {
|
||||||
|
pageURL = fmt.Sprintf("%s&page=%d&page_size=100", targetURL, page)
|
||||||
|
} else {
|
||||||
|
pageURL = fmt.Sprintf("%s?page=%d&page_size=100", targetURL, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlContent, err := g.fetchWithRetry(ctx, pageURL)
|
||||||
|
if err != nil {
|
||||||
|
if page == 1 {
|
||||||
|
htmlContent, err = g.fetchWithRetry(ctx, targetURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan for job IDs in both <a href> tags and inline script data
|
||||||
|
reID := regexp.MustCompile(`jobs/results/(\d[\w\-]+)`)
|
||||||
|
matches := reID.FindAllStringSubmatch(htmlContent, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) >= 2 {
|
||||||
|
jobID := m[1]
|
||||||
|
// Filter out page= params that look like job IDs
|
||||||
|
if strings.HasPrefix(jobID, "?") || len(jobID) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyURL := fmt.Sprintf("https://www.google.com/about/careers/applications/jobs/results/%s", jobID)
|
||||||
|
if !seen[applyURL] {
|
||||||
|
seen[applyURL] = true
|
||||||
|
discovered = append(discovered, crawler.DiscoveredJob{
|
||||||
|
CanonicalID: fmt.Sprintf("google:%s", jobID),
|
||||||
|
ApplyURL: applyURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchJobDetail fetches a single job detail page and extracts structured data
|
||||||
|
// from the AF_initDataCallback JSON embedded in the page's <script> tags.
|
||||||
|
//
|
||||||
|
// Google Careers pages are JavaScript-rendered SPAs. The server-side HTML embeds
|
||||||
|
// structured job data in AF_initDataCallback({key:'ds:0', data:[...]}) payloads.
|
||||||
|
// The DOM itself (spans under <main>) only contains a sidebar list of unrelated jobs,
|
||||||
|
// so we must parse the script data, not the DOM.
|
||||||
|
func (g *GoogleCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*crawler.ExtractedJob, error) {
|
||||||
|
htmlContent, err := g.fetchWithRetry(ctx, applyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID := parseIDFromHref(applyURL)
|
||||||
|
if jobID == "" {
|
||||||
|
jobID = fmt.Sprintf("%x", sha256.Sum256([]byte(applyURL)))[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse structured data from AF_initDataCallback (ds:0)
|
||||||
|
title, company, location := extractFromAFData(htmlContent)
|
||||||
|
|
||||||
|
// Fallback: data-title attribute
|
||||||
|
if title == "" {
|
||||||
|
if m := regexp.MustCompile(`data-title="([^"]+)"`).FindStringSubmatch(htmlContent); len(m) >= 2 {
|
||||||
|
title = m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: URL slug
|
||||||
|
if title == "" || strings.EqualFold(title, "job details") {
|
||||||
|
title = parseTitleFromURLSlug(applyURL)
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "Unknown Position"
|
||||||
|
}
|
||||||
|
if company == "" {
|
||||||
|
company = "Google"
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
location = "See Job Posting"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw content: render the <main> container HTML
|
||||||
|
var buf bytes.Buffer
|
||||||
|
doc, parseErr := html.Parse(strings.NewReader(htmlContent))
|
||||||
|
if parseErr == nil {
|
||||||
|
if main := findElement(doc, "main"); main != nil {
|
||||||
|
_ = html.Render(&buf, main)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buf.Len() < 100 {
|
||||||
|
buf.Reset()
|
||||||
|
buf.WriteString(htmlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &crawler.ExtractedJob{
|
||||||
|
CanonicalID: fmt.Sprintf("google:%s", jobID),
|
||||||
|
Title: cleanTitle(title),
|
||||||
|
Company: company,
|
||||||
|
Location: location,
|
||||||
|
ApplyURL: applyURL,
|
||||||
|
RawContent: buf.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFromAFData parses the AF_initDataCallback JSON for key 'ds:0'.
|
||||||
|
//
|
||||||
|
// The HTML contains: <script class="ds:0">AF_initDataCallback({key:'ds:0', ..., data:[[...]], sideChannel:{}});</script>
|
||||||
|
//
|
||||||
|
// The data array is: data[0] = [jobID, title, signinURL, responsibilities, qualifications,
|
||||||
|
//
|
||||||
|
// tenantPath, null, COMPANY, language, LOCATIONS, aboutHTML, ...]
|
||||||
|
//
|
||||||
|
// Indices:
|
||||||
|
//
|
||||||
|
// [0] = Job ID (string)
|
||||||
|
// [1] = Title (string)
|
||||||
|
// [7] = Company (string: "Google", "YouTube", "Google DeepMind", "Waymo", etc.)
|
||||||
|
// [9] = Locations (array of arrays: [["Shanghai, China", [...], "Shanghai", null, "Shanghai", "CN"]])
|
||||||
|
func extractFromAFData(htmlContent string) (title, company, location string) {
|
||||||
|
// Find the start of the ds:0 data payload using string search (more reliable than regex for large content)
|
||||||
|
marker := `AF_initDataCallback({key: 'ds:0'`
|
||||||
|
idx := strings.Index(htmlContent, marker)
|
||||||
|
if idx == -1 {
|
||||||
|
log.Printf("[GoogleCrawler] AF_initDataCallback ds:0 not found")
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find "data:" after the marker
|
||||||
|
dataMarker := "data:"
|
||||||
|
dataIdx := strings.Index(htmlContent[idx:], dataMarker)
|
||||||
|
if dataIdx == -1 {
|
||||||
|
log.Printf("[GoogleCrawler] 'data:' not found after ds:0 marker")
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
dataStart := idx + dataIdx + len(dataMarker)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
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)
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
if len(outerArray) == 0 {
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// [1] = Title
|
||||||
|
if len(record) > 1 {
|
||||||
|
_ = json.Unmarshal(record[1], &title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [7] = Company
|
||||||
|
if len(record) > 7 {
|
||||||
|
_ = json.Unmarshal(record[7], &company)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [9] = Locations array: [["Shanghai, China", [...], ...], ...]
|
||||||
|
if len(record) > 9 {
|
||||||
|
var locs [][]json.RawMessage
|
||||||
|
if err := json.Unmarshal(record[9], &locs); err == nil {
|
||||||
|
var parts []string
|
||||||
|
for _, loc := range locs {
|
||||||
|
if len(loc) > 0 {
|
||||||
|
var display string
|
||||||
|
if err := json.Unmarshal(loc[0], &display); err == nil && display != "" {
|
||||||
|
parts = append(parts, display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
location = strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[GoogleCrawler] AF_initDataCallback parsed: title=%q company=%q location=%q", title, company, location)
|
||||||
|
return title, company, location
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBalancedBracket finds the JSON array starting at position startIdx in s,
|
||||||
|
// using bracket counting to handle nested arrays/objects.
|
||||||
|
func extractBalancedBracket(s string, startIdx int) string {
|
||||||
|
// Skip whitespace to find the opening [
|
||||||
|
i := startIdx
|
||||||
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= len(s) || s[i] != '[' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
depth := 0
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for j := i; j < len(s); j++ {
|
||||||
|
ch := s[j]
|
||||||
|
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '\\' && inString {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
inString = !inString
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inString {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '[' || ch == '{' {
|
||||||
|
depth++
|
||||||
|
} else if ch == ']' || ch == '}' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return s[i : j+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchWithRetry fetches a URL with retry logic for HTTP 429 rate limiting.
|
||||||
|
func (g *GoogleCrawler) fetchWithRetry(ctx context.Context, urlStr string) (string, error) {
|
||||||
|
var lastErr error
|
||||||
|
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)
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := g.fetchURL(ctx, urlStr)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "status 429") {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("max retries exceeded for %s: %w", urlStr, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GoogleCrawler) 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,image/avif,image/webp,*/*;q=0.8")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
|
||||||
|
resp, err := g.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("HTTP fetch failed for %s: %w", urlStr, err)
|
||||||
|
}
|
||||||
|
defer 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 body for %s: %w", urlStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIDFromHref(href string) string {
|
||||||
|
re := regexp.MustCompile(`jobs/results/(\d[\w\-]+)`)
|
||||||
|
m := re.FindStringSubmatch(href)
|
||||||
|
if len(m) >= 2 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTitleFromURLSlug(applyURL string) string {
|
||||||
|
re := regexp.MustCompile(`jobs/results/\d+\-([a-zA-Z0-9\-]+)`)
|
||||||
|
m := re.FindStringSubmatch(applyURL)
|
||||||
|
if len(m) >= 2 {
|
||||||
|
slug := m[1]
|
||||||
|
words := strings.Split(slug, "-")
|
||||||
|
for i, w := range words {
|
||||||
|
if len(w) > 0 {
|
||||||
|
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(words, " ")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findElement(n *html.Node, tag string) *html.Node {
|
||||||
|
if n.Type == html.ElementNode && n.Data == tag {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
if found := findElement(c, tag); found != nil {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanTitle(s string) string {
|
||||||
|
cleaned := strings.TrimSpace(s)
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, `\u0026`, "&")
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, `\u003d`, "=")
|
||||||
|
|
||||||
|
prefixes := []string{
|
||||||
|
"Learn more about ",
|
||||||
|
"Learn more ",
|
||||||
|
"Apply for ",
|
||||||
|
"View job: ",
|
||||||
|
}
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if strings.HasPrefix(cleaned, p) {
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package google
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanHandle(t *testing.T) {
|
||||||
|
crawler := NewGoogleCrawler()
|
||||||
|
|
||||||
|
if !crawler.CanHandle("google_jobs") {
|
||||||
|
t.Errorf("expected CanHandle('google_jobs') = true")
|
||||||
|
}
|
||||||
|
if !crawler.CanHandle("google") {
|
||||||
|
t.Errorf("expected CanHandle('google') = true")
|
||||||
|
}
|
||||||
|
if !crawler.CanHandle("https://www.google.com/about/careers/applications/") {
|
||||||
|
t.Errorf("expected CanHandle for Google careers URL = true")
|
||||||
|
}
|
||||||
|
if crawler.CanHandle("linkedin") {
|
||||||
|
t.Errorf("expected CanHandle('linkedin') = false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultTestTargetURL(t *testing.T) {
|
||||||
|
if !strings.Contains(DefaultTestTargetURL, "employment_type=FULL_TIME") {
|
||||||
|
t.Errorf("DefaultTestTargetURL missing employment_type filter")
|
||||||
|
}
|
||||||
|
if !strings.Contains(DefaultTestTargetURL, "location=Seattle%2C%20WA%2C%20USA") {
|
||||||
|
t.Errorf("DefaultTestTargetURL missing Seattle location filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoogleCrawler_RealNetworkCrawling tests DiscoverJobs and FetchJobDetail
|
||||||
|
func TestGoogleCrawler_RealNetworkCrawling(t *testing.T) {
|
||||||
|
crawler := NewGoogleCrawler()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 1. Step 1: Discover jobs
|
||||||
|
discovered, err := crawler.DiscoverJobs(ctx, DefaultTestTargetURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Notice: Network fetch error (expected in offline sandbox): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(discovered) == 0 {
|
||||||
|
t.Errorf("⚠️ YELLOW FLAG: 0 jobs discovered from Google Careers target URL (%s).", DefaultTestTargetURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully discovered %d job links", len(discovered))
|
||||||
|
|
||||||
|
// 2. Step 3: Fetch detail for first discovered job link
|
||||||
|
first := discovered[0]
|
||||||
|
if first.CanonicalID == "" || !strings.HasPrefix(first.CanonicalID, "google:") {
|
||||||
|
t.Errorf("invalid CanonicalID: %q", first.CanonicalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := crawler.FetchJobDetail(ctx, first.ApplyURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Notice: Detail fetch error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(detail.Title) == "" {
|
||||||
|
t.Errorf("detail job has empty Title")
|
||||||
|
}
|
||||||
|
if detail.Company == "" {
|
||||||
|
t.Errorf("detail job has empty Company")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(detail.RawContent) == "" {
|
||||||
|
t.Errorf("detail job has empty RawContent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFromAFData(t *testing.T) {
|
||||||
|
// Realistic AF_initDataCallback payload mimicking Google Careers HTML
|
||||||
|
html := `<html><script class="ds:0">AF_initDataCallback({key: 'ds:0', hash: '1', data:[["141612485990851270","Software Engineer Manager, Modem NPI","https://apply.url",[null,"responsibilities"],[null,"qualifications"],"projects/gweb-careers-proto/tenants/xxx/companies/yyy",null,"Google","en-US",[["Shanghai, China",["Shanghai, China"],"Shanghai",null,"Shanghai","CN"]],[null,"about the job"],[2]]], sideChannel: {}});</script></html>`
|
||||||
|
|
||||||
|
title, company, location := extractFromAFData(html)
|
||||||
|
|
||||||
|
if title != "Software Engineer Manager, Modem NPI" {
|
||||||
|
t.Errorf("title = %q, want 'Software Engineer Manager, Modem NPI'", title)
|
||||||
|
}
|
||||||
|
if company != "Google" {
|
||||||
|
t.Errorf("company = %q, want 'Google'", company)
|
||||||
|
}
|
||||||
|
if location != "Shanghai, China" {
|
||||||
|
t.Errorf("location = %q, want 'Shanghai, China'", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with multiple locations and different company
|
||||||
|
html2 := `<script class="ds:0">AF_initDataCallback({key: 'ds:0', hash: '1', data:[["123","Strategy Associate, YouTube","https://apply.url",[null,"resp"],[null,"quals"],"projects/x",null,"YouTube","en-US",[["San Bruno, CA, USA",["addr"],"San Bruno","94066","CA","US"],["New York, NY, USA",["addr"],"New York",null,"NY","US"]],[null,"about"]]], sideChannel: {}});</script>`
|
||||||
|
|
||||||
|
title2, company2, location2 := extractFromAFData(html2)
|
||||||
|
|
||||||
|
if title2 != "Strategy Associate, YouTube" {
|
||||||
|
t.Errorf("title2 = %q, want 'Strategy Associate, YouTube'", title2)
|
||||||
|
}
|
||||||
|
if company2 != "YouTube" {
|
||||||
|
t.Errorf("company2 = %q, want 'YouTube'", company2)
|
||||||
|
}
|
||||||
|
if location2 != "San Bruno, CA, USA; New York, NY, USA" {
|
||||||
|
t.Errorf("location2 = %q, want 'San Bruno, CA, USA; New York, NY, USA'", location2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with DeepMind company
|
||||||
|
html3 := `<script class="ds:0">AF_initDataCallback({key: 'ds:0', hash: '1', data:[["456","Research Scientist","https://apply.url",[null,"resp"],[null,"quals"],"projects/x",null,"Google DeepMind","en-US",[["London, UK",["addr"],"London",null,"England","GB"]],[null,"about"]]], sideChannel: {}});</script>`
|
||||||
|
|
||||||
|
_, company3, _ := extractFromAFData(html3)
|
||||||
|
if company3 != "Google DeepMind" {
|
||||||
|
t.Errorf("company3 = %q, want 'Google DeepMind'", company3)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user