通用论坛正文提取

本人长期出售超大量微博数据、旅游网站评论数据,并提供各种指定数据爬取服务,Message to YuboonaZhang@Yahoo.com。同时欢迎加入社交媒体数据交流群:99918768

背景

参加泰迪杯数据挖掘竞赛,这次真的学习到了不少东西,最后差不多可以完成要求的内容,准确率也还行。总共的代码,算上中间的过程处理也不超过500行,代码思想也还比较简单,主要是根据论坛的短文本特性和楼层之间内容的相似来完成的。(通俗点说就是去噪去噪去噪,然后只留下相对有规律的日期,内容)

前期准备

  1. 软件和开发环境: Pycharm,Python2.7,Linux系统

  2. 用的主要Python包: jieba, requests, BeautifulSoup, goose, selenium, PhantomJS, pymongo等(部分软件的安装我前面的博客有介绍)

网页预处理

首先因为网站很多是动态的,直接用bs4是获取不到有些信息的,所以我们使用selenium和phantomjs将文件保存在本地,然后再处理。

相关的代码是

1
2
3
4
5
6
7
8
9
10
11
def save(baseUrl):
driver = webdriver.PhantomJS()
driver.get(baseUrl) # seconds
try:
element = WebDriverWait(driver, 10).until(isload(driver) is True)
except Exception, e:
print e
finally:
data = driver.page_source # 取到加载js后的页面content
driver.quit()
return data

由于网页中存在着大量的噪音(广告,图片等),首先我们需要将与我们所提取内容不一致的所有噪声尽可能去除。我们首先选择将一些带有典型噪声意义的噪声标签去除,比如script等,方法我们选择BeautifulSoup来完成。

代码大概是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for element in soup(text=lambda text: isinstance(text, Comment)):
element.extract()

[s.extract() for s in soup('script')]
[s.extract() for s in soup('meta')]
[s.extract() for s in soup('style')]
[s.extract() for s in soup('link')]
[s.extract() for s in soup('img')]
[s.extract() for s in soup('input')]
[s.extract() for s in soup('br')]
[s.extract() for s in soup('li')]
[s.extract() for s in soup('ul')]

print (soup.prettify())

处理之后的网页对比

之前

之后

可以看出网页噪声少了很多,但是还是不足以从这么多噪声中提取出我们所要的内容

由于我们不需要标签只需要标签里面的文字,所以我们可以利用BeautifulSoup提取出文字内容再进行分析

1
2
3
4
for string in soup.stripped_strings:
print(string)
with open(os.path.join(os.getcwd())+"/data/3.txt", 'a') as f:
f.writelines(string.encode('utf-8')+'\n')

去除噪声标签之后的信息

可以看出来还是非常杂乱,但是又是十分有规律的。我们可以发现每个楼层中的文本内容实质上都差不多,可以说重复的很多,而且都是一些特定的词,比如: 直达楼层, 板凳,沙发,等这类的词,所以我们需要将这些词删掉然后再进行分析

我所用的方法是利用jieba分词来对获取的网页文本进行分词,统计出出现词频最高的词,同时也是容易出现在噪声文章中的词语,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import jieba.analyse

text = open(r"./data/get.txt", "r").read()

dic = {}
cut = jieba.cut_for_search(text)

for fc in cut:
if fc in dic:
dic[fc] += 1
else:
dic[fc] = 1
blog = jieba.analyse.extract_tags(text, topK=1000, withWeight=True)

for word_weight in blog:
# print (word_weight[0].encode('utf-8'), dic.get(word_weight[0], 'not found'))
with open('cut.txt', 'a') as f:
f.writelines(word_weight[0].encode('utf-8') + " " + str(dic.get(word_weight[0], 'not found')) + '\n')

统计出来然后经过我们测试和筛选得出的停用词有这些

回帖
积分
帖子
登录
论坛
注册
离线
时间
作者
签到
主题
精华
客户端
手机
下载
分享

目前统计的词大约200左右。

然后还有去除重复文本的工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 去重函数
def remove_dup(items):
pattern1 = re.compile(r'发表于')
pattern2 = re.compile('\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2}:\d{2}')
pattern3 = re.compile('\d{1,2}-\d{1,2} \d{2}:\d{2}')
pattern4 = re.compile('\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2}')
pattern5 = re.compile(r'[^0-9a-zA-Z]{7,}')

# 用集合来作为容器,来做一部分的重复判断依据,另外的部分由匹配来做
# yield用于将合适的文本用生成器得到迭代器,这样就进行了文本的删除,在函数外面
# 可以用函数进行文本的迭代
seen = set()
for item in items:
match1 = pattern1.match(item)
match2 = pattern2.match(item)
match3 = pattern3.match(item)
match4 = pattern4.match(item)
match5 = pattern5.match(item)
if item not in seen or match1 or match2 or match3 or match4 or match5:
yield item
seen.add(item) # 向集合中加入item,集合会自动化删除掉重复的项目

