File size: 31,536 Bytes
4138418
 
 
d9a9432
23f6c06
 
4138418
 
fbd4742
 
 
 
 
781bb19
 
 
 
 
 
4138418
 
 
 
 
 
 
9f7f01a
4138418
2811cbd
4138418
 
 
 
 
 
 
 
 
 
 
 
781bb19
4138418
 
 
 
 
d9a9432
 
 
4138418
d9a9432
 
4138418
 
d9a9432
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0382dd
 
4138418
e0382dd
4138418
e0382dd
 
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4fe594
 
 
4138418
c4fe594
 
 
 
 
 
 
 
 
 
 
4138418
 
 
dd1d936
4138418
dd1d936
 
 
 
4138418
 
 
 
dd1d936
 
 
 
 
 
 
 
 
 
 
 
4138418
 
1d82218
4138418
 
 
1d82218
4138418
 
 
 
 
23f6c06
 
 
 
 
 
 
81062d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4138418
 
 
 
 
 
23f6c06
 
 
 
4138418
 
 
e604fea
4138418
 
 
 
 
e604fea
4138418
 
 
99b29e3
 
 
 
 
 
 
4138418
99b29e3
 
4138418
e604fea
 
 
 
 
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
988c22e
 
dd1d936
 
4ac7fa5
12ec7e1
 
 
 
988c22e
4138418
dd1d936
 
12ec7e1
4138418
4ac7fa5
4138418
 
 
 
 
 
 
 
 
 
81062d4
4138418
988c22e
4138418
 
 
 
 
 
 
 
 
 
 
 
 
81062d4
4138418
 
 
 
 
 
 
 
 
988c22e
4138418
 
d9a9432
 
 
 
 
4138418
 
 
988c22e
 
4138418
 
 
 
 
 
 
 
 
 
 
 
988c22e
 
4138418
 
 
 
 
988c22e
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
988c22e
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cede326
 
 
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99b29e3
 
 
 
 
4138418
e604fea
4138418
e604fea
4138418
e604fea
 
4138418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781bb19
 
4138418
 
 
 
 
 
 
 
e269916
 
 
 
 
 
 
 
 
 
 
 
 
45a24c3
 
 
e269916
 
 
 
 
 
 
4138418
 
 
 
 
 
 
 
 
 
 
 
727f4a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e269916
727f4a3
e269916
 
 
 
 
 
 
 
 
 
 
 
 
45a24c3
 
 
e269916
 
 
 
 
727f4a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4138418
 
 
 
 
 
dd1d936
4138418
 
dd1d936
4138418
dd1d936
 
5d91810
 
 
 
 
 
dd1d936
 
 
 
5d91810
dd1d936
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
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()


# ─── 公開ページ ───────────────────────────────────────────

@app.get("/", response_class=HTMLResponse)
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,
    })


# ─── 管理者ページ ─────────────────────────────────────────

@app.get("/admin", response_class=HTMLResponse)
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})

@app.post("/admin/login")
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": "合言葉が違います"})

@app.post("/admin/logout")
async def admin_logout():
    response = RedirectResponse("/admin", status_code=302)
    response.delete_cookie("auth")
    return response

@app.get("/admin/articles", response_class=HTMLResponse)
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})

@app.get("/admin/new", response_class=HTMLResponse)
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": [],
    })

@app.get("/admin/edit/{article_id}", response_class=HTMLResponse)
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 ─────────────────────────────────────────────

@app.post("/api/save")
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)})

@app.delete("/api/article/{article_id}")
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)})

@app.post("/api/upload-image")
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)})

@app.get("/api/backups/{article_id}")
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)})

@app.post("/api/ai-decorate")
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})

@app.post("/api/ai-decorate-all")
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})

@app.post("/api/comment")
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})

@app.post("/api/like/{article_id}")
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})