PowerShell: Ninja Downloader (Modular File Downloading Utility)

PowerShell: Ninja Downloader (Modular File Downloading Utility)

Download Files With PowerShell Dynamically!

Knowing PowerShell can come in handy when you need to download files. Invoke-WebRequest is the command to get to know when working with web parsing, and obtaining downloads.

I've noticed, however, that different files show up as different content types, and parsing out the file name requires all sorts of voodoo. What if there was a way to use one tool that could utilize the power of PowerShell, and make downloading files a modular experience?

This tool, and blog post, are is inspired by folks asking me for help downloading files via PowerShell. I always appreciate feedback and questions, and this is exactly why!

In this post

Prerequisites

  • PowerShell 3.0+ 
  • Access to the internet

Ninja Downloader Overview

Ninja Downloader works by executing the main script (download.ps1), which takes the following parameters:

  • DownloadName
    • This is the name of the script you'd like to execute (use 'all' to execute all scripts)
    • Scripts are located in the .\scripts directory
    • Argument must omit the .ps1 extension of the script in the .\scripts directory
  • OutputType
    • This parameter let's you specify the output type, the default is none.
      • XML -> Export results as clixml
      • CSV -> Export results as a CSV file (default)
      • HTML -> Export results as an HMTL file
      • All -> Export results as all of the above
    • Output is exported to the .\output directory
  • DownloadFolder
    • This parameter allows you to specify a location to place the downloaded files
      • Folder will be created if it does not exist
    • If left empty the folder .\downloads will be used
  • UnZip
    • This parameter will look for zip archives after files are downloaded and attempt to extract them
      • Files extracted to .\downloads\fileName_HHmm-MMddyy\
  • ListOnly
    • This parameter (a switch) will give you a list of all possible names to use for DownloadName, as well as the paths to the scripts.

Downloading a File

There are several scripts included by default with tool. 

  • Ccleaner.ps1
  • Chrome.ps1
  • FireFox.ps1
  • Java.ps1
  • Skype.ps1
  • template.ps1 (never executed, this is a template for creating your own download script)

So how do we use them, then?

  1. Open PowerShell, and navigate to the root directory of the project/script.
  2. Run the following code:
$downloadResults = .\download.ps1 -DownloadName ccleaner

This will give us access to the results in $downloadResults.

downloadresults.PNG

You can see that the results we get include the name of the script executed, if it was executed successfully, any errors, and another object inside of this object named FileInfo.

FileInfo contains the file namepath to the file (full), any errors, and if we could verify it exists.

This attempt was successful, and our results echoed that! Let's take a look in the downloads folder just to be sure...

Awesome!

Downloading All Files

What if we wanted to download all the files via every script in the .\scripts folder?

  1. Open PowerShell, and navigate to the root directory.
  2. Run the following code:
$downloadResults = .\download.ps1 -DownloadName all -Verbose

This time we can see some of the output as it happens via the -Verbose switch.

Now let's take a look at $downloadResults:

For good measure, we'll also look at the downloads folder:

Alright! It worked.

Output Types

This script allows you output the results in various ways. All of the results will be time-stamped with the date and time.

CSV

To output results as a CSV, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType csv

After it runs, the results will be in the .\output folder.

csv.PNG

HTML

To output results as HTML, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType html

After it runs, the results will be in the .\output folder.

XML

To output results as XML, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType xml

After it runs, the results will be in the .\output folder.

All

To output results in all three formats, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType all

After it runs, the results will be in the .\output folder.

Creating Your Own Download Script

You can create your own script to use with the Ninja Downloader tool. The template provided is a working example of how Firefox is downloaded. The template is located in the .\scripts folder.

Template code:

#Template example (works for Firefox, adjust as needed for your download)

#Set this to the URL you'll be navigating to first
$navUrl    = 'https://www.mozilla.org/en-US/firefox/all/' 

#Text to match on, if applicable
$matchText = '.+windows.+64.+English.+\(US\)'

# IMPORTANT: This is the format of the object needed to be returned to the description
# Whichever way you get the information, you need to return an object with the following properties:
# DownloadName (string, file name)
# Content (byte array, file contents)
# Success (boolean)
# Error (string, any error received)
$downloadInfo = [PSCustomObject]@{

    DownloadName = ''
    Content      = ''
    Success      = $false
    Error        = ''  

} 

#Go to first page
Try {

    $downloadRequest = Invoke-WebRequest -Uri $navURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

}
Catch {

    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Look for urls that match
$downloadURL = $downloadRequest.Links | Where-Object {$_.Title -Match $matchText} | Select-Object -ExpandProperty href

#Go to matching URL, look for download file (keeping redirects at 0)
try {

    $downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

}
catch {
    
    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Get file info
$downloadFile = $downloadRequest.Headers.Location

#Parse file name, whichever way needed
if ($downloadRequest.Headers.Location) {
            
    $downloadInfo.DownloadName = $downloadFile.SubString($downloadFile.LastIndexOf('/')+1).Replace('%20',' ')

}  

#Switch out the StatusDescription, as applicable
Switch ($downloadRequest.StatusDescription) {

    'Found' {
                
        $downloadRequest = Invoke-WebRequest -Uri $downloadRequest.Headers.Location -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox 

    }

    default {

        $downloadInfo.Error = "Status description [$($downloadRequest.StatusDescription)] not handled!"
        
        return $downloadInfo

    }

}

#Switch out the proper content type for the file download
Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/x-msdos-program' {
                
        $downloadInfo.Content = $downloadRequest.Content
        $downloadInfo.Success = $true

        return $downloadInfo

    }
   
    Default {

        $downloadInfo.Error = "Content type [$($downloadRequest.BaseResponse.ContentType)] not handled!"
        
        return $downloadInfo

    }

}

What matters the most is that you return an object that has the following properties:

  • DownloadName
    • String value of the downloaded file name
  • Content
    • Byte array containing the actual file
  • Success
    • Boolean (true if the file was able to be downloaded)
  • Error
    • String representing any errors encountered

Example script creation:

For this example, let's download ElvUI (as we learned how to in detail, here).

First, I'll save the template as elvui.ps1

Then, based on our knowledge of web parsing (more here if you would like to learn more about Invoke-WebRequest), we can edit the template to reflect the correct web parsing needs and content type.

Full code for elvui.ps1

#Template example (works for Firefox, adjust as needed for your download)

#Set this to the URL you'll be navigating to first
$navUrl    = 'http://www.tukui.org/dl.php' 

# IMPORTANT: This is the format of the object needed to be returned to the description
# Whichever way you get the information, you need to return an object with the following properties:
# DownloadName (string, file name)
# Content (byte array, file contents)
# Success (boolean)
# Error (string, any error received)
$downloadInfo = [PSCustomObject]@{

    DownloadName = ''
    Content      = ''
    Success      = $false
    Error        = ''  

} 

#Go to first page
Try {

    $downloadRequest = Invoke-WebRequest -Uri $navUrl -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

}
Catch {

    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Look for urls that match
$downloadURL = ($downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}).href
$downloadInfo.DownloadName = $downloadURL.Substring($downloadURL.LastIndexOf('/')+1)

#Go to matching URL, look for download file (keeping redirects at 0)
try {
     
    $downloadRequest = Invoke-WebRequest -Uri $downloadURL 

}
catch {
    
    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Switch out the proper content type for the file download
Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/zip' {
                
        $downloadInfo.Content = $downloadRequest.Content
        $downloadInfo.Success = $true

        return $downloadInfo

    }
   
    Default {

        $downloadInfo.Error = "Content type [$($downloadRequest.BaseResponse.ContentType)] not handled!"
        
        return $downloadInfo

    }

}

Let's test it out!

.\download.ps1 -downloadName elvui

And there you have it, modular file downloading via PowerShell.

Github Repository

This project is available on Github!

Click here to go to the psNinjaDownloader repository.

You can download the contents as a ZIP file by going to 'Clone or download', and then selecting 'Download ZIP'.

If you download the code, and would like to run it, you'll need to unblock download.ps1 first.

To do this, right click download.ps1, and go to Properties, and check 'unblock', then click Apply/OK.

unblock.PNG

You'll also need to do this for all the scripts in the .\scripts folder

You can repeat the above step for every script in the .\scripts folderor we can use PowerShell to do it!

  1. In PowerShell, navigate to the .\scripts folder of psNinjaDownloader
  2. Run the following command:
Get-ChildItem | Unblock-File

Voila, you now have a working copy of the script!

What's Next?

  • Create your own download scripts, and test downloading things auto-magically.
  • Use this to download the latest version of tools as a schedule task
  • See the full help for the script by running: 
Get-Help .\download.ps1 -Full

If you have any feedback, questions, or issues, leave a comment here or contact me!

[top]

PowerShell Quick Tip: Using ValidateSet

PowerShell Quick Tip: Using ValidateSet

PowerShell Quick Tip: Using ValidateSet

Why Use ValidateSet?

ValidateSet is part of advanced parameters. It allows you to constrain the input of the parameter to a set of values. It also will autocomplete those options for you as you tab through them!

This can really come in handy if you only want to accept a specific set of options as input to the parameter. I've found it useful in the following scenarios:

  • Scripts that work with AD, and you'd like a specific set of options for a given parameters
  • Scripts that work with an API, and only a specific set of values are accepted
  • Scripts that are used in web parsing where you'd like to constrain the options for a specific parameter
  • You want to reduce the amount of logic in your script, and offload it to parameter validation (added bonus: error generation for you)

Using ValidateSet

Using ValidateSet is easy! You just add the following line above your parameter:

[ValidateSet('Green','Blue','Red')]

The above example will ensure the input to the parameter we create is either Green, Blue, or Red.

Here is a simple function I put together to demonstrate how this works:

function Write-Color {
    [cmdletbinding()]
    param(       
        [Parameter(Mandatory)]  
        [ValidateSet('Green','Blue','Red')]
        [string]
        $color,
        $message
    )

    Write-Host $message -ForegroundColor $color 

}

When a function is in memory in the ISE, and ValidateSet is used, it will actually give you a visual list of the available options!

With this function in memory, let's run these commands and see what happens:

Write-Color -color Blue -Message "Validate: Blue"
Write-Color -color Red -Message "Validate: Red"
Write-Color -color Green -Message "Validate: Green"

That worked!

What if we used a color that's not in the group specified?

Write-Color -color DarkBlue -message "Validate: DarkBlue"

The command will also auto-complete the options for you in the console if you specify -color and then hit tab.

Limitations

There are some limitations when doing this. 

  • If you set a default value to one outside the array of options, it will work as it only checks incoming input
Hard to see, but it worked even though DarkBlue isn't in the set above!

Hard to see, but it worked even though DarkBlue isn't in the set above!

 

  • You're unable to generate your own error messages based on what happens in the function
    • This is fine, though, as you can wrap this up in a Try/Catch outside the function!
function Write-Color {
    [cmdletbinding()]
    param(       
        [Parameter()]  
        [ValidateSet('Green','Blue','Red')]
        [string]
        $color,
        $message
    )

    Write-Host $message -ForegroundColor $color 

}

Try {

    Write-Color -color Yellow -message "This will not work!" 

}

Catch [System.Management.Automation.ParameterBindingException] {

    $errorMessage = $_.Exception.Message
    Write-Host "Error: [$errorMessage]" -ForegroundColor Red -BackgroundColor DarkBlue
    <#

    Code to handle error

    #>
}

Error message after running:

Instead of using Write-Host, and merely showing the error, you'd want to have code in place that takes action based on the specific event.

Wrap Up

That's about it for using ValidateSet! It can really come in handy, and save you time when writing out your scripts.

Do you use ValidateSet? Leave a comment, and let me know how you use it. I always love hearing different use cases.

PowerShell: Configure Your Scripts With a GUI!

PowerShell: Configure Your Scripts With a GUI!

Configure Your Scripts With a GUI!

Making a GUI in PowerShell is a relatively easy process. GUIs can really come in handy, too! You can use them in scripts to guide execution a specific way, and even ease people into using PowerShell scripts that may otherwise stray away from anything command-line. 

One of the coolest uses I've found for GUIs in PowerShell, is using them for script configuration. You run the script, set a parameter to true, and boom you have a GUI open that allows you to change and configure parts of the script the next time it executes. 

If you're on a team, and you don't want people to have to edit your scripts (actually, if you don't want YOU to even have to edit your scripts), this is the way to go.

Enough blabbing already, let's get to it!

In this post

Prerequisites

  • PowerShell 3.0+ (for this example)
  • Visual Studio Community (or above)
  • Script created in folder that contains the following subfolders:
    • Output
    • Input
      • Example:

A note about Write-Host

I use Write-Host in all my example code for one reason: readability. When you're actually scripting and writing code, you'll want to be sure to use something more versatile like Write-Verbose. Even better, use some logging functions to output to a log file and/or console.

Create a GUI in PowerShell

The GUI creation method I use involved using Visual Studio Community, and outlining the application in the XAML GUI editor. Then I use what I learned from Stephen Owen on his site, Foxdeploy. As this post will not be a how-to on creating GUIs in general, if you're interested in the behind the scenes on what will be happening here when it comes to the GUI, I highly recommend reading through his series, here: https://foxdeploy.com/2015/04/10/part-i-creating-powershell-guis-in-minutes-using-visual-studio-a-new-hope/

Creating Our GUI

The first thing we'll want to do is create our GUI. I will use Visual Studio Community for this.

1. Create new C# WPF Application

2. Design GUI via tools provided by Visual Studio

3. Copy the XAML generated by Visual Studio into PowerShell

Code so far

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

}

#Call function to open the form
Invoke-GUI

We can run the code now, and verify our GUI pops up as it should!

Now to get to work on making things happen!

Configuration File Creation

Let's add the following code to the top of our script:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

This gives us a parameter we can set later that will allow us to pop up the GUI (or not), and we setup some paths the script can use based on where it is run from. We also set the path to the configuration file we'll be creating and using.

Current folder structure:

Function for generating configuration file

Now let's create a function that will serve two purposes:

  • Accept input as the configuration file contents to export
  • Generate a base config if we don't pass any content to export
    • This is nice for first time script execution, and we can include some defaults that we'll expand upon later
function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

Next we'll add in an if statement that will:

  • Create a config file if it doesn't exist (and import it)
  • OR import a config file if it does exist
#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

Full example code so far:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

}

function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

Execution of script with no configuration file:

We can take a peek in the input folder now to verify if it's there, and look at the contents.

Using the Configuration Data

Now that we have the configuration data available, and imported, we can use it in our script.

First, let's take a look at what the config data looks like after it is imported into PowerShell via Import-Clixml.

We have our data handy, and we can confirm that the OUList is indeed an array (as we'd want it to be), if we pipe $config to Get-Member.

With this data available to us, we can add some logic to our script to use it.

First, we'll add an if statement to the script that will determine if we want to launch the configuration GUI, or actually run through the script logic.

Code:

#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

#Script logic
if ($configScript) { #Begin if to see if $configScript is set to true

    #If it's true, run this function to launch the GUI
    Invoke-GUI
    
} else { #Begin if/else for script exeuction (non-config)

    #Simple example for using the OUList defined in the config file
    ForEach ($ou in $script:configData.OUList) { #Begin foreach loop for OU actions

        Write-Host "Performing action on [$ou]!" -ForegroundColor Green -BackgroundColor Black

    } #End foreach loop for OU actions

    #Create some test users
    $userList = Invoke-UserDiscovery

    #Take actions on each user and store results in $processedUsers
    $processedUsers = $userList | Invoke-UserAction
    
    #Create file name for data export
    $outputFileName = ("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv" -f (Get-Date))
    
    #Export processed users various data types
    $processedUsers | Export-Csv -Path $outputFileName -NoTypeInformation
    $processedUsers | Export-Clixml -Path ($outputFileName -replace 'csv','xml')

    Write-Host "File exported to [$outputDir]!"

    #Take a look at the array
    $processedUsers | Format-Table

} #End if/else for script actions (non-config)

In the code above, I have referenced a few different  functions well need for the script to run correctly.  They are Invoke-UserDiscovery and Invoke-UserAction.  

Here is the code for those functions:

function Invoke-UserDiscovery { #Begin function Invoke-UserDiscovery
    [cmdletbinding()]
    param()

    #Create empty arrayList object
    [System.Collections.ArrayList]$userList = @()

    #Create users and add them to array
    $testUser2 = [PSCustomObject]@{

        DisplayName   = 'Mike Jones'
        UserName      = 'jonesm'
        LastLogon     = (Get-Date).AddDays(-35) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }

    $testUser1 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-24) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }    

    $testUser3 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-10) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }  

    $testUser4 = [PSCustomObject]@{

        DisplayName   = 'This WontWork'
        UserName      = 'wontworkt'        
        LastLogon     = $null 
        OU            = Get-Random -inputObject $script:configData.OUList

    }
    
    $testUser5 = [PSCustomObject]@{

        DisplayName   = 'This AlsoWontWork'
        UserName      = 'alsowontworkt'        
        LastLogon     = 'this many!'
        OU            = Get-Random -inputObject $script:configData.OUList

    }        

    #Add users to arraylist
    $userList.Add($testUser1) | Out-Null
    $userList.Add($testUser2) | Out-Null
    $userList.Add($testUser3) | Out-Null
    $userList.Add($testUser4) | Out-Null
    $userList.Add($testUser5) | Out-Null

    #Return list
    Return $userList

} #End function Invoke-UserDiscovery

