「Funpack2-5期ESP32-S3」基于esp32 box lite的局域网小说阅读器
使用微软的Playwright框架抓取小说章节内容,使用Golang解析小说目录并调用脚本抓取完整小说章节并保存到本地。用Gin框架实现小说局域网阅读的微服务。
标签
Funpack活动
显示
开发板
忙碌的死龙
更新2023-08-02
622

「Funpack2-5期ESP32-S3」基于esp32 box lite的局域网小说阅读器

一、项目介绍

项目需求:局域网小说阅读器

本项目旨在使用 ESP32-Box-Lite 开发板和 CircuitPython 环境,制作一个在线小说阅读器。该阅读器可以连接到局域网,通过 Wi-Fi 下载和局域网服务器上的小说,并提供舒适的阅读体验。

主要功能需求:

  1. 小说抓取 用户可以抓取小说内容,自动将所有章节保存到服务器上。
  2. 本地的Web服务器提供局域网小说阅读功能,通过json数据发送小说目录,小说章节信息,章节内容等。
  3. 界面设计:阅读器的界面需要简洁美观,并提供舒适的阅读体验。包括章节目录浏览、阅读进度条、字体大小调整、夜间模式等功能,以满足用户不同的阅读习惯。

二、设计思路

由于 ESP32 的性能有限,我们将把小说抓取的任务放到一个更强大的服务器上,而不是直接在 ESP32 上执行。

  1. 小说抓取服务:编写使用 Playwright 的脚本,用于从指定的小说网站抓取小说内容。
  2. 本地局域网Web服务:在服务器上搭建本地局域网Web服务,将小说抓取的内容存储在服务器上。用户可以通过Json链接浏览章节目录、选择章节阅读等。
  3. 小说阅读功能:在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 人机交互设备、控制面板、多协议网关等多样的应用。

四、功能介绍

FmjhAV-o0iOBAql6QNDp8OugZFmG

  1. 小说章节抓取功能 由于国内小说网站很多都不能直接使用http.get方式抓取,也不想反复去测试不同小说网站的反爬虫行为。本项目使用了由 Microsoft 开发的开源工具(Playwright),用于自动化浏览器行为,包括抓取网页内容、模拟用户交互等。它支持多种编程语言,如 JavaScript、Python 和 C#,可以在不同的浏览器(如 Chromium、Firefox 和 WebKit)上运行。 

Fucnf8qsXBfVPw0YSi7tbUHWszAY

      2. 小说目录分析功能 小说目录网页的数据格式非常简单,这里我们就直接使用Golang读取保存下来的小说目录页面,逐行读取文件,然后解析小说目录并调用上面写好的小说章节抓取脚本,将所有小说章节保存下来。

      3. 小说局域网阅读微服务 使用Gin框架提供小说的目录清单、章节清单、章节内容阅读服务,使用Json数据发送给设备端。   

FsLatBjLgzl1uaOA_sbSNvMSYAp5

     FogVvNP9JTlH79qElG4l4OIqxWwp

Fq8qVut3x_o6aX88MgpOGDTGK7q2

        4. 小说阅读功能 ESP32-S3-BOX Lite 上显示带背景的小说内容,使用中文字体,确保不出现乱码。 

FrYtj88TDiJ8TZZGE-IuXP8nwuLW

五、主要代码

  1. 小说章节抓取代码
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. 小说目录分析、完整小说抓取代码
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. 小说局域网阅读微服务代码
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 上启动服务
}
4. Esp32 Box上的小说阅读代码(由于时间不够充足,没能完成完整的小说阅读功能),经过长时间的尝试和搜索,发现CircuitPython的GUI功能其实并不适合做小说阅读应用。因为中文字体太大了,Python在绘制字体时需要多次读取Flash并解析字体文件,造成大量的时间花费在绘制过程中。只有运行几分钟后,字体基本上都缓冲到内存里了,才能迅速绘制。
基于以上的原因,加上时间不足,未能完成Esp32 Box上的完整功能(小说章节选择和小说选择未完成)
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 来进一步优化和完善设备的功能和性能。

附件下载
小说抓取和微服务.tar.gz
Golang的小说抓取 和微服务代码
ebook.py
esp32上的小说阅读代码
get.ts
获取小说章节的Nodejs脚本代码
团队介绍
广西玉林市虾米科技有限公司
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号