-
Notifications
You must be signed in to change notification settings - Fork 1
【substack-mcp】サムネ付き記事投稿CLIを整備する #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
nanameru
wants to merge
1
commit into
main
Choose a base branch
from
issue-1-article-cover-cli
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ htmlcov/ | |
| # IDE | ||
| .vscode/ | ||
| .idea/ | ||
| .serena/ | ||
| *.swp | ||
| .DS_Store | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| print(json.dumps(result, ensure_ascii=False, indent=2)) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 Exceptioncan 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.