function Invoke-UserAction { #Begin function Invoke-UserAction
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        $usersToProcess
    )

    Begin { #Begin begin block for Invoke-UserAction

        #Create array to store results in
        [System.Collections.ArrayList]$processedArray = @()

        Write-Host `n"User processing started!"`n -ForegroundColor Black -BackgroundColor Green

    } #End begin block for Invoke-UserAction

    Process { #Begin process block for function Invoke-UserAction

        foreach ($user in $usersToProcess) { #Begin user foreach loop
        
            #Set variables to null so they are not set by the last iteration
            $lastLogonDays = $null
            $userAction    = $null
            
            $notes         = 'N/A'

            #Some error handling for getting the last logon days
            Try {

                #Set value based on calculation using the LastLogon value of the user
                $lastLogonDays = ((Get-Date) - $user.LastLogon).Days 

            }

            Catch {
            
                #Capture message into variable $errorMessage, and set other variables accordingly
                $errorMessage  = $_.Exception.Message
                $lastLogonDays = $null
                $notes         = $errorMessage 

                Write-Host `n"Error while calculating last logon days [$errorMessage]"`n -ForegroundColor Red -BackgroundColor DarkBlue

            }

            Write-Host `n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."

            #Switch statement to switch out the value of $lastLogonDays
            Switch ($lastLogonDays) { #Begin action switch

                #This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file
                {$_ -lt $script:configData.DisableDays -and $_ -ge $script:configData.WarnDays} { #Begin actions for warning

                    $userAction = 'Warn'

                    Write-Host "Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays - $lastLogonDays)] days!"`n

                    Break

                } #End actions for warning

                #This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file
                {$_ -ge $script:configData.DisableDays} { #Begin actions for disable

                    $userAction = 'Disable'

                    Write-Host "[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays - $script:ConfigData.DisableDays)] days past the threshold!"`n

                    Break

                } #End actions for disable

                {$_ -eq $null} { #Begin actions for a null value

                    $userAction = 'Error'

                    Write-Host "Something went wrong, no value specified for last logon days!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    Break

                } #End actions for a null value

                #Adding a default to catch other values
                default { #Begin default actions

                    $userAction = 'None'                    
                    Write-Host "$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n

                } #Begin default actions

            } #End action switch

            #Create object to store in array
            $processedObject = [PSCustomObject]@{
                
                DisplayName   = $user.DisplayName
                UserName      = $user.UserName
                OU            = $user.OU
                LastLogon     = $user.LastLogon
                LastLogonDays = $lastLogonDays
                Action        = $userAction                
                Notes         = $notes                

            }

            #Add object to array of processed users
            $processedArray.Add($processedObject) | Out-Null

        } #End user foreach loop

    } #End process block for function Invoke-UserAction

    End { #Begin end block for Invoke-UserAction

        Write-Host `n"User processing ended!"`n -ForegroundColor Black -BackgroundColor Green
        
        #Return array
        Return $processedArray

    } #End end block for Invoke-UserAction

} #End function Invoke-UserAction

Full code so far:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

function Invoke-UserDiscovery { #Begin function Invoke-UserDiscovery
    [cmdletbinding()]
    param()

    #Create empty arrayList object
    [System.Collections.ArrayList]$userList = @()

    #Create users and add them to array
    $testUser2 = [PSCustomObject]@{

        DisplayName   = 'Mike Jones'
        UserName      = 'jonesm'
        LastLogon     = (Get-Date).AddDays(-35) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }

    $testUser1 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-24) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }    

    $testUser3 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-10) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }  

    $testUser4 = [PSCustomObject]@{

        DisplayName   = 'This WontWork'
        UserName      = 'wontworkt'        
        LastLogon     = $null 
        OU            = Get-Random -inputObject $script:configData.OUList

    }
    
    $testUser5 = [PSCustomObject]@{

        DisplayName   = 'This AlsoWontWork'
        UserName      = 'alsowontworkt'        
        LastLogon     = 'this many!'
        OU            = Get-Random -inputObject $script:configData.OUList

    }        

    #Add users to arraylist
    $userList.Add($testUser1) | Out-Null
    $userList.Add($testUser2) | Out-Null
    $userList.Add($testUser3) | Out-Null
    $userList.Add($testUser4) | Out-Null
    $userList.Add($testUser5) | Out-Null

    #Return list
    Return $userList

} #End function Invoke-UserDiscovery

function Invoke-UserAction { #Begin function Invoke-UserAction
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        $usersToProcess
    )

    Begin { #Begin begin block for Invoke-UserAction

        #Create array to store results in
        [System.Collections.ArrayList]$processedArray = @()

        Write-Host `n"User processing started!"`n -ForegroundColor Black -BackgroundColor Green

    } #End begin block for Invoke-UserAction

    Process { #Begin process block for function Invoke-UserAction

        foreach ($user in $usersToProcess) { #Begin user foreach loop
        
            #Set variables to null so they are not set by the last iteration
            $lastLogonDays = $null
            $userAction    = $null
            
            $notes         = 'N/A'

            #Some error handling for getting the last logon days
            Try {

                #Set value based on calculation using the LastLogon value of the user
                $lastLogonDays = ((Get-Date) - $user.LastLogon).Days 

            }

            Catch {
            
                #Capture message into variable $errorMessage, and set other variables accordingly
                $errorMessage  = $_.Exception.Message
                $lastLogonDays = $null
                $notes         = $errorMessage 

                Write-Host `n"Error while calculating last logon days [$errorMessage]"`n -ForegroundColor Red -BackgroundColor DarkBlue

            }

            Write-Host `n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."

            #Switch statement to switch out the value of $lastLogonDays
            Switch ($lastLogonDays) { #Begin action switch

                #This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file
                {$_ -lt $script:configData.DisableDays -and $_ -ge $script:configData.WarnDays} { #Begin actions for warning

                    $userAction = 'Warn'

                    Write-Host "Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays - $lastLogonDays)] days!"`n

                    Break

                } #End actions for warning

                #This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file
                {$_ -ge $script:configData.DisableDays} { #Begin actions for disable

                    $userAction = 'Disable'

                    Write-Host "[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays - $script:ConfigData.DisableDays)] days past the threshold!"`n

                    Break

                } #End actions for disable

                {$_ -eq $null} { #Begin actions for a null value

                    $userAction = 'Error'

                    Write-Host "Something went wrong, no value specified for last logon days!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    Break

                } #End actions for a null value

                #Adding a default to catch other values
                default { #Begin default actions

                    $userAction = 'None'                    
                    Write-Host "$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n

                } #Begin default actions

            } #End action switch

            #Create object to store in array
            $processedObject = [PSCustomObject]@{
                
                DisplayName   = $user.DisplayName
                UserName      = $user.UserName
                OU            = $user.OU
                LastLogon     = $user.LastLogon
                LastLogonDays = $lastLogonDays
                Action        = $userAction                
                Notes         = $notes                

            }

            #Add object to array of processed users
            $processedArray.Add($processedObject) | Out-Null

        } #End user foreach loop

    } #End process block for function Invoke-UserAction

    End { #Begin end block for Invoke-UserAction

        Write-Host `n"User processing ended!"`n -ForegroundColor Black -BackgroundColor Green
        
        #Return array
        Return $processedArray

    } #End end block for Invoke-UserAction

} #End function Invoke-UserAction

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

} #End function Invoke-GUI

function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

#Script logic
if ($configScript) { #Begin if to see if $configScript is set to true

    #If it's true, run this function to launch the GUI
    Invoke-GUI
    
} else { #Begin if/else for script exeuction (non-config)

    #Simple example for using the OUList defined in the config file
    ForEach ($ou in $script:configData.OUList) { #Begin foreach loop for OU actions

        Write-Host "Performing action on [$ou]!" -ForegroundColor Green -BackgroundColor Black

    } #End foreach loop for OU actions

    #Create some test users
    $userList = Invoke-UserDiscovery

    #Take actions on each user and store results in $processedUsers
    $processedUsers = $userList | Invoke-UserAction
    
    #Create file name for data export
    $outputFileName = ("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv" -f (Get-Date))
    
    #Export processed users various data types
    $processedUsers | Export-Csv -Path $outputFileName -NoTypeInformation
    $processedUsers | Export-Clixml -Path ($outputFileName -replace 'csv','xml')

    Write-Host "File exported to [$outputDir]!"

    #Take a look at the array
    $processedUsers | Format-Table

} #End if/else for script actions (non-config)

Overview of what will happen:

  • If $configScript is set to $false, the else statement will execute
  • An example of using config data will be displayed for each of the OUs stored
  • We generate a list of users with some last logon values, and random OUs from the OU list (Invoke-UserDiscovery)
  • We then go through the users and process them, by piping the user array to Invoke-UserAction
  • We store those results in $processedUsers, and then export them to a CSV and Clixml file in the script's output directory
  • Finally, we pipe the results to Format-Table, to review in the console

Let's run the script as is, and see what happens!

You can see that it performed all the steps above, as we would expect it to.

The output directory now contains two files:

CSV File Contents:

Clixml File Contents:

Adding a Modifiable Configuration

Now onto the ability to modify and save the configuration via the GUI we created!

Normally, I stray away from creating functions within functions...

...but in this case I like the way it creates a clean and understandable layout. 

We're going to focus entirely on the function we created earlier, Invoke-GUI.

Here is the new code:

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    #We technically don't need these, but they may come in handy later if you want to pop up message boxes, etc
    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    #Input XAML here
    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Setup the form    
    function Invoke-FormSetup { #Begin function Invoke-FormSetup

        #Here we set the default states of the objects that represent the buttons/fields
        $WPFbtnEdit.IsEnabled          = $true
        $WPFbtnSave.IsEnabled          = $false
        $WPFtxtBoxWarnLow.IsEnabled    = $false
        $WPFtxtBoxDisableLow.IsEnabled = $false
        $WPFtxtBoxOUList.IsEnabled     = $false
        $WPFbtnExceptions.IsEnabled    = $false

        #We will use the current values we imported from the script scoped variable configData
        $WPFtxtBoxWarnLow.Text    = $script:configData.WarnDays
        $WPFtxtBoxDisableLow.Text = $script:configData.DisableDays
        $WPFtxtBoxOUList.Text     = $script:configData.OUList | Out-String

    } #End function Invoke-FormSetup

    function Invoke-FormSaveData { #Begin function Invoke-FormSaveData

        #This function will perform the action to save the form data
        
        #We setup the variables based on the current values of the form
        $warnDays     = [int]$WPFtxtBoxWarnLow.Text 
        $disableDays  = [int]$WPFtxtBoxDisableLow.Text
        $ouList       = ($WPFtxtBoxOUList.Text | Out-String).Trim() -split '[\r\n]' | Where-Object {$_ -ne ''}

        #This object will contain the current configuration we would like to export
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #We then pass the configuration to the function we created earlier that will export the options we pass in
        Invoke-ConfigurationGeneration -configurationOptions $configurationOptions

        #Then we re-import the config file after it is exported via the function above
        $script:configData = Import-Clixml -Path $configFile

        #Finally we revert the GUI to the original state, which will also reflect the lastest configuration that we just exported
        Invoke-FormSetup

    } #End function Invoke-FormSaveData

    #Now we perform actions using the functions we created, as well as code that runs when buttons are clicked

    #Run form setup on launch
    Invoke-FormSetup

    #Button actions
    $WPFbtnEdit.Add_Click{ #Begin edit button actions

        #This will 'open up' the form and allow fields to be edited
        $WPFbtnExceptions.IsEnabled    = $true
        $WPFbtnSave.IsEnabled          = $true
        $WPFtxtBoxWarnLow.IsEnabled    = $true
        $WPFtxtBoxDisableLow.IsEnabled = $true
        $WPFtxtBoxOUList.IsEnabled     = $true
        $WPFbtnExceptions.IsEnabled    = $true

    } #End edit button actions

    $WPFbtnSave.Add_Click{ #Begin save button actions

        #The save button calls the Invoke-FormSaveData function
        Invoke-FormSaveData

    } #End save button actions

    #Show the form
    $form.showDialog() | Out-Null

} #End function Invoke-GUI

Overview:

  • We add a function to save the data when the save button is clicked (Invoke-FormSaveData)
  • We add a function that resets the data on the form and displays the current configuration (Invoke-FormSetup)
  • We add some code that executes when the Edit and Save buttons are clicked

Notes:

The XAML has also been modified a bit (one line of it), to allow us to use the enter key, as well as to add a scrollbar (if needed).

The line that represents the OU list has had the following appended to it: 

AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"

Full line:

<TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>

It was a bit tricky at first when I was learning how to save the OU text box value as an array, here is the code that made it happen:

$ouList = ($WPFtxtBoxOUList.Text | Out-String).Trim() -split '[\r\n]' | Where-Object {$_ -ne ''}

This bit of code allows you to split the text box into an array, and filter out any blank entries.

Running the script to configure the GUI

Now let's set the value of config parameter to $true when we run the script. This will launch the configuration GUI.

configrun.PNG

Time to test it out!

First, I'll simply add another OU.

I'll need to click Edit, and then add the OU:

Now when I click Save it should lock the form again, and display a message in the console.

configrun.PNG

To verify it worked, we can run the script without flagging the parameter to $true.

Here are the current results:

The IT OU has been added, and used!

We can also test changing the warning and disable thresholds, to see how that affects our users and their actions.

Run script in config mode:

I'll set the warning threshold to 10 days, and the disable threshold to 15 days:

Let's run the script again, and check out the results!

It worked! You now have a script that you can modify when you need to with a GUI.

Create Configuration Shortcut

We can create a shortcut that when used, will launch the script with $configScript set to $true, and launch the configuration GUI.

To do this we will need to go to the script directory, and create a new shortcut.

Then, we will set the location to C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe.

After that, we give it a name that makes sense.

Now we need to edit the properties of the shortcut to change a couple things.

First, you'll want to append this to the target: -noprofile -command "& '.\script.ps1' -configScript:$true"

Full Target Value: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -noprofile -command "& '.\script.ps1' -configScript:$true"

Finally, clear out anything in Start in:, and click OK.

You should now be able to use the shortcut to configure the script!

Code on GitHub

All of the code for this script is placed on GitHub, you can check it out here.

You can download the contents as a ZIP file by going to 'Clone or download', and then selecting 'Download ZIP'.

If you download the code, and would like to run it as it using the shortcut or console, you'll need to unblock script.ps1 first.

To do this, right click script.ps1, and go to Properties

Now you'll want to check 'unblock' , and click Apply/OK.

unblock2.PNG

Using the code:

You can then testing out the code by opening PowerShell, and browsing to where you extracted the contents. You can then run .\script.ps1.

