iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 27

以 Go + Echo 打造部落格|第 11 集 RSS、Sitemap、SEO Meta:讓搜尋引擎找到你

  • 分享至 

  • xImage
  •  

任務清單

  • 產生 RSS:/rss.xml(讓讀者用閱讀器追新文)
  • 產生 Sitemap:/sitemap.xml(讓搜尋引擎知道有哪些頁)
  • SEO meta:title/description/OG/Twitter Card(內頁/列表)

時區:Asia/Taipei(+08:00)|日期格式2006-01-02
難度設定:國中生友善,工程師夜半也能貼了就跑。😎


  1. 為什麼要做這個?
    想像一下,你的部落格是一間開在網路上的「秘密基地」:

RSS 就像訂報紙:讀者訂了你的 RSS App(像是 NetNewsWire、Feedly),你一發新文,App 就會「噹噹噹」通知他,新文章自己送到讀者手機上,根本不用他自己跑來你的網站看。 超黏讀者!

Sitemap 是「導航地圖」:Google 搜尋引擎就是個「路痴送貨員」。你給他一張 Sitemap(地圖),告訴他:「我的網站有哪些房間(網址)、哪個房間的貨(文章)最新!」這樣 Google 就不會迷路,能更快把你「蓋章收錄」,讓大家搜得到你。

SEO meta 是門面設計:當你把文章連結貼到臉書或 LINE 群組時,會不會跳出 漂亮的標題、摘要、跟一張大大的封面圖?這就是 meta 做的!門面顧好,大家看了才想點進來,點擊率 UP!

一句話:這三招下去,路人、粉絲、跟 Google 大神都找得到你,而且看起來有質感、夠專業! ✨

一句話:讓人和機器都找得到你,而且看起來有質感。✨


  1. 專案變更樹(新增/修改)
    我們要把專案多加一些東西,讓它可以「吐」出 XML 檔案和「塞」入 meta 標籤:
go-echo-blog/
├─ cmd/server/main.go                 # 掛上 /rss.xml 與 /sitemap.xml 路由
├─ internal/http/handlers/
│  ├─ rss.go                          # RSS 產生器(encoding/xml)
│  ├─ sitemap.go                      # Sitemap 產生器
│  └─ front_posts.go                  #(已有)補上模板資料 SEO 欄位
├─ internal/site/
│  └─ site.go                         # 站點設定(BaseURL、SiteName、SiteDesc、DefaultImage)
├─ web/templates/layouts/base.html    # 注入 SEO meta(OG/Twitter)
├─ .env.example                       # 新增 SITE_* 參數
└─ ...

  1. 新增網站的「身份證字號」與「大頭照」
    我們要先在 .env.example 這個設定檔裡,把網站的基本資料寫好:

.env.example

SITE_BASE_URL=https://blog.example.com
SITE_NAME=Go Echo Blog
SITE_DESC=用 Go + Echo 打造的極簡部落格
SITE_DEFAULT_IMAGE=/static/og-default.png

SITE_BASE_URL 請放正式網域(有 http/https),RSS 與 Sitemap 都會用到。
SITE_DEFAULT_IMAGE 用於貼文沒封面時的預設縮圖。

注意! SITE_BASE_URL 一定要寫你的正式網址(要有 http 或 https 喔),不然 RSS 和 Sitemap 裡面的連結會是錯的!

SITE_DEFAULT_IMAGE 就是當你文章忘記放封面圖時,會自動跳出來「救場」的預設縮圖。


  1. 站點設定載入(把身份證字號讀出來)
    internal/site/site.go 就是負責把上面那些環境變數(網站基本資料)讀進程式的地方,很無聊,就是一堆 Go 語言程式碼,功能是把資料「打包」起來。

internal/site/site.go

package site

import (
	"os"
	"strings"
)

type Config struct {
	BaseURL      string
	Name         string
	Description  string
	DefaultImage string
}

