11 Commits

Author SHA1 Message Date
Arik Jones (aider)
e42ad24999 docs: update configuration section in README.md to include scrape parameters and example usage 2024-09-30 14:20:17 -05:00
Arik Jones (aider)
01465a08b7 fix: set default values for requests_per_second and burst_limit in configuration to prevent rate limiter errors 2024-09-30 14:19:00 -05:00
Arik Jones (aider)
e3355269b8 refactor: remove redundant scraping functions and update runWeb to utilize scraper.ScrapeSites for improved maintainability 2024-09-30 14:10:37 -05:00
Arik Jones (aider)
54c3776baf fix: update scrapeSingleURL calls to include visited map and currentDepth for thread safety and correct functionality 2024-09-30 14:08:16 -05:00
Arik Jones (aider)
ee1561c502 feat: add LinksContainerSelector to SiteConfig and enhance scraping logic with depth control and link extraction 2024-09-30 14:05:10 -05:00
Arik Jones (aider)
5e8a257ff8 feat: implement links container selector for targeted scraping of linked content 2024-09-30 14:04:41 -05:00
333b9a366c fix: Resolve playwright function deprecations and io/ioutil function deprecations. 2024-09-24 15:13:36 -05:00
Arik Jones (aider)
1869dae89a docs: update configuration section in README.md 2024-09-22 18:36:17 -05:00
Arik Jones (aider)
d3ff7cb862 docs: Update README.md CLI flag documentation 2024-09-22 18:33:24 -05:00
Arik Jones (aider)
ea410e4abb feat: Update README.md to reflect recent changes in functionality 2024-09-22 18:31:06 -05:00
Arik Jones (aider)
7d8e25b1ad docs: Add CHANGELOG.md with v0.0.3 release notes 2024-09-22 18:20:25 -05:00
8 changed files with 397 additions and 347 deletions

View File

