Spaces:
Running
Running
| from fastapi import FastAPI, Request, Response, UploadFile, File, Form, HTTPException | |
| from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse | |
| from fastapi.templating import Jinja2Templates | |
| import os, re, io, json, hashlib | |
| from datetime import datetime, timezone, timedelta | |
| JST = timezone(timedelta(hours=9)) | |
| import requests as http_requests | |
| from PIL import Image | |
| try: | |
| from pillow_heif import register_heif_opener | |
| register_heif_opener() | |
| except ImportError: | |
| pass | |
| try: | |
| import google.generativeai as genai | |
| GENAI_AVAILABLE = True | |
| except Exception: | |
| genai = None | |
| GENAI_AVAILABLE = False | |
| app = FastAPI() | |
| templates = Jinja2Templates(directory="templates") | |
| GEMINI_KEY = os.environ.get("GEMINI_API_KEY") | |
| CMS_SERVICE_ID = os.environ.get("MICROCMS_SERVICE_ID") | |
| CMS_API_KEY = os.environ.get("MICROCMS_API_KEY") | |
| ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "1234").strip() | |
| SECRET_KEY = os.environ.get("SECRET_KEY", "wasemas-secret-key") | |
| AUTHOR_LIST = ["にき", "かん", "なごみ", "蜜柑餅","メロン"] | |
| ARTICLES_PER_PAGE = 10 | |
| SPACE_URL = os.environ.get("SPACE_URL", "") | |
| GEMINI_MODEL = "gemini-2.5-flash" | |
| TEMPLATES = { | |
| "テンプレートなし": "", | |
| "📅 イベントレポート": "<h2>イベント概要</h2><p>イベント名:<br>日時:<br>場所:</p><h2>内容</h2><p>ここに内容を書いてください。</p><h2>感想</h2><p>ここに感想を書いてください。</p>", | |
| "📝 日常記事": "<h2>はじめに</h2><p>ここに導入を書いてください。</p><h2>本文</h2><p>ここに本文を書いてください。</p><h2>おわりに</h2><p>ここにまとめを書いてください。</p>", | |
| "🎵 ライブ・コンサートレポート": "<h2>公演情報</h2><p>公演名:<br>日時:<br>会場:<br>出演:</p><h2>セットリスト</h2><p>1. <br>2. <br>3. </p><h2>見どころ・感想</h2><p>ここに感想を書いてください。</p>", | |
| "📣 お知らせ": "<h2>お知らせ</h2><p>ここにお知らせ内容を書いてください。</p><h2>詳細</h2><p>日時:<br>場所:<br>参加方法:</p>", | |
| } | |
| if GEMINI_KEY and GENAI_AVAILABLE: | |
| genai.configure(api_key=GEMINI_KEY) | |
| # ─── 認証 ─────────────────────────────────────────────── | |
| def _auth_token() -> str: | |
| return hashlib.sha256(f"{ADMIN_PASSWORD}:{SECRET_KEY}".encode()).hexdigest() | |
| def set_auth_cookie(response: Response): | |
| response.set_cookie("auth", _auth_token(), httponly=True, | |
| max_age=86400 * 30, samesite="lax", path="/") | |
| def get_auth(request: Request) -> bool: | |
| return request.cookies.get("auth") == _auth_token() | |
| # ─── ユーティリティ ────────────────────────────────────── | |
| def strip_html(html: str) -> str: | |
| return re.sub(r"<[^>]*?>", "", html) | |
| def get_tags(article: dict) -> list: | |
| s = article.get("tags", "") | |
| return [t.strip() for t in s.split(",") if t.strip()] if s else [] | |
| def make_snippet(html: str) -> str: | |
| parts = re.split(r"</p>|</h[1-6]>|<br\s*/?>", html) | |
| for p in parts: | |
| t = strip_html(p).strip() | |
| if t: | |
| return t[:60] + "..." if len(t) > 60 else t | |
| return "" | |
| def get_content(article: dict) -> str: | |
| rich = article.get("contentRich", "") | |
| return rich if rich else article.get("content", "") | |
| def clean_html(html: str) -> str: | |
| if not html: | |
| return "" | |
| html = re.sub(r"<figure>", '<div style="margin:8px 0;">', html) | |
| html = re.sub(r"</figure>", "</div>", html) | |
| html = re.sub(r"(<p>\s*<br\s*/?>\s*</p>)(\s*<p>\s*<img)", r"\2", html) | |
| html = re.sub(r"(<p>\s*<img[^>]*/?>\\s*</p>)(\s*<p>\s*<br\s*/?>\s*</p>)", r"\1", html) | |
| return html.strip() | |
| def render_blocks_to_html(blocks: list) -> str: | |
| parts = [] | |
| for b in blocks: | |
| if b.get("type") == "text": | |
| parts.append(b.get("html", "")) | |
| elif b.get("type") == "image": | |
| url = b.get("url", "") | |
| alt = b.get("alt", "") | |
| max_w = b.get("maxWidth", 900) | |
| if url: | |
| caption = (f'<p style="text-align:center;font-size:0.8em;color:#9ca3af;margin-top:4px;">{alt}</p>' | |
| if alt else "") | |
| parts.append( | |
| f'<figure style="margin:8px 0;">' | |
| f'<img src="{url}" alt="{alt}" ' | |
| f'style="width:{max_w}px;max-width:100%;height:auto;display:block;margin:0 auto;" />' | |
| f'{caption}</figure>' | |
| ) | |
| return "\n".join(parts) | |
| def blocks_from_article(article: dict) -> list: | |
| cb = article.get("contentBlocks", "") | |
| if cb: | |
| try: | |
| return json.loads(cb) | |
| except Exception: | |
| pass | |
| html = article.get("contentRich", "") or article.get("content", "") | |
| if html: | |
| return [{"type": "text", "id": "block-0", "html": html}] | |
| return [{"type": "text", "id": "block-0", "html": ""}] | |
| def filter_articles(articles: list, q: str, author: str, tag: str) -> list: | |
| result = [] | |
| for a in articles: | |
| title = a.get("title", "") | |
| if title.startswith("【下書き】"): | |
| continue | |
| if q and q.lower() not in title.lower() and q.lower() not in strip_html(get_content(a)).lower(): | |
| continue | |
| if author and author != "全員" and a.get("author") != author: | |
| continue | |
| if tag and tag != "全タグ" and tag not in get_tags(a): | |
| continue | |
| result.append(a) | |
| return result | |
| # ─── microCMS API ───────────────────────────────────────── | |
| CMS_HEADERS_GET = lambda: {"X-MICROCMS-API-KEY": CMS_API_KEY} | |
| CMS_HEADERS_POST = lambda: {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"} | |
| def cms_url(path: str) -> str: | |
| return f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/{path}" | |
| def get_all_articles() -> list: | |
| try: | |
| r = http_requests.get(cms_url("blog"), headers=CMS_HEADERS_GET(), params={"limit": 100}) | |
| return r.json().get("contents", []) | |
| except Exception: | |
| return [] | |
| def get_article(article_id: str) -> dict | None: | |
| try: | |
| r = http_requests.get(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_GET()) | |
| return r.json() if r.status_code == 200 else None | |
| except Exception: | |
| return None | |
| def get_comments(article_id: str) -> list: | |
| all_comments = [] | |
| offset = 0 | |
| limit = 100 | |
| try: | |
| while True: | |
| r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(), params={ | |
| "filters": f"articleId[equals]{article_id}", | |
| "limit": limit, "offset": offset, "orders": "createdAt", | |
| }) | |
| data = r.json() | |
| all_comments.extend(data.get("contents", [])) | |
| if offset + limit >= data.get("totalCount", 0): | |
| break | |
| offset += limit | |
| return all_comments | |
| except Exception: | |
| return [] | |
| def post_comment_api(article_id: str, name: str, body: str, parent_id: str = "") -> bool: | |
| try: | |
| payload = {"articleId": article_id, "name": name, "body": body} | |
| if parent_id: | |
| payload["parentId"] = parent_id | |
| r = http_requests.post(cms_url("comments"), headers=CMS_HEADERS_POST(), json=payload) | |
| return r.status_code in [200, 201] | |
| except Exception: | |
| return False | |
| def build_comment_tree(comments: list) -> list: | |
| by_id = {c["id"]: {**c, "replies": []} for c in comments} | |
| roots = [] | |
| for c in comments: | |
| node = by_id[c["id"]] | |
| pid = c.get("parentId", "") | |
| if pid and pid in by_id: | |
| by_id[pid]["replies"].append(node) | |
| else: | |
| roots.append(node) | |
| return roots | |
| def migrate_comments(old_id: str, new_id: str) -> int: | |
| try: | |
| r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(), | |
| params={"filters": f"articleId[equals]{old_id}", "limit": 100}) | |
| comments = r.json().get("contents", []) | |
| for c in comments: | |
| http_requests.patch(cms_url(f"comments/{c['id']}"), headers=CMS_HEADERS_POST(), | |
| json={"articleId": new_id}) | |
| return len(comments) | |
| except Exception: | |
| return 0 | |
| def _to_jst_str(iso: str) -> str: | |
| try: | |
| dt = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(JST) | |
| return dt.strftime("%Y-%m-%d %H:%M") | |
| except Exception: | |
| return iso[:16].replace("T", " ") | |
| templates.env.filters["to_jst"] = _to_jst_str | |
| def get_all_comment_counts() -> dict: | |
| try: | |
| r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(), params={ | |
| "limit": 100, | |
| "fields": "articleId", | |
| }) | |
| counts: dict = {} | |
| for c in r.json().get("contents", []): | |
| aid = c.get("articleId", "") | |
| if aid: | |
| counts[aid] = counts.get(aid, 0) + 1 | |
| return counts | |
| except Exception: | |
| return {} | |
| def get_backups(original_id: str) -> list: | |
| try: | |
| r = http_requests.get(cms_url("blog-backup"), headers=CMS_HEADERS_GET(), params={ | |
| "filters": f"originalId[equals]{original_id}", | |
| "limit": 100, "orders": "-version", | |
| }) | |
| items = r.json().get("contents", []) | |
| for bk in items: | |
| bk["createdAt_jst"] = _to_jst_str(bk.get("createdAt", "")) | |
| return items | |
| except Exception: | |
| return [] | |
| def create_backup(original_id: str, article_data: dict) -> tuple[bool, str]: | |
| try: | |
| r = http_requests.get(cms_url("blog-backup"), headers=CMS_HEADERS_GET(), params={ | |
| "filters": f"originalId[equals]{original_id}", "limit": 1, "orders": "-version", | |
| }) | |
| if r.status_code not in [200, 201]: | |
| return False, f"backup list fetch failed: {r.status_code}" | |
| contents = r.json().get("contents", []) | |
| next_ver = (contents[0].get("version", 0) if contents else 0) + 1 | |
| body = { | |
| "originalId": original_id, | |
| "version": next_ver, | |
| "title": article_data.get("title", ""), | |
| "author": article_data.get("author", ""), | |
| "tags": article_data.get("tags", ""), | |
| "content": article_data.get("content", ""), | |
| "editorMode": "blocks", | |
| } | |
| if article_data.get("contentBlocks"): | |
| body["contentBlocks"] = article_data["contentBlocks"] | |
| r2 = http_requests.post(cms_url("blog-backup"), headers=CMS_HEADERS_POST(), json=body) | |
| if r2.status_code in [200, 201]: | |
| return True, "" | |
| return False, f"backup post failed: {r2.status_code} {r2.text[:200]}" | |
| except Exception as e: | |
| return False, str(e) | |
| def migrate_backup_ids(old_id: str, new_id: str): | |
| try: | |
| r = http_requests.get(cms_url("blog-backup"), headers=CMS_HEADERS_GET(), | |
| params={"filters": f"originalId[equals]{old_id}", "limit": 100}) | |
| for bk in r.json().get("contents", []): | |
| http_requests.patch(cms_url(f"blog-backup/{bk['id']}"), headers=CMS_HEADERS_POST(), | |
| json={"originalId": new_id}) | |
| except Exception: | |
| pass | |
| def upload_image_cms(image_bytes: bytes, filename: str = "upload.jpg") -> str | None: | |
| try: | |
| url = f"https://{CMS_SERVICE_ID}.microcms-management.io/api/v1/media" | |
| r = http_requests.post(url, headers={"X-MICROCMS-API-KEY": CMS_API_KEY}, | |
| files={"file": (filename, image_bytes, "image/jpeg")}) | |
| if r.status_code not in [200, 201]: | |
| return None | |
| return r.json().get("url") | |
| except Exception: | |
| return None | |
| def process_image(data: bytes, max_width: int = 1200) -> bytes: | |
| img = Image.open(io.BytesIO(data)) | |
| try: | |
| import PIL.ExifTags | |
| exif = img._getexif() | |
| if exif: | |
| for tag, val in exif.items(): | |
| if PIL.ExifTags.TAGS.get(tag) == "Orientation": | |
| if val == 3: img = img.rotate(180, expand=True) | |
| elif val == 6: img = img.rotate(270, expand=True) | |
| elif val == 8: img = img.rotate(90, expand=True) | |
| except Exception: | |
| pass | |
| if img.width > max_width: | |
| ratio = max_width / img.width | |
| img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS) | |
| if img.mode in ("RGBA", "P"): | |
| img = img.convert("RGB") | |
| buf = io.BytesIO() | |
| img.save(buf, format="JPEG", quality=85) | |
| return buf.getvalue() | |
| # ─── 公開ページ ─────────────────────────────────────────── | |
| async def index(request: Request, id: str = None, page: int = 0, | |
| q: str = "", author: str = "", tag: str = ""): | |
| if id: | |
| article = get_article(id) | |
| if not article: | |
| return templates.TemplateResponse(request, "blog_detail.html", | |
| {"article": None, "comments": []}) | |
| flat_comments = get_comments(id) | |
| comment_tree = build_comment_tree(flat_comments) | |
| base_url = SPACE_URL or str(request.base_url).rstrip("/") | |
| if article.get("contentBlocks"): | |
| rendered_content = render_blocks_to_html(blocks_from_article(article)) | |
| else: | |
| rendered_content = clean_html(get_content(article)) | |
| return templates.TemplateResponse(request, "blog_detail.html", { | |
| "article": article, | |
| "comments": comment_tree, | |
| "comment_count": len(flat_comments), | |
| "content": rendered_content, | |
| "tags": get_tags(article), | |
| "space_url": base_url, | |
| }) | |
| all_articles = get_all_articles() | |
| all_tags = sorted(set(t for a in all_articles for t in get_tags(a) | |
| if not a.get("title", "").startswith("【下書き】"))) | |
| filtered = filter_articles(all_articles, q, author, tag) | |
| total = len(filtered) | |
| total_pages = max(1, -(-total // ARTICLES_PER_PAGE)) | |
| page = max(0, min(page, total_pages - 1)) | |
| page_articles = filtered[page * ARTICLES_PER_PAGE:(page + 1) * ARTICLES_PER_PAGE] | |
| comment_counts = get_all_comment_counts() | |
| return templates.TemplateResponse(request, "blog_list.html", { | |
| "articles": page_articles, | |
| "total": total, | |
| "page": page, | |
| "total_pages": total_pages, | |
| "q": q, | |
| "author": author, | |
| "tag": tag, | |
| "all_tags": all_tags, | |
| "author_list": AUTHOR_LIST, | |
| "articles_per_page": ARTICLES_PER_PAGE, | |
| "get_tags": get_tags, | |
| "make_snippet": make_snippet, | |
| "get_content": get_content, | |
| "comment_counts": comment_counts, | |
| }) | |
| # ─── 管理者ページ ───────────────────────────────────────── | |
| async def admin_login_page(request: Request): | |
| if get_auth(request): | |
| return RedirectResponse("/admin/articles", status_code=302) | |
| return templates.TemplateResponse(request, "admin_login.html", {"error": None}) | |
| async def admin_login(request: Request, password: str = Form(default="")): | |
| if not password.strip(): | |
| return templates.TemplateResponse(request, "admin_login.html", | |
| {"error": "合言葉を入力してください"}) | |
| if password.strip() == ADMIN_PASSWORD: | |
| response = RedirectResponse("/admin/articles", status_code=302) | |
| set_auth_cookie(response) | |
| return response | |
| return templates.TemplateResponse(request, "admin_login.html", | |
| {"error": "合言葉が違います"}) | |
| async def admin_logout(): | |
| response = RedirectResponse("/admin", status_code=302) | |
| response.delete_cookie("auth") | |
| return response | |
| async def admin_articles(request: Request): | |
| if not get_auth(request): | |
| return RedirectResponse("/admin", status_code=302) | |
| articles = get_all_articles() | |
| return templates.TemplateResponse(request, "admin_list.html", | |
| {"articles": articles, "get_tags": get_tags}) | |
| async def admin_new(request: Request): | |
| if not get_auth(request): | |
| return RedirectResponse("/admin", status_code=302) | |
| return templates.TemplateResponse(request, "admin_editor.html", { | |
| "article": None, | |
| "blocks_json": json.dumps([{"type": "text", "id": "block-0", "html": ""}]), | |
| "author_list": AUTHOR_LIST, | |
| "templates_map": TEMPLATES, | |
| "backups": [], | |
| }) | |
| async def admin_edit(request: Request, article_id: str): | |
| if not get_auth(request): | |
| return RedirectResponse("/admin", status_code=302) | |
| article = get_article(article_id) | |
| if not article: | |
| raise HTTPException(status_code=404, detail="記事が見つかりません") | |
| blocks = blocks_from_article(article) | |
| ec = article.get("eyecatch") | |
| ec_url = ec.get("url", "") if isinstance(ec, dict) else (ec or "") | |
| backups = get_backups(article_id) | |
| return templates.TemplateResponse(request, "admin_editor.html", { | |
| "article": article, | |
| "blocks_json": json.dumps(blocks, ensure_ascii=False), | |
| "eyecatch_url": ec_url, | |
| "author_list": AUTHOR_LIST, | |
| "templates_map": TEMPLATES, | |
| "backups": backups, | |
| "get_tags": get_tags, | |
| }) | |
| # ─── JSON API ───────────────────────────────────────────── | |
| async def api_save(request: Request): | |
| if not get_auth(request): | |
| raise HTTPException(status_code=401) | |
| data = await request.json() | |
| article_id = data.get("article_id") or None | |
| title_raw = data.get("title", "").strip() | |
| author = data.get("author", AUTHOR_LIST[0]) | |
| tags = data.get("tags", "") | |
| eyecatch = data.get("eyecatch", "") | |
| blocks = data.get("blocks", []) | |
| status = data.get("status", "draft") | |
| clean_title = title_raw.replace("【下書き】", "").strip() | |
| save_title = f"【下書き】{clean_title}" if status == "draft" else clean_title | |
| tag_list = [t.strip() for t in tags.split(",") if t.strip()] | |
| rendered = clean_html(render_blocks_to_html(blocks)) | |
| blocks_json = json.dumps(blocks, ensure_ascii=False) | |
| post_data = { | |
| "title": save_title, | |
| "content": rendered, | |
| "contentRich": rendered, | |
| "contentBlocks": blocks_json, | |
| "author": author, | |
| "tags": ",".join(tag_list), | |
| "editorMode": "blocks", | |
| } | |
| if eyecatch: | |
| post_data["eyecatch"] = eyecatch | |
| base_url = cms_url("blog") | |
| was_draft = False | |
| becomes_public = (status == "public") | |
| new_id = None | |
| try: | |
| if article_id: | |
| existing = get_article(article_id) | |
| if existing: | |
| was_draft = existing.get("title", "").startswith("【下書き】") | |
| if was_draft and becomes_public: | |
| likes_count = int(existing.get("likes", 0) or 0) | |
| if likes_count: | |
| post_data["likes"] = likes_count | |
| http_requests.delete(f"{base_url}/{article_id}", headers=CMS_HEADERS_GET()) | |
| r = http_requests.post(base_url, headers=CMS_HEADERS_POST(), json=post_data) | |
| if r.status_code in [200, 201]: | |
| new_id = r.json().get("id") | |
| if new_id: | |
| migrate_comments(article_id, new_id) | |
| migrate_backup_ids(article_id, new_id) | |
| else: | |
| r = http_requests.patch(f"{base_url}/{article_id}", headers=CMS_HEADERS_POST(), json=post_data) | |
| else: | |
| r = http_requests.post(base_url, headers=CMS_HEADERS_POST(), json=post_data) | |
| if r.status_code not in [200, 201]: | |
| return JSONResponse({"success": False, "error": r.text}) | |
| saved_id = new_id or (r.json().get("id") if not article_id else article_id) | |
| if was_draft and becomes_public and new_id: | |
| saved_id = new_id | |
| backup_data = { | |
| "title": save_title, | |
| "author": author, | |
| "tags": ",".join(tag_list), | |
| "content": rendered, | |
| "contentBlocks": blocks_json, | |
| } | |
| backup_ok, backup_err = False, "saved_id missing" | |
| if saved_id: | |
| backup_ok, backup_err = create_backup(saved_id, backup_data) | |
| return JSONResponse({"success": True, "id": saved_id, | |
| "backup_ok": backup_ok, "backup_err": backup_err}) | |
| except Exception as e: | |
| return JSONResponse({"success": False, "error": str(e)}) | |
| async def api_delete_article(request: Request, article_id: str): | |
| if not get_auth(request): | |
| raise HTTPException(status_code=401) | |
| try: | |
| r = http_requests.delete(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_GET()) | |
| return JSONResponse({"success": r.status_code in [200, 202, 204]}) | |
| except Exception as e: | |
| return JSONResponse({"success": False, "error": str(e)}) | |
| async def api_upload_image(request: Request, file: UploadFile = File(...), | |
| max_width: int = Form(default=1200)): | |
| if not get_auth(request): | |
| raise HTTPException(status_code=401) | |
| try: | |
| raw = await file.read() | |
| processed = process_image(raw, max_width=max_width) | |
| url = upload_image_cms(processed, filename=file.filename or "upload.jpg") | |
| if url: | |
| return JSONResponse({"success": True, "url": url}) | |
| return JSONResponse({"success": False, "error": "アップロード失敗"}) | |
| except Exception as e: | |
| return JSONResponse({"success": False, "error": str(e)}) | |
| async def api_get_backups(request: Request, article_id: str): | |
| if not get_auth(request): | |
| raise HTTPException(status_code=401) | |
| return JSONResponse({"backups": get_backups(article_id)}) | |
| async def api_ai_decorate(request: Request): | |
| if not get_auth(request): | |
| raise HTTPException(status_code=401) | |
| if not GEMINI_KEY or not GENAI_AVAILABLE: | |
| return JSONResponse({"success": False, "error": "AI機能が利用できません(APIキー未設定または初期化エラー)"}) | |
| data = await request.json() | |
| html_content = data.get("html", "") | |
| instruction = data.get("instruction", "") | |
| if not html_content or not instruction: | |
| return JSONResponse({"success": False, "error": "htmlとinstructionが必要です"}) | |
| try: | |
| model = genai.GenerativeModel(model_name=GEMINI_MODEL) | |
| prompt = ( | |
| f"あなたはブログ記事のHTMLコーダーです。\n" | |
| f"以下のルールを守りながら、与えられた指示に基づいてHTMLを修正してください。\n\n" | |
| f"【文章の書き換え厳禁】\n" | |
| f"本文の内容は絶対に変更しないこと。推敲・削除・要約等は全て不要。HTMLタグとスタイルのみ変更すること。\n\n" | |
| f"【スコープルール】\n" | |
| f"・記事全体を <div class=\"article-body\"> で囲む\n" | |
| f"・CSSセレクタは必ず .article-body h2 {{}} のようにラッパークラスを先頭につけてスコープを閉じる\n" | |
| f"・body や html への直接スタイル指定は禁止\n" | |
| f"・CSS変数は :root ではなく .article-body に定義する(例: .article-body {{ --accent-color: #e07297; }})\n\n" | |
| f"【ダークモード対応】\n" | |
| f"・テキストの文字色は black/white などの固定色を使わず color: inherit を使う\n" | |
| f"・グレー系の背景は rgba(128,128,128,0.1) などの半透過色を使う\n" | |
| f"・ダークモード切替は @media (prefers-color-scheme: dark) 内で .article-body の変数を上書きする\n\n" | |
| f"【Twitter埋め込み(厳守)】\n" | |
| f"・<blockquote class=\"twitter-tweet\"> タグおよびその内部は一切変更・装飾・スタイル追加しないこと\n" | |
| f"・blockquote 要素や twitter-tweet クラスへの CSS 指定は全て禁止\n" | |
| f"・widget.jsの読み込みは不要(ページ側で処理する)\n\n" | |
| f"【出力形式】\n" | |
| f"・HTMLコードのみを返すこと(説明文・コードブロック記号は不要)\n" | |
| f"・CSSはできるだけ <style> タグにまとめる(インラインstyleは最小限に)\n" | |
| f"・可読性のため、本文の改行に合わせて適切にコードも改行すること\n\n" | |
| f"【現在のHTML】\n{html_content}\n\n" | |
| f"【修正指示】\n{instruction}" | |
| ) | |
| resp = model.generate_content(prompt) | |
| cleaned = re.sub(r"```html?", "", resp.text, flags=re.IGNORECASE).replace("```", "").strip() | |
| return JSONResponse({"success": True, "html": cleaned}) | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err: | |
| m = re.search(r"retry in (\d+(?:\.\d+)?)s", err) | |
| wait = int(float(m.group(1))) + 1 if m else 30 | |
| return JSONResponse({"success": False, "error": f"使用制限中。{wait}秒後に再試行してください"}) | |
| return JSONResponse({"success": False, "error": err}) | |
| async def api_ai_decorate_all(request: Request): | |
| if not get_auth(request): | |
| raise HTTPException(status_code=401) | |
| if not GEMINI_KEY or not GENAI_AVAILABLE: | |
| return JSONResponse({"success": False, "error": "AI機能が利用できません(APIキー未設定または初期化エラー)"}) | |
| data = await request.json() | |
| blocks_input = data.get("blocks", []) # [{id, html}, ...] | |
| instruction = data.get("instruction", "") | |
| if not blocks_input or not instruction: | |
| return JSONResponse({"success": False, "error": "blocksとinstructionが必要です"}) | |
| try: | |
| blocks_json_str = json.dumps(blocks_input, ensure_ascii=False) | |
| model = genai.GenerativeModel(model_name=GEMINI_MODEL) | |
| prompt = ( | |
| f"あなたはブログ記事のHTMLコーダーです。\n" | |
| f"以下は記事のテキストブロック(HTML)のJSON配列です。\n" | |
| f"全ブロックを通じて統一されたデザイン・スタイルになるよう、以下のルールと指示に基づいてHTMLを修正してください。\n\n" | |
| f"【文章の書き換え厳禁】\n" | |
| f"本文の内容は絶対に変更しないこと。推敲・削除・要約等は全て不要。HTMLタグとスタイルのみ変更すること。\n\n" | |
| f"【スコープルール】\n" | |
| f"・各ブロックのコンテンツを <div class=\"article-body\"> で囲む\n" | |
| f"・CSSセレクタは必ず .article-body h2 {{}} のようにラッパークラスを先頭につけてスコープを閉じる\n" | |
| f"・<style> タグは最初のブロック(配列の先頭)の先頭にのみ記載し、全ブロック共通スタイルをまとめる\n" | |
| f"・body や html への直接スタイル指定は禁止\n" | |
| f"・CSS変数は :root ではなく .article-body に定義する(例: .article-body {{ --accent-color: #e07297; }})\n\n" | |
| f"【ダークモード対応】\n" | |
| f"・テキストの文字色は black/white などの固定色を使わず color: inherit を使う\n" | |
| f"・グレー系の背景は rgba(128,128,128,0.1) などの半透過色を使う\n" | |
| f"・ダークモード切替は @media (prefers-color-scheme: dark) 内で .article-body の変数を上書きする\n\n" | |
| f"【Twitter埋め込み(厳守)】\n" | |
| f"・<blockquote class=\"twitter-tweet\"> タグおよびその内部は一切変更・装飾・スタイル追加しないこと\n" | |
| f"・blockquote 要素や twitter-tweet クラスへの CSS 指定は全て禁止\n" | |
| f"・widget.jsの読み込みは不要(ページ側で処理する)\n\n" | |
| f"【出力形式】\n" | |
| f"・同じJSON形式(idとhtmlのペアの配列)のみを返すこと。余分なテキストや説明は不要。\n" | |
| f"・CSSはできるだけ <style> タグにまとめる(最初のブロックに集約、インラインstyleは最小限に)\n" | |
| f"・可読性のため、本文の改行に合わせて適切にコードも改行すること\n\n" | |
| f"【ブロックデータ(JSON)】\n{blocks_json_str}\n" | |
| f"【装飾指示】{instruction}\n" | |
| f"【出力形式】[{{\"id\": \"block-xxx\", \"html\": \"装飾されたHTML\"}},...] のJSON配列のみ" | |
| ) | |
| resp = model.generate_content(prompt) | |
| cleaned = re.sub(r"```json?", "", resp.text, flags=re.IGNORECASE).replace("```", "").strip() | |
| decorated_list = json.loads(cleaned) | |
| result = {item["id"]: item["html"] for item in decorated_list} | |
| return JSONResponse({"success": True, "decorated": result}) | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err: | |
| m = re.search(r"retry in (\d+(?:\.\d+)?)s", err) | |
| wait = int(float(m.group(1))) + 1 if m else 30 | |
| return JSONResponse({"success": False, "error": f"使用制限中。{wait}秒後に再試行してください"}) | |
| return JSONResponse({"success": False, "error": err}) | |
| async def api_comment(request: Request): | |
| data = await request.json() | |
| article_id = data.get("article_id", "") | |
| name = data.get("name", "わせます太郎").strip() or "わせます太郎" | |
| body = data.get("body", "").strip() | |
| parent_id = data.get("parent_id", "") | |
| if not body: | |
| return JSONResponse({"success": False, "error": "コメントを入力してください"}) | |
| ok = post_comment_api(article_id, name, body, parent_id) | |
| return JSONResponse({"success": ok}) | |
| async def api_like(article_id: str, request: Request): | |
| try: | |
| body = await request.json() | |
| action = body.get("action", "add") | |
| except Exception: | |
| action = "add" | |
| article = get_article(article_id) | |
| if not article: | |
| return JSONResponse({"success": False, "likes": 0}) | |
| current = int(article.get("likes", 0) or 0) | |
| new_count = max(0, current + (1 if action == "add" else -1)) | |
| r = http_requests.patch(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_POST(), | |
| json={"likes": new_count}) | |
| return JSONResponse({"success": r.status_code in [200, 201], "likes": new_count}) | |