4 Commits
Author SHA1 Message Date
gouki 241a04496b fix(term): 清理百度百科摘要的截断结尾
Build and Publish Server / build (push) Successful in 2m6s
Publish Mini Program Dev Version / publish (push) Successful in 3m3s
百度百科 BaikeLemmaCardApi 摘要以 ... 硬截断,出现半截话。
新增 trimTruncatedTail:去掉结尾省略号后,若末句不完整则
回退到最后一个完整句标点(。!?),避免「唐宋时期,冬至...」
这类戛然而止的结尾。仅依赖百度百科,不引入维基,线上可用。
2026-08-18 00:09:12 +00:00
gouki bff792a974 fix(home): 首页排版细节优化
- 宜忌弹窗字体 44→32rpx、收紧间距,20+ 条可完整显示
- 历史上的今天内页年份加粗、文字稍小,修正层级
- 节日速查外页3条/内页10条,佛历开启时并入排序
- 统一「更多」箭头为 >>
2026-08-18 00:08:54 +00:00
gouki 5a5388234a feat(mini): 节气详情排版优化 + 本地开发连线上数据
Publish Mini Program Dev Version / publish (push) Successful in 1m2s
- wiki 页节气详情排版:配图圆角阴影、涵义引言(米黄渐变+红竖条)、
  简介两端对齐、结构化字段列表(红点标签)分块展示
- wiki.js 提取 meaning 单独展示,extraList 排除避免重复
- request.js develop 环境指向线上(本地 8080 被占用且 localhost 受域名校验限制)
- project.config.json 关闭 urlCheck 便于本地开发连线上调试
2026-08-13 23:37:12 +00:00
gouki fec383f257 feat(wiki): 二十四节气详情抓取百度百科并图文展示
Publish Mini Program Dev Version / publish (push) Successful in 1m12s
Build and Publish Server / build (push) Successful in 8m24s
数据源:百度百科开放接口 BaikeLemmaCardApi(无反爬,返回结构化 JSON)
与「历史上的今天」(维基)完全解耦,维基代码原样保留可随时恢复:

服务端:
- WikiEntry 增加 image(配图)和 extra(结构化字段 JSON)字段
- 新建 BaikeTermSyncLog 独立日志表(不污染维基 WikiSyncLog)
- 新建 BaikeTermSyncService 独立同步服务(24 节气,间隔 1.2s)
- 启动时缺详情自动补齐,之后按配置间隔定时增量刷新
- 管理 API:GET/POST /admin/api/wiki/term-sync*

管理后台:
- 百科条目 Tab 顶部加节气同步卡片(X/24 + 状态 + 同步按钮)
- 条目编辑器加配图 URL(含预览)和节气扩展字段编辑

小程序:
- 百科页展开详情图文展示:配图 + 简介 + 物候/习俗/养生等分块

