XAMPP 学习路线

XAMPP = Apache + MySQL + PHP + Perl

XAMPP是最流行的PHP开发环境

XAMPP是完全免费且易于安装的Apache发行版,其中包含MySQL、PHP和Perl。XAMPP开放源码包的设置让安装和使用出奇容易。

网站

XAMPP 官方网站
XAMPP - SourceForge

文件区别

  • 安装版(适合小型服务器安装)
    • xampp-win32-*-installer.exe - 有安装向导。
    • xampp-win32-*.zip - 解开是一个 xampp 目录,但可以随后注册服务等。
    • xampp-win32-*.7z - 和 .zip 版相同,压缩后体积更小。
  • 便携版(适合开发测试。不包含 FileZilla FTP 和 Mercury Mail Server,不能安装服务)
    • xampp-portable-win32-*-installer.exe
    • xampp-portable-win32-*.zip
    • xampp-portable-win32-*.7z

快速起步

**切勿自己摸索!**因为不同的版本的步骤有所不同。请阅读 xampp\readme_en.txt 中的 QUICK INSTALLATION 节。篇幅很短,不用担心 :)

潜在陷阱

  • 必须安装(或解压到)根目录下。例如 D:\xampp,或者 E:\xampp。
  • 注意缺省的 80 和 443 端口未被其它程序占用。netstat -ano |find "80"netstat -ano |find "443"
  • 如果用安装版的压缩包(.zip 或 .7z),并需要安装服务,请用提升权限的管理员账户打开 xampp-control.exe 进行安装。
  • 如果要能让别的机器或外网访问,请注意配置防火墙。
  • 通过 xampp-control.exe 启动,可能看不到完整的错误提示。请在命令行下启动 xampp_start.exe,可以看到更详细的错误提示。
  • 如果遇到错误,可以根据 xampp-control.exe 面板上的各个 Logs 按钮找到相应的日志。另外,可以通过 Windows 的事件查看部分日志。

用 XAMPP 搭建反向代理服务器

公网 IP 地址 + 80 端口是稀缺资源。在开发、测试阶段,我们常常需要在一个公网 IP 的 80 端口上,绑定多个 WEB 服务,这些服务可能部署在内网的多台异构服务器上(不同操作系统、不同服务器软件)。

用表格来表达就是:

外网访问 重定向到
http://home.test.com http://127.0.0.1:81
http://img.test.com http://127.0.0.1:82
http://js.test.com http://127.0.0.1:83

在 Linux 下,可以通过 vhost 程序来实现这个需求。在 Windows 下,我们有 XAMPP 和 IIS 两种选择。本文重点介绍 XAMPP 的实现方式。

分别搭建 3 个测试服务器

可以采用这些小工具快速创建测试服务器:

设置 hosts 以便测试

首先要让 3 个域名都指向本机。我们可以直接修改本地 hosts 文件以便测试。这种方式立刻生效,免去申请域名的麻烦。

用提升权限的记事本打开 %windir%\system32\drivers\etc\hosts 文件,加入这段:

127.0.0.1 home.test.com
127.0.0.1 img.test.com
127.0.0.1 js.test.com

这里有个快捷的方法,参见:PowerShell 技能连载 - 编辑“hosts”文件

搭建 XAMPP 环境

请参见 XAMPP 学习路线。只需要其中的 Apache 模块即可。确保 XAMPP 能够正常启动,并能够通过 http://127.0.0.1 访问缺省页面。

设置 XAMPP

编辑 xampp\apache\conf\httpd.conf,将 LoadModule proxy_http_module modules/mod_proxy_http.so 前的 # 号去掉。

编辑 xampp\apache\conf\extra\httpd-vhosts.conf,在尾部添加:

ProxyRequests Off

<Proxy *>
    Order deny,allow
    Allow from all
</Proxy>

<VirtualHost *:80>
    ServerName blog.test.com
    ProxyPass / http://127.0.0.1:81/
    ProxyPassReverse / http://127.0.0.1:81/
</VirtualHost>

<VirtualHost *:80>
    ServerName img.test.com
    ProxyPass / http://127.0.0.1:82/
    ProxyPassReverse / http://127.0.0.1:82/