@@ -4,16 +4,18 @@ Rollup aggregates the contents of text-based files and webpages into a markdown
## Features ## Features
- File type filtering - File type filtering for targeted content aggregation
- Ignore patterns for excluding files - Ignore patterns for excluding specific files or directories
- Support for code-generated file detection - Support for code-generated file detection and exclusion
- Advanced web scraping functionality - Advanced web scraping functionality with depth control
- Verbose logging option for detailed output - Verbose logging option for detailed operation insights
- Exclusionary CSS selectors for web scraping - Exclusionary CSS selectors for precise web content extraction
- Support for multiple URLs in web scraping - Support for multiple URLs in web scraping operations
- Configurable output format for web scraping (single file or separate files) - Configurable output format for web scraping (single file or separate files)
- Configuration file support (YAML) - Flexible configuration file support (YAML)
- Generation of default configuration file - Automatic generation of default configuration file
- Custom output file naming
- Concurrent processing for improved performance
## Installation ## Installation
@@ -61,27 +63,37 @@ rollup [command] [flags]
Rollup can be configured using a YAML file. By default, it looks for `rollup.yml` in the current directory. You can specify a different configuration file using the `--config` flag. Rollup can be configured using a YAML file. By default, it looks for `rollup.yml` in the current directory. You can specify a different configuration file using the `--config` flag.
Example `rollup.yml`: **Scrape Configuration Parameters:**
- `requests_per_second`: *(float, optional)* The rate at which requests are made per second during web scraping. Default is `1.0`.
- `burst_limit`: *(integer, optional)* The maximum number of requests that can be made in a burst. Default is `5`.
These parameters help control the request rate to avoid overloading the target servers and to comply with their rate limits.
**Example `rollup.yml` with Scrape Configuration:**
```yaml ```yaml
file_types:
- go
- md
ignore:
- node_modules/**
- vendor/**
- .git/**
code_generated:
- **/generated/**
scrape: scrape:
urls: requests_per_second: 1.0
- url: https://example.com burst_limit: 5
sites:
- base_url: https://example.com
css_locator: .content css_locator: .content
exclude_selectors: exclude_selectors:
- .ads - .ads
- .navigation - .navigation
max_depth: 2
allowed_paths:
- /blog
- /docs
exclude_paths:
- /admin
output_alias: example output_alias: example
output_type: single path_overrides:
- path: /special-page
css_locator: .special-content
exclude_selectors:
- .special-ads
``` ```
## Examples ## Examples
@@ -92,10 +104,10 @@ scrape:
rollup files rollup files
``` ```
2. Web scraping with multiple URLs: 2. Web scraping with multiple URLs and increased concurrency:
```bash ```bash
rollup web --urls=https://example.com,https://another-example.com rollup web --urls=https://example.com,https://another-example.com --concurrent=8
``` ```
3. Generate a default configuration file: 3. Generate a default configuration file:
@@ -104,15 +116,25 @@ scrape:
rollup generate rollup generate
``` ```
4. Use a custom configuration file: 4. Use a custom configuration file and specify output:
```bash ```bash
rollup files --config=my-config.yml rollup files --config=my-config.yml --output=project_summary.md
``` ```
5. Web scraping with separate output files: 5. Web scraping with separate output files and custom timeout:
```bash ```bash
rollup web --urls=https://example.com,https://another-example.com --output=separate rollup web --urls=https://example.com,https://another-example.com --output=separate --timeout=60
```
6. Rollup files with specific types and ignore patterns:
```bash
rollup files --types=.go,.md --ignore=vendor/**,*_test.go
```
7. Web scraping with depth and CSS selector:
```bash
rollup web --urls=https://example.com --depth=2 --css=.main-content
``` ```
## Contributing ## Contributing

View File

@@ -2,11 +2,9 @@ package cmd
import ( import (
"fmt" "fmt"
"io/ioutil" "io"
"log" "log"
"net/url"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
@@ -23,8 +21,6 @@ var (
excludeSelectors []string excludeSelectors []string
) )
var scraperConfig scraper.Config
var webCmd = &cobra.Command{ var webCmd = &cobra.Command{
Use: "web", Use: "web",
Short: "Scrape main content from webpages and convert to Markdown", Short: "Scrape main content from webpages and convert to Markdown",
@@ -40,17 +36,28 @@ func init() {
webCmd.Flags().StringSliceVar(&excludeSelectors, "exclude", []string{}, "CSS selectors to exclude from the extracted content (comma-separated)") webCmd.Flags().StringSliceVar(&excludeSelectors, "exclude", []string{}, "CSS selectors to exclude from the extracted content (comma-separated)")
} }
func validateScrapeConfig(scrapeConfig config.ScrapeConfig) error {
if scrapeConfig.RequestsPerSecond <= 0 {
return fmt.Errorf("requests_per_second must be greater than 0")
}
if scrapeConfig.BurstLimit <= 0 {
return fmt.Errorf("burst_limit must be greater than 0")
}
return nil
}
func runWeb(cmd *cobra.Command, args []string) error { func runWeb(cmd *cobra.Command, args []string) error {
scraper.SetupLogger(verbose) scraper.SetupLogger(verbose)
logger := log.New(os.Stdout, "WEB: ", log.LstdFlags) logger := log.New(os.Stdout, "WEB: ", log.LstdFlags)
if !verbose { if !verbose {
logger.SetOutput(ioutil.Discard) logger.SetOutput(io.Discard)
} }
logger.Printf("Starting web scraping process with verbose mode: %v", verbose) logger.Printf("Starting web scraping process with verbose mode: %v", verbose)
scraperConfig.Verbose = verbose
// Prepare site configurations
var siteConfigs []scraper.SiteConfig var siteConfigs []scraper.SiteConfig
if len(cfg.Scrape.Sites) > 0 { if len(cfg.Scrape.Sites) > 0 {
// Use configurations from rollup.yml
logger.Printf("Using configuration from rollup.yml for %d sites", len(cfg.Scrape.Sites)) logger.Printf("Using configuration from rollup.yml for %d sites", len(cfg.Scrape.Sites))
siteConfigs = make([]scraper.SiteConfig, len(cfg.Scrape.Sites)) siteConfigs = make([]scraper.SiteConfig, len(cfg.Scrape.Sites))
for i, site := range cfg.Scrape.Sites { for i, site := range cfg.Scrape.Sites {
@@ -65,10 +72,14 @@ func runWeb(cmd *cobra.Command, args []string) error {
PathOverrides: convertPathOverrides(site.PathOverrides), PathOverrides: convertPathOverrides(site.PathOverrides),
} }
logger.Printf("Site %d configuration: BaseURL=%s, CSSLocator=%s, MaxDepth=%d, AllowedPaths=%v", logger.Printf("Site %d configuration: BaseURL=%s, CSSLocator=%s, MaxDepth=%d, AllowedPaths=%v",
i+1, site.BaseURL, site.CSSLocator, site.MaxDepth, site.AllowedPaths) i+1, site.BaseURL, site.CSSLocator, site.MaxDepth, site.AllowedPaths)
} }
} else { } else {
logger.Printf("No sites defined in rollup.yml, falling back to URL-based configuration") // Use command-line URLs
if len(urls) == 0 {
logger.Println("Error: No URLs provided via --urls flag")
return fmt.Errorf("no URLs provided. Use --urls flag with comma-separated URLs or set 'scrape.sites' in the rollup.yml file")
}
siteConfigs = make([]scraper.SiteConfig, len(urls)) siteConfigs = make([]scraper.SiteConfig, len(urls))
for i, u := range urls { for i, u := range urls {
siteConfigs[i] = scraper.SiteConfig{ siteConfigs[i] = scraper.SiteConfig{
@@ -76,43 +87,34 @@ func runWeb(cmd *cobra.Command, args []string) error {
CSSLocator: includeSelector, CSSLocator: includeSelector,
ExcludeSelectors: excludeSelectors, ExcludeSelectors: excludeSelectors,
MaxDepth: depth, MaxDepth: depth,
AllowedPaths: []string{"/"}, // Allow all paths by default
} }
logger.Printf("URL %d configuration: BaseURL=%s, CSSLocator=%s, MaxDepth=%d", logger.Printf("URL %d configuration: BaseURL=%s, CSSLocator=%s, MaxDepth=%d",
i+1, u, includeSelector, depth) i+1, u, includeSelector, depth)
} }
} }
if len(siteConfigs) == 0 { // Set up scraper configuration
logger.Println("Error: No sites or URLs provided")
return fmt.Errorf("no sites or URLs provided. Use --urls flag with comma-separated URLs or set 'scrape.sites' in the rollup.yml file")
}
// Set default values for rate limiting
defaultRequestsPerSecond := 1.0
defaultBurstLimit := 3
// Use default values if not set in the configuration
requestsPerSecond := cfg.Scrape.RequestsPerSecond
if requestsPerSecond == 0 {
requestsPerSecond = defaultRequestsPerSecond
}
burstLimit := cfg.Scrape.BurstLimit
if burstLimit == 0 {
burstLimit = defaultBurstLimit
}
scraperConfig := scraper.Config{ scraperConfig := scraper.Config{
Sites: siteConfigs, Sites: siteConfigs,
OutputType: outputType, OutputType: outputType,
Verbose: verbose, Verbose: verbose,
Scrape: scraper.ScrapeConfig{ Scrape: scraper.ScrapeConfig{
RequestsPerSecond: requestsPerSecond, RequestsPerSecond: cfg.Scrape.RequestsPerSecond,
BurstLimit: burstLimit, BurstLimit: cfg.Scrape.BurstLimit,
}, },
} }
logger.Printf("Scraper configuration: OutputType=%s, RequestsPerSecond=%f, BurstLimit=%d", logger.Printf("Scraper configuration: OutputType=%s, RequestsPerSecond=%f, BurstLimit=%d",
outputType, requestsPerSecond, burstLimit) outputType, scraperConfig.Scrape.RequestsPerSecond, scraperConfig.Scrape.BurstLimit)
// Validate scrape configuration
err := validateScrapeConfig(cfg.Scrape)
if err != nil {
logger.Printf("Invalid scrape configuration: %v", err)
return err
}
// Start scraping using scraper.ScrapeSites
logger.Println("Starting scraping process") logger.Println("Starting scraping process")
scrapedContent, err := scraper.ScrapeSites(scraperConfig) scrapedContent, err := scraper.ScrapeSites(scraperConfig)
if err != nil { if err != nil {
@@ -121,6 +123,7 @@ func runWeb(cmd *cobra.Command, args []string) error {
} }
logger.Printf("Scraping completed. Total content scraped: %d", len(scrapedContent)) logger.Printf("Scraping completed. Total content scraped: %d", len(scrapedContent))
// Write output to files
if outputType == "single" { if outputType == "single" {
logger.Println("Writing content to a single file") logger.Println("Writing content to a single file")
return writeSingleFile(scrapedContent) return writeSingleFile(scrapedContent)
@@ -139,7 +142,7 @@ func writeSingleFile(content map[string]string) error {
defer file.Close() defer file.Close()
for url, c := range content { for url, c := range content {
_, err = fmt.Fprintf(file, "# Content from %s\n\n%s\n\n---\n\n", url, c) _, err = fmt.Fprintf(file, "# ::: Content from %s\n\n%s\n\n---\n\n", url, c)
if err != nil { if err != nil {
return fmt.Errorf("error writing content to file: %v", err) return fmt.Errorf("error writing content to file: %v", err)
} }
@@ -151,17 +154,13 @@ func writeSingleFile(content map[string]string) error {
func writeMultipleFiles(content map[string]string) error { func writeMultipleFiles(content map[string]string) error {
for url, c := range content { for url, c := range content {
filename, err := getFilenameFromContent(c, url) filename := sanitizeFilename(url) + ".rollup.md"
if err != nil {
return fmt.Errorf("error generating filename for %s: %v", url, err)
}
file, err := os.Create(filename) file, err := os.Create(filename)
if err != nil { if err != nil {
return fmt.Errorf("error creating output file %s: %v", filename, err) return fmt.Errorf("error creating output file %s: %v", filename, err)
} }
_, err = file.WriteString(fmt.Sprintf("# Content from %s\n\n%s\n", url, c)) _, err = file.WriteString(fmt.Sprintf("# ::: Content from %s\n\n%s\n", url, c))
if err != nil { if err != nil {
file.Close() file.Close()
return fmt.Errorf("error writing content to file %s: %v", filename, err) return fmt.Errorf("error writing content to file %s: %v", filename, err)
@@ -179,104 +178,14 @@ func generateDefaultFilename() string {
return fmt.Sprintf("web-%s.rollup.md", timestamp) return fmt.Sprintf("web-%s.rollup.md", timestamp)
} }
func scrapeRecursively(urlStr string, depth int) (string, error) {
visited := make(map[string]bool)
return scrapeURL(urlStr, depth, visited)
}
func scrapeURL(urlStr string, depth int, visited map[string]bool) (string, error) {
if depth < 0 || visited[urlStr] {
return "", nil
}
visited[urlStr] = true
content, err := testExtractAndConvertContent(urlStr)
if err != nil {
return "", err
}
if depth > 0 {
links, err := testExtractLinks(urlStr)
if err != nil {
return content, fmt.Errorf("error extracting links: %v", err)
}
for _, link := range links {
subContent, err := scrapeURL(link, depth-1, visited)
if err != nil {
fmt.Printf("Warning: Error scraping %s: %v\n", link, err)
continue
}
content += "\n\n---\n\n" + subContent
}
}
return content, nil
}
var testExtractAndConvertContent = extractAndConvertContent
var testExtractLinks = scraper.ExtractLinks
func extractAndConvertContent(urlStr string) (string, error) {
content, err := scraper.FetchWebpageContent(urlStr)
if err != nil {
return "", fmt.Errorf("error fetching webpage content: %v", err)
}
if includeSelector != "" {
content, err = scraper.ExtractContentWithCSS(content, includeSelector, excludeSelectors)
if err != nil {
return "", fmt.Errorf("error extracting content with CSS: %v", err)
}
}
markdown, err := scraper.ProcessHTMLContent(content, scraper.Config{})
if err != nil {
return "", fmt.Errorf("error processing HTML content: %v", err)
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("error parsing URL: %v", err)
}
header := fmt.Sprintf("# Content from %s\n\n", parsedURL.String())
return header + markdown + "\n\n", nil
}
func getFilenameFromContent(content, urlStr string) (string, error) {
// Try to extract title from content
titleStart := strings.Index(content, "<title>")
titleEnd := strings.Index(content, "</title>")
if titleStart != -1 && titleEnd != -1 && titleEnd > titleStart {
title := strings.TrimSpace(content[titleStart+7 : titleEnd])
if title != "" {
return sanitizeFilename(title) + ".rollup.md", nil
}
}
// If no title found or title is empty, use the URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %v", err)
}
if parsedURL.Host == "" {
return "", fmt.Errorf("invalid URL: missing host")
}
filename := parsedURL.Host
if parsedURL.Path != "" && parsedURL.Path != "/" {
filename += strings.TrimSuffix(parsedURL.Path, "/")
}
return sanitizeFilename(filename) + ".rollup.md", nil
}
func sanitizeFilename(name string) string { func sanitizeFilename(name string) string {
// Remove any character that isn't alphanumeric, dash, or underscore // Remove any character that isn't alphanumeric, dash, or underscore
reg := regexp.MustCompile("[^a-zA-Z0-9-_]+") name = strings.Map(func(r rune) rune {
name = reg.ReplaceAllString(name, "_") if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, name)
// Trim any leading or trailing underscores // Trim any leading or trailing underscores
name = strings.Trim(name, "_") name = strings.Trim(name, "_")

View File

@@ -103,7 +103,7 @@ func mockExtractAndConvertContent(urlStr string) (string, error) {
return "Mocked content for " + urlStr, nil return "Mocked content for " + urlStr, nil
} }
func mockExtractLinks(urlStr string) ([]string, error) { func mockExtractLinks() ([]string, error) {
return []string{"http://example.com/link1", "http://example.com/link2"}, nil return []string{"http://example.com/link1", "http://example.com/link2"}, nil
} }

21
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.3] - 2024-09-22
### Added
- Implemented web scraping functionality using Playwright
- Added support for CSS selectors to extract specific content
- Introduced rate limiting for web requests
- Created configuration options for scraping settings
### Changed
- Improved error handling and logging throughout the application
- Enhanced URL parsing and validation
### Fixed
- Resolved issues with concurrent scraping operations

View File

@@ -30,6 +30,7 @@ type SiteConfig struct {
ExcludePaths []string `yaml:"exclude_paths"` ExcludePaths []string `yaml:"exclude_paths"`
OutputAlias string `yaml:"output_alias"` OutputAlias string `yaml:"output_alias"`
PathOverrides []PathOverride `yaml:"path_overrides"` PathOverrides []PathOverride `yaml:"path_overrides"`
LinksContainerSelector string `yaml:"links_container_selector"`
} }
type PathOverride struct { type PathOverride struct {
@@ -50,6 +51,13 @@ func Load(configPath string) (*Config, error) {
return nil, fmt.Errorf("error parsing config file: %v", err) return nil, fmt.Errorf("error parsing config file: %v", err)
} }
// Set default values if they are zero or missing
if config.Scrape.RequestsPerSecond <= 0 {
config.Scrape.RequestsPerSecond = 1.0
}
if config.Scrape.BurstLimit <= 0 {
config.Scrape.BurstLimit = 5
}
return &config, nil return &config, nil
} }

View File

@@ -45,10 +45,10 @@ scrape:
} }
defer os.Remove(tmpfile.Name()) defer os.Remove(tmpfile.Name())
if _, err := tmpfile.Write(content); err != nil { if _, err = tmpfile.Write(content); err != nil {
t.Fatalf("Failed to write to temp file: %v", err) t.Fatalf("Failed to write to temp file: %v", err)
} }
if err := tmpfile.Close(); err != nil { if err = tmpfile.Close(); err != nil {
t.Fatalf("Failed to close temp file: %v", err) t.Fatalf("Failed to close temp file: %v", err)
} }

View File

@@ -1,21 +1,21 @@
package scraper package scraper
import ( import (
"context"
"fmt" "fmt"
"io/ioutil" "io"
"log" "log"
"math/rand" "math/rand"
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"strings" "strings"
"time"
"sync" "sync"
"context" "time"
md "github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/playwright-community/playwright-go" "github.com/playwright-community/playwright-go"
md "github.com/JohannesKaufmann/html-to-markdown"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -50,6 +50,7 @@ type SiteConfig struct {
ExcludePaths []string ExcludePaths []string
OutputAlias string OutputAlias string
PathOverrides []PathOverride PathOverrides []PathOverride
LinksContainerSelector string
} }
// PathOverride holds path-specific overrides // PathOverride holds path-specific overrides
@@ -60,208 +61,280 @@ type PathOverride struct {
} }
func ScrapeSites(config Config) (map[string]string, error) { func ScrapeSites(config Config) (map[string]string, error) {
logger.Println("Starting ScrapeSites function - Verbose mode is active") logger.Println("Starting ScrapeSites function - Verbose mode is active")
results := make(chan struct { results := make(chan struct {
url string url string
content string content string
err error err error
}) })
limiter := rate.NewLimiter(rate.Limit(config.Scrape.RequestsPerSecond), config.Scrape.BurstLimit) // Ensure RequestsPerSecond and BurstLimit are valid
logger.Printf("Rate limiter configured with %f requests per second and burst limit of %d\n", config.Scrape.RequestsPerSecond, config.Scrape.BurstLimit) if config.Scrape.RequestsPerSecond <= 0 {
config.Scrape.RequestsPerSecond = 1.0
}
if config.Scrape.BurstLimit <= 0 {
config.Scrape.BurstLimit = 5
}
var wg sync.WaitGroup limiter := rate.NewLimiter(rate.Limit(config.Scrape.RequestsPerSecond), config.Scrape.BurstLimit)
totalURLs := 0 logger.Printf("Rate limiter configured with %f requests per second and burst limit of %d\n", config.Scrape.RequestsPerSecond, config.Scrape.BurstLimit)
for _, site := range config.Sites {
logger.Printf("Processing site: %s\n", site.BaseURL)
wg.Add(1)
go func(site SiteConfig) {
defer wg.Done()
for _, path := range site.AllowedPaths {
fullURL := site.BaseURL + path
totalURLs++
logger.Printf("Queueing URL for scraping: %s\n", fullURL)
scrapeSingleURL(fullURL, site, config, results, limiter)
}
}(site)
}
go func() { var wg sync.WaitGroup
wg.Wait() totalURLs := 0
close(results) var mu sync.Mutex
logger.Println("All goroutines completed, results channel closed") for _, site := range config.Sites {
}() logger.Printf("Processing site: %s\n", site.BaseURL)
wg.Add(1)
go func(site SiteConfig) {
defer wg.Done()
visited := make(map[string]bool)
for _, path := range site.AllowedPaths {
fullURL := site.BaseURL + path
mu.Lock()
totalURLs++
mu.Unlock()
logger.Printf("Queueing URL for scraping: %s\n", fullURL)
scrapeSingleURL(fullURL, site, results, limiter, visited, 0)
}
}(site)
}
scrapedContent := make(map[string]string) go func() {
for result := range results { wg.Wait()
if result.err != nil { close(results)
logger.Printf("Error scraping %s: %v\n", result.url, result.err) logger.Println("All goroutines completed, results channel closed")
continue }()
}
logger.Printf("Successfully scraped content from %s (length: %d)\n", result.url, len(result.content))
scrapedContent[result.url] = result.content
}
logger.Printf("Total URLs processed: %d\n", totalURLs) scrapedContent := make(map[string]string)
logger.Printf("Successfully scraped content from %d URLs\n", len(scrapedContent)) for result := range results {
if result.err != nil {
logger.Printf("Error scraping %s: %v\n", result.url, result.err)
continue
}
logger.Printf("Successfully scraped content from %s (length: %d)\n", result.url, len(result.content))
scrapedContent[result.url] = result.content
}
return scrapedContent, nil logger.Printf("Total URLs processed: %d\n", totalURLs)
logger.Printf("Successfully scraped content from %d URLs\n", len(scrapedContent))
return scrapedContent, nil
} }
func scrapeSingleURL(url string, site SiteConfig, config Config, results chan<- struct { func scrapeSingleURL(url string, site SiteConfig, results chan<- struct {
url string url string
content string content string
err error err error
}, limiter *rate.Limiter) { }, limiter *rate.Limiter, visited map[string]bool, currentDepth int) {
logger.Printf("Starting to scrape URL: %s\n", url) if site.MaxDepth > 0 && currentDepth > site.MaxDepth {
return
}
// Wait for rate limiter before making the request if visited[url] {
err := limiter.Wait(context.Background()) return
if err != nil { }
logger.Printf("Rate limiter error for %s: %v\n", url, err) visited[url] = true
results <- struct {
url string
content string
err error
}{url, "", fmt.Errorf("rate limiter error: %v", err)}
return
}
cssLocator, excludeSelectors := getOverrides(url, site) logger.Printf("Starting to scrape URL: %s\n", url)
logger.Printf("Using CSS locator for %s: %s\n", url, cssLocator)
logger.Printf("Exclude selectors for %s: %v\n", url, excludeSelectors)
content, err := scrapeURL(url, cssLocator, excludeSelectors) // Wait for rate limiter before making the request
if err != nil { err := limiter.Wait(context.Background())
logger.Printf("Error scraping %s: %v\n", url, err) if err != nil {
results <- struct { logger.Printf("Rate limiter error for %s: %v\n", url, err)
url string results <- struct {
content string url string
err error content string
}{url, "", err} err error
return }{url, "", fmt.Errorf("rate limiter error: %v", err)}
} return
}
if content == "" { content, err := FetchWebpageContent(url)
logger.Printf("Warning: Empty content scraped from %s\n", url) if err != nil {
} else { logger.Printf("Error fetching content for %s: %v\n", url, err)
logger.Printf("Successfully scraped content from %s (length: %d)\n", url, len(content)) results <- struct {
} url string
content string
err error
}{url, "", err}
return
}
results <- struct { doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
url string if err != nil {
content string logger.Printf("Error parsing HTML for %s: %v\n", url, err)
err error results <- struct {
}{url, content, nil} url string
content string
err error
}{url, "", fmt.Errorf("error parsing HTML: %v", err)}
return
}
if site.LinksContainerSelector != "" {
logger.Printf("Processing links container for %s\n", url)
linkContainers := doc.Find(site.LinksContainerSelector)
linkContainers.Each(func(i int, container *goquery.Selection) {
container.Find("a[href]").Each(func(j int, link *goquery.Selection) {
href, exists := link.Attr("href")
if exists {
resolvedURL := resolveURL(href, url)
if isAllowedURL(resolvedURL, site) && !visited[resolvedURL] {
go scrapeSingleURL(resolvedURL, site, results, limiter, visited, currentDepth+1)
}
}
})
})
return
}
cssLocator, excludeSelectors := getOverrides(url, site)
logger.Printf("Using CSS locator for %s: %s\n", url, cssLocator)
logger.Printf("Exclude selectors for %s: %v\n", url, excludeSelectors)
extractedContent, err := ExtractContentWithCSS(content, cssLocator, excludeSelectors)
if err != nil {
logger.Printf("Error extracting content for %s: %v\n", url, err)
results <- struct {
url string
content string
err error
}{url, "", err}
return
}
if extractedContent == "" {
logger.Printf("Warning: Empty content scraped from %s\n", url)
} else {
logger.Printf("Successfully scraped content from %s (length: %d)\n", url, len(extractedContent))
}
results <- struct {
url string
content string
err error
}{url, extractedContent, nil}
} }
func scrapeSite(site SiteConfig, config Config, results chan<- struct { func scrapeSite(site SiteConfig, results chan<- struct {
url string url string
content string content string
err error err error
}, limiter *rate.Limiter) { }, limiter *rate.Limiter,
visited := make(map[string]bool) ) {
queue := []string{site.BaseURL} visited := make(map[string]bool)
queue := []string{site.BaseURL}
for len(queue) > 0 { for len(queue) > 0 {
url := queue[0] url := queue[0]
queue = queue[1:] queue = queue[1:]
if visited[url] { if visited[url] {
continue continue
} }
visited[url] = true visited[url] = true
if !isAllowedURL(url, site) { if !isAllowedURL(url, site) {
continue continue
} }
// Wait for rate limiter before making the request // Wait for rate limiter before making the request
err := limiter.Wait(context.Background()) err := limiter.Wait(context.Background())
if err != nil { if err != nil {
results <- struct { results <- struct {
url string url string
content string content string
err error err error
}{url, "", fmt.Errorf("rate limiter error: %v", err)} }{url, "", fmt.Errorf("rate limiter error: %v", err)}
continue continue
} }
cssLocator, excludeSelectors := getOverrides(url, site) cssLocator, excludeSelectors := getOverrides(url, site)
content, err := scrapeURL(url, cssLocator, excludeSelectors) content, err := scrapeURL(url, cssLocator, excludeSelectors)
results <- struct { results <- struct {
url string url string
content string content string
err error err error
}{url, content, err} }{url, content, err}
if len(visited) < site.MaxDepth { if len(visited) < site.MaxDepth {
links, _ := ExtractLinks(url) links, _ := ExtractLinks(url)
for _, link := range links { for _, link := range links {
if !visited[link] && isAllowedURL(link, site) { if !visited[link] && isAllowedURL(link, site) {
queue = append(queue, link) queue = append(queue, link)
} }
} }
} }
} }
} }
func isAllowedURL(urlStr string, site SiteConfig) bool { func isAllowedURL(urlStr string, site SiteConfig) bool {
parsedURL, err := url.Parse(urlStr) parsedURL, err := url.Parse(urlStr)
if err != nil { if err != nil {
return false return false
} }
baseURL, _ := url.Parse(site.BaseURL) baseURL, _ := url.Parse(site.BaseURL)
if parsedURL.Host != baseURL.Host { if parsedURL.Host != baseURL.Host {
return false return false
} }
path := parsedURL.Path path := parsedURL.Path
for _, allowedPath := range site.AllowedPaths {
if strings.HasPrefix(path, allowedPath) { // Check if the URL is within allowed paths
for _, excludePath := range site.ExcludePaths { if len(site.AllowedPaths) > 0 {
if strings.HasPrefix(path, excludePath) { allowed := false
return false for _, allowedPath := range site.AllowedPaths {
} if strings.HasPrefix(path, allowedPath) {
} allowed = true
return true break
} }
} }
if !allowed {
return false
}
}
return false // Check if the URL is in excluded paths
for _, excludePath := range site.ExcludePaths {
if strings.HasPrefix(path, excludePath) {
return false
}
}
return true
} }
func getOverrides(urlStr string, site SiteConfig) (string, []string) { func getOverrides(urlStr string, site SiteConfig) (string, []string) {
parsedURL, _ := url.Parse(urlStr) parsedURL, _ := url.Parse(urlStr)
path := parsedURL.Path path := parsedURL.Path
for _, override := range site.PathOverrides { for _, override := range site.PathOverrides {
if strings.HasPrefix(path, override.Path) { if strings.HasPrefix(path, override.Path) {
if override.CSSLocator != "" { if override.CSSLocator != "" {
return override.CSSLocator, override.ExcludeSelectors return override.CSSLocator, override.ExcludeSelectors
} }
return site.CSSLocator, override.ExcludeSelectors return site.CSSLocator, override.ExcludeSelectors
} }
} }
return site.CSSLocator, site.ExcludeSelectors return site.CSSLocator, site.ExcludeSelectors
} }
func scrapeURL(url, cssLocator string, excludeSelectors []string) (string, error) { func scrapeURL(url, cssLocator string, excludeSelectors []string) (string, error) {
content, err := FetchWebpageContent(url) content, err := FetchWebpageContent(url)
if err != nil { if err != nil {
return "", err return "", err
} }
if cssLocator != "" { if cssLocator != "" {
content, err = ExtractContentWithCSS(content, cssLocator, excludeSelectors) content, err = ExtractContentWithCSS(content, cssLocator, excludeSelectors)
if err != nil { if err != nil {
return "", err return "", err
} }
} }
return ProcessHTMLContent(content, Config{}) return ProcessHTMLContent(content, Config{})
} }
func getFilenameFromContent(content, url string) string { func getFilenameFromContent(content, url string) string {
@@ -296,7 +369,7 @@ func SetupLogger(verbose bool) {
if verbose { if verbose {
logger = log.New(os.Stdout, "SCRAPER: ", log.LstdFlags) logger = log.New(os.Stdout, "SCRAPER: ", log.LstdFlags)
} else { } else {
logger = log.New(ioutil.Discard, "", 0) logger = log.New(io.Discard, "", 0)
} }
} }
@@ -387,7 +460,9 @@ func FetchWebpageContent(urlStr string) (string, error) {
} }
logger.Println("Waiting for body element") logger.Println("Waiting for body element")
_, err = page.WaitForSelector("body", playwright.PageWaitForSelectorOptions{
bodyElement := page.Locator("body")
err = bodyElement.WaitFor(playwright.LocatorWaitForOptions{
State: playwright.WaitForSelectorStateVisible, State: playwright.WaitForSelectorStateVisible,
}) })
if err != nil { if err != nil {
@@ -404,7 +479,7 @@ func FetchWebpageContent(urlStr string) (string, error) {
if content == "" { if content == "" {
logger.Println(" content is empty, falling back to body content") logger.Println(" content is empty, falling back to body content")
content, err = page.InnerHTML("body") content, err = bodyElement.InnerHTML()
if err != nil { if err != nil {
logger.Printf("Error getting body content: %v\n", err) logger.Printf("Error getting body content: %v\n", err)
return "", fmt.Errorf("could not get body content: %v", err) return "", fmt.Errorf("could not get body content: %v", err)
@@ -457,6 +532,8 @@ func scrollPage(page playwright.Page) error {
() => { () => {
window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, document.body.scrollHeight);
return document.body.scrollHeight; return document.body.scrollHeight;
// wait for 500 ms
new Promise(resolve => setTimeout(resolve, 500));
} }
` `
@@ -488,7 +565,9 @@ func scrollPage(page playwright.Page) error {
previousHeight = currentHeight previousHeight = currentHeight
page.WaitForTimeout(500) // Wait for a while before scrolling again
} }
logger.Println("Scrolling back to top") logger.Println("Scrolling back to top")
@@ -584,3 +663,14 @@ func ExtractContentWithCSS(content, includeSelector string, excludeSelectors []s
logger.Printf("Extracted content length: %d\n", len(selectedContent)) logger.Printf("Extracted content length: %d\n", len(selectedContent))
return selectedContent, nil return selectedContent, nil
} }
func resolveURL(href, base string) string {
parsedBase, err := url.Parse(base)
if err != nil {
return href
}
parsedHref, err := url.Parse(href)
if err != nil {
return href
}
return parsedBase.ResolveReference(parsedHref).String()
}

View File

@@ -1,13 +1,13 @@
package scraper package scraper
import ( import (
"testing" "io"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"reflect" "reflect"
"log" "strings"
"io/ioutil" "testing"
) )
func TestIsAllowedURL(t *testing.T) { func TestIsAllowedURL(t *testing.T) {
@@ -51,9 +51,9 @@ func TestGetOverrides(t *testing.T) {
} }
tests := []struct { tests := []struct {
url string url string
expectedLocator string expectedLocator string
expectedExcludes []string expectedExcludes []string
}{ }{
{"https://example.com/normal", "main", []string{".ads"}}, {"https://example.com/normal", "main", []string{".ads"}},
{"https://example.com/special", ".special-content", []string{".sidebar"}}, {"https://example.com/special", ".special-content", []string{".sidebar"}},
@@ -73,7 +73,7 @@ func TestGetOverrides(t *testing.T) {
func TestExtractContentWithCSS(t *testing.T) { func TestExtractContentWithCSS(t *testing.T) {
// Initialize logger for testing // Initialize logger for testing
logger = log.New(ioutil.Discard, "", 0) logger = log.New(io.Discard, "", 0)
html := ` html := `
<html> <html>