mirror of
https://github.com/tnypxl/rollup.git
synced 2025-12-13 06:23:18 +00:00
Compare commits
6 Commits
feat/click
...
improve-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b6218c7f | ||
|
|
8a16cec600 | ||
|
|
7676638fa4 | ||
|
|
ad5147551a | ||
|
|
6870258944 | ||
|
|
da92da21dc |
67
README.md
67
README.md
@@ -15,7 +15,7 @@ Rollup aggregates the contents of text-based files and webpages into a markdown
|
|||||||
- Flexible configuration file support (YAML)
|
- Flexible configuration file support (YAML)
|
||||||
- Automatic generation of default configuration file
|
- Automatic generation of default configuration file
|
||||||
- Custom output file naming
|
- Custom output file naming
|
||||||
- Concurrent processing for improved performance
|
- Rate limiting for web scraping to respect server resources
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -66,37 +66,36 @@ Rollup can be configured using a YAML file. By default, it looks for `rollup.yml
|
|||||||
Example `rollup.yml`:
|
Example `rollup.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
file_types:
|
file_extensions:
|
||||||
- go
|
- go
|
||||||
- md
|
- md
|
||||||
ignore:
|
ignore_paths:
|
||||||
- node_modules/**
|
- node_modules/**
|
||||||
- vendor/**
|
- vendor/**
|
||||||
- .git/**
|
- .git/**
|
||||||
code_generated:
|
code_generated_paths:
|
||||||
- **/generated/**
|
- **/generated/**
|
||||||
scrape:
|
sites:
|
||||||
sites:
|
- base_url: https://example.com
|
||||||
- base_url: https://example.com
|
css_locator: .content
|
||||||
css_locator: .content
|
exclude_selectors:
|
||||||
exclude_selectors:
|
- .ads
|
||||||
- .ads
|
- .navigation
|
||||||
- .navigation
|
max_depth: 2
|
||||||
max_depth: 2
|
allowed_paths:
|
||||||
allowed_paths:
|
- /blog
|
||||||
- /blog
|
- /docs
|
||||||
- /docs
|
exclude_paths:
|
||||||
exclude_paths:
|
- /admin
|
||||||
- /admin
|
output_alias: example
|
||||||
output_alias: example
|
path_overrides:
|
||||||
path_overrides:
|
- path: /special-page
|
||||||
- path: /special-page
|
css_locator: .special-content
|
||||||
css_locator: .special-content
|
exclude_selectors:
|
||||||
exclude_selectors:
|
- .special-ads
|
||||||
- .special-ads
|
output_type: single
|
||||||
output_type: single
|
requests_per_second: 1.0
|
||||||
requests_per_second: 1.0
|
burst_limit: 3
|
||||||
burst_limit: 3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -107,10 +106,10 @@ scrape:
|
|||||||
rollup files
|
rollup files
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Web scraping with multiple URLs and increased concurrency:
|
2. Web scraping with multiple URLs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rollup web --urls=https://example.com,https://another-example.com --concurrent=8
|
rollup web --urls=https://example.com,https://another-example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Generate a default configuration file:
|
3. Generate a default configuration file:
|
||||||
@@ -119,20 +118,22 @@ scrape:
|
|||||||
rollup generate
|
rollup generate
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Use a custom configuration file and specify output:
|
4. Use a custom configuration file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rollup files --config=my-config.yml --output=project_summary.md
|
rollup files --config=my-config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Web scraping with separate output files and custom timeout:
|
5. Web scraping with separate output files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rollup web --urls=https://example.com,https://another-example.com --output=separate --timeout=60
|
rollup web --urls=https://example.com,https://another-example.com --output=separate
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Rollup files with specific types and ignore patterns:
|
6. Rollup files with specific types and ignore patterns:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rollup files --types=.go,.md --ignore=vendor/**,*_test.go
|
rollup files --types=go,md --ignore=vendor/**,*_test.go
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Web scraping with depth and CSS selector:
|
7. Web scraping with depth and CSS selector:
|
||||||
|
|||||||
64
cmd/files.go
64
cmd/files.go
@@ -108,19 +108,20 @@ func isIgnored(filePath string, patterns []string) bool {
|
|||||||
|
|
||||||
func runRollup(cfg *config.Config) error {
|
func runRollup(cfg *config.Config) error {
|
||||||
// Use config if available, otherwise use command-line flags
|
// Use config if available, otherwise use command-line flags
|
||||||
var types, codeGenList, ignoreList []string
|
var types []string
|
||||||
if cfg != nil && len(cfg.FileTypes) > 0 {
|
var codeGenList, ignoreList []string
|
||||||
types = cfg.FileTypes
|
if cfg != nil && len(cfg.FileExtensions) > 0 {
|
||||||
|
types = cfg.FileExtensions
|
||||||
} else {
|
} else {
|
||||||
types = strings.Split(fileTypes, ",")
|
types = strings.Split(fileTypes, ",")
|
||||||
}
|
}
|
||||||
if cfg != nil && len(cfg.CodeGenerated) > 0 {
|
if cfg != nil && len(cfg.CodeGeneratedPaths) > 0 {
|
||||||
codeGenList = cfg.CodeGenerated
|
codeGenList = cfg.CodeGeneratedPaths
|
||||||
} else {
|
} else {
|
||||||
codeGenList = strings.Split(codeGenPatterns, ",")
|
codeGenList = strings.Split(codeGenPatterns, ",")
|
||||||
}
|
}
|
||||||
if cfg != nil && cfg.Ignore != nil && len(cfg.Ignore) > 0 {
|
if cfg != nil && len(cfg.IgnorePaths) > 0 {
|
||||||
ignoreList = cfg.Ignore
|
ignoreList = cfg.IgnorePaths
|
||||||
} else {
|
} else {
|
||||||
ignoreList = strings.Split(ignorePatterns, ",")
|
ignoreList = strings.Split(ignorePatterns, ",")
|
||||||
}
|
}
|
||||||
@@ -145,6 +146,11 @@ func runRollup(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
defer outputFile.Close()
|
defer outputFile.Close()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
showProgress := false
|
||||||
|
progressTicker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer progressTicker.Stop()
|
||||||
|
|
||||||
// Walk through the directory
|
// Walk through the directory
|
||||||
err = filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error {
|
err = filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,16 +166,25 @@ func runRollup(cfg *config.Config) error {
|
|||||||
|
|
||||||
// Check if the file should be ignored
|
// Check if the file should be ignored
|
||||||
if isIgnored(relPath, ignoreList) {
|
if isIgnored(relPath, ignoreList) {
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Ignoring file: %s\n", relPath)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(path)
|
ext := filepath.Ext(path)
|
||||||
for _, t := range types {
|
for _, t := range types {
|
||||||
if ext == "."+t {
|
if ext == "."+t {
|
||||||
|
// Verbose logging for processed file
|
||||||
|
if verbose {
|
||||||
|
size := humanReadableSize(info.Size())
|
||||||
|
fmt.Printf("Processing file: %s (%s)\n", relPath, size)
|
||||||
|
}
|
||||||
|
|
||||||
// Read file contents
|
// Read file contents
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error reading file %s: %v", path, err)
|
fmt.Printf("Error reading file %s: %v\n", path, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +200,43 @@ func runRollup(cfg *config.Config) error {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !showProgress && time.Since(startTime) > 5*time.Second {
|
||||||
|
showProgress = true
|
||||||
|
fmt.Print("This is taking a while (hold tight) ")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-progressTicker.C:
|
||||||
|
if showProgress {
|
||||||
|
fmt.Print(".")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error walking through directory: %v", err)
|
return fmt.Errorf("error walking through directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Rollup complete. Output file: %s", outputFileName)
|
if showProgress {
|
||||||
|
fmt.Println() // Print a newline after the progress dots
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Rollup complete. Output file: %s\n", outputFileName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func humanReadableSize(size int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := size / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,9 +112,9 @@ func TestRunRollup(t *testing.T) {
|
|||||||
|
|
||||||
// Set up test configuration
|
// Set up test configuration
|
||||||
cfg = &config.Config{
|
cfg = &config.Config{
|
||||||
FileTypes: []string{"go", "txt", "md"},
|
FileExtensions: []string{"go", "txt", "md"},
|
||||||
Ignore: []string{"*.json", ".git/**", "vendor/**"},
|
IgnorePaths: []string{"*.json", ".git/**", "vendor/**"},
|
||||||
CodeGenerated: []string{"generated_*.go"},
|
CodeGeneratedPaths: []string{"generated_*.go"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change working directory to the temp directory
|
// Change working directory to the temp directory
|
||||||
|
|||||||
@@ -38,23 +38,23 @@ func runGenerate(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
FileTypes: make([]string, 0, len(fileTypes)),
|
FileExtensions: make([]string, 0, len(fileTypes)),
|
||||||
Ignore: []string{"node_modules/**", "vendor/**", ".git/**"},
|
IgnorePaths: []string{"node_modules/**", "vendor/**", ".git/**"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for ext := range fileTypes {
|
for ext := range fileTypes {
|
||||||
cfg.FileTypes = append(cfg.FileTypes, ext)
|
cfg.FileExtensions = append(cfg.FileExtensions, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort file types for consistency
|
// Sort file types for consistency
|
||||||
sort.Strings(cfg.FileTypes)
|
sort.Strings(cfg.FileExtensions)
|
||||||
|
|
||||||
yamlData, err := yaml.Marshal(&cfg)
|
yamlData, err := yaml.Marshal(&cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error marshaling config: %v", err)
|
return fmt.Errorf("error marshaling config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
outputPath := config.DefaultConfigPath()
|
outputPath := "rollup.yml"
|
||||||
err = os.WriteFile(outputPath, yamlData, 0644)
|
err = os.WriteFile(outputPath, yamlData, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error writing config file: %v", err)
|
return fmt.Errorf("error writing config file: %v", err)
|
||||||
|
|||||||
87
cmd/web.go
87
cmd/web.go
@@ -18,7 +18,6 @@ import (
|
|||||||
var (
|
var (
|
||||||
urls []string
|
urls []string
|
||||||
outputType string
|
outputType string
|
||||||
depth int
|
|
||||||
includeSelector string
|
includeSelector string
|
||||||
excludeSelectors []string
|
excludeSelectors []string
|
||||||
)
|
)
|
||||||
@@ -35,7 +34,6 @@ var webCmd = &cobra.Command{
|
|||||||
func init() {
|
func init() {
|
||||||
webCmd.Flags().StringSliceVarP(&urls, "urls", "u", []string{}, "URLs of the webpages to scrape (comma-separated)")
|
webCmd.Flags().StringSliceVarP(&urls, "urls", "u", []string{}, "URLs of the webpages to scrape (comma-separated)")
|
||||||
webCmd.Flags().StringVarP(&outputType, "output", "o", "single", "Output type: 'single' for one file, 'separate' for multiple files")
|
webCmd.Flags().StringVarP(&outputType, "output", "o", "single", "Output type: 'single' for one file, 'separate' for multiple files")
|
||||||
webCmd.Flags().IntVarP(&depth, "depth", "d", 0, "Depth of link traversal (default: 0, only scrape the given URLs)")
|
|
||||||
webCmd.Flags().StringVar(&includeSelector, "css", "", "CSS selector to extract specific content")
|
webCmd.Flags().StringVar(&includeSelector, "css", "", "CSS selector to extract specific content")
|
||||||
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)")
|
||||||
}
|
}
|
||||||
@@ -50,22 +48,21 @@ func runWeb(cmd *cobra.Command, args []string) error {
|
|||||||
scraperConfig.Verbose = verbose
|
scraperConfig.Verbose = verbose
|
||||||
|
|
||||||
var siteConfigs []scraper.SiteConfig
|
var siteConfigs []scraper.SiteConfig
|
||||||
if len(cfg.Scrape.Sites) > 0 {
|
if len(cfg.Sites) > 0 {
|
||||||
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.Sites))
|
||||||
siteConfigs = make([]scraper.SiteConfig, len(cfg.Scrape.Sites))
|
siteConfigs = make([]scraper.SiteConfig, len(cfg.Sites))
|
||||||
for i, site := range cfg.Scrape.Sites {
|
for i, site := range cfg.Sites {
|
||||||
siteConfigs[i] = scraper.SiteConfig{
|
siteConfigs[i] = scraper.SiteConfig{
|
||||||
BaseURL: site.BaseURL,
|
BaseURL: site.BaseURL,
|
||||||
CSSLocator: site.CSSLocator,
|
CSSLocator: site.CSSLocator,
|
||||||
ExcludeSelectors: site.ExcludeSelectors,
|
ExcludeSelectors: site.ExcludeSelectors,
|
||||||
MaxDepth: site.MaxDepth,
|
|
||||||
AllowedPaths: site.AllowedPaths,
|
AllowedPaths: site.AllowedPaths,
|
||||||
ExcludePaths: site.ExcludePaths,
|
ExcludePaths: site.ExcludePaths,
|
||||||
OutputAlias: site.OutputAlias,
|
OutputAlias: site.OutputAlias,
|
||||||
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, AllowedPaths=%v",
|
||||||
i+1, site.BaseURL, site.CSSLocator, site.MaxDepth, site.AllowedPaths)
|
i+1, site.BaseURL, site.CSSLocator, site.AllowedPaths)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Printf("No sites defined in rollup.yml, falling back to URL-based configuration")
|
logger.Printf("No sites defined in rollup.yml, falling back to URL-based configuration")
|
||||||
@@ -75,10 +72,9 @@ func runWeb(cmd *cobra.Command, args []string) error {
|
|||||||
BaseURL: u,
|
BaseURL: u,
|
||||||
CSSLocator: includeSelector,
|
CSSLocator: includeSelector,
|
||||||
ExcludeSelectors: excludeSelectors,
|
ExcludeSelectors: excludeSelectors,
|
||||||
MaxDepth: depth,
|
|
||||||
}
|
}
|
||||||
logger.Printf("URL %d configuration: BaseURL=%s, CSSLocator=%s, MaxDepth=%d",
|
logger.Printf("URL %d configuration: BaseURL=%s, CSSLocator=%s",
|
||||||
i+1, u, includeSelector, depth)
|
i+1, u, includeSelector)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,13 +88,13 @@ func runWeb(cmd *cobra.Command, args []string) error {
|
|||||||
defaultBurstLimit := 3
|
defaultBurstLimit := 3
|
||||||
|
|
||||||
// Use default values if not set in the configuration
|
// Use default values if not set in the configuration
|
||||||
requestsPerSecond := cfg.Scrape.RequestsPerSecond
|
requestsPerSecond := defaultRequestsPerSecond
|
||||||
if requestsPerSecond == 0 {
|
if cfg.RequestsPerSecond != nil {
|
||||||
requestsPerSecond = defaultRequestsPerSecond
|
requestsPerSecond = *cfg.RequestsPerSecond
|
||||||
}
|
}
|
||||||
burstLimit := cfg.Scrape.BurstLimit
|
burstLimit := defaultBurstLimit
|
||||||
if burstLimit == 0 {
|
if cfg.BurstLimit != nil {
|
||||||
burstLimit = defaultBurstLimit
|
burstLimit = *cfg.BurstLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
scraperConfig := scraper.Config{
|
scraperConfig := scraper.Config{
|
||||||
@@ -114,7 +110,32 @@ func runWeb(cmd *cobra.Command, args []string) error {
|
|||||||
outputType, requestsPerSecond, burstLimit)
|
outputType, requestsPerSecond, burstLimit)
|
||||||
|
|
||||||
logger.Println("Starting scraping process")
|
logger.Println("Starting scraping process")
|
||||||
|
startTime := time.Now()
|
||||||
|
progressTicker := time.NewTicker(time.Second)
|
||||||
|
defer progressTicker.Stop()
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
messagePrinted := false
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-progressTicker.C:
|
||||||
|
if time.Since(startTime) > 5*time.Second && !messagePrinted {
|
||||||
|
fmt.Print("This is taking a while (hold tight) ")
|
||||||
|
messagePrinted = true
|
||||||
|
} else if messagePrinted {
|
||||||
|
fmt.Print(".")
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
scrapedContent, err := scraper.ScrapeSites(scraperConfig)
|
scrapedContent, err := scraper.ScrapeSites(scraperConfig)
|
||||||
|
done <- true
|
||||||
|
fmt.Println() // New line after progress indicator
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Error occurred during scraping: %v", err)
|
logger.Printf("Error occurred during scraping: %v", err)
|
||||||
return fmt.Errorf("error scraping content: %v", err)
|
return fmt.Errorf("error scraping content: %v", err)
|
||||||
@@ -179,45 +200,17 @@ 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) {
|
func scrapeURL(urlStr string) (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)
|
content, err := testExtractAndConvertContent(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
testExtractAndConvertContent = extractAndConvertContent
|
testExtractAndConvertContent = extractAndConvertContent
|
||||||
testExtractLinks = scraper.ExtractLinks
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractAndConvertContent(urlStr string) (string, error) {
|
func extractAndConvertContent(urlStr string) (string, error) {
|
||||||
|
|||||||
@@ -7,35 +7,64 @@ import (
|
|||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config represents the configuration for the rollup tool
|
||||||
type Config struct {
|
type Config struct {
|
||||||
FileTypes []string `yaml:"file_types"`
|
// FileExtensions is a list of file extensions to include in the rollup
|
||||||
Ignore []string `yaml:"ignore"`
|
FileExtensions []string `yaml:"file_extensions"`
|
||||||
CodeGenerated []string `yaml:"code_generated"`
|
|
||||||
Scrape ScrapeConfig `yaml:"scrape"`
|
// IgnorePaths is a list of glob patterns for paths to ignore
|
||||||
}
|
IgnorePaths []string `yaml:"ignore_paths"`
|
||||||
|
|
||||||
type ScrapeConfig struct {
|
// CodeGeneratedPaths is a list of glob patterns for code-generated files
|
||||||
Sites []SiteConfig `yaml:"sites"`
|
CodeGeneratedPaths []string `yaml:"code_generated_paths"`
|
||||||
OutputType string `yaml:"output_type"`
|
|
||||||
RequestsPerSecond float64 `yaml:"requests_per_second"`
|
// Sites is a list of site configurations for web scraping
|
||||||
BurstLimit int `yaml:"burst_limit"`
|
Sites []SiteConfig `yaml:"sites"`
|
||||||
|
|
||||||
|
// OutputType specifies how the output should be generated
|
||||||
|
OutputType string `yaml:"output_type"`
|
||||||
|
|
||||||
|
// RequestsPerSecond limits the rate of web requests
|
||||||
|
RequestsPerSecond *float64 `yaml:"requests_per_second,omitempty"`
|
||||||
|
|
||||||
|
// BurstLimit sets the maximum burst size for rate limiting
|
||||||
|
BurstLimit *int `yaml:"burst_limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteConfig contains configuration for scraping a single site
|
||||||
type SiteConfig struct {
|
type SiteConfig struct {
|
||||||
BaseURL string `yaml:"base_url"`
|
// BaseURL is the starting point for scraping this site
|
||||||
CSSLocator string `yaml:"css_locator"`
|
BaseURL string `yaml:"base_url"`
|
||||||
ExcludeSelectors []string `yaml:"exclude_selectors"`
|
|
||||||
MaxDepth int `yaml:"max_depth"`
|
// CSSLocator is used to extract specific content
|
||||||
AllowedPaths []string `yaml:"allowed_paths"`
|
CSSLocator string `yaml:"css_locator"`
|
||||||
ExcludePaths []string `yaml:"exclude_paths"`
|
|
||||||
OutputAlias string `yaml:"output_alias"`
|
// ExcludeSelectors lists CSS selectors for content to exclude
|
||||||
PathOverrides []PathOverride `yaml:"path_overrides"`
|
ExcludeSelectors []string `yaml:"exclude_selectors"`
|
||||||
|
|
||||||
|
// AllowedPaths lists paths that are allowed to be scraped
|
||||||
|
AllowedPaths []string `yaml:"allowed_paths"`
|
||||||
|
|
||||||
|
// ExcludePaths lists paths that should not be scraped
|
||||||
|
ExcludePaths []string `yaml:"exclude_paths"`
|
||||||
|
|
||||||
|
// OutputAlias provides an alternative name for output files
|
||||||
|
OutputAlias string `yaml:"output_alias"`
|
||||||
|
|
||||||
|
// PathOverrides allows for path-specific configurations
|
||||||
|
PathOverrides []PathOverride `yaml:"path_overrides"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PathOverride allows for path-specific configurations
|
||||||
type PathOverride struct {
|
type PathOverride struct {
|
||||||
Path string `yaml:"path"`
|
// Path is the URL path this override applies to
|
||||||
CSSLocator string `yaml:"css_locator"`
|
Path string `yaml:"path"`
|
||||||
ExcludeSelectors []string `yaml:"exclude_selectors"`
|
|
||||||
|
// CSSLocator overrides the site-wide CSS locator for this path
|
||||||
|
CSSLocator string `yaml:"css_locator"`
|
||||||
|
|
||||||
|
// ExcludeSelectors overrides the site-wide exclude selectors for this path
|
||||||
|
ExcludeSelectors []string `yaml:"exclude_selectors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(configPath string) (*Config, error) {
|
func Load(configPath string) (*Config, error) {
|
||||||
@@ -50,15 +79,28 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfigPath() string {
|
// Validate checks the configuration for any invalid values
|
||||||
return "rollup.yml"
|
func (c *Config) Validate() error {
|
||||||
}
|
if c.RequestsPerSecond != nil && *c.RequestsPerSecond <= 0 {
|
||||||
|
return fmt.Errorf("requests_per_second must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
func FileExists(filename string) bool {
|
if c.BurstLimit != nil && *c.BurstLimit <= 0 {
|
||||||
_, err := os.Stat(filename)
|
return fmt.Errorf("burst_limit must be positive")
|
||||||
return err == nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
for _, site := range c.Sites {
|
||||||
|
if site.BaseURL == "" {
|
||||||
|
return fmt.Errorf("base_url must be specified for each site")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,34 +9,33 @@ import (
|
|||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
// Create a temporary config file
|
// Create a temporary config file
|
||||||
content := []byte(`
|
content := []byte(`
|
||||||
file_types:
|
file_extensions:
|
||||||
- go
|
- .go
|
||||||
- md
|
- .md
|
||||||
ignore:
|
ignore_paths:
|
||||||
- "*.tmp"
|
- "*.tmp"
|
||||||
- "**/*.log"
|
- "**/*.log"
|
||||||
code_generated:
|
code_generated_paths:
|
||||||
- "generated_*.go"
|
- "generated_*.go"
|
||||||
scrape:
|
sites:
|
||||||
sites:
|
- base_url: "https://example.com"
|
||||||
- base_url: "https://example.com"
|
css_locator: "main"
|
||||||
css_locator: "main"
|
exclude_selectors:
|
||||||
exclude_selectors:
|
- ".ads"
|
||||||
- ".ads"
|
max_depth: 2
|
||||||
max_depth: 2
|
allowed_paths:
|
||||||
allowed_paths:
|
- "/blog"
|
||||||
- "/blog"
|
exclude_paths:
|
||||||
exclude_paths:
|
- "/admin"
|
||||||
- "/admin"
|
output_alias: "example"
|
||||||
output_alias: "example"
|
path_overrides:
|
||||||
path_overrides:
|
- path: "/special"
|
||||||
- path: "/special"
|
css_locator: ".special-content"
|
||||||
css_locator: ".special-content"
|
exclude_selectors:
|
||||||
exclude_selectors:
|
- ".sidebar"
|
||||||
- ".sidebar"
|
output_type: "single"
|
||||||
output_type: "single"
|
requests_per_second: 1.0
|
||||||
requests_per_second: 1.0
|
burst_limit: 5
|
||||||
burst_limit: 5
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
tmpfile, err := os.CreateTemp("", "config*.yml")
|
tmpfile, err := os.CreateTemp("", "config*.yml")
|
||||||
@@ -59,33 +58,33 @@ scrape:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the loaded config matches the expected values
|
// Check if the loaded config matches the expected values
|
||||||
|
rps := 1.0
|
||||||
|
bl := 5
|
||||||
expectedConfig := &Config{
|
expectedConfig := &Config{
|
||||||
FileTypes: []string{"go", "md"},
|
FileExtensions: []string{".go", ".md"},
|
||||||
Ignore: []string{"*.tmp", "**/*.log"},
|
IgnorePaths: []string{"*.tmp", "**/*.log"},
|
||||||
CodeGenerated: []string{"generated_*.go"},
|
CodeGeneratedPaths: []string{"generated_*.go"},
|
||||||
Scrape: ScrapeConfig{
|
Sites: []SiteConfig{
|
||||||
Sites: []SiteConfig{
|
{
|
||||||
{
|
BaseURL: "https://example.com",
|
||||||
BaseURL: "https://example.com",
|
CSSLocator: "main",
|
||||||
CSSLocator: "main",
|
ExcludeSelectors: []string{".ads"},
|
||||||
ExcludeSelectors: []string{".ads"},
|
MaxDepth: 2,
|
||||||
MaxDepth: 2,
|
AllowedPaths: []string{"/blog"},
|
||||||
AllowedPaths: []string{"/blog"},
|
ExcludePaths: []string{"/admin"},
|
||||||
ExcludePaths: []string{"/admin"},
|
OutputAlias: "example",
|
||||||
OutputAlias: "example",
|
PathOverrides: []PathOverride{
|
||||||
PathOverrides: []PathOverride{
|
{
|
||||||
{
|
Path: "/special",
|
||||||
Path: "/special",
|
CSSLocator: ".special-content",
|
||||||
CSSLocator: ".special-content",
|
ExcludeSelectors: []string{".sidebar"},
|
||||||
ExcludeSelectors: []string{".sidebar"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
OutputType: "single",
|
|
||||||
RequestsPerSecond: 1.0,
|
|
||||||
BurstLimit: 5,
|
|
||||||
},
|
},
|
||||||
|
OutputType: "single",
|
||||||
|
RequestsPerSecond: &rps,
|
||||||
|
BurstLimit: &bl,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(config, expectedConfig) {
|
if !reflect.DeepEqual(config, expectedConfig) {
|
||||||
@@ -93,28 +92,67 @@ scrape:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfigPath(t *testing.T) {
|
func TestValidate(t *testing.T) {
|
||||||
expected := "rollup.yml"
|
tests := []struct {
|
||||||
result := DefaultConfigPath()
|
name string
|
||||||
if result != expected {
|
config Config
|
||||||
t.Errorf("DefaultConfigPath() = %q, want %q", result, expected)
|
wantErr bool
|
||||||
}
|
}{
|
||||||
}
|
{
|
||||||
|
name: "Valid config",
|
||||||
func TestFileExists(t *testing.T) {
|
config: Config{
|
||||||
// Test with an existing file
|
FileExtensions: []string{".go"},
|
||||||
tmpfile, err := os.CreateTemp("", "testfile")
|
Sites: []SiteConfig{
|
||||||
if err != nil {
|
{BaseURL: "https://example.com", MaxDepth: 2},
|
||||||
t.Fatalf("Failed to create temp file: %v", err)
|
},
|
||||||
}
|
},
|
||||||
defer os.Remove(tmpfile.Name())
|
wantErr: false,
|
||||||
|
},
|
||||||
if !FileExists(tmpfile.Name()) {
|
{
|
||||||
t.Errorf("FileExists(%q) = false, want true", tmpfile.Name())
|
name: "No file extensions",
|
||||||
}
|
config: Config{},
|
||||||
|
wantErr: true,
|
||||||
// Test with a non-existing file
|
},
|
||||||
if FileExists("non_existing_file.txt") {
|
{
|
||||||
t.Errorf("FileExists(\"non_existing_file.txt\") = true, want false")
|
name: "Invalid requests per second",
|
||||||
|
config: Config{
|
||||||
|
FileExtensions: []string{".go"},
|
||||||
|
RequestsPerSecond: func() *float64 { f := -1.0; return &f }(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid burst limit",
|
||||||
|
config: Config{
|
||||||
|
FileExtensions: []string{".go"},
|
||||||
|
BurstLimit: func() *int { i := -1; return &i }(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Site without base URL",
|
||||||
|
config: Config{
|
||||||
|
FileExtensions: []string{".go"},
|
||||||
|
Sites: []SiteConfig{{}},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Negative max depth",
|
||||||
|
config: Config{
|
||||||
|
FileExtensions: []string{".go"},
|
||||||
|
Sites: []SiteConfig{{BaseURL: "https://example.com", MaxDepth: -1}},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.config.Validate()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ type SiteConfig struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
CSSLocator string
|
CSSLocator string
|
||||||
ExcludeSelectors []string
|
ExcludeSelectors []string
|
||||||
MaxDepth int
|
|
||||||
AllowedPaths []string
|
AllowedPaths []string
|
||||||
ExcludePaths []string
|
ExcludePaths []string
|
||||||
OutputAlias string
|
OutputAlias string
|
||||||
@@ -156,57 +155,6 @@ func scrapeSingleURL(url string, site SiteConfig, results chan<- struct {
|
|||||||
}{url, content, nil}
|
}{url, content, nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrapeSite(site SiteConfig, results chan<- struct {
|
|
||||||
url string
|
|
||||||
content string
|
|
||||||
err error
|
|
||||||
}, limiter *rate.Limiter,
|
|
||||||
) {
|
|
||||||
visited := make(map[string]bool)
|
|
||||||
queue := []string{site.BaseURL}
|
|
||||||
|
|
||||||
for len(queue) > 0 {
|
|
||||||
url := queue[0]
|
|
||||||
queue = queue[1:]
|
|
||||||
|
|
||||||
if visited[url] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
visited[url] = true
|
|
||||||
|
|
||||||
if !isAllowedURL(url, site) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for rate limiter before making the request
|
|
||||||
err := limiter.Wait(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
results <- struct {
|
|
||||||
url string
|
|
||||||
content string
|
|
||||||
err error
|
|
||||||
}{url, "", fmt.Errorf("rate limiter error: %v", err)}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cssLocator, excludeSelectors := getOverrides(url, site)
|
|
||||||
content, err := scrapeURL(url, cssLocator, excludeSelectors)
|
|
||||||
results <- struct {
|
|
||||||
url string
|
|
||||||
content string
|
|
||||||
err error
|
|
||||||
}{url, content, err}
|
|
||||||
|
|
||||||
if len(visited) < site.MaxDepth {
|
|
||||||
links, _ := ExtractLinks(url)
|
|
||||||
for _, link := range links {
|
|
||||||
if !visited[link] && isAllowedURL(link, site) {
|
|
||||||
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)
|
||||||
@@ -510,40 +458,6 @@ func scrollPage(page playwright.Page) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractLinks extracts all links from the given URL
|
|
||||||
func ExtractLinks(urlStr string) ([]string, error) {
|
|
||||||
logger.Printf("Extracting links from URL: %s\n", urlStr)
|
|
||||||
|
|
||||||
page, err := browser.NewPage()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not create page: %v", err)
|
|
||||||
}
|
|
||||||
defer page.Close()
|
|
||||||
|
|
||||||
if _, err = page.Goto(urlStr, playwright.PageGotoOptions{
|
|
||||||
WaitUntil: playwright.WaitUntilStateNetworkidle,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, fmt.Errorf("could not go to page: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
links, err := page.Evaluate(`() => {
|
|
||||||
const anchors = document.querySelectorAll('a');
|
|
||||||
return Array.from(anchors).map(a => a.href);
|
|
||||||
}`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not extract links: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []string
|
|
||||||
for _, link := range links.([]interface{}) {
|
|
||||||
// Normalize URL by removing trailing slash
|
|
||||||
normalizedLink := strings.TrimRight(link.(string), "/")
|
|
||||||
result = append(result, normalizedLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("Extracted %d links\n", len(result))
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractContentWithCSS extracts content from HTML using a CSS selector
|
// ExtractContentWithCSS extracts content from HTML using a CSS selector
|
||||||
func ExtractContentWithCSS(content, includeSelector string, excludeSelectors []string) (string, error) {
|
func ExtractContentWithCSS(content, includeSelector string, excludeSelectors []string) (string, error) {
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -20,7 +20,7 @@ func main() {
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
if !isHelpCommand {
|
if !isHelpCommand {
|
||||||
configPath := config.DefaultConfigPath()
|
configPath := "rollup.yml"
|
||||||
cfg, err = config.Load(configPath)
|
cfg, err = config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to load configuration: %v", err)
|
log.Printf("Warning: Failed to load configuration: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user