package seo
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// PageFetcher retrieves and extracts data from web pages.
type PageFetcher struct {
httpClient *http.Client
userAgent string
cache map[string]*FetchResult
cacheTTL time.Duration
}
// FetchResult contains fetched page data.
type FetchResult struct {
URL string `json:"url"`
StatusCode int `json:"status_code"`
ContentHash string `json:"content_hash"`
Title string `json:"title"`
Description string `json:"description"`
Canonical string `json:"canonical"`
Author string `json:"author"`
DatePublished string `json:"date_published"`
DateModified string `json:"date_modified"`
Language string `json:"language"`
RawHTML []byte `json:"-"`
FAQItems []FAQItem `json:"faq_items,omitempty"`
StructuredHints []StructuredHint `json:"structured_hints,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
Error string `json:"error,omitempty"`
}
// FAQItem represents an extracted Q&A pair.
type FAQItem struct {
Question string `json:"question"`
Answer string `json:"answer"`
}
// StructuredHint is a detected schema.org type hint.
type StructuredHint struct {
Type string `json:"type"`
Confidence float64 `json:"confidence"`
Source string `json:"source"` // path, content, existing_schema
}
// NewPageFetcher creates a fetcher.
func NewPageFetcher() *PageFetcher {
return &PageFetcher{
httpClient: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
userAgent: "MEGAMIND-SEO/1.0 (Schema Generator)",
cache: make(map[string]*FetchResult),
cacheTTL: 5 * time.Minute,
}
}
// Fetch retrieves and parses a page.
func (f *PageFetcher) Fetch(url string) (*FetchResult, error) {
// Check cache
if cached := f.getFromCache(url); cached != nil {
return cached, nil
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", f.userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := f.httpClient.Do(req)
if err != nil {
return &FetchResult{
URL: url,
Error: err.Error(),
FetchedAt: time.Now(),
}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
result := f.parseHTML(url, resp.StatusCode, body)
f.putInCache(url, result)
return result, nil
}
// FetchWithHash fetches and returns both result and content hash.
func (f *PageFetcher) FetchWithHash(url string) (*FetchResult, string, error) {
result, err := f.Fetch(url)
if err != nil {
return result, "", err
}
return result, result.ContentHash, nil
}
func (f *PageFetcher) parseHTML(url string, statusCode int, body []byte) *FetchResult {
result := &FetchResult{
URL: url,
StatusCode: statusCode,
RawHTML: body,
FetchedAt: time.Now(),
}
// Content hash
hash := sha256.Sum256(body)
result.ContentHash = hex.EncodeToString(hash[:])
content := string(body)
// Title
if matches := regexp.MustCompile(`(?i)
]*>([^<]+)`).FindStringSubmatch(content); len(matches) > 1 {
result.Title = cleanText(matches[1])
}
// Meta description
if matches := regexp.MustCompile(`(?i)]+name=["']description["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(content); len(matches) > 1 {
result.Description = cleanText(matches[1])
}
// Also try reverse order
if result.Description == "" {
if matches := regexp.MustCompile(`(?i)]+content=["']([^"']+)["'][^>]+name=["']description["']`).FindStringSubmatch(content); len(matches) > 1 {
result.Description = cleanText(matches[1])
}
}
// Canonical URL
if matches := regexp.MustCompile(`(?i)]+rel=["']canonical["'][^>]+href=["']([^"']+)["']`).FindStringSubmatch(content); len(matches) > 1 {
result.Canonical = matches[1]
}
// Author
if matches := regexp.MustCompile(`(?i)]+name=["']author["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(content); len(matches) > 1 {
result.Author = cleanText(matches[1])
}
// Date published
if matches := regexp.MustCompile(`(?i)]+property=["']article:published_time["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(content); len(matches) > 1 {
result.DatePublished = matches[1]
}
if result.DatePublished == "" {
if matches := regexp.MustCompile(`(?i)