# 用 express 支撑小爬虫
# 安装使用 express
express是一个基于 node 的 web 后端框架,特点是极简灵活,提供精简的基本 Web 应用程序功能。也是第一代 node 后端框架,也是目前使用率第一的框架,有很多框架还有程序是基于它的。
局部安装 express 和它的类型声明文件:npm install express --save
,npm install @types/express --save
编写 index.ts,代码如下,使用 ts-node 运行它,在"http://localhost:7001/"里就能看到打印的“Hello”
import express from "express";
// 创建一个新的应用程序
const app = express();
// 定义路由,发送内容
app.get("/", (req, res) => {
res.send("Hello");
});
// 绑定并监听连接
app.listen(7001, () => {
console.log("start serve");
});
2
3
4
5
6
7
8
9
10
11
# 定义路由项
像上面这个简单例子里使用了app.get
定义了一个路由,如果使用多个呢?可以使用 express 中的Router
来定义,并且可以单独抽到一个 ts 文件里书写。
router.ts
import { Router, Request, Response } from "express";
const router = Router();
router.get("/", (req: Request, res: Response) => {
res.send("Hello");
});
router.get("/data", (req: Request, res: Response) => {
res.send("Hello Data");
});
export default router;
2
3
4
5
6
7
8
9
10
index.ts
import express from "express";
import router from "./router";
// 创建一个新的应用程序
const app = express();
// 使用之前定义的路由
app.use(router);
// 绑定并监听连接
app.listen(7001, () => {
console.log("start serve");
});
2
3
4
5
6
7
8
9
10
11
# 拿取爬虫数据
将之前写的爬虫相关的类Crawler
、BookAnalyzer
导进来,在/data
路由下再去爬取新数据。但是要给访问/data
路由进行限制,拥有权限的才允许访问。可以使用post 请求传递密码,然后对路由的 request 的 body 进行校验。
但是校验过程中,request 的 body 解析会有问题,我们需要借助一个中间件,npm install body-parser --save
,安装完后在 index.ts 导入它,并在使用路由前加上这一行代码app.use(bodyParser.urlencoded({ extended: false }))
。接着完善我们的router.ts:
import { Router, Request, Response } from "express";
import Crawler from "./crawler";
import BookAnalyzer from "./bookAnalyzer";
const router = Router();
router.get("/", (req: Request, res: Response) => {
res.send(`<html>
<body><form method="post" action="/data">
<input type="password" name="password" />
<button>提交</button>
</form></body>
</html>`);
});
// 对应前端的post请求
router.post("/data", (req: Request, res: Response) => {
if (req.body.password === "123") {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.send("爬取数据成功!");
} else {
res.send("密码错误!");
}
});
export default router;
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
# 扩展 express 的类型定义
虽然 express 有类型定义文件,但会有些瑕疵,比如 Request 和 Response 的 body 在 express 里是any,这不太符合规范。解决方案就是使用interface约束一个新类型,并且这个新类型去继承老类型,然后在使用时就使用这个新类型。
interface InewRequest extends Request {
body: {
[key: string]: string | undefined;
};
}
2
3
4
5
还有就是使用中间件的话,特别是使用自定义中间件,难免会遇到在 Request新添加属性。可以将 express 原有的类型定义和自己写的类型定义融合,这里要明确一点:是融合不是覆盖以前的。像上面使用inteface
配合extends
是使用新类型名,类型定义融合虽然使用老的类型名,但也只是扩充了老类型的属性而已。
declare namespace Express {
// 这里要跟原来的*.d.ts里的一样
interface Request {
// 这里要跟原来的*.d.ts里的一样
xxx: string; // 而这里就是新添的属性,后面就会类型定义融合
}
}
2
3
4
5
6
7
# 状态持久化
# 使用 cookie-session 中间件
登陆的状态需要持久化,可以使用中间件npm install cookie-session --save
,它有自己的类型定义npm install @types/cookie-session -D
。
加上使用中间件的代码:
import express from "express";
import cookieSession from "cookie-session";
import bodyParser from "body-parser";
import router from "./router";
// 创建一个新的应用程序
const app = express();
// 使用cookie-session中间件
app.use(
cookieSession({
name: "session",
keys: ["login"],
// Cookie Options
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
//使用中间件来解析form表单请求里的body
app.use(bodyParser.urlencoded({ extended: false }));
// 使用路由
app.use(router);
// 绑定并监听连接
app.listen(7001, () => {
console.log("start serve");
});
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
# 登陆登出
最主要的就在于登陆状态的判断,req.session
上一小节里的中间件,登陆成功后给 session 里塞一个login
属性,在下次访问对应路由时会校验它。
我们还添加了一个中间页面,里面有“查看数据”、“爬取数据”、“退出登陆”这三个功能性按钮,对应也给他们设置了路由。
import { Router, Request, Response } from "express";
import Crawler from "./crawler/crawler";
import BookAnalyzer from "./crawler/bookAnalyzer";
import fs from "fs";
import path from "path";
interface ExpRequest extends Request {
body: {
[key: string]: string | undefined;
};
}
const router = Router();
// 登陆状态
const checkLogin = (req: ExpRequest, res: Response) => {
if (!req.session || !req.session.login) {
res.send(
`<html>
<body>
您还没有登陆!请先<a href="/">登陆!</a>
</body>
</html>`
);
return false;
}
return true;
};
// 主页
router.get("/", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 登陆过了
res.send(
`<html>
<body>
<a href="/show">查看数据</a>
<a href="/data">爬取数据</a>
<a href="/logout">退出登陆</a>
</body>
</html>`
);
} else {
// 没有登陆显示登陆框
res.send(`
<html>
<body>
<form method="post" action="/login">
<input type="password" name="password" />
<button>登陆</button>
</form>
</body>
</html>
`);
}
});
// 登陆校验
router.post("/login", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
} else {
const { password } = req.body;
if (password === "123") {
// 登陆状态更新
req.session.login = true;
// 重定向到中转站
res.redirect("/");
} else {
res.send(
`<html>
<body>
密码错误!请重新<a href="/">登陆!</a><br/>
</body>
</html>`
);
}
}
});
// 以get的方式访问登陆校验
router.get("/login", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
}
});
// 登出操作
router.get("/logout", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
req.session.login = undefined;
res.send(
`<html>
<body>
登出成功!需要再<a href="/">登陆</a>吗?
</body>
</html>`
);
} else {
res.send(
`<html>
<body>
您本来就是未登录状态!请问需要<a href="/">登陆</a>吗?
</body>
</html>`
);
}
});
// 展示爬虫数据
router.get("/show", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const filePath = path.resolve(__dirname, "../data/book.json");
const jsonStr = fs.readFileSync(filePath, "utf-8");
res.send(JSON.parse(jsonStr));
} catch (error) {
res.send(
`<html>
<body>
展示数据出错!<a href="/">返回</a><br/>
${error}
</body>
</html>`
);
}
}
});
// 爬一次数据
router.get("/data", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.send(
`<html>
<body>
爬取成功!<br/>
<a href="/show">查看全部数据</a>
</body>
</html>`
);
} catch (error) {
res.send(`爬取出错!`);
}
}
});
export default router;
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# 接口化
减少res.send
给前端发送 html 元素,将其换成res.jsonn
,也就是以接口的形式返回给前端,前端根据接口再去写 html。
getResponse.ts
interface Iresult {
success: boolean;
errMsg?: string;
data: Object;
}
export const getResponse = (data: Object, errMsg?: string): Iresult => {
return { success: errMsg == null, errMsg, data };
};
2
3
4
5
6
7
8
router.ts
import { Router, Request, Response } from "express";
import Crawler from "./crawler/crawler";
import BookAnalyzer from "./crawler/bookAnalyzer";
import fs from "fs";
import path from "path";
import { getResponse } from "./utils/getResponse";
interface ExpRequest extends Request {
body: {
[key: string]: string | undefined;
};
}
const router = Router();
// 登陆状态
const checkLogin = (req: ExpRequest, res: Response) => {
if (!req.session || !req.session.login) {
res.json(getResponse(false, "您还没有登陆!请先登陆!"));
return false;
}
return true;
};
// 主页
router.get("/", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 登陆过了
res.send(
`<html>
<body>
<a href="/show">查看数据</a>
<a href="/data">爬取数据</a>
<a href="/logout">退出登陆</a>
</body>
</html>`
);
} else {
// 没有登陆显示登陆框
res.send(`
<html>
<body>
<form method="post" action="/login">
<input type="password" name="password" />
<button>登陆</button>
</form>
</body>
</html>
`);
}
});
// 登陆校验
router.post("/login", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
} else {
const { password } = req.body;
if (password === "123") {
// 登陆状态更新
req.session.login = true;
// 重定向到中转站
res.redirect("/");
} else {
res.json(getResponse(false, "密码错误!"));
}
}
});
// 以get的方式访问登陆校验
router.get("/login", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
// 之前登陆过了,重定向到中转站
res.redirect("/");
}
});
// 登出操作
router.get("/logout", (req: ExpRequest, res: Response) => {
if (req.session && req.session.login) {
req.session.login = undefined;
res.json(getResponse(true));
} else {
res.json(getResponse(false, "您本来就是未登录状态!"));
}
});
// 展示爬虫数据
router.get("/show", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const filePath = path.resolve(__dirname, "../data/book.json");
const jsonStr = fs.readFileSync(filePath, "utf-8");
res.json(getResponse(JSON.parse(jsonStr)));
} catch (error) {
res.json(getResponse(error, "展示数据出错!"));
}
}
});
// 爬一次数据
router.get("/data", (req: ExpRequest, res: Response) => {
if (checkLogin(req, res)) {
try {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.json(getResponse(true));
} catch (error) {
res.json(getResponse(false, "爬取出错!"));
}
}
});
export default router;
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# 使用装饰器来优化
我们发现 router.ts 里面有很多 get 方法,并且不是使用 Class 形式编写的代码。如果要使用 Class 形式来编写,可以考虑使用一个类来编写各种路由 handler,用另一个类来操作前一个类的原型和 Router。
其实可以使用装饰器配合元数据,思路:写一个 Class,首先成员方法是 handler 事件处理函数,给这个成员方法写个方法装饰器用来存储路由路径和本方法;然后对 Class 写个类装饰器,遍历这个类的原型上的属性,如果该属性属于元数据中的一员,证明它就是 handler 事件处理函数,然后将事件处理函数加入到 Router中。这样就完成了路由的添加,并且这个 Class无需实例化,只需要导入到 index.ts 即可,也就是触发装饰器。
对接口的返回做了优化,data 不使用 Object,而是使用泛型,泛型传入来规定 data 是个什么类型。
还对代码结构层次做了优化,将各种装饰器全都分离出来并且放到一个文件夹里,另外将登陆相关逻辑和爬取操作也分离开并且放在一个文件夹里。
src
├──controller (控制层)
│ ├──CrawlController.ts (爬取操作业务逻辑)
│ └──LoginController.ts (登陆处理业务逻辑)
├──crawler (爬虫过程)
│ ├──bookAnalyzer.ts (分析器)
│ └──crawler.ts (爬虫读取写入)
├──decorator (装饰器)
│ ├──controllerDecorator.ts (业务类的装饰器)
│ ├──requestDecorator.ts (router的handler的装饰器)
│ └──useDecorator.ts (router的中间件的装饰器)
├──utils (工具)
│ └──getResponse.ts (响应报文组装)
├──index.ts (入口文件,也是编译入口)
└──router.ts (router)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 优化后的代码清单
getResponse.ts
interface Iresult<T> {
success: boolean;
errMsg?: string;
data: T;
}
// 返回报文
export const getResponse = <T>(data: T, errMsg?: string): Iresult<T> => {
return { success: errMsg == null, errMsg, data };
};
2
3
4
5
6
7
8
9
CrawlController.ts
import fs from "fs";
import path from "path";
import { Response } from "express";
import { controller } from "../decorator/controllerDecorator";
import { get } from "../decorator/requestDecorator";
import { use } from "../decorator/useDecorator";
import Crawler from "../crawler/crawler";
import BookAnalyzer, { IrankInfo } from "../crawler/bookAnalyzer";
import { ExpRequest, checkLogin } from "./LoginController";
import { getResponse } from "../utils/getResponse";
// 这个类不用实例化也不用静态调用,但是一定要在index.ts里导入,因为要触发装饰器
@controller("/")
class CrawlController {
// 展示爬取数据
@get("/show")
@use(checkLogin)
private show(req: ExpRequest, res: Response): void {
try {
const filePath = path.resolve(__dirname, "../../data/book.json");
const jsonStr = fs.readFileSync(filePath, "utf-8");
res.json(getResponse<IrankInfo>(JSON.parse(jsonStr)));
} catch (error) {
res.json(getResponse<boolean>(false, "展示数据出错!"));
}
}
// 去爬取一次数据
@get("/data")
@use(checkLogin)
private data(req: ExpRequest, res: Response): void {
try {
const url = "http://top.hengyan.com/haokan/";
const crawler: Crawler = new Crawler(url, BookAnalyzer.I);
res.json(getResponse<boolean>(true));
} catch (error) {
res.json(getResponse<boolean>(false, "爬取出错!"));
}
}
}
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
LoginController.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import "reflect-metadata";
import { controller } from "../decorator/controllerDecorator";
import { get, post } from "../decorator/requestDecorator";
import { use } from "../decorator/useDecorator";
import { getResponse } from "../utils/getResponse";
// 请求体的session
interface ISession extends CookieSessionInterfaces.CookieSessionObject {
login: boolean;
}
// 请求体
export interface ExpRequest extends Request {
body: {
[key: string]: string | undefined;
};
session: ISession;
}
// 登陆状态
const isLogin = (req: ExpRequest, res: Response): boolean => {
return !!(req.session && req.session.login);
};
// 登陆状态的中间件
export const checkLogin: RequestHandler = (req: ExpRequest, res: Response, next: NextFunction): void => {
if (isLogin(req, res)) {
next();
} else {
res.json(getResponse<boolean>(false, "您还没有登陆!请先登陆!"));
}
};
// 根路径
const root: string = "/";
// 这个类不用实例化也不用静态调用,但是一定要在index.ts里导入,因为要触发装饰器
@controller(root)
class LoginController {
// 主页
@get("/")
private home(req: ExpRequest, res: Response): void {
if (isLogin(req, res)) {
res.send(
`<html>
<body>
<a href="/show">查看数据</a>
<a href="/data">爬取数据</a>
<a href="/logout">退出登陆</a>
</body>
</html>`
);
} else {
// 没有登陆显示登陆框
res.send(`
<html>
<body>
<form method="post" action="/login">
<input type="password" name="password" />
<button>登陆</button>
</form>
</body>
</html>
`);
}
}
// 登陆校验
@post("/login")
private loginPost(req: ExpRequest, res: Response): void {
const rootPath = !root || root === "/" ? "" : root;
if (isLogin(req, res)) {
// 之前登陆过了,重定向到主页
res.redirect(`${rootPath}/`);
} else {
const { password } = req.body;
if (password === "123") {
// 登陆状态更新
req.session.login = true;
// 重定向到主页
res.redirect(`${rootPath}/`);
} else {
res.json(getResponse<boolean>(false, "密码错误!"));
}
}
}
// 登出
@get("/logout")
private logout(req: ExpRequest, res: Response): void {
if (isLogin(req, res)) {
req.session.login = undefined;
res.json(getResponse<boolean>(true));
} else {
res.json(getResponse<boolean>(false, "您本来就是未登录状态!"));
}
}
}
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
controllerDecorator.ts
import { RequestHandler } from "express";
import router from "../router";
import { PathType } from "./requestDecorator";
// 类的装饰器,这里包了一层工厂函数,目的是controller自带接口路径
export function controller(root: string) {
// 真正的装饰器
return function (target: new (...args: any[]) => {}) {
// 遍历原型上的属性
for (let key in target.prototype) {
// 路由路径
const path: string = Reflect.getMetadata("path", target.prototype, key);
// 是router.get还是router.post
const method: PathType = Reflect.getMetadata("method", target.prototype, key);
// 中间件
const middleWare: RequestHandler = Reflect.getMetadata("middleWare", target.prototype, key);
// 方法
const handler = target.prototype[key];
// 都存在才添加路由
if (path && method && handler) {
const fullPath = !root || root === "/" ? path : `${root}${path}`;
if (middleWare) {
// 使用中间件
router[method](fullPath, middleWare, handler);
} else {
router[method](fullPath, handler);
}
}
}
};
}
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
requestDecorator.ts
// 枚举,是router.get还是router.post
export enum PathType {
Get = "get",
Post = "post",
}
// methodType来区分是router.get还是router.post,这是包第一层工厂函数的原因
function getRequestDecorator(methodType: PathType) {
// value是外部调用get(value)的入参,这也是再包一层工厂函数的原因
return function (value: string) {
// 类的方法的装饰器:target是方法所在的原型对象,key是方法名。最后的这个才是真正的装饰器。
return function (target: any, key: string) {
// 将handler和对应方法存入元素数据
// 'path'是metadataKey,value是metadataValue,target是方法所在的原型对象,key是方法名
Reflect.defineMetadata("path", value, target, key);
// 存入元数据,你是router.get还是router.post
Reflect.defineMetadata("method", methodType, target, key);
};
};
}
// get的装饰器,用于使用router.get()
export const get = getRequestDecorator(PathType.Get);
// post的装饰器,用于使用router.post()
export const post = getRequestDecorator(PathType.Post);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
useDecorator.ts
import { RequestHandler } from "express";
// use装饰器,用于使用中间件。这里包了一层工厂函数,因为要传middleWare进来
export function use(middleWare: RequestHandler) {
// 真正的装饰器
return function (target: any, key: string) {
const originMiddleWare: RequestHandler[] = Reflect.getMetadata("middleWares", target, key) || [];
// 可以使用多个中间件
originMiddleWare.push(middleWare);
Reflect.defineMetadata("middleWares", originMiddleWare, target, key);
};
}
2
3
4
5
6
7
8
9
10
11