Spin Text For SEO – A PHP Spinner

Update: A new version of this code is available on the Updated PHP Spinner page.

I recently had an SEO expert give us a few hours of his time to provide some suggestions to compliment our SEO strategy. One of the techniques that he introduced I was so impressed with (due to it’s utter simplicity) that I am kicking myself for not thinking of it before! Basically, if you have ‘doorway’ pages into your site (e.g. you have pages for ‘Cambridge Widgets’ and ‘Preston Widgets’ and alike) and want them to be dynamically generated, from the same content, but not to suffer horrible duplicate content penalties, you can use a ‘spinning’ function to generate contextually similar, different, content. The original concept is here, but I have enhanced it a bit to better suit my needs.

The original function had a few issues which made it unsuitable for my use, but a train journey home after our meeting provided me with ample time to enhance it for my needs. Basically I needed it to be:

  • either pseudo random or predicable (always the same on a per page basis) without including a block of code outside the function
  • able to include curly braces without spinning – just make the function require two braces, not one (i.e. {{spin|me}} )
  • able to use the same ‘spin block’ (i.e. {{phrase 1|phrase 2|phrase 3}}) multiple times, but treat each one differently
  • able to calculate the number of permutations that I was passing in (to ensure I was waaaay over the number of pages I was driving from one set of text)

None of these were difficult to add and I eventually ended up with two functions (the spin function and one to replace only the first instance of a string) for the job:

function spin($string, $seedPageName = true, $calculate = false, $openingConstruct = '{{', $closingConstruct = '}}', $separator = '|')
{
    # Choose whether to return the string or the number of permutations
    $return = 'string';
    if($calculate)
    {
        $permutations   = 1;
        $return         = 'permutations';
    }

    # If we have nothing to spin just exit (don't use a regexp)
    if(strpos($string, $openingConstruct) === false)
    {
        return $$return;
    }
   
    if(preg_match_all('!'.$openingConstruct.'(.*?)'.$closingConstruct.'!s', $string, $matches))
    {
        # Optional, always show a particular combination on the page
        if($seedPageName)
        {
            mt_srand(crc32($_SERVER['REQUEST_URI']));
        }

        $find       = array();
        $replace    = array();

        foreach($matches[0] as $key => $match)
        {
            $choices = explode($separator, $matches[1][$key]);

            if($calculate)
            {
                $permutations *= count($choices);
            }
            else
            {
                $find[]     = $match;
                $replace[]  = $choices[mt_rand(0, count($choices) - 1)];
            }
        }

        if(!$calculate)
        {
            # Ensure multiple instances of the same spinning combinations will spin differently
            $string = str_replace_first($find, $replace, $string);
        }
    }

    return $$return;
}

# Similar to str_replace, but only replaces the first instance of the needle
function str_replace_first($find, $replace, $string)
{
    # Ensure we are dealing with arrays
    if(!is_array($find))
    {
        $find = array($find);
    }

    if(!is_array($replace))
    {
        $replace = array($replace);
    }

    foreach($find as $key => $value)
    {
        if(($pos = mb_strpos($string, $value)) !== false)
        {
            # If we have no replacement make it empty
            if(!isset($replace[$key]))
            {
                $replace[$key] = '';
            }

            $string = mb_substr($string, 0, $pos).$replace[$key].mb_substr($string, $pos + mb_strlen($value));
        }
    }

    return $string;
}

And an example:

$string = '{{The|A}} {{quick|speedy|fast}} {{brown|black|red}} {{fox|wolf}} {{jumped|bounded|hopped|skipped}} over the {{lazy|tired}} {{dog|hound}}';

echo '<p><b>'.spin($string, false, true).'</b> permutations...</p><p>';

for($i = 1; $i <= 5; $i++)
{
    echo spin($string, false).'<br />';
}

echo '</p>';

Which produces:

576 permutations…

The speedy black wolf bounded over the lazy hound
A speedy brown fox skipped over the tired hound
The quick red wolf bounded over the lazy hound
The fast brown fox hopped over the tired dog
The speedy brown fox jumped over the tired dog

I’m sure that it isn’t perfect, but perhaps it will provide inspiration to someone else, like it did for me!

Update

Due to a request for nested brackets I have produced another (very different) version of this system that allows spin block nesting. It’s a bit rough and ready (because I have limited time at the minute) and requires an extra function to run (strpos_all). The cost of this enhancement is that it no longer calculates permutations (because that is pretty difficult and I’m lazy!), but I’m sure it’s possible if you really want to add it… Just remember when using this that nested spin blocks provide far less permutations than separate ones!

