From 699a8c720cad59be978b46218fe06cbacca445e1 Mon Sep 17 00:00:00 2001 From: nanameru <4869nanataitai@gmail.com> Date: Thu, 2 Jul 2026 11:48:22 +1000 Subject: [PATCH] =?UTF-8?q?=E3=82=B5=E3=83=A0=E3=83=8D=E4=BB=98=E3=81=8D?= =?UTF-8?q?=E8=A8=98=E4=BA=8B=E6=8A=95=E7=A8=BFCLI=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 --- .gitignore | 1 + README.md | 26 +++++++ articles/fable5.md | 36 +++++++++ pyproject.toml | 1 + src/substack_mcp/article_cli.py | 133 ++++++++++++++++++++++++++++++++ src/substack_mcp/client.py | 3 +- src/substack_mcp/constants.py | 3 + test-board.yaml | 19 +++++ tests/test_article_cli.py | 76 ++++++++++++++++++ 9 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 articles/fable5.md create mode 100644 src/substack_mcp/article_cli.py create mode 100644 src/substack_mcp/constants.py create mode 100644 test-board.yaml create mode 100644 tests/test_article_cli.py diff --git a/.gitignore b/.gitignore index fa5a5b6..6f18dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ htmlcov/ # IDE .vscode/ .idea/ +.serena/ *.swp .DS_Store diff --git a/README.md b/README.md index ee3e7d2..9b83e36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/articles/fable5.md b/articles/fable5.md new file mode 100644 index 0000000..4f0ea78 --- /dev/null +++ b/articles/fable5.md @@ -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/ diff --git a/pyproject.toml b/pyproject.toml index 0840294..c09b483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/substack_mcp/article_cli.py b/src/substack_mcp/article_cli.py new file mode 100644 index 0000000..89fb605 --- /dev/null +++ b/src/substack_mcp/article_cli.py @@ -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 + + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/substack_mcp/client.py b/src/substack_mcp/client.py index 62bb329..cbf7ac7 100644 --- a/src/substack_mcp/client.py +++ b/src/substack_mcp/client.py @@ -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 diff --git a/src/substack_mcp/constants.py b/src/substack_mcp/constants.py new file mode 100644 index 0000000..64bc02f --- /dev/null +++ b/src/substack_mcp/constants.py @@ -0,0 +1,3 @@ +"""Shared constants for Substack MCP.""" + +VALID_AUDIENCES = {"everyone", "only_paid", "founding", "only_free"} diff --git a/test-board.yaml b/test-board.yaml new file mode 100644 index 0000000..c0afc14 --- /dev/null +++ b/test-board.yaml @@ -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] diff --git a/tests/test_article_cli.py b/tests/test_article_cli.py new file mode 100644 index 0000000..00fa9ef --- /dev/null +++ b/tests/test_article_cli.py @@ -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()