搭建一个弹幕新闻网站

本项目仅供学习使用, 请勿用来进行商业用途

本期知识点:

  1. 使用JS制作弹幕的方法
  2. 使用分组定位来实现弹幕不重叠
  3. 使用flask构建网站
  4. 爬虫: 百度新闻, B站榜单, 知乎热榜

前言

你是否在 刷B站 或 刷知乎 时觉得不够畅? 是否想在一个平台上同时获取多个平台的有趣内容?

这个网站将为你打开一扇快速通道

先来看效果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DZkmz6Nx-1588593087094)()]

  1. 弹幕可分类显示, 也可以全部显示(可自己添加更多网站, 接口的使用方法见下文)
  2. 弹幕列表展示当前网站上显示的所有弹幕
  3. 点击弹幕可以查看详情, 包括作者/热度 和预览图(可扩展)
  4. 前后端分离, 后端无论使用什么语言和框架, 只要有数据传输到接口即可实现.

制作网站的缘由是我在刷新闻时的突发奇想, 纯属个人爱好, 项目源码: https://github.com/zhanghao19/LetMeSee

网站的核心框架选择的是Flask, 优势是便捷, 且官方文档中有详细的入门教程: 快速上手flask

文章的描述顺序也是笔者搭建的流程

1>前端

弹幕新闻的重点在于展示, 其原理简单来说就像"往杯子里倒水"一样

1.1>网站框架

这里网站的框架指的是弹幕所显示在的地方, 我使用的是之前在学习Django的时候搭建的一个框架

以原网站作为基础框架, 使用jinja的继承功能来使我们的主要内容融入基础框架

你可以使用任意一款你喜欢的网站模板, 来作为放置弹幕的容器, 参考网站: Bootstrap

下载好你的模板, 参考以下代码中的block位置, 对自己的模板进行静态抽取:

<!-- Web/App/templates/base.html -->
<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="utf-8">
  <title id="title">{% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="../static/css/reset.css">
  <link rel="stylesheet" href="../static/css/base.css">
    <!-- 上面的是模板自带的静态文件, 下面是为项目需要准备的 -->
    {% block link %}{% endblock %}
</head>
<body>
<!-- header start -->
<header id="header">
  <div class="mw1200 header-contain clearfix">
    <!-- logo start -->
    <h1 class="logo">
      <a href="javascript:void(0);" class="logo-title">Python</a>
    </h1>
    <!-- logo end -->
    <!-- nav start -->
    <nav class="nav">
      <ul class="menu">
        <!-- 这里是导航条上的一些选项-->
          {% block nav %}{% endblock %}
      </ul>
    </nav>
    <!-- nav end -->
  </div>
</header>
<!-- header end -->
<!-- mian start -->
<main id="main">
<!-- 弹幕的容器 -->
{% block main %}{% endblock %}
</main>
<!-- main end -->
<!-- footer start -->
<footer id="footer"...>
<!-- footer end -->
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.js"></script>
{% block script %}{% endblock %}
</body>
</html>

1.2>网站内容

这里的内容是弹幕的主体, 可以放在大部分的网站模板上使用

下面的代码包含, 弹幕的容器, 弹幕列表, 弹幕详情页

<!-- Web/App/templates/barrage.html -->
{% extends 'base.html' %}
{% block title %}LetMeSee-弹幕新闻网{% endblock %}
{% block link %}
    <link rel="stylesheet" href="../static/css/barrage.css">
    <!-- 解决图片加载失败的问题 -->
    <meta name="referrer" content="no-referrer" />
{% endblock %}

{% block nav %}
<li><a href="/">全部</a></li>
<li><a href="/baidu/">新闻</a></li>
<li><a href="/bilibili/">B站</a></li>
<li><a href="/zhihu/">知乎</a></li>
{% endblock %}
{% block main %}
    <div class="box">
        <div class="barrage-container-wrap clearfix">
            <div class="barrage-container">
                <!-- 弹幕主体 -->
            </div>
             <div class="expand">
                 <img src="../static/img/list.png" alt="expand" title="弹幕列表">
             </div>
        </div>
    </div>
    <!-- 弹幕列表 start -->
    <div class="barrage-list">
        <div class="list-header">弹幕列表
            <img src="../static/img/close.png" alt="close" class="close-btn" title="关闭">
        </div>
        <ul>
            {% for barrage in barrages %}
                <!-- for循环展示弹幕 -->
                <li class="barrage-list-item" data-id="{{ barrage.BID }}">
                    <!-- truncate_text过滤器,过长字符串末尾显示为... -->
                    {{ barrage.BText | truncate_text }}
                </li>
            {% endfor %}
        </ul>
    </div>
    <!-- 弹幕列表 end -->
    <!-- 弹幕详情 start -->
    <div class="barrage-detail-panel">
        <div class="list-header">弹幕详情
            <img src="../static/img/close.png" alt="close" class="close-btn" title="关闭">
        </div>
        <h3 class="title"></h3>
        <p class="author"></p>
        <img src="../static/img/loading.gif" alt="弹幕封面" class="cover">
        <a class="source"><--查看源网页--></a>
    </div>
    <!-- 弹幕列表 弹幕详情 -->
{% endblock %}
{% block script %}
    <script type="text/javascript">
    //js和html文件是分开的,传递数据需要先定义好参数,再执行js。参考:https://blog.csdn.net/m0_38061194/article/details/78891125
        var Server = {
            barrages:{{ barrages|safe }}
        };
    </script>
    <script src="../static/js/barrage.js"></script>
    <script src="../static/js/barrage_list.js"></script>
    <script src="../static/js/barrage_details.js"></script>
{% endblock %}

自定义的过滤器truncate_text如下, 作用是过长字符串末尾显示为…

# Web/App/my_filters/truncate_text.py
def truncate_text(text):
    if len(text) > 19:
        new_text = text[0:17] + "..."
        return new_text
    else:
        return text

整理一下上面代码在页面中实现的框架, 如图(同色表示同级):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Wzh5zAL-1588593087098)()]

  1. barrage-container-wrap是弹幕容器的底层画布, barrage-container是盛放弹幕的容器

  2. barrege-listbarrage-detail是触发点击事件才显示的.