本地验证:24/24 节气抓取成功,image/extra 全部填充,维基数据不受影响
2026-08-13 00:31:43 +00:00
18 changed files with 941 additions and 27 deletions
+5 -2
View File
@@ -47,6 +47,7 @@ Page({
zoomTitle: '',
zoomType: '',
zoomData: null,
zoomScrollTop: 0,
// 设置
showBuddhist: false,
highlightLunar: true
@@ -97,7 +98,8 @@ Page({
const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : '';
const isFirstOrFifteen = this.data.highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15);
const nextBuddhist = this.data.showBuddhist ? getNextBuddhistFestival(y, m, d) : null;
const upcoming = getUpcomingFestivals(10, y, m, d);
// 外页只显示最近3条(佛历开启时并入排序);内页弹窗见 openZoom 取10条
const upcoming = getUpcomingFestivals(3, y, m, d, this.data.showBuddhist);
return {
key: fmt(y, m, d),
dayInfo, almanac, legalHoliday, workday, termTime,
@@ -429,7 +431,8 @@ Page({
};
} else if (type === 'fest') {
zoomTitle = '近期节日';
zoomData = { upcoming: dayItem.upcoming };
// 内页显示最近10条(佛历开启时并入排序)
zoomData = { upcoming: getUpcomingFestivals(10, dayInfo.solarYear, dayInfo.solarMonth, dayInfo.solarDay, this.data.showBuddhist) };
} else if (type === 'fest-year') {
zoomTitle = `${dayInfo.solarYear}年节日`;
zoomData = { yearFestivals: getYearFestivals(dayInfo.solarYear) };
+7 -2
View File
@@ -156,7 +156,7 @@
</view>
<view class="otd-foot">
<text class="otd-source">来源:中文维基百科</text>
<text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 </text>
<text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 >></text>
</view>
</view>
<view class="card placeholder-card" wx:elif="{{dayItem.otdLoading}}">
@@ -236,6 +236,7 @@
<text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="zoom-end-pad" />
</scroll-view>
<!-- 详情放大 -->
@@ -280,6 +281,7 @@
<text class="zoom-detail-label">彭祖百忌</text>
<text class="zoom-detail-value">{{zoomData.pengZu}}</text>
</view>
<view class="zoom-end-pad" />
</scroll-view>
<!-- 节日放大 -->
@@ -289,15 +291,17 @@
<text class="zoom-fest-date">{{item.solarDate}}</text>
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
</view>
<view class="zoom-end-pad" />
</scroll-view>
<!-- 今年节日(字体稍小,避免溢出) -->
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'fest-year' && zoomData}}">
<scroll-view scroll-y class="zoom-body" scroll-top="{{zoomScrollTop}}" wx:if="{{zoomType === 'fest-year' && zoomData}}">
<view class="zoom-fest-year-row" wx:for="{{zoomData.yearFestivals}}" wx:key="name" bindtap="goFestival" data-date="{{item.solarDate}}">
<text class="zoom-fest-year-name">{{item.name}}</text>
<text class="zoom-fest-year-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
<text class="zoom-fest-year-type {{item.type === 'lunar' ? 'lunar' : 'solar'}}">{{item.type === 'lunar' ? '农历' : '公历'}}</text>
</view>
<view class="zoom-end-pad" />
</scroll-view>
<!-- 历史上的今天放大 -->
@@ -330,6 +334,7 @@
</view>
</block>
<view class="zoom-otd-source">数据来源:中文维基百科(CC BY-SA 4.0</view>
<view class="zoom-end-pad" />
</scroll-view>
</view>
</view>
+27 -11
View File
@@ -434,19 +434,23 @@
.zoom-fest-year-name {
font-size: 32rpx;
font-weight: 600;
min-width: 140rpx;
width: 160rpx;
flex-shrink: 0;
}
.zoom-fest-year-date {
font-size: 28rpx;
color: #9E8E7E;
flex: 1;
min-width: 0;
}
.zoom-fest-year-type {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 999rpx;
flex-shrink: 0;
white-space: nowrap;
}
.zoom-fest-year-type.lunar {
@@ -668,10 +672,18 @@
}
.zoom-body {
width: 100%;
box-sizing: border-box;
padding: 32rpx 28rpx;
max-height: 70vh;
}
/* scroll-view 底部占位:padding-bottom 不计入滚动高度,用真实元素保证最后一项完整显示 */
.zoom-end-pad {
height: 32rpx;
}
/* 宜忌放大 */
.zoom-yiji-block {
margin-bottom: 40rpx;
@@ -695,13 +707,13 @@
.zoom-yiji-items {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
gap: 14rpx;
}
.zoom-yiji-item {
font-size: 44rpx;
padding: 16rpx 32rpx;
border-radius: 16rpx;
font-size: 32rpx;
padding: 12rpx 24rpx;
border-radius: 12rpx;
font-weight: 500;
}
@@ -744,21 +756,24 @@
}
.zoom-fest-name {
font-size: 44rpx;
font-size: 40rpx;
font-weight: 600;
min-width: 200rpx;
flex-shrink: 0;
}
.zoom-fest-date {
font-size: 36rpx;
font-size: 32rpx;
color: #9E8E7E;
flex: 1;
min-width: 0;
}
.zoom-fest-left {
font-size: 36rpx;
font-size: 32rpx;
color: #C41E3A;
font-weight: 500;
flex-shrink: 0;
white-space: nowrap;
}
.zoom-fest-left.is-today {
@@ -874,12 +889,13 @@
}
.zoom-otd-item .otd-year {
font-size: 30rpx;
font-size: 28rpx;
font-weight: 600;
min-width: 120rpx;
}
.zoom-otd-item .otd-text {
font-size: 32rpx;
font-size: 28rpx;
-webkit-line-clamp: unset;
overflow: visible;
}
+41
View File
@@ -41,6 +41,44 @@ const TERM_EXPLAINS = [
{ title: '胎神方位', brief: '孕妇需避开的方位', content: '胎神是传统民俗中保护胎儿的神灵,每日所在方位不同。孕妇不宜在胎神方位动土、钉钉或搬动重物,以免惊动胎神。' }
];
// 节气扩展字段的展示顺序与中文标签
const EXTRA_LABELS = [
{ key: 'alias', name: '别名' },
{ key: 'english', name: '外文名' },
{ key: 'meaning', name: '涵义' },
{ key: 'solarDate', name: '公历时间' },
{ key: 'ecliptic', name: '黄道位置' },
{ key: 'climate', name: '气候特点' },
{ key: 'phenology', name: '物候现象' },
{ key: 'farming', name: '农事活动' },
{ key: 'customs', name: '传统习俗' },
{ key: 'health', name: '起居养生' },
{ key: 'flower', name: '花信' }
];
// buildExtraList 把服务端 extra JSON 字符串解析为 [{name, value}] 展示列表(仅保留非空项,按定义顺序;meaning 单独展示故排除)
function buildExtraList(extraStr) {
if (!extraStr) return [];
let obj = {};
try { obj = JSON.parse(extraStr); } catch (e) { return []; }
const list = [];
EXTRA_LABELS.forEach(f => {
if (f.key === 'meaning') return; // 涵义在引言区单独突出显示
const v = (obj[f.key] || '').trim();
if (v) list.push({ name: f.name, value: v });
});
return list;
}
// extractMeaning 从 extra JSON 中提取涵义(用于引言区突出显示)
function extractMeaning(extraStr) {
if (!extraStr) return '';
try {
const obj = JSON.parse(extraStr);
return (obj.meaning || '').trim();
} catch (e) { return ''; }
}
function buildEntries() {
const entries = [];
@@ -139,6 +177,9 @@ Page({
title: s.title,
brief: s.brief,
content: s.content,
image: s.image || '',
meaning: extractMeaning(s.extra),
extraList: buildExtraList(s.extra),
open: false
};
if (titleIndex[s.title] !== undefined) {
+22 -2
View File
@@ -25,8 +25,28 @@
<text class="badge badge-outline">{{item.catName}}</text>
</view>
<view class="text-sm text-muted mt-1" wx:if="{{!item.open}}">{{item.brief}}</view>
<view class="entry-detail mt-2" wx:if="{{item.open}}">
<text class="text-base">{{item.content}}</text>
<view class="entry-detail" wx:if="{{item.open}}">
<!-- 配图(仅服务端条目有图时显示) -->
<image wx:if="{{item.image}}" class="entry-image" src="{{item.image}}" mode="widthFix" lazy-load/>
<!-- 涵义引言(节气有 meaning 时突出显示) -->
<view class="entry-meaning" wx:if="{{item.meaning}}">
<text class="meaning-text">{{item.meaning}}</text>
</view>
<!-- 详细简介 -->
<text class="text-base entry-content">{{item.content}}</text>
<!-- 结构化扩展字段(节气:物候/习俗/养生等) -->
<view class="extra-list" wx:if="{{item.extraList && item.extraList.length > 0}}">
<view class="extra-item" wx:for="{{item.extraList}}" wx:key="name" wx:for-item="field">
<view class="extra-name">
<text class="extra-dot"></text>
<text>{{field.name}}</text>
</view>
<text class="extra-value">{{field.value}}</text>
</view>
</view>
</view>
</view>
+86 -1
View File
@@ -47,7 +47,92 @@
.entry-detail {
border-top: 1rpx solid #F0E6DA;
padding-top: 16rpx;
margin-top: 16rpx;
padding-top: 20rpx;
}
/* 节气配图 */
.entry-image {
width: 100%;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: #F5F0EA;
box-shadow: 0 4rpx 16rpx rgba(74, 64, 54, 0.08);
}
/* 涵义引言(突出显示) */
.entry-meaning {
position: relative;
background: linear-gradient(135deg, #FBF3EC 0%, #F7ECE2 100%);
border-left: 6rpx solid #C41E3A;
border-radius: 8rpx;
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
}
.meaning-text {
font-size: 28rpx;
color: #8C6D4F;
line-height: 1.6;
font-weight: 500;
letter-spacing: 1rpx;
}
/* 详细简介 */
.entry-content {
display: block;
color: #4A4036;
font-size: 28rpx;
line-height: 1.9;
text-align: justify;
letter-spacing: 0.5rpx;
}
/* 结构化扩展字段列表 */
.extra-list {
margin-top: 24rpx;
background: #FBF7F1;
border-radius: 16rpx;
padding: 8rpx 24rpx;
}
.extra-item {
display: flex;
align-items: flex-start;
padding: 16rpx 0;
border-bottom: 1rpx solid #F0E6DA;
}
.extra-item:last-child {
border-bottom: none;
}
.extra-name {
flex-shrink: 0;
width: 150rpx;
display: flex;
align-items: center;
font-size: 26rpx;
color: #A08B76;
font-weight: 500;
padding-top: 2rpx;
}
/* 标签前的小圆点装饰 */
.extra-dot {
display: inline-block;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #C41E3A;
margin-right: 12rpx;
flex-shrink: 0;
}
.extra-value {
flex: 1;
font-size: 26rpx;
color: #4A4036;
line-height: 1.7;
}
+1 -1
View File
@@ -7,7 +7,7 @@
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"urlCheck": false,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
+27 -1
View File
@@ -1228,8 +1228,9 @@ function daysBetween(fromY, fromM, fromD, toY, toM, toD) {
/** 获取即将到来的 N 个节日(含基准日之后的)
* baseYear/baseMonth/baseDay 可选,默认今天
* includeBuddhist 为 true 时,把佛教节日也并入一起按倒计时排序
*/
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay, includeBuddhist = false) {
const now = new Date();
const y = baseYear || now.getFullYear();
const m = baseMonth || (now.getMonth() + 1);
@@ -1245,6 +1246,7 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
if (diff >= 0) {
list.push({
name: fest.name,
type: fest.type,
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
solarYear: solar.year,
solarMonth: solar.month,
@@ -1256,6 +1258,30 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
}
}
// 佛历节日(开启后并入,统一按倒计时排序)
if (includeBuddhist) {
for (const [key, name] of Object.entries(BUDDHIST_FESTIVALS)) {
const [lm, ld] = key.split('-').map(Number);
for (const tryYear of [y, y + 1]) {
const solar = lunarFestivalToSolar(tryYear, lm, ld);
if (!solar) continue;
const diff = daysBetween(y, m, d, solar.year, solar.month, solar.day);
if (diff >= 0) {
list.push({
name,
type: 'buddhist',
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
solarYear: solar.year,
solarMonth: solar.month,
solarDay: solar.day,
daysLeft: diff
});
break;
}
}
}
}
list.sort((a, b) => a.daysLeft - b.daysLeft);
return list.slice(0, count);
}
+2 -2
View File
@@ -1,9 +1,9 @@
// 网络请求封装
// 按小程序运行环境自动切换后端地址:
// - develop(开发者工具):本地开发服务
// - develop(开发者工具):连线上数据(本地 8080 被其他服务占用,且 localhost 受域名校验限制)
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
const API_HOSTS = {
develop: 'http://localhost:8080',
develop: 'https://lunar.neatcn.com',
trial: 'https://lunar.neatcn.com',
release: 'https://lunar.neatcn.com',
};
+7
View File
@@ -53,6 +53,10 @@ func main() {
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 设置运行模式
if cfg.Server.Env == "production" {
gin.SetMode(gin.ReleaseMode)
@@ -155,6 +159,9 @@ func main() {
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
// 节气详情同步(百度百科,独立于维基)
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
}
}
+1
View File
@@ -62,6 +62,7 @@ func autoMigrate() error {
&model.WikiEntry{},
&model.WikiOnThisDay{},
&model.WikiSyncLog{},
&model.BaikeTermSyncLog{},
); err != nil {
return err
}
+40
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
"github.com/gouki/lunar-server/internal/service"
)
@@ -12,6 +13,9 @@ import (
// WikiSync 维基百科同步服务实例(main 中注入)
var WikiSync *service.WikiSyncService
// BaikeTermSync 节气详情同步服务实例(main 中注入,独立于维基同步)
var BaikeTermSync *service.BaikeTermSyncService
// ===== 公开接口(小程序) =====
// GetWikiEntries 百科条目列表(小程序百科页)
@@ -159,3 +163,39 @@ func AdminWikiSyncTrigger(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "同步任务已启动"})
}
// ===== 节气详情同步(百度百科,独立于维基) =====
// AdminTermSyncStatus 节气详情同步状态(X/24 已含详情 + 最近一次同步日志)
func AdminTermSyncStatus(c *gin.Context) {
db := config.GetDB()
var detailed int64
db.Model(&model.WikiEntry{}).Where("category = ? AND image != ''", "term").Count(&detailed)
var lastLog model.BaikeTermSyncLog
db.Order("started_at DESC").First(&lastLog)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"running": BaikeTermSync != nil && BaikeTermSync.IsRunning(),
"detailedTerms": detailed,
"totalTerms": 24,
"lastSync": lastLog,
},
})
}
// AdminTermSyncTrigger 手动触发节气详情同步(异步执行)
func AdminTermSyncTrigger(c *gin.Context) {
if BaikeTermSync == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "节气同步服务未初始化"})
return
}
if !BaikeTermSync.TriggerSync("admin", true) {
c.JSON(http.StatusConflict, gin.H{"code": 409, "msg": "已有节气同步任务正在执行,请稍后再试"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "节气同步任务已启动"})
}
+18
View File
@@ -0,0 +1,18 @@
package model
import (
"time"
)
// BaikeTermSyncLog 节气详情同步日志(独立于维基 WikiSyncLog,互不影响)
type BaikeTermSyncLog struct {
ID uint `gorm:"primaryKey" json:"id"`
Trigger string `gorm:"size:20" json:"trigger"` // startup:启动自动 cron:定时任务 admin:手动
Status string `gorm:"size:20" json:"status"` // running:进行中 success:成功 partial:部分失败 failed:失败
Pages int `json:"pages"` // 总节气数(24
Success int `json:"success"` // 成功数
Failed int `json:"failed"` // 失败数
Error string `gorm:"size:500" json:"error"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt"` // 同步完成时间,进行中为 NULL
}
+2
View File
@@ -11,6 +11,8 @@ type WikiEntry struct {
Title string `gorm:"size:50" json:"title"`
Brief string `gorm:"size:255" json:"brief"` // 简介(列表折叠时显示)
Content string `gorm:"type:text" json:"content"` // 详细内容(展开后显示)
Image string `gorm:"size:255" json:"image"` // 配图 URL(节气等条目的配图)
Extra string `gorm:"type:text" json:"extra"` // 扩展字段 JSON(节气:别名/外文名/物候/习俗/养生等)
Sort int `gorm:"default:0" json:"sort"`
Status int `gorm:"default:1" json:"status"` // 1:显示 0:隐藏
CreatedAt time.Time `json:"createdAt"`
+398
View File
@@ -0,0 +1,398 @@
package service
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/gorm"
)
// BaikeTermSyncService 二十四节气详情同步服务
// 数据源:百度百科开放接口 BaikeLemmaCardApi(词条卡片,含简介/配图/信息卡)
// 与「历史上的今天」(WikiSyncService)完全独立,互不影响
type BaikeTermSyncService struct {
db *gorm.DB
running int32 // 原子标记:是否有同步正在执行
httpClient *http.Client
}
// NewBaikeTermSyncService 创建节气同步服务
func NewBaikeTermSyncService(db *gorm.DB) *BaikeTermSyncService {
return &BaikeTermSyncService{
db: db,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// solarTermNames 二十四节气(顺序即 Sort 排序)
var solarTermNames = []string{
"立春", "雨水", "惊蛰", "春分", "清明", "谷雨",
"立夏", "小满", "芒种", "夏至", "小暑", "大暑",
"立秋", "处暑", "白露", "秋分", "寒露", "霜降",
"立冬", "小雪", "大雪", "冬至", "小寒", "大寒",
}
var (
// reTermSup 匹配 <sup>...</sup> 引用标记(含内容一并去除,避免残留引用编号)
reTermSup = regexp.MustCompile(`(?s)<sup[^>]*>.*?</sup>`)
reTermHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
reTermSpace = regexp.MustCompile(`\s+`)
)
// IsRunning 是否有同步任务正在执行
func (s *BaikeTermSyncService) IsRunning() bool {
return atomic.LoadInt32(&s.running) == 1
}
// detailedTermCount 已含详情(image 非空)的节气条目数
func (s *BaikeTermSyncService) detailedTermCount() int64 {
var count int64
s.db.Model(&model.WikiEntry{}).
Where("category = ? AND image != ''", "term").
Count(&count)
return count
}
// TriggerSync 触发一次节气同步(异步执行);已有任务执行中时返回 false
// full=true 全量刷新 24 节气;full=false 仅补齐缺详情的节气
func (s *BaikeTermSyncService) TriggerSync(trigger string, full bool) bool {
names := solarTermNames
if !full {
names = s.missingTerms()
if len(names) == 0 {
return false // 无缺失,无需同步
}
}
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
return false
}
go func() {
defer atomic.StoreInt32(&s.running, 0)
s.runSync(trigger, names)
}()
return true
}
// missingTerms 尚未抓取详情的节气(image 为空)
func (s *BaikeTermSyncService) missingTerms() []string {
var existing []string
s.db.Model(&model.WikiEntry{}).
Where("category = ? AND image != ''", "term").
Pluck("title", &existing)
set := make(map[string]bool, len(existing))
for _, t := range existing {
set[t] = true
}
missing := make([]string, 0, len(solarTermNames))
for _, name := range solarTermNames {
if !set[name] {
missing = append(missing, name)
}
}
return missing
}
// StartScheduler 启动定时同步任务:
// - 启动时若节气详情缺失,延迟 15 秒后自动补齐
// - 之后每天在 syncHour 小时点检查,距上次成功超过 intervalDays 天才执行
func (s *BaikeTermSyncService) StartScheduler(intervalDays, syncHour int) {
go func() {
if missing := s.missingTerms(); len(missing) > 0 {
log.Printf("term sync: 缺失 %d/24 节气详情,15秒后自动补齐", len(missing))
time.Sleep(15 * time.Second)
s.TriggerSync("startup", false)
}
if intervalDays <= 0 {
intervalDays = 7
}
if syncHour < 0 || syncHour > 23 {
syncHour = 4
}
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
if now.Hour() != syncHour {
continue
}
if last, ok := s.lastSuccessAt(); ok && time.Since(last) < time.Duration(intervalDays)*24*time.Hour {
continue // 未到刷新周期
}
log.Println("term sync: 定时任务触发全量同步")
s.TriggerSync("cron", true)
}
}()
}
// lastSuccessAt 最近一次成功(含部分成功)的同步完成时间
func (s *BaikeTermSyncService) lastSuccessAt() (time.Time, bool) {
var syncLog model.BaikeTermSyncLog
err := s.db.Where("status IN ?", []string{"success", "partial"}).
Order("finished_at DESC").First(&syncLog).Error
if err != nil || syncLog.FinishedAt == nil {
return time.Time{}, false
}
return *syncLog.FinishedAt, true
}
// runSync 同步指定节气集合(全量 24 或缺失补齐)
func (s *BaikeTermSyncService) runSync(trigger string, names []string) {
syncLog := model.BaikeTermSyncLog{
Trigger: trigger,
Status: "running",
Pages: len(names),
StartedAt: time.Now(),
}
s.db.Create(&syncLog)
var success, failed int32
for _, name := range names {
if err := s.syncOneTerm(name); err != nil {
atomic.AddInt32(&failed, 1)
log.Printf("term sync: %s 失败: %v", name, err)
} else {
atomic.AddInt32(&success, 1)
}
time.Sleep(1200 * time.Millisecond) // 控制请求频率,避免触发限流
}
status := "success"
if failed > 0 {
status = "partial"
}
if success == 0 {
status = "failed"
}
now := time.Now()
s.db.Model(&syncLog).Updates(map[string]interface{}{
"status": status,
"success": int(success),
"failed": int(failed),
"finished_at": &now,
})
log.Printf("term sync: 同步完成 trigger=%s terms=%d success=%d failed=%d", trigger, len(names), success, failed)
}
// ===== 百度百科节气抓取与解析 =====
// baikeCardItem 信息卡单项
type baikeCardItem struct {
Key string `json:"key"`
Name string `json:"name"`
Value []string `json:"value"`
}
// baikeLemmaCard 百度百科词条卡片返回结构(只取需要的字段)
type baikeLemmaCard struct {
Key string `json:"key"` // 词条名
Desc string `json:"desc"` // 一句话描述
Title string `json:"title"` // 标题
Abstract string `json:"abstract"`// 简介(含 HTML
Image string `json:"image"` // 配图 URL
Card []baikeCardItem `json:"card"` // 信息卡
}
// syncOneTerm 抓取单个节气详情并写入/更新 WikiEntry
func (s *BaikeTermSyncService) syncOneTerm(name string) error {
lemma, err := s.fetchLemma(name)
if err != nil {
return err
}
// 解析信息卡为扩展字段
extra := map[string]string{}
for _, c := range lemma.Card {
text := cleanTermHTML(firstValue(c.Value))
if text == "" {
continue
}
switch c.Name {
case "别名":
extra["alias"] = text
case "外文名":
extra["english"] = text
case "涵义":
extra["meaning"] = text
case "公历时间":
extra["solarDate"] = text
case "黄道位置":
extra["ecliptic"] = text
case "气候特点":
extra["climate"] = text
case "物候现象":
extra["phenology"] = text
case "农事活动":
extra["farming"] = text
case "传统习俗":
extra["customs"] = text
case "起居养生":
extra["health"] = text
case "花信":
extra["flower"] = text
}
}
extraJSON, _ := json.Marshal(extra)
content := trimTruncatedTail(cleanTermHTML(lemma.Abstract))
brief := extra["meaning"]
if brief == "" {
brief = firstSentence(content)
}
if len([]rune(brief)) > 120 {
brief = string([]rune(brief)[:120])
}
// 按 category=term AND title=name 查找,存在则更新,不存在则新建
var entry model.WikiEntry
err = s.db.Where("category = ? AND title = ?", "term", name).First(&entry).Error
if err == gorm.ErrRecordNotFound {
entry = model.WikiEntry{
Category: "term",
Title: name,
Brief: brief,
Content: content,
Image: lemma.Image,
Extra: string(extraJSON),
Sort: termSort(name),
Status: 1,
}
return s.db.Create(&entry).Error
} else if err != nil {
return err
}
return s.db.Model(&entry).Updates(map[string]interface{}{
"brief": brief,
"content": content,
"image": lemma.Image,
"extra": string(extraJSON),
}).Error
}
// fetchLemma 调用百度百科 BaikeLemmaCardApi 抓取词条卡片(失败重试)
func (s *BaikeTermSyncService) fetchLemma(name string) (*baikeLemmaCard, error) {
apiURL := "https://baike.baidu.com/api/openapi/BaikeLemmaCardApi?scope=103&format=json&appid=379020&bk_length=600&bk_key=" + url.QueryEscape(name)
var lastErr error
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt*3) * time.Second)
}
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
req.Header.Set("Referer", "https://baike.baidu.com/")
req.Header.Set("Accept", "application/json, text/plain, */*")
resp, err := s.httpClient.Do(req)
if err != nil {
lastErr = err
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 上限 2MB
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
if err != nil {
lastErr = err
continue
}
var lemma baikeLemmaCard
if err := json.Unmarshal(body, &lemma); err != nil {
lastErr = fmt.Errorf("JSON 解析失败: %w", err)
continue
}
if lemma.Abstract == "" && len(lemma.Card) == 0 {
lastErr = fmt.Errorf("%s 返回内容为空", name)
continue
}
return &lemma, nil
}
return nil, lastErr
}
// termSort 节气在列表中的排序(按节气顺序)
func termSort(name string) int {
for i, n := range solarTermNames {
if n == name {
return i + 1
}
}
return 0
}
// firstValue 取信息卡 value 的第一个值
func firstValue(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}
// firstSentence 取文本首句(按句号/感叹号/问号切分)
func firstSentence(s string) string {
for _, sep := range []string{"。", "", ""} {
if idx := strings.Index(s, sep); idx > 0 {
return s[:idx+len(sep)]
}
}
return s
}
// cleanTermHTML 去除 HTML 标签(含 sup 引用标记整体)与多余空白,输出纯文本(本地实现,与维基同步解耦)
func cleanTermHTML(s string) string {
s = reTermSup.ReplaceAllString(s, "") // 先去 <sup>引用编号</sup>(含内容)
s = reTermHTMLTag.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&nbsp;", " ")
s = reTermSpace.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
// trimTruncatedTail 清理百度百科摘要的截断结尾:
// 摘要以「...」「…」等结尾说明被接口截断,先去掉省略号;
// 若去掉后末句不是完整句(不以 。!? 结尾),回退到最后一个完整句标点,避免半截话
func trimTruncatedTail(s string) string {
// 去掉结尾的省略号(英文 ... / 中文 … / 多个点)
s = strings.TrimRight(s, ".… ")
if s == "" {
return s
}
// 末字符已是完整句标点则无需回退
last := []rune(s)
if len(last) == 0 {
return s
}
if strings.ContainsRune("。!?", last[len(last)-1]) {
return s
}
// 回退到最后一个完整句标点(LastIndex 未找到返回 -1,需先判断 j >= 0
idx := -1
for _, sep := range []string{"。", "", ""} {
if j := strings.LastIndex(s, sep); j >= 0 && j+len(sep) > idx {
idx = j + len(sep)
}
}
if idx > 0 {
return s[:idx]
}
// 没有完整句标点则保留原文(避免清空)
return s
}
@@ -0,0 +1,151 @@
package service
import (
"encoding/json"
"strings"
"testing"
)
// 取自百度百科 BaikeLemmaCardApi 「立春」的真实返回片段
const sampleLemmaJSON = `{
"id":25702,"key":"立春","desc":"二十四节气之一","title":"立春",
"image":"https://bkimg.cdn.bcebos.com/pic/d62a6059252dd42a2834edd463674cb5c9ea15ce1205",
"abstract":"立春(Beginning of Spring),又名正月节、岁节、改岁、岁旦、打春等,为二十四节气之首。当太阳到达黄经315°时为立春,于每年公历2月3-5日交节。",
"card":[
{"key":"m25_nameC","name":"中文名","value":["立春"]},
{"key":"m25_nameE","name":"外文名","value":["Beginning of Spring<sup>3</sup><a name=\"ref_3\"></a>","Lichun"]},
{"key":"m25_alternateName","name":"别名","value":["岁首、岁节、改岁、立春节、正月节"]},
{"key":"m25_meaning","name":"涵义","value":["阳气上升,万物更生,新岁开启"]},
{"key":"m25_solarDate","name":"公历时间","value":["公历2月3日-2月5日交节"]},
{"key":"m25_ecliptic","name":"黄道位置","value":["太阳到达黄经315度"]},
{"key":"m25_phenology","name":"物候现象","value":["东风解冻、蛰虫始振、鱼陟负冰"]},
{"key":"m25_farming","name":"农事活动","value":["备耕春播"]},
{"key":"m25_customs","name":"传统习俗","value":["<a target=_blank href=\"/item/x\">演春</a>、迎春、打春"]},
{"key":"m25_health","name":"起居养生","value":["防躁动,避免过度劳累,保证睡眠情绪豁达。"]},
{"key":"m25_flower","name":"花信","value":["迎春、樱桃、望春<sup>35</sup><a name=\"ref_35\"></a>"]}
]
}`
func TestParseLemmaCard(t *testing.T) {
var lemma baikeLemmaCard
if err := json.Unmarshal([]byte(sampleLemmaJSON), &lemma); err != nil {
t.Fatalf("JSON 解析失败: %v", err)
}
if lemma.Key != "立春" {
t.Errorf("key = %q, want 立春", lemma.Key)
}
if lemma.Image == "" {
t.Error("image 不应为空")
}
if !strings.Contains(lemma.Abstract, "二十四节气之首") {
t.Errorf("abstract 应含「二十四节气之首」, got %q", lemma.Abstract)
}
if len(lemma.Card) == 0 {
t.Fatal("card 不应为空")
}
}
func TestLemmaCardMapping(t *testing.T) {
var lemma baikeLemmaCard
json.Unmarshal([]byte(sampleLemmaJSON), &lemma)
extra := map[string]string{}
for _, c := range lemma.Card {
text := cleanTermHTML(firstValue(c.Value))
if text == "" {
continue
}
switch c.Name {
case "别名":
extra["alias"] = text
case "外文名":
extra["english"] = text
case "涵义":
extra["meaning"] = text
case "物候现象":
extra["phenology"] = text
case "传统习俗":
extra["customs"] = text
case "起居养生":
extra["health"] = text
}
}
if extra["alias"] != "岁首、岁节、改岁、立春节、正月节" {
t.Errorf("alias = %q", extra["alias"])
}
if extra["meaning"] != "阳气上升,万物更生,新岁开启" {
t.Errorf("meaning = %q", extra["meaning"])
}
if extra["phenology"] != "东风解冻、蛰虫始振、鱼陟负冰" {
t.Errorf("phenology = %q", extra["phenology"])
}
// 传统习俗含 HTML 链接,应被清洗
if strings.Contains(extra["customs"], "<a") || strings.Contains(extra["customs"], "href") {
t.Errorf("customs 应清洗 HTML, got %q", extra["customs"])
}
if !strings.Contains(extra["customs"], "演春") {
t.Errorf("customs 应保留文字「演春」, got %q", extra["customs"])
}
// 外文名含 <sup> 应清洗
if strings.Contains(extra["english"], "<sup>") {
t.Errorf("english 应清洗 sup 标签, got %q", extra["english"])
}
}
func TestCleanTermHTML(t *testing.T) {
cases := map[string]string{
`<a target=_blank href="/item/x">演春</a>、迎春`: "演春、迎春",
`Beginning of Spring<sup>3</sup><a name="ref_3"></a>`: "Beginning of Spring",
` 多余&nbsp; 空白 `: "多余 空白",
}
for in, want := range cases {
if got := cleanTermHTML(in); got != want {
t.Errorf("cleanTermHTML(%q) = %q, want %q", in, got, want)
}
}
}
func TestFirstSentence(t *testing.T) {
s := "立春为二十四节气之首。当太阳到达黄经315°时为立春。"
if got := firstSentence(s); got != "立春为二十四节气之首。" {
t.Errorf("firstSentence = %q", got)
}
// 无句号时返回原文
if got := firstSentence("无句号文本"); got != "无句号文本" {
t.Errorf("firstSentence 无句号 = %q", got)
}
}
func TestTermSort(t *testing.T) {
if termSort("立春") != 1 {
t.Errorf("termSort(立春) = %d, want 1", termSort("立春"))
}
if termSort("大寒") != 24 {
t.Errorf("termSort(大寒) = %d, want 24", termSort("大寒"))
}
if termSort("不存在") != 0 {
t.Errorf("termSort(不存在) = %d, want 0", termSort("不存在"))
}
}
func TestTrimTruncatedTail(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"完整句号结尾不动", "夏至后人们普遍食用清补凉汤来避暑。", "夏至后人们普遍食用清补凉汤来避暑。"},
{"英文省略号回退", "表达感谢的心情。唐宋时期,冬至...", "表达感谢的心情。"},
{"中文省略号回退", "表达感谢的心情。唐宋时期,冬至…", "表达感谢的心情。"},
{"无句号半截话保留", "可明显感觉到早春的气...", "可明显感觉到早春的气"},
{"多点省略号", "农事谚语....", "农事谚语"},
{"结尾空白加省略号", "冬至大如年。 ...", "冬至大如年。"},
{"空字符串", "", ""},
}
for _, c := range cases {
if got := trimTruncatedTail(c.in); got != c.want {
t.Errorf("%s: trimTruncatedTail(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
+2
View File
@@ -61,6 +61,8 @@ func (s *WikiService) UpdateEntry(id uint, entry *model.WikiEntry) error {
"title": entry.Title,
"brief": entry.Brief,
"content": entry.Content,
"image": entry.Image,
"extra": entry.Extra,
"sort": entry.Sort,
"status": entry.Status,
}).Error
+104 -5
View File
@@ -47,6 +47,27 @@ export default {
<!-- 百科条目管理 -->
<div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'">
<!-- 节气详情同步百度百科 -->
<div class="bg-white shadow rounded-lg mb-4">
<div class="px-4 py-4 sm:px-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-4">
<div>
<div class="text-sm font-medium text-gray-900">节气详情百度百科</div>
<div class="text-xs text-gray-500 mt-0.5">
已含详情 {{ termSync.detailedTerms || 0 }} / {{ termSync.totalTerms || 24 }}
<span v-if="termSync.lastSync && termSync.lastSync.startedAt"> 最近同步{{ formatTime(termSync.lastSync.startedAt) }}</span>
<span v-if="termSync.lastSync && termSync.lastSync.status" :class="termSync.lastSync.status === 'success' ? 'text-green-600' : 'text-yellow-600'"> {{ termSync.lastSync.status === 'success' ? '成功' : termSync.lastSync.status === 'partial' ? '部分失败' : '失败' }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="loadTermSyncStatus" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新</button>
<button @click="triggerTermSync" :disabled="termSync.running || termSyncing" class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-sync mr-1" :class="termSync.running ? 'fa-spin' : ''"></i>{{ termSync.running ? '...' : '' }}
</button>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
@@ -170,6 +191,21 @@ export default {
<label class="block text-sm font-medium text-gray-700 mb-1">详细内容</label>
<textarea v-model="editor.form.content" rows="6" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">配图 URL</label>
<input v-model="editor.form.image" placeholder="https://..." class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
<div v-if="editor.form.image" class="mt-2"><img :src="editor.form.image" class="h-20 rounded border" alt="配图预览"/></div>
</div>
<!-- 节气扩展字段仅节气分类显示 -->
<div v-if="editor.form.category === 'term'">
<label class="block text-sm font-medium text-gray-700 mb-2">节气扩展信息</label>
<div class="grid grid-cols-2 gap-3">
<div v-for="f in termExtraFields" :key="f.key">
<label class="block text-xs text-gray-500 mb-1">{{ f.name }}</label>
<input v-model="editor.extra[f.key]" class="block w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">排序越小越靠前</label>
@@ -210,20 +246,39 @@ export default {
editor: {
visible: false,
id: null,
form: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 },
form: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 },
extra: {},
},
termExtraFields: [
{ key: 'alias', name: '别名' },
{ key: 'english', name: '外文名' },
{ key: 'meaning', name: '涵义' },
{ key: 'solarDate', name: '公历时间' },
{ key: 'ecliptic', name: '黄道位置' },
{ key: 'climate', name: '气候特点' },
{ key: 'phenology', name: '物候现象' },
{ key: 'farming', name: '农事活动' },
{ key: 'customs', name: '传统习俗' },
{ key: 'health', name: '起居养生' },
{ key: 'flower', name: '花信' },
],
saving: false,
syncing: false,
syncStatus: {},
syncTimer: null,
termSync: {},
termSyncing: false,
termSyncTimer: null,
};
},
mounted() {
this.loadEntries();
this.loadSyncStatus();
this.loadTermSyncStatus();
},
beforeUnmount() {
if (this.syncTimer) clearInterval(this.syncTimer);
if (this.termSyncTimer) clearInterval(this.termSyncTimer);
},
methods: {
catName(key) {
@@ -265,22 +320,40 @@ export default {
openEditor(entry) {
this.editor.id = entry ? entry.id : null;
this.editor.form = entry
? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, sort: entry.sort, status: entry.status }
: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 };
? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, image: entry.image || '', sort: entry.sort, status: entry.status }
: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 };
let extra = {};
if (entry && entry.extra) {
try { extra = JSON.parse(entry.extra); } catch (e) { extra = {}; }
}
this.editor.extra = extra;
this.editor.visible = true;
},
async saveEntry() {
const f = this.editor.form;
if (!f.title.trim()) { alert('标题不能为空'); return; }
// 节气分类:把 extra 对象序列化为 JSON 字符串
const payload = { ...f };
if (f.category === 'term') {
const extra = {};
this.termExtraFields.forEach(fd => {
const v = (this.editor.extra[fd.key] || '').trim();
if (v) extra[fd.key] = v;
});
payload.extra = JSON.stringify(extra);
} else {
payload.extra = '';
}
this.saving = true;
try {
if (this.editor.id) {
await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(f) });
await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(f) });
await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(payload) });
}
this.editor.visible = false;
this.loadEntries();
this.loadTermSyncStatus();
} catch (e) {
alert(e.message);
} finally {
@@ -322,5 +395,31 @@ export default {
this.syncing = false;
}
},
async loadTermSyncStatus() {
try {
this.termSync = await this.api('/admin/api/wiki/term-sync-status');
if (this.termSync.running && !this.termSyncTimer) {
this.termSyncTimer = setInterval(() => this.loadTermSyncStatus(), 5000);
} else if (!this.termSync.running && this.termSyncTimer) {
clearInterval(this.termSyncTimer);
this.termSyncTimer = null;
}
} catch (e) {
console.error(e);
}
},
async triggerTermSync() {
if (!confirm('将从百度百科抓取 24 节气的详细图文(约 30 秒),确认执行?')) return;
this.termSyncing = true;
try {
await this.api('/admin/api/wiki/term-sync', { method: 'POST' });
this.loadTermSyncStatus();
setTimeout(() => this.loadEntries(), 3000);
} catch (e) {
alert(e.message);
} finally {
this.termSyncing = false;
}
},
},
}