}
*/
public static function extract(\WP_Post $post): array
{
$fields = Settings::fields();
$title = in_array('title', $fields, true) || $post->post_type === 'attachment'
? self::clean(get_the_title($post))
: '';
$lead = [];
if ($post->post_type === 'attachment') {
$alt = self::clean((string) get_post_meta($post->ID, '_wp_attachment_image_alt', true));
if ($alt !== '') {
$lead[] = 'Image: ' . $alt;
}
if (trim($post->post_excerpt) !== '') {
$lead[] = 'Caption: ' . self::clean($post->post_excerpt);
}
} else {
if (in_array('excerpt', $fields, true) && trim($post->post_excerpt) !== '') {
$lead[] = 'Summary: ' . self::clean($post->post_excerpt);
}
if (in_array('meta_description', $fields, true)) {
$key = Backfill::metaDescriptionKey();
$desc = $key !== '' ? self::clean((string) get_post_meta($post->ID, $key, true)) : '';
if ($desc !== '') {
$lead[] = 'Description: ' . $desc;
}
}
}
if (in_array('terms', $fields, true)) {
$names = self::termNames($post);
if ($names !== []) {
$lead[] = 'Topics: ' . implode(', ', $names);
}
}
if (in_array('author', $fields, true) && $post->post_type !== 'attachment') {
$author = self::clean((string) get_the_author_meta('display_name', (int) $post->post_author));
if ($author !== '') {
$lead[] = 'Author: ' . $author;
}
}
if (in_array('custom_fields', $fields, true)) {
foreach (Settings::metaKeys() as $key) {
$value = self::metaText(get_post_meta($post->ID, $key, false));
if ($value !== '') {
$lead[] = self::label($key) . ': ' . $value;
}
}
}
$content = in_array('content', $fields, true) || $post->post_type === 'attachment'
? self::contentToText((string) $post->post_content)
: '';
$body = trim(implode("\n", $lead) . ($lead !== [] && $content !== '' ? "\n\n" : '') . $content);
/**
* Filters the text embedded for a post, before chunking. Headings are "## " lines.
*
* @param string $body The text.
* @param \WP_Post $post The post or attachment.
*/
$body = (string) apply_filters('aipcf_embed_text', $body, $post);
return ['title' => $title, 'body' => $body, 'metadata' => self::metadata($post)];
}
/**
* Words in a text: runs of letters and digits, with the marks that belong to them, so
* Marathi and Hindi (whose vowel signs are marks) count like English. Scripts written
* without spaces (Chinese, Japanese, Thai) count each run as one word.
*/
public static function words(string $text): int
{
return (int) preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\p{M}\'\x{2019}-]*/u', $text);
}
/**
* Saved post content to plain text with "## " headings and blank-line paragraphs.
*/
public static function contentToText(string $html): string
{
$html = (string) preg_replace('//s', '', $html); // block delimiters, comments
$html = (string) preg_replace('#<(script|style|noscript|svg|iframe)\b.*?\1>#is', '', $html);
$html = (string) preg_replace('/\[\/?[A-Za-z][\w-]*(?:\s[^\]]*)?\]/', ' ', $html); // shortcode tags, keep inner text
$html = (string) preg_replace_callback('#
#is', [self::class, 'tableToText'], $html);
$html = (string) preg_replace_callback(
'#]*>(.*?)#is',
static fn(array $m): string => "\n\n## " . trim(self::clean($m[1])) . "\n\n",
$html
);
$html = (string) preg_replace('#<(br|hr)\b[^>]*>#i', "\n", $html);
$html = (string) preg_replace('#?(p|div|section|article|blockquote|figure|figcaption|ul|ol|pre|details|summary)\b[^>]*>#i', "\n\n", $html);
$html = (string) preg_replace('#]*>#i', "\n- ", $html);
$text = html_entity_decode(wp_strip_all_tags($html, false), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = str_replace("\xC2\xA0", ' ', $text);
$text = (string) preg_replace('/[ \t]+/', ' ', $text);
$text = (string) preg_replace('/ *\n */', "\n", $text);
$text = (string) preg_replace('/\n{3,}/', "\n\n", $text);
$text = (string) preg_replace("/^## *\n/m", '', $text); // empty headings
return trim($text);
}
/**
* A table as one line per row, cells separated by " | ", whatever whitespace the HTML has.
*
* @param array $m
*/
private static function tableToText(array $m): string
{
$rows = [];
foreach (preg_split('#]*>#i', $m[0]) ?: [] as $row) {
$cells = [];
foreach (preg_split('##i', $row) ?: [] as $cell) {
$cell = self::clean($cell);
if ($cell !== '') {
$cells[] = $cell;
}
}
if ($cells !== []) {
$rows[] = implode(' | ', $cells);
}
}
return "\n\n" . implode("\n", $rows) . "\n\n";
}
/**
* Stored with every chunk of the post. Keys with metadata indexes in the store
* (site, post_type, object_id, lang) are filterable.
*
* @return array
*/
public static function metadata(\WP_Post $post): array
{
$metadata = [
'site' => self::siteKey(),
'object_type' => $post->post_type === 'attachment' ? 'attachment' : 'post',
'object_id' => (int) $post->ID,
'post_type' => $post->post_type,
'title' => mb_substr(self::clean(get_the_title($post)), 0, 200),
'url' => $post->post_type === 'attachment' ? (string) wp_get_attachment_url($post->ID) : (string) get_permalink($post),
'date' => (string) $post->post_date_gmt,
// No modified time: it changes on every save and is part of the content
// hash, so a save without edits would be embedded (and billed) again.
'lang' => self::lang($post),
];
/**
* Filters the metadata stored with each chunk of a post (text is added per chunk).
*
* @param array $metadata
* @param \WP_Post $post
*/
return (array) apply_filters('aipcf_embed_metadata', $metadata, $post);
}
/**
* Short, stable ID for this site, so one index can hold several sites.
*/
public static function siteKey(): string
{
return substr(md5(home_url() . '|' . get_current_blog_id()), 0, 8);
}
private static function lang(\WP_Post $post): string
{
$locale = (string) apply_filters('aipcf_embed_locale', get_locale(), $post);
return strtolower((string) strtok($locale, '_-'));
}
/**
* @return list
*/
private static function termNames(\WP_Post $post): array
{
$names = [];
foreach (get_object_taxonomies($post->post_type, 'objects') as $taxonomy) {
if (!$taxonomy->public) {
continue;
}
$terms = get_the_terms($post, $taxonomy->name);
if (is_array($terms)) {
foreach ($terms as $term) {
if ($term->slug !== 'uncategorized') {
$names[] = self::clean($term->name);
}
}
}
}
return array_values(array_unique(array_filter($names)));
}
/**
* @param array $values
*/
private static function metaText(array $values): string
{
$out = [];
array_walk_recursive($values, static function ($v) use (&$out): void {
if (is_scalar($v) && !is_bool($v)) {
$t = self::clean((string) $v);
// Skip empty values and long numbers (IDs, timestamps), which carry no meaning.
if ($t !== '' && !(is_numeric($t) && strlen($t) >= 10)) {
$out[] = $t;
}
}
});
return mb_substr(implode(', ', array_unique($out)), 0, 2000);
}
private static function label(string $metaKey): string
{
return ucfirst(trim(str_replace(['_', '-'], ' ', $metaKey)));
}
private static function clean(string $text): string
{
$text = html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', str_replace("\xC2\xA0", ' ', $text)));
}
}