Efficient Scraper

高效地提取网页数据

Posted by Hexi on December 4, 2018

前几个月打算开个爬虫的坑,然后开出了一坨新坑。

其实最早的坑很容易: 拿到 HTML -> 解析 HTML,但总觉得前人的做法过于原始,要糊一堆模板代码。于是我构思了一下,我只要能做到这两件事,就能高效地完成我的工作。

  • 高效地拿到 HTML
  • 高效地解析 HTML

目前来看,最接近我第一个需求的项目就是 retrofit,而能满足第二个需求的好像并找不到。

找不到就只能自己造咯,由于最早不想写 Java,我两个月前造出了 unhtml(见上一篇文章)和 gotten

unhtml 就不多介绍了,gotten 大概用法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package example

import (
	"fmt"
	"github.com/Hexilee/gotten"
	"net/http"
	"time"
)

type (
	SimpleParams struct {
		Id   int `type:"path"`
		Page int `type:"query"`
	}

	Item struct {
		TypeId      int
		IId         int
		Name        string
		Description string
	}

	SimpleService struct {
		GetItems func(*SimpleParams) (gotten.Response, error) `method:"GET";path:"itemType/{id}"`
	}
)

var (
	creator, err = gotten.NewBuilder().
		SetBaseUrl("https://api.sample.com").
		AddCookie(&http.Cookie{Name: "clientcookieid", Value: "121", Expires: time.Now().Add(111 * time.Second)}).
		Build()

	simpleServiceImpl = new(SimpleService)
)

func init() {
	err := creator.Impl(simpleServiceImpl)
	if err != nil {
		panic(err)
	}
}

func InYourFunc() {
	resp, err := simpleServiceImpl.GetItems(&SimpleParams{1, 1})
	if err == nil && resp.StatusCode() == http.StatusOK {
		result := make([]*Item, 0) 
		err = resp.Unmarshal(&result)
		fmt.Printf("%#v\n", result)
	}
}

用起来还不错,我用它写了个练手项目 box-go-sdk。但总觉得 API 不是很优美而且有额外的运行时开销,于是我又糊出了 http-service,大概这么用:

先安装 go get github.com/rady-io/http-service,然后确保它在 PATH 里再:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package test

import (
	"io"
	"net/http"
	"time"
)

//go:generate http-service Service

/*
@HttpService
 */
type (
	/*
	@Base {scheme}://box.zjuqsc.com/item
	@Header(User-Agent) {userAgent}
	@Cookie(ga) {ga}
	@Cookie(qsc_session) secure_7y7y1n570y
	*/
	Service interface {
		/*
		@Get /get/{token}?page={page}&limit={limit}
		 */
		GetItem(token int, page int, limit int) (*http.Response, error)

		/*
		@Post /upload
		@Body multipart
		@Header(Content-Type) {contentType}
		@Cookie(ga) {cookie}
		@File(avatar) /var/log/{path}
		 */
		UploadItem(path string, contentType string, cookie string, video io.Reader) (*http.Response, error)

		/*
		@Put /change/{id}
		@Body json
		@Cookie(ga) {cookie}
		@Result json
		 */
		UpdateItem(id int, cookie string, data *time.Time, apiKey string) (result *UploadResult, statusCode int, err error)

		/*
		@Post /stat/{id}
		@SingleBody json
		 */
		StatItem(id int, body *StatBody) (*http.Response, error)

		/*
		@Post /stat/{id}
		@SingleBody json
		 */
		StatByReader(id int, body io.Reader) (*http.Response, error)

		/*
		@Post
		@Body form
		@Param(name) {firstName}.Lee
		 */
		PostInfo(id int, firstName string) (*http.Request, error)
	}
)

type UploadResult struct {
}

type StatBody struct {
}

再执行 go generate,然后同目录下就会出现 service_impl.go 文件,里面有个 serviceImpl 结构体和 NewService 方法,直接拿来用就好了。

看起来挺优雅的,我又糊了个练手项目 box-go-sdk-v2

但毕竟 go 没有 annotation 更没有 annotation processor,解析注释也不过是旁门左道。

“还是去写 Java 吧” —— 我发出了真香的声音。

当然并不能就这样向 Java 屈服,我选择了写 Kotlin。然后我糊出了 unhtml.kt

这个专门给 Kotlin data class 用的 HTML Deserializer,由于比较屎我近期不打算维护/重写所以我没有给文档也没有上传到任何一个包仓库。这里给个例子,感兴趣的人可以去看 test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package test
import me.hexilee.unhtml.annotations.Selector
import me.hexilee.unhtml.annotations.Value
import me.hexilee.unhtml.Deserializer
import org.junit.Assert.*

fun simpleDataTest() {
  val user = Deserializer("""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <div id="test">
        <div>
            <p>Hexilee</p>
            <p>20</p>
            <p>true</p>
        </div>
    </div>
</body>
</html>""", "#test").new<SimpleUser>()
  assertEquals("Hexilee", user.name)
  assertEquals(20, user.age)
  assertTrue(user.likeLemon)
}
  
data class SimpleUser(
  @Selector("p:nth-child(1)")
  @Value
  val name: String,

  @Selector("p:nth-child(2)")
  @Value
  val age: Int,

  @Selector("p:nth-child(3)")
  @Value
  val likeLemon: Boolean
)

所以我说它屎到底屎在哪儿呢?

主要问题是泛型擦除,其次是基础类型的封装类没有实现同一个 FromString 之类的 interface,我得一个个特判并用相应的 String.toXXX 方法(这个问题在 go 里面更严重)。

第一个问题貌似可以用 annotation processor 解决,但我不是很喜欢那个接口而且 —— 既然要代码生成我为什么不写宏呢?

于是就有了 unhtml.rs ,这个项目用起来大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#[macro_use]
extern crate unhtml_derive;
extern crate unhtml;
use unhtml::{self, FromHtml};

#[derive(FromHtml)]
#[html(selector = "#test")]
struct SingleUser {
    #[html(selector = "p:nth-child(1)", attr = "inner")]
    name: String,

    #[html(selector = "p:nth-child(2)", attr = "inner")]
    age: u8,

    #[html(selector = "p:nth-child(3)", attr = "inner")]
    like_lemon: bool,
}

let user = SingleUser::from_html(r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <div id="test">
        <div>
            <p>Hexilee</p>
            <p>20</p>
            <p>true</p>
        </div>
    </div>
</body>
</html>"#).unwrap();
assert_eq!("Hexilee", &user.name);
assert_eq!(20, user.age);
assert!(user.like_lemon);

这个不管从结果和实现上来说都比较让我满意。主要是 rust 给所有的 buildin type 实现了 FromStr,并且支持包外 impl trait。过程宏现在也 stable 了,还有逐渐 stable 的 nll (不过貌似没人关心它是不是 stable),非常稳。

先说这么多吧,rusthttp client 还有一坨坑等着踩呢……