保姆級指南:一文擁有屬於你的 puppeteer 爬蟲應用

王志遠,微醫前端技術部。愛好吉他、健身、桌遊,最最關鍵,資深大廠員工(kfc 外賣小哥),trust me,好奇心使生命有趣起來!

背景

公司有日報,每天需要在公司的週報系統中填寫並提交,日報內容很簡單,業務層面把自己的提交記錄整理下,然後加點其他如架構、團隊等方面的產出就好;但每次都要【打開週報系統 - 登陸 - 複製粘貼 - 提交】,覺得很麻煩,之前瞭解過前端爬蟲神器 puppetter,遂決定深入學習一番。

但涉及到公司內部的週報系統,咱愛微醫,可不能幹這事兒。發現掘金是前端渲染項目,所以本文的案例採用掘金作爲實戰對象(小白鼠??),啥也不說了,就看小編能不能過了,如果各位看官看到本文,請給掘金的大度點個大大的贊!

本文目標

知識點

  1. 前端爬蟲知識入門:後端爬蟲依賴接口,如果是前端渲染頁面就無法爬取數據了,所以需要無頭瀏覽器實現前端爬蟲

  2. 提效工具的前置基礎知識:模擬人爲操作的提效工具,擴展思考範圍

實戰產出

爬取掘金首頁信息搭建了一個博客網站

相關資料

當前實現效果

項目開始前置動作

依賴版本鎖定

{
  "name""crawl",
  "version""1.0.0",
  "description""",
  "main""index.js",
  "scripts"{
    "test""echo "Error: no test specified" && exit 1"
  },
  "keywords"[],
  "author""",
  "license""ISC",
  "dependencies"{
    "axios""^0.27.2",
    "bluebird""^3.7.2",
    "body-parser""^1.20.0",
    "chalk""^5.0.1",
    "cheerio""^1.0.0-rc.11",
    "child_process""^1.0.2",
    "cron""^2.0.0",
    "ejs""^3.1.8",
    "express""^4.18.1",
    "express-session""^1.17.3",
    "iconv-lite""^0.6.3",
    "mysql""^2.18.1",
    "nodemailer""^6.7.5",
    "puppeteer""^14.1.1",
    "request""^2.88.2",
    "request-promise""^4.2.6",
    "urijs""^1.19.11"
  }
}

目錄結構

這個目錄結構可以在實戰時用作參考(別有壓力呀!)

.
├── 1. puppertee
│   ├── 1.js
│   ├── 2.js
│   ├── 3.js
│   ├── 4.js
│   ├── 5. 爬取京東.js
│   ├── baidu.png
│   └── items-0.png
├── 2. request
│   ├── 1.request-json.js
│   ├── 2.request-form.js
│   ├── 3.request-file.js
│   └── avatar.jpeg
├── 3. cheerio
│   ├── 1.cheerio.js
│   ├── 2.cheerio-selector.js
│   ├── 3.cheerio-attr.js
│   ├── 4.cheerio-props.js
│   └── 5.cheerio-find.js
├── 4. dependens
│   ├── 1. cron.js
│   ├── 2. error.js
│   ├── 3.debug.js
│   ├── 4. pm2.js
│   ├── 5. iconv-lite.js
│   ├── 6.mail.js
│   ├── 7.read.js
│   └── my-debug.js
├── bdyp.js
├── crawl-server
│   ├── app.js
│   ├── bin
│   │   └── www
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   │   ├── images
│   │   ├── javascripts
│   │   └── stylesheets
│   ├── routes
│   │   ├── index.js
│   │   └── users.js
│   └── uploads
│       ├── 50d33a30f74fd55ffc0f3c0aaea989b6
│       └── f24715d08bab6243f62bbe9f16a52d05
├── crawl.sql
├── db.js
├── mail.js
├── main.js
├── package-lock.json
├── package.json
├── read
│   ├── article-detail.js
│   ├── articles.js
│   ├── index.js
│   ├── tags.js
│   └── text.html
├── readme.md
├── utils
│   ├── domain-util.js
│   └── puppeteer-utils.js
├── web
│   ├── middleware
│   │   └── auth.js
│   ├── public
│   │   └── css
│   ├── router
│   │   └── bdyp.js
│   ├── server.js
│   ├── update
│   └── views
│       ├── detail.html
│       ├── footer.html
│       ├── header.html
│       ├── index.html
│       ├── login.html
│       └── subscribe.html
└── write
    ├── articles.js
    ├── index.js
    └── tags.js

