🔥

CNCF様を引用してブログネタを決めるためのツールを作成しました

に公開

今回は、ついにApacheルーレットのCNCF版であるCNCFルーレットを作成しました。Apacheルーレットが全て完了するまで作る予定はなかったのですが、頑固にならずにやろうと思い作りました。なお、コードは基本的にApacheルーレットの時と一緒です。

https://zenn.dev/akasan/articles/7e30ad266c02c4

データの用意

CNCFのLandscapeページから全てのCNCFプロジェクトw取得するために、CNCFページをスクレイピングしてページを取得し、そこからCNCFプロジェクト名を取得するPythonコードを作成しました。なお、コードはclaude codeに作ってもらいました。

scraping_cncf_projects.py
#!/usr/bin/env python3
"""
CNCF Landscape Project Name Scraper

This script extracts all project names from the CNCF Landscape website
and saves them to a text file.
"""

import requests
from bs4 import BeautifulSoup
import json
import re
from typing import List, Set
import logging


def fetch_cncf_landscape_page(url: str) -> str:
    """CNCFランドスケープページのHTMLを取得"""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
    }
    
    try:
        logging.info(f"Fetching page: {url}")
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        logging.info("Page fetched successfully")
        return response.text
    except requests.RequestException as e:
        logging.error(f"Error fetching page: {e}")
        raise


def extract_project_names(html_content: str) -> List[str]:
    """HTMLコンテンツからプロジェクト名を抽出"""
    project_names = set()
    
    # BeautifulSoupでHTMLを解析
    soup = BeautifulSoup(html_content, 'html.parser')
    
    # スクリプトタグ内のwindow.baseDSを探す
    script_tags = soup.find_all('script')
    
    for script in script_tags:
        if script.string and 'window.baseDS' in script.string:
            logging.info("Found window.baseDS in script tag")
            
            # window.baseDSの内容を抽出
            try:
                # 正規表現でwindow.baseDSの値を抽出
                match = re.search(r'window\.baseDS\s*=\s*({[\s\S]*?});', script.string)
                if match:
                    json_str = match.group(1)
                    
                    # JSONとして解析を試みる
                    try:
                        data = json.loads(json_str)
                        extract_names_from_data(data, project_names)
                    except json.JSONDecodeError:
                        # JSON解析が失敗した場合、文字列パターンで名前を抽出
                        logging.warning("JSON parsing failed, trying pattern matching")
                        pattern = r'"name"\s*:\s*"([^"]+)"'
                        names = re.findall(pattern, json_str)
                        project_names.update(names)
            except Exception as e:
                logging.error(f"Error extracting data: {e}")
    
    # プロジェクト名をソート
    sorted_names = sorted(list(project_names))
    logging.info(f"Found {len(sorted_names)} unique project names")
    
    return sorted_names


def extract_names_from_data(data, project_names: Set[str]):
    """再帰的にデータ構造からプロジェクト名を抽出"""
    if isinstance(data, dict):
        if 'name' in data and isinstance(data['name'], str):
            project_names.add(data['name'])
        
        # 再帰的に探索
        for value in data.values():
            extract_names_from_data(value, project_names)
    
    elif isinstance(data, list):
        for item in data:
            extract_names_from_data(item, project_names)


def save_project_names(project_names: List[str], output_file: str):
    """プロジェクト名をテキストファイルに保存"""
    try:
        with open(output_file, 'w', encoding='utf-8') as f:
            for name in project_names:
                f.write(f"{name}\n")
        
        logging.info(f"Project names saved to {output_file}")
        logging.info(f"Total projects: {len(project_names)}")
    except IOError as e:
        logging.error(f"Error saving file: {e}")
        raise


