Cara Mengubah Blogspot Menjadi Headless CMS Pakai Cloudflare Workers
Blogspot atau Blogger adalah salah satu platform blogging legendaris yang masih eksis sampai sekarang. Alasan utamanya sederhana: gratis, infrastrukturnya dikelola langsung oleh Google, dan tidak ada biaya perawatan server bulanan. Namun, bagi kamu yang ingin melakukan optimasi SEO tingkat lanjut, Blogspot sering kali terasa seperti penjara. Struktur URL yang kaku (wajib menggunakan format tahun/bulan dan berakhiran .html) serta keterbatasan dalam memanipulasi kode HTML secara dinamis di server-side menjadi tembok pembatas yang menyebalkan.
Kabar baiknya, kamu tidak perlu langsung buru-buru migrasi ke WordPress self-hosted atau VPS yang menguras dompet. Kamu bisa memanfaatkan teknologi Edge Computing dari Cloudflare Workers untuk menyulap Blogspot menjadi sebuah Headless CMS yang super cepat, fleksibel, dan memiliki kontrol SEO penuh.
Artikel ini akan memberikan cara kerja script Blogspot Headless SEO Worker v4.2, bagaimana cara memasangnya di akun Cloudflare serta bagaimana sistem ini bisa mendongkrak performa web kamu secara drastis.
Kenapa Harus Mengubah Blogspot Menjadi Headless?
Sebelum masuk ke langkah tutorial, mari kita samakan persepsi dulu tentang apa itu Headless CMS dalam konteks Blogspot. Dalam arsitektur web tradisional, Blogspot mengatur dua hal sekaligus: tempat kamu menulis artikel (backend) dan tampilan blog yang dilihat oleh pengunjung (frontend).
Saat kita mengubahnya menjadi headless, kita memisahkan kedua fungsi ini. Kita tetap menggunakan dashboard Blogspot yang nyaman untuk menulis konten dan mengatur feed. Namun, urusan menampilkan konten ke pengunjung diambil alih oleh Cloudflare Workers yang bertindak sebagai lapisan Edge Proxy.
Berikut adalah beberapa keuntungan luar biasa yang akan kamu dapatkan dengan metode ini:
- Kontrol Penuh Atas URL (Custom Slug): Kamu bisa membuang format
/2026/06/judul-artikel.htmldan mengubahnya menjadi URL yang jauh lebih bersih dan ramah SEO seperti/judul-artikelsaja. - Kecepatan Akses Setara Static Site: Cloudflare memiliki jaringan server global (Edge Servers). Konten dari Blogspot akan disimpan di cache Cloudflare terdekat dari lokasi pengunjung blogmu. Hasilnya? Loading web menjadi instan.
- Injeksi Metadata Otomatis Berbasis AI: Script v4.2 ini dilengkapi integrasi dengan model AI Cloudflare (Llama 3.1) untuk membuat meta deskripsi otomatis jika kamu lupa mengisinya di Blogspot.
- Skema Markup Terbuka (Schema.org) yang Sempurna: Worker secara otomatis menyuntikkan JSON-LD terstruktur untuk
WebSite,BlogPosting, danBreadcrumbListtanpa merusak struktur template bawaan.
Membedah Fitur Utama Script Worker v4.2
Script yang kamu miliki bukan sekadar script proxy biasa. Kode tersebut dirancang khusus untuk mengatasi masalah-masalah kronis SEO pada Blogspot. Mari kita lihat fungsi dari beberapa bagian kode penting di dalamnya:
1. Manajemen Cache
Pada objek CONFIG, terdapat beberapa pengaturan TTL (Time to Live) atau masa kedaluwarsa cache:
FEED_CACHE_TTL: 1800, // Cache feed 30 menit
SLUG_CACHE_TTL: 86400, // Cache slug map 24 jam
PAGE_CACHE_TTL: 3600, // Cache halaman 1 jam
STATIC_CACHE_TTL: 2592000, // Asset statis 30 hari
Artinya, ketika seseorang mengakses artikelmu, Cloudflare tidak akan meminta data terus-menerus ke server Google Blogspot. Konten HTML akan disimpan selama 1 jam di jaringan Cloudflare, sedangkan file gambar, CSS, dan JS (aset statis) akan disimpan selama 30 hari. Ini membuat penggunaan bandwidth menjadi sangat efisien dan kecepatan load halaman melonjak tajam.
2. Fitur Auto-Refresh di Latar Belakang (Stale-While-Revalidate)
Sering kali, masalah utama menggunakan cache yang lama adalah konten blog menjadi telat update saat kamu melakukan revisi. Script ini mengatasinya dengan cerdas pada fungsi route:
if (age > CONFIG.PAGE_CACHE_TTL * 0.8) {
ctx.waitUntil(refreshPageCache(request, env, ctx, edgeCacheKey, edgeCache, url, origin));
}
Jika umur cache halaman sudah mencapai 80%, Cloudflare akan langsung memberikan halaman cache tersebut ke pengunjung (agar loading tetap instan), namun di latar belakang (background process), Worker akan mengambil data terbaru dari Blogspot untuk memperbarui cache. Pengunjung tidak akan menyadari proses ini, dan kunjungan berikutnya dipastikan mendapatkan versi artikel terbaru.
3. Fuzzy Match untuk Pencarian Slug
Salah satu fitur tercanggih di versi 4.2 ini adalah fungsi fuzzyMatch. Ketika kamu mengubah URL Blogspot dari struktur lama ke struktur headless baru, Worker akan memetakan judul artikel menjadi slug yang bersih. Jika ada sedikit perbedaan ketikan atau variasi URL, algoritma tokenisasi pada fungsi ini akan memastikan pengunjung tidak tersasar ke halaman error 404, melainkan tetap diarahkan ke artikel yang tepat dengan akurasi kecocokan di atas 80%.
Panduan Langkah demi Langkah Setup di Cloudflare
Sekarang, mari kita pasang script ini ke dalam akun Cloudflare kamu. Ikuti panduan ini langkah demi langkah agar tidak ada eror saat produksi.
Langkah 1: Siapkan Akun dan Domain di Cloudflare
Pastikan kamu sudah mendaftarkan domain custom kamu (misal: www.blogkamu.com) ke Cloudflare dan mengubah Name Server domain kamu ke Cloudflare. Blogspot asli kamu biarkan saja menggunakan alamat bawaan, misalnya namablogmu.blogspot.com.
Langkah 2: Buat Worker Baru
- Masuk ke dashboard Cloudflare kamu.
- Pada menu sebelah kiri, klik Workers & Pages > Overview.
- Klik tombol Create Application, lalu pilih Create Worker.
- Beri nama worker kamu (misalnya:
blogspot-headless-seo), lalu klik Deploy.
Langkah 3: Masukkan Kode Script
- Setelah berhasil deploy, klik Edit Code untuk membuka Web Editor Cloudflare.
- Hapus semua kode bawaan yang ada di editor tersebut.
- Salin seluruh kode script dibawah ini
dan tempel (paste) ke dalam editor./** * =========================================================================== * Blogspot Headless SEO Worker v4.2 - 100% FIXED PRODUCTION * =========================================================================== */ const CONFIG = { // ⚠️ WAJIB DIISI: Domain asli blogspot Anda (bukan domain custom) BLOGSPOT_ORIGIN: "https://NAMABLOGMU.blogspot.com", // ← GANTI INI! BLOGSPOT_HOST: "ghs.googlehosted.com", FEED_MAX_RESULTS: 500, FEED_CACHE_TTL: 1800, // Cache feed 30 menit SLUG_CACHE_TTL: 86400, // Cache slug map 24 jam PAGE_CACHE_TTL: 3600, // Cache halaman 1 jam STATIC_CACHE_TTL: 2592000, // Asset statis 30 hari AI_META_CACHE_TTL: 604800, // AI meta deskripsi 7 hari POSTS_PER_SITEMAP: 1000, AI_FALLBACK: true, AI_MODEL: "@cf/meta/llama-3.1-8b-instruct-fp8-fast", SECURITY_HEADERS: { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "SAMEORIGIN", "Referrer-Policy": "strict-origin-when-cross-origin", "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", }, BLACKLIST_PATHS: new Set([ "search", "label", "feeds", "b", "admin", "api", "sitemap", "robots", "favicon", "archive", "css", "js" ]), }; const cfOpts = (ttl, isStatic = false) => ({ ...(ttl && isStatic ? { cacheTtl: ttl, cacheEverything: true } : ttl ? { cacheTtl: ttl } : {}), }); export default { async fetch(request, env, ctx) { try { return await route(request, env, ctx); } catch (err) { console.error("[Worker]", err); return new Response("Kesalahan Server Internal", { status: 500 }); } }, }; async function route(request, env, ctx) { const url = new URL(request.url); const origin = url.origin; const path = url.pathname; if (path === "/sitemap.xml") return handleSitemap(origin, ctx); if (path === "/robots.txt") return handleRobots(origin); if (isStaticAsset(path)) return staticProxy(request, CONFIG.BLOGSPOT_ORIGIN, path + url.search); if (isPassthrough(path)) return blogspotProxy(request, CONFIG.BLOGSPOT_ORIGIN, path + url.search, ctx); const edgeCacheKey = new Request(url.href, { method: "GET" }); const edgeCache = caches.default; const edgeHit = await edgeCache.match(edgeCacheKey); if (edgeHit) { const age = getAge(edgeHit); if (age > CONFIG.PAGE_CACHE_TTL * 0.8) { ctx.waitUntil(refreshPageCache(request, env, ctx, edgeCacheKey, edgeCache, url, origin)); } return edgeHit; } const nativeMatch = path.match(/^\/(\d{4})\/(\d{2})\/([^/?#]+)\.html$/); if (nativeMatch) { const entry = await findEntryByPath(path, origin, ctx); if (entry) { const slug = titleToSlug(entry.title?.$t || ""); if (slug) { return Response.redirect(origin + "/" + encodeSlug(slug) + (url.search || ""), 301); } } const fileName = nativeMatch[3]; const fallbackSlug = titleToSlug(decodeURIComponent(fileName).replace(/\.html?$/i, "")); if (fallbackSlug && fallbackSlug.length > 2 && !CONFIG.BLACKLIST_PATHS.has(fallbackSlug)) { return Response.redirect(origin + "/" + encodeSlug(fallbackSlug) + (url.search || ""), 301); } return blogspotProxy(request, CONFIG.BLOGSPOT_ORIGIN, path + url.search, ctx); } const slug = extractSlug(path); if (slug) { const entry = await findEntryBySlug(slug, origin, ctx); if (entry) { const nativePath = entryPath(entry); const canonicalUrl = origin + "/" + encodeSlug(titleToSlug(entry.title?.$t || "")); const response = await buildPage(request, env, ctx, origin, nativePath, canonicalUrl, entry); if (response.status === 200) { cacheResponse(edgeCache, edgeCacheKey, response.clone(), CONFIG.PAGE_CACHE_TTL, ctx); } return response; } } const response = await buildPage(request, env, ctx, origin, path + url.search, url.href, null); if (response.status === 200) { cacheResponse(edgeCache, edgeCacheKey, response.clone(), CONFIG.PAGE_CACHE_TTL, ctx); } return response; } async function buildPage(request, env, ctx, origin, targetPath, canonicalUrl, entry) { const res = await fetchFromBlogspot(request, CONFIG.BLOGSPOT_ORIGIN, targetPath, ctx); if (!res) return new Response("Kesalahan Origin", { status: 502 }); if ([301, 302, 307, 308].includes(res.status)) { const loc = res.headers.get("location") || ""; const newPath = resolveRedirect(loc, origin); if (newPath) return buildPage(request, env, ctx, origin, newPath, canonicalUrl, entry); return res; } if (res.status === 404) { return new Response("Halaman tidak ditemukan", { status: 404, headers: { "Content-Type": "text/html; charset=UTF-8" } }); } const ct = res.headers.get("Content-Type") || ""; const isHtml = ct.includes("text/html"); const headers = buildResponseHeaders(res.headers, request, isHtml); if (!isHtml || !res.body) { return new Response(res.body, { status: res.status, headers }); } const rawHtml = await res.text(); const [feed, aiMeta] = await Promise.all([ fetchFeed(origin), entry ? getAiMeta(entry, env, ctx) : Promise.resolve(null), ]); const slugMap = feed ? buildSlugMap(feed, origin) : {}; const seoData = buildSeoData(rawHtml, entry, canonicalUrl, origin, aiMeta); const schemaJson = buildSchema(seoData, entry, origin, feed); const finalHtml = await injectSeoWithRewriter(rawHtml, seoData, schemaJson, slugMap, origin, canonicalUrl); headers.delete("Content-Encoding"); headers.set("Content-Length", String(new TextEncoder().encode(finalHtml).length)); return new Response(finalHtml, { status: res.status, headers }); } function buildSeoData(html, entry, canonicalUrl, origin, aiMeta) { const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); const rawTitle = titleMatch ? titleMatch[1].trim() : ""; const bodyText = stripHtml(extractPostBody(html)).slice(0, 500); const existingDesc = extractMeta(html, "description") || extractMeta(html, "og:description"); const description = aiMeta || existingDesc || (bodyText.length > 50 ? bodyText.slice(0, 160) : "Baca artikel menarik dan informatif di blog kami."); const thumbnail = extractMeta(html, "og:image") || (html.match(/<img[^>]*src=["']([^"']+)["']/i)?.[1] || ""); const published = entry?.published?.$t || extractMeta(html, "article:published_time") || ""; const updated = entry?.updated?.$t || extractMeta(html, "article:modified_time") || ""; const labels = (entry?.category || []).map(c => c.term); const author = entry?.author?.[0]?.name?.$t || ""; return { title: rawTitle, description, thumbnail, published, updated, labels, author, canonicalUrl, origin, isPost: !!entry }; } async function getAiMeta(entry, env, ctx) { if (!env?.AI) return null; const cache = caches.default; const path = entryPath(entry); const cacheKey = new Request("https://ai-meta.internal/__meta__/" + encodeURIComponent(path || "")); const hit = await cache.match(cacheKey); if (hit) return hit.text(); const title = entry.title?.$t || ""; const summary = stripHtml(entry.summary?.$t || "").slice(0, 300); if (!title && !summary) return null; try { const result = await env.AI.run(CONFIG.AI_MODEL, { messages: [ { role: "system", content: "Kamu adalah ahli SEO Indonesia. Buatkan meta deskripsi dari judul dan konten yang diberikan. Panjang MAKSIMAL 150 karakter. JAWAB HANYA DENGAN TEKS DESKRIPSI UTAMA, tanpa teks pembuka/penutup, tanpa tanda kutip, tanpa penjelasan apapun." }, { role: "user", content: `Judul: ${title}\nKonten: ${summary}` }, ], max_tokens: 200, }); const meta = result?.response?.trim().slice(0, 160) || null; if (meta) { ctx.waitUntil(cache.put(cacheKey, new Response(meta, { headers: { "Cache-Control": "public, max-age=" + CONFIG.AI_META_CACHE_TTL }, })).catch(() => {})); } return meta; } catch (e) { console.warn("[AI Meta Error]", e.message); return null; } } function buildSchema(seo, entry, origin, feed) { const schemas = []; const blogInfo = feed ? { name: feed.feed?.title?.$t || "", url: origin } : { name: "", url: origin }; schemas.push({ "@context": "https://schema.org", "@type": "WebSite", "name": blogInfo.name, "url": origin, "potentialAction": { "@type": "SearchAction", "target": { "@type": "EntryPoint", "urlTemplate": origin + "/search?q={search_term_string}" }, "query-input": "required name=search_term_string", }, }); if (entry && seo.isPost) { schemas.push({ "@context": "https://schema.org", "@type": "BlogPosting", "headline": seo.title, "description": seo.description, "url": seo.canonicalUrl, "datePublished": seo.published, "dateModified": seo.updated || seo.published, "author": { "@type": "Person", "name": seo.author }, "publisher": { "@type": "Organization", "name": blogInfo.name, "url": origin }, ...(seo.thumbnail ? { "image": { "@type": "ImageObject", "url": seo.thumbnail } } : {}), "keywords": seo.labels.join(", "), "inLanguage": "id", "isPartOf": { "@type": "Blog", "url": origin }, }); schemas.push({ "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Beranda", "item": origin }, ...(seo.labels[0] ? [{ "@type": "ListItem", "position": 2, "name": seo.labels[0], "item": origin + "/label/" + encodeURIComponent(seo.labels[0]) }] : []), { "@type": "ListItem", "position": seo.labels[0] ? 3 : 2, "name": seo.title, "item": seo.canonicalUrl }, ], }); } else { schemas.push({ "@context": "https://schema.org", "@type": "Blog", "name": blogInfo.name, "url": origin, "inLanguage": "id" }); } return schemas.map(s => { const jsonString = JSON.stringify(s).replace(/</g, "\\u003c").replace(/>/g, "\\u003e"); return `<script type="application/ld+json">${jsonString}<\/script>`; }).join("\n"); } async function injectSeoWithRewriter(html, seo, schemaJson, slugMap, origin, canonicalUrl) { let imgCount = 0; class SeoInjector { constructor() { this.seoTags = buildSeoTags(); this.preconnect = buildPreconnectTags(); this.finalTags = buildFinalSeoTags(seo, canonicalUrl, schemaJson); } element(element) { if (element.tagName === "head") { element.append(this.seoTags + this.preconnect + "\n" + this.finalTags, { html: true }); } } } class MetaTagRemover { element(element) { const name = element.getAttribute("name"); const property = element.getAttribute("property"); const rel = element.getAttribute("rel"); if (name === "description" || name?.startsWith("twitter:") || property === "og:url" || property === "og:title" || property === "og:description" || property === "og:image" || rel === "canonical") { element.remove(); } } } class UrlRewriter { element(element) { const href = element.getAttribute("href"); if (href) { const newHref = rewriteUrl(href, origin, slugMap); if (newHref !== href) element.setAttribute("href", newHref); } const src = element.getAttribute("src"); if (src) { const newSrc = rewriteUrl(src, origin, slugMap); if (newSrc !== src) element.setAttribute("src", newSrc); } const action = element.getAttribute("action"); if (action) { const newAction = rewriteUrl(action, origin, slugMap); if (newAction !== action) element.setAttribute("action", newAction); } } } class LazyLoadHandler { element(element) { imgCount++; if (imgCount === 1) return; const existing = element.getAttribute("loading"); if (!existing) element.setAttribute("loading", "lazy"); } } class ScriptHandler { element(element) { const src = element.getAttribute("src"); if (src && (src.includes("googletagmanager") || src.includes("adsbygoogle"))) { element.setAttribute("async", "true"); } } } const rewriter = new HTMLRewriter() .on("head", new SeoInjector()) .on("meta", new MetaTagRemover()) .on("link[rel='canonical']", new MetaTagRemover()) .on("a", new UrlRewriter()) .on("img", new LazyLoadHandler()) .on("script", new ScriptHandler()) .on("form", new UrlRewriter()); return await rewriter.transform(new Response(html)).text(); } function rewriteUrl(url, origin, slugMap) { let rewritten = url.replace(/https?:\/\/[a-zA-Z0-9-]+\.blogspot\.com(\.[^/]+)?/g, origin); const nativeMatch = rewritten.match(/(\/\d{4}\/\d{2}\/[^"'/?#]+\.html)/); if (nativeMatch) { const nativePath = nativeMatch[1]; const newPath = slugMap[nativePath]; if (newPath) rewritten = rewritten.replace(nativePath, newPath); } return rewritten; } function buildSeoTags() { return `<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"/>`; } function buildPreconnectTags() { return ` <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin/> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/> <link rel="preconnect" href="https://www.googletagmanager.com" crossorigin/> <link rel="dns-prefetch" href="https://pagead2.googlesyndication.com"/> <link rel="preconnect" href="https://pagead2.googlesyndication.com" crossorigin/>`; } function buildFinalSeoTags(seo, canonicalUrl, schemaJson) { const tags = []; tags.push(`<link rel="canonical" href="${esc(canonicalUrl)}"/>`); if (seo.description) tags.push(`<meta name="description" content="${esc(seo.description)}"/>`); tags.push(`<meta property="og:type" content="${seo.isPost ? "article" : "website"}"/>`); tags.push(`<meta property="og:url" content="${esc(canonicalUrl)}"/>`); if (seo.title) tags.push(`<meta property="og:title" content="${esc(seo.title)}"/>`); if (seo.description) tags.push(`<meta property="og:description" content="${esc(seo.description)}"/>`); if (seo.thumbnail) tags.push(`<meta property="og:image" content="${esc(seo.thumbnail)}"/>`); if (seo.origin) tags.push(`<meta property="og:site_name" content="${esc(seo.origin)}"/>`); tags.push(`<meta name="twitter:card" content="${seo.thumbnail ? "summary_large_image" : "summary"}"/>`); if (seo.title) tags.push(`<meta name="twitter:title" content="${esc(seo.title)}"/>`); if (seo.description) tags.push(`<meta name="twitter:description" content="${esc(seo.description)}"/>`); if (seo.thumbnail) tags.push(`<meta name="twitter:image" content="${esc(seo.thumbnail)}"/>`); if (seo.isPost) { if (seo.published) tags.push(`<meta property="article:published_time" content="${esc(seo.published)}"/>`); if (seo.updated) tags.push(`<meta property="article:modified_time" content="${esc(seo.updated)}"/>`); for (const l of seo.labels) tags.push(`<meta property="article:tag" content="${esc(l)}"/>`); if (seo.author) tags.push(`<meta property="article:author" content="${esc(seo.author)}"/>`); } tags.push(schemaJson); return tags.join("\n"); } async function handleSitemap(origin, ctx) { const cache = caches.default; const cacheKey = new Request(origin + "/sitemap.xml"); const hit = await cache.match(cacheKey); if (hit) return hit; const feed = await fetchFeed(origin); if (!feed) return new Response("<?xml version='1.0'?><urlset/>", { headers: { "Content-Type": "application/xml" } }); const entries = feed.feed?.entry || []; const urls = [{ loc: origin + "/", priority: "1.0", changefreq: "daily" }]; for (const entry of entries.slice(0, CONFIG.POSTS_PER_SITEMAP)) { const title = entry.title?.$t || ""; const slug = titleToSlug(title); const updated = entry.updated?.$t || entry.published?.$t || ""; if (!slug) continue; urls.push({ loc: origin + "/" + encodeSlug(slug), lastmod: updated ? updated.slice(0, 10) : "", priority: "0.8", changefreq: "weekly" }); } const labelSet = new Set(); for (const entry of entries) { for (const c of (entry.category || [])) labelSet.add(c.term); } for (const label of labelSet) { urls.push({ loc: origin + "/label/" + encodeURIComponent(label), priority: "0.6", changefreq: "weekly" }); } const xml = `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${urls.map(u => ` <url> <loc>${esc(u.loc)}</loc> ${u.lastmod ? `<lastmod>${esc(u.lastmod)}</lastmod>` : ""} <changefreq>${esc(u.changefreq)}</changefreq> <priority>${esc(u.priority)}</priority> </url>`).join("\n")} </urlset>`; const response = new Response(xml, { headers: { "Content-Type": "application/xml; charset=UTF-8", "Cache-Control": "public, max-age=3600, stale-while-revalidate=300" }, }); ctx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => {})); return response; } function handleRobots(origin) { const txt = `User-agent: * Allow: / Disallow: /search? Disallow: /__slug__/ Disallow: /__path__/ Disallow: /__feed__ Disallow: /feeds/ Sitemap: ${origin}/sitemap.xml`; return new Response(txt, { headers: { "Content-Type": "text/plain; charset=UTF-8", "Cache-Control": "public, max-age=86400" }, }); } async function fetchFeed(origin) { const cache = caches.default; const key = new Request(origin + "/__feed__"); const hit = await cache.match(key); if (hit) { try { return await hit.json(); } catch {} } const feedUrl = CONFIG.BLOGSPOT_ORIGIN + "/feeds/posts/default?alt=json&max-results=" + CONFIG.FEED_MAX_RESULTS; for (let i = 0; i < 3; i++) { try { const res = await fetch(feedUrl, { headers: { "X-Forwarded-Proto": "https", "Accept-Encoding": "gzip" }, cf: cfOpts(CONFIG.FEED_CACHE_TTL), }); if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); cache.put(key, new Response(JSON.stringify(data), { headers: { "Cache-Control": "public, max-age=" + CONFIG.FEED_CACHE_TTL }, })).catch(() => {}); return data; } catch (e) { if (i === 2) return null; await sleep(300 * (i + 1)); } } return null; } function buildSlugMap(feed, origin) { const map = {}; for (const entry of (feed.feed?.entry || [])) { const path = entryPath(entry); const title = entry.title?.$t || ""; const slug = titleToSlug(title); if (path && slug) map[path] = "/" + encodeSlug(slug); } return map; } async function findEntryBySlug(slug, origin, ctx) { const cache = caches.default; const key = new Request(origin + "/__slug__/" + encodeURIComponent(slug)); const hit = await cache.match(key); if (hit) { try { return JSON.parse(await hit.text()); } catch {} } const feed = await fetchFeed(origin); if (!feed) return null; const targetNorm = normCmp(slug); for (const entry of (feed.feed?.entry || [])) { const s = titleToSlug(entry.title?.$t || ""); if (!s) continue; // PERBAIKAN UTAMA: fuzzyMatch memproses input teks mentah terpisah sebelum normalisasi parsial if (normCmp(s) === targetNorm || fuzzyMatch(s, slug)) { ctx.waitUntil(saveSlugCache(entry, origin, cache)); return entry; } } return null; } async function findEntryByPath(path, origin, ctx) { const feed = await fetchFeed(origin); if (!feed) return null; const entry = (feed.feed?.entry || []).find(e => entryPath(e) === path); if (entry) ctx.waitUntil(saveSlugCache(entry, origin, caches.default)); return entry || null; } async function saveSlugCache(entry, origin, cache) { const slug = titleToSlug(entry.title?.$t || ""); const path = entryPath(entry); if (!slug || !path) return; const ttl = CONFIG.SLUG_CACHE_TTL; const val = JSON.stringify(entry); await Promise.all([ cache.put(new Request(origin + "/__slug__/" + encodeURIComponent(slug)), new Response(val, { headers: { "Cache-Control": "public, max-age=" + ttl } })), cache.put(new Request(origin + "/__path__/" + encodeURIComponent(path)), new Response(val, { headers: { "Cache-Control": "public, max-age=" + ttl } })), ]).catch(() => {}); } function cacheResponse(cache, key, response, ttl, ctx) { const headers = new Headers(response.headers); headers.set("Cache-Control", `public, max-age=${ttl}, stale-while-revalidate=300`); ctx.waitUntil(cache.put(key, new Response(response.body, { status: response.status, headers })).catch(() => {})); } function getAge(response) { const age = response.headers.get("Age"); return age ? parseInt(age) : 0; } async function refreshPageCache(request, env, ctx, cacheKey, cache, url, origin) { try { const slug = extractSlug(url.pathname); const entry = slug ? await findEntryBySlug(slug, origin, ctx) : null; const nativePath = entry ? entryPath(entry) : url.pathname + url.search; const canonicalUrl = entry ? origin + "/" + encodeSlug(titleToSlug(entry.title?.$t || "")) : url.href; const fresh = await buildPage(request, env, ctx, origin, nativePath, canonicalUrl, entry); if (fresh.status === 200) cacheResponse(cache, cacheKey, fresh, CONFIG.PAGE_CACHE_TTL, ctx); } catch {} } async function fetchFromBlogspot(request, blogspotOrigin, targetPath, ctx, depth = 0) { if (depth > 3) return null; const isStatic = isStaticAsset(targetPath.split("?")[0]); const reqHeaders = new Headers(); for (const [k, v] of request.headers) { const l = k.toLowerCase(); if (["host","cf-ray","cf-visitor","cf-connecting-ip","x-forwarded-for","x-real-ip","cdn-loop"].includes(l)) continue; reqHeaders.set(k, v); } reqHeaders.set("X-Forwarded-Proto", "https"); reqHeaders.set("Accept-Encoding", "br, gzip, deflate"); try { return await fetch(new Request(blogspotOrigin + targetPath, { method: request.method || "GET", headers: reqHeaders, body: ["GET","HEAD"].includes(request.method || "GET") ? null : request.body, redirect: "manual", }), { cf: cfOpts(isStatic ? CONFIG.STATIC_CACHE_TTL : CONFIG.PAGE_CACHE_TTL, isStatic) }); } catch (err) { console.error("[fetchFromBlogspot]", err.message); return null; } } async function staticProxy(request, blogspotOrigin, targetPath) { const reqHeaders = new Headers(); for (const [k, v] of request.headers) { const l = k.toLowerCase(); if (["host","cf-ray","cf-visitor"].includes(l)) continue; reqHeaders.set(k, v); } reqHeaders.set("X-Forwarded-Proto", "https"); try { const res = await fetch(new Request(blogspotOrigin + targetPath, { method: "GET", headers: reqHeaders, redirect: "follow" }), { cf: cfOpts(CONFIG.STATIC_CACHE_TTL, true) }); const headers = new Headers(res.headers); headers.set("Cache-Control", `public, max-age=${CONFIG.STATIC_CACHE_TTL}, immutable`); for (const [k, v] of Object.entries(CONFIG.SECURITY_HEADERS)) headers.set(k, v); return new Response(res.body, { status: res.status, headers }); } catch (err) { return new Response("Kesalahan proxy statis", { status: 502 }); } } async function blogspotProxy(request, blogspotOrigin, targetPath, ctx, depth = 0) { if (depth > 3) return new Response("Terlalu banyak redirect", { status: 508 }); const res = await fetchFromBlogspot(request, blogspotOrigin, targetPath, ctx, depth); if (!res) return new Response("Kesalahan origin", { status: 502 }); const currentOrigin = new URL(request.url).origin; if ([301,302,307,308].includes(res.status)) { const loc = res.headers.get("location") || ""; const newPath = resolveRedirect(loc, currentOrigin); if (newPath) return blogspotProxy(request, blogspotOrigin, newPath, ctx, depth + 1); return res; } const headers = buildResponseHeaders(res.headers, request, false); return new Response(res.body, { status: res.status, headers }); } function resolveRedirect(loc, origin) { if (loc.startsWith("/")) return loc; if (loc.startsWith(origin)) return loc.slice(origin.length) || "/"; if (loc.includes("blogspot.com")) { try { const u = new URL(loc); return u.pathname + u.search; } catch {} } return null; } function buildResponseHeaders(originHeaders, request, isHtml) { const headers = new Headers(originHeaders); const host = new URL(request.url).hostname; const cookie = headers.get("set-cookie"); if (cookie) headers.set("set-cookie", cookie.replace(/domain=[^;,]+/gi, "domain=" + host)); for (const [k, v] of Object.entries(CONFIG.SECURITY_HEADERS)) headers.set(k, v); if (isHtml) headers.set("Cache-Control", `public, max-age=${CONFIG.PAGE_CACHE_TTL}, stale-while-revalidate=300`); headers.set("Vary", "Accept-Encoding"); headers.set("X-Worker", "blogspot-seo-v4.2-fixed"); return headers; } function titleToSlug(title) { if (!title) return ""; return title .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .trim() .toLowerCase() .replace(/[\[\](){}「」『』【】《》〈〉<>]/g, " ") .replace(/[!?&@#$%^*+=|\/"'\`~]/g, " ") .replace(/[^\w\s-]/g, "") .trim() .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function encodeSlug(slug) { return slug.split("-").map(part => /^[a-z0-9]+$/.test(part) ? part : encodeURIComponent(part)).join("-"); } function extractSlug(pathname) { const raw = pathname.replace(/^\//, "").split("/")[0]; if (!raw) return null; let decoded; try { decoded = decodeURIComponent(raw); } catch { decoded = raw; } if (/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(decoded) && decoded.length > 2 && !CONFIG.BLACKLIST_PATHS.has(decoded)) { return decoded; } return null; } function entryPath(entry) { const alt = (entry?.link || []).find(l => l.rel === "alternate" && l.type === "text/html"); if (!alt?.href) return null; try { return new URL(alt.href).pathname; } catch { return null; } } function extractPostBody(html) { const patterns = [ /<div[^>]*class=["'][^"']*post-body[^"']*["'][^>]*>([\s\S]*?)<\/div>/i, /<div[^>]*class=["'][^"']*entry-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i, /<article[^>]*>([\s\S]*?)<\/article>/i, ]; for (const p of patterns) { const m = html.match(p); if (m) return m[1]; } return ""; } function extractMeta(html, name) { const patterns = [ new RegExp(`<meta[^>]*name=["']${name}["'][^>]*content=["']([^"']+)["']`, "i"), new RegExp(`<meta[^>]*content=["']([^"']+)["'][^>]*name=["']${name}["']`, "i"), new RegExp(`<meta[^>]*property=["']${name}["'][^>]*content=["']([^"']+)["']`, "i"), new RegExp(`<meta[^>]*content=["']([^"']+)["'][^>]*property=["']${name}["']`, "i"), ]; for (const p of patterns) { const m = html.match(p); if (m) return m[1]; } return ""; } function stripHtml(html) { return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); } // Hanya membersihkan karakter non Alfanumerik mutlak untuk perbandingan dasar function normCmp(s) { return s.toLowerCase().replace(/[^a-z0-9]/g, "").trim(); } // PERBAIKAN LOGIKA FUZZY: Tokenisasi spasi dan tanda hubung berjalan dengan sempurna function fuzzyMatch(a, b) { if (!a || !b) return false; const normA = a.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim(); const normB = b.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim(); if (normA === normB) return true; const ta = normA.split(/[-\s]+/).filter(Boolean); const tb = normB.split(/[-\s]+/).filter(Boolean); if (!ta.length || !tb.length) return false; const setA = new Set(ta); const setB = new Set(tb); const inter = [...setA].filter(t => setB.has(t)).length; const union = new Set([...setA, ...setB]).size; return inter / union >= 0.8; } function isStaticAsset(p) { return /\.(css|js|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|svg|ico|mp4|webm|pdf|xml)$/i.test(p); } function isPassthrough(p) { return /^\/(feeds\/|b\/|favicon\.ico)/.test(p); } function esc(s) { if (!s) return ""; return String(s) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/\\/g, "\") .replace(/\x00/g, "") .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } - Cari bagian
CONFIGdi paling atas kode, lalu ubah baris berikut sesuai dengan alamat Blogspot asli milikmu:
// ⚠️ WAJIB DIISI: Domain asli blogspot Anda (bukan domain custom)
BLOGSPOT_ORIGIN: "https://namablogmu.blogspot.com", // ← GANTI INI DENGAN BLOGSPOTMU!
- Klik tombol Deploy di pojok kanan atas untuk menyimpan perubahan.
Langkah 4: Setup Bindings untuk Fitur AI (Opsional tapi Sangat Direkomendasikan)
Agar fitur pembuatan meta deskripsi otomatis menggunakan AI bisa berjalan, kamu harus memberikan izin akses AI ke Worker kamu.
- Kembali ke halaman utama dashboard Worker kamu (di luar editor kode).
- Pilih tab Settings > Variables.
- Scroll ke bawah sampai kamu menemukan bagian AI Bindings.
- Klik Add Binding, beri nama variabelnya dengan huruf kapital:
AI. - Klik Save atau Deploy.
Langkah 5: Hubungkan Worker ke Domain Custom Kamu
Langkah terakhir adalah mengarahkan trafik dari domain utama kamu agar diproses oleh Worker ini.
- Masuk ke halaman domain kamu di Cloudflare (pilih dari menu utama).
- Klik menu Workers Routes > Add Route.
- Isi kolom Route dengan format:
www.blogkamu.com/*(ganti dengan domain aslimu). Jika kamu menggunakan root domain tanpa www, gunakanblogkamu.com/*. - Pada pilihan Worker, pilih nama worker yang baru saja kamu buat (
blogspot-headless-seo). - Klik Save.
Selamat! Sekarang setiap kali ada orang yang mengakses domain kamu, Cloudflare Workers akan mengambil konten dari Blogspot secara diam-diam, mengubah strukturnya menjadi ramah SEO, dan menyajikannya dalam waktu kurang dari sekejap mata.
Praktik Terbaik Menulis Konten dengan Sistem Headless
Setelah sistem headless aktif, ada beberapa penyesuaian kecil yang perlu kamu lakukan saat menulis konten di dashboard Blogspot agar optimasi SEO berjalan maksimal:
1. Maksimalkan Penulisan Judul Artikel
Karena Worker menggunakan judul artikel untuk membuat URL otomatis (slug), pastikan kamu menulis judul artikel yang mengandung kata kunci utama di awal kalimat. Hindari menggunakan karakter khusus yang aneh pada judul, karena script ini akan otomatis membersihkan karakter tersebut demi keamanan URL.
2. Kelola Label (Tag) dengan Konsisten
Script v4.2 akan mengambil label pertama dari artikelmu untuk dijadikan kategori dalam struktur Breadcrumb Schema. Jika kamu menulis artikel tentang resep makanan dan memberikan label "Kue Tradisional", maka Google akan membaca struktur navigasimu sebagai Beranda > Kue Tradisional > Judul Artikel. Gunakan satu atau dua label saja per artikel agar struktur data tidak membingungkan mesin pencari.
3. Atur Gambar Utama (Featured Image)
Worker akan memindai tag gambar pertama (<img>) di dalam artikel kamu untuk dijadikan konten og:image dan twitter:image. Pastikan gambar pertama di artikel kamu memiliki kualitas yang baik dan rasio yang pas (disarankan 1200x630 piksel) agar saat artikel kamu dibagikan ke media sosial seperti Facebook atau X (Twitter), tampilannya terlihat profesional.
Daftar Referensi
- Dokumentasi Resmi Cloudflare Workers tentang cara kerja Edge Computing dan deployment: Cloudflare Workers Documentation
- Panduan resmi Google tentang optimasi data terstruktur untuk artikel blog: Google Search Central: Article Structured Data
- Model katalog Cloudflare AI untuk instruksi teks menggunakan Llama: Cloudflare Workers AI Models
- Standar optimasi meta tags untuk protokol Open Graph: The Open Graph Protocol Official Site
Komentar