cd C:\users\thegn\Desktop\code\
.\script.ps1

You can actually launch the configuration shortcut from here too!

& '.\Configure Script.lnk'

I'll change the warning threshold to 9 days and re-run the script to verify it is working.

Now to run the script and see if everyone at least gets a warning:

There you go, it worked! Please feel free to take whatever pieces of the code you need, and make them work to your heart's desire.

What Next?

  • Apply the logic learned here to scripts you want to have the ability to easily edit on the fly
  • Learn more, and dive in by getting that exceptions button to do... well anything!
  • Dive deeper into PowerShell GUI creation

[top]

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

Why Control Internet Explorer with PowerShell?

I've covered using Invoke-WebRequest, as well as Invoke-RestMethod for basic and advanced tasks... why would we need anything else? Sometimes you'll try using Invoke-WebRequest, and you swear the data should be there, yet for some reason you just can't seem to parse it out. 

The reasons for this can vary. Typically, it is because the page renders something with a script, and you can only gather it with a browser being opened/controlled. If you're having a hard time parsing the data from Invoke-WebRequest, controlling IE can be a time saving solution. This can come in handy if you need to script up something quick, and then work on a long term solution after you know what's possible.

COM Object Instantiation

The first thing we will need to do is instantiate an instance of the Internet Explorer COM object. To do this, we'll use New-Object.

$ieObject = New-Object -ComObject 'InternetExplorer.Application'

$ieObject will store the object and contains the properties and methods we can work with. Let's take a peek:

$ieObject | Get-Member
gm.PNG

We will be using a few of these properties and methods as we work with controlling IE.

By default, the instance of Internet Explorer is not visible. For automation, that's great!
For demoing and testing, not so much!

Let's show the current instance of IE:

$ieObject.Visible = $true

There's our window.

After instantiating our object, we can use it to navigate to a website.

To do this, we will utilize the Navigate method, and pass along a string value to navigate to.

$ieObject.Navigate('http://www.tukui.org/forums/bb-login.php')

Now the browser should be at the website.

Form Elements/Information Gathering

Now that we have navigated to the site, we will need to login. To login, we'll first need do some some information gathering.

First, we will store the current page's document in a new variable named $currentDocument

Then, we will list out all of the tags named input, along with their associated types and names

$currentDocument = $ieObject.Document
$currentDocument.IHTMLDocument3_getElementsByTagName("input") | Select-Object Type,Name

This gives us enough information to proceed to the next section on filling out the form, however let's take a look at a couple more things.

What if we wanted to gather all the links?

$currentDocument.links | Select-Object outerText,href

If we wanted to get just the download URL, we could use something like:

$downloadLink = $currentDocument.links | Where-Object {$_.outerText -eq 'Download'} | Select-Object -ExpandProperty href
$downloadLink

Hmm... it appears that there are two identical links to the download page provided. To make sure we only grab one, we will need to use something like:

$downloadLink = $currentDocument.links | Where-Object {$_.outerText -eq 'Download'} | Select-Object -ExpandProperty href -First 1
$downloadLink

There we go!

Filling Out Forms and Clicking Buttons

Now that we have parsed out all of the input types, and have their names, we can proceed with filling out the form information.

Here they are for review:

Username

First let's set the username field. 

The username field is named user_login, so let's store that field in a variable named $userNameBox.

$userNameBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'user_login'}

To set the username, we can change the value property to our username. I have a credential object which contains my username, and I will use that to set it. You can pass along any string value, but I highly recommend using a credential object, or other secure method (more for the password, but it at also omits even showing the raw string for your username in the code).

$userNameBox.value = $myCredentials.UserName

You can see that this is set on the website now!

Password

Now to set the password. To do this we'll use the same logic as we did for the username, but specify the name password.

$passwordBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'password'}

The password object will take a string for the password (just as the username object did). I highly recommend using a credential object, and then using the GetNetworkCredential method, and then the Password property.

$passwordBox.value = $myCredentials.GetNetworkCredential().Password

Looks good!

Clicking Submit ("Log in »")

We've filled out our username and password, what now? Well... that button ain't gonna click itself! Let's click it via PowerShell.

There is no name set on the object for the button, so we'll use its type for uniqueness, and set the variable to store it that way ($submitButton).

$submitButton = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.type -eq 'submit'}

Now to click it! There is a method on this object named Click.

$submitButton | Get-Member -MemberType method

Ok but for real now, let's 'click' it!

$submitButton.click()

And if all goes well, you'll see the next page and be logged in.

Now that we're logged in, we could:

  • Set the $currentDocument variable to $ieObject.Document as the value is now different since a new page has been loaded.
  • Parse out information we need by exploring the webpage.
  • Automate looking for a specific post

It really all depends on what you want to do. 

One thing I have done is wrote up a script for a project where I had to rename user IDs. I had a list of old IDs and new IDs, and would iterate through all of them (sometimes 300+), until they were all renamed. All with logging and results output for later review and verification. Once you have the basics down you can really expand into the realm of full automation, and reap the benefits of what PowerShell can do.

!!**Note**!!

After each time you use the Navigate method, or click a button, you'll want to wait for the object's status to not be busy. 

Here is one way to accomplish that.

Store this function in memory:

function Invoke-IEWait {
    [cmdletbinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject
    )

    While ($ieObject.Busy) {

        Start-Sleep -Milliseconds 10

    }

}

You can store it in the same script, or as part of a module. You can invoke this function whenever you need to now, and keep your code looking clean.

It's also good to always refresh your $currentDocument variable after a button click / page load. 

Here's some code for an example of when/how to use it:

#Set the URL we want to navigate to
$webURL = 'http://www.tukui.org/forums/bb-login.php'

#Create / store object invoked in a variable
$ieObject = New-Object -ComObject 'InternetExplorer.Application'

#By default it will not be visible, and this is likely how you'd want it set in your scripts
#But for demo purposes, lets set it to visible
$ieObject.Visible = $true

#Take a look at the object
$ieObject | Get-Member

#Navigate to the URL we set earlier
$ieObject.Navigate($webURL)

#Wait for the page to load
$ieObject | Invoke-IEWait

#Store current document in variable
$currentDocument = $ieObject.Document

Alright, time to clean things up.

Cleanup

Let's cleanup the mess we made.

First we will log out of the website:

$logoutURL = $currentDocument.links | Where-Object {$_.outerText -eq 'log out'} | Select-Object -ExpandProperty href -First 1
$logoutURL

Now that we've verified the URL (and only have one thanks to Select-Object along with -First 1), we can logout by navigating to it.

$ieObject.Navigate($logoutURL)

Now to verify...

Now to clean up the COM object we instantiated.

First we'll quit IE:

$ieObject.Quit()

Our IE window should have went poof.

If you really want to perform the best cleanup, you can follow that up with this (to release the COM object):

[void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)

Example Code

Here's some example code. I took what we accomplished above, and broke parts of it out into functions.

To use the code you'll need an account on the Tukui forums.

Here's what it does:

  • Prompts for credentials
  • Navigates to the site
    • Enters credentials
    • Clicks Submit
    • Parses links / text from posts
    • Outputs results to text files
  • Logs out of site
  • Quits IE
  • Releases IE COM object
function Invoke-IEWait { #Begin function Invoke-IEWait
    [cmdletbinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject
    )

    While ($ieObject.Busy) {

        Start-Sleep -Milliseconds 10

    }

} #End function Invoke-IEWait

function Invoke-SiteLogon { #Begin function Invoke-SiteLogon
    [cmdletbinding()]
    param()

    #Set the URL we want to navigate to
    $webURL = 'http://www.tukui.org/forums/bb-login.php'

    #Create / store object invoked in a variable
    $ieObject = New-Object -ComObject 'InternetExplorer.Application'

    #By default it will not be visible, and this is likely how you'd want it set in your scripts
    #But for demo purposes, let's set it to visible
    $ieObject.Visible = $true

    #Navigate to the URL we set earlier
    $ieObject.Navigate($webURL)

    #Wait for the page to load
    $ieObject | Invoke-IEWait

    #Store current document in a variable
    $currentDocument = $ieObject.Document

    #Username field
    $userNameBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'user_login'}

    #Fill out username value
    $userNameBox.value = $myCredentials.UserName

    #Password field
    $passwordBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'password'}

    #Fill out password value
    $passwordBox.value = $myCredentials.GetNetworkCredential().Password

    #Submit button
    $submitButton = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.type -eq 'submit'}

    #Invoke click method on submit button
    $submitButton.click()

    #Wait for the page to load
    $ieObject | Invoke-IEWait

    #Return the object so we can work with it further in the script
    Return $ieObject

} #End function Invoke-SiteLogon

function Invoke-IECleanUp { #Begin function Invoke-IECleanUp
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject        
    )

    #Store logout URL
    $logoutURL = $currentDocument.links | Where-Object {$_.outerText -eq 'log out'} | Select-Object -ExpandProperty href -First 1

    #Use logout URL to logout via the Navigate method
    $ieObject.Navigate($logoutURL)

    #Wait for logout
    $ieObject | Invoke-IEWait

    #Clean up IE Object
    $ieObject.Quit()

    #Release COM Object
    [void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)

} #End function Invoke-IECleanUp

#Get credentials
$myCredentials = Get-Credential

#Login to site
$ieObject = Invoke-SiteLogon

#Wait in case it is still busy
$ieObject | Invoke-IEWait

#Set the current document variable 
$currentDocument = $ieObject.Document

#Get all elements that may have text values in a table, and store in a text file
$currentDocument.IHTMLDocument3_getElementsByTagName('td') | Select-Object OuterText | Out-File '.\siteStuff.txt'

#Get all links and select name/href, and store in a text file
$currentDocument.links | Select-Object outerText,href | Out-File '.\links.txt'

#Log out / clean up IE Object now that we're done
$ieObject | Invoke-IECleanUp

It will create the text files in the current working directory.

Homework

I've provided some links for hints.

I hope you've enjoyed this series.
As always, leave a comment if you have any feedback or questions!

[top]

PowerShell: Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

PowerShell: Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

Continuing With Invoke-RestMethod

In part 2, we went over Invoke-RestMethod's basics, including:

In this post, I will be going over some more ways to use Invoke-RestMethod to authenticate to different APIs. I will also go over how to send information to the API, and work with the results we get back.

Base64 Encoded Key in Header

Some APIs will require you to authenticate with a Base64 encoded key in the header information. I happen to own a LIFX light, and their API uses that method of authentication.

If you own a LIFX light, and want to follow along, you can get a personal access token here: https://cloud.lifx.com/settings.

Information recap:

Now let's get to work! I have my key, which let's just say is: 'YouNeedYourOwnKey123321$'

Let's store that in a variable.

$apiKey          = 'YouNeedYourOwnKey123321$'

The next thing we'll need to do is convert that to a Base64 encoded string. To do that we'll use the [Convert] .NET class with the ToBase64String method. For the method's overloads, we'll use another class to convert the string (in this case our key), to a byte array. If you don't understand exactly how this works, don't worry! Here's the code:

$base64Key       =  [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(($apiKey)))

Let's run that and then check out what $base64Key contains.

The next step here is to store that key in the header information (as a hashtable), with the key name of "Authorization".

$headers         =  @{Authorization=("Basic {0}" -f $base64Key)}

Let's take a peek at $headers to ensure it contains what we need.

Looks good! Now to check it out. The URL in the documentation to get the lights available is 'https://api.lifx.com/v1/lights/all'. I like to store information like this in variables. 

$allURL          = 'https://api.lifx.com/v1/lights/all'

Now to put it all to use! We'll be utilizing Invoke-RestMethod with the following parameters/arguments:

  • Headers
    • Since we already have our headers stored in a nifty hashtable, we'll simply specify $headers
  • Uri
    • We'll pass along $allURL, and attempt to get a list of available lights

Here's all of the code so far. 

We'll be storing the results of the Invoke-RestMethod command in $ninjaLights.

$apiKey          = 'YouNeedYourOwnKey123321$'
$base64Key       =  [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(($apiKey)))
$headers         =  @{Authorization=("Basic {0}" -f $base64Key)}
$allURL          = 'https://api.lifx.com/v1/lights/all'

$ninjaLights     = Invoke-RestMethod -Headers $headers -Uri $allURL

Success! 

Sending Data to a REST API via Body (LIFX API)