</VirtualHost>

<VirtualHost *:80>
    ServerName js.test.com
    ProxyPass / http://127.0.0.1:83/
    ProxyPassReverse / http://127.0.0.1:83/
</VirtualHost>

重启 XAMPP 中的 Apache 组件

姊妹篇 - 用 IIS 搭建反向代理服务器

用 IIS 也可以实现相同的功能。

注意有个坑:

%windir%\System32\inetsrv\iis.msc 或通过“这台电脑 - 右键 - 计算机管理” 启动 IIS 管理器,可能看不到 ARR 组件而通过 %windir%\system32\inetsrv\InetMgr.exe 则可以看到。

鸣谢

打包 node 应用程序为单一文件可执行程序

解决方案

  • crcn/nexe - create a single executable out of your node.js apps。支持多个平台,似乎靠谱。
  • areve/node2exe - 只支持 Windows,用 copy /b 合并多个文件。
  • appjs/appjs - 已过期,被 node-webkit 替代。
  • rogerwang/node-webkit - Call all Node.js modules directly from DOM and enable a new way of writing applications with all Web technologies. 带图形界面的不二选择。
  • creationix/topcube - Gives node developers a way to have a desktop GUI to their node servers using HTML5 + CSS3 as the GUI platform.

讨论

Hexo 博客学习路线

Hexo 是一个快速、便捷、强大的博客框架,通过 Node.js 技术构建。

  • 如果你对默认配置满意,只需几个命令便可秒搭一个hexo。
  • 如果你跟我一样喜欢折腾下,30分钟也足够个性化。
  • 如果你过于喜欢折腾,可以折腾个把星期,尽情的玩。

HEXO

官方链接

教程

云空间

工具

我制作了一系列脚本,用于整理博客文章、抓取外部图片、批处理生成发布等,请参见 victorwoo/victorwoo - GitCafe

AngularJS 学习路线

AngularJS 是Google开源的一款JavaScript MVC框架,弥补了HTML在构建应用方面的不足,诞生以来吸引了大量的目光,也迅速成为了Web开发领域的新宠。

AngularJS

网站

博客

入门

官方使用指南

w3school

链接合集

请展开看以下链接内容*

DEMO 项目

参考手册

视频教程

纸质书籍

用AngularJS开发下一代Web应用

图表

  • 一张图告诉你Angular的内核结构
    一张图告诉你Angular的内核结构

指令收藏(Directives)

  • jirikavi/AngularJS-Toaster - AngularJS Toaster is a customized version of “toastr” non-blocking notification javascript library.

自动生成 PowerShell 技能连载

Tobias Weltner 博士每个工作日都在 www.powershell.com 发布一篇 PowerShell 技能。这个系列在 PowerShell 的技术社区里已是家喻户晓,成为关注 PowerShell 技术动态的一扇窗口。

这个套技能连载在中国有两个版本的翻译。一套是由荔非苔创立的 Powershell小技巧,现由荔非苔和 CodeCook 两人维护另一套是本站的 PowerShell 技能连载

本站是采用 JekyllBootstrap 系统搭建,使用 markdown 编辑文章,并使用 git 发布到 GitHub Pages 上。采用这些略带 geek 感的技术来做这个站点,是因为:

  • 用 markdown 可以使我关注文案的内容,而不是格式。并且它十分适合编写技术文章。
  • git 很酷,可以多人协作,可以像写代码一样写文章。
  • 可以自由地调整源代码,加入新奇的功能,甚至可以用脚本来做站点搬家。
  • GitHub Pages 可以为我们提供免费的服务器。
  • 一切都是开源的,您可以查看到这个站点的一切源代码、文章源文件,甚至所有的维护脚本。
  • 我本身是一个 geek。

