【Go语言爬虫系列04】数据存储与导出

📚 原创系列: “Go语言爬虫系列”

🔄 转载说明: 本文最初发布于"Gopher部落"微信公众号,经原作者授权转载。

🔗 关注原创: 欢迎扫描文末二维码,关注"Gopher部落"微信公众号获取第一手Go技术文章。

📑 Go语言爬虫系列导航

本文是【Go语言爬虫系列】的第4篇,点击下方链接查看更多文章

🚀 Go爬虫系列:共14篇
  1. 爬虫入门与Colly框架基础
  2. HTML解析与Goquery技术详解
  3. 并发控制与错误处理
  4. 数据存储与导出 👈 当前位置
  5. 反爬虫策略应对技术
  6. 模拟登录与会话维持
  7. 分布式爬虫架构
  8. JavaScript渲染页面抓取
  9. 移动应用数据抓取
  10. 爬虫性能优化技术
  11. 爬虫数据分析与应用
  12. 爬虫系统安全与伦理
  13. 爬虫系统监控与运维
  14. 综合项目实战:新闻聚合系统开发中 - 关注公众号获取发布通知!

📢 特别提示:《综合项目实战:新闻聚合系统》正在精心制作中!这将是一个完整的实战项目,带您从零构建一个多站点新闻聚合系统。扫描文末二维码关注公众号并回复「新闻聚合」,获取项目发布通知和源码下载链接!

📖 文章导读

在前三篇文章中,我们学习了爬虫的基础知识、HTML解析技术以及并发控制与错误处理。获取数据后,如何高效存储和管理这些数据成为关键问题。本文作为系列的第四篇,将深入探讨爬虫数据的存储与导出技术,主要内容包括:

  1. 爬虫数据存储方案的比较与选择
  2. 文件系统存储实现(CSV、JSON、XML)
  3. 关系型数据库存储(MySQL)实战
  4. NoSQL数据库(MongoDB)在爬虫中的应用
  5. 数据导出与格式转换的有效方法
  6. 爬虫数据的备份与恢复策略

通过本文的学习,您将能够为爬虫系统选择最适合的数据存储方案,并掌握数据管理的最佳实践,构建完整的爬虫数据处理流程。

一、爬虫数据存储方案比较

1.1 数据存储的重要性与挑战

在爬虫系统中,数据存储是连接数据采集和数据分析的关键环节。一个优秀的存储方案应当能够:

  • 高效处理大量数据:爬虫可能在短时间内采集海量信息
  • 支持多样化数据结构:不同网站的数据结构差异很大
  • 便于数据查询与分析:支持灵活的检索和统计功能
  • 确保数据一致性:防止重复或丢失数据
  • 具备良好扩展性:能够随着数据量增长进行扩展

然而,爬虫数据存储也面临着特殊挑战:

  1. 数据量大且增长快:某些爬虫项目可能每天产生GB甚至TB级数据
  2. 数据结构多变:网站结构可能随时变化,存储方案需要适应这种变化
  3. 读写性能平衡:既要满足高速写入,又要支持高效查询
  4. 数据清洗需求:原始数据往往需要清洗处理后再存储
  5. 资源限制:受限于服务器存储空间、内存和网络带宽

1.2 主流存储方案对比

根据项目需求不同,可以选择不同类型的存储方案。以下是主流存储方案的对比:

文件系统存储
格式优势劣势适用场景
CSV简单易用,兼容性好,适合表格数据不支持复杂结构,大文件处理困难结构简单的表格数据,小型爬虫项目
JSON支持嵌套结构,可读性好,语言无关空间效率较低,查询性能一般结构多变的数据,中小型项目
XML结构严格,支持命名空间和验证冗余大,解析开销大需要严格结构验证的场景
二进制存储效率高,读写速度快可读性差,通用性低需要高性能的特定场景
数据库存储
类型代表产品优势劣势适用场景
关系型数据库MySQL, PostgreSQL事务支持,强一致性,成熟稳定扩展性受限,不适合非结构化数据结构稳定的数据,需要复杂查询
文档型数据库MongoDB, CouchDB灵活的数据模型,易于扩展事务支持有限,索引占空间结构多变的数据,需要快速迭代
键值存储Redis, LevelDB极高的读写性能,易于扩展查询能力有限,功能相对简单缓存,队列,会话存储
列式数据库Cassandra, HBase优秀的写入性能,高可扩展性实现复杂,学习曲线陡峭超大规模数据,时间序列数据

1.3 存储方案选择策略

选择合适的存储方案应考虑以下因素:

  1. 数据规模:小型项目(<1GB)可考虑文件存储,中型项目(1-100GB)适合单机数据库,大型项目(>100GB)需考虑分布式解决方案

  2. 数据结构

    • 结构化数据(表格型):关系型数据库或CSV
    • 半结构化数据(嵌套复杂):文档数据库或JSON
    • 非结构化数据(图片、文本):专用存储系统或文件系统
  3. 查询需求

    • 复杂关联查询:关系型数据库
    • 简单键值查询:键值存储
    • 全文检索:搜索引擎(如Elasticsearch)
  4. 性能要求

    • 高并发写入:键值存储、列式数据库
    • 快速读取:内存数据库、良好索引的关系型数据库
    • 批量处理:支持批处理的数据库或文件系统
  5. 开发资源

    • 团队熟悉度
    • 维护成本
    • 社区支持

1.4 数据存储与系统架构的关系

存储方案的选择应与整个爬虫系统的架构协调一致:

┌─────────────────┐      ┌───────────────────┐      ┌─────────────────┐
│                 │      │                   │      │                 │
│  数据采集模块   │─────▶│   数据存储系统    │─────▶│  数据分析模块   │
│                 │      │                   │      │                 │
└─────────────────┘      └───────────────────┘      └─────────────────┘
                                   │
                                   │
                         ┌─────────▼──────────┐
                         │                    │
                         │   数据导出/备份    │
                         │                    │
                         └────────────────────┘

在实际项目中,可能需要组合使用多种存储方案:

  • 使用Redis作为URL队列和临时缓存
  • 使用MongoDB存储原始爬取数据
  • 使用MySQL存储清洗后的结构化数据
  • 使用文件系统存储大型二进制数据(图片、文档等)

接下来,我们将依次介绍各种存储方案的具体实现方法。

二、文件系统存储实现

文件系统存储是最基础的数据持久化方式,适用于小型爬虫项目或原型开发。Go语言提供了丰富的文件操作API,可以轻松实现各种格式的文件存储。

2.1 CSV文件存储

CSV(逗号分隔值)是一种简单的表格数据格式,特别适合存储结构化、表格型数据。Go语言标准库提供了encoding/csv包用于CSV文件的读写操作。

基本写入示例
package main

import (
	"encoding/csv"
	"log"
	"os"
)

type Product struct {
	ID       string
	Name     string
	Price    string
	Category string
	URL      string
}