Now that we've authenticated to LIFX, we can work on sending information over, and control the light. Here are the steps I generally follow when using an API for the first time:

  • Review documentation
  • Review documentation (This can't be emphasized enough)
  • Test out a bunch of different ways to utilize the API
    • Get errors, handle errors, see what's expected, clean up the code, and build a function

Here is a snippet from their documentation:

What that tells us is how the text is to be formatted (JSON)and that we'll be using the PUT method.

Here are the parameters they accept:

Let's get started by ensuring we have working code, and then build a function around it.

The URL is 'https://api.lifx.com/v1/lights/all/state', which we'll store in $lightStateURL. We'll also setup some variables for the accepted parameters, as follows:

  • $state
    • on or off, we'll choose on
  • $color
    • We'll set this to red
  • $brightness
    • We'll set this to .5, or 50%
  • $duration
    • This will be set to .0, which is indefinitely

After setting up these variables, we'll use a here-string to build the payload, and finally use Invoke-RestMethod with the following parameters/arguments:

  • Uri
    • The $lightStateURL variable
  • Method
    • We'll be using PUT, per their documentation
  • Headers
  • Body
    • We will set this to $payloadBuilder (which is the here-string that contains the parameters we're passing to the LIFX API)

Here is the full code so far to test this:

$apiKey          = 'YouNeedYourOwnKey123321$'
$base64Key       =  [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(($apiKey)))
$headers         =  @{Authorization=("Basic {0}" -f $base64Key)}

$lightStateURL = 'https://api.lifx.com/v1/lights/all/state'

$state      = 'on'
$color      = 'red'
$brightness = .5
$duration   = 0.0

$payloadBuilder = @"
{
    "power": "$state",
    "color": "$color",
    "brightness": "$brightness",
    "duration": "$duration"
}
"@

$setResults = Invoke-RestMethod -Uri $lightStateURL -Method Put -Headers $headers -Body $payloadBuilder

Let's go ahead and run that to see if it worked:

This is an easy one to verify!

We can also take a look at the results property of $setResults.

Now that we know that it works, it can be used in a function to control the light.

Here is the code for an example of how to build a function with this information:

$apiKey          = 'YouNeedYourOwnKey123321$'
$base64Key       =  [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(($apiKey)))
$headers         =  @{Authorization=("Basic {0}" -f $base64Key)}
$allURL          = 'https://api.lifx.com/v1/lights/all' 
$baseURL         = 'https://api.lifx.com/v1/lights/'

function Set-LightState { #Begin function Set-LightState
    [cmdletbinding()]
    Param (
        [Parameter(Mandatory)]
        $headers,
        [String]
        $state = 'on',
        [String]
        $color = 'White',
        [Double]
        $brightness = 0.5,
        [Double]
        $duration = 0.0
    )

    #If our light shows as connected...
    if ($ninjaLights.Connected) {

        #Get the ID of the light we want to change
        $ninjaID  = $ninjaLights.id
        #Set the selected as the ID of the light (per LIFX documentation)
        $selector = 'id:' + $ninjaID
        #Construct the URL to include the selector and /state
        $fullURL  = $baseurl + $selector + '/state'

        #Build the payload
        $payloadBuilder = @"
{
    "power": "$state",
    "color": "$color",
    "brightness": "$brightness",
    "duration": "$duration"
}
"@

        Write-Host "Changing light from:" 
        Write-Host `t"State     : $($ninjaLights.Power)" 
        Write-Host `t"Color     : $($ninjaLights.Color)"
        Write-Host `t"Brightness: $($($ninjaLights.Brightness * 100))%"`n

        Write-Host "Changing light to:" 
        Write-Host `t"State     : $state" 
        Write-Host `t"Color     : $color"
        Write-Host `t"Brightness: $($brightness * 100)%" 
        Write-Host `t"Duration  : $duration"`n

        #Store results in $setResults
        $setResults = Invoke-RestMethod -Uri $fullURL -Method Put -Headers $headers -Body $payloadBuilder
    
        Write-Host "API status:"
        Write-Host `t"Light :" $setResults.results.label
        Write-Host `t"Status:" $setResults.results.status `n
    
    }

} #End function Set-LightState

$ninjaLights = Invoke-RestMethod -Headers $headers -Uri $allURL

Set-LightState -headers $headers -color blue

Let's see what happens when we run it! 

(NOTE) I use Write-Host here merely as a tool to give you a visual of the data you are working with. Ultimately it would be best to create some custom objects, and return the results as you need to with the code you're writing.

The information here is provided to show you what you can do, how you can build a payload, and then work with the data you are returned. You can do anything you put your mind to, from silly things (the next example), to even using it with another script (that may get weather information), and then flash the light red a few times if there is a weather alert.

For this next example, I will utilize a loop, and our new function, to confuse my girlfriend. Well, with all the testing I've been doing... maybe not anymore ;) 

Here's the code:

[array]$colors   = @('white','red','orange','yellow','cyan','green','blue','purple','pink')

$originalBrightness = $ninjaLights.Brightness
$originalColor      = $ninjalights.Color
$originalState      = $ninjalights.Power
$colorString        = "hue:" + $originalcolor.hue + " saturation:" + $originalcolor.saturation + " kelvin:" + $originalColor.Kelvin
        
$i = 0
        
While ($i -le 20) {
            
    $color              = Get-Random $colors
    [double]$brightness = "{0:n2}" -f (Get-Random -Maximum 1 -Minimum 0.00)
 
    Set-LightState -headers $headers -color $color -brightness $brightness -state $state
    Start-Sleep -seconds 1

    $i++
            
}
        
Set-LightState -headers $headers -state $originalState -color $colorString -brightness $originalBrightness

And off we go!

It worked! 

At the end it will set the light back to the value it was discovered with:

Username/Password in Request URL (PRTG API)

Some APIs authenticate you via including a username/password (hopefully eventually password hash) in the request URL. PRTG has one of those APIs. PRTG is one of my favorite monitoring tools, as not only is it great out of the box, but it also has great synergy with PowerShell.

PRTG information:

Let's start by getting the credentials of the account you want to use with the API. This account will be an account that has access to do what you need to do in PRTG. I will use the PRTG admin account, but you'll want to ensure you use one setup just to use with the API.

$prtgCredential = Get-Credential

If you're demoing PRTG, and are using a self-signed cert, you'll need the following code to allow Invoke-RestMethod to work with the self-signed cert. This code will only affect your current session.

Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;

    public class TemporarySelfSignedCert : ICertificatePolicy {
        public TemporarySelfSignedCert() {}
        public bool CheckValidationResult(
            ServicePoint sPoint, X509Certificate cert,
            WebRequest wRequest, int certProb) {
                return true;
            }
    }
"@

[System.Net.ServicePointManager]::CertificatePolicy = New-Object TemporarySelfSignedCert

Now let's setup a variable that contains our PRTG hostname, and a custom object with information for the API URLs.

$hostName = 'localhost'
$prtgInfo = [PSCustomObject]@{

    BaseURL        = "https://$hostName/api"
    SensorCountURL = "https://$hostName/api/table.xml?content=sensors&columns=sensor"
    
}

Next, per PRTG's documenation, we'll construct the URL we need to use to get the password hash.

$getHashURL = "$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"

Now we can finally use Invoke-RestMethod to get the hash:

$getHash    = Invoke-RestMethod -Uri $getHashURL

Let's make sure $getHash has our password hash.

Alright, got it! Now we can create a new PS Credential object to store our username, and password hash (instead of our password).

First. we'll convert the hash we created into a secure string. Then, we will create a new PS Credential object using the username we specified in $prtgCredential, and the secure string we just created as the password.

$passwordHash        = ConvertTo-SecureString  $getHash -AsPlainText -Force
$prtgCredentialwHash = New-Object System.Management.Automation.PSCredential ($($prtgCredential.UserName), $passwordHash)

We can then verify the information by displaying the contents of $prtgCredentialwHash, and also using the GetNetworkCredential().Password method.

Now that we know $prtgCredentialwHash contains what we need, we can construct a URL to test out the API.

Let's set some variables up:

$prtgUser         = $prtgCredentialwHash.UserName
$prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

$credentialAppend = "&username=$prtgUser&passhash=$prtgPass"

We can check the value of $credentialAppend to ensure it is correct:

Now to construct the URL, and test out the API via Invoke-RestMethod.

Invoke-RestMethod -uri ($prtgInfo.SensorCountURL + $credentialAppend)

Note: The full URL that is constructed is (I cut out my actual password hash on purpose, you'll see your hash after the = if all went well):

If all went well, you will see the results of the request (if not, you'll see an unauthorized message).

Success!

Sending Data via URL (PRTG API)

The PRTG API accepts data in the URL of the request. The below example will pause a sensor for a specific duration, with a specific message:

#Set the $duration (in minutes)
$duration = 10
#Set $sensorID to the sensor we want to pause
$sensorID = 45
#Set the pause message
$message  = "Paused via PS PRTG"

#Construct the $pauseURL
$pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"

#Use Invoke-RestMethod with the $pauseURL + $credentialAppend
#We will always append $credentialAppend as this is how the API accepts authentication
Invoke-RestMethod -Uri ($pauseURL + $credentialAppend)

Awesome, it worked!

Mega Example With Concept Code

This last example contains some concept code for a project I'm working on. Feel free to judge and use it as you wish, however I will note now that it is nowhere near finalized. I'm still in the exploration, see what's possible, and try to get it all to work phase. 

Requirements for concept code to work:

  • PRTG Installaction
  • Folder structure
  • Module
    • NinjaLogging.psm1 (included in ZIP file below)

NOTE: I have not fully cleaned up or officially released any of the code yet. That includes the logging module. 

Here is my TODO list for the code:

  • Upload it to GitHub
  • Clean up/add error handling
  • Create readme files
  • Add parameter sets to some of the psprtg.ps1 functions
  • Fully convert psprtg.ps1 to a module
  • Add functionality to psprtg.ps1 to include an 'oh crap!' undo feature
    • This will include exporting a custom object with the device ID and action taken

With that said, here is a link to download it (or you can skip to the code if that's all you want to see):

psPRTG download

How to get it working/examples:

  • Import the script as a module via Import-Module .\psprtg.ps1
    • The first time you do this, it will ask for your PRTG credentials. The module will then export the credential object to the input folder, and then use that exported credential the next time the module is used
  • Contents of machine/user encrypted credential file stored in the input directory:

 

  • Find a device and pause it via Invoke-SensorModification -findDevice
    • Note: You can also specify -Duration and -Message
  • Use a text file list of devices to find and take an action on

Pausing example:

Resuming example:

Log File Contents:

Code

psprtg.ps1

#Setup
$scriptPath      = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$moduleDir       = "$scriptPath\modules"
$logDir          = "$scriptPath\logs"
$outputDir       = "$scriptPath\output"
$inputDir        = "$scriptPath\input"
$hashFile        = "$inputDir\prtgCredentialwHash.xml"
$hostName        = 'localhost'
$modules         = ('ninjaLogging')

$prtgInfo = [PSCustomObject]@{

    BaseURL        = "https://$hostName/api"
    SensorCountURL = "https://$hostName/api/table.xml?content=sensors&columns=sensor"
    
}

function Invoke-ModuleImport { #Begin function Invoke-ModuleImport
    [cmdletbinding()]
    param($modules)

    Push-Location

    ForEach ($module in $modules) {

        if (Get-Module -ListAvailable -Name $module) {
    
            Import-Module $module
    
        } elseif (!(Get-Module -Name $module)) {
              
            Write-Verbose "Importing module: [$module] from: [$moduleDir\$module]" 
            Import-Module "$moduleDir\$module"

        } Else {

            Write-Verbose "Module [$module] already loaded!"

        }

    }

    Pop-Location

} #End function Invoke-ModuleImport

function Invoke-CredentialCheck { #Begin function Invoke-CredentialCheck
    [cmdletbinding()]
    Param()

    Try { #Begin try for credential path existence

        #Check if our hashed credential object exists
        if (Test-Path $hashFile) { #Begin if for testing credential path existence 

            #Import credentials from hash file

            Write-LogFile -logPath $logFile -logValue "Importing credentials from [$hashFile]" -Verbose

            $prtgCredentialwHash = Import-Clixml $hashFile

            #Add to info hash

            $prtgInfo | Add-Member -Type NoteProperty -Name Credentials -Value $prtgCredentialwHash

            #create credential string for URLs
            $prtgUser         = $prtgCredentialwHash.UserName
            $prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

            $credentialAppend = "&username=$prtgUser&passhash=$prtgPass"
            
            Return $credentialAppend

        #If it doesn't exist, attempt to create it
        } else {

            Write-Host "We need to get your hashed password to use with the API!" `n
            Write-Host "Please enter your PRTG credentials"`n

            #Store credential in $prtgCredential
            $prtgCredential = Get-Credential

            #Get the password hash via ConvertTo-SecureString and the Get-PRTGPasswordHash function.
            #Note: If you are not using a self-signed certificate (hopefully not, but if it is a demo/test install you may be): use -selfSignedCert:$True
            $passwordHash   = ConvertTo-SecureString  "$(Get-PRTGPasswordHash -PRTGCredential $prtgCredential -selfSignedCert:$True)" -AsPlainText -Force

            #Create new credential object and export it to the input folder
            #This allows us to use it when the script runs
            $prtgCredentialwHash = New-Object System.Management.Automation.PSCredential ($($prtgCredential.UserName), $passwordHash)

            Write-LogFile -logPath $logFile -logValue "Exporting hashed credentials to [$hashFile]." -Verbose

            #Export to file
            $prtgCredentialwHash | Export-Clixml $hashFile

            #create credential string for URLs
            $prtgUser         = $prtgCredentialwHash.UserName
            $prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

            $credentialAppend = "&username=$prtgUser&passhash=$prtgPass"

            Return $credentialAppend

        }  #End if for testing credential path existence

    } #End try for credential path existence

    Catch {

        $errorMessage = $_.Exception.Message

        Write-LogFileError -logPath $logFile -errorDesc "Error while checking for credentials/importing credentials: [$errorMessage]!"
    
        #Resolve the log file
        Resolve-LogFile -logPath $logFile

        Break

    }

} #End function Invoke-CredentialCheck

function Get-PRTGPasswordHash { #Begin function Get-PRTGPasswordHash
    [cmdletbinding()]
    param(
        [Parameter(Mandatory)]
        [PSCredential]
        $PRTGCredential,

        [Parameter()]
        [Boolean]
        $selfSignedCert = $false
    )

    if ($selfSignedCert) {

        Try {

            Add-Type @"
                using System.Net;
                using System.Security.Cryptography.X509Certificates;

                    public class TemporarySelfSignedCert : ICertificatePolicy {
                    public TemporarySelfSignedCert() {}
                    public bool CheckValidationResult(
                        ServicePoint sPoint, X509Certificate cert,
                        WebRequest wRequest, int certProb) {
                        return true;
                    }
                }
"@

            [System.Net.ServicePointManager]::CertificatePolicy = New-Object TemporarySelfSignedCert

    }

        Catch {
        
            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Error while allowing self signed certs: [$errorMessage]" -ForegroundColor Red -BackgroundColor DarkBlue
            
            #Resolve the log file
            Resolve-LogFile -logPath $logFile

            Break


        }

    }

    Try {

        $getHashURL = "$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"
        $getHash    = Invoke-RestMethod -Uri $getHashURL 

        Return $getHash

    }

    Catch {

        $errorMessage = $_.Exception.Message

        Write-LogFileError -logPath $logFile -errorDesc "Error while getting hash: [$errorMessage]" -Verbose

        Break

    }

} #End function Get-PRTGPasswordHash

function Invoke-DeviceSearch { #Begin function Invoke-DeviceSearch
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        $findMe
    )

    [System.Collections.ArrayList]$foundDeviceIDs = @()

    ForEach ($find in $findMe) {

        Try {

            $getDeviceURL       = "$($prtgInfo.BaseURL)/table.json?content=devices&output=json&columns=objid,probe,group,device,host,downsens,partialdownsens,downacksens,upsens,warnsens,pausedsens,unusualsens,undefinedsens"
            $devices            = Invoke-RestMethod -Uri ($getDeviceURL + $credentialAppend) 
            $findDeviceWildCard = '*' + $find + '*'

            ForEach ($device in $devices.devices) {

                Switch ($device.device) {

                    {$_ -like $findDeviceWildCard} {
                    
                        $foundDeviceIDs.Add($device.objid) | Out-Null
                    
                    }

                }

            } 
        
        }

        Catch {

            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Unable to get device list: [$errorMessage]" -Verbose

        }

    }

    Return $foundDeviceIDs

} #End function Invoke-DeviceSearch

function Invoke-SensorModification { #Begin function Invoke-SensorModification
    [cmdletbinding()]
    Param(
        [parameter(Mandatory)]
        [string]
        $action,
        [parameter()]
        [string]
        $findDevice,
        [Parameter()]
        [int]
        $sensorID,
        [Parameter()]
        [String]
        $message,
        [Parameter()]
        [int]
        $duration,
        [Parameter()]
        $List
    )

    $logFile = New-logFile -logPath $logDir -logName 'PSPRTG.log' -addDate:$true

    if (!$duration) {

        $duration = 30

    }

    if (!$message) {

        $message = "Sensor paused by: [$((Get-ChildItem Env:\USERNAME).Value)] via PS PRTG."    

    }

    if ($List) {
        
        Switch ($List | Get-Member | Select-Object -ExpandProperty TypeName -Unique) {

            {$_ -eq 'System.String'} {

                if ($list -like '*.txt') {
                
                    Write-Host "File detected" 

                    $deviceList = Get-Content $list

                    $foundDeviceIDs = Invoke-DeviceSearch -findMe $deviceList

                } else {

                    Write-Host 'Single string or array detected'

                    $deviceList = $list 

                    $foundDeviceIDs = Invoke-DeviceSearch -findMe $deviceList

                }

            }

            {$_ -eq 'System.Management.Automation.PSCustomObject'} {

                Write-Host 'Custom Object detected'

            }

        }

    }

    if ($findDevice) {

        Try {
            
            $foundDeviceIDs = Invoke-DeviceSearch -findMe $findDevice
        
        }

        Catch {

            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Unable to get device list: [$errorMessage]" -Verbose
            
            #Resolve the log file
            Resolve-LogFile -logPath $logFile

            Break
        
        }

    }

    Switch ($action) { #Begin switch for PRTG action

        {$_ -eq 'Pause'} {

            if ($findDevice -or $List) {

                ForEach ($id in $foundDeviceIDs) {

                    $sensorID     = $null
                    $sensorID     = $id

                    $pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"
        
                    Write-LogFile -logPath $logFile -Value "Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]" -Verbose
            
                    $pauseAttempt = Invoke-WebRequest -Uri ($pauseURL + $credentialAppend)   
                
                } 

                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            } 
            
            Else { 

                $pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"
        
                Write-LogFile -logPath $logFile -Value "Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]" -Verbose
            
                $pauseAttempt = Invoke-WebRequest -Uri ($pauseURL + $credentialAppend)

                #Resolve the log file
                Resolve-LogFile -logPath $logFile 
            

            }

        }

        {$_ -eq 'Resume'} {

            if ($findDevice -or $List) {

                ForEach ($id in $foundDeviceIDs) {

                    $sensorID     = $null
                    $sensorID     = $id

                    $resumeURL     = "$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"
        
                    Write-LogFile -logPath $logFile -Value "Attempting to resume sensor ID: [$sensorID]." -Verbose
            
                    $resumeAttempt =  Invoke-WebRequest -Uri ($resumeURL + $credentialAppend)
                
                }
                
                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            } 
            
            Else { 

                $resumeURL     = "$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"
        
                Write-LogFile -logPath $logFile -Value "Attempting to resume sensor ID: [$sensorID]." -Verbose
            
                $resumeAttempt =  Invoke-WebRequest -Uri ($resumeURL + $credentialAppend)
            
                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            }

        }

        Default {

            Write-LogFileError -logPath $logFile -errorDesc "Unable to perform action [$action], as it does not match any valid actions!" -Verbose

            Resolve-LogFile -logPath $logFile 

            Break

        }

    } #End switch for PRTG action

} #End function Invoke-SensorModification

#Test API
Try{

    #Import modules 
    ForEach ($module in $modules) {Invoke-ModuleImport -modules $module}

    #Create log file
    $logFile = New-logFile -logPath $logDir -logName 'PSPRTG.log' -addDate:$true

    #Attempt to import/set credentials and get the returned string to append to the request for authentication
    $credentialAppend = Invoke-CredentialCheck
    
    #Attempt to get information as a test to see if the API will work
    Invoke-RestMethod -uri ($prtgInfo.SensorCountURL + $credentialAppend) | Out-Null

    #If it works, set TestPassed as true
    $prtgInfo | Add-Member -MemberType NoteProperty -Name TestPassed -Value $true
    
}

Catch {

    $errorMessage = $_.Exception.Message
    
    Write-LogFileError -logPath $logFile -errorDesc "Error while attempting to use API: [$errorMessage]" -Verbose

    #If it doesn't work, set TestPassed as false and break out
    $prtgInfo | Add-Member -MemberType NoteProperty -Name TestPassed -Value $false

    #Resolve the log file
    Resolve-LogFile -logPath $logFile

    Break

}

NinjaLogging.psm1

Set-StrictMode -Version Latest

$scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition

Switch ($MyInvocation.PSCommandPath) {

    {$_ -match '\\\\'} {
    
    
        $scriptName = $_.SubString($_.LastIndexOf('\')+1)
    
    }

    Default { 
    
        $scriptName = Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName
        
    }

}

if ('Count' -in ($scriptName.psobject.Members.Name)) {
    
    $scriptName = 'ScriptLog'
    
}

function New-LogFile {
<#
.SYNOPSIS
   New-LogFile will create a log file.

.DESCRIPTION
   New-LogFile will create a log file. 

   You can specify different paramaters to change the file's name, and where it is stored.
   By default it will attempt to get the name of the calling function or script via $scriptName = (Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName).
   It will also attempt to get the path via $scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition.
   You can also specify the path and name, as well as if you'd like to append the date in the following format: MM-dd-yy_HHmm.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the path to the logfile

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER scriptVersion
    Type : Double

    Specify the version of your script being run. If left blank, will default to 0.1

.PARAMETER addDate
    Type : Boolean

    Specify if you'd like to add the date to the file name.  If you're specifying logName, you can use addDate to append the current date/time in the format: MM-dd-yy_HHmm.

.NOTES
    Name: New-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    

.LINK
    http://www.gngrninja.com

.EXAMPLE
    $logFile = New-LogFile 
    -----------------------------
    
    gngrNinja> $logFile
    C:\PowerShell\logs\ScriptLog_05-11-16_1612.log

.EXAMPLE
    $logFile = New-LogFile -Verbose
    -----------------------------
    
    VERBOSE: No path specified. Using: C:\PowerShell\logs
    VERBOSE:
    VERBOSE: No log name specified. Setting log name to: ScriptLog.log and adding date.
    VERBOSE:
    VERBOSE: Adding date to log file with an extension! New file name:
    ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: Created C:\PowerShell\logs\ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: File C:\PowerShell\logs\ScriptLog_05-11-16_1613.log created and verified to
    exist.
    VERBOSE:
    VERBOSE: Adding the following information to:
    C:\PowerShell\logs\ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:13:11]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:
    gngrNinja>

.EXAMPLE
    $logfile = New-LogFile -Name 'testName.log' -path  'c:\temp' -addDate $true -Verbose
    -----------------------------

    VERBOSE: Adding date to log file with an extension! New file name:
    testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: Created c:\temp\testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: File C:\temp\testName_05-11-16_1615.log created and verified to exist.
    VERBOSE:
    VERBOSE: Adding the following information to: C:\temp\testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:15:13]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:

.OUTPUTS
    Full path to the log file created.
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $false,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Name')]
        [string]
        $logName,
        [Parameter(Mandatory = $false,
                   Position  = 2)]
        [double]
        $scriptVersion = 0.1,
        [Parameter(Mandatory = $false,
                   Position  = 3)]
        [boolean]
        $addDate = $false
    )

#Check if file/path are set
    if (!$logPath) {
        
        $logPath = "$scriptPath\logs"
        
        Write-Verbose "No path specified. Using: $logPath"
        Write-Verbose ""
        
    }
    
    if (!$logName) {
        
        $logName = $scriptName + '.log'
        $addDate = $true
        
        Write-Verbose "No log name specified. Setting log name to: $logName and adding date."
        Write-Verbose ""
      
    }

    #Check if $addDate is $true, take action if so
    if ($addDate) {
        
        if ($logName.Contains('.')) {
            
            $logName = $logName.SubString(0,$logName.LastIndexOf('.')) + "_{0:MM-dd-yy_HHmm}" -f (Get-Date) + $logName.Substring($logName.LastIndexOf('.'))
            
            Write-Verbose "Adding date to log file with an extension! New file name: $logName"
            Write-Verbose ""
           
        } else {
            
            $logName = $logName + "_{0:MM-dd-yy_HHmm}" -f (Get-Date)
            
            Write-Verbose "Adding date to log file. New file name: $logName"
            Write-Verbose ""
            
        }
         
    }
    
    #Variable set up
    $time     = Get-Date
    $fullPath = $logPath + '\' + $logName
    $curUser  = (Get-ChildItem Env:\USERNAME).Value
    $curComp  = (Get-ChildItem Env:\COMPUTERNAME).Value
    
    #Checking paths / Creating directory if needed
    
    if (!(Test-Path $logPath)) {
        
        Try {
            
            New-Item -Path $logPath -ItemType Directory -ErrorAction Stop | Out-Null
            
            Write-Verbose "Folder $logPath created as it did not exist."
            Write-Verbose ""
            
        }
        
        Catch {
            
            $message = $_.Exception.Message
            
            Write-Output "Could not create folder due to an error. Aborting. (See error details below)"
            Write-Error $message
            
            Break
          
        }
    
    }
    
    #Checking to see if a file with the name name exists, renaming it if so.
    if (Test-Path $fullPath) {
        
        Try {
            
            $renFileName = ($fullPath + (Get-Random -Minimum ($time.Second) -Maximum 999) + 'old')
            
            Rename-Item $fullPath -NewName ($renFileName.Substring($renFileName.LastIndexOf('\')+1)) -Force -ErrorAction Stop | Out-Null
            
            Write-Verbose "Renamed $fullPath to $($renFileName.Substring($renFileName.LastIndexOf('\')+1))"
            Write-Verbose ""
            
        }
        
        Catch {
            
            $message = $_.Excetion.Message
            
            Write-Output "Could not rename existing file due to an error. Aborting. (See error details below)"
            Write-Error $message
            
            Break
            
        }
        
    }
    
    #File creation
    Try {
        
        New-Item -Path $fullPath -ItemType File -ErrorAction Stop | Out-Null
        
        Write-Verbose "Created $fullPath"
        Write-Verbose ""
        
    } 
    
    Catch {
        
        $message = $_.Exception.Message
        
        Write-Output "Could not create directory due to an error. Aborting. (See error details below)"
        Write-Error $message
        
        Break
        
    }
    
    #Get the full path in case of dot sourcing
    $fullPath = (Get-ChildItem $fullPath).FullName
    
    if (Test-Path $fullPath) {
        
        $flairLength = ("Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]").Length + 1
        
        Write-Verbose "File $fullPath created and verified to exist."
        Write-Verbose ""
        Write-Verbose "Adding the following information to: $fullPath"
        Write-Verbose ""
        Write-Verbose ('-'*$flairLength)
        Write-Verbose "Started logging at [$time]"
        Write-Verbose "Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose ""
        
        Add-Content -Path $fullPath -Value ('-'*$flairLength)
        Add-Content -Path $fullPath -Value "Started logging at [$time]"
        Add-Content -Path $fullPath -Value "Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"
        Add-Content -Path $fullPath -Value ('-'*$flairLength)
        Add-Content -Path $fullPath -Value ""
        
        Return [string]$fullPath
         
    } else {
        
        Write-Error "File $fullPath does not exist. Aborting script."

        Break
        
    }
       
}

function Write-LogFile {
<#
.SYNOPSIS
   Write-LogFile will add information to a log file created with New-LogFile.

.DESCRIPTION
   Write-LogFile will add information to a log file created with New-LogFile.

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER logValue
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.NOTES
    Name: Write-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test log value!'
    -----------------------------

    gngrNinja> more $logfile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:19:37]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:23:24] test log value!

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test log value!' -Verbose
    -----------------------------
    
    VERBOSE: Adding [05-11-16 16:25:19] test log value! to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Get-Process | Write-LogFile $logFile -Verbose
    -----------------------------

    ...
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (wininit) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (winlogon) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WUDFHost) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    ...

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test without timestamp' -addTimeStamp $false -Verbose
    -----------------------------

    VERBOSE: Adding test without timestamp to C:\PowerShell\logs\ScriptLog_05-11-16_1631.log
    VERBOSE:
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $true,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory                       = $true,
                   ValueFromPipeline               = $true,
                   ValueFromPipelineByPropertyName = $true,
                   Position                        = 1)]
        [Alias('Value')]
        [string]
        $logValue,
        [Parameter(Mandatory = $false,
                   Position  = 2)]
        [boolean]
        $addTimeStamp = $true
    )
    
    Begin {

        if (!(Test-Path $logPath)) {
        
            Write-Error "Unable to access $logPath"

            Break
        
        } 

    }

    Process {

        ForEach ($value in $logValue) {
        
            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }
         
    }

}