function spin($string, $seedPageName = true, $openingConstruct = '{{', $closingConstruct = '}}', $separator = '|')
{
    # If we have nothing to spin just exit
    if(strpos($string, $openingConstruct) === false)
    {
        return $string;
    }

    # Find all positions of the starting and opening braces
    $startPositions = strpos_all($string, $openingConstruct);
    $endPositions   = strpos_all($string, $closingConstruct);

    # There must be the same number of opening constructs to closing ones
    if($startPositions === false OR count($startPositions) !== count($endPositions))
    {
        return $string;
    }

    # Optional, always show a particular combination on the page
    if($seedPageName)
    {
        mt_srand(crc32($_SERVER['REQUEST_URI']));
    }

    # Might as well calculate these once
    $openingConstructLength = mb_strlen($openingConstruct);
    $closingConstructLength = mb_strlen($closingConstruct);

    # Organise the starting and opening values into a simple array showing orders
    foreach($startPositions as $pos)
    {
        $order[$pos] = 'open';
    }
    foreach($endPositions as $pos)
    {
        $order[$pos] = 'close';
    }
    ksort($order);

    # Go through the positions to get the depths
    $depth = 0;
    $chunk = 0;
    foreach($order as $position => $state)
    {
        if($state == 'open')
        {
            $depth++;
            $history[] = $position;
        }
        else
        {
            $lastPosition   = end($history);
            $lastKey        = key($history);
            unset($history[$lastKey]);

            $store[$depth][] = mb_substr($string, $lastPosition + $openingConstructLength, $position - $lastPosition - $closingConstructLength);
            $depth--;
        }
    }
    krsort($store);

    # Remove the old array and make sure we know what the original state of the top level spin blocks was
    unset($order);
    $original = $store[1];

    # Move through all elements and spin them
    foreach($store as $depth => $values)
    {
        foreach($values as $key => $spin)
        {
            # Get the choices
            $choices = explode($separator, $store[$depth][$key]);
            $replace = $choices[mt_rand(0, count($choices) - 1)];

            # Move down to the lower levels
            $level = $depth;
            while($level > 0)
            {
                foreach($store[$level] as $k => $v)
                {
                    $find = $openingConstruct.$store[$depth][$key].$closingConstruct;
                    if($level == 1 AND $depth == 1)
                    {
                        $find = $store[$depth][$key];
                    }
                    $store[$level][$k] = str_replace_first($find, $replace, $store[$level][$k]);
                }
                $level--;
            }
        }
    }

    # Put the very lowest level back into the original string
    foreach($original as $key => $value)
    {
        $string = str_replace_first($openingConstruct.$value.$closingConstruct, $store[1][$key], $string);
    }

    return $string;
}

# Similar to str_replace, but only replaces the first instance of the needle
function str_replace_first($find, $replace, $string)
{
    # Ensure we are dealing with arrays
    if(!is_array($find))
    {
        $find = array($find);
    }

    if(!is_array($replace))
    {
        $replace = array($replace);
    }

    foreach($find as $key => $value)
    {
        if(($pos = mb_strpos($string, $value)) !== false)
        {
            # If we have no replacement make it empty
            if(!isset($replace[$key]))
            {
                $replace[$key] = '';
            }

            $string = mb_substr($string, 0, $pos).$replace[$key].mb_substr($string, $pos + mb_strlen($value));
        }
    }

    return $string;
}

# Finds all instances of a needle in the haystack and returns the array
function strpos_all($haystack, $needle)
{
    $offset = 0;
    $i      = 0;
    $return = false;
   
    while(is_integer($i))
    {  
        $i = mb_strpos($haystack, $needle, $offset);
       
        if(is_integer($i))
        {
            $return[]   = $i;
            $offset     = $i + mb_strlen($needle);
        }
    }

    return $return;
}

And an example:

$string = '{{A {{simple|basic}} example|An uncomplicated scenario|The {{simplest|trivial|fundamental|rudimentary}} case|My {{test|invest{{igative|igation}}}} case}} to illustrate the {{function|problem}}';

echo '<p>';

for($i = 1; $i <= 5; $i++)
{
    echo spin($string, false).'<br />';
}

echo '</p>';

Which produces:

A basic example to illustrate the function
My test case to illustrate the problem
The rudimentary case to illustrate the function
An uncomplicated scenario to illustrate the problem
The fundamental case to illustrate the problem

