refactor and remove internal file
This commit is contained in:
parent
0ab6954231
commit
0e1b64b7b9
29 changed files with 43 additions and 1765 deletions
|
|
@ -5,10 +5,10 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jobs-scraper/api/commands/job"
|
||||
"github.com/jobs-scraper/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/internal/ports"
|
||||
"github.com/jobs-scraper/internal/repo"
|
||||
"github.com/jobs-scraper/internal/server"
|
||||
"github.com/jobs-scraper/shared/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/shared/ports"
|
||||
"github.com/jobs-scraper/shared/repo"
|
||||
"github.com/jobs-scraper/shared/server"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import (
|
|||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/jobs-scraper/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
"github.com/jobs-scraper/internal/ports"
|
||||
"github.com/jobs-scraper/internal/repo"
|
||||
"github.com/jobs-scraper/shared/domain"
|
||||
"github.com/jobs-scraper/shared/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/shared/ports"
|
||||
"github.com/jobs-scraper/shared/repo"
|
||||
)
|
||||
|
||||
type CreateJob struct {
|
||||
|
|
|
|||
10
api/main.go
10
api/main.go
|
|
@ -11,11 +11,11 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jobs-scraper/api/app"
|
||||
_ "github.com/jobs-scraper/docs"
|
||||
"github.com/jobs-scraper/infrastructure"
|
||||
httpHandler "github.com/jobs-scraper/infrastructure/http"
|
||||
"github.com/jobs-scraper/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/internal/repo"
|
||||
"github.com/jobs-scraper/shared/infrastructure"
|
||||
httpHandler "github.com/jobs-scraper/shared/infrastructure/http"
|
||||
"github.com/jobs-scraper/shared/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/shared/repo"
|
||||
_ "github.com/jobs-scraper/swagger"
|
||||
"github.com/joho/godotenv"
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"log"
|
||||
|
||||
"github.com/jobs-scraper/cron"
|
||||
"github.com/jobs-scraper/infrastructure"
|
||||
"github.com/jobs-scraper/shared/infrastructure"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"log"
|
||||
|
||||
"github.com/jobs-scraper/infrastructure"
|
||||
"github.com/jobs-scraper/shared/infrastructure"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jobs-scraper/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
"github.com/jobs-scraper/internal/repo"
|
||||
"github.com/jobs-scraper/internal/services"
|
||||
"github.com/jobs-scraper/shared/domain"
|
||||
"github.com/jobs-scraper/shared/infrastructure/rabbitmq"
|
||||
"github.com/jobs-scraper/shared/openrouter"
|
||||
"github.com/jobs-scraper/shared/repo"
|
||||
)
|
||||
|
||||
type JobAnalyzer struct {
|
||||
db *sql.DB
|
||||
jobRepo *repo.JobRepository
|
||||
jobDescriptionRepo *repo.JobDescriptionRepository
|
||||
jobAnalysisResultRepo *repo.JobAnalysisResultRepository
|
||||
openRouterService services.OpenRouterService
|
||||
rmq *rabbitmq.RabbitMQClient
|
||||
db *sql.DB
|
||||
jobRepo *repo.JobRepository
|
||||
jobDescriptionRepo *repo.JobDescriptionRepository
|
||||
jobAnalysisResultRepo *repo.JobAnalysisResultRepository
|
||||
openRouterService services.OpenRouterService
|
||||
rmq *rabbitmq.RabbitMQClient
|
||||
}
|
||||
|
||||
func NewJobAnalyzer(db *sql.DB) *JobAnalyzer {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/jobs-scraper/infrastructure"
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
"github.com/jobs-scraper/internal/repo"
|
||||
"github.com/jobs-scraper/internal/services"
|
||||
"github.com/jobs-scraper/shared/domain"
|
||||
"github.com/jobs-scraper/shared/infrastructure"
|
||||
"github.com/jobs-scraper/shared/openrouter"
|
||||
"github.com/jobs-scraper/shared/repo"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
|
|
|
|||
1
go.work
1
go.work
|
|
@ -7,5 +7,6 @@ use (
|
|||
./shared/infrastructure
|
||||
./shared/ports
|
||||
./shared/repo
|
||||
./shared/server
|
||||
./shared/utils
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
package infrastructure
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Config holds database configuration
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
// NewConnection creates a new database connection
|
||||
func NewConnection(config Config) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode)
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database connection: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv loads database configuration from environment variables
|
||||
func LoadConfigFromEnv() Config {
|
||||
return Config{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "5432"),
|
||||
User: getEnv("DB_USER", "postgres"),
|
||||
Password: getEnv("DB_PASSWORD", "password"),
|
||||
DBName: getEnv("DB_NAME", "linkedin_jobs"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// RunMigrations runs all pending migrations
|
||||
func RunMigrations(db *sql.DB) error {
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create postgres driver: %w", err)
|
||||
}
|
||||
|
||||
// Get the migrations directory path
|
||||
migrationsPath, err := getMigrationsPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get migrations path: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
fmt.Sprintf("file://%s", migrationsPath),
|
||||
"postgres",
|
||||
driver,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackMigrations rolls back migrations by the specified number of steps
|
||||
func RollbackMigrations(db *sql.DB, steps int) error {
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create postgres driver: %w", err)
|
||||
}
|
||||
|
||||
migrationsPath, err := getMigrationsPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get migrations path: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
fmt.Sprintf("file://%s", migrationsPath),
|
||||
"postgres",
|
||||
driver,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if err := m.Steps(-steps); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to rollback migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMigrationVersion returns the current migration version
|
||||
func GetMigrationVersion(db *sql.DB) (uint, bool, error) {
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to create postgres driver: %w", err)
|
||||
}
|
||||
|
||||
migrationsPath, err := getMigrationsPath()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to get migrations path: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
fmt.Sprintf("file://%s", migrationsPath),
|
||||
"postgres",
|
||||
driver,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
version, dirty, err := m.Version()
|
||||
if err != nil {
|
||||
if errors.Is(err, migrate.ErrNilVersion) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, fmt.Errorf("failed to get migration version: %w", err)
|
||||
}
|
||||
|
||||
return version, dirty, nil
|
||||
}
|
||||
|
||||
func getMigrationsPath() (string, error) {
|
||||
// Get the directory of the current source file
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("failed to get current file path")
|
||||
}
|
||||
|
||||
// Get the project root (go up from infrastructure/ to project root)
|
||||
projectRoot := filepath.Dir(filepath.Dir(filename))
|
||||
migrationsPath := filepath.Join(projectRoot, "migrations")
|
||||
|
||||
// Verify the migrations directory exists
|
||||
if _, err := os.Stat(migrationsPath); err != nil {
|
||||
return "", fmt.Errorf("migrations directory not found at %s: %w", migrationsPath, err)
|
||||
}
|
||||
|
||||
return migrationsPath, nil
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/jobs-scraper/internal/ports"
|
||||
"github.com/jobs-scraper/internal/repo"
|
||||
"github.com/jobs-scraper/internal/utils"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// JobHandler handles HTTP requests related to jobs
|
||||
type JobHandler struct {
|
||||
jobCommands *ports.JobCommands
|
||||
jobAnalysisResultRepo *repo.JobAnalysisResultRepository
|
||||
}
|
||||
|
||||
// NewJobHandler creates a new instance of JobHandler
|
||||
func NewJobHandler(jobCommands *ports.JobCommands, jobAnalysisResultRepo *repo.JobAnalysisResultRepository) *JobHandler {
|
||||
return &JobHandler{
|
||||
jobCommands: jobCommands,
|
||||
jobAnalysisResultRepo: jobAnalysisResultRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all routes to the router
|
||||
func (h *JobHandler) RegisterRoutes(router *mux.Router) {
|
||||
jobs := router.PathPrefix("/jobs").Subrouter()
|
||||
|
||||
//jobs.Use(AuthMiddleware)
|
||||
|
||||
jobs.HandleFunc("", utils.Make(h.CreateJob)).Methods("POST")
|
||||
jobs.HandleFunc("/analysis", utils.Make(h.GetAllAnalysisResults)).Methods("GET")
|
||||
jobs.HandleFunc("/{id}/analysis", utils.Make(h.GetJobAnalysisResult)).Methods("GET")
|
||||
jobs.HandleFunc("/analysis/top-matches", utils.Make(h.GetTopMatches)).Methods("GET")
|
||||
}
|
||||
|
||||
// CreateJob handles the creation of a new job
|
||||
// @Summary Create a new job
|
||||
// @Description Creates a new job with the provided specifications
|
||||
// @Tags jobs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param job body ports.CreateJobCommand true "Job creation data"
|
||||
// @Success 201 {object} map[string]string "Successfully created job"
|
||||
// @Failure 400 {object} map[string]string "Invalid request data"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /jobs [post]
|
||||
func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) error {
|
||||
var cmd ports.CreateJobCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
slog.Error(err.Error())
|
||||
return utils.NewAPIError(http.StatusBadRequest, utils.InvalidJSON())
|
||||
}
|
||||
|
||||
err := h.jobCommands.CreateJob.Handle(cmd)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return utils.NewAPIError(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusCreated, map[string]string{"message": "Success"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllAnalysisResults handles retrieving all job analysis results
|
||||
// @Summary Get all job analysis results
|
||||
// @Description Retrieves all job analysis results from the database
|
||||
// @Tags jobs
|
||||
// @Produce json
|
||||
// @Success 200 {array} domain.JobAnalysisResult "List of job analysis results"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /jobs/analysis [get]
|
||||
func (h *JobHandler) GetAllAnalysisResults(w http.ResponseWriter, r *http.Request) error {
|
||||
results, err := h.jobAnalysisResultRepo.GetAllAnalysisResults()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get analysis results", "error", err)
|
||||
return utils.NewAPIError(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, results)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobAnalysisResult handles retrieving analysis result for a specific job
|
||||
// @Summary Get job analysis result by job ID
|
||||
// @Description Retrieves the analysis result for a specific job
|
||||
// @Tags jobs
|
||||
// @Produce json
|
||||
// @Param id path int true "Job ID"
|
||||
// @Success 200 {object} domain.JobAnalysisResult "Job analysis result"
|
||||
// @Failure 400 {object} map[string]string "Invalid job ID"
|
||||
// @Failure 404 {object} map[string]string "Analysis result not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /jobs/{id}/analysis [get]
|
||||
func (h *JobHandler) GetJobAnalysisResult(w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
jobID, err := strconv.ParseInt(vars["id"], 10, 64)
|
||||
if err != nil {
|
||||
return utils.NewAPIError(http.StatusBadRequest, fmt.Errorf("invalid job ID"))
|
||||
}
|
||||
|
||||
result, err := h.jobAnalysisResultRepo.GetAnalysisResultByJobID(jobID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get analysis result", "jobID", jobID, "error", err)
|
||||
return utils.NewAPIError(http.StatusNotFound, fmt.Errorf("analysis result not found"))
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTopMatches handles retrieving top matching jobs based on analysis score
|
||||
// @Summary Get top matching jobs
|
||||
// @Description Retrieves jobs with high analysis match scores
|
||||
// @Tags jobs
|
||||
// @Produce json
|
||||
// @Param min_score query int false "Minimum match score (default: 70)"
|
||||
// @Success 200 {array} domain.JobAnalysisResult "List of top matching job analysis results"
|
||||
// @Failure 400 {object} map[string]string "Invalid minimum score"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /jobs/analysis/top-matches [get]
|
||||
func (h *JobHandler) GetTopMatches(w http.ResponseWriter, r *http.Request) error {
|
||||
minScoreStr := r.URL.Query().Get("min_score")
|
||||
minScore := 70 // default minimum score
|
||||
|
||||
if minScoreStr != "" {
|
||||
var err error
|
||||
minScore, err = strconv.Atoi(minScoreStr)
|
||||
if err != nil {
|
||||
return utils.NewAPIError(http.StatusBadRequest, fmt.Errorf("invalid minimum score"))
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.jobAnalysisResultRepo.GetAnalysisResultsByMatchScore(minScore)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get top matches", "minScore", minScore, "error", err)
|
||||
return utils.NewAPIError(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, results)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CORSMiddleware handles Cross-Origin Resource Sharing
|
||||
func CORSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// LogsMiddleware logs HTTP requests with timing information
|
||||
func LogsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.RequestURI, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// AuthMiddleware handles JWT token validation, API keys, and basic auth
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for swagger endpoints and health checks
|
||||
if strings.HasPrefix(r.URL.Path, "/swagger/") || r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Try Bearer token authentication first
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token != "" {
|
||||
userID, err := validateJWTToken(token)
|
||||
if err == nil {
|
||||
ctx := context.WithValue(r.Context(), "user_id", userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try API Key authentication
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey != "" {
|
||||
if isValidAPIKey(apiKey) {
|
||||
ctx := context.WithValue(r.Context(), "user_id", getUserIDFromAPIKey(apiKey))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try Basic authentication
|
||||
username, password, ok := r.BasicAuth()
|
||||
if ok {
|
||||
if isValidCredentials(username, password) {
|
||||
ctx := context.WithValue(r.Context(), "username", username)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to cookie-based auth (existing behavior)
|
||||
token, err := r.Cookie("token")
|
||||
if err == nil && token.Value == "123" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// No valid authentication found
|
||||
response := map[string]interface{}{
|
||||
"error": "Unauthorized",
|
||||
"message": "Valid authentication required. Use Bearer token, API key, or basic auth.",
|
||||
"code": http.StatusUnauthorized,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions for authentication validation
|
||||
func validateJWTToken(token string) (string, error) {
|
||||
// TODO: Implement JWT validation logic with your preferred JWT library
|
||||
// For demonstration, accept a demo token
|
||||
if token == "valid-jwt-token" {
|
||||
return "user-123", nil
|
||||
}
|
||||
return "", http.ErrAbortHandler
|
||||
}
|
||||
|
||||
func isValidAPIKey(apiKey string) bool {
|
||||
// TODO: Implement API key validation logic
|
||||
// For demonstration, accept a demo API key
|
||||
return apiKey == "demo-api-key-123"
|
||||
}
|
||||
|
||||
func getUserIDFromAPIKey(apiKey string) string {
|
||||
// TODO: Implement logic to get user ID from API key
|
||||
// For demonstration, return a mock user ID
|
||||
return "api-user-123"
|
||||
}
|
||||
|
||||
func isValidCredentials(username, password string) bool {
|
||||
// TODO: Implement credential validation logic
|
||||
// For demonstration, accept demo credentials
|
||||
return username == "admin" && password == "password"
|
||||
}
|
||||
|
||||
// GetUserIDFromContext extracts user ID from request context
|
||||
func GetUserIDFromContext(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value("user_id").(string)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
// GetUsernameFromContext extracts username from request context
|
||||
func GetUsernameFromContext(ctx context.Context) (string, bool) {
|
||||
username, ok := ctx.Value("username").(string)
|
||||
return username, ok
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package rabbitmq
|
||||
|
||||
type CvAnalyzeMessage struct {
|
||||
JobID int64 `json:"jobID"`
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
package rabbitmq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const (
|
||||
ScraperExchange = "scraper_exchange"
|
||||
LinkedInQueue = "scraper.linkedin"
|
||||
IndeedQueue = "scraper.indeed"
|
||||
BaytQueue = "scraper.bayt"
|
||||
TokyoDevQueue = "scraper.tokyodev"
|
||||
GlassDoorQueue = "scraper.glassdoor"
|
||||
JapanDevQueue = "scraper.japandev"
|
||||
DeadLetterExchange = "scraper_dlx"
|
||||
CvAnalyzeExchange = "cv_exchange"
|
||||
CvAnalyzeQueue = "cv.analyze"
|
||||
)
|
||||
|
||||
type RabbitMQClient struct {
|
||||
Conn *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
}
|
||||
|
||||
type MessageHandler func([]byte) error
|
||||
|
||||
func NewRabbitMQClient() (*RabbitMQClient, error) {
|
||||
// Get RabbitMQ URL from environment or use default
|
||||
rabbitmqURL := os.Getenv("RABBITMQ_URL")
|
||||
if rabbitmqURL == "" {
|
||||
rabbitmqURL = "amqp://guest:guest@localhost:5672/"
|
||||
}
|
||||
|
||||
// Connect to RabbitMQ with retry logic
|
||||
var conn *amqp.Connection
|
||||
var err error
|
||||
|
||||
for i := range 5 {
|
||||
conn, err = amqp.Dial(rabbitmqURL)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("Failed to connect to RabbitMQ (attempt %d/5): %v", i+1, err)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to RabbitMQ after 5 attempts: %v", err)
|
||||
}
|
||||
|
||||
// Create a channel
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("error creating channel: %v", err)
|
||||
}
|
||||
|
||||
client := &RabbitMQClient{
|
||||
Conn: conn,
|
||||
Channel: ch,
|
||||
}
|
||||
|
||||
// Setup exchanges and queues
|
||||
if err := client.setupInfrastructure(); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("error setting up infrastructure: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Successfully connected to RabbitMQ and set up infrastructure")
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (r *RabbitMQClient) setupInfrastructure() error {
|
||||
// Declare dead letter exchange
|
||||
err := r.Channel.ExchangeDeclare(
|
||||
DeadLetterExchange,
|
||||
"direct",
|
||||
true, // durable
|
||||
false, // auto-delete
|
||||
false, // internal
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error declaring dead letter exchange: %v", err)
|
||||
}
|
||||
|
||||
// Declare main exchange
|
||||
err = r.Channel.ExchangeDeclare(
|
||||
ScraperExchange,
|
||||
"topic",
|
||||
true, // durable
|
||||
false, // auto-delete
|
||||
false, // internal
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error declaring exchange: %v", err)
|
||||
}
|
||||
|
||||
// Define queues with their routing keys
|
||||
queues := map[string]string{
|
||||
LinkedInQueue: "scraper.linkedin",
|
||||
IndeedQueue: "scraper.indeed",
|
||||
BaytQueue: "scraper.bayt",
|
||||
TokyoDevQueue: "scraper.tokyodev",
|
||||
JapanDevQueue: "scraper.japandev",
|
||||
GlassDoorQueue: "scraper.glassdoor",
|
||||
}
|
||||
|
||||
// Declare queues with dead letter exchange and TTL
|
||||
for queueName, routingKey := range queues {
|
||||
dlqName := queueName + "_dlq"
|
||||
_, err := r.Channel.QueueDeclare(
|
||||
dlqName,
|
||||
true, // durable
|
||||
false, // delete when unused
|
||||
false, // exclusive
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error declaring dead letter queue %s: %v", dlqName, err)
|
||||
}
|
||||
|
||||
err = r.Channel.QueueBind(
|
||||
dlqName,
|
||||
queueName, // routing key is the original queue name
|
||||
DeadLetterExchange,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error binding dead letter queue %s: %v", dlqName, err)
|
||||
}
|
||||
|
||||
_, err = r.Channel.QueueDeclare(
|
||||
queueName,
|
||||
true, // durable
|
||||
false, // delete when unused
|
||||
false, // exclusive
|
||||
false, // no-wait
|
||||
amqp.Table{
|
||||
"x-dead-letter-exchange": DeadLetterExchange,
|
||||
"x-dead-letter-routing-key": queueName,
|
||||
"x-message-ttl": int64(24 * time.Hour / time.Millisecond), // 24 hours TTL
|
||||
"x-max-retries": 3,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error declaring queue %s: %v", queueName, err)
|
||||
}
|
||||
|
||||
err = r.Channel.QueueBind(
|
||||
queueName,
|
||||
routingKey,
|
||||
ScraperExchange,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error binding queue %s: %v", queueName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Publish publishes a message to a specific routing key
|
||||
func (r *RabbitMQClient) Publish(routingKey, exchange string, data []byte) error {
|
||||
log.Printf("Publishing to exchange=%s, routingKey=%s, data=%s", ScraperExchange, routingKey, string(data))
|
||||
err := r.Channel.Publish(
|
||||
ScraperExchange,
|
||||
routingKey,
|
||||
false,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: data,
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now().UTC(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error publishing: %v", err)
|
||||
return fmt.Errorf("error publishing message: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe creates a subscription to a specific queue with production-ready error handling
|
||||
func (r *RabbitMQClient) Subscribe(queueName, consumerName string, handler MessageHandler) error {
|
||||
err := r.Channel.Qos(
|
||||
1, // prefetch count
|
||||
0, // prefetch size
|
||||
false, // global
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting QoS: %v", err)
|
||||
}
|
||||
|
||||
// Start consuming messages
|
||||
msgs, err := r.Channel.Consume(
|
||||
queueName,
|
||||
consumerName,
|
||||
false, // auto-ack
|
||||
false, // exclusive
|
||||
false, // no-local
|
||||
false, // no-wait
|
||||
nil, // args
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error starting consumer: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for msg := range msgs {
|
||||
// Call the handler
|
||||
err := handler(msg.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error processing message: %v", err)
|
||||
// Reject and requeue the message (will go to DLX after max retries)
|
||||
msg.Nack(false, true)
|
||||
} else {
|
||||
msg.Ack(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Successfully subscribed to queue: %s ", queueName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the RabbitMQ connection and channel
|
||||
func (r *RabbitMQClient) Close() {
|
||||
if r.Channel != nil {
|
||||
r.Channel.Close()
|
||||
}
|
||||
if r.Conn != nil {
|
||||
r.Conn.Close()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package domain
|
||||
|
||||
type JobDescription struct {
|
||||
JobID int64
|
||||
Description string
|
||||
Criteria map[string]string
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package domain
|
||||
|
||||
type JobWithDescription struct {
|
||||
Job Job
|
||||
JobDescription JobDescription
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// JobProvider represents different job board providers
|
||||
type JobProvider int
|
||||
|
||||
type JobStatus int
|
||||
|
||||
const (
|
||||
LinkedIn JobProvider = iota
|
||||
Indeed
|
||||
Glassdoor
|
||||
Bayt
|
||||
TokyoDev
|
||||
JapanDev
|
||||
)
|
||||
|
||||
const (
|
||||
JobStatusCreated JobStatus = iota
|
||||
JobStatusAnalyzed
|
||||
JobStatusError
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
ID int64
|
||||
Title string
|
||||
Company string
|
||||
CompanyLink string
|
||||
Location string
|
||||
JobLink string
|
||||
Provider JobProvider
|
||||
JobPostTime *time.Time
|
||||
Status JobStatus
|
||||
}
|
||||
|
||||
type JobAnalysisResult struct {
|
||||
ID int64
|
||||
JobID int64
|
||||
AnalysisResult string
|
||||
MatchScore *int
|
||||
KeySkills []string
|
||||
MissingSkills []string
|
||||
Recommendations *string
|
||||
AnalyzedAt time.Time
|
||||
}
|
||||
|
||||
type SearchQuery struct {
|
||||
Keywords string `json:"keywords"`
|
||||
Location string `json:"location"`
|
||||
NumPages int `json:"numPages"`
|
||||
FWT string `json:"f_WT"` // Work type filter (1=onsite, 2=remote, 3=hybrid)
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package ports
|
||||
|
||||
import "github.com/jobs-scraper/internal/domain"
|
||||
|
||||
type JobCommands struct {
|
||||
CreateJob JobCommandHandler
|
||||
}
|
||||
|
||||
// CreateJobCommand represents the command to create a job
|
||||
type CreateJobCommand struct {
|
||||
Location string
|
||||
Keywords string
|
||||
FWT string
|
||||
Provider domain.JobProvider
|
||||
NumPages int
|
||||
}
|
||||
|
||||
// JobCommandHandler defines the interface for handling job commands
|
||||
type JobCommandHandler interface {
|
||||
Handle(cmd CreateJobCommand) error
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type JobAnalysisResultRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewJobAnalysisResultRepository(db *sql.DB) *JobAnalysisResultRepository {
|
||||
return &JobAnalysisResultRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *JobAnalysisResultRepository) SaveAnalysisResult(result *domain.JobAnalysisResult) error {
|
||||
query := `
|
||||
INSERT INTO job_analysis_results (job_id, analysis_result, match_score, key_skills, missing_skills, recommendations)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, analyzed_at
|
||||
`
|
||||
|
||||
err := r.db.QueryRow(
|
||||
query,
|
||||
result.JobID,
|
||||
result.AnalysisResult,
|
||||
result.MatchScore,
|
||||
pq.Array(result.KeySkills),
|
||||
pq.Array(result.MissingSkills),
|
||||
result.Recommendations,
|
||||
).Scan(&result.ID, &result.AnalyzedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving job analysis result: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JobAnalysisResultRepository) GetAnalysisResultByJobID(jobID int64) (*domain.JobAnalysisResult, error) {
|
||||
query := `
|
||||
SELECT id, job_id, analysis_result, match_score, key_skills, missing_skills, recommendations, analyzed_at
|
||||
FROM job_analysis_results
|
||||
WHERE job_id = $1
|
||||
ORDER BY analyzed_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var result domain.JobAnalysisResult
|
||||
err := r.db.QueryRow(query, jobID).Scan(
|
||||
&result.ID,
|
||||
&result.JobID,
|
||||
&result.AnalysisResult,
|
||||
&result.MatchScore,
|
||||
pq.Array(&result.KeySkills),
|
||||
pq.Array(&result.MissingSkills),
|
||||
&result.Recommendations,
|
||||
&result.AnalyzedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("no analysis result found for job ID %d", jobID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying job analysis result: %v", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *JobAnalysisResultRepository) GetAllAnalysisResults() ([]domain.JobAnalysisResult, error) {
|
||||
query := `
|
||||
SELECT id, job_id, analysis_result, match_score, key_skills, missing_skills, recommendations, analyzed_at
|
||||
FROM job_analysis_results
|
||||
ORDER BY analyzed_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying job analysis results: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []domain.JobAnalysisResult
|
||||
for rows.Next() {
|
||||
var result domain.JobAnalysisResult
|
||||
err := rows.Scan(
|
||||
&result.ID,
|
||||
&result.JobID,
|
||||
&result.AnalysisResult,
|
||||
&result.MatchScore,
|
||||
pq.Array(&result.KeySkills),
|
||||
pq.Array(&result.MissingSkills),
|
||||
&result.Recommendations,
|
||||
&result.AnalyzedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error scanning job analysis result: %v", err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating over job analysis result rows: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *JobAnalysisResultRepository) GetAnalysisResultsByMatchScore(minScore int) ([]domain.JobAnalysisResult, error) {
|
||||
query := `
|
||||
SELECT id, job_id, analysis_result, match_score, key_skills, missing_skills, recommendations, analyzed_at
|
||||
FROM job_analysis_results
|
||||
WHERE match_score >= $1
|
||||
ORDER BY match_score DESC, analyzed_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, minScore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying job analysis results by match score: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []domain.JobAnalysisResult
|
||||
for rows.Next() {
|
||||
var result domain.JobAnalysisResult
|
||||
err := rows.Scan(
|
||||
&result.ID,
|
||||
&result.JobID,
|
||||
&result.AnalysisResult,
|
||||
&result.MatchScore,
|
||||
pq.Array(&result.KeySkills),
|
||||
pq.Array(&result.MissingSkills),
|
||||
&result.Recommendations,
|
||||
&result.AnalyzedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error scanning job analysis result: %v", err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating over job analysis result rows: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
)
|
||||
|
||||
type JobDescriptionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type JobDescriptionData struct {
|
||||
JobID int
|
||||
Description string
|
||||
Criteria map[string]string
|
||||
}
|
||||
|
||||
func NewJobDescriptionRepository(db *sql.DB) *JobDescriptionRepository {
|
||||
return &JobDescriptionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *JobDescriptionRepository) SaveJobDescriptions(jobDescriptions []domain.JobDescription) error {
|
||||
if len(jobDescriptions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the VALUES clause dynamically
|
||||
valueStrings := make([]string, 0, len(jobDescriptions))
|
||||
valueArgs := make([]interface{}, 0, len(jobDescriptions)*3)
|
||||
|
||||
for i, jd := range jobDescriptions {
|
||||
// Convert criteria map to JSONB
|
||||
criteriaByte, err := json.Marshal(jd.Criteria)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling job criteria for job %d: %v", jd.JobID, err)
|
||||
}
|
||||
|
||||
valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d, $%d)", i*3+1, i*3+2, i*3+3))
|
||||
valueArgs = append(valueArgs, jd.JobID, jd.Description, criteriaByte)
|
||||
}
|
||||
|
||||
sqlStatement := fmt.Sprintf(`
|
||||
INSERT INTO job_descriptions (job_id, description, job_criteria)
|
||||
VALUES %s
|
||||
ON CONFLICT (job_id) DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
job_criteria = EXCLUDED.job_criteria,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, strings.Join(valueStrings, ","))
|
||||
|
||||
_, err := r.db.Exec(sqlStatement, valueArgs...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving job descriptions: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JobDescriptionRepository) GetJobDescriptionByJobID(jobID int64) (string, map[string]string, error) {
|
||||
var (
|
||||
description string
|
||||
criteriaByte []byte
|
||||
criteria map[string]string
|
||||
)
|
||||
|
||||
sqlStatement := `SELECT description, job_criteria FROM job_descriptions WHERE job_id = $1`
|
||||
err := r.db.QueryRow(sqlStatement, jobID).Scan(&description, &criteriaByte)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil, nil // No description found
|
||||
}
|
||||
return "", nil, fmt.Errorf("error fetching job description: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal the JSON criteria
|
||||
if criteriaByte != nil {
|
||||
if err := json.Unmarshal(criteriaByte, &criteria); err != nil {
|
||||
return "", nil, fmt.Errorf("error unmarshaling job criteria: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return description, criteria, nil
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
)
|
||||
|
||||
type JobRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewJobRepository(db *sql.DB) *JobRepository {
|
||||
return &JobRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *JobRepository) SaveJobs(jobs []domain.Job) error {
|
||||
if len(jobs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deduplicate jobs by ID to avoid duplicate errors
|
||||
jobMap := make(map[int64]domain.Job)
|
||||
for _, job := range jobs {
|
||||
jobMap[job.ID] = job
|
||||
}
|
||||
|
||||
// Convert to slice of pointers for the bulk function
|
||||
uniqueJobs := make([]*domain.Job, 0, len(jobMap))
|
||||
for _, job := range jobMap {
|
||||
jobCopy := job // Important: create a copy to avoid pointer issues
|
||||
uniqueJobs = append(uniqueJobs, &jobCopy)
|
||||
}
|
||||
|
||||
sqlTemplate := `
|
||||
INSERT INTO jobs (id, title, company, company_link, location, job_link, job_timestamp)
|
||||
VALUES %s
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
company = EXCLUDED.company,
|
||||
company_link = EXCLUDED.company_link,
|
||||
location = EXCLUDED.location,
|
||||
job_link = EXCLUDED.job_link,
|
||||
job_timestamp = EXCLUDED.job_timestamp
|
||||
`
|
||||
|
||||
sqlStatement, vals := prepareQueryCreateBulk(sqlTemplate, uniqueJobs)
|
||||
|
||||
_, err := r.db.Exec(sqlStatement, vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error inserting jobs: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JobRepository) GetAllJobs() ([]domain.Job, error) {
|
||||
rows, err := r.db.Query("SELECT * FROM jobs")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying jobs: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []domain.Job
|
||||
for rows.Next() {
|
||||
var job domain.Job
|
||||
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)
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating over job rows: %v", err)
|
||||
}
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
func (r *JobRepository) GetJobByID(id int) (*domain.Job, error) {
|
||||
var job domain.Job
|
||||
|
||||
sqlStatement := `
|
||||
SELECT id, title, company, company_link, location, job_link, job_timestamp
|
||||
FROM jobs
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
err := r.db.QueryRow(sqlStatement, id).Scan(
|
||||
&job.ID,
|
||||
&job.Title,
|
||||
&job.Company,
|
||||
&job.CompanyLink,
|
||||
&job.Location,
|
||||
&job.JobLink,
|
||||
&job.JobPostTime,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("job with ID %d not found", id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying job: %v", err)
|
||||
}
|
||||
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
func prepareQueryCreateBulk(s string, models []*domain.Job) (string, []interface{}) {
|
||||
bf := bytes.Buffer{}
|
||||
values := make([]interface{}, 0, len(models)*7)
|
||||
|
||||
for i, v := range models {
|
||||
values = append(values, v.ID, v.Title, v.Company,
|
||||
v.CompanyLink, v.Location, v.JobLink, v.JobPostTime,
|
||||
)
|
||||
|
||||
numFields := 7 // the number of fields you are inserting
|
||||
n := i * numFields
|
||||
|
||||
bf.WriteString("(")
|
||||
for j := 0; j < numFields; j++ {
|
||||
bf.WriteString("$")
|
||||
bf.WriteString(strconv.Itoa(n + j + 1))
|
||||
if j < numFields-1 {
|
||||
bf.WriteString(", ")
|
||||
}
|
||||
}
|
||||
bf.WriteString(")")
|
||||
if i < len(models)-1 {
|
||||
bf.WriteString(", ")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(s, bf.String()), values
|
||||
}
|
||||
|
||||
func (r *JobRepository) GetJobsByStatus(status domain.JobStatus) ([]domain.Job, error) {
|
||||
query := `
|
||||
SELECT id, title, company, company_link, location, job_link, job_timestamp
|
||||
FROM jobs
|
||||
WHERE status = $1
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, int(status))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []domain.Job
|
||||
for rows.Next() {
|
||||
var job domain.Job
|
||||
err := rows.Scan(
|
||||
&job.ID,
|
||||
&job.Title,
|
||||
&job.Company,
|
||||
&job.CompanyLink,
|
||||
&job.Location,
|
||||
&job.JobLink,
|
||||
&job.JobPostTime,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *JobRepository) UpdateJobStatus(jobID int64, status domain.JobStatus) error {
|
||||
query := `UPDATE jobs SET status = $1 WHERE id = $2`
|
||||
_, err := r.db.Exec(query, int(status), jobID)
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// APIError represents an API error with status code and message
|
||||
type APIError struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Msg any `json:"msg"`
|
||||
}
|
||||
|
||||
// Error implements the error interface for APIError
|
||||
func (e APIError) Error() string {
|
||||
return fmt.Sprintf("api error: %d", e.StatusCode)
|
||||
}
|
||||
|
||||
// NewAPIError creates a new APIError with the given status code and error message
|
||||
func NewAPIError(statusCode int, err error) APIError {
|
||||
return APIError{
|
||||
StatusCode: statusCode,
|
||||
Msg: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidRequestData creates an APIError for validation errors with a map of field errors
|
||||
func InvalidRequestData(msg string) APIError {
|
||||
return APIError{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Msg: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidJSON creates an APIError for invalid JSON format
|
||||
func InvalidJSON() APIError {
|
||||
return NewAPIError(http.StatusBadRequest, fmt.Errorf("invalid JSON request data"))
|
||||
}
|
||||
|
||||
// APIResponse represents a standardized API response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error any `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WriteJSON writes the response as JSON with given status code, wrapped in a data object
|
||||
func WriteJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
response := APIResponse{
|
||||
Success: status >= 200 && status < 300,
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
response.Data = data
|
||||
} else {
|
||||
response.Error = data
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// APIFunc is the signature for API handler functions
|
||||
type APIFunc func(w http.ResponseWriter, r *http.Request) error
|
||||
|
||||
// Make wraps an APIFunc and handles errors consistently
|
||||
func Make(h APIFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h(w, r); err != nil {
|
||||
if apiErr, ok := err.(APIError); ok {
|
||||
WriteJSON(w, apiErr.StatusCode, apiErr)
|
||||
} else {
|
||||
errResp := map[string]any{
|
||||
"statusCode": http.StatusInternalServerError,
|
||||
"msg": "internal server error",
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, errResp)
|
||||
// You could add logging here
|
||||
slog.Error("HTTP API error", "err", err.Error(), "path", r.URL.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
// "crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
// "net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RetryConfig struct {
|
||||
MaxRetries int
|
||||
BaseDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
}
|
||||
|
||||
type RetryableHTTPRequestImpl struct {
|
||||
client *http.Client
|
||||
config RetryConfig
|
||||
}
|
||||
|
||||
func NewRetryableHTTPRequest(config RetryConfig) *RetryableHTTPRequestImpl {
|
||||
return NewRetryableHTTPRequestWithProxy(config, "")
|
||||
}
|
||||
|
||||
func NewRetryableHTTPRequestWithProxy(config RetryConfig, proxyURL string) *RetryableHTTPRequestImpl {
|
||||
// var transport *http.Transport
|
||||
|
||||
// if proxyURL != "" {
|
||||
// proxyUrl, err := url.Parse(proxyURL)
|
||||
// if err != nil {
|
||||
// panic(fmt.Sprintf("Failed to parse proxy URL '%s': %v", proxyURL, err))
|
||||
// }
|
||||
|
||||
// transport = &http.Transport{
|
||||
// Proxy: http.ProxyURL(proxyUrl),
|
||||
// TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
// }
|
||||
// }
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
// Transport: transport,
|
||||
}
|
||||
|
||||
return &RetryableHTTPRequestImpl{
|
||||
client: client,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RetryableHTTPRequestImpl) RetryableHTTPRequest(ctx context.Context, url, method string, body io.Reader, headers []http.Header) (*http.Response, error) {
|
||||
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= s.config.MaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
for _, header := range headers {
|
||||
req.Header.Set(header.Get("Key"), header.Get("Value"))
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Printf("Request attempt %d failed: %v\n", attempt+1, err)
|
||||
} else if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
// Success
|
||||
return resp, nil
|
||||
} else if resp.StatusCode == http.StatusTooManyRequests {
|
||||
// 429 Too Many Requests - retry with backoff
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited: %d %s", resp.StatusCode, resp.Status)
|
||||
fmt.Printf("Request attempt %d failed with status %d (rate limited)\n", attempt+1, resp.StatusCode)
|
||||
} else {
|
||||
// All other errors (4xx, 5xx) - don't retry
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("request failed %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// max attempts for now = 3
|
||||
if attempt < s.config.MaxRetries {
|
||||
// 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)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
// Continue to next attempt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all retry attempts failed, last error: %w", lastErr)
|
||||
}
|
||||
|
||||
// 5.10.245.81:80
|
||||
// 172.67.70.206:80
|
||||
// 190.93.245.33:80
|
||||
// 185.221.160.176:80
|
||||
// 141.101.123.156:80
|
||||
// 103.21.244.129:80
|
||||
// 103.169.142.59:80
|
||||
// 172.67.81.185:80
|
||||
// 37.18.73.60:5566
|
||||
// 209.38.83.56:1088
|
||||
// 89.169.36.109:1080
|
||||
// 103.21.244.248:80
|
||||
// 172.67.70.92:80
|
||||
// 172.67.88.34:80
|
||||
// 141.101.120.38:80
|
||||
// 172.67.75.255:80
|
||||
// 172.67.75.171:80
|
||||
// 45.131.4.157:80
|
||||
// 141.101.121.208:80
|
||||
// 141.101.120.70:80
|
||||
// 103.21.244.70:80
|
||||
// 103.21.244.140:80
|
||||
// 103.21.244.138:80
|
||||
// 154.16.146.43:80
|
||||
// 128.199.202.122:3128
|
||||
// 160.153.0.28:80
|
||||
// 185.170.166.31:80
|
||||
// 172.67.70.95:80
|
||||
// 141.101.123.91:80
|
||||
// 141.101.121.81:80
|
||||
// 141.101.121.71:80
|
||||
// 141.101.120.224:80
|
||||
// 192.111.137.35:4145
|
||||
// 45.12.31.20:80
|
||||
// 103.21.244.150:80
|
||||
// 103.21.244.214:80
|
||||
// 103.21.244.29:80
|
||||
// 195.85.23.88:80
|
||||
// 23.227.39.134:80
|
||||
// 66.42.224.229:41679
|
||||
// 172.67.73.123:80
|
||||
// 79.110.200.27:8000
|
||||
// 5.182.34.33:80
|
||||
// 103.21.244.174:80
|
||||
// 172.67.75.203:80
|
||||
// 141.101.122.150:80
|
||||
// 141.101.120.35:80
|
||||
// 175.47.237.95:6128
|
||||
// 103.21.244.185:80
|
||||
// 141.101.122.86:80
|
||||
// 141.101.121.89:80
|
||||
// 103.21.244.105:80
|
||||
// 103.21.244.234:80
|
||||
// 172.67.70.20:80
|
||||
// 163.223.172.27:1080
|
||||
// 172.67.84.128:80
|
||||
// 103.21.244.57:80
|
||||
// 31.43.179.191:80
|
||||
// 173.245.59.61:80
|
||||
// 141.101.120.48:80
|
||||
// 185.162.230.19:80
|
||||
// 172.67.172.150:80
|
||||
// 159.112.235.63:80
|
||||
// 172.64.40.184:80
|
||||
// 103.21.244.55:80
|
||||
// 103.21.244.49:80
|
||||
// 172.67.70.228:80
|
||||
// 103.21.244.8:80
|
||||
// 66.235.200.77:80
|
||||
// 45.85.119.147:80
|
||||
// 172.67.177.231:80
|
||||
// 173.245.49.28:80
|
||||
// 110.232.92.49:8080
|
||||
// 212.183.88.212:80
|
||||
// 108.162.193.73:80
|
||||
// 185.238.228.29:80
|
||||
// 172.67.127.8:80
|
||||
// 164.38.155.86:80
|
||||
// 160.153.0.18:80
|
||||
// 141.101.122.119:80
|
||||
// 103.21.244.186:80
|
||||
// 172.67.172.162:80
|
||||
// 8.218.39.40:10800
|
||||
// 47.237.132.101:60031
|
||||
// 222.59.173.105:44193
|
||||
// 143.110.190.60:1080
|
||||
// 152.53.194.55:32059
|
||||
// 115.187.50.40:5678
|
||||
// 171.254.219.180:1080
|
||||
// 103.165.157.155:8080
|
||||
// 45.233.169.57:999
|
||||
// 222.59.173.105:45035
|
||||
// 202.40.179.18:4145
|
||||
// 170.79.181.188:60606
|
||||
// 37.186.66.36:3629
|
||||
// 104.200.152.30:4145
|
||||
// 106.13.58.110:8888
|
||||
// 202.40.181.220:31247
|
||||
// 43.135.36.240:80
|
||||
// 103.82.27.107:10001
|
||||
// 192.252.209.158:4145
|
||||
// 142.54.236.97:4145
|
||||
// 142.54.239.1:4145
|
||||
// 184.170.245.148:4145
|
||||
// 68.71.247.130:4145
|
||||
// 72.195.34.58:4145
|
||||
// 172.65.90.2:80
|
||||
// 172.65.90.0:80
|
||||
// 36.138.53.26:10019
|
||||
// 183.215.23.242:9091
|
||||
// 45.131.7.34:80
|
||||
// 141.101.120.230:80
|
||||
// 172.64.78.193:80
|
||||
// 141.101.122.9:80
|
||||
// 103.21.244.80:80
|
||||
// 103.21.244.147:80
|
||||
// 103.21.244.106:80
|
||||
// 103.21.244.16:80
|
||||
// 199.34.229.210:80
|
||||
// 141.101.90.98:80
|
||||
// 172.67.70.127:80
|
||||
// 103.21.244.221:80
|
||||
// 172.67.171.203:80
|
||||
// 172.67.181.188:80
|
||||
// 72.195.101.99:4145
|
||||
// 141.101.120.106:80
|
||||
// 103.21.244.180:80
|
||||
// 103.21.244.102:80
|
||||
// 172.67.70.233:80
|
||||
// 108.162.193.107:80
|
||||
// 45.131.208.112:80
|
||||
// 154.194.12.195:80
|
||||
// 69.84.182.23:80
|
||||
// 141.101.120.128:80
|
||||
// 141.101.120.149:80
|
||||
// 141.101.120.232:80
|
||||
// 141.101.120.6:80
|
||||
// 172.67.98.18:80
|
||||
// 141.101.113.20:80
|
||||
// 208.65.90.21:4145
|
||||
// 198.177.254.131:4145
|
||||
// 142.54.237.38:4145
|
||||
// 192.252.220.89:4145
|
||||
// 199.187.210.54:4145
|
||||
// 199.102.105.242:4145
|
||||
// 199.102.107.145:4145
|
||||
// 198.8.84.3:4145
|
||||
// 98.182.171.161:4145
|
||||
// 192.111.129.150:4145
|
||||
// 184.170.248.5:4145
|
||||
// 98.188.47.150:4145
|
||||
// 125.228.94.232:4145
|
||||
// 125.228.94.153:4145
|
||||
// 185.162.229.219:80
|
||||
// 103.21.244.22:80
|
||||
// 172.64.89.97:80
|
||||
// 103.21.244.54:80
|
||||
// 103.21.244.37:80
|
||||
// 141.101.123.225:80
|
||||
// 103.21.244.168:80
|
||||
// 103.21.244.149:80
|
||||
// 103.21.244.100:80
|
||||
// 103.21.244.24:80
|
||||
// 23.227.39.209:80
|
||||
// 172.67.83.15:80
|
||||
// 45.131.4.241:80
|
||||
// 141.101.120.201:80
|
||||
// 172.67.191.237:80
|
||||
// 172.67.127.188:80
|
||||
// 103.21.244.92:80
|
||||
// 103.160.204.22:80
|
||||
// 220.197.44.36:3128
|
||||
// 39.185.41.193:5911
|
||||
// 23.227.39.121:80
|
||||
// 139.162.78.109:80
|
||||
// 188.114.99.144:80
|
||||
// 45.131.5.37:80
|
||||
// 45.131.4.250:80
|
||||
// 23.227.39.65:80
|
||||
// 141.101.121.191:80
|
||||
// 172.67.188.16:80
|
||||
// 172.67.254.148:80
|
||||
// 221.1.104.177:7302
|
||||
// 222.59.173.105:44008
|
||||
// 103.21.244.83:80
|
||||
// 172.64.149.1:80
|
||||
// 45.12.30.22:80
|
||||
// 222.59.173.105:44027
|
||||
// 172.64.149.26:80
|
||||
// 103.21.244.46:80
|
||||
// 45.12.31.242:80
|
||||
// 23.227.38.195:80
|
||||
// 172.67.177.162:80
|
||||
// 103.160.204.200:80
|
||||
// 172.67.70.129:80
|
||||
// 141.101.123.245:80
|
||||
// 185.162.230.117:80
|
||||
// 185.162.230.183:80
|
||||
// 69.61.200.104:36181
|
||||
// 208.65.90.3:4145
|
||||
// 72.223.188.92:4145
|
||||
// 192.252.214.17:4145
|
||||
// 68.71.242.118:4145
|
||||
// 192.252.210.233:4145
|
||||
// 107.181.161.81:4145
|
||||
// 107.181.168.145:4145
|
||||
// 206.220.175.2:4145
|
||||
// 72.37.217.3:4145
|
||||
// 192.252.208.70:14282
|
||||
// 70.166.167.55:57745
|
||||
// 192.111.137.37:18762
|
||||
// 98.188.47.132:4145
|
||||
// 62.99.138.162:80
|
||||
// 172.64.90.186:80
|
||||
// 172.67.74.57:80
|
||||
// 45.131.6.67:80
|
||||
// 141.101.122.37:80
|
||||
// 103.21.244.134:80
|
||||
// 172.64.155.71:80
|
||||
// 5.182.34.139:80
|
||||
// 45.131.6.31:80
|
||||
// 141.101.121.21:80
|
||||
// 141.101.120.190:80
|
||||
// 103.21.244.161:80
|
||||
// 103.21.244.156:80
|
||||
// 103.21.244.151:80
|
||||
// 172.64.89.0:80
|
||||
// 58.216.109.17:800
|
||||
// 58.241.88.18:800
|
||||
// 36.147.78.166:80
|
||||
// 170.244.26.36:8888
|
||||
// 170.244.25.52:8888
|
||||
// 170.244.26.206:8888
|
||||
// 36.138.53.26:10017
|
||||
// 201.148.32.162:80
|
||||
// 203.19.38.114:1080
|
||||
// 31.43.179.204:80
|
||||
// 45.12.31.50:80
|
||||
// 170.244.27.142:8888
|
||||
// 170.244.26.195:8888
|
||||
// 170.244.27.58:8888
|
||||
// 170.244.27.61:8888
|
||||
// 170.244.27.150:8888
|
||||
// 47.243.94.125:1080
|
||||
// 40.177.65.8:80
|
||||
// 47.57.13.107:80
|
||||
// 194.158.203.14:80
|
||||
// 194.219.134.234:80
|
||||
// 103.21.244.51:80
|
||||
// 103.21.244.160:80
|
||||
// 103.21.244.144:80
|
||||
// 213.33.126.130:80
|
||||
// 103.21.244.69:80
|
||||
// 103.21.244.192:80
|
||||
// 103.21.244.133:80
|
||||
// 141.193.213.189:80
|
||||
// 68.71.254.6:4145
|
||||
// 185.221.160.21:80
|
||||
// 188.114.99.97:80
|
||||
// 63.141.128.94:80
|
||||
// 141.193.213.213:80
|
||||
// 185.162.228.234:80
|
||||
// 213.143.113.82:80
|
||||
// 154.194.12.207:80
|
||||
// 189.203.181.34:1080
|
||||
// 202.144.134.150:5678
|
||||
// 190.242.157.215:8080
|
||||
// 72.49.49.11:31034
|
||||
// 142.54.237.34:4145
|
||||
// 68.71.249.153:48606
|
||||
// 192.252.209.155:14455
|
||||
// 192.252.216.86:4145
|
||||
// 142.54.229.249:4145
|
||||
// 209.97.150.167:8080
|
||||
// 192.252.220.92:17328
|
||||
// 98.178.72.21:10919
|
||||
// 144.124.228.87:1080
|
||||
// 183.240.46.42:80
|
||||
// 32.223.6.94:80
|
||||
// 192.252.214.20:15864
|
||||
// 198.177.252.24:4145
|
||||
// 192.252.208.67:14287
|
||||
// 198.8.94.170:4145
|
||||
// 68.71.241.33:4145
|
||||
// 98.181.137.83:4145
|
||||
// 45.12.30.139:80
|
||||
// 103.21.244.97:80
|
||||
// 65.1.148.157:80
|
||||
// 199.58.184.97:4145
|
||||
// 141.101.120.148:80
|
||||
// 185.238.228.77:80
|
||||
// 172.67.202.134:80
|
||||
// 172.67.182.49:80
|
||||
// 45.131.210.112:80
|
||||
// 141.101.120.100:80
|
||||
// 103.21.244.59:80
|
||||
// 103.21.244.189:80
|
||||
// 103.21.244.154:80
|
||||
// 103.21.244.23:80
|
||||
// 173.245.49.40:80
|
||||
// 82.200.235.134:38191
|
||||
// 43.224.118.89:2626
|
||||
// 119.148.47.226:16464
|
||||
// 211.230.49.122:3128
|
||||
// 62.171.159.232:8888
|
||||
// 115.127.112.34:1080
|
||||
// 185.191.236.162:3128
|
||||
// 41.223.119.156:3128
|
||||
// 103.138.123.242:8082
|
||||
// 121.169.46.116:1090
|
||||
// 185.145.185.218:8080
|
||||
// 45.166.93.113:999
|
||||
// 163.53.204.178:9813
|
||||
// 181.224.226.154:8080
|
||||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/eduardolat/openroutergo"
|
||||
"github.com/jobs-scraper/internal/domain"
|
||||
"github.com/jobs-scraper/shared/domain"
|
||||
)
|
||||
|
||||
// JobAnalysisResult represents the structured response from job analysis
|
||||
7
shared/server/go.mod
Normal file
7
shared/server/go.mod
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module github.com/jobs-scraper/shared/server
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/gorilla/mux v1.8.1
|
||||
|
||||
replace github.com/jobs-scraper/shared/server => ./
|
||||
2
shared/server/go.sum
Normal file
2
shared/server/go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
Loading…
Reference in a new issue