手摸手,带你用Electron+React开发一款桌面应用(2.5)

431 阅读6分钟

我正在参加跨端技术专题征文活动,详情查看:juejin.cn/post/710123…

前因

大家好,我是爱吃鱼的桶哥,本来这篇文章是准备继续给大家分享图书搜索下载这款应用的,但是我们在前面两篇文章中只是告诉大家该怎么完成图书搜索界面及下载图书的相关操作,没有具体的介绍我们的图书的来源是怎么获取的,那么这篇文章就为大家分享一下我是怎么找到图书的,因此这篇文章只能算是2.5版本,话不多说,我们开整!

在最初做这款应用的时候,我向大家阐述了具体的需求和原因,因为没有一款能够满足自己搜索图书并进行下载的软件,才萌发了要自己开发的念头。既然自己开发,那么我们需要找到书源,但是我们不可能自己去搭建一个图书服务器,那样太费时费力了,那我们该如何找到合适的书源呢?

思考

一开始我在网络上搜索图书api,但是基本都搜不到合适的接口,这时我就开始思考,是否能够找到一个本身有图书搜索的网站,然后去爬取它的数据作为我这个应用自己的书源呢?说干就干,于是我在google上搜《三体.mobi》这本书书,结果刚才被我搜到了一个网站,它就是爱悦读

这个网站本身不提供图书的下载,但是它的网站里面有很多图书的资源,它把所有的图书都放在了城通网盘上(ps: 虽然城通网盘很lj,下载速度很慢,但我们没有更好的选择),因为这样是可以规避它自身的风险的,并且这个网站本身是提供图书搜索的,基于这个设想,我抓取了它搜索的接口,以及某本书的详情页的网盘跳转地址,这样我们的图书来源就解决了,下面我们开始实操。

书源

在前面我主要是告诉大家我是如何思考以及怎么去查找书源的,现在我们就开始实操来获取我们想要的数据,还记得我们最开始在图书搜索界面的时候从渲染进程给主进程发了一个搜索的通知吗?我们就从这个搜索的通知开始,交给大家该如何获取到搜索的接口,具体的代码如下:

// packages/main/search.ts

const superagent = require('superagent');
const cheerio = require('cheerio');
const axios = require('axios').default;

// 图书搜索
const searchUrl = `https://www.iyd.wang`;

const search = async ({
    value,
    currentPage = 1,
}: {
    value: string;
    currentPage: number;
}) => {
    return new Promise(async (resolve, reject) => {
        try {
            const res = await superagent.get(
                `${searchUrl}/page/${currentPage}/?s=${encodeURI(value)}`
            );
            const $ = cheerio.load(res.text);
            const breadcrumb = $('.breadcrumb').text();
            const page = breadcrumb.match(/\d+/g)[0];
            const article = $('#main').find('article');
            const array: SearchType[] = [];
            for (let i = 0; i < article.length; i++) {
                const title = $(article[i]).find('.entry-title').text();
                const link = $(article[i])
                    .find('.entry-title')
                    .find('a')
                    .attr('href');
                array.push({
                    title,
                    link,
                    types: [],
                });
            }

            return resolve({ data: array, page });
        } catch (err) {
            console.error(err);
            return reject(err);
        }
    });
};

简单讲解一下上面的代码。首先我们通过superagent.get获取到爱悦读的搜索页面,它的页面如下所示:

image.png

当我们通过superagent.get获取到这个页面的数据后,我们使用cheerio.load()来获取网页的DOM结构,后面的操作就跟JQuery的操作一样,找到对应的元素,然后获取它的链接和标题,然后装到一个数组里面,最后把爬取到的结果返回出来,再通知给渲染进程,这就是我们之前在搜索界面输入图书名后搜索数据的来源了。

搜索页的数据我们已经获取到了,还记得我们在软件中的操作吗?对了,就是点击图书后面的按钮会有一个弹窗,里面展示当前图书的封面和不同的格式,那这个数据是怎么获取的呢?其实就是通过我们点击的这条数据去获取它的详情页,具体的代码如下:

// packages/main/search.ts

...other code

