找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

311

积分

0

好友

25

主题
发表于 昨天 02:43 | 查看: 2| 回复: 0

这个项目使用 ESP32-Box Lite 实现了一个 局域网小说阅读器,核心功能包括连接Wi-Fi、从本地服务器自动获取小说内容、通过触控屏幕翻页,并在小尺寸屏幕上实现流畅的阅读体验。

设计思路

考虑到ESP32性能有限,本设计将任务进行了拆分:核心的小说内容抓取任务由功能更强的服务器承担,ESP32设备主要负责GUI显示与用户交互。

  1. 小说抓取服务:在服务器端编写脚本,使用 Playwright 工具从指定的小说网站抓取内容。
  2. 本地Web服务:在服务器上搭建本地局域网Web服务,存储抓取的小说内容,并通过 JSON API 提供小说目录、章节列表及章节内容。
  3. 设备端阅读功能:在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接口,非常适合作为智能家居控制面板或人机交互设备原型进行开发。

功能实现

FmjhAV-o0iOBAql6QNDp8OugZFmG

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

Fucnf8qsXBfVPw0YSi7tbUHWszAY

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

FsLatBjLgzl1uaOA_sbSNvMSYAp5

FogVvNP9JTlH79qElG4l4OIqxWwp

Fq8qVut3x_o6aX88MgpOGDTGK7q2

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

FrYtj88TDiJ8TZZGE-IuXP8nwuLW

核心代码

  1. 章节抓取脚本 (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 章节编号
  1. 目录解析与批量抓取程序 (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)
    }
}
  1. 小说阅读微服务 (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 上启动服务
}
  1. 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

项目总结图片




上一篇:Java高并发秒杀系统架构设计:支撑10万QPS的性能优化实战
下一篇:Python持续夺冠:专业开发者如何看待其生态短板与应用挑战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-9 00:09 , Processed in 0.079994 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表