1.3>JS部分

1.3.1>弹幕主体

网上有很多中弹幕的设计方式, 个人认为区别点在于弹幕的不重叠, 本次使用的方式是通过分组定位来实现弹幕不重叠.

//Web/App/static/js/barrage.js
//弹幕的实现
(function () {
    /*******定义参数********/
    let barrageColorArray = {baidu : '#5519EB', bilibili: '#ff53e0', zhihu: '#0099cc'};
    let barrageBoxWrap = document.querySelector('.barrage-container-wrap');
    let barrageBox = document.querySelector('.barrage-container');

    //容器的宽高度
    let contentWidth = ~~window.getComputedStyle(barrageBoxWrap).width.replace('px', '');
    let boxHeight = ~~window.getComputedStyle(barrageBox).height.replace('px', '');
    //当前窗口可以垂直展示多少个弹幕, 30代表弹幕字体大小
    let howManyBarrageY = Math.round(boxHeight / 30);
    //定义一个包含弹幕的宽和高度范围的数组
    let heightArray = [];
    //将每个可用的高度,放入数组, 以便在创建数组时使用
    for (let i = 30; i < boxHeight - 10; i += 30) {
        heightArray.push(i)
    }

    /*******创建弹幕**********/
    function createBarrage(item, index, forTime) {
        if (index >= howManyBarrageY) {
            //如果索引达到高度数组的长度,则需重置索引到0,因此取余数
            index = index % howManyBarrageY;
        }
        let divNode = document.createElement('div');    //弹幕的标签
        let divChildNode = document.createElement('div');  //提示文本的标签

        divNode.innerHTML = item.BText;    //将弹幕内容插入标签中, innerHTML表示这个标签中的字符内容
        divNode.classList.add('barrage-item');  //追加class
        barrageBox.appendChild(divNode);    //弹幕的标签作为弹幕容器的子代标签

        divChildNode.innerHTML = '点击查看详情';  //鼠标悬停展示的内容
        divChildNode.classList.add('barrage-link');
        divNode.appendChild(divChildNode);  //提示文本的标签作为弹幕标签的子代标签

        //***设置弹幕的初始位置***
        //以容器的宽度为基准随机生成每条弹幕的左侧偏移值
        let barrageOffsetLeft = getRandom(contentWidth * forTime, contentWidth * (forTime + 0.618));
        //以容器的高度为基准随机生成每条弹幕的上方偏移值
        let barrageOffsetTop = heightArray[index];
        //通过弹幕类型选择颜色
        let barrageColor = barrageColorArray[item.BType];
        
        //执行初始化滚动
        //fun.call()传入的第一个参数作为之后的this,详解:https://codeplayer.vip/p/j7sj5
        initBarrage.call(divNode, {
            left: barrageOffsetLeft,
            top: barrageOffsetTop,
            color: barrageColor,
            barrageId: item.BID,
        });
    }

    /*******初始化弹幕移动(速度,延迟)*********/
    function initBarrage(obj) {
        //初始化位置颜色
        this.style.left = obj.left + 'px';
        this.style.top = obj.top + 'px';
        this.style.color = obj.color;

        //添加属性
        this.distance = 0;  //移动速度基准值
        this.width = ~~window.getComputedStyle(this).width.replace('px', '');   //弹幕的长度
        this.offsetLeft = obj.left;
        this.timer = null;
        this.timeOut = null;

        //弹幕子节点,即提示信息,span标签
        let barrageChileNode = this.children[0];
        barrageChileNode.style.left = (this.width - barrageTipWidth) / 2 + 'px';//定义span标签的位置

        //运动
        barrageAnimate(this);

        //鼠标悬停停止
        this.onmouseenter = function () {
            cancelAnimationFrame(this.timer);//弹幕停止移动
            function showDetailPopups() {
                //显示提示****此处用于展示详情窗口
                barrageChileNode.style.display = 'block';
            }
            //设置延迟显示
            this.timeOut = setTimeout(showDetailPopups, 1000);
        };

        //鼠标移走
        this.onmouseleave = function () {
            //鼠标移走,隐藏提示
            barrageChileNode.style.display = 'none';
            barrageAnimate(this);//弹幕继续移动
            clearTimeout(this.timeOut)
        };

        //打开弹幕对应的目标页面
        this.onclick = function () {
            let url = "/detail/",
                data = {barrage_id:obj.barrageId};
            $.ajax({
                type : "get",
                async : false,  //同步请求
                url : url,
                data : data,
                dataType: "json",
                success:function(barrage){
                    showDetailPanel(barrage)
                    // console.log(barrage)
                },
                error: function() {
                   alert("失败,请稍后再试!");
                }
            });
        };
    }
    
    /*******辅助弹幕移动*********/
    //弹幕动画
    function barrageAnimate(obj) {
        move(obj);

        if (Math.abs(obj.distance) < obj.width + obj.offsetLeft) {
            //满足以上条件说明弹幕在可见范围内
            obj.timer = requestAnimationFrame(function () {
                //在页面重绘之前会调用这个回调函数-->让弹幕继续移动
                barrageAnimate(obj);
            });
        } else {
            //超出可见范围,取消回调函数的调用-->让弹幕停止移动
            cancelAnimationFrame(obj.timer);
            //删除节点
            obj.parentNode.removeChild(obj);
        }
    }//回流:增删元素会引起回流,重绘:改变样式会引起重绘
    
    //弹幕移动
    function move(obj) {
        obj.distance -= 2; //移动速度为一次1像素
        //transform可以对元素进行翻转、移动、倾斜等操作,这里主要使用了移动元素的效果
        obj.style.transform = 'translateX(' + obj.distance + 'px)';
    }

    //随机获取区间内的一个值
    function getRandom(start, end) {
        return start + (Math.random() * (end - start)); //Math.random()随机获取一个0~1之间的值
    }

    /*******初始化事件**********/    //整个事件的入口
    //获取弹幕数据集
    let barrageArray = Server.barrages;
    //循环弹幕数组所需的切片次数, 弹幕总数/垂直可以显示的弹幕数=弹幕播放组数
    let howManyGroupBarrages = Math.ceil(barrageArray.length / howManyBarrageY);
    for (let i = 0; i < howManyGroupBarrages; i++) {
        //对弹幕数组切片,取出一部分要显示的弹幕,一直循环到取完
        let eachBarrageArray = barrageArray.slice(howManyBarrageY * i, howManyBarrageY * (i + 1));
        for (let item of eachBarrageArray) {
            //遍历每个弹幕, 并传入弹幕元素的索引,和循环次数(用作定位)
            createBarrage(item, eachBarrageArray.indexOf(item), i + 1);
        }
    }
})();