在经过观察处理后的网页文本,我们发现还有一项噪声无法忽略,那就是纯数字。因为网页文本中有很多纯数字但是又不重复,比如点赞数等,所以我准备用正则匹配出纯数字然后删除。但是这样就会出现问题…因为有些用户名是纯数字的,这样我们会把用户名删掉的。为了解决这个问题我们使用保留字符数大于7的纯数字,这样既删除了大部分的没用信息又尽可能的保留了用户名

相关的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
st = []
for stop_word in stop_words:
st.append(stop_word.strip('\n'))
t = tuple(st)
# t,元组,和列表的区别是,不能修改使用(,,,,),与【,,,】列表不同
lines = []
# 删除停用词和短数字实现
for j in after_string:
# 如果一行的开头不是以停用词开头,那么读取这一行
if not j.startswith(t):
# 如何一行不全是数字,或者这行的数字数大于7(区别无关数字和数字用户名)读取这一行
if not re.match('\d+$', j) or len(j) > 7:
lines.append(j.strip())
# 删除所有空格并输出
print (j.strip())

处理之后的文本如下,规律十分明显了

去除噪声标签之后的信息

接下来就是我们进行内容提取的时候了

内容提取

内容提取无非是找到评论块,而评论块在上面我们的图中已经十分清晰了,我们自然而然的想到根据日期来区分评论块。经过观察,所有的论坛中日期的形式只有5种(目前只看到5种,当然后期可以加上)。我们可以用正则匹配出日期所在的行,根据两个日期所在行数的中间所夹的就是评论内容和用户名来完成我们的评论内容提取。

传入我们处理后的文本然后就匹配出日期所在行数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 匹配日期返回get_list
def match_date(lines):
pattern1 = re.compile(r'发表于')
pattern2 = re.compile('\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2}:\d{2}')
pattern3 = re.compile('\d{1,2}-\d{1,2} \d{2}:\d{2}')
pattern4 = re.compile('\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2}')
pattern5 = re.compile(r'发表日期')

pre_count = -1
get_list = []

# 匹配日期文本
for string in lines:
match1 = pattern1.match(string)
match2 = pattern2.match(string)
match3 = pattern3.match(string)
match4 = pattern4.match(string)
match5 = pattern5.match(string)
pre_count += 1
if match1 or match2 or match3 or match4 or match5:
get_dic = {'count': pre_count, 'date': string}
get_list.append(get_dic)

# 返回的是匹配日期后的信息
return get_list

因为有回帖和没有回帖处理方式也不一样所以我们需要分类进行讨论。因为我们知道评论的内容是在两个匹配日期的中间,这样就有一个问题就是最后一个评论的内容区域不好分。但是考虑到大部分的最后一个回帖都是一行我们可以暂取值为3(sub==3,考虑一行评论和一行用户名),后来想到一种更为科学的方法,比如判断后面几行的文本密度,如果很小说明只有一行评论的可能性更大。

下面的代码是获取日期所在行数和两个日期之间的行数差

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 返回my_count
def get_count(get_list):
my_count = []
date = []
# 获取时间所在行数
for i in get_list:
k, t = i.get('count'), i.get('date')
my_count.append(k)
date.append(t)
if len(get_list) > 1:
# 最后一行暂时取3
my_count.append(my_count[-1] + 3)
return my_count
else:
return my_count

# 获取两个时间所在的行数差
def get_sub(my_count):
sub = []
for i in range(len(my_count) - 1):
sub.append(my_count[i + 1] - my_count[i])
return sub

接下来就要分类讨论了

  1. 如果只有楼主没有评论(即my——count==1),这个时候我们可以使用开源的正文提取软件goose来提取正文。

  2. 如果有评论我们就需要根据sub的值来进行分类如果sub==2占多数(或者说比sub==3)占的多,那么我们就认为可能是用户名被删掉,删掉的原因有很多,比如去重的时候有人在楼中楼回复了导致用户名重复被删除,有可能该网站的标签比较特殊用户名在去标签的时候删除等,情况比较复杂且出现的频率不太高,暂未考虑。何况不影响我们提取评论内容,只需分类出来考虑就行


