爬虫与MongoDB简直就是绝配

377 阅读9分钟

前言

最近在使用影刀RPA做一些爬虫操作,图形化的界面确实对之前没有啥爬虫知识的我非常友好,大部分功能都有完整组件,部分没封装的组件通过AI+python生成代码也能解决问题。

爬取到的数据当然可以保存到本地excel,但是为了后续使用方便,我觉得把数据存到数据库中,此时mongoDB就是一个不错的选择

关于mongoDB的特性与传统关系型数据库的对比,此处不再赘述。因为我不想在爬虫中暴露数据库连接信息,也方便后续集成其他功能,我决定用Java + MongoDB做一个后端

SpringBoot配置

JPA很方便的集成了MongoDB,但没想到第一次配置的时候就提示连接错误。明明是在我本地启动的,账号密码都是对的,为啥就是提示连接错误呢?

错误示例

spring:
  data:
    mongodb:
      host: 127.0.0.1
      port: 27017
      database: reader
      username: abc
      password: abc123

在 MongoDB 中,authentication-database 参数指定了 验证账号和密码 的数据库。理解这一点需要明白以下两点:

  1. MongoDB 用户和认证数据库的关系
    在 MongoDB 中,用户是与某个数据库关联的,并且账号验证的元数据(如用户名、密码)会存储在该数据库中,通常是 admin 数据库。
    如果你的账号是在 admin 数据库中创建的,那么即使你连接的是其他数据库,也需要向 admin 数据库发送认证请求。
  2. 为什么认证失败?
    如果你不指定 authentication-database,Spring Boot 会默认将当前连接的 database(在你这里是 reader)作为认证数据库。这会导致以下问题:
    • Spring Boot 发送用户名和密码时,会尝试在 reader 数据库中验证。
    • 但你的账号 abc 和密码 abc123 是存储在 admin 数据库中的,因此认证会失败。

解决方法

在你的配置中,加上:authentication-database:admin即可解决

完整配置

spring:
  data:
    mongodb:
      host: 127.0.0.1
      port: 27017
      database: reader
      username: abc
      password: abc123
      authentication-database: admin 
      auto-index-creation: true

创建实体类

因为mongo的集合Bson类似json,所以可以很好的支持各种嵌套结构,也可以通过注解方便的创建索引

要让注解自动创建 需要开启配置 auto-index-creation: true 可参见上面的配置

假设你没有显式指定 _id,MongoDB 会自动为每个插入的文档生成一个 _id

默认情况下,MongoDB 使用 ObjectId 类型来生成这个 _id,它是一个 12 字节的 BSON 类型,包含了以下信息:

  • 4 字节:时间戳(从 Unix 时间戳开始,表示文档创建的时间)
  • 5 字节:随机值(通常是机器 ID)
  • 3 字节:进程 ID(产生文档的进程 ID)
  • 3 字节:自增计数器(用于避免冲突)

这个 12 字节的 ObjectId 保证了每个生成的 ID 都是唯一的,并且按时间顺序递增。

在Java中虽然可以用String类型接收,但是在mongo中实际存储的类型是ObjectId,所以在使用比如 Navicat等工具筛选时,需要使用正确的类型

@Data
@NoArgsConstructor
@Document(collection = "users") // MongoDB 集合名为 "users"
@CompoundIndex(def = "{'username': 1, 'email': 1}") // 复合索引
public class User {

    @Id
    private String id;  // MongoDB 的 _id 字段

    @Indexed(unique = true) // 为 "username" 字段创建唯一索引
    private String username;

    @Indexed(unique = true) // 为 "email" 字段创建唯一索引
    private String email;

    private String name;

    private List<String> roles;

    private long createdAt;

    private boolean active; 

}

操作数据库

在 Spring Data MongoDB 中,主要有两种常见的方式来与 MongoDB 交互:使用

  • MongoRepository 集成了常见的 crud方法,基本的操作已经足够使用
  • MongoTemplate 可以使用更复杂的查询,充分发挥mongo的灵活特性

