PHP Spinner Updated - Spin articles for SEO

Quite 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 nested version of this spinning code the other day so set about creating a more efficient version of my previous nested spin code and packed it up in a class wrapper so it would choose whether to use the nested spinner or the flat version dynamically.

Without further ado here is the new PHP Spinner code.

class Spinner
{
	# Detects whether to use the nested or flat version of the spinner (costs some speed)
	public static function detect($text, $seedPageName = true, $openingConstruct = '{{', $closingConstruct = '}}', $separator = '|')
	{
		if(preg_match('~'.$openingConstruct.'(?:(?!'.$closingConstruct.').)*'.$openingConstruct.'~s', $text))
		{
			return self::nested($text, $seedPageName, $openingConstruct, $closingConstruct, $separator);
		}
		else
		{
			return self::flat($text, $seedPageName, false, $openingConstruct, $closingConstruct, $separator);
		}
	}
 
	# The flat version does not allow nested spin blocks, but is much faster (~2x)
	public static function flat($text, $seedPageName = true, $calculate = false, $openingConstruct = '{{', $closingConstruct = '}}', $separator = '|')
	{
		# Choose whether to return the string or the number of permutations
		$return = 'text';
		if($calculate)
		{
			$permutations	= 1;
			$return			= 'permutations';
		}
 
		# If we have nothing to spin just exit (don't use a regexp)
		if(strpos($text, $openingConstruct) === false)
		{
			return $$return;
		}
 
		if(preg_match_all('!'.$openingConstruct.'(.*?)'.$closingConstruct.'!s', $text, $matches))
		{
			# Optional, always show a particular combination on the page
			self::checkSeed($seedPageName);
 
			$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
				$text = self::str_replace_first($find, $replace, $text);
			}
		}
 
		return $$return;
	}
 
	# The nested version allows nested spin blocks, but is slower
	public static function nested($text, $seedPageName = true, $openingConstruct = '{{', $closingConstruct = '}}', $separator = '|')
	{
		# If we have nothing to spin just exit (don't use a regexp)
		if(strpos($text, $openingConstruct) === false)
		{
			return $text;
		}
 
		# Find the first whole match
		if(preg_match('!'.$openingConstruct.'(.+?)'.$closingConstruct.'!s', $text, $matches))
		{
			# Optional, always show a particular combination on the page
			self::checkSeed($seedPageName);
 
			# Only take the last block
			if(($pos = mb_strrpos($matches[1], $openingConstruct)) !== false)
			{
				$matches[1] = mb_substr($matches[1], $pos + mb_strlen($openingConstruct));
			}
 
			# And spin it
			$parts	= explode($separator, $matches[1]);
			$text	= self::str_replace_first($openingConstruct.$matches[1].$closingConstruct, $parts[mt_rand(0, count($parts) - 1)], $text);
 
			# We need to continue until there is nothing left to spin
			return self::nested($text, $seedPageName, $openingConstruct, $closingConstruct, $separator);
		}
		else
		{
			# If we have nothing to spin just exit
			return $text;
		}
	}
 
	# Similar to str_replace, but only replaces the first instance of the needle
	private static 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(!empty($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;
	}
 
	private static function checkSeed($seedPageName)
	{
		# Don't do the check if we are using random seeds
		if($seedPageName)
		{
			if($seedPageName === true)
			{
				mt_srand(crc32($_SERVER['REQUEST_URI']));
			}
			elseif($seedPageName == 'every second')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y-m-d-H-i-s')));
			}
			elseif($seedPageName == 'every minute')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y-m-d-H-i')));
			}
			elseif($seedPageName == 'hourly' OR $seedPageName == 'every hour')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y-m-d-H')));
			}
			elseif($seedPageName == 'daily' OR $seedPageName == 'every day')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y-m-d')));
			}
			elseif($seedPageName == 'weekly' OR $seedPageName == 'every week')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y-W')));
			}
			elseif($seedPageName == 'monthly' OR $seedPageName == 'every month')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y-m')));
			}
			elseif($seedPageName == 'annually' OR $seedPageName == 'every year')
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].date('Y')));
			}
			elseif(preg_match('!every ([0-9.]+) seconds!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / $matches[1])));
			}
			elseif(preg_match('!every ([0-9.]+) minutes!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / ($matches[1] * 60))));
			}
			elseif(preg_match('!every ([0-9.]+) hours!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / ($matches[1] * 3600))));
			}
			elseif(preg_match('!every ([0-9.]+) days!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / ($matches[1] * 86400))));
			}
			elseif(preg_match('!every ([0-9.]+) weeks!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / ($matches[1] * 604800))));
			}
			elseif(preg_match('!every ([0-9.]+) months!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / ($matches[1] * 2620800))));
			}
			elseif(preg_match('!every ([0-9.]+) years!', $seedPageName, $matches))
			{
				mt_srand(crc32($_SERVER['REQUEST_URI'].floor(time() / ($matches[1] * 31449600))));
			}
			else
			{
				throw new Exception($seedPageName. ' Was not a valid spin time option!');
			}
		}
	}
}

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>';
 
for($i = 1; $i <= 5; $i++)
{
	echo Spinner::detect($string, false).'<br />';
    // or Spinner::flat($string, false).'<br />';
}
 
echo '</p>';

Which produces:

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

and it will work happily as a nested PHP spinner too:

$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 Spinner::detect($string, false).'<br />';
	// or Spinner::nested($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

Improvement

The spinner now allows seed page options to be strings such as 'daily', 'every 2.5 hours' or 'every 3 weeks' rather than just true / false so the spun text will automatically update every set period of time. See the checkSeed (bottom) function for allowed values.

Have fun spinning articles! Any comments, suggestions or improvements are welcome.