Blog

  • Huttons News Importer

    final class Huttons_News_Importer
    {
    private const CRON_HOOK = ‘huttons_news_hourly’;
    private const CATALOG_URL = ‘https://www.huttonsgroup.com/property-news’;
    private const MINIMUM_DATE = ‘2026-01-01’;
    private const POST_TYPE = ‘insight’;
    private const SOURCE_META = ‘source’;
    private const ARTICLE_ID_META = ‘_huttons_article_id’;
    private const LOCK_KEY = ‘huttons_news_import_lock’;

    public static function init(): void
    {
    add_action(self::CRON_HOOK, [self::class, ‘run’]);
    add_filter(‘cron_schedules’, [self::class, ‘ensure_hourly_schedule’]);
    add_action(‘init’, [self::class, ‘ensure_scheduled’], 100);
    }

    public static function ensure_scheduled(): void
    {
    if (!wp_next_scheduled(self::CRON_HOOK)) {
    wp_schedule_event(time() + 60, ‘hourly’, self::CRON_HOOK);
    }
    }

    public static function ensure_hourly_schedule(array $schedules): array
    {
    if (!isset($schedules[‘hourly’])) {
    $schedules[‘hourly’] = [
    ‘interval’ => HOUR_IN_SECONDS,
    ‘display’ => __(‘Once Hourly’, ‘huttons-news-importer’),
    ];
    }

    return $schedules;
    }

    public static function run()
    {
    if (get_transient(self::LOCK_KEY)) {
    return new WP_Error(‘huttons_import_locked’, ‘A Huttons import is already running.’);
    }

    set_transient(self::LOCK_KEY, 1, 15 * MINUTE_IN_SECONDS);

    try {
    if (!post_type_exists(self::POST_TYPE)) {
    return new WP_Error(‘huttons_missing_post_type’, ‘The insight post type does not exist.’);
    }

    $articles = self::fetch_catalog();
    if (is_wp_error($articles)) {
    return $articles;
    }

    foreach ($articles as $article) {
    if (self::already_imported($article)) {
    continue;
    }

    return self::import_article($article);
    }

    return 0;
    } finally {
    delete_transient(self::LOCK_KEY);
    }
    }

    private static function fetch_catalog()
    {
    $response = wp_remote_get(self::CATALOG_URL, self::request_args());
    if (is_wp_error($response)) {
    return $response;
    }

    $status = wp_remote_retrieve_response_code($response);
    if ($status !== 200) {
    return new WP_Error(‘huttons_catalog_http_error’, ‘Huttons catalog returned HTTP ‘ . $status . ‘.’);
    }

    $payload = self::extract_inertia_payload(wp_remote_retrieve_body($response));
    if (is_wp_error($payload)) {
    return $payload;
    }

    $collections = [];
    self::find_article_collections($payload, $collections);
    if (!$collections) {
    return new WP_Error(‘huttons_no_articles’, ‘No articles were found in the Huttons page data.’);
    }

    usort($collections, static function (array $a, array $b): int {
    return count($b) <=> count($a);
    });

    $articles = [];
    foreach ($collections[0] as $raw) {
    $url = !empty($raw[‘link’]) ? esc_url_raw($raw[‘link’]) : ”;
    if (!$url && !empty($raw[‘slug’])) {
    $url = ‘https://www.huttonsgroup.com/article/’ . sanitize_title($raw[‘slug’]);
    }
    if (!$url || empty($raw[‘title’])) {
    continue;
    }

    $published_date = self::normalise_date($raw[‘date’] ?? ”);
    if (!$published_date || $published_date < self::MINIMUM_DATE) { continue; } $articles[] = [ 'id' => isset($raw[‘id’]) ? absint($raw[‘id’]) : 0,
    ‘title’ => html_entity_decode(wp_strip_all_tags($raw[‘title’]), ENT_QUOTES | ENT_HTML5, ‘UTF-8’),
    ‘date’ => $published_date,
    ‘url’ => $url,
    ‘image’ => !empty($raw[‘image’]) ? esc_url_raw($raw[‘image’]) : ”,
    ‘source’ => !empty($raw[‘source’]) ? sanitize_text_field($raw[‘source’]) : ‘Huttons Group’,
    ‘category’ => !empty($raw[‘category’]) ? sanitize_text_field($raw[‘category’]) : ”,
    ‘slug’ => !empty($raw[‘slug’]) ? sanitize_title($raw[‘slug’]) : sanitize_title($raw[‘title’]),
    ];
    }

    usort($articles, static function (array $a, array $b): int {
    return strcmp($b[‘date’], $a[‘date’]);
    });

    return $articles;
    }

    private static function import_article(array $article)
    {
    $body = self::fetch_article_body($article[‘url’]);
    if (is_wp_error($body)) {
    return $body;
    }

    $body = self::remove_content_after_end_marker($body);

    $source_link = sprintf(

    Source: Huttons Group

    ‘,
    esc_url($article[‘url’])
    );

    $post_date = $article[‘date’] ? $article[‘date’] . ‘ 12:00:00’ : current_time(‘mysql’);
    $post_id = wp_insert_post([
    ‘post_type’ => self::POST_TYPE,
    ‘post_status’ => ‘publish’,
    ‘post_title’ => $article[‘title’],
    ‘post_name’ => $article[‘slug’],
    ‘post_content’ => wp_kses_post($body . “\n” . $source_link),
    ‘post_excerpt’ => ”,
    ‘post_date’ => $post_date,
    ], true);

    if (is_wp_error($post_id)) {
    return $post_id;
    }

    update_post_meta($post_id, self::SOURCE_META, $article[‘url’]);
    if ($article[‘id’]) {
    update_post_meta($post_id, self::ARTICLE_ID_META, $article[‘id’]);
    }

    if (!empty($article[‘image’])) {
    self::set_featured_image($post_id, $article[‘image’], $article[‘title’]);
    }

    return $post_id;
    }

    private static function remove_content_after_end_marker(string $content): string
    {
    $position = strpos($content, ‘- END -‘);
    if ($position !== false) {
    $block_start = false;
    foreach ([‘ $block_start)) {
    $block_start = $candidate;
    }
    }

    $content = substr($content, 0, $block_start !== false ? $block_start : $position);
    }

    return force_balance_tags(trim($content));
    }

    private static function set_featured_image(int $post_id, string $image_url, string $title): void
    {
    require_once ABSPATH . ‘wp-admin/includes/file.php’;
    require_once ABSPATH . ‘wp-admin/includes/media.php’;
    require_once ABSPATH . ‘wp-admin/includes/image.php’;

    if (strpos($image_url, ‘//’) === 0) {
    $image_url = ‘https:’ . $image_url;
    } elseif (strpos($image_url, ‘/’) === 0) {
    $image_url = ‘https://www.huttonsgroup.com’ . $image_url;
    }

    $temporary_file = download_url($image_url, 30);
    if (is_wp_error($temporary_file)) {
    return;
    }

    $path = (string) wp_parse_url($image_url, PHP_URL_PATH);
    $filename = sanitize_file_name(wp_basename($path));
    if (!$filename || !pathinfo($filename, PATHINFO_EXTENSION)) {
    $filename = sanitize_title($title) . ‘.jpg’;
    }

    $file = [
    ‘name’ => $filename,
    ‘tmp_name’ => $temporary_file,
    ];
    $attachment_id = media_handle_sideload($file, $post_id, $title);

    if (is_wp_error($attachment_id)) {
    @unlink($temporary_file);
    return;
    }

    set_post_thumbnail($post_id, $attachment_id);
    update_post_meta($attachment_id, ‘_wp_attachment_image_alt’, sanitize_text_field($title));
    }

    private static function fetch_article_body(string $url)
    {
    $response = wp_remote_get($url, self::request_args());
    if (is_wp_error($response)) {
    return $response;
    }

    $status = wp_remote_retrieve_response_code($response);
    if ($status !== 200) {
    return new WP_Error(‘huttons_article_http_error’, ‘Huttons article returned HTTP ‘ . $status . ‘.’);
    }

    $html = wp_remote_retrieve_body($response);
    $payload = self::extract_inertia_payload($html);
    if (!is_wp_error($payload)) {
    $content = self::find_largest_content($payload);
    if ($content) {
    return wp_kses_post($content);
    }
    }

    if (class_exists(‘DOMDocument’)) {
    $document = new DOMDocument();
    libxml_use_internal_errors(true);
    $loaded = $document->loadHTML(‘‘ . $html);
    libxml_clear_errors();
    if ($loaded) {
    $xpath = new DOMXPath($document);
    foreach ([‘//article’, ‘//main’] as $query) {
    $nodes = $xpath->query($query);
    if ($nodes && $nodes->length) {
    return wp_kses_post(self::inner_html($nodes->item(0)));
    }
    }
    }
    }

    return ‘

    Read the full article at Huttons Group.

    ‘;
    }

    private static function extract_inertia_payload(string $html)
    {
    if (!preg_match(‘/data-page=(?:”([^”]+)”|\'([^\’]+)\’)/s’, $html, $matches)) {
    return new WP_Error(‘huttons_missing_payload’, ‘The Huttons Inertia page payload was not found.’);
    }

    $encoded = $matches[1] !== ” ? $matches[1] : $matches[2];
    $decoded = html_entity_decode($encoded, ENT_QUOTES | ENT_HTML5, ‘UTF-8’);
    $payload = json_decode($decoded, true);

    return is_array($payload)
    ? $payload
    : new WP_Error(‘huttons_invalid_payload’, ‘The Huttons page payload was invalid JSON.’);
    }

    private static function find_article_collections($value, array &$collections): void
    {
    if (!is_array($value)) {
    return;
    }

    if (self::is_list($value)) {
    $matches = array_values(array_filter($value, static function ($item): bool {
    return is_array($item) && !empty($item[‘title’]) && (!empty($item[‘link’]) || !empty($item[‘slug’]));
    }));
    if ($matches && count($matches) >= max(1, intdiv(count($value), 2))) {
    $collections[] = $matches;
    return;
    }
    }

    foreach ($value as $child) {
    self::find_article_collections($child, $collections);
    }
    }

    private static function find_largest_content($value): string
    {
    $candidates = [];
    self::collect_content($value, $candidates);
    if (!$candidates) {
    return ”;
    }
    usort($candidates, static function (string $a, string $b): int {
    return strlen($b) <=> strlen($a);
    });
    return $candidates[0];
    }

    private static function collect_content($value, array &$candidates): void
    {
    if (!is_array($value)) {
    return;
    }
    foreach ($value as $key => $child) {
    if (is_string($child) && in_array((string) $key, [‘content’, ‘body’, ‘article_content’], true) && strlen(wp_strip_all_tags($child)) > 100) {
    $candidates[] = $child;
    } elseif (is_array($child)) {
    self::collect_content($child, $candidates);
    }
    }
    }

    private static function already_imported(array $article): bool
    {
    $existing = get_posts([
    ‘post_type’ => self::POST_TYPE,
    ‘post_status’ => ‘any’,
    ‘posts_per_page’ => 1,
    ‘fields’ => ‘ids’,
    ‘meta_key’ => self::SOURCE_META,
    ‘meta_value’ => $article[‘url’],
    ‘no_found_rows’ => true,
    ]);

    if ($existing) {
    return true;
    }

    return (bool) get_page_by_path($article[‘slug’], OBJECT, self::POST_TYPE);
    }

    private static function normalise_date(string $date): string
    {
    $parsed = DateTime::createFromFormat(‘d/m/Y’, $date);
    return $parsed ? $parsed->format(‘Y-m-d’) : ”;
    }

    private static function is_list(array $value): bool
    {
    if ($value === []) {
    return true;
    }

    return array_keys($value) === range(0, count($value) – 1);
    }

    private static function request_args(): array
    {
    return [
    ‘timeout’ => 30,
    ‘redirection’ => 5,
    ‘user-agent’ => ‘EzyHomes-Huttons-News-Importer/1.0; ‘ . home_url(‘/’),
    ‘headers’ => [‘Accept’ => ‘text/html,application/xhtml+xml’],
    ];
    }

    private static function inner_html(DOMNode $node): string
    {
    $html = ”;
    foreach ($node->childNodes as $child) {
    $html .= $node->ownerDocument->saveHTML($child);
    }
    return $html;
    }
    }

    Huttons_News_Importer::init();

    if (defined(‘WP_CLI’) && WP_CLI) {
    WP_CLI::add_command(‘huttons-news import-one’, static function (): void {
    $result = Huttons_News_Importer::run();
    if (is_wp_error($result)) {
    WP_CLI::error($result->get_error_message());
    } elseif ($result === 0) {
    WP_CLI::success(‘No unimported Huttons articles remain.’);
    } else {
    WP_CLI::success(‘Imported insight post #’ . $result . ‘.’);
    }
    });
    }