Manual property enhancements: MLS status sync, agent clone, description formatting
- Manual properties linked to MLS now inherit status (Active/Pending/Closed) and days_on_market from the MLS listing dynamically - Properties not in MLS default to Closed status - Clone feature now auto-populates listing agent by matching MLS ID to Agent CPT - Description formatter detects embedded headers (unpunctuated text after sentences) and splits them into separate paragraphs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,15 @@
|
||||
/**
|
||||
* Rate limiter class for MLS Grid API compliance
|
||||
*
|
||||
* MLS Grid Rate Limits:
|
||||
* - 2 requests per second
|
||||
* - 7,200 requests per hour
|
||||
* - 40,000 requests per day
|
||||
* - 4GB data per hour
|
||||
* MLS Grid Rate Limits (warning thresholds):
|
||||
* - 4 requests per second (suspension at 6)
|
||||
* - 7,200 requests per hour (suspension at 18,000)
|
||||
* - 40,000 requests per 24 hours (suspension at 60,000)
|
||||
* - 3GB data per hour / 40GB per 24 hours (suspension at 4GB/60GB)
|
||||
*
|
||||
* Our strategy: Throttle sync operations to use max 50% of daily quota
|
||||
* even if running continuously for 24 hours. This leaves 50% budget
|
||||
* for on-demand image fetches and other operations.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
@@ -16,17 +20,36 @@ if (!defined('ABSPATH')) {
|
||||
class MLS_Rate_Limiter {
|
||||
|
||||
/**
|
||||
* Rate limit constants
|
||||
* MLS Grid absolute limits (for reference)
|
||||
*/
|
||||
const MLSGRID_LIMIT_PER_SECOND = 4;
|
||||
const MLSGRID_LIMIT_PER_HOUR = 7200;
|
||||
const MLSGRID_LIMIT_PER_DAY = 40000;
|
||||
const MLSGRID_BYTES_PER_HOUR = 3221225472; // 3GB
|
||||
const MLSGRID_BYTES_PER_DAY = 42949672960; // 40GB
|
||||
|
||||
/**
|
||||
* Sync operation limits (50% of daily quota paced over 24 hours)
|
||||
*
|
||||
* Goal: If sync ran continuously for 24h, use max 50% of daily quota
|
||||
* - 20,000 requests / 86,400 seconds = 0.23 RPS (~4.3s between requests)
|
||||
* - 20GB data / 86,400 seconds = ~243KB/s average
|
||||
*/
|
||||
const SYNC_REQUESTS_PER_DAY = 20000; // 50% of 40,000
|
||||
const SYNC_BYTES_PER_DAY = 21474836480; // 20GB (50% of 40GB)
|
||||
const SYNC_MIN_INTERVAL_MS = 4320; // 86400000ms / 20000 = 4.32s between requests
|
||||
|
||||
/**
|
||||
* Rate limit constants (used for tracking against MLS Grid limits)
|
||||
*/
|
||||
const LIMIT_PER_SECOND = 2;
|
||||
const LIMIT_PER_HOUR = 7200;
|
||||
const LIMIT_PER_DAY = 40000;
|
||||
const LIMIT_BYTES_PER_HOUR = 4294967296; // 4GB
|
||||
const LIMIT_BYTES_PER_HOUR = 3221225472; // 3GB
|
||||
const LIMIT_BYTES_PER_DAY = 42949672960; // 40GB
|
||||
|
||||
/**
|
||||
* Window types
|
||||
*/
|
||||
const WINDOW_SECOND = 'second';
|
||||
const WINDOW_HOUR = 'hour';
|
||||
const WINDOW_DAY = 'day';
|
||||
|
||||
@@ -52,14 +75,18 @@ class MLS_Rate_Limiter {
|
||||
/**
|
||||
* Check if we can make a request (and wait if needed)
|
||||
*
|
||||
* For sync operations, this enforces the 50% daily quota pacing.
|
||||
* The minimum interval between requests ensures that even continuous
|
||||
* syncing won't exceed 50% of the daily quota.
|
||||
*
|
||||
* @param bool $wait Whether to wait if rate limited
|
||||
* @return bool True if request can proceed
|
||||
*/
|
||||
public function check_and_wait($wait = true) {
|
||||
// Check per-second limit (most restrictive)
|
||||
$this->enforce_per_second_limit();
|
||||
// Enforce sync pacing (4.32s between requests for 50% daily quota)
|
||||
$this->enforce_sync_pacing();
|
||||
|
||||
// Check hourly limit
|
||||
// Check hourly limit (hard stop if approaching MLS Grid limits)
|
||||
if (!$this->check_limit(self::WINDOW_HOUR, self::LIMIT_PER_HOUR)) {
|
||||
if ($wait) {
|
||||
$this->wait_for_window(self::WINDOW_HOUR);
|
||||
@@ -68,7 +95,7 @@ class MLS_Rate_Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily limit
|
||||
// Check daily limit (hard stop if approaching MLS Grid limits)
|
||||
if (!$this->check_limit(self::WINDOW_DAY, self::LIMIT_PER_DAY)) {
|
||||
if ($wait) {
|
||||
$this->wait_for_window(self::WINDOW_DAY);
|
||||
@@ -81,11 +108,14 @@ class MLS_Rate_Limiter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce per-second rate limit
|
||||
* Enforce sync operation pacing
|
||||
*
|
||||
* Ensures minimum interval between sync requests so that
|
||||
* 24 hours of continuous syncing uses max 50% of daily quota.
|
||||
*/
|
||||
private function enforce_per_second_limit() {
|
||||
private function enforce_sync_pacing() {
|
||||
$now = microtime(true);
|
||||
$min_interval = 1.0 / self::LIMIT_PER_SECOND; // 0.5 seconds
|
||||
$min_interval = self::SYNC_MIN_INTERVAL_MS / 1000.0; // Convert ms to seconds (4.32s)
|
||||
|
||||
if ($this->last_request_time > 0) {
|
||||
$elapsed = $now - $this->last_request_time;
|
||||
@@ -141,9 +171,6 @@ class MLS_Rate_Limiter {
|
||||
$now = current_time('timestamp');
|
||||
|
||||
switch ($window_type) {
|
||||
case self::WINDOW_SECOND:
|
||||
return gmdate('Y-m-d H:i:s', $now);
|
||||
|
||||
case self::WINDOW_HOUR:
|
||||
return gmdate('Y-m-d H:00:00', $now);
|
||||
|
||||
@@ -261,6 +288,9 @@ class MLS_Rate_Limiter {
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public function get_status() {
|
||||
$bytes_hour = $this->get_bytes_this_hour();
|
||||
$bytes_day = $this->get_bytes_today();
|
||||
|
||||
return array(
|
||||
'hourly' => array(
|
||||
'used' => $this->get_window_count(self::WINDOW_HOUR),
|
||||
@@ -272,7 +302,18 @@ class MLS_Rate_Limiter {
|
||||
'limit' => self::LIMIT_PER_DAY,
|
||||
'remaining' => max(0, self::LIMIT_PER_DAY - $this->get_window_count(self::WINDOW_DAY)),
|
||||
),
|
||||
'bytes_this_hour' => $this->get_bytes_this_hour(),
|
||||
'data_hourly' => array(
|
||||
'used' => $bytes_hour,
|
||||
'limit' => self::LIMIT_BYTES_PER_HOUR,
|
||||
'remaining' => max(0, self::LIMIT_BYTES_PER_HOUR - $bytes_hour),
|
||||
),
|
||||
'data_daily' => array(
|
||||
'used' => $bytes_day,
|
||||
'limit' => self::LIMIT_BYTES_PER_DAY,
|
||||
'remaining' => max(0, self::LIMIT_BYTES_PER_DAY - $bytes_day),
|
||||
),
|
||||
// Legacy fields for backward compatibility
|
||||
'bytes_this_hour' => $bytes_hour,
|
||||
'bytes_limit' => self::LIMIT_BYTES_PER_HOUR,
|
||||
);
|
||||
}
|
||||
@@ -282,7 +323,7 @@ class MLS_Rate_Limiter {
|
||||
*
|
||||
* @return int Bytes
|
||||
*/
|
||||
private function get_bytes_this_hour() {
|
||||
public function get_bytes_this_hour() {
|
||||
global $wpdb;
|
||||
|
||||
$window_start = $this->get_window_start(self::WINDOW_HOUR);
|
||||
@@ -297,6 +338,103 @@ class MLS_Rate_Limiter {
|
||||
return $bytes ? (int) $bytes : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bytes transferred today
|
||||
*
|
||||
* @return int Bytes
|
||||
*/
|
||||
public function get_bytes_today() {
|
||||
global $wpdb;
|
||||
|
||||
$window_start = $this->get_window_start(self::WINDOW_DAY);
|
||||
|
||||
$bytes = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT bytes_transferred FROM {$this->db->rate_limits_table()}
|
||||
WHERE window_type = %s AND window_start = %s",
|
||||
self::WINDOW_DAY,
|
||||
$window_start
|
||||
));
|
||||
|
||||
return $bytes ? (int) $bytes : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining daily data budget
|
||||
*
|
||||
* @return int Remaining bytes
|
||||
*/
|
||||
public function get_daily_data_remaining() {
|
||||
return max(0, self::LIMIT_BYTES_PER_DAY - $this->get_bytes_today());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can fetch an image based on remaining daily data budget
|
||||
*
|
||||
* @param int $estimated_bytes Estimated size of image (default 400KB)
|
||||
* @return bool True if we have budget for this image
|
||||
*/
|
||||
public function can_fetch_image($estimated_bytes = 409600) {
|
||||
return $this->get_daily_data_remaining() > $estimated_bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record data transfer (for image downloads, separate from API requests)
|
||||
*
|
||||
* This tracks bytes against the daily data cap without incrementing
|
||||
* the request count (since image fetches aren't API requests).
|
||||
*
|
||||
* @param int $bytes Bytes transferred
|
||||
*/
|
||||
public function record_data_transfer($bytes) {
|
||||
global $wpdb;
|
||||
|
||||
if ($bytes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record for hourly window (data only, no request count)
|
||||
$this->increment_data_only(self::WINDOW_HOUR, $bytes);
|
||||
|
||||
// Record for daily window (data only, no request count)
|
||||
$this->increment_data_only(self::WINDOW_DAY, $bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment data transfer for a window without incrementing request count
|
||||
*
|
||||
* @param string $window_type Window type
|
||||
* @param int $bytes Bytes transferred
|
||||
*/
|
||||
private function increment_data_only($window_type, $bytes) {
|
||||
global $wpdb;
|
||||
|
||||
$window_start = $this->get_window_start($window_type);
|
||||
|
||||
// Try to update existing record
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->db->rate_limits_table()}
|
||||
SET bytes_transferred = bytes_transferred + %d
|
||||
WHERE window_type = %s AND window_start = %s",
|
||||
$bytes,
|
||||
$window_type,
|
||||
$window_start
|
||||
));
|
||||
|
||||
// If no record existed, insert new one (request_count = 0 since this is data-only)
|
||||
if (0 === $updated) {
|
||||
$wpdb->insert(
|
||||
$this->db->rate_limits_table(),
|
||||
array(
|
||||
'window_type' => $window_type,
|
||||
'window_start' => $window_start,
|
||||
'request_count' => 0,
|
||||
'bytes_transferred' => $bytes,
|
||||
),
|
||||
array('%s', '%s', '%d', '%d')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're approaching rate limits
|
||||
*
|
||||
@@ -308,8 +446,26 @@ class MLS_Rate_Limiter {
|
||||
|
||||
$hourly_pct = $status['hourly']['used'] / $status['hourly']['limit'];
|
||||
$daily_pct = $status['daily']['used'] / $status['daily']['limit'];
|
||||
$data_daily_pct = $status['data_daily']['used'] / $status['data_daily']['limit'];
|
||||
|
||||
return $hourly_pct >= $threshold || $daily_pct >= $threshold;
|
||||
return $hourly_pct >= $threshold || $daily_pct >= $threshold || $data_daily_pct >= $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of current usage for logging/display
|
||||
*
|
||||
* @return array Summary with percentages
|
||||
*/
|
||||
public function get_usage_summary() {
|
||||
$status = $this->get_status();
|
||||
|
||||
return array(
|
||||
'requests_hourly_pct' => round(($status['hourly']['used'] / $status['hourly']['limit']) * 100, 1),
|
||||
'requests_daily_pct' => round(($status['daily']['used'] / $status['daily']['limit']) * 100, 1),
|
||||
'data_hourly_pct' => round(($status['data_hourly']['used'] / $status['data_hourly']['limit']) * 100, 1),
|
||||
'data_daily_pct' => round(($status['data_daily']['used'] / $status['data_daily']['limit']) * 100, 1),
|
||||
'data_daily_remaining_gb' => round($status['data_daily']['remaining'] / 1073741824, 2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user