function Write-LogFileError {
<#
.SYNOPSIS
   Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

.DESCRIPTION
   Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER errorDesc
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged as errors.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    This parameter let's you specify $true if you'd like to exit the script after the error is logged. 
    It defaults to $false.
    
.NOTES
    Name: Write-LogFileError
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFileError -logPath $logFile -errorDesc 'test log value error!'
    -----------------------------

    gngrNinja> more $logFile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:31:44]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:31:51] [ERROR ENCOUNTERED]: test log value error!
#>
    [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true,
                       Position  = 0)]
            [Alias('Path')]
            [string]
            $logPath,
            [Parameter(Mandatory                       = $true,
                       ValueFromPipeline               = $true,
                       ValueFromPipelineByPropertyName = $true,
                       Position                        = 1)]
            [string]
            $errorDesc,
            [Parameter(Mandatory = $false,
                       Position  = 2)]
            [boolean]
            $addTimeStamp = $true,
            [Parameter(Mandatory = $false,
                       Position  = 3)]
            [Alias('Exit')]           
            [boolean]
            $exitScript = $false
        )
    
    Begin {

        if (!(Test-Path $logPath)) {
    
            Write-Error "Unable to access $logPath"
            Break

        }

    }

    Process {
     
        ForEach ($value in $errorDesc) { 

            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            $value     = "[ERROR ENCOUNTERED]: $value"
        
            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }

    }

    End {
         
         if ($exitScript) {
            
            Write-Verbose "Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"
            Write-Verbose ""
            
            Resolve-LogFile -logPath $logPath -exitScript $true
            
        }

    }
 
    
    
}

function Write-LogFileWarning {
<#
.SYNOPSIS
   Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

.DESCRIPTION
   Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER warningDesc
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged as errors.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    This parameter let's you specify $true if you'd like to exit the script after the error is logged. 
    It defaults to $false.

.NOTES
    Name: Write-LogFileWarning
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFileWarning -logPath $logFile -warningDesc 'test log value warning!'
    -----------------------------

    gngrNinja> more $logFile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:38:29]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:38:48] [WARNING]: test log value warning!
#>
    [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true,
                       Position  = 0)]
            [Alias('Path')]
            [string]
            $logPath,
            [Parameter(Mandatory                       = $true,
                       ValueFromPipeline               = $true,
                       ValueFromPipelineByPropertyName = $true,
                       Position                        = 1)]
            [string]
            $warningDesc,
            [Parameter(Mandatory = $false,
                       Position  = 2)]
            [boolean]
            $addTimeStamp = $true,
            [Parameter(Mandatory = $false,
                       Position  = 3)]
            [Alias('Exit')]           
            [boolean]
            $exitScript = $false
        )
    
    Begin {

        if (!(Test-Path $logPath)) {
    
            Write-Error "Unable to access $logPath"

            Break

        }

    }

    Process {
     
        ForEach ($value in $warningDesc) { 

            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            $value     = "[WARNING]: $value"
        
            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }

    }

    End {
         
         if ($exitScript) {
            
            Write-Verbose "Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"
            Write-Verbose ""
            
            Resolve-LogFile -logPath $logPath -exitScript $true
            
        }

    }
    
}

function Resolve-LogFile {
<#
.SYNOPSIS
   Resolve-LogFile will resolve a created log file.

.DESCRIPTION
   Resolve-LogFile will resolve a created log file.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path, including name, to the log file to be resolved.

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    Specify $true if you'd like to exit the script after the log file is resolved. 
    It defaults to $false.

.NOTES
    Name: Resolve-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    
.LINK
    http://www.gngrninja.com

.EXAMPLE
    $logFile = New-LogFile 
    -----------------------------
    
    gngrNinja> $logFile
    C:\PowerShell\logs\ScriptLog_05-11-16_1612.log

.EXAMPLE

    $logFile = New-LogFile 
    Get-Process | Write-LogFile $logFile

    Resolve-LogFile $logFile
    -----------------------------
    
    ...
    [05-11-16 16:43:58] System.Diagnostics.Process (wininit)

    [05-11-16 16:43:58] System.Diagnostics.Process (winlogon)

    [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:43:58] System.Diagnostics.Process (WUDFHost)

    ---------------------------------------------
    Ended logging at [05/11/2016 16:44:01]
    ---------------------------------------------
#>
    [cmdletbinding()]
    param(
        [parameter(Mandatory = $true,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Exit')]
        [boolean]
        $exitScript = $false
    )
    
    $time = Get-Date
    
    if (Test-Path $logPath) {
        
        $flairLength = ("Finished processing at [$time]").Length + 1
        
        Write-Verbose "Adding the following content to: $logPath"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose "Ended logging at [$time]"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose ""
        
        Add-Content -Path $logPath -Value ('-'*$flairLength)
        Add-Content -Path $logPath -Value "Ended logging at [$time]"
        Add-Content -Path $logPath -Value ('-'*$flairLength)
   
    } else {
        
        Write-Error "Unable to access $logPath"

        Break
        
    }
    
    if ($exitScript) {
        
        Write-Verbose "Exiting on completion specified, exiting..."
        
        Exit
        
    } 
   
}

function Out-LogFile {
<#
.SYNOPSIS
   Out-LogFile will create, add to, and resolve a logfile.

.DESCRIPTION
   Out-LogFile will create, add to, and resolve a logfile.

   Value from the pipeline is accepted.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the path to the logFile you'd like created.

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER logValue
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.NOTES
    Name: Out-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    
.LINK
    http://www.gngrninja.com

.EXAMPLE
    $outLog = Get-Process | Out-LogFile -logPath c:\temp -logName 'outlog.log' -Verbose 
    -----------------------------
    
    VERBOSE: Created c:\temp\outlog.log
    VERBOSE:
    VERBOSE: File C:\temp\outlog.log created and verified to exist.
    VERBOSE:
    VERBOSE: Adding the following information to: C:\temp\outlog.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:58:43]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:58:43] System.Diagnostics.Process (AdobeUpdateService) to
    C:\temp\outlog.log
    VERBOSE:

.EXAMPLE
    $outLog = Get-Process | Out-LogFile -Verbose
    -----------------------------
    
    gngrNinja> more $outLog
    ...
    [05-11-16 16:56:02] System.Diagnostics.Process (winlogon)

    [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:56:02] System.Diagnostics.Process (WUDFHost)

    ---------------------------------------------
    Ended logging at [05/11/2016 16:56:02]
    ---------------------------------------------

gngrNinja>
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $false,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Name')]
        [string]
        $logName,
        [Parameter(Mandatory = $true,
                   ValueFromPipeLine               = $true,
                   ValueFromPipelineByPropertyName = $true,
                   Position                        = 2)]
        [Alias('Value')]
        [string]
        $logValue,
        [Parameter(Mandatory = $false,
                   Position  = 3)]
        [boolean]
        $addTimeStamp = $true
    )
    
    Begin {
        
        $logFile = New-LogFile -logPath $logPath -logName $logName

    }
    
    Process {

        ForEach ($value in $logValue) {
        
            Write-LogFile -logPath $logFile -logValue $value -addTimeStamp $addTimeStamp
        
        }


    }
    
    End {
    
        Resolve-LogFile $logFile 

        Return $logFile

    }
    
}

