这个项目使用 ESP32-Box Lite 实现了一个 局域网小说阅读器,核心功能包括连接Wi-Fi、从本地服务器自动获取小说内容、通过触控屏幕翻页,并在小尺寸屏幕上实现流畅的阅读体验。
设计思路
考虑到ESP32性能有限,本设计将任务进行了拆分:核心的小说内容抓取任务由功能更强的服务器承担,ESP32设备主要负责GUI显示与用户交互。
- 小说抓取服务:在服务器端编写脚本,使用 Playwright 工具从指定的小说网站抓取内容。
- 本地Web服务:在服务器上搭建本地局域网Web服务,存储抓取的小说内容,并通过 JSON API 提供小说目录、章节列表及章节内容。
- 设备端阅读功能:在ESP32-Box设备上,基于HTTP协议获取数据,实现分章节阅读、触控翻页等功能。
项目的难点在于GUI设计,由于国内关于CircuitPython进行GUI开发的教程较少,其性能和潜在的兼容性问题需要自行探索。
硬件平台
本项目基于 ESP32-S3-BOX Lite 开发板。它搭载ESP32-S3 AI SoC,集成 512 KB SRAM、16 MB QSPI flash 和 8 MB Octal PSRAM。开发板自带一块2.4寸显示屏(分辨率 320 x 240),双麦克风,一个扬声器以及Type-C接口,非常适合作为智能家居控制面板或人机交互设备原型进行开发。
功能实现

- 小说抓取:针对国内小说网站常见的反爬机制,项目采用了微软开源的 Playwright 工具进行自动化抓取。它支持多浏览器引擎,能有效模拟真实用户行为。

- 目录解析与批量抓取:服务器端使用Golang读取并解析小说目录页的HTML,提取所有章节链接后,并发调用上述抓取脚本,将整本小说保存到本地。
- 阅读微服务:使用 Gin 框架搭建了一个轻量级的RESTful API服务,为设备端提供书籍列表、章节列表及章节文本内容的JSON数据接口。



- 设备端阅读:在ESP32-Box上使用CircuitPython的显示库,加载中文字体,将从服务器获取的章节文本进行自动换行排版,并在屏幕上绘制显示。

