極客時間離線課堂

特點

我們徒手擼代碼, 除了zip部分,都是原生代碼

前言

最近發現極客時間的有些課程不錯,不貴還實惠。有一天有一個同事找到我說,你在看什麼呢,不覺明歷,能分享給我嗎。 我說,是好東西要分享。可是這個網站不支持多地方登陸。

同事說,那把網頁下載下來不就好了嗎。

網頁下載下來不就好了嗎?網頁下載下來不就好了嗎?網頁下載下來不就好了嗎

這句話不聽的在我腦中盤旋,我頭都炸了, 午覺也沒睡好,於是就打算下載下來。

下載下來的原始需求是什麼:

  • 無限看
  • 免費看
  • 斷網看

其實,這個百度一下,也有破解的App.
github搜一搜,也有

這些優秀的開源項目。
當然,因爲極客時間本身也在迭代,有些可能已經不能用了。
但是,再擼一個又何妨。

數據之源 API分析

萬惡之源,均是數據。那我們就先來看看分分析這個極客時間的原始數據。

API分析 - 1. 課程列表

https://time.geekbang.org/dashboard/course 這個頁面就是你已經購買的課程列表。
獲取這個課程的列表的接口地址爲:https://time.geekbang.org/serv/v3/learn/product

參數:

{
    "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. 課程文章列表摘要信息

就是一個課程的全部文章, 全部小節。 這裏是摘要信息,不是文正或者視頻本身。
接口地址: https://time.geekbang.org/serv/v1/column/articles

參數:

{
    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. 課程章節

課程會分幾章,每章會有子文章。 在網頁的體現就是導航。
地址:https://time.geekbang.org/serv/v1/chapters

參數:

{
    "cid": number
}

cid: 專欄ID

返回:

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

API分析 - 4.課程文章內容

這就是真正的內容部分了
這裏主要說兩種, 一種是視頻。

視頻
網頁上的播放地址是:
https://time.geekbang.org/course/detail/[xxxxx]-[yyyy]

xxxxx: 對應的產品ID
yyyy: 對應文章id,這個在 2.課程文章列表摘要信息的接口獲得。

文章專欄
https://time.geekbang.org/column/article/[yyyy]
yyyy: 對應文章id,這個在 2.課程文章列表摘要信息的接口獲得。

獲取源數據接口地址是:
https://time.geekbang.org/serv/v1/article

參數:

{
    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.課程評論

這個就沒說的呢,所謂評論更精彩
地址:https://time.geekbang.org/serv/v1/comments

參數:

{
    "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.json
index.html  // 列表頁
article.html // 文章頁
product.json // 產品列表

搭建預覽網站 - 課程列表

代碼非常的簡單,預覽


    <ul id="product-list">
       
    </ul>

    <script>
        function getJSONData(url) {
            return fetch(url).then(res => res.json())
        };

        const plEl = document.getElementById("product-list");
        (async () => {
            try {
                const res = await getJSONData("./product.json");

                res.products.forEach(p=>{

                    const pEl = document.createElement("div");
                    pEl.innerHTML = `
                    <li> <a href="./article.html?id=${p.id}">
                            ${p.title}
                        </a>
                    </li>
                    `
                    plEl.appendChild(pEl);

                });

            } catch (err) {
                console.log("讀取產品列表失敗", err);
            }
        })();

    </script>

大致效果如下:

搭建預覽網站 - 文章詳情

上面傳了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格式的,簡單的方式是沒法下載。
其用的是阿里的一套東西,沒錯的話,屬於點播模式。


點播模式有兩個重要的參數

參數:

aid: number
source_type: 1
video_id: ""

結果

{
    "code":0,"
    data":{
        "play_auth":""  // 這個就是playAuth
    }
}

獲得了,這兩個參數,你就可以去
https://player.alicdn.com/detection.html 播放。
據我觀察,這個play_auth一定時間內是不更新的,是不是你要笑了。

到這裏,你們就會問,你廢話那麼多,那到底下載下來沒有。
答案是有方案的,已經能下載下來啦。 但是現在還不夠完美。

後續

  • 無頭瀏覽器版
    期間我還嘗試了無頭瀏覽器,目前已經實現自動登錄。
  • m3u8的下載
  • 純服務腳本下載
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章