我用vue3和egg開(kāi)發(fā)了一個(gè)早報(bào)學(xué)習(xí)平臺(tái),帶領(lǐng)群友走向技術(shù)大佬

項(xiàng)目功能介紹
技術(shù)棧介紹
前端實(shí)現(xiàn)
創(chuàng)建項(xiàng)目
按需引入antd組件
首頁(yè)
后端實(shí)現(xiàn)
創(chuàng)建項(xiàng)目
文章的獲取
分析html,獲取文章列表
發(fā)送信息到企業(yè)微信群
總結(jié)
好文推薦

項(xiàng)目功能介紹

該項(xiàng)目的出發(fā)點(diǎn)是獲取最新最值得推薦的文章以及面經(jīng),供群友們學(xué)習(xí)使用。帶領(lǐng)前端陽(yáng)光的群友們一起成為技術(shù)大佬。

當(dāng)點(diǎn)擊掘金的時(shí)候,就會(huì)獲取掘金當(dāng)前推薦的前端文章

當(dāng)點(diǎn)擊??途W(wǎng)的時(shí)候,就會(huì)獲取到最新的前端面經(jīng)

點(diǎn)擊【查看】就會(huì)跳到文章詳情頁(yè)

勾選后點(diǎn)擊確認(rèn),就會(huì)把文章標(biāo)題拼接到右邊的輸入框中,然后點(diǎn)擊發(fā)送,就會(huì)將信息發(fā)送到學(xué)習(xí)群里供大家閱讀。

項(xiàng)目源碼已經(jīng)放到github,歡迎fork,歡迎star。
地址:https://github.com/Sunny-lucking/morning-news
項(xiàng)目啟動(dòng):分別進(jìn)入server和client項(xiàng)目,執(zhí)行npm i安裝相關(guān)依賴,然后啟動(dòng)即可。
技術(shù)棧介紹

本項(xiàng)目采用的是前后端分離方案
前端使用:vue3 + ts + antd
后端使用:egg.js + puppeter
前端實(shí)現(xiàn)

創(chuàng)建項(xiàng)目
使用vue-cli 創(chuàng)建vue3的項(xiàng)目。

按需引入antd組件
借助babel-plugin-import實(shí)現(xiàn)按需引入
npm install babel-plugin-import --dev
然后創(chuàng)建配置.babelrc文件就可以了。
{
  "plugins": [
    ["import", { "libraryName": "ant-design-vue", "libraryDirectory": "es", "style": "css" }] // `style: true` 會(huì)加載 less 文件
  ]
}
我們可以把需要引入的組件統(tǒng)一寫(xiě)在一個(gè)文件里
antd.ts
import {
  Button,
  Row,
  Col,
  Input,
  Form,
  Checkbox,
  Card,
  Spin,
  Modal,
} from "ant-design-vue";

const FormItem = Form.Item;

export default [
  Button,
  Row,
  Col,
  Input,
  Form,
  FormItem,
  Checkbox,
  Card,
  Spin,
  Modal,
];
然后在入口文件里面use應(yīng)用它們main.js
import { createApp } from "vue";
import App from "./App.vue";
import antdCompArr from "@/antd";

const app = createApp(App);
antdCompArr.forEach((comp) => {
  app.use(comp);
});

app.mount("#app");
首頁(yè)






其實(shí)就一個(gè)頁(yè)面,所以,直接寫(xiě)在App.vue了
布局比較簡(jiǎn)單,直接亮html
<template>
  <div class="pape-wrap">
    <a-row :gutter="16">
      <a-col :span="16">
        <a-card
          v-for="group in paperList"
          :key="group.name"
          class="box-card"
          shadow="always"
        >
          <div class="clearfix">
            <span>{{ group.name }}</span>
          </div>
          <div class="channels">
            <a-button
              :style="{ 'margin-top': '10px', 'margin-left': '10px' }"
              size="large"
              v-for="item in group.list"
              :key="item.href"
              class="btn-channel"
              @click="onClick(item)"
            >
              {{ item.name }}
            </a-button>
          </div>
        </a-card>
      </a-col>
      <a-col :span="8">
        <a-form>
          <a-form-item
            :laba-col="{ span: 24 }"
            label="支持markdown輸入"
            label-align="left"
          >
            <a-textarea
              v-model:value="content"
              placeholder="暫支持mardown語(yǔ)法"
              show-count
            />
          </a-form-item>
          <a-form-item>
            <a-button @click="handleSendMsg"> 發(fā)消息 </a-button>
          </a-form-item>
        </a-form>
      </a-col>
    </a-row>

    <a-modal
      v-model:visible="visible"
      custom-class="post-modal"
      title="文章列表"
      @ok="handleComfirm"
    >
      <a-spin tip="Loading..." :spinning="isLoading">
        <div class="post-list">
          <div :style="{ borderBottom: '1px solid #E9E9E9' }">
            <a-checkbox
              v-model="checkAll"
              :indeterminate="indeterminate"
              @change="handleCheckAll"
              >全選</a-checkbox
            >
          </div>
          <br />
          <a-checkbox-group v-model:value="checkedList">
            <a-checkbox
              :value="item.value"
              v-for="item in checkoptions"
              :key="item.value"
            >
              {{ item.label }}
              <a
                class="a-button--text"
                style="font-size: 14px"
                target="_blank"
                :href="item.value"
                @click.stop
              >
                &nbsp; &nbsp;查看</a
              >
            </a-checkbox>
          </a-checkbox-group>
        </div>
      </a-spin>

      <span>
        <a-button @click="handleComfirm">確認(rèn)</a-button>
      </span>
    </a-modal>
  </div>
