Files
CLOAK/lib/Cloak/FpRiskExplain.php
2026-06-22 21:55:33 +08:00

635 lines
28 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 二次风控(byApiRisk)判定过程解析:结合 reason、配置阈值与 fp_hdata 字段标注依据。
*/
class FpRiskExplain
{
/** @var bool */
private static $analyzerLoaded = false;
/**
* @param string|array|null $hdataRaw JSON 或已解码数组
* @param string $reason visitor_logs.reasonAPI 返回)
* @param string $result visitor_logs.result true/false/wait
* @param string $domain 用于读取站点 CLOAK_RISK_NUMBER
* @return array{summary:string,html:string,data:array}
*/
public static function build($hdataRaw, $reason, $result, $domain = '')
{
$hdata = self::decodeHdata($hdataRaw);
$reason = trim((string) $reason);
$result = strtolower(trim((string) $result));
$cfg = self::readRiskConfigForDomain($domain);
$parsedReason = self::parseApiReason($reason);
$local = self::runLocalAnalyzer($hdata);
$localLevel = isset($local['risk_level']) ? (int) $local['risk_level'] : null;
$apiLevel = $parsedReason['risk_level'];
$riskLevel = $localLevel !== null ? $localLevel : $apiLevel;
$factors = !empty($local['risk_factors']) ? $local['risk_factors'] : $parsedReason['factors'];
$riskNumber = (int) $cfg['risk_number'];
$configEnhanced = strtoupper($cfg['risk_enhanced']) === 'ON';
$hdataEnhanced = self::isEnhancedHdata($hdata);
$enhanced = $hdataEnhanced || $configEnhanced;
if ($riskNumber <= 0) {
$verdictText = '二次风控已关闭(风险系数=0)';
$compareText = '未与 API 阈值比较';
$expectedBlocked = null;
} else {
$expectedBlocked = ($riskLevel !== null && $riskLevel >= $riskNumber);
$verdictText = $expectedBlocked ? '应拦截 → 安全页' : '应放行 → 真实页';
$compareText = ($riskLevel !== null)
? sprintf('%d %s %d → %s', $riskLevel, $expectedBlocked ? '>=' : '<', $riskNumber, $expectedBlocked ? '拦截' : '放行')
: '未能解析风险分';
}
$actualPass = ($result === 'true' || $result === '1');
$actualText = $result === 'wait' || $result === '检测中'
? '检测中'
: ($actualPass ? '实际:放行(TRUE)' : '实际:拦截(FALSE)');
$factorRows = self::buildFactorRows($factors, $hdata);
$fieldMap = self::collectHighlightedFields($factorRows);
$data = [
'risk_level' => $riskLevel,
'local_risk_level' => $localLevel,
'api_risk_level' => $apiLevel,
'risk_number' => $riskNumber,
'recommended_risk' => self::getRecommendedRiskNumber($configEnhanced),
'risk_enhanced' => $enhanced,
'config_enhanced' => $configEnhanced,
'hdata_enhanced' => $hdataEnhanced,
'config_name' => $cfg['config_name'],
'verdict_text' => $verdictText,
'compare_text' => $compareText,
'expected_blocked' => $expectedBlocked,
'actual_result' => $result,
'actual_pass' => $actualPass,
'actual_text' => $actualText,
'api_reason' => $reason,
'parsed_reason' => $parsedReason,
'local_analysis' => $local,
'factors' => $factorRows,
'highlight_fields' => array_keys($fieldMap),
'field_values' => $fieldMap,
'config_edit_url' => 'dashboard.php?action=edit_config&name=' . rawurlencode($cfg['config_name']),
];
$data['conclusions'] = self::buildAnalysisConclusions($data, $hdata);
$summary = self::buildSummary($data);
$html = self::buildHtml($data, $hdata);
return [
'summary' => $summary,
'html' => $html,
'data' => $data,
];
}
private static function isEnhancedHdata(array $hdata): bool
{
if (!empty($hdata['risk_enhanced'])) {
return true;
}
return !empty($hdata['canvas']) && is_array($hdata['canvas'])
&& !empty($hdata['webgl']) && is_array($hdata['webgl']);
}
private static function getRecommendedRiskNumber(bool $configEnhanced): int
{
return $configEnhanced ? 52 : 68;
}
private static function decodeHdata($raw)
{
if (is_array($raw)) {
return $raw;
}
$raw = trim((string) $raw);
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
private static function loadAnalyzer()
{
if (!self::$analyzerLoaded) {
require_once __DIR__ . '/LogRiskAnalyzer.php';
self::$analyzerLoaded = true;
}
}
private static function runLocalAnalyzer(array $hdata)
{
if ($hdata === []) {
return [];
}
self::loadAnalyzer();
try {
$analyzer = new LogRiskAnalyzer($hdata);
return $analyzer->analyze();
} catch (Throwable $e) {
return ['error' => $e->getMessage()];
}
}
/**
* 从 config 文件读取阈值,避免 datalist 循环 include 污染常量。
*/
public static function readRiskConfigForDomain($domain)
{
$baseDir = dirname(__DIR__, 2);
$host = parse_url((string) $domain, PHP_URL_HOST);
if ($host === null || $host === '') {
$host = preg_replace('#^https?://#i', '', (string) $domain);
$host = explode('/', $host)[0];
}
if (!class_exists('DomainRepository', false)) {
require_once __DIR__ . '/DomainRepository.php';
}
if (!class_exists('ConfigLoader', false)) {
require_once dirname(__DIR__, 2) . '/config/ConfigLoader.php';
}
$configName = ConfigLoader::resolveConfigNameFromUrl((string) $domain, $baseDir);
if ($configName === null) {
$configName = DomainRepository::resolveConfigNameForHost((string) $host, $baseDir);
}
$file = $baseDir . '/check_config/' . $configName . '_config.php';
if (!is_file($file)) {
$configName = 'index';
$file = $baseDir . '/check_config/index_config.php';
}
$riskNumber = 68;
$riskEnhanced = 'OFF';
if (is_readable($file)) {
$content = file_get_contents($file);
if (preg_match("/define\s*\(\s*'CLOAK_RISK_NUMBER'\s*,\s*'(\d+)'\s*\)/", $content, $m)) {
$riskNumber = (int) $m[1];
}
if (preg_match("/define\s*\(\s*'CLOAK_RISK_ENHANCED'\s*,\s*'([A-Z]+)'\s*\)/", $content, $m)) {
$riskEnhanced = strtoupper($m[1]);
}
}
return [
'config_name' => $configName,
'risk_number' => $riskNumber,
'risk_enhanced' => $riskEnhanced,
];
}
/**
* 解析 API reason:常见为「风险分 + 因子列表」。
*/
public static function parseApiReason($reason)
{
$reason = trim((string) $reason);
$out = ['risk_level' => null, 'factors' => [], 'raw' => $reason];
if ($reason === '') {
return $out;
}
if (preg_match('/^\d+$/', $reason)) {
$out['risk_level'] = (int) $reason;
return $out;
}
if (preg_match('/风险(?:系数|值|分)?\s*[:]\s*(\d+)/u', $reason, $m)) {
$out['risk_level'] = (int) $m[1];
} elseif (preg_match('/^(\d+)\s*[:|\/\-—]\s*(.*)$/su', $reason, $m)) {
$out['risk_level'] = (int) $m[1];
$reason = trim($m[2]);
} elseif (preg_match('/\b(\d{1,3})\s*\/\s*(\d{1,3})\b/', $reason, $m)) {
$out['risk_level'] = (int) $m[1];
}
$factorText = $reason;
if (preg_match('/(?:理由|因素|规则)[:]\s*(.+)$/su', $reason, $m)) {
$factorText = trim($m[1]);
}
$parts = preg_split('/[;|]+/u', $factorText) ?: [];
foreach ($parts as $part) {
$part = trim($part);
if ($part === '' || preg_match('/^\d+$/', $part)) {
continue;
}
$out['factors'][] = $part;
}
return $out;
}
private static function buildFactorRows(array $factors, array $hdata)
{
$rows = [];
foreach ($factors as $i => $text) {
$text = trim((string) $text);
$mapped = self::mapFactorToFields($text);
$values = [];
foreach ($mapped['fields'] as $path) {
$val = self::getNestedValue($hdata, $path);
if ($val !== null) {
$values[$path] = $val;
}
}
$rows[] = [
'index' => $i + 1,
'text' => $text,
'category' => $mapped['category'],
'fields' => $mapped['fields'],
'values' => $values,
];
}
return $rows;
}
private static function collectHighlightedFields(array $factorRows)
{
$map = [];
foreach ($factorRows as $row) {
foreach ($row['values'] as $path => $val) {
$map[$path] = self::formatValue($val);
}
}
return $map;
}
private static function mapFactorToFields($text)
{
$rules = [
['cat' => 'webdriver', 're' => '/WebDriver|webdriver/i', 'fields' => ['webdriver']],
['cat' => 'automation', 're' => '/Phantom|Selenium|window\.chrome|无头|自动化|爬虫|UA/i', 'fields' => ['userAgent', 'automation']],
['cat' => 'canvas', 're' => '/Canvas|canvas/i', 'fields' => ['canvas']],
['cat' => 'webgl', 're' => '/WebGL|renderer|渲染|虚拟|SwiftShader|GPU/i', 'fields' => ['renderer', 'vendor', 'webgl', 'webGpuData', 'webGPU']],
['cat' => 'hardware', 're' => '/硬件|并发|内存|deviceMemory|hardware/i', 'fields' => ['hardwareConcurrency', 'deviceMemory']],
['cat' => 'touch', 're' => '/触摸|touch|仿真器|Emulator/i', 'fields' => ['touchscreen', 'isEmulators', 'platform', 'browser.device.type']],
['cat' => 'screen', 're' => '/分辨率|像素比|视口|availScreen|screen/i', 'fields' => ['screen', 'availScreen', 'devicePixelRatio', 'scale', 'visualViewportOffset']],
['cat' => 'browser', 're' => '/WebRTC|WebGPU|WebGL/i', 'fields' => ['experimental', 'webGPU', 'webGpuData', 'webgl']],
['cat' => 'geo', 're' => '/语言|时区|country|ciso/i', 'fields' => ['languages', 'language', 'ciso', 'timezone', 'timezoneOffset']],
['cat' => 'hostname', 're' => '/DNS|爬虫|Google|Facebook|Tiktok/i', 'fields' => ['ip']],
['cat' => 'fonts', 're' => '/字体|Calibri|Segoe|San Francisco/i', 'fields' => ['fonts', 'browser.os.name']],
];
foreach ($rules as $rule) {
if (preg_match($rule['re'], $text)) {
return ['category' => $rule['cat'], 'fields' => $rule['fields']];
}
}
return ['category' => 'other', 'fields' => ['userAgent', 'platform', 'risk_enhanced']];
}
private static function getNestedValue(array $data, $path)
{
if (strpos($path, '.') === false) {
return array_key_exists($path, $data) ? $data[$path] : null;
}
$cur = $data;
foreach (explode('.', $path) as $seg) {
if (!is_array($cur) || !array_key_exists($seg, $cur)) {
return null;
}
$cur = $cur[$seg];
}
return $cur;
}
private static function formatValue($val)
{
if ($val === null) {
return 'null';
}
if (is_bool($val)) {
return $val ? 'true' : 'false';
}
if (is_array($val)) {
$json = json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json !== false && strlen($json) > 200) {
return substr($json, 0, 200) . '…';
}
return $json ?: '[]';
}
$s = (string) $val;
return strlen($s) > 200 ? substr($s, 0, 200) . '…' : $s;
}
/**
* 根据 reason、hdata、配置阈值与本地复算,生成可操作的诊断结论。
*
* @return array<int,array{level:string,text:string,action:string}>
*/
private static function buildAnalysisConclusions(array $data, array $hdata)
{
$lines = [];
$riskNumber = (int) $data['risk_number'];
$riskLevel = $data['risk_level'];
$localLevel = $data['local_risk_level'];
$apiLevel = $data['api_risk_level'];
$recommended = (int) $data['recommended_risk'];
$configUrl = (string) ($data['config_edit_url'] ?? 'dashboard.php');
$configHint = '可在 <a href="' . htmlspecialchars($configUrl, ENT_QUOTES, 'UTF-8') . '" target="_blank">配置中心</a> 调整风险系数与加强判断';
if ($hdata === [] && trim($data['api_reason']) === '') {
$lines[] = [
'level' => 'warn',
'text' => '本条无指纹 hdata 且无 API reason,二次风控可能未执行、未完成或尚未落库。',
'action' => '若 result 为 wait,请检查 cloakjs / f_check 回调;若长期为空,检查 CLOAK_RISK_NUMBER 是否为 0。',
];
return $lines;
}
if ($riskNumber <= 0) {
if ($hdata !== [] || trim($data['api_reason']) !== '') {
$lines[] = [
'level' => 'warn',
'text' => '配置中二次风控已关闭(风险系数=0),但本条仍出现指纹数据或 reason。',
'action' => 'result/reason 可能来自首次判定或其他规则,不代表 byApiRisk 生效。' . $configHint . '。',
];
} else {
$lines[] = [
'level' => 'ok',
'text' => '二次风控已关闭,不会调用 byApiRisk。',
'action' => $configHint . ' 开启并设置合适分数后可启用。',
];
}
return $lines;
}
if ($data['config_enhanced'] && !$data['hdata_enhanced']) {
$lines[] = [
'level' => 'warn',
'text' => '加强判断已开启,但 hdata 缺少 Canvas/WebGL 等深度字段,API 只能按基础规则计分。',
'action' => '常见于弱网、老旧浏览器或前端采集超时。可暂时关闭加强判断,或排查 cloakjs 加载与采集耗时。',
];
} elseif (!$data['config_enhanced'] && $data['hdata_enhanced']) {
$lines[] = [
'level' => 'ok',
'text' => '配置为基础模式,但 hdata 含完整指纹;本地复算会识别为加强数据。',
'action' => '若需利用 Canvas/WebGL 规则,请在配置中开启加强判断并将风险系数设为 52。',
];
}
if ($riskNumber === 51 && !$data['config_enhanced']) {
$lines[] = [
'level' => 'warn',
'text' => '当前风险系数为 51(旧版默认值),基础模式下易误拦正常移动端访客。',
'action' => '建议改为 68。' . $configHint . '。',
];
} elseif ($riskNumber !== $recommended) {
$delta = $riskNumber - $recommended;
if (!$data['config_enhanced'] && $riskNumber < 65) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('基础模式下风险系数 %d 偏低(建议 %d),拦截偏严,真实用户更易被误拦。', $riskNumber, $recommended),
'action' => '建议提高 3~5 分。' . $configHint . '。',
];
} elseif (!$data['config_enhanced'] && $riskNumber > 75) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('基础模式下风险系数 %d 偏高(建议 %d),自动化/爬虫更易漏过。', $riskNumber, $recommended),
'action' => '建议降低 3~5 分。' . $configHint . '。',
];
} elseif ($data['config_enhanced'] && $riskNumber < 48) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('加强模式下风险系数 %d 偏低(建议 %d),拦截会非常严格。', $riskNumber, $recommended),
'action' => '若误拦过多,可提高到 52 附近。' . $configHint . '。',
];
} elseif ($data['config_enhanced'] && $riskNumber > 55) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('加强模式下风险系数 %d 偏高(建议 %d),高信号自动化可能仍被放行。', $riskNumber, $recommended),
'action' => '建议降低到 52 附近。' . $configHint . '。',
];
} elseif (abs($delta) >= 3) {
$lines[] = [
'level' => 'ok',
'text' => sprintf('当前风险系数 %d,与%s模式建议值 %d 相差 %d 分。', $riskNumber, $data['config_enhanced'] ? '加强' : '基础', $recommended, abs($delta)),
'action' => '若误拦或漏拦明显,可按 ±3~5 微调。' . $configHint . '。',
];
}
}
if ($localLevel !== null && $apiLevel !== null && abs($localLevel - $apiLevel) > 3) {
$lines[] = [
'level' => 'alert',
'text' => sprintf('API reason 解析风险分 %d,日志复算风险分 %d,两者不一致。', $apiLevel, $localLevel),
'action' => '线上 byApiRisk 版本可能与 lib/Cloak/LogRiskAnalyzer.php 不同步,或 reason 格式有变,需核对 API 部署。',
];
}
if ($data['expected_blocked'] !== null && $riskLevel !== null) {
$expectedPass = !$data['expected_blocked'];
if ($expectedPass !== $data['actual_pass']) {
$lines[] = [
'level' => 'alert',
'text' => sprintf(
'本地复算:风险 %d %s 阈值 %d → 应%s;但 API 实际返回 %s。',
$riskLevel,
$data['expected_blocked'] ? '>=' : '<',
$riskNumber,
$expectedPass ? '放行' : '拦截',
$data['actual_pass'] ? 'TRUE(放行)' : 'FALSE(拦截)'
),
'action' => '请确认 f_check 传给 API 的 risk_number 与配置一致,且线上 API 判定逻辑未单独覆盖阈值。',
];
} else {
$lines[] = [
'level' => 'ok',
'text' => sprintf('API 结果与本地复算一致:风险 %d,阈值 %d,结论为%s。', $riskLevel, $riskNumber, $data['actual_pass'] ? '放行' : '拦截'),
'action' => '',
];
}
$margin = abs($riskLevel - $riskNumber);
if ($margin <= 5) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('本条为临界分值(距阈值仅 %d 分),小幅调整风险系数即可改变结果。', $margin),
'action' => $data['actual_pass']
? '若认为应拦截,可将风险系数降低 3~5。'
: '若认为应放行,可将风险系数提高 3~5。',
];
}
} elseif ($riskLevel === null && trim($data['api_reason']) !== '') {
$lines[] = [
'level' => 'warn',
'text' => 'API 返回了 reason,但未能解析出有效风险分。',
'action' => '请检查 reason 格式是否变更;下方「API reason」与触发规则可帮助人工判断。',
];
}
if (!empty($data['factors']) && empty($data['parsed_reason']['factors']) && $localLevel === null) {
$lines[] = [
'level' => 'warn',
'text' => 'reason 中未包含触发规则明细,仅有分数或简短文本。',
'action' => '不利于排查误拦原因,建议在 API 侧返回 risk_factors 列表。',
];
}
if (!empty($hdata['webdriver']) && $data['actual_pass']) {
$lines[] = [
'level' => 'alert',
'text' => 'hdata 显示 webdriver=true,但本条仍被放行。',
'action' => sprintf('webdriver 单项约 55 分,当前阈值 %d 可能过高或未命中 API 规则。建议加强模式 + 阈值 52。', $riskNumber),
];
}
$strongHit = false;
$weakOnly = !empty($data['factors']);
foreach ($data['factors'] as $row) {
$cat = $row['category'] ?? 'other';
if (in_array($cat, ['webdriver', 'automation', 'canvas', 'webgl'], true)) {
$strongHit = true;
}
if (!in_array($cat, ['geo', 'hardware'], true)) {
$weakOnly = false;
}
}
if (!$data['actual_pass'] && $weakOnly && !empty($data['factors'])) {
$lines[] = [
'level' => 'warn',
'text' => '本条被拦截,但触发信号以语言/时区/硬件等弱规则为主,无 webdriver/Canvas/WebGL 等强信号。',
'action' => '若大量出现,可能阈值偏低或 IP 国家与浏览器环境不匹配,建议提高风险系数或检查误伤样本。',
];
}
if ($data['actual_pass'] && $strongHit && $riskLevel !== null && $riskLevel >= ($riskNumber - 8)) {
$lines[] = [
'level' => 'warn',
'text' => '存在自动化/Canvas/WebGL 等强信号,风险分接近阈值但仍被放行。',
'action' => '建议降低风险系数 3~5,或确认加强判断已开启。',
];
}
if ($lines === []) {
$lines[] = [
'level' => 'ok',
'text' => '暂无异常,当前配置与 API 返回基本一致。',
'action' => $configHint . '。',
];
}
return $lines;
}
private static function buildSummary(array $data)
{
if (empty($data['local_analysis']) && trim($data['api_reason']) === '') {
return '';
}
$level = $data['risk_level'];
$num = $data['risk_number'];
if ($num <= 0) {
return '二次风控关闭';
}
if ($level === null) {
return '风控 ' . $data['actual_result'];
}
$mode = $data['risk_enhanced'] ? '加强' : '基础';
return sprintf('风险 %d/%d(%s) · %s', $level, $num, $mode, $data['actual_pass'] ? 'TRUE' : 'FALSE');
}
private static function buildHtml(array $data, array $hdata)
{
if ($hdata === [] && trim($data['api_reason']) === '') {
return '';
}
$esc = static function ($s) {
return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
};
$html = '<div class="fp-risk-explain">';
$html .= '<div class="fp-risk-explain-head">';
$html .= '<strong>二次风控判定解析</strong>';
$html .= '<span class="fp-risk-tag">' . $esc($data['actual_result'] === 'true' ? '放行 TRUE' : ($data['actual_result'] === 'false' ? '拦截 FALSE' : $data['actual_result'])) . '</span>';
$html .= '</div>';
if (!empty($data['conclusions'])) {
$html .= '<div class="fp-risk-section-title">分析结论</div>';
$html .= '<ul class="fp-risk-conclusions">';
foreach ($data['conclusions'] as $item) {
$cls = $esc($item['level'] ?? 'ok');
$html .= '<li class="' . $cls . '">';
$html .= $esc($item['text'] ?? '');
if (!empty($item['action'])) {
$html .= '<span class="fp-conc-action">' . $item['action'] . '</span>';
}
$html .= '</li>';
}
$html .= '</ul>';
}
$html .= '<table class="fp-risk-kv"><tbody>';
$html .= '<tr><th>配置</th><td>' . $esc($data['config_name']) . ' · 风险系数 <code>' . (int) $data['risk_number'] . '</code>'
. '(建议 <code>' . (int) $data['recommended_risk'] . '</code> · '
. ($data['config_enhanced'] ? '加强判断 ON' : '加强判断 OFF')
. ($data['hdata_enhanced'] ? ' · hdata 含深度指纹' : '')
. '</td></tr>';
$html .= '<tr><th>风险分</th><td>本地复算 <code>' . ($data['local_risk_level'] !== null ? (int) $data['local_risk_level'] : '—') . '</code>'
. ' · API reason <code>' . ($data['api_risk_level'] !== null ? (int) $data['api_risk_level'] : '—') . '</code></td></tr>';
$html .= '<tr><th>比较</th><td>' . $esc($data['compare_text']) . ' → ' . $esc($data['verdict_text']) . ' · ' . $esc($data['actual_text'] ?? '') . '</td></tr>';
$html .= '<tr><th>API reason</th><td class="fp-risk-reason-raw">' . $esc($data['api_reason'] ?: '—') . '</td></tr>';
if (!empty($data['local_analysis']['risk_category'])) {
$html .= '<tr><th>风险档</th><td>' . $esc($data['local_analysis']['risk_category'] . ' — ' . ($data['local_analysis']['risk_description'] ?? '')) . '</td></tr>';
}
$html .= '</tbody></table>';
if (!empty($data['factors'])) {
$html .= '<div class="fp-risk-section-title">触发规则与依据字段</div><ol class="fp-risk-factor-list">';
foreach ($data['factors'] as $row) {
$html .= '<li><div class="fp-factor-text">' . $esc($row['text']) . '</div>';
if (!empty($row['values'])) {
$html .= '<ul class="fp-factor-fields">';
foreach ($row['values'] as $path => $val) {
$html .= '<li><mark class="fp-field-key">' . $esc($path) . '</mark> = <code>' . $esc(self::formatValue($val)) . '</code></li>';
}
$html .= '</ul>';
} else {
$html .= '<div class="fp-factor-miss text-muted">(未在 hdata 中找到对应字段值,可能为基础模式未采集)</div>';
}
$html .= '</li>';
}
$html .= '</ol>';
}
$html .= '<div class="fp-risk-section-title">完整 hdata(依据字段已高亮)</div>';
$html .= '<pre class="fp-hdata-json">' . self::renderHighlightedHdata($hdata, $data['highlight_fields']) . '</pre>';
$html .= '</div>';
return $html;
}
private static function renderHighlightedHdata(array $hdata, array $highlightFields)
{
$lines = [];
$json = json_encode($hdata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
return '';
}
$hlKeys = array_flip($highlightFields);
foreach (explode("\n", $json) as $line) {
$escaped = htmlspecialchars($line, ENT_QUOTES, 'UTF-8');
if (preg_match('/^\s*"([^"]+)"\s*:/', $line, $m)) {
$key = $m[1];
if (isset($hlKeys[$key])) {
$escaped = '<span class="fp-hl-line">' . $escaped . '</span>';
}
}
$lines[] = $escaped;
}
return implode("\n", $lines);
}
}