【免费】基于Spark实时社交媒体舆情分析与趋势预测(Python版本+pyspark+可视化大屏+Kafka+FastAPI+Vue3) 锋哥原创出品,必属精品
大家好,我是Java1234_小锋老师,分享一套锋哥原创的基于Spark实时社交媒体舆情分析与趋势预测(Python版本+pyspark+可视化大屏+Kafka+FastAPI+Vue3)

项目介绍
随着微博、抖音、知乎、小红书等社交媒体的快速发展,网络舆情呈现出数据规模大、传播速度快、情感变化剧烈等特点。传统基于离线批处理的舆情分析方法难以满足“秒级感知、分钟级研判”的业务需求。针对上述问题,本文设计并实现了一套基于 Spark 的实时社交媒体舆情分析与趋势预测系统。系统采用前后端分离架构:前端基于 Vue3、Element Plus 与 ECharts 构建管理后台和可视化大屏;后端基于 Python 与 FastAPI 提供统一 REST API;消息层引入 Kafka 承接高并发舆情事件流;计算层使用 Spark Streaming(Structured Streaming)完成按小时窗口的帖文量、独立用户数、正中负情感分布与热度指数聚合;预测层基于 Spark ML 线性回归,结合滞后热度特征与小时特征,对舆情热度进行趋势预测,并以 RMSE、MAE、MAPE 评估模型误差。数据持久化采用 MySQL,数据库名为 db_social_opinion。系统还设计了 Kafka/Spark 不可用时的 pandas 与 scikit-learn 降级方案,保证演示与实验环境的可用性。测试结果表明,系统能够稳定完成舆情数据采集、实时统计、趋势预测与可视化展示,功能完整、结构清晰,达到本科毕业设计要求。
源码下载
链接: https://pan.baidu.com/s/15-uuTt3lRlIFc0AzRH8ANw?pwd=1234
提取码: 1234
系统展示



