「Funpack2-5期ESP32-S3」基于esp32 box lite的局域网小说阅读器
一、项目介绍
项目需求:局域网小说阅读器
本项目旨在使用 ESP32-Box-Lite 开发板和 CircuitPython 环境,制作一个在线小说阅读器。该阅读器可以连接到局域网,通过 Wi-Fi 下载和局域网服务器上的小说,并提供舒适的阅读体验。
主要功能需求:
- 小说抓取 用户可以抓取小说内容,自动将所有章节保存到服务器上。
- 本地的Web服务器提供局域网小说阅读功能,通过json数据发送小说目录,小说章节信息,章节内容等。
- 界面设计:阅读器的界面需要简洁美观,并提供舒适的阅读体验。包括章节目录浏览、阅读进度条、字体大小调整、夜间模式等功能,以满足用户不同的阅读习惯。
二、设计思路
由于 ESP32 的性能有限,我们将把小说抓取的任务放到一个更强大的服务器上,而不是直接在 ESP32 上执行。
- 小说抓取服务:编写使用 Playwright 的脚本,用于从指定的小说网站抓取小说内容。
- 本地局域网Web服务:在服务器上搭建本地局域网Web服务,将小说抓取的内容存储在服务器上。用户可以通过Json链接浏览章节目录、选择章节阅读等。
- 小说阅读功能:在esp32-box设备上,实现小说的分章节阅读功能,允许用户通过点击页面翻页、选择章节等方式进行阅读。
项目难点:功能拆分的比较大,需要学习的知识比较多。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),双麦克风,一个扬声器和两个用于硬件拓展的 Pmod™ 兼容接口;采用 Type-C USB 连接器,提供 5 V 电源输入和串口/JTAG 调试接口。
他们为用户提供了一个基于语音助手、传感器、红外控制器和智能 Wi-Fi 网关等功能,开发和控制智能家居设备的平台。开发板出厂支持离线语音交互功能,用户通过乐鑫丰富的 SDK 和解决方案,能够轻松构建在线和离线语音助手、智能语音设备、HMI 人机交互设备、控制面板、多协议网关等多样的应用。
四、功能介绍
-
小说章节抓取功能 由于国内小说网站很多都不能直接使用http.get方式抓取,也不想反复去测试不同小说网站的反爬虫行为。本项目使用了由 Microsoft 开发的开源工具(Playwright),用于自动化浏览器行为,包括抓取网页内容、模拟用户交互等。它支持多种编程语言,如 JavaScript、Python 和 C#,可以在不同的浏览器(如 Chromium、Firefox 和 WebKit)上运行。
2. 小说目录分析功能 小说目录网页的数据格式非常简单,这里我们就直接使用Golang读取保存下来的小说目录页面,逐行读取文件,然后解析小说目录并调用上面写好的小说章节抓取脚本,将所有小说章节保存下来。
3. 小说局域网阅读微服务 使用Gin框架提供小说的目录清单、章节清单、章节内容阅读服务,使用Json数据发送给设备端。
4. 小说阅读功能 ESP32-S3-BOX Lite 上显示带背景的小说内容,使用中文字体,确保不出现乱码。
五、主要代码
- 小说章节抓取代码
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 章节编号
- 小说目录分析、完整小说抓取代码
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)
}
}
- 小说局域网阅读微服务代码
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 上启动服务
}
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 语言,执行效率较低。在一些特定的应用场景下,可能会受到性能的限制。总的来说,本次活动让我对 CircuitPython 有了更深入的了解,并在项目中取得了一定的成果。同时,我也意识到了其性能上的一些限制(做一些简单界面没问题,较多中文内容影响绘图性能),因此在未来的项目中我会考虑采用 C 语言 + LVGL 来进一步优化和完善设备的功能和性能。