Files
Giorgos Komninos 9b5d2c9d8f Add resumable CLI scraping
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.
2026-09-13 11:39:46 +03:00

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))
}