func main() {
	// 准备数据
	products := []Product{
		{ID: "1001", Name: "笔记本电脑", Price: "4999.00", Category: "电子产品", URL: "https://example.com/p/1001"},
		{ID: "1002", Name: "机械键盘", Price: "399.00", Category: "电脑配件", URL: "https://example.com/p/1002"},
		{ID: "1003", Name: "无线鼠标", Price: "129.00", Category: "电脑配件", URL: "https://example.com/p/1003"},
	}

	// 创建文件
	file, err := os.Create("products.csv")
	if err != nil {
		log.Fatalf("无法创建文件: %v", err)
	}
	defer file.Close()

	// 创建CSV写入器
	writer := csv.NewWriter(file)
	defer writer.Flush()

	// 写入表头
	header := []string{"ID", "Name", "Price", "Category", "URL"}
	if err := writer.Write(header); err != nil {
		log.Fatalf("写入表头失败: %v", err)
	}

	// 写入数据
	for _, product := range products {
		record := []string{
			product.ID,
			product.Name,
			product.Price,
			product.Category,
			product.URL,
		}
		if err := writer.Write(record); err != nil {
			log.Fatalf("写入记录失败: %v", err)
		}
	}

	log.Println("数据已成功写入 products.csv")
}
CSV数据读取
func readCSVFile(filename string) ([]Product, error) {
	// 打开文件
	file, err := os.Open(filename)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	// 创建CSV读取器
	reader := csv.NewReader(file)

	// 读取表头(如果存在)
	header, err := reader.Read()
	if err != nil {
		return nil, err
	}
	log.Printf("表头: %v", header)

	// 读取所有记录
	records, err := reader.ReadAll()
	if err != nil {
		return nil, err
	}

	// 转换为Product结构
	products := make([]Product, 0, len(records))
	for _, record := range records {
		if len(record) == 5 { // 确保记录有足够的字段
			product := Product{
				ID:       record[0],
				Name:     record[1],
				Price:    record[2],
				Category: record[3],
				URL:      record[4],
			}
			products = append(products, product)
		}
	}

	return products, nil
}
实用的CSV存储封装

为了更方便地使用CSV存储,我们可以创建一个简单的封装:

type CSVStorage struct {
	Filename  string
	Delimiter rune
	file      *os.File
	writer    *csv.Writer
}

func NewCSVStorage(filename string) (*CSVStorage, error) {
	file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return nil, err
	}

	writer := csv.NewWriter(file)
	writer.Comma = ',' // 默认分隔符

	return &CSVStorage{
		Filename:  filename,
		Delimiter: ',',
		file:      file,
		writer:    writer,
	}, nil
}

func (s *CSVStorage) SetDelimiter(delimiter rune) {
	s.Delimiter = delimiter
	s.writer.Comma = delimiter
}

func (s *CSVStorage) WriteHeader(header []string) error {
	return s.writer.Write(header)
}

func (s *CSVStorage) WriteRecord(record []string) error {
	return s.writer.Write(record)
}

func (s *CSVStorage) WriteRecords(records [][]string) error {
	return s.writer.WriteAll(records)
}

func (s *CSVStorage) Flush() {
	s.writer.Flush()
}

func (s *CSVStorage) Close() error {
	s.writer.Flush()
	return s.file.Close()
}

CSV存储的优缺点:

  • 优点:简单易用,兼容性极好,可直接导入Excel等工具
  • 缺点:不支持嵌套结构,不适合复杂数据类型,大文件处理性能较差

2.2 JSON文件存储

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,支持复杂的嵌套结构,非常适合存储爬虫采集的半结构化数据。Go语言标准库提供了encoding/json包用于JSON处理。

基本写入示例
package main

import (
	"encoding/json"
	"io/ioutil"
	"log"
	"os"
)

type Review struct {
	Content string
	Rating  int
	Author  string
	Date    string
}

type Product struct {
	ID          string
	Name        string
	Price       float64
	Description string
	Category    string
	URL         string
	Images      []string
	Specs       map[string]string
	Reviews     []Review
}

func main() {
	// 准备数据
	products := []Product{
		{
			ID:          "1001",
			Name:        "高性能笔记本电脑",
			Price:       4999.99,
			Description: "最新一代处理器,16GB内存,512GB固态硬盘",
			Category:    "电子产品",
			URL:         "https://example.com/p/1001",
			Images:      []string{"img1.jpg", "img2.jpg", "img3.jpg"},
			Specs: map[string]string{
				"处理器": "Intel Core i7",
				"内存":  "16GB DDR4",
				"存储":  "512GB SSD",
				"显卡":  "NVIDIA RTX 3060",
			},
			Reviews: []Review{
				{Content: "非常好用的笔记本", Rating: 5, Author: "用户A", Date: "2023-01-15"},
				{Content: "性价比很高", Rating: 4, Author: "用户B", Date: "2023-02-20"},
			},
		},
		// 可以添加更多产品...
	}

	// 转换为JSON
	jsonData, err := json.MarshalIndent(products, "", "  ")
	if err != nil {
		log.Fatalf("JSON编码失败: %v", err)
	}

	// 写入文件
	err = ioutil.WriteFile("products.json", jsonData, 0644)
	if err != nil {
		log.Fatalf("写入文件失败: %v", err)
	}

	log.Println("数据已成功写入 products.json")
}
JSON数据读取
func readJSONFile(filename string) ([]Product, error) {
	// 读取文件
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, err
	}

	// 解析JSON
	var products []Product
	err = json.Unmarshal(data, &products)
	if err != nil {
		return nil, err
	}

	return products, nil
}
流式JSON处理

对于大型JSON文件,可以使用流式处理避免一次性加载整个文件到内存:

func streamWriteJSON(filename string, products []Product) error {
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	// 创建JSON编码器
	encoder := json.NewEncoder(file)
	encoder.SetIndent("", "  ")

	// 写入JSON数组开始标记
	file.WriteString("[\n")

	// 依次写入每个产品
	for i, product := range products {
		// 编码单个产品
		productJSON, err := json.MarshalIndent(product, "  ", "  ")
		if err != nil {
			return err
		}

		// 写入产品JSON
		file.Write(productJSON)

		// 添加逗号,除非是最后一个元素
		if i < len(products)-1 {
			file.WriteString(",\n")
		} else {
			file.WriteString("\n")
		}
	}

	// 写入JSON数组结束标记
	file.WriteString("]\n")

	return nil
}

func streamReadJSON(filename string, callback func(product Product) error) error {
	file, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	// 创建JSON解码器
	decoder := json.NewDecoder(file)

	// 读取开始的数组标记
	_, err = decoder.Token()
	if err != nil {
		return err
	}

	// 循环解码每个产品
	for decoder.More() {
		var product Product
		err := decoder.Decode(&product)
		if err != nil {
			return err
		}

		// 调用回调函数处理产品
		err = callback(product)
		if err != nil {
			return err
		}
	}

	// 读取结束的数组标记
	_, err = decoder.Token()
	if err != nil {
		return err
	}

	return nil
}

JSON存储的优缺点:

  • 优点:格式灵活,支持复杂数据结构,可读性好,语言无关
  • 缺点:相比二进制格式占用空间大,解析性能一般,不支持局部更新

2.3 XML文件存储

XML(可扩展标记语言)是一种结构严格的标记语言,支持复杂的层次结构和数据验证。Go语言标准库提供了encoding/xml包用于XML处理。

基本写入示例
package main

import (
	"encoding/xml"
	"io/ioutil"
	"log"
	"os"
)

// 定义XML结构
type Image struct {
	URL  string `xml:"url,attr"`
	Type string `xml:"type,attr,omitempty"`
}

type Specification struct {
	Name  string `xml:"name,attr"`
	Value string `xml:",chardata"`
}