上面的代码主要完成的了弹幕的生成, 简单来讲就是:生成->分组->定位, 下面这张图能更清楚的表达逻辑:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PoJM59hU-1588593087101)()]

  1. 初始化弹幕: 从后端获取弹幕数据. 计算屏幕的高度可以显示多少弹幕, 并对其进行切片分组. 然后传入创建弹幕事件.
  2. 创建弹幕: 在一个指定区域内, 通过随机值的方式设置弹幕的初始位置. 将设置好的弹幕元素传入初始化弹幕移动事件.
  3. 初始化弹幕移动: 左侧偏移值递减, 从而使弹幕移动, 然后将元素带入移动动画方法使移动轨迹更丝滑. 同时给弹幕元素设置一些事件(滑入, 滑出, 点击)
  4. 到这里第一组弹幕就开始移动了, 之所以弹幕会顺序播放且不会重叠, 根本原因就是他们的初始位置有足够的距离.

PS: 弹幕不重叠还可以使用时间延迟的方式来实现, 有兴趣的同学可以参考文章:不碰撞弹幕的研究与实现

1.3.2>弹幕列表
//Web/App/static/js/barrage_list.js
let barrageList = document.querySelector('.barrage-list'),
    barrageDetailPanel = document.querySelector('.barrage-detail-panel');