下面有几个基础示例

MongoRepository

JPA的经典语法糖,仅需通过方法名定义就能生成查询简单的语句

@Query注解也可以做许多稍复杂一些的查询

伟大无需多言

@Repository
public interface UserRepository extends MongoRepository<User, String> {
    // 根据名字查找用户,自动实现查询
    List<User> findByName(String name);

    // 根据年龄查找用户
    List<User> findByAgeGreaterThan(int age);

    // 查找名字为 "John" 的用户,并按年龄排序
    @Query("{ 'name': 'John' }")
    List<User> findByNameSortedByAge();

    // 查找年龄大于等于给定值且小于 30 的用户
    @Query("{ 'age': { $gte: ?0, $lt: 30 } }")
    List<User> findUsersByAgeRange(int age);
}

MongoTemplate

通过构造 Query 和 Update 对象可以实现更复杂一些的场景,比如动态条件查询、直接通过条件更新等。还支持聚合查询等更为复杂的查询,这里简单介绍暂不深入

@Service
public class UserService {

    @Autowired
    private MongoTemplate mongoTemplate;

    // 查找名字为 "John" 的用户
    public List<User> getUsersByName(String name) {
        Query query = new Query(Criteria.where("name").is(name));
        return mongoTemplate.find(query, User.class);  // 查找符合条件的用户
    }

    // 查找年龄大于 18 岁的用户
    public List<User> getUsersByAgeGreaterThan(int age) {
        Query query = new Query(Criteria.where("age").gte(age));
        return mongoTemplate.find(query, User.class);  // 使用 Criteria 构建查询
    }

    // 更新用户年龄
    public void updateUserAge(String id, int newAge) {
        Query query = new Query(Criteria.where("id").is(id));
        Update update = new Update().set("age", newAge);  // 创建更新对象
        mongoTemplate.updateFirst(query, update, User.class);  // 执行更新
    }
}

MongoDB总结

通过上面的简单配置,就可以愉快的使用mongo了。如果你也尝试了使用Java创建集合,你会发现在你的集合中多出了一个 _class 字段,这里记录着实体类的完整类名,也可以通过配置不生成,但是在如果实体类有集成之类的其他额外属性,就很可能产生一些不必要的问题,通常还是保留这个字段

图片转码

爬虫爬取到图片的url很可能带有防盗链或者限时访问,为了防止网络图片失效,还是需要将网络图片保存到本地。网络图片的格式多种多样,一方面为了统一格式,也为了在保持画质的前提下压缩体积,我决定在图片入库之前先对图片进行转码。

WebP格式的优势

WebP 是 Google 推出的现代图像格式,旨在通过更高效的压缩算法减少文件大小,同时保持视觉质量。与传统的图像格式如 PNG、JPG、JPEG 相比, WebP 可以在无损有损压缩下,保持较小的文件大小。并且也支持动画与透明

特性WebPJPG/JPEGPNG
压缩类型有损/无损有损压缩无损压缩
文件大小更小,效率高适中,文件较大文件大,但质量无损
透明通道支持支持不支持支持
动画支持支持不支持不支持
色彩质量高,支持 24 位色彩高,但文件大高,且无损
浏览器支持新版主流浏览器全面支持所有浏览器都支持所有浏览器都支持

据我的不专业实验,同一张图片 webp格式对比 jpg 通常只有 75%~80% 的大小

起码对我来说这个画质没啥区别


Java的一大优势,直接使用现成的工具库就好,这里我使用的是 imageio

<dependency>
  <groupId>org.sejda.imageio</groupId>
  <artifactId>webp-imageio</artifactId>
  <version>0.1.6</version>
</dependency>

这里我入库的时候转为了Base64,虽然刚刚压缩的体积又被base64给浪费了,但是能不用传输文件流还是挺方便的

public class ImageUtil {