type Review struct {
	Author  string `xml:"author"`
	Rating  int    `xml:"rating"`
	Content string `xml:"content"`
	Date    string `xml:"date"`
}

type Product struct {
	XMLName      xml.Name       `xml:"product"`
	ID           string         `xml:"id,attr"`
	Name         string         `xml:"name"`
	Price        float64        `xml:"price"`
	Description  string         `xml:"description"`
	Category     string         `xml:"category"`
	URL          string         `xml:"url"`
	Images       []Image        `xml:"images>image"`
	Specs        []Specification `xml:"specifications>spec"`
	Reviews      []Review       `xml:"reviews>review"`
}

type ProductCatalog struct {
	XMLName  xml.Name  `xml:"catalog"`
	Products []Product `xml:"product"`
}

func main() {
	// 准备数据
	catalog := ProductCatalog{
		Products: []Product{
			{
				ID:          "1001",
				Name:        "高性能笔记本电脑",
				Price:       4999.99,
				Description: "最新一代处理器,16GB内存,512GB固态硬盘",
				Category:    "电子产品",
				URL:         "https://example.com/p/1001",
				Images: []Image{
					{URL: "https://example.com/img/1001-1.jpg"},
					{URL: "https://example.com/img/1001-2.jpg", Type: "detail"},
				},
				Specs: []Specification{
					{Name: "处理器", Value: "Intel Core i7"},
					{Name: "内存", Value: "16GB DDR4"},
					{Name: "存储", Value: "512GB SSD"},
				},
				Reviews: []Review{
					{Content: "非常好用的笔记本", Rating: 5, Author: "用户A", Date: "2023-01-15"},
					{Content: "性价比很高", Rating: 4, Author: "用户B", Date: "2023-02-20"},
				},
			},
			// 可以添加更多产品...
		},
	}

	// 创建文件
	file, err := os.Create("products.xml")
	if err != nil {
		log.Fatalf("无法创建文件: %v", err)
	}
	defer file.Close()

	// 写入XML头
	file.WriteString(xml.Header)

	// 创建编码器
	encoder := xml.NewEncoder(file)
	encoder.Indent("", "  ")

	// 编码并写入
	if err := encoder.Encode(catalog); err != nil {
		log.Fatalf("XML编码失败: %v", err)
	}

	log.Println("数据已成功写入 products.xml")
}
XML数据读取
func readXMLFile(filename string) (*ProductCatalog, error) {
	// 读取文件
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, err
	}

	// 解析XML
	var catalog ProductCatalog
	err = xml.Unmarshal(data, &catalog)
	if err != nil {
		return nil, err
	}

	return &catalog, nil
}

XML存储的优缺点:

  • 优点:结构严格,支持命名空间和验证,适合EDI等正式场合
  • 缺点:文件体积大,解析开销高,配置复杂,近年来逐渐被JSON取代

三、关系型数据库存储(MySQL)

关系型数据库是存储结构化数据的经典解决方案,提供了事务支持、参照完整性和复杂查询能力。在爬虫系统中,关系型数据库特别适合存储经过清洗和结构化的数据。

3.1 MySQL数据库设计原则

在爬虫项目中使用MySQL等关系型数据库时,应遵循以下设计原则:

  1. 合理设计表结构

    • 适当拆分表,避免过多无关字段
    • 为大字段(如文章内容)单独建表
    • 使用适当的字段类型和长度
  2. 建立合适的索引

    • 对频繁查询的字段建立索引
    • 避免过度索引影响写入性能
    • 考虑复合索引优化复杂查询
  3. 处理重复数据

    • 使用唯一键约束防止重复数据
    • 实现"更新或插入"(UPSERT)逻辑
  4. 批量操作

    • 使用事务和批量插入提高性能
    • 控制每批次的记录数量

3.2 连接MySQL数据库

Go语言中连接MySQL主要使用database/sql包和MySQL驱动。最常用的MySQL驱动是go-sql-driver/mysql

package main

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

func main() {
	// 配置DSN (Data Source Name)
	// 格式: [username[:password]@][protocol[(address)]]/dbname[?param=value]
	dsn := "root:password@tcp(127.0.0.1:3306)/crawler?charset=utf8mb4&parseTime=True&loc=Local"
	
	// 打开数据库连接
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatalf("数据库连接失败: %v", err)
	}
	defer db.Close()
	
	// 设置连接池参数
	db.SetMaxOpenConns(100)        // 最大连接数
	db.SetMaxIdleConns(20)         // 最大空闲连接数
	db.SetConnMaxLifetime(time.Hour) // 连接最大生命周期
	
	// 测试连接
	if err := db.Ping(); err != nil {
		log.Fatalf("数据库Ping失败: %v", err)
	}
	
	fmt.Println("成功连接到MySQL数据库")
}

3.3 创建数据表

在存储爬虫数据前,需要先创建相应的数据表:

func setupDatabase(db *sql.DB) error {
	// 创建产品表
	productTable := `
	CREATE TABLE IF NOT EXISTS products (
		id VARCHAR(50) PRIMARY KEY,
		name VARCHAR(200) NOT NULL,
		price DECIMAL(10,2) NOT NULL,
		category VARCHAR(100),
		url VARCHAR(500) UNIQUE,
		description TEXT,
		created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
		updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
	) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
	`
	
	_, err := db.Exec(productTable)
	if err != nil {
		return err
	}
	
	// 创建产品图片表
	imageTable := `
	CREATE TABLE IF NOT EXISTS product_images (
		id INT AUTO_INCREMENT PRIMARY KEY,
		product_id VARCHAR(50),
		image_url VARCHAR(500) NOT NULL,
		image_type VARCHAR(50),
		FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
		UNIQUE KEY unique_product_image (product_id, image_url)
	) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
	`
	
	_, err = db.Exec(imageTable)
	if err != nil {
		return err
	}
	
	// 创建产品规格表
	specTable := `
	CREATE TABLE IF NOT EXISTS product_specs (
		id INT AUTO_INCREMENT PRIMARY KEY,
		product_id VARCHAR(50),
		spec_name VARCHAR(100) NOT NULL,
		spec_value TEXT,
		FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
		UNIQUE KEY unique_product_spec (product_id, spec_name)
	) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
	`
	
	_, err = db.Exec(specTable)
	if err != nil {
		return err
	}
	
	// 创建产品评论表
	reviewTable := `
	CREATE TABLE IF NOT EXISTS product_reviews (
		id INT AUTO_INCREMENT PRIMARY KEY,
		product_id VARCHAR(50),
		content TEXT,
		rating INT,
		author VARCHAR(100),
		review_date VARCHAR(50),
		created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
		FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
	) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
	`
	
	_, err = db.Exec(reviewTable)
	return err
}

3.4 数据插入与更新

在爬虫中,需要实现高效的数据插入与更新操作,同时处理重复数据问题。