//弹幕列表的实现
(function () {
    let expandBtn = document.querySelector('.expand');
    expandBtn.onclick = function () {
        //点击展开再次点击关闭
        if (barrageList.style.display === "none") {
            barrageList.style.display = "block";
        }else {
            barrageList.style.display = "none";
        }
        //关闭详情页显示列表页
        barrageDetailPanel.style.display = 'none'
    };

    let barrageItems = document.getElementsByClassName('barrage-list-item');    //li的集合
    for (let item of barrageItems){
        let barrageId = item.getAttribute('data-id');
        //点击单项打开详情页
        item.onclick = function () {
            let url = "/detail/",
                data = {barrage_id:barrageId};
            //ajax请求, 携带参数barrage_id
            $.ajax({
                type : "get",
                async : false,  //同步请求
                url : url,
                data : data,
                dataType: "json",
                success:function(barrage){
                    showDetailPanel(barrage)
                },
                error: function() {
                   alert("失败,请稍后再试!");
                }
            });
        };
    }
})();
1.3.3>弹幕详情
//Web/App/static/js/barrage_details.js
//展示弹幕详情页
function showDetailPanel(obj) {
    let barrageTitle = document.querySelector('.title'),
        barrageAuthor = document.querySelector('.author'),
        barrageCover = document.querySelector('.cover'),
        barrageURL = document.querySelector('.source');
    //关闭列表页显示详情页
    barrageDetailPanel.style.display = 'block';
    barrageList.style.display = "none";
    //设置详情页的参数
    barrageTitle.innerHTML = obj.BText;
    barrageAuthor.innerHTML = '--' + obj.BAuthor;
    barrageCover.setAttribute('src', obj.BCover);
    
    barrageURL.onclick = function () {
        window.open(obj.BUrl);
    };
}

//close button event
let closeBtns = document.querySelectorAll('.close-btn');
for (let closeBtn of closeBtns){
    closeBtn.onclick = function () {
        barrageDetailPanel.style.display = "none";
        barrageList.style.display = "none";
    };
}

1.4>其它静态文件

CSS

https://github.com/zhanghao19/LetMeSee/tree/master/Web/App/static/css

Image

https://github.com/zhanghao19/LetMeSee/tree/master/Web/App/static/css

2>后端

2.1>用flask构建网站

# Web/App/views/first_blue.py
import random
from pymongo import MongoClient
from flask import Blueprint, render_template, request, jsonify

