长文自动摘要与自动封面:未填时从正文派生——摘要剥语法取开头 110 字(标题行/代码块/图链接剔除),封面取正文第一张图;更新只在「新旧都空」时补,站主主动清空的保得住;保存响应回写编辑器可见
This commit is contained in:
@@ -231,6 +231,7 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
in.Status = store.NormalizeStatus(in.Status)
|
in.Status = store.NormalizeStatus(in.Status)
|
||||||
attachLinkCard(r.Context(), &in, nil)
|
attachLinkCard(r.Context(), &in, nil)
|
||||||
|
autoMeta(&in, nil)
|
||||||
p, err := a.Store.Create(in)
|
p, err := a.Store.Create(in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.ServerError(w, err)
|
httpx.ServerError(w, err)
|
||||||
@@ -270,12 +271,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.Status != "" {
|
if in.Status != "" {
|
||||||
in.Status = store.NormalizeStatus(in.Status)
|
in.Status = store.NormalizeStatus(in.Status)
|
||||||
}
|
}
|
||||||
// 多读一次当前行只为拿到已有卡片:链接没变就不必再打远端
|
// 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动
|
||||||
|
// 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住
|
||||||
var curCard *model.LinkCard
|
var curCard *model.LinkCard
|
||||||
|
var curPost *model.Post
|
||||||
if cur, err := a.Store.Get(id); err == nil {
|
if cur, err := a.Store.Get(id); err == nil {
|
||||||
curCard = cur.LinkCard
|
curCard = cur.LinkCard
|
||||||
|
curPost = &cur
|
||||||
}
|
}
|
||||||
attachLinkCard(r.Context(), &in, curCard)
|
attachLinkCard(r.Context(), &in, curCard)
|
||||||
|
autoMeta(&in, curPost)
|
||||||
p, err := a.Store.Update(id, in)
|
p, err := a.Store.Update(id, in)
|
||||||
writeOne(w, p, err)
|
writeOne(w, p, err)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 长文的自动摘要与自动封面:站主没填这两项时从正文派生——
|
||||||
|
// 摘要取正文开头的一段纯文本,封面取正文第一张图。
|
||||||
|
// 更新路径只在「新值与旧值都为空」时才生成,站主主动清空的要保得住。
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oneblog/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autoSummaryLen = 110
|
||||||
|
|
||||||
|
var (
|
||||||
|
mdImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)[^)]*\)`)
|
||||||
|
mdCodeFence = regexp.MustCompile("(?s)```.*?```")
|
||||||
|
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||||
|
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+.*$`) // 标题行整行不进摘要(与文章标题重复)
|
||||||
|
mdNoiseRe = regexp.MustCompile(`(?m)^([>\s*-]+|\d+\.\s)+`)
|
||||||
|
mdEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "*", "", "_", "")
|
||||||
|
wsRe = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// autoMeta 按需填充 in.Summary / in.CoverURL。
|
||||||
|
// cur 为更新前的旧文章(新建时传 nil)。
|
||||||
|
func autoMeta(in *model.PostInput, cur *model.Post) {
|
||||||
|
if in.Kind != model.KindLong || strings.TrimSpace(in.ContentMd) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Summary) == "" && (cur == nil || strings.TrimSpace(cur.Summary) == "") {
|
||||||
|
in.Summary = summarizeMarkdown(in.ContentMd, autoSummaryLen)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.CoverURL) == "" && (cur == nil || strings.TrimSpace(cur.CoverURL) == "") {
|
||||||
|
in.CoverURL = firstImage(in.ContentMd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeMarkdown 剥掉 markdown 语法后取正文开头一段纯文本
|
||||||
|
func summarizeMarkdown(md string, maxRunes int) string {
|
||||||
|
s := mdCodeFence.ReplaceAllString(md, " ") // 代码块整段不进摘要
|
||||||
|
s = mdHeadingRe.ReplaceAllString(s, " ")
|
||||||
|
s = mdImageRe.ReplaceAllString(s, " ")
|
||||||
|
s = mdLinkRe.ReplaceAllString(s, "$1")
|
||||||
|
s = mdNoiseRe.ReplaceAllString(s, " ")
|
||||||
|
s = mdEmphasis.Replace(s)
|
||||||
|
s = strings.ReplaceAll(s, "`", "")
|
||||||
|
s = wsRe.ReplaceAllString(s, " ")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > maxRunes {
|
||||||
|
return string(runes[:maxRunes]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstImage 取正文第一张图片的地址(markdown 图;http/站内路径均可)
|
||||||
|
func firstImage(md string) string {
|
||||||
|
m := mdImageRe.FindStringSubmatch(md)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u := m[1]
|
||||||
|
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/uploads/") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oneblog/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSummarizeMarkdown(t *testing.T) {
|
||||||
|
md := "## 标题\n\n这是**正文**的第一段,含[链接](https://x.com)与图片:\n\n\n\n```go\ncode ignored\n```\n\n后续内容"
|
||||||
|
got := summarizeMarkdown(md, 110)
|
||||||
|
for _, bad := range []string{"#", "**", "![]", "```", "code ignored"} {
|
||||||
|
if strings.Contains(got, bad) {
|
||||||
|
t.Errorf("summary 含语法残留 %q: %q", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "链接") || !strings.HasPrefix(got, "这是") {
|
||||||
|
t.Errorf("summary 应以正文开头并保留链接文字: %q", got)
|
||||||
|
}
|
||||||
|
long := strings.Repeat("长", 200)
|
||||||
|
if got := summarizeMarkdown(long, 110); len([]rune(got)) != 111 || !strings.HasSuffix(got, "…") {
|
||||||
|
t.Errorf("超长应截到 110+省略号, got %d runes", len([]rune(got)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstImage(t *testing.T) {
|
||||||
|
md := "前言\n\n\n\n"
|
||||||
|
if got := firstImage(md); got != "https://a.com/1.png" {
|
||||||
|
t.Errorf("firstImage = %q", got)
|
||||||
|
}
|
||||||
|
if got := firstImage("没有图片"); got != "" {
|
||||||
|
t.Errorf("无图应返回空, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoMeta(t *testing.T) {
|
||||||
|
in := model.PostInput{Kind: model.KindLong, ContentMd: "# T\n\n正文内容 "}
|
||||||
|
autoMeta(&in, nil)
|
||||||
|
if in.Summary == "" || in.CoverURL != "https://a.com/x.png" {
|
||||||
|
t.Errorf("空字段应自动填充: summary=%q cover=%q", in.Summary, in.CoverURL)
|
||||||
|
}
|
||||||
|
// 站主已填的不能覆盖
|
||||||
|
in2 := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "手写的", CoverURL: ""}
|
||||||
|
autoMeta(&in2, nil)
|
||||||
|
if in2.Summary != "手写的" {
|
||||||
|
t.Errorf("手写摘要不应被覆盖: %q", in2.Summary)
|
||||||
|
}
|
||||||
|
// 更新路径:新值与旧值都空才补——主动清空的保得住
|
||||||
|
cleared := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "", CoverURL: ""}
|
||||||
|
cur := model.Post{Summary: "旧摘要", CoverURL: "旧封面"}
|
||||||
|
autoMeta(&cleared, &cur)
|
||||||
|
if cleared.Summary != "" || cleared.CoverURL != "" {
|
||||||
|
t.Errorf("主动清空不应复活: summary=%q cover=%q", cleared.Summary, cleared.CoverURL)
|
||||||
|
}
|
||||||
|
// 新旧都空 → 生成
|
||||||
|
never := model.PostInput{Kind: model.KindLong, ContentMd: "正文 "}
|
||||||
|
autoMeta(&never, &model.Post{})
|
||||||
|
if never.CoverURL != "/uploads/a.png" {
|
||||||
|
t.Errorf("新旧都空应取首图: %q", never.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,6 +281,9 @@ async function autosave() {
|
|||||||
function applySaved(saved) {
|
function applySaved(saved) {
|
||||||
form.slug = saved.slug
|
form.slug = saved.slug
|
||||||
form.status = saved.status
|
form.status = saved.status
|
||||||
|
// 自动摘要/自动封面在服务端补齐的,回写表单让站主看见(清空仍会保留为空)
|
||||||
|
if (typeof saved.summary === 'string') form.summary = saved.summary
|
||||||
|
if (typeof saved.cover_url === 'string') form.cover_url = saved.cover_url
|
||||||
publishedLocal.value = isoToLocal(saved.published_at)
|
publishedLocal.value = isoToLocal(saved.published_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user