Friday, October 17, 2008

PowerShell and StackOverflow.com

Not long ago, I created an account on StackOverflow.com, Jeff Attwood and Joel Spolsky's new question and answer site for programmers. Asking a good question or providing a good answer will result in the other members of the communtity responding by voting for your question or answer. Users can also vote down questions or answers they don't think are useful or correct. Each positive vote increases the user's reputation, which is used to determine how much a user is "trusted" on the site. More "trust" allows the user to have more control over what is on the site; it determines what aspects of the site they can edit, wiki-style. Currently, I only have enough reputation to edit the tags associated with a question, but it is possible for a user to have enough reputation that they can edit other user's questions and even close them.
Something that was missing from the site when I signed up was the ability to figure out where your reputation points were coming from. If you had several questions or answers, it was hard to keep track of which ones were contributing to your reputation score. Another user of the site noticed this deficiency before I did, and created a Python script that will tell you what has changed with your account since the last time your ran the script. The script requires Python and SQLite. This seemed like something could be done pretty easily using PowerShell, so I put together a similar script that does pretty much the same thing:


param ( [int] $userNumber = 3950 )

[Reflection.Assembly]::LoadWithPartialName( "System.Web" ) | Out-Null

$webClient = New-Object Net.WebClient

$profile = $webClient.DownloadString( 
    "http://stackoverflow.com/users/$userNumber/myProfile.html" )

$userRegex = 'User (?<User>.*?) - Stack Overflow'
$user = [regex]::Match( $profile, $userRegex ).Groups[ "User" ].Value

$reputationRegex = '<div[^>]+class="summarycount">' + 
                   '[^\d]+(?<Reputation>[,\d]+)</div>' + 
                   '[^<]*<div[^>]+>Reputation'

$reputation = [regex]::Match( 
    $profile, $reputationRegex ).Groups[ "Reputation" ].Value -replace ","

$badgeRegex = '<div[^>]+class="summarycount"[^>]*>' + 
              '[^\d]+(?<Badges>\d+)</div>[^<]*</td>' + 
              '[^<]*<td[^>]*>[^<]*<h1[^>]*>Badges'

$badges = [regex]::Match( 
    $profile, $badgeRegex ).Groups[ "Badges" ].Value -replace ","

$profileXmlPath = Join-Path ( Get-Location ) "Profile_$userNumber.xml"

if ( !Test-Path $profileXmlPath ) )
{
    Out-File -FilePath $profileXmlPath -InputObject @"
<Profile>
  <User>$user</User>
  <Reputation>$reputation</Reputation>
  <Badges>$badges</Badges>
  <Questions />
</Profile>    
"@
}

[xml]$profileXml = Get-Content $profileXmlPath

$existingQuestions = $profileXml.Profile.Questions.Clone()

if ( $profileXml.Profile[ "Questions" ].Question )
{
    $profileXml.Profile.Questions.RemoveAll()
}

function Process-Questions( [string] $questionRegex, [string] $questionType )
{
    $questionChangeHash = @{}

    foreach ( $questionMatch in [regex]::Matches( 
        $profile, $questionRegex, 'SingleLine' ) )
    {
        $id = $questionMatch.Groups[ "ID" ].Value
        $question = $questionMatch.Groups[ "Question" ].Value
        $votes = $questionMatch.Groups[ "Votes" ].Value

        $questionNode = $existingQuestions.Question | 
            Where-Object { $_.ID -eq $id }

        if ( $questionNode )
        {
            if ( [int]$votes -gt $questionNode.Votes )
            {
                $questionChangeHash$id ] = 
                    "+$( [int]$votes - $questionNode.Votes )"
            }
            elseif ( [int]$votes -lt $questionNode.Votes )
            {
                $questionChangeHash$id ] = [int]$votes - $questionNode.Votes
            }

            $questionNode.Votes = $votes

            $profileXml.Profile[ "Questions" ].AppendChild( 
                $questionNode ) | Out-Null
        }
        else
        {
            & {
                $script:questionNode = $profileXml.CreateElement( "Question" )

                $script:questionNode.AppendChild( 
                    $profileXml.CreateElement( "ID" ) )
                $script:questionNode.ID = $id

                $script:questionNode.AppendChild( 
                    $profileXml.CreateElement( "Type" ) )
                $script:questionNode.Type = $questionType

                $script:questionNode.AppendChild( 
                    $profileXml.CreateElement( "Question" ) )
                $script:questionNode.Question = $question

                $script:questionNode.AppendChild( 
                    $profileXml.CreateElement( "Votes" ) )
                $script:questionNode.Votes = $votes

                $profileXml.Profile[ "Questions" ].AppendChild( 
                    $script:questionNode )
            } | Out-Null

            $questionChangeHash$id ] = "(New)"
        }
    }

    $screenWidth = $Host.UI.RawUI.WindowSize.Width
    $elipsis = "..."

    if ( $questionChangeHash.Keys.Count -gt 0 )
    {
        $profileXml.Profile.Questions.Question | 
            Where-Object { $_.Type -eq $questionType } | 
            Select-Object `
                @{ Name = "$( $questionType )s"
                   Expression = {
                      $question = 
                        [System.Web.HttpUtility]::HtmlDecode( $_.Question ) 
                      $questionMaxLength = 
                        $screenWidth - ( " Votes Change ".Length )

                      if ( $question.Length -gt $questionMaxLength )
                      {
                          $question = "{0}$elipsis" -f 
                              $question.SubString( 
                                0, $questionMaxLength - $elipsis.Length ) 
                      }

                      $question
                   } 
                }, 
                Votes, 
                @{ Name = "Change"
                   Expression = {
                       $questionChangeHash$_.ID ] 
                   } 
                } | Format-Table -AutoSize
    }
    else
    {
        $profileXml.Profile.Questions.Question | 
            Where-Object { $_.Type -eq $questionType } | 
            Select-Object `
                @{ Name = "$( $questionType )s"
                   Expression = { 
                      $question = 
                        [System.Web.HttpUtility]::HtmlDecode( $_.Question ) 
                      $questionMaxLength = 
                        $screenWidth - ( " Votes ".Length )

                      if ( $question.Length -gt $questionMaxLength )
                      {
                          $question = "{0}$elipsis" -f 
                              $question.SubString( 
                                0, $questionMaxLength - $elipsis.Length ) 
                      }

                      $question
                   } 
                }, 
                Votes | Format-Table -AutoSize
    }    
}