我已翻译了PowerShell 技能连载的 200 多篇文章。与此同时,我也采用各种技术方法提升翻译工作的效率。翻译一篇文章所占用的时间已从最初的 30 分钟缩短到现在的 10 分钟。以下部分将演示我是如何做到的。翻译一篇文章,需要经历以下步骤:

  • www.powershell.com 搜集新文章。
  • 创建新的 markdown 文件。文件名对应原文的 url 地址。
  • 编辑文件头的元数据。
  • 将英文的内容转换成 markdown 格式,贴到文件正文部分。
  • 在文本编辑器中,将英文内容逐段翻译成中文,并将原文逐段删除。
  • 处理文章中的图片。
    • 将它下载到本地。
    • 以文章的文件名作为前缀,并加上序号作为文件名。
    • 修正文章中的图片地址。
  • 在浏览器中打开原文,以供对照参考。

我们可以用 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

其中 linkidcontent 对我们有用。contentXmlElement 类型的对象,我们一会儿会用到。

从更新列表中提取文章信息

由于 id 的变化部分是以 yyyy/mm/dd 开头的,所以我们可以放心地用 Sort-Object 直接进行字符串排序。这个 cmdlet 有个常用的别名,叫做 sort

接下来用正则表达式提取 yearmonthdayname 这几个元素。通过这些元素,我们就可以组织目标文件名:

$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 文件

模板方法

每篇文章对应一个 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() 静态方法。

解析 DOM 结构

上述代码调用了一个 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()

将 HTML 转换为 markdown

编写 Node.js 程序

在 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 脚本。这个脚本的原理是:

  • 解析 node.exe 进程的参数,提取输入的 HTML 文件路径和输出的 markdown 文件路径
  • 读入 HTML 文件内容
  • 调用 html2markdown 将 HTML 转换为 markdown
  • 写入 markdown 文件内容

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);

通过 PowerShell 调用 Node.js

以下代码是生成 $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 中的图片是这样表示的:

![description](http://www.xxx.com/yyy.png)

我们假设文章的文件名是 aaa.md,那么需要做的事情是把 http://www.xxx.com/yyy.png 下载下来,保存在网站的 ..\assets\post_img 目录下,并且按 aaa-001.pngaaa-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 脚本将书签批量导入 Delicious

前言

美味书签Delicious 在线书签服务的中国本地化版本。由于各方面原因,美味书签实现的功能有限,远远达不到 Delicious 的功能。所以我希望将美味书签中的使用记录迁移回 Delicious。

经过一年使用,我在美味书签中已经积累了 5000+ 条书签记录。由于美味书签不支持书签导出功能,所以将美味书签中的书签导出至 Delicious 是一件需要动手动脑的事。幸好我们有 PowerShell 脚本,可以助我们完成这项单调枯燥的事。

这是一个系列文章,一共分为 3 部分:

  1. 用 PowerShell 脚本来导出美味书签
  2. [用 PowerShell 脚本来清除 Delicious 账户下的所有书签][用 PowerShell 脚本来批量删除 Delicious 账户下的所有书签]
  3. 用 PowerShell 脚本将书签批量导入 Delicious

原理分析

Delicious API

通过阅读 Delicious API,可以知道我们只需要这样一条 API /v1/posts/add?,它的参数为:

  • &url={URL} (required) — The url of the item.
  • &description={...} (required) — The description of the item.
  • &extended={...} (optional) — Botes for the item.
  • &tags={...} (optional) — Tags for the item (comma delimited).
  • &dt={CCYY-MM-DDThh:mm:ssZ} (optional) — Datestamp of the item (format “CCYY-MM-DDThh:mm:ssZ”). Requires a LITERAL “T” and “Z” like in ISO8601 at http://www.cl.cam.ac.uk/~mgk25/iso-time.html for Example: 1984-09-01T14:21:31Z
  • &replace=no (optional) — Don’t replace post if given url has already been posted.
  • &shared=no (optional) — Make the item private

关于身份验证,请参考本系列的另一篇文章 用 PowerShell 脚本来清除 Delicious 账户下的所有书签

URL 编码

我们需要提交的书签中,description 字段和 tags 字段是有可能出现 URL 中不允许的字符的,例如 ?&,以及中文字符等。我们需要将它们进行 URL 编码以后,才可以拼接到 URL 字符串中。在 PowerShell 中进行 URL 编码的方法如下:

Add-Type -AssemblyName 'System.Web'
[System.Web.HttpUtility]::UrlEncode('中文')

其中第一行是为了加载 System.Web 程序集。还可以用以下两种方法来实现:

[void][system.Reflection.Assembly]::LoadWithPartialName("System.Web")

以及:

[Reflection.Assembly]::LoadFile('C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\System.Web.dll') | Out-Null

生成查询字符串

我们要在查询字符串中包含 API 文档中提到的那 7 个参数。用 string -f 的方式显得有点笨拙。于是我们编写这样一个函数:

function Get-QueryString ($params) {
    $keyValuePairs = ($params.Keys | % {
        write ('{0}={1}' -f $_, $params[$_])
    })
    return $keyValuePairs -join '&'
}

这个函数接收一个哈希表作为参数,也可以是 [ordered] (即OrderedDictionary)。函数中循环地取出所有键,将它们的值用 & 符号拼接在一起。

容错设计

若是 Invoke-WebRequest 命令抛出异常,或是 HTTP 响应码不为 200,或是 XML 中不是 <result code="done" /> 这样的返回,那么表示添加书签失败。我们可以把这些书签收集起来,输出到 failed_import.csv 文件中。然后下次可以再对这个文件进行导入。直到这个文件中没有记录为止。当然,您也可以将脚本改进一下,全自动地做完上述的事情。那么您一定是懒(勤劳)到家了 ;-)

