如果你以为搭建一个图书网站最难的是写代码,那说明你还没真正上线过一个项目。
静流书站(book.coffeedeals.club)最早的数据来源,是我从各种公开渠道爬下来的公开书单。爬虫跑了一周,硬盘里躺了200万条图书记录,我以为接下来就是喝着咖啡调调CSS,岁月静好。结果打开数据库一看,差点当场离职。
这哪是数据,简直是垃圾场。

第一回合:ISBN不止是数字
先给大家看看我遇到的第一类“惊喜”。下面这段代码是我最早期的数据模型,看起来没什么问题对吧?
早期的错误示范
class Book(models.Model):
isbn = models.CharField(max_length=20) 没加unique
title = models.CharField(max_length=200)
...
就是这行没加unique的isbn,让我吃尽了苦头。同一个ISBN的《三体》,在数据库里出现了17次——有的是爬虫重复抓取,有的是不同源站的数据打架,还有的是ISBN字段被错误解析成了其他编号。
更坑爹的是,有些网站的ISBN字段里居然混进了“ISBN”这个前缀。比如“ISBN 9787536692930”,存进数据库的时候连前缀一起存了。这就导致同一个书,在A源是“9787536692930”,在B源是“ISBN 9787536692930”,程序根本认不出它们是同一本书。
清洗的第一步,我写了个简单的标准化函数:
def clean_isbn(raw_isbn):
"""
清洗ISBN数据:只保留数字,去除常见前缀
"""
if not raw_isbn:
return None
转为字符串,去除空格和常见分隔符
raw = str(raw_isbn).strip().replace('', '').replace(' ', '')
去除常见的"ISBN"前缀(不区分大小写)
if raw.lower().startswith('isbn'):
raw = raw[4:]
只保留数字
import re
digits = re.sub(r'\D', '', raw)
ISBN10是10位,ISBN13是13位,其他的可能是脏数据
if len(digits) in (10, 13):
return digits
return None 不符合规范的就标记待人工处理
这只是一个开始。光是清洗ISBN字段,我就跑了三遍脚本,每次都会发现新的边界情况。数据清洗就是这样,你以为搞定了90%,剩下的10%能再消耗你90%的时间。
第二回合:封面图片的“盗链”危机
数据干净了,接下来是图片。
早期版本我直接引用了豆瓣的封面图链接。上线第三天,发现三分之一的封面变成了豆瓣的“403”防盗链图。这其实不怪豆瓣,任何正规网站都不会允许别人在自己的服务器上“蹭”流量。
解决方案有两个选择:一是自己存一份,二是用第三方图片托管。考虑到服务器带宽成本,我选择了折中方案——用又拍云或者七牛做图片中转,第一次访问时抓取并缓存。
这里涉及到Django信号的一个实用场景:当Book模型保存时,如果发现cover_url有变化,就触发异步下载任务。
signals.py 封面图下载触发器
from django.db.models.signals import pre_save
from django.dispatch import receiver
from .models import Book
from .tasks import download_cover_image
@receiver(pre_save, sender=Book)
def handle_cover_change(sender, instance, kwargs):
"""
如果封面URL发生变化,标记需要下载新图
"""
if not instance.pk: 新对象,直接下载
instance._cover_needs_download = True
return
try:
old = Book.objects.get(pk=instance.pk)
if old.cover_url != instance.cover_url:
instance._cover_needs_download = True
except Book.DoesNotExist:
instance._cover_needs_download = True
tasks.py 异步下载任务
@shared_task
def download_cover_image(book_id, cover_url):
"""
下载封面图并保存到本地/CDN
"""
import requests
from django.core.files.base import ContentFile
try:
resp = requests.get(cover_url, timeout=10)
if resp.status_code == 200:
book = Book.objects.get(id=book_id)
将图片保存到ImageField
filename = f"{book.isbn}.jpg"
book.cover.save(filename, ContentFile(resp.content), save=True)
return f"Downloaded cover for {book.title}"
except Exception as e:
return f"Failed: {str(e)}"
这段代码的思路是:在模型保存前判断URL是否有变化,如果有变化就在保存后异步下载新图。这样一来,运营人员在admin后台修改封面链接时,系统会自动把新图拉下来存到自己的OSS,前端直接引用本地地址,再也不用担心防盗链了。
第三回合:搜索不走寻常路
数据干净了,图片也稳定了,接下来是搜索。
Django自带的ORM搜索,说白了就是icontains,在几十万条数据上跑LIKE '%关键词%',慢得让人想摔键盘。我一开始打算上Elasticsearch,但看了眼服务器的1核2G内存,默默关掉了这个页面。
后来我用了取巧的办法:SQLite的FTS5虚拟表。
如果你用的是SQLite 3.9以上版本,它自带全文搜索扩展。我单独建了一个FTS表,把书名、作者、简介都放进去,搜索的时候直接走虚拟表,速度提升了几十倍。
初始化FTS表的代码如下:
from django.db import connection
def init_fts_table():
"""
初始化SQLite FTS5虚拟表
"""
with connection.cursor() as cursor:
创建虚拟表
cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts
USING fts5(title, author_names, summary, content=books);
""")
从主表导入数据
cursor.execute("""
INSERT INTO book_fts(rowid, title, author_names, summary)
SELECT
b.id,
b.title,
group_concat(a.name, ' '),
b.summary
FROM books_book b
LEFT JOIN books_book_authors ba ON b.id = ba.book_id
LEFT JOIN books_author a ON ba.author_id = a.id
GROUP BY b.id;
""")
搜索的时候直接:
def search_books(keyword):
with connection.cursor() as cursor:
cursor.execute("""
SELECT b.id, b.title, b.cover_url
FROM book_fts f
JOIN books_book b ON f.rowid = b.id
WHERE book_fts MATCH %s
ORDER BY rank
LIMIT 20;
""", [keyword])
return cursor.fetchall()
虽然不如Elasticsearch功能强大,但对于中小型站点完全够用,而且零额外服务依赖。
为什么静流书站值得你用?
说了这么多技术上的折腾,其实最终目的只有一个:让找书这件事变得简单一点。
现在的静流书站(book.coffeedeals.club)数据量稳定在几十万册,覆盖了主流出版社的推荐书单和各大榜单。首页不搞什么个性化推荐算法,就是最简单的新书和好评榜——因为我始终觉得,发现好书应该是一种惊喜,而不是被算法算计。
写给想自己动手的朋友
如果你也想搭一个类似的内容站,我的建议是:
- 先想清楚数据来源:是爬公开数据,还是自己录入?数据清洗的坑,比写代码的坑多得多。
- 别急着上微服务:Django单体应用跑个几十万数据完全没问题,先把业务跑通再说。
- 缓存能解决90%的性能问题:能用Redis扛的,就别急着上复杂的搜索引擎。
最后,代码都在慢慢优化中,如果你对这个项目的技术细节感兴趣,或者发现了什么bug,欢迎在评论区交流。也欢迎来静流书站逛逛,看看有没有你喜欢的书。
浙公网安备 33010602011771号