refactor
This commit is contained in:
parent
c470edd187
commit
7bf35ccb6b
24 changed files with 238 additions and 66 deletions
|
|
@ -91,7 +91,7 @@ jobs-scraper/
|
||||||
func (s *Scraper) RetryableHTTPRequest(ctx context.Context, url string) (*http.Response, error)
|
func (s *Scraper) RetryableHTTPRequest(ctx context.Context, url string) (*http.Response, error)
|
||||||
|
|
||||||
// Context-aware job scraping with retry support
|
// Context-aware job scraping with retry support
|
||||||
func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params SearchQuery) ([]models.Job, error)
|
func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params SearchQuery) ([]domain.Job, error)
|
||||||
```
|
```
|
||||||
- **Retry Logic**: Up to 3 attempts with exponential backoff (1s → 2s → 4s → 8s)
|
- **Retry Logic**: Up to 3 attempts with exponential backoff (1s → 2s → 4s → 8s)
|
||||||
- **Smart Error Handling**: Retries 5xx errors, fails fast on 4xx errors
|
- **Smart Error Handling**: Retries 5xx errors, fails fast on 4xx errors
|
||||||
|
|
@ -101,7 +101,7 @@ func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params Se
|
||||||
### 2. Scraper Service (`internal/services/scraper.go`)
|
### 2. Scraper Service (`internal/services/scraper.go`)
|
||||||
```go
|
```go
|
||||||
// Streams jobs with configurable search parameters
|
// Streams jobs with configurable search parameters
|
||||||
func (s *Scraper) ScrapeLinkedInJobsStreaming(ctx context.Context, numPages int, jobChan chan<- models.Job, params SearchQuery) error
|
func (s *Scraper) ScrapeLinkedInJobsStreaming(ctx context.Context, numPages int, jobChan chan<- domain.Job, params SearchQuery) error
|
||||||
```
|
```
|
||||||
- **Sequential Processing**: Pages scraped one by one to avoid detection
|
- **Sequential Processing**: Pages scraped one by one to avoid detection
|
||||||
- **Rate Limited**: 2-second delays between requests
|
- **Rate Limited**: 2-second delays between requests
|
||||||
|
|
@ -124,7 +124,7 @@ func (p *JobPipeline) ProcessJobsStreaming(ctx context.Context, numPages int, jo
|
||||||
### 4. Job Description Workers
|
### 4. Job Description Workers
|
||||||
```go
|
```go
|
||||||
// Each worker processes jobs with retry logic
|
// Each worker processes jobs with retry logic
|
||||||
func (p *JobPipeline) jobDescriptionWorker(ctx context.Context, jobChan <-chan models.Job, resultChan chan<- JobDescriptionResult)
|
func (p *JobPipeline) jobDescriptionWorker(ctx context.Context, jobChan <-chan domain.Job, resultChan chan<- JobDescriptionResult)
|
||||||
```
|
```
|
||||||
- **Retry-Enabled**: Uses `ScrapeJobDescriptionWithContext` with retry logic
|
- **Retry-Enabled**: Uses `ScrapeJobDescriptionWithContext` with retry logic
|
||||||
- **Rate Limited**: 2-second delays per worker to avoid overwhelming servers
|
- **Rate Limited**: 2-second delays per worker to avoid overwhelming servers
|
||||||
|
|
|
||||||
26
api/app.go
Normal file
26
api/app.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/jobs-scraper/api/commands/job"
|
||||||
|
"github.com/jobs-scraper/application"
|
||||||
|
"github.com/jobs-scraper/internal/repo"
|
||||||
|
"github.com/jobs-scraper/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewApplication(router *mux.Router, db *sql.DB) *application.Application {
|
||||||
|
s := server.NewServer(router)
|
||||||
|
|
||||||
|
jobRepo := repo.NewJobRepository(db)
|
||||||
|
|
||||||
|
return &application.Application{
|
||||||
|
Server: s,
|
||||||
|
Router: router,
|
||||||
|
DB: db,
|
||||||
|
JobCommands: &application.JobCommands{
|
||||||
|
CreateJob: job.NewCreateJobHandler(jobRepo),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
21
api/commands/job/create-job.go
Normal file
21
api/commands/job/create-job.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jobs-scraper/internal/ports"
|
||||||
|
"github.com/jobs-scraper/internal/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateJob struct {
|
||||||
|
jobRepo *repo.JobRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateJobHandler(jobRepo *repo.JobRepository) *CreateJob {
|
||||||
|
return &CreateJob{
|
||||||
|
jobRepo: jobRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cj *CreateJob) Handle(cmd ports.CreateJobCommand) error {
|
||||||
|
// Implementation will go here
|
||||||
|
return nil
|
||||||
|
}
|
||||||
5
api/main.go
Normal file
5
api/main.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
}
|
||||||
20
application/app.go
Normal file
20
application/app.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/jobs-scraper/internal/ports"
|
||||||
|
"github.com/jobs-scraper/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
Router *mux.Router
|
||||||
|
DB *sql.DB
|
||||||
|
Server *server.AppServer
|
||||||
|
JobCommands *JobCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobCommands struct {
|
||||||
|
CreateJob ports.JobCommandHandler
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/jobs-scraper/infrastructure"
|
"github.com/jobs-scraper/infrastructure"
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
"github.com/jobs-scraper/internal/repo"
|
"github.com/jobs-scraper/internal/repo"
|
||||||
"github.com/jobs-scraper/internal/services"
|
"github.com/jobs-scraper/internal/services"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
@ -65,7 +65,7 @@ func main() {
|
||||||
log.Fatalf("Failed to get job description: %v", err)
|
log.Fatalf("Failed to get job description: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := openRouterService.AnalyzeJobDescription(string(cv), models.JobDescription{
|
result, err := openRouterService.AnalyzeJobDescription(string(cv), domain.JobDescription{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
Description: jobDescription,
|
Description: jobDescription,
|
||||||
Criteria: jobCriteria,
|
Criteria: jobCriteria,
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -8,9 +8,9 @@ require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.3
|
github.com/PuerkitoBio/goquery v1.10.3
|
||||||
github.com/eduardolat/openroutergo v0.1.0
|
github.com/eduardolat/openroutergo v0.1.0
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.0
|
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
golang.org/x/time v0.5.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -35,6 +35,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
|
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
|
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
|
@ -135,8 +137,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|
|
||||||
7
internal/domain/job-description.go
Normal file
7
internal/domain/job-description.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
type JobDescription struct {
|
||||||
|
JobID int64
|
||||||
|
Description string
|
||||||
|
Criteria map[string]string
|
||||||
|
}
|
||||||
6
internal/domain/job-with-description.go
Normal file
6
internal/domain/job-with-description.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
type JobWithDescription struct {
|
||||||
|
Job Job
|
||||||
|
JobDescription JobDescription
|
||||||
|
}
|
||||||
29
internal/domain/job.go
Normal file
29
internal/domain/job.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// JobProvider represents different job board providers
|
||||||
|
type JobProvider int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LinkedIn JobProvider = iota
|
||||||
|
Indeed
|
||||||
|
Glassdoor
|
||||||
|
Bayt
|
||||||
|
TokyoDev
|
||||||
|
JapanDev
|
||||||
|
)
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Company string
|
||||||
|
CompanyLink string
|
||||||
|
Location string
|
||||||
|
JobLink string
|
||||||
|
Provider JobProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchQuery struct {
|
||||||
|
Keywords string `json:"keywords"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
FWT string `json:"f_WT"` // Work type filter (1=onsite, 2=remote, 3=hybrid)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "github.com/jobs-scraper/internal/domain"
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int64
|
ID int64
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -7,11 +9,11 @@ type Job struct {
|
||||||
CompanyLink string
|
CompanyLink string
|
||||||
Location string
|
Location string
|
||||||
JobLink string
|
JobLink string
|
||||||
|
Provider domain.JobProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchQuery struct {
|
type SearchQuery struct {
|
||||||
Keywords string `json:"keywords"`
|
Keywords string `json:"keywords"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
FWT string `json:"f_WT"` // Work type filter (1=onsite, 2=remote, 3=hybrid)
|
FWT string `json:"f_WT"` // Work type filter (1=onsite, 2=remote, 3=hybrid)
|
||||||
GeoId string `json:"geoId"` // Geographic location ID
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ import (
|
||||||
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
"github.com/jobs-scraper/internal/repo"
|
"github.com/jobs-scraper/internal/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobDescriptionResult represents the result of job description scraping
|
// JobDescriptionResult represents the result of job description scraping
|
||||||
type JobDescriptionResult struct {
|
type JobDescriptionResult struct {
|
||||||
Job models.Job
|
Job domain.Job
|
||||||
Description string
|
Description string
|
||||||
Criteria map[string]string
|
Criteria map[string]string
|
||||||
Error error
|
Error error
|
||||||
|
|
@ -38,12 +38,12 @@ func NewJobPipeline(scraperService *Scraper, numWorkers int, rateLimit time.Dura
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessJobsStreaming processes jobs and job descriptions concurrently
|
// ProcessJobsStreaming processes jobs and job descriptions concurrently
|
||||||
func (p *JobPipeline) ProcessJobsStreaming(ctx context.Context, numPages int, jobRepo *repo.JobRepository, jobDescRepo *repo.JobDescriptionRepository, params models.SearchQuery) error {
|
func (p *JobPipeline) ProcessJobsStreaming(ctx context.Context, numPages int, jobRepo *repo.JobRepository, jobDescRepo *repo.JobDescriptionRepository, searchQuery domain.SearchQuery) error {
|
||||||
// Create channels for the pipeline
|
// Create channels for the pipeline
|
||||||
allJobs := make([]models.Job, 0, 100)
|
allJobs := make([]domain.Job, 0, 100)
|
||||||
allJobDescriptions := make([]models.JobDescription, 0, 100)
|
allJobDescriptions := make([]domain.JobDescription, 0, 100)
|
||||||
var jbMu sync.Mutex
|
var jbMu sync.Mutex
|
||||||
jobsChan := GetJobs(ctx, p.scraperService)
|
jobsChan := GetJobs(ctx, p.scraperService, searchQuery)
|
||||||
jobWithDescriptionChan := GetJobDescription(ctx, p.scraperService, jobsChan, 3)
|
jobWithDescriptionChan := GetJobDescription(ctx, p.scraperService, jobsChan, 3)
|
||||||
|
|
||||||
for jobWithDescription := range jobWithDescriptionChan {
|
for jobWithDescription := range jobWithDescriptionChan {
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,15 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetJobs(context context.Context, scraperService *Scraper) <-chan models.Job {
|
func GetJobs(context context.Context, scraperService *Scraper, searchQuery domain.SearchQuery) <-chan domain.Job {
|
||||||
jobChan := make(chan models.Job, 100)
|
jobChan := make(chan domain.Job, 100)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(jobChan)
|
defer close(jobChan)
|
||||||
if err := scraperService.ScrapeLinkedInJobsStreaming(context, 10, jobChan, models.SearchQuery{
|
if err := scraperService.ScrapeLinkedInJobsStreaming(context, 10, jobChan, searchQuery); err != nil {
|
||||||
Keywords: "Frontend Developer",
|
|
||||||
Location: "Japan",
|
|
||||||
FWT: "2,3",
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("Error scraping jobs: %v", err)
|
log.Printf("Error scraping jobs: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -25,8 +21,8 @@ func GetJobs(context context.Context, scraperService *Scraper) <-chan models.Job
|
||||||
return jobChan
|
return jobChan
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetJobDescription(context context.Context, scraperService *Scraper, jobChan <-chan models.Job, numWorkers int) <-chan models.JobWithDescription {
|
func GetJobDescription(context context.Context, scraperService *Scraper, jobChan <-chan domain.Job, numWorkers int) <-chan domain.JobWithDescription {
|
||||||
jobDescriptionChan := make(chan models.JobWithDescription, 100)
|
jobDescriptionChan := make(chan domain.JobWithDescription, 100)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
|
@ -46,9 +42,9 @@ func GetJobDescription(context context.Context, scraperService *Scraper, jobChan
|
||||||
if jd, jc, err := scraperService.ScrapeJobDescriptionWithContext(context, job); err != nil {
|
if jd, jc, err := scraperService.ScrapeJobDescriptionWithContext(context, job); err != nil {
|
||||||
log.Printf("Error scraping jobs: %v", err)
|
log.Printf("Error scraping jobs: %v", err)
|
||||||
} else {
|
} else {
|
||||||
jobDescriptionChan <- models.JobWithDescription{
|
jobDescriptionChan <- domain.JobWithDescription{
|
||||||
Job: job,
|
Job: job,
|
||||||
JobDescription: models.JobDescription{
|
JobDescription: domain.JobDescription{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
Description: jd,
|
Description: jd,
|
||||||
Criteria: jc,
|
Criteria: jc,
|
||||||
|
|
@ -11,13 +11,12 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
"github.com/jobs-scraper/internal/utils"
|
"github.com/jobs-scraper/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Timespan string // Time filter for job postings (e.g., "r86400" for last 24 hours)
|
Timespan string // Time filter for job postings (e.g., "r86400" for last 24 hours)
|
||||||
Distance string // Search radius in miles (e.g., "25")
|
|
||||||
SortBy string // Sort results by (R for relevance, DD for date posted)
|
SortBy string // Sort results by (R for relevance, DD for date posted)
|
||||||
MaxRetries int // Maximum number of retries for failed requests
|
MaxRetries int // Maximum number of retries for failed requests
|
||||||
BaseDelay time.Duration // Base delay for exponential backoff
|
BaseDelay time.Duration // Base delay for exponential backoff
|
||||||
|
|
@ -59,7 +58,7 @@ func NewScraper(config Config) *Scraper {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScrapeLinkedInJobsStreaming scrapes jobs page by page and sends them to channel immediately
|
// ScrapeLinkedInJobsStreaming scrapes jobs page by page and sends them to channel immediately
|
||||||
func (s *Scraper) ScrapeLinkedInJobsStreaming(ctx context.Context, numPages int, jobChan chan<- models.Job, params models.SearchQuery) error {
|
func (s *Scraper) ScrapeLinkedInJobsStreaming(ctx context.Context, numPages int, jobChan chan<- domain.Job, params domain.SearchQuery) error {
|
||||||
// Process pages sequentially to send jobs immediately
|
// Process pages sequentially to send jobs immediately
|
||||||
for i := range numPages {
|
for i := range numPages {
|
||||||
select {
|
select {
|
||||||
|
|
@ -84,8 +83,8 @@ func (s *Scraper) ScrapeLinkedInJobsStreaming(ctx context.Context, numPages int,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params models.SearchQuery) ([]models.Job, error) {
|
func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params domain.SearchQuery) ([]domain.Job, error) {
|
||||||
jobs := make([]models.Job, 0, 10)
|
jobs := make([]domain.Job, 0, 10)
|
||||||
url := s.buildSearchURL(params, page)
|
url := s.buildSearchURL(params, page)
|
||||||
|
|
||||||
retryableRequest := utils.NewRetryableHTTPRequest(utils.RetryConfig{
|
retryableRequest := utils.NewRetryableHTTPRequest(utils.RetryConfig{
|
||||||
|
|
@ -108,12 +107,12 @@ func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params mo
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.Find("li > div.base-card").Each(func(i int, s *goquery.Selection) {
|
doc.Find("li > div.base-card").Each(func(i int, s *goquery.Selection) {
|
||||||
job := models.Job{}
|
job := domain.Job{}
|
||||||
title := s.Find("[class*=_title]").Text()
|
title := s.Find("[class*=_title]").Text()
|
||||||
job.Title = strings.TrimSpace(title)
|
job.Title = strings.TrimSpace(title)
|
||||||
job.Company = strings.TrimSpace(s.Find(".hidden-nested-link").Text())
|
job.Company = strings.TrimSpace(s.Find(".hidden-nested-link").Text())
|
||||||
job.CompanyLink = strings.TrimSpace(s.Find(".hidden-nested-link").AttrOr("href", ""))
|
job.CompanyLink = strings.TrimSpace(s.Find(".hidden-nested-link").AttrOr("href", ""))
|
||||||
job.Location = strings.TrimSpace(s.Find(".job-search-card__location").Text())
|
job.Location = strings.TrimSpace(params.Location)
|
||||||
job.JobLink = strings.TrimSpace(s.Find("a.base-card__full-link").AttrOr("href", ""))
|
job.JobLink = strings.TrimSpace(s.Find("a.base-card__full-link").AttrOr("href", ""))
|
||||||
jobId, err := extractJobIDFromURL(job.JobLink)
|
jobId, err := extractJobIDFromURL(job.JobLink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -125,6 +124,7 @@ func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params mo
|
||||||
fmt.Printf("Error extracting job ID from URL %s: %v\n", job.JobLink, err)
|
fmt.Printf("Error extracting job ID from URL %s: %v\n", job.JobLink, err)
|
||||||
}
|
}
|
||||||
job.ID = jobIdInt
|
job.ID = jobIdInt
|
||||||
|
job.Provider = 0
|
||||||
|
|
||||||
if job.Title != "" && job.Company != "" && job.Location != "" && job.JobLink != "" {
|
if job.Title != "" && job.Company != "" && job.Location != "" && job.JobLink != "" {
|
||||||
jobs = append(jobs, job)
|
jobs = append(jobs, job)
|
||||||
|
|
@ -134,7 +134,7 @@ func (s *Scraper) ScrapeJobsWithContext(ctx context.Context, page int, params mo
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scraper) ScrapeJobDescriptionWithContext(ctx context.Context, job models.Job) (string, map[string]string, error) {
|
func (s *Scraper) ScrapeJobDescriptionWithContext(ctx context.Context, job domain.Job) (string, map[string]string, error) {
|
||||||
var jobDescription string
|
var jobDescription string
|
||||||
url := s.buildJobDescriptionSearchURL(job.JobLink)
|
url := s.buildJobDescriptionSearchURL(job.JobLink)
|
||||||
|
|
||||||
|
|
@ -166,23 +166,20 @@ func (s *Scraper) ScrapeJobDescriptionWithContext(ctx context.Context, job model
|
||||||
return strings.TrimSpace(jobDescription), jobCriteria, nil
|
return strings.TrimSpace(jobDescription), jobCriteria, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scraper) buildSearchURL(query models.SearchQuery, page int) string {
|
func (s *Scraper) buildSearchURL(query domain.SearchQuery, page int) string {
|
||||||
baseURL := "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
|
baseURL := "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
|
|
||||||
// Required search parameters
|
// Set keywords and location WITHOUT pre-escaping
|
||||||
params.Set("keywords", url.QueryEscape(query.Keywords))
|
params.Set("keywords", query.Keywords)
|
||||||
params.Set("location", url.QueryEscape(query.Location))
|
params.Set("location", query.Location)
|
||||||
|
|
||||||
// Time filter
|
// Work type filter (e.g., "2,3" for remote + hybrid)
|
||||||
// params.Set("f_TPR", s.config.Timespan)
|
|
||||||
|
|
||||||
// Work type filter
|
|
||||||
if query.FWT != "" {
|
if query.FWT != "" {
|
||||||
params.Set("f_WT", query.FWT)
|
params.Set("f_WT", query.FWT) // "2,3" is valid; url.Values will encode comma if needed, but LinkedIn accepts raw comma
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination
|
// Pagination: LinkedIn uses 0, 25, 50, ...
|
||||||
params.Set("start", strconv.Itoa(25*page))
|
params.Set("start", strconv.Itoa(25*page))
|
||||||
|
|
||||||
return fmt.Sprintf("%s?%s", baseURL, params.Encode())
|
return fmt.Sprintf("%s?%s", baseURL, params.Encode())
|
||||||
|
|
|
||||||
16
internal/ports/commands.go
Normal file
16
internal/ports/commands.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package ports
|
||||||
|
|
||||||
|
import "github.com/jobs-scraper/internal/domain"
|
||||||
|
|
||||||
|
// CreateJobCommand represents the command to create a job
|
||||||
|
type CreateJobCommand struct {
|
||||||
|
Location string
|
||||||
|
Keywords string
|
||||||
|
FWT string
|
||||||
|
Provider domain.JobProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobCommandHandler defines the interface for handling job commands
|
||||||
|
type JobCommandHandler interface {
|
||||||
|
Handle(cmd CreateJobCommand) error
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JobDescriptionRepository struct {
|
type JobDescriptionRepository struct {
|
||||||
|
|
@ -23,7 +23,7 @@ func NewJobDescriptionRepository(db *sql.DB) *JobDescriptionRepository {
|
||||||
return &JobDescriptionRepository{db: db}
|
return &JobDescriptionRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobDescriptionRepository) SaveJobDescriptions(jobDescriptions []models.JobDescription) error {
|
func (r *JobDescriptionRepository) SaveJobDescriptions(jobDescriptions []domain.JobDescription) error {
|
||||||
if len(jobDescriptions) == 0 {
|
if len(jobDescriptions) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JobRepository struct {
|
type JobRepository struct {
|
||||||
|
|
@ -15,18 +15,18 @@ func NewJobRepository(db *sql.DB) *JobRepository {
|
||||||
return &JobRepository{db: db}
|
return &JobRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobRepository) SaveJobs(jobs []models.Job) error {
|
func (r *JobRepository) SaveJobs(jobs []domain.Job) error {
|
||||||
if len(jobs) == 0 {
|
if len(jobs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate jobs by ID to avoid duplicate errors
|
// Deduplicate jobs by ID to avoid duplicate errors
|
||||||
jobMap := make(map[int64]models.Job)
|
jobMap := make(map[int64]domain.Job)
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
jobMap[job.ID] = job
|
jobMap[job.ID] = job
|
||||||
}
|
}
|
||||||
|
|
||||||
uniqueJobs := make([]models.Job, 0, len(jobMap))
|
uniqueJobs := make([]domain.Job, 0, len(jobMap))
|
||||||
for _, job := range jobMap {
|
for _, job := range jobMap {
|
||||||
uniqueJobs = append(uniqueJobs, job)
|
uniqueJobs = append(uniqueJobs, job)
|
||||||
}
|
}
|
||||||
|
|
@ -67,16 +67,16 @@ func (r *JobRepository) SaveJobs(jobs []models.Job) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobRepository) GetAllJobs() ([]models.Job, error) {
|
func (r *JobRepository) GetAllJobs() ([]domain.Job, error) {
|
||||||
rows, err := r.db.Query("SELECT id, title, company, company_link, location, job_link FROM jobs")
|
rows, err := r.db.Query("SELECT id, title, company, company_link, location, job_link FROM jobs")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error querying jobs: %v", err)
|
return nil, fmt.Errorf("error querying jobs: %v", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var jobs []models.Job
|
var jobs []domain.Job
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var job models.Job
|
var job domain.Job
|
||||||
if err := rows.Scan(&job.ID, &job.Title, &job.Company, &job.CompanyLink, &job.Location, &job.JobLink); err != nil {
|
if err := rows.Scan(&job.ID, &job.Title, &job.Company, &job.CompanyLink, &job.Location, &job.JobLink); err != nil {
|
||||||
return nil, fmt.Errorf("error scanning job row: %v", err)
|
return nil, fmt.Errorf("error scanning job row: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -89,8 +89,8 @@ func (r *JobRepository) GetAllJobs() ([]models.Job, error) {
|
||||||
|
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
func (r *JobRepository) GetJobByID(id int) (*models.Job, error) {
|
func (r *JobRepository) GetJobByID(id int) (*domain.Job, error) {
|
||||||
var job models.Job
|
var job domain.Job
|
||||||
|
|
||||||
sqlStatement := `
|
sqlStatement := `
|
||||||
SELECT id, title, company, company_link, location, job_link
|
SELECT id, title, company, company_link, location, job_link
|
||||||
|
|
|
||||||
40
internal/server/server.go
Normal file
40
internal/server/server.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
http "net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppServer struct {
|
||||||
|
router *mux.Router
|
||||||
|
httpServer *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(router *mux.Router) *AppServer {
|
||||||
|
port := "8080"
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
server := &AppServer{
|
||||||
|
router: router,
|
||||||
|
httpServer: httpServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppServer) Start() error {
|
||||||
|
return s.httpServer.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppServer) Stop(ctx context.Context) {
|
||||||
|
s.httpServer.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/eduardolat/openroutergo"
|
"github.com/eduardolat/openroutergo"
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobAnalysisResult represents the structured response from job analysis
|
// JobAnalysisResult represents the structured response from job analysis
|
||||||
|
|
@ -42,7 +42,7 @@ func NewOpenRouterService(model string, apiKey string) OpenRouterService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenRouterService) AnalyzeJobDescription(cv string, jobDesc models.JobDescription) (*JobAnalysisResult, error) {
|
func (s *OpenRouterService) AnalyzeJobDescription(cv string, jobDesc domain.JobDescription) (*JobAnalysisResult, error) {
|
||||||
client, err := openroutergo.
|
client, err := openroutergo.
|
||||||
NewClient().
|
NewClient().
|
||||||
WithAPIKey(s.apiKey).
|
WithAPIKey(s.apiKey).
|
||||||
|
|
@ -110,5 +110,5 @@ func (s *OpenRouterService) AnalyzeJobDescription(cv string, jobDesc models.JobD
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (s *OpenRouterService) CreateCV(cv string, jobDesc models.JobDescription) (*JobAnalysisResult, error) {
|
// func (s *OpenRouterService) CreateCV(cv string, jobDesc domain.JobDescription) (*JobAnalysisResult, error) {
|
||||||
// }
|
// }
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,13 @@ func (s *RetryableHTTPRequestImpl) RetryableHTTPRequest(ctx context.Context, url
|
||||||
|
|
||||||
// max attempts for now = 3
|
// max attempts for now = 3
|
||||||
if attempt < s.config.MaxRetries {
|
if attempt < s.config.MaxRetries {
|
||||||
delay := time.Second * 2
|
// Increment delay by 2 seconds for each attempt
|
||||||
|
delay := time.Second * time.Duration(2*(attempt+1)) // 2s, 4s, 6s, ...
|
||||||
|
|
||||||
|
// Cap the delay to MaxDelay if set
|
||||||
|
if s.config.MaxDelay > 0 && delay > s.config.MaxDelay {
|
||||||
|
delay = s.config.MaxDelay
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Retrying in %v... (attempt %d/%d)\n", delay, attempt+1, s.config.MaxRetries)
|
fmt.Printf("Retrying in %v... (attempt %d/%d)\n", delay, attempt+1, s.config.MaxRetries)
|
||||||
|
|
||||||
|
|
|
||||||
1
migrations/005_job_provider.down.sql
Normal file
1
migrations/005_job_provider.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE jobs DROP COLUMN IF EXISTS provider;
|
||||||
1
migrations/005_job_provider.up.sql
Normal file
1
migrations/005_job_provider.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS provider INTEGER DEFAULT 0;
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jobs-scraper/infrastructure"
|
"github.com/jobs-scraper/infrastructure"
|
||||||
"github.com/jobs-scraper/internal/models"
|
"github.com/jobs-scraper/internal/domain"
|
||||||
"github.com/jobs-scraper/internal/pipeline"
|
"github.com/jobs-scraper/internal/pipeline"
|
||||||
"github.com/jobs-scraper/internal/repo"
|
"github.com/jobs-scraper/internal/repo"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
@ -40,7 +40,6 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
scraper := pipeline.NewScraper(pipeline.Config{
|
scraper := pipeline.NewScraper(pipeline.Config{
|
||||||
Distance: "25",
|
|
||||||
SortBy: "R",
|
SortBy: "R",
|
||||||
MaxRetries: 3,
|
MaxRetries: 3,
|
||||||
BaseDelay: 1 * time.Second,
|
BaseDelay: 1 * time.Second,
|
||||||
|
|
@ -55,9 +54,9 @@ func main() {
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
searchParams := models.SearchQuery{
|
searchParams := domain.SearchQuery{
|
||||||
Keywords: "Frontend Developer",
|
Keywords: "Javascript",
|
||||||
Location: "Japan",
|
Location: "US",
|
||||||
FWT: "2,3",
|
FWT: "2,3",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue