与 description——绝大多数站点至少满足其中一个。
+func parse(doc string, base *url.URL) *Card {
+ c := &Card{URL: base.String(), Site: base.Hostname()}
+
+ head := doc
+ if m := headRe.FindStringSubmatch(doc); m != nil {
+ head = m[1]
+ } else if len(head) > 64<<10 {
+ head = head[:64<<10] // 没有闭合 时也别整篇扫
+ }
+
+ // 同一个 key 出现多次时先出现的赢(后面的往往是重复声明)
+ meta := map[string]string{}
+ for _, tag := range metaRe.FindAllString(head, -1) {
+ key, val := metaTag(tag)
+ if key == "" || val == "" {
+ continue
+ }
+ if _, seen := meta[key]; !seen {
+ meta[key] = val
+ }
+ }
+
+ c.Title = first(meta["og:title"], meta["twitter:title"])
+ if c.Title == "" {
+ if m := titleRe.FindStringSubmatch(head); m != nil {
+ c.Title = clean(m[1])
+ }
+ }
+ c.Desc = first(meta["og:description"], meta["twitter:description"], meta["description"])
+ if site := meta["og:site_name"]; site != "" {
+ c.Site = site
+ }
+ if img := first(meta["og:image"], meta["twitter:image"], meta["twitter:image:src"]); img != "" {
+ c.Image = resolve(img, base)
+ }
+ return c
+}
+
+// metaTag 从单个 标签里取 (key, content),属性顺序不限。
+func metaTag(tag string) (string, string) {
+ var key, val string
+ for _, a := range attrRe.FindAllStringSubmatch(tag, -1) {
+ v := first(a[3], a[4], a[5])
+ switch strings.ToLower(a[1]) {
+ case "property", "name", "http-equiv":
+ key = strings.ToLower(v)
+ case "content":
+ val = v
+ }
+ }
+ return key, clean(val)
+}
+
+// clean 去标签、解实体、压空白:抓来的文本可能带内联标签或连续换行。
+func clean(s string) string {
+ s = html.UnescapeString(tagRe.ReplaceAllString(s, " "))
+ return strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
+}
+
+// resolve 把相对的图片地址补成绝对 URL;解析不了就丢掉这个字段。
+func resolve(ref string, base *url.URL) string {
+ ref = strings.TrimSpace(ref)
+ if ref == "" || strings.HasPrefix(ref, "data:") {
+ return ""
+ }
+ u, err := url.Parse(ref)
+ if err != nil {
+ return ""
+ }
+ return base.ResolveReference(u).String()
+}
+
+func first(vals ...string) string {
+ for _, v := range vals {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go
index 1e7726b..2437bbf 100644
--- a/backend/internal/model/model.go
+++ b/backend/internal/model/model.go
@@ -32,6 +32,23 @@ type Post struct {
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
Images []string `json:"images"`
+ // LinkCard 是正文里第一个外链的预览卡片(发布时抓一次,存 posts.link_card)。
+ // nil = 没有链接或抓取失败,前台不渲染卡片。
+ LinkCard *LinkCard `json:"link_card,omitempty"`
+}
+
+// LinkCard 是链接预览卡片:由 internal/linkmeta 抓取,以 JSON 存在 posts 表里。
+type LinkCard struct {
+ URL string `json:"url"`
+ Title string `json:"title,omitempty"`
+ Desc string `json:"desc,omitempty"`
+ Site string `json:"site,omitempty"`
+ Image string `json:"image,omitempty"`
+}
+
+// Empty 报告卡片有没有可展示的字段(只有 URL 不算)。
+func (c *LinkCard) Empty() bool {
+ return c == nil || (c.Title == "" && c.Desc == "" && c.Image == "")
}
type PostInput struct {
@@ -47,6 +64,8 @@ type PostInput struct {
ReadingMinutes *int `json:"reading_minutes"`
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
Images []string `json:"images"`
+ // LinkCard 由服务端在保存时按正文内容重算(站主不必发这个字段)
+ LinkCard *LinkCard `json:"link_card,omitempty"`
}
type Tag struct {
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index d893003..575cce5 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -135,6 +135,8 @@ func (s *Store) migrate() error {
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
// 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
+ // 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串
+ `ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
}
for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
@@ -477,14 +479,14 @@ func sanitizeOrder(o string) string {
}
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
- published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images`
+ published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// listCols 用于列表/时间线:不传 content_md(前端不用),
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
- status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images`
+ status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
func decodeImages(s string) []string {
@@ -509,14 +511,39 @@ func encodeImages(imgs []string) string {
return string(b)
}
+// link_card 列的 JSON 编解码:空串/坏数据一律当作「没有卡片」。
+func decodeLinkCard(s string) *model.LinkCard {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil
+ }
+ var c model.LinkCard
+ if err := json.Unmarshal([]byte(s), &c); err != nil || c.Empty() {
+ return nil
+ }
+ return &c
+}
+
+func encodeLinkCard(c *model.LinkCard) string {
+ if c.Empty() {
+ return ""
+ }
+ b, err := json.Marshal(c)
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post
- var imgs string
+ var imgs, card string
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
- &p.ReadingMinutes, &p.ContentLen, &imgs)
+ &p.ReadingMinutes, &p.ContentLen, &imgs, &card)
p.Tags = []string{}
p.Images = decodeImages(imgs)
+ p.LinkCard = decodeLinkCard(card)
return p, err
}
@@ -689,10 +716,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
}
func scanPostInto(row *sql.Row, p *model.Post) error {
- var imgs string
+ var imgs, card string
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
- &p.ReadingMinutes, &p.ContentLen, &imgs)
+ &p.ReadingMinutes, &p.ContentLen, &imgs, &card)
if err == sql.ErrNoRows {
return ErrNotFound
}
@@ -701,6 +728,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
}
p.Tags = []string{}
p.Images = decodeImages(imgs)
+ p.LinkCard = decodeLinkCard(card)
return nil
}
@@ -945,17 +973,19 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
- published_at,created_at,updated_at,reading_minutes,images)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
+ published_at,created_at,updated_at,reading_minutes,images,link_card)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
- p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)).Scan(&id)
+ p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
+ encodeLinkCard(in.LinkCard)).Scan(&id)
if err != nil {
return p, err
}
} else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
- p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images))
+ p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
+ encodeLinkCard(in.LinkCard))
if err != nil {
return p, err
}
@@ -1007,6 +1037,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
if in.Images != nil {
p.Images = in.Images
}
+ // LinkCard 由 admin 层按正文重算后传入;空卡片(含只有 URL)= 清空
+ if in.LinkCard != nil {
+ p.LinkCard = in.LinkCard
+ }
p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
@@ -1015,9 +1049,9 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
}
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
- content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=? WHERE id=?`),
+ content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=?,link_card=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
- p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil {
+ p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), encodeLinkCard(p.LinkCard), id); err != nil {
return p, err
}
if in.Tags != nil {
diff --git a/frontend/src/components/LinkCard.vue b/frontend/src/components/LinkCard.vue
new file mode 100644
index 0000000..2180a25
--- /dev/null
+++ b/frontend/src/components/LinkCard.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+ {{ card.site }}
+ {{ card.title }}
+ {{ card.desc }}
+
+
![]()
+
+
+
+
diff --git a/frontend/src/components/PostCard.vue b/frontend/src/components/PostCard.vue
index 29eb2be..bb9cc18 100644
--- a/frontend/src/components/PostCard.vue
+++ b/frontend/src/components/PostCard.vue
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
+import LinkCard from './LinkCard.vue'
const router = useRouter()
@@ -83,6 +84,9 @@ const gridW = computed(() => (props.post.images && props.post.images.length ===
/>
+
+
+
diff --git a/frontend/src/ui/yohaku/YohakuArticle.vue b/frontend/src/ui/yohaku/YohakuArticle.vue
index ffb13f5..6dca2ba 100644
--- a/frontend/src/ui/yohaku/YohakuArticle.vue
+++ b/frontend/src/ui/yohaku/YohakuArticle.vue
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue'
+import LinkCard from '../../components/LinkCard.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
@@ -186,6 +187,9 @@ function onTocClick(e) {
/>
+
+
+
{{ t }}
diff --git a/frontend/src/ui/yohaku/YohakuFeed.vue b/frontend/src/ui/yohaku/YohakuFeed.vue
index 4b07eb0..8ea5aa4 100644
--- a/frontend/src/ui/yohaku/YohakuFeed.vue
+++ b/frontend/src/ui/yohaku/YohakuFeed.vue
@@ -2,6 +2,7 @@
import { RouterLink } from 'vue-router'
import { openLightbox } from '../../lightbox'
import { thumbURL, thumbifyHtml } from '../../utils'
+import LinkCard from '../../components/LinkCard.vue'
// 余白列表。对应设计稿的 :长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
@@ -72,6 +73,9 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
@click.prevent.stop="openLightbox(p.images, i)"
/>
+
+
+
SHORT
diff --git a/frontend/src/views/PostView.vue b/frontend/src/views/PostView.vue
index f242ad9..3d47ff0 100644
--- a/frontend/src/views/PostView.vue
+++ b/frontend/src/views/PostView.vue
@@ -5,6 +5,7 @@ import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue'
+import LinkCard from '../components/LinkCard.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api'
import { site, applyDocTitle } from '../site'
@@ -124,6 +125,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
/>
+
+
+