</template>
主要就是遍歷了paperList,而paperList的值是前端寫(xiě)死的。在constant文件里
export const channels = [
  {
    name: "前端",
    list: [
      {
        name: "掘金",
        bizType: "juejin",
        url: "https://juejin.cn/frontend",
      },
      {
        name: "segmentfault",
        bizType: "segmentfault",
        url: "https://segmentfault.com/channel/frontend",
      },
      {
        name: "Chrome V8 源碼",
        bizType: "zhihu",
        url: "https://zhuanlan.zhihu.com/v8core",
      },
      {
        name: "github-Sunny-Lucky前端",
        bizType: "githubIssues",
        url: "https://github.com/Sunny-lucking/blog/issues",
      },
    ],
  },
  {
    name: "Node",
    list: [
      {
        name: "掘金-后端",
        bizType: "juejin",
        url: "https://juejin.cn/frontend/Node.js",
      },
    ],
  },
  {
    name: "面經(jīng)",
    list: [
      {
        name: "??途W(wǎng)",
        bizType: "newcoder",
        url: "https://www.nowcoder.com/discuss/experience?tagId=644",
      },
    ],
  },
];


點(diǎn)擊按鈕的時(shí)候,出現(xiàn)彈窗,然后向后端發(fā)起請(qǐng)求,獲取相應(yīng)的文章。
點(diǎn)擊方法如下:
const onClick = async (item: any) => {
  visible.value = true;
  currentChannel.value = item.url;
  if (cache[currentChannel.value]?.list.length > 0) {
    const list = cache[currentChannel.value].list;
    state.checkedList = cache[currentChannel.value].checkedList || [];
    state.postList = list;
    return list;
  }
  isLoading.value = true;
  state.postList = [];
  const { data } = await getPostList({
    link: item.url,
    bizType: item.bizType,
  });
  if (data.success) {
    isLoading.value = false;
    const list = data.data || [];
    state.postList = list;
    cache[currentChannel.value] = {};
    cache[currentChannel.value].list = list;
  } else {
    message.error("加載失敗!");
  }
};
獲得文章渲染之后,勾選所選項(xiàng)之后,點(diǎn)擊確認(rèn),會(huì)將所勾選的內(nèi)容拼接到content里
const updateContent = () => {
  const date = moment().format("YYYY/MM/DD");
  // eslint-disable-next-line no-useless-escape
  const header = `<font color=\"#389e0d\">前端早報(bào)-${date}</font>,歡迎大家閱讀。\n>`;
  const tail = `本服務(wù)由**前端陽(yáng)光**提供技術(shù)支持`;
  const body = state.preList
    .map((item, index) => `#### ${index + 1}. ${item}`)
    .join("\n");
  state.content = `${header}***\n${body}\n***\n${tail}`;
};

const handleComfirm = () => {
  visible.value = false;
  const selectedPosts = state.postList.filter((item: any) =>
    state.checkedList.includes(item.href as never)
  );
  const selectedList = selectedPosts.map((item, index) => {
    return `[${item.title.trim()}](${item.href})`;
  });
  state.preList = [...new Set([...state.preList, ...selectedList])];
  updateContent();
};
然后點(diǎn)擊發(fā)送,就可以將拼接的內(nèi)容發(fā)送給后端了,后端拿到后再轉(zhuǎn)發(fā)給企業(yè)微信群
const handleSendMsg = async () => {
  const params = {
    content: state.content,
  };
  await sendMsg(params);
  message.success("發(fā)送成功!");
};
前端的內(nèi)容就講到這里,大家可以直接去看源碼:https://github.com/Sunny-lucking/morning-news
后端實(shí)現(xiàn)

創(chuàng)建項(xiàng)目
后端是使用egg框架實(shí)現(xiàn)的
快速生成項(xiàng)目
npm init egg
可以直接看看morningController的業(yè)務(wù)邏輯,其實(shí)主要實(shí)現(xiàn)了兩個(gè)方法,一個(gè)是獲取文章列表頁(yè)返回給前端,一個(gè)是發(fā)送消息。
export default class MorningPaper extends Controller {
  public async index() {
    const link = this.ctx.query.link;
    const bizType = this.ctx.query.bizType;
    let html = '';
    if (!link) {
      this.fail({
        msg: '入?yún)⑿r?yàn)不通過(guò)',
      });
      return;
    }
    const htmlResult = await this.service.puppeteer.page.getHtml(link);
    if (htmlResult.status === false) {
      this.fail({
        msg: '爬取html失敗,請(qǐng)稍后重試或者調(diào)整超時(shí)時(shí)間',
      });
      return;
    }
    html = htmlResult.data as string;
    const links = this.service.morningPaper.index.formatHtmlByBizType(bizType, html) || [];
    this.success({
      data: links.filter(item => !item.title.match('招聘')),
    });
    return;
  }

