124 lines
4.1 KiB
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;
|
|
}
|
|
}
|
|
}
|