From 6bd7676b7338805f261209ff1dceed0dee809155 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 17 Jul 2026 10:33:00 +0800 Subject: [PATCH] fix(yiyan): stop invalid Hitokoto category requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public hitokoto v1 rejects c=hitokoto (and ignores batch count). Whitelist categories a–l, multi-fetch for carousel, and share full markup via aksrt_render_yiyan_block so single-post shells actually render quotes. --- assets/js/yiyan.js | 260 ++++++++++++++---- inc/bootstrap.php | 1 + .../widgets/class-aksrt-yiyan-widget.php | 232 +++++++++------- inc/helpers/aksrt-single-render.php | 18 +- inc/helpers/aksrt-yiyan.php | 55 ++++ 5 files changed, 402 insertions(+), 164 deletions(-) create mode 100644 inc/helpers/aksrt-yiyan.php diff --git a/assets/js/yiyan.js b/assets/js/yiyan.js index 3a9557da..b5edeadf 100644 --- a/assets/js/yiyan.js +++ b/assets/js/yiyan.js @@ -2,6 +2,8 @@ * AKSRT Yiyan — 一言轮播组件 * * Fetches random quotes from hitokoto.cn API and auto-rotates. + * Public v1 returns one sentence per request; multi-quote carousels + * issue parallel fetches. Category `c` must be a letter a–l (or omitted). * * @package AKSRT */ @@ -9,22 +11,168 @@ (function () { 'use strict'; + var API_BASE = 'https://v1.hitokoto.cn/'; + var TYPE_RE = /^[a-l]$/i; + var MIN_COUNT = 1; + var MAX_COUNT = 10; + var DEFAULT_COUNT = 5; + var DEFAULT_INTERVAL_SEC = 5; + + function normalizeType(raw) { + if (raw == null || raw === '') { + return ''; + } + var t = String(raw).trim().toLowerCase(); + return TYPE_RE.test(t) ? t : ''; + } + + function normalizeCount(raw) { + var n = parseInt(raw, 10); + if (isNaN(n) || n < MIN_COUNT) { + return DEFAULT_COUNT; + } + return Math.min(MAX_COUNT, n); + } + + function normalizeIntervalMs(raw) { + var sec = parseInt(raw, 10); + if (isNaN(sec) || sec < 3) { + sec = DEFAULT_INTERVAL_SEC; + } + return Math.min(60, sec) * 1000; + } + + function buildApiUrl(type) { + var params = new URLSearchParams(); + params.set('encode', 'json'); + if (type) { + params.set('c', type); + } + return API_BASE + '?' + params.toString(); + } + + function mapQuote(data) { + if (!data || typeof data !== 'object') { + return null; + } + var text = data.hitokoto || data.text || ''; + if (!text) { + return null; + } + return { + text: String(text), + from: data.from ? String(data.from) : '', + }; + } + + function ensureStructure(el) { + var textEl = el.querySelector('.aksrt-yiyan__text'); + if (textEl) { + return { + textEl: textEl, + fromEl: el.querySelector('.aksrt-yiyan__from'), + dotsEl: el.querySelector('.aksrt-yiyan__dots'), + }; + } + + var content = document.createElement('div'); + content.className = 'aksrt-yiyan__content'; + + textEl = document.createElement('p'); + textEl.className = 'aksrt-yiyan__text'; + textEl.textContent = '加载中…'; + content.appendChild(textEl); + + var fromEl = document.createElement('p'); + fromEl.className = 'aksrt-yiyan__from'; + content.appendChild(fromEl); + + el.appendChild(content); + + var dotsEl = document.createElement('div'); + dotsEl.className = 'aksrt-yiyan__dots'; + dotsEl.setAttribute('aria-hidden', 'true'); + el.appendChild(dotsEl); + + return { textEl: textEl, fromEl: fromEl, dotsEl: dotsEl }; + } + + function fetchOneQuote(type) { + return fetch(buildApiUrl(type), { + credentials: 'omit', + mode: 'cors', + }).then(function (r) { + return r + .json() + .catch(function () { + return null; + }) + .then(function (data) { + if (!r.ok) { + var msg = + data && data.message + ? String(data.message) + : 'HTTP ' + r.status; + throw new Error(msg); + } + var quote = mapQuote(data); + if (!quote) { + throw new Error('empty'); + } + return quote; + }); + }); + } + + function fetchQuotes(type, count) { + var jobs = []; + var i; + for (i = 0; i < count; i += 1) { + jobs.push( + fetchOneQuote(type).catch(function () { + return null; + }) + ); + } + return Promise.all(jobs).then(function (results) { + var quotes = []; + var seen = Object.create(null); + results.forEach(function (q) { + if (!q || !q.text) { + return; + } + // Dedupe identical text from parallel random draws. + if (seen[q.text]) { + return; + } + seen[q.text] = 1; + quotes.push(q); + }); + return quotes; + }); + } + function initYiyan(el) { - if (el.dataset.yiyanBound) return; + if (el.dataset.yiyanBound) { + return; + } el.dataset.yiyanBound = '1'; - var count = parseInt(el.dataset.count || '5', 10); - var interval = parseInt(el.dataset.interval || '5', 10) * 1000; - var type = el.dataset.type || 'a'; - var textEl = el.querySelector('.aksrt-yiyan__text'); - var fromEl = el.querySelector('.aksrt-yiyan__from'); - var dotsEl = el.querySelector('.aksrt-yiyan__dots'); + var count = normalizeCount(el.dataset.count); + var interval = normalizeIntervalMs(el.dataset.interval); + var type = normalizeType(el.dataset.type); + var parts = ensureStructure(el); + var textEl = parts.textEl; + var fromEl = parts.fromEl; + var dotsEl = parts.dotsEl; var quotes = []; var currentIndex = 0; var timer = null; function showQuote(index) { - if (!quotes.length || !textEl) return; + if (!quotes.length || !textEl) { + return; + } var q = quotes[index % quotes.length]; textEl.style.opacity = '0'; setTimeout(function () { @@ -35,7 +183,6 @@ } }, 200); - // Update dots if (dotsEl) { dotsEl.querySelectorAll('.aksrt-yiyan__dot').forEach(function (dot, i) { dot.classList.toggle('is-active', i === index % quotes.length); @@ -44,65 +191,64 @@ } function nextQuote() { + if (quotes.length < 2) { + return; + } currentIndex = (currentIndex + 1) % quotes.length; showQuote(currentIndex); } - function fetchQuotes() { - var url = 'https://v1.hitokoto.cn/?c=' + encodeURIComponent(type) + '&encode=json&count=' + count; - fetch(url) - .then(function (r) { return r.json(); }) - .then(function (data) { - // API returns single object or array - if (Array.isArray(data)) { - quotes = data.map(function (item) { - return { text: item.hitokoto || '', from: item.from || '' }; - }); - } else if (data && data.hitokoto) { - quotes = [{ text: data.hitokoto, from: data.from || '' }]; - } - - if (!quotes.length) { - if (textEl) textEl.textContent = '暂无内容'; - return; - } - - // Build dots - if (dotsEl && quotes.length > 1) { - dotsEl.innerHTML = ''; - quotes.forEach(function (_, i) { - var dot = document.createElement('span'); - dot.className = 'aksrt-yiyan__dot' + (i === 0 ? ' is-active' : ''); - dotsEl.appendChild(dot); - }); - } - - showQuote(0); - - // Auto-rotate - if (quotes.length > 1) { - timer = setInterval(nextQuote, interval); - } - }) - .catch(function () { - if (textEl) textEl.textContent = '加载失败,请稍后再试'; - }); - } - - // Pause on hover - el.addEventListener('mouseenter', function () { - if (timer) clearInterval(timer); - }); - el.addEventListener('mouseleave', function () { + function startTimer() { + if (timer) { + clearInterval(timer); + timer = null; + } if (quotes.length > 1) { timer = setInterval(nextQuote, interval); } + } + + function buildDots() { + if (!dotsEl || quotes.length < 2) { + if (dotsEl) { + dotsEl.innerHTML = ''; + } + return; + } + dotsEl.innerHTML = ''; + quotes.forEach(function (_, i) { + var dot = document.createElement('span'); + dot.className = 'aksrt-yiyan__dot' + (i === 0 ? ' is-active' : ''); + dotsEl.appendChild(dot); + }); + } + + el.addEventListener('mouseenter', function () { + if (timer) { + clearInterval(timer); + timer = null; + } + }); + el.addEventListener('mouseleave', function () { + startTimer(); }); - fetchQuotes(); + fetchQuotes(type, count) + .then(function (list) { + quotes = list; + if (!quotes.length) { + textEl.textContent = '暂无内容'; + return; + } + buildDots(); + showQuote(0); + startTimer(); + }) + .catch(function () { + textEl.textContent = '加载失败,请稍后再试'; + }); } - // Auto-init all yiyan widgets document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('[data-aksrt-yiyan]').forEach(initYiyan); }); diff --git a/inc/bootstrap.php b/inc/bootstrap.php index cb5926db..c5e1091f 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -29,6 +29,7 @@ require_once AKSRT_DIR . '/inc/helpers/aksrt-flow.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-homepage.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-single-components.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-single.php'; +require_once AKSRT_DIR . '/inc/helpers/aksrt-yiyan.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-single-render.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-archive.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-archive-filters.php'; diff --git a/inc/classes/widgets/class-aksrt-yiyan-widget.php b/inc/classes/widgets/class-aksrt-yiyan-widget.php index 1df95f98..558e81a6 100644 --- a/inc/classes/widgets/class-aksrt-yiyan-widget.php +++ b/inc/classes/widgets/class-aksrt-yiyan-widget.php @@ -3,6 +3,7 @@ * AKSRT Yiyan Widget — 一言轮播组件 * * Fetches random quotes from hitokoto.cn API and displays with auto-rotation. + * Category values map to Hitokoto `c` letters a–l; empty = mixed random. * * @package AKSRT */ @@ -11,115 +12,142 @@ defined('ABSPATH') || exit; class AKSRT_Widget_Yiyan extends WP_Widget { - public function __construct() { - parent::__construct( - 'aksrt_yiyan', - __('一言轮播', 'aksrt'), - ['description' => __('显示随机一言/每日语录,支持自动轮播。', 'aksrt')] - ); - } + /** + * Hitokoto category codes → labels (API `c` param). + * + * @return array + */ + private static function type_labels(): array { + return [ + '' => __('随机', 'aksrt'), + 'a' => __('动画', 'aksrt'), + 'b' => __('漫画', 'aksrt'), + 'c' => __('游戏', 'aksrt'), + 'd' => __('文学', 'aksrt'), + 'e' => __('原创', 'aksrt'), + 'f' => __('来自网络', 'aksrt'), + 'g' => __('其他', 'aksrt'), + 'h' => __('影视', 'aksrt'), + 'i' => __('诗词', 'aksrt'), + 'j' => __('网易云', 'aksrt'), + 'k' => __('哲学', 'aksrt'), + 'l' => __('抖机灵', 'aksrt'), + ]; + } - public function widget($args, $instance): void { - $title = !empty($instance['title']) ? $instance['title'] : ''; - $count = !empty($instance['count']) ? max(3, min(20, (int) $instance['count'])) : 5; - $interval = !empty($instance['interval']) ? max(3, min(30, (int) $instance['interval'])) : 5; - $type = !empty($instance['type']) ? sanitize_text_field($instance['type']) : 'a'; - $icon = !empty($instance['icon']) ? $instance['icon'] : 'fas fa-quote-left'; + /** + * Sanitize stored type to a valid Hitokoto category letter or empty. + */ + private static function sanitize_type(string $type): string { + $type = strtolower(trim($type)); + return preg_match('/^[a-l]$/', $type) ? $type : ''; + } - echo $args['before_widget']; + public function __construct() { + parent::__construct( + 'aksrt_yiyan', + __('一言轮播', 'aksrt'), + ['description' => __('显示随机一言/每日语录,支持自动轮播。', 'aksrt')] + ); + } - if ($title) { - echo $args['before_title'] . esc_html($title) . $args['after_title']; - } + public function widget($args, $instance): void { + $title = !empty($instance['title']) ? $instance['title'] : ''; + $count = !empty($instance['count']) ? max(1, min(10, (int) $instance['count'])) : 5; + $interval = !empty($instance['interval']) ? max(3, min(30, (int) $instance['interval'])) : 5; + $type = self::sanitize_type((string) ($instance['type'] ?? '')); + $icon = !empty($instance['icon']) ? $instance['icon'] : 'fas fa-quote-left'; - $types_map = [ - 'a' => '动画', - 'b' => '漫画', - 'c' => '游戏', - 'd' => '文学', - 'e' => '原创', - 'f' => '来自网络', - 'g' => '其他', - 'h' => '影视', - 'i' => '诗词', - 'j' => '网易云', - 'k' => '哲学', - 'l' => '抖机灵', - ]; + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - $type_label = $types_map[$type] ?? '综合'; + if ($title) { + echo $args['before_title'] . esc_html($title) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } - ?> -
-
- -

加载中…

-

-
- -
- 'aksrt-yiyan', + 'type' => $type, + 'count' => $count, + 'interval' => $interval, + 'icon' => $icon, + ]); + } else { + // Fallback if single-render helpers are not loaded. + ?> +
> +
+ +

+

+
+ +
+ -

- - -

-

- - -

-

- - -

-

- - -

-

- - -

- +

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ -
+ 'article-yiyan yiyan-box aksrt-yiyan yiyan--footer', + ]); + ?> @@ -855,7 +859,11 @@ if (!function_exists('aksrt_single_render_article')) :
-
+ 'article-yiyan yiyan-box aksrt-yiyan yiyan--header theme-box text-center radius8 main-shadow', + ]); + ?> @@ -910,9 +918,9 @@ if (!function_exists('aksrt_single_render_after_box')) : $settings = is_array($ctx['settings'] ?? null) ? $ctx['settings'] : []; if (!empty($settings['yiyan_box'])) { - ?> -
- 'yiyan-box main-bg theme-box text-center box-body radius8 main-shadow aksrt-yiyan', + ]); } // Accept both `authordesc` (Settings DTO) and legacy `authordesc_s`. diff --git a/inc/helpers/aksrt-yiyan.php b/inc/helpers/aksrt-yiyan.php new file mode 100644 index 00000000..8b9426db --- /dev/null +++ b/inc/helpers/aksrt-yiyan.php @@ -0,0 +1,55 @@ + 1. + * + * @param array $args { + * @type string $class Extra CSS classes on the root. + * @type string $type Category letter a–l, or empty for random. + * @type int $count Number of quotes to load (1–10). + * @type int $interval Rotation interval in seconds. + * @type string $icon Optional FA icon class; empty = no icon. + * } + */ + function aksrt_render_yiyan_block(array $args = []): void { + $class = trim((string) ($args['class'] ?? 'aksrt-yiyan')); + $type_raw = strtolower(trim((string) ($args['type'] ?? ''))); + $type = preg_match('/^[a-l]$/', $type_raw) ? $type_raw : ''; + $count = max(1, min(10, (int) ($args['count'] ?? 5))); + $interval = max(3, min(30, (int) ($args['interval'] ?? 8))); + $icon = sanitize_text_field((string) ($args['icon'] ?? '')); + + if (strpos($class, 'aksrt-yiyan') === false) { + $class = trim($class . ' aksrt-yiyan'); + } + ?> +
> +
+ + + +

+

+
+ +
+