$questionRegex = '<div class="question-summary narrow"[^>]+>' + 
                 '.*?<div class="mini-counts">(?<Votes>[\d,-]+).*?' + 
                 '<div class="summary">[^<]*<h3>[^<]*' + 
                 '<a\s*href="/questions/(?<ID>\d+)[^>]+>(?<Question>[^<]+)'

Process-Questions $questionRegex "Question"

$answerRegex = '<div class="answer-summary">' + 
               '<a[^>]+><div class="answer-votes[^>]+>(?<Votes>[\d,-]+)' +
               '</div></a><div class="answer-link">' + 
               '<a\s*href="/questions/(?<ID>\d+)[^>]+>(?<Question>[^<]+)'

Process-Questions $answerRegex "Answer"

$reputationChange = ""
$badgeChange = ""

if ( [int]$reputation -gt $profileXml.Profile.Reputation )
{
    $reputationChange = 
        "+$( [int]$reputation - $profileXml.Profile.Reputation )"
}
elseif ( [int]$reputation -lt $profileXml.Profile.Reputation )
{
    $reputationChange = $reputation - $profileXml.Profile.Reputation
}

if ( [int]$badges -gt $profileXml.Profile.Badges )
{
    $badgeChange = "+$( [int]$badges - $profileXml.Profile.Badges )"
}
elseif ( [int]$badges -lt $profileXml.Profile.Badges )
{
    $badgeChange = [int]$badges - $profileXml.Profile.Badges
}

$profileXml.Profile.User = $user
$profileXml.Profile.Reputation = $reputation
$profileXml.Profile.Badges = $badges

$profileXml.Profile | 
    Select-Object User,
                  @{ Name = "Reputation"
                     Expression = {
                         if ( $reputationChange )
                         {
                             "{0} ({1})" -f 
                                $_.Reputation, $reputationChange 
                         }
                         else
                         {
                             $_.Reputation
                         }
                     } 
                  },
                  @{ Name = "Badges"
                     Expression = {
                         if ( $badgeChange )
                         {
                             "{0} ({1})" -f $_.Badges, $badgeChange 
                         }
                         else
                         {
                             $_.Badges
                         }
                     } 
                  } | Format-List

$profileXml.Save( $profileXmlPath )


The results are displayed in the typical PowerShell way:

PSH$ .\Get-StackOverflowReputation.ps1 3950

Answers Votes
------- -----
What is the one programming skill you have always wanted to master but haven't had time? 30
Is there a meaningful correlation between spelling and programming ability? 6
Factorial Algorithms in different languages 6
Modal popups - usability 6
How can I uninstall an application using PowerShell? 5
Function pointers in C - address operator "unnecessary" 5
Getting developers fired up about development 5
Rule you know you should follow but don't 4
"Hidden Secrets" of the Visual Studio .NET debugger? 4
C++ Restrict Template Function 4
Should a novice programmer spend time learning to write "desktop" applications these days,... 4
Parsing a log file with regular expressions 3
Shortcut for commenting CSS in VS 2008 3
How do I perform string operations on variables in a for loop? 2
Expose an event handler to VBScript users of my COM object 2
Where do "pure virtual function call" crashes come from? 2
Why do C# and VB have Generics? What benefit do they provide? Generics, FTW 2
regular expression to replace two (or more) consecutive characters by only one? 2
Anyone using a third-party Windows registry editor that they would recommend to others? 2
Transparent form on the desktop 1
Test if a Font is installed 1
Using what I've learned from stackoverflow. (HTML Scraper) 1
How to detect the presence of a default recording device in the system? 1
How do I add Debug Breakpoints to lines displayed in a "Find Results" window in Visual Studio 0
Regex Question - One or more spaces outside of a quote enclosed block of text 0




User : Jeff Hillman
Reputation : 790
Badges : 9


StackOverflow.com has recently added a feature on the user account page that provides some information about how the user's reputation has changed, but it still doesn't give you the detail that these scripts provide.

This is just another example to me of how easy PowerShell can make tasks like this. This isn't likely to be useful for a long time, but it was fun. PowerShell is fun.