基本插入
func insertProduct(db *sql.DB, product Product) error {
	// 开始事务
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	
	// 声明延迟提交或回滚
	defer func() {
		if err != nil {
			tx.Rollback()
			return
		}
		err = tx.Commit()
	}()
	
	// 插入产品基本信息
	_, err = tx.Exec(`
		INSERT INTO products (id, name, price, category, url, description)
		VALUES (?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			name = VALUES(name),
			price = VALUES(price),
			category = VALUES(category),
			description = VALUES(description)
	`, product.ID, product.Name, product.Price, product.Category, product.URL, product.Description)
	
	if err != nil {
		return err
	}
	
	// 插入产品图片
	for _, img := range product.Images {
		_, err = tx.Exec(`
			INSERT INTO product_images (product_id, image_url, image_type)
			VALUES (?, ?, ?)
			ON DUPLICATE KEY UPDATE
				image_type = VALUES(image_type)
		`, product.ID, img.URL, img.Type)
		
		if err != nil {
			return err
		}
	}
	
	// 插入产品规格
	for _, spec := range product.Specs {
		_, err = tx.Exec(`
			INSERT INTO product_specs (product_id, spec_name, spec_value)
			VALUES (?, ?, ?)
			ON DUPLICATE KEY UPDATE
				spec_value = VALUES(spec_value)
		`, product.ID, spec.Name, spec.Value)
		
		if err != nil {
			return err
		}
	}
	
	// 插入产品评论
	for _, review := range product.Reviews {
		_, err = tx.Exec(`
			INSERT INTO product_reviews (product_id, content, rating, author, review_date)
			VALUES (?, ?, ?, ?, ?)
		`, product.ID, review.Content, review.Rating, review.Author, review.Date)
		
		if err != nil {
			return err
		}
	}
	
	return nil
}
批量插入优化

对于大量数据插入,可以使用批量插入提高性能:

func batchInsertProducts(db *sql.DB, products []Product, batchSize int) error {
	// 开始事务
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	
	// 声明延迟提交或回滚
	defer func() {
		if err != nil {
			tx.Rollback()
			return
		}
		err = tx.Commit()
	}()
	
	// 准备批量插入产品的语句
	stmt, err := tx.Prepare(`
		INSERT INTO products (id, name, price, category, url, description)
		VALUES (?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			name = VALUES(name),
			price = VALUES(price),
			category = VALUES(category),
			description = VALUES(description)
	`)
	if err != nil {
		return err
	}
	defer stmt.Close()
	
	// 批量插入产品
	for i, product := range products {
		_, err = stmt.Exec(
			product.ID, product.Name, product.Price, 
			product.Category, product.URL, product.Description,
		)
		if err != nil {
			return err
		}
		
		// 每到一批的大小就提交一次事务
		if (i+1) % batchSize == 0 || i == len(products)-1 {
			if err = tx.Commit(); err != nil {
				return err
			}
			
			// 开始新事务
			tx, err = db.Begin()
			if err != nil {
				return err
			}
			
			// 准备新的语句
			stmt, err = tx.Prepare(`
				INSERT INTO products (id, name, price, category, url, description)
				VALUES (?, ?, ?, ?, ?, ?)
				ON DUPLICATE KEY UPDATE
					name = VALUES(name),
					price = VALUES(price),
					category = VALUES(category),
					description = VALUES(description)
			`)
			if err != nil {
				return err
			}
		}
	}
	
	// 最后一批不足batchSize的数据会在defer中的tx.Commit()提交
	return nil
}

3.5 数据查询

爬虫系统经常需要查询已爬取的数据,以下是一些常用查询操作:

// 根据ID查询完整产品信息
func getProductByID(db *sql.DB, productID string) (*Product, error) {
	// 查询产品基本信息
	product := &Product{ID: productID}
	
	err := db.QueryRow(`
		SELECT name, price, category, url, description 
		FROM products 
		WHERE id = ?
	`, productID).Scan(&product.Name, &product.Price, &product.Category, &product.URL, &product.Description)
	
	if err != nil {
		return nil, err
	}
	
	// 查询产品图片
	rows, err := db.Query(`
		SELECT image_url, image_type 
		FROM product_images 
		WHERE product_id = ?
	`, productID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	
	for rows.Next() {
		var img Image
		if err := rows.Scan(&img.URL, &img.Type); err != nil {
			return nil, err
		}
		product.Images = append(product.Images, img)
	}
	
	// 查询产品规格
	rows, err = db.Query(`
		SELECT spec_name, spec_value 
		FROM product_specs 
		WHERE product_id = ?
	`, productID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	
	product.Specs = make([]Specification, 0)
	for rows.Next() {
		var spec Specification
		if err := rows.Scan(&spec.Name, &spec.Value); err != nil {
			return nil, err
		}
		product.Specs = append(product.Specs, spec)
	}
	
	// 查询产品评论
	rows, err = db.Query(`
		SELECT content, rating, author, review_date 
		FROM product_reviews 
		WHERE product_id = ?
	`, productID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	
	for rows.Next() {
		var review Review
		if err := rows.Scan(&review.Content, &review.Rating, &review.Author, &review.Date); err != nil {
			return nil, err
		}
		product.Reviews = append(product.Reviews, review)
	}
	
	return product, nil
}

// 查询产品列表
func listProducts(db *sql.DB, category string, limit, offset int) ([]Product, error) {
	query := `
		SELECT id, name, price, category, url 
		FROM products 
		WHERE 1=1 
	`
	args := []interface{}{}
	
	if category != "" {
		query += "AND category = ? "
		args = append(args, category)
	}
	
	query += "ORDER BY updated_at DESC LIMIT ? OFFSET ?"
	args = append(args, limit, offset)
	
	rows, err := db.Query(query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	
	products := []Product{}
	for rows.Next() {
		var p Product
		if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Category, &p.URL); err != nil {
			return nil, err
		}
		products = append(products, p)
	}
	
	return products, nil
}

3.6 使用ORM简化数据库操作

对于复杂的数据库操作,使用ORM(对象关系映射)可以简化开发。Go语言中流行的ORM包括GORM、XORM等。以下是使用GORM的示例:

package main

import (
	"log"
	"time"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

// 定义模型
type Product struct {
	ID          string `gorm:"primaryKey"`
	Name        string `gorm:"size:200;not null"`
	Price       float64
	Category    string
	URL         string `gorm:"size:500;uniqueIndex"`
	Description string `gorm:"type:text"`
	CreatedAt   time.Time
	UpdatedAt   time.Time
	Images      []ProductImage `gorm:"foreignKey:ProductID"`
	Specs       []ProductSpec  `gorm:"foreignKey:ProductID"`
	Reviews     []ProductReview `gorm:"foreignKey:ProductID"`
}

type ProductImage struct {
	ID        uint   `gorm:"primaryKey"`
	ProductID string
	ImageURL  string `gorm:"size:500;not null"`
	ImageType string
}

type ProductSpec struct {
	ID        uint   `gorm:"primaryKey"`
	ProductID string
	Name      string `gorm:"size:100;not null"`
	Value     string `gorm:"type:text"`
}

type ProductReview struct {
	ID         uint   `gorm:"primaryKey"`
	ProductID  string
	Content    string `gorm:"type:text"`
	Rating     int
	Author     string
	ReviewDate string
	CreatedAt  time.Time
}

func main() {
	// 连接数据库
	dsn := "root:password@tcp(127.0.0.1:3306)/crawler?charset=utf8mb4&parseTime=True&loc=Local"
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info),
	})
	if err != nil {
		log.Fatalf("连接数据库失败: %v", err)
	}
	
	// 自动迁移schema
	db.AutoMigrate(&Product{}, &ProductImage{}, &ProductSpec{}, &ProductReview{})
	
	// 创建产品
	product := Product{
		ID:          "1001",
		Name:        "高性能笔记本电脑",
		Price:       4999.99,
		Category:    "电子产品",
		URL:         "https://example.com/p/1001",
		Description: "最新一代处理器,16GB内存,512GB固态硬盘",
		Images: []ProductImage{
			{ImageURL: "https://example.com/img/1001-1.jpg"},
			{ImageURL: "https://example.com/img/1001-2.jpg", ImageType: "detail"},
		},
		Specs: []ProductSpec{
			{Name: "处理器", Value: "Intel Core i7"},
			{Name: "内存", Value: "16GB DDR4"},
			{Name: "存储", Value: "512GB SSD"},
		},
		Reviews: []ProductReview{
			{Content: "非常好用的笔记本", Rating: 5, Author: "用户A", ReviewDate: "2023-01-15"},
		},
	}
	
	// 保存产品(包括关联数据)
	result := db.Create(&product)
	if result.Error != nil {
		log.Fatalf("创建产品失败: %v", result.Error)
	}
	
	log.Printf("成功创建产品, ID: %s, 影响行数: %d\n", product.ID, result.RowsAffected)
	
	// 更新产品
	db.Model(&product).Updates(map[string]interface{}{
		"price": 4899.99,
		"description": "最新一代处理器,16GB内存,512GB固态硬盘,限时优惠",
	})
	
	// 查询产品
	var retrievedProduct Product
	db.Preload("Images").Preload("Specs").Preload("Reviews").First(&retrievedProduct, "id = ?", "1001")
	
	log.Printf("查询到产品: %s, 价格: %.2f, 图片数: %d, 规格数: %d, 评论数: %d\n",
		retrievedProduct.Name,
		retrievedProduct.Price,
		len(retrievedProduct.Images),
		len(retrievedProduct.Specs),
		len(retrievedProduct.Reviews),
	)
}

MySQL等关系型数据库的优缺点:

  • 优点:强大的事务支持,数据一致性保证,丰富的查询语言,成熟稳定
  • 缺点:处理海量数据时扩展性受限,不适合非结构化或频繁变化的数据结构

四、NoSQL数据库存储(MongoDB)

NoSQL数据库特别适合存储结构多变的爬虫原始数据。其中,MongoDB作为文档型数据库的代表,能够以JSON形式存储复杂的嵌套文档,非常适合爬虫数据存储。

4.1 MongoDB数据模型设计

在爬虫系统中使用MongoDB时,应考虑以下设计原则:

  1. 尽量使用嵌入式文档

    • 将相关数据嵌入单个文档,减少查询次数
    • 对于一对多关系,根据数据量决定嵌入还是引用
  2. 合理设计字段

    • 使用有意义的字段名
    • 添加元数据字段(如爬取时间、来源等)
    • 设置TTL(生存时间)适时清理过期数据
  3. 为查询场景优化索引

    • 为常用查询字段创建索引
    • 使用复合索引优化多字段查询
    • 考虑文本索引支持全文搜索

4.2 连接MongoDB

Go语言中连接MongoDB主要使用官方驱动mongo-go-driver

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
	"go.mongodb.org/mongo-driver/mongo/readpref"
)