注意:下面余弦相似度这个是我开始的时候想多了!大部分情况就是:日期-评论-用户名,后来我没有考虑余弦相似度分类,代码少了,精度也没有下降。这里不删是想留下一个思考的过程。代码看看就好,最后有修改后的源码。
  1. 还有就是最常见的内容,就是sub==3占多数的情况。因为大部分的评论都是一行文本,所以我们需要考虑的的是sub==3的时候获取的评论文本在哪一行。通俗来说就是这三行的内容是日期-评论-用户名,还是日期-用户名-评论呢?虽然大部分是第一种情况,但是第二种情况我们也不能忽略。怎么判断这两种情况呢?这确实让我思考了很长一段时间,后来想到可以用余弦相似度来解决这个问题.科普余弦相似度可以看这里。简单来说就是用户名的长度都是相似的,但是评论的内容长度差异就非常大了。比如用户名长度都是7个字符左右,但是评论的长度可以数百,也可以只有一个。所以我们可以两两比较余弦相似度,然后取平均,相似度大的就是用户名了。这样我们就可以区分出评论内容进行提取了!这就是主要的思想。剩下的就是代码的实现了。

简单贴一下相关的代码

1
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
# 利用goose获取正文内容
def goose_content(my_count, lines, my_url):
g = Goose({'stopwords_class': StopWordsChinese})
content_1 = g.extract(url=my_url)
host = {}
my_list = []
host['content'] = content_1.cleaned_text
host['date'] = lines[my_count[0]]
host['title'] = get_title(my_url)
result = {"post": host, "replys": my_list}
SpiderBBS_info.insert(result)

# 计算余弦相似度函数
def cos_dist(a, b):
if len(a) != len(b):
return None
part_up = 0.0
a_sq = 0.0
b_sq = 0.0
for a1, b1 in zip(a, b):
part_up += a1 * b1
a_sq += a1 ** 2
b_sq += b1 ** 2
part_down = math.sqrt(a_sq * b_sq)
if part_down == 0.0:
return None
else:
return part_up / part_down

# 判断评论内容在哪一行(可能在3行评论块的中间,可能在三行评论块的最后)
def get_3_comment(my_count, lines):
get_pd_1 = []
get_pd_2 = []
# 如果间隔为3取出所在行的文本长度
test_sat_1 = []
test_sat_2 = []
for num in range(len(my_count)-1):
if my_count[num+1] - 3 == my_count[num]:
pd_1 = (len(lines[my_count[num]]), len(lines[my_count[num]+2]))
get_pd_1.append(pd_1)
pd_2 = (len(lines[my_count[num]]), len(lines[my_count[num]+1]))
get_pd_2.append(pd_2)

for i_cos in range(len(get_pd_1)-1):
for j_cos in range(i_cos+1, len(get_pd_1)):
# 计算文本余弦相似度
test_sat_1.append(cos_dist(get_pd_1[j_cos], get_pd_1[i_cos]))
test_sat_2.append(cos_dist(get_pd_2[j_cos], get_pd_2[i_cos]))

# 计算余弦相似度的平均值
get_mean_1 = numpy.array(test_sat_1)
print (get_mean_1.mean())
get_mean_2 = numpy.array(test_sat_2)
print (get_mean_2.mean())

# 比较大小返回是否应该按
if get_mean_1.mean() >= get_mean_2.mean():
return 1
elif get_mean_1.mean() < get_mean_2.mean():
return 2

# 获取评论内容
def solve__3(num, my_count, sub, lines, my_url):
# 如果get_3_comment()返回的值是1,那么说明最后一行是用户名的可能性更大,否则第一行是用户名的可能性更大
if num == 1:
host = {}
my_list = []
host['content'] = ''.join(lines[my_count[0]+1: my_count[1]+sub[0]-1])
host['date'] = lines[my_count[0]]
host['title'] = get_title(my_url)
for use in range(1, len(my_count)-1):
pl = {'content': ''.join(lines[my_count[use] + 1:my_count[use + 1] - 1]), 'date': lines[my_count[use]],
'title': get_title(my_url)}
my_list.append(pl)

result = {"post": host, "replys": my_list}
SpiderBBS_info.insert(result)

if num == 2:
host = {}
my_list = []
host['content'] = ''.join(lines[my_count[0]+2: my_count[1]+sub[0]])
host['date'] = lines[my_count[0]]
host['title'] = get_title(my_url)
for use in range(1, len(my_count) - 1):
pl = {'content': ''.join(lines[my_count[use] + 2:my_count[use + 1]]), 'date': lines[my_count[use]],
'title': get_title(my_url)}
my_list.append(pl)

result = {"post": host, "replys": my_list}
SpiderBBS_info.insert(result)

展望

提取的准确率应该要分析更多的bbs网站,优化删除重复词(太粗暴),优化停用词,针对短文本没回复情况的优化,准确提取楼主的用户名等,无奈时间太紧无法进一步优化。才疏学浅,刚学了几个月python,代码难免有不合理的地方,望各位提出宝贵意见。