前面的髒活累活都整完啦,開搞開搞

第一步:熟悉爬蟲基礎概念

期待產出

這裏注意一定要先完成【開營計劃】中的項目前置,包括了依賴安裝,避免依賴版本導致的報錯

傳統爬蟲怎麼工作的:利用 request 包爬取掘金前端標籤下的首頁所有文章標題

目標

掘金前端標籤下的首頁所有文章標題並保存至【titles.txt】中

思路
實戰

我們先在倉庫根目錄下新建一個第一天實戰的目錄及文件1.puppertee/1.js(在 git bash 中可以直接執行如下命令)

mkdir 1.puppertee && cd 1.puppertee && touch 1.js

然後實現如下內容即可

let request = require("request");
let url = "https://juejin.cn/tag/%E5%89%8D%E7%AB%AF";
let fs = require("fs");
let regexp = /class="title" data-v-\w+>(.+?)</a>/g;

request(url, (err, response, body) ={
  let titles = [];
  body.replace(regexp, (matched, title) ={
    titles.push(title);
  });
  console.log(titles);
  fs.writeFileSync("titles.txt", titles);
});

執行如下命令查看效果

node 1.js
實現效果如下

前端爬蟲面向的問題:前端渲染導致傳統爬蟲無法抓取到數據

這是我們提出一個需求,我們希望抓取掘金文章,你們會發現抓取不到東西,因爲掘金文章是前端渲染的頁面,後端接口請求時只能獲取到掛載點和未執行的 js 文件(這就不過多解釋了),那我們該怎麼辦?

前端爬蟲!我們使用 puppeteer 實現前端爬蟲

puppeteer

閒話不多說,實戰下就知道了

前端爬蟲怎麼工作的:利用 puppetter 對百度官網進行截圖

目標

利用 puppetter 打開百度官網,並對頁面進行截圖,存儲在項目根路徑

思路
實戰

我們先在倉庫根目錄下新建一個第一天實戰的目錄及文件1.puppertee/2.js(在 git bash 中可以直接執行如下命令)

touch 2.js

然後實現如下內容即可

let puppeteer = require("puppeteer");
(async () ={
  // 打開一個無界面的瀏覽器
  const browser = await puppeteer.launch();
  // 打開一個空白頁
  let page = await browser.newPage();
  // 在地址欄中輸入百度的地址
  await page.goto("http://baidu.com");
  // 把當前頁面進行截圖 保存在 baidu.png 文件中
  await page.screenshot({
    path: "baidu.png",
  });
  await browser.close(); //關閉瀏覽器
})();

執行如下命令查看效果

node 2.js
實現效果如下

前端爬蟲怎麼工作的:利用 puppetter 爬取京東,模擬搜索實現爬取京東手機列表

目標

利用 puppetter 爬取京東,模擬搜索實現爬取京東手機列表

思路
實戰

我們先在倉庫根目錄下新建一個第一天實戰的目錄及文件1.puppertee/3.js(在 git bash 中可以直接執行如下命令)

touch 3.js

然後實現如下內容即可

const puppeteer = require("puppeteer");
(async function () {
  const browser = await puppeteer.launch({ headless: false }); //啓動瀏覽器
  let page = await browser.newPage(); //創建一個 Page 實例
  await page.setJavaScriptEnabled(true); //啓用 javascript
  await page.goto("https://www.jd.com/");
  const searchInput = await page.$("#key"); //獲取元素
  await searchInput.focus(); //定位到搜索框
  await page.keyboard.type("手機"); //輸入手機
  const searchBtn = await page.$(".button");
  await searchBtn.click();
  await page.waitForSelector(".gl-item"); //等待元素加載之後,否則獲取不了異步加載的元素
  const links = await page.$$eval(
    ".gl-item > .gl-i-wrap > .p-img > a",
    (links) ={
      return links.map((a) ={
        return {
          href: a.href.trim(),
          title: a.title,
        };
      });
    }
  );
  console.log(links);
})();

