代码改变世界

一次批量修改博客文章的经验(下):操作过程

2010-01-05 19:40  Jeffrey Zhao  阅读(5569)  评论(8编辑  收藏  举报

上一篇文章中我们进行了一些预备工作,主要是了解了该如何使用MetaWeblog API读取和修改博客园的文章——包括同步和异步两种调用方式。此外,由于F#在异步调用方面的优势,我决定使用F#来完成批量修改文章任务。这个任务并不困难,但很“危险”,一旦出错可能之前的文章就无法恢复了。因此,我把这个任务拆成多个步骤,每个步骤都会将数据保存在硬盘上。由此,即便出错,还是有挽回的余地。

获取所有文章ID

首先,我们便要下载所有文章了,这又该怎么做呢?虽然MetaWeblog API提供了getRecentPosts方法用来获取最近的文章,但是这个接口却并不好用。例如,它只能用来获取最新的几篇文章内容,但对我来说,我想修改的其实是很久之前的文章。那么,难道要我下载全部500多篇文章才行吗?后来我统计了一下,所有文章大小存成文本文件大约有10M,一个请求下载10M内容还是有些夸张的——而且还看不到进度。因此,我最后打算“曲线救国”,先着手获得所有公开文章的ID,再通过getPost接口获得文章内容。

MetaWeblog API并不提供获取所有文章ID的接口,但这并不影响我们从网页上直接进行抓取。我们从博客园提供的“月份汇总”页面入手,即这样的一张页面。博客园的月份汇总的URL非常有规律,获得它的HTML内容之后即可使用正则表达式来捕获文章ID了。您可能会想,一篇文章可能会取别名(这样URL上就不显示ID了),而网页上各种URL也很多,有什么办法可以准确而方便地分析出文章ID吗?其实这个问题很简单,因为博客园为每篇文章都放置了一个“编辑”链接,它的URL是.../EditPosts.aspx?postid=1633416,对我们来说再方便不过了。

于是下载和捕获文章ID的方法可谓手到擒来:

type WebClient with 

    member c.GetStringAsync(url) =
        async {
            let completeEvent = c.DownloadStringCompleted
            do c.DownloadStringAsync(new Uri(url))
            let! args = Async.AwaitEvent(completeEvent)
            return args.Result
        }    

let downloadPostIdsAsync (beginMonth : DateTime) (endMonth : DateTime) = 

    let downloadPostIdsAsync' (m : DateTime) = 
        async {
            let webClient = new WebClient()
            let url = sprintf "http://www.cnblogs.com/JeffreyZhao/archive/%i/%i.html" m.Year m.Month

            let! html = webClient.GetStringAsync(url)

            let regex = @"EditPosts\.aspx\?postid=(\d+)"
            return [ for m in Regex.Matches(html, regex) -> m.Groups.Item(1).Value |> Int32.Parse ]
        }

    async {
        let! lists =
            Seq.initInfinite (fun i -> beginMonth.AddMonths(i))
            |> Seq.takeWhile (fun m -> m <= endMonth)
            |> Seq.map downloadPostIdsAsync'
            |> Async.Parallel

        lists
        |> List.concat
        |> List.sort
        |> List.map (fun i -> i.ToString())
        |> fun lines -> File.WriteAllLines("postIds.txt", lines)
    }

下载文章ID的任务由downloadPostIdsAsync函数完成,它接受beginMonth和endMonth两个DateTime参数来表示月份的区间,我们将从中获取所有的文章ID。downloadPostIdsAsync函数会生成一个异步工作流,执行这个工作流便会将所有的文章ID进行排序,并保存至postIds.txt文件中去,一行一个。获取单月的文章ID由内部的downloadPostIdsAsync'这个辅助函数负责,它会构造出下载单个月份文章ID的异步工作流,再由外部函数合并而成——换句话说,所有月份的文章将同时进行异步下载,提高效率。

我们可以在main方法中执行downloadPostIdsAsync函数,这样postIds.txt文件中便会出现所有的文章ID了:

System.Net.ServicePointManager.DefaultConnectionLimit <- 10

Blogging.downloadPostIdsAsync (new DateTime(2006, 9, 1)) (new DateTime(2008, 12, 1))
|> Async.RunSynchronously

由于WebClient基于WebRequest对象实现,而WebRequest受到ServicePointManager控制,因此我们要设置其DefaultConnectionLimit属性来打开对单个域名的限制——并控制对并发连接的数量进行限制,以免对服务器产生太大压力(当然我们其实工作量本不大,且博客园也不会那么脆弱)。当然,这个限制也可以通过配置进行更改。

下载所有文章

这个任务要比之前简单许多,因为MetaWeblog API已经提供了可以直接使用的接口。

let apiUrl = "http://www.cnblogs.com/JeffreyZhao/services/metaweblog.aspx"
let userName, password = "JeffreyZhao", "..."

let downloadPostsAsync() = 
    let downloadPostAsync id =        
        async {
            let proxy = MetaWeblog.createProxy()
            do proxy.Url <- apiUrl

            let! post = proxy.GetPostAsync(id, userName, password)

            let file = sprintf @"posts\%i.xml" post.PostID
            let xml = XmlSerialization.serialize post
            File.WriteAllText(file, xml);

            printfn "post %i downloaded" post.PostID
        }
    
    File.ReadAllLines("postIds.txt")
    //|> Seq.take 10
    |> Seq.map downloadPostAsync
    |> Async.Parallel
    |> Async.Ignore

调用:

Blogging.downloadPostsAsync() |> Async.RunSynchronously

与之前一样,所有文章都同时下载,并使用之前讨论过的XML序列化方式保存在磁盘上。执行downloadPostsAsync函数时,控制台上会陆陆续续地打印出文章下载完成的字样。这也是我为什么不希望使用getRecentPosts接口获取文章的原因:有进度,有盼头。

修改及提交文章

对于此类修改任务,最直接的方法还是使用正则表达式。首先,我们取回所有的下载好的文章,筛选出所有需要修改的那些,再将修改后的内容另存为新的文件:

let updateLocalPosts() =

    let regex = @"(?i)(<p\b[^>]*>) ? ?"
    let target = "$1"

    let updatePost (post : MetaWeblog.Post) = 
        post.Content <- Regex.Replace(post.Content, regex, target)
        let file = sprintf @"updated\%i.xml" post.PostID
        let xml = XmlSerialization.serialize post
        File.WriteAllText(file, xml);

    Directory.GetFiles(@"posts\", "*.xml")
    |> Seq.map (fun f -> File.ReadAllText(f))
    |> Seq.map XmlSerialization.deserialize<MetaWeblog.Post>
    |> Seq.filter (fun p -> Regex.IsMatch(p.Content, regex))
    |> Seq.iter updatePost

最后则是重新使用MetaWeblog API提交文章的过程,它只不过是读回所有文章信息,再调用我们之前准备的代理而已:

let updateRemotePosts() = 
    let updateFromFile file = 
        async {
            let proxy = MetaWeblog.createProxy()
            do proxy.Url <- apiUrl

            let post = File.ReadAllText(file) |> XmlSerialization.deserialize<MetaWeblog.Post> xml
            let! result = proxy.UpdatePostAsync(post.PostID.ToString(), userName, password, post, true)

            File.Delete(file)
            printfn "post %i updated" post.PostID
        }

    Directory.GetFiles(@"updated\", "*.xml")
    |> Seq.map updateFromFile
    |> Async.Parallel
    |> Async.Ignore

调用:

Blogging.updateLocalPosts()

Blogging.updateRemotePostsAsync() |> Async.RunSynchronously

如此,我们的批量更新任务就完成了。

总结

除了“去除段首空格”之外,我还做了其他一些修改,例如调整以前不太好的代码粘贴方式等等,这些只需要对代码进行简单修改就行了。在实际执行任务时,我也并非一蹴而就,也使用了少数几篇文章进行试验,确定没有问题之后才作了批量提交。总体来说,整个过程没有遇到什么麻烦,现在那些旧文章的格式终于也得到了一定程度的清理。

可惜的是,我最早的几十篇文章中,分段简直是乱来的,一会儿是div,一会儿又用<br />,甚至连用多个……真是只要“所见即所得”就不顾一切了。对于这种问题,有机会再想办法做调整吧。

本文代码

相关文章