feat(batch8): remote-data presets — cached JSON fetch, OpenWeather, Google Place reviews, cache list/clear

This commit is contained in:
2026-10-16 01:19:28 +00:00
parent 0e9f92c808
commit 234e0c9517
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* Batch 8 — Remote-data presets (Pro-equivalent, net-new).
*
* Independent implementation of dynamic remote-data sources via wp_remote_get with
* transient caching: generic JSON fetch, OpenWeather, Google Place reviews, plus
* cache listing/clearing. API keys are passed per-call and never stored in code.
* GPL-2.0-or-later. See NOTICE.md.
*
* Registered via the asktam_mcp_register_tools hook (glob auto-loader).
*/
namespace AskTamMCP\Extensions;
defined('ABSPATH') || exit;
class Batch8 {
private const PREFIX = 'amp_rd_';
public static function register($registry): void {
$def = function (string $name, string $desc, array $props, array $required, string $scope, string $method) {
$schema = ['type' => 'object', 'properties' => $props];
if ($required) $schema['required'] = $required;
return ['name' => $name, 'description' => $desc, 'scope' => $scope,
'handler' => 'AskTamMCP\\Extensions\\Batch8::' . $method, 'inputSchema' => $schema];
};
$registry->register($def('remote_fetch_json', 'Fetch a JSON URL (GET) with transient caching. Optional dot-path (e.g. "data.0.name") extracts part of the response. cache_minutes default 15.',
['url' => ['type' => 'string'], 'cache_minutes' => ['type' => 'integer', 'default' => 15], 'path' => ['type' => 'string'], 'headers' => ['type' => 'object']],
['url'], 'read', 'fetchJson'));
$registry->register($def('remote_weather', 'Current weather from OpenWeather. Provide api_key and either city or lat+lon. units=metric (default) or imperial. Cached 30 min.',
['api_key' => ['type' => 'string'], 'city' => ['type' => 'string'], 'lat' => ['type' => 'number'], 'lon' => ['type' => 'number'], 'units' => ['type' => 'string', 'enum' => ['metric', 'imperial'], 'default' => 'metric']],
['api_key'], 'read', 'weather'));
$registry->register($def('remote_place_reviews', 'Google Place rating + reviews via Places Details API. Provide api_key and place_id. Cached 60 min.',
['api_key' => ['type' => 'string'], 'place_id' => ['type' => 'string']],
['api_key', 'place_id'], 'read', 'placeReviews'));
$registry->register($def('remote_list_cache', 'List cached remote-data entries (key + size).', [], [], 'read', 'listCache'));
$registry->register($def('remote_clear_cache', 'Clear cached remote-data. Provide key to clear one, or omit to clear all remote-data cache.',
['key' => ['type' => 'string']], [], 'write', 'clearCache'));
}
private static function cacheKey(string $raw): string {
return self::PREFIX . md5($raw);
}
private static function path($data, string $path) {
if ($path === '') return $data;
foreach (explode('.', $path) as $seg) {
if (is_array($data) && array_key_exists($seg, $data)) $data = $data[$seg];
elseif (is_array($data) && ctype_digit($seg) && array_key_exists((int) $seg, $data)) $data = $data[(int) $seg];
else return null;
}
return $data;
}
private static function getJson(string $url, int $cacheMin, array $headers = []) {
$key = self::cacheKey($url . serialize($headers));
$cached = get_transient($key);
if ($cached !== false) return ['data' => $cached, 'cached' => true];
$args = ['timeout' => 15];
if ($headers) $args['headers'] = $headers;
$resp = wp_remote_get($url, $args);
if (is_wp_error($resp)) throw new \RuntimeException('Fetch failed: ' . $resp->get_error_message());
$code = (int) wp_remote_retrieve_response_code($resp);
$body = wp_remote_retrieve_body($resp);
$json = json_decode($body, true);
if ($json === null && trim($body) !== 'null') throw new \RuntimeException("Non-JSON response (HTTP {$code}).");
if ($cacheMin > 0) set_transient($key, $json, $cacheMin * MINUTE_IN_SECONDS);
return ['data' => $json, 'cached' => false, 'http_code' => $code];
}
public static function fetchJson(array $args): array {
$url = (string) ($args['url'] ?? '');
if (!filter_var($url, FILTER_VALIDATE_URL)) throw new \RuntimeException('Valid url is required.');
$min = (int) ($args['cache_minutes'] ?? 15);
$headers = is_array($args['headers'] ?? null) ? $args['headers'] : [];
$res = self::getJson($url, $min, $headers);
$data = isset($args['path']) ? self::path($res['data'], (string) $args['path']) : $res['data'];
return ['url' => $url, 'cached' => $res['cached'], 'data' => $data];
}
public static function weather(array $args): array {
$key = (string) ($args['api_key'] ?? '');
if ($key === '') throw new \RuntimeException('api_key is required.');
$units = (($args['units'] ?? 'metric') === 'imperial') ? 'imperial' : 'metric';
$q = ['appid' => $key, 'units' => $units];
if (!empty($args['city'])) $q['q'] = (string) $args['city'];
elseif (isset($args['lat'], $args['lon'])) { $q['lat'] = (float) $args['lat']; $q['lon'] = (float) $args['lon']; }
else throw new \RuntimeException('Provide city or lat+lon.');
$url = 'https://api.openweathermap.org/data/2.5/weather?' . http_build_query($q);
$res = self::getJson($url, (int) ($args['cache_minutes'] ?? 30));
$d = $res['data'];
return [
'cached' => $res['cached'],
'location' => $d['name'] ?? '',
'temp' => $d['main']['temp'] ?? null,
'feels_like' => $d['main']['feels_like'] ?? null,
'humidity' => $d['main']['humidity'] ?? null,
'conditions' => $d['weather'][0]['description'] ?? '',
'units' => $units,
];
}
public static function placeReviews(array $args): array {
$key = (string) ($args['api_key'] ?? '');
$pid = (string) ($args['place_id'] ?? '');
if ($key === '' || $pid === '') throw new \RuntimeException('api_key and place_id are required.');
$url = 'https://maps.googleapis.com/maps/api/place/details/json?' . http_build_query([
'place_id' => $pid, 'key' => $key, 'fields' => 'name,rating,user_ratings_total,reviews',
]);
$res = self::getJson($url, (int) ($args['cache_minutes'] ?? 60));
$r = $res['data']['result'] ?? [];
$reviews = array_map(fn($rv) => [
'author' => $rv['author_name'] ?? '', 'rating' => $rv['rating'] ?? null,
'text' => $rv['text'] ?? '', 'time' => $rv['relative_time_description'] ?? '',
], (array) ($r['reviews'] ?? []));
return ['cached' => $res['cached'], 'name' => $r['name'] ?? '', 'rating' => $r['rating'] ?? null,
'total_ratings' => $r['user_ratings_total'] ?? null, 'reviews' => $reviews];
}
public static function listCache(array $args): array {
global $wpdb;
$like = '_transient_' . self::PREFIX . '%';
$rows = $wpdb->get_results($wpdb->prepare("SELECT option_name, LENGTH(option_value) AS len FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
$out = [];
foreach ((array) $rows as $row) {
$out[] = ['key' => str_replace('_transient_', '', $row->option_name), 'bytes' => (int) $row->len];
}
return ['count' => count($out), 'entries' => $out];
}
public static function clearCache(array $args): array {
global $wpdb;
if (!empty($args['key'])) {
$k = (string) $args['key'];
$ok = delete_transient($k);
return ['cleared' => $ok ? 1 : 0, 'key' => $k];
}
$like = '_transient_' . self::PREFIX . '%';
$names = $wpdb->get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
$n = 0;
foreach ((array) $names as $name) {
if (delete_transient(str_replace('_transient_', '', $name))) $n++;
}
return ['cleared' => $n];
}
}