執行如下命令查看效果

node 3.js
實現效果如下

個人思考

我們已經可以利用 puppeteer 實現模擬用戶動作(寫入手機並觸發搜索),那如果我們要拿返回信息跳轉詳情並爬取詳情信息呢?請試試

第二步:抓去掘金數據

編寫網絡爬蟲抓取掘金數據(掘金標籤、文章、文章詳情),並存儲到 MySQL 數據庫中

  1. mysql 數據庫服務
  1. node 服務搭建(基於 express),提供觸發爬取接口

實現效果

爬取 tag 信息

入庫數據

mysql 數據庫服務

要建表,需搭建 mysql 服務,可參考個人文章(保姆級指南:centos 安裝 mysql ):https://juejin.cn/post/7104346481787666446/

我們找個可視化工具來控制 mysql,這裏選用了 navicat,破解版分享網盤如下

鏈接: https://pan.baidu.com/s/1RyTNoApa7MxkIDTtbQ2jkQ  密碼: jfjl
--來自百度網盤超級會員 V4 的分享
鏈接: https://pan.baidu.com/s/1WSxWm1eCqRnao9j8AGLQFQ  密碼: gdv1
--來自百度網盤超級會員 V4 的分享

一路 next 即可,安裝好後,連接剛剛搭建好的遠程 mysql,新建數據庫crawl-db

建表

使用 Navicat 導入數據庫

sql 數據

先下載對應的數據庫結構 sql 文件

鏈接: https://pan.baidu.com/s/12XVYjur54zz6eN9x-Ez9LA  密碼: mhjj
--來自百度網盤超級會員 V4 的分享

打開 Navicat Premium,然後點擊右鍵選擇新建數據庫,名字跟我們要導入的數據庫的名字一樣

點擊確定後,我們就可以見到左邊出現剛剛我們建立好的數據庫了,然後右擊選擇 “運行 SQL 文件” 會彈出一個框,點擊 “...” 選擇文件所在的路徑,

點擊開始,文件就會導入成功!

node 服務搭建

入口文件 server.js

我們先在倉庫根目錄下新建一個第二天實戰的目錄及文件web/server.js(在 git bash 中可以直接執行如下命令)

mkdir web && cd web && touch server.js

然後實現如下內容即可

let express = require("express");
const { query } = require("../db");
let app = express();
let read = require("./utils/read");
let write = require("./utils/write");

app.listen(8082);
app.post("/tag", async function (req, res) {
  // 獲取所有標籤
  let tagUrl = "https://juejin.im/subscribe/all";
  //讀取掘金的標籤列表
  let tags = await read.tags(tagUrl);
  // 把標籤寫到數據庫中
  await write.tags(tags);
});

app.post("/article", async function (req, res) {
  let { tagName } = req.query;
  let tags = await query(`SELECT * FROM tags`);
  tags = tags.filter((tag) => tag.name === tagName);
  // 根據標籤獲取所有的文章
  let allAricles = {};
  // 標籤有很多,不同的標籤下面的文章可能會重複
  for (tag of tags) {
    let articles = await read.articles(tag.href, 1);

    articles.forEach((article) =(allAricles[article.id] = article));
  }
  // {id:article}
  await write.articles(Object.values(allAricles));
});

其中爬取數據和寫入數據至數據庫的實現放在utils中,目錄結構如下

├── domain-util.js
├── puppeteer-utils.js
├── read
│   ├── article-detail.js
│   ├── articles.js
│   ├── index.js
│   ├── tags.js
└── write
    ├── articles.js
    ├── index.js
    └── tags.js

創建好後依次實現即可;

domain-util.js
const URI = require("urijs");

/**
 * @param {Mixin} url 地址或 uri
 */
