Hexo 博客实战:钓鱼相册数据统计与地图实现全记录

前言

ByteFisher 博客上线了两年,积累了 7 个钓鱼相册、500+ 张照片。但一直有个遗憾:相册只是按年份一字排开,缺少两个关键视角

一是数据视角——5 年钓了多少鱼?哪个鱼种最多?哪年最高产?这些信息分散在上百个 HTML 页面里,读者无从感知。

二是空间视角——日记里提到的”花碑水库””三一水库””固驿沙坑”到底在哪里?活动范围覆盖了多大区域?

本文完整记录两个功能的实现过程:鱼获数据 Dashboard 和钓鱼地图。这不是一篇”纯教程”,而是踩坑实录——包括坐标偏移、CDN 被墙、API 改版等你在官方文档里查不到的坑。


一、整体架构

两个功能虽然同属”钓鱼特色模块”,但技术路线完全不同:

1
2
3
4
5
6
7
8
9
10
11
12
┌──────────────────────────────────────────────────────────┐
│ 构建时 (hexo g) │ 运行时 (浏览器) │
│ │ │
│ fish-dashboard-data.js │ fish-dashboard.js │
│ └─ 扫描 img*/index.md │ └─ fetch data.json │
│ └─ 匹配 data-lightbox 计数 │ └─ Chart.js 渲染 │
│ └─ 输出 data.json │ │
│ │ │
│ spots.json (手工维护) │ fish-map.js │
│ └─ 13个钓点的坐标/鱼种/描述 │ └─ 动态加载高德 JS API │
│ │ └─ CircleMarker 渲染 │
└──────────────────────────────────────────────────────────┘

Dashboard 走”构建时计算 → 输出静态 JSON”的路线,地图走”运行时动态渲染”的路线,选择依据很简单:

  • Dashboard 的数据变化频率低(加一张照片才需要更新),构建时算好最省性能
  • 地图是交互式组件,必须运行时渲染

二、鱼获数据 Dashboard

2.1 数据自动采集

初期想过用手工维护 data.json,但在相册里加了 3 次照片、改了 3 次数据后,我放弃了——这不该是人干的事。

思路转变:相册本身已经包含了数据。每个相册页面用 data-lightbox 属性标记照片,我只需要在构建时数一下出现次数就好。

写了一个 Hexo Generator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// scripts/generators/fish-dashboard-data.js
'use strict';

var fs = require('fs');
var path = require('path');

hexo.extend.generator.register('fish-dashboard-data', function(locals) {
var fishSourceDir = path.join(hexo.source_dir, 'fish');
var config = hexo.config.fish_dashboard || {};
var entries = fs.readdirSync(fishSourceDir, { withFileTypes: true });

var yearlyData = [];
var totalPhotos = 0;

entries.forEach(function(entry) {
if (!entry.isDirectory()) return;
if (!entry.name.match(/^img/)) return;

var indexPath = path.join(fishSourceDir, entry.name, 'index.md');
var content = fs.readFileSync(indexPath, 'utf-8');

var fishMatches = content.match(/data-lightbox="fishPhotos"/g);
var siteMatches = content.match(/data-lightbox="anglingSite"/g);
var count = (fishMatches ? fishMatches.length : 0)
+ (siteMatches ? siteMatches.length : 0);

totalPhotos += count;

var yearMatch = entry.name.match(/^img(\d{4})$/);
if (yearMatch) {
yearlyData.push({
year: parseInt(yearMatch[1], 10),
count: count
});
}
});

yearlyData.sort(function(a, b) { return a.year - b.year; });

var data = {
totalPhotos: totalPhotos,
totalYears: yearlyData.length,
yearlyData: yearlyData,
speciesStats: config.speciesStats || {},
topMonths: config.topMonths || [],
bestSpots: config.bestSpots || []
};

return {
path: 'fish/dashboard/data.json',
data: JSON.stringify(data, null, 2),
layout: false
};
});

Generator 负责自动化部分(照片数量、年份统计),鱼种分布和最佳钓点这类无法从文件名推断的数据,放在 _config.yml 中配置:

1
2
3
4
5
6
7
8
9
10
11
fish_dashboard:
speciesStats:
红尾鲴: 220
鲫鱼: 180
鲤鱼: 95
草鱼: 72
鳊鱼: 1
青鱼: 0
其他: 133
topMonths: ["五月", "十月", "四月", "三月"]
bestSpots: ["花碑水库", "野河", "三一水库"]

生成的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"totalPhotos": 546,
"totalYears": 5,
"yearlyData": [
{ "year": 2022, "count": 95 },
{ "year": 2023, "count": 120 },
{ "year": 2024, "count": 58 },
{ "year": 2025, "count": 65 },
{ "year": 2026, "count": 73 }
],
"speciesStats": {
"红尾鲴": 220, "鲫鱼": 180, "鲤鱼": 95,
"草鱼": 72, "鳊鱼": 1, "青鱼": 0, "其他": 133
}
}

2.2 Chart.js 可视化

页面结构很简单:4 张统计卡片 + 2 个 Chart.js 画布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- source/fish/dashboard/index.md -->
<div class="fish-dashboard">
<h1>🎣 鱼获数据统计</h1>

<div class="dash-stats-grid" id="dash-stats">
<div class="dash-stat-card">
<span class="dash-stat-value" id="total-photos">-</span>
<span class="dash-stat-label">总照片数</span>
</div>
<div class="dash-stat-card">
<span class="dash-stat-value" id="total-years">-</span>
<span class="dash-stat-label">钓鱼年数</span>
</div>
<div class="dash-stat-card">
<span class="dash-stat-value" id="avg-yearly">-</span>
<span class="dash-stat-label">年均鱼获</span>
</div>
<div class="dash-stat-card">
<span class="dash-stat-value" id="best-year">-</span>
<span class="dash-stat-label">最高产年份</span>
</div>
</div>

<div class="dash-chart-wrap">
<canvas id="yearlyChart"></canvas>
</div>
<div class="dash-chart-wrap">
<canvas id="speciesChart"></canvas>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/chart.js" data-pjax></script>
<script src="/js/fish-dashboard.js" data-pjax></script>

JS 部分比较直接——fetch JSON → 填充卡片 → 渲染两个图表。唯一需要注意的就是 PJAX 兼容:给 <script> 加上 data-pjax 属性,确保 PJAX 导航后脚本能重新执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// source/js/fish-dashboard.js(核心摘要)
function init() {
fetch('/fish/dashboard/data.json')
.then(function(r) { return r.json(); })
.then(function(data) {
renderStats(data);
renderYearlyChart(data);
renderSpeciesChart(data);
});
}

function renderYearlyChart(data) {
new Chart(document.getElementById('yearlyChart'), {
type: 'bar',
data: {
labels: data.yearlyData.map(function(d) { return d.year; }),
datasets: [{
label: '鱼获照片数',
data: data.yearlyData.map(function(d) { return d.count; }),
backgroundColor: 'rgba(55, 198, 192, 0.6)',
borderColor: '#37c6c0',
borderWidth: 2,
borderRadius: 6
}]
},
options: {
responsive: true,
plugins: {
title: { display: true, text: '历年鱼获数量趋势', font: { size: 16 } }
}
}
});
}

2.3 踩坑小结

原因 解决
Chart.js CDN 加载后但数据未 fetch 完 网络竞争条件 DOMContentLoaded 确保 DOM 就绪
PJAX 切换页面后图表消失 脚本未重新执行 data-pjax 属性
data-lightbox 计数不准 风景照和鱼获照使用不同属性 两种属性分别统计后求和

三、钓鱼地图 1.0:Leaflet + OpenStreetMap

3.1 初始方案

选用 Leaflet.js 的理由很充分:6KB(压缩后)、零依赖、API 清爽。配上 OpenStreetMap 的免费瓦片,代码量极小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// v1 初始实现
function init() {
var map = L.map('fish-map').setView([30.6, 103.5], 11);

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18
}).addTo(map);

