您可以用 -split 运算符轻松地将一个路径分割成独立的部分。结果是一个数组。
只需要用比较运算符来排除您不需要的部分,或者对其中的一部分改名,然后用 -join 运算符将路径合并回来。
以下代码将排除掉某个路径下所有包含单词“test”的子文件夹:
$path = 'C:\folder\test\unit1\testing\results\report.txt'
$path -split '\\' -notlike '*test*' -join '\'
您可以用 -split 运算符轻松地将一个路径分割成独立的部分。结果是一个数组。
只需要用比较运算符来排除您不需要的部分,或者对其中的一部分改名,然后用 -join 运算符将路径合并回来。
以下代码将排除掉某个路径下所有包含单词“test”的子文件夹:
$path = 'C:\folder\test\unit1\testing\results\report.txt'
$path -split '\\' -notlike '*test*' -join '\'
有些时候您也许希望跳过配置文件中的某些部分。例如,在 ISE 编辑器中,只需要将这段代码加入您的配置脚本(配置脚本的路径可以在通过 $profile 变量查看,它也有可能还没有创建):
if([System.Windows.Input.Keyboard]::IsKeyDown('Ctrl')) { return }
如果您启动 ISE 编辑器时按住 CTRL 键,将跳过您配置脚本中的剩余部分。
或者,您可以这样使用:
if([System.Windows.Input.Keyboard]::IsKeyDown('Ctrl') -eq $false)
{
Write-Warning 'You DID NOT press CTRL, so I could execute things here.'
}
这样写的话,仅当您启动 ISE 时没有按住 CTRL 键时,才会运行花括号内部的代码。
如果您希望这段代码也能用在 PowerShel 控制台中,那么需要加载对应的程序集。这段代码在所有的配置脚本中都通用:
Add-Type -AssemblyName PresentationFramework
if([System.Windows.Input.Keyboard]::IsKeyDown('Ctrl') -eq $false)
{
Write-Warning 'You DID NOT press CTRL, so I could execute things here.'
}
Tobias Weltner 博士每个工作日都在 www.powershell.com 发布一篇 PowerShell 技能。这个系列在 PowerShell 的技术社区里已是家喻户晓,成为关注 PowerShell 技术动态的一扇窗口。
这个套技能连载在中国有两个版本的翻译。一套是由荔非苔创立的 Powershell小技巧,现由荔非苔和 CodeCook 两人维护另一套是本站的 PowerShell 技能连载。
本站是采用 JekyllBootstrap 系统搭建,使用 markdown 编辑文章,并使用 git 发布到 GitHub Pages 上。采用这些略带 geek 感的技术来做这个站点,是因为:
我已翻译了PowerShell 技能连载的 200 多篇文章。与此同时,我也采用各种技术方法提升翻译工作的效率。翻译一篇文章所占用的时间已从最初的 30 分钟缩短到现在的 10 分钟。以下部分将演示我是如何做到的。翻译一篇文章,需要经历以下步骤:
我们可以用 PowerShell 逐一解决这些问题:
浏览 www.powershell.com 的源代码,发现该网站有提供 PowerShell tips 的 RSS 订阅:
<link rel="alternate" type="application/atom+xml" title="Power Tips (Atom 1.0)" href="http://powershell.com/cs/blogs/tips/atom.aspx" />
通过 RSS 服务,我们就可以通过编程的方式,自动化地获取文章的内容。
$atomUrl = 'http://powershell.com/cs/blogs/tips/atom.aspx'
$feed = Invoke-RestMethod $atomUrl
获取到的 $feed 变量大概是这样的一个数组:
title : Using Profile Scripts
link : link
id : /cs/blogs/tips/archive/2014/06/13/using-profile-scripts.aspx
published : 2014-06-13T11:00:00Z
updated : 2014-06-13T11:00:00Z
content : content
author : author
title : Be Aware of Side Effects
link : link
id : /cs/blogs/tips/archive/2014/06/12/be-aware-of-side-effects.aspx
published : 2014-06-12T11:00:00Z
updated : 2014-06-12T11:00:00Z
content : content
author : author
其中 link、id、content 对我们有用。content 是 XmlElement 类型的对象,我们一会儿会用到。
由于 id 的变化部分是以 yyyy/mm/dd 开头的,所以我们可以放心地用 Sort-Object 直接进行字符串排序。这个 cmdlet 有个常用的别名,叫做 sort。
接下来用正则表达式提取 year、month、day、name 这几个元素。通过这些元素,我们就可以组织目标文件名:
$feed | sort { $_.id } | foreach {
$entry = $_
if ($entry.id -cmatch '^/cs/blogs/tips/archive/(?<year>\d{4})/(?<month>\d{2})/(?<day>\d{2})/(?<name>.+)\.aspx$') {
$year = $matches['year']
$month = $matches['month']
$day = $matches['day']
$name = $matches['name']
}
$targetFile = Join-Path $folder "$year-$month-$day-$name.md"
由于 RSS 中返回的是最后 15 篇文章,也就是近 3 周来的文章列表,如果遇到已翻译过的文章,需要自动跳过:
if (Test-Path $targetFile) {
echo "[文件已存在] $year-$month-$day-$name.md"
} else {
每篇文章对应一个 markdown 文件。在文件的头部,有一段用 yaml 描述的元数据,也就是用两条 --- 分隔的部分:
function Get-Post ($enty) {
$postTemplate = @'
layout: post
title: "PowerShell 技能连载 - ___"
description: "PowerTip of the Day - {0}"
categories: [powershell, tip]
tags: [powershell, tip, powertip, series, translation]
{1}
<!--more-->
本文国际来源:[{2}]({3})
'@
$entryUrl = 'http://powershell.com' + $entry.link.href
$htmlContent = $entry.content.'#text'
$htmlDoc = Get-Document $htmlContent
$htmlContent = $htmlDoc.documentElement.innerHTML
$htmlDoc.Close()
$markdown = Get-Markdown $htmlContent
return $postTemplate -f $entry.title, $markdown, $entry.title, $entryUrl
}
代码中的 $postTemplate 是一个用 here string 描述的文件模板,其中所有的变量都用 {x} 占位符来代替。而 title 部分的 ___ 是为中文名称预留的位置。由于程序无法自动填充中文名,所以这个位置需要在人工翻译阶段手动填充。
函数尾部的 $postTemplate -f $entry.title, $markdown, $entry.title, $entryUrl 是采用字符串的 -f 运算符进行格式化,将各个变量填充到 {x} 占位符处。-f 的本质是调用了 String 类的 Format() 静态方法。
上述代码调用了一个 Get-Document 函数,将 $htmlContent 字符串转换为一个 DOM 对象。它的实现方法如下:
function Get-Document($text) {
$htmlDoc= New-Object -com "HTMLFILE"
if ($htmlDoc.IHTMLDocument2_write) {
$htmlDoc.IHTMLDocument2_write($text)
} else {
$htmlDoc.write($text)
}
return $htmlDoc
}
这里采用了 HTMLFILE COM 对象的 IHTMLDocument2_write() 或 write() 方法,来返回一个 HtmlDocument 对象。因为我发现不同的机器上,存在不同的版本。很遗憾关于这块的 MSDN 文档不太好找,这是我自创的方式,有效果,不知有没有更好的方法实现。
拿到 HtmlDocument 以后,就可以进行 DOM 操作了。这里我们简单地获取 Document 对象的 innerHTML 属性,并且注意及时关闭 COM 对象以释放资源:
$htmlContent = $htmlDoc.documentElement.innerHTML
$htmlDoc.Close()
在 PowerShell 和 .NET 的世界里,目前没有很理想的 HTML 转 markdown 库可用。不过 Node.js 中有一个不错的库 html2markdown。
由于 node.exe 传入太长的参数可能会有意想不到的问题,所以直接向 Node.js 程序传递 HTML 字符串不可靠。更可靠的方式是将 HTML 字符串保存到临时文件中,将临时文件的文件名传递给 Node.js 程序。
我们可以写一个 Node.js 的小程序,目的是让 PowerShell 以这种方式调用:
node.exe index.js htmlFilePath markdownFilePath
其中 htmlFilePath 为输入的 HTML 文件路径,markdownFilePath 为输出的 markdown 文件路径。
接下来用 npm 快速创建一个 Node.js 工程(暂时也叫 html2markdown,虽然和库的名字相同,不过影响使用):
npm init html2markdown
对向导的提示一路回车即可。然后添加 html2markdown 库的引用:
npm install html2markdown --save
生成好的 package.json 文件如下:
{
"name": "html2markdown",
"version": "0.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"html2markdown": "~1.1.0"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
接下来编写 index.js 脚本。这个脚本的原理是:
index.js 脚本的内容如下:
var html2markdown = require('html2markdown'),
fs = require('fs'),
args = process.argv.splice(2),
htmlFile = args[0],
markdownFile = args[1],
html,
markdown;
html = fs.readFileSync(htmlFile, { encoding: 'utf8' });
markdown = html2markdown(html, { inlineStyle: true });
fs.writeFileSync(markdownFile, markdown);
以下代码是生成 $htmlFile、$markdownFile 两个临时文件,然后通过 node .\html2markdown\index.js $htmlFile $markdownFile 调用 Node.js 程序。当 Node.js 程序执行完毕以后,通过 gc 命令从输出的临时文件中获取 markdown 内容字符串,然后删除所有临时文件。
代码的第一行用正则表达把 HTML 中的 Twitter 回访链接给清理掉:
function Get-Markdown ($html) {
$html = $html -creplace '(?sm)^<P><A href="http://twitter\.com/home/\?status=.*$', ''
$htmlFile = [System.IO.Path]::GetTempFileName()
$markdownFile = [System.IO.Path]::GetTempFileName()
sc $htmlFile $html
node .\html2markdown\index.js $htmlFile $markdownFile
$markdown = gc -Raw $markdownFile
del $htmlFile
del $markdownFile
$markdown = $markdown.Trim()
return $markdown
}
到此为止,markdown 形式的文章内容已经生成好了。
到目前为止,文章中的图片都可以正常显示,不过它们都是位于 www.powershell.com 服务器上。从翻译的角度来说,图片最好保存一份到自己的服务器上,图片跟着文章走。这样就不会因为源网站服务器宕机或其它原因导致译文中的图片出问题。
markdown 中的图片是这样表示的:

我们假设文章的文件名是 aaa.md,那么需要做的事情是把 http://www.xxx.com/yyy.png 下载下来,保存在网站的 ..\assets\post_img 目录下,并且按 aaa-001.png、aaa-002.png 这样的方式重命名。这样就能很清楚地体现哪个图片是属于哪篇文章。
代码中采用这个正则表达式来提取图片的描述和 URL:
[regex] '!\[(?<desc>.*?)\]\((?<url>.*?)\)'
这句代码是用来生成新的图片文件名:
$targetPath = "$fileBaseName-{0:d3}$extension" -f $index++
我们可以用一个泛型的哈希表(字典)来保存源路径和替换后的目标路径:
[System.Collections.Generic.Dictionary[[string],[string]]] $dict = New-Object 'System.Collections.Generic.Dictionary[[string], [string]]'
它等效于 C# 中的这行代码:
var dict = new Dictionary<string, string>();
这里的 PowerShell 代码就显得不如 C# 直观了。
以下是实现代码:
function Get-Picture($file) {
$index = 1
$fileBaseName = ([System.IO.FileInfo]$file).BaseName
[System.Collections.Generic.Dictionary[[string],[string]]] $dict = New-Object 'System.Collections.Generic.Dictionary[[string], [string]]'
cat $file -Encoding UTF8 -Raw | % {
$regex = [regex] '!\[(?<desc>.*?)\]\((?<url>.*?)\)'
$matches = $regex.Matches($_)
if ($matches.Count) {
$matches.ForEach({
$fullMatch = $_.Value
$desc = $_.Groups['desc'].Value
$url = $_.Groups['url'].Value
if ($url -like 'http*') {
$extension = [System.IO.Path]::GetExtension($url)
$targetPath = "$fileBaseName-{0:d3}$extension" -f $index++
$result = Download-Picture $url $targetPath
if ($result) {
$dict.Add($url, $targetPath)
}
}
})
}
}
$newContent = cat $file -Encoding UTF8 -Raw | % {
$line = $_
$dict.Keys | % {
$url = $_
$newPath = $relateUrl + $dict[$url]
$line = [string]$line.Replace($url, $newPath)
}
$line
}
$bytes = [System.Text.Encoding]::UTF8.GetBytes($newContent)
sc $file $bytes -Encoding Byte
#[IO.File]::WriteAllText($file, $newContent, [System.Text.Encoding]::UTF8)
}
这里为什么用字典来保存源地址和目标地址呢?主要是因为我们不仅需要把每个匹配成功的结果替换成新的文本,还需要针对每个结果执行一段自定义的代码。利用字典先把匹配的结果保存起来,然后遍历字典项,对它们进行进一步的处理。
实际上有个更简单的办法。C# 中 Regex 对象的 Replace() 方法支持 lambda 表达式,对应的 PowerShell 方法支持用代码块的方式。MSDN 链接为 Regex.Replace 方法 (String, MatchEvaluator)。可以一边匹配一边做处理。
只是由于我先写出了文中的版本,所以没有去改进。
Invoke-WebRequest 可以方便地下载 HTTP 文件,这和 Linux 系统中的 wget 命令十分相似。实际上,Invoke-WebRequest 命令有个别名,就叫做 wget。下载图片的代码如下:
function Download-Picture($url, $fileName) {
echo "downloading $url to $fileName"
$fullPath = Join-Path $downloadPath $fileName
Invoke-WebRequest -Uri $url -OutFile $fullPath
return $?
}
到此为止,我已完整地介绍了《PowerShell 技能连载》翻译工作中采用脚本进行自动化的全部原理。相应的代码可以在 这里 找到。
读者也许会觉得为这些小小的功能用手工操作也可以完成。不过,简单计算一下就能够体现出产生的红利。目前已翻译了 200 多篇文章,按照每篇文章节约 3 分钟计算,自动化操作总共为我节约了 600 分钟,也就是节约了 10 个小时的机械劳动。用这宝贵的一个多工作日来开发脚本、撰写心得,做些动脑筋的事情,不亦是一种提升么?
您可能知道 PowerShell 支持配置脚本。只需要确保 $profile 所指定的文件存在即可。它是一个普通的脚本,每当 PowerShell 宿主启动的时候都会执行。
所以可以很方便地配置 PowerShell 环境、加载模块、增加 snap-in,以及做其它调整。这段代码将缩短您的 PowerShell 提示符,并且在标题栏显示当前路径:
function prompt
{
'PS> '
$host.UI.RawUI.WindowTitle = Get-Location
}
请注意 $profile 指定的配置脚本是和宿主有关的。每个宿主有独立的配置脚本(包括 PowerShell 控制台、ISE 编辑器以及所有的 PowerShell 宿主)。
要在所有宿主中自动执行代码,请使用这个文件:
$profile.CurrentUserAllHosts
它们的路径基本上相同,除了后者文件名不含宿主名,而只是叫做“profile.ps1”。
PowerShell 可以使用许多底层的系统函数。例如这个,可以创建一个临时文件名:
[System.IO.Path]::GetTempFileName()
然而,它不仅只做这一件事。它还真实地创建了那个文件。所以如果您使用这个函数来创建临时文件名,您可能最终会在文件系统中创建一堆孤立的文件。请在您的确需要创建一个临时文件的时候才使用它。
假设在一个文件夹中有一大堆脚本(或照片、日志等任意文件),并且您想要重命名所有的文件。比如新文件名的格式为固定前缀 + 自增的编号。
以下是实现方法。
这个例子将重命名指定文件夹中所有扩展名为 .ps1 的 PowerShell 脚本。新文件名为 powershellscriptX.ps1,其中“X”为自增的数字。
请注意脚本禁止了真正的重命名操作。如果要真正地重命名文件,请移除 -WhatIf 参数,但必须非常小心!如果您敲错一个变量或使用了错误的文件夹路径,那么您的脚本将会十分开心地重命名成千上万个错误的文件。
$Path = 'c:\temp'
$Filter = '*.ps1'
$Prefix = 'powershellscript'
$Counter = 1
Get-ChildItem -Path $Path -Filter $Filter -Recurse |
Rename-Item -NewName {
$extension = [System.IO.Path]::GetExtension($_.Name)
'{0}{1}.{2}' -f $Prefix, $script:Counter, $extension
$script:Counter++
} -WhatIf
如果您想重新整理您的照片库,以下这段代码能帮您从照片文件中读取拍摄日期信息。
这个例子使用了一个系统函数来查找“我的照片”的路径,然后递归搜索它的子文件夹。输出的结果通过管道传递给 Get-DataTaken,该函数返回照片的文件名、文件夹名,以及照片的拍摄时间。
function Get-DateTaken
{
param
(
[Parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[Alias('FullName')]
[String]
$Path
)
begin
{
$shell = New-Object -COMObject Shell.Application
}
process
{
$returnvalue = 1 | Select-Object -Property Name, DateTaken, Folder
$returnvalue.Name = Split-Path $path -Leaf
$returnvalue.Folder = Split-Path $path
$shellfolder = $shell.Namespace($returnvalue.Folder)
$shellfile = $shellfolder.ParseName($returnvalue.Name)
$returnvalue.DateTaken = $shellfolder.GetDetailsOf($shellfile, 12)
$returnvalue
}
}
$picturePath = [System.Environment]::GetFolderPath('MyPictures')
Get-ChildItem -Path $picturePath -Recurse -ErrorAction SilentlyContinue |
Get-DateTaken
大多数软件都会在注册表中登记自己。以下是一段从能从本地和远程的 32 位及 64 位注册表中读取已安装的软件列表的代码。它还是一个演示如何读取远程注册表的不错的例子。
# NOTE: RemoteRegistry Service needs to run on a target system!
$Hive = 'LocalMachine'
# you can specify as many keys as you want as long as they are all in the same hive
$Key = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
# you can specify as many value names as you want
$Value = 'DisplayName', 'DisplayVersion', 'UninstallString'
# you can specify a remote computer name as long as the RemoteRegistry service runs on the target machine,
# you have admin permissions on the target, and the firewall does not block you. Default is the local machine:
$ComputerName = $env:COMPUTERNAME
# add the value "RegPath" which will contain the actual Registry path the value came from (since you can specify more than one key)
$Value = @($Value) + 'RegPath'
# now for each regkey you specified...
$Key | ForEach-Object {
# ...open the hive on the appropriate machine
$RegHive = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey($Hive, $ComputerName)
# ...open the key in that hive...
$RegKey = $RegHive.OpenSubKey($_)
# ...find names of all subkeys...
$RegKey.GetSubKeyNames() | ForEach-Object {
# ...open subkeys...
$SubKey = $RegKey.OpenSubKey($_)
# ...and read all the requested values from each subkey
# ...to store them, use Select-Object to create a simple new object
$returnValue = 1 | Select-Object -Property $Value
$Value | ForEach-Object {
$returnValue.$_ = $subkey.GetValue($_)
}
# ...add the current regkey path name
$returnValue.RegPath = $SubKey.Name
# return the values:
$returnValue
# close the subkey
$SubKey.Close()
}
# close the regkey
$RegKey.Close()
# close the hive
$RegHive.Close()
} | Out-GridView
PowerShell 技能连载 - 远程执行 gpupdate
您可以用这样的一段脚本远程执行 gpupdate.exe:
function Start-GPUpdate
{
param
(
[String[]]
$ComputerName
)
$code = {
$rv = 1 | Select-Object -Property ComputerName, ExitCode
$null = gpupdate.exe /force
$rv.Exitcode = $LASTEXITCODE
$rv.ComputerName = $env:COMPUTERNAME
$rv
}
Invoke-Command -ScriptBlock $code -ComputerName $ComputerName |
Select-Object -Property ComputerName, ExitCode
}
Start-GPUpdate 接受一个或多个计算机名,然后对每台计算机运行 gpupdate.exe,并返回执行结果。
这段脚本利用了 PowerShell 远程管理技术,所以它需要目标计算机启用了 PowerShell 远程管理,并且您需要这些机器的本地管理员权限。
您是否疑惑过一个数据库的连接字符串到底长什么样?当您从控制面板中创建一个数据源时,一个向导将指引您完成整个创建过程。以下是一个利用这个向导并获取生成的连接字符串的方法。
请注意该向导的选择要依赖于您机器上所安装的数据库驱动。
function Get-ConnectionString
{
$Path = Join-Path -Path $env:TEMP -ChildPath 'dummy.udl'
$null = New-Item -Path $Path -ItemType File -Force
$CommandArg = """$env:CommonProgramFiles\System\OLE DB\oledb32.dll"",OpenDSLFile " + $Path
Start-Process -FilePath Rundll32.exe -Argument $CommandArg -Wait
$ConnectionString = Get-Content -Path $Path | Select-Object -Last 1
$ConnectionString | clip.exe
Write-Warning 'Connection String is also available from clipboard'
$ConnectionString
}
当您调用 Get-ConnectionString 方法时,将会创建一个临时的 udl 文件,并且用控制面板向导打开它。您可以通过向导完成配置。配置完成之后,PowerShell 将会检测临时文件并且返回连接字符串。
它的工作原理是 Get-Process 函数带了 -Wait 参数,它能够挂起脚本的执行,直到向导退出。在向导退出以后,脚本就可以安全地访问 udl 文件了。