func main() {
	// 设置客户端选项
	clientOptions := options.Client().
		ApplyURI("mongodb://localhost:27017").
		SetMaxPoolSize(20).
		SetConnectTimeout(5 * time.Second)
	
	// 连接MongoDB
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	
	client, err := mongo.Connect(ctx, clientOptions)
	if err != nil {
		log.Fatalf("连接MongoDB失败: %v", err)
	}
	
	// 确保在函数退出时断开连接
	defer func() {
		if err = client.Disconnect(ctx); err != nil {
			log.Fatalf("断开MongoDB连接失败: %v", err)
		}
	}()
	
	// 检查连接
	err = client.Ping(ctx, readpref.Primary())
	if err != nil {
		log.Fatalf("MongoDB Ping失败: %v", err)
	}
	
	fmt.Println("成功连接到MongoDB")
	
	// 获取数据库和集合
	db := client.Database("crawler")
	collection := db.Collection("products")
	
	fmt.Printf("准备好操作集合: %s\n", collection.Name())
}

4.3 数据存储与更新

MongoDB非常适合存储爬虫采集的原始数据,特别是结构复杂或不固定的数据。

基本插入与更新
// 定义产品文档结构
type ProductDocument struct {
	ID          string           `bson:"_id"`
	Name        string           `bson:"name"`
	Price       float64          `bson:"price"`
	Category    string           `bson:"category"`
	URL         string           `bson:"url"`
	Description string           `bson:"description"`
	Images      []ImageDocument  `bson:"images"`
	Specs       []SpecDocument   `bson:"specs"`
	Reviews     []ReviewDocument `bson:"reviews"`
	MetaData    MetaData         `bson:"metadata"`
}

type ImageDocument struct {
	URL  string `bson:"url"`
	Type string `bson:"type,omitempty"`
}

type SpecDocument struct {
	Name  string `bson:"name"`
	Value string `bson:"value"`
}

type ReviewDocument struct {
	Content    string    `bson:"content"`
	Rating     int       `bson:"rating"`
	Author     string    `bson:"author"`
	ReviewDate string    `bson:"review_date"`
	CreatedAt  time.Time `bson:"created_at"`
}

type MetaData struct {
	CreatedAt      time.Time `bson:"created_at"`
	UpdatedAt      time.Time `bson:"updated_at"`
	Source         string    `bson:"source"`
	CrawlerVersion string    `bson:"crawler_version"`
}

// 插入产品数据
func insertProductDocument(collection *mongo.Collection, product ProductDocument) error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	
	// 设置元数据
	now := time.Now()
	product.MetaData.CreatedAt = now
	product.MetaData.UpdatedAt = now
	
	// 插入文档
	_, err := collection.InsertOne(ctx, product)
	return err
}

// 更新产品数据
func updateProductDocument(collection *mongo.Collection, product ProductDocument) error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	
	// 更新元数据
	product.MetaData.UpdatedAt = time.Now()
	
	// 构建更新操作
	filter := bson.M{"_id": product.ID}
	update := bson.M{"$set": product}
	
	// 使用upsert选项
	opts := options.Update().SetUpsert(true)
	
	// 执行更新
	_, err := collection.UpdateOne(ctx, filter, update, opts)
	return err
}
批量插入

对于爬取大量数据的场景,批量插入可以显著提高性能:

func batchInsertProducts(collection *mongo.Collection, products []ProductDocument) error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	
	// 准备批量插入的文档
	docs := make([]interface{}, len(products))
	now := time.Now()
	
	for i, product := range products {
		// 设置元数据
		product.MetaData.CreatedAt = now
		product.MetaData.UpdatedAt = now
		docs[i] = product
	}
	
	// 执行批量插入
	_, err := collection.InsertMany(ctx, docs)
	return err
}

// 使用批量写操作处理混合的插入和更新
func bulkWriteProducts(collection *mongo.Collection, products []ProductDocument) error {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	
	// 准备批量写操作
	var models []mongo.WriteModel
	now := time.Now()
	
	for _, product := range products {
		// 设置元数据
		product.MetaData.UpdatedAt = now
		
		model := mongo.NewReplaceOneModel().
			SetFilter(bson.M{"_id": product.ID}).
			SetReplacement(product).
			SetUpsert(true)
		
		models = append(models, model)
	}
	
	// 执行批量写操作
	opts := options.BulkWrite().SetOrdered(false)
	_, err := collection.BulkWrite(ctx, models, opts)
	return err
}

