效果
实时渲染预览:
loneapex.cn/heatmap/(通过php实现)
loneapex.cn/heatmap/index_.html(通过html实现)
原理
上一次逛GitHub时,心血来潮想给博客整一个热力图,用来记录博客的更新频率。奈何在网上找了一圈后,有关博客热力图的案例寥寥无几,而且为数不多的案例全部失效。这其中很大原因就是wordpress博客引擎无法精确统计字数的问题(因为wordpress无法统计中文)
所以,我们不妨换个思路,抛开wordpress,把目光投向几乎所有的博客系统都能使用的方案——通过rss统计字数和时间。
正是因为要保证兼容性,几乎所有博客的rss订阅文件样式都是统一的(xml格式),因此这个程序理论上可以统计任意具有rss功能的博客网站,而且不必担心随着时间而失效。
如图,这是本站的rss文件开头部分。不难发现,每篇文章开头部分总有一个item
标识,并且都标明了发布日期,所以我们可以通过item查找每篇文章并统计文章字数。
由于通过rss统计,所以博客rss必须保证显示在统计范围内(一年)的全部文章,并且rss要显示全文,否则会统计不准确。
1.必须打开rss功能
2.rss能显示近一年的文章,且全文显示
最后的最后,当然要知道自己网站的rss订阅链接啦~后面会用到。(比如我的是https://loneapex.cn/feed)
实现
由于遇了到博客不支持php的现象,我又让chatgpt重构了一个纯html+JavaScript的版本,但它俩实现的效果都是一模一样的。
php实现
注:php获取rss文件时可能会报错(好像是域名dns解析错误),这种情况下把域名改成ip即可
在注释 // 在此处填写你的 RSS 地址
填上rss地址,剩下的按照注释去做即可。
<!--博客热力图 start-->
<?php
date_default_timezone_set('Asia/Shanghai');
// 获取RSS内容(使用cURL)
function getRssContent($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$content = curl_exec($ch);
if (curl_errno($ch)) {
die("无法获取RSS内容: " . curl_error($ch));
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode !== 200) {
die("RSS请求失败,HTTP状态码: $httpCode");
}
curl_close($ch);
return $content;
}
// 解析RSS并统计字数
function parseRss($rssContent) {
libxml_use_internal_errors(true);
$xml = simplexml_load_string($rssContent);
if ($xml === false) {
die("RSS解析失败");
}
$stats = [];
$namespaces = $xml->getNamespaces(true);
foreach ($xml->channel->item as $item) {
$pubDate = (string)$item->pubDate;
$date = date('Y-m-d', strtotime($pubDate));
$contentNS = $item->children($namespaces['content']);
$contentEncoded = (string)$contentNS->encoded;
$content = html_entity_decode(strip_tags($contentEncoded));
$content = preg_replace('/\s+/', ' ', $content);
$content = trim($content);
$wordCount = mb_strlen($content);
if (!isset($stats[$date])) {
$stats[$date] = 0;
}
$stats[$date] += $wordCount;
}
return $stats;
}
// 生成完整日期范围
function generateDateRange($startDate, $endDate) {
$interval = new DateInterval('P1D');
$period = new DatePeriod($startDate, $interval, $endDate);
$fullStats = [];
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$fullStats[$dateStr] = 0;
}
return $fullStats;
}
// 颜色等级计算
function getColorLevel($count) {
if ($count === 0) return 0;
elseif ($count <= 100) return 1;
elseif ($count <= 300) return 2;
elseif ($count <= 500) return 3;
else return 4;
}
// 主程序
try {
$rssUrl = 'https://123.60.42.216/feed'; // 请在此处填写你的 RSS 地址
$rssContent = getRssContent($rssUrl);
$stats = parseRss($rssContent);
// 调整日期范围,使其从最近一年前的周一开始,到最近的周日结束
$startDate = new DateTime('-1 year');
if ($startDate->format('N') != 1) { // N: 1 (周一) ... 7 (周日)
$startDate->modify('last monday');
}
$endDate = new DateTime();
if ($endDate->format('N') != 7) {
$endDate->modify('next sunday');
}
$endDate->modify('+1 day'); // 包含最后一天
$fullStats = generateDateRange($startDate, $endDate);
// 合并统计数据
foreach ($stats as $date => $count) {
if (isset($fullStats[$date])) {
$fullStats[$date] = $count;
}
}
// 计算总字数
$totalCount = array_sum($fullStats);
// 将统计数据按天顺序存入数组,并按每7天一组分成每周的数据
$days = [];
foreach ($fullStats as $date => $count) {
$days[] = [
'date' => $date,
'count' => $count,
'level' => getColorLevel($count)
];
}
$weeks = array_chunk($days, 7);
// 生成每列(月)的标签:当本周第一天的月份与上一周不同则显示
$monthLabels = [];
$prevMonth = '';
foreach ($weeks as $i => $week) {
$weekStart = new DateTime($week[0]['date']);
$month = $weekStart->format('n'); // 月份(无前导零)
$year = $weekStart->format('Y');
$label = "$year.$month";
if ($month !== $prevMonth) {
$monthLabels[$i] = $label;
$prevMonth = $month;
} else {
$monthLabels[$i] = '';
}
}
} catch (Exception $e) {
die("发生错误: " . $e->getMessage());
}
?>
<!--主HTML-->
<style>
.heatmap-table {
border-collapse: collapse;
margin: 0 auto;
}
.heatmap-table th, .heatmap-table td {
padding: 2px;
}
.month-label {
text-align: center;
font-size: 12px;
color: #666;
}
.day-label {
font-size: 12px;
color: #666;
text-align: right;
padding-right: 4px;
}
.day-cell {
width: 12px;
height: 12px;
background-color: #ebedf0;
border-radius: 2px;
position: relative;
}
.day-cell:hover::after {
content: attr(data-date) ": " attr(data-count) "字";
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
font-size: 12px;
z-index: 10;
}
.level-0 { background-color: #ebedf0; }
.level-1 { background-color: #c6e48b; }
.level-2 { background-color: #7bc96f; }
.level-3 { background-color: #239a3b; }
.level-4 { background-color: #196127; }
.scroll-container {
overflow-x: auto; /* 允许水平滚动 */
-webkit-overflow-scrolling: touch; /* iOS上更顺滑的滚动 */
}
</style>
<div class="scroll-container">
<!-- 上部月份标签 -->
<table class="heatmap-table">
<tr>
<th></th>
<?php foreach ($weeks as $index => $week): ?>
<th class="month-label"><?= $monthLabels[$index] ?></th>
<?php endforeach; ?>
</tr>
</table>
<!-- 主体热力图 -->
<table class="heatmap-table">
<?php
// 定义一周内7天的名称(从周一到周日)
$dayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
// 需要显示标签的行索引:0(周一)、2(周三)、4(周五)、6(周日)
$labelRows = [0, 2, 4, 6];
?>
<?php for ($i = 0; $i < 7; $i++): ?>
<tr>
<td class="day-label">
<?php if (in_array($i, $labelRows)): ?>
<?= $dayNames[$i] ?>
<?php endif; ?>
</td>
<?php foreach ($weeks as $week): ?>
<?php $day = $week[$i]; ?>
<td>
<div class="day-cell level-<?= $day['level'] ?>"
data-date="<?= $day['date'] ?>"
data-count="<?= $day['count'] ?>">
</div>
</td>
<?php endforeach; ?>
</tr>
<?php endfor; ?>
</table>
<!-- 底部统计总字数 -->
<div style="text-align:center; margin-top:20px; font-size:14px; color:#333;">
本站近365天的废话总产量(含代码、「说说」短文章):<?= $totalCount ?>
</div>
</div>
<!--博客热力图 end-->
HTML+js实现
注:如果通过ip访问rss的话,可能会遇到跨域错误。具体去配置一下nginx就行了。当然,用域名的话当我没说
ps.通过前端加载的方式相较于php而言,有概率加载失败,而且加载速度很慢。建议优先使用PHP的那个版本
<!--博客热力图 start-->
<style>
.heatmap-table {
border-collapse: collapse;
margin: 0 auto;
}
.heatmap-table th, .heatmap-table td {
padding: 2px;
}
.month-label {
text-align: center;
font-size: 12px;
color: #666;
}
.day-label {
font-size: 12px;
color: #666;
text-align: right;
padding-right: 4px;
}
.day-cell {
width: 12px;
height: 12px;
background-color: #ebedf0;
border-radius: 2px;
position: relative;
}
.day-cell:hover::after {
content: attr(data-date) ": " attr(data-count) "字";
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
font-size: 12px;
z-index: 10;
}
.level-0 { background-color: #ebedf0; }
.level-1 { background-color: #c6e48b; }
.level-2 { background-color: #7bc96f; }
.level-3 { background-color: #239a3b; }
.level-4 { background-color: #196127; }
.scroll-container {
overflow-x: auto; /* 允许水平滚动 */
-webkit-overflow-scrolling: touch; /* iOS上更顺滑的滚动 */
}
</style>
<div class="scroll-container">
<div id="heatmap-container">
<!-- 热力图内容由 JavaScript 动态生成 -->
</div>
<div id="totalCount" style="text-align:center; margin-top:20px; font-size:14px; color:#333;"></div>
</div>
<script>
// 请修改为你的RSS地址,如若跨域问题,可使用代理,例如:
// const rssUrl = 'https://api.allorigins.hexocode.repl.co/get?disableCache=true&url=' + encodeURIComponent('https://123.60.42.216/feed');
const rssUrl = 'https://loneapex.cn/feed';
// 获取RSS内容(使用 fetch )
function fetchRss(url) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('网络错误,状态码:' + response.status);
}
return response.text();
});
}
// 解析 XML 字符串
function parseXML(str) {
return (new window.DOMParser()).parseFromString(str, "text/xml");
}
// 格式化日期为 YYYY-MM-DD
function formatDate(date) {
const y = date.getFullYear();
const m = ('0' + (date.getMonth() + 1)).slice(-2);
const d = ('0' + date.getDate()).slice(-2);
return `${y}-${m}-${d}`;
}
// 计算颜色等级(统计字符数)
function getColorLevel(count) {
if (count === 0) return 0;
else if (count <= 100) return 1;
else if (count <= 300) return 2;
else if (count <= 500) return 3;
else return 4;
}
// 获取指定日期所在那一周的周一(JS中周日为0,周一为1)
function getLastMonday(date) {
const day = date.getDay();
const diff = (day === 0 ? 6 : day - 1);
const lastMonday = new Date(date);
lastMonday.setDate(date.getDate() - diff);
return lastMonday;
}
// 获取指定日期所在那一周的周日
function getNextSunday(date) {
const day = date.getDay();
const diff = (day === 0 ? 0 : 7 - day);
const nextSunday = new Date(date);
nextSunday.setDate(date.getDate() + diff);
return nextSunday;
}
// 生成完整日期范围对象:{ "YYYY-MM-DD": 0, ... }
function generateDateRange(startDate, endDate) {
const fullStats = {};
const current = new Date(startDate);
while (current <= endDate) {
fullStats[formatDate(current)] = 0;
current.setDate(current.getDate() + 1);
}
return fullStats;
}
// 去除 HTML 标签
function stripHTML(html) {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || div.innerText || "";
}
// 主函数:获取RSS、统计字数、生成热力图
function generateHeatmap() {
fetchRss(rssUrl)
.then(text => {
const xml = parseXML(text);
const items = xml.getElementsByTagName("item");
const stats = {};
// 遍历每个RSS条目
for (let i = 0; i < items.length; i++) {
const item = items[i];
const pubDateElem = item.getElementsByTagName("pubDate")[0];
if (!pubDateElem) continue;
const pubDate = pubDateElem.textContent;
const dateObj = new Date(pubDate);
const dateStr = formatDate(dateObj);
// 优先获取 content:encoded 元素,没有则使用 description
let content = "";
const contentEncoded = item.getElementsByTagName("content:encoded")[0];
if (contentEncoded) {
content = contentEncoded.textContent;
} else {
const description = item.getElementsByTagName("description")[0];
if (description) {
content = description.textContent;
}
}
content = stripHTML(content).replace(/\s+/g, ' ').trim();
const wordCount = content.length; // 此处统计字符数,可根据需要改为单词数
if (!stats[dateStr]) {
stats[dateStr] = 0;
}
stats[dateStr] += wordCount;
}
// 设置日期范围:从1年前的上一个周一到最近的下一个周日
let startDate = new Date();
startDate.setFullYear(startDate.getFullYear() - 1);
startDate = getLastMonday(startDate);
let endDate = new Date();
endDate = getNextSunday(endDate);
const fullStats = generateDateRange(startDate, endDate);
// 合并 RSS 数据
for (let date in stats) {
if (fullStats.hasOwnProperty(date)) {
fullStats[date] = stats[date];
}
}
// 计算总字数
let totalCount = 0;
for (let date in fullStats) {
totalCount += fullStats[date];
}
// 将数据转换为数组,按日期顺序排列
const days = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dStr = formatDate(d);
days.push({
date: dStr,
count: fullStats[dStr],
level: getColorLevel(fullStats[dStr])
});
}
// 按每7天分组为一周(保证从周一开始)
const weeks = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
// 生成月份标签:当本周第一天的月份与上一周不同则显示
const monthLabels = [];
let prevMonth = "";
for (let i = 0; i < weeks.length; i++) {
const weekStart = new Date(weeks[i][0].date);
const month = weekStart.getMonth() + 1;
const year = weekStart.getFullYear();
const label = `${year}.${month}`;
if (month !== parseInt(prevMonth)) {
monthLabels.push(label);
prevMonth = month;
} else {
monthLabels.push("");
}
}
// 构建 HTML 结构
let html = "";
// 月份标签表格
html += '<table class="heatmap-table"><tr><th></th>';
for (let i = 0; i < weeks.length; i++) {
html += `<th class="month-label">${monthLabels[i]}</th>`;
}
html += '</tr></table>';
// 主体热力图表格
html += '<table class="heatmap-table">';
const dayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const labelRows = [0, 2, 4, 6]; // 仅在这些行显示标签
for (let row = 0; row < 7; row++) {
html += '<tr>';
// 左侧行标签
if (labelRows.includes(row)) {
html += `<td class="day-label">${dayNames[row]}</td>`;
} else {
html += '<td class="day-label"></td>';
}
// 每周的单元格
for (let w = 0; w < weeks.length; w++) {
const day = weeks[w][row];
html += `<td><div class="day-cell level-${day.level}" data-date="${day.date}" data-count="${day.count}"></div></td>`;
}
html += '</tr>';
}
html += '</table>';
// 插入页面中
document.getElementById("heatmap-container").innerHTML = html;
document.getElementById("totalCount").innerHTML = `<span style="color: #f1c40f;" !important>本站近365天的废话总产量(含代码、「说说」短文章):${totalCount} 字</span>`;
})
.catch(error => {
console.error("发生错误:", error);
document.getElementById("heatmap-container").innerText = "加载RSS失败:" + error.message;
});
}
document.addEventListener("DOMContentLoaded", generateHeatmap);
</script>
<!--博客热力图 end-->
写在最后
正如开头所言,本文几乎所有的代码都是chatgpt生成的。不过虽说这样,Mimosa也花了几个小时来调试代码,好累…
可话又说回来了,前几个月在本站的介绍页上挂了「Not By AI」运动的标识,转过头来就用AI写的代码发文章,这何尝不是违背了初心呢(笑
嘛,不可否认的是,chatgpt确实很强。
如果让Mimosa比较一下常用的AI在生活/文学/中文领域的排名,我会选择:
DeepSeep>ChatGPT>gemini>ClaudeAI
但如果在编程领域呢,我想我的选择是这样的:
ChatGPT>DeepSeep>gemini>ClaudeAI,而且chatgpt完爆deepseek.
感觉这样会有一些不可避免问题,
说说我的思路
创建插件,方便部署和调戏
使用php直接查询数据库以获取数据
处理数据,缓存结果,每当发布和新文章时更新缓存
可以预留一些接口,方便在前端展示
调戏代码(被代码调戏)可还行(不是
确实,现在主流的博客热力图都是查询数据库实现的
(不过我对后端数据库一窍不通,转而用前端查询rss 这种简单粗暴的方式啦
不过用我这种方式问题挺明显的,rss必须要保存时间范围内全部的文章,这导致网站的rss源文件变得非常大,以至于在rss阅读器上加载很慢
我也是在网上冲浪时发现一些博客的有一个展示类似GitHub贡献的日历墙,研究了一下,但是都是现成的博客安装插件,配置一下就直接可以使用,但是我博客是我自己一点一点写出来的没有这种懒人方法,然后我也实现了热力图在2023-01-14 11:54,使用原生js实现的,只要从数据库查询出来全部的文章时间就可以了,然后将数据通过js就可以自己渲染,这样子后端只要是不一样的数据就可以渲染不一样的内容,在我的blog中我在站点更新以及网站地图中都使用了
妙呀ヾ(≧∇≦*)ゝ
有意思,有空就我也想开发一个热力图
哈哈,期待大佬的作品呀
会复制也是一种能力,这个社会要的是结果
嗯嗯,是这样哒