特點
我們徒手擼代碼, 除了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
- https://time.geekbang.org/serv/v1/article 返回結果的cid的值
- https://time.geekbang.org/serv/v1/column/articles 返回 column_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格式的,簡單的方式是沒法下載。
其用的是阿里的一套東西,沒錯的話,屬於點播模式。
點播模式有兩個重要的參數
- vid: 視頻ID
這個參數,在[articleId].json裏面有, 名爲video_id - playAuth :
這個是通過video_id換的
https://time.geekbang.org/serv/v3/source_auth/video_play_auth
參數:
aid: number
source_type: 1
video_id: ""
結果
{
"code":0,"
data":{
"play_auth":"" // 這個就是playAuth
}
}
獲得了,這兩個參數,你就可以去
https://player.alicdn.com/detection.html 播放。
據我觀察,這個play_auth一定時間內是不更新的,是不是你要笑了。
到這裏,你們就會問,你廢話那麼多,那到底下載下來沒有。
答案是有方案的,已經能下載下來啦。 但是現在還不夠完美。
後續
- 無頭瀏覽器版
期間我還嘗試了無頭瀏覽器,目前已經實現自動登錄。 - m3u8的下載
- 純服務腳本下載