4.4 数据查询

MongoDB提供了灵活的查询语言,支持复杂查询条件、聚合管道等高级功能。

基本查询
// 根据ID查询产品
func getProductByID(collection *mongo.Collection, productID string) (*ProductDocument, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	
	var product ProductDocument
	err := collection.FindOne(ctx, bson.M{"_id": productID}).Decode(&product)
	if err != nil {
		return nil, err
	}
	
	return &product, nil
}

// 查询产品列表
func listProducts(collection *mongo.Collection, category string, limit, skip int64) ([]ProductDocument, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	
	// 构建查询条件
	filter := bson.M{}
	if category != "" {
		filter["category"] = category
	}
	
	// 设置查询选项
	opts := options.Find().
		SetSort(bson.M{"metadata.updated_at": -1}).
		SetLimit(limit).
		SetSkip(skip)
	
	// 执行查询
	cursor, err := collection.Find(ctx, filter, opts)
	if err != nil {
		return nil, err
	}
	defer cursor.Close(ctx)
	
	// 解码结果
	var products []ProductDocument
	if err = cursor.All(ctx, &products); err != nil {
		return nil, err
	}
	
	return products, nil
}
高级查询与聚合

MongoDB强大的聚合管道功能适合数据分析和统计:

// 按分类统计产品数量和平均价格
func aggregateProductStats(collection *mongo.Collection) ([]bson.M, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	
	// 定义聚合管道
	pipeline := mongo.Pipeline{
		// 按分类分组
		bson.D{{"$group", bson.D{
			{"_id", "$category"},
			{"count", bson.D{{"$sum", 1}}},
			{"avgPrice", bson.D{{"$avg", "$price"}}},
			{"minPrice", bson.D{{"$min", "$price"}}},
			{"maxPrice", bson.D{{"$max", "$price"}}},
		}}},
		// 按计数降序排序
		bson.D{{"$sort", bson.D{{"count", -1}}}},
	}
	
	// 执行聚合
	cursor, err := collection.Aggregate(ctx, pipeline)
	if err != nil {
		return nil, err
	}
	defer cursor.Close(ctx)
	
	// 解码结果
	var results []bson.M
	if err = cursor.All(ctx, &results); err != nil {
		return nil, err
	}
	
	return results, nil
}

// 查找评分高的产品
func findHighRatedProducts(collection *mongo.Collection, minRating float64) ([]ProductDocument, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	
	// 定义聚合管道
	pipeline := mongo.Pipeline{
		// 展开评论数组
		bson.D{{"$unwind", "$reviews"}},
		// 按产品ID分组并计算平均评分
		bson.D{{"$group", bson.D{
			{"_id", "$_id"},
			{"avgRating", bson.D{{"$avg", "$reviews.rating"}}},
			{"product", bson.D{{"$first", "$$ROOT"}}},
		}}},
		// 筛选高评分产品
		bson.D{{"$match", bson.D{{"avgRating", bson.D{{"$gte", minRating}}}}}},
		// 按平均评分降序排序
		bson.D{{"$sort", bson.D{{"avgRating", -1}}}},
		// 提取产品信息
		bson.D{{"$replaceRoot", bson.D{{"newRoot", "$product"}}}},
	}
	
	// 执行聚合
	cursor, err := collection.Aggregate(ctx, pipeline)
	if err != nil {
		return nil, err
	}
	defer cursor.Close(ctx)
	
	// 解码结果
	var products []ProductDocument
	if err = cursor.All(ctx, &products); err != nil {
		return nil, err
	}
	
	return products, nil
}

MongoDB的优缺点:

  • 优点:灵活的文档模型,优秀的水平扩展能力,适合频繁变化的数据结构,丰富的查询和聚合功能
  • 缺点:事务支持有限(4.0以前),索引占用空间大,复杂查询性能不如关系型数据库

五、数据导出与格式转换

爬虫数据采集后,经常需要将数据导出为特定格式,用于分析、报告或其他系统集成。

5.1 常见数据导出需求

爬虫系统中的数据导出通常有以下几种场景:

  1. 数据报表导出:生成CSV/Excel报表供业务人员使用
  2. 数据迁移导出:从一个存储系统导出到另一个系统
  3. 数据共享导出:生成标准格式文件供第三方系统使用
  4. 数据备份导出:定期备份爬取的数据

5.2 从数据库导出到CSV

从MySQL导出到CSV
func exportProductsToCSV(db *sql.DB, filename string) error {
	// 创建CSV文件
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()
	
	// 创建CSV写入器
	writer := csv.NewWriter(file)
	defer writer.Flush()
	
	// 写入表头
	header := []string{"ID", "Name", "Price", "Category", "URL", "Description"}
	if err := writer.Write(header); err != nil {
		return err
	}
	
	// 查询产品数据
	rows, err := db.Query(`
		SELECT id, name, price, category, url, description 
		FROM products
	`)
	if err != nil {
		return err
	}
	defer rows.Close()
	
	// 逐行写入CSV
	for rows.Next() {
		var id, name, category, url, description string
		var price float64
		
		if err := rows.Scan(&id, &name, &price, &category, &url, &description); err != nil {
			return err
		}
		
		record := []string{
			id,
			name,
			fmt.Sprintf("%.2f", price),
			category,
			url,
			// 处理可能包含换行符的字段
			strings.ReplaceAll(description, "\n", " "),
		}
		
		if err := writer.Write(record); err != nil {
			return err
		}
	}
	
	return rows.Err()
}
从MongoDB导出到CSV
func exportMongoProductsToCSV(collection *mongo.Collection, filename string) error {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	
	// 创建CSV文件
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()
	
	// 创建CSV写入器
	writer := csv.NewWriter(file)
	defer writer.Flush()
	
	// 写入表头
	header := []string{"ID", "Name", "Price", "Category", "URL", "Description", "Image Count", "Avg Rating"}
	if err := writer.Write(header); err != nil {
		return err
	}
	
	// 定义聚合管道,计算每个产品的平均评分
	pipeline := mongo.Pipeline{
		// 添加字段:图片数量和平均评分
		bson.D{{"$addFields", bson.D{
			{"imageCount", bson.D{{"$size", "$images"}}},
			{"avgRating", bson.D{
				{"$cond", bson.A{
					bson.D{{"$gt", bson.A{bson.D{{"$size", "$reviews"}}, 0}}},
					bson.D{{"$avg", "$reviews.rating"}},
					0,
				}},
			}},
		}}},
		// 投影需要的字段
		bson.D{{"$project", bson.D{
			{"_id", 1},
			{"name", 1},
			{"price", 1},
			{"category", 1},
			{"url", 1},
			{"description", 1},
			{"imageCount", 1},
			{"avgRating", 1},
		}}},
	}
	
	// 执行聚合查询
	cursor, err := collection.Aggregate(ctx, pipeline)
	if err != nil {
		return err
	}
	defer cursor.Close(ctx)
	
	// 逐个文档处理
	for cursor.Next(ctx) {
		var result bson.M
		if err := cursor.Decode(&result); err != nil {
			return err
		}
		
		// 提取字段,处理可能的空值
		id := getStringValue(result, "_id", "")
		name := getStringValue(result, "name", "")
		price := getFloatValue(result, "price", 0)
		category := getStringValue(result, "category", "")
		url := getStringValue(result, "url", "")
		description := getStringValue(result, "description", "")
		imageCount := getIntValue(result, "imageCount", 0)
		avgRating := getFloatValue(result, "avgRating", 0)
		
		// 写入CSV记录
		record := []string{
			id,
			name,
			fmt.Sprintf("%.2f", price),
			category,
			url,
			strings.ReplaceAll(description, "\n", " "),
			fmt.Sprintf("%d", imageCount),
			fmt.Sprintf("%.1f", avgRating),
		}
		
		if err := writer.Write(record); err != nil {
			return err
		}
	}
	
	return cursor.Err()
}

