File: //home/ezoshosting.com/scripts/price-sync.php
#!/usr/local/lsws/lsphp74/bin/php
<?php
declare(strict_types=1);
const PRICE_SOURCE_URL = 'https://support.ezoshosting.com/products/';
const DEFAULT_TARGET_PATH = '/home/ezoshosting.com/public_html/assets/data/prices.json';
const DEFAULT_LOG_PATH = '/home/ezoshosting.com/logs/price-sync.log';
const USER_AGENT = 'EZOS-PriceSync/1.0';
const HTTP_TIMEOUT_SECONDS = 15;
$targetPath = isset($argv[1]) && $argv[1] !== '' ? $argv[1] : DEFAULT_TARGET_PATH;
$logFromEnvironment = getenv('PRICE_SYNC_LOG');
$logPath = isset($argv[2]) && $argv[2] !== ''
? $argv[2]
: ($logFromEnvironment !== false && $logFromEnvironment !== '' ? $logFromEnvironment : DEFAULT_LOG_PATH);
$temporaryPath = null;
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
function writeLog(string $logPath, string $level, string $message): void
{
$line = sprintf('[%s] %s %s%s', gmdate('Y-m-d\TH:i:s\Z'), $level, $message, PHP_EOL);
@file_put_contents($logPath, $line, FILE_APPEND | LOCK_EX);
}
function fetchSource(string $url): string
{
if (extension_loaded('curl')) {
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Could not initialize cURL.');
}
curl_setopt_array($handle, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => HTTP_TIMEOUT_SECONDS,
CURLOPT_TIMEOUT => HTTP_TIMEOUT_SECONDS,
CURLOPT_USERAGENT => USER_AGENT,
CURLOPT_HTTPHEADER => array('Accept: text/html,application/xhtml+xml'),
CURLOPT_ENCODING => '',
));
$body = curl_exec($handle);
$curlError = curl_error($handle);
$statusCode = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
curl_close($handle);
if ($body === false) {
throw new RuntimeException('Source request failed: ' . $curlError);
}
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException('Source request returned HTTP ' . $statusCode . '.');
}
if ($body === '') {
throw new RuntimeException('Source response was empty.');
}
return $body;
}
$context = stream_context_create(array(
'http' => array(
'method' => 'GET',
'timeout' => HTTP_TIMEOUT_SECONDS,
'ignore_errors' => true,
'header' => "User-Agent: " . USER_AGENT . "\r\nAccept: text/html,application/xhtml+xml\r\n",
),
));
$body = file_get_contents($url, false, $context);
if ($body === false || $body === '') {
throw new RuntimeException('Source request failed or returned an empty response.');
}
$statusCode = 0;
if (isset($http_response_header) && preg_match('/^HTTP\/\S+\s+(\d{3})\b/', $http_response_header[0], $match)) {
$statusCode = (int) $match[1];
}
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException('Source request returned HTTP ' . ($statusCode ?: 'unknown') . '.');
}
return $body;
}
/** @return array<int, array{value: float, offset: int}> */
function findDollarAmounts(string $html): array
{
$result = preg_match_all(
'/\$\s*([0-9]+(?:,[0-9]{3})*(?:\.[0-9]{1,2})?)(?![0-9.])/',
$html,
$matches,
PREG_OFFSET_CAPTURE
);
if ($result === false) {
throw new RuntimeException('Dollar amount regex failed.');
}
$amounts = array();
foreach ($matches[1] as $index => $capture) {
$numeric = str_replace(',', '', $capture[0]);
if (is_numeric($numeric)) {
$amounts[] = array('value' => (float) $numeric, 'offset' => (int) $matches[0][$index][1]);
}
}
return $amounts;
}
/** @return int[] */
function findCategoryOffsets(string $html, string $pattern): array
{
$result = preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE);
if ($result === false) {
throw new RuntimeException('Category regex failed.');
}
return array_map(static function (array $capture): int {
return (int) $capture[1];
}, $matches[0]);
}
/**
* @param int[] $categoryOffsets
* @param array<int, array{value: float, offset: int}> $amounts
* @return array<int, array{amount_index: int, distance: int}>
*/
function nearbyCandidates(array $categoryOffsets, array $amounts): array
{
$candidates = array();
foreach ($categoryOffsets as $categoryOffset) {
foreach ($amounts as $amountIndex => $amount) {
$distance = abs($amount['offset'] - $categoryOffset);
if ($distance <= 2500) {
$candidates[] = array('amount_index' => $amountIndex, 'distance' => $distance);
}
}
}
usort($candidates, static function (array $left, array $right): int {
return $left['distance'] <=> $right['distance'];
});
return $candidates;
}
/** @return array{managed_local_ai_from: float, open_source_hosting_from: float} */
function extractPrices(string $html): array
{
$amounts = findDollarAmounts($html);
$localOffsets = findCategoryOffsets($html, '/(?:Managed\s+Local\s+AI|managed-local-ai)/i');
$hostingOffsets = findCategoryOffsets($html, '/(?:Open\s+Source\s+Hosting|open-source-hosting)/i');
$localCandidates = nearbyCandidates($localOffsets, $amounts);
$hostingCandidates = nearbyCandidates($hostingOffsets, $amounts);
$bestPair = null;
$bestScore = PHP_INT_MAX;
foreach ($localCandidates as $localCandidate) {
foreach ($hostingCandidates as $hostingCandidate) {
if ($localCandidate['amount_index'] === $hostingCandidate['amount_index']) {
continue;
}
$score = $localCandidate['distance'] + $hostingCandidate['distance'];
if ($score < $bestScore) {
$bestScore = $score;
$bestPair = array($localCandidate['amount_index'], $hostingCandidate['amount_index']);
}
}
}
if ($bestPair !== null) {
return array(
'managed_local_ai_from' => $amounts[$bestPair[0]]['value'],
'open_source_hosting_from' => $amounts[$bestPair[1]]['value'],
);
}
if (count($amounts) !== 2) {
throw new RuntimeException(
'Could not map prices to categories; fallback requires exactly 2 dollar amounts, found ' . count($amounts) . '.'
);
}
$values = array($amounts[0]['value'], $amounts[1]['value']);
sort($values, SORT_NUMERIC);
if ($values[0] === $values[1]) {
throw new RuntimeException('Fallback amounts are identical and cannot be assigned by magnitude.');
}
return array('managed_local_ai_from' => $values[1], 'open_source_hosting_from' => $values[0]);
}
/** @param array{managed_local_ai_from: float, open_source_hosting_from: float} $prices */
function validatePrices(array $prices): void
{
$localAi = $prices['managed_local_ai_from'];
$hosting = $prices['open_source_hosting_from'];
if (!is_finite($localAi) || $localAi < 100 || $localAi > 5000) {
throw new RuntimeException('Managed Local AI price failed the 100..5000 sanity range.');
}
if (!is_finite($hosting) || $hosting < 1 || $hosting > 500) {
throw new RuntimeException('Open Source Hosting price failed the 1..500 sanity range.');
}
}
try {
$html = fetchSource(PRICE_SOURCE_URL);
$prices = extractPrices($html);
validatePrices($prices);
$payload = array(
'updated' => gmdate('Y-m-d\TH:i:s\Z'),
'currency' => 'USD',
'managed_local_ai_from' => $prices['managed_local_ai_from'],
'open_source_hosting_from' => $prices['open_source_hosting_from'],
);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
if ($json === false) {
throw new RuntimeException('Could not encode price JSON: ' . json_last_error_msg());
}
$json .= PHP_EOL;
$targetDirectory = dirname($targetPath);
if (!is_dir($targetDirectory) || !is_writable($targetDirectory)) {
throw new RuntimeException('Target directory does not exist or is not writable: ' . $targetDirectory);
}
$temporaryPath = tempnam($targetDirectory, '.prices.');
if ($temporaryPath === false) {
throw new RuntimeException('Could not create a temporary file in the target directory.');
}
$bytesWritten = file_put_contents($temporaryPath, $json, LOCK_EX);
if ($bytesWritten === false || $bytesWritten !== strlen($json)) {
throw new RuntimeException('Could not write the complete temporary JSON file.');
}
if (!chmod($temporaryPath, 0644)) {
throw new RuntimeException('Could not set permissions on the temporary JSON file.');
}
if (!rename($temporaryPath, $targetPath)) {
throw new RuntimeException('Could not atomically replace the target JSON file.');
}
$temporaryPath = null;
writeLog(
$logPath,
'OK',
sprintf(
'updated prices: managed_local_ai_from=%.2f open_source_hosting_from=%.2f target=%s',
$prices['managed_local_ai_from'],
$prices['open_source_hosting_from'],
$targetPath
)
);
exit(0);
} catch (Throwable $error) {
if ($temporaryPath !== null && is_file($temporaryPath)) {
@unlink($temporaryPath);
}
writeLog($logPath, 'ERROR', $error->getMessage());
exit(1);
}