const getHostName = function (uri) {
  uri = new URI(uri);
  return uri.hostname();
};
module.exports = {
  getHostName,
};
puppeteer-utils.js
let puppeteer = require("puppeteer");
const cheerio = require("cheerio");
const domainUtil = require("./domain-util");

let browser;
async function getHTML(uri, isAutoScrollToBottom = true) {
  let page = await openPage(uri);
  /** 自動滾動至頁面底部,用於處理頁面觸底加載的情況
   * @param {*} page page 對象
   * @param {*} interval 間隔請求時間,儘可能趨近【被爬頁面觸底加載請求接口】的返回時間,但一定不要小於,不然就會出現爬取不完整的情況
   */
  async function autoScrollToBottom(page, interval = 3000) {
    // Expose a function 這個用於客戶端代碼 debugger 避免源碼映射失效的情況 //# sourceURL=__puppeteer_evaluation_script_
    // 解決方案來源:https://stackoverflow.com/questions/65584989/debug-in-chromium-puppeteer-doesnt-populate-evaluate-script
    // 這個 api 原意是偵聽頁面中觸發的自定義事件,可見文檔 https://www.qikegu.com/docs/4564
    page.exposeFunction("nothing"() => null);
    //   放在這裏的函數會在客戶端環境下執行 並且裏面的內容和外層是隔絕的,這意味着外面的依賴、方法都不能使用
    await page.evaluate(async (...args) ={
      await new Promise((resolve, reject) ={
        let totalHeight = 0;
        function exec() {
          totalHeight = document.body.scrollHeight;
          // 1. 滾動到底部
          window.scrollBy(0, totalHeight);
          // 2. 等待 10s 判斷頁面高度有無變化
          setTimeout(() ={
            // 1. 變化了,則重複行爲
            if (document.body.scrollHeight > totalHeight) {
              exec();
            } else {
              //   2. 沒變化,則結束行爲
              resolve();
            }
          }, 3000);
        }
        exec();
      });
    });
  }
  if (isAutoScrollToBottom) {
    await autoScrollToBottom(page);
  }
  // 獲取頁面完整 dom
  let sum = await page.content();
  const $ = cheerio.load(sum);
  return $;
}
/** 打開一個無頭瀏覽器
 *
 * @param {*} opts
 * @returns
 */
async function getPage(
  opts = {
    headless: false,
    devtools: true,
  }
) {
  if (!browser) {
    browser = await puppeteer.launch(opts);
  }
  // 打開一個空白頁
  let page = await browser.newPage();
  return page;
}
/** 打開一個無頭瀏覽器 並跳轉至指定地址
 * @param {*} uri
 * @returns page 對象
 */
async function openPage(
  uri,
  opts = {
    headless: false,
    devtools: true,
  }
) {
  let page = await getPage(opts);
  //設置頁面打開時的頁面寬度高度
  //   await page.setViewport({
  //     width: 1920,
  //     height: 1080,
  //   });
  // 在地址欄中輸入百度的地址
  await page.goto(uri, {
    waitUntil: "networkidle2",
  });
  return page;
}
/** 爲頁面對象添加 cookies
 * @param {*} cookies
 * @param {*} page
 * @param {*} domain
 */
const addCookies = async (page, cookies, domain) ={
  if (typeof cookies === "string") {
    cookies = cookies.split(";").map((pair) ={
      let name = pair.trim().slice(0, pair.trim().indexOf("="));
      let value = pair.trim().slice(pair.trim().indexOf("=") + 1);
      return { name, value, domain };
    });
  }
  await Promise.all(
    cookies.map((pair) ={
      return page.setCookie(pair);
    })
  );
};
/**
 *
 * @param {*} url
 * @param {*} cookies 自己的 cookies 支持數組和字符串形式
 */