// 辅助函数:安全地获取字符串字段
func getStringValue(m bson.M, key, defaultVal string) string {
	if val, ok := m[key]; ok && val != nil {
		if strVal, ok := val.(string); ok {
			return strVal
		}
	}
	return defaultVal
}

// 辅助函数:安全地获取浮点数字段
func getFloatValue(m bson.M, key string, defaultVal float64) float64 {
	if val, ok := m[key]; ok && val != nil {
		switch v := val.(type) {
		case float64:
			return v
		case float32:
			return float64(v)
		case int:
			return float64(v)
		case int32:
			return float64(v)
		case int64:
			return float64(v)
		}
	}
	return defaultVal
}

// 辅助函数:安全地获取整数字段
func getIntValue(m bson.M, key string, defaultVal int) int {
	if val, ok := m[key]; ok && val != nil {
		switch v := val.(type) {
		case int:
			return v
		case int32:
			return int(v)
		case int64:
			return int(v)
		case float64:
			return int(v)
		case float32:
			return int(v)
		}
	}
	return defaultVal
}

5.3 生成Excel报表

Excel文件比CSV更丰富,支持多个工作表、格式化、公式等。Go语言中可以使用excelize库生成Excel文件:

package main

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	"github.com/xuri/excelize/v2"
	_ "github.com/go-sql-driver/mysql"
)

func exportProductsToExcel(db *sql.DB, filename string) error {
	// 创建Excel文件
	f := excelize.NewFile()
	
	// 设置工作表名称
	sheetName := "Products"
	index, err := f.NewSheet(sheetName)
	if err != nil {
		return err
	}
	f.SetActiveSheet(index)
	
	// 设置表头样式
	headerStyle, err := f.NewStyle(&excelize.Style{
		Font: &excelize.Font{
			Bold:   true,
			Size:   12,
			Color:  "#FFFFFF",
		},
		Fill: excelize.Fill{
			Type:    "pattern",
			Color:   []string{"#4472C4"},
			Pattern: 1,
		},
		Alignment: &excelize.Alignment{
			Horizontal: "center",
			Vertical:   "center",
		},
		Border: []excelize.Border{
			{Type: "top", Color: "#000000", Style: 1},
			{Type: "bottom", Color: "#000000", Style: 1},
			{Type: "left", Color: "#000000", Style: 1},
			{Type: "right", Color: "#000000", Style: 1},
		},
	})
	if err != nil {
		return err
	}
	
	// 设置数据样式
	dataStyle, err := f.NewStyle(&excelize.Style{
		Border: []excelize.Border{
			{Type: "top", Color: "#D9D9D9", Style: 1},
			{Type: "bottom", Color: "#D9D9D9", Style: 1},
			{Type: "left", Color: "#D9D9D9", Style: 1},
			{Type: "right", Color: "#D9D9D9", Style: 1},
		},
	})
	if err != nil {
		return err
	}
	
	// 写入表头
	headers := []string{"ID", "产品名称", "价格", "分类", "URL", "描述", "更新时间"}
	for i, header := range headers {
		cell := fmt.Sprintf("%c%d", 'A'+i, 1)
		f.SetCellValue(sheetName, cell, header)
	}
	
	// 应用表头样式
	f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%c1", 'A'+len(headers)-1), headerStyle)
	
	// 设置列宽
	f.SetColWidth(sheetName, "A", "A", 15)
	f.SetColWidth(sheetName, "B", "B", 40)
	f.SetColWidth(sheetName, "C", "C", 12)
	f.SetColWidth(sheetName, "D", "D", 15)
	f.SetColWidth(sheetName, "E", "E", 50)
	f.SetColWidth(sheetName, "F", "F", 60)
	f.SetColWidth(sheetName, "G", "G", 20)
	
	// 查询产品数据
	rows, err := db.Query(`
		SELECT id, name, price, category, url, description, updated_at 
		FROM products
		ORDER BY updated_at DESC
	`)
	if err != nil {
		return err
	}
	defer rows.Close()
	
	// 逐行写入Excel
	rowIndex := 2 // 从第2行开始写入数据
	for rows.Next() {
		var id, name, category, url, description string
		var price float64
		var updatedAt time.Time
		
		if err := rows.Scan(&id, &name, &price, &category, &url, &description, &updatedAt); err != nil {
			return err
		}
		
		// 写入单元格
		f.SetCellValue(sheetName, fmt.Sprintf("A%d", rowIndex), id)
		f.SetCellValue(sheetName, fmt.Sprintf("B%d", rowIndex), name)
		f.SetCellValue(sheetName, fmt.Sprintf("C%d", rowIndex), price)
		f.SetCellValue(sheetName, fmt.Sprintf("D%d", rowIndex), category)
		f.SetCellValue(sheetName, fmt.Sprintf("E%d", rowIndex), url)
		f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowIndex), description)
		f.SetCellValue(sheetName, fmt.Sprintf("G%d", rowIndex), updatedAt.Format("2006-01-02 15:04:05"))
		
		// 应用数据样式
		f.SetCellStyle(sheetName, fmt.Sprintf("A%d", rowIndex), fmt.Sprintf("%c%d", 'A'+len(headers)-1, rowIndex), dataStyle)
		
		rowIndex++
	}
	
	// 保存文件
	if err := f.SaveAs(filename); err != nil {
		return err
	}
	
	return nil
}

5.4 数据格式转换

JSON到CSV转换
func convertJSONToCSV(jsonFilePath, csvFilePath string, keyMap map[string]string) error {
	// 读取JSON文件
	jsonData, err := ioutil.ReadFile(jsonFilePath)
	if err != nil {
		return err
	}
	
	// 解析JSON
	var data []map[string]interface{}
	if err := json.Unmarshal(jsonData, &data); err != nil {
		return err
	}
	
	// 创建CSV文件
	file, err := os.Create(csvFilePath)
	if err != nil {
		return err
	}
	defer file.Close()
	
	// 创建CSV写入器
	writer := csv.NewWriter(file)
	defer writer.Flush()
	
	// 提取表头
	var headers []string
	for _, v := range keyMap {
		headers = append(headers, v)
	}
	
	// 写入表头
	if err := writer.Write(headers); err != nil {
		return err
	}
	
	// 写入数据行
	for _, item := range data {
		var row []string
		for k, _ := range keyMap {
			// 提取字段值,处理可能的嵌套
			value := extractNestedValue(item, k)
			row = append(row, fmt.Sprintf("%v", value))
		}
		if err := writer.Write(row); err != nil {
			return err
		}
	}
	
	return nil
}