fetch('/fish/map/spots.json')
.then(function(r) { return r.json(); })
.then(function(data) {
data.spots.forEach(function(spot) {
L.circleMarker([spot.lat, spot.lng], {
radius: Math.min(8 + spot.photos * 0.4, 24),
color: '#37c6c0',
fillColor: '#37c6c0',
fillOpacity: 0.6,
weight: 2
}).addTo(map).bindPopup(
'<b>' + spot.name + '</b><br>' +
'📸 ' + spot.photos + ' 张照片<br>' +
'🎣 ' + spot.species.join('、')
);
});
});
}

十几个小时的代码量,看起来一切正常。

3.2 坐标偏移——GCJ-02 vs WGS-84

第一次在地图上看到标记位置时,所有钓点都偏了大约 500 米。这是因为国内手机 GPS 记录的是 GCJ-02(火星坐标系),而全球通用的 OSM 用的是 WGS-84。这两种坐标系之间有个非线性偏移——Google 和高德都是”境外用 WGS-84,境内用 GCJ-02,坐标互转需要对偏移算法逆向求解”。

于是写了 35 行坐标转换代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// GCJ-02 → WGS-84 坐标转换
var PI = Math.PI;
var A = 6378245.0;
var EE = 0.00669342162296594323;

function transformLat(x, y) {
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y
+ 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}

function transformLng(x, y) {
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x
+ 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}

function gcj02ToWgs84(lat, lng) {
var dLat = transformLat(lng - 105.0, lat - 35.0);
var dLng = transformLng(lng - 105.0, lat - 35.0);
var radLat = lat / 180.0 * PI;
var magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
var sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI);
return { lat: lat - dLat, lng: lng - dLng };
}

这段代码的本质是对高德开源的 GCJ-02 加密算法做逆向逼近——先用多项式拟合出偏移量,再从原始坐标中减去这个偏移量。参数 A 和 EE 直接取自克拉索夫斯基椭球体。不是 100% 精确(GCJ-02 到 WGS-84 的转换本身就是个病态问题,没有精确解析解),但对于展示钓点位置来说,误差在 10 米以内,完全可以接受。

3.3 数据准备

13 个钓点,覆盖四川成都周边的水库、河流、沙坑和溪流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"spots": [
{
"name": "三一水库",
"type": "水库",
"lat": 30.34,
"lng": 103.49,
"year": 2026,
"photos": 230,
"species": ["红尾鲴", "鲫鱼", "鲤鱼", "草鱼", "鲈鱼", "其它"],
"desc": "主力钓点,多次爆护,小红尾鲴多"
},
{
"name": "古镇小河钓点1",
"type": "河流",
"lat": 30.2989,
"lng": 103.5116,
"year": 2022,
"photos": 27,
"species": ["鲫鱼", "鲤鱼", "白条", "老虎鱼", "其它"],
"desc": "古镇小河,水面水葫芦多"
},
{
"name": "通江鲢鱼牵",
"type": "溪流",
"lat": 31.9187,
"lng": 107.3374,
"year": 2023,
"photos": 3,
"species": ["鲫鱼", "马口", "白条", "溪石斑", "金麦线", "其它"],
"desc": "山区溪流钓溪石斑"
}
]
}

照片数最多的钓点(三一水库,230 张)自动成为地图默认中心,气泡半径按 8 + photos * 0.4 线性映射,上限 24px。

3.4 坑:坐标顺序搞反了

本地开发环境测试时,有一半的标记点跑到了非洲西海岸。查了半天发现 Leaflet 的 L.circleMarker 接收的是 [lat, lng],而我脑子里一直以为是 [lng, lat]。在 spots.json 里写的是 { lat: 30.34, lng: 103.49 },传参时拆成 [30.34, 103.49] 才对——但我写的却是 [spot.lng, spot.lat]

修了两次才彻底记住:Leaflet = [纬度, 经度];Mapbox/高德 = [经度, 纬度]


四、钓鱼地图 2.0:迁移到高德 JS API

