| Current Path : /home/digilove/public_html/plugins/system/forseo/platform/components/ |
| Current File : /home/digilove/public_html/plugins/system/forseo/platform/components/content.php |
<?php
/**
* Project: 4SEO
*
* @package 4SEO
* @copyright Copyright Weeblr llc - 2020-2024
* @author Yannick Gaultier - Weeblr llc
* @license GNU General Public License version 3; see LICENSE.md
* @version 6.2.0.2478
* @date 2024-10-03
*/
namespace Weeblr\Forseo\Platform\Components;
use Weeblr\Forseo\Data;
use Weeblr\Forseo\Helper;
use Weeblr\Wblib\Forseo\Wb;
use Weeblr\Wblib\Forseo\System;
use Weeblr\Wblib\Forseo\Html;
use Weeblr\Wblib\Forseo\Joomla\Registry;
use Joomla\CMS\Factory as JoomlaFactory;
use Joomla\CMS\Router\Route;
// no direct access
defined('_JEXEC') || defined('WBLIB_EXEC') || die;
/**
*/
class Content extends Base
{
/**
* @var string Component name - with leading com_ removed, eg content for com_content
*/
protected $component = 'content';
/**
* @var \stdClass Cache for com_content being rendered.
*/
protected $contentData = null;
/**
* @var string[] com_content views we can store.
*/
protected $includedViews = [
'archive',
'article',
'categories',
'category',
'featured'
];
/**
* @var null|array List of layouts names that should be stored. None if null. All if empty.
*/
protected $includedLayouts = [];
/**
* @var null|array List of layouts names that should NOT be stored. No effect if null or empty.
*/
protected $excludedLayouts = [
'edit'
];
/**
* @var array List of schema types supported by this plugin.
*/
protected $supportedSdTypes = [
Data\Sd::ARTICLE,
Data\Sd::NEWS_ARTICLE,
Data\Sd::BLOG_POSTING,
Data\Sd::VIDEO_OBJECT,
Data\Sd::COURSE,
Data\Sd::EVENT,
Data\Sd::PRODUCT,
Data\Sd::RECIPE,
Data\Sd::FAQ_PAGE,
Data\SD::MOVIE
];
/**
* @var bool Whether the plugin wants to filter raw, SEF URL. Time consuming, avoid if not needed.
*/
protected $filterShouldCollectUrlsFoundOnPage = false;
/**
* Add handlers for desired com_content hooks.
*/
public function addHooks()
{
parent::addHooks();
$this->hook->add(
'forseo_page_canonical_or_duplicate',
[
$this,
'filterPageCanonicalOrDuplicate'
]
);
$this->hook->add(
'forseo_expandable_variables',
[
$this,
'filterExpandableVariables'
]
);
$this->hook->add(
'forseo_extract_images',
[
$this,
'filterExtractImages'
]
);
}
/**
* Tries to build automatically a canonical link for the current page described
* by the Page object passed in.
*
* Canonical returned will be made absolute downstream if not already fully qualified.
*
* Return null if no canonical can be determined based solely on the current request data.
*
* @param bool $dynamicCanonical
* @param Data\Page $pageData Collected request information.
*
* @return null | string
*
* @throws \Exception
*/
protected function filterDynamicCanonical($dynamicCanonical, $pageData)
{
$dynamicCanonical = parent::filterDynamicCanonical($dynamicCanonical, $pageData);
if (!is_null($dynamicCanonical))
{
return $dynamicCanonical;
}
$inputVars = $pageData->get('input_vars', []);
$format = Wb\arrayGet($inputVars, 'format', 'html');
if ('html' !== $format)
{
return $dynamicCanonical;
}
$view = Wb\arrayGet($inputVars, 'view');
if (!in_array($view, ['article', 'category']))
{
return $dynamicCanonical;
}
if (
Wb\arrayIsTruthy($inputVars, 'task')
||
Wb\arrayIsTruthy($inputVars, 'a_id')
)
{
return $dynamicCanonical;
}
$id = Wb\arrayGet($inputVars, 'id');
if (empty($id))
{
return $dynamicCanonical;
}
// hack for Joomla 4 bug
if ('article' === $view)
{
$layout = Wb\arrayGet($inputVars, 'layout');
if ('blog' === $layout)
{
unset($inputVars['layout']);
}
}
// keep going now
$nonSefVars = array_intersect_key(
$inputVars,
array_flip(
[
'option',
'view',
'layout',
'id',
'catid',
'limitstart',
'limit',
'Itemid'
]
)
);
$nonSefUrl = implode(
'?',
[
'index.php',
http_build_query(
$nonSefVars,
'',
'&',
PHP_QUERY_RFC3986
)
]
);
return Route::link(
'site',
$nonSefUrl,
false // $xhtml
);
}
/**
* Actually add SD rules to the current request.
*
* @param array $rules
* @param Data\Requestinfo $requestInfo
* @param Data\Page $pageData
* @param string $baseId
* @return array
* @throws \Exception
*/
protected function filterSdRules($rules, $requestInfo, $pageData, $baseId)
{
if ('article' !== $pageData->get('view'))
{
return $rules;
}
if ($this->factory->getThis('forseo.config', 'sd')->isFalsy('enabledBuiltInRules'))
{
return $rules;
}
$rule = $this->factory->getA(Data\Rule::class);
// Article
$ruleData = [
'actionSdType' => [Data\Sd::ARTICLE],
];
$rule->set(
[
'rule' => $ruleData,
'source' => Data\Rule::SOURCE_BUILT_IN
]
);
$rules[] = $rule;
return $rules;
}
/**
* Decides whether a given SD rule can apply to the current page.
* By default is null.
* If a plugin can support, it sets it to true.
* If a plugin says this SD type cannot exist on this page, it sets it to false.
* Else leave as is.
*
* In the end, returned value must be true (ie at least one plugin can support and no other
* contradict) for the rule to run.
*
* NB: At this stage, it has already been checked that:
*
* - the current request is for this plugin extension
* - the current plugin lists the SD rule type in its $supportedSdTypes property.
*
* @param bool $canRunRule
* @param array $spec
* @param Data\Requestinfo $requestInfo
* @param Data\Page $pageData
* @return bool
* @throws \Exception
*/
public function filterSdCanRunRule($canRunRule, $spec, $requestInfo, $pageData)
{
if ('article' !== strtolower($pageData->get('view')))
{
return false;
}
return $canRunRule;
}
/**
* Build automatically computed structured data for a com_content article.
*
* @param array $autoFieldsData
* @param array $autoFields
* @param array $spec
* @param Data\Requestinfo $requestInfo
* @param Data\Page $pageData
* @param string $baseId
* @return array
* @throws \Exception
*/
protected function filterSdData($autoFieldsData, $autoFields, $spec, $requestInfo, $pageData, $baseId)
{
if (!$this->shouldRunFilter($pageData))
{
return $autoFieldsData;
}
if (array_key_exists('url', $autoFields))
{
$autoFieldsData['sdData']['url'] = $requestInfo->get('page_url');
}
if (
!empty($this->contentData)
&&
!empty($this->contentData['content'])
)
{
if (
array_key_exists('headline', $autoFields)
&&
isset($this->contentData['content']->title))
{
$autoFieldsData['sdData']['headline'] = $this->contentData['content']->title;
}
if (
array_key_exists('name', $autoFields)
&&
isset($this->contentData['content']->title))
{
$autoFieldsData['sdData']['name'] = $this->contentData['content']->title;
}
if (
array_key_exists('datePublished', $autoFields)
&&
isset($this->contentData['content']->publish_up)
)
{
$autoFieldsData['sdData']['datePublished'] = $this->contentData['content']->publish_up;
}
if (
array_key_exists('dateCreated', $autoFields)
&&
isset($this->contentData['content']->publish_up)
)
{
$autoFieldsData['sdData']['dateCreated'] = $this->contentData['content']->publish_up;
}
if (
array_key_exists('dateModified', $autoFields)
&&
isset($this->contentData['content']->modified))
{
$dateModified =
empty($this->contentData['content']->modified)
||
'0000-00-00 00:00:00' == $this->contentData['content']->modified
? $this->contentData['content']->publish_up
: $this->contentData['content']->modified;
$autoFieldsData['sdData']['dateModified'] = $dateModified;
}
if (
array_key_exists('author', $autoFields)
&&
isset($this->contentData['content']->author)
&&
isset($this->contentData['content']->created_by)
&&
!Wb\arrayIsEmpty($spec, 'useItemAuthor')
)
{
$authorUserName = $this->contentData['content']->author;
$authorId = $this->factory
->getA(Helper\Sd::class)
->toId(
$authorUserName
. '_'
. System\Auth::shortHash(
$authorUserName . $this->contentData['content']->created_by
)
);
$autoFieldsData['identitiesUsed'] = [
'author' => $authorId
];
$autoFieldsData['identitiesCreated'] = [
$authorId => [
'@type' => Data\Sd::PERSON, // Person | Organization
'name' => $authorUserName
]
];
$authorField = [
'@id' => $baseId . '#' . $authorId
];
$autoFieldsData['sdData']['author'] = $authorField;
}
if (array_key_exists('aggregateRating', $autoFields))
{
$reviews = $this->buildAggregateRating($spec);
if (!empty($reviews))
{
$autoFieldsData['sdData']['aggregateRating'] = $reviews;
}
}
// VideoObject
if (
array_key_exists('contentUrl', $autoFields)
&&
isset($this->contentData['content']->publish_up))
{
// search for 1st mp4 file in src attr in content
$url = Html\Extract::extractVideo(
$this->contentData['content']->text,
'mp4'
);
if (!empty($url))
{
$autoFieldsData['sdData']['contentUrl'] = $url;
}
}
if (
array_key_exists('uploadDate', $autoFields)
&&
isset($this->contentData['content']->publish_up))
{
$autoFieldsData['sdData']['uploadDate'] = $this->contentData['content']->publish_up;
}
// Event
if (
array_key_exists('startDate', $autoFields)
&&
isset($this->contentData['content']->publish_up))
{
$autoFieldsData['sdData']['startDate'] = $this->contentData['content']->publish_up;
}
}
return $autoFieldsData;
}
/**
* Build an aggregateRating record if applicable to current content type.
*
* @param array $spec
* @return array|null
*/
private function buildAggregateRating($spec)
{
$reviews = null;
$itemType = Wb\arrayGet($spec, 'actualType', '');
if (in_array($itemType, Data\Sd::REVIEWABLE_TYPES))
{
$rating = $this->contentData['content']->rating;
$ratingCount = $this->contentData['content']->rating_count;
if (null !== $rating && !empty($ratingCount))
{
$reviews = [
'@type' => Data\Sd::AGGREGATE_RATING,
'ratingValue' => $rating,
'reviewCount' => $ratingCount,
'worstRating' => 0,
'bestRating' => 5
];
}
}
return $reviews;
}
/**
* Filters page data collected at the onAfterRoute event.
*
* @param Data\Page $pageData
*
* @return Data\Page
* @throws \Exception
*/
protected function filterAfterRoutePageData($pageData)
{
$pageData = parent::filterAfterRoutePageData($pageData);
$view = $pageData->get('view');
if (!in_array($view, ['featured']))
{
return $pageData;
}
// this is a featured view
// this a category view, are we on a second or more pages?
// if so, include page number in id to distinguish them.
$inputVars = $pageData->get('input_vars', []);
$Itemid = Wb\arrayGet(
$inputVars,
'Itemid',
null
);
if (is_null($Itemid))
{
return $pageData;
}
// search menu item for a category specification
$menuItem = JoomlaFactory::getApplication()
->getMenu('site')
->getItem($Itemid);
if (empty($menuItem))
{
return $pageData;
}
$featuredCategories = $menuItem
->getParams()
->get('featured_categories');
if (empty($featuredCategories))
{
return $pageData;
}
// update the item_id based on non-sef variables
$pageData->set(
'item_id',
$this->helper->compactValuesList($featuredCategories)
);
return $pageData;
}
/**
* Whether passed page should be considered canonical or duplicate (automatically). Presence of a duplicate
* (ie with same content_id) has already been checked.
*
* @param int $urlType Data\Page::CANONICAL | Data\Page::DUPLICATE
* @param Data\Page $pageData The page object.
*
* @return int
*
* @throws \Exception
*/
public function filterPageCanonicalOrDuplicate(int $urlType, Data\Page $pageData)
{
if (!$this->shouldRunFilter($pageData))
{
return $urlType;
}
// Multipage articles: if current page has ?showall, it should be canonical
$inputVars = $pageData->get('input_vars', []);
$hasShowallVar = Wb\arrayGetInt($inputVars, 'showall') === 1;
$isShowAllMultipage = $hasShowallVar
||
Wb\contains($pageData->get('full_url'), 'showall=1');
if (
!$isShowAllMultipage
&&
$pageData->isFalsy('isMultiPage')
)
{
return $urlType;
}
$hasShowAll = $this->platform->isShowAllEnabled();
// Site is configured to display a showAll page.
if ($hasShowAll)
{
$urlType = $isShowAllMultipage
? Data\Page::CANONICAL
: Data\Page::DUPLICATE;
}
// Site is not configured to display show all
// all pages are canonical
if (!$hasShowAll)
{
$urlType = Data\Page::CANONICAL;
}
return $urlType;
}
/**
* Implement construction of com_content item unique id.
*
* @param null|array $id
* @param null|Data\Page $pageData
*
* @return array
* @throws \Exception
*/
protected function filterPageBuildContentId($id, $pageData)
{
$id = $this->defaultPageBuildContentId($id, $pageData);
// clean up id of item title
$itemId = Wb\arrayGet($id, 'id');
if (!empty($itemId))
{
$id['id'] = $this->helper->cleanIdsWithColons($itemId);
}
// multipage article
unset($id['showall']);
if ('article' === Wb\arrayGet($id, 'view'))
{
// for an article, only keep option, view and id
$contentIdvars = [
'option',
'view',
'id'
];
if (!$this->platform->isShowAllEnabled())
{
$contentIdvars[] = 'limitstart';
}
// and limitstart if showall is not enabled (ie all subpages re canonical)
$id = array_intersect_key(
$id,
array_flip(
$contentIdvars
)
);
}
return $id;
}
/**
* Implement default construction of a content hash that may be computed
* from a raw content array as provided by the platform.
*
* @param string $hash
* @param array $contentData
* @param null|Data\Page $pageData
*
* @return string
* @throws \Exception
*/
protected function filterPageBuildContentHash($hash, $contentData, $pageData)
{
$context = Wb\arrayGet($contentData, 'context', '');
if ('com_content.article' != $context)
{
return $hash;
}
$content = Wb\arrayGet($contentData, 'content');
if (
empty($content)
||
// Some extensions (GSD) create invalid records, missing some parts.
!isset($content->catid)
||
!isset($content->author)
||
!isset($content->title)
)
{
return $hash;
}
$id = $content->catid
. $content->author
. strip_tags($content->text)
. $content->images
. $content->language
. $content->title
. $content->urls;
return md5(json_encode($id));
}
/**
* Hook to store the finalized content data of current page.
*
* @param array $contentData
* @return void
*/
public function actionStorePreparedContent($contentData)
{
$context = Wb\arrayGet($contentData, 'context', '');
if ('com_content.article' !== $context)
{
return;
}
$this->contentData = $contentData;
}
/**
* Filter automatically detected images from content data object.
*
* @param array $extractedImages
* @param string $context An option string representing the context, the content type.
* @param string $content Rendered content.
* @param Object $contentObject Data object holding the content data.
* @param Data\Page $pageData Collected request information.
* @param Data\Meta $pageMeta Collected meta data about the request.
*
* @return array
*
*/
protected function filterExtractPageImagesFromContentData($extractedImages, $context, $content, $contentObject, $pageData, $pageMeta)
{
if ('com_content.article' == $context)
{
$appConfig = $this->factory->getThis('forseo.config', 'app');
$imageSpec = $appConfig->get('imageDetectionRequireSizeSd');
$ogpImageSpec = $appConfig->get('imageDetectionRequireSizeOgp');
$extractedImages['page_image'] = $this->detectComContentImages($contentObject, $imageSpec);
$extractedImages['page_sharing_image'] = $this->detectComContentImages($contentObject, $ogpImageSpec);
}
return $extractedImages;
}
/**
* Detect whether an article Full or intro image are suitable as a page image.
*
* @param Object $contentObject
* @param array $imageSpec
*
* @return array|string
*/
private function detectComContentImages($contentObject, $imageSpec)
{
if (!empty($contentObject) && !empty($contentObject->images))
{
$imageDef = new Registry\Registry($contentObject->images);
$possibleImages = [
'image_fulltext',
'image_intro'
];
$imageHelper = $this->factory->getA(Helper\Meta::class);
foreach ($possibleImages as $possibleImage)
{
$imageUrl = $imageDef->get(
$possibleImage,
''
);
// special J4 cleanup
if (Wb\contains($imageUrl, '#joomlaImage'))
{
$imageUrlBits = explode('#joomlaImage', $imageUrl, 2);
$imageUrl = array_shift($imageUrlBits);
}
$image = $imageHelper->validateImageFromContent(
$imageUrl,
$imageSpec
);
// return right away if a valid image is found
// User has set those images, they should be representative
if (!empty($image))
{
// possibly extract alt
$image['alt'] = $imageDef->get($possibleImage . '_alt', '');
// possibly extract caption
$image['caption'] = $imageDef->get($possibleImage . '_caption', '');
return $image;
}
}
}
return '';
}
/**
* Implement default construction of finding out modified_at date time.
*
* Use MYSQL format (Y-m-d H:i:s), assumes UTC.
*
* Null if unable to determine.
*
* @param null|string $lastMod
* @param Data\Page $pageData
*
* @return null | string
* @throws \Exception
*/
protected function filterPageModifiedAt($lastMod, $pageData)
{
$inputVars = $pageData->get('input_vars', []);
$view = Wb\arrayGet($inputVars, 'view', '');
if (!in_array($view, ['article', 'category']))
{
return $lastMod;
}
$itemId = (int)Wb\arrayGet($inputVars, 'id');
if (empty($itemId))
{
return $lastMod;
}
if ('article' == $view)
{
$dbTable = '#__content';
$modificationColumn = 'modified';
$modificationBackupColumn = 'created';
}
else
{
$dbTable = '#__categories';
$modificationColumn = 'modified_time';
$modificationBackupColumn = 'created_time';
}
$modData = $this->factory
->getThe('db')
->selectAssoc(
$dbTable,
[$modificationColumn, $modificationBackupColumn],
[
'id' => $itemId
]
);
if (empty($modData))
{
return null;
}
$lastMod = Wb\arrayGet($modData, $modificationColumn, null);
$lastMod = empty($lastMod) || '0000-00-00 00:00:00' == $lastMod
? Wb\arrayGet($modData, $modificationBackupColumn, null)
: $lastMod;
return $lastMod;
}
/**
* Filters whether the content described by in $pageData is considered archived. Will have an impact on
* sitemap inclusion.
*
* @param bool $isArchived True if content hyas support for archiving and is archived, false otherwise.
* @param Data\Page $pageData
*
* @return bool
* @throws \Exception
*/
protected function filterPageIsArchived($isArchived, $pageData)
{
$inputVars = $pageData->get('input_vars', []);
$view = Wb\arrayGet($inputVars, 'view', '');
if (!in_array($view, ['article', 'category']))
{
return $isArchived;
}
$itemId = (int)Wb\arrayGet($inputVars, 'id');
if (empty($itemId))
{
return $isArchived;
}
if ('article' == $view)
{
$dbTable = '#__content';
$column = 'state';
}
else
{
$dbTable = '#__categories';
$column = 'published';
}
$stateData = $this->factory
->getThe('db')
->selectAssoc(
$dbTable,
[$column],
[
'id' => $itemId
]
);
$state = Wb\arrayGet($stateData, $column, null);
return !empty($state) && 2 == $state;
}
/**
* Filter auto-generated dynamic variables that will be substituted by 4SEO based on rules.
*
* @param array $variables
* @param Data\Page $pageData
* @return array
* @throws \Exception
*/
public function filterExpandableVariables($variables, $pageData)
{
if (!$this->shouldRunFilter($pageData))
{
return $variables;
}
if (
empty($this->contentData)
||
empty($this->contentData['content'])
)
{
return $variables;
}
$contentVariables = [];
if (
'article' === $pageData->get('view')
&&
'com_content.article' === $this->contentData['context']
)
{
$contentVariables['article_id'] = $this->contentData['content']->id;
if (!empty($this->contentData['content']->title))
{
$contentVariables['article_title'] = $this->contentData['content']->title;
}
if (!empty($this->contentData['content']->metadesc))
{
$contentVariables['article_description'] = $this->contentData['content']->metadesc;
}
$contentVariables['article_date_modified'] = $this->contentData['content']->modified;
$contentVariables['article_date_published'] = $this->contentData['content']->publish_up;
$contentVariables['article_category_id'] = $this->contentData['content']->catid;
$contentVariables['article_category'] = $this->contentData['content']->category_title;
$contentVariables['article_author'] = $this->contentData['content']->author;
$contentVariables['article_rating'] = empty($this->contentData['content']->rating) ? 0 : $this->contentData['content']->rating;
$contentVariables['article_rating_count'] = empty($this->contentData['content']->rating_count) ? 0 : $this->contentData['content']->rating_count;
$contentVariables['article_hits'] = $this->contentData['content']->hits;
}
return array_merge(
$variables,
$contentVariables
);
}
/**
* Extract best images from gallery found in a page.
*
* $images[$href] = [
* 'url' => $href,
* 'title' => $imgTag->getAttribute('title'),
* 'alt' => $imgTag->getAttribute('alt'),
* 'el_width' => $imgTag->getAttribute('width'),
* 'el_height' => $imgTag->getAttribute('height'),
* 'data' => $dataAttributes
* ];
*
* @param null|array $extractedImages
* @param string $buffer
* @param \DOMDocument $dom
* @param \DocNodeList $imgTags
* @param array $options
* @param Data\Page $pageData
* @return null|array
* @throws \Exception
*/
public function filterExtractImages($extractedImages, $buffer, $dom, $imgTags, $options, $pageData)
{
if (!$this->shouldRunFilter($pageData))
{
return $extractedImages;
}
$view = strtolower($pageData->get('view'));
if ('article' !== $view)
{
return [];
}
return $extractedImages;
}
/**
* Check whether the current plugin can retrieve a custom field value
* associated with the provided context string.
*
* @param string $context
* @return bool
*/
protected function isValidCustomFieldContext($context)
{
return Wb\startsWith(
$context,
'com_content.article'
);
}
}