SEO Tools, my first Ruby on Rails application

So for the last two days I’ve completed my first RoR application that is actually used by somebody, my SEO colleagues to be more precise, their first wish was a script that would make it much easier to submit links to link directories, that and an article spinner.


Let’s start going through the code, to begin with I had to review my earlier piece in order to get going, I remembered very little of the startup phase, there are so many commands to be executed that you can’t possibly keep them in memory if you’re not working with RoR everyday.

The below code works but should in no way be regarded as best practices, in fact a real rubyist and/or rails master would probably be able to shorten it down a lot, or simply employ different solutions altogether, especially when it comes to the activerecord stuff. I post it for my own reference and as a help to beginners to solve certain problems that might arise.

It was harder than I thought to gather the information that enabled me to complete this first draft of an application, there seems to be a lot of implicit knowledge that doesn’t need mentioning in a lot of places, well guess what, noobs won’t understand a thing. Hopefully this tutorial can remedy the situation somewhat, despite its lack of perfect (or even good) Ruby and RoR use.

First the routing in config/routes.rb:

ActionController::Routing::Routes.draw do |map|
  map.resources :page_tags
  map.resources :tags
  map.connect 'spinner/spin_article', :controller => 'article_spinner', :action => 'spin_article'
  map.connect 'spinner/spin', :controller => 'article_spinner', :action => 'spin_string'
  map.connect 'pages/generate1', :controller => 'pages', :action => 'generate1'
  map.connect 'pages/generate2', :controller => 'pages', :action => 'generate2'
  map.connect 'pages/search', :controller => 'pages', :action => 'search'
  map.connect 'pages/search_result', :controller => 'pages', :action => 'search_result'
  map.resources :pages
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end

The custom lines are all needed to map non default urls to the correct controller and action. Without these pointers Rails won’t manage to route them correctly. Compare the above listing with your own routes.rb after you’re done with the scaffolding.

After you’re done with initiating, scaffolding and the rake db:migrate command you run the magic models script to generate the models (see the prior tutorial). For this application we want to store pages in the database, pages that we want to be able to tag and search for by tag.

With that in mind let’s start with the pages controller and view, this is what we see when we want to create a page:

<h1>New page</h1>
<% form_for(@page) do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <p>
    <%= f.label :url %><br />
    <%= f.text_field :url %>
  </p>
  <p>
    <%= f.label :next_page %><br />
    <%= f.text_field :page_id %>
  </p>
  <p>
    <%= f.label :fields %><br />
    <%= f.text_area :fields %>
  </p>
  <p>
    <%= f.label :captcha %><br />
    <%= f.text_field :captcha %>
  </p>
  <p>
    Tags (separate with commas):<br />
    <input type="text" name="tags" />
  </p>
  <p>
    <%= f.submit "Create" %>
  </p>
<% end %>
<%= link_to 'Back', pages_path %>

Note that extra field there, the comma separated tags field, this is how we handle it in the pages controller:

def handle_tags
    @page.page_tags = params[:tags].split(/\s*,\s*/).collect do |tag|
      PageTag.new({:tag => Tag.with_name(tag.strip), :page => @page})
    end
  end

  def create
    @page = Page.new(params[:page])
    self.handle_tags
    respond_to do |format|
      if @page.save
        flash[:notice] = 'Page was successfully created.'
        format.html { redirect_to(@page) }
        format.xml  { render :xml => @page, :status => :created, :location => @page }
      else
        format.html { render :action => "new" }
        format.xml  { render :xml => @page.errors, :status => :unprocessable_entity }
      end
    end
  end

The handle_tags method is my creation, the rest is created by the scaffolding script. To dig deeper we need to take a look at the tag model:

class Tag < ActiveRecord::Base
  has_many :page_tags, :class_name => 'PageTag', :foreign_key => :tag_id
  has_many :pages, :through => :page_tags
  validates_length_of :name, :allow_nil => true, :maximum => 255

  def self.with_name(tag)
    tg = self.find(:first, :conditions => "name = '#{tag}'")
    return tg if tg != nil
    tg = self.new(:name => tag)
    tg.save
    tg
  end

  def self.in_name(tags)
    self.find(:all, :conditions => "name IN('#{tags}')")
  end
end

The with_name method is a typical example where Rails probably already has some function to accomplish the same thing, however I was not able to find one. Anyway, if we already have a tag with the name in question we return it, otherwise we create one with that name. Everything but with_name and in_name here was created by the magic models script.

Note the use of self.method in the definitions, that’s the way to create static methods in Ruby. Now that we’re already looking at the models let’s take a look at the page model:

class Page < ActiveRecord::Base
  has_many :page_tags, :class_name => 'PageTag', :foreign_key => :page_id
  has_one :page, :class_name => 'Page'
  has_many :tags, :through => :page_tags
  validates_length_of :url, :allow_nil => true, :maximum => 255
  validates_numericality_of :captcha, :allow_nil => true, :only_integer => true
  validates_length_of :name, :allow_nil => true, :maximum => 255

  def clear_tags
    self.page_tags.each{|pt| pt.destroy}
  end

  def self.with_tag(tags)
    tags = tags.gsub(/\s*,\s*/, "','")
    self.find(:all, :joins => :tags, :conditions => ["tags.name IN('#{tags}')"])
  end

  def self.with_tags(tags)
    tag_arr = tags.split(/\s*,\s*/)
    and_arr = [tag_arr[0].strip]
    and_str = 'tags.name = ?'
    tag_arr.each do |t|
      and_str << ' OR tags.name = ?'
      and_arr << t.strip
    end
    and_arr.unshift(and_str)
    result = self.find(:all, :joins => :tags, :conditions => and_arr)
    result.delete_if{|p1|
      count = 0
      result.each{|p2| count += 1 if p2.id == p1.id}
      count < tag_arr.length
    }.uniq
  end
end

So we’ve got three custom methods here:

clear_tags: Will get rid of all the entries in the page_tags table which is our link table responsible for making the many to many relationship work. I created it because I couldn’t find out how to do this in an implicit way, the result is that when we are updating a page all links will first be removed and then recreated with the comma separated links we get from the update form.

with_tag: Is responsible for getting all pages belonging to a specific tag, regardless of whether they belong to other tags or not.

with_tags: This is an abomination on many levels, it exists because I couldn’t manage to create an SQL string that will fetch all pages belonging to ALL the requested tags, not just one of them. Apart from that the problem could probably have been solved by some nifty activerecord stuff I don’t know about. Anyway, it’s ugly, slow and works, for now the works part is all I need.

Let’s take a look at the search form:

<h1>Search</h1>
<% form_tag('search_result') do %>
  <p>
    Tags (separate with commas):<br />
    <%= text_field_tag 'tags' %><br />
  </p>
  <p>
    <%= label_tag 'Has All: ' %><br />
    <%= label_tag 'Yes: ' %><%= radio_button_tag 'has_all', 1 %>
    <%= label_tag 'No: ' %><%= radio_button_tag 'has_all', 0 %>
  </p>
  <p>
    <%= submit_tag "Show Result" %>
  </p>
<% end %>
<%= link_to 'Back', pages_path %>

Note the difference from the first HTML listing above, this is the “standalone” version of doing things, we don’t need to loop through @something to make it work when we use these _tag versions, great. So it seems we’re posting to pages/search_result, let’s look at it:

def get_pages
    if params[:tags].length > 0
      params[:has_all] == '0' ? Page.with_tag(params[:tags]) : Page.with_tags(params[:tags])
    else
      Page.find(:all)
    end
  end

  def search_result
    @pages = self.get_pages
    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render :xml => @pages }
    end
  end

So this is the code that is using the methods we went through above, if we have checked Yes in the has all radio group we use with_tags, otherwise with_tag, if none has been checked we simply return everything. The search result HTML was copied from the index.html.erb file that the scaffolding created, not much to say about that one.

Let’s move on to the very heart of the application, the ability to generate a lot of iMacros with the help of the pages and some input, we start with the generate form:

<h1>Search</h1>
<% form_tag('generate2') do %>
  <p>
    Tags (separate with commas):<br />
    <%= text_field_tag 'tags' %><br />
  </p>
  <p>
    <%= label_tag 'Has All: ' %><br />
    <%= label_tag 'Yes: ' %><%= radio_button_tag 'has_all', 1 %>
    <%= label_tag 'No: ' %><%= radio_button_tag 'has_all', 0 %>
  </p>
  <p>
    Info:<br />
    <%= text_area_tag 'info', nil, :rows => 25, :cols => 80 %><br />
  </p>
  <p>
    <%= submit_tag "Generate iMacros" %>
  </p>
<% end %>
<%= link_to 'Back', pages_path %>

So, it looks basically like the search form with the exception of that added textarea. The area will contain info of the following form:
fieldname1::some data we want to put in field1#fieldname2::and some more info

It will be matched with info looking like this that we’ve already added in each page when we create them:
fieldname1:text#fieldname2:select#fieldname3:textarea and so on.

Let’s look at the magic:

def create_macrofile(j)
    cfile = File.new("macros/seotools#{j}.iim", File::CREAT|File::TRUNC|File::RDWR)
    cfile.write(@out)
    cfile.close
    @out = ''
  end

  def generate2
    require 'article_spinner.rb'
    require 'fileutils'
    
    `rm -rf "macros"`
    `mkdir "macros"`

    info_hsh = {}
    params[:info].split("#").compact.each do |i|
      info = i.split('::')
      info_hsh[ info[0].strip ] = info[1].strip
    end

    @out = ''
    i = 1
    j = 1
    self.get_pages.each do |p|
      out = "TAB T=#{i}\nURL GOTO=#{p.url}\n"
      p.fields.split("#").compact.each do |f|
        f_info = f.split(':')
        content = ArticleSpinner.spin_random(info_hsh[ f_info[0].strip ]).gsub(" ", "<SP>").gsub("\n", "<BR>")
        content = "%" + content if content.match(/^\d*$/)
        type = f_info[1].strip == 'text' ? "INPUT:TEXT" : f_info[1].strip.upcase
        out += "TAG POS=1 TYPE=#{type} ATTR=NAME:#{f_info[0].strip} CONTENT=#{content}\n"
      end
      
      i += 1
      
      if i == 10
        i = 1
        self.create_macrofile(j)
        j += 1
      else
        out += "TAB OPEN\n"
      end
      @out += out
    end
    self.create_macrofile(j)
    `rm "public/downloads/macros.zip"`
    `zip -r "public/downloads/macros.zip" "macros"`
    send_file "public/downloads/macros.zip"
  end

The interesting stuff here is send_file at the end which is the RoR way of creating a download. And then there are the back ticks which apparently executes the command they contain, here we create a zip file of the folder we’ve just stored the macros in.

As you can see there is an ArticleSpinner in effect here, let’s take a look at it:

class ArticleSpinner
  def initialize
    
  end

  def self.spin_random(str)
    str.gsub(/\{\{(.+?)\}\}/) {|m|
      arr = m.gsub('{{', '').gsub('}}', '').split('|')
      arr[ rand(arr.length) ]
    }
  end
end

So we’re not permutating here, only randomly selecting alternatives in strings looking like this:
Hello, my name is {{Carl|Hank}} I’m a {{programmer|plumber|asshole}}.

That leaves us with the standalone article spinner requirement, for that we have the article spinner controller:

class ArticleSpinnerController < ApplicationController
  protect_from_forgery :except => :spin_string

  def initialize
    
  end

  def spin_string
    require 'article_spinner.rb'
    render :text => ArticleSpinner.spin_random(params[:str])
  end

  def spin_article

  end
end

Note two important things here, first the protect_from_forgery invocation which will allow us to run spin_string without having the security key present in the header information, the reason for this is that spin_string will be called through Ajax. The jQuery snippet doing the post isn’t sending the security key so we make sure we don’t need it.

The second thing is the render :text text call, in this case the receiving JavaScript won’t need any HTML so it’s just enough to output the resultant string directly without involving any views.

Let’s take a look a the HTML:

<script>
  $(document).ready(function(){
    $("#spin_exec").click(function(){
      $.post('spin', {str: $("#to_spin").val()}, function(ret){
        $("#spun").html(ret);
      });
    });
  });
</script>
<p>
  To Spin:<br />
  <%= text_area_tag 'to_spin', nil, :rows => 25, :cols => 80 %><br />
</p>
<%= submit_tag "Spin", :id => 'spin_exec' %>
<p>
  Spun:<br />
  <%= text_area_tag 'spun', nil, :rows => 25, :cols => 80 %><br />
</p>
<%= link_to 'Back', pages_path %>

Yes we’re using jQuery in a raw fashion, I’m not into this stuff deep enough to be using jRails and a lot of helper functions, raw JS will do for now. That’s why the application.html.erb layout looks like this:

<html>
  <head>
    <%= javascript_include_tag 'jquery.js' %>
  </head>
  <body>
    <%= yield :layout %>
  </body>
</html>

Note the explicit inclusion of jQuery there.

Sure I could’ve done this thing in JS exclusively, the spinning algorithm is easy enough, however, I wanted to learn how to manage Ajax calls in RoR, and eventually I did.

Finally, the main menu:

<h1>SEO Tools</h1>
<br />
<%= link_to 'New page', new_page_path %>
<br />
<%= link_to 'Search page', :controller => "pages", :action => "search" %>
<br />
<%= link_to 'Generate iMacro code', :controller => "pages", :action => "generate1" %>
<br />
<%= link_to 'Spin article', :controller => "article_spinner", :action => "spin_article" %>

Note the different types of arguments to link_to, I never managed to make it work like in the first link to “New Page” so I ended up doing it in some kind of old fashioned way.

I spent roughly one day just doing research, and one day working with the actual problems, getting info on Rails specific stuff is harder than for instance with PHP, anyway, once one gets into it the going is really quick. This is way faster and easier than clunky PHP imitations, comparing for instance Symphony with RoR makes me smile.

The SEO guys are happy now anyway, they will be saving a lot of work with this one. I’m glad they only see the outside, the shiny exterior.

Related Posts

Tags: , , , , ,