# Blueprint(蓝图),提供快速注册端口,方便快捷.
# https://dormousehole.readthedocs.io/en/latest/blueprints.html#blueprints
first_blue = Blueprint('index', __name__)   # 创建一个蓝图对象

coll = MongoClient(host="localhost", port=27017).Spider.LetMeSee

# 从数据库中获取数据
baidu_barrages = [i for i in coll.find(
    {'BType': 'baidu'},
    {'_id': 0, 'BID': 1, 'BText': 1, 'BUrl': 1, 'BType': 1})]

bilibili_barrages = [i for i in coll.find(
    {'BType': 'bilibili'},
    {'_id': 0, 'BID': 1, 'BText': 1, 'BUrl': 1, 'BType': 1})]

zhihu_barrages = [i for i in coll.find(
    {'BType': 'zhihu'},
    {'_id': 0, 'BID': 1, 'BText': 1, 'BUrl': 1, 'BType': 1})]

@first_blue.route('/')
def index():
    # 拼接两个弹幕列表
    barrages = baidu_barrages + bilibili_barrages + zhihu_barrages
    random.shuffle(barrages)    # 打乱列表的顺序
    # 渲染模板, 传递数据
    return render_template('barrage.html', barrages=barrages)


@first_blue.route('/baidu/')
def baidu():
    return render_template('barrage.html', barrages=baidu_barrages)


@first_blue.route('/bilibili/')
def bilibili():
    return render_template('barrage.html', barrages=bilibili_barrages)


@first_blue.route('/zhihu/')
def zhihu():
    return render_template('barrage.html', barrages=zhihu_barrages)


@first_blue.route('/detail/')
def barrage_details():
    # 获取ajax请求携带的data中的barrage_id
    barrage_id = request.args.get('barrage_id')
    # 通过barrage_id取匹配数据库里的项
    barrage = coll.find_one(
        {'BID': barrage_id},
        {'_id': 0, 'WriteTime': 0})
    print(barrage, barrage_id, type(barrage_id))
    # 以json的形式返回响应
    return jsonify(barrage)

# Web/App/views/__init__.py
from .first_blue import first_blue
from Web.App.my_filters.truncate_text import truncate_text


def init_view(app):
    # 在应用对象上注册这个蓝图对象
    app.register_blueprint(first_blue)
    # 指定jinja引擎
    env = app.jinja_env
    # 加载自定义过滤器
    env.filters["truncate_text"] = truncate_text
    
# Web/App/__init__.py
from flask import Flask

from Web.App.views import init_view


def create_app():
    # 创建一个应用对象
    app = Flask(__name__)
    # 调用该方法,以初始化路由
    init_view(app)
    return app

# manage.py
from flask_script import Manager

from Web.App import create_app

app = create_app()
manager = Manager(app=app)

if __name__ == '__main__':
    manager.run()	# 使flask能够像django一样使用命令启动, "python manage.py runserver -r -d"
    

参考文档: 快速上手flask / Blueprint / jsonify

参考视频: 黑马程序员-6节课入门Flask框架web开发视频

ps: 我也是看这个视频学的flask, 老师讲解的很棒!

2.2>爬虫

2.2.1>百度新闻
# Spider/spider_mode/baidu_spider.py
import requests
from datetime import datetime
from lxml import etree

from pymongo import MongoClient

coll = MongoClient(host="localhost", port=27017).Spider.LetMeSee

resp = requests.get('https://news.baidu.com/')	# 请求页面
html = etree.HTML(resp.text)	# 创建xpath对象
barrage = []
item = {}

title_ls = html.xpath('//*[@id="pane-news"]//a//text()')	# 提取标题
url_ls = html.xpath('//*[@id="pane-news"]//a/@href')	# 提取链接

for n in range(len(title_ls)):
    item['BID'] = f'{n + 86000}'  # id
    item['BText'] = title_ls[n]
    item['BUrl'] = url_ls[n]
    item['BType'] = 'baidu'
    item['BCover'] = r'D:\Fire\PycharmProject\LetMeSee\Web\App\static\img\loading.gif'  # 封面
    item['BAuthor'] = '未知作者'  # 作者
    item['WriteTime'] = datetime.utcnow()  # 写入时间, 用于设置过期时间
    coll.insert_one(dict(item))
