From 286278c1ecd5c00d2406f6d44a63e7680acb4c71 Mon Sep 17 00:00:00 2001 From: haopengzhan Date: Sat, 15 Aug 2026 00:00:41 -0700 Subject: [PATCH] feat: implement task queue statistics and add UI templates for job management and visualization. --- data/config/targets.yaml | 6 +- go.mod | 6 +- go.sum | 6 + internal/db/tasks.go | 26 ++ internal/ui/handlers.go | 73 +++- internal/ui/template_apply.go | 139 +++++++ internal/ui/template_jobs.go | 301 +++++++++++++++ internal/ui/template_kanban.go | 83 ++++ internal/ui/template_layout.go | 374 ++++++++++++++++++ internal/ui/template_targets.go | 90 +++++ internal/ui/templates.go | 657 ++------------------------------ pkg/crawler/google/google.go | 27 +- 12 files changed, 1144 insertions(+), 644 deletions(-) create mode 100644 internal/ui/template_apply.go create mode 100644 internal/ui/template_jobs.go create mode 100644 internal/ui/template_kanban.go create mode 100644 internal/ui/template_layout.go create mode 100644 internal/ui/template_targets.go diff --git a/data/config/targets.yaml b/data/config/targets.yaml index 035f054..d3018f4 100644 --- a/data/config/targets.yaml +++ b/data/config/targets.yaml @@ -2,8 +2,4 @@ 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 + is_active: true \ No newline at end of file diff --git a/go.mod b/go.mod index ecc14e4..a95fd04 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,17 @@ module github.com/hireme/hireme go 1.26 require ( + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 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 + go.uber.org/zap v1.28.0 + golang.org/x/net v0.55.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.28.0 // indirect ) diff --git a/go.sum b/go.sum index dc4bf09..5debd4c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= github.com/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= @@ -14,6 +18,8 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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= diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 1c19392..f3e99df 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -7,6 +7,32 @@ import ( "log" ) +// QueueStats holds task queue counters +type QueueStats struct { + Pending int `json:"pending"` + Running int `json:"running"` + Failed int `json:"failed"` + Completed int `json:"completed"` +} + +// GetQueueStats returns counts for each task status +func (db *DB) GetQueueStats() (*QueueStats, error) { + query := ` + SELECT + COALESCE(SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'RUNNING' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END), 0) + FROM tasks + ` + var stats QueueStats + err := db.QueryRow(query).Scan(&stats.Pending, &stats.Running, &stats.Failed, &stats.Completed) + if err != nil { + return nil, err + } + return &stats, nil +} + // EnqueueTask adds a new task to the queue func (db *DB) EnqueueTask(taskType string, payload interface{}) error { payloadBytes, err := json.Marshal(payload) diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 0255c2a..6d15d2d 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -1,9 +1,9 @@ package ui import ( + "encoding/json" "net/http" "strconv" - "strings" "github.com/hireme/hireme/internal/config" "github.com/hireme/hireme/internal/db" @@ -33,12 +33,15 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /jobs", s.handleGetJobs) mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail) mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob) + mux.HandleFunc("POST /jobs/{id}/opt-out", s.handleOptOut) + mux.HandleFunc("GET /applications", s.handleGetApplications) mux.HandleFunc("POST /applications", s.handleCreateApplication) mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus) mux.HandleFunc("POST /api/tasks/discover", s.handleDiscoverTasks) mux.HandleFunc("POST /api/tasks/clean", s.handleCleanTasks) mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs) mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks) + mux.HandleFunc("GET /api/status", s.handleGetStatus) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -189,6 +192,39 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`Analysis Queued`)) } +func (s *Server) handleOptOut(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 + } + + _, err = s.db.CreateOrUpdateApplication(id, "OPTED_OUT", "") + 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 + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = RenderJobList(w, jobs) +} + +func (s *Server) handleGetApplications(w http.ResponseWriter, r *http.Request) { + apps, err := s.db.GetApplications() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = RenderApplyTable(w, apps) +} + func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -213,13 +249,7 @@ func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) 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 - } - + // Always return the updated job list so the action column refreshes s.handleGetJobs(w, r) } @@ -270,7 +300,7 @@ func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) { updatedApps, _ := s.db.GetApplications() w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = RenderKanbanBoard(w, updatedApps) + _ = RenderApplyTable(w, updatedApps) } func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) { @@ -295,7 +325,7 @@ func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) { count++ } } - + w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "success", "message": "Queued ` + strconv.Itoa(count) + ` discovery tasks"}`)) } @@ -332,3 +362,26 @@ func (s *Server) handleToggleTasks(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "` + status + `"}`)) } + +// handleGetStatus returns a JSON snapshot of worker pool state and task queue counts. +// Used by the bottom bar to auto-refresh every few seconds without a full page reload. +func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) { + workerStatus := "paused" + if s.pool.IsRunning() { + workerStatus = "running" + } + + stats, err := s.db.GetQueueStats() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp := map[string]interface{}{ + "worker_status": workerStatus, + "queue": stats, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/internal/ui/template_apply.go b/internal/ui/template_apply.go new file mode 100644 index 0000000..49da52b --- /dev/null +++ b/internal/ui/template_apply.go @@ -0,0 +1,139 @@ +package ui + +const applyTabHTML = `{{ define "applyTab" }} +
+ +
+
+

Application Pipeline

+

Track all saved and in-progress applications

+
+
+ + + +
+
+ + +
+ {{ template "applyTable" .Applications }} +
+
+{{ end }}` + +const applyTableHTML = `{{ define "applyTable" }} +
+ + + + + + + + + + + + + {{ range . }} + {{ if .App }} + + + + + + + + + + + + + + + + + + + + + {{ end }} + {{ else }} + + + + {{ end }} + +
Job & CompanyPipeline StageScoreLinkLast UpdatedChange Status
+ + {{ .Company }} + πŸ“ {{ .Location }} + +
+ Saved + β–Ά + Applied + β–Ά + Interview + β–Ά + {{ if eq .App.Status "OFFER" }} + Offer πŸŽ‰ + {{ else if eq .App.Status "REJECTED" }} + Rejected + {{ else }} + Offer + {{ end }} +
+
+ {{ if .Analysis }} + + {{ printf "%.0f" .Analysis.Score }} + + {{ else }} + – + {{ end }} + + + πŸ”— + + + {{ formatDate .App.UpdatedAt }} + + +
+

πŸ“‹

+

No saved applications yet. Go to Jobs tab and click + Save on any job.

+
+
+{{ end }}` diff --git a/internal/ui/template_jobs.go b/internal/ui/template_jobs.go new file mode 100644 index 0000000..28a66af --- /dev/null +++ b/internal/ui/template_jobs.go @@ -0,0 +1,301 @@ +package ui + +const jobsTabHTML = `{{ define "jobsTab" }} +
+ +
+
+

Job Opportunities

+

All ingested postings Β· AI-scored Β· sortable

+
+
+ + + + +
+
+ + +
+ {{ template "jobs" .Jobs }} +
+
+{{ end }}` + +const jobListHTML = `{{ define "jobs" }} +
+ + + + + + + + + + + + + + {{ range . }} + {{- $qual := "pending" -}} + {{- if .Analysis }}{{ if .Analysis.IsQualified }}{{ $qual = "qualified" }}{{ else }}{{ $qual = "not-qualified" }}{{ end }}{{ end -}} + {{- $appClass := "none" -}} + {{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}} + + + + + + + + + + + + + + + + + + + + + + + + {{ else }} + + + + {{ end }} + +
Job Title & CompanyLinkAnalysisScore & QualificationReason / TagsCrawledActions
+ +
+ {{ .Company }} + • + πŸ“ {{ .Location }} +
+
+ + πŸ”— + + + {{ if .Analysis }} + {{ if eq .Analysis.Status "PARSED" }} + PARSED + {{ else if eq .Analysis.Status "PENDING" }} + PENDING + {{ else if eq .Analysis.Status "DONE" }} + DONE + {{ else }} + {{ .Analysis.Status }} + {{ end }} + {{ else }} + UNANALYZED + {{ end }} + + {{ if .Analysis }} + {{ if .Analysis.IsQualified }} + + β˜… {{ printf "%.0f" .Analysis.Score }} + + {{ else }} + + βœ— Not Qualified + + {{ end }} + {{ else }} + – + {{ end }} + + {{ if .App }}{{ if eq .App.Status "OPTED_OUT" }} + Opted out + {{ end }}{{ end }} + {{ if .Analysis }} + {{ if .Analysis.IsQualified }} +
+ {{ range parseJSON .Analysis.MatchedHighlights }} + {{ . }} + {{ else }} + No tags + {{ end }} +
+ {{ else }} + {{ $missing := parseJSON .Analysis.MissingHighlights }} + {{ if $missing }} + {{ index $missing 0 }} + {{ end }} + {{ end }} + {{ end }} +
+ {{ formatDate .CrawledAt }} + + {{ if .App }} + {{ if eq .App.Status "OPTED_OUT" }} + + + {{ else }} + + + {{ .App.Status }} + + {{ end }} + {{ else }} + +
+ + +
+ {{ end }} +
+

πŸ“­

+

No job postings found. Click πŸ” Discover in the bottom bar to start crawling.

+
+
+{{ end }}` + +const jobDetailHTML = `{{ define "jobDetail" }} +
+ +
+
+ {{ .Company }} +

{{ .Title }}

+

πŸ“ {{ .Location }} • Ingested {{ formatDate .CrawledAt }}

+
+ +
+ + {{ if .Analysis }} + +
+
+ Match Score + {{ if .Analysis.IsQualified }} + {{ printf "%.1f" .Analysis.Score }} / 100 + {{ else }} + Not Qualified + {{ end }} +
+
+ Analysis Status + {{ .Analysis.Status }} +
+
+ + +
+ {{ if .Analysis.IsQualified }} +
+

βœ“ Matched Highlights

+
+ {{ range parseJSON .Analysis.MatchedHighlights }} + {{ . }} + {{ else }} + None identified + {{ end }} +
+
+ {{ end }} + +
+

! {{ if .Analysis.IsQualified }}Gaps / Missing Skills{{ else }}Disqualification Reasons{{ end }}

+
+ {{ range parseJSON .Analysis.MissingHighlights }} + {{ . }} + {{ else }} + None identified + {{ end }} +
+
+ + {{ if .Analysis.IsQualified }} +
+

πŸ’‘ Resume Tailoring Tips

+
    + {{ range parseJSON .Analysis.TailoringSuggestions }} +
  • • {{ . }}
  • + {{ else }} +
  • None
  • + {{ end }} +
+
+ {{ end }} +
+ {{ end }} + + +
+

Job Description

+
+ {{ rawContent .RawContent }} +
+
+ + +
+ + Apply External β†— + +
+ + +
+
+
+{{ end }}` diff --git a/internal/ui/template_kanban.go b/internal/ui/template_kanban.go new file mode 100644 index 0000000..c759c52 --- /dev/null +++ b/internal/ui/template_kanban.go @@ -0,0 +1,83 @@ +package ui + + +const kanbanBoardHTML = `{{ define "kanban" }} +
+ +
+
+

πŸ’Ύ Saved

+ {{ range . }}{{ if eq .App.Status "SAVED" }}+1{{ end }}{{ end }} +
+
+ {{ range . }} + {{ if eq .App.Status "SAVED" }}{{ template "kanbanCard" . }}{{ end }} + {{ end }} +
+
+ + +
+
+

πŸš€ Applied

+
+
+ {{ range . }} + {{ if eq .App.Status "APPLIED" }}{{ template "kanbanCard" . }}{{ end }} + {{ end }} +
+
+ + +
+
+

πŸ’¬ Interview

+
+
+ {{ range . }} + {{ if eq .App.Status "INTERVIEW" }}{{ template "kanbanCard" . }}{{ end }} + {{ end }} +
+
+ + +
+
+

πŸŽ‰ Offer / Closed

+
+
+ {{ range . }} + {{ if or (eq .App.Status "OFFER") (eq .App.Status "REJECTED") }}{{ template "kanbanCard" . }}{{ end }} + {{ end }} +
+
+
+{{ end }}` + +const kanbanCardHTML = `{{ define "kanbanCard" }} +
+
+ {{ .Company }} +

{{ .Title }}

+ πŸ“ {{ .Location }} +
+ {{ if .Analysis }} +
+ Score: {{ printf "%.0f" .Analysis.Score }} +
+ {{ end }} +
+ Status: {{ .App.Status }} + +
+
+{{ end }}` diff --git a/internal/ui/template_layout.go b/internal/ui/template_layout.go new file mode 100644 index 0000000..4a0c748 --- /dev/null +++ b/internal/ui/template_layout.go @@ -0,0 +1,374 @@ +package ui + + +const mainLayoutHTML = `{{ define "page" }} + + + + + Hireme | Job Command Center + + + + + + + + + +
+
+
+
+ H +
+
+

Hireme

+

Job Command Center

+
+
+ + + +
+
+ + +
+
+
+ + + +
+
+
+ + +
+ + +
+ {{ template "jobsTab" . }} +
+ + + + + + + +
+ + + + + +
+
+ + +
+
+ + Workers: + Running +
+
+ Pending: – + Running: – + Failed: – +
+
+ + +
+ + + + +
+
+
+ + + +{{ end }}` diff --git a/internal/ui/template_targets.go b/internal/ui/template_targets.go new file mode 100644 index 0000000..f4aa1d0 --- /dev/null +++ b/internal/ui/template_targets.go @@ -0,0 +1,90 @@ +package ui + + +const targetsTabHTML = `{{ define "targetsTab" }} +
+
+
+

Crawler Targets

+

Configure job board URLs to crawl for new postings

+
+
+ + +
+

Add New Target

+
+ + + + +
+
+ + +
+ {{ template "targets" .Targets }} +
+
+{{ end }}` + +const targetListHTML = `{{ define "targets" }} +
+ + + + + + + + + + + + + {{ range . }} + + + + + + + + + {{ else }} + + + + {{ end }} + +
PlatformNameTarget URLLast CrawledStatusAction
{{ platformIcon .Platform }}{{ .Name }} + {{ .TargetURL }} + {{ formatTime .LastCrawledAt }} + {{ if .IsActive }} + Active + {{ else }} + Paused + {{ end }} + + +
+

🎯

+

No search targets configured yet. Add one above.

+
+
+{{ end }}` diff --git a/internal/ui/templates.go b/internal/ui/templates.go index 59cecf9..5b3f26c 100644 --- a/internal/ui/templates.go +++ b/internal/ui/templates.go @@ -4,6 +4,7 @@ import ( "encoding/json" "html/template" "io" + "regexp" "strings" "time" @@ -49,8 +50,20 @@ var funcMap = template.FuncMap{ } return "🌐 Web Target" }, + // rawContent returns the stored Markdown string (already clean) + "rawContent": func(s string) string { + return strings.TrimSpace(s) + }, + // htmlStripTags is a safety net for any legacy HTML still in the DB + "htmlStripTags": func(s string) string { + re := regexp.MustCompile(`<[^>]+>`) + clean := re.ReplaceAllString(s, " ") + space := regexp.MustCompile(`\s+`) + return strings.TrimSpace(space.ReplaceAllString(clean, " ")) + }, } +// PageData is the data payload for the main page render. type PageData struct { Targets []db.SearchTarget Jobs []db.JobWithAnalysis @@ -62,36 +75,32 @@ type PageData struct { ActiveApplications int } -var ( - rootTemplate *template.Template -) +var rootTemplate *template.Template func init() { + // All template HTML strings are collected here in a defined order. + // Sub-files do NOT register via init() β€” they export named constants that + // are referenced directly in this slice to avoid init ordering issues. + sources := []string{ + mainLayoutHTML, + jobsTabHTML, + jobListHTML, + jobDetailHTML, + targetsTabHTML, + targetListHTML, + applyTabHTML, + applyTableHTML, + kanbanBoardHTML, + kanbanCardHTML, + } + 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) + for _, src := range sources { + rootTemplate, err = rootTemplate.Parse(src) + if err != nil { + panic("template parse error: " + err.Error()) + } } } @@ -115,596 +124,6 @@ func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error { return rootTemplate.ExecuteTemplate(w, "kanban", apps) } -const mainLayoutHTML = `{{ define "page" }} - - - - - Hireme | Job Command Center - - - - - - - - - - -
-
-
-
- H -
-
-

Hireme

-

Job Application Command Center

-
-
- - -
- - -
- - - - -
-
-
-
- - -
- - -
-
-
-

Total Search Targets

-

{{ len .Targets }}

- Active Crawler Pipelines -
- -
-
-

Qualified Jobs Matched

-

{{ .QualifiedJobsCount }}

- Passed Keyword & Score Filter -
- -
-
-

Total Ingested Postings

-

{{ .TotalIngestedCount }}

- Deduplicated Raw Crawls -
- -
-
-

Active Applications

-

{{ .ActiveApplications }}

- Tracked in Kanban Pipeline -
-
- - -
- -
- - - -
- - -
-
-
-

Qualified Job Opportunities

-

Ranked by rule score + cached LLM skill gap analysis

-
- -
-
- {{ template "jobs" .Jobs }} -
-
- - - - - - -
- -
- - - - - - - -{{ end }}` - -const targetListHTML = `{{ define "targets" }} -
- - - - - - - - - - - - - {{ range . }} - - - - - - - - - {{ else }} - - - - {{ end }} - -
PlatformNameTarget URLLast CrawledStatusAction
{{ platformIcon .Platform }}{{ .Name }} - {{ .TargetURL }} - {{ formatTime .LastCrawledAt }} - {{ if .IsActive }} - Active - {{ else }} - Paused - {{ end }} - - -
No search targets configured yet.
-
{{ end }}` - -const jobListHTML = `{{ define "jobs" }} -
- - - - - - - - - - - - - - {{ range . }} - - - - - - - - - - - - - - - - - - - - - - - {{ else }} - - - - {{ end }} - -
Job Title & CompanyOriginal LinkAnalysis StatusScore & QualificationKeyword TagsCrawled DateAction
- -
- {{ .Company }} - • - πŸ“ {{ .Location }} -
-
- - πŸ”— - - - {{ if .Analysis }} - {{ if eq .Analysis.Status "PARSED" }} - - PARSED - - {{ else if eq .Analysis.Status "FILTERED_OUT" }} - - FILTERED_OUT - - {{ else }} - - {{ .Analysis.Status }} - - {{ end }} - {{ else }} - - UNANALYZED - - {{ end }} - - {{ if .Analysis }} - {{ if .Analysis.IsQualified }} - - β˜… {{ printf "%.0f" .Analysis.Score }} Qualified - - {{ else }} -
- - Not Qualified - - {{ $missing := parseJSON .Analysis.MissingHighlights }} - {{ if $missing }} - {{ index $missing 0 }} - {{ end }} -
- {{ end }} - {{ else }} - - - {{ end }} -
- {{ if .Analysis }} -
- {{ range parseJSON .Analysis.MatchedHighlights }} - - {{ . }} - - {{ else }} - No tags - {{ end }} -
- {{ else }} - No tags - {{ end }} -
- {{ formatDate .CrawledAt }} - - {{ if .App }} - - {{ .App.Status }} - - {{ else }} - - {{ end }} -
- No job postings found yet. Click πŸ” Discover Jobs to enqueue crawler tasks. -
-
{{ end }}` - -const jobDetailHTML = `{{ define "jobDetail" }} -
-
-
- {{ .Company }} -

{{ .Title }}

-

πŸ“ {{ .Location }} • Ingested {{ formatDate .CrawledAt }}

-
- -
- - {{ if .Analysis }} -
-
- Match Relevance Score - {{ printf "%.1f" .Analysis.Score }} / 100 -
-
- LLM Status - {{ .Analysis.Status }} -
-
- - -
-
-

βœ“ Matched Technical Highlights

-
- {{ range parseJSON .Analysis.MatchedHighlights }} - - {{ . }} - - {{ end }} -
-
- -
-

! Skill & Experience Gaps

-
- {{ range parseJSON .Analysis.MissingHighlights }} - - {{ . }} - - {{ end }} -
-
- -
-

πŸ’‘ Resume Tailoring Action Points

-
    - {{ range parseJSON .Analysis.TailoringSuggestions }} -
  • - • {{ . }} -
  • - {{ end }} -
-
-
- {{ end }} - - -
-

Job Description Payload

-
- {{ .RawContent }} -
-
- - -
- - Apply External β†— - - -
- - -
-
-
{{ end }}` - -const kanbanBoardHTML = `{{ define "kanban" }} -
- -
-
-

πŸ’Ύ Saved

-
-
- {{ range . }} - {{ if eq .App.Status "SAVED" }} - {{ template "kanbanCard" . }} - {{ end }} - {{ end }} -
-
- - -
-
-

πŸš€ Applied

-
-
- {{ range . }} - {{ if eq .App.Status "APPLIED" }} - {{ template "kanbanCard" . }} - {{ end }} - {{ end }} -
-
- - -
-
-

πŸ’¬ Interview

-
-
- {{ range . }} - {{ if eq .App.Status "INTERVIEW" }} - {{ template "kanbanCard" . }} - {{ end }} - {{ end }} -
-
- - -
-
-

πŸŽ‰ Offer / Closed

-
-
- {{ range . }} - {{ if or (eq .App.Status "OFFER") (eq .App.Status "REJECTED") }} - {{ template "kanbanCard" . }} - {{ end }} - {{ end }} -
-
-
{{ end }}` - -const kanbanCardHTML = ` -{{ define "kanbanCard" }} -
-
- {{ .Company }} -

{{ .Title }}

- πŸ“ {{ .Location }} -
- -
- Status: {{ .App.Status }} - - -
-
-{{ end }}` +func RenderApplyTable(w io.Writer, apps []db.JobWithAnalysis) error { + return rootTemplate.ExecuteTemplate(w, "applyTable", apps) +} diff --git a/pkg/crawler/google/google.go b/pkg/crawler/google/google.go index 84d39a1..75a4e56 100644 --- a/pkg/crawler/google/google.go +++ b/pkg/crawler/google/google.go @@ -13,6 +13,7 @@ import ( "strings" "time" + md "github.com/JohannesKaufmann/html-to-markdown/v2" "github.com/hireme/hireme/internal/crawler" "golang.org/x/net/html" ) @@ -130,17 +131,27 @@ func (g *GoogleCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*c location = "See Job Posting" } - // Raw content: render the
container HTML - var buf bytes.Buffer + // Raw content: extract
HTML, convert to Markdown for LLM efficiency + var rawMarkdown string doc, parseErr := html.Parse(strings.NewReader(htmlContent)) if parseErr == nil { - if main := findElement(doc, "main"); main != nil { - _ = html.Render(&buf, main) + if mainEl := findElement(doc, "main"); mainEl != nil { + var buf bytes.Buffer + _ = html.Render(&buf, mainEl) + converted, convErr := md.ConvertString(buf.String()) + if convErr == nil && len(converted) > 50 { + rawMarkdown = converted + } } } - if buf.Len() < 100 { - buf.Reset() - buf.WriteString(htmlContent) + if rawMarkdown == "" { + // Fallback: convert the whole page + converted, convErr := md.ConvertString(htmlContent) + if convErr == nil { + rawMarkdown = converted + } else { + rawMarkdown = htmlContent + } } return &crawler.ExtractedJob{ @@ -149,7 +160,7 @@ func (g *GoogleCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*c Company: company, Location: location, ApplyURL: applyURL, - RawContent: buf.String(), + RawContent: rawMarkdown, }, nil }