周末项目:用Python+Neo4j从零搭建电影知识图谱(含爬虫技巧)
周末项目:用Python+Neo4j从零搭建电影知识图谱(含爬虫技巧)
周末想找点有挑战性又有趣的事情做?如果你对数据、Python和那些藏在电影背后的复杂关系网感兴趣,那么这个项目正适合你。我们不再满足于简单的数据列表,而是要亲手构建一个动态的、可视化的电影知识图谱。想象一下,你能清晰地看到汤姆·汉克斯主演了哪些电影,这些电影又属于什么类型,导演是谁,以及它们之间千丝万缕的联系——这远比看一张Excel表格要酷得多。这个项目面向有一定Python基础,想通过实战深入理解数据采集、非结构化数据处理和图数据库的开发者。我们将聚焦于最核心也最有趣的两个环节:如何从互联网上“抓取”电影数据,以及如何将这些数据转化为图数据库中的节点和关系。整个过程就像一次数字考古,你会亲手挖掘数据,并赋予它们新的生命。
1. 数据采集:从零开始的电影信息爬虫实战
构建知识图谱的第一步,也是决定图谱质量的关键一步,就是获取数据。我们不可能手动录入成千上万部电影的信息,因此,编写一个高效、健壮的爬虫是必经之路。这里,我们选择豆瓣电影作为数据源,因为它信息相对规整,社区活跃,数据维度丰富。
1.1 目标分析与请求策略
在动手写代码之前,我们必须明确要抓取什么,以及如何以友好的方式向目标网站发起请求。我们的目标是构建一个包含电影、演员、导演、类型等实体及其关系的图谱。因此,我们需要抓取以下核心信息:
- 电影实体:片名、上映年份、评分、简介。
- 人物实体:演员和导演的姓名。
- 关系:“演员-主演-电影”、“导演-执导-电影”、“电影-属于-类型”。
豆瓣电影有详细的列表页和详情页。一个高效的策略是:先从列表页(例如“正在热映”、“Top250”榜单)获取一批电影的ID和基础信息,再根据这些ID去详情页抓取更全面的数据,包括演员和导演列表。
注意:网络爬虫必须遵守网站的
robots.txt协议,并设置合理的请求间隔(如每次请求后休眠1-2秒),避免对目标服务器造成过大压力,这既是道德要求,也能防止你的IP被封锁。
为了实现这个策略,我们需要用到几个关键的Python库:
import requests
from bs4 import BeautifulSoup
import time
import re
requests用于发送HTTP请求,BeautifulSoup用于解析HTML文档,time用于控制请求频率,re则用来处理文本中的正则表达式匹配。
1.2 编写核心爬虫函数
让我们从定义一个函数开始,它负责获取并解析一个电影详情页。这个函数是整个数据采集流水线的核心。
def fetch_movie_detail(movie_id):
"""
根据电影ID抓取豆瓣电影详情页信息
Args:
movie_id (str): 豆瓣电影ID
Returns:
dict: 包含电影详细信息的字典,若抓取失败返回None
"""
url = f'https://movie.douban.com/subject/{movie_id}/'
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
try:
# 发送请求,并加入延时
time.sleep(1.5)
response = requests.get(url, headers=headers)
response.raise_for_status() # 检查请求是否成功
response.encoding = 'utf-8'
soup = BeautifulSoup(response.text, 'html.parser')
# 提取电影标题和年份
title_tag = soup.find('span', property='v:itemreviewed')
title = title_tag.text.split(' ')[0] if title_tag else '未知标题'
year_tag = soup.find('span', class_='year')
year = re.search(r'\((\d{4})\)', year_tag.text).group(1) if year_tag else '未知年份'
# 提取评分
rating_tag = soup.find('strong', class_='ll rating_num')
rating = rating_tag.text if rating_tag else '0.0'
# 提取简介(取前200字符)
summary_tag = soup.find('span', property='v:summary')
summary = summary_tag.text.strip().replace('\n', '')[:200] if summary_tag else ''
# 提取导演和演员信息(这是一个关键且稍复杂的部分)
directors = []
actors = []
# 查找包含导演和演员信息的区域
info = soup.find('div', id='info')
if info:
# 遍历所有文本,寻找“导演”和“主演”行
for line in info.stripped_strings:
if line.startswith('导演'):
# 找到导演行后,获取其后所有的a标签(导演姓名)
director_links = info.find('span', class_='attrs').find_all('a')
directors = [link.text for link in director_links]
break
# 演员信息通常在“主演”之后,处理方式类似但需注意页面结构变化
celebs = soup.find_all('a', rel='v:starring')
actors = [celeb.text for celeb in celebs[:5]] # 取前5位主要演员
# 提取电影类型
genres = []
genre_tags = soup.find_all('span', property='v:genre')
genres = [tag.text for tag in genre_tags]
return {
'id': movie_id,
'title': title,
'year': year,
'rating': rating,
'summary': summary,
'directors': directors,
'actors': actors,
'genres': genres
}
except requests.RequestException as e:
print(f"请求电影 {movie_id} 详情页失败: {e}")
return None
except Exception as e:
print(f"解析电影 {movie_id} 数据时发生错误: {e}")
return None
这个函数展示了如何处理一个相对复杂的HTML页面:定位特定标签、处理嵌套结构、使用正则表达式提取精确信息,以及进行健壮的异常处理。在实际操作中,你可能需要根据豆瓣页面结构的微小调整来更新选择器。
1.3 数据清洗与持久化
爬取到的原始数据往往是“脏”的,包含多余的空格、换行符、不一致的格式,甚至缺失值。在存入数据库前,必须进行清洗。
def clean_movie_data(raw_data):
"""
清洗单部电影数据
"""
cleaned = raw_data.copy()
# 清洗标题:去除首尾空格
cleaned['title'] = cleaned['title'].strip()
# 确保导演和演员列表不为None,且元素为字符串
cleaned['directors'] = [d.strip() for d in cleaned.get('directors', []) if isinstance(d, str)]
cleaned['actors'] = [a.strip() for a in cleaned.get('actors', []) if isinstance(a, str)]
# 处理简介:将多个连续空格合并为一个
if cleaned['summary']:
cleaned['summary'] = ' '.join(cleaned['summary'].split())
# 确保年份是数字字符串
if cleaned['year'].isdigit():
cleaned['year'] = int(cleaned['year'])
else:
cleaned['year'] = None # 标记为缺失
return cleaned
清洗完成后,我们可以选择将数据暂存为JSON或CSV文件,方便后续处理,也为爬虫中断后重启提供便利。
import json
def save_to_json(data_list, filename='movies_data.json'):
"""
将电影数据列表保存为JSON文件
"""
with open(filename, 'w', encoding='utf-8') as f:
json.dump(data_list, f, ensure_ascii=False, indent=2)
print(f"数据已保存至 {filename}")
2. 图数据库入门:Neo4j的核心概念与环境搭建
数据准备好了,接下来需要一个地方来存储它们,并直观地体现其间的联系。传统的关系型数据库(如MySQL)在处理这种多对多、层层关联的数据时,需要大量的JOIN操作,效率低下且查询语句复杂。而图数据库是为此类场景而生的。
2.1 为什么选择Neo4j?
Neo4j是目前最流行的原生图数据库。它的核心优势在于其属性图模型,这个模型非常直观,由三个基本元素构成:
- 节点(Node):代表实体。在我们的项目中,就是“电影”、“演员”、“导演”、“类型”。每个节点可以有标签(如
:Movie)和属性(如title: “肖申克的救赎”)。 - 关系(Relationship):代表节点之间的连接。关系是有方向的(从起始节点指向结束节点)并且可以有类型和属性。例如,
(演员)-[:主演]->(电影),(电影)-[:属于]->(类型)。 - 属性(Property):是存储在节点和关系上的键值对,用于描述其特征。
这种结构使得查询朋友的朋友(社交网络)、查找电影的共同演员(推荐系统)变得异常简单和高效。下表对比了关系型数据库与图数据库在处理关联查询时的思维差异:
| 查询场景 | 关系型数据库(SQL)思路 | 图数据库(Cypher)思路 |
|---|---|---|
| “找出汤姆·汉克斯主演的所有电影” | 在actors表找到ID -> 通过movie_actor关联表找到电影ID列表 -> 在movies表查询这些ID对应的电影信息。 |
匹配标签为Person、属性name为“汤姆·汉克斯”的节点,查找由ACTED_IN关系指向的所有Movie节点。 |
| “找出与电影A有相同导演和至少一位相同演员的所有电影” | 多层嵌套子查询或多次JOIN,语句复杂,性能随数据量增长可能急剧下降。 | 从电影A节点出发,沿DIRECTED_BY关系找到导演,再沿反向关系找到该导演的其他电影;同时沿ACTED_IN关系找到演员,再找这些演员的其他电影,最后取交集。路径表达清晰。 |
2.2 本地安装与初步使用
访问Neo4j官网下载免费的Neo4j Desktop。这是一个集成了数据库、浏览器和Bloom可视化工具的一体化开发环境,对初学者极其友好。
安装完成后,创建一个新的“Project”和“DBMS”(数据库管理系统)。启动数据库后,点击“Open”按钮,Neo4j Browser(一个Web界面)会自动打开。在这里,你可以使用Cypher查询语言与数据库交互。
首先,让我们运行一个简单的命令来感受一下Cypher。在Browser顶部的输入框中键入:
CREATE (m:Movie {title: 'The Matrix', released: 1999, tagline: 'Welcome to the Real World'})
RETURN m
点击执行,你会在右侧的图形视图看到一个孤零零的节点。这条语句的意思是:**创建(CREATE)一个带有标签Movie的节点m,并设置其属性,最后返回(RETURN)**这个节点。
接下来,我们创建一个人物并建立关系:
MATCH (m:Movie {title: 'The Matrix'})
CREATE (p:Person {name: 'Keanu Reeves', born: 1964})
CREATE (p)-[:ACTED_IN {roles: ['Neo']}]->(m)
RETURN m, p
这里先用**匹配(MATCH)**找到了刚才创建的《黑客帝国》电影节点,然后创建了一个人物“基努·里维斯”,最后创建了一个从人物指向电影的ACTED_IN关系,并在关系上添加了roles属性。
提示:在Neo4j Browser中,你可以随时使用
:play intro命令来运行一个官方的互动教程,这是快速上手Cypher的最佳途径。
3. 构建图谱:将电影数据导入Neo4j
现在,我们手握清洗好的JSON数据,也熟悉了Neo4j的基本操作。是时候将两者连接起来,实现从数据文件到知识图谱的自动构建了。我们将使用Neo4j的官方Python驱动neo4j。
3.1 建立Python与Neo4j的连接
首先,安装必要的驱动:
pip install neo4j
然后,编写连接代码。请将下面的URI、用户名和密码替换成你自己Neo4j Desktop中DBMS的设置(默认用户是neo4j,首次连接后会要求你修改密码)。
from neo4j import GraphDatabase
class Neo4jConnection:
def __init__(self, uri, user, password):
self._driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
self._driver.close()
def run_query(self, query, parameters=None):
with self._driver.session() as session:
result = session.run(query, parameters)
# 对于写入操作,我们消费结果以确保事务完成
return list(result)
# 初始化连接
conn = Neo4jConnection("bolt://localhost:7687", "neo4j", "your_password_here")
3.2 设计图谱数据模型并实现导入
在编写导入脚本前,我们需要在脑中(或纸上)清晰定义图谱的模型。基于我们的数据,一个简单而有效的模型如下:
- 节点标签:
Movie,Person,Genre - 关系类型:
(:Person)-[:DIRECTED]->(:Movie)(:Person)-[:ACTED_IN]->(:Movie)(:Movie)-[:BELONGS_TO]->(:Genre)
这个模型清晰地分离了实体和关系。接下来,我们编写一个函数,将一部电影的数据插入到这个模型中。这里的关键是使用Cypher的MERGE语句。MERGE会检查模式是否存在,如果不存在则创建,存在则不做改变(或进行更新)。这能有效避免创建重复的节点。
def create_movie_graph(tx, movie_data):
"""
在一个事务中创建一部电影及其相关节点和关系的子图
"""
# 1. 创建或匹配电影节点
tx.run("""
MERGE (m:Movie {id: $id})
SET m.title = $title,
m.year = $year,
m.rating = $rating,
m.summary = $summary
""", **movie_data)
# 2. 处理导演关系
for director_name in movie_data.get('directors', []):
tx.run("""
MERGE (p:Person {name: $name})
SET p:Director
MERGE (p)-[:DIRECTED]->(m:Movie {id: $movie_id})
""", name=director_name, movie_id=movie_data['id'])
# 3. 处理演员关系
for actor_name in movie_data.get('actors', []):
tx.run("""
MERGE (p:Person {name: $name})
SET p:Actor
MERGE (p)-[:ACTED_IN]->(m:Movie {id: $movie_id})
""", name=actor_name, movie_id=movie_data['id'])
# 4. 处理类型关系
for genre_name in movie_data.get('genres', []):
tx.run("""
MERGE (g:Genre {name: $name})
MERGE (m:Movie {id: $movie_id})-[:BELONGS_TO]->(g)
""", name=genre_name, movie_id=movie_data['id'])
最后,我们编写主函数,读取JSON文件,并遍历每部电影数据,调用上述函数将其导入Neo4j。
def import_movies_from_json(filename, connection):
with open(filename, 'r', encoding='utf-8') as f:
movies = json.load(f)
with connection._driver.session() as session:
for i, movie in enumerate(movies):
# 每处理100部电影打印一次进度
if i % 100 == 0:
print(f"正在导入第 {i} 部电影...")
session.execute_write(create_movie_graph, movie)
print("所有电影数据导入完成!")
# 执行导入
import_movies_from_json('cleaned_movies_data.json', conn)
conn.close()
运行这个脚本,耐心等待一会儿(取决于数据量)。完成后,打开Neo4j Browser,输入 MATCH (n) RETURN n LIMIT 50,你就能看到初步成型的电影知识图谱了!密密麻麻的节点和关系线可能会让你眼花缭乱,但这正是图数据库的魅力所在。
4. 图谱查询与应用:用Cypher挖掘电影关系
数据导入后,静态的图谱只是开始。真正的价值在于如何查询和利用这些关系。Cypher是Neo4j的查询语言,它的语法非常直观,旨在匹配图中的模式。
4.1 基础查询与模式匹配
让我们从几个实用的查询开始,感受Cypher如何像描述一幅画一样描述数据关系。
查询1:找出某位演员主演的所有电影及其评分。
MATCH (p:Person {name: '汤姆·汉克斯'})-[:ACTED_IN]->(m:Movie)
RETURN m.title AS 电影名称, m.year AS 上映年份, m.rating AS 评分
ORDER BY m.year DESC
这条语句匹配了所有“名为汤姆·汉克斯的人”-“主演了”->“电影”的模式,并返回电影的属性。
查询2:查找同时由两位特定演员参演的电影。
MATCH (p1:Person {name: '莱昂纳多·迪卡普里奥'})-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(p2:Person {name: '凯特·温斯莱特'})
RETURN m.title AS 合作电影, m.year AS 年份
这里匹配了一个更复杂的模式:两个演员节点通过ACTED_IN关系指向同一个电影节点。中间的<-[:ACTED_IN]-表示反向的关系。
4.2 高级查询:路径发现与推荐逻辑
图数据库最强大的能力之一是路径查找。例如,我们想了解两位演员之间的“合作距离”。
查询3:找出连接两位演员的最短路径(通过共同电影)。
MATCH path = shortestPath(
(p1:Person {name: '吴京'})-[:ACTED_IN|DIRECTED*]-(p2:Person {name: '斯嘉丽·约翰逊'})
)
RETURN path
shortestPath函数会寻找两点间的最短路径。[:ACTED_IN|DIRECTED*]表示路径可以由ACTED_IN或DIRECTED关系构成,*表示任意长度。执行后,Neo4j Browser会图形化展示这条路径,你可能发现他们通过一系列电影和共同合作者被连接起来。
基于图谱,我们可以实现简单的推荐功能。一个经典的思路是“喜欢这个的人也可能喜欢那个”。
查询4:基于共同演员的电影推荐。
// 假设用户喜欢《盗梦空间》
MATCH (target:Movie {title: '盗梦空间'})<-[:ACTED_IN]-(actor:Person)-[:ACTED_IN]->(recommendation:Movie)
WHERE recommendation <> target
RETURN recommendation.title AS 推荐电影,
collect(actor.name) AS 共同演员, // 收集所有共同演员的名字
count(actor) AS 共同演员数 // 统计共同演员的数量作为推荐强度
ORDER BY 共同演员数 DESC
LIMIT 10
这个查询首先找到《盗梦空间》的所有演员,然后找出这些演员参演的其他电影,最后按共同演员的数量排序,推荐关联最紧密的电影。
4.3 性能优化与小技巧
随着数据量增长,查询性能变得重要。以下是一些立竿见影的优化技巧:
- 创建索引:对经常用于查询条件的节点属性创建索引,可以大幅提升匹配速度。
CREATE INDEX ON :Movie(title); CREATE INDEX ON :Person(name); CREATE INDEX ON :Genre(name); - 使用
PROFILE或EXPLAIN:在查询语句前加上PROFILE,Neo4j会显示查询的执行计划,帮助你识别性能瓶颈(如全节点扫描)。 - 限制路径深度:在可变长度关系查询中(如
[*..5]),务必设置上限,避免查询失控。 - 在应用层分页:对于返回大量结果的查询,使用
SKIP和LIMIT进行分页,而不是一次性拉取所有数据。
当你运行这些查询,看到复杂的关系被轻松地挖掘和可视化出来时,周末项目的成就感便达到了顶峰。你构建的不仅仅是一个数据库,而是一个可以探索的电影宇宙模型。接下来,你可以考虑为这个图谱添加Web前端,或者集成一个简单的自然语言接口,让它变成一个真正的“电影问答系统”。不过,那将是另一个有趣的故事了。我自己的经验是,在导入几万条数据后,尝试写一个查询来找出“谁是连接不同类型电影最多的演员”,结果花了些时间优化,但最终跑出来的结果和可视化呈现,感觉之前所有的调试都是值得的。
更多推荐



所有评论(0)