更讽刺的是,第二天我想把这本《DDIA》分享给同事,找了半小时没找到,最后在某个名为“学习资料勿删”的目录里翻出来一个扫描版,文件名还是乱码。
那一刻我意识到:存储不等于拥有,索引才是真正的拥有。
于是有了这个周末+业余时间折腾的项目——新城书站(book.cndgn.com)。今天不聊虚的,纯粹复盘一下我如何用开源工具链,把这堆烂摊子收拾成可检索、可分享的图书站。

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 踩过的坑
- ES内存配置:默认JVM堆内存1G,小服务器撑不住。改成512M才跑起来。
- MySQL字符集:默认不是utf8mb4,中文emoji插不进去。要在配置里强制指定。
- MinIO权限:匿名访问要开Bucket Policy,否则下载链接无效。
- 容器间通信:用服务名(如
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。
浙公网安备 33010602011771号