4.1 国内加载不了

Lealet 版部署上线后,在浏览器看到的是一片空白加”地图加载失败”。打开 F12 → Console,问题一目了然:

  • unpkg.com/leaflet@1.9.4/dist/leaflet.js —— 请求被阻断
  • tile.openstreetmap.org/...png —— 瓦片服务器不可用

两个资源都依赖境外 CDN,在国内不开代理就无法加载。

4.2 换高德 JS API

快速评估了三个方案:

方案 操作量 坐标处理 国内访问
Leaflet + 国内 CDN + 天地图 需保留 GCJ-02→WGS-84
高德 JS API 原生支持 GCJ-02,删除转换代码
百度地图 需转换到 BD-09

选了高德,因为可以直接删掉那 35 行坐标纠偏代码——spots.json 里的 GCJ-02 坐标原封不动传进去。

4.3 动态加载脚本

NexT 启用了 PJAX(无刷新页面切换),如果直接把高德 SDK 写在 <script> 标签里,PJAX 导航后不会自动重新初始化。解决策略:把 SDK 加载逻辑封装在 fish-map.js 内部,第一次加载后设置缓存标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function loadAmap(callback) {
if (typeof AMap !== 'undefined') {
callback(); // 已加载过,直接回调
return;
}
window._AMapSecurityConfig = {
securityJsCode: 'xxx' // 高德安全密钥
};
var script = document.createElement('script');
script.src = 'https://webapi.amap.com/maps?v=2.0&key=xxx';
script.onload = callback;
script.onerror = function() {
document.getElementById('fish-map').innerHTML =
'<p>地图加载失败 🐟</p>';
};
document.head.appendChild(script);
}

function start() {
var el = document.getElementById('fish-map');
if (!el) return;
loadAmap(init);
}

document.addEventListener('DOMContentLoaded', start);

页面上的 HTML 也从两行(Leaflet CSS + JS)精简到一行:

1
2
3
- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
- <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-pjax></script>
<script src="/js/fish-map.js" data-pjax></script>

4.4 踩坑:TypeError: Cannot read properties of undefined(reading ‘addListener’)

这是整个开发过程中卡得最久的一个 Bug。

迁移完代码后页面仍然一片空白。F12 Console 看到:

1
2
3
4
fish-map.js:84 [FishMap] 错误: TypeError: Cannot read properties of undefined (reading 'addListener')
at fish-map.js:78:22
at Array.forEach (<anonymous>)
at fish-map.js:51:20

定位到第 78 行:

1
2
3
AMap.event.addListener(marker, 'click', function() {
info.open(map, marker.getCenter());
});

高德 JS API v2.0 移除了 AMap.event 命名空间。所有事件方法直接挂载在对象实例上:

