任務清單
時區:Asia/Taipei(+08:00)|日期格式:2006-01-02
難度設定:國中生友善,工程師夜半也能貼了就跑。😎
RSS 就像訂報紙:讀者訂了你的 RSS App(像是 NetNewsWire、Feedly),你一發新文,App 就會「噹噹噹」通知他,新文章自己送到讀者手機上,根本不用他自己跑來你的網站看。 超黏讀者!
Sitemap 是「導航地圖」:Google 搜尋引擎就是個「路痴送貨員」。你給他一張 Sitemap(地圖),告訴他:「我的網站有哪些房間(網址)、哪個房間的貨(文章)最新!」這樣 Google 就不會迷路,能更快把你「蓋章收錄」,讓大家搜得到你。
SEO meta 是門面設計:當你把文章連結貼到臉書或 LINE 群組時,會不會跳出 漂亮的標題、摘要、跟一張大大的封面圖?這就是 meta 做的!門面顧好,大家看了才想點進來,點擊率 UP!
一句話:這三招下去,路人、粉絲、跟 Google 大神都找得到你,而且看起來有質感、夠專業! ✨
一句話:讓人和機器都找得到你,而且看起來有質感。✨
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_* 參數
└─ ...
.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 就是當你文章忘記放封面圖時,會自動跳出來「救場」的預設縮圖。
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
}
這段程式碼主要做了三件事:
結構定義:用 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 }
它也做了類似的事情:
結構定義:定義了 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)
}
我們要在 區塊裡,把所有跟 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)
}
當有人打電話(發出 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"))
}
# RSS(內容是 XML)
curl -s http://localhost:1323/rss.xml | head -n 20
# Sitemap(內容是 XML)
curl -s http://localhost:1323/sitemap.xml | head -n 20
SITE_DEFAULT_IMAGE
,維持社群分享的美觀。/sitemap-index.xml
),本篇先以 MVP 版示範。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
}