将 Hexo Fluid 的 UV/PV 统计数据迁移到 Supabase

LeanCloud 将于 2027 年 1 月 12 日起正式停止服务,Hexo Fluid 支持的平台还有不蒜子Umami, 但是前者不支持初始化数据,只能从 0 开始,后者的话我没有自己的服务器😭,所以最后准备迁移到 LeanCloud 相接近的 Supabase.

1. 注册一个 Supabase 账号

记录下:

  • Project 的 URL (Settings → Data API → URL)
  • anon_key (Settings → API Keys → Legacy anon, … API keys)

2. 建立数据库

在 SQL Editor 里运行:

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
-- 1. 创建总数表
create table counters (
slug text primary key, -- 存储 'site-pv', 'site-uv', '/posts/xxx'
value bigint default 0
);

-- 2. 创建流水日志表
create table visit_logs (
id bigint generated by default as identity primary key,
created_at timestamp with time zone default now(),
slug text, -- 记录是哪个页面/指标发生了变化
increment_val int
);

-- 3. 开启权限
alter table counters enable row level security;
alter table visit_logs enable row level security;
create policy "Public read counters" on counters for select using (true);
create policy "Public insert logs" on visit_logs for insert with check (true);

-- 4. 原子更新
create or replace function update_counter(slug_input text, increment_amount int)
returns bigint
language plpgsql
security definer -- 让函数拥有执行权限
as $$
declare
new_val bigint;
begin
if increment_amount > 0 then
insert into visit_logs (slug, increment_val) values (slug_input, increment_amount);
end if;

insert into counters (slug, value)
values (slug_input, increment_amount)
on conflict (slug)
do update set value = counters.value + increment_amount
returning value into new_val;

return new_val;
end;
$$;

Supabase 默认时区是 UTC+0, 你可以修改为东八区:

1
alter database postgres set timezone to 'Asia/Shanghai';

3. 复制一份主题文件

node_modules/hexo-theme-fluid 复制到 themes/hexo-theme-fluid 下,_config.fluid.yml 也复制一份,命名为 _config.hexo-theme-fluid.yml;

_config.yml 中的 theme 改为 hexo-theme-fluid;

_config.hexo-theme-fluid.ymlweb_analytics.leancloudserver_urlapp_key 分别修改为第 1 步记录的 URL 和 anon_key;

找到 hexo-theme-fluid/source/js/leancloud.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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
/* global CONFIG */
// eslint-disable-next-line no-console

(function(window, document) {
var supabaseUrl = CONFIG.web_analytics.leancloud.server_url;
var supabaseKey = CONFIG.web_analytics.leancloud.app_key;

if (!supabaseUrl) {
throw new Error('Supabase serverUrl is empty');
}
if (!supabaseKey) {
throw new Error('Supabase anonKey is empty');
}

// 参数: target (slug), amount (增加的数量,1 或 0)
// 这个 RPC 调用同时包含了更新和查询逻辑
function updateCounter(target, amount) {
return fetch(`${supabaseUrl}/rest/v1/rpc/update_counter`, {
method: 'POST',
headers: {
'apikey': supabaseKey,
'Authorization': `Bearer ${supabaseKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
slug_input: target,
increment_amount: amount
})
})
.then(res => res.json())
.then(data => data)
.catch(err => {
console.error('Supabase Error:', err);
return 0;
});
}

// 校验是否为有效的 Host
function validHost() {
if (window.location.protocol === 'file:') {
return false;
}

if (CONFIG.web_analytics.leancloud.ignore_local) {
var hostname = window.location.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return false;
}
}
return true;
}

// 校验是否为有效的 UV
function validUV() {
var key = 'leancloud_UV_Flag';
var flag = localStorage.getItem(key);
if (flag) {
// 距离标记小于 24 小时则不计为 UV
if (new Date().getTime() - parseInt(flag, 10) <= 86400000) {
return false;
}
}
localStorage.setItem(key, new Date().getTime().toString());
return true;
}

function addCount() {
var enableIncr = CONFIG.web_analytics.enable && !Fluid.ctx.dnt && validHost();

// 请求 PV 并自增
var pvCtn = document.querySelector('#leancloud-site-pv-container');
if (pvCtn) {
// 如果允许统计则 +1,否则 +0 (只读)
var amount = enableIncr ? 1 : 0;
updateCounter('site-pv', amount).then(count => {
var ele = document.querySelector('#leancloud-site-pv');
if (ele) {
ele.innerText = count;
pvCtn.style.display = 'inline';
}
});
}

// 请求 UV 并自增
var uvCtn = document.querySelector('#leancloud-site-uv-container');
if (uvCtn) {
var amount = (enableIncr && validUV()) ? 1 : 0;
updateCounter('site-uv', amount).then(count => {
var ele = document.querySelector('#leancloud-site-uv');
if (ele) {
ele.innerText = count;
uvCtn.style.display = 'inline';
}
});
}

// 如果有页面浏览数节点,则请求浏览数并自增
var viewCtn = document.querySelector('#leancloud-page-views-container');
if (viewCtn) {
var path = eval(CONFIG.web_analytics.leancloud.path || 'window.location.pathname');
var target = decodeURI(path.replace(/\/*(index.html)?$/, '/'));

var amount = enableIncr ? 1 : 0;
updateCounter(target, amount).then(count => {
var ele = document.querySelector('#leancloud-page-views');
if (ele) {
ele.innerText = count;
viewCtn.style.display = 'inline';
}
});
}
}
addCount();
})(window, document);

4. 将 Leancloud 的数据导入 Supabase

参考如下 Python 脚本:

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import csv
import os
from datetime import datetime


def migrate():
# 1. 获取输入文件名
input_file = "Counter_2026xxxx_xxxxxx.csv" # 导出的 csv 文件
counters_file = "supabase_counters.csv"
logs_file = "supabase_visit_logs.csv"

if not os.path.exists(input_file):
print(f"找不到文件 '{input_file}'")
return

# 获取当前时间,作为迁移日志的时间戳,默认东八区
migration_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S+08")

try:
with open(input_file, mode="r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)

counters_data = []
logs_data = []
count = 0

for row in reader:
slug = row.get("target")
value = row.get("time")

if slug and value:
# 总数表数据
counters_data.append({"slug": slug, "value": value})

# 日志表数据:将 LeanCloud 的当前总数作为一次性增量存入
logs_data.append(
{
"created_at": migration_time,
"slug": slug,
"increment_val": value,
}
)
count += 1

# 2. 写入 counters 表的 CSV
with open(counters_file, mode="w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["slug", "value"])
writer.writeheader()
writer.writerows(counters_data)

# 3. 写入 visit_logs 表的 CSV
with open(logs_file, mode="w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["created_at", "slug", "increment_val"]
)
writer.writeheader()
writer.writerows(logs_data)

print(f"转换成功!共处理 {count} 条数据。")
print(f"请将 '{counters_file}' 导入到 counters 表。")
print(f"请将 '{logs_file}' 导入到 visit_logs 表。")

except Exception as e:
print(f"转换过程中出现错误: {e}")


if __name__ == "__main__":
migrate()

最后将生成的 supabase_counters.csvsupabase_visit_logs.csv 分别导入两张表即可。


将 Hexo Fluid 的 UV/PV 统计数据迁移到 Supabase
https://eastmonster.github.io/2026/01/14/migrate-uv-pv-to-supabase/
作者
East Monster
发布于
2026年1月14日
许可协议