function Send-LogEmail {
    [cmdletbinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]
        $To,
        [Parameter(Mandatory=$true)]
        [string]
        $Subject,
        [Parameter(Mandatory=$true)]
        [string]
        $Body,
        [Parameter(Mandatory=$true)]
        [string]
        $emailFrom,
        [Parameter(Mandatory=$true)]
        [string]
        $emailUser,
        [Parameter(Mandatory=$false)]
        [string]
        $provider = 'gmail',
        [Parameter(Mandatory=$true)]
        $password = (Read-Host "Password?" -AsSecureString)
    )

    if (!$to)        {Write-Error "No recipient specified";break}
    if (!$subject)   {Write-Error "No subject specified";break}
    if (!$body)      {Write-Error "No body specified";break}
    if (!$emailFrom) {$emailFrom = 'Ninja_PS_Logging@gngrninja.com'}
   
    Switch ($provider) {

        {$_ -eq 'gmail'} {
            
            $SMTPServer   = "smtp.gmail.com"
            $SMTPPort = 587

        }

        {$_ -eq 'custom'} {

            $SMTPServer = 'Your.SMTP.Server'
            $SMTPPort   = 'Your.SMTP.Server.Port'

        }

    }

    $gmailCredential = New-Object System.Management.Automation.PSCredential($emailUser,$password)
    Send-MailMessage -To $to -From $emailFrom -Body $body -BodyAsHtml:$true -Subject $Subject -SmtpServer $smtpServer -Port $smtpPort -UseSsl -Credential $gmailCredential

}

Notes:

  • I've spent some time on the help with the NinjaLogging.psm1 module
    • Feel free to browse through and use it as you wish, let me know if you have any problems!
  • I have not finished the email sending feature
  • This code will eventually find its way to Github, and be a lot more polished

Homework

  • Find an API for an application you'd like control with PowerShell
    • Study up on the documentation, and find ways to send data to it
    • Automate something using what you've learned today

[Back to top]

PowerShell: Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

PowerShell: Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

Invoke-RestMethod

If Invoke-WebRequest had a brother, it would be Invoke-RestMethod. Well… brother, cousin, sister… you get the idea! They are related. Invoke-RestMethod allows us to send requests to REST web services (Representational State Transfer), and then returns to us a lovely object to use as we need to.

If we get an RSS feed as a response, we’ll get XML as the returned data type. When working with APIs, and using Invoke-RestMethod, you’ll get a custom object back. This is because more times than not we'll get the information back as JSON (JavaScript Object Notation). PowerShell automatically converts that into the custom object, which you can then dive into the properties of.

Here are the different methods we can send:

  • Default
  • Delete
  • Get
  • Head
  • Merge
  • Options
  • Patch
  • Post
  • Put
  • Trace

 

Whether we’re working with an RSS feed, or an API of sorts, Invoke-RestMethod allows us to do so with ease. After learning how to use it, it has quickly become one of my favorite commands in PowerShell.

Using Invoke-RestMethod

Let's go into some examples of how to use Invoke-RestMethod. To start out, we will use it to get some information via RSS, and then dive a little deeper into APIs.

RSS Feeds

Invoke-RestMethod can be used to gather information from RSS feeds. For this example, we'll get the feed for http://www.reddit.com/r/powershell. To do that, we simply append .rss to the URL.

Here's the command:

$redditRSS = Invoke-RestMethod 'http://www.reddit.com/r/PowerShell.rss'

Let's pipe our variable to Get-Member, and see what's up.

$redditRSS | Get-Member

It looks like we indeed have an XML object, and its associated methods/properties. 

Let's see what the data looks like!

$redditRSS

The object we have here has a lot of properties that we can dive into. 

For instance, let's say we want the author information for the first element in the array...

$redditRSS[0].author

Under the author property there are more properties that contain the information we're looking for. To access those, we need to drill down to them.

$redditRSS[0].author.name
$redditRSS[0].author.name.uri

This is part of the discovery you'll want to do when you receive an object back after using Invoke-RestMethod. Dig around, check documentation from the website if there is any, and discover all that you can. There is a lot of information returned, and the more you know, the more things you can do!

Let's dig into the content of the first array element.

$redditRSS[0].content

It looks like we want the '#text' property to see the actual returned content.

$redditRSS[0].content.'#text'

The content we received is in HTML. With some parsing we could make it more readable in the console.

Here's one way to clean it up, by stripping out the HTML tags:

$redditRSS[0].content.'#text' | ForEach-Object { $_ -replace '<[^>]+>','' }

With this object ($redditRSS[0]), we can also get the title and URL to the actual post.

$redditRSS[0].title
$redditRSS[0].link.href

The link value was buried in the href property under link.

What can we do with this information? Well... whatever you need/want to! That's the awesome part about PowerShell. If you need it to do something, there's a way to do it! We could setup a script that watches for a certain post with a specific title, and then have it email you or send you a text. 

You can even make a make-shift post explorer, wherein you can check out the posts and comments of a specific subreddit. I have some example code for doing that as the conclusion to this section.

Code for subreddit exploration:

function Invoke-RedditBrowsing { #Begin function Invoke-RedditBrowsing
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        [String]
        $subReddit
    )

    #Get the RSS feed for the subreddit requested
    $redditRSS = Invoke-RestMethod "http://www.reddit.com/r/$subReddit.rss"

    #This is a hashtable for the text we'll replace in order to make the comments more readable
    $textToReplace = [hashtable]@{

        '<[^>]+>' = ''
        '&quot;'  = '"'
        '&#39;'   = "'"
        '&#32;'   = ' '

    }

    #Make sure there is content before we proceed
    if ($redditRSS) { #Begin if for $redditRSS existence

        #Use the evil Write-Host to display information on what to do next
        Write-Host 'Select a [#] from the options below!' `n -ForegroundColor Black -BackgroundColor Green

        #Set $i to 0 so our loop iterates and displays $i correctly
        $i = 0 

        #ForEach $post in the subReddit's feed...
        ForEach ($post in $redditRSS) {

            #Use evil Write-Host to display the current value of $i and the title, then the author's name and post updated date
            Write-Host "[$i] -> $($post.Title)"
            Write-Host `t"By: [$($post.author.name)], Updated [$($post.updated)]"`n`n

            #Iterate $i by 1
            $i++

        }

        #Write-Host (new line) to make the next line look prettier
        Write-Host `n

        #Try / Catch to make sure we can convert the input to an integer. If not, we error out
        Try {
        
            #Ask for the post selection so we can proceed
            [int]$selection = Read-Host 'Which [#]? (any invalid option quits)' -ErrorAction SilentlyContinue

        }

        Catch {

            #If we can't make it an int... quit!
            Write-Host "Invalid option, quitting!" -ForegroundColor Red -BackgroundColor DarkBlue

            Break

        }
        
        #Switch statement for $selection. This makes sure it is in bounds for the array of posts.
        Switch ($selection) { #Begin Switch for the $selection value

            #if $i is less than or equal to the total number of values in the array $redditRSS...
            {$_ -le ($i - 1)} { #Begin option for valid selection

                #Get the comments RSS feed from the link provided
                $redditComments = Invoke-RestMethod -Uri "$($redditRSS[$_].link.href).rss"
                
                #Write-Host the title we'll be viewing comments for
                Write-Host `n
                Write-Host "Title: [$($redditRSS[$_].Title)]" -ForegroundColor Black -BackgroundColor Green

                #ForEach comment in the comments feed
                ForEach ($comment in $redditComments) {

                    #Null out anything set in $commentText
                    $commentText = $null

                    #Set the comment text as the property which contains the actual comment
                    $commentText = $comment.Content.'#text'

                    #Go through each key in the hashtable we created earlier
                    ForEach ($text in $textToReplace.Keys) {
            
                        #Re-create the $commentText variable while replace the key ($text), with the value ($textToReplace.$text)
                        #For example: it will match '<[^>]+>', then replace it with ''
                        $commentText = $commentText -replace ($text,$textToReplace.$text) 

                    }

                    #Use Write-Host to write out the author/comment text we cleaned up
                    Write-Host `n`n
                    Write-Host "By: [$($comment.author.name)]" -ForegroundColor Black -BackgroundColor Green
                    Write-Host `t"Comment: [$commentText]"

                }

            } #End option for valid selection

            #If the number does not match a value that is valid, quit!
            Default {

                Write-Host "Invalid option, quitting!" -ForegroundColor Red -BackgroundColor DarkBlue
        
                Break

            }

        } #End Switch for the $selection value

    #If there is nothing in $redditRSS... quit!
    } else {

        Write-Host `n"Unable to get RSS feed for [$subReddit]." -ForegroundColor Red -BackgroundColor DarkBlue

    } #End if $redditRSS

} #End function Invoke-RedditBrowsing

Invoke-RedditBrowsing

Now to run it! It will prompt for the subreddit, which can be any subreddit. I will go with PowerShell.

browse1.PNG

The code will then run, and get the list of posts:

I will choose option [0], which is the first post.

There you have it, one example of what you can do with the data returned via Invoke-RestMethod. The primary motivation for doing something like this, at least for me, is that I like to visualize the data. 

Using Write-Host is not suitable for automation, as it will not be seen by anyone. There are better options as well when you want to make specific things verbose, and others specifically for debug / errors. So why use it? Well, when visualizing the data you have available, it really can help certain bits of text stand out. 

With a visual representation of what's available, it can lead to even more discovery and experimenting, which can in turn lead to more script ideas and ways to handle the data later.

Invoke-RestMethod and APIs

Invoke-RestMethod allows us to work with REST APIs with relative ease. When working with APIs, the most important thing is to familiarize yourself with their documentation. I'll link to the documentation for the different APIs I'll be using here.

Example 1: No Authentication

For this example we will use Weather Underground's Autocomplete API. This API returns a list of locations that best match the input it receives. It can be used in tandem with their full API to get the weather information.

Let's try the following:

$city          = 'Chicago'

$lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

$lookupResults

Looks like we have an object we can explore now! 

$lookupResults | Get-Member

It is a custom object with one property (RESULTS). Let's check it out.

$lookupResults

Quite a few results! We can access the first result by using $lookupResults.RESULTS[0].

The 'l' property is something we will actually use later when getting a forecast for Chicago.

Let's wrap this up in a nice little function.

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

Now we can use (with that function in memory): 

Get-WeatherCity -city Chicago

Example 2: API With a Key

For this example I will use Weather Underground's free API (for developers). To sign up for a key, go here. For the API's documentation, go here.

We'll use the following base code along side this example: 

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

With that function in memory, let's get a returned object for Chicago.

$cityInfo = Get-WeatherCity Chicago

Now for the setup to use the API to get weather information.

This code is one time code to keep my key secure so somebody reading the script can't see it in plain text. 

To do this I will:

  • Set the API key as a secure string in a variable
  • Create a credential object to store the key
  • Export that credential object via Export-Clixml
$apiKey          = 'EnterYourKeyHere' | ConvertTo-SecureString -AsPlainText -Force
$apiCredential   = New-Object System.Management.Automation.PSCredential('apiKey',$apiKey)
$apiCredential | Export-Clixml .\apiCred.xml

With that one time setup out of the way, you can then use:

$apiKey = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password

The key will be available to the script (as plain text), but it is stored in the $apiKey variable.

With all the setup done, here's the block of code we will be running:

$apiKey          = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password
$baseURL         = 'http://api.wunderground.com/api/'
$cityInfo        = Get-WeatherCity Chicago
$fullURL         = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($cityInfo.l).json"
$weatherForecast = Invoke-RestMethod -Uri $fullURL

With the above code executed, $weatherForecast will now contain a plethora of data for us to work with.

$weatherForecast | Get-Member

This is one rich object! For alerts, you can use: $weatherForecast.alerts.

You can do a lot with the information available. I wrote a script that uses it to email a forecast (just the way we want it) to my significant other and myself. I took a parts of the script, and heavily modified them to fit this example. In the following example we will:

  • Setup the API information needed
  • Set a limit on use during the script's execution to 10
  • Declare 3 cities we want the forecast for
  • Create a PowerShell class (PowerShell version 5 only!)
    • This class will have a method that formats the forecast in HTML
  • Lookup the city information
  • Pass the city information to a function that gathers the API data
  • Create an object using the class
  • Loop through the cities, get the information, update the object we created with the class, and export the HTML forecast after using the object's method to create it

Here's the code:

$apiKey          = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password
$baseURL         = 'http://api.wunderground.com/api/'
$cityInfo        = Get-WeatherCity Chicago
$fullURL         = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($cityInfo.l).json"
$weatherForecast = Invoke-RestMethod -Uri $fullURL
$citiesToGet     = ('Chicago','San Francisco','Portland')
$script:apiLimit = 10

