Index PDF and Office file contents via Apache Tika

Solr 10 dropped the ExtractingRequestHandler, so a dedicated Tika
container on the Solr VPS (Caddy route /tika/*, own basic-auth
credential; Tika itself has no auth) extracts file text during
indexing: TikaDownloadContentIndexer listens on
BeforeDocumentIsProcessedForIndexingEvent, resolves the file through
the existing DownloadFileResolver and appends the text (mime
whitelist, 30 MB cap, 100k chars, fail-soft) to the document's content
field. Extractions are cached in var/tika-cache keyed on
path+size+mtime - a full re-index of 177 downloads drops from 2:12 min
to 27 s, replacing a file re-extracts naturally. Datasheet
specifications ("625i", "genlock") are now searchable. Spec v1.15.
This commit is contained in:
orasche committed 2026-09-11 13:15:05 +02:00
1 parent 5bb4e374b4
commit 8c881d153c
9 files changed
+499 -6

No files matched your search

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use ApacheSolrForTypo3\Solr\Event\Indexing\BeforeDocumentIsProcessedForIndexingEvent;
use Evomedien\Vitec\Service\DownloadFileResolver;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Adds the extracted file text (PDF/Office) to the Solr `content` field of
* download documents. Solr 10 dropped the built-in ExtractingRequestHandler,
* so extraction goes through the Apache Tika server on the Solr VPS
* (Caddy route /tika/*, credentials via TIKA_* env in additional.php).
*
* Extracted text is cached in var/tika-cache keyed on path+size+mtime -
* a full re-index does not re-upload 177 unchanged PDFs, replacing a file
* invalidates its entry naturally. Fail-soft throughout (Clause 9.5): no
* Tika, no file, or an extraction error leave the document as it was -
* title/teaser/keywords still get it indexed.
*/
#[AsEventListener(identifier: 'vitec/tika-download-content')]
final class TikaDownloadContentIndexer
{
/** Files above this size are not sent to Tika (bytes). */
private const MAX_FILE_SIZE = 31457280;
/** Extracted text is truncated to this many characters before indexing. */
private const MAX_TEXT_LENGTH = 100000;
private const EXTRACTABLE_MIME_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
public function __invoke(BeforeDocumentIsProcessedForIndexingEvent $event): void
{
try {
$item = $event->getIndexQueueItem();
if ($item->getType() !== 'tx_vitec_domain_model_download') {
return;
}
$file = DownloadFileResolver::resolve($item->getRecordUid());
if (
$file === null
|| $file['size'] <= 0
|| $file['size'] > self::MAX_FILE_SIZE
|| !in_array($file['mimeType'], self::EXTRACTABLE_MIME_TYPES, true)
) {
return;
}
$text = $this->extract($file['path']);
if ($text === '') {
return;
}
$document = $event->getDocument();
$existing = trim((string)($document['content'] ?? ''));
$document->setField('content', trim($existing . ' ' . $text));
} catch (\Throwable) {
// fail soft - the download stays indexed with its metadata
}
}
private function extract(string $path): string
{
$cacheFile = $this->cacheFileFor($path);
if ($cacheFile !== '' && is_file($cacheFile)) {
return (string)file_get_contents($cacheFile);
}
$baseUrl = (string)getenv('TIKA_URL');
if ($baseUrl === '') {
return '';
}
$context = stream_context_create(['http' => [
'method' => 'PUT',
'header' => [
'Authorization: Basic ' . base64_encode(getenv('TIKA_USERNAME') . ':' . getenv('TIKA_PASSWORD')),
'Accept: text/plain',
'Content-Type: application/octet-stream',
],
'content' => (string)file_get_contents($path),
'timeout' => 30,
'ignore_errors' => true,
]]);
$raw = file_get_contents(rtrim($baseUrl, '/') . '/tika', false, $context);
$status = (string)($http_response_header[0] ?? '');
if ($raw === false || !str_contains($status, '200')) {
return '';
}
$text = mb_substr(trim((string)preg_replace('/\s+/u', ' ', $raw)), 0, self::MAX_TEXT_LENGTH);
if ($cacheFile !== '' && $text !== '') {
GeneralUtility::mkdir_deep(dirname($cacheFile));
GeneralUtility::writeFile($cacheFile, $text);
}
return $text;
}
/**
* Cache key covers path, size and mtime - replacing a file re-extracts.
*/
private function cacheFileFor(string $path): string
{
$size = @filesize($path);
$mtime = @filemtime($path);
if ($size === false || $mtime === false) {
return '';
}
return Environment::getVarPath() . '/tika-cache/'
. sha1($path . '|' . $size . '|' . $mtime) . '.txt';
}
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Data source for the product filter: the product category hierarchy, and
* nothing else.
*
* The editor places ONE `vitec_productfinder` element above the product
* lists on the products page. It renders no product cards - the React front
* end builds the filter selects from this payload and filters the products
* the `vitec_productlist` elements already delivered, client side, via URL
* parameters (?cat=58&subcat=66). No change to the list plugin was needed.
*
* Emitted shape:
*
* content.productfinder.categories[] = { uid, title, subcategories[] }
* subcategories[] = { uid, title }
*
* Level 1 below the configured root becomes the main categories, their
* direct children the subcategories - the same tree the product records
* hang on, so a filter value maps straight onto `product.categories[].uid`.
*
* TYPO3 v14 hands the ContentObjectRenderer in through the setter; without
* it $this->cObj stays null and the element cannot read its own FlexForm.
*/
final class ProductFinderJsonRenderer
{
private const CATEGORY_TABLE = 'sys_category';
/** Fallback when no root is configured: the category tree the products use. */
private const DEFAULT_ROOT_TITLE = 'Product';
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$settings = $this->settings();
$root = (int)($settings['categoryRoot'] ?? 0);
if ($root <= 0) {
$root = $this->rootByTitle(self::DEFAULT_ROOT_TITLE);
}
if ($root <= 0) {
return (string)json_encode(['categories' => []]);
}
$order = (string)($settings['sorting'] ?? 'sorting');
$categories = [];
foreach ($this->childrenOf($root, $order) as $main) {
$categories[] = [
'uid' => (int)$main['uid'],
'title' => (string)$main['title'],
'subcategories' => array_map(
static fn(array $sub): array => [
'uid' => (int)$sub['uid'],
'title' => (string)$sub['title'],
],
$this->childrenOf((int)$main['uid'], $order)
),
];
}
return (string)json_encode(['categories' => $categories], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
// A broken filter must never take the page payload down with it.
return (string)json_encode(['categories' => []]);
}
}
/**
* FlexForm settings of the element being rendered.
*
* @return array<string,mixed>
*/
private function settings(): array
{
$flexform = (string)($this->cObj->data['pi_flexform'] ?? '');
if ($flexform === '') {
return [];
}
$parsed = GeneralUtility::makeInstance(FlexFormService::class)
->convertFlexFormContentToArray($flexform);
return is_array($parsed['settings'] ?? null) ? $parsed['settings'] : [];
}
/**
* Direct children of a category, honouring the frontend restrictions
* (hidden, start/end time, deleted).
*
* @return array<int,array<string,mixed>>
*/
private function childrenOf(int $parent, string $order): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(self::CATEGORY_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
$query = $queryBuilder->select('uid', 'title', 'sorting')
->from(self::CATEGORY_TABLE)
->where(
$queryBuilder->expr()->eq('parent', $queryBuilder->createNamedParameter($parent, ParameterType::INTEGER)),
$queryBuilder->expr()->in('sys_language_uid', [-1, 0])
);
match ($order) {
'title' => $query->orderBy('title', 'ASC'),
'uid' => $query->orderBy('uid', 'ASC'),
default => $query->orderBy('sorting', 'ASC')->addOrderBy('title', 'ASC'),
};
return $query->executeQuery()->fetchAllAssociative();
}
/** Uid of a root-level category by title, 0 when it does not exist. */
private function rootByTitle(string $title): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(self::CATEGORY_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
$uid = $queryBuilder->select('uid')->from(self::CATEGORY_TABLE)
->where(
$queryBuilder->expr()->eq('parent', 0),
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($title))
)
->setMaxResults(1)
->executeQuery()->fetchOne();
return is_numeric($uid) ? (int)$uid : 0;
}
}
@@ -73,6 +73,7 @@ class SearchJsonRenderer
'pages' => 'page',
'tx_vitec_domain_model_product' => 'product',
'tx_vitec_domain_model_market' => 'market',
'tx_vitec_domain_model_solution' => 'solution',
'tx_vitec_domain_model_usecase' => 'story',
'tx_news_domain_model_news' => 'news',
'tx_vitec_domain_model_download' => 'download',
@@ -152,6 +153,15 @@ class SearchJsonRenderer
$activeType = '';
}
// Secondary facet filters: value comes verbatim from facets.<name>[].value
$facetFilters = [];
foreach (['market', 'category'] as $facetParam) {
$facetValue = trim((string)($params[$facetParam] ?? ''));
if ($facetValue !== '') {
$facetFilters[$facetParam] = $facetValue;
}
}
if ($query === '') {
return (string)json_encode($this->emptyResult());
}
@@ -177,8 +187,15 @@ class SearchJsonRenderer
$search,
);
$arguments = ['q' => $query, 'page' => $page];
$filterArguments = [];
if ($activeType !== '') {
$arguments['filter'] = ['type:' . $typeFilterField];
$filterArguments[] = 'type:' . $typeFilterField;
}
foreach ($facetFilters as $facetName => $facetValue) {
$filterArguments[] = $facetName . ':' . $facetValue;
}
if ($filterArguments !== []) {
$arguments['filter'] = $filterArguments;
}
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
->buildForSearch($arguments, $pageId, $languageId);
@@ -255,6 +272,7 @@ class SearchJsonRenderer
'numFound' => $numFound,
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
'filter' => $activeType !== '' ? $activeType : null,
'activeFacets' => (object)$facetFilters,
'facets' => (object)$facets,
'results' => $results,
'suggestions' => array_values(array_unique($suggestions)),
@@ -276,6 +294,7 @@ class SearchJsonRenderer
'numFound' => 0,
'totalPages' => 0,
'filter' => null,
'activeFacets' => new stdClass(),
'facets' => new \stdClass(),
'results' => [],
'suggestions' => [],