// 辅助函数:从嵌套结构中提取值
func extractNestedValue(data map[string]interface{}, path string) interface{} {
	parts := strings.Split(path, ".")
	current := data
	
	for i, part := range parts {
		if i == len(parts)-1 {
			return current[part]
		}
		
		if nextMap, ok := current[part].(map[string]interface{}); ok {
			current = nextMap
		} else {
			return nil
		}
	}
	
	return nil
}
CSV到JSON转换
func convertCSVToJSON(csvFilePath, jsonFilePath string) error {
	// 打开CSV文件
	file, err := os.Open(csvFilePath)
	if err != nil {
		return err
	}
	defer file.Close()
	
	// 创建CSV读取器
	reader := csv.NewReader(file)
	
	// 读取表头
	headers, err := reader.Read()
	if err != nil {
		return err
	}
	
	// 读取所有记录
	records, err := reader.ReadAll()
	if err != nil {
		return err
	}
	
	// 转换为JSON格式
	var jsonData []map[string]string
	for _, record := range records {
		item := make(map[string]string)
		for i, value := range record {
			if i < len(headers) {
				item[headers[i]] = value
			}
		}
		jsonData = append(jsonData, item)
	}
	
	// 转换为JSON字符串
	jsonBytes, err := json.MarshalIndent(jsonData, "", "  ")
	if err != nil {
		return err
	}
	
	// 写入文件
	return ioutil.WriteFile(jsonFilePath, jsonBytes, 0644)
}

5.5 数据备份与恢复策略

爬虫系统需要定期备份数据,以防止数据丢失。以下是一些常用的备份策略:

MySQL数据库备份
func backupMySQLDatabase(dbName, backupPath string) error {
	// 构建备份命令
	backupFile := fmt.Sprintf("%s/%s_%s.sql", backupPath, dbName, time.Now().Format("20060102_150405"))
	cmd := exec.Command("mysqldump", 
		"-u", "root", 
		"-p[password]", // 替换为实际密码
		"--databases", dbName,
		"--single-transaction",
		"--quick",
		"--lock-tables=false",
		"-r", backupFile)
	
	// 执行命令
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("备份失败: %v, 输出: %s", err, output)
	}
	
	log.Printf("数据库 %s 已备份到 %s", dbName, backupFile)
	return nil
}
MongoDB数据库备份
func backupMongoDatabase(dbName, backupPath string) error {
	// 构建备份命令
	backupDir := fmt.Sprintf("%s/%s_%s", backupPath, dbName, time.Now().Format("20060102_150405"))
	cmd := exec.Command("mongodump",
		"--db", dbName,
		"--out", backupDir)
	
	// 执行命令
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("备份失败: %v, 输出: %s", err, output)
	}
	
	log.Printf("数据库 %s 已备份到 %s", dbName, backupDir)
	return nil
}
自动化定期备份

在实际应用中,可以使用cron作业定期执行备份任务:

package main

import (
	"log"
	"time"

	"github.com/robfig/cron/v3"
)

func main() {
	c := cron.New()
	
	// 每天凌晨3点执行MySQL备份
	c.AddFunc("0 3 * * *", func() {
		if err := backupMySQLDatabase("crawler", "/backup/mysql"); err != nil {
			log.Printf("MySQL备份失败: %v", err)
		}
	})
	
	// 每周日凌晨4点执行MongoDB备份
	c.AddFunc("0 4 * * 0", func() {
		if err := backupMongoDatabase("crawler", "/backup/mongo"); err != nil {
			log.Printf("MongoDB备份失败: %v", err)
		}
	})
	
	// 每月删除3个月前的备份
	c.AddFunc("0 5 1 * *", func() {
		cleanupOldBackups("/backup", 90*24*time.Hour)
	})
	
	c.Start()
	
	// 让程序保持运行
	select {}
}

// 清理旧备份
func cleanupOldBackups(backupDir string, maxAge time.Duration) error {
	cutoffTime := time.Now().Add(-maxAge)
	
	// 遍历备份目录
	entries, err := ioutil.ReadDir(backupDir)
	if err != nil {
		return err
	}
	
	for _, entry := range entries {
		if entry.ModTime().Before(cutoffTime) {
			path := filepath.Join(backupDir, entry.Name())
			log.Printf("删除过期备份: %s", path)
			
			if err := os.RemoveAll(path); err != nil {
				log.Printf("删除失败: %v", err)
			}
		}
	}
	
	return nil
}

📝 练习与思考

  1. 基础练习:创建一个爬虫程序,爬取一个新闻网站的文章列表,将数据分别存储到CSV文件和MySQL数据库中。

  2. 进阶练习:对已爬取的数据实现三种不同格式的导出功能(CSV、JSON、Excel),并设计直观的命令行参数控制导出选项。

  3. 思考题

    • 当爬取的数据量达到10GB级别时,哪种存储方案更合适?为什么?
    • 如何设计数据模型以同时满足爬取性能和查询性能的需求?
    • 在爬虫系统中,什么情况下应该使用NoSQL数据库而非关系型数据库?

💡 小结

在本文中,我们深入探讨了爬虫数据的存储与导出技术,主要内容包括:

  1. 数据存储方案比较:我们对比了文件系统、关系型数据库和NoSQL数据库等不同存储方案的优缺点,帮助读者根据项目需求选择最合适的方案。

  2. 文件系统存储实现:详细介绍了如何使用Go语言实现CSV、JSON和XML等不同格式的文件存储,适合小型爬虫项目或原型开发。

  3. 关系型数据库存储:重点讲解了MySQL数据库的设计原则、连接配置、表结构设计以及数据的插入和查询操作,适合存储结构化数据。

  4. NoSQL数据库存储:通过MongoDB的实例,展示了如何存储结构复杂、多变的爬虫数据,以及如何利用文档数据库的灵活性和查询能力。

  5. 数据导出与格式转换:介绍了如何将爬取的数据导出为CSV、Excel等格式,以及不同格式间的转换方法,方便数据分享和分析。

  6. 数据备份与恢复:提供了数据库备份和自动化定期备份的实用技术,确保爬虫数据的安全。

通过掌握这些技术,您可以为爬虫系统构建高效、可靠的数据管理模块,根据实际需求选择最合适的存储方案,并实现数据的高效处理和利用。

下篇预告

在下一篇文章【反爬虫策略应对技术】中,我们将深入探讨如何应对各种网站的反爬虫措施,包括IP限制、User-Agent检测、验证码识别等关键技术,帮助您构建更稳定、更健壮的爬虫系统。敬请期待!

👨‍💻 关于作者与Gopher部落

"Gopher部落"专注于Go语言技术分享,提供从入门到精通的完整学习路线。

🌟 为什么关注我们?

  1. 系统化学习路径:本系列14篇文章循序渐进,带你完整掌握Go爬虫开发
  2. 实战驱动教学:理论结合实践,每篇文章都有可操作的代码示例
  3. 持续更新内容:定期分享最新Go生态技术动态与大厂实践经验
  4. 专业技术社区:加入我们的技术交流群,与众多Go开发者共同成长

📱 关注方式

  1. 微信公众号:搜索 “Gopher部落”“GopherTribe”
  2. 优快云专栏:点击页面右上角"关注"按钮

💡 读者福利

关注公众号回复 “Go爬虫” 即可获取:

  • 完整Go爬虫学习资料
  • 本系列示例代码
  • 项目实战源码

期待与您在Go语言的学习旅程中共同成长!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Gopher部落

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值