源代码

$userName = 'vichamp'
$importFileName = 'meiweisq-export-20131030.csv'
#$importFileName = 'failed_import.csv'

Add-Type -AssemblyName 'System.Web'
#$password = ConvertTo-SecureString –String "xxx" –AsPlainText -Force

$credential = Get-Credential -UserName $userName -Message '请输入密码'

function Get-QueryString ($params) {
    $keyValuePairs = ($params.Keys | % {
        write ('{0}={1}' -f $_, $params[$_])
    })
    return $keyValuePairs -join '&'
}

$startTime = [datetime]::Now
$template = 'https://api.del.icio.us/v1/posts/add?{0}'

$bookmarks = Import-Csv $importFileName
$failedBookmarks = @()
$index = 0
$bookmarks | foreach {
    $params = @{}
    $params.Add('description', [System.Web.HttpUtility]::UrlEncode($_.Title))
    if ($false) {
        $params.Add('extended', [System.Web.HttpUtility]::UrlEncode(''))
    }
    $params.Add('tags', [System.Web.HttpUtility]::UrlEncode([string]::Join(',', $_.Tags -split ', ')))
    $params.Add('dt', ("{0}T00:00:00Z" -f ($_.LinkTime -creplace '/', '-')))
    $params.Add('replace', 'no')
    $params.Add('shared', 'yes')
    $params.Add('url', $_.Url)

    $queryString = Get-QueryString $params
    $url = $template -f $queryString

    $message = "Bookmark: {0} / {1}, Elapsed: {2}" -f @(
        $($index + 1),
        $bookmarks.Length,
        ([datetime]::Now - $startTime).ToString()
    )
    Write-Progress -Activity 'Adding bookmarks' -PercentComplete (100 * $index / $bookmarks.Length) -CurrentOperation $message
    #echo "Requesting $_.Url"

    $isSuccess = $false
    try {
        [xml]$response = Invoke-WebRequest -Uri $url -Credential $credential
        $isSuccess = $response.StatusCode -eq 200 -and $response.result.code -eq 'done'
    } catch { }

    if ($isSuccess) {
        Write-Output "[SUCC] $($_.Url)"
    } else {
        Write-Warning "[FAIL] $($_.Url)"
        $failedBookmarks += $_
    }

    $index++
}

$failedBookmarks | Export-Csv 'failed_import.csv' -Encoding UTF8 -NoTypeInformation

您也可以点击这里下载源代码。

用 PowerShell 脚本来导出美味书签

前言

美味书签Delicious 在线书签服务的中国本地化版本。由于各方面原因,美味书签实现的功能有限,远远达不到 Delicious 的功能。所以我希望将美味书签中的使用记录迁移回 Delicious。

