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 = '
还缺:' . esc_html(implode('、', $miss)) . '
'; $h .= '先填 API Key,这里会出现一键授权链接。
'; } $h .= '授权后会跳到一个显示 code 的页面,复制它填回本页的「授权码」框,保存即可 —— 主题会用它换 refresh_token,之后自动续期。
'; $h .= '站点 ID 在百度统计的「管理 → 网站列表」里,点进站点后地址栏的 siteId= 后面那串数字。
' . esc_html($msg) . '
'; $h .= '设置页其它部分不受影响,可以照常修改并保存。' . '若是 token 失效,重新走一次上面的授权流程即可。
'; $h .= '近 7 天没有可用的设备数据。
'; } $h .= '' + String(msg).replace(/[<>&]/g, function(c){ return {'<':'<','>':'>','&':'&'}[c]; }) + '
设置页其它部分不受影响。
' + '请求没走通(' + String(err && err.message ? err.message : err) + '),可能是网络或权限问题。
' + '设置页其它部分不受影响,可以照常保存。