Skip to main content
Glama
106-

Kakuyomu MCP Server

by 106-
main.py12.1 kB
#!/usr/bin/env python3 """ Kakuyomu MCP Server 小説投稿サイト「カクヨム」のコンテンツを読み込むためのMCP (Model Context Protocol) サーバー """ import argparse import os import json import logging from typing import Any, List, Dict import requests from bs4 import BeautifulSoup from mcp.server.fastmcp import FastMCP logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) host = os.getenv("HOST", "127.0.0.1") port = int(os.getenv("PORT", 9468)) mcp = FastMCP("kakuyomu-mcp", host=host, port=port) def kakuyomu_request(url: str, params: dict = None) -> BeautifulSoup: """カクヨムのページを取得してBeautifulSoupオブジェクトを返す""" default_user_agent = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/138.0.0.0 Safari/537.36" ) headers = {"User-Agent": os.getenv("USER_AGENT", default_user_agent)} res = requests.get(url, params=params, headers=headers) res.raise_for_status() return BeautifulSoup(res.text, "html.parser") def parse_apollo_data(soup: BeautifulSoup) -> Dict[str, Any]: """__NEXT_DATA__からApollo状態データを抽出""" script_tag = soup.find("script", id="__NEXT_DATA__") if not script_tag: raise ValueError("__NEXT_DATA__スクリプトタグが見つかりません") data = json.loads(script_tag.string) return data["props"]["pageProps"]["__APOLLO_STATE__"] def works_to_string(data: Dict[str, Dict[str, Any]], works: List[str]) -> str: """作品一覧を文字列に変換""" output_lines: List[str] = [] for work_id in works: work = data[work_id] id_ = work.get("id") if id_: output_lines.append(f"ID: {id_}") title = work.get("title") if title: output_lines.append(f"タイトル: {title}") catchphrase = work.get("catchphrase") if catchphrase: output_lines.append(f"キャッチフレーズ: {catchphrase}") tags = work.get("tagLabels") if tags: tag_str = ", ".join(tags) output_lines.append(f"タグ: {tag_str}") introduction = work.get("introduction") if introduction: output_lines.append("イントロダクション:\n```\n" + introduction + "\n```") output_lines.append("") # 区切りの空行 return "\n".join(output_lines) def episodes_to_string(data: Dict[str, Dict[str, Any]], episodes: List[str]) -> str: """エピソード一覧を文字列に変換""" output_lines: List[str] = [] for episode_id in episodes: episode = data[episode_id] id_ = episode.get("id") if id_: output_lines.append(f"ID: {id_}") title = episode.get("title") if title: output_lines.append(f"タイトル: {title}") publishedAt = episode.get("publishedAt") if publishedAt: output_lines.append(f"公開日: {publishedAt}") output_lines.append("") # 区切りの空行 return "\n".join(output_lines) def rankings_to_string(rankings: List[Dict[str, Any]]) -> str: """ランキングデータを文字列に変換""" output_lines: List[str] = [] for ranking_data in rankings: rank = ranking_data.get("rank") if rank: output_lines.append(f"順位: {rank}") id_ = ranking_data.get("id") if id_: output_lines.append(f"ID: {id_}") title = ranking_data.get("title") if title: output_lines.append(f"タイトル: {title}") author = ranking_data.get("author") if author: output_lines.append(f"作者: {author}") catchphrase = ranking_data.get("catchphrase") if catchphrase: output_lines.append(f"キャッチフレーズ: {catchphrase}") tags = ranking_data.get("tags") if tags: tag_str = ", ".join(tags) output_lines.append(f"タグ: {tag_str}") introduction = ranking_data.get("introduction") if introduction: output_lines.append("イントロダクション:\n```\n" + introduction + "\n```") output_lines.append("") # 区切りの空行 return "\n".join(output_lines) @mcp.tool() def get_top_page(limit: int = 10) -> str: """カクヨムのトップページから最新作品一覧を取得""" try: soup = kakuyomu_request("https://kakuyomu.jp/") data = parse_apollo_data(soup) works = list(filter(lambda x: x.startswith("Work"), data.keys())) return works_to_string(data, works[:limit]) except Exception as e: logger.error(f"Error in get_top_page: {str(e)}") return f"エラーが発生しました: {str(e)}" @mcp.tool() def search_works( q: str, page: int = 1, ex_q: str = None, serial_status: str = None, genre_name: str = None, total_review_point_range: str = None, total_character_count_range: str = None, published_date_range: str = None, last_episode_published_date_range: str = None, limit: int = 10, ) -> str: """カクヨムで作品を検索""" try: params = {"q": q, "page": str(page)} # オプションパラメータを追加 optional_params = { "ex_q": ex_q, "serial_status": serial_status, "genre_name": genre_name, "total_review_point_range": total_review_point_range, "total_character_count_range": total_character_count_range, "published_date_range": published_date_range, "last_episode_published_date_range": last_episode_published_date_range, } for key, value in optional_params.items(): if value: params[key] = value soup = kakuyomu_request("https://kakuyomu.jp/search", params) data = parse_apollo_data(soup) works = list(filter(lambda x: x.startswith("Work:"), data.keys())) return works_to_string(data, works[:limit]) except Exception as e: logger.error(f"Error in search_works: {str(e)}") return f"エラーが発生しました: {str(e)}" @mcp.tool() def get_work_episodes(work_id: str, limit: int = 20) -> str: """特定の作品のエピソード一覧を取得""" try: soup = kakuyomu_request(f"https://kakuyomu.jp/works/{work_id}") data = parse_apollo_data(soup) episodes = list(filter(lambda x: x.startswith("Episode:"), data.keys())) return episodes_to_string(data, episodes[:limit]) except Exception as e: logger.error(f"Error in get_work_episodes: {str(e)}") return f"エラーが発生しました: {str(e)}" @mcp.tool() def get_episode_content(work_id: str, episode_id: str) -> str: """特定のエピソードの本文を取得""" try: soup = kakuyomu_request( f"https://kakuyomu.jp/works/{work_id}/episodes/{episode_id}" ) episode_body = soup.find("div", class_="widget-episodeBody js-episode-body") if not episode_body: return "エピソードの本文が見つかりませんでした。" # class="blank" を除いた <p> タグのテキストだけ抽出 paragraphs = [ p.get_text(strip=True) for p in episode_body.find_all("p") if "blank" not in p.get("class", []) ] return "\n".join(paragraphs) except Exception as e: logger.error(f"Error in get_episode_content: {str(e)}") return f"エラーが発生しました: {str(e)}" @mcp.tool() def get_rankings(genre: str = "all", period: str = "daily", limit: int = 10) -> str: """カクヨムのランキングページから作品ランキングを取得""" try: soup = kakuyomu_request(f"https://kakuyomu.jp/rankings/{genre}/{period}") # ランキングの作品要素を取得 work_elements = soup.find_all("div", class_="widget-work float-parent") rankings = [] for work_element in work_elements[:limit]: ranking_data = {} # 順位を取得 rank_element = work_element.find("p", class_="widget-work-rank") if rank_element: ranking_data["rank"] = rank_element.get_text(strip=True) # 作品IDを取得(URLから抽出) title_link = work_element.find("a", class_="widget-workCard-titleLabel") if title_link and title_link.get("href"): href = title_link.get("href") work_id = href.split("/works/")[-1] if "/works/" in href else None if work_id: ranking_data["id"] = work_id # タイトルを取得 if title_link: ranking_data["title"] = title_link.get_text(strip=True) # 作者を取得 author_link = work_element.find("a", class_="widget-workCard-authorLabel") if author_link: ranking_data["author"] = author_link.get_text(strip=True) # キャッチフレーズを取得(最初のレビューから) catchphrase_element = work_element.find("a", itemprop="reviewBody") if catchphrase_element: ranking_data["catchphrase"] = catchphrase_element.get_text(strip=True) # タグを取得 tag_elements = work_element.find_all( "a", href=lambda x: x and "/tags/" in x ) if tag_elements: tags = [ tag.find("span").get_text(strip=True) for tag in tag_elements if tag.find("span") ] ranking_data["tags"] = tags # イントロダクションを取得 intro_element = work_element.find( "p", class_="widget-workCard-introduction" ) if intro_element: intro_link = intro_element.find("a") if intro_link: ranking_data["introduction"] = intro_link.get_text(strip=True) rankings.append(ranking_data) return rankings_to_string(rankings) except Exception as e: logger.error(f"Error in get_rankings: {str(e)}") return f"エラーが発生しました: {str(e)}" @mcp.resource("info://kakuyomu-server") def get_server_info() -> str: """カクヨムMCPサーバーについての情報""" return """カクヨム MCP サーバー 小説投稿サイト「カクヨム」のコンテンツを読み込むためのMCPサーバーです。 利用可能なツール: 1. get_top_page - トップページから最新作品一覧を取得 2. search_works - 作品を検索 3. get_work_episodes - 作品のエピソード一覧を取得 4. get_episode_content - エピソードの本文を取得 5. get_rankings - ランキングページから作品ランキングを取得 """ def main(): """メインエントリーポイント""" parser = argparse.ArgumentParser( description="カクヨムMCPサーバー - 小説投稿サイト「カクヨム」のコンテンツを読み込むMCPサーバー", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""使用例: %(prog)s --transport stdio # stdioモードで起動 (デフォルト) %(prog)s --transport streamable-http # HTTPモードで起動""", ) parser.add_argument( "--transport", choices=["stdio", "streamable-http"], default="stdio", help="トランスポート方式 (デフォルト: stdio)", ) args = parser.parse_args() if args.transport == "stdio": logger.info("Starting Kakuyomu MCP server with stdio transport") mcp.run(transport="stdio") else: logger.info("Starting Kakuyomu MCP server with streamable-http transport") mcp.run(transport="streamable-http") if __name__ == "__main__": main()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/106-/kakuyomu-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server