1
2
3
4
- AMap.event.addListener(marker, 'click', function() {
+ marker.on('click', function() {
info.open(map, marker.getCenter());
});

改完这一行就正常了。在官方文档里翻了几十分钟没找到关于 AMap.event 的废弃说明——它在 v1.4 的文档里还存在,但 v2.0 的参考手册直接没有这个命名空间了。

4.5 安全密钥与 localhost 白名单

高德开放平台在 2021 年 12 月之后申请的 API Key 要求同时配置 securityJsCode(安全密钥)。配置方式:

1
2
3
window._AMapSecurityConfig = {
securityJsCode: '8210555...' // 你的安全密钥
};

这段配置必须在加载 SDK 之前设置,所以动态加载方案天然满足这个顺序要求。

然后本地测试又卡住了:高德的安全域名配置不支持 localhost127.0.0.1,加了会提示”不符合规范的域名”。解决方案是走 安全密钥验证 而非域名白名单验证——只要正确设置了 securityJsCode,SDK 就不会检查域名。

4.6 最终版本

迁移完的 fish-map.js 从 105 行精简到 97 行(去掉了坐标转换代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// source/js/fish-map.js(最终版本)
(function() {
var typeColors = {
'水库': '#37c6c0',
'河流': '#6bcb77',
'沙坑': '#f0a050',
'溪流': '#4d96ff'
};

function loadAmap(callback) { /* ... 动态加载逻辑 ... */ }

function init() {
var map = new AMap.Map('fish-map', { zoom: 12, resizeEnable: true });

fetch('/fish/map/spots.json')
.then(function(r) { return r.json(); })
.then(function(data) {
var topSpot = data.spots.reduce(function(a, b) {
return a.photos > b.photos ? a : b;
});
map.setCenter([topSpot.lng, topSpot.lat]);

data.spots.forEach(function(spot) {
var radius = Math.min(8 + spot.photos * 0.4, 24);
var color = typeColors[spot.type] || '#c9c9c9';

var marker = new AMap.CircleMarker({
center: [spot.lng, spot.lat],
radius: radius,
fillColor: color,
fillOpacity: 0.6,
strokeColor: '#fff',
strokeWeight: 2
});
marker.setMap(map);

var info = new AMap.InfoWindow({
content: buildPopupContent(spot),
offset: new AMap.Pixel(0, -20)
});

marker.on('click', function() {
info.open(map, marker.getCenter());
});
});
});
}

// 自动定位到照片数最多的钓点 → 地图中心
// 气泡颜色按类型区分
// 点击气泡弹出信息窗体
})();

最终效果:

  • 国内用户不需要代理,高德 SDK 和瓦片都走国内 CDN
  • PJAX 兼容,动态加载方案适配无刷新导航
  • 坐标零转换,GCJ-02 直出高德地图
  • 自备降级,SDK 加载失败或数据 fetch 失败都有友好提示

五、数据流对比

两个功能的数据流完全不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Dashboard(构建时计算):
hexo g
→ fish-dashboard-data.js(Generator)
→ 扫描 source/fish/img*/index.md
→ 正则匹配 data-lightbox 属性
→ 输出 public/fish/dashboard/data.json
hexo s(浏览器)
→ fish-dashboard.js fetch data.json
→ Chart.js 渲染柱状图 + 环形图
→ 4 张统计卡片填充数值

地图(运行时渲染):
手工维护 source/fish/map/spots.json
hexo g 直接复制到 public/fish/map/spots.json
hexo s(浏览器)
→ fish-map.js 动态加载高德 SDK
→ fetch spots.json
→ AMap.CircleMarker 逐个渲染
→ AMap.InfoWindow 绑定点击事件

核心区别:

维度 Dashboard 钓鱼地图
数据源 自动扫描相册文件 手工维护 JSON
构建阶段 Generator 做数据聚合 纯静态文件拷贝
渲染时机 构建时完成 → 运行时只展示 完全运行时渲染
更新方式 加照片后 hexo g 自动更新 新增钓点需手动改 spots.json
外部依赖 Chart.js(CDN) 高德 JS API(动态加载)

六、总结与后续

两个功能上线后,钓鱼相册从”一个只陈列照片的页面”变成了数据展示 + 空间叙事的组合体验:

  • 点开 Dashboard,一眼看到 5 年钓了 546 张照片、鱼种分布饼图、历年趋势柱状图
  • 点开钓鱼地图,13 个钓点在卫星图上按类型着色排列,照片越多气泡越大

从技术角度看,没有太复杂的东西——就是几个 Hexo Generator + 两个图表 + 一个地图 SDK。但坑确实踩了不少,尤其是坐标转换和地图迁移。

后续还有一些可以优化的地方:

  1. 图床迁移 —— 当前所有图片托管在 iili.io 免费图床,有挂服风险,后续考虑迁移到 OSS 或 Cloudflare R2
  2. Dashboard 与 spots.json 联动 —— 现在 Spot 数据需要手工维护,理想状态是 Generator 自动从相册中提取钓点位置
  3. 地图点聚合 —— 成都周边的钓点集中在邛崃区域,缩小时气泡重叠严重,可以加 AMap.MarkerCluster 做聚合

📊 查看 鱼获数据统计
🗺️ 查看 钓鱼地图

分享: