在用随手记勤勤恳恳记了6年账之后,突然有天开始变懒了。既然每笔消费都从手机上支出,为什么不写个程序来自动完成这个动作呢?
目前日常支出无非两种,一种是支付宝,一种是微信。说到这个,再次吐槽微信支付做的真的是一坨shit。
因为都绑定了信用卡,所以只要是能使用信用卡的地方都优先使用了信用卡来付款,这样的好处,每笔消费记录都以电子账单的形式发送到邮箱。
基于以上,一个自动记账的程序大体思路就浮现了。
第一步 读取邮箱邮件内容,取得招商银行的的账单信息,分析账单得到每笔消费
第二步 获取到"随手记"系统的支出类别的类别编码
第三步 根据消费店铺的收款方和付款时间来识别这笔消费属于什么分类,比如 只要含有地铁或者深圳通 就可以识别为公共交通类的支出
第四步 把识别好的账单记录保存到随手记
本篇博客没想要怎么介绍从头搭建一整套框架,只是想实现一个身边的需求来方便自己,重点是引发各位怎么样去实现自己身边的需求,所以不会介绍怎么从头搭建一套框架。
在此推荐一个SpringBoot + mybatis 的种子项目
Git地址: https://github.com/lihengming/spring-boot-api-project-seed
email
表来记录收到邮件的日期,邮件ID等防止重复读取邮件account_flow
来保存从邮件中解析到的具体消费信息ssj_type
表relation
表,来保存随手记系统中的类型ID和我们的消费店铺关系以上,总共4张表。
java 使用 JavaMail 组件网络上已经有大量资料,推荐系列文章JavaMail学习笔记
此处有几个要注意的地方
为了解析邮件内容,需要引入 jsoup包 Maven坐标如下
org.jsoup jsoup 1.11.3
参考资料: jsoup中文学习手册
以招商银行账单为例 ,把邮件内容另存为html可以看的如下图。
我们只要解析这个部位的html,拿到表格内容也就得到了具体的消费信息
右键查看源码,可以看到,每行的内容都在一个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来模拟登录随手记
这部分思路是,打开随手记官网登录 用谷歌浏览器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协议可能也会看的很虚,总得来说算是一个比较好的启发项目。
至此所有功能都已经全部实现,我们还需要一个前端的界面来美化和展示。