本项目仅供学习使用, 请勿用来进行商业用途
本期知识点:
- 使用JS制作弹幕的方法
- 使用分组定位来实现弹幕不重叠
- 使用flask构建网站
- 爬虫: 百度新闻, B站榜单, 知乎热榜
前言
你是否在 刷B站 或 刷知乎 时觉得不够畅? 是否想在一个平台上同时获取多个平台的有趣内容?
这个网站将为你打开一扇快速通道
先来看效果
- 弹幕可分类显示, 也可以全部显示(可自己添加更多网站, 接口的使用方法见下文)
- 弹幕列表展示当前网站上显示的所有弹幕
- 点击弹幕可以查看详情, 包括作者/热度 和预览图(可扩展)
- 前后端分离, 后端无论使用什么语言和框架, 只要有数据传输到接口即可实现.
制作网站的缘由是我在刷新闻时的突发奇想, 纯属个人爱好, 项目源码: 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.youkuaiyun.com/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
整理一下上面代码在页面中实现的框架, 如图(同色表示同级):
-
barrage-container-wrap
是弹幕容器的底层画布,barrage-container
是盛放弹幕的容器 -
barrege-list
和barrage-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);
}
}
})();
上面的代码主要完成的了弹幕的生成, 简单来讲就是:生成->分组->定位
, 下面这张图能更清楚的表达逻辑:
- 初始化弹幕: 从后端获取弹幕数据. 计算屏幕的高度可以显示多少弹幕, 并对其进行切片分组. 然后传入创建弹幕事件.
- 创建弹幕: 在一个指定区域内, 通过随机值的方式设置弹幕的初始位置. 将设置好的弹幕元素传入初始化弹幕移动事件.
- 初始化弹幕移动: 左侧偏移值递减, 从而使弹幕移动, 然后将元素带入移动动画方法使移动轨迹更丝滑. 同时给弹幕元素设置一些事件(滑入, 滑出, 点击)
- 到这里第一组弹幕就开始移动了, 之所以弹幕会顺序播放且不会重叠, 根本原因就是他们的初始位置有足够的距离.
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>总结
好了以上就是本次分享的全部内容了, 目前项目的规模不算大, 但有很大的扩展性, 后续如果有更多点子会再更新这个项目.