3.2.49: 修「文章聚合→热点」只显示一条(换全站累计热度算法) #19
根因不是排序,是候选池:旧 SQL 先圈「最近 30 天发布」再按评论排序取 6。
本站实测 30 天只有 1 篇 post(7 天 0 / 90 天 1 / 180 天 6 / 365 天 15 / 全站 967),
候选池只有 1 条,LIMIT 6 也只能返回 1 条。
改成全站累计热度,三项取对数加权:
3*log10(views+1) + 5*log10(love+1) + 4*log10(comments+1)
· 取 log10:views 中位 4981/最大 56075,而 love 最大 40、评论最大 93,
不压缩则 views 一项吃掉全部权重。
· 权重 3/5/4(不是通用 1/3/5):用通用值实测前 8 名 6 篇是 2009 年老随笔
—— 评论 1.97*5=9.86 > views 4.75*1=4.75,退化成「评论数排行榜」。
· 有意不加时间衰减:HN/Reddit 那套是「新内容榜」,会把老文压到看不见,
正好回到原问题。要时效榜是另一个功能。
· 仍限定 post_type='post'(用户要求只看文章,不看 page;
ID 217「留言本」/522「博客时间」核查过都是 page,天然被挡掉)。
顺手修掉三处旧问题:
· 删 date_default_timezone_set("PRC") —— 它会改掉整个 PHP 请求的全局时区
· 标题进属性改 esc_attr()(旧代码裸拼,含双引号会截断 title 属性)
· get_posts 参数从 'numberposts=6 & offset=0' 字符串改成数组
(字符串里那个空格让参数名变成 ' offset',offset=0 实际从未生效)
后台:「统计天数」输入框下线(它控制的就是那个缩死候选池的时间窗),
update() 不再保存 days,老实例残留值下次保存时丢掉。
验证:
· 主测试 33/0(分数用独立实现重算比对,不调用产品函数算期望值)
· 反向验证 20/0(基线 2 + 框架自检 2 + 注入 8 组×2)
八组注入:时间窗加回 / 去 post_type / 去 log10 / 权重改 1-3-5 /
标题退裸拼 / 全局时区加回 / 排序改升序 / 后台加回统计天数
· 真浏览器几何 22/0(1440+1180 两视口):6 条真画出来、宽度等于面板、
图标存在、不重叠、长标题走省略号不溢出
· 发布反证 8/8、线上包校验 10/10
This commit is contained in:
+191
-31
@@ -35,26 +35,163 @@ function widgets_init()
|
||||
}
|
||||
add_action('widgets_init', 'widgets_init');
|
||||
|
||||
// 小工具文章聚合 - 热点文章
|
||||
function most_comm_posts($days = 30, $nums = 6)
|
||||
/* ==========================================================================
|
||||
* 热度算法(3.2.49)
|
||||
*
|
||||
* ── 为什么废弃旧的 most_comm_posts() ──────────────────────────────────
|
||||
* 旧实现的候选池是「最近 N 天(默认 30)**发布**的文章」:
|
||||
*
|
||||
* WHERE post_date BETWEEN '$daysago' AND '$today'
|
||||
* ORDER BY comment_count DESC LIMIT 6
|
||||
*
|
||||
* 对日更站点没问题,对低频更新的站点直接失效。本站实测(2026-10-26):
|
||||
* 7 天内发布 0 篇 / 30 天内 1 篇 / 90 天内 1 篇 / 180 天内 6 篇
|
||||
* 30 天候选池只有 1 篇 ⇒ LIMIT 6 也只能返回 1 条。
|
||||
* 这就是侧栏「热点」永远只显示一条的真正原因 —— **不是排序错,是池子空**。
|
||||
*
|
||||
* 而且「最近 30 天发布」本身就不是「热」的定义:热是累计互动,不是新鲜度。
|
||||
*
|
||||
* ── 现在的算法 ────────────────────────────────────────────────────────
|
||||
* 候选池:全站所有已发布 post(不含 page —— 「留言本」这种页面 views 214 万,
|
||||
* 它的热度对"最热文章"没有意义;`post_type='post'` 天然挡掉)。
|
||||
* 打分:三个指标各取 log10 后加权求和
|
||||
*
|
||||
* score = 3*log10(views+1) + 5*log10(love+1) + 4*log10(comments+1)
|
||||
*
|
||||
* 为什么取 log10:本站 views 中位数 4981、最大 56075(差 11 倍),
|
||||
* 但 love 最大只有 40、评论最大 93。不取对数的话 views 一个指标就吃掉
|
||||
* 全部权重,点赞和评论等于没参与。取对数后三者量级被拉平到可比的区间。
|
||||
*
|
||||
* 为什么权重是 3 / 5 / 4(不是常见的 1 / 3 / 5):
|
||||
* 用通用值实测,前 8 名有 6 篇是 2009 年的老随笔 —— 因为那批文章评论
|
||||
* 80~93 条,仅评论一项(1.97×5=9.86)就压过 views 最大值的贡献(4.75×1=4.75),
|
||||
* 结果变成"评论数排行榜"。把 views 权重提到 3 之后三个指标影响力均衡:
|
||||
* 点赞最高(5)—— 点👍 是比评论更强的认可信号,且本站只有 197 篇有点赞,
|
||||
* 稀缺性让它更有区分度;评论次之(4)—— 要付出打字成本;浏览最轻(3)。
|
||||
*
|
||||
* 实测这组权重的排名(前 8):Anaconda多用户安装(love=40) · kratos主题发布(love=9,评论37)
|
||||
* · LaTex上划线(love=19) · 那一年2009 · LaTex字体(love=12) · Windows7自带WMP(评论93)
|
||||
* → 技术文章与高互动随笔混合出现,2009 年那批也没有被完全埋掉。
|
||||
*
|
||||
* ── 有意不加时间衰减 ──────────────────────────────────────────────────
|
||||
* 「全站历史最热」是累计榜,不是时效榜。Hacker News / Reddit 那套
|
||||
* gravity 衰减是给「新内容榜」用的,加上去会把老文章压到看不见,
|
||||
* 那就又回到"只有最近几条"的老问题。要时效榜是另一个功能,不塞在这里。
|
||||
*
|
||||
* ── 顺手修掉的两个坑 ──────────────────────────────────────────────────
|
||||
* ① 旧代码里 `date_default_timezone_set("PRC")` 直接改 **PHP 全局时区**,
|
||||
* 影响同一请求里之后所有时间函数。已删。
|
||||
* ② 旧代码把 `$title` 原样插进 `title="..."` 属性,标题里有双引号就会
|
||||
* 截断属性(甚至注入)。已改用 esc_attr()。
|
||||
* ====================================================================== */
|
||||
|
||||
if (!function_exists('yang_hot_score')):
|
||||
|
||||
/**
|
||||
* 单篇文章的热度分。
|
||||
*
|
||||
* @param int $views 浏览数
|
||||
* @param int $love 点赞数
|
||||
* @param int $comments 评论数
|
||||
* @return float
|
||||
*/
|
||||
function yang_hot_score($views, $love, $comments)
|
||||
{
|
||||
return 3 * log10((int) $views + 1)
|
||||
+ 5 * log10((int) $love + 1)
|
||||
+ 4 * log10((int) $comments + 1);
|
||||
}
|
||||
|
||||
endif;
|
||||
|
||||
if (!function_exists('yang_hot_posts')):
|
||||
|
||||
/**
|
||||
* 取热度最高的文章。
|
||||
*
|
||||
* 为什么在 PHP 里算分、不在 SQL 里 ORDER BY:
|
||||
* 分值是三项 log 加权和,SQL 里要写三段 LOG10 + COALESCE + 两个
|
||||
* LEFT JOIN 到 postmeta,可读性极差,而且没法复用 yang_hot_score()
|
||||
* (测试和产品必须是同一份实现,不能各写一遍)。
|
||||
* 代价是要把候选行取回来 —— 本站 967 篇 post,一次取 4 个字段
|
||||
* 内存占用可忽略;如果将来文章上万,再加 LIMIT / 缓存。
|
||||
* 为防止极端情况,SQL 侧先用 views 粗排取前 N*10(对数排序里
|
||||
* 真正的高分不会来自 views 极低的文章,这个粗排是安全的)。
|
||||
*
|
||||
* @param int $nums 返回条数
|
||||
* @return array 文章对象数组,每项含 ID/post_title/views/love/comments/score
|
||||
*/
|
||||
function yang_hot_posts($nums = 6)
|
||||
{
|
||||
global $wpdb;
|
||||
date_default_timezone_set("PRC");
|
||||
$today = date("Y-m-d H:i:s");
|
||||
$daysago = date("Y-m-d H:i:s", strtotime($today) - ($days * 24 * 60 * 60));
|
||||
$result = $wpdb->get_results("SELECT comment_count, ID, post_title, post_date FROM $wpdb->posts WHERE post_date BETWEEN '$daysago' AND '$today' and post_type='post' and post_status='publish' ORDER BY comment_count DESC LIMIT 0 , $nums");
|
||||
|
||||
$nums = max(1, (int) $nums);
|
||||
// 粗排窗口:给足余量。views 前 200 名以外不可能靠 love/评论翻盘
|
||||
// (log10 域下,love 满分 1.61×5=8.05,补不回 views 差 1 个数量级的 3 分)。
|
||||
$pool = max(200, $nums * 20);
|
||||
|
||||
$sql = $wpdb->prepare(
|
||||
"SELECT p.ID, p.post_title, p.post_date, p.comment_count,
|
||||
COALESCE(CAST(mv.meta_value AS UNSIGNED), 0) AS views,
|
||||
COALESCE(CAST(ml.meta_value AS UNSIGNED), 0) AS love
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN {$wpdb->postmeta} mv ON mv.post_id = p.ID AND mv.meta_key = 'views'
|
||||
LEFT JOIN {$wpdb->postmeta} ml ON ml.post_id = p.ID AND ml.meta_key = 'love'
|
||||
WHERE p.post_status = 'publish'
|
||||
AND p.post_type = 'post'
|
||||
ORDER BY views DESC
|
||||
LIMIT %d",
|
||||
$pool
|
||||
);
|
||||
$rows = $wpdb->get_results($sql);
|
||||
if (empty($rows)) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$scored = array();
|
||||
foreach ($rows as $r) {
|
||||
$r->views = (int) $r->views;
|
||||
$r->love = (int) $r->love;
|
||||
$r->comments = (int) $r->comment_count;
|
||||
$r->score = yang_hot_score($r->views, $r->love, $r->comments);
|
||||
$scored[] = $r;
|
||||
}
|
||||
|
||||
usort($scored, function ($a, $b) {
|
||||
if ($a->score == $b->score) {
|
||||
// 同分按浏览数兜底,保证顺序稳定(否则每次刷新顺序会跳)
|
||||
return $b->views <=> $a->views;
|
||||
}
|
||||
return $b->score <=> $a->score;
|
||||
});
|
||||
|
||||
return array_slice($scored, 0, $nums);
|
||||
}
|
||||
|
||||
endif;
|
||||
|
||||
/**
|
||||
* 小工具文章聚合 —— 热点文章
|
||||
*
|
||||
* ⚠️ 这个函数名和签名保留着,因为 `widget_posts::widget()` 里有一句
|
||||
* `if (function_exists('most_comm_posts'))` 调用它。留着旧名不破兼容,
|
||||
* 但内部已经完全换成 yang_hot_posts()。
|
||||
*
|
||||
* @param int $days 已废弃。旧实现用它当"最近 N 天发布"的时间窗,
|
||||
* 那正是只显示一条的根因。保留参数只为不破坏调用签名。
|
||||
* @param int $nums 返回条数
|
||||
*/
|
||||
function most_comm_posts($days = 30, $nums = 6)
|
||||
{
|
||||
$posts = yang_hot_posts($nums);
|
||||
|
||||
$output = '';
|
||||
if (!empty($result)) {
|
||||
foreach ($result as $topten) {
|
||||
$postid = $topten->ID;
|
||||
$title = $topten->post_title;
|
||||
$commentcount = $topten->comment_count;
|
||||
if ($commentcount >= 0) {
|
||||
$output .= '<a class="bookmark-item" title="' . $title . '" href="' . get_permalink($postid) . '" rel="bookmark"><i class="kicon i-book"></i>';
|
||||
$output .= strip_tags($title);
|
||||
$output .= '</a>';
|
||||
}
|
||||
}
|
||||
foreach ($posts as $p) {
|
||||
$output .= '<a class="bookmark-item" title="' . esc_attr($p->post_title) . '"'
|
||||
. ' href="' . esc_url(get_permalink($p->ID)) . '" rel="bookmark">'
|
||||
. '<i class="kicon i-book"></i>'
|
||||
. esc_html(wp_strip_all_tags($p->post_title))
|
||||
. '</a>';
|
||||
}
|
||||
echo $output;
|
||||
}
|
||||
@@ -314,8 +451,10 @@ class widget_posts extends WP_Widget
|
||||
|
||||
public function widget($args, $instance)
|
||||
{
|
||||
$number = !empty($instance['number']) ? $instance['number'] : '6';
|
||||
$days = !empty($instance['days']) ? $instance['days'] : '30';
|
||||
$number = !empty($instance['number']) ? (int) $instance['number'] : 6;
|
||||
if ($number < 1) {
|
||||
$number = 6;
|
||||
}
|
||||
|
||||
echo '<div class="widget w-recommended">';
|
||||
?>
|
||||
@@ -331,16 +470,37 @@ class widget_posts extends WP_Widget
|
||||
</div>
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
<div class="tab-pane fade show active" id="nav-new" role="tabpanel" aria-labelledby="nav-new-tab">
|
||||
<?php $myposts = get_posts('numberposts=' . $number . ' & offset=0');foreach ($myposts as $post): ?>
|
||||
<a class="bookmark-item" title="<?php echo $post->post_title; ?>" href="<?php echo get_permalink($post->ID); ?>" rel="bookmark"><i class="kicon i-book"></i><?php echo strip_tags($post->post_title) ?></a>
|
||||
<?php
|
||||
/* 🔴 原来写的是 `'numberposts=' . $number . ' & offset=0'` ——
|
||||
* 字符串里带空格,WP_Query 解析时参数名变成 `' offset'`(前面
|
||||
* 有空格),`offset=0` 实际没生效。offset=0 本身是默认值,
|
||||
* 所以症状看不出来,但这是错的写法。改成数组传参。 */
|
||||
$myposts = get_posts(array(
|
||||
'numberposts' => $number,
|
||||
'offset' => 0,
|
||||
));
|
||||
foreach ($myposts as $post): ?>
|
||||
<a class="bookmark-item" title="<?php echo esc_attr($post->post_title); ?>" href="<?php echo esc_url(get_permalink($post->ID)); ?>" rel="bookmark"><i class="kicon i-book"></i><?php echo esc_html(wp_strip_all_tags($post->post_title)) ?></a>
|
||||
<?php endforeach;?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-hot" role="tabpanel" aria-labelledby="nav-hot-tab">
|
||||
<?php if (function_exists('most_comm_posts')) {most_comm_posts($days, $number);}?>
|
||||
<?php
|
||||
/* 热点:全站累计热度(views/love/评论 三项 log 加权),
|
||||
* 不再受「最近 N 天发布」的时间窗限制。原因见 most_comm_posts()
|
||||
* 上方的注释块。 */
|
||||
if (function_exists('most_comm_posts')) {
|
||||
most_comm_posts(0, $number);
|
||||
} ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-random" role="tabpanel" aria-labelledby="nav-random-tab">
|
||||
<?php $myposts = get_posts('numberposts=' . $number . ' & offset=0 & orderby=rand');foreach ($myposts as $post): ?>
|
||||
<a class="bookmark-item" title="<?php echo $post->post_title; ?>" href="<?php echo get_permalink($post->ID); ?>" rel="bookmark"><i class="kicon i-book"></i><?php echo strip_tags($post->post_title) ?></a>
|
||||
<?php
|
||||
$myposts = get_posts(array(
|
||||
'numberposts' => $number,
|
||||
'offset' => 0,
|
||||
'orderby' => 'rand',
|
||||
));
|
||||
foreach ($myposts as $post): ?>
|
||||
<a class="bookmark-item" title="<?php echo esc_attr($post->post_title); ?>" href="<?php echo esc_url(get_permalink($post->ID)); ?>" rel="bookmark"><i class="kicon i-book"></i><?php echo esc_html(wp_strip_all_tags($post->post_title)) ?></a>
|
||||
<?php endforeach;?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,25 +511,25 @@ class widget_posts extends WP_Widget
|
||||
{
|
||||
$instance = array();
|
||||
|
||||
$instance['number'] = (!empty($new_instance['number'])) ? $new_instance['number'] : '';
|
||||
$instance['days'] = (!empty($new_instance['days'])) ? $new_instance['days'] : '';
|
||||
$instance['number'] = (!empty($new_instance['number'])) ? (int) $new_instance['number'] : 6;
|
||||
|
||||
/* `days` 已废弃:旧实现拿它当「最近 N 天发布」的时间窗,
|
||||
* 那正是热点只显示一条的根因。这里不再保存,
|
||||
* 老实例里残留的 days 值会在下次保存时被丢掉。 */
|
||||
return $instance;
|
||||
}
|
||||
public function form($instance)
|
||||
{
|
||||
global $wpdb;
|
||||
$number = !empty($instance['number']) ? $instance['number'] : '6';
|
||||
$days = !empty($instance['days']) ? $instance['days'] : '30';
|
||||
?>
|
||||
<div class="media-widget-control">
|
||||
<p>
|
||||
<label for="<?php echo $this->get_field_id('number'); ?>"><?php _e('展示数量:', 'kratos');?></label>
|
||||
<input class="widefat" id="<?php echo $this->get_field_id('number'); ?>" name="<?php echo $this->get_field_name('number'); ?>" type="text" value="<?php echo esc_attr($number); ?>" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo $this->get_field_id('days'); ?>"><?php _e('统计天数:', 'kratos');?></label>
|
||||
<input class="widefat" id="<?php echo $this->get_field_id('days'); ?>" name="<?php echo $this->get_field_name('days'); ?>" type="text" value="<?php echo esc_attr($days); ?>" />
|
||||
<p class="description">
|
||||
<?php _e('「最新」「随机」「热点」三个标签页都用这个数量。', 'kratos'); ?><br>
|
||||
<?php _e('「热点」按全站累计热度排序:浏览量、点赞数、评论数三项分别取对数后加权(3 : 5 : 4),不受发布时间限制。', 'kratos'); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
Reference in New Issue
Block a user