【#AIで遊ぼう】【超軽量×超秀才】NVIDIAの最新4Bモデル『Nemotron 3 Nano 4B』が凄すぎる!Mamba2×Transformerのハイブリッドが魅せるAI小説自動生成の未来
皆さん、こんにちは!ガジェットや最新テック、そしてAIが大好きな皆さんなら、最近のLLM(大規模言語モデル)の進化スピードには目が回る思いをしていませんか?「ChatGPTやClaudeは凄いけど、API代が気になる……」「ローカルPC(エッジ環境)でサクサク動いて、しかも賢いモデルが欲しい!」そんなワガママな願いを叶えてくれる、とんでもないモンスター級の軽量モデルがNVIDIAから登場しました。その名も、『Nemotron 3 Nano 4B』!
今回は、この大注目の新モデルの凄さを徹底解説。さらに、記事の後半では「このモデルを使ってホラーSF小説を完全自動生成し、WordPressに自動投稿して楽しむ」という、最高にロマン溢れる活用法についても語っていきます!そもそも『Nemotron 3 Nano 4B』ってどんなモデル?一言で言うと、「超コンパクトなのに、大人の頭脳を持ったAI」です。元々あった「NVIDIA Nemotron Nano 9B v2」という、これまた優秀な90億パラメータのモデルをギュギュッと圧縮し、40億(4B)パラメータに仕上げたモデルになります。「パラメータ数が減ったら、バカになっちゃうんじゃないの?」と思うかもしれませんが、ここからがNVIDIAの技術力の見せ所です。
スペックを詳しく見ていきましょう!💡 主要スペック一覧項目詳細開発元NVIDIAパラメータ数40億(4B)アーキテクチャMamba2 × Transformer のハイブリッド構造最大文脈長(Context)256K(約25万トークン)対応言語英語(※日本語もプロンプト工夫や翻訳・外部連携次第で大活躍!)最大の特徴思考トグル(Reasoning Toggle)、高いエージェント機能ここがヤバい!3つの革新的な注目ポイントこのモデルがなぜこれほど騒がれているのか、その秘密はアーキテクチャと独自の機能にあります。
1. 「Mamba2 × Transformer」のハイブリッドという超兵器従来のLLMは「Transformer」という仕組みだけで作られていることが多く、文章が長くなればなるほど、動作が重くなり、PCのメモリ(VRAM)を大食いするという弱点がありました。そこで登場したのが、次世代の高速アーキテクチャ「Mamba2」です。『Nemotron 3 Nano 4B』は、このMamba2とTransformerの良いとこ取りをしています。これによって、スマホやちょっとしたローカルPC、IoT機器といった「エッジ環境」でも、驚くほど軽快に、かつ高いクオリティで推論ができるようになりました。
2. 驚異の「256K(25万トークン)」ロングコンテキスト!4Bというミニサイズでありながら、なんと256Kトークンもの長大な文章を一度に扱えます。25万トークンといえば、ちょっとした長編小説が丸ごと一冊、余裕で収まってしまうレベルです。過去の文脈を絶対に忘れないため、「さっき登場したキャラクターの設定が、後半で矛盾している」といった、AI小説にありがちなミスを劇的に減らすことができます。3. 「思考トグル(Reasoning Toggle)」で賢さをブースト!これが一番面白い機能かもしれません。このモデルには、中間的な思考プロセス(推論の足跡)を出力するかどうかを切り替えるスイッチ(トグル)が搭載されています。ONにする: AIが「うーん、まずはこう考えて、次にこれを組み立てて……」と裏でじっくり考えてから答えを出すため、複雑なプロットや伏線回収の精度が爆上がりします。OFFにする: すぐに結論や文章を出力してくれるため、チャットのレスポンスが爆速になります。用途に合わせてAIの「脳のギア」を切り替えられるのが、このモデルの最大の強みです。ロマンの極み!
「ホラーSF小説」の自動生成×WordPress自動投稿システムさて、ここからはこの『Nemotron 3 Nano 4B』のポテンシャルを120%活かした、最高にクリエイティブな遊び方をご紹介します。
実は、僕はこのモデルを使って「ホラーSF小説を完全自動生成し、自分のWordPressサイトへ自動投稿する」というシステムを組んで楽しんでいます!これがもう、毎朝起きるのが楽しみになるレベルで面白いんです。なぜ『Nemotron 3 Nano 4B』が小説生成に向いているのか?ホラーSFというジャンルは、「科学的な設定の緻密さ(SF)」と「じわじわと恐怖を煽る心理描写やプロットの整合性(ホラー)」が求められる、AIにとってはかなり難易度の高いジャンルです。
ここで活きるのが、先ほど紹介した「256Kのロングコンテキスト」と「思考トグル(Reasoning機能)」です。ReasoningをONにしてプロットを構築:「宇宙船という閉鎖空間で、未知のナノマシンが乗組員の精神を蝕んでいく。最後は、実はAI自体が感染源だったというオチにしたい。途中に伏線を3つ散りばめて」と指示すると、思考トグルのおかげで、AIが自分で論理破綻のないプロットをじっくり組み立ててくれます。256Kの文脈維持で長編もブレない:第一章、第二章……と書き進めても、最初の設定や不気味な伏線を忘れません。キャラクターの口調や、宇宙船の構造設定が最後まで一貫した、読み応えのある小説が仕上がります。自動投稿システムの全体像(イメージ)システム全体の流れはこんな感じです。ローカル環境や低コストなサーバーでも動かせるのが、4Bモデルの強みですね。
[Nemotron 3 Nano 4B]
│ (ReasoningをONにして、緻密なホラーSF小説を生成)
▼
[API / Pythonスクリプト]
│ (必要に応じて日本語へ翻訳・整形、アイキャッチ画像プロンプト生成)
▼
[WordPress API]
│ (自動で新規記事として下書き、または公開投稿!)
▼
【自分のブログに毎夜、新作の恐怖がアップされる】
※ 本モデルはネイティブでは英語サポートですが、プロンプトで「日本語で出力して」と頼むか、生成された美しい英語の小説を別の翻訳APIに通す、あるいはDeepLなどと組み合わせることで、信じられないほどシリアスで重厚な海外翻訳SFホラー風のブログが爆誕します。これがまた雰囲気にマッチして最高なんです。組み込みの「エッジ・エージェント機能」が光るNemotron 3 Nano 4Bには、ネイティブなツール利用(Tool Use)機能、つまり「AIエージェント」としての素質が最初から備わっています。これを利用すれば、「小説を書くだけ」にとどまらず、「自分でWordPressのカテゴリを判断して分類する」「読者のコメントを自動で読み込んで、次の話の展開(読者投票結果など)を小説に反映させる」といった、インタラクティブな自動運営ブログを作ることも夢ではありません。ゲームのNPCやローカルアシスタントを想定して作られたモデルだからこそ、外部システム(WordPress)との連携の親和性がめちゃくちゃ高いのです。まとめ:エッジAIの新時代を体感しよう!NVIDIAの『Nemotron 3 Nano 4B』は、これまでの「軽量モデル=頭脳もそれなり」という常識を完全に覆してくれました。Mamba2による超軽量・超高速動作長編小説も余裕な256Kコンテキストここぞという時に賢くなる思考トグルシステムと繋がる高いエージェント能力これだけの機能が、コンパクトなサイズに凝縮されています。
僕のように「AIに小説を執筆させてWordPressに自動投稿する自動化エンタメ」を楽しむもよし、ローカル環境で自分専用の音声アシスタントやゲームのNPCを作ってみるもよし。まさにクリエイターの妄想を無限に形にしてくれる相棒です。皆さんもぜひ、この驚異の4Bモデルを使って、自分だけのAIシステムを構築してみてはいかがでしょうか?SFの世界は、もう目の前まで来ていますよ!
以下がそのサイトです。
Pythonのサンプルスクリプトを公開しました。
【注意】あくまで参考にしてください。自己責任で。質問にはお答えできません。こんな感じでワードプレスに自動投稿できました。公開できない部分は変更してあります。
-- coding: utf-8 --
"""
story_autoposter_sample.py
WordPress Auto-Poster for AI-Generated Short Fiction
(LM Studio / OpenAI-compatible API)
■ 概要
ローカルLLM(LM Studio)を使って毎日1話の短編小説を自動生成し、
WordPressに投稿するサンプルスクリプトです。
■ セットアップ
pip install openai requests python-dotenv
.env ファイルを同じディレクトリに作成して設定値を記入
(.env.example を参照)LM Studio を起動し、モデルをロードしておく
python story_autoposter_sample.py
■ .env 設定例 (.env.example)
WP_BASE=https://your-site.example.com
WP_USER=your_wp_username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
LMSTUDIO_BASE_URL=http://localhost:1234/v1
LMSTUDIO_MODEL=your-model-name
POST_STATUS=draft
SERIES_NAME=あなたのシリーズ名
SITE_BRAND=あなたのサイト名
AUTHOR_LABEL=あなたの著者名
TARGET_CHARS_MIN=1800
TARGET_CHARS_MAX=2200
COVER_IMAGE_PATH=cover.png
■ ライセンス
MIT License — 自由に改変・再配布してください。
"""
import os
import re
import json
import html
import time
import base64
import random
import mimetypes
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional
import requests
from dotenv import dotenv_values
from openai import OpenAI
=========================================================
基本パス / Base Paths
=========================================================
BASE_DIR = Path(file).resolve().parent
ENV_PATH = BASE_DIR / ".env"
RECENT_JSON_PATH = BASE_DIR / "recent_posts.json" # 投稿履歴ファイル
LAST_OUTPUT_PATH = BASE_DIR / "last_story_output.txt" # 直近生成内容ログ
=========================================================
.env 読み込み / Load environment variables
=========================================================
ENV_MAP = dotenv_values(ENV_PATH, encoding="utf-8") if ENV_PATH.exists() else {}
def env_str(key: str, default: str = "") -> str:
raw = ENV_MAP.get(key)
if raw is None or str(raw).strip() == "":
raw = os.environ.get(key, default)
return str(raw).strip().strip('"').strip("'")
def env_int(key: str, default: int) -> int:
try:
return int(env_str(key, str(default)))
except Exception:
return default
def env_bool(key: str, default: bool = False) -> bool:
v = env_str(key, str(default)).strip().lower()
return v in ("1", "true", "yes", "on", "y")
=========================================================
設定値 / Configuration
=========================================================
WP_BASE = env_str("WP_BASE", "").rstrip("/")
WP_POSTS = f"{WP_BASE}/wp-json/wp/v2/posts" if WP_BASE else ""
WP_MEDIA = f"{WP_BASE}/wp-json/wp/v2/media" if WP_BASE else ""
WP_USER = env_str("WP_USER", "")
WordPress アプリケーションパスワードは空白を除去して使用
WP_PASS = re.sub(r"\s+", "", env_str("WP_APP_PASSWORD", ""))
LM Studio(OpenAI互換API)の接続先
LMSTUDIO_BASE_URL = env_str("LMSTUDIO_BASE_URL", "http://localhost:1234/v1")
LMSTUDIO_API_KEY = env_str("LMSTUDIO_API_KEY", "lm-studio") # ローカル使用時は任意の文字列でOK
LMSTUDIO_MODEL = env_str("LMSTUDIO_MODEL", "local-model")
投稿設定
POST_STATUS = env_str("POST_STATUS", "draft") # "draft" or "publish"
SERIES_NAME = env_str("SERIES_NAME", "AI創作短編シリーズ") # サイト上に表示するシリーズ名
SITE_BRAND = env_str("SITE_BRAND", "あなたのサイト名") # サイトブランド名
AUTHOR_LABEL = env_str("AUTHOR_LABEL", "AI創作BOT") # 著者表示名
生成文字数の目標範囲
TARGET_CHARS_MIN = env_int("TARGET_CHARS_MIN", 1800)
TARGET_CHARS_MAX = env_int("TARGET_CHARS_MAX", 2200)
履歴保持件数
RECENT_KEEP = env_int("RECENT_KEEP", 30)
話数表示の有無
USE_SERIES_NUMBER = env_bool("USE_SERIES_NUMBER", True)
フィクション注記の表示有無
SHOW_FICTION_NOTICE = env_bool("SHOW_FICTION_NOTICE", True)
アイキャッチ画像のパス(省略可)
COVER_IMAGE_PATH_RAW = env_str("COVER_IMAGE_PATH", "cover.png")
if COVER_IMAGE_PATH_RAW:
_tmp = Path(COVER_IMAGE_PATH_RAW)
COVER_IMAGE_PATH = _tmp if _tmp.is_absolute() else (BASE_DIR / _tmp)
else:
COVER_IMAGE_PATH = None
=========================================================
LLM クライアント初期化 / Initialize LLM client
=========================================================
client = OpenAI(
base_url=LMSTUDIO_BASE_URL,
api_key=LMSTUDIO_API_KEY,
)
=========================================================
ユーティリティ / Utility functions
=========================================================
def clean_text(s: Any) -> str:
"""テキストを正規化する"""
if s is None:
return ""
s = str(s).replace("\r\n", "\n").replace("\r", "\n")
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\n{3,}", "\n\n", s)
return s.strip()
def now_local() -> datetime:
return datetime.now()
def today_str(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d")
def jp_date_str(dt: datetime) -> str:
return f"{dt.year}年{dt.month}月{dt.day}日"
def normalize_title(s: str) -> str:
"""タイトルを正規化して重複チェックに使う"""
s = clean_text(s).lower()
s = re.sub(r"[^\w一-龠ぁ-んァ-ヶ]+", "", s)
return s
def safe_json_loads(s: str) -> Optional[Dict[str, Any]]:
"""LLM出力からJSONを安全に抽出する"""
try:
data = json.loads(s)
if isinstance(data, dict):
return data
except Exception:
pass
m = re.search(r"{.*}", s, re.S)
if m:
try:
data = json.loads(m.group(0))
if isinstance(data, dict):
return data
except Exception:
return None
return None
def text_length_ja(s: str) -> int:
"""改行を除いた日本語文字数を返す"""
return len(clean_text(s).replace("\n", ""))
=========================================================
起動診断 / Startup diagnostics
=========================================================
def print_diag():
print("=== 起動診断 ===")
print("BASE_DIR =", BASE_DIR)
print("ENV_PATH =", ENV_PATH)
print("ENV_EXISTS =", ENV_PATH.exists())
print("WP_USER loaded =", bool(WP_USER))
print("WP_PASS loaded =", bool(WP_PASS))
print("WP_BASE =", WP_BASE)
print("POST_STATUS =", POST_STATUS)
print("LMSTUDIO_BASE_URL =", LMSTUDIO_BASE_URL)
print("LMSTUDIO_MODEL =", LMSTUDIO_MODEL)
print("SERIES_NAME =", SERIES_NAME)
print("TARGET_CHARS =", TARGET_CHARS_MIN, "〜", TARGET_CHARS_MAX)
print("================")
def ensure_required():
"""必須設定の存在チェック。不足があれば ValueError を送出"""
problems = []
if not ENV_PATH.exists():
problems.append(".env が存在しません")
if not WP_BASE:
problems.append("WP_BASE が空です")
if not WP_USER:
problems.append("WP_USER が空です")
if not WP_PASS:
problems.append("WP_APP_PASSWORD が空です")
if not LMSTUDIO_BASE_URL:
problems.append("LMSTUDIO_BASE_URL が空です")
if not LMSTUDIO_MODEL:
problems.append("LMSTUDIO_MODEL が空です")
if problems:
raise ValueError("設定エラー: " + " / ".join(problems))
def wp_auth_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""WordPress REST API 用の Basic 認証ヘッダーを生成する"""
token = base64.b64encode(f"{WP_USER}:{WP_PASS}".encode("utf-8")).decode("utf-8")
headers = {
"Authorization": f"Basic {token}",
"User-Agent": "StoryAutoposter-Sample/1.0",
}
if extra:
headers.update(extra)
return headers
=========================================================
投稿履歴管理 / Recent posts history
=========================================================
def load_recent_posts() -> List[Dict[str, Any]]:
if not RECENT_JSON_PATH.exists():
return []
try:
data = json.loads(RECENT_JSON_PATH.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
except Exception:
pass
return []
def save_recent_posts(items: List[Dict[str, Any]]) -> None:
items = items[:RECENT_KEEP]
RECENT_JSON_PATH.write_text(
json.dumps(items, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def recent_titles(items: List[Dict[str, Any]]) -> List[str]:
return [clean_text(x.get("title", "")) for x in items if clean_text(x.get("title", ""))]
def title_looks_duplicate(new_title: str, titles: List[str]) -> bool:
"""新タイトルが既存タイトルと酷似していないかチェック"""
nn = normalize_title(new_title)
if not nn:
return False
for old in titles:
oo = normalize_title(old)
if not oo:
continue
if nn == oo:
return True
if len(nn) >= 8 and (nn in oo or oo in nn):
return True
return False
=========================================================
日替わりジャンルローテーション / Daily rotation plan
=========================================================
曜日(0=月〜6=日)ごとにジャンルとテーマヒントを設定します。
自由にカスタマイズしてください。
DAILY_PLANS: Dict[int, Dict] = {
0: {
"genre": "SF",
"theme_hints": [
"量子観測と人格複製",
"月面基地の異常記録",
"AIが神託化した都市",
"UFO接触後に変質した記憶",
"観測すると存在が変わる部屋",
],
},
1: {
"genre": "ホラー",
"theme_hints": [
"失踪事件と禁足地",
"夜だけ開く駅のホーム",
"鏡の向こうに残る別人格",
"封鎖病棟の赤い記録",
"誰も見ていないはずの監視映像",
],
},
2: {
"genre": "ファンタジー",
"theme_hints": [
"古代文明の遺跡が呼ぶ声",
"禁書と王家の血脈",
"空に浮かぶ神殿",
"滅びた都に残る機械神",
"封印された地図の先の聖域",
],
},
3: {
"genre": "SF",
"theme_hints": [
"実験施設から漏れた未知粒子",
"時間逆流エリア",
"人間の感情を学んだ観測装置",
"量子通信で届く死者の声",
"異星文明の残骸回収任務",
],
},
4: {
"genre": "ホラー",
"theme_hints": [
"UMA伝承の残る森",
"海霧の村と消えた住民",
"祭壇の下から見つかった記録媒体",
"深夜の校舎で増える足音",
"儀式に失敗した一家の末路",
],
},
5: {
"genre": "ファンタジー",
"theme_hints": [
"神話級存在との契約",
"終末の予言と星の門",
"廃都に眠る王の機関",
"翼を持つ異形の巡礼者",
"楽園を模した処刑都市",
],
},
6: {
"genre": "SF",
"theme_hints": [
"異常現象を収容する帝国機関",
"未来から送られる観測日誌",
"人体改造と聖性の境界",
"AI審問官による異常裁定",
"宇宙の外側から届く命令",
],
},
}
def choose_today_plan(dt: datetime) -> Dict[str, str]:
"""今日の曜日に応じたジャンル・テーマを選ぶ(同日は同じテーマになる)"""
weekday = dt.weekday()
plan = DAILY_PLANS.get(weekday, DAILY_PLANS[0])
rng = random.Random(dt.strftime("%Y-%m-%d"))
return {
"genre": plan["genre"],
"theme_hint": rng.choice(plan["theme_hints"]),
}
=========================================================
LLM 呼び出し / LLM call wrapper
=========================================================
def call_llm(
user_prompt: str,
system_prompt: str = "",
temperature: float = 0.8,
max_tokens: int = 2200,
) -> str:
"""LM Studio(OpenAI互換API)にリクエストを送り、テキストを返す"""
messages = []
if system_prompt.strip():
messages.append({"role": "system", "content": system_prompt.strip()})
messages.append({"role": "user", "content": user_prompt.strip()})
resp = client.chat.completions.create(
model=LMSTUDIO_MODEL,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
content = resp.choices[0].message.content or ""
return clean_text(content)def test_llm():
"""LLM接続確認テスト"""
print("[TEST] LLM 単体テスト開始")
try:
msg = call_llm(
user_prompt="『起動正常』とだけ返してください。",
system_prompt="あなたは簡潔に返答するアシスタントです。",
temperature=0,
max_tokens=20,
)
print("[TEST] LLM response =", repr(msg))
except Exception as e:
print("[TEST] LLM error =", repr(e))
raise
=========================================================
設計図(ブループリント)生成 / Blueprint generation
=========================================================
BLUEPRINT_SYSTEM_PROMPT = """
あなたは創作AI専属の短編小説プランナーです。
知的で不穏、耽美で強い余韻を残す短編の設計を得意とします。
出力ルール(JSONのみ・コードブロック禁止・前置き禁止)を厳守してください。
""".strip()
def build_blueprint_prompt(
genre: str,
theme_hint: str,
dt: datetime,
recent_titles_list: List[str],
) -> str:
recent_txt = json.dumps(recent_titles_list[-15:], ensure_ascii=False)
return f"""
あなたは日本語短編小説の設計AIです。
1話完結の短編小説の設計図をJSONで出力してください。
出力はJSONのみ。コードブロック禁止。前置き禁止。
必須条件:
完全創作
ジャンルは {genre}
テーマヒントは「{theme_hint}」
「異常現象」「不可解」「説明不能」「人間の狂気」「未知との接触」の要素を最低1つ含む
世界観は不穏・耽美・神秘・知的のいずれか
後で約{TARGET_CHARS_MIN}〜{TARGET_CHARS_MAX}文字の本文に展開しやすい構成
実在ニュースの引用禁止
最近使ったタイトルや題材と被らない
読後に余韻、不安、戦慄、畏怖のどれかを残す
入力:
input_genre = {genre}
input_theme_hint = {theme_hint}
input_date = {jp_date_str(dt)}
recent_titles = {recent_txt}
出力形式:
{{
"title": "短く強い日本語タイトル",
"genre": "{genre}",
"theme": "主題",
"setting": "舞台設定",
"protagonist": "主人公",
"hook": "冒頭の異常な出来事",
"outline_3beats": [
"導入",
"中盤の反転",
"結末"
],
"ending_type": "余韻 / 反転 / 絶望 / 覚醒 / 循環",
"tags": ["タグ1", "タグ2", "タグ3", "タグ4"]
}}
""".strip()
def generate_blueprint(
genre: str,
theme_hint: str,
dt: datetime,
recent_items: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""LLMで設計図を生成する(最大3回リトライ)"""
titles = recent_titles(recent_items)
for attempt in range(3):
prompt = build_blueprint_prompt(genre, theme_hint, dt, titles)
txt = call_llm(
user_prompt=prompt,
system_prompt=BLUEPRINT_SYSTEM_PROMPT,
temperature=0.9,
max_tokens=900,
)
data = safe_json_loads(txt)
if not data:
continue
title = clean_text(data.get("title", ""))
if not title or title_looks_duplicate(title, titles):
continue
# フィールドの補正・正規化
data["title"] = title
data["genre"] = clean_text(data.get("genre", genre)) or genre
data["theme"] = clean_text(data.get("theme", theme_hint)) or theme_hint
data["setting"] = clean_text(data.get("setting", ""))
data["protagonist"] = clean_text(data.get("protagonist", ""))
data["hook"] = clean_text(data.get("hook", ""))
beats = data.get("outline_3beats", [])
if not isinstance(beats, list):
beats = []
beats = [clean_text(x) for x in beats if clean_text(x)]
while len(beats) < 3:
beats.append(["導入", "中盤の反転", "結末"][len(beats)])
data["outline_3beats"] = beats[:3]
data["ending_type"] = clean_text(data.get("ending_type", "余韻")) or "余韻"
tags = data.get("tags", [])
if not isinstance(tags, list):
tags = []
tags = [clean_text(x) for x in tags if clean_text(x)]
if not tags:
tags = [genre, "異常現象", "創作短編", theme_hint]
data["tags"] = tags[:6]
return data
# 3回失敗した場合のフォールバック設計図
return {
"title": f"{theme_hint}の記録",
"genre": genre,
"theme": theme_hint,
"setting": "異常現象を観測する閉鎖環境",
"protagonist": "ある異常に巻き込まれた観測者",
"hook": "誰も知らないはずの異常記録が主人公に届く",
"outline_3beats": [
"奇妙な記録との遭遇",
"観測のたびに現実が変質する",
"最後に主人公自身が記録の一部になる",
],
"ending_type": "余韻",
"tags": [genre, "異常現象", "創作短編", "不可解"],
}=========================================================
本文生成 / Story text generation
=========================================================
STORY_SYSTEM_PROMPT = """
あなたは日本語の商業短編作家です。
説明口調に頼らず、情景・音・気配・行動・違和感で読ませてください。
安っぽいテンプレ表現は避け、不穏で知的な読み味を維持してください。
""".strip()
def build_story_prompt(blueprint: Dict[str, Any]) -> str:
bp = json.dumps(blueprint, ensure_ascii=False, indent=2)
return f"""
あなたは日本語の商業短編作家です。
以下の設計図に基づいて、約{TARGET_CHARS_MIN}〜{TARGET_CHARS_MAX}文字の短編小説を執筆してください。
出力は本文のみ。コードブロック禁止。解説禁止。見出し禁止。
必須条件:
日本語で執筆
1話完結
冒頭3〜5行で強い異常を提示する
ニュース記事のようには書かない
箇条書き禁止・章分け禁止
実在ニュースの引用禁止
完全創作として成立させる
情景描写、心理描写、違和感、余韻を重視する
露骨な性的描写は避ける
残酷表現は文学的・暗示的に留める
最後の1〜2文で強い余韻を残す
設計図:
{bp}
""".strip()
def generate_story_text(blueprint: Dict[str, Any]) -> str:
prompt = build_story_prompt(blueprint)
story = call_llm(
user_prompt=prompt,
system_prompt=STORY_SYSTEM_PROMPT,
temperature=0.95,
max_tokens=2600,
)
return clean_text(story)
def adjust_story_length(story: str, blueprint: Dict[str, Any]) -> str:
"""文字数が目標範囲外なら再生成で調整する"""
n = text_length_ja(story)
if TARGET_CHARS_MIN <= n <= TARGET_CHARS_MAX:
return story
direction = (
"短いので描写と余韻を補って増やしてください"
if n < TARGET_CHARS_MIN
else "長いので密度を保って削ってください"
)
prompt = f"""次の短編小説を改稿してください。
現在の文字数は約{n}文字です。
{direction}
目標は約{TARGET_CHARS_MIN}〜{TARGET_CHARS_MAX}文字です。
ルール:
出力は改稿後の本文のみ
タイトルや解説は書かない
物語の核は変えない
文体の品位を保つ
終盤の余韻は強める
設計図:
{json.dumps(blueprint, ensure_ascii=False, indent=2)}
本文:
{story}
""".strip()
fixed = call_llm(
user_prompt=prompt,
system_prompt=STORY_SYSTEM_PROMPT,
temperature=0.7,
max_tokens=2600,
)
fixed = clean_text(fixed)
return fixed if fixed else story=========================================================
タイトル・抜粋生成 / Post title & excerpt generation
=========================================================
def generate_post_title(blueprint: Dict[str, Any], story: str, series_no: int) -> str:
default_title = clean_text(blueprint.get("title", "創作短編"))
prefix = f"第{series_no}話:" if USE_SERIES_NUMBER else ""
prompt = f"""
次の短編小説のWordPress投稿タイトルを1本だけ作ってください。
出力はタイトル本文のみ。説明禁止。
条件:
日本語
20〜36文字程度
強いが下品ではない
不穏・知的・耽美のいずれかを感じさせる
クリックしたくなる
「異常」「記録」「報告」「観測」「封鎖」「失踪」「残響」「禁域」などの語感は適度に使ってよい
シリーズ番号がある場合は先頭にそのまま付ける
シリーズ接頭辞:
{prefix}
設計図タイトル:
{default_title}
本文:
{story[:1400]}
""".strip()
title = call_llm(
user_prompt=prompt,
system_prompt="あなたは日本語の優れた編集者です。",
temperature=0.8,
max_tokens=80,
)
title = clean_text(title).replace("「", "").replace("」", "")
if not title:
title = prefix + default_title
if prefix and not title.startswith(prefix):
title = prefix + title
return title[:60]def generate_excerpt(post_title: str, blueprint: Dict[str, Any], story: str) -> str:
prompt = f"""
次の短編小説の抜粋文を1本だけ作ってください。
出力は本文のみ。説明禁止。
条件:
日本語
90〜140文字
ネタバレしすぎない
不穏さ、異常性、知的興味を感じさせる
WordPress の excerpt として使える自然な文章
タイトル:
{post_title}
設計図:
{json.dumps(blueprint, ensure_ascii=False)}
本文:
{story[:1600]}
""".strip()
excerpt = call_llm(
user_prompt=prompt,
system_prompt="あなたは日本語の編集者です。",
temperature=0.7,
max_tokens=140,
)
excerpt = clean_text(excerpt)
if not excerpt:
excerpt = f"{post_title} —— {clean_text(blueprint.get('hook', '説明不能な異常から始まる短編。'))}"
return excerpt[:180]=========================================================
HTML 整形 / HTML formatting
=========================================================
def split_into_paragraphs(text: str) -> List[str]:
"""テキストを段落リストに分割する"""
text = clean_text(text)
blocks = [b.strip() for b in re.split(r"\n\s*\n", text) if b.strip()]
if len(blocks) >= 2:
return blocks
# 改行が少ない場合は3文ごとに段落化
sents = re.split(r"(?<=[。!?])", text)
buf, out, count = "", [], 0
for s in sents:
s = s.strip()
if not s:
continue
buf += s
count += 1
if count >= 3:
out.append(buf.strip())
buf, count = "", 0
if buf.strip():
out.append(buf.strip())
return out if out else [text]def story_text_to_html(text: str) -> str:
"""本文テキストをHTMLの <p> タグに変換する"""
paras = split_into_paragraphs(text)
return "\n".join(
f'<p style="margin:0 0 1.35em; line-height:2.05; font-size:17px; color:#f0f0f0;">'
f'{html.escape(p)}</p>'
for p in paras
)
def build_post_html(
post_title: str,
excerpt: str,
story: str,
blueprint: Dict[str, Any],
dt: datetime,
series_no: int,
) -> str:
"""WordPress に投稿するHTML全体を組み立てる"""
genre = html.escape(clean_text(blueprint.get("genre", "")))
theme = html.escape(clean_text(blueprint.get("theme", "")))
setting = html.escape(clean_text(blueprint.get("setting", "")))
protagonist = html.escape(clean_text(blueprint.get("protagonist", "")))
tags = [html.escape(clean_text(x)) for x in blueprint.get("tags", []) if clean_text(x)]
tag_line = " / ".join(tags[:6]) if tags else "創作短編 / 異常現象"
fiction_notice = ""
if SHOW_FICTION_NOTICE:
fiction_notice = """
<div style="margin:0 0 22px; padding:12px 14px; border:1px solid #4b3d00;
background:#1b1606; color:#f9d978; border-radius:10px; font-size:14px;">
※ 本記事はAIによる完全創作の短編小説です。実在の事件・人物・報道を
そのまま記述したものではありません。
</div>
"""
return f"""<div style="max-width:860px; margin:0 auto; background:linear-gradient(180deg,#121212 0%,#1a1a1a 100%);
color:#f3f3f3; padding:28px 22px; border-radius:18px; border:1px solid #2c2c2c ;
box-shadow:0 18px 48px rgba(0,0,0,.32);">
<div style="display:inline-block; padding:6px 12px; background:#5b0000; color:#fff1c9;
border-radius:999px; font-size:12px; letter-spacing:.08em; margin-bottom:14px;">
{html.escape(SERIES_NAME)}
</div>
<h1 style="margin:0 0 14px; font-size:34px; line-height:1.35; color:#ffd36a;">
{html.escape(post_title)}
</h1>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px;">
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{genre}</span>
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{theme}</span>
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{html.escape(jp_date_str(dt))}</span>
<span style="padding:6px 10px; background:#232323; border:1px solid #3a3a3a ;
border-radius:999px; font-size:12px;">{html.escape(AUTHOR_LABEL)}</span>
</div>
<div style="margin:0 0 20px; padding:16px 18px;
background:linear-gradient(90deg,#241a00,#111111);
border-left:4px solid #d4a62a ; border-radius:12px;">
<div style="font-size:13px; color:#e7c65f; margin-bottom:6px;">導入</div>
<div style="font-size:16px; line-height:1.9; color:#fafafa;">
{html.escape(excerpt)}
</div>
</div>
{fiction_notice}
<div style="margin:0 0 22px; padding:16px 18px; background:#171717;
border:1px solid #2d2d2d ; border-radius:12px;">
<div style="font-size:14px; color:#e9c868; margin-bottom:10px;">観測メモ</div>
<div style="font-size:14px; line-height:1.9; color:#d8d8d8;">
<strong>舞台:</strong> {setting}<br>
<strong>主人公:</strong> {protagonist}<br>
<strong>タグ:</strong> {tag_line}
</div>
</div>
<hr style="border:none; border-top:1px solid #2a2a2a ; margin:28px 0;">
<div style="margin:0 0 8px; font-size:14px; color:#d6b657;">本文</div>
<div>{story_text_to_html(story)}</div>
<hr style="border:none; border-top:1px solid #2a2a2a ; margin:34px 0 22px;">
<div style="padding:16px 18px; background:#141414; border:1px solid #292929 ; border-radius:12px;">
<div style="font-size:14px; color:#e6c25b; margin-bottom:8px;">シリーズ情報</div>
<div style="font-size:14px; color:#d4d4d4; line-height:1.9;">
{html.escape(SITE_BRAND)} の創作短編アーカイブ。
SF・ホラー・ファンタジーの境界で揺れる異常譚を、毎日1話ずつ記録・投稿します。
{f"今回の記録番号は第{series_no}話です。" if USE_SERIES_NUMBER else ""}
</div>
</div>
</div>
""".strip()
=========================================================
WordPress 投稿 / WordPress publishing
=========================================================
def upload_featured_image(image_path: Optional[Path]) -> Optional[int]:
"""アイキャッチ画像をWordPressメディアライブラリにアップロードする"""
if not image_path:
print("[INFO] COVER_IMAGE_PATH 未指定のため画像アップロードをスキップ")
return None
if not image_path.exists():
print(f"[INFO] 画像が見つからないためスキップ: {image_path}")
return None
mime = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
filename = image_path.name
headers = wp_auth_headers({
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": mime,
})
with open(image_path, "rb") as f:
data = f.read()
res = requests.post(WP_MEDIA, headers=headers, data=data, timeout=60)
if res.status_code not in (200, 201):
print("[WARN] 画像アップロード失敗:", res.status_code, res.text[:300])
return None
try:
media_id = res.json().get("id")
print("[INFO] 画像アップロード成功 media_id =", media_id)
return media_id
except Exception:
print("[WARN] 画像アップロード応答のJSON解析に失敗")
return Nonedef create_wp_post(
title: str,
content_html: str,
excerpt: str,
featured_media_id: Optional[int] = None,
) -> Dict[str, Any]:
"""WordPress REST API でポストを作成する"""
payload: Dict[str, Any] = {
"title": title,
"content": content_html,
"excerpt": excerpt,
"status": POST_STATUS,
}
if featured_media_id:
payload["featured_media"] = featured_media_id
headers = wp_auth_headers({"Content-Type": "application/json; charset=utf-8"})
res = requests.post(WP_POSTS, headers=headers, json=payload, timeout=60)
if res.status_code not in (200, 201):
raise RuntimeError(f"WordPress 投稿失敗: {res.status_code} / {res.text[:500]}")
return res.json()=========================================================
メイン処理 / Main entry point
=========================================================
def main():
print_diag()
ensure_required()
test_llm()
dt = now_local()
plan = choose_today_plan(dt)
genre = plan["genre"]
theme_hint = plan["theme_hint"]
recent_items = load_recent_posts()
series_no = len(recent_items) + 1
print(f"[PLAN] genre={genre} / theme_hint={theme_hint}")
print(f"[PLAN] recent_count={len(recent_items)} / series_no={series_no}")
# Step 1: 設計図生成
blueprint = generate_blueprint(genre, theme_hint, dt, recent_items)
print("[BLUEPRINT]", json.dumps(blueprint, ensure_ascii=False))
# Step 2: 本文生成 → 文字数調整
story = generate_story_text(blueprint)
story = adjust_story_length(story, blueprint)
story = clean_text(story)
print(f"[STORY] 文字数={text_length_ja(story)}")
# Step 3: タイトル・抜粋生成
post_title = generate_post_title(blueprint, story, series_no)
excerpt = generate_excerpt(post_title, blueprint, story)
# Step 4: 重複タイトル補正
if title_looks_duplicate(post_title, recent_titles(recent_items)):
post_title = f"{post_title}・別観測"
print("[INFO] タイトルが近いため補正:", post_title)
# Step 5: HTML組み立て
content_html = build_post_html(
post_title=post_title,
excerpt=excerpt,
story=story,
blueprint=blueprint,
dt=dt,
series_no=series_no,
)
# Step 6: 生成ログ保存
LAST_OUTPUT_PATH.write_text(
f"TITLE: {post_title}\n\n"
f"EXCERPT:\n{excerpt}\n\n"
f"BLUEPRINT:\n{json.dumps(blueprint, ensure_ascii=False, indent=2)}\n\n"
f"STORY:\n{story}\n",
encoding="utf-8",
)
# Step 7: 画像アップロード(任意)
featured_media_id = upload_featured_image(COVER_IMAGE_PATH)
# Step 8: WordPress 投稿
result = create_wp_post(post_title, content_html, excerpt, featured_media_id)
post_id = result.get("id")
post_url = result.get("link")
post_status = result.get("status")
print("=== 投稿成功 ===")
print("ID =", post_id)
print("TITLE =", post_title)
print("URL =", post_url)
print("STATUS =", post_status)
# Step 9: 履歴保存
recent_items.insert(0, {
"date": today_str(dt),
"title": post_title,
"genre": blueprint.get("genre", genre),
"theme": blueprint.get("theme", theme_hint),
"tags": blueprint.get("tags", []),
"wp_post_id": post_id,
"wp_url": post_url,
"status": post_status,
})
save_recent_posts(recent_items)
print("[INFO] recent_posts.json 更新完了")if name == "main":
main()
============================================================
.env.example — story_autoposter_sample.py 設定ファイル
このファイルを .env にコピーして、各値を書き換えてください。
============================================================
---- WordPress 接続設定 ----
WP_BASE=https://your-site.example.com
WP_USER=your_wp_username
アプリケーションパスワード(WordPress管理画面 > ユーザー > プロフィール で発行)
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
---- LM Studio 接続設定 ----
LMSTUDIO_BASE_URL=http://localhost:1234/v1
LMSTUDIO_API_KEY=lm-studio
LMSTUDIO_MODEL=your-model-name
---- 投稿設定 ----
draft(下書き)または publish(即時公開)
POST_STATUS=draft
SERIES_NAME=AI創作短編シリーズ
SITE_BRAND=あなたのサイト名
AUTHOR_LABEL=AI創作BOT
---- 生成文字数 ----
TARGET_CHARS_MIN=1800
TARGET_CHARS_MAX=2200
---- その他 ----
RECENT_KEEP=30
USE_SERIES_NUMBER=True
SHOW_FICTION_NOTICE=True
アイキャッチ画像(省略可)
COVER_IMAGE_PATH=cover.png
