SpringBoot + Thymeleaf + Bootstrap + 随手记 实现自动记账

SpringBoot + Thymeleaf + Bootstrap + 随手记 实现自动记账 一

  • 前情提要
  • 思路
  • 实现
  • 项目大体分以下7个部分
  • 1.搭建后台框架
  • 2.数据库表设计
  • 3.引入JavaMail 组件 解析邮件内容
  • 4.解析并存储邮件里的账单内容
  • 5.爬取随手记获得所有类型
  • 识别账单和随手记中类别的对应关系
  • 保存到随手记

前情提要

在用随手记勤勤恳恳记了6年账之后,突然有天开始变懒了。既然每笔消费都从手机上支出,为什么不写个程序来自动完成这个动作呢?

思路

目前日常支出无非两种,一种是支付宝,一种是微信。说到这个,再次吐槽微信支付做的真的是一坨shit。
因为都绑定了信用卡,所以只要是能使用信用卡的地方都优先使用了信用卡来付款,这样的好处,每笔消费记录都以电子账单的形式发送到邮箱。
基于以上,一个自动记账的程序大体思路就浮现了。

第一步 读取邮箱邮件内容,取得招商银行的的账单信息,分析账单得到每笔消费
第二步 获取到"随手记"系统的支出类别的类别编码
第三步 根据消费店铺的收款方和付款时间来识别这笔消费属于什么分类,比如 只要含有地铁或者深圳通 就可以识别为公共交通类的支出
第四步 把识别好的账单记录保存到随手记

实现

本篇博客没想要怎么介绍从头搭建一整套框架,只是想实现一个身边的需求来方便自己,重点是引发各位怎么样去实现自己身边的需求,所以不会介绍怎么从头搭建一套框架。

项目大体分以下7个部分

  1. 搭建后台框架
  2. 设计数据库表
  3. 引入JavaMail 组件 解析邮件内容
  4. 解析并存储邮件里的账单内容
  5. 爬取随手记获得所有类型
  6. 识别账单和随手记中类别的对应关系
  7. 保存到随手记

1.搭建后台框架

在此推荐一个SpringBoot + mybatis 的种子项目
Git地址: https://github.com/lihengming/spring-boot-api-project-seed

2.数据库表设计

  1. 首先我们需要一张邮件email表来记录收到邮件的日期,邮件ID等防止重复读取邮件
  2. 然后我们需要账单流水表account_flow来保存从邮件中解析到的具体消费信息
  3. 保存从随手记上同步到的类型我们需要一张类型ssj_type
  4. 最后我们需要一张relation表,来保存随手记系统中的类型ID和我们的消费店铺关系

以上,总共4张表。

3.引入JavaMail 组件 解析邮件内容

java 使用 JavaMail 组件网络上已经有大量资料,推荐系列文章JavaMail学习笔记

此处有几个要注意的地方

  1. 邮箱必须要开启POP3接受 设置->账户 里面开启
  2. 以QQ邮箱为例默认抓取的是最近30天的邮件,可自行更改

4.解析并存储邮件里的账单内容

为了解析邮件内容,需要引入 jsoup包 Maven坐标如下

   
       org.jsoup
       jsoup
       1.11.3
   

参考资料: jsoup中文学习手册
以招商银行账单为例 ,把邮件内容另存为html可以看的如下图。
我们只要解析这个部位的html,拿到表格内容也就得到了具体的消费信息
SpringBoot + Thymeleaf + Bootstrap + 随手记 实现自动记账_第1张图片
右键查看源码,可以看到,每行的内容都在一个id为 fixBand28 的span标记下
在这里插入图片描述
我们可以使用 jsoup 来解析这段html, 关键代码如下

    Document doc = Jsoup.parse("邮件正文");
	Elements spans = doc.select("[id=fixBand28]");
	saveAccountFlow(spans, mailId);

	/**
	 * 保存账务流水
	 * @param spans 招商银行账单表格TD
	 * @param mailId 内容来自的邮件ID
	 */
	private void saveAccountFlow(Elements spans, Integer mailId) {
		List<AccountFlow> accountFlowList = new ArrayList<>();
		for (Element span: spans) {
			Elements cardNo = span.select("[id=fixBand29]");
			Elements tradeDate = span.select("[id=fixBand30]");
			Elements tradeTime = span.select("[id=fixBand31]");
			Elements currency = span.select("[id=fixBand32]");
			Elements store = span.select("[id=fixBand33]");
			Elements money = span.select("[id=fixBand34]");

			AccountFlow accountFlow = new AccountFlow();
			accountFlow.setEmailId(mailId);
			accountFlow.setCardNo(cardNo.text());
			Date tradeDateTime = turnDateStr(tradeDate.text() +" " +tradeTime.text());
			accountFlow.setTradeDate(tradeDateTime);
			accountFlow.setCurrency(currency.text());
			accountFlow.setStore(store.text());
			accountFlow.setMoney(new BigDecimal(money.text()));
			accountFlowList.add(accountFlow);
		}
		accountFlowService.save(accountFlowList);
	}

