options->advanced->headlineAnalyzer ) { return; } add_filter( 'monsterinsights_headline_analyzer_enabled', '__return_false' ); add_filter( 'exactmetrics_headline_analyzer_enabled', '__return_false' ); } /** * Enqueues the headline analyzer. * * @since 4.1.2 * * @return void */ public function enqueue() { if ( ! aioseo()->helpers->isScreenBase( 'post' ) || ! aioseo()->access->hasCapability( 'aioseo_page_analysis' ) ) { return; } if ( ! aioseo()->options->advanced->headlineAnalyzer ) { return; } $screen = get_current_screen(); if ( is_object( $screen ) && ! empty( $screen->post_type ) && ! $this->supportsPostType( $screen->post_type ) ) { return; } $path = '/vendor/jwhennessey/phpinsight/autoload.php'; if ( ! aioseo()->core->fs->exists( AIOSEO_DIR . $path ) ) { return; } require_once AIOSEO_DIR . $path; aioseo()->core->assets->load( 'src/vue/standalone/headline-analyzer/main.js' ); } /** * Determines whether the Headline Analyzer applies to the given post type. * * @since 5.0.0 * * @param string $postType The post type. * @return bool Whether the analyzer applies to the post type. */ public function supportsPostType( $postType ) { // The scoring targets editorial headlines; WooCommerce product titles are // commercial and don't map to it, so products are excluded. return ! in_array( $postType, [ 'product' ], true ); } /** * Determines whether the Headline Analyzer applies to the given analysis locale. * * @since 5.0.0 * * @param string $locale The analysis locale. * @return bool Whether the analyzer applies to the locale. */ public function supportsLocale( $locale ) { // The scoring engine matches against hard-coded English word lists and // {@see self::getHeadlineScore()} strips the headline to ASCII, so a non-English // headline either scores meaninglessly or can't be scored at all. return 'en' === strtolower( (string) preg_replace( '/[-_].*$/', '', (string) $locale ) ); } /** * Returns the score for a post's current title, or null when the analyzer doesn't apply. * * The score isn't persisted per post, so every consumer recomputes it. Owning the * applicability rule here keeps those consumers from drifting apart. * * @since 5.0.0 * * @param int $postId The post ID. * @param Models\Post|null $aioseoPost The AIOSEO post, for the analysis locale. Fetched when omitted. * @return int|null The score, or null when the analyzer doesn't apply. */ public function getScoreForPost( $postId, $aioseoPost = null ) { if ( ! aioseo()->options->advanced->headlineAnalyzer ) { return null; } if ( ! $this->supportsPostType( get_post_type( $postId ) ) ) { return null; } $aioseoPost = $aioseoPost ? $aioseoPost : Models\Post::getPost( $postId ); $locale = ! empty( $aioseoPost->truseo_locale ) ? $aioseoPost->truseo_locale : get_locale(); if ( ! $this->supportsLocale( $locale ) ) { return null; } // Sanitizing first keeps the score consistent with the editor, which analyzes the // title only after it has been through the same sanitization. $title = sanitize_text_field( get_the_title( $postId ) ); if ( '' === trim( $title ) ) { return null; } // The scoring library is autoloaded, so this only fails when the vendor directory // has been trimmed — bail instead of fataling. if ( ! class_exists( '\PHPInsight\Sentiment' ) ) { return null; } return (int) $this->getResult( $title )['score']; } /** * Returns the result of the analsyis. * * @since 4.1.2 * * @param string $title The title. * @return array The result. */ public function getResult( $title ) { $result = $this->getHeadlineScore( aioseo()->helpers->decodeHtmlEntities( $title ) ); return [ 'result' => $result, 'analysed' => ! $result->err, 'sentence' => ucwords( wp_unslash( sanitize_text_field( $title ) ) ), 'score' => ! empty( $result->score ) ? $result->score : 0 ]; } /** * Returns the score. * * @since 4.1.2 * * @param string $title The title. * @return \stdClass The result. */ public function getHeadlineScore( $title ) { $result = new \stdClass(); $result->originalExplodedHeadline = explode( ' ', wp_unslash( $title ) ); // The full headline as displayed (spaces + punctuation kept), for the // character count below. Captured before the word-matching passes strip it // down to ASCII alphanumerics. $fullHeadline = trim( (string) preg_replace( '!\s+!', ' ', wp_unslash( (string) $title ) ) ); // Strip useless characters and whitespace. $title = preg_replace( '/[^A-Za-z0-9 ]/', '', (string) $title ); $title = preg_replace( '!\s+!', ' ', (string) $title ); $title = strtolower( $title ); $result->input = $title; // If the headline is invalid, return an error. if ( ! $title || ' ' === $title || trim( $title ) === '' ) { $result->err = true; $result->msg = 'The headline is invalid.'; return $result; } $totalScore = 0; $explodedHeadline = explode( ' ', $title ); $result->explodedHeadline = $explodedHeadline; $result->err = false; // The optimal length is 55 characters — measured on the full headline as // search engines display it (multibyte-safe), not the stripped word tokens. $result->length = function_exists( 'mb_strlen' ) ? mb_strlen( $fullHeadline ) : strlen( $fullHeadline ); $totalScore = $totalScore + 3; //phpcs:disable Squiz.ControlStructures.ControlSignature if ( $result->length <= 19 ) { $totalScore += 5; } elseif ( $result->length >= 20 && $result->length <= 34 ) { $totalScore += 8; } elseif ( $result->length >= 35 && $result->length <= 66 ) { $totalScore += 11; } elseif ( $result->length >= 67 && $result->length <= 79 ) { $totalScore += 8; } elseif ( $result->length >= 80 ) { $totalScore += 5; } // The average headline is 6-7 words long. $result->wordCount = count( $explodedHeadline ); $totalScore = $totalScore + 3; if ( 0 === $result->wordCount ) { $totalScore = 0; } elseif ( $result->wordCount >= 2 && $result->wordCount <= 4 ) { $totalScore += 5; } elseif ( $result->wordCount >= 5 && $result->wordCount <= 9 ) { $totalScore += 11; } elseif ( $result->wordCount >= 10 && $result->wordCount <= 11 ) { $totalScore += 8; } elseif ( $result->wordCount >= 12 ) { $totalScore += 5; } // Check for power words, emotional words, etc. $result->powerWords = $this->matchWords( $result->input, $result->explodedHeadline, $this->powerWords() ); $result->powerWordsPercentage = count( $result->powerWords ) / $result->wordCount; $result->emotionWords = $this->matchWords( $result->input, $result->explodedHeadline, $this->emotionPowerWords() ); $result->emotionalWordsPercentage = count( $result->emotionWords ) / $result->wordCount; $result->commonWords = $this->matchWords( $result->input, $result->explodedHeadline, $this->commonWords() ); $result->commonWordsPercentage = count( $result->commonWords ) / $result->wordCount; $result->uncommonWords = $this->matchWords( $result->input, $result->explodedHeadline, $this->uncommonWords() ); $result->uncommonWordsPercentage = count( $result->uncommonWords ) / $result->wordCount; $result->detectedWordTypes = []; if ( $result->emotionalWordsPercentage < 0.1 ) { $result->detectedWordTypes[] = 'emotion'; } else { $totalScore = $totalScore + 15; } if ( $result->commonWordsPercentage < 0.2 ) { $result->detectedWordTypes[] = 'common'; } else { $totalScore = $totalScore + 11; } if ( $result->uncommonWordsPercentage < 0.1 ) { $result->detectedWordTypes[] = 'uncommon'; } else { $totalScore = $totalScore + 15; } if ( count( $result->powerWords ) < 1 ) { $result->detectedWordTypes[] = 'power'; } else { $totalScore = $totalScore + 19; } if ( $result->emotionalWordsPercentage >= 0.1 && $result->commonWordsPercentage >= 0.2 && $result->uncommonWordsPercentage >= 0.1 && count( $result->powerWords ) >= 1 ) { $totalScore = $totalScore + 3; } $sentiment = new \PHPInsight\Sentiment(); $sentimentClass = $sentiment->categorise( $title ); $result->sentiment = $sentimentClass; $totalScore = $totalScore + ( 'pos' === $result->sentiment ? 10 : ( 'neg' === $result->sentiment ? 10 : 7 ) ); $headlineTypes = []; if ( strpos( $title, 'how to' ) !== false || strpos( $title, 'howto' ) !== false ) { $headlineTypes[] = __( 'How-To', 'all-in-one-seo-pack' ); $totalScore = $totalScore + 7; } $listWords = array_intersect( $explodedHeadline, $this->numericalIndicators() ); if ( preg_match( '~[0-9]+~', (string) $title ) || ! empty( $listWords ) ) { $headlineTypes[] = __( 'List', 'all-in-one-seo-pack' ); $totalScore = $totalScore + 7; } if ( in_array( $explodedHeadline[0], $this->primaryQuestionIndicators(), true ) ) { if ( isset( $explodedHeadline[1] ) && in_array( $explodedHeadline[1], $this->secondaryQuestionIndicators(), true ) ) { $headlineTypes[] = __( 'Question', 'all-in-one-seo-pack' ); $totalScore = $totalScore + 7; } } if ( empty( $headlineTypes ) ) { $headlineTypes[] = __( 'General', 'all-in-one-seo-pack' ); $totalScore = $totalScore + 5; } $result->headlineTypes = $headlineTypes; // The individual signals can sum past 100 (e.g. a keyword-rich how-to list // with strong sentiment), so clamp to a 0-100 scale. 100 is achievable. $result->score = min( 100, $totalScore ); return $result; } /** * Tries to find matches for power words, emotional words, etc. in the headline. * * @since 4.1.2 * * @param string $headline The headline. * @param array $explodedHeadline The exploded headline. * @param array $words The words to match. * @return array The matches that were found. */ public function matchWords( $headline, $explodedHeadline, $words ) { $foundMatches = []; foreach ( $words as $word ) { $strippedWord = preg_replace( '/[^A-Za-z0-9 ]/', '', (string) $word ); // Check if word is a phrase. if ( strpos( $word, ' ' ) !== false ) { if ( strpos( $headline, $strippedWord ) !== false ) { $foundMatches[] = $word; } continue; } // Check if it is a single word. if ( in_array( $strippedWord, $explodedHeadline, true ) ) { $foundMatches[] = $word; } } return $foundMatches; } /** * Returns a list of numerical indicators. * * @since 4.1.2 * * @return array The list of numerical indicators. */ private function numericalIndicators() { return [ 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'eleven', 'twelve', 'thirt', 'fift', 'hundred', 'thousand' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } /** * Returns a list of primary question indicators. * * @since 4.1.2 * * @return array The list of primary question indicators. */ private function primaryQuestionIndicators() { return [ 'where', 'when', 'how', 'what', 'have', 'has', 'does', 'do', 'can', 'are', 'will' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } /** * Returns a list of secondary question indicators. * * @since 4.1.2 * * @return array The list of secondary question indicators. */ private function secondaryQuestionIndicators() { return [ 'you', 'they', 'he', 'she', 'your', 'it', 'my', 'have', 'has', 'does', 'do', 'can', 'are', 'will' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } /** * Returns a list of power words. * * @since 4.1.2 * * @return array The list of power words. */ private function powerWords() { return [ 'great', 'free', 'focus', 'remarkable', 'confidential', 'sale', 'wanted', 'obsession', 'sizable', 'new', 'absolutely lowest', 'surging', 'wonderful', 'professional', 'interesting', 'revisited', 'delivered', 'guaranteed', 'challenge', 'unique', 'secrets', 'special', 'lifetime', 'bargain', 'scarce', 'tested', 'highest', 'hurry', 'alert famous', 'improved', 'expert', 'daring', 'strong', 'immediately', 'advice', 'pioneering', 'unusual', 'limited', 'the truth about', 'destiny', 'outstanding', 'simplistic', 'compare', 'unsurpassed', 'energy', 'powerful', 'colorful', 'genuine', 'instructive', 'big', 'affordable', 'informative', 'liberal', 'popular', 'ultimate', 'mainstream', 'rare', 'exclusive', 'willpower', 'complete', 'edge', 'valuable', 'attractive', 'last chance', 'superior', 'how to', 'easily', 'exploit', 'unparalleled', 'endorsed', 'approved', 'quality', 'fascinating', 'unlimited', 'competitive', 'gigantic', 'compromise', 'discount', 'full', 'love', 'odd', 'fundamentals', 'mammoth', 'lavishly', 'bottom line', 'under priced', 'innovative', 'reliable', 'zinger', 'suddenly', 'it\'s here', 'terrific', 'simplified', 'perspective', 'just arrived', 'breakthrough', 'tremendous', 'launching', 'sure fire', 'emerging', 'helpful', 'skill', 'soar', 'profitable', 'special offer', 'reduced', 'beautiful', 'sampler', 'technology', 'better', 'crammed', 'noted', 'selected', 'shrewd', 'growth', 'luxury', 'sturdy', 'enormous', 'promising', 'unconditional', 'wealth', 'spotlight', 'astonishing', 'timely', 'successful', 'useful', 'imagination', 'bonanza', 'opportunities', 'survival', 'greatest', 'security', 'last minute', 'largest', 'high tech', 'refundable', 'monumental', 'colossal', 'latest', 'quickly', 'startling', 'now', 'important', 'revolutionary', 'quick', 'unlock', 'urgent', 'miracle', 'easy', 'fortune', 'amazing', 'magic', 'direct', 'authentic', 'exciting', 'proven', 'simple', 'announcing', 'portfolio', 'reward', 'strange', 'huge gift', 'revealing', 'weird', 'value', 'introducing', 'sensational', 'surprise', 'insider', 'practical', 'excellent', 'delighted', 'download' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } /** * Returns a list of common words. * * @since 4.1.2 * * @return array The list of common words. */ private function commonWords() { return [ 'a', 'for', 'about', 'from', 'after', 'get', 'all', 'has', 'an', 'have', 'and', 'he', 'are', 'her', 'as', 'his', 'at', 'how', 'be', 'I', 'but', 'if', 'by', 'in', 'can', 'is', 'did', 'it', 'do', 'just', 'ever', 'like', 'these', 'me', 'they', 'most', 'things', 'my', 'this', 'no', 'to', 'not', 'up', 'of', 'was', 'on', 'what', 'when', 'she', 'who', 'should', 'why', 'so', 'will', 'that', 'with', 'the', 'you', 'their', 'your', 'there' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } /** * Returns a list of uncommon words. * * @since 4.1.2 * * @return array The list of uncommon words. */ private function uncommonWords() { return [ 'actually', 'happened', 'need', 'thing', 'awesome', 'heart', 'never', 'think', 'baby', 'here', 'new', 'time', 'beautiful', 'its', 'now', 'valentines', 'being', 'know', 'old', 'video', 'best', 'life', 'one', 'want', 'better', 'little', 'out', 'watch', 'boy', 'look', 'people', 'way', 'dog', 'love', 'photos', 'ways', 'down', 'made', 'really', 'world', 'facebook', 'make', 'reasons', 'year', 'first', 'makes', 'right', 'years', 'found', 'man', 'see', 'you’ll', 'girl', 'media', 'seen', 'good', 'mind', 'social', 'guy', 'more', 'something' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } /** * Returns a list of emotional power words. * * @since 4.1.2 * * @return array The list of emotional power words. */ private function emotionPowerWords() { return [ 'destroy', 'extra', 'in a', 'devastating', 'eye-opening', 'gift', 'in the world', 'devoted', 'fail', 'in the', 'faith', 'grateful', 'inexpensive', 'dirty', 'famous', 'disastrous', 'fantastic', 'greed', 'grit', 'insanely', 'disgusting', 'fearless', 'disinformation', 'feast', 'insidious', 'dollar', 'feeble', 'gullible', 'double', 'fire', 'hack', 'fleece', 'had enough', 'invasion', 'drowning', 'floundering', 'happy', 'ironclad', 'dumb', 'flush', 'hate', 'irresistibly', 'hazardous', 'is the', 'fool', 'is what happens when', 'fooled', 'helpless', 'it looks like a', 'embarrass', 'for the first time', 'help are the', 'jackpot', 'forbidden', 'hidden', 'jail', 'empower', 'force-fed', 'high', 'jaw-dropping', 'forgotten', 'jeopardy', 'energize', 'hoax', 'jubilant', 'foul', 'hope', 'killer', 'frantic', 'horrific', 'know it all', 'epic', 'how to make', 'evil', 'freebie', 'frenzy', 'hurricane', 'excited', 'fresh on the mind', 'frightening', 'hypnotic', 'lawsuit', 'frugal', 'illegal', 'fulfill', 'lick', 'explode', 'lies', 'exposed', 'gambling', 'like a normal', 'nightmare', 'results', 'line', 'no good', 'pound', 'loathsome', 'no questions asked', 'revenge', 'lonely', 'looks like a', 'obnoxious', 'preposterous', 'revolting', 'looming', 'priced', 'lost', 'prison', 'lowest', 'of the', 'privacy', 'rich', 'lunatic', 'off-limits', 'private', 'risky', 'lurking', 'offer', 'prize', 'ruthless', 'lust', 'official', 'luxurious', 'on the', 'profit', 'scary', 'lying', 'outlawed', 'protected', 'scream', 'searing', 'overcome', 'provocative', 'make you', 'painful', 'pummel', 'secure', 'pale', 'punish', 'marked down', 'panic', 'quadruple', 'seductively', 'massive', 'pay zero', 'seize', 'meltdown', 'payback', 'might look like a', 'peril', 'mind-blowing', 'shameless', 'minute', 'rave', 'shatter', 'piranha', 'reckoning', 'shellacking', 'mired', 'pitfall', 'reclaim', 'mistakes', 'plague', 'sick and tired', 'money', 'played', 'refugee', 'silly', 'money-grubbing', 'pluck', 'refund', 'moneyback', 'plummet', 'plunge', 'murder', 'pointless', 'sinful', 'myths', 'poor', 'remarkably', 'six-figure', 'never again', 'research', 'surrender', 'to the', 'varify', 'skyrocket', 'toxic', 'vibrant', 'slaughter', 'swindle', 'trap', 'victim', 'sleazy', 'taboo', 'treasure', 'victory', 'smash', 'tailspin', 'vindication', 'smug', 'tank', 'triple', 'viral', 'smuggled', 'tantalizing', 'triumph', 'volatile', 'sniveling', 'targeted', 'truth', 'vulnerable', 'snob', 'tawdry', 'try before you buy', 'tech', 'turn the tables', 'wanton', 'soaring', 'warning', 'teetering', 'unauthorized', 'spectacular', 'temporary fix', 'unbelievably', 'spine', 'tempting', 'uncommonly', 'what happened', 'spirit', 'what happens when', 'terror', 'under', 'what happens', 'staggering', 'underhanded', 'what this', 'that will make you', 'undo', 'when you see', 'that will make', 'unexpected', 'when you', 'strangle', 'that will', 'whip', 'the best', 'whopping', 'stuck up', 'the ranking of', 'wicked', 'stunning', 'the most', 'will make you', 'stupid', 'the reason why is', 'unscrupulous', 'thing ive ever seen', 'withheld', 'this is the', 'this is what happens', 'unusually', 'wondrous', 'this is what', 'uplifting', 'worry', 'sure', 'this is', 'wounded', 'surge', 'thrilled', 'you need to know', 'thrilling', 'valor', 'you need to', 'you see what', 'surprising', 'tired', 'you see', 'surprisingly', 'to be', 'vaporize' // phpcs:ignore Generic.Files.LineLength.MaxExceeded, WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine ]; } }