大致效果如下:
搭建预览网站 - 文章详情
上面传了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("http://www.likecs.com/").pop();
const filePath = path.join(IMAGES_PATH, productId, articleId, rerouceSrc.split("http://www.likecs.com/").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