Files
VITEC-website/packages/vitec/Classes/Import/WordPressClient.php

124 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Import;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Reads posts and categories from a WordPress site through its REST API.
*
* The REST route was chosen over direct database access on purpose: the VITEC
* blog happens to sit on the same server, but datapath.co.uk does not and its
* MySQL is not reachable from here. One code path serves both, and nothing has
* to store a database password.
*
* The trade-off is that `/wp/v2/posts` only exposes published posts. That is
* what a content migration wants; drafts would need an application password.
*
* Read-only throughout - this class never writes to the source.
*/
final class WordPressClient
{
/** WordPress caps per_page at 100. */
private const PER_PAGE = 100;
/** Generous: 358 posts with embedded media is a lot of JSON. */
private const TIMEOUT = 120;
/** Guard against a misconfigured source paginating forever. */
private const MAX_PAGES = 50;
/**
* All published posts, newest first, with the featured image embedded so the
* list does not need one extra request per post.
*
* @return array{posts:array<int,array<string,mixed>>, error:?string}
*/
public function posts(string $baseUrl): array
{
$api = $this->apiBase($baseUrl);
if ($api === null) {
return ['posts' => [], 'error' => 'No usable URL.'];
}
$posts = [];
for ($page = 1; $page <= self::MAX_PAGES; $page++) {
$url = $api . '/posts?per_page=' . self::PER_PAGE . '&page=' . $page . '&_embed=wp:featuredmedia,wp:term';
$batch = $this->get($url);
if ($batch === null) {
// Page 1 failing is a real error; later pages simply mean the end
// (WordPress answers 400 rest_post_invalid_page_number past it).
return $page === 1
? ['posts' => [], 'error' => 'The source did not answer with a post list. Check the URL.']
: ['posts' => $posts, 'error' => null];
}
$posts = array_merge($posts, $batch);
if (count($batch) < self::PER_PAGE) {
break;
}
}
return ['posts' => $posts, 'error' => null];
}
/**
* Category id => name, for showing what a post was filed under over there.
*
* @return array<int,string>
*/
public function categories(string $baseUrl): array
{
$api = $this->apiBase($baseUrl);
if ($api === null) {
return [];
}
$rows = $this->get($api . '/categories?per_page=' . self::PER_PAGE) ?? [];
$map = [];
foreach ($rows as $row) {
$map[(int)($row['id'] ?? 0)] = html_entity_decode((string)($row['name'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return $map;
}
/**
* Turns whatever the editor typed into the v2 API base.
* "vitec.com/blog", "https://vitec.com/blog/" and a full ".../wp-json/wp/v2"
* all end up the same.
*/
public function apiBase(string $baseUrl): ?string
{
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
return null;
}
if (!preg_match('#^https?://#i', $baseUrl)) {
$baseUrl = 'https://' . $baseUrl;
}
$baseUrl = rtrim($baseUrl, '/');
if (str_contains($baseUrl, '/wp-json')) {
return $baseUrl;
}
return $baseUrl . '/wp-json/wp/v2';
}
/** @return array<int,array<string,mixed>>|null */
private function get(string $url): ?array
{
try {
$response = GeneralUtility::makeInstance(RequestFactory::class)
->request($url, 'GET', ['timeout' => self::TIMEOUT, 'headers' => ['Accept' => 'application/json']]);
if ($response->getStatusCode() !== 200) {
return null;
}
$decoded = json_decode((string)$response->getBody(), true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable $e) {
return null;
}
}
}