Skip to content

用 express 支撑小爬虫

安装使用 express

express是一个基于 node 的 web 后端框架,特点是极简灵活,提供精简的基本 Web 应用程序功能。也是第一代 node 后端框架,也是目前使用率第一的框架,有很多框架还有程序是基于它的。

局部安装 express 和它的类型声明文件:npm install express --savenpm install @types/express --save

编写 index.ts,代码如下,使用 ts-node 运行它,在http://localhost:7001/里就能看到打印的“Hello”

ts
import express from "express";
// 创建一个新的应用程序
const app = express();
// 定义路由,发送内容
app.get("/", (req, res) => {
  res.send("Hello");
});
// 绑定并监听连接
app.listen(7001, () => {
  console.log("start serve");
});

定义路由项

像上面这个简单例子里使用了app.get定义了一个路由,如果使用多个呢?可以使用 express 中的Router来定义,并且可以单独抽到一个 ts 文件里书写。

router.ts

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;

index.ts

ts
import express from "express";
import router from "./router";

// 创建一个新的应用程序
const app = express();
// 使用之前定义的路由
app.use(router);
// 绑定并监听连接
app.listen(7001, () => {
  console.log("start serve");
});

拿取爬虫数据

将之前写的爬虫相关的类CrawlerBookAnalyzer导进来,在/data路由下再去爬取新数据。但是要给访问/data路由进行限制,拥有权限的才允许访问。可以使用post 请求传递密码,然后对路由的 request 的 body 进行校验。

但是校验过程中,request 的 body 解析会有问题,我们需要借助一个中间件,npm install body-parser --save,安装完后在 index.ts 导入它,并在使用路由前加上这一行代码app.use(bodyParser.urlencoded({ extended: false }))。接着完善我们的router.ts

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;

扩展 express 的类型定义

虽然 express 有类型定义文件,但会有些瑕疵,比如 Request 和 Response 的 body 在 express 里是any,这不太符合规范。解决方案就是使用interface约束一个新类型,并且这个新类型去继承老类型,然后在使用时就使用这个新类型。

ts
interface InewRequest extends Request {
  body: {
    [key: string]: string | undefined;
  };
}

还有就是使用中间件的话,特别是使用自定义中间件,难免会遇到在 Request新添加属性。可以将 express 原有的类型定义和自己写的类型定义融合,这里要明确一点:是融合不是覆盖以前的。像上面使用inteface配合extends是使用新类型名,类型定义融合虽然使用老的类型名,但也只是扩充了老类型的属性而已。

ts
declare namespace Express {
  // 这里要跟原来的*.d.ts里的一样
  interface Request {
    // 这里要跟原来的*.d.ts里的一样
    xxx: string; // 而这里就是新添的属性,后面就会类型定义融合
  }
}

状态持久化

登陆的状态需要持久化,可以使用中间件npm install cookie-session --save,它有自己的类型定义npm install @types/cookie-session -D

加上使用中间件的代码:

ts
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");
});

登陆登出

最主要的就在于登陆状态的判断,req.session上一小节里的中间件,登陆成功后给 session 里塞一个login属性,在下次访问对应路由时会校验它。

我们还添加了一个中间页面,里面有“查看数据”、“爬取数据”、“退出登陆”这三个功能性按钮,对应也给他们设置了路由。

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

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;

接口化

减少res.send给前端发送 html 元素,将其换成res.jsonn,也就是以接口的形式返回给前端,前端根据接口再去写 html。

getResponse.ts

ts
interface Iresult {
  success: boolean;
  errMsg?: string;
  data: Object;
}
export const getResponse = (data: Object, errMsg?: string): Iresult => {
  return { success: errMsg == null, errMsg, data };
};

router.ts

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;

使用装饰器来优化

我们发现 router.ts 里面有很多 get 方法,并且不是使用 Class 形式编写的代码。如果要使用 Class 形式来编写,可以考虑使用一个类来编写各种路由 handler,用另一个类来操作前一个类的原型和 Router。

其实可以使用装饰器配合元数据,思路:写一个 Class,首先成员方法是 handler 事件处理函数,给这个成员方法写个方法装饰器用来存储路由路径和本方法;然后对 Class 写个类装饰器,遍历这个类的原型上的属性,如果该属性属于元数据中的一员,证明它就是 handler 事件处理函数,然后将事件处理函数加入到 Router中。这样就完成了路由的添加,并且这个 Class无需实例化,只需要导入到 index.ts 即可,也就是触发装饰器。

对接口的返回做了优化,data 不使用 Object,而是使用泛型,泛型传入来规定 data 是个什么类型。

还对代码结构层次做了优化,将各种装饰器全都分离出来并且放到一个文件夹里,另外将登陆相关逻辑和爬取操作也分离开并且放在一个文件夹里。

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

优化后的代码清单

getResponse.ts

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

CrawlController.ts

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, "爬取出错!"));
    }
  }
}

LoginController.ts

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, "您本来就是未登录状态!"));
    }
  }
}

controllerDecorator.ts

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

requestDecorator.ts

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

useDecorator.ts

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

MIT Licensed | Copyright © 2023-present LiawnLiu