def main():
    """メイン処理"""
    url = "https://landscape.cncf.io/?view-mode=card&classify=category&sort-by=name&sort-direction=asc#app-definition-and-development--application-definition-image-build"
    output_file = "cncf_projects.txt"
    
    try:
        # ページを取得
        html_content = fetch_cncf_landscape_page(url)
        
        # プロジェクト名を抽出
        project_names = extract_project_names(html_content)
        
        # ファイルに保存
        save_project_names(project_names, output_file)
        
        # 最初の10個のプロジェクト名を表示
        print("\n最初の10個のプロジェクト:")
        for i, name in enumerate(project_names[:10], 1):
            print(f"{i}. {name}")
        
        if len(project_names) > 10:
            print(f"... 他 {len(project_names) - 10} プロジェクト")
        
    except Exception as e:
        logging.error(f"Script failed: {e}")
        raise


if __name__ == "__main__":
    main()

こちらのコードを実行すると、こちらからHTMLコードを取得して、一覧からCNCFプロジェクト名を抽出して、cncf_projects.txtにリストアップするようになっています。こちらを実行すると、以下のようにプロジェクト名が保存されることが確認できました。memberやKCSPなどがついているのは製品名というよりパートナーなどの要素だと思います。

.NET
1NCE (member)
23 Technologies (member)
3-Shake (KCSP)
3-Shake (member)
3Scale
42on (member)
6WIND (member)
6WIND Virtual Service Router (VSR)
8gears (member)
ACC ICT (KCSP)
ACC ICT (member)
ACKSTORM (member)
AI/Machine Learning
AIBrix
ALASCA (member)
AMD (member)
AOE (KCSP)
AOE (member)
APE Factory (KCSP)

ちなみに、記事執筆時点で2421行あり、そのうちかっこがついてないのは1326行あるようです、数年のアドベントカレンダーは網羅できそうですw

ルーレットの実装

先ほど取得したcncf_projects.txtを読み込んで、ブログにするネタを取得するものを作成しました。なお、(member)のようにかっこがついているものはスキップするようにしてみました。

cncf_rourette.py
from random import choice
import sqlite3

INPUT_DATA = "cncf_projects.txt"


def select_item(conn):
    with open(INPUT_DATA, "r") as f:
        lines = [line.strip() for line in f.readlines()]
        cur = conn.cursor()
        cur.execute("SELECT project_name from selected_items;")
        previous_selected_items = [item[0] for item in cur.fetchall()]
        if len(previous_selected_items) == len(lines):
            print("No project remains")
            return

        while True:
            selected_item = choice(lines)
            if not select_item in previous_selected_items:
                cur.execute(f"INSERT INTO selected_items(project_name) values('{selected_item}')")
                conn.commit()
                if "(" in selected_item:
                    continue

                print(f"{selected_item=}")
                break


if __name__ == "__main__":
    conn = sqlite3.connect('./SELECTED_ITEMS.db')
    conn.execute('CREATE TABLE IF NOT EXISTS selected_items(id INTEGER PRIMARY KEY AUTOINCREMENT, project_name STRING)')
    select_item(conn)
    conn.close()

cncf_projects.txtから一覧を読み取り、sqlite3と接続して未登録のものが来たらネタとして利用するようになっています。テストで10回実行してみると、以下のように結果が取得できました(なお、この実験の後データベースは削除してリセットしています)。

for i in `seq 0 9`
do
  uv run main.py
done

# 結果
selected_item='Ferymon Cloud'
selected_item='Stacklock Minder'
selected_item='EJBCA Community'
selected_item='Siddhi'
selected_item='Datera'
selected_item='wasm3'
selected_item='PostgreSQL'
selected_item='KubeCon CloudNativeCon North America 2025'
selected_item='Cilium'
selected_item='OPEA'

このように、リストの中からランダムに取得できていることがわかります。

まとめ

今回はAPacheルーレットに続きCNCFルーレットを作成してみました。私個人としては圧倒的にCNCFの方が普段お世話になる可能性は高いのですが、まずはApacaheルーレットを完遂するべく努力しようと思っています。ただしよく疲れるのでその時はこのルーレットを使おうと思います!ぜひ第一弾をお楽しみにしてください。

Discussion