print('百度新闻--爬取完成!')
2.2.2>B站榜单
# Spider/spider_mode/bilibili_spider.py
from datetime import datetime
import json
import requests
import re

from pymongo import MongoClient

coll = MongoClient(host="localhost", port=27017).Spider.LetMeSee

resp = requests.get('https://www.bilibili.com/ranking')	# 请求页面
# 使用正则获取源码中存放在script标签中的数据
data_url = re.findall('window.__INITIAL_STATE__=(.*);\(function', resp.text)[0]
data_loaded = json.loads(data_url)  # 使用loads方法从 字符串 变成 字典
rankList = data_loaded['rankList']  # 排行榜中100个视频的信息

item ={}
for i in range(len(rankList)):
    item['BID'] = f'{i + 81000}'     # id
    item['BText'] = rankList[i]['title']    # 标题
    item['BAuthor'] = rankList[i]['author']  # 作者
    item['BUrl'] = 'https://www.bilibili.com/video/' + rankList[i]['bvid']   # 拼接的视频av号
    item['BType'] = 'bilibili'
    item['BCover'] = rankList[i]['pic']    # 封面
    item['WriteTime'] = datetime.utcnow()   # 写入时间, 用于设置过期时间
    coll.insert_one(dict(item))
print('B站榜单--爬取完成!')
2.2.3>知乎热榜
# Spider/spider_mode/zhihu_spider.py
import json
from datetime import datetime

import requests
from lxml import etree
from pymongo import MongoClient

# 用户登录后的cookies,直接F12->Network复制Request Headers的cookie即可, 这里只是自己建了一个放cookies的文件
from util.zhihu_cookies import Cookies


coll = MongoClient(host="localhost", port=27017).Spider.LetMeSee

headers = {'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
           'cache-control': 'max-age=0',
           'cookie': Cookies,	# 也可以直接将cookies直接copy到这里
           'upgrade-insecure-requests': '1',
           'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36'}
resp = requests.get('https://www.zhihu.com/hot', headers=headers)	# 请求页面

html = etree.HTML(resp.text)	# 创建xpath对象

data = html.xpath('//*[@id="js-initialData"]/text()')[0]	# 提取数据集
data_loaded = json.loads(data)	# 使用loads方法从 字符串 变成 字典
hotList = data_loaded["initialState"]["topstory"]["hotList"]	# 提取目标数据'hotList'

item ={}
for i in range(len(hotList)):
    item['BID'] = f'{i + 83000}'     # id
    item['BText'] = hotList[i]["target"]["titleArea"]["text"]    # 标题
    item['BAuthor'] = hotList[i]["target"]["metricsArea"]["text"]    # 标题
    item['BUrl'] = hotList[i]["target"]["link"]["url"]   # 拼接的视频av号
    item['BType'] = 'zhihu'
    item['BCover'] = hotList[i]["target"]["imageArea"]["url"]    # 封面
    item['WriteTime'] = datetime.utcnow()   # 写入时间, 用于设置过期时间
    coll.insert_one(dict(item))
print('知乎热榜--爬取完成!')

2.3>运行爬虫

爬虫文件都可以直接运行, 为了节省不必要的时间, 所以将它们整理到一个文件中运行, 如下:

# Spider/runSpider.py
from pymongo import MongoClient
import os


# 创建数据库对象
coll = MongoClient(host="localhost", port=27017).Spider.LetMeSee
coll.drop()	# 清空LetMeSee, 目的是使数据保持最新
# 设置延迟删除字段, 单位为秒
coll.create_index([('WriteTime', 1)], expireAfterSeconds=43200)

os.system(r"python D:\Fire\PycharmProject\LetMeSee\Spider\spider_mode\bilibili_spider.py")
os.system(r"python D:\Fire\PycharmProject\LetMeSee\Spider\spider_mode\baidu_spider.py")
os.system(r"python D:\Fire\PycharmProject\LetMeSee\Spider\spider_mode\zhihu_spider.py")

3>总结

好了以上就是本次分享的全部内容了, 目前项目的规模不算大, 但有很大的扩展性, 后续如果有更多点子会再更新这个项目.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值