// 搜索图书详情页
const searchDetail = async (link: string) => {
    return new Promise(async (resolve, reject) => {
        try {
            const res = await superagent.get(link);
            const $ = cheerio.load(res.text);
            const content = $('.single-content');
            const imgSrc = content
                .find('.wp-caption.aligncenter')
                .find('img')
                .attr('src');
            const tagP = content.find('blockquote').find('p');
            const array: DetailType[] = [];
            for (let i = 0; i < tagP.length; i++) {
                const link = $(tagP[i]).find('a').attr('href');
                const text = $(tagP[i]).find('a').text();
                array.push({ link, text });
            }
            // console.log('array', array);
            return resolve({ data: array, src: imgSrc });
        } catch (err) {
            console.error(err);
            return reject(err);
        }
    });
};

我们在搜索页的弹窗中给主进程发了获取图书详情的通知,于是主进程通过上述的代码为我们获取到了具体图书的详情页,并把相关的信息返回给了渲染进程,上述的代码基本的逻辑跟前面的搜索也是很类似的,都很简单,这里就不做过多的讲解了,如果有不明白的,可以给我留言,我会一一回复并进行解答。

图书的详情页大家可以看一下具体如下所示:

image.png

image.png

在详情页的底部可以看到当前图书所有的格式,以及下载的链接地址,并且这个网站的所有图书的下载密码都是一样的,因此我们即可以通过爬取页面上的密码来使用,也可以直接在代码里面写死,不过为了防止后续密码的变动,我们还是做灵活一点比较好。

上述的两步操作完成后,我们可以通过点击电子书的下载链接跳转到城通网盘,这时候就已经脱离了这个网站了,而需求去解析城通网盘的信息,从而获取到真实的下载路径。

城通网盘的页面打开如下所示:

image.png

这个页面是没有带密码的,所以可以直接打开。如果本身的带有密码的,则会让你输入一个密码,如下所示:

image.png

那么我们该如何从城通网盘获取到真实的图书下载地址呢?

地址解析

既然我们的目标是解决从网盘获取到图书的下载地址,那网盘是否提供了相关的api呢?其实是有的,通过搜索后,我发现网盘其实本身是提供了相关的api地址,并且相关的参数也都列出来了,具体的地址在这里,既然已经有了相关的api了,那我们该处理一下相关的链接,然后获取到真实的下载路径了,具体的代码如下:

// packages/main/search.ts

...other code

/**
 * 解析图书下载地址
 * @param param0
 */
const parseUrl = async ({ bookPass: pass = 526663, bookId: fileID }: BookDataProps) => {
    const r = Math.random(); //随机一个小于1的小数
    const fileInfoUrl = `${baseUrl}/getfile.php?path=${pass ? 'f' : 'file'}&f=${fileID}&passcode=${pass}&token=false&r=${r}`;

    return new Promise(async (resolve, reject) => {
        try {
            // 获取文件相关信息
            const { data } = await axios({ url: fileInfoUrl, method: 'GET' });
            if (data.code === 200) {
                const { userid, file_id, file_chk } = data.file;
                // 获取文件真实路径
                const fileUrl = `${baseUrl}/get_file_url.php?uid=${userid}&fid=${file_id}&file_chk=${file_chk}&folder_id=0&mb=0&app=0&acheck=1&rd=${r}`;
                // 获取文件下载地址
                const { data: resData } = await axios.get(fileUrl);
                return resolve(resData);
            } else {
                return resolve({
                    code: data.file.code,
                    message: data.file.message,
                });
            }
        } catch (err) {
            console.error(err);
            return reject(err);
        }
    });
};

以上的代码就是一个基本的的图书下载链接解析并获取真实的下载地址,但是这个代码本身是有问题的,我通过观察发现网盘的链接地址分为三种,第一种是带密码的,并且图书的id类似这样的xxxx-xxxx-xxxx;第二种是不带密码的,图书的id类似xxxx-xxxx-xxxx;而第三种是图书本身的id只有一道横岗隔开,类似这样的xxxx-xxxx,而网盘根据不同的id类似,他们的解析其实也不一样,上述的代码其实并没有很准确的区分它们的类型,因此有一些特殊的下载链接会获取失败。

