Skip to content

Commit

Permalink
刷新朋友头像
Browse files Browse the repository at this point in the history
  • Loading branch information
movsb committed Jul 31, 2024
1 parent 206deb1 commit 9437fa3
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 49 deletions.
15 changes: 11 additions & 4 deletions service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
commentgeo "github.com/movsb/taoblog/service/modules/comment_geo"
"github.com/movsb/taoblog/service/modules/comment_notify"
"github.com/movsb/taoblog/service/modules/renderers/exif"
"github.com/movsb/taoblog/service/modules/renderers/friends"
"github.com/movsb/taoblog/service/modules/search"
theme_fs "github.com/movsb/taoblog/theme/modules/fs"
"github.com/movsb/taorm"
Expand Down Expand Up @@ -78,8 +79,9 @@ type Service struct {
// 通用缓存
cache *lru.TTLCache[string, any]
// 图片元数据缓存任务。
exifTask *exif.Task
exifDebouncer *utils.BatchDebouncer[int]
exifTask *exif.Task
// 朋友头像数据缓存任务
friendsTask *friends.Task

// 文章内容缓存。
// NOTE:缓存 Key 是跟文章编号和内容选项相关的(因为内容选项不同内容也就不同),
Expand Down Expand Up @@ -207,12 +209,17 @@ func newService(ctx context.Context, cancel context.CancelFunc, cfg *config.Conf

s.cacheAllCommenterData()

s.exifDebouncer = utils.NewBatchDebouncer(time.Second*10, func(id int) {
exifDebouncer := utils.NewBatchDebouncer(time.Second*10, func(id int) {
s.deletePostContentCacheFor(int64(id))
s.updatePostMetadataTime(int64(id), time.Now())
})
s.exifTask = exif.NewTask(s.getPluginStorage(`exif`), func(id int) {
s.exifDebouncer.Enter(id)
exifDebouncer.Enter(id)
})

s.friendsTask = friends.NewTask(s.getPluginStorage(`friends`), func(postID int) {
s.deletePostContentCacheFor(int64(postID))
s.updatePostMetadataTime(int64(postID), time.Now())
})

s.certDaysLeft.Store(-1)
Expand Down
61 changes: 17 additions & 44 deletions service/modules/renderers/friends/friends.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@ package friends

import (
"bytes"
"context"
"embed"
"encoding/base64"
"fmt"
"html/template"
"io"
"log"
"mime"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"

"github.com/PuerkitoBio/goquery"
Expand All @@ -25,12 +20,17 @@ import (
var _root embed.FS

type Friends struct {
task *Task
postID int
}

type Option func(f *Friends)

func New(options ...Option) *Friends {
f := &Friends{}
func New(task *Task, postID int, options ...Option) *Friends {
f := &Friends{
task: task,
postID: postID,
}

for _, opt := range options {
opt(f)
Expand Down Expand Up @@ -74,10 +74,7 @@ func (f *Friends) TransformHtml(doc *goquery.Document) error {
return nil
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
f.prepareIconURL(ctx, bundle.Friends)
<-ctx.Done()
f.prepareIconURL(bundle.Friends)

doc.Find(`div.friends`).Each(func(_ int, s *goquery.Selection) {
for _, f := range bundle.Friends {
Expand All @@ -99,7 +96,6 @@ var svg = `<svg width="80" height="80" xmlns="http://www.w3.org/2000/svg">

// Name 是网站的名字,取首字符的大写作为图标。
func genSvgURL(name string) string {
// 预告填充成 SVG 首字母(因为可能加载失败)。
var first rune
if len(name) > 0 {
first, _ = utf8.DecodeRune([]byte(strings.ToUpper(name)))
Expand Down Expand Up @@ -129,42 +125,19 @@ func resolveIconURL(siteURL, faviconURL string) (string, error) {
return uf.String(), nil
}

func (f *Friends) prepareIconURL(ctx context.Context, fs []*Friend) {
for i, fr := range fs {
fr.iconDataURL = genSvgURL(fr.Name)

func (f *Friends) prepareIconURL(fs []*Friend) {
for _, fr := range fs {
u, err := resolveIconURL(fr.URL, fr.Icon)
if err != nil {
log.Println(err)
continue
}

go func(i int, u string, fr *Friend) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
log.Println(`头像请求失败:`, err)
return
}
rsp, err := http.DefaultClient.Do(req)
if err != nil {
log.Println(`头像请求失败:`, err)
return
}
defer rsp.Body.Close()
if rsp.StatusCode != http.StatusOK {
log.Println(`头像请求失败:`, rsp.Status)
return
}
body, _ := io.ReadAll(io.LimitReader(rsp.Body, 100<<10))
contentType, _, _ := mime.ParseMediaType(rsp.Header.Get(`Content-Type`))
if contentType == "" {
contentType = http.DetectContentType(body)
}
if contentType == "" {
return
}
uri := fmt.Sprintf(`data:%s;base64,%s`, contentType, base64.StdEncoding.EncodeToString(body))
fr.iconDataURL = uri
}(i, u, fr)
contentType, content, found := f.task.Get(f.postID, u)
if !found {
// 预填充成 SVG 首字母(因为可能加载失败)。
fr.iconDataURL = genSvgURL(fr.Name)
} else {
fr.iconDataURL = fmt.Sprintf(`data:%s;base64,%s`, contentType, base64.StdEncoding.EncodeToString(content))
}
}
}
186 changes: 186 additions & 0 deletions service/modules/renderers/friends/task.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package friends

import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"net/http"
"sync"
"time"

"github.com/movsb/taoblog/modules/utils"
"github.com/phuslu/lru"
)

type CacheKey struct {
PostID int
FaviconURL string
}

func (k CacheKey) String() string {
return string(utils.Must1(json.Marshal(k)))
}

func CacheKeyFromString(s string) CacheKey {
var k CacheKey
json.Unmarshal([]byte(s), &k)
return k
}

type CacheValue struct {
ContentType string
Content []byte
}

type Task struct {
cache *lru.TTLCache[CacheKey, CacheValue]
lock sync.Mutex
keys []CacheKey
store utils.PluginStorage
deb *utils.Debouncer
invalidate func(postID int)
}

func NewTask(storage utils.PluginStorage, invalidate func(postID int)) *Task {
t := &Task{
cache: lru.NewTTLCache[CacheKey, CacheValue](1024),
store: storage,
invalidate: invalidate,
}
t.deb = utils.NewDebouncer(time.Second*10, t.save)
t.load()
go t.refreshLoop(context.Background())
return t
}

const ttl = time.Hour * 24 * 7

func (t *Task) load() {
cached, err := t.store.Get(`cache`)
if err != nil {
log.Println(err)
return
}

m := map[string]CacheValue{}
if err := json.Unmarshal([]byte(cached), &m); err != nil {
log.Println(err)
return
}

for k, v := range m {
ck := CacheKeyFromString(k)
t.cache.Set(ck, v, ttl)
t.keys = append(t.keys, ck)
}

log.Println(`已恢复朋友头像数据`)
}

func (t *Task) save() {
t.lock.Lock()
defer t.lock.Unlock()

m := map[string]CacheValue{}
existingKeys := []CacheKey{}
for _, k := range t.keys {
if value, ok := t.cache.Get(k); ok {
m[k.String()] = value
existingKeys = append(existingKeys, k)
}
}

data := string(utils.Must1(json.Marshal(m)))
t.store.Set(`cache`, data)
t.keys = existingKeys

log.Println(`已存储朋友头像数据`)
}

func (t *Task) Get(postID int, faviconURL string) (string, []byte, bool) {
if value, found := t.cache.Get(CacheKey{postID, faviconURL}); found {
return value.ContentType, value.Content, true
}

go t.update(postID, faviconURL)

return ``, nil, false
}

const refreshTTL = time.Hour * 6

func (t *Task) refreshLoop(ctx context.Context) {
refresh := func() {
log.Println(`即将更新朋友头像`)
t.lock.Lock()
defer t.lock.Unlock()

for _, k := range t.keys {
go t.update(k.PostID, k.FaviconURL)
}
}

// refresh()

for {
select {
case <-ctx.Done():
return
case <-time.After(refreshTTL):
refresh()
}
}
}

func (t *Task) update(postID int, faviconURL string) {
contentType, content, err := t.get(faviconURL)
if err != nil {
log.Println(faviconURL, err)
return
}
t.cache.Set(CacheKey{postID, faviconURL}, CacheValue{
ContentType: contentType,
Content: content,
}, ttl)
t.lock.Lock()
t.keys = append(t.keys, CacheKey{postID, faviconURL})
t.lock.Unlock()
t.invalidate(postID)
t.deb.Enter()
log.Println(`已更新朋友头像数据:`, faviconURL)
}

const maxBodySize = 100 << 10

// 返回 [ContentType, Data]
func (t *Task) get(faviconURL string) (string, []byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, nil)
if err != nil {
log.Println(`头像请求失败:`, err)
return ``, nil, err
}
rsp, err := http.DefaultClient.Do(req)
if err != nil {
log.Println(`头像请求失败:`, err)
return ``, nil, err
}
defer rsp.Body.Close()
if rsp.StatusCode != http.StatusOK {
log.Println(`头像请求失败:`, rsp.Status)
return ``, nil, fmt.Errorf(`StatusCode: %d`, rsp.StatusCode)
}
body, _ := io.ReadAll(io.LimitReader(rsp.Body, maxBodySize))
contentType, _, _ := mime.ParseMediaType(rsp.Header.Get(`Content-Type`))
if contentType == "" {
contentType = http.DetectContentType(body)
}
if contentType == "" {
return ``, nil, fmt.Errorf(`无法识别的内容类型`)
}
return contentType, body, nil
}
2 changes: 1 addition & 1 deletion service/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,9 @@ func (s *Service) renderMarkdown(secure bool, postId, commentId int64, sourceTyp
renderers.WithReserveListItemMarkerStyle(),
renderers.WithLazyLoadingFrames(),
renderers.WithImageRenderer(),
friends.New(),
katex.New(),
exif.New(s.OpenAsset(postId), s.exifTask, int(postId)),
friends.New(s.friendsTask, int(postId)),
emojis.New(),
wikitable.New(),
extension.GFM,
Expand Down

0 comments on commit 9437fa3

Please sign in to comment.