第七阶段:数据存储与部署

第七阶段:数据存储与部署

合辑 :Python 爬虫阶段学习手册 | 阶段 :7 / 7 | 定位 :将采集结果持久化到数据库,并把爬虫交付到服务器按计划自动运行 | 建议学时:14--18 小时

大家好,我是小马不起床。

阶段导语

前六个阶段解决的是"如何把数据取出来":构造请求、解析页面、对抗反爬、提高吞吐。第七阶段解决另外两个问题:数据存放在哪里,程序如何长期运行。

数据持久化(Persistence)是爬虫进入工程化的第一步。以文件形式存储难以支撑结构化查询与增量更新:数据量增长后检索效率下降,字段变动需要重写整个文件,去重要求全量读入内存。本阶段依次引入 MySQL(结构化数据)、MongoDB(原始抓取数据)与 Redis(去重、队列、缓存)三类存储,并给出各自的客户端用法与 Scrapy Pipeline 集成方式。

部署部分覆盖 Linux 基础操作、nohup 后台运行、Scrapyd 服务化部署、Docker 容器化(Containerization)以及 crontab / APScheduler 定时调度,最后以一段完整的上线脚本结束。完成本阶段后,爬虫从"本地手动执行的脚本"转变为"服务器上按计划运行的服务"。

学习目标

  • 能按数据规模、结构稳定性与查询需求完成存储选型,并说明 MySQL、MongoDB、Redis 在爬虫链路中的分工
  • 能使用 pymysql 完成建表、插入、批量插入、查询、更新与删除,并正确处理唯一约束引发的重复数据
  • 能使用 SQLAlchemy 定义模型并通过会话(Session)完成增删改查,说明对象关系映射(ORM, Object-Relational Mapping)与手写 SQL 的差异
  • 能编写 Scrapy Pipeline,将 Item 分别写入 MySQL 与 MongoDB,并为重复数据实现更新策略
  • 能使用 Redis 的 String、List 与 Set 实现计数器、请求队列、URL 去重与代理池
  • 能使用 nohup、Scrapyd、Docker Compose 与 crontab / APScheduler 完成部署与定时调度,并配置日志输出
  • 能说明连接池耗尽、字符集乱码、MongoDB 批量写入失败、crontab 不执行等常见故障的成因与处理方式

前置知识

  • 第五阶段 Scrapy Pipeline :ITEM_PIPELINES 的配置方式,open_spider / close_spider / process_item 三个生命周期方法的调用时机(参见《05_Scrapy爬虫框架》)
  • 第一阶段文件 I/O :JSON 与 CSV 的写入方式、ensure_ascii 与文件编码参数(参见《01_Python语言底座》)
  • 第四阶段并发 :大规模 URL 场景下队列与去重的需求来源(参见《04_高并发与高性能爬虫》)
  • 运行环境:Python 3.10 及以上;一台本地或云端的 Linux 服务器;Docker 与 Docker Compose(容器化部署部分需要)

