init
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Esun\BlogImport\Model;
|
||||
|
||||
use Magento\Framework\App\Cache\TypeListInterface;
|
||||
use Magento\Framework\App\Filesystem\DirectoryList;
|
||||
use Magento\Framework\App\ResourceConnection;
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use Magento\Framework\Filter\TranslitUrl;
|
||||
use Mageplaza\Blog\Model\Post;
|
||||
use Mageplaza\Blog\Model\PostFactory;
|
||||
use Mageplaza\Blog\Model\ResourceModel\Category\CollectionFactory as CategoryCollectionFactory;
|
||||
use Mageplaza\Blog\Model\ResourceModel\Post\CollectionFactory as PostCollectionFactory;
|
||||
use Mageplaza\Blog\Model\ResourceModel\Post as PostResource;
|
||||
|
||||
class BlogImporter
|
||||
{
|
||||
private const MEDIUMTEXT_MAX_BYTES = 16777215;
|
||||
private const MAGEPLAZA_POST_MEDIA_PREFIX = 'mageplaza/blog/post/';
|
||||
|
||||
public function __construct(
|
||||
private readonly SourceReader $sourceReader,
|
||||
private readonly HtmlProcessor $htmlProcessor,
|
||||
private readonly MediaNormalizer $mediaNormalizer,
|
||||
private readonly DirectoryList $directoryList,
|
||||
private readonly TranslitUrl $translitUrl,
|
||||
private readonly CategoryCollectionFactory $categoryCollectionFactory,
|
||||
private readonly PostCollectionFactory $postCollectionFactory,
|
||||
private readonly PostFactory $postFactory,
|
||||
private readonly PostResource $postResource,
|
||||
private readonly ResourceConnection $resourceConnection,
|
||||
private readonly TypeListInterface $cacheTypeList
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string): void|null $progress
|
||||
* @return array<string, int>
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function import(
|
||||
string $sourceDirectory,
|
||||
int $storeId,
|
||||
string $mediaSubdirectory,
|
||||
bool $dryRun,
|
||||
bool $allowMissingMedia,
|
||||
?callable $progress = null
|
||||
): array {
|
||||
$mediaSubdirectory = trim($mediaSubdirectory, '/');
|
||||
if ($mediaSubdirectory === '' || str_contains($mediaSubdirectory, '..')) {
|
||||
throw new LocalizedException(__('The media subdirectory is invalid.'));
|
||||
}
|
||||
|
||||
$source = $this->sourceReader->read($sourceDirectory);
|
||||
$categoryIds = $this->resolveCategoryIds();
|
||||
$prepared = [];
|
||||
$missingMedia = [];
|
||||
$mediaToNormalize = [];
|
||||
$mediaRoot = $this->directoryList->getPath(DirectoryList::MEDIA)
|
||||
. DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mediaSubdirectory);
|
||||
|
||||
foreach ($source['records'] as $record) {
|
||||
$urlKey = $this->translitUrl->filter(rawurldecode((string)$record['source_url_key']));
|
||||
if ($urlKey === '') {
|
||||
throw new LocalizedException(__('URL key is empty after normalization: %1', $record['source_url']));
|
||||
}
|
||||
if (isset($prepared[$urlKey]) && $prepared[$urlKey]['source_url'] !== $record['source_url']) {
|
||||
throw new LocalizedException(__('Two source URLs normalize to the same URL key: %1', $urlKey));
|
||||
}
|
||||
|
||||
$processed = $this->htmlProcessor->process($record['html_path'], $mediaSubdirectory);
|
||||
$contentBytes = strlen($processed['html']);
|
||||
if ($contentBytes > self::MEDIUMTEXT_MAX_BYTES) {
|
||||
throw new LocalizedException(
|
||||
__(
|
||||
'Processed HTML for %1 is %2 bytes and exceeds the MEDIUMTEXT limit of %3 bytes.',
|
||||
$record['source_url'],
|
||||
$contentBytes,
|
||||
self::MEDIUMTEXT_MAX_BYTES
|
||||
)
|
||||
);
|
||||
}
|
||||
foreach ($processed['media'] as $relativePath) {
|
||||
$mediaPath = $mediaRoot . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
|
||||
if (!is_file($mediaPath) || !is_readable($mediaPath)) {
|
||||
$missingMedia[$relativePath] = $relativePath;
|
||||
} elseif ($this->mediaNormalizer->needsNormalization($mediaPath)) {
|
||||
$mediaToNormalize[$mediaPath] = $mediaPath;
|
||||
}
|
||||
}
|
||||
if ($record['image_filename'] !== '') {
|
||||
$coverPath = $mediaRoot . DIRECTORY_SEPARATOR . $record['image_filename'];
|
||||
if (!is_file($coverPath) || !is_readable($coverPath)) {
|
||||
$missingMedia[$record['image_filename']] = $record['image_filename'];
|
||||
} elseif ($this->mediaNormalizer->needsNormalization($coverPath)) {
|
||||
$mediaToNormalize[$coverPath] = $coverPath;
|
||||
}
|
||||
}
|
||||
$record['url_key'] = $urlKey;
|
||||
$record['post_content'] = $processed['html'];
|
||||
$record['image'] = $this->buildPostImagePath($mediaSubdirectory, $record['image_filename']);
|
||||
if (strlen($record['image']) > 255) {
|
||||
throw new LocalizedException(__('Post image path exceeds 255 bytes for %1.', $record['source_url']));
|
||||
}
|
||||
$record['category_ids'] = array_values(array_map(
|
||||
static fn(string $category): int => $categoryIds[$category],
|
||||
array_values($record['categories'])
|
||||
));
|
||||
$prepared[$urlKey] = $record;
|
||||
}
|
||||
|
||||
if ($missingMedia !== [] && !$allowMissingMedia) {
|
||||
$sample = implode(', ', array_slice(array_values($missingMedia), 0, 10));
|
||||
throw new LocalizedException(
|
||||
__('Found %1 missing media file(s). First entries: %2', count($missingMedia), $sample)
|
||||
);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'json_records' => (int)$source['json_records'],
|
||||
'unique_posts' => count($prepared),
|
||||
'duplicate_records' => (int)$source['duplicate_records'],
|
||||
'missing_media' => count($missingMedia),
|
||||
'normalized_media' => count($mediaToNormalize),
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
if (!$dryRun) {
|
||||
foreach ($mediaToNormalize as $mediaPath) {
|
||||
$this->mediaNormalizer->normalize($mediaPath);
|
||||
}
|
||||
}
|
||||
$resolvedPosts = [];
|
||||
foreach ($prepared as $urlKey => $record) {
|
||||
$resolvedPosts[$urlKey] = $this->loadPost($record);
|
||||
}
|
||||
if ($dryRun) {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
foreach ($prepared as $urlKey => $record) {
|
||||
$connection = $this->resourceConnection->getConnection();
|
||||
$connection->beginTransaction();
|
||||
try {
|
||||
[$post, $state] = $resolvedPosts[$urlKey];
|
||||
$changed = $this->populatePost($post, $record, $storeId);
|
||||
if ($changed || $state === 'created') {
|
||||
$this->postResource->save($post);
|
||||
if ((string)$post->getUrlKey() !== $record['url_key']) {
|
||||
throw new LocalizedException(
|
||||
__('Mageplaza changed URL key %1 to %2.', $record['url_key'], $post->getUrlKey())
|
||||
);
|
||||
}
|
||||
$stats[$state]++;
|
||||
if ($progress !== null) {
|
||||
$progress(sprintf('%s %s', ucfirst($state), $record['url_key']));
|
||||
}
|
||||
} else {
|
||||
$stats['unchanged']++;
|
||||
}
|
||||
$connection->commit();
|
||||
} catch (\Throwable $exception) {
|
||||
$connection->rollBack();
|
||||
$stats['failed']++;
|
||||
throw new LocalizedException(
|
||||
__('Failed importing %1: %2', $record['source_url'], $exception->getMessage()),
|
||||
$exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->cacheTypeList->invalidate('block_html');
|
||||
$this->cacheTypeList->invalidate('full_page');
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
private function resolveCategoryIds(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (['industry', 'activities', 'exhibition', 'news'] as $category) {
|
||||
$collection = $this->categoryCollectionFactory->create()
|
||||
->addFieldToFilter('url_key', $category)
|
||||
->setPageSize(2);
|
||||
if ((int)$collection->getSize() === 0) {
|
||||
$collection = $this->categoryCollectionFactory->create()
|
||||
->addFieldToFilter('name', $category)
|
||||
->setPageSize(2);
|
||||
}
|
||||
if ((int)$collection->getSize() !== 1) {
|
||||
throw new LocalizedException(
|
||||
__('Expected exactly one Mageplaza Blog category for "%1"; found %2.', $category, $collection->getSize())
|
||||
);
|
||||
}
|
||||
$result[$category] = (int)$collection->getFirstItem()->getId();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{0: Post, 1: 'created'|'updated'} */
|
||||
private function loadPost(array $record): array
|
||||
{
|
||||
/** @var Post $post */
|
||||
$post = $this->postCollectionFactory->create()
|
||||
->addFieldToFilter('import_source', $record['source_url'])
|
||||
->setPageSize(1)
|
||||
->getFirstItem();
|
||||
if ($post->getId()) {
|
||||
$collision = $this->postCollectionFactory->create()
|
||||
->addFieldToFilter('url_key', $record['url_key'])
|
||||
->setPageSize(1)
|
||||
->getFirstItem();
|
||||
if ($collision->getId() && (int)$collision->getId() !== (int)$post->getId()) {
|
||||
throw new LocalizedException(
|
||||
__('URL key "%1" is already used by post ID %2.', $record['url_key'], $collision->getId())
|
||||
);
|
||||
}
|
||||
|
||||
return [$post, 'updated'];
|
||||
}
|
||||
|
||||
$collision = $this->postCollectionFactory->create()
|
||||
->addFieldToFilter('url_key', $record['url_key'])
|
||||
->setPageSize(1)
|
||||
->getFirstItem();
|
||||
if ($collision->getId()) {
|
||||
if (!$collision->getData('import_source')
|
||||
&& trim((string)$collision->getName()) === trim((string)$record['title'])
|
||||
) {
|
||||
return [$collision, 'updated'];
|
||||
}
|
||||
throw new LocalizedException(
|
||||
__('URL key "%1" is already used by post ID %2 with a different import source.', $record['url_key'], $collision->getId())
|
||||
);
|
||||
}
|
||||
|
||||
return [$this->postFactory->create(), 'created'];
|
||||
}
|
||||
|
||||
private function populatePost(Post $post, array $record, int $storeId): bool
|
||||
{
|
||||
$data = [
|
||||
'name' => $record['title'],
|
||||
'short_description' => $record['short_description'],
|
||||
'post_content' => $record['post_content'],
|
||||
'image' => $record['image'],
|
||||
'store_ids' => [$storeId],
|
||||
'enabled' => 1,
|
||||
'url_key' => $record['url_key'],
|
||||
'in_rss' => 0,
|
||||
'layout' => '2columns-left',
|
||||
'allow_comment' => 0,
|
||||
'meta_title' => $record['title'],
|
||||
'publish_date' => $record['publish_date'] . ' 00:00:00',
|
||||
'import_source' => $record['source_url'],
|
||||
'categories_ids' => $record['category_ids'],
|
||||
];
|
||||
if (!$post->getId()) {
|
||||
$data['created_at'] = $record['publish_date'] . ' 00:00:00';
|
||||
}
|
||||
$data['updated_at'] = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$changed = !$post->getId();
|
||||
foreach ($data as $key => $value) {
|
||||
$oldValue = $key === 'categories_ids' ? $post->getCategoryIds() : $post->getData($key);
|
||||
if ($key === 'store_ids' && is_string($oldValue)) {
|
||||
$oldValue = array_filter(explode(',', $oldValue), 'strlen');
|
||||
}
|
||||
if ($key === 'updated_at') {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$old = array_map('strval', (array)$oldValue);
|
||||
$new = array_map('strval', $value);
|
||||
sort($old);
|
||||
sort($new);
|
||||
$changed = $changed || $old !== $new;
|
||||
} else {
|
||||
$changed = $changed || (string)$oldValue !== (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$post->addData($data);
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
private function buildPostImagePath(string $mediaSubdirectory, string $imageFilename): string
|
||||
{
|
||||
if ($imageFilename === '') {
|
||||
return '';
|
||||
}
|
||||
if (str_starts_with($mediaSubdirectory . '/', self::MAGEPLAZA_POST_MEDIA_PREFIX)) {
|
||||
$mediaSubdirectory = substr($mediaSubdirectory, strlen(self::MAGEPLAZA_POST_MEDIA_PREFIX));
|
||||
}
|
||||
|
||||
return trim($mediaSubdirectory, '/') . '/' . $imageFilename;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Esun\BlogImport\Model;
|
||||
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
|
||||
class HtmlProcessor
|
||||
{
|
||||
private const ESUN_MEDIA_HOSTS = ['esun3d.com', 'www.esun3d.com'];
|
||||
|
||||
private const MEDIA_EXTENSIONS = [
|
||||
'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'm4v', 'mov', 'mp4', 'ogg', 'png', 'svg', 'tif', 'tiff', 'webm', 'webp',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{html: string, media: string[]}
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function process(string $htmlPath, string $mediaSubdirectory): array
|
||||
{
|
||||
$html = file_get_contents($htmlPath);
|
||||
if ($html === false || trim($html) === '') {
|
||||
throw new LocalizedException(__('HTML file is empty or unreadable: %1', $htmlPath));
|
||||
}
|
||||
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$loaded = $document->loadHTML(
|
||||
'<?xml encoding="UTF-8"><div id="esun-import-root">' . $html . '</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
if (!$loaded) {
|
||||
throw new LocalizedException(__('Unable to parse HTML file: %1', $htmlPath));
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$article = $xpath->query(
|
||||
'//*[contains(concat(" ", normalize-space(@class), " "), " blog-article ")]'
|
||||
)->item(0);
|
||||
$contentRoot = $article instanceof \DOMElement
|
||||
? $article
|
||||
: $xpath->query('//*[@id="esun-import-root"]')->item(0);
|
||||
if (!$contentRoot instanceof \DOMElement) {
|
||||
throw new LocalizedException(__('No importable HTML content found in %1.', $htmlPath));
|
||||
}
|
||||
|
||||
$removeQueries = [
|
||||
'.//h1[contains(concat(" ", normalize-space(@class), " "), " news-title ")]',
|
||||
'.//*[contains(concat(" ", normalize-space(@class), " "), " news-time ")]',
|
||||
'.//script',
|
||||
'.//*[contains(concat(" ", normalize-space(@class), " "), " picker ")]',
|
||||
'./*[contains(concat(" ", normalize-space(@class), " "), " clear ")]',
|
||||
];
|
||||
foreach ($removeQueries as $query) {
|
||||
$nodes = iterator_to_array($xpath->query($query, $contentRoot));
|
||||
foreach ($nodes as $node) {
|
||||
$node->parentNode?->removeChild($node);
|
||||
}
|
||||
}
|
||||
|
||||
$media = [];
|
||||
$mediaReplacements = [];
|
||||
foreach (iterator_to_array($xpath->query('.//*', $contentRoot)) as $element) {
|
||||
if (!$element instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
foreach (['src', 'href', 'poster'] as $attribute) {
|
||||
if (!$element->hasAttribute($attribute)) {
|
||||
continue;
|
||||
}
|
||||
$relativeMediaPath = $this->getLegacyMediaPath($element->getAttribute($attribute));
|
||||
if ($relativeMediaPath === null) {
|
||||
continue;
|
||||
}
|
||||
$media[$relativeMediaPath] = $relativeMediaPath;
|
||||
$placeholder = sprintf('__ESUN_IMPORTED_MEDIA_%d__', count($mediaReplacements));
|
||||
$mediaReplacements[$placeholder] = sprintf(
|
||||
"{{media url='%s/%s'}}",
|
||||
$mediaSubdirectory,
|
||||
str_replace("'", ''', $relativeMediaPath)
|
||||
);
|
||||
$element->setAttribute($attribute, $placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
$output = '';
|
||||
foreach ($contentRoot->childNodes as $child) {
|
||||
$output .= $document->saveHTML($child);
|
||||
}
|
||||
$output = trim(strtr($output, $mediaReplacements));
|
||||
if ($output === '') {
|
||||
throw new LocalizedException(__('HTML content became empty after processing: %1', $htmlPath));
|
||||
}
|
||||
|
||||
return ['html' => $output, 'media' => array_values($media)];
|
||||
}
|
||||
|
||||
private function getLegacyMediaPath(string $url): ?string
|
||||
{
|
||||
$url = html_entity_decode(trim($url), ENT_QUOTES | ENT_HTML5);
|
||||
$path = '';
|
||||
if (str_starts_with($url, '/uploads/')) {
|
||||
$embeddedUrl = rawurldecode(substr($url, strlen('/uploads/')));
|
||||
if (preg_match('#^(?:https?:)?//#iu', $embeddedUrl)) {
|
||||
return null;
|
||||
}
|
||||
$path = $url;
|
||||
} elseif (preg_match(
|
||||
'#^(?:https?:)?//([^/]+)(/.*)$#iu',
|
||||
$url,
|
||||
$matches
|
||||
)) {
|
||||
if (!in_array(strtolower($matches[1]), self::ESUN_MEDIA_HOSTS, true)) {
|
||||
return null;
|
||||
}
|
||||
$path = $matches[2];
|
||||
}
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
$path = (string)preg_split('/[?#]/', $path, 2)[0];
|
||||
$filename = rawurldecode(basename($path));
|
||||
if ($filename === '' || $filename === '.' || $filename === '..') {
|
||||
return null;
|
||||
}
|
||||
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, self::MEDIA_EXTENSIONS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Esun\BlogImport\Model;
|
||||
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class MediaNormalizer
|
||||
{
|
||||
/** @var array<string, int> */
|
||||
private const EXPECTED_IMAGE_TYPES = [
|
||||
'gif' => IMAGETYPE_GIF,
|
||||
'jpeg' => IMAGETYPE_JPEG,
|
||||
'jpg' => IMAGETYPE_JPEG,
|
||||
'png' => IMAGETYPE_PNG,
|
||||
];
|
||||
|
||||
public function needsNormalization(string $path): bool
|
||||
{
|
||||
$expectedType = $this->getExpectedType($path);
|
||||
if ($expectedType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getActualType($path) !== $expectedType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image data in place so that its real format matches its filename extension.
|
||||
*
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function normalize(string $path): bool
|
||||
{
|
||||
$expectedType = $this->getExpectedType($path);
|
||||
if ($expectedType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actualType = $this->getActualType($path);
|
||||
if ($actualType === $expectedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$createCallback = match ($actualType) {
|
||||
IMAGETYPE_GIF => 'imagecreatefromgif',
|
||||
IMAGETYPE_JPEG => 'imagecreatefromjpeg',
|
||||
IMAGETYPE_PNG => 'imagecreatefrompng',
|
||||
IMAGETYPE_WEBP => 'imagecreatefromwebp',
|
||||
default => null,
|
||||
};
|
||||
if ($createCallback === null || !is_callable($createCallback)) {
|
||||
throw new LocalizedException(
|
||||
__('Cannot convert unsupported image data in %1.', $path)
|
||||
);
|
||||
}
|
||||
|
||||
$sourceImage = @$createCallback($path);
|
||||
|
||||
$temporaryPath = tempnam(dirname($path), '.esun-media-');
|
||||
if ($temporaryPath === false) {
|
||||
if ($sourceImage instanceof \GdImage) {
|
||||
imagedestroy($sourceImage);
|
||||
}
|
||||
throw new LocalizedException(__('Unable to create a temporary image beside %1.', $path));
|
||||
}
|
||||
|
||||
try {
|
||||
$written = $sourceImage instanceof \GdImage
|
||||
? $this->writeImage($sourceImage, $temporaryPath, $expectedType)
|
||||
: $this->writeAnimatedWebp($path, $temporaryPath, $actualType, $expectedType);
|
||||
if (!$written) {
|
||||
throw new LocalizedException(__('Unable to encode image %1.', $path));
|
||||
}
|
||||
$permissions = fileperms($path);
|
||||
if ($permissions !== false) {
|
||||
@chmod($temporaryPath, $permissions & 0777);
|
||||
}
|
||||
if (!@rename($temporaryPath, $path)) {
|
||||
throw new LocalizedException(__('Unable to replace image %1 after conversion.', $path));
|
||||
}
|
||||
} finally {
|
||||
if ($sourceImage instanceof \GdImage) {
|
||||
imagedestroy($sourceImage);
|
||||
}
|
||||
if (is_file($temporaryPath)) {
|
||||
@unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getExpectedType(string $path): ?int
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return self::EXPECTED_IMAGE_TYPES[$extension] ?? null;
|
||||
}
|
||||
|
||||
/** @throws LocalizedException */
|
||||
private function getActualType(string $path): int
|
||||
{
|
||||
$imageInfo = @getimagesize($path);
|
||||
if ($imageInfo === false || !isset($imageInfo[2])) {
|
||||
throw new LocalizedException(__('Unable to determine the real image format of %1.', $path));
|
||||
}
|
||||
|
||||
return (int)$imageInfo[2];
|
||||
}
|
||||
|
||||
private function writeImage(\GdImage $sourceImage, string $path, int $expectedType): bool
|
||||
{
|
||||
if ($expectedType === IMAGETYPE_JPEG) {
|
||||
$flattened = imagecreatetruecolor(imagesx($sourceImage), imagesy($sourceImage));
|
||||
if (!$flattened instanceof \GdImage) {
|
||||
return false;
|
||||
}
|
||||
$white = imagecolorallocate($flattened, 255, 255, 255);
|
||||
imagefill($flattened, 0, 0, $white);
|
||||
imagecopy(
|
||||
$flattened,
|
||||
$sourceImage,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
imagesx($sourceImage),
|
||||
imagesy($sourceImage)
|
||||
);
|
||||
$written = imagejpeg($flattened, $path, 90);
|
||||
imagedestroy($flattened);
|
||||
|
||||
return $written;
|
||||
}
|
||||
if ($expectedType === IMAGETYPE_PNG) {
|
||||
imagealphablending($sourceImage, false);
|
||||
imagesavealpha($sourceImage, true);
|
||||
|
||||
return imagepng($sourceImage, $path, 6);
|
||||
}
|
||||
|
||||
return imagegif($sourceImage, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* GD cannot decode animated WebP. Use ffmpeg as a fallback and keep animation when the target is GIF.
|
||||
*
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
private function writeAnimatedWebp(
|
||||
string $sourcePath,
|
||||
string $destinationPath,
|
||||
int $actualType,
|
||||
int $expectedType
|
||||
): bool {
|
||||
if ($actualType !== IMAGETYPE_WEBP) {
|
||||
throw new LocalizedException(__('Unable to decode image %1.', $sourcePath));
|
||||
}
|
||||
|
||||
$outputArguments = match ($expectedType) {
|
||||
IMAGETYPE_GIF => ['-f', 'gif'],
|
||||
IMAGETYPE_JPEG => ['-frames:v', '1', '-c:v', 'mjpeg', '-f', 'image2'],
|
||||
IMAGETYPE_PNG => ['-frames:v', '1', '-c:v', 'png', '-f', 'image2'],
|
||||
default => [],
|
||||
};
|
||||
$command = array_merge(
|
||||
['ffmpeg', '-v', 'error', '-y', '-i', $sourcePath],
|
||||
$outputArguments,
|
||||
[$destinationPath]
|
||||
);
|
||||
$process = new Process($command);
|
||||
$process->setTimeout(60);
|
||||
try {
|
||||
$process->mustRun();
|
||||
} catch (ProcessFailedException $ffmpegException) {
|
||||
@unlink($destinationPath);
|
||||
try {
|
||||
return $this->writeFirstWebpFrame($sourcePath, $destinationPath, $expectedType);
|
||||
} catch (\Throwable $fallbackException) {
|
||||
throw new LocalizedException(
|
||||
__(
|
||||
'Unable to convert animated WebP image %1. Install ffmpeg or the WebP tools (webpmux and dwebp). %2',
|
||||
$sourcePath,
|
||||
trim($fallbackException->getMessage())
|
||||
),
|
||||
$ffmpegException
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return is_file($destinationPath) && filesize($destinationPath) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a static first frame when ffmpeg cannot decode the animated WebP stream.
|
||||
*/
|
||||
private function writeFirstWebpFrame(string $sourcePath, string $destinationPath, int $expectedType): bool
|
||||
{
|
||||
$frameWebp = tempnam(dirname($sourcePath), '.esun-webp-frame-');
|
||||
$framePng = tempnam(dirname($sourcePath), '.esun-png-frame-');
|
||||
if ($frameWebp === false || $framePng === false) {
|
||||
if (is_string($frameWebp)) {
|
||||
@unlink($frameWebp);
|
||||
}
|
||||
if (is_string($framePng)) {
|
||||
@unlink($framePng);
|
||||
}
|
||||
throw new LocalizedException(__('Unable to create temporary WebP frame files.'));
|
||||
}
|
||||
|
||||
$frameImage = null;
|
||||
try {
|
||||
(new Process(['webpmux', '-get', 'frame', '1', $sourcePath, '-o', $frameWebp]))
|
||||
->setTimeout(60)
|
||||
->mustRun();
|
||||
(new Process(['dwebp', $frameWebp, '-o', $framePng]))
|
||||
->setTimeout(60)
|
||||
->mustRun();
|
||||
$frameImage = @imagecreatefrompng($framePng);
|
||||
if (!$frameImage instanceof \GdImage) {
|
||||
throw new LocalizedException(__('Unable to decode the extracted WebP frame.'));
|
||||
}
|
||||
|
||||
return $this->writeImage($frameImage, $destinationPath, $expectedType);
|
||||
} finally {
|
||||
if ($frameImage instanceof \GdImage) {
|
||||
imagedestroy($frameImage);
|
||||
}
|
||||
@unlink($frameWebp);
|
||||
@unlink($framePng);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Esun\BlogImport\Model;
|
||||
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use Magento\Framework\Serialize\Serializer\Json;
|
||||
|
||||
class SourceReader
|
||||
{
|
||||
private const SOURCE_FILES = [
|
||||
'industry' => 'esun_industry_details.json',
|
||||
'activities' => 'esun_activities_details.json',
|
||||
'exhibition' => 'esun_exhibition_details.json',
|
||||
'news' => 'esun_news_details.json',
|
||||
];
|
||||
|
||||
private const LIST_FILES = [
|
||||
'industry' => 'esun_industry_all.json',
|
||||
'activities' => 'esun_activities_all.json',
|
||||
'exhibition' => 'esun_exhibition_all.json',
|
||||
'news' => 'esun_news_all.json',
|
||||
];
|
||||
|
||||
public function __construct(private readonly Json $json)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{records: array<string, array<string, mixed>>, json_records: int, duplicate_records: int}
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function read(string $sourceDirectory): array
|
||||
{
|
||||
$sourceRoot = realpath($sourceDirectory);
|
||||
if ($sourceRoot === false || !is_dir($sourceRoot) || !is_readable($sourceRoot)) {
|
||||
throw new LocalizedException(__('Import source is not a readable directory: %1', $sourceDirectory));
|
||||
}
|
||||
|
||||
$records = [];
|
||||
$jsonRecords = 0;
|
||||
foreach (self::SOURCE_FILES as $category => $filename) {
|
||||
$listItems = $this->readListItems($sourceRoot, $category);
|
||||
$jsonPath = $sourceRoot . DIRECTORY_SEPARATOR . $filename;
|
||||
if (!is_file($jsonPath) || !is_readable($jsonPath)) {
|
||||
throw new LocalizedException(__('Required JSON file is missing or unreadable: %1', $jsonPath));
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->json->unserialize((string)file_get_contents($jsonPath));
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw new LocalizedException(__('Invalid JSON in %1: %2', $filename, $exception->getMessage()));
|
||||
}
|
||||
if (!is_array($data) || !isset($data['details']) || !is_array($data['details'])) {
|
||||
throw new LocalizedException(__('The details array is missing from %1.', $filename));
|
||||
}
|
||||
if (isset($data['total_count']) && (int)$data['total_count'] !== count($data['details'])) {
|
||||
throw new LocalizedException(
|
||||
__('The total_count in %1 does not match its details count.', $filename)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($data['details'] as $index => $item) {
|
||||
$jsonRecords++;
|
||||
if (!is_array($item)) {
|
||||
throw new LocalizedException(__('Invalid item %1 in %2.', $index + 1, $filename));
|
||||
}
|
||||
$this->validateItem($item, $index, $filename);
|
||||
|
||||
$sourceUrl = trim((string)$item['sourceUrl']);
|
||||
if (!isset($listItems[$sourceUrl])) {
|
||||
throw new LocalizedException(
|
||||
__('No matching list item was found for %1 in %2.', $sourceUrl, self::LIST_FILES[$category])
|
||||
);
|
||||
}
|
||||
$listItem = $listItems[$sourceUrl];
|
||||
$path = (string)parse_url($sourceUrl, PHP_URL_PATH);
|
||||
$pathSegments = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
|
||||
$sourceUrlKey = $pathSegments ? (string)end($pathSegments) : '';
|
||||
if ($sourceUrlKey === '') {
|
||||
throw new LocalizedException(
|
||||
__('Cannot derive a URL key from item %1 in %2.', $index + 1, $filename)
|
||||
);
|
||||
}
|
||||
|
||||
$htmlPath = $this->resolveHtmlPath($sourceRoot, (string)$item['localHtmlFile']);
|
||||
if (isset($records[$sourceUrl])) {
|
||||
$existing = $records[$sourceUrl];
|
||||
if ($existing['title'] !== trim((string)$item['title'])
|
||||
|| $existing['publish_date'] !== (string)$item['publishDate']
|
||||
) {
|
||||
throw new LocalizedException(
|
||||
__('Conflicting duplicate post data for source URL %1.', $sourceUrl)
|
||||
);
|
||||
}
|
||||
if ($records[$sourceUrl]['short_description'] !== ''
|
||||
&& $listItem['summary'] !== ''
|
||||
&& $records[$sourceUrl]['short_description'] !== $listItem['summary']
|
||||
) {
|
||||
throw new LocalizedException(__('Conflicting summaries for source URL %1.', $sourceUrl));
|
||||
}
|
||||
if ($records[$sourceUrl]['image_filename'] !== ''
|
||||
&& $listItem['image_filename'] !== ''
|
||||
&& $records[$sourceUrl]['image_filename'] !== $listItem['image_filename']
|
||||
) {
|
||||
throw new LocalizedException(__('Conflicting list images for source URL %1.', $sourceUrl));
|
||||
}
|
||||
if ($records[$sourceUrl]['short_description'] === '') {
|
||||
$records[$sourceUrl]['short_description'] = $listItem['summary'];
|
||||
}
|
||||
if ($records[$sourceUrl]['image_filename'] === '') {
|
||||
$records[$sourceUrl]['image_filename'] = $listItem['image_filename'];
|
||||
}
|
||||
$records[$sourceUrl]['categories'][$category] = $category;
|
||||
continue;
|
||||
}
|
||||
|
||||
$records[$sourceUrl] = [
|
||||
'title' => trim((string)$item['title']),
|
||||
'source_url' => $sourceUrl,
|
||||
'source_url_key' => $sourceUrlKey,
|
||||
'publish_date' => (string)$item['publishDate'],
|
||||
'html_path' => $htmlPath,
|
||||
'short_description' => $listItem['summary'],
|
||||
'image_filename' => $listItem['image_filename'],
|
||||
'categories' => [$category => $category],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'records' => $records,
|
||||
'json_records' => $jsonRecords,
|
||||
'duplicate_records' => $jsonRecords - count($records),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{summary: string, image_filename: string}>
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
private function readListItems(string $sourceRoot, string $category): array
|
||||
{
|
||||
$filename = self::LIST_FILES[$category];
|
||||
$jsonPath = $sourceRoot . DIRECTORY_SEPARATOR . $filename;
|
||||
if (!is_file($jsonPath) || !is_readable($jsonPath)) {
|
||||
throw new LocalizedException(__('Required list JSON file is missing or unreadable: %1', $jsonPath));
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->json->unserialize((string)file_get_contents($jsonPath));
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw new LocalizedException(__('Invalid JSON in %1: %2', $filename, $exception->getMessage()));
|
||||
}
|
||||
if (!is_array($data) || !isset($data['news']) || !is_array($data['news'])) {
|
||||
throw new LocalizedException(__('The news array is missing from %1.', $filename));
|
||||
}
|
||||
if (isset($data['total_count']) && (int)$data['total_count'] !== count($data['news'])) {
|
||||
throw new LocalizedException(__('The total_count in %1 does not match its news count.', $filename));
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($data['news'] as $index => $item) {
|
||||
if (!is_array($item) || !isset($item['link']) || !is_string($item['link']) || trim($item['link']) === '') {
|
||||
throw new LocalizedException(__('Missing or invalid link in item %1 of %2.', $index + 1, $filename));
|
||||
}
|
||||
$link = trim($item['link']);
|
||||
if (isset($result[$link])) {
|
||||
throw new LocalizedException(__('Duplicate link %1 in %2.', $link, $filename));
|
||||
}
|
||||
$summary = isset($item['summary']) && is_string($item['summary']) ? trim($item['summary']) : '';
|
||||
$imageUrl = isset($item['imageUrl']) && is_string($item['imageUrl']) ? trim($item['imageUrl']) : '';
|
||||
$result[$link] = [
|
||||
'summary' => $summary,
|
||||
'image_filename' => $this->extractImageFilename($imageUrl, $index, $filename),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @throws LocalizedException */
|
||||
private function extractImageFilename(string $imageUrl, int $index, string $filename): string
|
||||
{
|
||||
if ($imageUrl === '') {
|
||||
return '';
|
||||
}
|
||||
$path = (string)preg_split('/[?#]/', $imageUrl, 2)[0];
|
||||
$imageFilename = rawurldecode(basename($path));
|
||||
if ($imageFilename === '' || $imageFilename === '.' || $imageFilename === '..') {
|
||||
throw new LocalizedException(__('Invalid imageUrl in item %1 of %2.', $index + 1, $filename));
|
||||
}
|
||||
|
||||
return $imageFilename;
|
||||
}
|
||||
|
||||
/** @throws LocalizedException */
|
||||
private function validateItem(array $item, int $index, string $filename): void
|
||||
{
|
||||
foreach (['title', 'sourceUrl', 'publishDate', 'localHtmlFile'] as $field) {
|
||||
if (!isset($item[$field]) || !is_string($item[$field]) || trim($item[$field]) === '') {
|
||||
throw new LocalizedException(
|
||||
__('Missing or invalid %1 in item %2 of %3.', $field, $index + 1, $filename)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$sourceUrl = (string)$item['sourceUrl'];
|
||||
$parts = parse_url($sourceUrl);
|
||||
if (!is_array($parts)
|
||||
|| ($parts['scheme'] ?? '') !== 'https'
|
||||
|| strtolower((string)($parts['host'] ?? '')) !== 'www.esun3d.com'
|
||||
|| !str_starts_with((string)($parts['path'] ?? ''), '/news/')
|
||||
) {
|
||||
throw new LocalizedException(__('Invalid sourceUrl in item %1 of %2.', $index + 1, $filename));
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', (string)$item['publishDate']);
|
||||
if ($date === false || $date->format('Y-m-d') !== $item['publishDate']) {
|
||||
throw new LocalizedException(__('Invalid publishDate in item %1 of %2.', $index + 1, $filename));
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws LocalizedException */
|
||||
private function resolveHtmlPath(string $sourceRoot, string $relativePath): string
|
||||
{
|
||||
if ($relativePath === '' || str_contains($relativePath, "\0") || str_starts_with($relativePath, '/')) {
|
||||
throw new LocalizedException(__('Invalid localHtmlFile path: %1', $relativePath));
|
||||
}
|
||||
$htmlPath = realpath($sourceRoot . DIRECTORY_SEPARATOR . $relativePath);
|
||||
$rootPrefix = rtrim($sourceRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
if ($htmlPath === false
|
||||
|| !is_file($htmlPath)
|
||||
|| !is_readable($htmlPath)
|
||||
|| !str_starts_with($htmlPath, $rootPrefix)
|
||||
) {
|
||||
throw new LocalizedException(__('HTML file is missing, unreadable, or outside the source: %1', $relativePath));
|
||||
}
|
||||
|
||||
return $htmlPath;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user