Hugo 博客添加游戏记录栏目,并且支持自动抓取游戏数据

English

之前我给博客增加了 电影记录栏目,还是用手动添加数据的方式,比较麻烦。后来我又添加了游戏记录栏目,这次我决定用脚本抓取游戏数据,只需要输入名字或者ID。

先看一下最终实现的效果,按照年份区分tab,支持点击跳转,并且有评分、游玩时间、平台等详细信息。我个人比较满意的,接下来我们一步一步实现这个效果。

show result

新增页面文件

首先在 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命令,然后手动修改你的个人评分和游玩时长就可以啦。

加载中...
📊 加载中...
感谢Jimmy | 隐私政策 | 赞赏支持
Liu 的 AI 助手