如果你以为搭建一个图书网站最难的是写代码,那说明你还没真正上线过一个项目。

静流书站book.coffeedeals.club)最早的数据来源,是我从各种公开渠道爬下来的公开书单。爬虫跑了一周,硬盘里躺了200万条图书记录,我以为接下来就是喝着咖啡调调CSS,岁月静好。结果打开数据库一看,差点当场离职。

这哪是数据,简直是垃圾场。
6low

第一回合:ISBN不止是数字

先给大家看看我遇到的第一类“惊喜”。下面这段代码是我最早期的数据模型,看起来没什么问题对吧?

 早期的错误示范
class Book(models.Model):
    isbn = models.CharField(max_length=20)   没加unique
    title = models.CharField(max_length=200)
     ...

就是这行没加uniqueisbn,让我吃尽了苦头。同一个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)数据量稳定在几十万册,覆盖了主流出版社的推荐书单和各大榜单。首页不搞什么个性化推荐算法,就是最简单的新书和好评榜——因为我始终觉得,发现好书应该是一种惊喜,而不是被算法算计。

写给想自己动手的朋友

如果你也想搭一个类似的内容站,我的建议是:

  1. 先想清楚数据来源:是爬公开数据,还是自己录入?数据清洗的坑,比写代码的坑多得多。
  2. 别急着上微服务:Django单体应用跑个几十万数据完全没问题,先把业务跑通再说。
  3. 缓存能解决90%的性能问题:能用Redis扛的,就别急着上复杂的搜索引擎。

最后,代码都在慢慢优化中,如果你对这个项目的技术细节感兴趣,或者发现了什么bug,欢迎在评论区交流。也欢迎来静流书站逛逛,看看有没有你喜欢的书。

posted on 2026-03-03 13:29  yqqwe  阅读(1)  评论(0)    收藏  举报