async function login(url, cookies) {
  let page = await getPage({
    ignoreHTTPSErrors: true,
    headless: false,
    args: ["--no-sandbox""--disable-setuid-sandbox"],
  });
  // const ps = await browser.pages();
  // await ps[0].close();
  await addCookies(page, cookies, domainUtil.getHostName(url)); //雲盤域名
  await page.setViewport({
    //修改瀏覽器視窗大小
    width: 1920,
    height: 1080,
  });
  await page.goto(url, {
    timeout: 600000,
    waitUntil: "networkidle2",
  });
  return page;
}

async function click(page, select) {
  await page.waitForSelector(select);
  let node = await page.$(select);
  node.click();
}
module.exports = {
  getHTML,
  openPage,
  login,
  click,
};
reead/index.js
let { tags } = require("./tags");
let { articles } = require("./articles");
module.exports = {
  tags,
  articles,
};
read/tags.js
const debug = require("debug")("juejin:task:read");
const puppeteerUtils = require("../puppeteer-utils");

function get(owner, props) {
  if (owner) {
    return owner[props];
  } else {
    return "";
  }
}

exports.tags = async function (uri) {
  debug("讀取文章標籤列表");
  let $ = await puppeteerUtils.getHTML(uri);
  let tags = [];

  let domTags = $("li.item");
  domTags.each((i, item) ={
    let tag = $(item);
    let image = tag.find("img.thumb").first();
    let title = tag.find(".title").first();
    let subscribe = tag.find(".subscribe").first();
    let article = tag.find(".article").first();
    let name = title.text().trim();
    tags.push({
      image: image.data("src") ? image.data("src").trim() : image.data("src"),
      name,
      url: `https://juejin.im/tag/${encodeURIComponent(title.text().trim())}`,
      subscribe: get(Number(subscribe.text().match(/(\d+)/)[1])),
      article: get(Number(article.text().match(/(\d+)/)[1])),
    });
    debug(`讀取文章標籤:${name}`);
  });
  return tags.filter((item) => item.name);
};
read/article-detail.js
const debug = require("debug")("juejin:task:read-detail");
const puppeteerUtils = require("../puppeteer-utils");
async function readArticle(id, uri) {
  debug("讀取博文");
  let $ = await puppeteerUtils.getHTML(uri, false);
  let article = $(".main-container");
  let title = article.find("h1").text().trim();
  let content = article.find(".article-content").html();
  // let tags = article.find(".tag-list-box>div.tag-list .tag-title");
  // tags = tags.map((index, item) ={
  //   let href = $(item).attr("href");
  //   return href ? href.slice(4) : href;
  // });
  let tags;
  // 獲取 yuan
  let metas = article.find("meta");
  for (let index = 0; index < metas.length; index++) {
    const meta = metas[index];
    if (meta.attribs && meta.attribs.itemprop === "keywords") {
      tags = meta.attribs.content;
    }
  }

  tags = tags.split(",");
  debug(`讀取文章詳情:${title}`);
  return {
    id,
    title,
    content,
    tags,
  };
}

module.exports = {
  readArticle,
};
read/article.js
const debug = require("debug")("juejin:task:read");
const puppeteerUtils = require("../puppeteer-utils");
const { readArticle } = require("./article-detail");
function removeEmoji(content) {
  return (content || "").replace(
    /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g,
    ""
  );
}

