更讽刺的是,第二天我想把这本《DDIA》分享给同事,找了半小时没找到,最后在某个名为“学习资料勿删”的目录里翻出来一个扫描版,文件名还是乱码。

那一刻我意识到:存储不等于拥有,索引才是真正的拥有。

于是有了这个周末+业余时间折腾的项目——新城书站book.cndgn.com)。今天不聊虚的,纯粹复盘一下我如何用开源工具链,把这堆烂摊子收拾成可检索、可分享的图书站。

1 (10)low

0x01 问题拆解:从混沌到结构化

我的硬盘里有约8000本技术电子书,主要问题如下:

| 问题类型 | 具体表现 | 技术本质 |
||||
| 命名混乱 | 同一本书有N种文件名 | 非结构化数据 |
| 元数据缺失 | 看不到出版社、年份、ISBN | 信息不完备 |
| 重复存储 | 多版本散落各处 | 数据冗余 |
| 检索困难 | 只能靠Windows搜索 | 缺少索引 |

要解决这些问题,我需要一套数据清洗流水线,把非结构化的文件名转化成结构化的元数据,再建立高效的检索系统。

0x02 工具链选型:开源优先

既然是程序员,能自己动手的就自己动手,能用开源的绝不用商业闭源。我选了这么一套组合:

核心工具清单

数据采集层
  ├── Python 3.10(胶水语言,粘合一切)
  ├── watchdog(监控文件变化)
  ├── PyPDF2 / ebooklib(提取PDF/EPUB元数据)
  └── Scrapy(爬取豆瓣/Google Books补全信息)

数据存储层
  ├── MySQL 8.0(存元数据)
  ├── Redis 7.0(缓存热点数据)
  ├── MinIO(分布式对象存储,存文件本体)
  └── Elasticsearch 7.17(全文检索)

服务层
  ├── Flask(轻量级Web框架)
  ├── Gunicorn(WSGI服务器)
  └── Celery(异步任务处理)

部署层
  ├── Docker + Docker Compose(容器化)
  └── Nginx(反向代理+负载均衡)

这套选型的思路是:每个组件只做一件事,但做到极致。

0x03 数据清洗:和文件名斗智斗勇

3.1 暴力扫描

第一步先把硬盘里所有电子书扫出来:

import os
import hashlib
from pathlib import Path

def scan_books(root_dir):
    """递归扫描目录,收集所有电子书文件"""
    books = []
    for file_path in Path(root_dir).rglob(''):
        if file_path.suffix.lower() in ['.pdf', '.epub', '.mobi']:
            stat = file_path.stat()
             计算文件哈希,用于去重
            with open(file_path, 'rb') as f:
                file_hash = hashlib.md5(f.read(8192)).hexdigest()
            
            books.append({
                'path': str(file_path),
                'name': file_path.name,
                'size': stat.st_size,
                'format': file_path.suffix[1:].lower(),
                'hash': file_hash,
                'mtime': stat.st_mtime
            })
    return books

跑一遍,得到约8000条记录,存到MySQL里。

3.2 文件名解析——最恶心的一步

文件名长啥样的都有,举几个真实例子:

📁 乱七八糟的文件名集锦/
├── [高清]Python核心编程(第3版).pdf
├── Go语言实战  李兆海译.epub
├── Designing DataIntensive Applications  Martin Kleppmann  2017.pdf
├── 深入理解计算机系统(原书第3版) 兰德尔·E·布莱恩特 机械工业出版社.epub
├── 算法(第4版).[美]Robert Sedgewick.[美]Kevin Wayne.epub
├── 49961081《代码大全2》.pdf
└── 新建文本文档.txt(里面写着“这本书不错,留着”)

写个通用的正则解析器不太现实,我用了分层解析策略:

import re