经过一年使用,我在美味书签中已经积累了 5000+ 条书签记录。由于美味书签不支持书签导出功能,所以将美味书签中的书签导出至 Delicious 是一件需要动手动脑的事。幸好我们有 PowerShell 脚本,可以助我们完成这项单调枯燥的事。

这是一个系列文章,一共分为 3 部分:

  1. 用 PowerShell 脚本来导出美味书签
  2. 用 PowerShell 脚本来清除 Delicious 账户下的所有书签
  3. 用 PowerShell 脚本将书签批量导入 Delicious

原理分析

模拟美味书签的登录过程

美味书签的登录页面地址为 http://meiweisq.com/login 。我们可以使用 Invoke-WebRequest 获取登录页面,同时把会话信息记录到 $rb 变量中。

相应的 PowerShell 代码如下:

$response = Invoke-WebRequest -Uri $homeUrl -Method Default -SessionVariable rb -ContentType application/html

得到的响应中其中包含多个表单。通过查看网页源代码,我们可以确定 Action 为“/login”的那个表单是我们所要的:

相应的 PowerShell 代码为:

$loginForm = ($response.Forms | where { $_.Action -eq '/login' })[0]

我们在 Chrome 浏览器中登录一下,通过“开发者工具”的“Network”选项卡查看提交的数据:

根据提交的数据,我们可以编写 PowerShell 代码来提交表单,模拟登录按钮的动作。注意传入会话变量 $rb,以在后续的过程中保持会话身份,否则下次提交又会提示需要登录:

$loginForm.Fields['email'] = $email
$loginForm.Fields['password'] = $password
$loginForm.Fields['type'] = '登录'
$loginForm.Fields['return-url'] = '/home'
$loginForm.Fields['remember'] = 'off'
$response = Invoke-WebRequest -Uri $loginAction -WebSession $rb -Method POST -Body $loginForm

取得书签总数

在登录后的页面底部有“1 - 30 共 5126 个书签”的字样,其中 305126 两个数字是我们关心的。我们用正则表达式 1 - (\d+) 共 (\d+) 个书签 从整个网页中提取书签的总数量。在 PowerShell 使用正则表达式:

$response.Content -cmatch '1 - (\d+) 共 (\d+) 个书签'
$page1Count = $Matches[1]
$totalCount = $Matches[2]
echo "1 - $page1Count 共 $totalCount 个书签"

根据 $page1Count$totalCount,就可以计算总页数了:

$pageCount = [math]::Ceiling($totalCount / $bookmarksPerPage)

遍历每一页

知道了总页数,自然想到用 for 循环来遍历它们。我们观察每一页的规律,发现页码是通过 URL 的 page 参数指定的。我们用 PowerShell 来拼接 URL 字符串:

$uri = 'http://meiweisq.com/home?page=' + $page

对于每一页,继续用 Invoke-WebRequest 来获取它的内容:

$response = Invoke-WebRequest -Uri $uri -Method Default -WebSession $rb

分析书签

在每一页中,含有不超过 30 个书签,其中包含了书签的标题、URL、标签、时间等信息。

接下来是一些 DOM 的分析,需要一点耐心。我们先把它输出为 .html 文件,以便分析:

$response.Content > current_page.html

从 Chrome 的开发者工具中,可以观察到 DOM 的结构。和我们有关系的是 class 为 links、link、tags、tag这些元素。我们用 jQuery 的语法来表达它们,整理成一个表格如下:

选择器 含义
div.links 本页所有书签的集合
div.links > div.link 一个书签
div.links > div.link a.link-title 书签标题、URL
div.links > div.link a.link-time 时间
div.links > div.link ul.tags > tag 标签

请注意一下,在 Invoke-WebRequest 的结果(COM 对象)中做 DOM 查询,是有点慢的,不像 WEB 中的 jQuery 那么高效。在我们需要做一定的优化,以缩短大量的查询的总时间。我的优化原则如下:

  1. 能用 id 过滤的,不用 tag。
  2. 如果需要查询一个节点的子节点,则把前者保存到临时变量中,不要每次都从根对象(document)开始查询。

