之前我给博客增加了 电影记录栏目,还是用手动添加数据的方式,比较麻烦。后来我又添加了游戏记录栏目,这次我决定用脚本抓取游戏数据,只需要输入名字或者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 文件,这里需要修改的地方有两点,一个是修改RAWG_KEY,这个需要去RAWG官网申请;另一个是修改PROXIES,这些api访问一般需要代理访问:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fetch_game.py (Python 3.9 compatible)
Supports two data sources: Steam (--appid) and RAWG (--rawg) and supports fetching 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
STEAM_APPDETAILS = "https://store.steampowered.com/api/appdetails"
STEAM_APPREVIEWS = "https://store.steampowered.com/appreviews/{appid}?json=1&language=all&purchase_type=all"
RAWG_API = "https://api.rawg.io/api/games"
# Replace YOUR_RAWG_API_KEY with your Rawg.io API key
RAWG_KEY = os.getenv("RAWG_API_KEY", "YOUR_RAWG_API_KEY")
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible)"}
# PROXIES = {"http": "http://127.0.0.1:1087", "https": "http://127.0.0.1:1087"}
PROXIES = {}
DEFAULT_PLAYTIME = 15
# ---- HTTP ----
def http_get_json(url, params=None, timeout=15):
r = requests.get(url, params=params, timeout=timeout, headers=HEADERS, proxies=PROXIES)
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)
with requests.get(url, stream=True, headers=HEADERS, timeout=20) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return True
except Exception as e:
print(f"⚠️ Failed to save image: {e}")
return False
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 ""
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:
rawg_rec = fetch_rawg(title, images_root)
if rawg_rec and rawg_rec.get("cover"):
cover_rel = rawg_rec.get("cover")
if not rating10 and rawg_rec:
rating10 = rawg_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),
}
# ---- RAWG ----
def fetch_rawg(name: str, images_root: str) -> Optional[Dict[str, Any]]:
if not RAWG_KEY or RAWG_KEY == "YOUR_RAWG_API_KEY":
print("⚠️ RAWG API key not configured")
return None
try:
params = {"search": name}
if RAWG_KEY: params["key"] = RAWG_KEY
data = http_get_json(RAWG_API, params=params)
results = data.get("results", [])
if not results:
return None
g = results[0]
title = g.get("name") or name
year = parse_year_from_date_str(g.get("released")) or datetime.now().year
rating10 = None
try:
rv, rt = float(g.get("rating", 0)), float(g.get("rating_top") or 5)
if rt > 0: rating10 = round(rv / rt * 10.0, 1)
except: pass
if rating10 is None and g.get("metacritic"):
try: rating10 = round(float(g["metacritic"]) / 10.0, 1)
except: pass
platforms = [ (p.get("platform") or {}).get("name") for p in g.get("platforms", []) if (p.get("platform") or {}).get("name") ]
cover_rel = None
bg = g.get("background_image") or g.get("background_image_additional")
if bg:
slug = slugify(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": title,
"cover": cover_rel,
"year": year,
"rating": rating10,
"selfRating": rating10,
"platform": ", ".join(platforms) if platforms else None,
"playTime": DEFAULT_PLAYTIME,
"id": f"https://rawg.io/games/{g.get('slug') or slugify(title)}",
}
except Exception as e:
print("⚠️ RAWG 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("--rawg", type=str)
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_rawg(args.name, args.images_root)
if not record:
print("❌ Steam/RAWG not found"); sys.exit(1)
elif args.rawg:
record = fetch_rawg(args.rawg, args.images_root)
if not record:
print("❌ RAWG not found"); sys.exit(1)
else:
print("⚠️ Must provide --appid / --name / --rawg"); 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
通过rawg
python3 ./scripts/fetch_game.py --rawg "Mario Kart World"
通过名称
python3 ./scripts/fetch_game.py --name "逸剑风云决"
增加多语言支持
这一步可选,如果你的网站支持多语言,比如英文需要配置 i18n/en.yaml,追加以下内容:
ratings: "RTG"
self_ratings: "MY"
经过这六个步骤,你已经成功创建了这个博客游戏记录栏目了~,以后需要添加游戏只需要运行python命令,然后手动修改你的个人评分和游玩时长就可以啦。