⚡Share🌿Life
Hugo 博客添加游戏记录栏目,并且支持自动抓取游戏数据
•
461 字 · 2 分钟
•... 次阅读
文章目录
之前我给博客增加了 电影记录栏目,还是用手动添加数据的方式,比较麻烦。后来我又添加了游戏记录栏目,这次我决定用脚本抓取游戏数据,只需要输入名字或者ID。
先看一下最终实现的效果,按照年份区分tab,支持点击跳转,并且有评分、游玩时间、平台等详细信息。我个人比较满意的,接下来我们一步一步实现这个效果。

新增页面文件
首先在 content/page 目录下新增 games 目录,然后添加index.md文件,下面这些参数只需要修改标题和描述,以及在菜单中的排序和图标,注意项目里默认是没有 game.svg 的,你需要手动下载放在 assets/icons 目录下
---
title: 游戏
description: 个人年度游戏总结
slug: games
layout: games
readingTime: false
toc: false
comments: false
license: false
menu:
main:
weight: 4
params:
icon: game
---
新增html模版文件
新增layouts/_dafault/games.html文件,内容如下,代码无需修改,可以修改一些样式:
{{ define "body-class" }}template-movies{{ end }}
{{ define "main" }}
<header>
<h2 class="article-title">{{ .Title }}</h2>
</header>
<!-- Movies Section -->
{{ $moviesData := .Site.Data.games }}
{{ $currentYear := now.Format "2006" }}
{{ $isEnglish := eq .Site.Language.Lang "en" }}
{{ $sortedYears := slice }}
{{ range $year, $movies := $moviesData }}
{{ $sortedYears = $sortedYears | append $year }}
{{ end }}
{{ $sortedYears = sort $sortedYears "value" "desc" }}
{{ $latestYear := index $sortedYears 0 }}
<div class="year-buttons">
{{ range $sortedYears }}
<button class="year-btn{{ if eq . $latestYear }} active{{ end }}" data-year="{{ . }}">{{ . }}</button>
{{ end }}
</div>
{{ range $year, $movies := $moviesData }}
<div class="movies-grid{{ if eq $year $latestYear }} active{{ end }}" data-year="{{ $year }}" data-type="movies">
{{ range $movies }}
<div class="movie-card {{ if .id }}clickable{{ end }}" onclick="goToMoviePage('{{ .id }}')">
<div class="movie-poster">
{{ if .cover }}
<img src="{{ .cover }}" alt="{{ .title }}" loading="lazy">
{{ else }}
<div class="no-poster">No Poster Available</div>
{{ end }}
</div>
<div class="movie-info">
<h3 class="movie-title">{{ if $isEnglish }}
{{ .enTitle | default .title }}
{{ else }}
{{ .title }}
{{ end }} <span class="movie-year">{{ .year }}</span></h3>
<div class="movie-meta">
{{ if .rating }}
<span class="rating">{{ i18n "ratings" | default "媒体" }}⭐ {{ printf "%.1f" .rating }}</span>
{{ end }}
{{ if .platform }}
<span class="platform">{{ .platform }}</span>
{{ end }}
</div>
<div class="movie-meta">
{{ if .selfRating }}
<span class="rating">{{ i18n "self_ratings" | default "个人" }}⭐ {{ printf "%.1f" .selfRating }}</span>
{{ end }}
{{ if .playTime }}
<span class="platform">{{ .playTime }}H</span>
{{ end }}
</div>
</div>
</div>
{{ end }}
</div>
{{ end }}
<style>
.year-buttons {
margin: 0rem 0;
text-align: left;
}
.year-btn {
padding: 0.7rem 1.5rem;
margin: 0 0rem;
border: 2px solid var(--card-background);
background: transparent;
color: var(--card-text-color-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 1.6rem;
font-weight: 500;
opacity: 0.7;
}
.year-btn:hover {
opacity: 0.9;
border-color: var(--accent-color);
}
.year-btn.active {
background: var(--accent-color);
color: var(--accent-color-text);
border-color: var(--accent-color);
opacity: 1;
}
.movies-grid {
display: none;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1.5rem;
padding: 0rem 0;
}
@media (min-width: 560px) {
.movies-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 2rem;
}
}
.movies-grid.active {
display: grid;
}
.movie-card {
background: var(--card-background);
border-radius: 6px;
overflow: hidden;
transition: transform 0.25s ease, box-shadow 0.25s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
cursor: default;
display: flex; /* Make card a flex container */
flex-direction: column; /* Stack children vertically */
}
.movie-card.clickable {
cursor: pointer;
}
.movie-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.movie-card.clickable:hover .movie-poster img {
filter: brightness(1.05) contrast(1.05);
}
.movie-poster {
position: relative;
padding-top: 57%;
background: #2a2a2a;
}
.movie-poster img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: filter 0.25s ease;
}
.no-poster {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #888;
font-size: 0.9rem;
}
.movie-info {
padding: 0.8rem 0.8rem 0.4rem 0.8rem;
display: flex; /* ⭐ 1. Make this a flex container */
flex-direction: column; /* ⭐ 2. Stack its children vertically */
flex-grow: 1; /* ⭐ 3. Allow this container to grow and fill the card's height */
}
.movie-title {
margin: 0 0 0.8rem 0;
font-size: 1.6rem;
line-height: 1.3;
font-weight: 700;
color: var(--card-text-color-main);
font-family: var(--article-font-family);
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1; /* ⭐ 4. Make the title area grow, pushing the meta info down */
}
.movie-year {
font-size: 1.2rem;
color: var(--card-text-color-secondary);
font-weight: 600;
align-self: center;
margin-left: 0.5rem;
}
.movie-meta {
padding: 0.5rem 0 0.3rem 0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.3rem;
color: var(--card-text-color-secondary);
}
@media (max-width: 768px) {
.movie-title {
font-size: 1.4rem;
}
.movie-meta {
font-size: 1.2rem;
}
}
.rating {
font-weight: 500;
color: var(--card-text-color-secondary);
}
.platform {
font-size: 1.2rem;
color: var(--card-text-color-secondary);
font-weight: 500;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const yearButtons = document.querySelectorAll('.year-btn');
const grids = document.querySelectorAll('.movies-grid');
yearButtons.forEach(button => {
button.addEventListener('click', function() {
const year = this.dataset.year;
yearButtons.forEach(btn => btn.classList.remove('active'));
grids.forEach(grid => grid.classList.remove('active'));
this.classList.add('active');
document.querySelectorAll(`.movies-grid[data-year="${year}"]`).forEach(grid => {
grid.classList.add('active');
});
});
});
});
function goToMoviePage(movieId) {
if (movieId.includes("https")) {
window.open(movieId, '_blank');
return;
}
window.open('https://store.steampowered.com/app/' + movieId, '_blank');
}
</script>
{{ partialCached "footer/footer" . }}
{{ end }}
新建游戏数据文件
在根目录下新建一个data文件夹,创建 games.yaml 文件,内容如下:
2025:
新建script脚本
在根目录下新建一个scripts文件夹,创建一个 fetch_game.py 文件:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fetch_game.py (Python 3.9 compatible)
Supports two main data sources: Steam (--appid) and Bangumi (--bgm), as well as searching by name (--name).
"""
import argparse
import os
import re
import sys
import yaml
import requests
from datetime import datetime
from typing import Optional, Dict, Any, List
from urllib.parse import quote
from PIL import Image
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
STEAM_APPDETAILS = "https://store.steampowered.com/api/appdetails"
STEAM_APPREVIEWS = "https://store.steampowered.com/appreviews/{appid}?json=1&language=all&purchase_type=all"
BGM_SEARCH_API = "https://api.bgm.tv/v0/search/subjects"
BGM_SUBJECT_API = "https://api.bgm.tv/v0/subjects/{subject_id}"
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
PROXIES = {"http": "http://127.0.0.1:10808", "https": "http://127.0.0.1:10808"}
DEFAULT_PLAYTIME = 15
# ---- HTTP ----
def http_get_json(url, params=None, timeout=15):
try:
r = requests.get(url, params=params, timeout=timeout, headers=HEADERS, proxies=PROXIES)
r.raise_for_status()
return r.json()
except Exception:
# Fallback to direct connection without proxy if proxy fails
r = requests.get(url, params=params, timeout=timeout, headers=HEADERS)
r.raise_for_status()
return r.json()
def http_post_json(url, json_data=None, timeout=15):
try:
r = requests.post(url, json=json_data, timeout=timeout, headers=HEADERS, proxies=PROXIES)
r.raise_for_status()
return r.json()
except Exception:
# Fallback to direct connection without proxy if proxy fails
r = requests.post(url, json=json_data, timeout=timeout, headers=HEADERS)
r.raise_for_status()
return r.json()
# ---- utils ----
def slugify(title: str) -> str:
s = re.sub(r"[^a-z0-9\- ]+", "", title.lower())
s = re.sub(r"\s+", "-", s)
return s[:80].strip("-") or "game"
def ensure_dir_for_path(path: str):
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
def save_image(url: str, path: str) -> bool:
try:
ensure_dir_for_path(path)
try:
r = requests.get(url, stream=True, headers=HEADERS, timeout=20, proxies=PROXIES)
r.raise_for_status()
except Exception:
r = requests.get(url, stream=True, headers=HEADERS, timeout=20)
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
# 检查图片大小,如果超过200KB则进行压缩
compress_image_if_needed(path, 200 * 1024) # 200KB
return True
except Exception as e:
print(f"⚠️ Failed to save image: {e}")
return False
def compress_image_if_needed(image_path: str, max_size_bytes: int):
"""检查图片大小,如果超过指定大小则通过降低分辨率进行压缩"""
try:
# 检查文件大小
file_size = os.path.getsize(image_path)
if file_size <= max_size_bytes:
return # 文件大小未超过限制,无需压缩
print(f"🔄 图片大小 {file_size/1024:.1f}KB 超过 {max_size_bytes/1024:.0f}KB,开始压缩...")
# 打开图片
with Image.open(image_path) as img:
# 如果是RGBA模式,转换为RGB模式以减小文件大小
if img.mode in ('RGBA', 'LA', 'P'):
# 创建白色背景
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = background
# 计算目标尺寸以满足文件大小要求
scale_factor = (max_size_bytes / file_size)
target_width = int(img.width * scale_factor)
target_height = int(img.height * scale_factor)
# 确保最小尺寸不小于200像素
target_width = max(target_width, 200)
target_height = max(target_height, 200)
# 调整图片尺寸
resized_img = img.resize((target_width, target_height), Image.LANCZOS)
# 保存调整尺寸后的图片
resized_img.save(image_path, format='JPEG', quality=85, optimize=True)
# 检查压缩后的文件大小
final_size = os.path.getsize(image_path)
if final_size <= max_size_bytes:
print(f"✅ 图片通过降低分辨率压缩完成,最终尺寸 {target_width}x{target_height},大小 {final_size/1024:.1f}KB")
return
# 如果仍然超过大小限制,进一步降低质量
quality = 70
while quality >= 30 and final_size > max_size_bytes:
resized_img.save(image_path, format='JPEG', quality=quality, optimize=True)
final_size = os.path.getsize(image_path)
print(f"🔄 进一步调整质量到 {quality},当前大小 {final_size/1024:.1f}KB")
quality -= 10
print(f"✅ 图片压缩完成,最终尺寸 {target_width}x{target_height},大小 {final_size/1024:.1f}KB")
except Exception as e:
print(f"⚠️ 图片压缩失败: {e}")
def parse_year_from_date_str(date_str: str) -> Optional[int]:
if not date_str:
return None
m = re.search(r"(20\d{2}|19\d{2})", date_str)
return int(m.group(1)) if m else None
def to_platform_list_from_steam(platforms: Dict[str, Any]) -> List[str]:
out = []
if platforms.get("windows"): out.append("PC")
if platforms.get("mac"): out.append("macOS")
if platforms.get("linux"): out.append("Linux")
return out
# ---- Steam ----
def search_steam(query_name: str) -> Optional[Dict[str, Any]]:
url = f"https://store.steampowered.com/api/storesearch/?cc=CN&l=schinese&term={quote(query_name)}"
try:
data = http_get_json(url)
return (data.get("items") or [None])[0]
except Exception:
return None
def fetch_appdetails(appid: int, lang="schinese") -> Optional[Dict[str, Any]]:
try:
params = {"appids": str(appid), "l": lang}
data = http_get_json(STEAM_APPDETAILS, params=params)
node = data.get(str(appid)) if isinstance(data, dict) else None
if node and node.get("success") and isinstance(node.get("data"), dict):
return node["data"]
except Exception:
return None
def fetch_review_ratio(appid: int) -> Optional[float]:
try:
url = STEAM_APPREVIEWS.format(appid=appid)
data = http_get_json(url)
qs = (data or {}).get("query_summary") or {}
pos, neg = qs.get("total_positive", 0), qs.get("total_negative", 0)
total = pos + neg
return pos / total if total > 0 else None
except Exception:
return None
def build_record_from_steam(appid: int, images_root: str, fallback_name: Optional[str]=None) -> Optional[Dict[str, Any]]:
zh = fetch_appdetails(appid, lang="schinese")
en = fetch_appdetails(appid, lang="english")
if not zh and not en:
return None
zh_title, en_title = zh.get("name"), en.get("name")
title = zh_title or en_title or fallback_name or "Unknown"
enTitle = en_title if en_title != title else title
year = datetime.now().year
platforms = to_platform_list_from_steam(zh.get("platforms") or {}) or ["PC"]
ratio = fetch_review_ratio(appid)
rating10 = round(ratio * 10, 1) if ratio is not None else None
slug = slugify(en_title or title)
rel_dir = os.path.join(images_root, str(year))
rel_path = os.path.join(rel_dir, slug + ".jpg")
cover_rel = f"/images/games/{year}/{slug}.jpg"
header_url = f"https://cdn.akamai.steamstatic.com/steam/apps/{appid}/header.jpg"
cover_saved = save_image(header_url, rel_path)
if not cover_saved:
bgm_rec = fetch_bgm(title, images_root)
if bgm_rec and bgm_rec.get("cover"):
cover_rel = bgm_rec.get("cover")
if not rating10 and bgm_rec:
rating10 = bgm_rec.get("rating")
return {
"title": title,
"enTitle": enTitle,
"cover": cover_rel,
"year": year,
"rating": rating10,
"selfRating": rating10,
"platform": ", ".join(platforms),
"playTime": DEFAULT_PLAYTIME,
"id": int(appid),
}
# ---- Bangumi (bgm.tv) ----
def fetch_bgm(name: str, images_root: str) -> Optional[Dict[str, Any]]:
try:
payload = {"keyword": name, "filter": {"type": [4]}}
data = http_post_json(BGM_SEARCH_API, json_data=payload)
items = data.get("data") or []
if not items:
return None
g = items[0]
bgm_id = g.get("id")
try:
detail = http_get_json(BGM_SUBJECT_API.format(subject_id=bgm_id))
except Exception:
detail = g
name_cn = detail.get("name_cn") or detail.get("name")
name_en = detail.get("name")
infobox = detail.get("infobox") or []
platforms = []
en_alias = None
for entry in infobox:
key = entry.get("key")
val = entry.get("value")
if key in ["平台", "Platform", "平台:"]:
if isinstance(val, list):
platforms = [v.get("v") for v in val if isinstance(v, dict) and v.get("v")]
elif isinstance(val, str):
platforms = [val]
elif key in ["别名", "英文名", "Alias"]:
if isinstance(val, list):
for v in val:
v_str = v.get("v") if isinstance(v, dict) else str(v)
if v_str and re.match(r"^[A-Za-z0-9\s\-:\.\'\!]+$", v_str):
en_alias = v_str
break
elif isinstance(val, str) and re.match(r"^[A-Za-z0-9\s\-:\.\'\!]+$", val):
en_alias = val
title = name_cn or name
enTitle = en_alias or (name_en if re.match(r"^[A-Za-z0-9\s\-:\.\'\!]+$", name_en or "") else title)
date_str = detail.get("date") or ""
year = parse_year_from_date_str(date_str) or datetime.now().year
rating = None
score = (detail.get("rating") or {}).get("score")
if score:
try:
rating = round(float(score), 1)
except Exception:
pass
cover_rel = None
bg = (detail.get("images") or {}).get("large") or (detail.get("images") or {}).get("common")
if bg:
slug = slugify(enTitle or title)
this_year = datetime.now().year
rel_dir = os.path.join(images_root, str(this_year))
rel_path = os.path.join(rel_dir, slug + ".jpg")
if save_image(bg, rel_path):
cover_rel = f"/images/games/{this_year}/{slug}.jpg"
return {
"title": title,
"enTitle": enTitle,
"cover": cover_rel,
"year": year,
"rating": rating,
"selfRating": rating,
"platform": ", ".join(platforms) if platforms else None,
"playTime": DEFAULT_PLAYTIME,
"id": f"https://bgm.tv/subject/{bgm_id}",
}
except Exception as e:
print("⚠️ Bangumi fetch error: ", e)
return None
# ---- YAML ----
def load_lines(path: str) -> List[str]:
return open(path, "r", encoding="utf-8").readlines() if os.path.exists(path) else []
def write_with_insertion(path: str, first_line: Optional[str], formatted_lines: List[str], rest_lines: List[str]):
with open(path, "w", encoding="utf-8") as f:
if first_line: f.write(first_line.rstrip("\n") + "\n")
for line in formatted_lines: f.write(line.rstrip("\n") + "\n")
for line in rest_lines: f.write(line)
# ---- main ----
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--appid", type=int)
ap.add_argument("--name", type=str)
ap.add_argument("--bgm", type=str, help="Search game on Bangumi")
ap.add_argument("--yaml-path", type=str, default="./data/games.yaml")
ap.add_argument("--images-root", type=str, default="./static/images/games")
args = ap.parse_args()
record = None
if args.appid:
record = build_record_from_steam(args.appid, args.images_root)
if not record:
print("❌ Steam not found"); sys.exit(1)
elif args.name:
item = search_steam(args.name)
record = build_record_from_steam(item["id"], args.images_root, args.name) if item else fetch_bgm(args.name, args.images_root)
if not record:
print("❌ Steam/Bangumi not found"); sys.exit(1)
elif args.bgm:
record = fetch_bgm(args.bgm, args.images_root)
if not record:
print("❌ Bangumi not found"); sys.exit(1)
else:
print("⚠️ Must provide --appid / --name / --bgm"); sys.exit(1)
lines = load_lines(args.yaml_path)
first_line, rest_lines = (lines[0], lines[1:]) if lines else (None, [])
single_yaml = yaml.dump(record, allow_unicode=True, sort_keys=False)
formatted = [(" - " + ln if i == 0 else " " + ln) for i, ln in enumerate(single_yaml.splitlines())]
write_with_insertion(args.yaml_path, first_line, formatted, rest_lines)
print("✅ Successfully written: ", record.get("title"))
if record.get("enTitle"): print(" English title: ", record.get("enTitle"))
if __name__ == "__main__":
main()
执行python脚本抓取数据
需要你的电脑有python3.9及以上的环境,执行以下命令测试抓取游戏数据,如果 python3 无法运行,请使用 python 代替:
通过steam
python3 ./scripts/fetch_game.py --appid 1746030
通过Bangumi
python3 ./scripts/fetch_game.py --bgm "Mario Kart World"
通过名称
python3 ./scripts/fetch_game.py --name "逸剑风云决"
增加多语言支持
这一步可选,如果你的网站支持多语言,比如英文需要配置 i18n/en.yaml,追加以下内容:
ratings: "RTG"
self_ratings: "MY"
经过这六个步骤,你已经成功创建了这个博客游戏记录栏目了~,以后需要添加游戏只需要运行python命令,然后手动修改你的个人评分和游玩时长就可以啦。