需求设计 + 实现分析
系统通过访问URL得到html代码,通过正则表达式匹配html,通过反向引用来得到商品的标题、图片、价格、原价、id,这部分逻辑在java中实现。
匹配商品的正则做成可视化编辑,因为不同网站的结构不同,同一个网站的结构会随时间发生变化,为方便修改,做成可视化编辑。以九块邮为例分析匹配商品的正则:
由此图可见一个正则由多个单元项组成,每个单元项都是一个单独的正则(包括匹配商品的字段项和字段项前后的标志字符串),比如匹配价格的[\d\.]+,价格前面的html >¥ 。最终组合成的正则需要能够正确解析出一个个商品的标题、图片、价格、原价和id字段。
后端代码
匹配代码
package com.learn.reptile.utils;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.learn.reptile.entity.po.Item;
import cn.hutool.http.HttpUtil;
public class ItemCraw {
/**
* 通过url获取html,然后解析出出商品
* @param url
* @param regexpStr 商品匹配正则表达式
* @param startStr 开始匹配字符串
* @param endStr 结束匹配字符串
* @return
*/
public static List<Item> parseItemsFromUrl(String url, String regexpStr, String startStr, String endStr) {
String html = HttpUtil.get(url);
if(StringUtils.isNotBlank(endStr)) {
html = html.substring(html.indexOf(startStr), html.lastIndexOf(endStr));
} else {
html = html.substring(html.indexOf(startStr));
}
List<Item> items = new ArrayList<>();
Pattern pattern = Pattern.compile(regexpStr);
Matcher matcher = pattern.matcher(html);
// 每一个匹配整体
while(matcher.find()) {
Item item = new Item();
item.setItemId(matcher.group("id"));
item.setPic(matcher.group("pic"));
item.setTitle(matcher.group("title"));
item.setPrice(Double.parseDouble(matcher.group("price")));
item.setPrePrice(Double.parseDouble(matcher.group("prePrice")));
items.add(item);
}
return items;
}
}
匹配结果实体类
package com.learn.reptile.entity.po;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
@Data
public class Item {
@TableId(type = IdType.AUTO)
private Long id;
// 淘宝商品id
private String itemId;
// 来源,匹配网站的编码
private String source;
private String title;
private String pic;
private double price;
private double prePrice;
// 采集时间
private Date createTime;
}
controller类
package com.learn.reptile.web.controller;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.learn.reptile.entity.po.Item;
import com.learn.reptile.entity.po.ItemWebsite;
import com.learn.reptile.entity.vo.R;
import com.learn.reptile.utils.ItemCraw;
@RequestMapping("/item")
@RestController
public class ItemController {
@PostMapping("test")
public R<List<Item>> test(@RequestBody ItemWebsite itemWebsite) {
return R.ok(ItemCraw.parseItemsFromUrl(itemWebsite.getUrl(), itemWebsite.getRegexpStr(), itemWebsite.getStartStr(), itemWebsite.getEndStr()));
}
}
前端代码
添加router,位置:src/router/modules/home.js。router的path中增加了参数:id,即网站的id。
{
path: '/item',
component: Layout,
name: 'item',
meta: {
title: '商品',
},
icon: 'icon-home',
children: [
{
path: 'itemWebsite',
name: 'itemWebiste',
component: () => import('@/views/item_website/index.vue'),
meta: {
title: '网站',
},
},
{
path: 'itemRegexp/:id',
name: 'itemRegexp',
component: () => import('@/views/item_website/regexp.vue'),
meta: {
title: '商品匹配正则',
},
hidden: true,
},
],
},
位置src/views/item_website/regexp.vue
分析
用regexpItems表示正则单元项列表,每个regexpItem包含三个字段:type 表示匹配商品的某个字段还是仅仅是分隔部分,matchType 表示该部分的正则模式,matchStr 表示该正则模式需要用到的字符串。
type 数据
key | value |
id | 商品id |
title | 标题 |
pic | 图片 |
price | 价格 |
prePrice | 原价 |
其他 |
matchType 数据
key | value |
all | 任意字符串 |
exclude | 不包含 某些字符串 的字符串 |
fix | 固定字符串 |
number | 价格,[\d\.]+ |
正则单元项html:
<div
class="regexp_item"
v-for="(regexpItem, index) in regexpItems"
:key="index"
>
{{ index + 1 }}
<el-icon @click="regexpItems.splice(index, 1)"><CloseBold /></el-icon>
<div class="line">
<div class="label">类型</div>
<div class="field">
<el-select
v-model="regexpItem.type"
@change="changeType(regexpItem)"
>
<el-option
v-for="(name, code) in types"
:key="code"
:value="code"
:label="name"
>
{{ name }}
</el-option>
</el-select>
</div>
</div>
<div class="line">
<div class="label">匹配类型</div>
<div class="field">
<el-radio-group v-model="regexpItem.matchType">
<el-radio value="number" label="number">数值</el-radio>
<el-radio value="all" label="all">任意字符</el-radio>
<el-radio value="exclude" label="exclude">除</el-radio>
<el-input
class="match_input"
v-if="regexpItem.matchType == 'exclude'"
v-model="regexpItem.matchStr"
/>
<el-radio value="fix" label="fix">固定</el-radio>
<el-input
v-if="regexpItem.matchType == 'fix'"
v-model="regexpItem.matchStr"
/>
</el-radio-group>
</div>
</div>
</div>
页面整体布局为左中右结构,左侧是正则单元项列表,中间是操作按钮,右边是测试匹配结果,完整html部分代码如下:
<template>
<div style="margin: 10px;">{{ itemWebsite.name }}匹配规则</div>
<div style="display: flex;">
<div style="width: 60%">
<div class="form">
<div class="form_label">
匹配开始字符串
</div>
<div class="form_field">
<el-input v-model="itemWebsite.startStr"></el-input>
</div>
<div class="form_label">
匹配结束字符串
</div>
<div class="form_field">
<el-input v-model="itemWebsite.endStr"></el-input>
</div>
</div>
<div
class="regexp_item"
v-for="(regexpItem, index) in regexpItems"
:key="index"
>
{{ index + 1 }}
<el-icon @click="regexpItems.splice(index, 1)"><CloseBold /></el-icon>
<div class="line">
<div class="label">类型</div>
<div class="field">
<el-select
v-model="regexpItem.type"
@change="changeType(regexpItem)"
>
<el-option
v-for="(name, code) in types"
:key="code"
:value="code"
:label="name"
>
{{ name }}
</el-option>
</el-select>
</div>
</div>
<div class="line">
<div class="label">匹配类型</div>
<div class="field">
<el-radio-group v-model="regexpItem.matchType">
<el-radio value="number" label="number">数值</el-radio>
<el-radio value="all" label="all">任意字符</el-radio>
<el-radio value="exclude" label="exclude">除</el-radio>
<el-input
class="match_input"
v-if="regexpItem.matchType == 'exclude'"
v-model="regexpItem.matchStr"
/>
<el-radio value="fix" label="fix">固定</el-radio>
<el-input
v-if="regexpItem.matchType == 'fix'"
v-model="regexpItem.matchStr"
/>
</el-radio-group>
</div>
</div>
</div>
</div>
<div style="width: 180px; text-align: center;">
<div style="margin-bottom: 10px;">
<el-button round type="primary" @click="add">增加匹配项</el-button>
</div>
<div style="margin-bottom: 10px;">
<el-button type="primary" round @click="save">保存</el-button>
</div>
<el-button type="primary" round @click="test">测试</el-button>
</div>
<div style="width: 40%;">
<pre>{{ resultItems }}</pre>
</div>
</div>
</template>
javascript部分:
import {
getCurrentInstance,
reactive,
toRefs,
ref,
computed,
watch,
onMounted,
} from 'vue'
import { getById, update } from '@/api/itemWebsite'
import { test } from '@/api/item'
import { ElMessageBox } from 'element-plus'
export default {
setup() {
const { proxy: ctx } = getCurrentInstance()
const state = reactive({
id: '',
itemWebsite: {},
regexpItems: [],
types: {
title: '标题',
pic: '图片',
id: '商品id',
price: '价格',
prePrice: '原价',
'': '其他',
},
resultItems: '',
add() {
ElMessageBox.prompt('请输入添加的位置下标', '添加匹配项', {
inputPattern: /\d+/,
inputErrorMessage: '下标必须为正整数',
}).then(({ value }) => {
const index = parseInt(value)
ctx.regexpItems.splice(index - 1, 0, {
type: '',
matchType: '',
matchStr: '',
})
})
},
changeType(regexpItem) {
switch (regexpItem.type) {
case 'price':
case 'prePrice':
regexpItem.matchType = 'number'
break
case 'pic':
case 'itemId':
regexpItem.matchType = 'exclude'
regexpItem.matchStr = '"'
break
case 'title':
regexpItem.matchType = 'exclude'
regexpItem.matchStr = '<'
}
},
save() {
var regexpStr = '' // 通过正则单元项列表生成正则字符串
ctx.regexpItems.forEach(item => {
var str = ''
if (item.matchType == 'all') {
str = '.+?'
} else if (item.matchType == 'exclude') {
str = '[^' + item.matchStr + ']+'
} else if (item.matchType == 'fix') {
str = item.matchStr
} else if (item.matchType == 'number') {
str = '[\\d\\.]+'
}
if (item.type) {
regexpStr += '(?<' + item.type + '>' + str + ')'
} else {
regexpStr += str
}
})
update({
startStr: ctx.itemWebsite.startStr,
endStr: ctx.itemWebsite.endStr,
regexpContents: JSON.stringify(ctx.regexpItems), // 正则单元项列表以json字符串保存
regexpStr: regexpStr,
id: ctx.id,
}).then(res => {
ctx.$message.success('保存成功')
})
},
test() {
var regexpStr = ''
ctx.regexpItems.forEach(item => {
var str = ''
if (item.matchType == 'all') {
str = '.+?'
} else if (item.matchType == 'exclude') {
str = '[^' + item.matchStr + ']+'
} else if (item.matchType == 'fix') {
str = item.matchStr
} else if (item.matchType == 'number') {
str = '[\\d\\.]+'
}
if (item.type) {
regexpStr += '(?<' + item.type + '>' + str + ')'
} else {
regexpStr += str
}
})
test({
url: ctx.itemWebsite.url,
startStr: ctx.itemWebsite.startStr,
endStr: ctx.itemWebsite.endStr,
regexpStr: regexpStr,
}).then(res => {
ctx.$message.success('测试成功')
ctx.resultItems = JSON.stringify(
res.data,
['itemId', 'title', 'pic', 'price', 'prePrice'],
'\t'
)
})
},
})
onMounted(() => {
ctx.id = ctx.$route.params.id
getById(ctx.id).then(res => {
ctx.itemWebsite = res.data
if (ctx.itemWebsite.regexpContents) {
ctx.regexpItems = eval('(' + ctx.itemWebsite.regexpContents + ')')
}
})
})
return {
...toRefs(state),
}
},
}
样式部分:
<style>
.regexp_item {
margin: 10px;
border-top: 1px solid gray;
border-right: 1px solid gray;
position: relative;
width: 100%;
}
.regexp_item .el-icon {
position: absolute;
right: -5px;
top: -5px;
color: red;
cursor: pointer;
}
.line {
display: flex;
}
.line > div {
border-bottom: 1px solid gray;
border-left: 1px solid gray;
padding: 5px;
}
.label {
width: 30%;
}
.field {
width: 70%;
}
.match_input {
width: 100px;
margin-right: 15px;
}
.form {
display: flex;
align-items: center;
margin: 10px;
width: 100%;
}
.form_label {
width: 20%;
margin-left: 20px;
}
.form_field {
width: 30%;
}
</style>
代码及演示网站见:正则采集器之一——需求说明-CSDN博客