由于网盘的解析分不同的id类型,基于这个原因,我在网上搜索了相关的解析例子,下面的代码就是我在网上看到别人写的比较好的代码,直接拿过来使用的,具体如下:

// packages/main/utils/format.ts

const axios = require('axios').default;

// 格式化下载链接
export const format = {
    // 获取下载链接(未处理之前的)
    getByLink: (link: string, password: number) => {
        return format.getByID(link.slice(link.lastIndexOf('/') + 1), password);
    },
    // 获取文件的id
    getByID: async (fileid: string, password: number) => {
        const origin = 'https://ctfile.qinlili.workers.dev';
        // 通过不同的文件id类型,拼接不同的解析地址
        const path = (id: string) => {
            switch (id.split('-').length) {
                case 2: {
                    return 'file';
                }
                case 3:
                default: {
                    return 'f';
                }
            }
        };
        
        // 解析文件
        const { data } = await axios(
            'https://webapi.ctfile.com/getfile.php?path=' +
                path(fileid) +
                '&f=' +
                fileid +
                '&passcode=' +
                password +
                '&token=false&r=' +
                Math.random() +
                '&ref=' +
                origin,
            {
                headers: {
                    origin,
                    referer: origin,
                },
            }
        );
        
        if (data.code === 200) {
            // 解析具体的文件下载地址,并格式化数据
            const { data: rest } = await axios(
                'https://webapi.ctfile.com/get_file_url.php?uid=' +
                    data.file.userid +
                    '&fid=' +
                    data.file.file_id +
                    '&file_chk=' +
                    data.file.file_chk +
                    // '&app=0&acheck=2&rd=' +
                    '&folder_id=0&mb=0&app=0&acheck=1&rd=' +
                    Math.random(),
                {
                    headers: {
                        origin,
                        referer: origin,
                    },
                }
            );

            if (rest.code === 200) {
                return {
                    success: true,
                    name: data.file.file_name,
                    size: data.file.file_size,
                    time: data.file.file_time,
                    link: rest.downurl,
                };
            } else {
                return {
                    success: false,
                    name: data.file.file_name,
                    size: data.file.file_size,
                    time: data.file.file_time,
                    errormsg: rest.message,
                };
            }
        } else {
            return {
                success: false,
                errormsg: data.file.message,
            };
        }
    },
};

上述的代码区分了具体的文件类型,并且将返回的数据做了一层简单的处理,让我们使用起来更加方便,所以我们可以将之前的代码做一个简单的修改,具体如下:

// packages/main/search.ts

import { format } from './utils/format';

...other code

/**
 * 解析图书下载地址
 * @param param0
 */
const parseUrl = async ({
    bookPass = 526663,
    bookId: fileID,
}: BookDataProps) => {
    return new Promise(async (resolve, reject) => {
        try {
            const data = await format.getByID(fileID, bookPass);
            return resolve(data);
        } catch (err) {
            console.error(err);
            return reject(err);
        }
    });
};

上面的代码一下就变的清爽了很多,我们始终保持一个原则,那就是一个函数只做一件事,如果不属于它本身要处理的内容,能够拆分就尽量拆分,这样后期的维护也会更加的方便。

自此,我们的图书搜索及下载的相关数据来源就全部讲完了,这里给大家分享了相关的思路,这样后续如果大家有自己的想法要开放一个新的软件时,知道该如何获取数据了。当然,如果你们公司本身就有后端开发提供相关的api接口,那还是不要去爬人家网站的数据了,毕竟爬虫用不好是会有风险的,大家好是谨慎使用,我这里只是做一个案例的讲解而已。

最后

通过前两篇文章的讲解,我们了解到渲染进程页面的相关操作;而通过这篇文章的讲解,我们了解了基本的图书数据来源,虽然我们是通过获取外部的链接来实现我们的功能,但如果你自己本身是有自己的稳定数据来源,还是不建议使用爬虫来获取别人的数据,这样也会给别人的网站带来一定的压力。

最后,本期的分享内容就到这里了,如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,谢谢大家

往期回顾

手摸手,带你用Electron+React开发一款桌面应用(1)

手摸手,带你用Electron+React开发一款桌面应用(2)

基于Github Actions完成Electron自动打包、发布及更新