class BookNameParser:
    def __init__(self):
        self.patterns = [
             模式1: [作者] 书名 (年份)
            (r'^\[(.?)\](?:[_\s])(.?)(?:[_\s][(\[]?(\d{4})[)\]]?)?$',
             lambda m: {'author': m.group(1), 'title': m.group(2), 'year': m.group(3)}),
            
             模式2: 书名  作者  年份
            (r'^(.?)[_]\s(.?)[_]\s(\d{4})$',
             lambda m: {'title': m.group(1), 'author': m.group(2), 'year': m.group(3)}),
            
             模式3: 书名 (作者,出版社,年份)
            (r'^(.?)[(\[]?(.?)[,、,]\s(.?)[,、,]\s(\d{4})[)\]]?$',
             lambda m: {'title': m.group(1), 'author': m.group(2), 
                       'publisher': m.group(3), 'year': m.group(4)}),
            
             模式4: ISBN号开头的那种
            (r'^(\d{13}|\d{10})[_\s](.?)(?:\.\w+$)?$',
             lambda m: {'isbn': m.group(1), 'title': m.group(2)}),
        ]
    
    def parse(self, filename):
         去掉扩展名
        name = re.sub(r'\.(pdf|epub|mobi)$', '', filename, flags=re.I)
        
         尝试各种模式
        for pattern, handler in self.patterns:
            m = re.match(pattern, name)
            if m:
                return handler(m)
        
         都匹配不上,整个当书名
        return {'title': name.strip()}

这个解析器覆盖了大约70%的情况,剩下的30%需要人工干预或者靠API补全。

3.3 API补全:给书办身份证

解析出来的信息是猜测的,需要通过外部API验证和补全。我用了豆瓣API + Google Books API双保险:

import requests
import time
from threading import Semaphore

class BookEnricher:
    def __init__(self, qps=1):
        self.sem = Semaphore(qps)   控制请求频率
        
    def enrich(self, title, author=None, isbn=None):
        """多源补全,优先用ISBN"""
        if isbn:
            result = self._query_by_isbn(isbn)
            if result:
                return result
        
        with self.sem:
             豆瓣API(中文书优先)
            result = self._query_douban(title, author)
            if result:
                return result
            
             Google Books(英文书兜底)
            result = self._query_google_books(title, author)
            return result
    
    def _query_douban(self, title, author):
        url = 'https://api.douban.com/v2/book/search'
        params = {'q': title}
        if author:
            params['q'] += f' {author}'
        
        try:
            resp = requests.get(url, params=params, timeout=5)
            if resp.status_code == 200:
                data = resp.json()
                if data.get('total', 0) > 0:
                    book = data['books'][0]
                    return {
                        'title': book.get('title'),
                        'author': ' / '.join(book.get('author', [])),
                        'publisher': book.get('publisher'),
                        'pubdate': book.get('pubdate'),
                        'isbn': book.get('isbn13') or book.get('isbn10'),
                        'pages': book.get('pages'),
                        'summary': book.get('summary'),
                        'cover': book.get('image')
                    }
            time.sleep(1)   礼貌限流
        except Exception as e:
            print(f'豆瓣API调用失败: {e}')
        return None

这里有个细节:API调用要限流,否则IP容易被封。我用Semaphore控制每秒不超过1次请求,配合指数退避重试。

0x04 存储设计:告别文件系统

4.1 元数据存MySQL

表结构设计如下:

CREATE TABLE books (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    title_zh VARCHAR(255),
    author VARCHAR(255),
    translator VARCHAR(255),
    publisher VARCHAR(255),
    pubdate DATE,
    isbn VARCHAR(20),
    language VARCHAR(50),
    pages INT,
    format VARCHAR(10),
    file_hash VARCHAR(32) UNIQUE,   用于去重
    file_size INT,
    minio_path VARCHAR(500),        MinIO中的存储路径
    cover_url VARCHAR(500),
    description TEXT,
    tags VARCHAR(255),
    source INT,                       来源:1=硬盘扫描 2=用户上传
    status TINYINT DEFAULT 1,         1=正常 0=下架
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_title (title),
    INDEX idx_author (author),
    INDEX idx_publisher (publisher),
    INDEX idx_isbn (isbn),
    INDEX idx_file_hash (file_hash),
    FULLTEXT INDEX ft_title_author_desc (title, author, description)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意file_hash字段,用来去重。同一本书的不同版本可以通过ISBN聚合,但完全相同的文件用哈希去重。

4.2 文件本体存MinIO

为什么不用本地存储?
本地磁盘空间有限
备份麻烦
不支持HTTP断点续传

MinIO是开源的对象存储,兼容S3 API,几行代码就能集成:

from minio import Minio

client = Minio(
    'minio.book.cndgn.com:9000',
    access_key='YOUR_ACCESS_KEY',
    secret_key='YOUR_SECRET_KEY',
    secure=True
)

def upload_to_minio(local_path, book_id):
    """上传文件到MinIO"""
    bucket_name = 'books'
    object_name = f'{book_id}/{os.path.basename(local_path)}'
    
     确保bucket存在
    if not client.bucket_exists(bucket_name):
        client.make_bucket(bucket_name)
    
     上传文件
    result = client.fput_object(
        bucket_name, 
        object_name, 
        local_path,
        metadata={'book_id': str(book_id)}
    )
    return object_name

MinIO还支持HTTP Range请求,可以实现断点续传,对大文件下载很友好。

0x05 搜索实现:从LIKE到ES

5.1 第一版:MySQL LIKE(太慢)

def search_like(keyword):
    sql = """
        SELECT  FROM books 
        WHERE title LIKE %s 
           OR author LIKE %s 
           OR publisher LIKE %s
    """
    pattern = f'%{keyword}%'
     执行查询... 耗时300ms+

数据量上来后,全表扫描扛不住。

5.2 第二版:MySQL全文索引(快了点,但权重难控)

ALTER TABLE books ADD FULLTEXT INDEX ft_idx (title, author, description);

查询速度上来了,但权重不可控。有时候描述字段匹配的比书名匹配的还靠前。

5.3 第三版:Elasticsearch(最终方案)

上ES后,搜索接口清爽多了:

from elasticsearch import Elasticsearch

es = Elasticsearch(['http://localhost:9200'])

def search_books(keyword, page=1, size=20):
    body = {
        'query': {
            'bool': {
                'should': [
                    {'match': {'title': {'query': keyword, 'boost': 10}}},
                    {'match': {'author': {'query': keyword, 'boost': 8}}},
                    {'match': {'publisher': {'query': keyword, 'boost': 5}}},
                    {'match': {'description': {'query': keyword, 'boost': 3}}},
                    {'match': {'tags': {'query': keyword, 'boost': 2}}}
                ]
            }
        },
        'from': (page  1)  size,
        'size': size,
        'sort': [
            {'_score': 'desc'},
            {'pubdate': 'desc'}
        ],
        'highlight': {
            'fields': {
                'title': {},
                'description': {}
            }
        }
    }
    
    resp = es.search(index='books', body=body)
    return {
        'total': resp['hits']['total']['value'],
        'books': [hit['_source'] for hit in resp['hits']['hits']],
        'highlights': [hit.get('highlight', {}) for hit in resp['hits']['hits']]
    }

ES支持自定义分词器,对中文专业术语(如“分布式锁”、“共识算法”)可以扩展词典,提升检索精度。

0x06 下载与安全:无注册也能防滥用

6.1 一次性下载Token

站点是免注册的,但直接暴露文件地址会被爬虫盗刷流量。我用一次性Token做防护:

import hashlib
import hmac
import time

class DownloadToken:
    def __init__(self, secret_key):
        self.secret = secret_key.encode()
    
    def generate(self, book_id, user_ip):
        """生成5分钟有效的下载Token"""
        timestamp = str(int(time.time()))
        message = f"{book_id}:{user_ip}:{timestamp}"
        token = hmac.new(
            self.secret, 
            message.encode(), 
            hashlib.sha256
        ).hexdigest()
        return f"{timestamp}.{token}"
    
    def verify(self, token, book_id, user_ip):
        """验证Token"""
        try:
            timestamp, signature = token.split('.')
             5分钟有效期
            if int(time.time())  int(timestamp) > 300:
                return False
            expected = self.generate(book_id, user_ip).split('.')[1]
            return hmac.compare_digest(signature, expected)
        except:
            return False

用户点击下载时,后端生成Token,重定向到/download/{book_id}?token=xxx,验证通过后才从MinIO获取文件。

6.2 IP限流

用Redis做分布式限流:

import redis
import time

r = redis.Redis(host='localhost', port=6379, db=0)

def rate_limit(ip, limit=60, window=60):
    """每分钟最多limit次请求"""
    key = f'rate:{ip}:{int(time.time() / window)}'
    count = r.incr(key)
    if count == 1:
        r.expire(key, window + 5)   多留5秒冗余
    return count <= limit

单个IP每分钟超过60次请求,直接返回429 Too Many Requests。

6.3 robots.txt配置

在根目录放robots.txt,礼貌地告诉爬虫哪些目录不能抓:

Useragent: 
Disallow: /download/
Disallow: /admin/
Allow: /

Sitemap: https://book.cndgn.com/sitemap.xml

0x07 容器化部署:告别环境依赖

7.1 Docker Compose编排

所有服务用Docker Compose管理:

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: book_mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE: bookdb
    volumes:
       ./data/mysql:/var/lib/mysql
       ./mysql/conf.d:/etc/mysql/conf.d   自定义配置
    ports:
       "3306:3306"
    networks:
       book_net

  redis:
    image: redis:7alpine
    container_name: book_redis
    restart: always
    ports:
       "6379:6379"
    networks:
       book_net

  minio:
    image: minio/minio:latest
    container_name: book_minio
    restart: always
    command: server /data consoleaddress ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
    volumes:
       ./data/minio:/data
    ports:
       "9000:9000"
       "9001:9001"
    networks:
       book_net

  elasticsearch:
    image: elasticsearch:7.17.0
    container_name: book_es
    restart: always
    environment:
       discovery.type=singlenode
       ES_JAVA_OPTS=Xms512m Xmx512m
       ELASTIC_PASSWORD=${ES_PASSWORD}
    volumes:
       ./data/es:/usr/share/elasticsearch/data
    ports:
       "9200:9200"
    networks:
       book_net

  web:
    build: ./web
    container_name: book_web
    restart: always
    depends_on:
       mysql
       redis
       minio
       elasticsearch
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis
      MINIO_HOST: minio
      ES_HOST: elasticsearch
    volumes:
       ./web:/app
    ports:
       "5000:5000"
    networks:
       book_net

  nginx:
    image: nginx:1.23alpine
    container_name: book_nginx
    restart: always
    depends_on:
       web
    volumes:
       ./nginx/conf.d:/etc/nginx/conf.d
       ./nginx/ssl:/etc/nginx/ssl
       ./static:/usr/share/nginx/html/static
    ports:
       "80:80"
       "443:443"
    networks:
       book_net

networks:
  book_net:
    driver: bridge

7.2 踩过的坑

  1. ES内存配置:默认JVM堆内存1G,小服务器撑不住。改成512M才跑起来。
  2. MySQL字符集:默认不是utf8mb4,中文emoji插不进去。要在配置里强制指定。
  3. MinIO权限:匿名访问要开Bucket Policy,否则下载链接无效。
  4. 容器间通信:用服务名(如mysql)而不是localhost

0x08 数据看板与效果

经过两个月断断续续的折腾,最终成果:

📚 图书总量:12480本
🔍 可检索字段:书名、作者、出版社、ISBN、标签、描述
⚡ 平均检索时间:<50ms
📦 存储体积:约320GB(MinIO中)
🚫 重复文件去重率:23%(原本有大量重复)

站点跑在腾讯云2C4G的轻量服务器上,每天几百访问,负载常年低于1.0。

0x09 一些思考

9.1 为什么要开源优先?

整个项目除了域名和服务器,没花一分钱软件授权费。MySQL、Redis、MinIO、Elasticsearch、Python、Flask全是开源软件,社区活跃、文档齐全、坑有人填。开源不是免费,而是降低了试错成本。

9.2 程序员的知识库应该是什么样?

我理想中的知识库应该是:
可检索的(不是靠记忆文件名)
结构化的(有元数据,能按作者/出版社筛选)
可分享的(一键生成分享链接,不用传文件)
版本可控的(同一本书的不同版本能区分)

9.3 下一步计划

AI辅助分类:用BERT模型对无标签图书自动打标签
社区贡献:开放用户上传,用Celery异步处理入库
水印溯源:对下载文件加盲水印,防滥用

0x0A 写在最后

如果你也被自己的电子书库困扰,不妨动手折腾一下。

不需要一开始就想做个大平台,先解决自己的问题:把硬盘里的文件扫一遍,去个重,写个简单的Web界面能搜就行。慢慢迭代,慢慢完善。

新城书站book.cndgn.com) 还在持续建设中,代码我会逐步整理开源。如果你有想找的书找不到,或者对某个技术细节感兴趣,欢迎评论区交流。

代码改变世界,先从整理自己的知识库开始。

【附:开源计划】

核心Python脚本已整理成独立仓库,包含:
file_scanner/:文件扫描与哈希去重
filename_parser/:文件名解析器(支持正则扩展)
book_enricher/:豆瓣/Google Books API封装
minio_utils/:MinIO上传下载工具类

GitHub搜bookmetatoolkit可找到,欢迎Star和PR。

posted on 2026-03-06 12:41  yqqwe  阅读(1)  评论(0)    收藏  举报