极客时间离线课堂
发布日期:2021-05-09 01:29:29 浏览次数:12 分类:博客文章

本文共 19885 字,大约阅读时间需要 66 分钟。

特点

我们徒手撸代码, 除了zip部分,都是原生代码

前言

最近发现极客时间的有些课程不错,不贵还实惠。有一天有一个同事找到我说,你在看什么呢,不觉明历,能分享给我吗。 我说,是好东西要分享。可是这个网站不支持多地方登陆。

同事说,那把网页下载下来不就好了吗。

网页下载下来不就好了吗?网页下载下来不就好了吗?网页下载下来不就好了吗

这句话不听的在我脑中盘旋,我头都炸了, 午觉也没睡好,于是就打算下载下来。

下载下来的原始需求是什么:

  • 无限看
  • 免费看
  • 断网看

其实,这个百度一下,也有破解的App.

github搜一搜,也有

这些优秀的开源项目。

当然,因为极客时间本身也在迭代,有些可能已经不能用了。
但是,再撸一个又何妨。

数据之源 API分析

万恶之源,均是数据。那我们就先来看看分分析这个极客时间的原始数据。

API分析 - 1. 课程列表

这个页面就是你已经购买的课程列表。

获取这个课程的列表的接口地址为:

参数:

{    "desc":true,    "expire":1,    "last_learn":0,    "learn_status":0,    "prev":0,    "size":20,  // 一页获取的大小    "sort":1,    "type":"",    "with_learn_count":1}

结果:

比较重要的是products里面的id

{  code: number;  data: {      articles:[];      has_expiring_product: boolean;      list: [{          pid: number      }],      products:[{            author: {                name: "",                intro: "",                avatar: "",            },            id: "",  // 产品id, 这个比较重            intro: "",            intro_html: "",            is_audio: true            is_column: true            is_core: true            is_dailylesson: false            is_finish: true            is_groupbuy: true            is_onborad: true            is_opencourse: false            is_promo: false            is_qconp: false            is_sale: true            is_shareget: false            is_sharesale: true            is_university: false            is_video: false       }]  }  error: {        }  extra: {  }}

API分析 - 2. 课程文章列表摘要信息

就是一个课程的全部文章, 全部小节。 这里是摘要信息,不是文正或者视频本身。

接口地址:

参数:

{    cid: string,   // 产品id    order: "earliest"    prev: 0    sample: false    size: 100}

结果:

{    code: 0,    data: {        list: [{            article_title: string, // 文章标题            id: number,  // 文章id            audio_download_url:string,            column_cover: string;            audio_time_arr: {                h: ""                m: ""                s: "            }        }]    },    error:[],    extra:[]}

API分析 - 3. 课程章节

课程会分几章,每章会有子文章。 在网页的体现就是导航。

地址:

参数:

{    "cid": number}

cid: 专栏ID

  • 返回结果的cid的值
  • 返回 column_id

返回:

{    code: 0,    data:[{        article_count: number;        id: string;        source_id: string;        title: string;    }]}

API分析 - 4.课程文章内容

这就是真正的内容部分了

这里主要说两种, 一种是视频。

视频

网页上的播放地址是:

xxxxx: 对应的产品ID

yyyy: 对应文章id,这个在 2.课程文章列表摘要信息的接口获得。

文章专栏

yyyy: 对应文章id,这个在 2.课程文章列表摘要信息的接口获得。

获取源数据接口地址是:

参数:

{    id: string, // 文章ID    include_neighbors: false,    is_freelyread: true}

结果:

{    code: 0    data:{        article_content: string; // 文章内容,富文本        article_cover: string;        article_sharetitle: string;        column_cover: string;        video_id: "";  // 视频id        column_id: number; // 章节ID,决定该文章属于课程的哪个章节        audio_time_arr: {            h: ""            m: ""            s: ""        }    }}

API分析 - 5.课程评论

这个就没说的呢,所谓评论更精彩

地址:

参数:

{    "aid": number, // 文章ID    "prev":0,    "size": 100 // 隐藏参数}

结果:

{    code: 0,    data: {        list: [{            can_delete: false            comment_content: ""            comment_ctime: number            comment_is_top: false            discussion_count: 0            had_liked: false            id: number            like_count: 0            product_id: number            product_type: "c1" // 产品类型 cl为专栏            score: string            ucode: string            uid: number            user_header: string            user_name: string        }]    }}

下载

最优解:服务脚本直接下载。当然cookie是关键。

次优解:无头浏览器。
无赖解:游猴脚本或者手帖脚本到浏览器。

那我们就从无赖解开始。有人就会说,你这不是带坏人吗,干嘛不从最优解开始。哈哈,因为从初级到高级,这个爽感,很享受。

实际上,无赖解,次优解,最优解绝大部分的逻辑代码是一致。

差别可能在文件存储,cookie上,多线程等上。

下载 - 1.入口

当然,我们是以课程(产品为单位的),无论如何,也要获取课程列表。

所以,要先写一个获取json数据的方法,全部是post接口,那么容我偷懒。

function request(url, data, options = {}) {    return fetch(url, {        method: "post",        headers: {            'Content-Type': 'application/json'        },        mode: 'cors',        "credentials": "include",        body: JSON.stringify(data),        ...options    }).then(res => res.json())

加一点常量

let BASEURL = "https://time.geekbang.org/serv";let API_URL = {    chapters: "/serv/v1/chapters",    articles: "/serv/v1/column/articles",    article: "/serv/v1/article",    comments: "/serv/v1/comments",    product: "/serv/v3/learn/product"};

获取之后,往界面里面注入一个下载面板,列出标题,和加一个下载按钮就好。

; (async function init() {    try {        // 获取已购买的产品        const resProducts = await request(API_URL.product, {            "desc": true,            "expire": 1,            "last_learn": 0,            "learn_status": 0,            "prev": 0,            "size": 50,            "sort": 1,            "type": "",            "with_learn_count": 1        });        if (resProducts.code != 0 || resProducts.data.list.length < 0) {            return console.log("穷光蛋,没有购买任何产品");        }        injectControlPanel(resProducts.data.products);    } catch (err) {        console.log("脚本执行异常", err);    }})();let CONTROL_PANEL_ID = "xxx_yyy_zzz";function injectControlPanel(products = []) {    if(document.getElementById(CONTROL_PANEL_ID)){        return;    }    const container = document.createElement("div");    container.id = CONTROL_PANEL_ID;    container.style.cssText = "position:fixed; top:20px; right:20px; z-index:99999; background-color:#DDD;padding: 10px;";    document.body.appendChild(container);    function onDownload(ev) {        const id = ev.target.dataset.id;        const product = products.find(p => p.id == id);        if (!product) {            return console.log("未找到 id为", id, "的产品");        }        console.log("开始下载");        // TODO:: 执行下载        }    products.forEach(p => {        const pEl = document.createElement("div");        const textEl = document.createTextNode(p.title);        const btnEl = document.createElement("button");        btnEl.type = "button";        btnEl.textContent = "下载";        btnEl.dataset.id = p.id;        btnEl.style.marginLeft = "10px";        btnEl.onclick = onDownload;        pEl.appendChild(textEl);        pEl.appendChild(btnEl);        container.appendChild(pEl)    })}

这个试行,你执行一次,就能在界面看到

下载 - 2. 注入JSZip

  • checkScript 检查某脚本是否已经注入
function checkScript({    names = [],    objectName} = {}) {    if (typeof objectName !== "string") {        return false    }    if (window[objectName] != null) {        return true;    }    const ns = Array.isArray(names) ? names : [names];    const scripts = Array.from(document.scripts);    return ns.some(n => {        return scripts.find(s => s.src && s.src.toLocaleLowerCase().endsWith(n.toLocaleLowerCase()))    })}
  • injectScript注入脚本, 用来注入JSZip
function injectScript(src) {    return new Promise((resolve, reject) => {        const d = delay(reject, 10000);        const script = document.createElement("script");        script.crossorigin = "anonymous";        script.src = src;        script.onerror = reject;        script.onload = () => {            d.cancel();            resolve();        }        document.body.appendChild(script);    });}

执行检查和注入

console.log("准备检查和注入JSZip");        if (!checkScript({            names: "jszip.min.js",            objectName: "JSzip",        })) {            await injectScript("https://cdn.bootcdn.net/ajax/libs/jszip/3.5.0/jszip.min.js");            console.log("注入JSZip成功");        }

下载 - 3.下载某个课程

这里其实也是蛮简单的思路。

  • 获取课程信息
  • 获取课程的章节信息
  • 依次获取课程的内容
  • 打包下载

这里,浏览器里面请求太快,会返回451的状态码。可以关闭再来。这个我怀疑是浏览器的行为,还不一定是极客时间的行为。如果有同志告诉我原因。最好了。

所以我这里需要几个工具方法。

  • delay 避免请求太快
function delay(delay = 5000, fn = () => { }, context = null) {    let ticket = null;    return {        run(...args) {            return new Promise((resolve, reject) => {                ticket = setTimeout(async () => {                    try {                        const res = await fn.apply(context, args);                        resolve(res);                    } catch (err) {                        reject(err)                    }                }, delay)            })        },        cancel: () => {            clearTimeout(ticket);        }    }}
  • download 下载
function downloadBlob(name, blob) {    const url = URL.createObjectURL(blob);    const a = document.createElement('a')    a.href = url;    a.download = name;    a.click();}

接下来,封装简单的课程下载:

ProductLoader, 传入一个product的信息,他就会自动给你下载课程原有的元数据, 对是 元数据, 还不能在浏览器里面看。

class ProductLoader {    constructor(proudct) {        /**         * 产品         */        this.product = proudct;        /**         * 专栏id         */        this.cid = null;        /**         * 全部文章         */        this.articles = [];        /**         * 全部文章内容         */        this.articlesContents = [];        /**         * 章节信息         */        this.chapters = null;    }    getActiles(pid) {        // 获取全部文章        return request(API_URL.articles, {            cid: `${pid}`,   // 产品id            order: "earliest",            prev: 0,            sample: false,            size: 200        }, {            "referrer": `https://time.geekbang.org/column/article/0?cid=${pid}`        });    }    async getArticlesContents(articles = []) {        const articlesContents = [];        const len = articles.length;        for (let i = 0; i < len; i++) {            const p = articles[i];            try {                const resArticle = await request(API_URL.article, {                    id: p.id,                    include_neighbors: false,                    is_freelyread: true                });                await delay().run();                console.log("get acticle success:", resArticle.data.id);                articlesContents.push(resArticle.data);                console.log("articlesContents", articlesContents.length);                // break;            } catch (err) {                return articlesContents;                // await delay().run();                // console.log(`downdload article failed: artile id ${p.id}`);                // continue;            }        }        return articlesContents;    }    getChapters(cid) {        return request(API_URL.chapters, {            cid        });    }    async getJSONData() {        try {            const resAticles = await this.getActiles(this.product.id);            if (resAticles.code !== 0) {                return console.log("获取全部文章失败");            }            const cid = resAticles.data.list[0].column_id;            const articles = resAticles.data.list;            this.cid = cid;            // 全部文章            this.articles = articles;            const resChapters = await this.getChapters(cid);            if (resChapters.code !== 0) {                return console.log("fetch chapters failed");            }            // 章节信息            this.chapters = resChapters.data;            // 文章内容            this.articlesContents = await this.getArticlesContents(articles);        } catch (err) {            console.log("download product failed, product id", this.product.id);            throw err        }    }    async zipDownload() {        try {            await this.getJSONData();            const zip = new JSZip();            zip.file("product.json", JSON.stringify(this.product));            zip.file("articles.json", JSON.stringify(this.articles));            zip.file("chapters.json", JSON.stringify(this.chapters));            const folder = zip.folder("articles");            this.articlesContents.forEach(a => {                folder.file(`${a.id}.json`, JSON.stringify(a));            })            const blob = await zip.generateAsync({ type: "blob" });            downloadBlob(this.product.id + ".zip", blob);        } catch (err) {            console.log("zipDownload error,", err);        }    }}

最后调整一下入口代码, 运行一下,就可以 某个课程的元数据了。

; (async function init() {    try {        // 获取已购买的产品        const resProducts = await request(API_URL.product, {            "desc": true,            "expire": 1,            "last_learn": 0,            "learn_status": 0,            "prev": 0,            "size": 50,            "sort": 1,            "type": "",            "with_learn_count": 1        });        if (resProducts.code != 0 || resProducts.data.list.length < 0) {            return console.log("穷光蛋,没有购买任何产品");        }        console.log("准备检查和注入JSZip");        if (!checkScript({            names: "jszip.min.js",            objectName: "JSzip",        })) {            await injectScript("https://cdn.bootcdn.net/ajax/libs/jszip/3.5.0/jszip.min.js");            console.log("注入JSZip成功");        }        injectControlPanel(resProducts.data.products);        download("product.json", JSON.stringify(resProducts.data))    } catch (err) {        console.log("脚本执行异常", err);    }})();

到此为止, 我们回顾一下,zip包的数据接口,因为这和接下来的预览网站相关。

[productId]    product.json   // 课程信息    chapters.json  // 章节信息    artilces.json  // 文章摘要信息    artilces       // 文章沐浴露        [article.id].json  // 具体的文章    comments  // 代码并未下载,自行添加        [article.id].json

搭建预览网站

这里分两步走搭建课程列表,文章详情。

先看一下文件接口

images    // 图片目录,之后下载图片和MP4有用    [productId]        [img-1]products  // 产品列表    [productId]        artiles            [artileId].json        acticles.json        chapters.json        product.jsonindex.html  // 列表页article.html // 文章页product.json // 产品列表

搭建预览网站 - 课程列表

代码非常的简单,预览

大致效果如下:

搭建预览网站 - 文章详情

上面传了id参数到这个页面,那么先得解queryString。就不写什么正则了,用原生API就好。

const urlSP = new URLSearchParams(window.location.href.split("?")[1]);        function getQueryString(key) {            return urlSP.get(key)        }

这里需要额外处理的就是章节信息。

articles.json返回的数据chapter_id就是chapters.json里面的id。

有一种特殊情况,章节信息为空数据。
这里需要组合一下。

async function getNavData(id) {            const articles = await getJSONData(`./products/${id}/articles.json`);            const chapters = await getJSONData(`./products/${id}/chapters.json`);            if (!chapters || chapters.length <= 0) {                chapters[0] = {                    id: "0",                    title: "全部章节"                }            }            const articlesMap = articles.reduce((obj, cur) => {                obj[cur.chapter_id] = obj[cur.chapter_id] || [];                obj[cur.chapter_id].push(cur);                return obj;            }, {})            chapters.forEach(c => {                c.children = articlesMap[c.id] || [];            })            return chapters;        }

有了组合的章节信息,那么我们就来创建菜单

async function createNav(id) {            try {                const chapters = await getNavData(id);                chapters.forEach(c => {                    const chapterEl = document.createElement("h3");                    chapterEl.innerHTML = c.title;                    chapterEl.style = "padding-left:10px";                    leftNavEl.appendChild(chapterEl);                    const ulEl = document.createElement("ul");                    c.children.forEach(cc => {                        const liEl = document.createElement("li");                        liEl.className = "menu-item";                        liEl.dataset.id = cc.id;                        liEl.innerText = cc.article_title;                        ulEl.onclick = onViewArticle;                        ulEl.appendChild(liEl);                    })                    leftNavEl.appendChild(ulEl);                });            } catch (err) {                console.log("createNav failed", err);            }        }

最后,我们需要默认选中第一个文章。

; (async function init() {            await createNav(id);            const fa = leftNavEl.querySelector(".menu-item");            if (fa) {                fa.click();            }        })()

最后就剩下,获取artile的内容了。

async function onViewArticle(ev) {            try {                acticleEl.innerHTML = "";                document.querySelectorAll(".menu-item.active").forEach(el=>{                    el.classList.remove("active");                })                ev.target.classList.add("active");                        const artileId = ev.target.dataset.id;                const article = await getJSONData(`./products/${id}/articles/${artileId}.json`);                acticleEl.innerHTML = article.article_content;                if (article.is_video_preview && Object.keys(article.video_preview).length > 0) {                    // createVideoPlayer(article);                }            } catch (err) {                console.log("获取文章内容失败", err);            }        }

目前为止,你的预览网站就搭建完毕了,是不是很简单。

我们来预览一下

图片和MP4下载

到这里为了,我们回头再看看需求

  • 无限看
  • 免费看
  • 断网看

无限看和免费看已经实现了,那么断网看还没有实现。

这里不得不说一下,极客文章里面的图片和文章的MP4视频,是直接外挂到CDN的。直接下载就好。
我们暂且处理两种格式的资源 img, video

基本思路:

  • [articleId].json文件正则匹配actile_conten
  • 下载资源
  • 替换actile_conten里面的 img, video地址

这里需要额外注意一下,如果是把某个mp4下载到内容,再保存到磁盘,可能会挂,所以才去pip直接传递

这里也不多说了,直接放代码。

可以改进的地方很多,比如多线程。
建议保留一份原始下载文件,毕竟下载下来还是很占空间的。保留原始的文件,可以随时切换离线和在线的 图片和视频

import * as fs from "fs";import * as path from "path";import axios from "axios";const WHITELIST = ["xxxxxxx"];function toPath(...paths: string[]) {    return path.join(__dirname, ...paths)}const srcReg = /src=[\'\"]?([^\'\"]*)[\'\"]?/img;const PRODUCTS_PATH = toPath("../../server/products");const IMAGES_PATH = toPath("../../server/images");async function downloadFiles(productId: string, articles: string[]) {    const productPath = path.join(IMAGES_PATH, productId);    if (!fs.existsSync(productPath)) {        fs.mkdirSync(productPath);    }    for (let i = 0; i < articles.length; i++) {        const article = articles[i];        const articleJSONPath = path.join(PRODUCTS_PATH, productId, "articles", article)        const articleJSON = JSON.parse(fs.readFileSync(articleJSONPath, "utf-8"));        const articleId = article.split(".")[0];        const articlePath = path.join(productPath, articleId);        if (!fs.existsSync(articlePath)) {            fs.mkdirSync(articlePath);        }        const content: string = articleJSON.article_content;        let contentNew = content;        while (srcReg.exec(content)) {            const rerouceSrc = RegExp.$1;            // 已经替换            if (!rerouceSrc.startsWith("http") || !rerouceSrc.startsWith("https")) {                continue;            }            const fileName = rerouceSrc.split("/").pop();            const filePath = path.join(IMAGES_PATH, productId, articleId, rerouceSrc.split("/").pop());            if (fs.existsSync(filePath) || fileName.endsWith("m3u8")) {                continue;            }            console.log("获取资源", rerouceSrc);            await axios({                method: 'get',                url: rerouceSrc,                responseType: 'stream'            }).then(function (response) {                response.data.pipe(fs.createWriteStream(filePath, { encoding: "utf-8" }))                // const buffer = response.data;                // fs.writeFileSync(filePath, buffer, { encoding: "utf-8" })            }).then(() => {                const from = rerouceSrc;                const to = `./images/${productId}/${articleId}/${fileName}`;                console.log("repalce", from, to);                contentNew = contentNew.replace(from, to);            })        }        articleJSON.article_content = contentNew;        fs.writeFileSync(articleJSONPath, JSON.stringify(articleJSON), {            encoding: "utf-8"        })    }}; (async () => {    // products 下面的所有文件    const files = fs.readdirSync(PRODUCTS_PATH);    for (let i = 0; i < files.length; i++) {        const dirName = files[i];        if (WHITELIST.length > 0 && !WHITELIST.includes(dirName)) {            continue;        }        // 文件跳过        if (fs.statSync(path.join(PRODUCTS_PATH, dirName)).isFile()) {            continue;        }        const dirFullPath = path.join(PRODUCTS_PATH, dirName, "articles");        const articles = fs.readdirSync(dirFullPath);        // 下载        downloadFiles(dirName, articles);        break;    }})();

直播格式的m3u8

目前为止,我提到的都是img和mp4的视频下载。

极客时间很多的视频都是m3u8格式的,简单的方式是没法下载。
其用的是阿里的一套东西,没错的话,属于点播模式。
点播模式有两个重要的参数

  • vid: 视频ID
    这个参数,在[articleId].json里面有, 名为video_id
  • playAuth :
    这个是通过video_id换的

参数:

aid: numbersource_type: 1video_id: ""

结果

{    "code":0,"    data":{        "play_auth":""  // 这个就是playAuth    }}

获得了,这两个参数,你就可以去

播放。
据我观察,这个play_auth一定时间内是不更新的,是不是你要笑了。

到这里,你们就会问,你废话那么多,那到底下载下来没有。

答案是有方案的,已经能下载下来啦。 但是现在还不够完美。

后续

  • 无头浏览器版
    期间我还尝试了无头浏览器,目前已经实现自动登录。
  • m3u8的下载
  • 纯服务脚本下载
上一篇:Phaser3 学习资料整理
下一篇:Why系列:0.1 + 0.2 != 0.3

发表评论

最新留言

关注你微信了!
[***.104.42.241]2025年04月08日 10时42分39秒