    @SneakyThrows
    public static String toBase64Webp(MultipartFile file) {
        String fileName = Optional.ofNullable(file).filter(x -> !x.isEmpty()).map(MultipartFile::getOriginalFilename)
                .filter(StrUtil::isNotBlank).orElseThrow(() -> new LevyException("文件为空"));

        BufferedImage image;
        try (ByteArrayInputStream bis = new ByteArrayInputStream(file.getBytes())) {
            image = ImageIO.read(bis);
        }
        if (image == null) {
            throw new LevyException("无法读取图片,请确认上传的是有效图片文件");
        }

        byte[] webpData;
        if (fileName.endsWith(".webp")) {
            webpData = file.getBytes();
        } else {
            try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
                ImageIO.write(image, "webp", baos);
                webpData = baos.toByteArray();
            }
        }


        if (webpData.length == 0) {
            throw new LevyException("WebP 数据为空");
        }
        byte[] encode = Base64.getEncoder().encode(webpData);
        String base64Content = new String(encode, StandardCharsets.UTF_8);
        return "data:image/webp;base64," + base64Content;
    }


}

浏览器缓存

通过日常观察我们可以发现,很多网站第一遍加载很慢,但是后面第二次第三次加载就快了。这是因为浏览器将很多静态资源缓存 (如图片、CSS、JS 等) 了 ,因为这些资源通常变化较少,缓存能显著提升加载性能。

由于我并没有使用oss存储图片,在前端使用图片时使用图片id请求后端,获取到base64后再渲染,默认就不会被浏览器缓存

给响应增加缓存

要知道如何给响应增加缓存,需要先了解为什么图片会被浏览器缓存 通过控制台查看普通图片的请求

请求方法

  • 对于 GET 请求,浏览器通常会遵循 Cache-Control 头的配置进行缓存。
  • 对于 POST 请求,HTTP 标准建议不要缓存响应,浏览器也会默认不缓存。

Content-Type

  • 浏览器可能会根据返回的 Content-Type 判断资源是否适合缓存。
  • 如果返回的是 application/json,浏览器默认会认为是动态数据,通常不会缓存。

Cache-Control

  • 例如:Cache-Control: max-age=2592000``max-age 表示缓存的有效期为 2592000 秒(即 30 天)。浏览器会在缓存有效期内直接从本地获取资源,而不会向服务器发送请求。

对于SpringBoot来说,可以很简单的给某个接口增加响应头。也可以使用其他方式通过通配符之类的配置,不过目前我没有这个需求

    @GetMapping("/{id}")
    public ResponseEntity<Result<Image>> getImageById(@PathVariable String id) {
        Image image = repository.findById(id).orElseThrow(() -> new LevyException("未找到图片"));
        Result<Image> imageResult = Result.success(image);
        return  ResponseEntity.ok()
                .cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS))
                .body(imageResult);
    }

关于为什么不适用localStorage缓存,因为对于单个域名是有限制的,而静态资源很快会将这个上线占满

特点浏览器缓存(HTTP 缓存)localStorage
数据存储存储服务器返回的 HTTP 响应数据。存储键值对数据(字符串)。
存储数据类型资源文件:HTML、CSS、JS、图片、API 响应等。只能存储文本字符串
数据存储方式由服务器通过 HTTP 响应头 控制缓存。由客户端代码显式设置和管理。
存储大小依赖于浏览器缓存机制(通常较大)。每个域名限制 5-10MB 大小。
管理自动化程度自动管理,遵循 HTTP 规范(Cache-Control等)。需要开发者手动编写逻辑管理。
生命周期取决于缓存头设置,自动过期或更新数据持久保存,除非手动清理。
安全性有一定的安全限制,不能跨域。也不能跨域,且容易被篡改。
适用场景静态资源、接口数据的缓存。配置参数、用户偏好、状态数据等。

后话

第一次使用爬虫和mongo的总结,文章混杂到了一起,但是在这个过程中确实解决了不少问题。虽然种种原因很多业务系统都不会采用mongo,但是自己用用才能体会到mongo的便捷