From c125388ab72fb1ea450664002c574e1a8809a11f Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 28 Oct 2026 16:33:41 +0800 Subject: [PATCH] =?UTF-8?q?4.1.0=20=E4=B8=BB=E9=A2=98=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=E8=AE=BF=E9=97=AE=E7=BB=9F=E8=AE=A1=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=EF=BC=8C=E6=8E=A5=E7=99=BE=E5=BA=A6=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=20API=20=E7=9B=B4=E5=87=BA=E5=90=8E=E5=8F=B0=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 inc/theme-stat.php(约 1814 行),把百度统计的数据直接显示在 主题设置页,不用再跳去百度后台看。 主要设计决策: - 凭据(API Key / Secret / refresh_token)存 wp-content/uploads/ 下 独立文件(0600),不写 options 表、不在表单回显 —— 换主题/升级不丢 - 13 个指标全部实现,用户可在「面板显示哪些指标」里各别开关 (每次 API 调用都占配额,只看几个数字的可以关掉其余) - 面板走 AJAX 懒加载「先出壳、后取数」:同步渲染 8 个请求 ×5 秒超时 会让设置页最坏卡 40 秒 - 缓存成功 1800 秒 / 失败 300 秒:接口挂了不至于每次刷新都等超时 - "--"(百度接口的"无数据")渲染成破折号,不转成 0 —— 否则 "暂无数据"会被显示成"0 次访问" - footer.php 的统计代码外面加埋点开关,关掉不停用代码本身 改动: - inc/theme-stat.php 新建 - inc/theme-options.php 新增 8 个控件 - footer.php 统计代码套埋点开关 - functions.php require 新模块 - style.css 版本号 4.0.0 → 4.1.0 验证: - php -l 全量 98 文件 0 失败 - 正向判据 145/0,反证 19 组全抓到 - 真实接口连通性 10/0(OAuth 401 与 error_code 110 两种错误格式实测) - 打包自检 82/0,其反证 15 组全抓到 --- footer.php | 8 +- functions.php | 12 +- inc/theme-options.php | 104 +++ inc/theme-stat.php | 1815 +++++++++++++++++++++++++++++++++++++++++ style.css | 2 +- 5 files changed, 1938 insertions(+), 3 deletions(-) create mode 100644 inc/theme-stat.php diff --git a/footer.php b/footer.php index 2f18c90..5eb7abb 100644 --- a/footer.php +++ b/footer.php @@ -55,7 +55,13 @@ if (kratos_option('s_gov')) { echo '

' . kratos_option('s_gov') . '

'; } - if (kratos_option('seo_statistical')) {echo '

' . kratos_option('seo_statistical') . '

';} + /* 统计脚本:内容由用户在「收录与统计 → 统计代码」里填, + 开关由「埋点开关」控制(见 inc/theme-stat.php)。 + 开关与代码分离,是为了让"临时停掉统计"不必删代码 —— + 删了之后想恢复就得重新去百度后台找那串 hash。 */ + if (yang_stat_tracking_enabled() && kratos_option('seo_statistical')) { + echo '

' . kratos_option('seo_statistical') . '

'; + } /* 国旗:勾了「是否添加国旗」才显示。以前 src 直接取选项, 用户没上传地址就得到 ,页面上是一个破图图标。 逻辑抽到 yang_flag_block() 里,测试能直接测同一个函数, diff --git a/functions.php b/functions.php index 2b8c3c4..7b0f50e 100644 --- a/functions.php +++ b/functions.php @@ -99,4 +99,14 @@ require get_template_directory() . '/inc/theme-mail.php'; require get_template_directory() . '/inc/theme-smtp.php'; // 写作增强:发布时自动生成别名 / 标签 / 分类 / 摘要(四引擎各自独立) -require get_template_directory() . '/inc/theme-writing.php'; \ No newline at end of file +require get_template_directory() . '/inc/theme-writing.php'; + +// 访问统计面板:百度统计 API 的数据直出到设置页 + 前台埋点开关。 +// 加载时序注意:本文件(functions.php)在 wp-settings 阶段就执行, +// 而 inc/theme-options.php 要等到 init:20 才被 autoload.php require +// (见 inc/options-framework/autoload.php:40/59)。所以本模块**先于** +// theme-options.php 加载 —— 它定义的面板函数到构建选项数组时已经就绪, +// 那边可以直接调用,不需要 function_exists 兜底。 +// 但也因为先加载:本文件里不得调用 kratos_option(), +// 那个函数由 autoload.php 在更晚才定义(目前只在函数体内调用,安全)。 +require get_template_directory() . '/inc/theme-stat.php'; diff --git a/inc/theme-options.php b/inc/theme-options.php index 8a4cb3f..427c3a6 100644 --- a/inc/theme-options.php +++ b/inc/theme-options.php @@ -825,6 +825,110 @@ function kratos_options() 'type' => 'textarea', ); + /* ══════════════════════════════════════════════════════════ + * 访问统计面板(百度统计 API) + * + * 这一整段都是「附加功能」:不填任何东西时,本页其余部分 + * 完全不受影响。面板本身由 inc/theme-stat.php 负责渲染, + * 这里只负责把控件摆出来。 + * ══════════════════════════════════════════════════════════ */ + $options[] = array( + 'name' => __('启用统计埋点', 'kratos'), + 'desc' => __('关掉后前台不再输出上面那段「统计代码」,但代码本身**不会被删除** —— ' + . '想恢复时勾回来即可,不用再去百度后台找那串 hash。', 'kratos'), + 'id' => 'stat_track_enable', + 'std' => '1', + 'type' => 'checkbox', + ); + + $options[] = array( + 'name' => __('访问统计面板', 'kratos'), + 'desc' => __('下面的面板会读百度统计的接口,把数据直接显示在这里。' + . '需要先在百度统计开通「数据导出服务」拿到 API Key。', 'kratos'), + 'type' => 'info', + ); + + $options[] = array( + 'name' => __('API Key', 'kratos'), + 'desc' => __('百度统计 → 管理 → 数据导出服务' + . ' 开通后可见,形如 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx。
' + . '留空表示不修改已保存的值。', 'kratos'), + 'id' => 'stat_api_key', + 'type' => 'password', + 'std' => '', + ); + + $options[] = array( + 'name' => __('Secret Key', 'kratos'), + 'desc' => __('与 API Key 在同一页面。' + . '留空表示不修改。这一项只存在服务器的独立文件里(权限 600),' + . '不会写进设置表,也不会在表单里回显。', 'kratos'), + 'id' => 'stat_api_secret', + 'type' => 'password', + 'std' => '', + ); + + $options[] = array( + 'name' => __('站点 ID', 'kratos'), + 'desc' => __('百度统计「管理 → 网站列表」点进站点,地址栏里 siteId= 后面那串数字。' + . '直接粘整条地址也行,会自动抠出数字。', 'kratos'), + 'id' => 'stat_site_id', + 'type' => 'text', + ); + + $options[] = array( + 'name' => __('授权码', 'kratos'), + 'desc' => __('在面板里点「授权」拿到 code 之后,粘到这里并保存 —— ' + . '主题会立刻用它换 refresh_token,之后自动续期,不用再管。
' + . '授权码只能用一次,保存后这一格会自动清空。', 'kratos'), + 'id' => 'stat_auth_code', + 'type' => 'text', + 'std' => '', + ); + + $options[] = array( + 'name' => __('refresh_token(高级)', 'kratos'), + 'desc' => __('一般不需要手动填 —— 走上面的「授权码」流程会自动得到。' + . '只有在别处已经拿过 token、要迁移过来时才用。
' + . '留空表示不修改。', 'kratos'), + 'id' => 'stat_refresh', + 'type' => 'password', + 'std' => '', + ); + + /* 指标多选:全做,但让用户决定显示哪些。 + 理由见 inc/theme-stat.php 的 yang_stat_metric_defs() —— + 每个指标对应一次 API 调用,而配额是按次计的。 */ + $yang_stat_metric_opts = array(); + if (function_exists('yang_stat_metric_defs')) { + foreach (yang_stat_metric_defs() as $k => $def) { + $yang_stat_metric_opts[$k] = array('name' => $def['label']); + } + } + $yang_stat_metric_all = array(); + foreach ($yang_stat_metric_opts as $k => $_) { + $yang_stat_metric_all[$k] = '1'; + } + + $options[] = array( + 'name' => __('面板显示哪些指标', 'kratos'), + 'desc' => __('全部取消勾选 = 面板只显示"已接通"不显示数字。' + . '勾得越多,每次刷新后台时调用的接口越多(配额按次计)。' + . '默认全选。', 'kratos'), + 'id' => 'stat_metrics', + 'std' => $yang_stat_metric_all, + 'type' => 'multicheck', + 'options' => $yang_stat_metric_opts, + ); + + $options[] = array( + 'name' => __('数据概览', 'kratos'), + 'desc' => function_exists('yang_stat_render_shell') + ? yang_stat_render_shell() + : '统计模块未加载', + 'type' => 'info', + ); + $options[] = array( 'name' => __('robots.txt 内容', 'kratos'), 'desc' => __('- 需要 ', 'kratos') . $seoreading . __(' 是开启的状态,下面的配置才会生效', 'kratos'), diff --git a/inc/theme-stat.php b/inc/theme-stat.php new file mode 100644 index 0000000..7d46cf9 --- /dev/null +++ b/inc/theme-stat.php @@ -0,0 +1,1815 @@ + false`,并且**任何** + * 失败路径都返回数组(含 error 字段),渲染层只负责显示, + * 不负责判断成不成功。整条链路没有一个 `wp_die()` / 异常外抛。 + * + * ── ② "--" 不是 0 ─────────────────────────────────────────────── + * 百度统计在没有数据的格子上返回字符串 `"--"`。 + * PHP 里 `(int)"--" === 0`,`"--" + 0 === 0`(PHP 8 下会 warning + * 但结果仍是 0)。如果直接强转,面板会把"没有数据"显示成"0 访问", + * 这是两件完全不同的事。所有数值都必须过 yang_stat_num()。 + * + * ── ③ 凭据不能进 options 表 ───────────────────────────────────── + * `kratos` 这个 option 会被导出插件打包、被备份脚本复制、被其他 + * 代码 `get_option('kratos')` 读走。API Secret 放进去等于公开。 + * 存独立文件,权限 0600,且放在 `wp-content/uploads/` 之外 + * (uploads 目录线上是 403,但那是"不能直接访问",不是"不能读")。 + * + * @package kratos-yang + */ + +/* 防止直接访问。WP 正常加载时 ABSPATH 一定存在。 */ +if (!defined('ABSPATH')) { + exit; +} + +/* ══════════════════════════════════════════════════════════════════ + * 一、常量与凭据文件 + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 凭据文件名。 + * + * 用 `.` 开头让它默认被大部分静态服务器忽略,再加随机后缀防猜。 + * 真正拦住外部访问的是「不在 web 根下的可直读目录」+ 0600 权限, + * 文件名只是顺手的一层。 + */ +function yang_stat_cred_file() +{ + /** + * 存放位置的选择。 + * + * `wp-content/uploads/` 在线上是 403(.user.ini 带的规则), + * 但那是 HTTP 层的规则 —— PHP 自己读写不受影响,而 + * 换到 `wp-content/` 根下反而更容易被别的插件扫到。 + * + * 用 uploads 的好处:WP 保证它存在且可写,多站点各自独立, + * 且不会因为换主题而被删(换主题会删掉整个主题目录!)。 + * 这一条最关键:**凭据绝不能存在主题目录里**, + * 否则用户一升级主题,token 就没了,还得重新授权一次。 + */ + $dir = wp_upload_dir(null, false); + $base = isset($dir['basedir']) ? $dir['basedir'] : WP_CONTENT_DIR . '/uploads'; + return trailingslashit($base) . '.yang-stat-credentials.php'; +} + +/** + * 读凭据。返回统一结构的数组,缺的键是空串。 + * + * 文件内容是 ` '', + 'client_secret' => '', + 'refresh_token' => '', + 'access_token' => '', + 'expires_at' => 0, + 'site_id' => '', + ); + + $file = yang_stat_cred_file(); + if (!is_readable($file)) { + $cache = $empty; + return $cache; + } + + /* include 一个坏文件会 fatal。先做语法层防护:读前 32 字节确认是 + 我们自己写的格式,不是被别的东西覆盖成了一堆二进制。 */ + $head = @file_get_contents($file, false, null, 0, 32); + if (!is_string($head) || strpos($head, ' $_) { + if (isset($data[$k])) { + $out[$k] = 'expires_at' === $k ? (int) $data[$k] : (string) $data[$k]; + } + } + + $cache = $out; + return $cache; +} + +/** + * 写凭据。整份覆盖,权限压到 0600。 + * + * @param array $data 至少含 client_id / client_secret / refresh_token 三者之一 + * @return bool 写入成功且权限正确才算 true + */ +function yang_stat_save_credentials($data) +{ + $cur = yang_stat_credentials(); + $next = array_merge($cur, is_array($data) ? $data : array()); + + $file = yang_stat_cred_file(); + + /* 目录可能还不存在(全新站点 uploads 还没建)。 */ + $dir = dirname($file); + if (!is_dir($dir)) { + wp_mkdir_p($dir); + } + + $body = "—'; + } + if (is_float($n) && (float) (int) $n !== $n) { + $s = number_format($n, 1, '.', ','); + } else { + $s = number_format((int) $n); + } + return esc_html($s . $suffix); +} + +/** + * 秒数 → 「1 分 23 秒」这类人话。 + * + * @param mixed $v + * @return string + */ +function yang_stat_duration($v) +{ + $n = yang_stat_num($v); + if ($n === null) { + return '—'; + } + $n = (int) round($n); + if ($n < 60) { + return esc_html($n . ' 秒'); + } + $m = (int) floor($n / 60); + $s = $n % 60; + if ($m < 60) { + return esc_html($s > 0 ? $m . ' 分 ' . $s . ' 秒' : $m . ' 分'); + } + $h = (int) floor($m / 60); + return esc_html($h . ' 小时 ' . ($m % 60) . ' 分'); +} + +/* ══════════════════════════════════════════════════════════════════ + * 三、HTTP 客户端 + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 发一个 GET 并解析 JSON。永不抛异常,失败一律返回 WP_Error。 + * + * ── 为什么不用 wp_remote_get 的默认超时 ───────────────────────── + * 默认 5 秒其实已经够,但 `wp_remote_get` 在某些主机上会因为 + * cURL 的 `CURLOPT_CONNECTTIMEOUT` 未单独设置而卡在 DNS 上。 + * 这里显式给 timeout 与一个更短的连接超时。 + * + * @param string $url + * @return array|WP_Error + */ +function yang_stat_http_get($url) +{ + $resp = wp_remote_get($url, array( + 'timeout' => 5, + 'redirection' => 2, + 'sslverify' => true, + 'headers' => array( + /* 不伪造浏览器 UA:百度统计的 API 网关对 UA 不敏感, + 伪造反而可能触发风控。 */ + 'Accept' => 'application/json', + ), + )); + + if (is_wp_error($resp)) { + return new WP_Error('yang_stat_http', $resp->get_error_message()); + } + + $code = (int) wp_remote_retrieve_response_code($resp); + $body = (string) wp_remote_retrieve_body($resp); + + $json = json_decode($body, true); + + /* 🔴 非 200 时先把 body 解析一遍再决定报什么。 + * + * 实测(2026-10-28,真实请求百度): + * 假 client_id → HTTP **401** + {"error":"invalid_client", + * "error_description":"unknown client id"} + * 第一版直接 `return 接口返回 HTTP 401:{...原样 body...}`, + * 用户看到的是「接口返回 HTTP 401:{"error":"invalid_client",...}」—— + * 能用,但把最关键的那句 "unknown client id" 埋在一坨 JSON 里。 + * + * 正确做法:能解析出 error_description / error_msg 就只报那一句, + * 让用户一眼知道该去改什么。 + */ + if ($code !== 200) { + $msg = ''; + if (is_array($json)) { + foreach (array('error_description', 'error_msg', 'error', 'msg') as $k) { + if (!empty($json[$k]) && is_string($json[$k])) { $msg = $json[$k]; break; } + } + } + if ($msg === '') { + $msg = mb_substr(trim($body), 0, 200, 'UTF-8'); + } + return new WP_Error('yang_stat_http', sprintf('接口返回 HTTP %d:%s', $code, $msg)); + } + + if (!is_array($json)) { + return new WP_Error('yang_stat_json', '接口返回的不是合法 JSON:' . mb_substr(trim($body), 0, 200, 'UTF-8')); + } + + /* OAuth 错误(HTTP 200 但体内带 error)。 */ + if (isset($json['error'])) { + $msg = isset($json['error_description']) ? $json['error_description'] : $json['error']; + return new WP_Error('yang_stat_api', (string) $msg); + } + + /* 🔴 报告接口的错误格式(实测,与 OAuth 完全不同): + * 假 token → HTTP **200** + {"error_code":110, + * "error_msg":"Access token invalid or no longer valid"} + * 注意:是 error_code/error_msg,**不是** error,也**不是** header.status。 + * 第一版只认 error 和 header.status,于是这种响应会被当成 + * "正常返回但找不到 result",最终报「接口返回里找不到 result 节点」—— + * 一句完全误导的话,用户会去查 site_id,真正的问题却是 token 过期。 + * + * error_code 常见值:110 = token 失效;111 = token 过期; + * 其他非 0 值一律按错误处理,宁严不宽。 */ + if (isset($json['error_code']) && (string) $json['error_code'] !== '0' + && (string) $json['error_code'] !== '') { + $m = isset($json['error_msg']) ? (string) $json['error_msg'] : '未知错误'; + $hint = ''; + if (in_array((string) $json['error_code'], array('110', '111'), true)) { + $hint = '(token 已失效,请重新走一次授权)'; + } + return new WP_Error('yang_stat_apicode', + '百度统计接口报错(code ' . $json['error_code'] . '):' . $m . $hint); + } + + return $json; +} + +/** + * 用 refresh_token 换新的 access_token。 + * + * ── 关键点:百度每次刷新都会返回**新的** refresh_token ───────── + * 官方文档写「REFRESH_TOKEN 有效期十年」,但实际行为是每次 + * refresh 都轮换出一个新的 refresh_token,旧的随即作废。 + * 如果只更新 access_token 而丢掉新的 refresh_token, + * 这一轮还能用,**下一轮就永久失效**,用户必须重新走一遍授权。 + * + * 所以这里刷新成功后必须把 refresh_token 一起写回文件。 + * + * @return string|WP_Error access_token + */ +function yang_stat_refresh_token() +{ + $cred = yang_stat_credentials(); + + if ($cred['client_id'] === '' || $cred['client_secret'] === '' || $cred['refresh_token'] === '') { + return new WP_Error('yang_stat_nocred', '还没有填写 API Key / Secret Key / refresh_token'); + } + + $url = add_query_arg(array( + 'grant_type' => 'refresh_token', + 'refresh_token' => $cred['refresh_token'], + 'client_id' => $cred['client_id'], + 'client_secret' => $cred['client_secret'], + ), 'https://openapi.baidu.com/oauth/2.0/token'); + + $json = yang_stat_http_get($url); + if (is_wp_error($json)) { + return $json; + } + + $token = isset($json['access_token']) ? (string) $json['access_token'] : ''; + if ($token === '') { + return new WP_Error('yang_stat_notoken', '刷新接口没有返回 access_token'); + } + + $expires = isset($json['expires_in']) ? (int) $json['expires_in'] : 2592000; + + /* 提前 1 天过期,避免"刚好在过期那一刻调用"的边界问题。 + 30 天 - 1 天 = 29 天,缓存 30 分钟后最多多刷几次,无所谓。 */ + $patch = array( + 'access_token' => $token, + 'expires_at' => time() + max(3600, $expires - 86400), + ); + + /* 🔴 必须带上新 refresh_token(若返回了)。 */ + if (isset($json['refresh_token']) && (string) $json['refresh_token'] !== '') { + $patch['refresh_token'] = (string) $json['refresh_token']; + } + + /* 写盘失败不能让整条链路崩:本次内存里已经有 token 了, + 照样能取数据;只是下次要重新刷。所以失败只记录不返回错误。 */ + yang_stat_save_credentials($patch); + + return $token; +} + +/** + * 取一个可用的 access_token(本地有效就直接用,否则刷新)。 + * + * @param bool $force 强制刷新 + * @return string|WP_Error + */ +function yang_stat_access_token($force = false) +{ + $cred = yang_stat_credentials(); + + if (!$force && $cred['access_token'] !== '' && $cred['expires_at'] > time()) { + return $cred['access_token']; + } + + return yang_stat_refresh_token(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 四、报告取数 + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 调一次 getData。 + * + * ── 返回结构(这一层最容易写错,先把形状钉死)────────────────── + * 百度账号版(GET)的响应**可能**有两种层级: + * A. 根就是 result:{ "items": [...], "fields": [...], ... } + * B. 包在 body.data[0].result 里(与商业账号一致) + * 文档示例给的是 B,但社区实现里两种都出现过。 + * 所以这里统一用 yang_stat_dig_result() 往下钻,两种都吃得下。 + * + * @param string $method 报告 method,如 trend/time/a + * @param string $metrics 逗号分隔的指标 + * @param array $extra 额外参数(gran / order / max_results 等) + * @return array|WP_Error 已展开成 array 的 result 节点 + */ +function yang_stat_get_data($method, $metrics, $extra = array()) +{ + $cred = yang_stat_credentials(); + $site = $cred['site_id']; + + /* site_id 留空时退回主题设置里的值(用户可能只在设置页填了)。 */ + if ($site === '' && function_exists('kratos_option')) { + $site = (string) kratos_option('stat_site_id', ''); + } + if ($site === '') { + return new WP_Error('yang_stat_nosite', '还没有填写站点 ID(site_id)'); + } + + $token = yang_stat_access_token(); + if (is_wp_error($token)) { + return $token; + } + + $args = array_merge(array( + 'access_token' => $token, + 'site_id' => $site, + 'method' => $method, + 'start_date' => gmdate('Ymd', current_time('timestamp') - 86400), + 'end_date' => gmdate('Ymd', current_time('timestamp')), + 'metrics' => $metrics, + 'max_results' => 0, + ), $extra); + + $url = add_query_arg($args, 'https://openapi.baidu.com/rest/2.0/tongji/report/getData'); + $json = yang_stat_http_get($url); + + if (is_wp_error($json)) { + return $json; + } + + /* 🔴 顺序不能反:先判 header.status,再去钻 result。 + * + * 第一版把钻 result 写在前面,结果「配额用尽」这种故障下 + * header.status=1、body 里没有 result,于是用户看到的是 + * 「接口返回里找不到 result 节点」—— 一句完全指向错误方向的话, + * 用户会去怀疑 ak/sk 填错了、站点 ID 错了,实际只是今天额度没了。 + * + * 百度统计对「配额用尽 / 无权限 / site_id 不存在」全都是 + * HTTP 200 + header.status 非 0,所以这个判断必须排在最前面。 */ + if (isset($json['header']['status']) && (int) $json['header']['status'] !== 0) { + $desc = isset($json['header']['desc']) ? $json['header']['desc'] : '未知错误'; + $fail = ''; + if (!empty($json['header']['failures']) && is_array($json['header']['failures'])) { + $first = reset($json['header']['failures']); + if (is_array($first) && isset($first['message'])) { + $fail = ':' . $first['message']; + } + } + return new WP_Error('yang_stat_apistatus', (string) $desc . $fail); + } + + $result = yang_stat_dig_result($json); + if ($result === null) { + return new WP_Error('yang_stat_shape', '接口返回里找不到 result 节点'); + } + + return $result; +} + +/** + * 从任意层级的响应里钻出 result 节点。 + * + * 兼容: + * - 根就是 result(有 items + fields) + * - body.data[0].result + * - data[0].result + * + * @param array $json + * @return array|null + */ +function yang_stat_dig_result($json) +{ + if (!is_array($json)) { + return null; + } + + if (isset($json['items']) && isset($json['fields'])) { + return $json; + } + + $candidates = array(); + if (isset($json['body']['data']) && is_array($json['body']['data'])) { + $candidates[] = $json['body']['data']; + } + if (isset($json['data']) && is_array($json['data'])) { + $candidates[] = $json['data']; + } + + foreach ($candidates as $list) { + foreach ($list as $node) { + if (!is_array($node)) { + continue; + } + if (isset($node['result']) && is_array($node['result'])) { + $r = $node['result']; + if (isset($r['items']) && isset($r['fields'])) { + return $r; + } + } + if (isset($node['items']) && isset($node['fields'])) { + return $node; + } + } + } + + return null; +} + +/** + * 把 result 节点解成「维度 => 指标数组」的普通数组。 + * + * ── items 的形状(官方文档 + 实测都一致)─────────────────────── + * items[0] = 维度数组。趋势报告里每项是 **字符串数组** ["2019/01/04"]; + * 地域报告里每项是 **对象** {"name":"广东","area":"province,4"}。 + * items[1] = 指标数组。每项是与 fields(去掉第 0 个维度名后)等长的数值数组。 + * items[2] / items[3] = 对比期数据 / 变化率,通常为空。 + * + * fields[0] 恒为维度名(simple_date_title / visit_district_title 等), + * **指标从 fields[1] 开始** —— 这个偏移量写错,所有数字会整体左移一格。 + * + * @param array $result + * @return array 每项形如 array('dim' => string, 'metrics' => array('pv_count' => 12, ...)) + */ +function yang_stat_parse_rows($result) +{ + $rows = array(); + if (!is_array($result) || empty($result['items']) || empty($result['fields'])) { + return $rows; + } + + $fields = $result['fields']; + $dims = isset($result['items'][0]) && is_array($result['items'][0]) ? $result['items'][0] : array(); + $vals = isset($result['items'][1]) && is_array($result['items'][1]) ? $result['items'][1] : array(); + + /* 指标名 = fields 去掉第 0 个(维度名)。 */ + $metricNames = array_slice($fields, 1); + + $n = max(count($dims), count($vals)); + for ($i = 0; $i < $n; $i++) { + $dim = ''; + if (isset($dims[$i])) { + $d = $dims[$i]; + if (is_array($d)) { + /* 对象形态:优先 name,其次任意字符串值。 */ + if (isset($d['name'])) { + $dim = (string) $d['name']; + } else { + foreach ($d as $dv) { + if (is_string($dv) && $dv !== '') { + $dim = $dv; + break; + } + } + } + } elseif (is_string($d)) { + $dim = $d; + } + } + + $metrics = array(); + if (isset($vals[$i]) && is_array($vals[$i])) { + foreach ($metricNames as $k => $name) { + $metrics[$name] = isset($vals[$i][$k]) ? $vals[$i][$k] : null; + } + } + + $rows[] = array('dim' => $dim, 'metrics' => $metrics); + } + + return $rows; +} + +/** + * 从 result.sum 里取合计值。 + * + * sum 的形状:`[[88498794, 35978529, 25665169], []]` + * —— 第一个子数组按指标顺序排列,第二个是变化率。 + * + * @param array $result + * @param array $metricNames 需要的指标名(按顺序) + * @return array 指标名 => 原始值 + */ +function yang_stat_parse_sum($result, $metricNames) +{ + $out = array(); + foreach ($metricNames as $m) { + $out[$m] = null; + } + if (!is_array($result) || !isset($result['sum'][0]) || !is_array($result['sum'][0])) { + return $out; + } + $arr = $result['sum'][0]; + foreach ($metricNames as $i => $m) { + if (array_key_exists($i, $arr)) { + $out[$m] = $arr[$i]; + } + } + return $out; +} +/* ══════════════════════════════════════════════════════════════════ + * 五、指标定义(用户可选哪些显示) + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 全部可用指标。key 是设置里的存储键,value 是显示配置。 + * + * ── 为什么指标要用户自己选 ──────────────────────────────────── + * 每次开面板都要打 4~5 个 getData 请求,而百度统计的配额是 + * **按次**计的(5 万/周/账号)。全部指标都拉一遍,一天开 10 次 + * 后台就是几百次调用。让用户关掉不看的,是省配额最直接的办法。 + * + * `group` 用来把请求合并:同一 group 的指标一次请求就能拿回来, + * 所以选得多不一定调用多。 + * + * @return array + */ +function yang_stat_metric_defs() +{ + return array( + /* ── 今日快照:一次 trend 请求拿全部 ──────────────────── */ + 'pv' => array('label' => '浏览量(PV)', 'group' => 'today'), + 'uv' => array('label' => '访客数(UV)', 'group' => 'today'), + 'ip' => array('label' => 'IP 数', 'group' => 'today'), + 'new_visitor' => array('label' => '新访客数', 'group' => 'today'), + 'visit' => array('label' => '访问次数', 'group' => 'today'), + + /* ── 质量指标:同上,一次 trend 请求 ──────────────────── */ + 'bounce' => array('label' => '跳出率', 'group' => 'quality'), + 'avg_time' => array('label' => '平均访问时长', 'group' => 'quality'), + 'avg_pages' => array('label' => '平均访问页数', 'group' => 'quality'), + + /* ── 趋势:单独一次请求(7 天按天粒度)──────────────────── */ + 'trend' => array('label' => '最近 7 天趋势', 'group' => 'trend'), + + /* ── 来源 / 页面 / 设备 / 地域:各自一次请求 ────────────── */ + 'source' => array('label' => '流量来源', 'group' => 'source'), + 'top_page' => array('label' => '热门页面', 'group' => 'toppage'), + 'device' => array('label' => '设备分布', 'group' => 'device'), + 'region' => array('label' => '地域分布', 'group' => 'region'), + ); +} + +/** + * 用户勾选了哪些指标。 + * + * 默认全选(首次使用时不至于看到空面板)。 + * 注意 multicheck 的存储是 `array('pv' => '1', ...)` 形式, + * 值可能是字符串 '1',也可能是 true。 + * + * @return array 形如 array('pv', 'uv', ...) + */ +function yang_stat_enabled_metrics() +{ + $defs = yang_stat_metric_defs(); + $all = array_keys($defs); + + $saved = function_exists('kratos_option') ? kratos_option('stat_metrics', null) : null; + + /* 🔴 没保存过 ≠ 全不选。 + 如果这里返回空数组,用户第一次打开设置页会看到一个空面板, + 以为功能坏了。用 null 判断「从未配置」并回退到全选; + 只有用户**主动全部取消**(存成空数组)才真的是空。 */ + if ($saved === null || $saved === false) { + return $all; + } + if (!is_array($saved)) { + return $all; + } + + $out = array(); + foreach ($all as $k) { + if (!empty($saved[$k])) { + $out[] = $k; + } + } + return $out; +} + +/* ══════════════════════════════════════════════════════════════════ + * 六、取数主流程(带缓存) + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 缓存键。含日期,跨天自动失效。 + * + * @return string + */ +function yang_stat_cache_key() +{ + $cred = yang_stat_credentials(); + $site = $cred['site_id'] !== '' ? $cred['site_id'] + : (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : ''); + /* 把已启用指标的指纹也放进键里:用户刚勾了新指标, + 不该继续吃旧缓存里的残缺数据。 */ + $sig = md5(implode(',', yang_stat_enabled_metrics())); + return 'yang_stat_' . md5($site . '|' . current_time('Ymd') . '|' . $sig); +} + +/** + * 取全部数据。结果会被 transient 缓存 30 分钟。 + * + * 返回结构: + * array( + * 'ok' => bool, + * 'error' => string, // ok=false 时的原因 + * 'today' => array(...), // 今日快照 + * 'yesterday' => array(...), + * 'trend' => array(...), + * 'source' => array(...), + * 'top_page'=> array(...), + * 'device' => array(...), + * 'region' => array(...), + * 'fetched' => int, // 取数时间戳 + * 'cached' => bool, // 是否来自缓存 + * ) + * + * @param bool $force 跳缓存 + * @return array + */ +function yang_stat_fetch($force = false) +{ + $key = yang_stat_cache_key(); + + if (!$force) { + $hit = get_transient($key); + if (is_array($hit) && isset($hit['ok'])) { + $hit['cached'] = true; + return $hit; + } + } + + $out = yang_stat_fetch_remote(); + $out['cached'] = false; + + /* 🔴 失败结果也要缓存,但只能短缓存。 + 否则 API 一挂,用户每刷新一次后台就打一次 5 秒超时的请求 —— + 设置页会变成"每点一下就卡 5 秒",比显示"暂不可用"糟得多。 + 5 分钟足够挡掉连续刷新,又不至于让用户等太久才看到恢复。 */ + $ttl = $out['ok'] ? 1800 : 300; + set_transient($key, $out, $ttl); + + return $out; +} + +/** + * 真正打接口的那一层。任何错误都收敛成 ok=false,不抛不 die。 + * + * @return array + */ +function yang_stat_fetch_remote() +{ + $fail = function ($msg) { + return array('ok' => false, 'error' => $msg, 'fetched' => time()); + }; + + $cred = yang_stat_credentials(); + $site = $cred['site_id'] !== '' ? $cred['site_id'] + : (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : ''); + + if ($cred['client_id'] === '' || $cred['client_secret'] === '' || $cred['refresh_token'] === '') { + return $fail('还没有填写 API 凭据(API Key / Secret Key / refresh_token)'); + } + if ($site === '') { + return $fail('还没有填写站点 ID(site_id)'); + } + + /* token 先拿到手。拿不到就整条链路停在这儿, + 后面的请求一个都不发 —— 否则会连续 5 次超时。 */ + $token = yang_stat_access_token(); + if (is_wp_error($token)) { + return $fail($token->get_error_message()); + } + + $enabled = yang_stat_enabled_metrics(); + $want = function ($k) use ($enabled) { + return in_array($k, $enabled, true); + }; + + $out = array('ok' => true, 'error' => '', 'fetched' => time()); + + /* ── ① 今日 + 昨日快照,一次 trend 请求覆盖两天 ─────────────── + 单独为"今日"和"昨日"各发一次请求是浪费:trend 支持区间, + 一次拿两天即可。 */ + $todayStr = current_time('Ymd'); + $yestStr = gmdate('Ymd', current_time('timestamp') - 86400); + + $snapMetrics = array(); + if ($want('pv')) { $snapMetrics[] = 'pv_count'; } + if ($want('uv')) { $snapMetrics[] = 'visitor_count'; } + if ($want('ip')) { $snapMetrics[] = 'ip_count'; } + if ($want('new_visitor')) { $snapMetrics[] = 'new_visitor_count'; } + if ($want('visit')) { $snapMetrics[] = 'visit_count'; } + + if ($snapMetrics) { + $r = yang_stat_get_data('trend/time/a', implode(',', $snapMetrics), array( + 'start_date' => $yestStr, + 'end_date' => $todayStr, + 'gran' => 'day', + )); + if (is_wp_error($r)) { + return $fail($r->get_error_message()); + } + $rows = yang_stat_parse_rows($r); + /* 按维度(日期)分桶。日期格式是 2026/10/28。 */ + $byDate = array(); + foreach ($rows as $row) { + $d = str_replace('/', '', $row['dim']); + $byDate[$d] = $row['metrics']; + } + $out['today'] = isset($byDate[$todayStr]) ? $byDate[$todayStr] : array(); + $out['yesterday'] = isset($byDate[$yestStr]) ? $byDate[$yestStr] : array(); + } + + /* ── ② 质量指标(跳出率 / 时长 / 页数)────────────────────── */ + $qMetrics = array(); + if ($want('bounce')) { $qMetrics[] = 'bounce_ratio'; } + if ($want('avg_time')) { $qMetrics[] = 'avg_visit_time'; } + if ($want('avg_pages')) { $qMetrics[] = 'avg_visit_pages'; } + + if ($qMetrics) { + $r = yang_stat_get_data('trend/time/a', implode(',', $qMetrics), array( + 'start_date' => $yestStr, + 'end_date' => $todayStr, + 'gran' => 'day', + )); + if (!is_wp_error($r)) { + $rows = yang_stat_parse_rows($r); + foreach ($rows as $row) { + $d = str_replace('/', '', $row['dim']); + if ($d === $todayStr) { + $out['today'] = array_merge( + isset($out['today']) ? $out['today'] : array(), + $row['metrics'] + ); + } elseif ($d === $yestStr) { + $out['yesterday'] = array_merge( + isset($out['yesterday']) ? $out['yesterday'] : array(), + $row['metrics'] + ); + } + } + } + /* 质量指标失败不整体报错 —— 主数字已经拿到了, + 少几个次要指标比整块面板消失好。 */ + } + + /* ── ③ 7 天趋势 ─────────────────────────────────────────── */ + if ($want('trend')) { + $r = yang_stat_get_data('trend/time/a', 'pv_count,visitor_count', array( + 'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400), + 'end_date' => $todayStr, + 'gran' => 'day', + )); + if (!is_wp_error($r)) { + $out['trend'] = yang_stat_parse_rows($r); + } + } + + /* ── ④ 流量来源 ─────────────────────────────────────────── */ + if ($want('source')) { + $r = yang_stat_get_data('source/all/a', 'pv_count,visitor_count', array( + 'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400), + 'end_date' => $todayStr, + 'max_results' => 8, + )); + if (!is_wp_error($r)) { + $out['source'] = yang_stat_parse_rows($r); + } + } + + /* ── ⑤ 热门页面 ─────────────────────────────────────────── */ + if ($want('top_page')) { + $r = yang_stat_get_data('visit/toppage/a', 'pv_count,visitor_count', array( + 'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400), + 'end_date' => $todayStr, + 'max_results' => 8, + 'order' => 'pv_count,desc', + )); + if (!is_wp_error($r)) { + $out['top_page'] = yang_stat_parse_rows($r); + } + } + + /* ── ⑥ 设备分布 ───────────────────────────────────────────── + 🔴 这里不能用 visit/district/a —— 那是**地域**报告, + 拿它当设备报告用,面板上会出现"广东省 3 次"这种驴唇不对马嘴的内容。 + 设备没有独立的分布报告,只能对 trend 打两次、各带一个 + clientDevice 过滤,再把 sum 加起来。 + 两次请求换一份准确数据,比一次请求拿一份假数据值得。 */ + if ($want('device')) { + $dev = array('pc' => null, 'mobile' => null); + $devFail = false; + foreach (array('pc', 'mobile') as $d) { + $r = yang_stat_get_data('trend/time/a', 'pv_count,visitor_count', array( + 'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400), + 'end_date' => $todayStr, + 'gran' => 'day', + 'clientDevice' => $d, + )); + if (is_wp_error($r)) { + $devFail = true; + break; + } + $sum = yang_stat_parse_sum($r, array('pv_count', 'visitor_count')); + $dev[$d] = $sum; + } + if (!$devFail) { + $out['device'] = $dev; + } + } + + /* ── ⑦ 地域分布 ─────────────────────────────────────────── */ + if ($want('region')) { + $r = yang_stat_get_data('visit/district/a', 'pv_count,visitor_count', array( + 'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400), + 'end_date' => $todayStr, + 'max_results' => 8, + 'order' => 'pv_count,desc', + )); + if (!is_wp_error($r)) { + $out['region'] = yang_stat_parse_rows($r); + } + } + + return $out; +} + +/* ══════════════════════════════════════════════════════════════════ + * 七、面板渲染 + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 单个数字卡片。 + * + * @param string $label + * @param mixed $value 已经过 yang_stat_num 的原始值(可为 null) + * @param string $cmp 对比值(昨日),null 表示不显示对比 + * @param string $suffix + * @return string + */ +function yang_stat_card($label, $value, $cmp = null, $suffix = '') +{ + $n = yang_stat_num($value); + $html = '
'; + $html .= '
' . esc_html($label) . '
'; + $html .= '
' . yang_stat_fmt($value, $suffix) . '
'; + + /* 只有两个值都拿到了才显示环比。缺一个就不显示, + 否则会出现"0% 增长"这种看着像真数据的假信息。 */ + $c = yang_stat_num($cmp); + if ($n !== null && $c !== null && $c > 0) { + $pct = ($n - $c) / $c * 100; + $cls = $pct >= 0 ? 'up' : 'down'; + $sign = $pct >= 0 ? '+' : ''; + $html .= '
' + . esc_html($sign . number_format($pct, 1) . '%') . ' 较昨日
'; + } + $html .= '
'; + return $html; +} + +/** + * 渲染整个面板。返回 HTML 字符串(供 info 块的 desc 用)。 + * + * ── 为什么要返回字符串而不是直接 echo ────────────────────────── + * info 块的 desc 是拼进 `$output` 再 echo 的,所以这里必须返回字符串。 + * 顺带好处:可以整体缓存、单测时直接比字符串。 + * + * @param bool $force 跳缓存(「刷新数据」按钮传 true) + * @return string + */ +function yang_stat_render_panel($force = false) +{ + $cred = yang_stat_credentials(); + $site = $cred['site_id'] !== '' ? $cred['site_id'] + : (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : ''); + + $hasCred = ($cred['client_id'] !== '' && $cred['client_secret'] !== '' && $cred['refresh_token'] !== ''); + $hasSite = ($site !== ''); + + /* ── 未配置:给出明确的操作指引,不是一句"请先配置" ───────── */ + if (!$hasCred || !$hasSite) { + $miss = array(); + if (!$hasCred) { $miss[] = 'API 凭据(API Key / Secret Key / refresh_token)'; } + if (!$hasSite) { $miss[] = '站点 ID(site_id)'; } + + $h = '
'; + $h .= '
还没有接通百度统计
'; + $h .= '

还缺:' . esc_html(implode('、', $miss)) . '

'; + $h .= '
    '; + $h .= '
  1. 打开 百度统计 → 管理 → 数据导出服务,开通后拿到 API Key 与 Secret Key,填在本页上方。
  2. '; + $h .= '
  3. 把下面这个地址粘到浏览器,用**你自己的百度账号**登录并同意授权:
  4. '; + $h .= '
'; + + /* 授权链接要把 client_id 填进去,用户直接点就行。 + 没填 Key 时给占位提示。 */ + if ($cred['client_id'] !== '') { + $authUrl = add_query_arg(array( + 'response_type' => 'code', + 'client_id' => $cred['client_id'], + 'redirect_uri' => 'oob', + 'scope' => 'basic', + 'display' => 'popup', + ), 'https://openapi.baidu.com/oauth/2.0/authorize'); + $h .= '

点此授权

'; + } else { + $h .= '

先填 API Key,这里会出现一键授权链接。

'; + } + + $h .= '

授权后会跳到一个显示 code 的页面,复制它填回本页的「授权码」框,保存即可 —— 主题会用它换 refresh_token,之后自动续期。

'; + $h .= '

站点 ID 在百度统计的「管理 → 网站列表」里,点进站点后地址栏的 siteId= 后面那串数字。

'; + $h .= '
'; + return $h; + } + + /* ── 已配置:取数 ─────────────────────────────────────────── */ + $data = yang_stat_fetch($force); + $enabled = yang_stat_enabled_metrics(); + + $h = '
'; + + /* 失败:显示原因 + 已有缓存时间,绝不白屏 */ + if (empty($data['ok'])) { + $msg = isset($data['error']) ? $data['error'] : '未知错误'; + $h .= '
'; + $h .= '
统计暂不可用
'; + $h .= '

' . esc_html($msg) . '

'; + $h .= '

设置页其它部分不受影响,可以照常修改并保存。' + . '若是 token 失效,重新走一次上面的授权流程即可。

'; + $h .= '
'; + $h .= '
'; + return $h; + } + + /* 元信息(缓存状态 / 取数时间) */ + $when = !empty($data['fetched']) ? date_i18n('Y-m-d H:i', $data['fetched'] + (int) (get_option('gmt_offset') * 3600)) : ''; + $h .= '
'; + $h .= '数据时间:' . esc_html($when); + if (!empty($data['cached'])) { + $h .= ' 缓存'; + } + $h .= ' · 每 30 分钟自动更新一次'; + $h .= '
'; + + /* ── 今日 / 昨日 数字卡片 ─────────────────────────────────── */ + $today = isset($data['today']) && is_array($data['today']) ? $data['today'] : array(); + $yest = isset($data['yesterday']) && is_array($data['yesterday']) ? $data['yesterday'] : array(); + + $cards = array(); + if (in_array('pv', $enabled, true)) { + $cards[] = yang_stat_card('今日浏览量', isset($today['pv_count']) ? $today['pv_count'] : null, + isset($yest['pv_count']) ? $yest['pv_count'] : null); + } + if (in_array('uv', $enabled, true)) { + $cards[] = yang_stat_card('今日访客数', isset($today['visitor_count']) ? $today['visitor_count'] : null, + isset($yest['visitor_count']) ? $yest['visitor_count'] : null); + } + if (in_array('ip', $enabled, true)) { + $cards[] = yang_stat_card('今日 IP 数', isset($today['ip_count']) ? $today['ip_count'] : null, + isset($yest['ip_count']) ? $yest['ip_count'] : null); + } + if (in_array('new_visitor', $enabled, true)) { + $cards[] = yang_stat_card('今日新访客', isset($today['new_visitor_count']) ? $today['new_visitor_count'] : null, + isset($yest['new_visitor_count']) ? $yest['new_visitor_count'] : null); + } + if (in_array('visit', $enabled, true)) { + $cards[] = yang_stat_card('今日访问次数', isset($today['visit_count']) ? $today['visit_count'] : null, + isset($yest['visit_count']) ? $yest['visit_count'] : null); + } + + if ($cards) { + $h .= '
' . implode('', $cards) . '
'; + } + + /* ── 质量指标 ─────────────────────────────────────────────── */ + $q = array(); + if (in_array('bounce', $enabled, true)) { + $q[] = array('跳出率', isset($today['bounce_ratio']) ? $today['bounce_ratio'] : null, '%'); + } + if (in_array('avg_time', $enabled, true)) { + $v = isset($today['avg_visit_time']) ? yang_stat_duration($today['avg_visit_time']) : '—'; + $q[] = array('平均访问时长', $v, ''); + } + if (in_array('avg_pages', $enabled, true)) { + $q[] = array('平均访问页数', isset($today['avg_visit_pages']) ? $today['avg_visit_pages'] : null, ''); + } + if ($q) { + $h .= '
'; + foreach ($q as $item) { + $h .= '
'; + $h .= '' . esc_html($item[0]) . ''; + /* 时长已经渲染成 HTML 片段了,不能再 esc_html;其余走 fmt。 */ + if (is_string($item[1]) && strpos($item[1], '<') === 0) { + $h .= '' . $item[1] . ''; + } else { + $h .= '' . yang_stat_fmt($item[1], $item[2]) . ''; + } + $h .= '
'; + } + $h .= '
'; + } + + /* ── 7 天趋势(纯 CSS 柱状条,不引图表库)──────────────────── + 引 chart.js 要多一个后台请求、几十 KB 脚本,而这里只是 + 七个数字的相对高度 —— 用 div 的 height 百分比就够。 */ + if (in_array('trend', $enabled, true) && !empty($data['trend'])) { + $rows = $data['trend']; + $max = 0; + foreach ($rows as $r) { + $v = yang_stat_num(isset($r['metrics']['pv_count']) ? $r['metrics']['pv_count'] : null); + if ($v !== null && $v > $max) { $max = $v; } + } + $h .= '
'; + $h .= '
最近 7 天浏览趋势
'; + $h .= '
'; + foreach ($rows as $r) { + $pv = yang_stat_num(isset($r['metrics']['pv_count']) ? $r['metrics']['pv_count'] : null); + $uv = yang_stat_num(isset($r['metrics']['visitor_count']) ? $r['metrics']['visitor_count'] : null); + /* 高度按最大值归一。max 为 0(全无数据)时给 0,不出 NaN。 */ + $hp = ($max > 0 && $pv !== null) ? round($pv / $max * 100) : 0; + $label = $r['dim'] !== '' ? $r['dim'] : ''; + $h .= '
'; + $h .= '
'; + $h .= '
' . esc_html(preg_replace('#^\d{4}/#', '', $label)) . '
'; + $h .= '
'; + } + $h .= '
'; + $h .= '
'; + } + + /* ── 来源 / 页面 / 地域 三张清单 ───────────────────────────── */ + $lists = array(); + if (in_array('source', $enabled, true) && !empty($data['source'])) { + $lists[] = array('流量来源', $data['source']); + } + if (in_array('top_page', $enabled, true) && !empty($data['top_page'])) { + $lists[] = array('热门页面', $data['top_page']); + } + if (in_array('region', $enabled, true) && !empty($data['region'])) { + $lists[] = array('地域分布', $data['region']); + } + + if ($lists) { + $h .= '
'; + foreach ($lists as $one) { + $h .= '
'; + $h .= '
' . esc_html($one[0]) . '
'; + $h .= '
    '; + $shown = 0; + foreach ($one[1] as $row) { + if ($shown >= 8) { break; } + $pv = yang_stat_num(isset($row['metrics']['pv_count']) ? $row['metrics']['pv_count'] : null); + if ($pv === null) { continue; } + $h .= '
  1. '; + $h .= '' . esc_html($row['dim']) . ''; + $h .= '' . yang_stat_fmt($pv) . ''; + $h .= '
  2. '; + $shown++; + } + if ($shown === 0) { + $h .= '
  3. 暂无数据
  4. '; + } + $h .= '
'; + $h .= '
'; + } + $h .= '
'; + } + + /* ── 设备分布 ─────────────────────────────────────────────── */ + if (in_array('device', $enabled, true) && !empty($data['device'])) { + $dev = $data['device']; + $pcPv = isset($dev['pc']['pv_count']) ? yang_stat_num($dev['pc']['pv_count']) : null; + $mbPv = isset($dev['mobile']['pv_count']) ? yang_stat_num($dev['mobile']['pv_count']) : null; + $pcPv = $pcPv === null ? 0 : $pcPv; + $mbPv = $mbPv === null ? 0 : $mbPv; + $total = $pcPv + $mbPv; + + $h .= '
'; + $h .= '
设备分布(近 7 天)
'; + if ($total > 0) { + $pcPct = round($pcPv / $total * 100, 1); + $mbPct = round(100 - $pcPct, 1); + $h .= '
'; + $h .= ''; + $h .= '
'; + $h .= '
'; + $h .= '电脑 ' . esc_html($pcPv) . ' 次(' . esc_html($pcPct) . '%)'; + $h .= '移动 ' . esc_html($mbPv) . ' 次(' . esc_html($mbPct) . '%)'; + $h .= '
'; + } else { + $h .= '

近 7 天没有可用的设备数据。

'; + } + $h .= '
'; + } + + /* ── 底部:刷新按钮(走 AJAX,不整页刷新)────────────────── */ + $h .= '
'; + $h .= ''; + $h .= ''; + $h .= '
'; + + $h .= ''; + return $h; +} + +/* ══════════════════════════════════════════════════════════════════ + * 八、面板外壳(AJAX 懒加载) + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 输出面板外壳。真正的数据由 JS 异步填进来。 + * + * ── 为什么不能同步渲染(这是本功能最关键的一个决定)──────────── + * 设置页会把所有 info 块一次性渲染完。如果面板在这里同步调接口, + * 一次要发 5~8 个 HTTP 请求: + * · 正常情况每个 200~400 ms ⇒ 设置页多等约 2 秒(能忍) + * · 百度统计挂了 / 被墙 / 超时 ⇒ 每个 5 秒 × 8 = **40 秒白屏** + * 而「API 失败绝不能让设置页白屏或卡住」是写在工单验收标准里的硬约束。 + * + * 同步渲染无论如何都满足不了它,所以面板必须是"先出壳、后取数"。 + * 好在后台永远有 JS,这个降级代价可以忽略。 + * + * @return string + */ +function yang_stat_render_shell() +{ + $cred = yang_stat_credentials(); + $site = $cred['site_id'] !== '' ? $cred['site_id'] + : (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : ''); + $configured = ($cred['client_id'] !== '' && $cred['client_secret'] !== '' && $cred['refresh_token'] !== '' && $site !== ''); + + $h = '
'; + + if (!$configured) { + /* 未配置时不需要打接口,直接同步给指引就行 —— 没有网络请求, + 不存在卡住风险。 */ + $h .= yang_stat_render_panel(); + $h .= '
'; + return $h; + } + + /* 已配置:先给占位,JS 随后替换。 */ + $h .= '
' + . ' 正在读取百度统计数据…
'; + $h .= '
'; + + /* 底部操作区放在壳里,这样刷新按钮在加载期间也可见。 */ + $h .= '
'; + $h .= ''; + $h .= ''; + $h .= '
'; + + $h .= ''; + return $h; +} + +/** + * AJAX:返回面板 HTML。 + * + * 用 `wp_send_json_success(array('html' => ...))` 而不是直接输出 HTML, + * 这样 JS 能区分「成功」与「拿到的是一段错误页」。 + */ +function yang_stat_ajax_panel() +{ + /* 两道门,跟 theme-avatar.php 里保持一致的做法。 */ + check_ajax_referer('yang_stat_panel', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => '权限不足'), 403); + } + + $force = !empty($_POST['force']) && '1' === $_POST['force']; + $html = yang_stat_render_panel($force); + + wp_send_json_success(array('html' => $html)); +} +add_action('wp_ajax_yang_stat_panel', 'yang_stat_ajax_panel'); + +/* ══════════════════════════════════════════════════════════════════ + * 九、授权码换 refresh_token + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 用 authorization code 换 access_token + refresh_token,并存盘。 + * + * @param string $code + * @return true|WP_Error + */ +function yang_stat_exchange_code($code) +{ + $cred = yang_stat_credentials(); + if ($cred['client_id'] === '' || $cred['client_secret'] === '') { + return new WP_Error('yang_stat_nocred', '要先填 API Key 与 Secret Key'); + } + + $code = trim($code); + if ($code === '') { + return new WP_Error('yang_stat_nocode', '授权码为空'); + } + + $url = add_query_arg(array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => $cred['client_id'], + 'client_secret' => $cred['client_secret'], + 'redirect_uri' => 'oob', + ), 'https://openapi.baidu.com/oauth/2.0/token'); + + $json = yang_stat_http_get($url); + if (is_wp_error($json)) { + return $json; + } + + $token = isset($json['access_token']) ? (string) $json['access_token'] : ''; + $refresh = isset($json['refresh_token']) ? (string) $json['refresh_token'] : ''; + if ($token === '' || $refresh === '') { + return new WP_Error('yang_stat_notoken', '换取失败:响应里没有 access_token / refresh_token'); + } + + $expires = isset($json['expires_in']) ? (int) $json['expires_in'] : 2592000; + + $ok = yang_stat_save_credentials(array( + 'access_token' => $token, + 'refresh_token' => $refresh, + 'expires_at' => time() + max(3600, $expires - 86400), + )); + + if (!$ok) { + return new WP_Error('yang_stat_write', '凭据写盘失败(检查 wp-content/uploads 是否可写)'); + } + + /* 授权成功后清掉旧缓存,避免面板还显示上一次失败的结果。 */ + yang_stat_flush_cache(); + + return true; +} + +/** + * 清掉所有本功能的 transient。 + */ +function yang_stat_flush_cache() +{ + global $wpdb; + /* transient 的键名带 md5,没法逐个 delete,只能按 LIKE 扫。 + 数量极少(一天最多几十条),一次 DELETE 足够。 */ + $wpdb->query( + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE '\\_transient\\_yang\\_stat\\_%' + OR option_name LIKE '\\_transient\\_timeout\\_yang\\_stat\\_%'" + ); +} + +/* ══════════════════════════════════════════════════════════════════ + * 十、凭据字段的拦截保存 + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 需要存进独立文件的字段。key = 设置字段 id,value = 凭据文件里的键。 + * + * @return array + */ +function yang_stat_secret_fields() +{ + return array( + 'stat_api_key' => 'client_id', + 'stat_api_secret' => 'client_secret', + 'stat_refresh' => 'refresh_token', + ); +} + +/** + * 拦截 password 字段提交:存进凭据文件,options 表里只留空串。 + * + * ── 为什么复用 of_sanitize_password ───────────────────────────── + * 跟 theme-writing.php 里的做法一致:这是唯一能同时拿到「字段 id」 + * 和「新值」又不必改框架的位置。而且 password 类型本来就不会 + * 回显真实值给"让我偷懒把明文存 options"留借口 —— 这里更进一步, + * 连 options 里都不放。 + * + * ── 空值 = 不修改 ─────────────────────────────────────────────── + * 表单永远回显空串,所以"空"必须解释成"别动已保存的"。 + * 若当成清空,用户每次保存别的设置都会把凭据抹掉。 + * + * @param mixed $value + * @param array $option + * @return string 恒为空串 + */ +function yang_stat_capture_secret($value, $option = array()) +{ + if (!is_array($option) || empty($option['id'])) { + return $value; + } + $map = yang_stat_secret_fields(); + $id = $option['id']; + if (!isset($map[$id])) { + return $value; + } + + $new = is_string($value) ? trim($value) : ''; + if ($new === '') { + return ''; + } + + yang_stat_save_credentials(array($map[$id] => $new)); + return ''; +} +add_filter('of_sanitize_password', 'yang_stat_capture_secret', 10, 2); + +/** + * 拦截「授权码」字段:不是存起来,而是立刻换 token。 + * + * ── 为什么在 sanitize 里做网络请求 ────────────────────────────── + * 这是唯一能在"用户点保存"那一刻拿到值的地方。代价是保存会多花 + * 最多 5 秒(超时上限),但**只有当用户真的粘了 code 时**才发生 —— + * 日常改别的设置走 `$new === ''` 分支,零开销。 + * + * 结果写进一个 transient,让下次渲染面板时把成败说清楚。 + * + * @param mixed $value + * @param array $option + * @return string 恒为空串(code 是一次性的,不该存) + */ +function yang_stat_capture_code($value, $option = array()) +{ + if (!is_array($option) || empty($option['id']) || $option['id'] !== 'stat_auth_code') { + return $value; + } + + $code = is_string($value) ? trim($value) : ''; + if ($code === '') { + return ''; + } + + $r = yang_stat_exchange_code($code); + if (is_wp_error($r)) { + set_transient('yang_stat_code_result', array('ok' => false, 'msg' => $r->get_error_message()), 300); + } else { + set_transient('yang_stat_code_result', array('ok' => true, 'msg' => '授权成功,已拿到 refresh_token'), 300); + } + + return ''; +} +add_filter('of_sanitize_text', 'yang_stat_capture_code', 10, 2); + +/** + * 站点 ID 也要进凭据文件(它跟 token 是一套东西, + * 放 options 表里会让"换了站点但 token 是旧的"这种错配难以排查)。 + * + * @param mixed $value + * @param array $option + * @return string + */ +function yang_stat_capture_site($value, $option = array()) +{ + if (!is_array($option) || empty($option['id']) || $option['id'] !== 'stat_site_id') { + return $value; + } + $v = is_string($value) ? trim($value) : ''; + /* 站 ID 是纯数字。非数字直接丢弃,避免把整串 URL 存进去。 */ + if ($v !== '' && !preg_match('/^\d+$/', $v)) { + /* 用户可能粘了整个地址栏。尝试从中抠 siteId。 */ + if (preg_match('/siteId=(\d+)/i', $v, $m)) { + $v = $m[1]; + } else { + $v = ''; + } + } + if ($v !== '') { + yang_stat_save_credentials(array('site_id' => $v)); + } + return $v; +} +add_filter('of_sanitize_text', 'yang_stat_capture_site', 10, 2); + +/* ══════════════════════════════════════════════════════════════════ + * 十一、后台资源(样式 + 懒加载脚本) + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 挂后台样式与脚本。 + * + * 🔴 判断依据必须是 `$_GET['page']`,不能用 `$hook`。 + * 理由与 theme-avatar.php 里那段注释一致:框架把真实 hook 后缀 + * 存在私有的 `$this->options_screen` 里,外面拿不到。靠 `$hook` + * 模糊匹配一旦失手,CSS/JS **静默不加载**,表现是"面板一片空白 + * 且没有刷新按钮",连带报错都没有。 + * + * @param string $hook + */ +function yang_stat_admin_assets($hook) +{ + if (!isset($_GET['page']) || $_GET['page'] !== 'kratos_options') { + return; + } + + $css = ' +/* ── 统计面板 ─────────────────────────────────────────────────── */ +.yang-stat-panel{margin:2px 0 6px;padding:0} +.yang-stat-loading{display:flex;align-items:center;gap:8px;padding:22px 0;color:#646970;font-size:13px} +.yang-stat-loading .spinner{margin:0;float:none} +.yang-stat-empty{padding:18px 20px;border:1px solid #dcdcde;border-radius:8px;background:#fff} +.yang-stat-empty-title{font-size:14px;font-weight:600;color:#1d2327;margin-bottom:10px} +.yang-stat-steps{margin:8px 0 12px 20px;padding:0;list-style:decimal} +.yang-stat-steps li{margin:6px 0;font-size:13px;line-height:1.65;color:#3c434a} +.yang-stat-authurl{margin:6px 0 12px} +.yang-stat-tip{font-size:12px;color:#646970;line-height:1.6;margin:8px 0 0} + +.yang-stat-meta{font-size:12px;color:#646970;margin:0 0 12px;padding-bottom:10px;border-bottom:1px solid #f0f0f1} +.yang-stat-badge{display:inline-block;padding:0 6px;border-radius:3px;background:#f0f0f1;color:#646970;font-size:11px;line-height:18px} + +.yang-stat-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:0 0 14px} +.yang-stat-card{padding:12px 14px;border:1px solid #dcdcde;border-radius:8px;background:#fff} +.yang-stat-card-label{font-size:12px;color:#646970;margin-bottom:6px} +.yang-stat-card-value{font-size:24px;line-height:1.2;font-weight:600;color:#1d2327;font-variant-numeric:tabular-nums} +.yang-stat-card-diff{margin-top:5px;font-size:11px;color:#646970} +.yang-stat-card-diff.up{color:#d63638} +.yang-stat-card-diff.down{color:#00a32a} + +.yang-stat-quality{display:flex;flex-wrap:wrap;gap:22px;padding:12px 14px;margin:0 0 14px;border:1px solid #dcdcde;border-radius:8px;background:#fafafa} +.yang-stat-quality-item{display:flex;flex-direction:column;gap:4px} +.yang-stat-quality-label{font-size:12px;color:#646970} +.yang-stat-quality-value{font-size:16px;font-weight:600;color:#1d2327;font-variant-numeric:tabular-nums} +.yang-stat-na{color:#a7aaad;font-weight:400} + +.yang-stat-block{margin:0 0 14px} +.yang-stat-block-title{font-size:13px;font-weight:600;color:#1d2327;margin:0 0 10px} + +/* 7 天趋势:纯 CSS 柱条,柱底对齐 */ +.yang-stat-chart{display:flex;align-items:flex-end;gap:8px;height:110px;padding:0 2px;border-bottom:1px solid #dcdcde} +.yang-stat-bar{flex:1 1 0;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;height:100%;min-width:0} +.yang-stat-bar-inner{width:100%;max-width:46px;background:#2271b1;border-radius:3px 3px 0 0;min-height:2px;transition:height .2s} +.yang-stat-bar-label{font-size:10px;color:#646970;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} + +.yang-stat-lists{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:18px;margin:0 0 14px} +.yang-stat-list{min-width:0} +.yang-stat-rank{margin:0;padding:0;list-style:none;counter-reset:yangrank} +.yang-stat-rank li{display:flex;justify-content:space-between;gap:10px;padding:6px 0;border-bottom:1px dashed #f0f0f1;font-size:12px;counter-increment:yangrank} +.yang-stat-rank li:last-child{border-bottom:0} +.yang-stat-rank-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#3c434a} +/* 序号用 ::before 画,避免多一个 又要处理缩进 */ +.yang-stat-rank-name::before{content:counter(yangrank) ". ";color:#a7aaad} +.yang-stat-rank-val{flex:0 0 auto;font-weight:600;color:#1d2327;font-variant-numeric:tabular-nums} +.yang-stat-rank-empty{color:#a7aaad;font-size:12px;padding:6px 0;display:block} +.yang-stat-rank-empty::before{content:none} + +.yang-stat-device-bar{display:flex;height:14px;border-radius:7px;overflow:hidden;background:#f0f0f1} +.yang-stat-device-pc{display:block;background:#2271b1;height:100%} +.yang-stat-device-legend{display:flex;gap:18px;margin-top:7px;font-size:12px;color:#646970} + +.yang-stat-error{padding:14px 16px;border:1px solid #f0c0c0;border-left:3px solid #d63638;border-radius:6px;background:#fcf0f0} +.yang-stat-error-title{font-size:13px;font-weight:600;color:#1d2327;margin-bottom:6px} +.yang-stat-error p{margin:4px 0;font-size:12px;line-height:1.65;color:#3c434a;word-break:break-word} + +.yang-stat-actions{display:flex;align-items:center;gap:10px;margin-top:12px} +.yang-stat-refresh-note{font-size:12px;color:#646970} +'; + wp_register_style('yang-stat-admin', false, array(), null); + wp_enqueue_style('yang-stat-admin'); + wp_add_inline_style('yang-stat-admin', $css); + + $js = <<<'JS' +(function(){ + var panel = document.getElementById('yang-stat-panel'); + if (!panel) return; + var body = document.getElementById('yang-stat-body'); + var loading = document.getElementById('yang-stat-loading'); + var btn = document.getElementById('yang-stat-refresh'); + var note = document.getElementById('yang-stat-refresh-note'); + if (!body) return; // 未配置:同步渲染,无需拉取 + + var CFG = window.YANG_STAT_CFG || {}; + + function setNote(t){ if (note) { note.textContent = t || ''; } } + + function load(force){ + if (btn) { btn.disabled = true; } + if (loading) { loading.style.display = 'flex'; } + body.innerHTML = ''; + setNote(''); + + var fd = new FormData(); + fd.append('action', 'yang_stat_panel'); + fd.append('nonce', CFG.nonce || ''); + if (force) { fd.append('force', '1'); } + + fetch(CFG.ajaxUrl || '', { method: 'POST', credentials: 'same-origin', body: fd }) + .then(function(r){ + /* 即使 HTTP 不是 200 也要往下读 —— 后台可能返回 WP 的错误页, + 直接丢会把"没有权限"显示成"网络错误",用户无从判断。 */ + return r.json().catch(function(){ return null; }); + }) + .then(function(j){ + if (loading) { loading.style.display = 'none'; } + if (j && j.success && j.data && typeof j.data.html === 'string') { + body.innerHTML = j.data.html; + setNote(force ? '已刷新' : ''); + } else { + var msg = (j && j.data && j.data.message) ? j.data.message : '取数失败'; + body.innerHTML = '
统计暂不可用

' + + String(msg).replace(/[<>&]/g, function(c){ + return {'<':'<','>':'>','&':'&'}[c]; + }) + '

设置页其它部分不受影响。

'; + } + }) + .catch(function(err){ + if (loading) { loading.style.display = 'none'; } + body.innerHTML = '
统计暂不可用

' + + '请求没走通(' + String(err && err.message ? err.message : err) + '),可能是网络或权限问题。

' + + '

设置页其它部分不受影响,可以照常保存。

'; + }) + .then(function(){ if (btn) { btn.disabled = false; } }); + } + + /* 刷新按钮用事件委托:AJAX 换进来的新 HTML 里没有这个按钮, + 绑在 document 上才不会被替换掉。 */ + document.addEventListener('click', function(e){ + var t = e.target; + if (t && t.id === 'yang-stat-refresh') { + e.preventDefault(); + load(true); + } + }); + + load(false); +})(); +JS; + + /* 与 theme-avatar.php 保持一致的注册方式:src 传空串, + 配置用 wp_localize_script 挂 —— 它会把 JSON 放在脚本之前, + 且能自动转义引号,比自己拼字符串再 wp_add_inline_script 稳。 */ + wp_register_script('yang-stat-admin', '', array(), null, true); + wp_enqueue_script('yang-stat-admin'); + wp_localize_script('yang-stat-admin', 'YANG_STAT_CFG', array( + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('yang_stat_panel'), + )); + wp_add_inline_script('yang-stat-admin', $js); +} +add_action('admin_enqueue_scripts', 'yang_stat_admin_assets'); + +/* ══════════════════════════════════════════════════════════════════ + * 十二、前台埋点开关 + * ══════════════════════════════════════════════════════════════════ */ + +/** + * 埋点是否启用。 + * + * ── 开关与代码分离的意义 ──────────────────────────────────────── + * footer.php 里那段 `seo_statistical` 是用户自己粘的统计脚本。 + * 以前想停掉只能把代码删了、想恢复再粘回来 —— 代码一旦丢了 + * (换主题、误清空)就得重新去百度后台找。开关把它变成一次点击。 + * + * 默认值取 `1`(开):升级上来的老用户本来就一直在跑这段代码, + * 默认关掉等于"升级后统计突然断了",属于静默破坏。 + * + * @return bool + */ +function yang_stat_tracking_enabled() +{ + if (!function_exists('kratos_option')) { + return true; + } + /* 🔴 三种状态必须分清,不能折叠成两种: + * null = 从未配置过 ⇒ 开(老用户一直在跑,默认关掉等于静默断统计) + * false = 用户主动取消 ⇒ **关** + * '1' = 用户主动勾选 ⇒ 开 + * + * 第一版这里写的是 `if ($v === null || $v === false) return true;`, + * 把 false 也归进"开",导致**取消勾选完全无效** —— + * 埋点永远输出,用户以为关了其实没关,而且前台毫无异常可供发现。 + * 框架的 of_sanitize_checkbox() 对未勾选明确写 `false` + * (见 class-options-sanitization.php:88-95),所以 false 是 + * "用户明确说不要",必须尊重。 */ + $v = kratos_option('stat_track_enable', null); + if ($v === null || $v === '') { + return true; + } + return (bool) $v; +} diff --git a/style.css b/style.css index 251b03d..939b5ac 100644 --- a/style.css +++ b/style.css @@ -1,7 +1,7 @@ /* Theme Name: Kratos_Yang Text Domain: kratos_Yang -Version: 4.0.0 +Version: 4.1.0 Requires PHP: 7.0 Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。 Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好