refactor: flatten src/AKSRT → src (drop theme-identifier dir)

Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
2026-08-02 22:06:06 +08:00
parent 56f1a8175f
commit 8c5b1555c9
285 changed files with 26 additions and 3600 deletions
+253
View File
@@ -0,0 +1,253 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class APIAudit {
private static $instance = null;
private static $table_name = '';
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
global $wpdb;
self::$table_name = $wpdb->prefix . 'aksrt_api_audit';
}
return self::$instance;
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_hooks']);
add_action('aksrt_db_migrate', [$this, 'maybe_create_table']);
}
/**
* Check if audit logging is enabled
*/
private function is_enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) return false;
return (bool) \AKSRT\Settings\Settings::get_value('api_audit_enabled', false);
}
/**
* Register REST pre-dispatch hook to log requests
*/
public function register_hooks(): void {
if (!$this->is_enabled()) return;
// Log after response to capture status code
add_filter('rest_post_dispatch', [$this, 'log_request'], 10, 3);
}
/**
* Log a REST API request
*/
public function log_request($result, $server, $request) {
$route = $request->get_route();
$method = $request->get_method();
// Only log write operations (POST, PUT, DELETE, PATCH)
if (!in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
return $result;
}
// Skip non-aksrt routes
if (strpos($route, '/aksrt/') === false) {
return $result;
}
// Skip health checks and non-sensitive routes
$skip_patterns = ['/health', '/settings/export', '/settings/import'];
foreach ($skip_patterns as $pattern) {
if (strpos($route, $pattern) !== false) {
return $result;
}
}
$user_id = get_current_user_id();
$ip = $this->get_client_ip();
$status = $result instanceof \WP_REST_Response ? $result->get_status() : 200;
$this->insert_log([
'user_id' => $user_id,
'method' => $method,
'route' => $route,
'ip' => $ip,
'status' => $status,
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
return $result;
}
/**
* Manually log an event
*/
public static function log(string $action, array $context = [], int $user_id = 0): void {
$instance = self::get_instance();
if (!$instance->is_enabled()) return;
$instance->insert_log([
'user_id' => $user_id ?: get_current_user_id(),
'method' => 'ACTION',
'route' => $action,
'ip' => $instance->get_client_ip(),
'status' => 200,
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
'context' => wp_json_encode($context, JSON_UNESCAPED_UNICODE),
]);
}
/**
* Insert a log entry
*/
private function insert_log(array $data): void {
global $wpdb;
$wpdb->insert(self::$table_name, [
'user_id' => $data['user_id'] ?? 0,
'method' => $data['method'] ?? '',
'route' => $data['route'] ?? '',
'ip' => $data['ip'] ?? '',
'status' => $data['status'] ?? 0,
'user_agent' => $data['user_agent'] ?? '',
'context' => $data['context'] ?? '',
'created_at' => current_time('mysql', true),
], ['%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
}
/**
* Get client IP
*/
private function get_client_ip(): string {
if (function_exists('aksrt_get_client_ip')) {
return sanitize_text_field((string) aksrt_get_client_ip());
}
$ip = '';
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$ip = (string) $_SERVER['HTTP_CF_CONNECTING_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim($parts[0]);
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
$ip = (string) $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = (string) $_SERVER['REMOTE_ADDR'];
}
return sanitize_text_field($ip);
}
/**
* Create audit table if not exists
*/
public function maybe_create_table(): void {
global $wpdb;
$table = self::$table_name;
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
method VARCHAR(10) NOT NULL DEFAULT '',
route VARCHAR(255) NOT NULL DEFAULT '',
ip VARCHAR(45) NOT NULL DEFAULT '',
status SMALLINT UNSIGNED NOT NULL DEFAULT 0,
user_agent VARCHAR(255) NOT NULL DEFAULT '',
context TEXT,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_user_id (user_id),
KEY idx_route (route),
KEY idx_created_at (created_at)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* Query audit logs with pagination
*/
public function query_logs(array $args = []): array {
global $wpdb;
$defaults = [
'per_page' => 20,
'page' => 1,
'user_id' => 0,
'route' => '',
'method' => '',
'from' => '',
'to' => '',
];
$args = wp_parse_args($args, $defaults);
$where = ['1=1'];
$values = [];
if ($args['user_id'] > 0) {
$where[] = 'user_id = %d';
$values[] = $args['user_id'];
}
if (!empty($args['route'])) {
$where[] = 'route LIKE %s';
$values[] = '%' . $wpdb->esc_like($args['route']) . '%';
}
if (!empty($args['method'])) {
$where[] = 'method = %s';
$values[] = $args['method'];
}
if (!empty($args['from'])) {
$where[] = 'created_at >= %s';
$values[] = $args['from'];
}
if (!empty($args['to'])) {
$where[] = 'created_at <= %s';
$values[] = $args['to'];
}
$where_sql = implode(' AND ', $where);
$offset = ($args['page'] - 1) * $args['per_page'];
$count_sql = "SELECT COUNT(*) FROM " . self::$table_name . " WHERE {$where_sql}";
$total = (int) ($values
? $wpdb->get_var($wpdb->prepare($count_sql, $values))
: $wpdb->get_var($count_sql));
$values[] = $args['per_page'];
$values[] = $offset;
$logs = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM " . self::$table_name . " WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$values
),
ARRAY_A
);
return [
'total' => $total,
'per_page'=> $args['per_page'],
'page' => $args['page'],
'logs' => $logs ?: [],
];
}
/**
* Purge old logs
*/
public function purge_old(int $days = 90): int {
global $wpdb;
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
return (int) $wpdb->query(
$wpdb->prepare("DELETE FROM " . self::$table_name . " WHERE created_at < %s", $cutoff)
);
}
}
+365
View File
@@ -0,0 +1,365 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class ContentAudit {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 审核文本内容
*
* @param string $text 待审文本
* @param bool $force 管理端测试用:跳过启用开关与失败放行策略,直接打服务商
* @return array{passed: bool, reason: string, labels: array, raw: array}
*/
public function audit_text(string $text, bool $force = false): array {
$config = $this->get_config();
if (
!$force
&& (empty($config['content_audit_enabled']) || empty($config['content_audit_text_enabled']))
) {
return $this->ok('disabled');
}
$provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu'));
$result = $this->call_text_api($provider, $text, $config);
// 管理端强制测试不写生产日志,避免污染审计流水
if (!$force && !empty($config['content_audit_log_enabled'])) {
$this->log_audit('text', $text, $result);
}
// 管理端强制测试保留服务商原始结论,不套用 on_failure=allow
if (
!$force
&& !$result['passed']
&& ($config['content_audit_on_failure'] ?? 'allow') === 'allow'
) {
$result['passed'] = true;
$result['reason'] .= ' (failure_policy: allow)';
}
return $result;
}
/**
* 审核图片
*
* @param string $image_path 本地文件路径或 URL
* @return array{passed: bool, reason: string, labels: array, raw: array}
*/
public function audit_image(string $image_path): array {
$config = $this->get_config();
if (empty($config['content_audit_enabled']) || empty($config['content_audit_image_enabled'])) {
return $this->ok('disabled');
}
$provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu'));
$result = $this->call_image_api($provider, $image_path, $config);
if (!empty($config['content_audit_log_enabled'])) {
$this->log_audit('image', $image_path, $result);
}
if (!$result['passed'] && ($config['content_audit_on_failure'] ?? 'allow') === 'allow') {
$result['passed'] = true;
$result['reason'] .= ' (failure_policy: allow)';
}
return $result;
}
/**
* 获取服务运行时状态
*/
public function get_status(): array {
$config = $this->get_config();
$enabled = !empty($config['content_audit_enabled']);
$provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu'));
$missing = [];
if ($enabled && $provider === 'baidu') {
if (empty($config['content_audit_api_key'])) $missing[] = 'content_audit_api_key';
if (empty($config['content_audit_api_secret'])) $missing[] = 'content_audit_api_secret';
}
return [
'enabled' => $enabled,
'provider' => $provider,
'configured' => $enabled && ($provider === 'null' || empty($missing)),
'available' => $enabled,
'verified' => !empty($config['content_audit_verified']),
'transport' => 'http_api',
'missing_fields' => $missing,
'text_enabled' => !empty($config['content_audit_text_enabled']),
'image_enabled' => !empty($config['content_audit_image_enabled']),
'on_failure' => (string) ($config['content_audit_on_failure'] ?? 'allow'),
'log_enabled' => !empty($config['content_audit_log_enabled']),
];
}
/* ============================================================
* Provider dispatch
* ============================================================ */
private function call_text_api(string $provider, string $text, array $config): array {
switch ($provider) {
case 'baidu':
return $this->baidu_text_censor($text, $config);
default:
return $this->local_pattern_check($text, $provider);
}
}
private function call_image_api(string $provider, string $image_path, array $config): array {
switch ($provider) {
case 'baidu':
return $this->baidu_image_censor($image_path, $config);
default:
return $this->ok('no_image_provider');
}
}
/* ============================================================
* 百度内容审核 Provider
* ============================================================ */
/**
* 百度文本审核
*/
private function baidu_text_censor(string $text, array $config): array {
$api_key = trim((string) ($config['content_audit_api_key'] ?? ''));
$api_secret = trim((string) ($config['content_audit_api_secret'] ?? ''));
if ($api_key === '' || $api_secret === '') {
return ['passed' => false, 'reason' => 'config_missing', 'labels' => [], 'raw' => []];
}
$token = $this->baidu_get_access_token($api_key, $api_secret, $config);
if ($token === '') {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => []];
}
$censor_url = trim((string) ($config['content_audit_baidu_text_url'] ?? ''));
if ($censor_url === '') {
$censor_url = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined';
}
$request_url = add_query_arg(['access_token' => $token], $censor_url);
$response = wp_remote_post($request_url, [
'timeout' => 15,
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => http_build_query(['text' => $text]),
]);
if (is_wp_error($response)) {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['error' => $response->get_error_message()]];
}
$status_code = (int) wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['status' => $status_code]];
}
$payload = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($payload) || isset($payload['error_code'])) {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => $payload ?: []];
}
return $this->parse_baidu_result($payload);
}
/**
* 百度图片审核
*/
private function baidu_image_censor(string $image_path, array $config): array {
$api_key = trim((string) ($config['content_audit_api_key'] ?? ''));
$api_secret = trim((string) ($config['content_audit_api_secret'] ?? ''));
if ($api_key === '' || $api_secret === '') {
return ['passed' => false, 'reason' => 'config_missing', 'labels' => [], 'raw' => []];
}
$token = $this->baidu_get_access_token($api_key, $api_secret, $config);
if ($token === '') {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => []];
}
// 支持本地路径和 URL
if (filter_var($image_path, FILTER_VALIDATE_URL)) {
$image_data = @file_get_contents($image_path);
} else {
$image_data = @file_get_contents($image_path);
}
if ($image_data === false) {
return ['passed' => false, 'reason' => 'file_error', 'labels' => [], 'raw' => []];
}
$censor_url = trim((string) ($config['content_audit_baidu_img_url'] ?? ''));
if ($censor_url === '') {
$censor_url = 'https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined';
}
$request_url = add_query_arg(['access_token' => $token], $censor_url);
$response = wp_remote_post($request_url, [
'timeout' => 30,
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => http_build_query(['image' => base64_encode($image_data)]),
]);
if (is_wp_error($response)) {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['error' => $response->get_error_message()]];
}
$status_code = (int) wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['status' => $status_code]];
}
$payload = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($payload) || isset($payload['error_code'])) {
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => $payload ?: []];
}
return $this->parse_baidu_result($payload);
}
/**
* 百度 access_token 获取(带缓存)
*/
private function baidu_get_access_token(string $api_key, string $api_secret, array $config): string {
$cache_key = 'aksrt_baidu_token_' . md5($api_key);
$cached = get_transient($cache_key);
if ($cached) return (string) $cached;
$token_url = trim((string) ($config['content_audit_baidu_token_url'] ?? ''));
if ($token_url === '') {
$token_url = 'https://aip.baidubce.com/oauth/2.0/token';
}
$request_url = add_query_arg([
'grant_type' => 'client_credentials',
'client_id' => $api_key,
'client_secret' => $api_secret,
], $token_url);
$response = wp_remote_get($request_url, ['timeout' => 10]);
if (is_wp_error($response)) return '';
$status_code = (int) wp_remote_retrieve_response_code($response);
if ($status_code !== 200) return '';
$payload = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($payload) || isset($payload['error'])) return '';
$token = (string) ($payload['access_token'] ?? '');
$expires = max(3600, (int) ($payload['expires_in'] ?? 3600) - 300);
if ($token !== '') {
set_transient($cache_key, $token, $expires);
}
return $token;
}
/**
* 解析百度审核结果 → 统一格式
*/
private function parse_baidu_result(array $payload): array {
$conclusion_type = isset($payload['conclusionType']) ? (int) $payload['conclusionType'] : 1;
$conclusion = (string) ($payload['conclusion'] ?? '');
$top_category = $this->baidu_extract_top_category($payload);
// conclusionType: 1=合规, 2=不合规, 3=疑似, 4=审核失败
if ($conclusion_type === 1) {
return [
'passed' => true,
'reason' => '',
'labels' => ['compliant'],
'raw' => $payload,
];
}
$score_map = [2 => 95, 3 => 70, 4 => 60];
$reason_map = [2 => 'non_compliant', 3 => 'suspected', 4 => 'review_failed'];
return [
'passed' => false,
'reason' => $reason_map[$conclusion_type] ?? 'unknown',
'labels' => [$top_category ?: ('baidu_type_' . $conclusion_type)],
'raw' => $payload,
];
}
private function baidu_extract_top_category(array $payload): string {
if (!isset($payload['data']) || !is_array($payload['data']) || empty($payload['data'][0])) {
return '';
}
$first = $payload['data'][0];
if (!is_array($first)) return '';
if (!empty($first['msg'])) return (string) $first['msg'];
if (!empty($first['type'])) return 'type_' . (string) $first['type'];
if (!empty($first['subType'])) return 'sub_' . (string) $first['subType'];
return '';
}
/* ============================================================
* 本地基础过滤(无 API 时的 fallback)
* ============================================================ */
private function local_pattern_check(string $text, string $provider): array {
$forbidden_patterns = ['/赌博/i', '/色情/i', '/枪支/i', '/毒品/i'];
foreach ($forbidden_patterns as $pattern) {
if (preg_match($pattern, $text, $m)) {
return [
'passed' => false,
'reason' => sprintf('本地敏感词匹配: %s', $m[0]),
'labels' => ['forbidden'],
'raw' => ['provider' => $provider, 'method' => 'local_pattern'],
];
}
}
return $this->ok('passthrough');
}
/* ============================================================
* Helpers
* ============================================================ */
private function ok(string $reason = ''): array {
return ['passed' => true, 'reason' => $reason, 'labels' => [], 'raw' => []];
}
private function get_config(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_content_audit_settings();
}
return ['content_audit_enabled' => false];
}
private function log_audit(string $type, string $content, array $result): void {
$log = get_option('aksrt_content_audit_log', []);
$log[] = [
'type' => $type,
'content' => mb_substr($content, 0, 100),
'passed' => $result['passed'],
'reason' => $result['reason'] ?? '',
'time' => current_time('mysql'),
];
if (count($log) > 500) {
$log = array_slice($log, -500);
}
update_option('aksrt_content_audit_log', $log);
}
}
+571
View File
@@ -0,0 +1,571 @@
<?php
/**
* IP 归属地查询(腾讯 / 高德 / 太平洋 / 轮询)。
*
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class IPGeo {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 查询 IP 归属地。
*
* @param string $ip IPv4 / IPv6
* @param bool $force 管理端测试:跳过启用开关与缓存;仍拒绝私有 IP
* @return array{country:string,province:string,city:string,raw:array,reason?:string,provider?:string,http_code?:int,detail?:string}
*/
public function lookup(string $ip, bool $force = false): array {
$config = $this->get_config();
$ip = trim($ip);
if (!$force && empty($config['ip_geo_enabled'])) {
return $this->empty_result('disabled');
}
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
return $this->empty_result('invalid_ip');
}
if ($this->is_private_ip($ip)) {
return $this->empty_result('private_ip');
}
$cache_key = 'aksrt_ip_geo_' . md5($ip);
$ttl = max(3600, (int) ($config['ip_geo_cache_ttl'] ?? 86400));
// Negative cache: avoid hammering providers on repeated failures (admin force skips).
$neg_key = 'aksrt_ip_geo_neg_' . md5($ip);
$neg_ttl = 600;
if (!$force) {
$cached = get_transient($cache_key);
if ($cached !== false && is_array($cached)) {
return $cached;
}
$neg = get_transient($neg_key);
if ($neg !== false && is_array($neg)) {
return $neg;
}
}
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
$result = $this->query_provider($provider, $ip, $config);
if ($this->has_location($result)) {
// Strip bulky raw payloads before caching.
$store = $result;
unset($store['raw']);
if (!$force) {
set_transient($cache_key, $store, $ttl);
delete_transient($neg_key);
}
return $result;
}
if (empty($result['reason'])) {
$result['reason'] = 'provider_empty';
}
// Cache miss with reason so front-end doesn't retry every page view.
if (!$force) {
$store = $result;
unset($store['raw']);
set_transient($neg_key, $store, $neg_ttl);
}
return $result;
}
public function get_status(): array {
$config = $this->get_config();
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
$enabled = !empty($config['ip_geo_enabled']);
$missing = [];
if ($enabled && $provider === 'qq') {
if (empty($config['ip_addr_sdk_qq_appkey'])) {
$missing[] = 'ip_addr_sdk_qq_appkey';
}
}
if ($enabled && $provider === 'amap') {
if (empty($config['ip_addr_sdk_amap_appkey'])) {
$missing[] = 'ip_addr_sdk_amap_appkey';
}
}
// polling / null (free): no hard credential requirements.
// Free + polling are always "configured" once enabled; paid need keys.
// verified flips after a successful admin test (or any write of ip_geo_verified).
$configured = $enabled && empty($missing);
return [
'enabled' => $enabled,
'provider' => $provider,
'configured' => $configured,
'available' => $enabled,
'verified' => !empty($config['ip_geo_verified']),
'transport' => 'http_api',
'missing_fields' => $missing,
'cache_ttl' => (int) ($config['ip_geo_cache_ttl'] ?? 86400),
'display' => (array) ($config['ip_geo_display'] ?? ['comment', 'user_home', 'user_center', 'post']),
'format' => (string) ($config['ip_geo_format'] ?? 'province_city'),
];
}
public function format(array $geo, string $format = ''): string {
if ($format === '') {
$config = $this->get_config();
$format = (string) ($config['ip_geo_format'] ?? 'province_city');
}
$country = $this->string_field($geo['country'] ?? '');
$province = $this->string_field($geo['province'] ?? '');
$city = $this->string_field($geo['city'] ?? '');
if ($province !== '' && $city !== '' && $province === $city) {
$city = '';
}
switch ($format) {
case 'province':
if ($province !== '') {
return $province;
}
return $city !== '' ? $city : $country;
case 'country_province':
$joined = trim($country . ' ' . $province);
return $joined !== '' ? $joined : $city;
case 'province_city':
default:
$parts = array_filter([$province, $city]);
return $parts ? implode(' ', $parts) : $country;
}
}
private function get_config(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_ip_geo_settings();
}
return ['ip_geo_enabled' => false];
}
private function query_provider(string $provider, string $ip, array $config): array {
switch ($provider) {
case 'qq':
return $this->query_qq($ip, $config);
case 'amap':
$amap = $this->query_amap($ip, $config);
if ($this->has_location($amap)) {
return $amap;
}
// Credential / platform mismatch: free path so front still works.
if ($this->should_fallback_free($amap)) {
$free = $this->query_pconline($ip);
if ($this->has_location($free)) {
$free['detail'] = 'amap_failed_fallback_free:' . (string) ($amap['detail'] ?? $amap['reason'] ?? '');
return $free;
}
}
return $amap;
case 'polling':
$last = $this->empty_result('provider_empty');
foreach (['qq', 'amap', 'null'] as $fallback) {
if ($fallback === 'qq' && empty($config['ip_addr_sdk_qq_appkey'])) {
continue;
}
if ($fallback === 'amap' && empty($config['ip_addr_sdk_amap_appkey'])) {
continue;
}
// Avoid re-entering amap→free→… recursion: call leaf methods.
if ($fallback === 'amap') {
$result = $this->query_amap($ip, $config);
} elseif ($fallback === 'qq') {
$result = $this->query_qq($ip, $config);
} else {
$result = $this->query_pconline($ip);
}
if ($this->has_location($result)) {
return $result;
}
$last = $result;
}
// Last resort free path.
$free = $this->query_pconline($ip);
return $this->has_location($free) ? $free : $last;
case 'null':
default:
// Admin label「太平洋公共接口」: pconline, with ip-api if blocked.
return $this->query_pconline($ip);
}
}
/**
* Whether a paid-provider failure should fall back to free IP lookup.
*/
private function should_fallback_free(array $result): bool {
$reason = (string) ($result['reason'] ?? '');
if (in_array($reason, ['missing_key', 'http_error'], true)) {
return true;
}
if ($reason !== 'provider_error') {
return false;
}
$detail = strtoupper((string) ($result['detail'] ?? ''));
foreach (['USERKEY_PLAT_NOMATCH', 'INVALID_USER_KEY', 'INVALID_USER_SCODE', 'DAILY_QUERY_OVER_LIMIT', 'ACCESS_TOO_FREQUENT', 'CUQPS_HAS_EXCEEDED_THE_LIMIT'] as $code) {
if (str_contains($detail, $code)) {
return true;
}
}
return false;
}
private function query_qq(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
$secretkey = trim((string) ($config['ip_addr_sdk_qq_secretkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result('missing_key', ['provider' => 'qq', 'detail' => 'missing appkey']);
}
// Path used for Tencent signature (zibll parity).
$path = '/ws/location/v1/ip';
$query = 'ip=' . rawurlencode($ip) . '&key=' . rawurlencode($appkey);
$url = 'https://apis.map.qq.com' . $path . '?' . $query;
if ($secretkey !== '') {
$url .= '&sig=' . md5($path . '?' . $query . $secretkey);
}
$http = $this->http_get($url, 8);
if (!$http['ok']) {
return $this->empty_result('http_error', [
'provider' => 'qq',
'http_code' => $http['code'],
'detail' => $http['error'],
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
]);
}
$data = $http['json'];
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) {
$msg = is_array($data) ? (string) ($data['message'] ?? 'status_error') : 'invalid_json';
return $this->empty_result('provider_error', [
'provider' => 'qq',
'http_code' => $http['code'],
'detail' => $msg,
'raw' => is_array($data) ? $data : [],
]);
}
$info = is_array($data['result']['ad_info'] ?? null) ? $data['result']['ad_info'] : [];
return [
'country' => $this->string_field($info['nation'] ?? '中国'),
'province' => $this->string_field($info['province'] ?? ''),
'city' => $this->string_field($info['city'] ?? ''),
'raw' => $data,
'provider' => 'qq',
];
}
private function query_amap(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
$secretkey = trim((string) ($config['ip_addr_sdk_amap_secretkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result('missing_key', [
'provider' => 'amap',
'detail' => 'missing appkey',
]);
}
// Amap IP API is IPv4-only; free path for IPv6.
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$free = $this->query_ip_api($ip);
if ($this->has_location($free)) {
$free['detail'] = 'amap_ipv6_fallback_ip_api';
return $free;
}
return $this->empty_result('provider_empty', [
'provider' => 'amap',
'detail' => 'amap_ipv4_only',
]);
}
// Amap sign: md5(sorted query without sig + secret) — zibll uses ip=&key= order.
$base_query = 'ip=' . $ip . '&key=' . $appkey;
$url = 'https://restapi.amap.com/v3/ip?' . $base_query;
if ($secretkey !== '') {
$url .= '&sig=' . md5($base_query . $secretkey);
}
$http = $this->http_get($url, 8);
if (!$http['ok']) {
return $this->empty_result('http_error', [
'provider' => 'amap',
'http_code' => $http['code'],
'detail' => $http['error'],
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
]);
}
$data = $http['json'];
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') {
$info = is_array($data) ? (string) ($data['info'] ?? 'status_error') : 'invalid_json';
// Common: INVALID_USER_KEY / DAILY_QUERY_OVER_LIMIT / USERKEY_PLAT_NOMATCH
return $this->empty_result('provider_error', [
'provider' => 'amap',
'http_code' => $http['code'],
'detail' => $info,
'raw' => is_array($data) ? $data : [],
]);
}
// Amap returns empty arrays for province/city on some IPs / overseas.
$province = $this->string_field($data['province'] ?? '');
$city = $this->string_field($data['city'] ?? '');
$country = '中国';
if ($province === '' && $city === '') {
return $this->empty_result('provider_empty', [
'provider' => 'amap',
'detail' => 'empty province/city',
'raw' => $data,
]);
}
return [
'country' => $country,
'province' => $province,
'city' => $city,
'raw' => $data,
'provider' => 'amap',
];
}
/**
* 太平洋网络 whois(GB2312 正文)。
*/
private function query_pconline(string $ip): array {
$url = 'http://whois.pconline.com.cn/ipJson.jsp?json=true&ip=' . rawurlencode($ip);
$http = $this->http_get($url, 8, [
'Accept' => '*/*',
'Accept-Language' => 'zh-CN,zh;q=0.9',
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer' => 'https://www.pconline.com.cn/',
]);
if (!$http['ok']) {
// Fallback: ip-api only when pconline network fails (not dual storage — runtime failover).
$fallback = $this->query_ip_api($ip);
if ($this->has_location($fallback)) {
$fallback['provider'] = 'ip-api';
$fallback['detail'] = 'pconline_failed_fallback_ip_api';
return $fallback;
}
return $this->empty_result('http_error', [
'provider' => 'pconline',
'http_code' => $http['code'],
'detail' => $http['error'] !== '' ? $http['error'] : 'pconline blocked or unreachable',
'raw' => $http['body_raw'] !== '' ? ['body' => mb_substr($http['body_raw'], 0, 500)] : [],
]);
}
$body = $http['body_raw'];
// pconline often serves GBK/GB2312.
if (function_exists('mb_convert_encoding')) {
$converted = @mb_convert_encoding($body, 'UTF-8', 'GBK,GB2312,UTF-8');
if (is_string($converted) && $converted !== '') {
$body = $converted;
}
}
$json = json_decode($body, true);
if (is_array($json)) {
$province = $this->string_field($json['pro'] ?? $json['province'] ?? '');
$city = $this->string_field($json['city'] ?? '');
$region = $this->string_field($json['region'] ?? '');
$addr = $this->string_field($json['addr'] ?? '');
$country = '';
if ($addr !== '' && preg_match('/^(\S+)/u', $addr, $m)) {
$country = $m[1];
}
if ($province !== '' || $city !== '') {
return [
'country' => $country !== '' ? $country : '中国',
'province' => $province,
'city' => $city !== '' ? $city : $region,
'raw' => $json,
'provider' => 'pconline',
];
}
}
// Regex fallback (zibll).
preg_match('/"pro":"(.*?)"/u', $body, $pro);
preg_match('/"city":"(.*?)"/u', $body, $city);
preg_match('/"region":"(.*?)"/u', $body, $region);
preg_match('/"addr":"(.*?)(\s)(.*?)"/u', $body, $addr);
$province = $this->string_field($pro[1] ?? '');
$city_s = $this->string_field($city[1] ?? '');
$country = $this->string_field($addr[1] ?? '');
if ($province === '' && $city_s === '') {
// HTTP OK but unusable body (block page / CAPTCHA / empty) → free path.
$fallback = $this->query_ip_api($ip);
if ($this->has_location($fallback)) {
$fallback['provider'] = 'ip-api';
$fallback['detail'] = 'pconline_parse_failed_fallback_ip_api';
return $fallback;
}
return $this->empty_result('provider_empty', [
'provider' => 'pconline',
'detail' => 'parse_failed',
'raw' => ['body' => mb_substr($body, 0, 500)],
]);
}
return [
'country' => $country !== '' ? $country : '中国',
'province' => $province,
'city' => $city_s !== '' ? $city_s : $this->string_field($region[1] ?? ''),
'raw' => ['body' => $body],
'provider' => 'pconline',
];
}
private function query_ip_api(string $ip): array {
$url = 'http://ip-api.com/json/' . rawurlencode($ip) . '?lang=zh-CN&fields=status,message,country,regionName,city,query';
$http = $this->http_get($url, 6);
if (!$http['ok']) {
return $this->empty_result('http_error', [
'provider' => 'ip-api',
'http_code' => $http['code'],
'detail' => $http['error'],
]);
}
$data = $http['json'];
if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
return $this->empty_result('provider_error', [
'provider' => 'ip-api',
'detail' => is_array($data) ? (string) ($data['message'] ?? 'fail') : 'invalid_json',
'raw' => is_array($data) ? $data : [],
]);
}
return [
'country' => $this->string_field($data['country'] ?? ''),
'province' => $this->string_field($data['regionName'] ?? ''),
'city' => $this->string_field($data['city'] ?? ''),
'raw' => $data,
'provider' => 'ip-api',
];
}
/**
* @param array<string,string> $extra_headers
* @return array{ok:bool,code:int,error:string,json:?array,body_raw:string}
*/
private function http_get(string $url, int $timeout = 8, array $extra_headers = []): array {
$headers = array_merge([
'Accept' => 'application/json,text/plain,*/*',
'User-Agent' => 'AKSRT-IPGeo/3.4 (+WordPress)',
], $extra_headers);
$response = wp_remote_get($url, [
'timeout' => $timeout,
'redirection' => 3,
'sslverify' => true,
'headers' => $headers,
]);
if (is_wp_error($response)) {
return [
'ok' => false,
'code' => 0,
'error' => $response->get_error_message(),
'json' => null,
'body_raw' => '',
];
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
return [
'ok' => false,
'code' => $code,
'error' => 'HTTP ' . $code,
'json' => null,
'body_raw' => $body,
];
}
$json = json_decode($body, true);
return [
'ok' => true,
'code' => $code,
'error' => '',
'json' => is_array($json) ? $json : null,
'body_raw' => $body,
];
}
private function is_private_ip(string $ip): bool {
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
private function has_location(array $result): bool {
return $this->string_field($result['province'] ?? '') !== ''
|| $this->string_field($result['city'] ?? '') !== ''
|| $this->string_field($result['country'] ?? '') !== '';
}
/**
* Amap may return [] for empty province/city.
*
* @param mixed $value Raw field.
*/
private function string_field($value): string {
if (is_array($value)) {
if ($value === []) {
return '';
}
$value = reset($value);
}
if (!is_scalar($value) && $value !== null) {
return '';
}
return trim((string) $value);
}
/**
* @param array<string,mixed> $extra Extra fields.
* @return array{country:string,province:string,city:string,raw:array,reason?:string}
*/
private function empty_result(string $reason = '', array $extra = []): array {
$out = [
'country' => '',
'province' => '',
'city' => '',
'raw' => is_array($extra['raw'] ?? null) ? $extra['raw'] : [],
];
if ($reason !== '') {
$out['reason'] = $reason;
}
foreach (['provider', 'http_code', 'detail'] as $k) {
if (array_key_exists($k, $extra)) {
$out[$k] = $extra[$k];
}
}
return $out;
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class MeiliSearch {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!$this->is_configured()) return;
add_filter('posts_search', [$this, 'override_search'], 10, 2);
add_action('aksrt_meilisearch_sync', [$this, 'sync_all_posts']);
add_action('save_post', [$this, 'on_save_post'], 10, 3);
add_action('delete_post', [$this, 'on_delete_post']);
// Register cron schedule
add_filter('cron_schedules', function ($schedules) {
$schedules['aksrt_meilisearch_hourly'] = [
'interval' => HOUR_IN_SECONDS,
'display' => __('每小时', 'aksrt'),
];
return $schedules;
});
if (!wp_next_scheduled('aksrt_meilisearch_sync')) {
wp_schedule_event(time(), 'aksrt_meilisearch_hourly', 'aksrt_meilisearch_sync');
}
}
/**
* Check if MeiliSearch is configured
*/
public function is_configured(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) return false;
$host = \AKSRT\Settings\Settings::get_value('meilisearch_host', '');
$key = \AKSRT\Settings\Settings::get_value('meilisearch_api_key', '');
return !empty($host) && !empty($key);
}
/**
* Resolve which post types are indexed/searched. Shares the single
* data-selection contract with front search and search push.
*
* @return string[]
*/
private function indexable_post_types(): array {
if (function_exists('aksrt_searchable_post_types')) {
return aksrt_searchable_post_types();
}
return ['post'];
}
/**
* Get MeiliSearch config
*/
private function get_config(): array {
return [
'host' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_host', ''),
'api_key' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_api_key', ''),
'index' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_index', 'aksrt_posts'),
];
}
/**
* Make HTTP request to MeiliSearch
*/
private function request(string $method, string $path, array $body = []): ?array {
$config = $this->get_config();
$url = rtrim($config['host'], '/') . $path;
$args = [
'method' => $method,
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . $config['api_key'],
'Content-Type' => 'application/json',
],
];
if (!empty($body)) {
$args['body'] = wp_json_encode($body);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return null;
}
$code = wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 300) {
return null;
}
$data = json_decode(wp_remote_retrieve_body($response), true);
return is_array($data) ? $data : null;
}
/**
* Search posts via MeiliSearch
*/
public function search(string $query, array $args = []): array {
$config = $this->get_config();
$index = $config['index'];
$body = [
'q' => $query,
'limit' => $args['posts_per_page'] ?? get_option('posts_per_page', 10),
'offset' => (($args['paged'] ?? 1) - 1) * ($args['posts_per_page'] ?? get_option('posts_per_page', 10)),
'attributesToRetrieve' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail'],
];
// Filter by post type
if (!empty($args['post_type'])) {
$body['filter'] = 'post_type = ' . $args['post_type'];
}
$result = $this->request('POST', "/indexes/{$index}/search", $body);
if (!$result) return [];
return [
'hits' => $result['hits'] ?? [],
'total' => $result['estimatedTotalHits'] ?? 0,
'query_time' => $result['processingTimeMs'] ?? 0,
];
}
/**
* Override WordPress search with MeiliSearch
*/
public function override_search(string $search, \WP_Query $query): string {
if (!$query->is_search() || !$query->is_main_query()) {
return $search;
}
$q = $query->get('s');
if (empty($q)) return $search;
$results = $this->search($q, [
'posts_per_page' => $query->get('posts_per_page'),
'paged' => $query->get('paged') ?: 1,
'post_type' => $query->get('post_type') ?: 'post',
]);
if (empty($results['hits'])) {
// No results — force empty
return 'AND 1=0';
}
$ids = array_map(function ($hit) {
return (int) ($hit['id'] ?? 0);
}, $results['hits']);
$ids = array_filter($ids);
if (empty($ids)) return 'AND 1=0';
global $wpdb;
return 'AND ' . $wpdb->posts . '.ID IN (' . implode(',', $ids) . ')';
}
/**
* Index a single post
*/
public function index_post(int $post_id): bool {
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') return false;
$config = $this->get_config();
$index = $config['index'];
$doc = [
'id' => $post_id,
'title' => $post->post_title,
'excerpt' => wp_strip_all_tags(get_the_excerpt($post)),
'content' => wp_strip_all_tags($post->post_content),
'url' => get_permalink($post_id),
'date' => $post->post_date,
'post_type' => $post->post_type,
'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '',
];
$result = $this->request('PUT', "/indexes/{$index}/documents", [$doc]);
return $result !== null;
}
/**
* Delete a post from index
*/
public function delete_post(int $post_id): bool {
$config = $this->get_config();
$index = $config['index'];
$result = $this->request('DELETE', "/indexes/{$index}/documents/{$post_id}");
return $result !== null;
}
/**
* Sync all published posts
*/
public function sync_all_posts(): void {
$config = $this->get_config();
$index = $config['index'];
// Create index if not exists
$this->request('POST', '/indexes', ['uid' => $index, 'primaryKey' => 'id']);
// Configure filterable attributes
$this->request('PUT', "/indexes/{$index}/settings", [
'filterableAttributes' => ['post_type'],
'searchableAttributes' => ['title', 'excerpt', 'content'],
'displayedAttributes' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail', 'post_type'],
]);
$posts = get_posts([
'post_type' => $this->indexable_post_types(),
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
]);
$batch = [];
foreach ($posts as $post_id) {
$post = get_post($post_id);
if (!$post) continue;
$batch[] = [
'id' => (int) $post_id,
'title' => $post->post_title,
'excerpt' => wp_strip_all_tags(get_the_excerpt($post)),
'content' => wp_strip_all_tags($post->post_content),
'url' => get_permalink($post_id),
'date' => $post->post_date,
'post_type' => $post->post_type,
'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '',
];
// Batch 100 at a time
if (count($batch) >= 100) {
$this->request('PUT', "/indexes/{$index}/documents", $batch);
$batch = [];
}
}
if (!empty($batch)) {
$this->request('PUT', "/indexes/{$index}/documents", $batch);
}
}
/**
* Hook: re-index on post save
*/
public function on_save_post(int $post_id, $post, bool $update): void {
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return;
if ($post->post_status !== 'publish') return;
if (!in_array($post->post_type, $this->indexable_post_types(), true)) return;
// Debounce: schedule async
wp_schedule_single_event(time() + 5, 'aksrt_meilisearch_index_single', [$post_id]);
}
/**
* Hook: remove from index on post delete
*/
public function on_delete_post(int $post_id): void {
$this->delete_post($post_id);
}
}
+235
View File
@@ -0,0 +1,235 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class SearchPush {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
/**
* 绑定运行时钩子:发布/更新主动推送 + 前台自动推送 JS。
*/
public function init(): void {
add_action('transition_post_status', [$this, 'on_transition_post_status'], 10, 3);
add_action('wp_footer', [$this, 'output_auto_push_script'], 99);
}
/**
* 文章发布/更新时主动推送链接到百度。
*
* @param string $new_status 新状态。
* @param string $old_status 旧状态。
* @param \WP_Post $post 文章对象。
*/
public function on_transition_post_status(string $new_status, string $old_status, $post): void {
if (!($post instanceof \WP_Post)) {
return;
}
if ($new_status !== 'publish') {
return;
}
if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
return;
}
// 仅推送公开可索引的内容类型。
$allowed = function_exists('aksrt_searchable_post_types')
? aksrt_searchable_post_types()
: ['post'];
if (!in_array($post->post_type, $allowed, true)) {
return;
}
$config = $this->get_config();
if (empty($config['baidu_push_enabled'])) {
return;
}
$is_new = $old_status !== 'publish';
if ($is_new && empty($config['baidu_push_on_publish'])) {
return;
}
if (!$is_new && empty($config['baidu_push_on_update'])) {
return;
}
$this->push_url((string) get_permalink($post));
}
/**
* 前台输出百度自动推送 JS(由 wp_footer 调用)。
*/
public function output_auto_push_script(): void {
if (is_admin()) {
return;
}
$script = $this->get_auto_push_script();
if ($script !== '') {
echo $script; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — 固定常量脚本,无用户输入
}
}
/**
* 推送单条 URL 到百度
*
* @param string $url 待推送的完整 URL
* @return array{success: bool, message: string, result: array}
*/
public function push_url(string $url): array {
$config = $this->get_config();
if (empty($config['baidu_push_enabled'])) {
return ['success' => false, 'message' => __('百度推送未启用', 'aksrt'), 'result' => []];
}
$token = (string) ($config['baidu_push_token'] ?? '');
if ($token === '') {
return ['success' => false, 'message' => __('推送 Token 未配置', 'aksrt'), 'result' => []];
}
$api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}";
$response = wp_remote_post($api_url, [
'timeout' => 10,
'headers' => ['Content-Type' => 'text/plain'],
'body' => $url,
]);
if (is_wp_error($response)) {
$this->log('error', $url, $response->get_error_message());
return ['success' => false, 'message' => $response->get_error_message(), 'result' => []];
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$status = wp_remote_retrieve_response_code($response);
if ($status === 200 && isset($body['success'])) {
$this->log('success', $url, wp_json_encode($body));
return ['success' => true, 'message' => __('推送成功', 'aksrt'), 'result' => $body];
}
$err_msg = $body['message'] ?? ($body['error'] ?? sprintf('HTTP %d', $status));
$this->log('error', $url, $err_msg);
return ['success' => false, 'message' => $err_msg, 'result' => $body ?? []];
}
/**
* 批量推送 URL
*
* @param string[] $urls
* @return array{success: int, failed: int, results: array}
*/
public function push_batch(array $urls): array {
$config = $this->get_config();
$batch_size = max(1, min(500, (int) ($config['baidu_push_batch_size'] ?? 50)));
$success = 0;
$failed = 0;
$results = [];
$chunks = array_chunk($urls, $batch_size);
foreach ($chunks as $chunk) {
$token = (string) ($config['baidu_push_token'] ?? '');
$api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}";
$body_str = implode("\n", $chunk);
$response = wp_remote_post($api_url, [
'timeout' => 15,
'headers' => ['Content-Type' => 'text/plain'],
'body' => $body_str,
]);
if (is_wp_error($response)) {
$failed += count($chunk);
$results[] = ['error' => $response->get_error_message()];
continue;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (isset($body['success'])) {
$success += (int) ($body['success'] ?? 0);
$failed += (int) ($body['not_same_site'] ?? 0) + (int) ($body['not_valid'] ?? 0);
} else {
$failed += count($chunk);
}
$results[] = $body ?? [];
}
return ['success' => $success, 'failed' => $failed, 'results' => $results];
}
/**
* 获取自动推送 JS 代码
*/
public function get_auto_push_script(): string {
$config = $this->get_config();
if (empty($config['baidu_push_enabled']) || empty($config['baidu_push_auto'])) {
return '';
}
$site_url = home_url('/');
return <<<SCRIPT
<script>
(function(){
var bp = document.createElement('script');
var curProtocol = window.location.protocol.split(':')[0];
if (curProtocol === 'https') {
bp.src = 'https://zz.bdstatic.com/linksubmit/push.js';
} else {
bp.src = 'http://push.zhanzhang.baidu.com/push.js';
}
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(bp, s);
})();
</script>
SCRIPT;
}
/**
* 获取服务运行时状态
*/
public function get_status(): array {
$config = $this->get_config();
$enabled = !empty($config['baidu_push_enabled']);
$token = (string) ($config['baidu_push_token'] ?? '');
$missing = [];
if ($enabled && $token === '') {
$missing[] = 'baidu_push_token';
}
return [
'enabled' => $enabled,
'provider' => 'baidu',
'configured' => $enabled && $token !== '',
'available' => $enabled && $token !== '',
'verified' => !empty($config['baidu_push_verified']),
'transport' => 'http_api',
'missing_fields' => $missing,
'auto_push' => !empty($config['baidu_push_auto']),
'on_publish' => !empty($config['baidu_push_on_publish']),
'on_update' => !empty($config['baidu_push_on_update']),
'batch_size' => max(1, (int) ($config['baidu_push_batch_size'] ?? 50)),
];
}
private function get_config(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_search_push_settings();
}
return ['baidu_push_enabled' => false];
}
private function log(string $level, string $url, string $message): void {
if (!defined('WP_DEBUG') || !WP_DEBUG) return;
error_log(sprintf('[AKSRT Search Push] [%s] URL: %s — %s', $level, $url, $message));
}
}