
本文共 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的下载
- 纯服务脚本下载
发表评论
最新留言
关于作者