核心代码
- 章节抓取脚本 (Node.js with Playwright)
import { chromium } from 'playwright'
import { readFileSync, writeFileSync } from 'fs'
import * as fs from 'fs'
import { join } from 'path'
import * as args from 'args'
function syncWriteFile(path: string, filename: string, data: any) {
writeFileSync(join(path, filename), data, {
flag: 'w',
})
const contents = readFileSync(join(path, filename), 'utf-8')
return contents
}
(async () => {
args
.option('title', 'Title of Chapter')
.option('link', 'Link of Chapter')
.option('path', 'Path for saving', __dirname)
.option('number', 'Number for Chapter', 0)
const flags = args.parse(process.argv)
const browser = await chromium.launch() // Or 'firefox' or 'webkit'.
const page = await browser.newPage()
await page.goto(flags.link)
let contains = await page.locator('#content p').allTextContents()
const filename = flags.number + '_' + flags.title.replace("/","_") + '.txt'
// 检查文件是否可读。
fs.access(filename, fs.constants.R_OK, (err) => {
if (err) {
console.log("Saving %s", filename)
syncWriteFile(flags.path, filename, contains.join("\r\n"));
} else {
console.log("%s file exists!", filename)
}
});
// other actions...
await browser.close()
})();
使用命令示例:
node get.js -l 'https://www.beqege.cc/130/1731.html' -t 章节名称 -p 保存目录 -n 章节编号
- 目录解析与批量抓取程序 (Golang)
package main
import (
"bufio"
"flag"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"github.com/gookit/color"
)
var inputFile = flag.String("file", "", "Set input file for books")
var savePath = flag.String("path", "", "Set path for save books")
var waitGroup = sync.WaitGroup{}
func SaveFile(lineString string, num int) {
linkRe, _ := regexp.Compile(`href=\"([\w:\/\.]+)\"`)
link := linkRe.FindString(lineString)
titleRe, _ := regexp.Compile(`\"\>.+\<\/a>`)
title := titleRe.FindString(lineString)
if link != "" && title != "" {
_link := strings.Split(link, "\"")[1]
len := len(title)
//color.Greenln(title[2:len-4], _link)
_fileName := strconv.Itoa(num) + "_" + title[2:len-4] + ".txt"
fileInfo, err := os.Stat(*savePath + _fileName)
if err != nil {
cmd := exec.Command("node",
"/home/walker/shrimp-box/xiaoshuo_playwright/get.js",
"-l", _link,
"-t", title[2:len-4],
"-p", *savePath,
"-n", strconv.Itoa(num))
out, _ := cmd.CombinedOutput()
color.Greenf(string(out))
} else if fileInfo.IsDir() == false {
color.Greenln("Saving: " + _fileName)
}
}
waitGroup.Done()
}
func ReadLine(filename string) {
f, err := os.Open(filename)
if err == nil {
defer f.Close()
r := bufio.NewReader(f)
var status = "None"
count := 0
for {
line, _, err := r.ReadLine()
if err != nil {
break
}
_line := string(line)
switch status {
case "None":
if strings.Contains(_line, "<dt>") {
status = "start"
color.Greenln("Start pulling!")
}
continue
case "start":
if strings.Contains(_line, "<dd><a href=") {
go SaveFile(_line, count)
count++
if count%10 == 0 {
waitGroup.Wait()
} else {
waitGroup.Add(1)
}
} else if strings.Contains(_line, "</dl>") {
color.Greenln("Finished pulling!")
break
}
continue
}
}
} else {
color.Error.Println("Can't open map file!")
}
}
func main() {
flag.Parse()
if *inputFile == "" {
color.Error.Println("Please set input file!")
} else if *savePath == "" {
color.Error.Println("Please set path!")
} else {
ReadLine(*inputFile)
}
}
- 小说阅读微服务 (Golang with Gin)
package main
import (
"bufio"
"encoding/json"
"os"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gookit/color"
)
type (
Book struct {
Name string
Mark int64
Id int
}
Chapter struct {
Name string
Id int
}
ChapterList []Chapter
Config struct {
Name string `json:"Name"`
Mark int64 `json:"Mark"`
Path string `json:"Path"`
Chapters ChapterList
}
)
func (cList ChapterList) Len() int { return len(cList) }
func (cList ChapterList) Less(i, j int) bool { return cList[i].Id < cList[j].Id }
func (cList ChapterList) Swap(i, j int) { cList[i], cList[j] = cList[j], cList[i] }
// 路径配置全局变量
var config []Config
func ProcessChapters(bookID int) {
files, _ := os.ReadDir(config[bookID].Path)
for _, file := range files {
arr := strings.Split(file.Name(), "_")
id, _ := strconv.Atoi(arr[0])
len := len(arr[1])
if !file.IsDir() {
_chapter := Chapter{
Name: arr[1][:len-4],
Id: id,
}
config[bookID].Chapters = append(config[bookID].Chapters, _chapter)
}
}
}
func GetChapters(page string, bookID string) []Chapter {
_page, _ := strconv.Atoi(page)
_id, _ := strconv.Atoi(bookID)
var chapters ChapterList
for _, c := range config[_id].Chapters {
if c.Id >= _page*8 && c.Id < (_page+1)*8 {
chapters = append(chapters, c)
}
}
sort.Sort(chapters)
return chapters
}
func GetChapterText(cid string, bookID string) []string {
_cid, _ := strconv.Atoi(cid)
_id, _ := strconv.Atoi(bookID)
var arr []string
cName := ""
for _, c := range config[_id].Chapters {
if c.Id == _cid {
cName = c.Name
}
}
filter_strs := []string{"huanyuanapp.org"}
cFile, err := os.Open(config[_id].Path + "/" + cid + "_" + cName + ".txt")
if err == nil {
defer cFile.Close()
r := bufio.NewReader(cFile)
for {
line, _, err := r.ReadLine()
if err != nil {
break
}
_status := "good"
_line := string(line)
for _, f := range filter_strs {
if strings.Index(_line, f) >= 0 {
_status = "bed"
}
}
if _status == "good" {
arr = append(arr, _line)
}
}
}
return arr
}
func main() {
// 读取books.json配置获取小说目录
configFile, err := os.Open("books.json")
if err != nil {
color.Redln("can't find books.json")
return
}
defer configFile.Close()
decoder := json.NewDecoder(configFile)
err = decoder.Decode(&config)
if err != nil {
color.Redln("decode json file error", err.Error())
}
var bookList []Book
for i, c := range config {
book := Book{
Name: c.Name,
Mark: c.Mark,
Id: i,
}
bookList = append(bookList, book)
// 初始化章节信息
ProcessChapters(i)
}
router := gin.Default()
router.GET("/books", func(c *gin.Context) {
c.JSON(200, bookList)
})
// chapterList?bookID=0&page=0
router.GET("/chapterList", func(c *gin.Context) {
page := c.DefaultQuery("page", "0")
bookID := c.Query("bookID")
_id, _ := strconv.Atoi(bookID)
chapters := GetChapters(page, bookID)
c.JSON(200, gin.H{"Book": config[_id].Name, "Chapters": chapters})
})
// viewCapter?bookID=0&CID=0
router.GET("/viewCapter", func(c *gin.Context) {
CID := c.DefaultQuery("CID", "0")
bookID := c.Query("bookID")
str_arr := GetChapterText(CID, bookID)
c.JSON(200, str_arr)
})
router.Run("192.168.50.223:3000") // 监听并在 0.0.0.0:8080 上启动服务
}
- ESP32-Box阅读端代码 (CircuitPython)
说明:由于时间关系,设备端的完整功能(如小说和章节选择)未能全部完成。在开发过程中发现,由于中文字体文件较大,CircuitPython在绘制文本时需要频繁读取Flash并解析字体,导致初期渲染极慢,需运行数分钟将字体缓存至内存后速度才可接受。因此,以下代码仅为核心的HTTP获取与文本显示演示。
import board
import busio
import pwmio
import displayio
import time
import espidf
from adafruit_st7789 import ST7789
import adafruit_imageload
from adafruit_display_text import label, wrap_text_to_lines
from adafruit_bitmap_font import bitmap_font
displayio.release_displays()
spi = busio.SPI(board.GPIO18, MOSI=board.GPIO17)
spi.try_lock()
busio.SPI.configure(spi, baudrate=40000000)
spi.unlock()
tft_cs = board.GPIO9
tft_dc = board.GPIO3
pwm = pwmio.PWMOut(board.GPIO11)
pwm.duty_cycle = 28000
display_bus = displayio.FourWire(spi, command=tft_dc, chip_select=tft_cs, reset=board.GPIO8)
display = ST7789(display_bus, width=320, height=240, colstart=0, rowstart=20, rotation=90)
# Load the sprite sheet (bitmap)
image, palette = adafruit_imageload.load("/book.png")
palette.make_transparent(1)
font = bitmap_font.load_font("wenquanyi_12pt.pcf")
color = 0x000000
import wifi
import socketpool
import adafruit_requests
SERVER_HOST="http://192.168.50.223:3000"
# connect to SSID
wifi.radio.connect(os.getenv('CIRCUITPY_WIFI_SSID'), os.getenv('CIRCUITPY_WIFI_PASSWORD'))
pool = socketpool.SocketPool(wifi.radio)
http = adafruit_requests.Session(pool)
global buffer_line = []
def getCapterTxt():
JSON_GET_URL = "%s/viewCapter?bookID=0&CID=0" % (SERVER_HOST)
response = http.get(JSON_GET_URL)
data = response.json()
response.close()
for line in data :
lines = wrap_text_to_lines(line, 20)
buffer_line = buffer_line + lines
getCapterTxt()
text_group = displayio.Group()
# Create a sprite (tilegrid)
grid = displayio.TileGrid(image, pixel_shader=palette)
# Add the grid to the Group
text_group.append(grid)
text_area = label.Label(font, text="测试页面", color=color)
text_area.x = 10
text_area.y = 10 + l*26
text_group.append(text_area)
display.show(text_group)
time.sleep(3)
while True:
text_area.text = "\n".join(buffer_line[count*9:(count+1)*9])
time.sleep(2)
count = count + 1
if count >= len(buffer_line) /9:
count = 0
总结与反思
通过本次项目,我对CircuitPython在嵌入式GUI和网络访问方面的应用进行了深入实践。CircuitPython开发环境简单易用,库生态丰富,能快速实现原型。然而,其作为解释型语言的性能瓶颈在此项目中显现,尤其是在处理大量中文字体渲染时,初期性能体验较差。
因此得出结论:对于简单的界面交互,CircuitPython是优秀选择;但对于需要处理大量文本刷新或复杂图形的应用(如本阅读器),性能可能无法满足要求。在未来的类似项目中,考虑使用C语言结合LVGL图形库进行开发,将是获得更佳性能和用户体验的更优方案。
github:https://github.com/espressif/esp-box
gitee:https://gitee.com/EspressifSystems/esp-box
