index-main/site/plugins/kirby-seo/classes/GoogleSearchConsole.php
isUnknown 04a14a7f1f chore: update kirby-seo plugin to v2.0.0-alpha.12
Update plugin from v1.1.2 to v2.0.0-alpha.12 for Kirby 5 compatibility.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 16:23:23 +01:00

386 lines
8.8 KiB
PHP

<?php
namespace tobimori\Seo;
use Kirby\Cache\Cache;
use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Data\Json;
use Kirby\Filesystem\F;
use Kirby\Http\Remote;
use Kirby\Http\Uri;
use function array_slice;
class GoogleSearchConsole
{
protected const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
protected const TOKEN_URL = 'https://oauth2.googleapis.com/token';
protected const SCOPES = 'email https://www.googleapis.com/auth/webmasters.readonly';
protected const CACHE_DURATION = 60 * 24; // 24 hours in minutes
protected static ?array $tokens = null;
protected static function cache(): Cache
{
return App::instance()->cache('tobimori.seo.searchConsole');
}
/**
* Get OAuth credentials from config
*/
public static function credentials(): ?array
{
return Seo::option('searchConsole.credentials.web');
}
/**
* Check if credentials are configured
*/
public static function hasCredentials(): bool
{
$credentials = static::credentials();
return !empty($credentials['client_id']) && !empty($credentials['client_secret']);
}
/**
* Get the token file path
*/
protected static function tokenPath(): string
{
$path = Seo::option('searchConsole.tokenPath');
return is_callable($path) ? $path() : $path;
}
/**
* Load tokens from file
*/
public static function tokens(): ?array
{
if (static::$tokens !== null) {
return static::$tokens;
}
$path = static::tokenPath();
if (!F::exists($path)) {
return null;
}
static::$tokens = Json::read($path);
return static::$tokens;
}
/**
* Save tokens to file
*/
protected static function saveTokens(array $tokens): void
{
static::$tokens = $tokens;
Json::write(static::tokenPath(), $tokens);
}
/**
* Check if we have valid tokens
*/
public static function isConnected(): bool
{
$tokens = static::tokens();
return !empty($tokens['access_token']) && !empty($tokens['refresh_token']);
}
/**
* Get the authorization URL
*/
public static function authUrl(string $redirectUri, string $state): string
{
$credentials = static::credentials();
$uri = new Uri(static::AUTH_URL);
$uri->query()->merge([
'client_id' => $credentials['client_id'],
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'access_type' => 'offline',
'prompt' => 'consent',
'scope' => static::SCOPES,
'state' => $state
]);
return $uri->toString();
}
/**
* Exchange authorization code for tokens
*/
public static function exchangeCode(string $code, string $redirectUri): array
{
$credentials = static::credentials();
$response = Remote::request(static::TOKEN_URL, [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded'
],
'data' => [
'client_id' => $credentials['client_id'],
'client_secret' => $credentials['client_secret'],
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUri
]
]);
$data = $response->json();
if (isset($data['error'])) {
throw new \Exception($data['error_description'] ?? $data['error']);
}
// store tokens with expiry timestamp
$tokens = [
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'],
'expires_at' => time() + $data['expires_in']
];
static::saveTokens($tokens);
return $tokens;
}
/**
* Refresh the access token
*/
public static function refreshToken(): string
{
$credentials = static::credentials();
$tokens = static::tokens();
if (empty($tokens['refresh_token'])) {
throw new \Exception('No refresh token available');
}
$response = Remote::request(static::TOKEN_URL, [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded'
],
'data' => [
'client_id' => $credentials['client_id'],
'client_secret' => $credentials['client_secret'],
'refresh_token' => $tokens['refresh_token'],
'grant_type' => 'refresh_token'
]
]);
$data = $response->json();
if (isset($data['error'])) {
throw new \Exception($data['error_description'] ?? $data['error']);
}
$tokens['access_token'] = $data['access_token'];
$tokens['expires_at'] = time() + $data['expires_in'];
static::saveTokens($tokens);
return $tokens['access_token'];
}
/**
* Get a valid access token, refreshing if needed
*/
public static function accessToken(): string
{
$tokens = static::tokens();
if (empty($tokens['access_token'])) {
throw new \Exception('Not connected to Google Search Console');
}
// refresh if expired or expiring soon (within 5 min)
if ($tokens['expires_at'] < time() + 300) {
return static::refreshToken();
}
return $tokens['access_token'];
}
/**
* Get the connected property URL
*/
public static function property(): ?string
{
$tokens = static::tokens();
return $tokens['property'] ?? null;
}
/**
* Find the best matching property for a site URL
*/
public static function findMatchingProperty(string $siteUrl): ?string
{
$properties = static::listProperties();
if (empty($properties)) {
return null;
}
$siteHost = parse_url($siteUrl, PHP_URL_HOST);
foreach ($properties as $p) {
$propUrl = $p['siteUrl'];
// check domain properties (sc-domain:example.com)
if (str_starts_with($propUrl, 'sc-domain:')) {
$domain = substr($propUrl, 10);
if ($domain === $siteHost || str_ends_with($siteHost, ".{$domain}")) {
return $propUrl;
}
}
// check URL prefix properties
if (str_starts_with($siteUrl, $propUrl) || $propUrl === "{$siteUrl}/") {
return $propUrl;
}
}
// fallback to first property
return $properties[0]['siteUrl'] ?? null;
}
/**
* Set the connected property URL
*/
public static function setProperty(string $property): void
{
$tokens = static::tokens() ?? [];
$tokens['property'] = $property;
static::saveTokens($tokens);
}
/**
* Disconnect (remove tokens)
*/
public static function disconnect(): void
{
$path = static::tokenPath();
if (F::exists($path)) {
F::remove($path);
}
static::$tokens = null;
}
/**
* List available GSC properties
*/
public static function listProperties(): array
{
$response = Remote::request('https://www.googleapis.com/webmasters/v3/sites', [
'method' => 'GET',
'headers' => [
'Authorization' => 'Bearer ' . static::accessToken()
]
]);
$data = $response->json();
if (isset($data['error'])) {
throw new \Exception($data['error']['message'] ?? 'Failed to list properties');
}
return $data['siteEntry'] ?? [];
}
/**
* Query search analytics data (fetches max 25k rows from Google, cached for 24h)
*/
public static function query(array $options = []): array
{
$property = static::property();
if (!$property) {
throw new \Exception('No property selected');
}
$body = [
'startDate' => $options['startDate'] ?? date('Y-m-d', strtotime('-28 days')),
'endDate' => $options['endDate'] ?? date('Y-m-d', strtotime('-1 day')),
'dimensions' => $options['dimensions'] ?? ['query'],
'rowLimit' => 25000
];
if (!empty($options['page'])) {
$body['dimensionFilterGroups'] = [[
'filters' => [[
'dimension' => 'page',
'operator' => $options['pageOperator'] ?? 'equals',
'expression' => $options['page']
]]
]];
}
$cacheKey = md5($property . json_encode($body));
$cached = static::cache()->get($cacheKey);
if ($cached !== null) {
return $cached;
}
$uri = new Uri('https://www.googleapis.com/webmasters/v3/sites');
$uri->setPath($uri->path() . '/' . urlencode($property) . '/searchAnalytics/query');
$response = Remote::request($uri->toString(), [
'method' => 'POST',
'headers' => [
'Authorization' => 'Bearer ' . static::accessToken(),
'Content-Type' => 'application/json'
],
'data' => json_encode($body)
]);
$data = $response->json();
if (isset($data['error'])) {
throw new \Exception($data['error']['message'] ?? 'Failed to query search analytics');
}
$rows = $data['rows'] ?? [];
static::cache()->set($cacheKey, $rows, static::CACHE_DURATION);
return $rows;
}
/**
* Query search data for a Kirby model (page or site), sorted by metric
*/
public static function queryForModel($model, string $metric = 'clicks', int $limit = 10, bool $asc = false): array
{
if ($model instanceof Page) {
// try exact URL match first
$data = static::query(['page' => $model->url()]);
// fallback: match by path
if (empty($data)) {
$path = $model->uri();
if ($path) {
$data = static::query([
'page' => "/{$path}",
'pageOperator' => 'contains'
]);
}
}
} else {
$data = static::query();
}
if (empty($data)) {
return [];
}
$dir = $asc ? 1 : -1;
usort($data, fn ($a, $b) => match ($metric) {
'query' => strcasecmp($a['keys'][0], $b['keys'][0]) * $dir,
default => ($a[$metric] <=> $b[$metric]) * $dir
});
return array_slice($data, 0, $limit);
}
}