  /**
   * 推送微信機(jī)器人消息
   */
  async sendMsg2Weixin() {
    const content = this.ctx.query.content;
    if (!content) {
      this.fail({
        resultObj: {
          msg: '入?yún)?shù)據(jù)異常',
        },
      });
      return;
    }
    const token = this.service.morningPaper.index.getBizTypeBoken();
    const status = await this.service.sendMsg.weixin.index(token, content);
    if (status) {
      this.success({
        resultObj: {
          msg: '發(fā)送成功',
        },
      });
      return;
    }

    this.fail({
      resultObj: {
        msg: '發(fā)送失敗',
      },
    });
    return;
  }
}
文章的獲取






先看看文章是怎么獲取的。
首先是調(diào)用了puppeter.page的getHtml方法
該方法是利用puppeter生成一個(gè)模擬的瀏覽器,然后模擬瀏覽器去瀏覽頁(yè)面的邏輯。
 public async getHtml(link) {
    const browser = await puppeteer.launch(this.launch);
    const page: any = await browser.newPage();
    await page.setViewport(this.viewport);
    await page.setUserAgent(this.userAgent);
    await page.goto(link);
    await waitTillHTMLRendered(page);
    const html = await page.evaluate(() => {
      return document?.querySelector('html')?.outerHTML;
    });
    await browser.close();
    return {
      status: true,
      data: html,
    };
  }
這里需要注意的是,需要await waitTillHTMLRendered(page);,它的作用是檢查頁(yè)面是否已經(jīng)加載完畢。
因?yàn)椋M(jìn)入頁(yè)面,page.evaluate的返回可能是頁(yè)面還在加載列表當(dāng)中,所以需要waitTillHTMLRendered判斷當(dāng)前頁(yè)面的列表是否加載完畢。
看看這個(gè)方法的實(shí)現(xiàn):每隔一秒鐘就判斷頁(yè)面的長(zhǎng)度是否發(fā)生了變化,如果三秒內(nèi)沒(méi)有發(fā)生變化,默認(rèn)頁(yè)面已經(jīng)加載完畢
const waitTillHTMLRendered = async (page, timeout = 30000) => {
  const checkDurationMsecs = 1000;
  const maxChecks = timeout / checkDurationMsecs;
  let lastHTMLSize = 0;
  let checkCounts = 1;
  let countStableSizeIterations = 0;
  const minStableSizeIterations = 3;

  while (checkCounts++ <= maxChecks) {
    const html = await page.content();
    const currentHTMLSize = html.length;

    // eslint-disable-next-line no-loop-func
    const bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length);

    console.log('last: ', lastHTMLSize, ' <> curr: ', currentHTMLSize, ' body html size: ', bodyHTMLSize);

    if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) { countStableSizeIterations++; } else { countStableSizeIterations = 0; } // reset the counter

    if (countStableSizeIterations >= minStableSizeIterations) {
      console.log('Page rendered fully..');
      break;
    }

    lastHTMLSize = currentHTMLSize;
    await page.waitForTimeout(checkDurationMsecs);
  }
};
分析html,獲取文章列表
上述的行為只會(huì)獲取了那個(gè)頁(yè)面的整個(gè)html,接下來(lái)需要分析html,然后獲取文章列表。
html的分析其實(shí) 是用到了cheerio,cheerio的用法和jQuery一樣,只不過(guò)它是在node端使用的。
已獲取掘金文章列表為例子:可以看到是非常簡(jiǎn)單地就獲取到了文章列表,接下來(lái)只要返回給前端就可以了。
  getHtmlContent($): Link[] {
    const articles: Link[] = [];
    $('.entry-list .entry').each((index, ele) => {
      const title = $(ele).find('a.title').text()
        .trim();
      const href = $(ele).find('a.title').attr('href');
      if (title && href) {
        articles.push({
          title,
          href: this.DOMAIN + href,
          index,
        });
      }
    });
    return articles;
  }
發(fā)送信息到企業(yè)微信群
這個(gè)業(yè)務(wù)邏輯主要有兩步,
首先要獲取我們企業(yè)微信群的機(jī)器人的token,
接下來(lái)就將token 拼接成下面這樣一個(gè)url
`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${token}`
然后利用egg 的curl方法發(fā)送信息就可以了
export default class Index extends BaseService {
  public async index(token, content): Promise<boolean> {
    const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${token}`;
    const data = {
      msgtype: 'markdown',
      markdown: {
        content,
      },
    };
    const result: any = await this.app.curl(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      data,
    });
    if (result.status !== 200) {
      return false;
    }
    return true;
  }
}
后端的實(shí)現(xiàn)大抵如此,大家可以看看源碼實(shí)現(xiàn):https://github.com/Sunny-lucking/morning-news



作者:事業(yè)有成的張啦啦


歡迎關(guān)注微信公眾號(hào) :前端陽(yáng)光