核心代码
"""
Spark Streaming 流式计算 - 消费 Kafka 数据并实时统计舆情
"""
from config import settings
def run_spark_streaming(events: list = None) -> list:
"""
运行 Spark Structured Streaming 处理社交媒体数据流
若传入 events 列表则直接处理(用于降级模式复用逻辑)
返回窗口统计结果列表
"""
try:
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
col, count, sum as spark_sum, countDistinct,
window, from_json, to_timestamp
)
from pyspark.sql.types import (
StructType, StructField, StringType, IntegerType, DoubleType
)
spark = SparkSession.builder \
.appName(settings.SPARK_APP_NAME) \
.master(settings.SPARK_MASTER) \
.config("spark.sql.shuffle.partitions", "4") \
.config("spark.driver.memory", "2g") \
.getOrCreate()
spark.sparkContext.setLogLevel("WARN")
schema = StructType([
StructField("user_id", IntegerType()),
StructField("platform_id", IntegerType()),
StructField("topic_id", IntegerType()),
StructField("content", StringType()),
StructField("sentiment", StringType()),
StructField("heat", DoubleType()),
StructField("event_time", StringType()),
])
if events:
df = spark.createDataFrame(events)
else:
raw_df = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", settings.KAFKA_BOOTSTRAP_SERVERS) \
.option("subscribe", settings.KAFKA_TOPIC) \
.option("startingOffsets", "earliest") \
.load()
df = raw_df.select(
from_json(col("value").cast("string"), schema).alias("data")
).select("data.*")
df = df.withColumn("ts", to_timestamp(col("event_time"), "yyyy-MM-dd HH:mm:ss"))
df = df.filter(col("ts").isNotNull())
windowed = df.groupBy(window(col("ts"), "1 hour")).agg(
count("*").alias("post_count"),
countDistinct("user_id").alias("uv"),
spark_sum((col("sentiment") == "positive").cast("int")).alias("positive"),
spark_sum((col("sentiment") == "neutral").cast("int")).alias("neutral"),
spark_sum((col("sentiment") == "negative").cast("int")).alias("negative"),
spark_sum("heat").alias("heat_index"),
)
if events:
rows = windowed.collect()
results = []
for row in rows:
start = row["window"].start
results.append({
"window_time": start.strftime("%Y-%m-%d %H:00:00"),
"post_count": int(row["post_count"]),
"uv": int(row["uv"]),
"positive": int(row["positive"]),
"neutral": int(row["neutral"]),
"negative": int(row["negative"]),
"heat_index": float(row["heat_index"]),
})
spark.stop()
return results
spark.stop()
return []
except Exception as e:
print(f"[Spark Streaming] 运行失败: {e}")
return None
def save_stats_to_db(stats: list):
"""
将统计结果写入数据库
"""
from database import SessionLocal
from models.realtime_stat import RealtimeStat
db = SessionLocal()
try:
for s in stats:
existing = db.query(RealtimeStat).filter(
RealtimeStat.window_time == s["window_time"]
).first()
if existing:
for k, v in s.items():
setattr(existing, k, v)
else:
db.add(RealtimeStat(**s))
db.commit()
print(f"[Spark Streaming] 已写入 {len(stats)} 条统计结果")
finally:
db.close()
<template>
<div class="page-container">
<div class="page-card">
<div class="page-title">舆情热度预测分析</div>
<div class="error-cards">
<div class="error-card">
<div class="metric-label">RMSE (均方根误差)</div>
<div class="metric-value">{{ errorMetric.rmse }}</div>
</div>
<div class="error-card">
<div class="metric-label">MAE (平均绝对误差)</div>
<div class="metric-value">{{ errorMetric.mae }}</div>
</div>
<div class="error-card">
<div class="metric-label">MAPE (平均绝对百分比误差 %)</div>
<div class="metric-value">{{ errorMetric.mape }}%</div>
</div>
</div>
<div ref="compareRef" class="pred-chart pred-chart-compare"></div>
<div ref="residualRef" class="pred-chart pred-chart-residual"></div>
<el-table :data="tableData" stripe border style="width:100%">
<el-table-column prop="window_time" label="时间窗口" min-width="170">
<template #default="{ row }">{{ formatWindowTime(row.window_time) }}</template>
</el-table-column>
<el-table-column prop="true_heat" label="真实热度" min-width="120">
<template #default="{ row }"><span style="color:#409eff;font-weight:600">{{ row.true_heat }}</span></template>
</el-table-column>
<el-table-column prop="pred_heat" label="预测热度" min-width="120">
<template #default="{ row }"><span style="color:#67c23a;font-weight:600">{{ row.pred_heat }}</span></template>
</el-table-column>
<el-table-column label="误差" min-width="100">
<template #default="{ row }">
<span :style="{ color: Math.abs(row.true_heat - row.pred_heat) > 50 ? '#f56c6c' : '#909399' }">
{{ (row.true_heat - row.pred_heat).toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="create_time" label="生成时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:16px;justify-content:flex-end" v-model:current-page="page" v-model:page-size="size" :total="total" layout="total, prev, pager, next" @change="loadTable" />
</div>
</div>
</template>
<script setup>
/**
* 预测分析页面:真实 vs 预测对比图 + 误差分析
*/
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import request from '@/utils/request'
import { formatDateTime, formatWindowTime } from '@/utils/format'
const errorMetric = ref({ rmse: 0, mae: 0, mape: 0 })
const tableData = ref([])
const page = ref(1)
const size = ref(10)
const total = ref(0)
const compareRef = ref(null)
const residualRef = ref(null)
let charts = []
function buildAxisLabel() {
return {
rotate: 30, interval: 'auto', fontSize: 11, margin: 16,
formatter(val) { const text = formatWindowTime(val); return text.length >= 16 ? `${text.slice(0,10)}\n${text.slice(11)}` : text },
}
}
function initCompareChart(data) {
const chart = echarts.init(compareRef.value)
const labels = data.map(d => formatWindowTime(d.window_time))
chart.setOption({
title: { text: '真实热度 vs 预测热度 对比', left: 'center', textStyle: { fontSize: 15 } },
tooltip: { trigger: 'axis' },
legend: { data: ['真实热度', '预测热度'], top: 32 },
xAxis: { type: 'category', data: labels, axisLabel: buildAxisLabel() },
yAxis: { type: 'value', name: '热度指数' },
series: [
{ name: '真实热度', type: 'line', smooth: true, data: data.map(d => Number(d.true_heat)), itemStyle: { color: '#409eff' }, lineStyle: { width: 3 } },
{ name: '预测热度', type: 'line', smooth: true, data: data.map(d => Number(d.pred_heat)), itemStyle: { color: '#67c23a' }, lineStyle: { width: 3, type: 'dashed' } },
],
grid: { left: 20, right: 24, bottom: 28, top: 72, containLabel: true },
})
charts.push(chart)
}
function initResidualChart(data) {
const chart = echarts.init(residualRef.value)
const labels = data.map(d => formatWindowTime(d.window_time))
chart.setOption({
title: { text: '预测残差分析 (真实值 - 预测值)', left: 'center', textStyle: { fontSize: 15 } },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: labels, axisLabel: buildAxisLabel() },
yAxis: { type: 'value', name: '残差' },
series: [{ type: 'bar', data: data.map(d => ({ value: d.residual, itemStyle: { color: d.residual >= 0 ? '#409eff' : '#f56c6c' } })), barWidth: 20 }],
grid: { left: 20, right: 24, bottom: 28, top: 56, containLabel: true },
})
charts.push(chart)
}
async function loadData() {
const [errorRes, compareRes, residualRes] = await Promise.all([
request.get('/prediction/error'),
request.get('/prediction/compare'),
request.get('/prediction/residual'),
])
errorMetric.value = errorRes.data
charts.forEach(c => c.dispose())
charts = []
initCompareChart(compareRes.data)
initResidualChart(residualRes.data)
}
async function loadTable() {
const res = await request.get('/prediction/list', { params: { page: page.value, size: size.value } })
tableData.value = res.data.items
total.value = res.data.total
}
onMounted(() => { loadData(); loadTable() })
onUnmounted(() => charts.forEach(c => c.dispose()))
</script>
<style scoped>
.pred-chart { width: 100%; margin-bottom: 24px; }
.pred-chart-compare { height: 480px; }
.pred-chart-residual { height: 420px; }
</style>
更多推荐





所有评论(0)