Ghost CMS Self-Hosted: Import Post dari CSV Pakai Admin API
Bisa kok bikin post Ghost dari CSV pakai Admin API. Jauh lebih aman daripada utak-atik database langsung, bro.
Masalahnya apa sih?
Gue lagi ngulik Ghost CMS self-hosted, terus kepikiran: kalau data drama ada di CSV, enaknya bisa nggak langsung diubah jadi post? Sekalian feature image-nya pakai URL external.
Jawaban singkatnya: bisa. Dan menurut gue, jalur yang paling waras itu pakai Ghost Admin API, bukan import langsung ke database.
Kenapa gue nggak saranin utak-atik database?
- Lebih rawan kalau ada schema yang berubah.
- Lebih ribet kalau Ghost butuh restart atau ada field yang harus sinkron.
- Kurang aman dibanding pakai API resmi.
- Lebih susah debug kalau ada data yang gagal masuk.
Kalau pakai Admin API, alurnya lebih jelas: baca CSV, bikin payload, kirim ke endpoint Ghost, beres.
Masalah: feature image pakai URL external
Di Ghost self-hosted, feature image dari URL external itu biasanya bisa dipakai. Tapi tetap ada beberapa hal yang perlu diingat:
- Kalau theme atau server punya CSP yang ketat, image dari domain lain bisa keblokir.
- Kalau URL gambar mati, ya post-nya ikut kehilangan thumbnail.
- Kalau ada proses optimasi image di setup tertentu, perilakunya bisa beda-beda.
Jadi kalau source image-nya stabil, aman. Kalau nggak, siap-siap gambar ngilang di kemudian hari.
Solusi yang gue pilih: Ghost Admin API
Ini pendekatan yang paling clean buat kasus CSV ke post. CSV dibaca satu per satu, lalu tiap baris dibikin jadi post Ghost.
Contoh kolom CSV yang gue anggap masuk akal:
drama_id,title,desc,total_episode,img_url,platform,tagsKalau file lu pakai delimiter tab, tinggal sesuaikan parser-nya. Kalau pakai koma, ya pakai default CSV biasa.
Contoh script Python
import csv
import jwt
import requests
from datetime import datetime
import time
GHOST_URL = "https://yourdomain.com"
ADMIN_API_KEY = "your_admin_api_key_here"
CSV_FILE = "drama.csv"
DEFAULT_PLATFORM = "rapidtv"
def get_jwt_token(api_key):
key_id, secret = api_key.split(":")
iat = int(datetime.now().timestamp())
payload = {
"iat": iat,
"exp": iat + 300,
"aud": "/admin/"
}
token = jwt.encode(
payload,
bytes.fromhex(secret),
algorithm="HS256",
headers={"kid": key_id}
)
return token
def slugify(drama_id):
return str(drama_id).strip().lower().replace(" ", "-")
def build_tags(tags_raw, platform):
tags = []
tags.append({"name": f"#{platform.strip().lower()}"})
if tags_raw:
for t in tags_raw.split(","):
t = t.strip()
if t:
tags.append({"name": t})
return tags
def build_post(row):
drama_id = row.get("drama_id", "").strip()
title = row.get("title", "").strip()
desc = row.get("desc", "").strip()
total_ep = row.get("total_episode", "").strip()
img_url = row.get("img_url", "").strip()
platform = row.get("platform", DEFAULT_PLATFORM).strip()
tags_raw = row.get("tags", "").strip()
tags = build_tags(tags_raw, platform)
html_content = f"""
{desc}
Total Episode: {total_ep} | Source: {platform}
"""
return {
"slug": slugify(drama_id),
"title": title,
"html": html_content,
"feature_image": img_url if img_url else None,
"tags": tags,
"status": "published",
"custom_excerpt": desc[:300] if len(desc) > 300 else desc,
}
def create_post(session, token, post_data):
url = f"{GHOST_URL}/ghost/api/admin/posts/?source=html"
headers = {
"Authorization": f"Ghost {token}",
"Content-Type": "application/json"
}
response = session.post(url, json={"posts": [post_data]}, headers=headers)
return response
def main():
token = get_jwt_token(ADMIN_API_KEY)
session = requests.Session()
success, failed, skipped = 0, 0, 0
failed_rows = []
with open(CSV_FILE, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter="\t")
for i, row in enumerate(reader):
if i > 0 and i % 50 == 0:
token = get_jwt_token(ADMIN_API_KEY)
print(f"Token refreshed at row {i}")
if not row.get("title", "").strip():
skipped += 1
continue
post = build_post(row)
response = create_post(session, token, post)
if response.status_code == 201:
print(f"OK [{i+1}] {row['title']}")
success += 1
elif response.status_code == 422:
print(f"Duplicate slug: {row.get('drama_id')} - skip")
skipped += 1
else:
print(f"Failed [{i+1}] {row['title']} | {response.status_code}")
failed_rows.append({"row": i+1, "title": row['title'], "error": response.text[:150]})
failed += 1
time.sleep(0.3)
print("Summary")
print(f"Success: {success}")
print(f"Skipped: {skipped}")
print(f"Failed: {failed}")
if __name__ == "__main__":
main()Setup singkat
Dependency yang dibutuhin:
pip install requests pyjwtLalu ambil Admin API Key dari:
- Ghost Admin
- Settings
- Integrations
- Add custom integration
- Copy Admin API Key format
id:secret
Kalau CSV lu pakai koma
Di script tadi, bagian ini:
reader = csv.DictReader(f, delimiter="\t")Ganti jadi:
reader = csv.DictReader(f)Catatan kecil biar nggak nyangkut
- Kalau slug bentrok, Ghost bakal nolak atau ngasih error validasi.
- Kalau mau aman, bikin slug dari
drama_idyang benar-benar unik. - Kalau mau tag internal, pakai pola
#platformbiar gampang difilter. - Kalau mau draft dulu, ubah
statusjadidraft.
Kesimpulan
Menurut gue, buat self-hosted Ghost, Admin API adalah jalan paling enak buat import post dari CSV. Lebih aman, lebih rapi, dan masih sesuai cara main Ghost. Database import? Bisa aja, tapi buat kasus kayak gini, mending jangan bikin hidup lu lebih susah.
Kalau lu mau, next gue bisa bantu bikinin versi script yang lebih siap pakai: ada validasi CSV, retry kalau request gagal, dan mode draft/publish.
Tags: Catatan Teknis, Ghost CMS, Admin API, CSV, Self-hosted