exports.articles = async function (uri, maxNum = 0) {
  let $ = await puppeteerUtils.getHTML(uri, false);
  let articleList = [];
  let items = $(".item .title");
  let articleNum = maxNum || items.length;
  for (let i = 0; i < articleNum; i++) {
    let article = $(items[i]);
    let href = article.attr("href").trim();
    let title = article.text().trim();
    let id = href.match(//(\w+)$/)[1];
    href = "https://juejin.im" + href;
    let articleDetail = await readArticle(id, href);
    articleList.push({
      href,
      title: removeEmoji(title),
      id,
      content: removeEmoji(articleDetail.content),
      tags: articleDetail.tags,
    });
    debug(`讀取文章列表:${title}`);
  }
  return articleList;
};
write/index.js
let { tags } = require("./tags");
let { articles } = require("./articles");
module.exports = {
  tags,
  articles,
};
write/tags.js
const { query, end } = require("../../../db");
const debug = require("debug")("juejin:task:write");
exports.tags = async function (tagList) {
  debug("保存文章標籤列表");
  // 這裏在表設計中新增了一個索引,用於確定 tag 名稱唯一
  for (tag of tagList) {
    let oldTags = await query(`SELECT * FROM tags WHERE name=? LIMIT 1 `[
      tag.name,
    ]);
    // oldTags = JSON.parse(JSON.stringify(oldTags));
    if (Array.isArray(oldTags) && oldTags.length > 0) {
      let oldTag = oldTags[0];
      await query(`UPDATE tags SET name=?,image=?,url=? WHERE id=?`[
        tag.name,
        tag.image,
        tag.url,
        oldTag.id,
      ]);
    } else {
      await query(`INSERT INTO tags(name,image,url) VALUES(?,?,?)`[
        tag.name,
        tag.image,
        tag.url,
      ]);
    }
  }
};
write/articles.js
const { query, end } = require("../../../db");
const debug = require("debug")("juejin:task:write");
const sendMail = require("../../../mail");
exports.articles = async function (articleList) {
  debug("寫入博文列表");
  for (article of articleList) {
    let oldArticles = await query(
      `SELECT * FROM articles WHERE id=? LIMIT 1 `,
      article.id
    );
    if (Array.isArray(oldArticles) && oldArticles.length > 0) {
      let oldArticle = oldArticles[0];
      await query(`UPDATE articles SET title=?,content=?,href=? WHERE id=?`[
        article.title,
        article.content,
        article.href,
        oldArticle.id,
      ]);
    } else {
      await query(
        `INSERT INTO articles(id,title,href,content) VALUES(?,?,?,?)`,
        [article.id, article.title, article.href, article.content]
      );
    }

    //   先全部刪除
    await query(`DELETE FROM article_tag WHERE article_id=`[article.id]);
    const where = "('" + article.tags.join("','") + "')";
    const sql = `SELECT id FROM tags WHERE name IN ${where}`;
    let tagIds = await query(sql);
    // 再全部插入
    for (row of tagIds) {
      await query(`INSERT INTO article_tag(article_id,tag_id) VALUES(?,?)`[
        article.id,
        row.id,
      ]);
    }
    let tagIDsString = tagIds.map((item) => item.id).join(",");

    // 在此,向所有訂閱了此標籤的用戶發送郵件
    let emailSQL = `
      SELECT DISTINCT users.email from user_tag INNER JOIN users ON user_tag.user_id = user_id WHERE tag_id IN (${tagIDsString})
    `;
    let emails = await query(emailSQL);
    for (let index = 0; index < emails.length; index++) {
      const emailInfo = emails[index];
      sendMail(
        emailInfo.email,
        `
        您訂閱的文章更新了
      `,
        `<a href="http:localhost:8080/detail/${article.id}">${article.title}</a>`
      );
    }
  }
};

第三步:將爬取到的數據在一個 web 應用中展示

將爬取到的數據在一個 web 應用中展示;包含

node 服務功能擴展(基於 express)

node 服務搭建

路由搭建:入口文件 server.js

實現如下內容即可

let express = require("express");
let bodyParser = require("body-parser");
let session = require("express-session");
let { checkLogin } = require("./middleware/auth");
const path = require("path");
const { query } = require("../db");
const CronJob = require("cron").CronJob;
const debug = require("debug")("crawl:server");
const { spawn } = require("child_process");
let app = express();
app.use(express.static("web/public"));
app.use(
  bodyParser.urlencoded({
    extends: true,
  })
);
app.use(bodyParser.json());
app.use(
  session({
    resave: true, // 每次都要重新保存 session
    saveUninitialized: true, // 保存未初始化的 session
    secret: "wzyan", // 指定密鑰
  })
);
app.use(function (req, res, next) {
  res.locals.user = req.session.user;
  next();
});
app.set("view engine""html");
app.set("views", path.resolve("web/views"));
app.engine("html", require("ejs").__express);
app.get("/", async function (req, res) {
  let { tagId } = req.query;
  let tags = await query(`SELECT * FROM tags`);
  tagId = tagId || tags[0].id;
  let articles = await query(
    `SELECT a.* from articles a inner join article_tag  t on a.id = t.article_id WHERE t.tag_id =`,
    [tagId]
  );
  res.render("index"{
    tags,
    articles,
  });
});
app.get("/login", async function (req, res) {
  res.render("login"{ title: "登錄" });
});
app.post("/login", async function (req, res) {
  let { email, password } = req.body;
  let oldUsers = await query(`SELECT * FROM users WHERE email=?`[email]);
  let user;
  if (Array.isArray(oldUsers) && oldUsers.length > 0) {
    user = oldUsers[0];
  } else {
    let result = await query(`INSERT INTO users(email,password) VALUES(?,?)`[
      email,
      password,
    ]);
    user = {
      id: result.insertId,
      email,
      password,
    };
  }
  // 如果登陸成功,就把當前的用戶信息放在會話中,並重定向到首頁
  req.session.user = user;
  res.redirect("/");
});

app.get("/subscribe", checkLogin, async function (req, res) {
  let tags = await query(`SELECT * FROM tags`);
  let user = req.session.user; //{id,name}
  let selectedTags = await query(
    `SELECT tag_id from user_tag WHERE user_id = ?`,
    [user.id]
  );
  let selectTagIds = selectedTags.map((item) => item["tag_id"]);
  tags.forEach((item) ={
    item.subscribe = selectTagIds.indexOf(item.id) != -1 ? true : false;
  });
  res.render("subscribe"{ title: "請訂閱你感興趣的標籤", tags });
});
app.post("/subscribe", checkLogin, async function (req, res) {
  console.log(req.body);
  let { tags } = req.body; //[ '1''2''9' ] }
  if (!tags) {
    tags = [];
  }
  if (typeof tags === "string") {
    tags = [tags];
  }
  function getNum(string) {
    return string.replace(/[^0-9]/gi, "");
  }
  tags = tags.map((tag) => getNum(tag));
  console.log(tags);
  let user = req.session.user; //{id,name}
  await query(`DELETE FROM user_tag WHERE user_id=?`[user.id]);
  for (let i = 0; i < tags.length; i++) {
    await query(`INSERT INTO user_tag(user_id,tag_id) VALUES(?,?)`[
      user.id,
      parseInt(tags[i]),
    ]);
  }
  res.redirect("/");
});

app.get("/detail/:id", async function (req, res) {
  let id = req.params.id;
  let articles = await query(`SELECT * FROM articles WHERE id=`[id]);
  res.render("detail"{ article: articles[0] });
});
app.listen(8082);

process.on("uncaughtException"function (err) {
  console.error("uncaughtException: %s", err.stack);
});

服務已經搭建完成了,就差一步啦,實現前端頁面,衝!

頁面實現:頁面模板文件夾 views

頁面都放在 views 中,目錄結構如下

├── detail.html
├── header.html
├── index.html
├── login.html
└── subscribe.html

這裏我們需要如下頁面

我們分別實現下,先實現首頁

mkdir views && cd views && touch index.html && touch login.html touch header.html touch detail.html touch subscribe.html

然後實現如下內容

<%- include ('header.html')%>
<div class="container">
  <div class="row">
    <div class="col-md-2">
      <ul class="list-group">
        <%tags.forEach(tag=>{%>
        <li class="list-group-item text-center">
          <a href="/?tagId=<%=tag.id%>">
            <img style="width: 25px; height: 25px" src="<%=tag.image%>" />
            <%=tag.name%>
          </a>
        </li>
        <%})%>
      </ul>
    </div>
    <div class="col-md-10">
      <ul class="list-group">
        <%articles.forEach(article=>{%>
        <li class="list-group-item">
          <a href="/detail/<%=article.id%>"> <%=article.title%> </a>
        </li>
        <%})%>
      </ul>
    </div>
  </div>
</div>

login 登陸頁,實現如下內容

<%- include ('header.html')%>
<div class="row">
  <div class="col-md-4 col-md-offset-4">
    <form method="POST">
      <input
        type="email"
        
        class="form-control"
        placeholder="請輸入郵箱進行登錄"
      />
      <input
        type="password"
        
        class="form-control"
        placeholder="密碼"
      />
      <button type="submit" class="btn btn-default">提交</button>
    </form>
  </div>
</div>

header 登陸頁,實現如下內容

<head>
  <meta charset="UTF-8" />
  <meta  />
  <meta http-equiv="X-UA-Compatible" content="ie=edge" />
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <title>博客列表</title>
</head>
<body>
  <nav class="navbar navbar-default">
    <div class="container-fluid">
      <!-- Brand and toggle get grouped for better mobile display -->
      <div class="navbar-header">
        <button
          type="button"
          class="navbar-toggle collapsed"
          data-toggle="collapse"
          data-target="#bs-example-navbar-collapse-1"
          aria-expanded="false"
        >
          <span>Toggle navigation</span>
          <span></span>
          <span></span>
          <span></span>
        </button>
        <a class="navbar-brand" href="#">博客列表</a>
      </div>

      <!-- Collect the nav links, forms, and other content for toggling -->
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav">
          <li><a href="/">首頁</a></li>
          <% if(user){ %>
          <li><a href="/subscribe">訂閱</a></li>
          <%} else {%>
          <li><a href="/login">登陸</a></li>
          <%}%>
        </ul>
      </div>
      <!-- /.navbar-collapse -->
    </div>
    <!-- /.container-fluid -->
  </nav>
</body>

detail 登陸頁,實現如下內容

<%- include ('header.html')%>
<div class="container">
  <div class="row">
    <div class="col-md-12">
      <div class="panel">
        <div class="panel-heading">
          <h1 class="text-center"><%- article.title%></h1>
        </div>
        <div class="panel-body"><%- article.content%></div>
        <div></div>
      </div>
    </div>
  </div>
</div>

subscribe 登陸頁,實現如下內容

<%- include ('header.html')%>
<style>
    .tag {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
    }
    .tag img {
        width: 50px;
        margin-bottom: 20px;
    }
</style>
<div class="row">
    <form method="POST">
        <input type="submit" class="btn btn-primary" />
        <%
            for(let i=0;i<tags.length;i++){
                let tag = tags[i];
                %>
            <div class="col-md-3 tag">
                <img src="<%=tag.image%>" />
                <p>
                    <%=tag.name%>
                </p>
                <p>
                    <%=tag.subscribe%> 關注 
                        <%=tag.article%> 文章
                </p>
                <div class="checkbox">
                    <label>
                        <input <%=tag.subscribe? "checked"""%> type="checkbox" 
                        <%=tag.id%>"> 關注
                    </label>
                </div>
            </div>
            <%}
        %>
    </form>
</div>

login 登陸頁,實現如下內容

<%- include ('header.html')%>
<div class="row">
  <div class="col-md-4 col-md-offset-4">
    <form method="POST">
      <input
        type="email"
        
        class="form-control"
        placeholder="請輸入郵箱進行登錄"
      />
      <input
        type="password"
        
        class="form-control"
        placeholder="密碼"
      />
      <button type="submit" class="btn btn-default">提交</button>
    </form>
  </div>
</div>
鑑權中間件實現:鑑權中間件

auth 鑑權中間件,在 web 目錄下執行如下命令

mkdir middleware && touch auth.html

然後實現如下內容

function checkLogin(req, res, next) {
  if (req.session && req.session.user) {
    next();
  } else {
    res.redirect("/login");
  }
}
module.exports = {
  checkLogin,
};

至此,我們就完成了用於數據展示的 node 服務搭建啦!

尾聲

少年們,心法已定,拿走不謝,嘗試動手自己實現下吧!希望可以幫到大家,爬蟲雖好,不要過度哦。

送上參考資料,助君一臂之力

參考資料

文檔
文章

前往微醫互聯網醫院在線診療平臺,快速問診,3 分鐘爲你找到三甲醫生。(https://wy.guahao.com/?channel=influence)

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ddmfrm1XVsxt1DOHaE4gxQ