以下是 DOM 查询的相关代码:

$html = $response.ParsedHtml
$linksDiv = ($html.getElementsByTagName('div') | where { $_.classname -eq 'links' })[0]
$linksDiv.getElementsByTagName('div') | where { $_.classname -cmatch '\blink[\s,$]' }

$linkTitle = $div.getElementsByTagName('a') | where { $_.className -eq 'link-title' }
$title = $linkTitle | select -exp innerText
$url = $linkTitle | select -exp href

$linkTime = $div.getElementsByTagName('p') | where { $_.className -eq 'link-time' } | select -exp innerText

$ul = $div.getElementsByTagName('ul') | where { $_.className -cmatch '\btags[\s,$]' }
$tags = $ul.getElementsByTagName('a') | where { $_.className -cmatch 'tag' }
$tagNames = $tags | foreach { $_.getAttribute('tag') }

Javascript 的时间转换

美味书签的时间以 yyyy/MM/dd 的形式表达,而 Delicious 导入/导出文件的时间以 Javascript 格式表达。它们之间的转换方法是,前者减去1970年1月1日0时整的时间差,得到的总秒数,即得到其 Javascript 的格式表达。PowerShell 实现代码如下:

$jsTime = ([datetime]::ParseExact($_.LinkTime, 'yyyy/MM/dd', $null) - [datetime]'1970-01-01').TotalSeconds

输出

经过上面的步骤,我们已将所有的书签以 PSObject 的形式存放在 $bookmarks 数组中。现在可以随心所欲地将 $bookmarks 输出为我们所希望的格式了:

这是输出为 CSV 格式的代码:

$bookmarks | Export-Csv ("meiweisq-export-{0:yyyyMMdd}.csv" -f [datetime]::Now) -Encoding UTF8 -NoTypeInformation

这是输出到 GUI 界面的代码:

$bookmarks | Out-GridView

另外,我们可以导出为 Delicious 的专用格式。由于格式比较简单,我们就不用 ConvertTo-HTML 之类的函数了。

源代码

$email = 'victorwoo@gmail.com'
$password = 'xxx'

$homeUrl = 'http://meiweisq.com/home'
$loginAction = 'http://meiweisq.com/login'

$bookmarksPerPage = 30
$countPerExport = 10

function Get-DeliciousHtml($bookmarks) {
    $pre = @"
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<!-- This is an automatically generated file.
It will be read and overwritten.
Do Not Edit! -->
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>

"@

    $post = @"
</DL><p>
"@

    $bookmarkTemplate = @"
<DT><A HREF="{0}" ADD_DATE="{1}" PRIVATE="{2}" TAGS="{3}">{4}</A>
<DD>{5}

"@
    $result = $pre
    $bookmarks | foreach {
        $jsTime = ([datetime]::ParseExact($_.LinkTime, 'yyyy/MM/dd', $null) - [datetime]'1970-01-01').TotalSeconds
        $tags = [string]::Join(',', $_.Tags -split ', ')
        $bookmarkString = $bookmarkTemplate -f $_.Url, $jsTime, 0, $tags, $_.Title, ''
        $result += $bookmarkString
    }
    $result += $post
    return $result
}

$startTime = [datetime]::Now

echo 'Requesting home'
$response = Invoke-WebRequest -Uri $homeUrl -Method Default -SessionVariable rb -ContentType application/html
if ($response.StatusCode -ne 200) {
    Write-Warning "[$response.StatusCode] $homeUrl"
    return
}
$response.Content > mwsq_login.html

echo 'Logining'
$loginForm = ($response.Forms | where { $_.Action -eq '/login' })[0]
$loginForm.Fields['email'] = $email
$loginForm.Fields['password'] = $password
$loginForm.Fields['type'] = '登录'
$loginForm.Fields['return-url'] = '/home'
$loginForm.Fields['remember'] = 'off'

$response = Invoke-WebRequest -Uri $loginAction -WebSession $rb -Method POST -Body $loginForm
if ($response.StatusCode -ne 200) {
    Write-Warning "[$response.StatusCode] $loginAction"
    return
}
$response.Content > mwsq_home.html

