mirror of
https://github.com/gosom/google-maps-scraper.git
synced 2026-09-19 07:27:12 +08:00
9b5d2c9d8f
Allow interrupted CLI file scrapes to continue without starting over or duplicating already-written places. Large scrape jobs can fail or be stopped after producing useful partial output. Previously, rerunning the same command would recreate the run from scratch and risk duplicating results or overwriting output. The new resume mode lets users keep existing CSV/JSONL output, append missing places and skip inputs that have already completed. This keeps the default behavior unchanged while giving long-running CLI jobs a practical recovery path.
362 lines
11 KiB
Go
362 lines
11 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
"golang.org/x/term"
|
|
|
|
"github.com/gosom/google-maps-scraper/internal/proxyconfig"
|
|
"github.com/gosom/google-maps-scraper/s3uploader"
|
|
"github.com/gosom/google-maps-scraper/tlmt"
|
|
"github.com/gosom/google-maps-scraper/tlmt/gonoop"
|
|
"github.com/gosom/google-maps-scraper/tlmt/goposthog"
|
|
"github.com/gosom/scrapemate/scrapemateapp"
|
|
)
|
|
|
|
const (
|
|
RunModeFile = iota + 1
|
|
RunModeDatabase
|
|
RunModeDatabaseProduce
|
|
RunModeInstallPlaywright
|
|
RunModeWeb
|
|
RunModeAwsLambda
|
|
RunModeAwsLambdaInvoker
|
|
)
|
|
|
|
var (
|
|
ErrInvalidRunMode = errors.New("invalid run mode")
|
|
)
|
|
|
|
type Runner interface {
|
|
Run(context.Context) error
|
|
Close(context.Context) error
|
|
}
|
|
|
|
type S3Uploader interface {
|
|
Upload(ctx context.Context, bucketName, key string, body io.Reader) error
|
|
}
|
|
|
|
type Config struct {
|
|
Concurrency int
|
|
CacheDir string
|
|
MaxDepth int
|
|
InputFile string
|
|
ResultsFile string
|
|
JSON bool
|
|
LangCode string
|
|
Debug bool
|
|
Dsn string
|
|
ProduceOnly bool
|
|
ExitOnInactivityDuration time.Duration
|
|
Email bool
|
|
CustomWriter string
|
|
GeoCoordinates string
|
|
Zoom int
|
|
RunMode int
|
|
DisableTelemetry bool
|
|
WebRunner bool
|
|
AwsLamdbaRunner bool
|
|
DataFolder string
|
|
Proxies []string
|
|
AwsAccessKey string
|
|
AwsSecretKey string
|
|
AwsRegion string
|
|
S3Uploader S3Uploader
|
|
S3Bucket string
|
|
AwsLambdaInvoker bool
|
|
FunctionName string
|
|
AwsLambdaChunkSize int
|
|
FastMode bool
|
|
Radius float64
|
|
Addr string
|
|
DisablePageReuse bool
|
|
ExtraReviews bool
|
|
LeadsDBAPIKey string
|
|
BrowserPoolSize int
|
|
MaxPagesPerBrowser int
|
|
Resume bool
|
|
|
|
// Grid scraping — divide a bounding box into cells to bypass the ~120
|
|
// results-per-search limit imposed by Google Maps.
|
|
GridBBox string // "minLat,minLon,maxLat,maxLon"
|
|
GridCellKm float64 // size of each grid cell in km (default: 1.0)
|
|
Version bool
|
|
}
|
|
|
|
//nolint:gocyclo // The cyclomatic complexity of this function is high due to the number of configuration options and validations.
|
|
func ParseConfig() *Config {
|
|
cfg := Config{}
|
|
|
|
if os.Getenv("PLAYWRIGHT_INSTALL_ONLY") == "1" {
|
|
cfg.RunMode = RunModeInstallPlaywright
|
|
|
|
return &cfg
|
|
}
|
|
|
|
var (
|
|
proxies string
|
|
proxiesFile string
|
|
)
|
|
|
|
flag.IntVar(&cfg.Concurrency, "c", min(runtime.NumCPU()/2, 1), "sets the concurrency [default: half of CPU cores]")
|
|
flag.StringVar(&cfg.CacheDir, "cache", "cache", "sets the cache directory [no effect at the moment]")
|
|
flag.IntVar(&cfg.MaxDepth, "depth", 10, "maximum scroll depth in search results [default: 10]")
|
|
flag.StringVar(&cfg.ResultsFile, "results", "stdout", "path to the results file [default: stdout]")
|
|
flag.StringVar(&cfg.InputFile, "input", "", "path to the input file with queries (one per line) [default: empty]")
|
|
flag.StringVar(&cfg.LangCode, "lang", "en", "language code for Google (e.g., 'de' for German) [default: en]")
|
|
flag.BoolVar(&cfg.Debug, "debug", false, "enable headful crawl (opens browser window) [default: false]")
|
|
flag.StringVar(&cfg.Dsn, "dsn", "", "database connection string [only valid with database provider]")
|
|
flag.BoolVar(&cfg.ProduceOnly, "produce", false, "produce seed jobs only (requires dsn)")
|
|
flag.DurationVar(&cfg.ExitOnInactivityDuration, "exit-on-inactivity", 0, "exit after inactivity duration (e.g., '5m')")
|
|
flag.BoolVar(&cfg.JSON, "json", false, "produce JSON output instead of CSV")
|
|
flag.BoolVar(&cfg.Email, "email", false, "extract emails from websites")
|
|
flag.StringVar(&cfg.CustomWriter, "writer", "", "use custom writer plugin (format: 'dir:pluginName')")
|
|
flag.StringVar(&cfg.GeoCoordinates, "geo", "", "set geo coordinates for search (e.g., '37.7749,-122.4194')")
|
|
flag.IntVar(&cfg.Zoom, "zoom", 15, "set zoom level (0-21) for search")
|
|
flag.BoolVar(&cfg.WebRunner, "web", false, "run web server instead of crawling")
|
|
flag.StringVar(&cfg.DataFolder, "data-folder", "webdata", "data folder for web runner")
|
|
flag.StringVar(&proxies, "proxies", "", "comma separated list of proxies to use in the format protocol://user:pass@host:port example: socks5://localhost:9050 or http://user:pass@localhost:9050")
|
|
flag.StringVar(&proxiesFile, "proxies-file", "", "path to a file containing one proxy URL per line")
|
|
flag.BoolVar(&cfg.AwsLamdbaRunner, "aws-lambda", false, "run as AWS Lambda function")
|
|
flag.BoolVar(&cfg.AwsLambdaInvoker, "aws-lambda-invoker", false, "run as AWS Lambda invoker")
|
|
flag.StringVar(&cfg.FunctionName, "function-name", "", "AWS Lambda function name")
|
|
flag.StringVar(&cfg.AwsAccessKey, "aws-access-key", "", "AWS access key")
|
|
flag.StringVar(&cfg.AwsSecretKey, "aws-secret-key", "", "AWS secret key")
|
|
flag.StringVar(&cfg.AwsRegion, "aws-region", "", "AWS region")
|
|
flag.StringVar(&cfg.S3Bucket, "s3-bucket", "", "S3 bucket name")
|
|
flag.IntVar(&cfg.AwsLambdaChunkSize, "aws-lambda-chunk-size", 100, "AWS Lambda chunk size")
|
|
flag.BoolVar(&cfg.FastMode, "fast-mode", false, "fast mode (reduced data collection)")
|
|
flag.Float64Var(&cfg.Radius, "radius", 10000, "search radius in meters. Default is 10000 meters")
|
|
flag.StringVar(&cfg.Addr, "addr", ":8080", "address to listen on for web server")
|
|
flag.BoolVar(&cfg.DisablePageReuse, "disable-page-reuse", false, "disable page reuse in playwright")
|
|
flag.BoolVar(&cfg.ExtraReviews, "extra-reviews", false, "enable extra reviews collection")
|
|
flag.StringVar(&cfg.LeadsDBAPIKey, "leadsdb-api-key", "", "LeadsDB API key for exporting results to LeadsDB")
|
|
flag.BoolVar(&cfg.Resume, "resume", false, "resume a CLI file scrape by reading existing results and appending missing places")
|
|
flag.StringVar(&cfg.GridBBox, "grid-bbox", "", "bounding box for grid scraping: 'minLat,minLon,maxLat,maxLon' (e.g. '40.30,-3.80,40.50,-3.60')")
|
|
flag.Float64Var(&cfg.GridCellKm, "grid-cell", 1.0, "grid cell size in km [default: 1.0]. Use with -grid-bbox")
|
|
flag.IntVar(&cfg.BrowserPoolSize, "browser-pool-size", 0, "number of browser contexts for JS mode; 0 derives from concurrency and pages-per-browser")
|
|
flag.IntVar(&cfg.MaxPagesPerBrowser, "pages-per-browser", 1, "maximum concurrent pages per browser context in JS mode")
|
|
flag.BoolVar(&cfg.Version, "version", false, "returns the version of the tool")
|
|
|
|
flag.Parse()
|
|
|
|
if cfg.Version {
|
|
info, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
fmt.Println("build info not available")
|
|
os.Exit(1)
|
|
}
|
|
|
|
version := info.Main.Version
|
|
|
|
var commit string
|
|
|
|
for _, s := range info.Settings {
|
|
if s.Key == "vcs.revision" {
|
|
commit = s.Value[:7]
|
|
}
|
|
}
|
|
|
|
fmt.Printf("%s-%s\n", version, commit)
|
|
|
|
os.Exit(0)
|
|
}
|
|
|
|
if cfg.AwsAccessKey == "" {
|
|
cfg.AwsAccessKey = os.Getenv("MY_AWS_ACCESS_KEY")
|
|
}
|
|
|
|
if cfg.AwsSecretKey == "" {
|
|
cfg.AwsSecretKey = os.Getenv("MY_AWS_SECRET_KEY")
|
|
}
|
|
|
|
if cfg.AwsRegion == "" {
|
|
cfg.AwsRegion = os.Getenv("MY_AWS_REGION")
|
|
}
|
|
|
|
if cfg.AwsLambdaInvoker && cfg.FunctionName == "" {
|
|
panic("FunctionName must be provided when using AwsLambdaInvoker")
|
|
}
|
|
|
|
if cfg.AwsLambdaInvoker && cfg.S3Bucket == "" {
|
|
panic("S3Bucket must be provided when using AwsLambdaInvoker")
|
|
}
|
|
|
|
if cfg.AwsLambdaInvoker && cfg.InputFile == "" {
|
|
panic("InputFile must be provided when using AwsLambdaInvoker")
|
|
}
|
|
|
|
if cfg.Concurrency < 1 {
|
|
panic("Concurrency must be greater than 0")
|
|
}
|
|
|
|
if cfg.MaxDepth < 1 {
|
|
panic("MaxDepth must be greater than 0")
|
|
}
|
|
|
|
if cfg.Zoom < 0 || cfg.Zoom > 21 {
|
|
panic("Zoom must be between 0 and 21")
|
|
}
|
|
|
|
if cfg.Dsn == "" && cfg.ProduceOnly {
|
|
panic("Dsn must be provided when using ProduceOnly")
|
|
}
|
|
|
|
resolvedProxies, err := proxyconfig.Resolve(proxies, proxiesFile)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
cfg.Proxies = resolvedProxies
|
|
|
|
if cfg.AwsAccessKey != "" && cfg.AwsSecretKey != "" && cfg.AwsRegion != "" {
|
|
cfg.S3Uploader = s3uploader.New(cfg.AwsAccessKey, cfg.AwsSecretKey, cfg.AwsRegion)
|
|
}
|
|
|
|
switch {
|
|
case cfg.AwsLambdaInvoker:
|
|
cfg.RunMode = RunModeAwsLambdaInvoker
|
|
case cfg.AwsLamdbaRunner:
|
|
cfg.RunMode = RunModeAwsLambda
|
|
case cfg.WebRunner || (cfg.Dsn == "" && cfg.InputFile == ""):
|
|
cfg.RunMode = RunModeWeb
|
|
case cfg.Dsn == "":
|
|
cfg.RunMode = RunModeFile
|
|
case cfg.ProduceOnly:
|
|
cfg.RunMode = RunModeDatabaseProduce
|
|
case cfg.Dsn != "":
|
|
cfg.RunMode = RunModeDatabase
|
|
default:
|
|
panic("Invalid configuration")
|
|
}
|
|
|
|
return &cfg
|
|
}
|
|
|
|
var (
|
|
telemetryOnce sync.Once
|
|
telemetry tlmt.Telemetry
|
|
)
|
|
|
|
func Telemetry() tlmt.Telemetry {
|
|
telemetryOnce.Do(func() {
|
|
disableTel := func() bool {
|
|
return os.Getenv("DISABLE_TELEMETRY") == "1"
|
|
}()
|
|
|
|
if disableTel {
|
|
telemetry = gonoop.New()
|
|
|
|
return
|
|
}
|
|
|
|
val, err := goposthog.New("phc_CHYBGEd1eJZzDE7ZWhyiSFuXa9KMLRnaYN47aoIAY2A", "https://eu.i.posthog.com")
|
|
if err != nil || val == nil {
|
|
telemetry = gonoop.New()
|
|
|
|
return
|
|
}
|
|
|
|
telemetry = val
|
|
})
|
|
|
|
return telemetry
|
|
}
|
|
|
|
func wrapText(text string, width int) []string {
|
|
var lines []string
|
|
|
|
currentLine := ""
|
|
currentWidth := 0
|
|
|
|
for _, r := range text {
|
|
runeWidth := runewidth.RuneWidth(r)
|
|
if currentWidth+runeWidth > width {
|
|
lines = append(lines, currentLine)
|
|
currentLine = string(r)
|
|
currentWidth = runeWidth
|
|
} else {
|
|
currentLine += string(r)
|
|
currentWidth += runeWidth
|
|
}
|
|
}
|
|
|
|
if currentLine != "" {
|
|
lines = append(lines, currentLine)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
func banner(messages []string, width int) string {
|
|
if width <= 0 {
|
|
var err error
|
|
|
|
width, _, err = term.GetSize(0)
|
|
if err != nil {
|
|
width = 80
|
|
}
|
|
}
|
|
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
|
|
contentWidth := width - 4
|
|
|
|
wrappedLines := make([]string, 0, len(messages))
|
|
for _, message := range messages {
|
|
wrappedLines = append(wrappedLines, wrapText(message, contentWidth)...)
|
|
}
|
|
|
|
var builder strings.Builder
|
|
|
|
builder.WriteString("╔" + strings.Repeat("═", width-2) + "╗\n")
|
|
|
|
for _, line := range wrappedLines {
|
|
lineWidth := runewidth.StringWidth(line)
|
|
paddingRight := contentWidth - lineWidth
|
|
|
|
if paddingRight < 0 {
|
|
paddingRight = 0
|
|
}
|
|
|
|
fmt.Fprintf(&builder, "║ %s%s ║\n", line, strings.Repeat(" ", paddingRight))
|
|
}
|
|
|
|
builder.WriteString("╚" + strings.Repeat("═", width-2) + "╝\n")
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
func AppendBrowserCapacityOptions(opts []func(*scrapemateapp.Config) error, cfg *Config) []func(*scrapemateapp.Config) error {
|
|
if cfg.MaxPagesPerBrowser > 1 {
|
|
opts = append(opts, scrapemateapp.WithMaxPagesPerBrowser(cfg.MaxPagesPerBrowser))
|
|
}
|
|
|
|
if cfg.BrowserPoolSize > 0 {
|
|
opts = append(opts, scrapemateapp.WithBrowserPoolSize(cfg.BrowserPoolSize))
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
func Banner() {
|
|
message1 := "🌍 Google Maps Scraper"
|
|
message2 := "⭐ If you find this project useful, please star it on GitHub: https://github.com/gosom/google-maps-scraper"
|
|
message3 := "💖 Consider sponsoring to support development: https://github.com/sponsors/gosom"
|
|
|
|
fmt.Fprintln(os.Stderr, banner([]string{message1, message2, message3}, 0))
|
|
}
|