为博客添加热力图(仿Github贡献图)
注:由于Mimosa对PHP一窍不通,本文几乎所有的代码都是chatgpt帮我写的,我只是给了它一个思路,然后帮它调调试、debug一下罢了。

效果

鼠标悬浮时会显示细节

实时渲染预览:

loneapex.cn/heatmap/(通过php实现)

loneapex.cn/heatmap/index_.html(通过html实现)


原理

上一次逛GitHub时,心血来潮想给博客整一个热力图,用来记录博客的更新频率。奈何在网上找了一圈后,有关博客热力图的案例寥寥无几,而且为数不多的案例全部失效。这其中很大原因就是wordpress博客引擎无法精确统计字数的问题(因为wordpress无法统计中文)

所以,我们不妨换个思路,抛开wordpress,把目光投向几乎所有的博客系统都能使用的方案——通过rss统计字数和时间。

正是因为要保证兼容性,几乎所有博客的rss订阅文件样式都是统一的(xml格式),因此这个程序理论上可以统计任意具有rss功能的博客网站,而且不必担心随着时间而失效。

如图,这是本站的rss文件开头部分。不难发现,每篇文章开头部分总有一个item标识,并且都标明了发布日期,所以我们可以通过item查找每篇文章并统计文章字数。

由于通过rss统计,所以博客rss必须保证显示在统计范围内(一年)的全部文章,并且rss要显示全文,否则会统计不准确。

*注意/ATTENTION*
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.

评论

  1. Android Chrome
    6 天前
    2025-3-28 0:07:05

    感觉这样会有一些不可避免问题,
    说说我的思路
    创建插件,方便部署和调戏
    使用php直接查询数据库以获取数据
    处理数据,缓存结果,每当发布和新文章时更新缓存
    可以预留一些接口,方便在前端展示

    • Mimosa233
      站长
      ksable
      Windows Edge
      3 天前
      2025-3-30 15:05:27

      调戏代码(被代码调戏)可还行(不是
      确实,现在主流的博客热力图都是查询数据库实现的
      (不过我对后端数据库一窍不通,转而用前端查询rss 这种简单粗暴的方式啦
      不过用我这种方式问题挺明显的,rss必须要保存时间范围内全部的文章,这导致网站的rss源文件变得非常大,以至于在rss阅读器上加载很慢

  2. Windows Chrome
    2 周前
    2025-3-17 10:31:57

    我也是在网上冲浪时发现一些博客的有一个展示类似GitHub贡献的日历墙,研究了一下,但是都是现成的博客安装插件,配置一下就直接可以使用,但是我博客是我自己一点一点写出来的没有这种懒人方法,然后我也实现了热力图在2023-01-14 11:54,使用原生js实现的,只要从数据库查询出来全部的文章时间就可以了,然后将数据通过js就可以自己渲染,这样子后端只要是不一样的数据就可以渲染不一样的内容,在我的blog中我在站点更新以及网站地图中都使用了

    • Mimosa233
      站长
      卟言
      Windows Edge
      2 周前
      2025-3-17 21:20:42

      妙呀ヾ(≧∇≦*)ゝ

  3. Linux Firefox
    2 周前
    2025-3-16 10:59:05

    有意思,有空就我也想开发一个热力图

    • Mimosa233
      站长
      小彦
      Windows Edge
      2 周前
      2025-3-16 17:47:41

      哈哈,期待大佬的作品呀

  4. Andy烧麦
    Windows Firefox
    2 周前
    2025-3-16 7:23:13

    会复制也是一种能力,这个社会要的是结果

    • Mimosa233
      站长
      Andy烧麦
      Windows Edge
      2 周前
      2025-3-16 17:47:17

      嗯嗯,是这样哒

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯
 ̄﹃ ̄
(/ω\)
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
(´っω・`。)
( ,,´・ω・)ノ)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•)
(ㆆᴗㆆ)
有希酱最可爱啦!(素材来自bilibili@最上川下山)
整活by Mimosa233
Source: github.com/k4yt3x/flowerhd
galgame系列表情by Mimosa233
颜文字
周防有希
小恐龙
夸夸我!
花!
可愛い!
上一篇
下一篇