if ($response.Content -cnotmatch '1 - (\d+) 共 (\d+) 个书签') {
    Write-Warning '找不到书签个数'
    return
}

$page1Count = $Matches[1]
$totalCount = $Matches[2]
echo "1 - $page1Count 共 $totalCount 个书签"
$pageCount = [math]::Ceiling($totalCount / $bookmarksPerPage)
echo "共 $pageCount 页"
echo ''

$bookmarks = @()
for ($page = 1; $page -le $pageCount; $page++) {
    $uri = 'http://meiweisq.com/home?page=' + $page
    echo "Requesting $uri"

    $isSuccess = $false
    while (!$isSuccess) {
        try {
            $response = Invoke-WebRequest -Uri $uri -Method Default -WebSession $rb
            if ($response.StatusCode -ne 200) {
                Write-Warning "[$response.StatusCode] $loginAction"
                continue
            }
            $isSuccess = $true
        } catch { }
    }

    $response.Content > current_page.html
    $html = $response.ParsedHtml
    $linksDiv = ($html.getElementsByTagName('div') | where { $_.classname -eq 'links' })[0]
    $linksDiv.getElementsByTagName('div') | where { $_.classname -cmatch '\blink[\s,$]' } | foreach {
        $message = "Bookmark: {0} / {1}, Page: {2} / {3}, Elapsed: {4}" -f @(
            $($bookmarks.Length + 1),
            $totalCount,
            $page
            $pageCount,
            ([datetime]::Now - $startTime).ToString()
        )
        Write-Progress -Activity 'Getting bookmarks' -PercentComplete (100 * ($bookmarks.Length + 1) / $totalCount) -CurrentOperation $message
        echo "$($bookmarks.Length + 1) of $totalCount"
        $div = $_
        $linkTitle = $div.getElementsByTagName('a') | where { $_.className -eq 'link-title' }

        $title = $linkTitle | select -exp innerText
        $title = $title.Trim()
        echo $title

        $url = $linkTitle | select -exp href
        echo $url

        $linkTime = $div.getElementsByTagName('p') | where { $_.className -eq 'link-time' } | select -exp innerText
        $linkTime = $linkTime.Trim()
        echo $linkTime

        $ul = $div.getElementsByTagName('ul') | where { $_.className -cmatch '\btags[\s,$]' }
        $tags = $ul.getElementsByTagName('a') | where { $_.className -cmatch 'tag' }
        $tagNames = $tags | foreach { $_.getAttribute('tag') }
        if ($tagNames -eq $null) {
            $tagNames = @()
        }


        echo "[$([string]::Join(' | ', $tagNames))]"
        echo ''

        $bookmark = [PSObject]@{
            Title = $title
            Url = $url
            LinkTime = $linkTime
            Tags = [string]::Join(', ', $tagNames)
        }

        $bookmarks += New-Object -TypeName PSObject -Property $bookmark
    }
}

echo 'Exporting html thant you can import into del.icio.us'
$index = 0
while ($index -lt $totalCount) {
    $currentCountInExport = [math]::Min($countPerExport, $totalCount - $index)
    $endIndex = $index + $currentCountInExport

    $deliciousHtml = Get-DeliciousHtml ($bookmarks | select -Skip $index -First $currentCountInExport)
    $deliciousHtml | sc -Encoding UTF8 ("meiweisq-export-{0:yyyyMMdd}-{1}-{2}.html" -f [datetime]::Now, $index, $endIndex)
    $index += $currentCountInExport
}

$deliciousHtml = Get-DeliciousHtml $bookmarks
$deliciousHtml | sc -Encoding UTF8 ("meiweisq-export-{0:yyyyMMdd}-all.html" -f [datetime]::Now)

echo 'Exporting CSV.'
$bookmarks | Export-Csv ("meiweisq-export-{0:yyyyMMdd}.csv" -f [datetime]::Now) -Encoding UTF8 -NoTypeInformation

echo 'Exporting GUI.'
$bookmarks | Out-GridView

echo 'All done.'

您也可以点击这里下载源代码。

PowerShell 技术 QQ 群