label. */ public static function lengths() { return array( 'standard' => __( 'Standard (about 3,000 to 4,000 characters, or 1,800 to 2,400 words)', 'acs-article-generator' ), 'long' => __( 'Long (about 4,000 to 6,000 characters, or 2,400 to 3,600 words)', 'acs-article-generator' ), 'longest' => __( 'Very long (about 6,000 to 8,000 characters, or 3,600 to 4,800 words)', 'acs-article-generator' ), 'custom' => __( 'Custom length', 'acs-article-generator' ), ); } /** * Turn a length choice into an instruction for the model. * * @param string $length Length key. * @param string $custom Free text used when the key is "custom". * @return string */ private static function length_instruction( $length, $custom ) { $floor = ' The article must never be shorter than 3,000 characters, or 1,800 words for languages written with spaces.'; switch ( $length ) { case 'long': return 'Between 4,000 and 6,000 characters, or 2,400 to 3,600 words.' . $floor; case 'longest': return 'Between 6,000 and 8,000 characters, or 3,600 to 4,800 words.' . $floor; case 'custom': $custom = trim( $custom ); if ( '' !== $custom ) { return $custom . $floor; } return 'Between 3,000 and 4,000 characters, or 1,800 to 2,400 words.' . $floor; case 'standard': default: return 'Between 3,000 and 4,000 characters, or 1,800 to 2,400 words.' . $floor; } } /** * Step the length down one notch after the model ran out of output room. * * @param string $length Current length key. * @return string */ private static function shorter_length( $length ) { $ladder = array( 'longest' => 'long', 'long' => 'standard', 'custom' => 'standard', ); return isset( $ladder[ $length ] ) ? $ladder[ $length ] : $length; } // ----------------------------------------------------------------- // Title suggestions. // ----------------------------------------------------------------- /** * Ask for article ideas that do not repeat what the site already has. * * @return string|WP_Error Markdown list of ideas. */ public static function suggest_ideas() { $titles = self::existing_titles( 100 ); $tone = ACSAGEN_Tone::get(); $concept = (string) ACSAGEN_Settings::get( 'site_concept', '' ); $language = ACSAGEN_Settings::language_for_prompt(); $title_list = ''; foreach ( $titles as $title ) { $title_list .= '- ' . $title['title'] . "\n"; } if ( '' === $title_list ) { $title_list = "(this site has not published anything yet)\n"; } $tone_section = '' !== $tone ? "## House style of this site\n{$tone}\n" : "(no style guide has been saved yet)\n"; $concept_section = '' !== $concept ? $concept : '(no site description has been saved yet)'; $prompt = "You are a search focused content strategist.\n" . "Suggest the articles this site should write next.\n\n" . "## What this site is about\n{$concept_section}\n\n" . "{$tone_section}\n" . "## Titles this site has already published, which you must not repeat\n{$title_list}\n" . "---\n\n" . "Suggest exactly 8 articles, grouped as 3 high priority, 3 steady value and 2 brand or seasonal.\n" . 'For each one give, on its own line: the working title, the main search phrase it targets, ' . "one existing post from the list above that it should link to, and one sentence on why it is worth writing now.\n" . "Write in {$language}. Use Markdown headings for the three groups and a numbered list inside each group.\n" . 'Never suggest a title that duplicates one already on the list.'; return ACSAGEN_Client::generate( $prompt ); } // ----------------------------------------------------------------- // Article writing. // ----------------------------------------------------------------- /** * Write an article and save it as a draft. * * @param array $args { * @type string $topic What the article should be about. Required. * @type string $length One of the keys from lengths(). * @type string $custom Free text length, used when length is "custom". * @type string $notes Extra instructions for the model. * @type string $experience First hand experience to weave into the article. * @type string $offer Affiliate offer or product to mention. * @type string $links External links, one per line. * } * @return array|WP_Error Draft details on success. */ public static function create_draft( $args ) { $topic = isset( $args['topic'] ) ? trim( (string) $args['topic'] ) : ''; if ( '' === $topic ) { return new WP_Error( 'acsagen_no_topic', __( 'Tell the plugin what the article should be about first.', 'acs-article-generator' ) ); } $article = self::write( $args ); // The reply ran past the output limit. The same request will always do // it again, so retry once with a shorter target. if ( is_wp_error( $article ) && ACSAGEN_Client::is_length_error( $article ) ) { $length = isset( $args['length'] ) ? (string) $args['length'] : 'standard'; $shorter = self::shorter_length( $length ); if ( $shorter !== $length ) { $retry_args = $args; $retry_args['length'] = $shorter; $retry_args['custom'] = ''; $article = self::write( $retry_args ); } } if ( is_wp_error( $article ) ) { return $article; } $draft = ACSAGEN_Draft::create( $article ); if ( is_wp_error( $draft ) ) { return $draft; } // Numbers written as the author's own experience that the author never // supplied. They are shown on the result screen and kept on the draft; // the article itself is left as it is. $numbers = ACSAGEN_Checks::unsupplied_numbers( (string) $article['content_html'], isset( $args['experience'] ) ? (string) $args['experience'] : '' ); if ( ! empty( $numbers ) ) { update_post_meta( $draft['post_id'], ACSAGEN_Checks::META_NUMBERS, $numbers ); } $draft['check_numbers'] = $numbers; update_option( 'acsagen_last_result', array( 'post_id' => $draft['post_id'], 'title' => $draft['title'], 'slug' => $draft['slug'], 'edit_url' => $draft['edit_url'], 'created' => current_time( 'mysql' ), ) ); return $draft; } /** * Ask the model for one article and check what comes back. * * @param array $args See create_draft(). * @return array|WP_Error */ private static function write( $args ) { $prompt = self::build_prompt( $args ); $raw = ACSAGEN_Client::generate( $prompt, true ); if ( is_wp_error( $raw ) ) { return $raw; } $data = ACSAGEN_Client::decode_json( $raw ); if ( is_wp_error( $data ) ) { return $data; } foreach ( array( 'title', 'meta_description', 'slug', 'content_html' ) as $key ) { if ( empty( $data[ $key ] ) || ! is_string( $data[ $key ] ) ) { return new WP_Error( 'acsagen_missing_field', sprintf( /* translators: %s: name of the missing field. */ __( 'The reply was missing the "%s" field. Please try again.', 'acs-article-generator' ), $key ) ); } } $guard = self::check_scale( (string) $data['content_html'] ); if ( is_wp_error( $guard ) ) { return $guard; } $links = self::parse_links( isset( $args['links'] ) ? (string) $args['links'] : '' ); if ( ! empty( $links ) ) { $data['content_html'] = self::apply_links( (string) $data['content_html'], $links ); } if ( ACSAGEN_Settings::get( 'fix_link_anchors', 1 ) ) { $data['content_html'] = ACSAGEN_Checks::fix_internal_anchors( (string) $data['content_html'] ); } return $data; } /** * Refuse a reply that cannot be a single article. * * A model that loses its place can repeat the same section hundreds of * times. Catching it here means nothing is written to the database. * * @param string $html Article body. * @return WP_Error|null */ private static function check_scale( $html ) { if ( mb_strlen( $html ) > self::MAX_CONTENT_CHARS ) { return new WP_Error( 'acsagen_oversized_article', __( 'The model returned far more text than any article should contain, so nothing was saved. Please try again.', 'acs-article-generator' ) ); } if ( preg_match( '/]*>.*?<\/h2>/su', $html, $matches ) ) { if ( substr_count( $html, $matches[0] ) >= 3 ) { return new WP_Error( 'acsagen_looping_article', __( 'The model repeated the same section over and over, so nothing was saved. Please try again.', 'acs-article-generator' ) ); } } return null; } /** * Build the writing prompt. * * @param array $args See create_draft(). * @return string */ private static function build_prompt( $args ) { $topic = isset( $args['topic'] ) ? (string) $args['topic'] : ''; $notes = isset( $args['notes'] ) ? trim( (string) $args['notes'] ) : ''; $experience = isset( $args['experience'] ) ? trim( (string) $args['experience'] ) : ''; $offer = isset( $args['offer'] ) ? trim( (string) $args['offer'] ) : ''; $length = isset( $args['length'] ) ? (string) $args['length'] : 'standard'; $custom = isset( $args['custom'] ) ? (string) $args['custom'] : ''; $language = ACSAGEN_Settings::language_for_prompt(); $tone = ACSAGEN_Tone::get(); $concept = (string) ACSAGEN_Settings::get( 'site_concept', '' ); $internal = ''; foreach ( self::existing_titles( 60 ) as $item ) { $internal .= '- [' . $item['title'] . '](' . $item['url'] . ")\n"; } $sections = array(); $sections[] = "## What the article is about\n" . $topic; if ( '' !== $concept ) { $sections[] = "## What this site is about\n" . $concept; } if ( '' !== $tone ) { $sections[] = "## House style of this site, which you must follow\n" . $tone; } if ( '' !== $offer ) { $sections[] = "## Product or offer to introduce\n" . 'Introduce "' . $offer . '" inside the article where it genuinely fits. ' . 'Do not open with it and do not repeat it in every section. ' . 'Be honest about what it does and does not do.'; } if ( '' !== $experience ) { $sections[] = "## First hand experience supplied by the author, to be woven into the article\n" . $experience . "\n" . 'Use this as the concrete, specific part of the article. Do not contradict it and do not invent extra details around it. ' . 'Every number, amount, duration or quantity that the article presents as the author\'s own experience must appear in this text. ' . 'A number that is not in it may only be given as general information, not in the first person, or left out.'; } $links = self::parse_links( isset( $args['links'] ) ? (string) $args['links'] : '' ); if ( ! empty( $links ) ) { $sections[] = self::links_prompt_section( $links ); } if ( '' !== $notes ) { $sections[] = "## Extra instructions from the author\n" . $notes; } if ( '' !== $internal ) { $sections[] = "## Posts on this site you may link to, choosing three to six that are actually relevant\n" . $internal; } $category_names = ACSAGEN_Checks::existing_category_names( 40 ); $category_list = ''; foreach ( $category_names as $category_name ) { $category_list .= '- ' . $category_name . "\n"; } if ( '' !== $category_list ) { $sections[] = "## Categories that already exist on this site, most used first\n" . $category_list . 'Choose the categories from this list and copy each name exactly as it is written here. ' . 'Do not translate, reword or invent category names.'; } $body = implode( "\n\n", $sections ); $length_instruction = self::length_instruction( $length, $custom ); return "You are an experienced SEO writer. Write one WordPress article and reply with JSON only.\n\n" . $body . "\n\n" . "---\n\n" . "## Reply format, JSON only, nothing before or after it\n" . "{\n" . " \"title\": \"the article title, written for search, with the main phrase in it\",\n" . " \"meta_description\": \"a meta description of at most 120 characters with the main phrase near the start\",\n" . " \"slug\": \"a-short-lowercase-english-slug-with-hyphens\",\n" . " \"tags\": [\"tag\", \"tag\", \"tag\"],\n" . " \"categories\": [\"category\"],\n" . " \"content_html\": \"the article body\"\n" . "}\n\n" . "## Rules for content_html\n" . "- Write it as WordPress block editor HTML, using wp:paragraph, wp:heading, wp:list and wp:table comments.\n" . "- Use h2 and h3 headings only. WordPress prints the h1 from the title.\n" . "- Length: {$length_instruction}\n" . "- Every heading must carry real substance: steps, numbers, examples, caveats. Do not pad.\n" . "- Put a short summary of what the reader will get near the top, and a conclusion at the end.\n" . "- Where you link to a post from the list above, use its exact URL.\n" . "- When you use a post title from the list above as anchor text, copy the title exactly as it is written there, in its original language. Never translate it.\n" . "- Do not include an h1, a table of contents, image placeholders, or any script or style tag.\n" . "- Do not claim first hand experience that was not supplied above.\n" . "- Numbers, amounts and durations written in the first person (I, my, we, or the equivalent in the article language) must come from the first hand experience above. Write any other number as general information without the first person, or leave it out.\n\n" . "## Language\n" . ( '' !== $category_list ? "Write the title, the meta description, the tags and the whole body in {$language}. " . 'The categories are copied from the list of existing categories exactly as written, whatever their language. ' : "Write the title, the meta description, the tags, the categories and the whole body in {$language}. " ) . "The slug stays lowercase English with hyphens.\n\n" . 'Reply with the JSON object and nothing else.'; } /** * Titles of published posts, newest first. * * @param int $limit How many to fetch. * @return array List of title and url pairs. */ private static function existing_titles( $limit ) { $query = new WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => max( 1, (int) $limit ), 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $titles = array(); foreach ( $query->posts as $post_id ) { $titles[] = array( 'title' => (string) get_the_title( $post_id ), 'url' => (string) get_permalink( $post_id ), ); } wp_reset_postdata(); return $titles; } // ----------------------------------------------------------------- // External links. // ----------------------------------------------------------------- /** * Read the external links box. * * One link per line. Fields after the URL are separated by a vertical bar * and may appear in any order: the anchor text, a rel value, or where in * the article the link belongs. * * @param string $input Raw text from the form. * @return array List of link definitions. */ public static function parse_links( $input ) { $links = array(); $lines = preg_split( '/\r\n|\r|\n/', (string) $input ); foreach ( (array) $lines as $line ) { $line = trim( (string) $line ); if ( '' === $line ) { continue; } $parts = array_map( 'trim', (array) preg_split( '/\s*\|\s*/u', $line ) ); $head = (string) array_shift( $parts ); if ( ! preg_match( '#https?://[^\s<>"\']+#u', $head, $matches ) ) { continue; } $url = esc_url_raw( rtrim( $matches[0], '.,)]}' ) ); if ( '' === $url || isset( $links[ $url ] ) ) { continue; } $link = array( 'url' => $url, 'anchor' => '', 'rel' => array(), 'position' => 'auto', ); foreach ( $parts as $part ) { if ( '' === $part ) { continue; } $rel = self::parse_rel( $part ); if ( null !== $rel ) { $link['rel'] = array_values( array_unique( array_merge( $link['rel'], $rel ) ) ); continue; } $position = self::parse_position( $part ); if ( null !== $position ) { $link['position'] = $position; continue; } if ( '' === $link['anchor'] ) { $link['anchor'] = mb_substr( wp_strip_all_tags( $part ), 0, 100 ); } } $links[ $url ] = $link; } return array_values( $links ); } /** * Read a rel value, or return null when the text is not a rel value. * * @param string $part One bar separated field. * @return array|null */ private static function parse_rel( $part ) { $text = strtolower( trim( $part ) ); $text = (string) preg_replace( '/^rel\s*[=:]\s*/u', '', $text ); $text = trim( $text, "\"' " ); if ( '' === $text ) { return null; } $rel = array(); foreach ( (array) preg_split( '/[\s,\/]+/u', $text ) as $token ) { if ( '' === $token ) { continue; } if ( in_array( $token, self::ALLOWED_REL, true ) ) { $rel[] = $token; continue; } if ( in_array( $token, array( 'dofollow', 'follow', 'noopener', 'noreferrer' ), true ) ) { continue; } return null; } return array_values( array_unique( $rel ) ); } /** * Read a position word, or return null when the text is not one. * * @param string $part One bar separated field. * @return string|null */ private static function parse_position( $part ) { $text = strtolower( trim( $part ) ); $text = (string) preg_replace( '/^position\s*[=:]\s*/u', '', $text ); $map = array( 'intro' => array( 'intro', 'top', 'start', 'first', 'beginning', 'opening' ), 'middle' => array( 'middle', 'mid', 'centre', 'center' ), 'end' => array( 'end', 'bottom', 'last', 'conclusion', 'summary' ), 'auto' => array( 'auto', 'any', 'anywhere' ), ); foreach ( $map as $position => $words ) { if ( in_array( $text, $words, true ) ) { return $position; } } return null; } /** * Describe the requested links to the model. * * @param array $links Link definitions. * @return string */ private static function links_prompt_section( $links ) { $labels = array( 'intro' => 'near the top, just after the first h2', 'middle' => 'around the middle of the article', 'end' => 'just before the closing section', 'auto' => 'wherever it fits most naturally', ); $lines = ''; $number = 0; foreach ( $links as $link ) { ++$number; $anchor = '' !== $link['anchor'] ? 'use exactly this anchor text: ' . $link['anchor'] : 'choose anchor text that fits the sentence'; $rel = 'noopener noreferrer' . ( ! empty( $link['rel'] ) ? ' ' . implode( ' ', $link['rel'] ) : '' ); $lines .= "{$number}. {$link['url']}\n"; $lines .= " - anchor text: {$anchor}\n"; $lines .= " - rel attribute, exactly: {$rel}\n"; $lines .= ' - place it ' . $labels[ $link['position'] ] . "\n"; } return "## External links that must appear in the article as clickable links\n" . $lines; } /** * Make sure the requested links really are in the article. * * Links the model wrote get the requested rel attribute. Links it forgot * are added as their own paragraph at the requested place. * * @param string $html Article body. * @param array $links Link definitions. * @return string */ public static function apply_links( $html, $links ) { foreach ( $links as $link ) { $url = isset( $link['url'] ) ? (string) $link['url'] : ''; if ( '' === $url ) { continue; } $base = rtrim( $url, '/' ); $pattern = '/]*?)\shref=(["\'])' . preg_quote( $base, '/' ) . '\/?\2([^>]*)>(.*?)<\/a>/isu'; $found = false; $replaced = preg_replace_callback( $pattern, function ( $matches ) use ( $link, &$found ) { $found = true; $existing = array(); if ( preg_match( '/\brel=(["\'])(.*?)\1/i', $matches[1] . ' ' . $matches[3], $rel_match ) ) { $existing = (array) preg_split( '/\s+/', strtolower( trim( $rel_match[2] ) ) ); } $inner = $matches[4]; if ( '' !== $link['anchor'] && false === strpos( $inner, '<' ) ) { $inner = esc_html( $link['anchor'] ); } return ACSAGEN_Writer::build_link( $link, $inner, $existing ); }, $html ); if ( null !== $replaced ) { $html = $replaced; } if ( ! $found ) { $block = "\n\n