Class Weather { #Begin Weather Class

    [pscustomobject]$apiData

    [string[]]formatForecastHTML() { #Begin method formatForecastHTML

        [System.Collections.ArrayList]$alertText    = @()
        [System.Collections.ArrayList]$forecastText = @() 
        $phases                                     = $null
        $todayForecast                              = $this.apiData.forecast.simpleforecast.forecastday
        $city                                       = $this.apiData.current_observation.display_location.full
        $selCam                                     = Get-Random $this.apiData.webcams.count
        $camImg                                     = $this.apiData.webcams[$selCam].CURRENTIMAGEURL
        $camName                                    = $this.apiData.webcams[$selCam].linktext
        $camLink                                    = $this.apiData.webcams[$selCam].link
        $curAlerts                                  = $this.apiData.alerts 
            
        if ($curAlerts) {

        foreach ($alert in $CurAlerts) {
            $typeName   = $null
            $typeName   = Get-WeatherInfo -Weather 'alert' -value $alert.type

            $alertText.Add(@"
<p><b><font color=`"red`">Weather Alert! ($typeName)</font></b></p>
<p>Date: $($alert.date) Expires: $($alert.Expires)</p>
<p>$($alert.Message)</p>
"@) | Out-Null              
                            
        } 

        }    
        
        $phases = @"
<p><b>Sun Information</b></p><p></p>
<p>Sunrise: $($this.apiData.sun_phase.sunrise.hour):$($this.apiData.sun_phase.sunrise.minute)</p>
<p>Sunset: $($this.apiData.sun_phase.sunset.hour):$($this.apiData.sun_phase.sunset.minute)</p>
<p></p>
<p><b>Moon Information</b></p><p></p>
<p>Moonrise: $($this.apiData.moon_phase.moonrise.hour):$($this.apiData.moon_phase.moonrise.minute)</p>
<p>Moonset: $($this.apiData.moon_phase.moonset.hour):$($this.apiData.moon_phase.moonset.minute)</p>
<p>Age: $($this.apiData.moon_phase.ageOfMoon) Days</p>
<p>Phase: $($this.apiData.moon_phase.phaseofMoon)</p>
<p>Illumination: $($this.apiData.moon_phase.percentIlluminated)%</p>
"@

        foreach ($day in $todayForecast) {
   
            $dayImg          = $day.icon_url
            $dayMonth        = $day.date.monthname
            $dayDay          = $day.date.day
            $dayName         = $day.date.weekday
            $dayHigh         = $day.high.fahrenheit  
            $dayLow          = $day.low.fahrenheit
            $maxWind         = $day.maxwind.mph
            $aveWind         = $day.avewind.mph
            $aveHum          = $day.avehumidity
            $conditions      = $day.conditions
            [int]$dayPrecip  = $day.pop
                
            $popText = Get-WeatherInfo -Weather 'preciptext' -value $dayPrecip
            
            $forecastText.Add(@"
<p></p>
<p></p>
<p><b>$dayName, $dayMonth $dayDay</b></p>
<p><img src=`"$dayImg`">$conditions</p>
<p>Chance of precipitation: $dayPrecip% / $popText</p>
<p>High: $dayHigh`F Low: $dayLow`F</p>
<p>Ave Winds: $aveWind`mph Max Winds: $maxWind`mph</p>
<p>Humidity: $aveHum%</p>
"@) | Out-Null                           

        }

        $body = @"
<p></p>
<p>Here is your 4 day forecast!</p>
<p>Random webcam shot from: <a href=`"$camLink`">$camName</a></p>
<p><img src=`"$camImg`"></p>
$($phases       | Out-String)
$($alertText    | Out-String)
$($forecastText | Out-String)
"@

        Return $body 

    } #End method formatForecastHTML

    [string[]]formatHourlyForecastHTML() { #Begin Class format4DayForecast

        [System.Collections.ArrayList]$alertText    = @()
        [System.Collections.ArrayList]$forecastText = @() 
        $phases                                     = $null
        $todayForecast                              = $this.apiData.forecast.simpleforecast.forecastday
        $city                                       = $this.apiData.current_observation.display_location.full
        $selCam                                     = Get-Random $this.apiData.webcams.count
        $camImg                                     = $this.apiData.webcams[$selCam].CURRENTIMAGEURL
        $camName                                    = $this.apiData.webcams[$selCam].linktext
        $camLink                                    = $this.apiData.webcams[$selCam].link
        $curAlerts                                  = $this.apiData.alerts 
        $hourlyForecast                             = $this.apiData.hourly_forecast
            
        if ($curAlerts) {

            foreach ($alert in $CurAlerts) {
            
                $typeName   = $null
                $typeName   = Get-WeatherInfo -Weather 'alert' -value $alert.type
            
                $alertText.Add(
@"
<p><b><font color=`"red`">Weather Alert! ($typeName)</font></b></p>
<p>Date: $($alert.date) Expires: $($alert.Expires)</p>
<p>$($alert.Message)</p>
"@) | Out-Null              
                            
            } 

        }  
         
        $phases = @"
<p><b>Sun Information</b></p><p></p>
<p>Sunrise: $($this.apiData.sun_phase.sunrise.hour):$($this.apiData.sun_phase.sunrise.minute)</p>
<p>Sunset: $($this.apiData.sun_phase.sunset.hour):$($this.apiData.sun_phase.sunset.minute)</p>
<p></p>
<p><b>Moon Information</b></p><p></p>
<p>Moonrise: $($this.apiData.moon_phase.moonrise.hour):$($this.apiData.moon_phase.moonrise.minute)</p>
<p>Moonset: $($this.apiData.moon_phase.moonset.hour):$($this.apiData.moon_phase.moonset.minute)</p>
<p>Age: $($this.apiData.moon_phase.ageOfMoon) Days</p>
<p>Phase: $($this.apiData.moon_phase.phaseofMoon)</p>
<p>Illumination: $($this.apiData.moon_phase.percentIlluminated)%</p>

"@            
        foreach ($hour in $hourlyForecast) {

            $prettyTime       = $hour.fcttime.pretty
            $hourTemp         = $hour.temp.english  
            $hourImg          = $hour.icon_url
            $hourChill        = $hour.windchill.english
            if ($hourChill -eq -9999) {
                
                $hourChill = 'N/A'
                    
            } else {
                    
                $hourChill = $hourChill + 'F'
                
            }

            $hourWind         = $hour.wspd.english
            $windDir          = $hour.wdir.dir
            $hourUV           = $hour.uvi
            $dewPoint         = $hour.dewpoint.english
            $hourFeels        = $hour.feelslike.english
            $hourHum          = $hour.humidity
            $conditions       = $hour.condition
            [int]$hourPrecip  = $hour.pop
                
            $popText = Get-WeatherInfo -Weather 'preciptext' -value $hourPrecip
            $forecastText.Add(
@"
<p></p>
<p></p>
<p><b>$prettyTime</b></p>
<p><img src=`"$hourImg`">$conditions</p>
<p>Chance of precipitation: $hourPrecip% / $popText</p>
<p>Current Temp: $hourTemp`F Wind Chill: $hourChill Feels Like: $hourFeels`F</p>
<p>Dew Point: $dewPoint</p>
<p>Wind Speed: $hourWind`mph Direction: $windDir</p>
<p>Humidity: $hourHum%</p>
<p>UV Index: $hourUV     
"@) | Out-Null                

        }

        $body = 
@"
<p></p>
<p>Here is your hourly forecast!</p>
<p>Random webcam shot from: <a href=`"$camLink`">$camName</a></p>
<p><img src=`"$camImg`"></p>
<p>$city Radar:</p>
<p><img src=`"$($this.radarURL)`"></p>
$($phases       | Out-String)
$($alertText    | Out-String)
$($forecastText | Out-String)
"@
        return $body 

    } #End method formatHourlyForecastHTML

    Weather([String]$apiURL, [String]$radarURL, [PSCustomObject]$apiData) {
        
        $this.radarURL = $radarURL
        $this.apiData  = $apiData

    }

    Weather() {}

} #End class Weather

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

function Get-WeatherAPIData { #Begin function Get-WeatherAPIData
    [cmdletbinding()]
    param(
        [parameter(Mandatory)]
        [PSCustomObject]
        $city
    )
    
    [System.Collections.ArrayList]$weatherCity = @()
    $weatherProperty                           = $null
    $weatherO                                  = $null

    $fullURL  = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($city.l).json"
    $radarURL = "http://api.wunderground.com/api/$apiKey/animatedradar/animatedsatellite" + "$($city.l).gif?num=6&delay=50&interval=30"

    Try {

        if ($script:apiCount -le $apiLimit) {

            Write-Host "Attempting to get API information for: [$($city.Name)]."  
                  
            $weatherForecast = Invoke-RestMethod -Uri $fullURL 
                
            $script:apiCount++

            $weatherForecast | Export-Clixml ("$outputDir\$($city.Name)APIData{0:MM-dd-yy_HHmm}.clixml" -f (Get-Date)) 

            Write-Host "The API has been used [$script:apiCount] times, against the limit of [$apiLimit]"

        } else {

            Write-Host "The API limit of [$apiLimit] has been reached!" -ForegroundColor Red -BackgroundColor DarkBlue

            Break

        }

    }

    Catch {

        $errorMessager = $_.Exception.Message 

        Write-Host "Unabled to get API data from Weather Underground: [$($_.Exception.Message)]." -ForegroundColor Red -BackgroundColor DarkBlue

    }

    if ($weatherForecast.forecast) {

        Write-Host "API information found for: [$($city.Name)]"

        $weatherO = [PSCustomObject]@{

            APIData  = $weatherForecast
            RadarURL = $radarURL 

        }

        $weatherCity.Add($weatherO) | Out-Null

        Return $weatherCity

    } else {

        Write-Host "API information not found for: [$($city.Name)]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
  
} #End function Get-WeatherAPIData

function Get-WeatherInfo { #Begin function Get-WeatherInfo
    [cmdletbinding()]
    param(
        [string]
        $weather,
        $value       
    )
    
    Switch ($weather) {
        
        {$_ -eq 'preciptext'} {
        
            Switch ($value) {
                
                {$_ -lt 20} {
                    
                    $popText = 'No mention'
                    
                }
                
                {$_ -eq 20} {
                    
                    $popText = 'Slight Chance'
                    
                }
                
                {($_ -lt 50 -and $_ -gt 20)} {
                    
                    $popText = 'Chance'
                    
                }
                
                {$_ -eq 50} {
                    
                    $popText = 'Good chance'
                    
                }
                
                {($_ -lt 70 -and $_ -gt 50)} {
                    
                    $popText = 'Likely'
                    
                }
                
                {$_ -ge 70} {
                    
                    $popText = 'Extremely likely'
                    
                }
                
            }
            
            Return $popText
            
        }   
        
        {$_ -eq 'alert'}    {
            
            Switch ($value) {
                
                'HEA' {$typeName = 'Heat Advisory'}
                'TOR' {$typeName = 'Tornado Warning'}
                'TOW' {$typeName = 'Tornado Watch'}
                'WRN' {$typeName = 'Severe Thunderstorm Warning'}
                'SEW' {$typeName = 'Severe Thunderstorm Watch'}
                'WIN' {$typeName = 'Winter Weather Advisory'}
                'FLO' {$typeName = 'Flood Warning'}
                'WAT' {$typeName = 'Flood Watch / Statement'}
                'WND' {$typeName = 'High Wind Advisory'}
                'SVR' {$typeName = 'Severe Weather Statement'}
                'HEA' {$typeName = 'Heat Advisory'}
                'FOG' {$typeName = 'Dense Fog Advisory'}
                'SPE' {$typeName = 'Special Weather Statement'}
                'FIR' {$typeName = 'Fire Weather Advisory'}
                'VOL' {$typeName = 'Volcanic Activity Statement'}
                'HWW' {$typeName = 'High Wind Warning'}
                'REC' {$typeName = 'Record Set'}
                'REP' {$typeName = 'Public Reports'}
                'PUB' {$typeName = 'Public Information Statement'}
                    
            }
                
            Return $typeName
    
        }    
    }
    
} #End function Get-WeatherInfo

$weatherObject = [Weather]::New()

Write-Host `n`n

ForEach ($cityName in $citiesToGet) {

    $findCity    = Get-WeatherCity -city $cityName
    $weatherCity = Get-WeatherAPIData -city $findCity -ErrorAction Stop    

    if ($weatherCity) {

        $weatherObject.apiData  = $null
        $weatherObject.apiData  = $weatherCity.APIData

        $foreCastFile = $null
        $foreCastFile = ".\$cityName.html"

        Write-Host `n"Exporting HTML formatted forecast to [$foreCastFile]."`n

        $weatherObject.formatHourlyForecastHTML() | Out-File ".\$cityName.html"

    } else {

        Write-Host "No data in [`$weatherCity] variable, halting execution on [$($userEmail.FirstName) $($userEmail.LastName)]." -ForegroundColor Red -BackgroundColor DarkBlue
                
        Break

    }

}

Write-Host `n`n

There should be 3 files in the folder now.

There they are. Let's take a look at Chicago's forecast.

You can do anything you put your mind to with the data received.

Homework

  • Explore the class created here to see how the information in the HTML generated forecast is created.
    • A lot of this information is transferable to functions and other methods of creating the HTML formatted forecast if you do not have PowerShell version 5.
  • See if a web service you use often has an API, read up on the documentation, and see if there's something you can automate doing with PowerShell!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

PowerShell: Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

Welcome to my Getting Started with Windows PowerShell series!

In case you missed the earlier posts, you can check them out here:

We will be exploring:

Coming in Parts 2 and 3:

  • Using Invoke-RestMethod
    • Working With RSS Feeds
    • Working with APIs
    • ...and more!
  • Controlling the Internet Explorer COM Object
    • Controlling IE
    • Navigating to pages
    • Logging in to pages

Why Work With the Web in PowerShell?

PowerShell can do quite a bit with web sites, and web services. 

Some of what we can do includes:

  • Checking if a website is up
  • Downloading files
  • Logging in to webpages
  • Parsing the content we receive
  • Utilizing REST methods

Learning how to use web services with PowerShell can expand your realm of possibility when scripting.

Using Invoke-WebRequest

Invoke-WebRequest is a command that allows us to retrieve content from web pages. The methods supported when using Invoke-WebRequest are:

  • Trace
  • Put
  • Post
  • Options
  • Patch
  • Merge
  • Delete
  • Default
  • Head
  • Get (Default)

Let's take a look at some different ways to utilize Invoke-WebRequest

Downloading a File

Let's download a file! In this particular example, we will download an addon for World of Warcraft.

The page we'll be looking at is http://www.tukui.org/dl.php.

In particular, let's get the ElvUI download.

PowerShell setup:

$downloadURL     = 'http://www.tukui.org/dl.php'
$downloadRequest = Invoke-WebRequest -Uri $downloadURL

Here I set the $downloadURL variable to store the main page we want to go to, and then use $downloadRequest to store the results of Invoke-WebRequest (using the $downloadURL as the URI).

Let's take a look at what's in $downloadRequest.

We'll go over some of the other properties later, and focus on Links for now. The Links property returns all the links found on the web site. $downloadRequest.Links

The list continues, but you get the idea. This is one of my favorite things about the Invoke-WebRequest command. The way it returns links is very easy to parse through.

Let's hunt for the ElvUI download link. To do that we'll pipe $downloadRequest.Links to Where-Object, and look for links that contain a phrase like Elv and Download.

$downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}

Got it! Now let's store that href property in a variable.

$elvLink         = ($downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}).href

That variable will now contain the download link.

Next we'll use Invoke-WebRequest again to download the file. There are two ways we can get the file:

  • Using Invoke-WebRequest to store the results in a variable,  and then write all the bytes to a file using the Contents property (which is a byte array).
  • Using Invoke-WebRequest with the -OutFile parameter set as the full path of the download. With this option we'll want to use -PassThru so we can still get the results from Invoke-WebRequest (otherwise success = empty result/no object returned).

Using the Contents property and writing the bytes out

$fileName        = $elvLink.Substring($elvLink.LastIndexOf('/')+1)
$downloadRequest = Invoke-WebRequest -Uri $elvLink 
$fileContents    = $downloadRequest.Content

The above code takes the link we stored, and gets the file name from it using LastIndexOf, and SubString.

It then stores the download request results in $downloadRequest

Finally, we get the contents (which is a byte array, if all went well), and store that in $fileContents.

The last thing we'll need to do is write the bytes out. To do that we'll use [io.file]WriteAllBytes(path,contents) to write the byte array to a file.

[io.file]::WriteAllBytes("c:\download\$fileName",$fileContents)

Let's run the code now, and see what happens!

Now we should have a file in "C:\download\"...

There it is! 

The $downloadRequest variable stores the results from the request, which you can use to validate accordingly. 

Using Invoke-WebRequest with -OutFile

Another way to download the file would be to use the -OutFile parameter with Invoke-WebRequest. We'll want to set the filename first:

$fileName        = $elvLink.Substring($elvLink.LastIndexOf('/')+1)

This is the same code from the previous example, and it uses LastIndexOf, and SubString.

Here's the code to download the file:

$downloadRequest  =  Invoke-WebRequest -Uri $elvLink -OutFile "C:\download\$fileName" -PassThru

Note that we used -PassThru as well. That is so we can still see the results of the request in the variable $downloadRequest. Otherwise a successful result would return no object, and your variable would be empty.

Let's see if that worked!

It did, and it was a bit easier than the previous example.

Downloading Files With a Redirect

Let's take a look at downloading files from sites that have a redirect. For this example I will use downloading WinPython from https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4. Note: Invoke-WebRequest is typically great at following redirected links. This example is here to show you how to retrieve redirected links, as well as a cleaner way to get the file.

Here is what a typical page that has a redirected download looks like:

Invoke-WebRequest includes a parameter that will force the maximum number of times it will accept redirection (-MaximumRedirection). We'll set that to 0, which will error it out, and use -ErrorAction SilentlyContinue to allow the error to be ignored. I will also use the parameter -UserAgent to send along the user agent string for FireFox. If we do not do this, the download will not work on this website.

Here's the initial code:

I set the URL we want to download from and store it in $downloadURL

$downloadURL     = 'https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4'

Then I store the result of our Invoke-WebRequest command in $downloadRequest.

$downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

Let's take a closer look at the Invoke-WebRequest line.

  • -MaximumRedirection 0
    • This ensures we do not get automatically redirected. Setting it to 0 forces no re-direction, and allows us to manually collect the redirection data.
  • -ErrorAction SilentlyContinue
    • This tells PowerShell to ignore the redirection error message. The downside to this is that it will be hard to capture any other errors. This is the only way I was able to find that keeps information in the variable $downloadRequest.
  • -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
    • This sends the user agent string for FireFox along in the request. We can see what [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox resolves to by simply typing(or pasting) it in the console.

Now we have some helpful information in our $downloadRequest variable, assuming all went well.

$downloadRequest.StatusDescription should be "Found".

Good! The redirect link is stored in the header information, accessible via $downloadRequest.Headers.Location.

I did some digging in the Content property, and found the string that matches the file name. I then added some code for the $fileName variable that looks for a string that matches the file name, and selects the matched value.

$fileName        = (Select-String -Pattern 'WinPython-.+exe' -InputObject $downloadRequest.Content -AllMatches).Matches.Value

Now that we have this information, we're ready to continue! I used a couple Switch statements to add some logic, in case the responses aren't what we expected.

Here's the full code for this example:

$downloadURL     = 'https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4'
$downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue
$fileName        = (Select-String -Pattern 'WinPython-.+exe' -InputObject $downloadRequest.Content -AllMatches).Matches.Value

Switch ($downloadRequest.StatusDescription) {

    'Found' {
        
        Write-Host "Status Description is [Found], downloading from redirect URL [$($downloadRequest.Headers.Location)]."`n
        $downloadRequest = Invoke-WebRequest -Uri $downloadRequest.Headers.Location -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox 

    }

    Default {

        Write-Host "Status Descrption is not [Found], received: [$($downloadRequest.StatusDescription)]. Perhaps something went wrong?" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/octet-stream' {

        Write-Host 'Application content type found!'`n
        Write-Host "Writing [$fileName] to [C:\download]"`n

        [io.file]::WriteAllBytes("c:\download\$fileName",$downloadRequest.Content)

    }

    Default {

        Write-Host "Content type is not an application! Not extracting file." -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

The first Switch statement ensures that the StatusDescription is "Found", then sets $downloadRequest as the result of the Invoke-WebRequest command that now points to the redirect URL. If the StatusDescription is not found, you'll see a message stating that something went wrong.

We then use a Switch statement that ensures our downloaded content (in $downloadRequest) has the Content Type of "application/octet-stream". If it is, we write the file out using [io.file]WriteAllBytes(path,contents).

Let's run the code, and then look in "C:\download\" to verify the results!

While it downloads, this progress indicator is displayed (Sometimes it will not match the actual progress):

Looks like everything worked! One last place to check.

We got it. All 277MB downloaded and written to the appropriate location.

Parsing Content

Using Invoke-WebRequest, the content of the request is returned to us in the object. There are many ways to go through the data. In this example I will demonstrate gathering the titles and their associated links from the PowerShell subreddit.

Here's the setup:

$parseURL    = 'http://www.reddit.com/r/powershell'
$webRequest  = Invoke-WebRequest -Uri $parseURL

Now let's take a look at the $webRequest variable.

  • RawContent
    • This is the content returned as it was received, including header data. 
  • Forms
    • This property contains any discovered forms. We'll go over this portion in more detail when we log in to a website.
  • Headers
    • This property contains just the returned header information.
  • Images
    • This property contains any images that were able to be discovered.
  • InputFields
    • This property returns discovered input fields on the website.
  • Links
    • This returns the links found on the website, in an easy format to iterate through.
  • ParsedHTML
    • This property allows you to access the DOM of the web site. DOM is short for Document Object Model. Think of DOM as a structured representation of the data on the website.

As always, if you want to see what other properties were returned, and any methods available, pipe $webRequest to Get-Member.

As you can see there are a few more properties that exist, but we'll be focusing on the ones described above in this article.

Now to get the title text from the current posts at http://www.reddit.com/r/powershell.

The fastest way to narrow it down, is to launch a browser and take a look at the DOM explorer. In Edge I used [F12] to launch the developer tools, and then used the [Select Element] option in the [DOM Explorer] tab. I then selected one of the posts to see what it looked like. 

It looks like the link is under a class named title, and the tag <p>. 

Let's use the ParsedHTML property to access the DOM, and look for all instances of <p> where the class is title.

$webRequest.ParsedHTML.getElementsByTagName('p') | Where-Object {$_.ClassName -eq 'title'}

The results should look similar to this (quite a lot of text will scroll by):

To verify if this is just the title information, let's pipe the above command to | Select-Object -ExpandProperty OuterText

$webRequest.ParsedHTML.getElementsByTagName('p') | Where-Object {$_.ClassName -eq 'title'} | Select-Object -ExpandProperty OuterText

Awesome, looks like what we want to see!

Let's store all those titles (minus the (text)) at the end, in $titles.

The biggest problem I encountered was just getting the title names (minus the text after at the very end such as: (self.PowerShell)), while also not omitting results that had (text) in other places. Here is the solution I came up with to store all the post titles in the variable $titles.

$titles      = $webRequest.ParsedHTML.getElementsByTagName('p') | 
               Where-Object {$_.ClassName -eq 'title'}          |                    
               ForEach-Object {
               
                    $splitTitle = $null
                    $splitCount = $null
                    $fixedTitle = $null

                    $splitTitle = $_.OuterText.Split(' ')
                    $splitCount = $splitTitle.Count

                    $splitTitle[($splitCount - 1)] = $null

                    $fixedTitle = ($splitTitle -join ' ').Trim()

                    Return $fixedTitle

               }

In the above command, I piped our title results to ForEach-Object, and then used some string manipulation to split the title into an array, null out the last entry in the array, join the array back, and finally trim it so there is no extra white space.

Now let's take a look at our $titles variable.

Perfect! The next step is matching the titles up with the property Links in our $webRequest variable. Remember that $webRequest.Links contains all the links on the web site.

After some digging, I found that the link property of outerText matches the titles in our $titles variable. Now we can iterate through all the titles in $titles, find the links that match, and create a custom object to store an index, title, and link.

We will need to do some more string manipulation to get the link out of the outerHTML property. It's all doable, though! 

Finally, we'll store the custom object in an array of custom objects. 

Here is the full code (with comments to explain what is happening):

#Ensure i is set to 0 so it iterates properly in the loop below
$i = 0
#Create the .Net ArrayList object to store our custom objects
[System.Collections.ArrayList]$prettyLinks = @()
#Iterate through all the titles
ForEach ($title in $titles) {

    #Search through the Links property of $webRequest and take action where the outerText equals the title
    #For each of the found requests (each link that matches)...
    $webRequest.Links  | Where-Object {$_.outerText -eq $title} | ForEach-Object {
    
    #Null out variables used in this loop
    $linkObject = $null
    $linkURL    = $null
    $titleText  = $null

    #Set the link URL as $link using the outerHTML property of the Link
    $link       = $_.outerHTML
    #Set the title text as $titleText using the outerText property of the Link
    $titleText  = $_.outerText

    #Split the link where there are spaces, and keep the part that matches href
    $link       = $link.Split(' ') | Where-Object {$_ -match '^href'}
    #Manipulate the link to get just the url, and trim and extra quotes
    $link       = $link.SubString($link.IndexOf('"')).Trim('"')

    #If the link is a link to an external source (starts with http:// or https://), then it is a full link, set $linkURL to $link
    if ($link -match '^https?://.+') {

        $linkURL = $link
    
    #Else it is a link to this subreddit as a self post, $linkURL prepends 'http://www.reddit.com' to the $link (which is /r/powershell/...)
    } else {

    
        $linkURL = ('http://www.reddit.com' + $link)

    }
    
    #Create a custom object to store the index ($i), the title, and link
    $linkObject = [PSCustomObject]@{

        Index = $i
        Title = $titleText
        Link  = $linkURL

    }

    #Add the object to our object array
    $prettyLinks.Add($linkObject) | Out-Null

    #Add 1 to $i
    $i++    
   
    }  

}

Let's run the code, and then take a look at our $prettyLinks variable.

That looks good, and the object is at our disposal for whatever we'd like to do with the information.

For an example on how the code can be used, check this out!

$browsing = $true

While ($browsing) {

    $selection = $null

    Write-Host "Select a [#] from the titles below!"`n -ForegroundColor Black -BackgroundColor Green

    ForEach ($pretty in $prettyLinks) {

        Write-Host "[$($pretty.Index)] $($pretty.Title)"`n

    }

    Try {
        
        [int]$selection = Read-Host 'Which [#]? "q" quits'

        if ($selection -le ($prettyLinks.Count -1)) {

            Start-Process $prettyLinks[$selection].Link 

        } else {

            $browsing = $false

            Write-Host '"q" or invalid option selected, browsing aborted!' -ForegroundColor Red -BackgroundColor DarkBlue
            
        }

    }

    Catch {

        $browsing = $false

        Write-Host '"q" or invalid option selected, browsing aborted!' -ForegroundColor Red -BackgroundColor DarkBlue        

    }

}

The above code creates a loop until the user inputs "q", or an invalid option. It will list out all of the titles, and then ask you for the number of the one you want to look at. Once you input the number, it will launch your default browser to the title's associated link.

This is but one of many examples of how the data can be used. Check out these screenshots to see the code in action.

Let's select 17.

Here's what happens if you put "q".

Working With Forms

Invoke-WebRequest can also work with form data. We can get the forms from the current request, manipulate the data, and then submit them.

Let's take a look at a simple one, searching Reddit.

$webRequest = Invoke-WebRequest 'http://www.reddit.com'

Let's take a look at $webRequest.Forms:

Now that we know that the search form is the first array value, let's declare $searchForm as $webRequest.Forms[0].

$searchForm = $webRequest.Forms[0]

Now $searchForm will contain the form we care about.

Here are the properties we see at a glance:

  • Method
    • This is the method we'll use when sending the request with the form data.
  • Action
    • This is the URL used with the request. Sometimes it is the full URL, other times it is part of a URL that we need to concatenate together with the main URL.
  • Fields
    • This is a hash table that contains the fields to be submitted in the request.

Here are the values in $searchForm.Fields

Let's set the value of "q" to what we'd like to search for. I will set it to: "PowerShell".

$searchForm.Fields.q = 'PowerShell'

It's always good to verify things are as they should be. 

$searchForm.Fields

That looks good! Now to format our next request, and search Reddit!

$searchReddit = Invoke-WebRequest -Uri $searchForm.Action -Method $searchform.Method -Body $searchForm.Fields

In this request, the following parameters are set:

  • -Uri
    • We use $searchForm.Action  for this as that contains the full URL we need to use.
  • -Method
    • We use $searchForm.Method for this. Technically it would default to using Get, but that is not the case for all forms. It is good to use what the form states to use.
  • -Body
    • We use $searchForm.Fields for this, and the body is submitted as the hash table's key/value pairs. In this case that is "q" = "PowerShell". 

Now that we have the results in $searchReddit, we can validate the data by taking a look at the links.

$searchReddit.Links | Where-Object {$_.Class -eq 'search-title may-blank'} | Select-Object InnerText,Href

Now that we've validated it worked, you could also parse the contents to get what you want out of it!

Full code for this example:

$webRequest          = Invoke-WebRequest 'http://www.reddit.com'
$searchForm          = $webRequest.Forms[0]
$searchForm.Fields.q = 'PowerShell'
$searchReddit        = Invoke-WebRequest -Uri $searchForm.Action -Method $searchform.Method -Body $searchForm.Fields

$searchReddit.Links | Where-Object {$_.Class -eq 'search-title may-blank'} | Select-Object InnerText,Href

Logging In To Web Sites

We can also use Invoke-WebRequest to log in to web sites. To do this we'll need to be sure to do the following:

  • Set the userAgent to Firefox.
    • This may not be required, but it is generally safe to do.
  • Use the sessionVariable parameter to create a variable.
    • This will be used to maintain the session, and store cookies.
  • Populate the correct form with login details.
    • We'll store the credentials in $credential via Get-Credential.

We'll start by storing my credentials for Reddit in $credential, setting $uaString to the FireFox user agent string, and finally using Invoke-WebRequest to initiate our session.

$credential = Get-Credential
$uaString   = [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
$webRequest = Invoke-WebRequest -Uri 'www.reddit.com' -SessionVariable webSession -UserAgent $uaString

!!NOTE!! When setting the parameter -SessionVariable, do not include the "$" in the variable name.

$webRequest.Forms contains all the forms.

The Id of the form we need is "login_login-main". Knowing this, we can use the following line to get just the form we need:

$loginForm               = $webRequest.Forms | Where-Object {$_.Id -eq 'login_login-main'}

Now to check $loginForm.Fields to be sure it is what we need, and to see what the properties we need to set are.

Let's set the fields "user" and "passwd" using the $credential variable we created earlier.

$loginForm.Fields.user   = $credential.UserName
$loginForm.Fields.passwd = $credential.GetNetworkCredential().Password

!!NOTE!! The $loginForms.Fields.passwd property will store the password as plain text.

Alright! Now that our setup is complete, we can use the following command to attempt to log in:

$webRequest = Invoke-WebRequest -Uri $loginForm.Action -Method $loginForm.Method -Body $loginForm.Fields -WebSession $webSession -UserAgent $uaString

This request contains the following information:

  • -Uri $loginForm.Action
    • This uses the URL provided in the property Action from the login form.
  • -Method $loginForm.Method
    • This uses the Method provided in the property Method from the login form.
  • -Body $loginForm.Fields
    • This sends along the hash table (which includes our username and password) along with the request.
  • -WebSession $webSession
    • This tells Invoke-WebRequest to use the SessionVariable we created for cookie storage. We use $webSession this time, as the initial request was creating the variable, and we are utilizing it now.
  • -UserAgent $uaString
    • This sends along the FireFox user agent string we set earlier.

We can now use the following code to verify if we've succeeded in logging in:

if ($webRequest.Links | Where-Object {$_ -like ('*' + $credential.UserName + '*')}) {

    Write-Host "Login verified!"

} else {

    Write-Host 'Login unsuccessful!' -ForegroundColor Red -BackgroundColor DarkBlue

}

It worked! This verification check works by doing a wildcard search for the username that is stored in the credential object $credential in any of the web site's links.

Now that you have an authenticated session, you can browse/use Reddit with it by using the parameter -WebSession, and the value $webSession.

Full code for this example:

#Store the credentials you are going to use!
#Note: if you want to securely store your credentials, you can use $credential | Export-CliXML .\credential.xml
#Then you can import it by using $credential = Import-CliXML .\credential.xml
$credential = Get-Credential
#Set $uaString as the FireFox user agent string
$uaString   = [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
#Store the initial WebRequest data in $webRequest, create the session variable $webSession
$webRequest = Invoke-WebRequest -Uri 'www.reddit.com' -SessionVariable webSession -UserAgent $uaString

#Gather and set login form details
$loginForm               = $webRequest.Forms | Where-Object {$_.Id -eq 'login_login-main'}
$loginForm.Fields.user   = $credential.UserName
#NOTE: This will store the password in the hash table as plain text
$loginForm.Fields.passwd = $credential.GetNetworkCredential().Password

#Attempt to log in using the Web Session $webSession, with the information provided in $loginForm
$webRequest = Invoke-WebRequest -Uri $loginForm.Action -Method $loginForm.Method -Body $loginForm.Fields -WebSession $webSession -UserAgent $uaString

#Validate if the login succeeded, then take action accordingly.
if ($webRequest.Links | Where-Object {$_ -like ('*' + $credential.UserName + '*')}) {

    Write-Host "Login verified!"

} else {

    Write-Host 'Login unsuccessful!' -ForegroundColor Red -BackgroundColor DarkBlue

}

Homework

  • What error checking could we have applied to some of these examples?
  • Think of things you could automate when using Invoke-WebRequest that you use a browser for every day.
  • What better way could we have validated the data in the final example in the parsing data section?
  • How could we prompt the user for input as to what to search Reddit for?

Keep an eye out for Parts 2 and 3, coming in the next couple weeks!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]