目录

  1. 数据存储方案选型
  2. [关系型数据库 --- MySQL](#关系型数据库 — MySQL)
  3. [非关系型数据库 --- MongoDB](#非关系型数据库 — MongoDB)
  4. [缓存数据库 --- Redis](#缓存数据库 — Redis)
  5. 部署方案
  6. 学习建议

1. 数据存储方案选型

1.1 选型依据

存储方案由数据规模、结构稳定性与使用方式共同决定,判断路径如下。

text 复制代码
数据量小(少于 1 万条)
  使用 JSON / CSV 文件存储即可满足需求(前几个阶段的方式)

数据量大(超过 1 万条),需要查询与分析
  数据结构固定,字段可提前定义 → MySQL / PostgreSQL
  数据结构灵活,字段随页面变化 → MongoDB(与爬虫数据的形态天然匹配)
  需要去重、缓存或队列 → Redis

需要长期运行、自动化调度
  部署到服务器 → Scrapyd / Docker / 定时任务

三项判断维度需要分别确认:

  1. 数据规模:决定文件存储是否仍然可用。文件方案的全量读写成本随数据量线性增长。
  2. 结构稳定性:决定关系型数据库与文档数据库的取舍。字段固定且需要事务与关联查询时选择 MySQL;字段随采集页面变化、需要频繁增删字段时选择 MongoDB。
  3. 访问模式:决定是否需要 Redis。去重判断、队列消费与临时缓存都属于高频读写,放入内存数据库可显著降低延迟。

1.2 存储方案对比

方案 数据模型 适用场景 Python 客户端
JSON / CSV 文件 半结构化文本 开发与调试阶段、百条以内的数据、一次性导出 标准库 json、csv
MySQL 关系表(行列固定,需先定义表结构) 清洗后的结构化数据、需要条件查询与统计聚合 pymysql、SQLAlchemy
PostgreSQL 关系表,支持 JSONB 字段 结构化数据同时需保留部分嵌套字段,或对事务与约束要求更高 psycopg2、SQLAlchemy
MongoDB 文档(BSON,结构灵活,无需建表) 原始抓取数据、字段不定的多源数据、高写入吞吐 pymongo
Redis 键值(String / List / Set / Hash 等) URL 去重、请求队列、代理池、计数器、页面缓存 redis

补充两点差异:MySQL 与 PostgreSQL 属于关系型数据库,数据以二维表组织,写入前需定义模式(Schema);MongoDB 属于文档型数据库,每条记录是一份 JSON 结构,字段可以逐条不同。Redis 数据常驻内存,读写延迟为微秒级,但容量受内存限制,通常不用于长期留存业务数据。

1.3 推荐组合与数据流向

爬虫项目的常见存储组合如下,三类存储各司其职,不存在相互替代关系。

text 复制代码
1. MongoDB:存放原始抓取数据。数据本身就是 JSON 结构,无需设计表结构
2. Redis:负责 URL 去重、代理池与请求队列
3. MySQL:存放清洗与结构化转换之后的最终数据(按下游需求决定是否引入)

数据流向如下:

text 复制代码
网页 → 爬虫 → MongoDB(原始数据)→ 清洗与转换 → MySQL(结构化数据)
                            ↑
                    Redis 在抓取阶段提供 URL 去重与代理池支持

数据规模较小的项目无需一次引入三类存储。单一 MongoDB 即可覆盖原始数据的存储与查询需求,待出现明确的关联分析或报表需求后再补充 MySQL。

  • 要点:存储选型依次看数据规模、结构稳定性与访问模式,三者共同决定方案。
  • 要点:文件存储适合开发与调试阶段,难以支撑结构化查询与增量更新。
  • 要点:MongoDB 存原始数据、MySQL 存清洗后的数据、Redis 承担去重与队列,是爬虫项目的标准分工。
  • 要点:入门练习仅需 MongoDB 一种存储即可完成,后续按需求扩展。

2. 关系型数据库 --- MySQL

2.1 安装与连接

MySQL 的操作接口按抽象层次分为两级:pymysql 提供直接执行 SQL 语句的能力,SQLAlchemy 在其之上提供对象关系映射(ORM, Object-Relational Mapping),用 Python 类与对象表达表与记录。

bash 复制代码
# 安装 pymysql(MySQL 驱动)
pip install pymysql

# 或同时安装 SQLAlchemy(ORM,功能更完整)
pip install sqlalchemy pymysql

服务端需要提前准备:本地安装 MySQL Server,或使用云数据库实例。以下示例假设已存在名为 spider_db 的数据库,账号为 root。生产环境应使用权限受限的账号,并按第 5 节的方式注入密码,不在代码中硬编码。

2.2 基础操作(pymysql)

覆盖建表、插入、批量插入、查询、更新与删除六类操作。

python 复制代码
import pymysql

# 连接数据库
conn = pymysql.connect(
    host="localhost",      # 数据库地址
    port=3306,             # 端口
    user="root",           # 用户名
    password="123456",     # 密码
    database="spider_db",  # 数据库名
    charset="utf8mb4",     # 编码,utf8mb4 可完整存储中文与 emoji
)
cursor = conn.cursor()

# 创建表
cursor.execute("""
CREATE TABLE IF NOT EXISTS movies (
    id INT AUTO_INCREMENT PRIMARY KEY,        # 自增主键
    title VARCHAR(255) NOT NULL,              # 电影名
    score DECIMAL(3, 1),                      # 评分
    year INT,                                 # 年份
    url VARCHAR(500) UNIQUE,                  # 详情页 URL,唯一约束用于防重复
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP  # 创建时间
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")

# 插入数据
sql = "INSERT INTO movies (title, score, year, url) VALUES (%s, %s, %s, %s)"
data = ("流浪地球2", 8.3, 2023, "https://example.com/movie/1")
cursor.execute(sql, data)
conn.commit()  # 提交事务,未提交的变更不会落库
print(f"插入成功,ID: {cursor.lastrowid}")

# 批量插入
movies = [
    ("满江红", 7.4, 2023, "https://example.com/movie/2"),
    ("哪吒", 9.0, 2025, "https://example.com/movie/3"),
    ("流浪地球2", 8.3, 2023, "https://example.com/movie/1"),  # URL 与上方重复
]
for movie in movies:
    try:
        cursor.execute(sql, movie)
    except pymysql.err.IntegrityError:
        print(f"重复: {movie[0]},跳过")
conn.commit()

# 查询数据
cursor.execute("SELECT title, score, year FROM movies WHERE score > 8.0 ORDER BY score DESC")
results = cursor.fetchall()  # 一次取出全部结果
for title, score, year in results:
    print(f"{title} | {score}分 | {year}")

# 更新数据
cursor.execute("UPDATE movies SET score = %s WHERE title = %s", (8.5, "流浪地球2"))
conn.commit()

# 删除数据
cursor.execute("DELETE FROM movies WHERE year < %s", (2020,))
conn.commit()

# 关闭连接
cursor.close()
conn.close()

三处约定需要说明:

  • 参数化占位符 :SQL 中使用 %s,参数由 execute 的第二个参数传入。该写法由驱动完成转义,同时避免 SQL 注入与引号转义错误,不应使用字符串拼接构造 SQL。
  • 唯一约束去重 :url VARCHAR(500) UNIQUE 使重复 URL 的插入抛出 IntegrityError。批量循环中捕获该异常并跳过,即可实现采集侧的幂等写入。
  • 事务提交 :INSERT / UPDATE / DELETE 均需显式调用 conn.commit(),否则连接关闭时变更回滚。

2.3 使用 with 上下文管理器

逐条手写 commit 与 close 容易在异常路径上遗漏连接释放。将连接获取封装为上下文管理器后,提交与回滚由统一入口处理。

python 复制代码
import pymysql
from contextlib import contextmanager


@contextmanager
def get_db():
    """数据库连接上下文管理器:正常退出时提交,异常时回滚并关闭连接"""
    conn = pymysql.connect(
        host="localhost",
        port=3306,
        user="root",
        password="123456",
        database="spider_db",
        charset="utf8mb4",
    )
    try:
        yield conn
        conn.commit()  # 无异常时提交
    except Exception:
        conn.rollback()  # 有异常时回滚
        raise
    finally:
        conn.close()


# 使用
with get_db() as conn:
    cursor = conn.cursor()
    cursor.execute("SELECT COUNT(*) FROM movies")
    count = cursor.fetchone()[0]
    print(f"共有 {count} 条记录")

2.4 爬虫实战:MySQL Pipeline

Scrapy 的数据写入统一由 Pipeline 承担。将下述代码放入项目的 pipelines.py,连接的建立与释放分别挂在爬虫启动与关闭的生命周期方法上。

python 复制代码
"""
Scrapy Pipeline:将数据写入 MySQL
将此代码放入项目的 pipelines.py
"""
import pymysql


class MySQLPipeline:
    """Scrapy Pipeline:数据存入 MySQL"""

    def open_spider(self, spider):
        """爬虫启动时建立数据库连接"""
        self.conn = pymysql.connect(
            host=spider.settings.get("MYSQL_HOST", "localhost"),
            port=spider.settings.get("MYSQL_PORT", 3306),
            user=spider.settings.get("MYSQL_USER", "root"),
            password=spider.settings.get("MYSQL_PASSWORD", ""),
            database=spider.settings.get("MYSQL_DATABASE", "spider"),
            charset="utf8mb4",
        )
        self.cursor = self.conn.cursor()

    def close_spider(self, spider):
        """爬虫关闭时释放连接"""
        self.conn.close()

    def process_item(self, item, spider):
        """逐条写入,URL 重复时更新评分"""
        sql = """
            INSERT INTO movies (title, score, year, url)
            VALUES (%s, %s, %s, %s)
            ON DUPLICATE KEY UPDATE score=VALUES(score)
        """
        # ON DUPLICATE KEY UPDATE:唯一键冲突时改为更新评分,而不是抛出异常
        data = (item["title"], item["score"], item["year"], item["url"])
        self.cursor.execute(sql, data)
        self.conn.commit()
        return item

配置写在项目的 settings.py 中,连接参数由配置项提供,避免代码与服务端环境耦合。

python 复制代码
# settings.py 配置
MYSQL_HOST = "localhost"
MYSQL_PORT = 3306
MYSQL_USER = "root"
MYSQL_PASSWORD = "123456"
MYSQL_DATABASE = "spider_db"

ITEM_PIPELINES = {
    "my_spider.pipelines.MySQLPipeline": 300,
}

数字 300 为执行优先级,数值越小越早执行,多个 Pipeline 按该顺序串联处理同一份 Item。

2.5 使用 SQLAlchemy(ORM)

ORM 将表映射为类、行映射为对象,查询通过链式方法表达,最终 SQL 由框架生成。其收益在于表结构调整只需修改模型定义,且会话统一管理事务与连接。

python 复制代码
"""
ORM = Object-Relational Mapping(对象关系映射)
用 Python 对象操作数据库,无需手写 SQL
"""

# pip install sqlalchemy pymysql

from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, func
from sqlalchemy.orm import declarative_base, sessionmaker

# 定义模型
Base = declarative_base()


class Movie(Base):
    __tablename__ = "movies"

    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(255), nullable=False)
    score = Column(Float)
    year = Column(Integer)
    url = Column(String(500), unique=True)  # 唯一约束
    created_at = Column(DateTime, default=func.now())

    def __repr__(self):
        return f"<Movie {self.title}>"


# 连接数据库
engine = create_engine(
    "mysql+pymysql://root:123456@localhost:3306/spider_db?charset=utf8mb4",
    echo=False,  # 设为 True 时打印生成的 SQL 语句,便于调试
)
# 按模型定义创建所有表
Base.metadata.create_all(engine)

# 创建会话类并实例化
Session = sessionmaker(bind=engine)
session = Session()


# 插入
movie = Movie(title="流浪地球2", score=8.3, year=2023, url="https://example.com/1")
session.add(movie)
session.commit()

# 批量插入
session.add_all([
    Movie(title="满江红", score=7.4, year=2023, url="https://example.com/2"),
    Movie(title="哪吒", score=9.0, year=2025, url="https://example.com/3"),
])
session.commit()


# 查询
# 查询全部
movies = session.query(Movie).all()

# 条件查询并排序
good_movies = session.query(Movie).filter(Movie.score > 8.0).order_by(Movie.score.desc()).all()
for m in good_movies:
    print(m.title, m.score)

# 查询单条
movie = session.query(Movie).filter_by(title="流浪地球2").first()

# 统计数量
count = session.query(Movie).count()


# 更新
movie = session.query(Movie).filter_by(title="流浪地球2").first()
if movie:
    movie.score = 8.5
    session.commit()


# 删除
session.query(Movie).filter(Movie.year < 2020).delete()
session.commit()


# 关闭会话
session.close()

连接串格式为 数据库方言+驱动://用户名:密码@主机:端口/库名?参数。create_engine 内部维护连接池,sessionmaker 从池中借出连接,因此无须像 pymysql 那样手工管理每条连接。若长时间空闲后出现连接失效,可在 create_engine 中增加 pool_pre_ping=True 与 pool_recycle=3600。

操作 SQLAlchemy 写法 对应 SQL
插入 session.add(obj) / session.add_all([...]) INSERT INTO ...
查询全部 session.query(Movie).all() SELECT * FROM movies
条件查询 session.query(Movie).filter(Movie.score > 8.0) SELECT ... WHERE score > 8.0
查询单条 session.query(Movie).filter_by(title="...").first() SELECT ... LIMIT 1
更新 修改对象属性后 session.commit() UPDATE ... SET ...
删除 session.query(Movie).filter(...).delete() DELETE FROM ...
  • 要点 :pymysql 直接执行 SQL,SQLAlchemy 以模型对象代替 SQL;前者的连接与事务需手工管理,后者由引擎连接池与会话托管。
  • 要点 :SQL 一律使用 %s 参数化占位符,不做字符串拼接。
  • 要点 :url 字段上的唯一约束配合 ON DUPLICATE KEY UPDATE 或异常捕获,实现重复 URL 的幂等写入。
  • 要点 :Pipeline 中连接在 open_spider 建立、close_spider 释放,连接参数全部来自 settings.py。
  • 要点 :写操作必须 commit;连接统一由上下文管理器或会话释放,避免连接泄漏导致连接池耗尽。

3. 非关系型数据库 --- MongoDB

3.1 MongoDB 与爬虫数据的适配性

MongoDB 属于文档型数据库,存储单位为 BSON 文档(Binary JSON,JSON 的二进制扩展),适配爬虫场景的原因有四点。

text 复制代码
1. 原生存储 JSON:爬虫解析结果本身就是 JSON 结构,无需转换即可写入
2. 模式(Schema)灵活:同一集合内每条文档的字段可以不同,新增字段无需修改表结构
3. 写入速度快:适合爬虫持续高吞吐写入的场景
4. 水平扩展方便:数据量增长后可通过分片(Sharding)分散到多个节点

对爬虫而言,这意味着抓到的数据可以直接写入,无须预先设计表结构。页面改版、字段增减都只需修改解析逻辑,数据库侧不需要执行 ALTER TABLE。

对比维度 MySQL MongoDB
数据结构 二维表,字段固定 文档,字段可变
写入前的准备 需先建表并定义列 集合可自动创建
字段变动成本 需修改表结构 直接写入新字段
事务与关联查询 支持完整 支持程度较低
爬虫中的定位 存放清洗后的结构化数据 存放原始抓取数据

3.2 安装与连接

bash 复制代码
pip install pymongo

服务端安装与启动按官方文档完成,默认监听 27017 端口。设置访问密码后连接串形式见 3.3 节注释。

3.3 基础操作(pymongo)

覆盖插入、查询、更新、删除、计数与索引六类操作。集合(Collection)相当于 MySQL 的表,文档(Document)相当于 MySQL 的行。

python 复制代码
from pymongo import MongoClient

# 连接
client = MongoClient("mongodb://localhost:27017")
# 带账号密码时:MongoClient("mongodb://user:password@localhost:27017")

# 选择数据库,不存在时于首次写入自动创建
db = client["spider_db"]

# 选择集合,相当于 MySQL 的表
collection = db["movies"]


# 插入
# insert_one:插入一条文档
movie = {
    "title": "流浪地球2",
    "score": 8.3,
    "year": 2023,
    "url": "https://example.com/movie/1",
    "director": "郭帆",
    "actors": ["吴京", "刘德华"],
}
result = collection.insert_one(movie)
print(f"插入 ID: {result.inserted_id}")

# insert_many:批量插入
movies = [
    {"title": "满江红", "score": 7.4, "year": 2023, "url": "https://example.com/2"},
    {"title": "哪吒", "score": 9.0, "year": 2025, "url": "https://example.com/3"},
]
result = collection.insert_many(movies)
print(f"插入了 {len(result.inserted_ids)} 条")


# 查询
# 查询全部
for movie in collection.find():
    print(movie["title"], movie["score"])

# 条件查询,$gt 表示大于
for movie in collection.find({"score": {"$gt": 8.0}}):
    print(movie["title"])

# 排序(-1 为降序)并限制返回条数
for movie in collection.find().sort("score", -1).limit(10):
    print(movie["title"], movie["score"])

# 查询单条
movie = collection.find_one({"title": "流浪地球2"})

# 只返回指定字段(投影)
for movie in collection.find({}, {"title": 1, "score": 1}):
    print(movie)


# 更新
collection.update_one(
    {"title": "流浪地球2"},
    {"$set": {"score": 8.5}}  # $set 只修改指定字段,其余字段保留
)

collection.update_many(
    {"year": 2023},
    {"$set": {"tag": "2023年电影"}}
)


# 删除
collection.delete_one({"title": "满江红"})
collection.delete_many({"year": {"$lt": 2020}})


# 符合条件的文档计数
count = collection.count_documents({"score": {"$gt": 8.0}})
print(f"高分电影: {count} 部")


# 创建索引,用于提高查询速度
collection.create_index("title")            # 单字段索引
collection.create_index([("score", -1)])    # 降序索引
collection.create_index("url", unique=True) # 唯一索引,用于防重复

查询条件由操作符表达,常用操作符如下。

操作符 含义 示例
$gt / $gte 大于 / 大于等于 {"score": {"$gte": 8.0}}
$lt / $lte 小于 / 小于等于 {"year": {"$lt": 2020}}
$eq / $ne 等于 / 不等于 {"year": {"$ne": 2023}}
$in / $nin 属于集合 / 不属于集合 {"year": {"$in": [2023, 2025]}}
$regex 正则匹配 {"title": {"$regex": "流浪"}}
$set 更新指定字段 {"$set": {"score": 8.5}}
$exists 字段是否存在 {"tag": {"$exists": True}}

两点实践约束:

  • 唯一索引前置 :create_index("url", unique=True) 应在首次写入前创建。已有重复数据时创建唯一索引会失败,需先清理重复文档。
  • 批量写入控制 :insert_many 默认按顺序写入,遇到唯一键冲突会中断后续写入。批量导入时可传入 ordered=False 跳过冲突继续写入,或使用 update_one(..., upsert=True) 实现"不存在则插入、存在则更新"。

3.4 爬虫实战:MongoDB Pipeline

Scrapy 场景下将写入逻辑放入 Pipeline,重复 URL 由唯一索引拦截并转为更新。

python 复制代码
"""
Scrapy Pipeline:数据存入 MongoDB
"""
from pymongo import MongoClient, errors


class MongoPipeline:
    """Scrapy Pipeline:数据存入 MongoDB"""

    def open_spider(self, spider):
        """爬虫启动时建立连接并创建唯一索引"""
        self.client = MongoClient(spider.settings.get("MONGO_URI", "mongodb://localhost:27017"))
        self.db = self.client[spider.settings.get("MONGO_DATABASE", "spider")]
        self.collection = self.db[spider.settings.get("MONGO_COLLECTION", "items")]
        # 创建唯一索引防重复,此处以 url 作为去重依据
        self.collection.create_index("url", unique=True)

    def close_spider(self, spider):
        """爬虫关闭时释放连接"""
        self.client.close()

    def process_item(self, item, spider):
        """写入单条数据,唯一键冲突时改为更新"""
        try:
            self.collection.insert_one(dict(item))
        except errors.DuplicateKeyError:
            # URL 已存在,覆盖更新原文档
            self.collection.update_one(
                {"url": item["url"]},
                {"$set": dict(item)}
            )
        return item
python 复制代码
# settings.py
MONGO_URI = "mongodb://localhost:27017"
MONGO_DATABASE = "spider_db"
MONGO_COLLECTION = "movies"

ITEM_PIPELINES 的注册方式与 MySQL Pipeline 相同,将类路径与优先级数字写入配置即可。

3.5 不依赖 Scrapy 的 MongoDB 存储类

requests + 解析库的抓取流程同样需要一个统一的存储入口。将连接、索引与写入封装为独立类,供脚本直接调用。

python 复制代码
"""
不使用 Scrapy 时,直接写入 MongoDB 的封装类
"""
from pymongo import MongoClient, errors


class MongoStorage:
    """独立的 MongoDB 存储类"""

    def __init__(self, uri="mongodb://localhost:27017", db_name="spider", collection_name="data"):
        self.client = MongoClient(uri)
        self.db = self.client[db_name]
        self.collection = self.db[collection_name]
        # url 唯一索引
        self.collection.create_index("url", unique=True)

    def save(self, item):
        """保存单条数据:新数据插入,重复数据更新"""
        try:
            self.collection.insert_one(item)
            return True
        except errors.DuplicateKeyError:
            # URL 重复时更新原数据
            self.collection.update_one(
                {"url": item["url"]},
                {"$set": item}
            )
            return False

    def save_many(self, items):
        """批量保存"""
        for item in items:
            self.save(item)

    def close(self):
        """释放连接"""
        self.client.close()


# 使用
storage = MongoStorage()
storage.save({"title": "流浪地球2", "score": 8.3, "url": "https://example.com/1"})
storage.close()

save 的返回值区分插入与更新,可用于统计本次运行的新增量。批量写入数据量较大时,save_many 的逐条写法会产生大量网络往返,可改用 update_one(..., upsert=True) 配合批量线程,或使用 bulk_write 接口提交批任务。

  • 要点:MongoDB 原生存储 JSON、模式灵活、写入快,与爬虫数据形态匹配,适合作为原始数据存储。
  • 要点 :url 字段的唯一索引是去重的前提,须在首次写入前创建。
  • 要点 :写入策略统一为"插入失败即更新",insert_one 捕获 DuplicateKeyError 后转 update_one。
  • 要点 :批量写入需关注 insert_many 的有序性与逐条写入的往返开销,必要时报错跳过或使用 bulk_write。
  • 要点:Pipeline 与独立存储类两种封装分别对应 Scrapy 项目与脚本型爬虫。

4. 缓存数据库 --- Redis

4.1 Redis 在爬虫中的作用

Redis 是内存数据库,读写在内存中完成,速度极快。在爬虫中的用途集中于高频小数据场景。

text 复制代码
Redis 在爬虫中的五类用途:

1. URL 去重:用 Set 存放已爬取的 URL,千万级数量仍可保持毫秒级判断
2. 请求队列:用 List 实现先进先出(FIFO, First In First Out)队列,是 Scrapy-Redis 的核心
3. 代理池:用 Set 存放可用代理,随机取用并剔除失效项
4. 计数器:用 INCR 命令统计已抓取数量、失败数量
5. 页面缓存:存放已抓取的页面内容,避免重复请求同一 URL

对应到数据结构的选择如下表。

数据结构 常用命令 爬虫用途 特性
String set / get / incr / expire 计数器、缓存页面、存 token 与 cookie 支持过期时间与原子递增
List lpush / rpush / lpop / brpop 请求队列、结果队列 有序,可阻塞弹出
Set sadd / sismember / scard / srandmember URL 去重、代理池 元素唯一,判断存在性为常数级
Hash hset / hgetall 存放单条记录的结构化字段 字段级读写
Sorted Set zadd / zrangebyscore 按优先级或延时排序的调度队列 按分值排序

4.2 安装与连接

bash 复制代码
pip install redis

服务端默认监听 6379 端口。生产环境应设置 requirepass,不将 Redis 直接暴露于公网。

4.3 基础操作(String / List / Set)

python 复制代码
import redis

# 连接
r = redis.Redis(
    host="localhost",
    port=6379,
    password="",     # 无密码时留空
    db=0,            # 数据库编号,取值范围 0-15
    decode_responses=True,  # 自动解码为字符串,否则返回 bytes
)

# 测试连接
print(r.ping())  # 返回 True 表示连接可用


# String(字符串)
r.set("key", "value")              # 设置
r.set("count", 0)
print(r.get("key"))                # "value"
r.incr("count")                    # 递增 1,结果为 1
r.incrby("count", 5)               # 递增 5,结果为 6
print(r.get("count"))              # "6"
r.expire("key", 3600)              # 设置过期时间,单位秒


# List(列表,用作队列)
r.lpush("url_queue", "https://a.com")  # 从左侧推入
r.rpush("url_queue", "https://b.com")  # 从右侧推入
r.lpush("url_queue", "https://c.com", "https://d.com")  # 批量推入

length = r.llen("url_queue")            # 队列长度
url = r.lpop("url_queue")               # 左侧弹出,与右侧推入配合实现先进先出
url = r.brpop("url_queue", timeout=5)   # 阻塞弹出,最多等待 5 秒
# brpop 用于多消费者场景:队列暂无任务时阻塞等待,出现任务立即取出


# Set(集合,用于去重)
r.sadd("visited_urls", "https://a.com")           # 添加元素
r.sadd("visited_urls", "https://a.com")           # 重复添加不生效
r.sadd("visited_urls", "https://b.com", "https://c.com")  # 批量添加

is_visited = r.sismember("visited_urls", "https://a.com")  # 判断是否存在,返回 True
all_visited = r.smembers("visited_urls")   # 取出全部已访问 URL
count = r.scard("visited_urls")            # 元素数量

incr 与 incrby 为原子操作,多个进程同时累加不会丢失计数,这是计数器不采用"读取后写回"写法的原因。brpop 返回的是 (键名, 值) 元组,取值时需解包。

4.4 爬虫实战:URL 去重器

Set 的添加操作同时承担"判断"与"登记"两件事:sadd 返回 1 表示元素原本不存在,返回 0 表示重复。

python 复制代码
class URLFilter:
    """基于 Redis Set 的 URL 去重器"""

    def __init__(self, redis_client, key="url_filter"):
        self.redis = redis_client
        self.key = key

    def is_duplicate(self, url):
        """检查 URL 是否已访问:未访问则登记并返回 False,已访问返回 True"""
        return self.redis.sadd(self.key, url) == 0
        # sadd 返回 1 表示新添加(未访问),0 表示已存在(重复)

    def clear(self):
        """清空记录"""
        self.redis.delete(self.key)


# 使用
rf = URLFilter(r)
urls = ["https://a.com", "https://b.com", "https://a.com", "https://c.com"]

for url in urls:
    if rf.is_duplicate(url):
        print(f"跳过(重复): {url}")
    else:
        print(f"抓取: {url}")

输出结果:

text 复制代码
抓取: https://a.com
抓取: https://b.com
跳过(重复): https://a.com
抓取: https://c.com

URL 数量达到千万级时,Set 的内存占用随之增长。此时可改用布隆过滤器(Bloom Filter)以固定内存空间换取可接受的误判率,代价是判断存在少量假阳性且无法删除单个元素。

4.5 爬虫实战:分布式代理池

Set 的无序性与唯一性适合维护代理集合:随机取用实现负载均衡,失效后即时剔除。

python 复制代码
class ProxyPool:
    """基于 Redis Set 的代理池"""

    def __init__(self, redis_client, key="proxy_pool"):
        self.redis = redis_client
        self.key = key

    def add(self, proxy):
        """添加代理"""
        self.redis.sadd(self.key, proxy)

    def get_random(self):
        """随机获取一个代理"""
        return self.redis.srandmember(self.key)

    def remove(self, proxy):
        """移除失效代理"""
        self.redis.srem(self.key, proxy)

    def count(self):
        """可用代理数量"""
        return self.redis.scard(self.key)

典型用法是:定时任务抓取免费代理并写入 add,请求前用 get_random 取用,请求失败或超时后调用 remove 剔除。由于 Redis 数据常驻内存且可被多台机器共享,该结构同时支撑了分布式爬虫的代理协同。

  • 要点:Redis 在爬虫中的四类核心用途为去重、队列、代理池与计数,均由其内存读写性能支撑。
  • 要点:数据结构与用途对应,去重用 Set、队列用 List、计数与缓存用 String。
  • 要点 :sadd 的返回值可直接作为重复判断依据,无须先查后写,避免并发下的重复抓取。
  • 要点 :decode_responses=True 返回字符串,未设置时返回 bytes,需要在解析前解码。
  • 要点:千万级 URL 场景下 Set 内存占用过高,可改用布隆过滤器。

5. 部署方案

部署的目标是使爬虫在无人工干预的条件下持续运行。四种方式的定位与取舍如下。

方式 适用对象 管理入口 定时能力 主要优势 主要局限
nohup 后台运行 单个 Python 脚本 命令行与日志文件 需配合 crontab 零改造成本,立即可用 进程与机器绑定,重启与多项目管理不便
Scrapyd Scrapy 项目 HTTP API(默认 6800 端口) 需配合 crontab 或插件 支持远程部署、启动、停止与状态查询 仅适用于 Scrapy,自身无调度能力
Docker / Docker Compose 爬虫及其依赖的数据库 容器编排命令 由外部调度或 entrypoint 控制 环境一致,依赖一并交付,迁移成本低 需要额外的镜像构建与学习成本
定时任务(crontab / APScheduler) 周期性采集 系统配置或 Python 代码 本身即为调度 实现计划执行与增量采集 需要自行处理日志与失败重试

5.1 Linux 基础操作

爬虫的最终运行环境通常为 Linux 服务器,以下命令覆盖部署过程中的日常操作。

bash 复制代码
# 文件和目录
ls -la              # 查看文件详情
cd /home/project    # 切换目录
pwd                 # 显示当前路径
mkdir spider        # 创建目录
cp file1 file2      # 复制文件
mv file1 dir/       # 移动文件
rm -rf dir/         # 递归删除目录,操作不可逆,执行前确认路径

# 查看文件
cat file.txt        # 输出全部内容
tail -f file.txt    # 实时跟踪末尾新增内容,用于查看日志
less file.txt       # 分页查看

# 权限
chmod +x script.sh  # 添加执行权限
chown user:group file  # 修改文件所有者与所属组

# 进程
ps aux              # 查看所有进程
kill PID            # 终止指定进程
top                 # 系统资源监控
nohup command &     # 后台运行,终端关闭后进程继续

# 网络
curl https://example.com  # 发起请求,用于接口连通性测试
wget https://example.com  # 下载文件

5.2 部署方式一:直接运行加 nohup

改造成本最低的方案,适用于单脚本、单项目的场景。nohup 使进程脱离终端,输出重定向到日志文件,末尾的 & 将任务放入后台。

bash 复制代码
# 后台运行爬虫,标准输出与错误输出均写入日志文件
nohup python my_spider.py > spider.log 2>&1 &

# 查看日志
tail -f spider.log

# 停止爬虫
ps aux | grep spider.py  # 查找进程号 PID
kill PID                 # 按进程号终止

> spider.log 2>&1 中,2>&1 表示将标准错误合并到标准输出,否则异常堆栈不会进入日志文件,故障难以定位。

5.3 部署方式二:Scrapyd

Scrapyd 是 Scrapy 生态的部署服务,通过 HTTP API 完成项目的上传、启动、停止与状态查询,适合集中管理多个 Scrapy 项目。

bash 复制代码
# 安装
pip install scrapyd

# 启动服务,默认监听 http://localhost:6800
scrapyd

部署需要安装客户端工具,并在 Scrapy 项目的 scrapy.cfg 中增加部署目标配置。

bash 复制代码
# 安装部署客户端
pip install scrapyd-client

# 在 Scrapy 项目根目录下执行部署,prod 为配置中的目标名称
scrapyd-deploy prod -p my_spider

scrapy.cfg 中追加的配置内容:

text 复制代码
[deploy:prod]
url = http://localhost:6800/
project = my_spider

服务端通过 HTTP 接口管理,以下命令均在服务器或运维机器上执行。

bash 复制代码
# 列出所有项目
curl http://localhost:6800/listprojects.json

# 启动爬虫
curl http://localhost:6800/schedule.json \
    -d project=my_spider \
    -d spider=example

# 停止爬虫,job 为启动接口返回的任务 ID
curl http://localhost:6800/cancel.json \
    -d project=my_spider \
    -d job=JOB_ID

# 查看运行状态
curl http://localhost:6800/listjobs.json?project=my_spider

Scrapyd 默认仅监听本机地址,跨机器访问需修改其配置文件中的 http_port 与绑定地址,并通过防火墙限制可访问来源;服务本身不含鉴权,不应直接暴露于公网。

5.4 部署方式三:Docker 容器化

容器化(Containerization)将代码、依赖与运行环境打包为镜像,使爬虫在不同服务器上获得一致的运行结果,排除环境差异导致的故障。

镜像构建文件如下。

dockerfile 复制代码
# Dockerfile
FROM python:3.11-slim

# 设置容器内工作目录
WORKDIR /app

# 先复制依赖清单并安装,利用构建缓存避免代码变动时重复安装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制项目代码
COPY . .

# 容器启动时执行的命令
CMD ["python", "run_spider.py"]

依赖清单 requirements.txt 固定版本号,保证构建结果可复现。

text 复制代码
requests==2.31.0
lxml==4.9.3
pymongo==4.5.0
redis==5.0.0
scrapy==2.11.0

爬虫、MongoDB 与 Redis 需要共同运行,使用 Docker Compose 一次性编排三个服务。

yaml 复制代码
# docker-compose.yml
version: '3.8'

services:
  # MongoDB 服务
  mongodb:
    image: mongo:7
    restart: always
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db

  # Redis 服务
  redis:
    image: redis:7
    restart: always
    ports:
      - "6379:6379"

  # 爬虫服务
  spider:
    build: .
    depends_on:
      - mongodb
      - redis
    environment:
      - MONGO_URI=mongodb://mongodb:27017
      - REDIS_HOST=redis
    restart: always
    # 需要定时执行时,将 entrypoint 指向调度脚本

volumes:
  mongo_data:

restart: always 使容器异常退出后自动重启;mongo_data 命名卷将数据库文件留在宿主机,容器重建不会丢失数据。容器之间以服务名互相访问,因此 MONGO_URI 中的主机名为 mongodb 而非 localhost。

bash 复制代码
# 构建并启动所有服务,-d 表示后台运行
docker-compose up -d

# 查看指定服务的日志
docker-compose logs -f spider

# 停止并移除所有服务
docker-compose down

5.5 定时运行

方式一:Linux crontab

crontab 是系统自带的定时任务工具,字段顺序为"分 时 日 月 周 命令"。

bash 复制代码
# 编辑当前用户的定时任务
crontab -e

# 查看已配置的任务
crontab -l

任务条目写法如下:

text 复制代码
# 每天凌晨 2 点运行爬虫
# 格式:分 时 日 月 周 命令
0 2 * * * cd /home/project/spider && /usr/bin/python run_spider.py >> spider.log 2>&1

# 每 6 小时运行一次
0 */6 * * * cd /home/project/spider && /usr/bin/python run_spider.py >> spider.log 2>&1

# 每周一早上 9 点运行
0 9 * * 1 cd /home/project/spider && /usr/bin/python run_spider.py >> spider.log 2>&1

三条约束必须遵守:命令中的解释器与路径一律使用绝对路径,crontab 执行时不加载用户 shell 的环境变量;命令前先用 cd 切换到项目目录,否则相对路径与日志写入位置不可控;输出必须重定向追加到日志文件,否则错误信息无处可查。

方式二:APScheduler(Python 定时任务)

不便于使用 crontab 时(例如 Windows 环境,或调度逻辑需要写在程序内部),可使用 APScheduler。

python 复制代码
"""
APScheduler:在 Python 进程内实现定时调度,跨平台可用
"""

# pip install apscheduler

from apscheduler.schedulers.blocking import BlockingScheduler
import requests
import json
from datetime import datetime


def crawl_job():
    """定时执行的爬虫任务"""
    print(f"[{datetime.now()}] 开始抓取")
    try:
        response = requests.get("https://example.com/data", timeout=10)
        data = response.json()
        with open(f"data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f"抓取完成,共 {len(data)} 条数据")
    except Exception as e:
        print(f"抓取失败: {e}")


# 创建调度器
scheduler = BlockingScheduler()

# 每天凌晨 2 点运行
scheduler.add_job(crawl_job, "cron", hour=2, minute=0)

# 每 6 小时运行一次
# scheduler.add_job(crawl_job, "interval", hours=6)

# 每周一早上 9 点运行
# scheduler.add_job(crawl_job, "cron", day_of_week="mon", hour=9, minute=0)

print("定时任务已启动")
scheduler.start()

BlockingScheduler 会阻塞当前线程并保持进程存活,因此该脚本自身需要以后台方式常驻运行,它替代的是任务内部的调度逻辑,而非 nohup 或 Docker 的进程守护。cron 触发器用于固定时刻执行,interval 触发器用于固定间隔执行。

5.6 完整部署脚本示例

以下脚本整合日志、MongoDB 存储与异常处理,构成一个可直接上线的最小实现,配合 crontab 或 APScheduler 即可实现无人值守采集。

python 复制代码
"""
完整的上线方案:爬虫 + MongoDB + 定时任务
"""
import logging
from datetime import datetime
from pymongo import MongoClient, errors

# 配置日志,同时输出到文件与标准输出
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("crawler.log", encoding="utf-8"),
        logging.StreamHandler(),
    ]
)
logger = logging.getLogger(__name__)


class ProductionCrawler:
    """生产环境爬虫"""

    def __init__(self):
        # MongoDB 连接参数
        self.mongo_uri = "mongodb://localhost:27017"
        self.db_name = "spider_prod"
        self.collection_name = "data"
        self.client = None
        self.collection = None

    def connect_db(self):
        """连接数据库并创建唯一索引"""
        self.client = MongoClient(self.mongo_uri)
        self.collection = self.client[self.db_name][self.collection_name]
        self.collection.create_index("url", unique=True)
        logger.info("MongoDB 连接成功")

    def fetch_data(self):
        """抓取数据,此处为示例数据,实际替换为抓取逻辑"""
        logger.info("开始抓取数据")
        results = []
        for i in range(5):
            results.append({
                "title": f"数据 {i}",
                "value": i * 10,
                "url": f"https://example.com/{i}",
                "crawled_at": datetime.now().isoformat(),
            })
        return results

    def save_data(self, items):
        """保存数据到 MongoDB,区分新增与更新数量"""
        saved = 0
        updated = 0
        for item in items:
            try:
                self.collection.insert_one(item)
                saved += 1
            except errors.DuplicateKeyError:
                self.collection.update_one(
                    {"url": item["url"]},
                    {"$set": item}
                )
                updated += 1

        logger.info(f"新增 {saved} 条,更新 {updated} 条")

    def run(self):
        """执行一次完整采集流程"""
        try:
            self.connect_db()
            data = self.fetch_data()
            self.save_data(data)
            logger.info("本次运行完成")
        except Exception as e:
            logger.error(f"运行失败: {e}", exc_info=True)
        finally:
            if self.client:
                self.client.close()


# 直接运行一次
if __name__ == "__main__":
    crawler = ProductionCrawler()
    crawler.run()

# 配合 APScheduler 定时运行
# scheduler = BlockingScheduler()
# scheduler.add_job(lambda: ProductionCrawler().run(), "interval", hours=6)
# scheduler.start()

该结构包含四项生产要素:日志记录(exc_info=True 保留异常堆栈)、连接释放(置于 finally)、幂等写入(唯一索引加更新策略)、运行统计(新增与更新条数)。接入调度时以 run 方法为任务入口,数据库地址等配置由环境变量注入而非硬编码。

  • 要点:四种部署方式的差异在于管理入口与适用对象,脚本用 nohup,Scrapy 项目用 Scrapyd,环境复杂用 Docker,周期执行用定时任务。
  • 要点 :日志重定向是部署的必要环节,2>&1 与 >> 决定故障能否被查到。
  • 要点:crontab 中的命令必须使用绝对路径并显式切换目录,因其不继承交互式 shell 的环境变量。
  • 要点:Docker Compose 内服务以名称互相访问,数据目录需挂载命名卷以避免容器重建后丢失。
  • 要点:生产脚本应包含日志、连接释放、幂等写入与运行统计四项要素。

6. 学习建议

  1. 从文件存储起步:JSON 与 CSV 在开发阶段完全可用,数据库应在查询与增量需求出现后引入,避免过早增加链路复杂度。
  2. 优先掌握 MongoDB:数据格式与爬虫输出天然匹配,无需建表且写入速度快,推荐作为爬虫数据存储的首选。
  3. Redis 用于去重与队列:Redis 并非必选项,但采集百万级 URL 时,其去重能力是文件方案无法达到的。
  4. Docker 是当前部署的标准做法:容器化本身有学习成本,收益在于消除"本机能运行、服务器不能运行"的环境差异问题。
  5. 定时任务必须写日志 :部署到服务器后无法实时看到输出,缺少 logging 时故障无从定位。
  6. 规划监控与告警:生产环境爬虫需要提前确定三类异常的处理方式:进程退出后的自动重启、数据量异常波动的告警通知、IP 被封禁后的代理自动切换。
  • 要点:引入顺序为文件存储、MongoDB、Redis、部署与调度,每一步由实际需求触发。
  • 要点:日志、监控与自动恢复属于部署的一部分,与代码同等重要。
  • 要点:本手册的七个阶段至此完成,后续能力扩展方向见下一节。

阶段小结

知识模块 核心要点 在爬虫中的作用
存储方案选型 数据规模、结构稳定性、访问模式三项判断;文件 / MySQL / PostgreSQL / MongoDB / Redis 对比;三类存储的组合与数据流向 确定数据落库位置与链路,避免以文件承载结构化查询
MySQL pymysql 建表与增删改查;%s 参数化;commit 与事务;上下文管理器;SQLAlchemy 模型与会话;ON DUPLICATE KEY UPDATE 存放清洗后的结构化数据,支撑条件查询与统计聚合
MongoDB 文档模型与灵活模式;pymongo 插入 / 查询 / 更新 / 删除;唯一索引;$gt 与 $set 等操作符;Pipeline 与独立存储类 存放原始抓取数据,承担高吞吐写入与字段可变场景
Redis String / List / Set 三类结构;incr 原子计数;brpop 阻塞队列;sadd 去重;URL 去重器与代理池封装 提供 URL 去重、请求队列、代理池与计数器,支撑分布式协作
部署方案 Linux 常用命令;nohup 后台运行;Scrapyd 的 HTTP API;Dockerfile 与 docker-compose 编排 使爬虫脱离本机环境,在服务器上持续运行
定时调度与生产脚本 crontab 表达式与绝对路径约束;APScheduler 的 cron 与 interval 触发器;日志、连接释放、幂等写入 实现按计划自动采集,并保证异常可追溯、数据可重跑

常见问题与排查

现象 原因 处理方式
长时间运行后报 MySQL server has gone away 或连接超时 单条连接空闲超过服务端 wait_timeout 后被回收,Pipeline 仍在使用失效连接 在 open_spider 中重建连接,或改用 SQLAlchemy 并设置 pool_pre_ping=True、pool_recycle=3600
并发写入时报 QueuePool limit of size ... reached 每个线程或协程独占连接且未及时归还,连接池被耗尽 显式设定 pool_size 与 max_overflow,操作结束调用 close,统一用上下文管理器或会话托管连接
中文写入后显示为 ??? 或乱码 连接、表或列的字符集不是 utf8mb4,仅靠 charset 参数不足以覆盖已存在的表结构 连接设置 charset="utf8mb4",同时确认表与列的字符集及排序规则为 utf8mb4
insert_many 报 BulkWriteError,部分文档未写入 默认按顺序写入,中途遇到唯一索引冲突即终止后续写入 传入 ordered=False 使冲突文档被跳过后继续,或改用 update_one(..., upsert=True) 逐条幂等写入
crontab 配置后任务未执行或无输出 使用了相对路径或依赖交互 shell 的环境变量,PATH 中找不到 python;目录未切换;输出无处可查 解释器与脚本均写绝对路径,命令前 cd 到项目目录,用 >> spider.log 2>&1 记录输出,并以 crontab -l 核对配置
Docker 容器内的爬虫无法连接 MongoDB 或 Redis 容器内使用 localhost 指向自身而非数据库服务,或三者不在同一网络 主机名改用 Compose 服务名(如 mongodb、redis),配置 depends_on,确认镜像与数据库服务处于同一网络

合辑收尾与进阶方向

本阶段是《Python 爬虫阶段学习手册》的终点:七个阶段依次完成语言底座、网络与静态抓取、动态页面与浏览器自动化、并发与性能、Scrapy 框架、反爬与 JS 逆向、数据存储与部署,构成从取数到落库再到上线的完整链路。

后续进阶可沿四条方向展开:其一,分布式爬虫,以 Scrapy-Redis 共享请求队列与去重集合,把采集能力横向扩展到多机;其二,数据管道,引入消息队列与 ETL 流程,使原始数据到结构化数据的清洗环节自动化;其三,调度平台,使用 Celery、Airflow 等工具替代 crontab,获得依赖编排、失败重试与任务告警能力;其四,采集治理,完善代理管理、指纹去重、数据质量校验与监控告警,使采集体系具备长期可运维性。

技术方案之外仍需守住边界:遵守 robots.txt、控制请求频率、不采集个人隐私与授权范围外的数据,是任何阶段都不应让出的前提。

相关推荐
天天被压力1 小时前
【Python量化实战宝典 #03】指数与板块行情接口全拆解:大盘·行业·概念一键取(Python 可跑)
java·人工智能·python
PegasusYu1 小时前
Node.js使用nedb轻量级数据库测试脚本
数据库·electron·node.js·脚本·测试·轻量级·nedb
weixin_461769401 小时前
conda create 创建容器 Failed to resolve ‘repo.anaconda.com‘问题
开发语言·python·conda·repo·网络问题·无法创建
Want5951 小时前
Python生日蛋糕
开发语言·python
小马不起床1 小时前
Django 后端实战指南_5:实现 JWT 登录
python·django·sqlite
AI源码1 小时前
PC 手机电商(MySQL 版)
数据库·mysql·智能手机
FOAF-lambda1 小时前
winuia-auto 截图全攻略(ToBitmap、Screenshot 与 CaptureToImage 详解)
windows·python·自动化
郝学胜-神的一滴1 小时前
金融数据分析实战:从数据清洗到策略回测,Python全链路落地
开发语言·python·程序人生·金融·数据挖掘·数据分析
茉莉玫瑰花茶1 小时前
GO [ context 原理解析 ]
数据库·oracle·iphone