" . self::build_link( $link ) . "

\n\n"; $html = self::insert_at( $html, $block, (string) $link['position'] ); } } return $html; } /** * Build one anchor tag with a whitelisted rel attribute. * * @param array $link Link definition. * @param string $inner Inner HTML, already escaped. * @param array $extra_rel rel values found on an existing tag. * @return string */ public static function build_link( $link, $inner = '', $extra_rel = array() ) { $rel = array_values( array_unique( array_merge( array( 'noopener', 'noreferrer' ), array_intersect( (array) $extra_rel, self::ALLOWED_REL ), isset( $link['rel'] ) ? (array) $link['rel'] : array() ) ) ); if ( '' === $inner ) { $inner = esc_html( '' !== $link['anchor'] ? $link['anchor'] : $link['url'] ); } return '' . $inner . ''; } /** * Insert a block near the top, the middle or the end of the article. * * @param string $html Article body. * @param string $block Block to insert. * @param string $position One of intro, middle, end or auto. * @return string */ private static function insert_at( $html, $block, $position ) { if ( 'auto' === $position ) { $position = 'end'; } $heads = array(); if ( preg_match_all( '/<\/h2>\s*(?:)?/iu', $html, $all, PREG_OFFSET_CAPTURE ) ) { $heads = $all[0]; } if ( 'intro' === $position ) { if ( ! empty( $heads ) ) { $at = $heads[0][1] + strlen( $heads[0][0] ); return substr( $html, 0, $at ) . $block . substr( $html, $at ); } return $block . $html; } if ( 'middle' === $position ) { if ( count( $heads ) >= 2 ) { $mid = $heads[ (int) floor( count( $heads ) / 2 ) ]; $at = $mid[1] + strlen( $mid[0] ); return substr( $html, 0, $at ) . $block . substr( $html, $at ); } return $html . $block; } if ( count( $heads ) >= 2 ) { $last = $heads[ count( $heads ) - 2 ]; $at = $last[1] + strlen( $last[0] ); return substr( $html, 0, $at ) . $block . substr( $html, $at ); } return $html . $block; } }