func Load() Config {
	return Config{
		BaseURL:      strings.TrimRight(get("SITE_BASE_URL", "http://localhost:1323"), "/"),
		Name:         get("SITE_NAME", "Go Echo Blog"),
		Description:  get("SITE_DESC", "A tiny blog built with Go + Echo"),
		DefaultImage: get("SITE_DEFAULT_IMAGE", "/static/og-default.png"),
	}
}

func get(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

  1. RSS 產生器(生出電子報 XML)
    internal/http/handlers/rss.go 這個檔案就是專門來處理當使用者或 RSS App 敲了 /rss.xml 這個網址時,網站要「吐」出什麼東西。

這段程式碼主要做了三件事:

結構定義:用 Go 語言定義了 rssFeed、rssChannel、rssItem 這些結構,它們會對應到 XML 裡面的標籤。

撈最新文章:從資料庫撈出最近 50 篇「已發佈」的文章。(沒發佈的文章當然不能送電子報啊!)

組裝 XML:把文章的標題、連結、摘要、更新時間等資料,一個一個「塞」進 RSS 的結構裡,最後用 encoding/xml 套件把它變成一堆 XML 文字吐出去。

小眉角:

Link:一定要是完整的網址(靠 h.Site.BaseURL 來補齊)。

internal/http/handlers/rss.go

package handlers

import (
	"encoding/xml"
	"fmt"
	"net/http"
	"time"

	"your/module/internal/site"
	"your/module/internal/storage/postgres" // 你的 Repo 包(假設這裡)
	"github.com/labstack/echo/v4"
)

type RSSHandler struct {
	Site site.Config
	Repo *postgres.PostsRepo // 需具備:ListPublished(limit int) ([]Post, error)
}

func NewRSSHandler(cfg site.Config, repo *postgres.PostsRepo) *RSSHandler {
	return &RSSHandler{Site: cfg, Repo: repo}
}

type rssFeed struct {
	XMLName xml.Name  `xml:"rss"`
	Version string    `xml:"version,attr"`
	Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
	Title         string     `xml:"title"`
	Link          string     `xml:"link"`
	Description   string     `xml:"description"`
	LastBuildDate string     `xml:"lastBuildDate"`
	Items         []rssItem  `xml:"item"`
}
type rssItem struct {
	Title       string `xml:"title"`
	Link        string `xml:"link"`
	GUID        string `xml:"guid"`
	PubDate     string `xml:"pubDate"`
	Description string `xml:"description"`
}

// GET /rss.xml
func (h *RSSHandler) RSS(c echo.Context) error {
	posts, err := h.Repo.ListPublished(50) // 最近 50 篇
	if err != nil {
		return c.String(http.StatusInternalServerError, "failed to load posts")
	}

	items := make([]rssItem, 0, len(posts))
	var last time.Time

	for _, p := range posts {
		// 組出文章連結
		link := fmt.Sprintf("%s/posts/%s", h.Site.BaseURL, p.Slug)

		// 用 UpdatedAt 或 CreatedAt 當 pubDate
		pt := time.Unix(p.UpdatedAt, 0)
		if pt.IsZero() {
			pt = time.Unix(p.CreatedAt, 0)
		}
		if pt.After(last) {
			last = pt
		}

		desc := p.Summary // 你可以在 Repo 中提供「前 160 字」摘要
		if desc == "" {
			desc = p.Title
		}

		items = append(items, rssItem{
			Title:       p.Title,
			Link:        link,
			GUID:        link,
			PubDate:     pt.UTC().Format(time.RFC1123Z),
			Description: desc,
		})
	}

	feed := rssFeed{
		Version: "2.0",
		Channel: rssChannel{
			Title:         h.Site.Name,
			Link:          h.Site.BaseURL,
			Description:   h.Site.Description,
			LastBuildDate: last.UTC().Format(time.RFC1123Z),
			Items:         items,
		},
	}

	c.Response().Header().Set(echo.HeaderContentType, "application/rss+xml; charset=utf-8")
	enc := xml.NewEncoder(c.Response())
	enc.Indent("", "  ")
	return enc.Encode(feed)
}

Repo 需求(示意)

// internal/storage/postgres/posts.go(節錄)
type Post struct {
	ID         int64
	Title      string
	Slug       string
	Summary    string    // 新增或以內容前 160 字生成
	Published  bool
	CoverImage *string
	CreatedAt  int64
	UpdatedAt  int64
}

type PostsRepo struct { /* ... */ }

// 依發佈時間 DESC 取已發佈的 N 篇
func (r *PostsRepo) ListPublished(limit int) ([]Post, error) { /* ... */ return nil, nil }

  1. Sitemap 產生器(生出藏寶圖 XML)
    internal/http/handlers/sitemap.go 這個檔案是給 Google 蜘蛛看的「地圖」。

它也做了類似的事情:

結構定義:定義了 urlSet 和 urlLoc 這些 XML 結構。

撈出所有文章:跟 RSS 不一樣,Sitemap 要撈出所有「已發佈」的文章。

組裝地圖:

先把首頁(靜態頁)加進去,說它是「每天更新 (daily)」,重要性最高 (1.0)。

然後把所有文章的完整連結、最後修改日期加進去,說它們「每週更新 (weekly)」,重要性次高 (0.8)。

專業名詞解釋:

LastMod:最後更新日期,Google 會看這個來決定要不要重新爬你的文章。

ChangeFreq:改變頻率,就是你大概多久會更新一次。

Priority:重要性,首頁通常是 1.0,文章是 0.8 左右。

internal/http/handlers/sitemap.go

package handlers

import (
	"encoding/xml"
	"fmt"
	"net/http"
	"time"

	"your/module/internal/site"
	"your/module/internal/storage/postgres"
	"github.com/labstack/echo/v4"
)

type SitemapHandler struct {
	Site site.Config
	Repo *postgres.PostsRepo // 需具備:ListAllPublished() ([]Post, error)
}

func NewSitemapHandler(cfg site.Config, repo *postgres.PostsRepo) *SitemapHandler {
	return &SitemapHandler{Site: cfg, Repo: repo}
}

// Sitemap XML 結構
type urlSet struct {
	XMLName xml.Name `xml:"urlset"`
	Xmlns   string   `xml:"xmlns,attr"`
	URLs    []urlLoc `xml:"url"`
}
type urlLoc struct {
	Loc        string `xml:"loc"`
	LastMod    string `xml:"lastmod,omitempty"`
	ChangeFreq string `xml:"changefreq,omitempty"`
	Priority   string `xml:"priority,omitempty"`
}

// GET /sitemap.xml
func (h *SitemapHandler) Sitemap(c echo.Context) error {
	posts, err := h.Repo.ListAllPublished()
	if err != nil {
		return c.String(http.StatusInternalServerError, "failed to load posts")
	}

	var urls []urlLoc

	// 靜態頁(首頁等)
	urls = append(urls, urlLoc{
		Loc:        h.Site.BaseURL + "/",
		ChangeFreq: "daily",
		Priority:   "1.0",
	})

	// 文章頁
	for _, p := range posts {
		t := time.Unix(p.UpdatedAt, 0)
		if t.IsZero() {
			t = time.Unix(p.CreatedAt, 0)
		}
		urls = append(urls, urlLoc{
			Loc:        fmt.Sprintf("%s/posts/%s", h.Site.BaseURL, p.Slug),
			LastMod:    t.UTC().Format("2006-01-02"),
			ChangeFreq: "weekly",
			Priority:   "0.8",
		})
	}

	set := urlSet{
		Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
		URLs:  urls,
	}

	c.Response().Header().Set(echo.HeaderContentType, "application/xml; charset=utf-8")
	enc := xml.NewEncoder(c.Response())
	enc.Indent("", "  ")
	return enc.Encode(set)
}

  1. 內頁與首頁加入 SEO meta(貼上超吸睛廣告)
    這部分就是要動網站的「樣板」(web/templates/layouts/base.html)。

我們要在 區塊裡,把所有跟 SEO 有關的「貼紙」貼上去,這些貼紙就是 標籤,它們不會在網頁上顯示,但瀏覽器、Google 搜尋、FB、Line 這些機器會讀!

基本 SEO:(頁面標題)、description(頁面描述)、canonical(告訴 Google 這是「正版」網址)。

Open Graph (OG):這是給 FB、Line、Discord 這些社群網站看的,讓你的分享連結有漂亮的縮圖和標題。

Twitter Card:這是給 X (Twitter) 看的。

接著,在處理文章內頁(front_posts.go)的 Go 程式碼裡,我們就要聰明地組裝這些資料:

Title / Description:用文章自己的標題和摘要。

Canonical:用 BaseURL 加上文章的 slug 湊成完整的文章網址。

OGType:文章頁面要設成 "article"。

OGImage:封面圖!如果有封面圖就用,沒有就用網站的預設大頭照(DefaultImage)。

最後把這些組好的資料「塞」給樣板,網頁渲染出來時, 裡面的 meta 標籤就會充滿正確的資訊了!

web/templates/layouts/base.html(示意版,取代 <head> 內部)

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>{{ .Title }}{{ if .SiteName }} - {{ .SiteName }}{{ end }}</title>

  <!-- 基本 SEO -->
  <meta name="description" content="{{ .Description }}" />
  <link rel="canonical" href="{{ .Canonical }}" />

  <!-- Open Graph -->
  <meta property="og:type" content="{{ or .OGType "website" }}" />
  <meta property="og:title" content="{{ .OGTitle }}" />
  <meta property="og:description" content="{{ .OGDescription }}" />
  <meta property="og:url" content="{{ .Canonical }}" />
  <meta property="og:image" content="{{ .OGImage }}" />

  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="{{ .OGTitle }}" />
  <meta name="twitter:description" content="{{ .OGDescription }}" />
  <meta name="twitter:image" content="{{ .OGImage }}" />

  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 text-gray-900">
  {{ template "content" . }}
</body>
</html>

內頁 handler 塞資料(slug 文章頁)
internal/http/handlers/front_posts.go(節錄)

// 在 render post 時,組 SEO 需要的資料放進模板
func (h *FrontPostsHandler) Show(c echo.Context) error {
	slug := c.Param("slug")
	post, err := h.Repo.GetPublishedBySlug(c.Request().Context(), slug)
	if err != nil {
		return echo.NewHTTPError(http.StatusNotFound, "post not found")
	}

	// 建 canonical / 圖片
	canonical := h.Site.BaseURL + "/posts/" + post.Slug
	ogImage := h.Site.BaseURL + h.Site.DefaultImage
	if post.CoverImage != nil && *post.CoverImage != "" {
		// 若封面是相對路徑(/uploads/...),補上 BaseURL
		if !strings.HasPrefix(*post.CoverImage, "http") {
			ogImage = h.Site.BaseURL + *post.CoverImage
		} else {
			ogImage = *post.CoverImage
		}
	}

	desc := post.Summary
	if desc == "" {
		desc = post.Title
	}

	data := map[string]any{
		"Title":         post.Title,
		"SiteName":      h.Site.Name,
		"Description":   desc,
		"Canonical":     canonical,
		"OGType":        "article",
		"OGTitle":       post.Title,
		"OGDescription": desc,
		"OGImage":       ogImage,
		"post":          post, // 給內容模板使用
	}

	return c.Render(http.StatusOK, "pages/post_show.html", data)
}

首頁列表 SEO(簡化版)

func (h *FrontPostsHandler) Index(c echo.Context) error {
	// ...你的分頁查詢
	data := map[string]any{
		"Title":         h.Site.Name,
		"SiteName":      h.Site.Name,
		"Description":   h.Site.Description,
		"Canonical":     h.Site.BaseURL + "/",
		"OGTitle":       h.Site.Name,
		"OGDescription": h.Site.Description,
		"OGImage":       h.Site.BaseURL + h.Site.DefaultImage,
		"posts":         posts,
		"pagination":    pager,
	}
	return c.Render(http.StatusOK, "pages/index.html", data)
}

  1. 主程式掛路由(開放服務)
    cmd/server/main.go 就像是網站的「總機」,我們要在這裡告訴它:

當有人打電話(發出 GET 請求)給 /rss.xml 時,請把電話轉給 rss.RSS 處理。

當有人打電話給 /sitemap.xml 時,請把電話轉給 sm.Sitemap 處理。

這樣,RSS 和 Sitemap 服務就正式「開張營業」啦!

cmd/server/main.go(新增片段)

package main

import (
	"net/http"
	"your/module/internal/http/handlers"
	"your/module/internal/site"
	"your/module/internal/storage/postgres"

	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()
	// 你原本的 Recover/Logger/CORS/Session 之類的中介層...

	cfg := site.Load()
	postsRepo := buildPostsRepo() // 你既有的初始化

	// RSS
	rss := handlers.NewRSSHandler(cfg, postsRepo)
	e.GET("/rss.xml", rss.RSS)

	// Sitemap
	sm := handlers.NewSitemapHandler(cfg, postsRepo)
	e.GET("/sitemap.xml", sm.Sitemap)

	// 健檢
	e.GET("/health", func(c echo.Context) error { return c.String(http.StatusOK, "ok") })

	e.Logger.Fatal(e.Start(":1323"))
}

  1. 本機動手測(最接地氣的 cURL - 檢查看看有沒有偷跑)
    我們用 cURL 這個工具(工程師最愛用的終極指令)來檢查一下成果:
# RSS(內容是 XML)
curl -s http://localhost:1323/rss.xml | head -n 20

# Sitemap(內容是 XML)
curl -s http://localhost:1323/sitemap.xml | head -n 20

如果你在瀏覽器上打開 http://localhost:1323/rss.xml,看到一堆規律的 XML 文字,恭喜你,你的 RSS 服務成功了!你可以把這個網址丟給朋友的 RSS 閱讀器試試看。🙌

9. 小叮嚀與排雷

  • BaseURL 要用正式網址(含 https),不然 RSS/Sitemap 的連結會是本機路徑。
  • 發佈狀態:RSS/Sitemap 只列 published 的文章,草稿別丟出去。
  • Summary:內頁 description 建議 80–160 字,抓內容 Markdown 前 160 字再去除 HTML。
  • 封面圖:沒封面就用 SITE_DEFAULT_IMAGE,維持社群分享的美觀。
  • 大型站點:文章很多時 Sitemap 可能要切分(/sitemap-index.xml),本篇先以 MVP 版示範。

10. 收工!今天我們把被看見的三要素補齊了

  • RSS:讀者訂閱你
  • Sitemap:搜尋引擎知道你
  • SEO meta:展示更好看

附錄:Summary 生成小工具(可選)

如果你的 Post.Summary 暫時沒有,可以用這個方式在 Repo 層生成(先取 Markdown 前 160 字、粗略去標籤):

func summarize(markdown string) string {
	const n = 160
	// 很粗的做法:移除 Markdown 常見符號
	s := strings.ReplaceAll(markdown, "#", "")
	s = strings.ReplaceAll(s, "*", "")
	s = strings.ReplaceAll(s, "`", "")
	s = strings.TrimSpace(s)
	if len([]rune(s)) > n {
		return string([]rune(s)[:n]) + "..."
	}
	return s
}

上一篇
以 Go + Echo 打造部落格|第 10 集:圖片上傳與封面處理
下一篇
以 Go + Echo 打造部落格|第 12 集 全文搜尋 & Redis 快取
系列文
Golang x Echo 30 天:零基礎GO , 後端入門29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言