一、需求说明
(1)确立项目:B2C结构的音乐搜索/播放平台
(2)项目功能介绍
-
用户注册登录:
1.注册:用户输入相关信息,能够创建账户 2.登录:用户输入用户名和密码,检验正确可以登录 3.Cookie:用户登录成功后在浏览器中保存Cookie以便保存登录信息 4.退出登录:清除用户cookie
-
音乐搜索:
1.搜索音乐:用户输入歌手/音乐的关键词,系统显示搜索到的音乐 2.下载音乐:用户可以对搜索到的音乐下载到本地 3.添加歌单:用户可以将搜索到的音乐加入自己的歌单
-
歌单:
1.歌单展示:首页会显示自己添加的所有歌曲 2.好友歌单:用户可以去到好友主页并看到他的歌单 3.歌曲播放:所有歌单的歌曲都可以直接点击播放
-
用户功能:
1.用户信息修改:在个人主页可以对自己的信息修改 2.添加/删除好友:用户通过对方用户名可以搜索到好友并选择添加或是删除 3.反馈:用户可以对网站提出建议和使用反馈
二、数据库设计/模型层设计
(1)相关说明
本次项目采用Django自带的模型层,Django模型层是Django框架自定义的一套独特的orm技术,能够有效防止sql注入等诸多问题,并能够提升开发效率。由于项目数据库表不多,因此本次项目直接直接采用在模型层设计好模型类,直接生成相关数据库表。
(2)模型类
在app目录下的models.py中创建如下类:
1. 用户数据库表
一共包含11个字段,分别为用户名(username)、密码(password)、电子邮件(email)、手机号(phonenumber)、学校(school)、账号/学号(number)、性别(gender)、用户状态(status)、个性签名(lable)、注册日期(regist_data)和一个自动生成的主键id,另外定义了一个自动转字典的方法toDict
class User(models.Model):
objects = None
username = models.CharField(max_length=255, unique=True)
password = models.CharField(max_length=64)
email = models.EmailField()
phonenumber = models.CharField(max_length=16)
school = models.CharField(max_length=64)
number = models.CharField(max_length=64)
gender = models.CharField(max_length=5)
status = models.IntegerField()
lable = models.CharField(max_length=255,default="")
regist_data = models.DateField(auto_now_add=True)
def toDict(self):
return {'id': self.id, 'username': self.username, 'password': self.password, 'email': self.password,
'phonenumber': self.phonenumber, 'school': self.school,'lable':self.lable,
'number': self.number, 'gender': self.gender, 'status': self.status, 'regist_data': self.regist_data}
2. 音乐表
用以存放用户收藏的歌曲,一共包含12个字段,分别为用户(user)、音乐名(musicname)、音乐作者名字(authorname)、音乐连接(music_href)、点击量(click)、图片链接(img_href)、图片(img)、评论(comments)、歌词(content)、歌曲状态:1表示正常,0表示删除 (status)、添加日期(add_data)和一个自动生成的主键id,另外定义了一个自动转字典的方法toDict
注意:用户表和音乐表是一对多的关系,一个用户可以收藏多首歌曲,所以user直接使用User外键,在数据库中这个字段为user_id。 另外,数据库中的部分字段在开发过程中并没有使用到,但为之后更新功能做准备就没有删除这些字段,例如点击量click设置的和status相同实质上目前并没有什么实质意义。
class Music(models.Model):
objects = None
user = models.ForeignKey("User",on_delete=models.CASCADE)
musicname = models.CharField(max_length=64)
authorname = models.CharField(max_length=64)
music_href = models.CharField(max_length=255)
click = models.IntegerField()
img_href = models.CharField(max_length=255)
img = models.ImageField()
comments = models.TextField()
content = models.TextField()
status = models.IntegerField() #0代表被删除 1代表正常
add_data = models.DateField(auto_now_add=True)
def toDict(self):
return {'id':self.id,'user': self.user, 'musicname': self.musicname, 'authorname': self.authorname,
'music_href': self.music_href,'click': self.click, 'img_href': self.img_href,
'img': self.img, 'comments': self.comments, 'content': self.content, 'status': self.status,'add_data':self.add_data}
3.反馈表
用于存放用户的反馈信息,一共包含5个字段,分别为反馈人姓名(name)、联系方式(contact)、建议内容(suggestion)、外键User(user)和一个自动生成的主键id
class Contact(models.Model):
user = models.ForeignKey("User", on_delete=models.CASCADE)
name = models.CharField(max_length=64)
contact = models.CharField(max_length=64)
suggestion = models.CharField(max_length=2000)
4.好友表
用以存放用户与用户之间的好友关系,一共3个字段,分别为用户id(userid)、好友id(friendid)和一个自动生成的主键id
class Friend(models.Model):
objects = None
userid = models.CharField(max_length=64)
friendid = models.CharField(max_length=64)
三、项目架构
(1)使用技术
-
基于Python语言,版本:>=3.7及以上
-
使用Django框架,版本:Django==3.2.5
-
Mysql数据库
-
连接数据库:PyMySQL==0.10.1
-
爬虫:requests 库、 AES加密:beautifulsoup4=4.9.3 pycryptodome=3.10.1
-
Web前端技术:HTML、CSS、Javascript和Jquery等
-
编译环境:Pycharm
(2)项目目录结构
主目录
│ manage.py
├─myadmin app文件
├─myweb 主文件
├─static 静态文件夹
└─templates 模板文件夹
myadmin目录
views下存放视图层py文件,用以用户交互逻辑的编写,middkeware.py为中间件py文件,urls用以设置路由
四、网易云音乐爬虫
网易云采取html嵌套,爬虫非常复杂,以下代码可以直接使用,如果不了解爬虫可以直接使用源代码。
待爬网址:https://music.163.com/#/search/m/?s={}
f12打开控制台,点开Network,刷新网页,发现web?csrf_token=这个文件的response由我们需要的所有信息,包括音乐名,作者名,音乐链接,音乐封面等等。
看到请求头如下:
得到请求网址是https://music.163.com/weapi/cloudsearch/get/web?csrf_token=,请求方式是POST,往下翻可以看到传递的参数是params和encSecKey,显然这两个参数是经过加密的。现在要做的就是模拟网易的加密过程,通过人工方式加密。
点开Initiator,可以看到执行栈,即所有经过的js脚本,打开第一个:
打开之后点击{}按钮:
看到程序停留在上面黄色的一行,即程序在这一行将数据发送出去,在这行打上断点(点击数字3516即可),刷新:
发现请求网址不是我们需要的网址,点击左上角运行下一步按钮,继续拦截下一次请求,直到得到我们想要的请求,发现在此时数据任然处于加密状态。
于是,继续往回找,打开Call Stack,点击第二个
此时参数d3x任然是加密状态
继续回找,直到找到未加密的数据data:
data = {
"#/404": "",
"csrf_token": "",
"hlposttag": "</span>",
"hlpretag": "<span class=\"s-fc7\">",
"limit": "30",
"offset": "0",
"s": self.kw, #搜索关键字
"total": "true",
"type": "1"
}
所以,可以断定在u3x.be3x文件时数据被加密了,找到加密的函数如下:
从这个函数第一行开始打断点,一行一行运行,可以发现在执行下面这一行开始,数据被加密了,将i3x(原始data数据)作为参数传入之后加密,在执行完下面的代码后产生了params和encSecKey。通过看下面的赋值过程可以发现我们要找的params就是encText,encSecKey就是encSecKey。
加密过程也就显而易见了,js执行window.asrsea()函数,向里面传入原数据data,和3个未知参数得到了buE3x这个参数,而这个参数里的encText,encSecKey分别赋值给了params以及encSecKey。那么直接在页面里查找window.asrsea,发现一共只有两处出现了它,一次是我们刚刚看见的它的调用,那么另外一次必然是它的定义。
我们看到程序将一个d赋值给了window.asrsea,而这个d就是在上面没几行的d函数,所以window.asrsea()就是d(),向里面传入的除了刚刚所说的原始数据data,另外3个未知参数就是efg。
至此,我们找到了js加密的全过程,d函数通过调用abc三个函数完成了数据的加密,我们只需要用python代码还原这个过程即可。后面的过程就不详细赘述了。
至于为什么直截取了abcd四个函数,是因为我们发现在执行过程中efg都可以视作定值,我们重新反观一下,我们最先得到的加密函数window.asrsea:
刚刚已经得知除了JSON.stringify(i3x)为原始数据外,另外三个参数就是efg,比如 bsf6Z([“流泪”, “强”]) 对应的就是 e,不如把它放到console控制台运行一下。其实会发现,不管怎么运行最后都是“010001”这个结果。
我们还原完毕整个加密过程后,就可以对内容进行爬取了。
最后,附上爬虫源代码:
import json
import requests
from Crypto.Cipher import AES
from base64 import b64encode
class MusicSpider:
def __init__(self,kw):
self.firsturl = "https://music.163.com/weapi/cloudsearch/get/web?csrf_token="
self.kw = kw
self.headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.164 Mobile Safari/537.36"
}
self.e = "010001"
self.f = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
self.g = "0CoJUm6Qyw8W8jud"
self.i = "a8YlfKxjlbqct8IK"
def get_EncKey(self):
return "15b80cbe4661d9a2f59580a5f550bf38b106c8b404dd957f8b02907ff42aa06bcd0a267dec83b0e1404d3b6cf590ed0bd7c25e51d2f7a05bc37f3bec2d647bbc053a896561842323df006719685dd7191bca9ee319028f73ac125301973980f6d988d69b753845839b64158cfa77a143539203c92ea3f4af05c5840f3d02d062"
def get_params(self,data):
first = self.enc_params(data,self.g)
second = self.enc_params(first, self.i)
return second
def to_16(self,data):
pad = 16 - len(data) % 16
data += chr(pad) * pad
return data
def enc_params(self,data,key):
iv = '0102030405060708'
data = self.to_16(data)
aes = AES.new(key = key.encode('utf-8'), IV=iv.encode('utf-8'), mode =AES.MODE_CBC)
bs = aes.encrypt(data.encode('utf-8'))
return str(b64encode(bs),'utf-8')
def get_music(self):
h_url = self.firsturl.format(self.kw, headers=self.headers)
data = {
"#/404": "",
"csrf_token": "",
"hlposttag": "</span>",
"hlpretag": "<span class=\"s-fc7\">",
"limit": "30",
"offset": "0",
"s": self.kw,
"total": "true",
"type": "1"
}
response = requests.post(h_url,headers = self.headers,data={
"params":self.get_params(json.dumps(data)),
"encSecKey":self.get_EncKey()
})
json1 = response.text
print(json1)
return json1
def get_msg(self):
musicurl = "https://music.163.com/song/media/outer/url?id="
dic = json.loads(self.get_music())
songs = dic['result']['songs']
res = []
for song in songs:
dict = {}
dict['musicname'] = song['name']
dict['authorname'] = song['ar'][0]['name']
dict['music_href'] = musicurl+str(song['id'])
dict['img_href'] = song['al']['picUrl']
res.append(dict)
return res
if __name__ == "__main__":
MusicSpider("薛之谦").get_msg()
五、Django视图层
(1) 登陆注册
交互逻辑
本项目登录注册全部放在了同一个函数中,首先判断过来的请求时GET还是POST,如果是GET直接,证明既不是登录也不是注册请求,直接返回登陆页面,如果是POST请求,判断收到的POST中是否含有phonenumber这个参数,如果没有的话,由于登录只需要填账号密码证明这是登录请求,去数据库中查询相关信息,如果能够和输入信息匹配,继续判断当前账号的状态为正常或是禁用,若为正常,为session赋值为用户对象返回的字典,并保留登录信息5天,并直接重定向到主界面函数,否则传回前端“该账户已被禁用”的信息。如果信息和数据库中的数据不一致返回“用户名或密码错误"的信息。如果数据库中查不到相关信息会发生异常,将这个异常直接抛掉并返回"用户名或密码错误"的信息给前端。
收到的POST中是否含有phonenumber这个参数表明这是注册请求,为用户创建User类,并将前端发送来的数据赋给User类,最后保存到数据库。用于用户名设置了unique=True,所以报错意味着数据库里已经存在这个用户名,抛出这个异常并向前端返回"用户名已存在"。
def login(request):
if request.method == 'GET':
#context = {"yz":'allow'}
return render(request, 'myadmin/index/login.html')
else:
if request.POST.get("phonenumber") == None:
try:
model = User.objects
user = model.get(username=request.POST['username'])
if user.status == 0:
context = {"info": "该账户被禁用"}
if user.password == request.POST['password']:
request.session.set_expiry(timedelta(days=5))
request.session['adminuser'] = user.toDict()
return redirect(reverse("myadmin_main"))
else:
context = {"info": "用户名或密码错误"}
except Exception as e:
print(e)
context = {"info": "登录账号不存在"}
return render(request, 'myadmin/index/login.html', context)
else:
try:
user = User()
user.username = request.POST['username']
user.password = request.POST['password']
user.email = request.POST['email']
user.number = request.POST['number']
user.phonenumber = request.POST['phonenumber']
user.school = request.POST['school']
user.status = 1
user.save()
context = {"registinfo": "注册成功"}
except Exception as e:
print(e)
context = {"registinfo": "用户名已存在"}
return render(request, 'myadmin/index/login.html', context)
中间件的配置
在app中设立中间件的目的旨在于拦截未登录用户想要直接访问登陆后页面的请求,如果用户未登录,在地址栏输入主页的网址会直接重定向到登陆页面。当然所有与登陆有关的内容会被全部放行。
import re
from django.shortcuts import redirect
from django.urls import reverse
class Middleware:
def __init__(self,get_response):
self.get_response = get_response
def __call__(self, request):
path = request.path
urllists = ['/myadmin/login','/myadmin/dologin','/myadmin/logout']
if re.match(r'^/myadmin',path) and (path not in urllists):
if 'adminuser' not in request.session:
return redirect(reverse("myadmin_login"))
response = self.get_response(request)
return response
(2)音乐搜索模块
前端以表单形式发起搜索的GET请求,后端得到搜索的内容,如果为空重定向到主页面,否则根据搜索内容运行爬虫程序,并将运行结果返回前端,前端以table形式展示(这一段代码不写在这里了)
视图层代码:
def search(request):
searchvalue = request.GET['searchvalue']
if searchvalue == "":
return redirect(reverse("myadmin_main"))
else:
musicpy = MusicSpider(searchvalue)
res = musicpy.get_msg()
context = {'msgs': res, "key": searchvalue}
return render(request, 'myadmin/music/search.html', context)
模板层html代码:
<form action="{% url 'myadmin_search' %}" method="get">
<input type="text" name='searchvalue' class="search-input" placeholder="歌名 / 歌手">
<button type="submit"><i class="fa fa-search" aria-hidden="true"></i></button>
</form>
(3)用户信息修改模块
首先通过session获取当前的用户信息,然后就是简单的对数据库进行增删改查操作,就不赘述了。
视图层代码:
def edit(request):
try:
myuserid = request.session['adminuser']['id']
usermode = User.objects
myuser = usermode.filter(id=myuserid)[0] # 获取自己的用户信息
myuser.username = request.POST['username']
myuser.gender = request.POST['gender']
myuser.number = request.POST['number']
myuser.phonenumber = request.POST['phonenumber']
myuser.email = request.POST['email']
myuser.school = request.POST['school']
myuser.lable = request.POST['lable']
myuser.save()
request.session['adminuser'] = myuser.toDict()
return redirect(reverse("myadmin_mybody"))
except:
myuserid = request.session['adminuser']['id']
usermode = User.objects
myuser = usermode.filter(id=myuserid)[0] # 获取自己的用户信息
dic = myuser.toDict() # 用户信息返回字典
context = {"info":"性别必须勾选或用户名已存在","myuser":dic}
return render(request,'myadmin/music/mybody.html',context)
模板层HTML代码:
<form method="post" action="{% url 'myadmin_edit' %}">
{% csrf_token %}
<font color="red">{{ info }}</font>
<p>
用户名 :
<input type="text" class="txt" size="12" value="{{ myuser.username }}" maxlength="20" name="username"/>
</p>
<p>
性 别:
{%if myuser.gender == "woman" %}
<input type="radio" name="gender" value="man"/>男
<input type="radio" name="gender" checked value="woman"/>女
{% else %}
<input type="radio" name="gender" checked value="man"/>男
<input type="radio" name="gender" value="woman"/>女
{% endif %}
</p>
<p>
账 号:
<input type="text" class="txt" value="{{ myuser.number}}" name="number"/>
</p>
<p>
联系电话:
<input type="text" class="txt" value="{{ myuser.phonenumber}}" name="phonenumber"/>
</p>
<p>
电子邮箱:
<input type="text" class="txt" value="{{ myuser.email}}" name="email"/>
</p>
<p>
学校:
<input type="text" class="txt" value="{{ myuser.school}}" name="school"/>
</p>
<p>
签名:
<br/>
<textarea name="lable" id="test" cols="50" rows="5">{{ myuser.lable }}</textarea>
<br/>
<input type="submit" name="submit" value="提交"/>
<input type="reset" name="reset" value="清除"/>
</p>
</form>
(4)添加/删除好友模块
这里当时在设计数据库的时候,忘记设置status了,后来又不想再动数据库,于是删除好友是真正意义上的将好友数据从数据库删除了。在前端还是利用form表单发送GET请求,然后数据库中搜索好友返回User类,将搜索到的内容返回前端。除此以外,在往前端返回的数据中含有一个friendmsg用以存放当前用户与搜索的用户之间的关系,如果搜索的用户在好友数据库表中能够查询到与自己为好友关系,friendmsg赋值为1,否则赋值为0。如果值为0在前端查询到的好友信息后加上"添加"的操作,如果值为1,证明两人已经是好友关系,那么就在前端查询到的好友信息后加上"删除"的操作。用于添加好友和个人信息修改在同一个界面,在返回的数据中顺带添加一个本用户的信息,否则提交后的页面本用户的信息将会消失。
视图层代码:
def searchfriend(request):
myuserid = request.session['adminuser']['id']
usermode = User.objects
myuser = usermode.filter(id=myuserid)[0] # 获取自己的用户信息
dic = myuser.toDict() # 用户信息返回字典
try:
friendname = request.POST["searchfriend"]
usermode = User.objects
frienduser = usermode.filter(username = friendname )[0]
frienduser_username = frienduser.username
frienduser_lable = frienduser.lable
friend_id = frienduser.id
friendmod = Friend.objects
friend = friendmod.filter(userid=str(myuserid),friendid=str(friend_id))
if len(friend) > 0:
friendmsg = 1
else:
friendmsg = 0
except Exception as e:
print(e)
frienduser_username = "没有查找到用户信息"
frienduser_lable = ""
friend_id = ""
friendmsg=""
context = {"friendusername":frienduser_username,"friendid":friend_id,"friendlable":frienduser_lable,"myuser":dic,"friendmsg":friendmsg}
return render(request,'myadmin/music/mybody.html',context)
模板层HTML代码:
{% if friendusername != None%}
<center>
<table style="margin-top:20px;margin-left:160px" width="800" border="1" >
<tr>
<th align="left"><h3 style="color:black">搜索结果</h3></th>
<th align="left"><h3 style="color:black">个性签名</h3></th>
<th align="left"><h3 style="color:black">操作</h3></th>
</tr>
<tr>
<td style="layout:fixed;height:90px">{{ friendusername }}</td>
<td style="layout:fixed;height:90px">{{ friendlable }}</td>
<td style="layout:fixed;height:90px">
{% if friendmsg == 0%}
<a href="{% url 'myadmin_addfriend' %}?friendid={{friendid}}"> 添加 </a>
{% elif friendmsg == 1%}
<a href="{% url 'myadmin_delfriend' %}?friendid={{friendid}}"> 删除 </a>
{% endif %}
</td>
</tr>
</table>
</center>
{%endif%}
以下是搜索19496222得到的结果,两人已经是好友关系,那么显示的操作是删除。
点击添加/删除按钮,通过在路由尾部加上?和好友id的方式向后端发送GET请求,后端得到好友id,将用户id与好友id一同存入数据库(从数据库中删除),执行完毕后重定向到初始界面。
def addfriend(request):
friendid = request.GET['friendid']
myuserid = request.session['adminuser']['id']
friend = Friend()
friend.userid = myuserid
friend.friendid = friendid
friend.save()
return redirect('/myadmin/mybody')
def delfriend(request):
friendid = request.GET['friendid']
myuserid = request.session['adminuser']['id']
friendmod = Friend.objects
friend = friendmod.filter(userid=str(myuserid), friendid=str(friendid))
friend.delete()
return redirect('/myadmin/mybody')
(5) 主界面渲染/歌曲播放
主界面渲染就是将主界面要展示的数据从数据库中读出来,然后传回前端。另外中间使用了Paginator分页器,做了翻页效果。
def main(request):
try:
page = request.GET['page']
if page == None:
pIndex = 1
else:
pIndex = int(page)
except:
pIndex=1
#用户歌单
mod = User.objects
user_id = request.session['adminuser']['id']
user = mod.filter(id=user_id)
musicmodel = Music.objects
umusics = musicmodel.filter(user=user[0],status=1)
p = Paginator(umusics,12) #12条数据一页,实例化分页对象
# 判断页码值是否有效
if pIndex < 1:
pIndex = 1
if pIndex > p.num_pages:
pIndex = p.num_pages
musics = p.page(pIndex)
music_res = []
for music in musics:
dic = music.toDict()
music_res.append(dic)
#热门榜单
superadmin = mod.filter(username = "superadmin")
hotmusics = musicmodel.filter(user=superadmin[0],status=1)
hotmusic_res = []
for hotmusic in hotmusics:
dic = hotmusic.toDict()
hotmusic_res.append(dic)
#好友展示
friendmod = Friend.objects
friends = friendmod.filter(userid=str(user_id))
md = User.objects
friendict = []
for friend in friends:
friendusr = md.filter(id=friend.friendid)
lable = friendusr[0].lable
if lable=="":
lable = "该用户暂无个性签名"
fdict = {"username":friendusr[0].username,"lable":lable}
friendict.append(fdict)
context = {"music_res":music_res,"hotmusic_res":hotmusic_res,"pIndex":pIndex,"pagelist":p.page_range,'pnum_pages':p.num_pages,"friendict":friendict}
return render(request, 'myadmin/music/index.html',context)
还是使用在路由后加?的方式强制发送GET请求,将要播放的音乐id传回后端,后端从数据库读取相关信息放回前端。
def musicplay(request):
musicid = request.GET['musicid']
musicmod = Music.objects
music = musicmod.filter(id = musicid,status=1)[0]
#下面是用于侧边栏的好友信息展示
mod = User.objects
user_id = request.session['adminuser']['id']
friendmod = Friend.objects
friends = friendmod.filter(userid=str(user_id))
md = User.objects
friendict = []
for friend in friends:
friendusr = md.filter(id=friend.friendid)
lable = friendusr[0].lable
if lable == "":
lable = "该用户暂无个性签名"
fdict = {"username": friendusr[0].username, "lable": lable}
friendict.append(fdict)
context = {'music':music,"friendict":friendict}
return render(request, 'myadmin/music/music.html',context)
其他功能模块如查看好友主页,查看好友歌单,播放好友歌曲的功能大同小异就不一一说明了。
六、项目展示
基于Django 以及 网易云音乐爬虫的音乐分享/下载/播放网站
附:
代码发布后发现的一个小bug:视图层里friend.py里搜索好友歌单的时候忘记加status=1的条件了,会导致好友歌单里会显示该好友已经删除的歌,记得修改一下
本项目前端使用的是网络模板,设计相关版权问题请联系我
代码地址:https://download.csdn.net/download/qq_49259434/20433919