feat: add carousel support and ribenyan crawler
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"sale/internal/crawler"
|
||||||
"sale/internal/models"
|
"sale/internal/models"
|
||||||
"sale/internal/schemas"
|
"sale/internal/schemas"
|
||||||
"sale/internal/utils"
|
"sale/internal/utils"
|
||||||
@@ -162,3 +163,15 @@ func (h *ArticleHandler) TogglePin(c *gin.Context) {
|
|||||||
utils.DB.Model(&article).Update("is_pinned", !article.IsPinned)
|
utils.DB.Model(&article).Update("is_pinned", !article.IsPinned)
|
||||||
c.JSON(http.StatusOK, gin.H{"data": article})
|
c.JSON(http.StatusOK, gin.H{"data": article})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CrawlRibenyan 采集 ribenyan.com 文章
|
||||||
|
func (h *ArticleHandler) CrawlRibenyan(c *gin.Context) {
|
||||||
|
crawler := crawler.NewRibenyanCrawler(utils.DB)
|
||||||
|
|
||||||
|
if err := crawler.CrawlArticles(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Crawl completed successfully"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
{
|
{
|
||||||
articles.GET("", articleHandler.List)
|
articles.GET("", articleHandler.List)
|
||||||
articles.GET("/:id", articleHandler.GetByID)
|
articles.GET("/:id", articleHandler.GetByID)
|
||||||
|
// 采集 API(管理员权限)
|
||||||
|
articles.POST("/crawl-ribenyan", articleHandler.CrawlRibenyan)
|
||||||
}
|
}
|
||||||
|
|
||||||
api.GET("/categories", categoryHandler.List)
|
api.GET("/categories", categoryHandler.List)
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package crawler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sale/internal/models"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RibenyanCrawler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRibenyanCrawler(db *gorm.DB) *RibenyanCrawler {
|
||||||
|
return &RibenyanCrawler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RibenyanCrawler) CrawlArticles() error {
|
||||||
|
log.Println("开始采集 ribenyan.com 文章...")
|
||||||
|
|
||||||
|
// 访问首页获取文章列表
|
||||||
|
resp, err := http.Get("https://ribenyan.com")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("请求失败:%v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("响应状态码:%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析 HTML 失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找文章列表(根据实际网站结构调整选择器)
|
||||||
|
articles := make([]models.Article, 0)
|
||||||
|
|
||||||
|
doc.Find("article, .post, .article, .news-item").Each(func(i int, s *goquery.Selection) {
|
||||||
|
if i >= 20 { // 最多采集 20 篇
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标题
|
||||||
|
title := ""
|
||||||
|
s.Find("h1, h2, h3, .post-title, .article-title").First().Each(func(j int, ts *goquery.Selection) {
|
||||||
|
title = strings.TrimSpace(ts.Text())
|
||||||
|
})
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取链接
|
||||||
|
link := ""
|
||||||
|
s.Find("a").First().Each(func(j int, ls *goquery.Selection) {
|
||||||
|
href, exists := ls.Attr("href")
|
||||||
|
if exists && !strings.HasPrefix(href, "http") {
|
||||||
|
link = "https://ribenyan.com" + href
|
||||||
|
} else if exists {
|
||||||
|
link = href
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取封面图
|
||||||
|
coverImage := ""
|
||||||
|
s.Find("img, .post-thumbnail, .article-cover").First().Each(func(j int, imgs *goquery.Selection) {
|
||||||
|
src, exists := imgs.Attr("src")
|
||||||
|
if exists {
|
||||||
|
if strings.HasPrefix(src, "http") {
|
||||||
|
coverImage = src
|
||||||
|
} else {
|
||||||
|
coverImage = "https://ribenyan.com" + src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取摘要
|
||||||
|
summary := ""
|
||||||
|
s.Find(".post-excerpt, .article-excerpt, .summary, p").First().Each(func(j int, ps *goquery.Selection) {
|
||||||
|
summary = strings.TrimSpace(ps.Text())
|
||||||
|
if len(summary) > 500 {
|
||||||
|
summary = summary[:500] + "..."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取轮播图(如果有)
|
||||||
|
carousel := ""
|
||||||
|
s.Find(".carousel, .slider, .gallery").First().Each(func(j int, cs *goquery.Selection) {
|
||||||
|
// 收集轮播图中的图片
|
||||||
|
var images []string
|
||||||
|
cs.Find("img").Each(func(k int, imgs *goquery.Selection) {
|
||||||
|
src, exists := imgs.Attr("src")
|
||||||
|
if exists {
|
||||||
|
if strings.HasPrefix(src, "http") {
|
||||||
|
images = append(images, src)
|
||||||
|
} else {
|
||||||
|
images = append(images, "https://ribenyan.com"+src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if len(images) > 0 {
|
||||||
|
carousel = strings.Join(images, ",")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
article := models.Article{
|
||||||
|
Title: title,
|
||||||
|
Summary: summary,
|
||||||
|
CoverImage: coverImage,
|
||||||
|
Carousel: carousel,
|
||||||
|
IsPublished: true,
|
||||||
|
IsPinned: i == 0, // 第一篇置顶
|
||||||
|
SortOrder: i,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
articles = append(articles, article)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量插入数据库
|
||||||
|
if len(articles) > 0 {
|
||||||
|
log.Printf("准备插入 %d 篇文章", len(articles))
|
||||||
|
|
||||||
|
// 检查是否已存在,避免重复
|
||||||
|
var existingIDs []uint
|
||||||
|
c.db.Model(&models.Article{}).Where("title IN ?", getTitles(articles)).Pluck("id", &existingIDs)
|
||||||
|
|
||||||
|
// 过滤掉已存在的文章
|
||||||
|
newArticles := make([]models.Article, 0)
|
||||||
|
for _, article := range articles {
|
||||||
|
exists := false
|
||||||
|
for _, id := range existingIDs {
|
||||||
|
if article.ID == id {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
newArticles = append(newArticles, article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newArticles) > 0 {
|
||||||
|
if err := c.db.Create(&newArticles).Error; err != nil {
|
||||||
|
return fmt.Errorf("插入失败:%v", err)
|
||||||
|
}
|
||||||
|
log.Printf("成功插入 %d 篇文章", len(newArticles))
|
||||||
|
} else {
|
||||||
|
log.Println("没有新文章需要插入")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Println("未找到文章")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTitles(articles []models.Article) []string {
|
||||||
|
titles := make([]string, len(articles))
|
||||||
|
for i, article := range articles {
|
||||||
|
titles[i] = article.Title
|
||||||
|
}
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个文章详情采集(可选)
|
||||||
|
func (c *RibenyanCrawler) CrawlArticleDetail(url string) (*models.Article, error) {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求失败:%v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 HTML 失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
article := &models.Article{
|
||||||
|
IsPublished: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标题
|
||||||
|
doc.Find("h1, .post-title, .article-title").First().Each(func(i int, s *goquery.Selection) {
|
||||||
|
article.Title = strings.TrimSpace(s.Text())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取内容
|
||||||
|
var contentBuilder strings.Builder
|
||||||
|
doc.Find("article, .post-content, .article-content, .entry-content").First().Each(func(i int, s *goquery.Selection) {
|
||||||
|
contentBuilder.WriteString(s.Html())
|
||||||
|
})
|
||||||
|
article.Content = contentBuilder.String()
|
||||||
|
|
||||||
|
// 获取封面图
|
||||||
|
doc.Find("img, .post-thumbnail").First().Each(func(i int, s *goquery.Selection) {
|
||||||
|
src, _ := s.Attr("src")
|
||||||
|
if strings.HasPrefix(src, "http") {
|
||||||
|
article.CoverImage = src
|
||||||
|
} else {
|
||||||
|
article.CoverImage = "https://ribenyan.com" + src
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取摘要
|
||||||
|
doc.Find(".post-excerpt, .article-excerpt, .summary").First().Each(func(i int, s *goquery.Selection) {
|
||||||
|
article.Summary = strings.TrimSpace(s.Text())
|
||||||
|
if len(article.Summary) > 500 {
|
||||||
|
article.Summary = article.Summary[:500] + "..."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return article, nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type Article struct {
|
|||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
Summary string `gorm:"size:500" json:"summary"`
|
Summary string `gorm:"size:500" json:"summary"`
|
||||||
CoverImage string `gorm:"size:500" json:"cover_image"`
|
CoverImage string `gorm:"size:500" json:"cover_image"`
|
||||||
|
Carousel string `gorm:"size:500" json:"carousel"`
|
||||||
IsPinned bool `gorm:"default:false" json:"is_pinned"`
|
IsPinned bool `gorm:"default:false" json:"is_pinned"`
|
||||||
IsPublished bool `gorm:"default:true" json:"is_published"`
|
IsPublished bool `gorm:"default:true" json:"is_published"`
|
||||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
|
|||||||
Reference in New Issue
Block a user