mirror of
https://github.com/tiennm99/miti-scraper.git
synced 2026-08-13 02:22:57 +00:00
287 lines
6.4 KiB
Go
287 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gocolly/colly/v2"
|
|
"golang.org/x/net/html"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Config struct {
|
|
RootURL string `yaml:"root_url"`
|
|
Whitelist []string `yaml:"whitelist"`
|
|
DataFile string `yaml:"data_file"`
|
|
Delay int `yaml:"delay_seconds"`
|
|
}
|
|
|
|
type WebScraper struct {
|
|
whitelist []string
|
|
processedURL map[string]bool
|
|
mutex sync.RWMutex
|
|
dataFile string
|
|
delay time.Duration
|
|
}
|
|
|
|
func NewWebScraper(whitelist []string, dataFile string, delay time.Duration) *WebScraper {
|
|
return &WebScraper{
|
|
whitelist: whitelist,
|
|
processedURL: make(map[string]bool),
|
|
dataFile: dataFile,
|
|
delay: delay,
|
|
}
|
|
}
|
|
|
|
func (ws *WebScraper) loadProcessedURLs() error {
|
|
file, err := os.Open(ws.dataFile)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
url := strings.TrimSpace(scanner.Text())
|
|
if url != "" {
|
|
ws.processedURL[url] = true
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func (ws *WebScraper) saveProcessedURLs() error {
|
|
file, err := os.Create(ws.dataFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
ws.mutex.RLock()
|
|
defer ws.mutex.RUnlock()
|
|
|
|
for url := range ws.processedURL {
|
|
_, err := file.WriteString(url + "\n")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ws *WebScraper) isWhitelisted(url string) bool {
|
|
for _, pattern := range ws.whitelist {
|
|
matched, err := regexp.MatchString(pattern, url)
|
|
if err != nil {
|
|
log.Printf("Invalid regex pattern '%s': %v", pattern, err)
|
|
continue
|
|
}
|
|
if matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (ws *WebScraper) isProcessed(url string) bool {
|
|
ws.mutex.RLock()
|
|
defer ws.mutex.RUnlock()
|
|
return ws.processedURL[url]
|
|
}
|
|
|
|
func (ws *WebScraper) markAsProcessed(url string) {
|
|
ws.mutex.Lock()
|
|
defer ws.mutex.Unlock()
|
|
ws.processedURL[url] = true
|
|
}
|
|
|
|
func (ws *WebScraper) normalizeURLForFilename(urlStr string) string {
|
|
parsedURL, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return "invalid-url"
|
|
}
|
|
|
|
filename := parsedURL.Host + parsedURL.Path
|
|
if parsedURL.RawQuery != "" {
|
|
filename += "_" + parsedURL.RawQuery
|
|
}
|
|
|
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
filename = reg.ReplaceAllString(filename, "_")
|
|
filename = strings.ReplaceAll(filename, ".", "_")
|
|
|
|
if filename == "" || filename[len(filename)-1] == '_' {
|
|
filename += "index"
|
|
}
|
|
|
|
return filename
|
|
}
|
|
|
|
func (ws *WebScraper) extractTextFromHTML(htmlContent string) string {
|
|
doc, err := html.Parse(strings.NewReader(htmlContent))
|
|
if err != nil {
|
|
log.Printf("Failed to parse HTML: %v", err)
|
|
return htmlContent
|
|
}
|
|
|
|
var textContent strings.Builder
|
|
ws.extractText(doc, &textContent)
|
|
|
|
text := textContent.String()
|
|
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
|
|
text = strings.TrimSpace(text)
|
|
|
|
return text
|
|
}
|
|
|
|
func (ws *WebScraper) extractText(n *html.Node, textContent *strings.Builder) {
|
|
if n.Type == html.TextNode {
|
|
textContent.WriteString(n.Data)
|
|
textContent.WriteString(" ")
|
|
}
|
|
|
|
if n.Type == html.ElementNode {
|
|
switch n.Data {
|
|
case "script", "style", "noscript", "head":
|
|
return
|
|
case "br", "p", "div", "h1", "h2", "h3", "h4", "h5", "h6":
|
|
textContent.WriteString(" ")
|
|
}
|
|
}
|
|
|
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
|
ws.extractText(child, textContent)
|
|
}
|
|
}
|
|
|
|
func (ws *WebScraper) saveContent(urlStr, content string) error {
|
|
dataDir := "data"
|
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
filename := ws.normalizeURLForFilename(urlStr) + ".txt"
|
|
filePath := filepath.Join(dataDir, filename)
|
|
|
|
textContent := ws.extractTextFromHTML(content)
|
|
return os.WriteFile(filePath, []byte(textContent), 0644)
|
|
}
|
|
|
|
func (ws *WebScraper) processURL(url string) {
|
|
fmt.Printf("Processing URL: %s\n", url)
|
|
ws.markAsProcessed(url)
|
|
}
|
|
|
|
func (ws *WebScraper) Start(rootURL string) error {
|
|
if err := ws.loadProcessedURLs(); err != nil {
|
|
return fmt.Errorf("failed to load processed URLs: %w", err)
|
|
}
|
|
|
|
c := colly.NewCollector(
|
|
colly.AllowedDomains(),
|
|
)
|
|
|
|
c.OnResponse(func(r *colly.Response) {
|
|
urlStr := r.Request.URL.String()
|
|
if ws.isWhitelisted(urlStr) {
|
|
content := string(r.Body)
|
|
if err := ws.saveContent(urlStr, content); err != nil {
|
|
log.Printf("Failed to save content for %s: %v", urlStr, err)
|
|
} else {
|
|
fmt.Printf("Saved content: %s\n", ws.normalizeURLForFilename(urlStr)+".txt")
|
|
}
|
|
}
|
|
})
|
|
|
|
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
|
|
link := e.Attr("href")
|
|
absoluteURL := e.Request.AbsoluteURL(link)
|
|
|
|
if ws.isProcessed(absoluteURL) {
|
|
return
|
|
}
|
|
|
|
if ws.isWhitelisted(absoluteURL) {
|
|
ws.processURL(absoluteURL)
|
|
c.Visit(absoluteURL)
|
|
}
|
|
})
|
|
|
|
c.OnRequest(func(r *colly.Request) {
|
|
fmt.Printf("Visiting: %s\n", r.URL.String())
|
|
if ws.delay > 0 {
|
|
time.Sleep(ws.delay)
|
|
}
|
|
})
|
|
|
|
c.OnError(func(r *colly.Response, err error) {
|
|
if r != nil {
|
|
switch r.StatusCode {
|
|
case 301, 302, 303, 307, 308:
|
|
log.Printf("REDIRECT: %s (Status: %d) -> Location: %s", r.Request.URL, r.StatusCode, r.Headers.Get("Location"))
|
|
case 403:
|
|
log.Printf("BLOCKED: Access forbidden to %s (Status: 403)", r.Request.URL)
|
|
case 429:
|
|
log.Printf("RATE_LIMITED: Too many requests to %s (Status: 429)", r.Request.URL)
|
|
case 404:
|
|
log.Printf("NOT_FOUND: %s (Status: 404)", r.Request.URL)
|
|
default:
|
|
log.Printf("ERROR: %s (Status: %d) - %v", r.Request.URL, r.StatusCode, err)
|
|
}
|
|
} else {
|
|
log.Printf("NETWORK_ERROR: Failed to connect to %s - %v", r.Request.URL, err)
|
|
}
|
|
})
|
|
|
|
if ws.isWhitelisted(rootURL) && !ws.isProcessed(rootURL) {
|
|
ws.processURL(rootURL)
|
|
}
|
|
|
|
err := c.Visit(rootURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to visit root URL: %w", err)
|
|
}
|
|
|
|
if err := ws.saveProcessedURLs(); err != nil {
|
|
return fmt.Errorf("failed to save processed URLs: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Scraping completed. Processed %d URLs.\n", len(ws.processedURL))
|
|
return nil
|
|
}
|
|
|
|
func loadConfig(filename string) (*Config, error) {
|
|
data, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var config Config
|
|
err = yaml.Unmarshal(data, &config)
|
|
return &config, err
|
|
}
|
|
|
|
func main() {
|
|
config, err := loadConfig("config.yaml")
|
|
if err != nil {
|
|
log.Fatal("Failed to load config:", err)
|
|
}
|
|
|
|
delay := time.Duration(config.Delay) * time.Second
|
|
scraper := NewWebScraper(config.Whitelist, config.DataFile, delay)
|
|
|
|
if err := scraper.Start(config.RootURL); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
} |