As before I’m sure that this one isn’t perfect, but perhaps it will provide inspiration to someone else. All corrections / comments are welcome.

31 Responses to Spin Text For SEO – A PHP Spinner

  1. croaker says:

    Nice improvement, I like it better than the original though I tweaked mine back to just using single brackets (‘{‘,’}'). Next improvement is to allow for nested brackets

  2. Zack Katz says:

    Hi, great code! I was wondering if you could update it to work with nested brackets…that would be IDEAL. Thanks!

  3. Paul Norman says:

    Zack Katz :

    Hi, great code! I was wondering if you could update it to work with nested brackets…that would be IDEAL. Thanks!

    Hi Zack, thanks. I have put together an updated version of this for you. It’s a bit of a rush job, but should give you a base to work from. Enjoy!

  4. Zack Katz says:

    Hi Paul,
    Thanks a lot, I look forward to trying out this code. I’ve been using the original code with

    ob_start()

    , which has worked very well.

    Thanks for your help!

  5. Peter says:

    Great addition of the nested brackets. I have been trying to figure that one out (looking into recussion and such).

    How how do you handle apostrophes or quotes? ha…

  6. Peter says:

    So yeah, I figured it out the apostrophe or quote thign… use heredocs instead of quotes.

    http://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc

    Works great!!

  7. Paul Norman says:

    Peter :

    So yeah, I figured it out the apostrophe or quote thign… use heredocs instead of quotes.

    Or you can just escape the quotes:

    $string = 'My string with "double" and \'single\' quotes...';
  8. Marcus says:

    Well done, great effort!

  9. Vince says:

    There is a PHP recursive pattern incantation that might be useful: http://php.net/manual/en/regexp.reference.recursive.php

  10. Trust says:

    The nested version is brilliant!
    thank you very much

  11. Paul Norman says:

    Vince :

    There is a PHP recursive pattern incantation that might be useful: http://php.net/manual/en/regexp.reference.recursive.php

    You’re right, I should, but personally I’ve never had any luck with the recursive functions – I always just end up running them too many times for it to be efficient. If you’d like to provide a better version please do and you can have the credit for it here!

  12. [...] zur Generierung von seocontent (was für ein beknacktes Wort) ist auf dem blog von Paul Norman (link) zu finden, wobei sein script auf einem script von Alex Poole (link) basiert. Wie das script in [...]

  13. Jackson Hill says:

    Some seo experts charge top dollars for website optimization

  14. [...] Bunu Türkçe metinler haline getirip botlarınıza eklerseniz ve kafanızı biraz çalıştırısanız paraya para demez kendi adınızı koyabilirsiniz. Elbetteki bunu ben yapmadım ama kullandım. Kaynak [...]

  15. alex says:

    Awesome code.

    Is there anyway to modify it so it only spins the content once a day or once a week?

  16. Katie (South France Immobilier) Radisson says:

    Hi Paul

    This looks really useful but I need some help as it seems to get stuck occasionally using this double nested format:

    {{Quick Hints for Learning French|Learning French to Quick and Effective Way|How to Learn French in Simple Steps That Will Take You From Beginner to Master|The Best Way To Learn French is Now Available and Easier Than Ever|Stop Procrastinating and Start Learning French With These Quick Hints}|{Handy Hints to Help You Learn French|Ways to Learn French Easily|Tips to Learn French Faster|Learning French Doesn’t Have to Be Hard|Simple Tricks to Learn French Quickly}|{Do You Need Help Learning French?|Sending Out An SOS for Learning French?|Desperately Seeking Help for Learning French?|Learning French? Has It Been A Hard Days Night?|Learning French and Can’t Get No Satisfaction?}|{Hints to Help You Learn French|A Strategy to Learn French|Learning French In a Nutshell|Learning French Is Fun and Easy|Foolproof Method to Learning French}|{Hints and Tips For Learning French|The Best Methods for Learning French|How to Make Your Study of French Easier and More Fun|Learning French Is Not as Hard as You Think|Quick Tips to Make Learning French Easier}}

    Any idea why it does that?

    Thanks

    Katie

  17. Paul Norman says:

    @alex rather than modifying the class I would suggest caching the result for a day or week to achieve the effect that you are looking for. That is, build it out to a flat file or save it in the database if the filemtime() or database timestamp is above a certain value.

    @Katie you need double braces everywhere you are going to spin something, you have double ones at the start and single ones everywhere else. I think that’ll fix it. Look at my last example for clarification.

  18. [...] a while ago I wrote a post on spinning text using PHP to create lots of fresh content from one single block of marked up text. I actually had use for a [...]

  19. Adam Online says:

    Hi Paul, i read your original entry and commented and then found this entry. Thanks for the script because i have a php minisite i want to use it on.

    ANYWAYS, wandering if you’d be so kind to explain how you could go about adjusting the code to where the content spins once every so often…such as once every 28 hours or once every 36 hours.

    I think this would be useful to make different pages update at different times, and also to avoid updating non stop and throwing up flags. Thanks

  20. Adam Online says:

    Oops, sorry, you seemed to answer that question with the cache. Could you be so kind to point to a page that explains how to do this kind of cache? The site im looking to do this on is simple php files with no database or anything. Im simply a newb with raw php because i typically use wordpress wordpress and joomla that seem to have plugins for most functionalities.

  21. Paul Norman says:

    If you view the updated spinner I’ve extended it to allow this kind of behaviour. All you need to do is pass in your seed value (e.g. ‘every 28 hours’) with the spin function (used to be only true or false) and the text will update only after the specified period. Enjoy!

  22. infos says:

    Thanks for ur Informations they where verey helpful

  23. peter says:

    Your code is ugly as ****, learn to use regex.

  24. Paul Norman says:

    @peter You could read the first paragraph and see the updated code (not the rushed nested spinner in this article), OR you could choose to share your own elegant solution…

    Sadly you have opted for neither and simply decided to waste both our of time to be rude and unhelpful. Yet another useful Internet contributer, I’m so very glad you exist!

  25. Trevor Schuil says:

    Hi Paul, I hope that this comment finds you in good health, I stumbled on this through my search for a php article spinner for my site and realized that it is extremely difficult to find a free one. To get to the point I would like to develop your functions to suit my site. I am working on it right away as i desperately need this so a very big thank you. I am offering my development to this post as a thanks man. I am going to include some basic database storage and administration such as a thesaurus with preferences.If anyone has any suggestions or would like the source just visit my site or mail me.

  26. Paul Norman says:

    Hi Trevor,

    I strongly suggest you use the updated version of this post in any projects. If you get anything going with a thesaurus etc I would be very interested to see it and will provide you links / an article for anything you do if you wish to share / promote it. This is something I have meant to do myself and have my own basic WYSIWYG editor (based on a content editable div rather than iframe) that I would like to plug it into ultimately – it’s just a dev time problem! My suggestions would be to think very carefully about the database structure of, and required AJAX calls to, a thesaurus as they are not trivial. Good luck!

    Cheers,

    Paul

  27. T Nathan says:

    looking for someone that can help me this clearer that I can use it. will pay to understand how to apply this to my site

    thx
    T. Nathan

  28. Paul Norman says:

    First up you should use the updated spinner, not this one. Direct usage of the spinner is very simple and explained here / there, but I’m going to assume that you need help with the generation of the SEO pages on which to use your spun text via the spinner in your CMS… This is far less simple and requires you to understand PHP or Apache to a reasonable level for the page generation and then PHP / MySQL to dynamically retrieve the content. I must make it clear that I am not a freelancer and do not do this kind of work, so your best bet would be freelancers.net, forums or better a digital agency for this.

    Looking at your current site it is already constructed in Joomla (a CMS system) and runs an e-commerce plugin called VirtueMart for shop management. I don’t know anything about either, but I can observe that the combo uses an Apache rewrite rule (everything is sent to index.php) with a $_GET variable structure (?page=shop.browse&category_id=XY) to allow the CMS to look up the pages in a database (likely MySQL). Anything you add on would either require a new re-write rule (in your .htaccess file) and a totally new PHP page (easy for any developer, though your style etc would have to be duplicated and maintained separately) or a new bespoke Joomla extension to implement your requirements (recommended, but more expensive).

    For this reason I suggest looking for a Joomla ‘expert’ to carry out the work, perhaps beginning on Joomla specific forums.

    I would also raise that you have also already added unique text about all of your products (which are not very similar) so you really have to determine what benefit an extra set of spun pages (which require totally generic text) would bring you…

    Good luck!

  29. payday says:

    Great! That worked, Thanks! =)

  30. how to seo says:

    I just could not depart your web site before suggesting that I extremely loved the usual info a person provide for your guests? Is going to be again continuously to investigate cross-check new posts

  31. Angelo Bonavera says:

    Great spinner. nested and everything. The spinning programs are expensive to and this is free… very affordable websites utility script

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>