Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ htmlcov/
# IDE
.vscode/
.idea/
.serena/
*.swp
.DS_Store

Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ your Substack publication.
- `get_draft(post_id)` — Get a draft's full body.
- `delete_draft(post_id)` — Permanent deletion.

## Create an article with a cover image

For local publishing workflows, the package includes a CLI that creates the
draft, uploads a cover image, sets it as the Substack thumbnail, and only
publishes when `--publish` is explicitly supplied.

```bash
substack-publish-article article.md \
--title "Fable 5は何を変えるのか" \
--subtitle "発売日、対応プラットフォーム、Albionの進化を整理する" \
--cover-image thumbnails/fable_cover.png
```

Add `--publish` to publish immediately. Add `--send-email` only when you also
want to email subscribers:

```bash
substack-publish-article article.md \
--title "Fable 5は何を変えるのか" \
--cover-image thumbnails/fable_cover.png \
--publish
```

`--send-email` and `--share-automatically` are rejected unless `--publish` is
present, so a normal run is safe for draft creation and review.

## Setup

```bash
Expand Down
36 changes: 36 additions & 0 deletions articles/fable5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 「Fable 5」は出ない。でも、新しい『Fable』はかなり大きな再出発になりそう

「Fable 5」という呼び方で探している人は多いはずです。ただ、2026年7月2日時点での公式タイトルはナンバリングではなく、シンプルに **『Fable』** です。実質的にはシリーズの再始動であり、Xboxの公式ページでも「beloved franchise」のリブートとして紹介されています。

発売日は、公式ページ上では **2027年2月23日**。対応プラットフォームは Xbox Series X|S と Windows 10/11 が明記され、Game Passでは初日から遊べるタイトルとして扱われています。ストア導線には Microsoft Store と Steam も並んでいるので、PC勢にとっても追いやすい一本です。

## 何が新しいのか

今回の『Fable』で一番大きいのは、単なる懐古ではなく「Albionを今のオープンワールドRPGとして作り直す」姿勢です。公式の説明では、選択が旅路を形作り、評判が重要になり、おとぎ話の結末は保証されない、とされています。

つまり、過去作の「善か悪か」をわかりやすく選ばせるノリは残しつつ、今作ではもう少し生活感のある世界になりそうです。英雄として戦うだけではなく、金持ちの大家になる、鍛冶屋になる、村人と恋愛する、家族を持つ、といった方向も紹介されています。

## Playground Gamesが作る意味

開発は Playground Games。『Forza Horizon』のスタジオがFableを作る、という時点で最初は意外でした。ただ、考えてみると「美しい風景を走り回る気持ちよさ」を作ってきたチームが、Albionという田園・森・城・奇妙な住人の世界を再構築するのは、かなり相性がいいのかもしれません。

公式ページのスクリーンショットや説明からも、今作は暗いファンタジー一辺倒ではありません。ダークユーモア、変な村人、チキン、HobbesやBalverinesのようなシリーズらしい敵。Fableらしさは、勇壮さよりも「少しズレた童話感」にあります。

## 期待したいポイント

個人的に注目しているのは、評判システムです。公式は「Hero’s reputation is everything」と説明しています。戦闘で強いだけではなく、村人が自分をどう見ているか、どんな噂が広がっているかが世界の反応を変えるなら、Fableらしいロールプレイが戻ってきます。

もうひとつは生活要素です。大家、鍛冶屋、恋愛、家族といった要素が本当にゲームの中心に食い込むなら、ただクエストを消化するRPGではなく、「Albionでどう生きるか」を遊ぶ作品になります。

## いま覚えておきたいこと

結論として、「Fable 5」は正式名称ではありません。けれど、多くの人がそう呼びたくなるくらい、シリーズの次の大きな本編として期待されているのは確かです。

発売日は2027年2月23日。Xbox Series X|S、Windows PC、Game Passで追う人は、ここから情報が一気に具体化していくタイミングに入っています。

長い沈黙のあとに戻ってくるAlbionが、ただ懐かしいだけの場所なのか。それとも、また変な噂と変な選択でプレイヤーを振り回してくれる場所なのか。そこが、新しい『Fable』の一番大事な見どころです。

## 出典

- Xbox公式『Fable』ページ: https://www.xbox.com/en-US/games/fable
- Xbox Wire Developer_Direct記事: https://news.xbox.com/en-us/2026/01/22/fable-interview-overview-details-developer-direct-2026/
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
[project.scripts]
substack-mcp = "substack_mcp.server:main"
substack-mcp-setup = "substack_mcp.setup_cli:main"
substack-publish-article = "substack_mcp.article_cli:main"

[build-system]
requires = ["hatchling"]
Expand Down
133 changes: 133 additions & 0 deletions src/substack_mcp/article_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""CLI for creating Substack articles with optional cover images."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

from .constants import VALID_AUDIENCES


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="substack-publish-article",
description=(
"Create a Substack article draft from Markdown, optionally upload a "
"cover image, and publish only when --publish is supplied."
),
)
parser.add_argument("markdown_file", help="Path to the article Markdown file")
parser.add_argument("--title", required=True, help="Article title")
parser.add_argument("--subtitle", default="", help="Article subtitle")
parser.add_argument(
"--cover-image",
help="Local image path or public URL to upload and set as the cover image",
)
parser.add_argument(
"--audience",
default="everyone",
choices=sorted(VALID_AUDIENCES),
help="Substack audience setting",
)
parser.add_argument(
"--publish",
action="store_true",
help="Publish immediately after creating the draft and setting the cover",
)
parser.add_argument(
"--send-email",
action="store_true",
help="Send email to subscribers. Requires --publish.",
)
parser.add_argument(
"--share-automatically",
action="store_true",
help="Ask Substack to auto-share after publish. Requires --publish.",
)
return parser


def publish_article(
client: Any,
*,
markdown_file: Path,
title: str,
subtitle: str = "",
cover_image: str | None = None,
audience: str = "everyone",
publish: bool = False,
send_email: bool = False,
share_automatically: bool = False,
) -> dict[str, Any]:
if send_email and not publish:
raise ValueError("--send-email requires --publish")
if share_automatically and not publish:
raise ValueError("--share-automatically requires --publish")
if audience not in VALID_AUDIENCES:
raise ValueError(f"audience must be one of {sorted(VALID_AUDIENCES)}")
if not markdown_file.exists():
raise FileNotFoundError(f"Markdown file not found: {markdown_file}")

content = markdown_file.read_text(encoding="utf-8")
draft = client.create_draft(
title=title,
content_markdown=content,
subtitle=subtitle,
audience=audience,
)

result: dict[str, Any] = {"draft": draft}
post_id = draft.get("post_id")
if cover_image:
if not post_id:
raise RuntimeError("Cannot set cover image because draft has no post_id")
upload = client.upload_image(cover_image)
cover_url = upload.get("url")
if not cover_url:
raise RuntimeError(f"Image upload did not return a url: {upload!r}")
result["image"] = upload
result["cover"] = client.set_cover_image(post_id=post_id, image_url=cover_url)

if publish:
if not post_id:
raise RuntimeError("Cannot publish because draft has no post_id")
result["published"] = client.publish_draft(
post_id=post_id,
send_email=send_email,
share_automatically=share_automatically,
)

return result


def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)

try:
from .client import SubstackClient

result = publish_article(
SubstackClient.from_env(),
markdown_file=Path(args.markdown_file),
title=args.title,
subtitle=args.subtitle,
cover_image=args.cover_image,
audience=args.audience,
publish=args.publish,
send_email=args.send_email,
share_automatically=args.share_automatically,
)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
Comment on lines +124 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Security Vulnerability: Replace generic exception handler with specific exceptions to prevent leaking sensitive information. The current bare except Exception can expose credentials or internal system details if SubstackClient.from_env() or client methods fail with unexpected errors. Handle credential loading failures separately from API errors to provide safe error messages.

Suggested change
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except RuntimeError as exc:
# Credential loading or API initialization errors
print(f"error: {exc}", file=sys.stderr)
return 1
except (ValueError, FileNotFoundError) as exc:
# Validation or file errors
print(f"error: {exc}", file=sys.stderr)
return 1
except Exception as exc:
# Unexpected errors - don't expose details
print(f"error: An unexpected error occurred during article processing", file=sys.stderr)
return 1


print(json.dumps(result, ensure_ascii=False, indent=2))
return 0


if __name__ == "__main__":
raise SystemExit(main())
3 changes: 1 addition & 2 deletions src/substack_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
from substack.post import Post

from .auth import Credentials, load_credentials, write_cookie_file
from .constants import VALID_AUDIENCES

logger = logging.getLogger(__name__)

VALID_AUDIENCES = {"everyone", "only_paid", "founding", "only_free"}

ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".heif"}

# Paths that should never be readable for image upload — defense against
Expand Down
3 changes: 3 additions & 0 deletions src/substack_mcp/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Shared constants for Substack MCP."""

VALID_AUDIENCES = {"everyone", "only_paid", "founding", "only_free"}
19 changes: 19 additions & 0 deletions test-board.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 1
project:
name: "substack-mcp"
test_command: "PYTHONPATH=src python -m unittest discover -s tests"
repo: "nanameru/substack-mcp"

source_roots: [src, tests, scripts]

cases:
- id: TC-001
title: "サムネ付き記事投稿CLI: Markdown本文とカバー画像からSubstack下書きを作成し、明示指定時だけ公開する"
feature: "サムネ付き記事投稿CLI"
scenario: "Markdown本文とカバー画像からSubstack下書きを作成し、明示指定時だけ公開する"
status: done
priority: medium
type: regression
source: [src/substack_mcp/article_cli.py]
test_file: "tests/test_article_cli.py"
issues: [1]
76 changes: 76 additions & 0 deletions tests/test_article_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import tempfile
import unittest
from pathlib import Path

from substack_mcp.article_cli import publish_article


class FakeClient:
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []

def create_draft(self, **kwargs):
self.calls.append(("create_draft", kwargs))
return {"post_id": "123", "title": kwargs["title"], "edit_url": "https://example.test/edit"}

def upload_image(self, image_path: str):
self.calls.append(("upload_image", {"image_path": image_path}))
return {"url": "https://cdn.example.test/cover.png"}

def set_cover_image(self, **kwargs):
self.calls.append(("set_cover_image", kwargs))
return {"post_id": kwargs["post_id"], "cover_image": kwargs["image_url"]}

def publish_draft(self, **kwargs):
self.calls.append(("publish_draft", kwargs))
return {"post_id": kwargs["post_id"], "public_url": "https://example.test/p/post"}


class PublishArticleTests(unittest.TestCase):
def test_creates_draft_sets_cover_then_publishes_when_requested(self) -> None:
client = FakeClient()
with tempfile.TemporaryDirectory() as tmp:
markdown = Path(tmp) / "article.md"
markdown.write_text("# Hello\n\nBody", encoding="utf-8")

result = publish_article(
client,
markdown_file=markdown,
title="記事タイトル",
subtitle="記事サブタイトル",
cover_image="/tmp/cover.png",
audience="everyone",
publish=True,
send_email=False,
)

self.assertEqual(
[name for name, _ in client.calls],
["create_draft", "upload_image", "set_cover_image", "publish_draft"],
)
self.assertEqual(client.calls[0][1]["content_markdown"], "# Hello\n\nBody")
self.assertEqual(client.calls[2][1]["post_id"], "123")
self.assertEqual(client.calls[3][1]["send_email"], False)
self.assertIn("published", result)

def test_send_email_requires_publish(self) -> None:
client = FakeClient()
with tempfile.TemporaryDirectory() as tmp:
markdown = Path(tmp) / "article.md"
markdown.write_text("Body", encoding="utf-8")

with self.assertRaisesRegex(ValueError, "--send-email requires --publish"):
publish_article(
client,
markdown_file=markdown,
title="記事タイトル",
send_email=True,
)

self.assertEqual(client.calls, [])


if __name__ == "__main__":
unittest.main()