至此,已经把账单内容从邮件中提取到了我们自己的数据库中,接下来是使用HttpClient来模拟登录随手记

5.爬取随手记获得所有类型

这部分思路是,打开随手记官网登录 用谷歌浏览器F12获得url,然后用httpclient来模拟登录。

这块遇到的第一个问题,随手记官网登录虽然没有使用验证码机制拦截,但是在传输时是用了JS加密密码。加密的方式是,在页面加载时获得一个vccode+uid 然后在登录时候 要在js里用vccode+密码+帐号 混淆密码后传输。
好消息是在 Java 8 中的Nashorn 引擎实现了用java去执行js脚本,省去了要用java实现一遍js加密逻辑的问题。
在随手记登录界面,右键查看源代码找到

把这段加密的js放入我们项目中,然后用java去驱动执行,关键代码如下

 	@GetMapping("/login")
	public Result add() throws FileNotFoundException {
	
		String userName = "[email protected]";
		String passWord = "xxx密码";

		//登录地址
		String url = "http://www.feidee.com/sso/login.do?opt=vccode";
		String result = HttpUtils.get(url);
		//返回值转换为json对象
		JSONObject jsonObject = JSON.parseObject(result);
		String vccode = jsonObject.get("vccode").toString();
		String uid = jsonObject.get("uid").toString();

		//加载js方法
		Login method = getMethod(Login.class);
		assert method != null;
		//执行JS方法加密密码
		passWord = method.hex_sha1(passWord);
		passWord = method.hex_sha1(userName + passWord);
		passWord = method.hex_sha1(passWord + vccode);

		//登录地址
		url = "https://www.feidee.com/sso/login.do?email=" + userName + "&status=0&password=" + passWord
				+ "&uid=" + uid;
		result = HttpUtils.get(url);

		//登录后会重定向此页面并返回鉴权参数
		url = "https://www.feidee.com/sso/auth.do";
		result = HttpUtils.get(url);

		//解析鉴权参数,里面有url地址需要再次向这个地址请求,才能完成登录
		Document doc = Jsoup.parse(result);
		Elements inputs = doc.select("input");
		Map<String, Object> mapParam = new HashMap<>(5);
		for (Element input : inputs) {
			String key = input.attr("name");
			String value = input.attr("value");
			mapParam.put(key, value);
		}

		Elements form = doc.select("form");
		//获得鉴权的地址进行post请求,至此登录完成
		url = form.attr("action");
		result = HttpUtils.postMap(url, mapParam);
		return ResultGenerator.genSuccessResult(result);
	}
	
	/**
	 * 从给定的js文件中获取指定接口中的方法的实例
	 *
	 * @param clazz 接口的class
	 * @return 返回一个指定接口方法的实例
	 */
	private <T> T getMethod(Class<T> clazz) {
		ScriptEngineManager manager = new ScriptEngineManager();
		ScriptEngine engine = manager.getEngineByName("js");
		try {
			File file = ResourceUtils.getFile("classpath:login.js");

			// FileReader的参数为所要执行的js文件的路径
			engine.eval(new FileReader(file));
			if (engine instanceof Invocable) {
				Invocable invocable = (Invocable) engine;
				return invocable.getInterface(clazz);
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}

随手记的登录逻辑是
登录页面加载获得 vccode和uid ,然后点击登录时,用js把密码+vccode+uid 进行混淆,然后登录。验证成功后会重定向到权限页面 https://www.feidee.com/sso/auth.do 。
这个页面会返回一个 form表单,表单里有4个token参数和action 然后会用一段js在加载完成后执行一个提交操作,至此权限验证完成。 这样做的目的不知道不是为了反爬虫?

登录完成后就简单了,直接请求https://www.sui.com/tally/new.do 页面,里面有所有的账户信息和类别信息,这时候又要用到jsoup来解析所有类别关键代码如下

	@GetMapping("/get/type")
	public Result getAllType() {

		// 跳转首页获取分类信息
		String url = "https://www.sui.com/tally/new.do";

		String result = HttpUtils.get(url);

		assert result != null;
		Document doc = Jsoup.parse(result);
		Elements liList = doc.select("li.ls-li.ls-li2");

		List<SsjType> ssjTypes = new ArrayList<>();
		for (Element li : liList) {
			String oriStr = li.attr("onclick");
			Matcher matcher = pattern.matcher(oriStr);
			StringBuilder bracketsBuilder = new StringBuilder();
			while (matcher.find()) {
				bracketsBuilder.append(matcher.group());
			}
			String[] brackets = bracketsBuilder.toString().split(",");
			if (brackets.length >= 3) {
				SsjType ssjType = new SsjType();
				ssjType.setTypeName(brackets[1].replace("'", "").trim());
				ssjType.setTypeId(brackets[2].replace("'", "").trim());
				ssjType.setCreateBy("SYSTEM");
				ssjType.setLastUpdateDate(new Date());
				ssjTypes.add(ssjType);
			}
		}
		ssjTypeService.save(ssjTypes);

		return ResultGenerator.genSuccessResult(result);
	}

至此,类别信息已经拿到。

识别账单和随手记中类别的对应关系

这部分比较简单,新建一张relation表,如下
在这里插入图片描述
type 1 代表着要全字匹配,type 2代表只要消费店铺含有这个关键字就识别为此分类。
然后就是查询出之前保存的所有账单流水,然后根据消费店铺进行一个粗略的分类,对于没有匹配的店铺,我们统统归类到其他下面,关键代码如下

	@GetMapping("/match")
	public Result matchType() {
		Condition condition = new Condition(AccountFlow.class);
		Example.Criteria criteria = condition.createCriteria();
		criteria.andEqualTo("isSendSsj", "0");
		List<AccountFlow> accountFlowList = accountFlowService.findByCondition(condition);
		log.debug("流水总数{}", accountFlowList.size());

		// 全名称匹配
		condition = new Condition(Relation.class);
		criteria = condition.createCriteria();
		criteria.andEqualTo("type", "1");
		List<Relation> relationList = relationService.findByCondition(condition);

		//关键字匹配
		condition = new Condition(Relation.class);
		criteria = condition.createCriteria();
		criteria.andEqualTo("type", "2");
		List<Relation> relationList2 = relationService.findByCondition(condition);

		Map<String, Relation> fullNameMap = relationList.stream().collect(Collectors.toMap(Relation::getKeyWords, r -> r));

		for (AccountFlow accountFlow : accountFlowList) {
			String store = accountFlow.getStore();
			String ssjTypeId = "266619228780";
			String ssjTypeName = "其他";

			if (fullNameMap.containsKey(store)) {
				ssjTypeId = fullNameMap.get(store).getSsjType();
				ssjTypeName = fullNameMap.get(store).getSsjTypeName();
			} else {
				//关键字匹配
				for (Relation r : relationList2) {
					if (store.contains(r.getKeyWords())) {
						ssjTypeId = r.getSsjType();
						ssjTypeName = r.getSsjTypeName();
						break;
					}
				}
			}
			accountFlow.setSsjType(ssjTypeId);
			accountFlow.setSsjTypeName(ssjTypeName);
			accountFlowService.update(accountFlow);
		}
		return ResultGenerator.genSuccessResult();
	}

保存到随手记

因为已经搞定了登录随手记的代码,所以保存就比较简单了。
首先打开F12,然后在网页上记录一笔账单,这样可以知道调用的接口
然后用httpclient模拟这个接口即可,关键代码如下

	@PostMapping("/send")
	public Result sendToSsj() {
		// 查询所有未发送的数据到随手记
		Condition condition = new Condition(AccountFlow.class);
		Example.Criteria criteria = condition.createCriteria();
		criteria.andEqualTo("isSendSsj", "0");
		List<AccountFlow> accountFlowList = accountFlowService.findByCondition(condition);

		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
		for (AccountFlow accountFlow : accountFlowList) {
			Map<String, Object> mapParam = new HashMap<>(5);
			mapParam.put("id", "0");
			mapParam.put("category", accountFlow.getSsjType());
			mapParam.put("store", "0");
			mapParam.put("time", sdf.format(accountFlow.getTradeDate()));
			mapParam.put("project", "0");
			mapParam.put("member", "0");
			mapParam.put("memo", accountFlow.getStore().replace("消费 ", ""));
			mapParam.put("url", "");
			mapParam.put("out_account", "0");
			mapParam.put("in_account", "0");
			mapParam.put("debt_account", "");
			mapParam.put("account", "24237910630");
			mapParam.put("price", accountFlow.getMoney().toString());
			mapParam.put("price2", "");
			log.info("参数:{}", mapParam);

			String url = "https://www.sui.com/tally/payout.rmi";
			String result = HttpUtils.postMap(url, mapParam);
			if (result != null && result.contains("{id:{id")) {
				accountFlow.setIsSendSsj(1);
				accountFlowService.update(accountFlow);
			}
		}
		return ResultGenerator.genSuccessResult();
	}

这个项目技术难度其实不大,但是也综合考虑了很多东西,要求前后台都有一定的了解。比如jsoup如果没有jquery或者css选择器的知识可能看的懵懂。对于模拟登录,不了解http协议可能也会看的很虚,总得来说算是一个比较好的启发项目。

至此所有功能都已经全部实现,我们还需要一个前端的界面来美化和展示。

你可能感兴趣的:(SpringBoot + Thymeleaf + Bootstrap + 随手记 实现自动记账)