笔记:
标题
招标方:供应商
中标方:发布机构
成交时间 = 中标时间:发布日期
中标金额:成交金额
只要“结果公告、结果公示”


项目背景

政府的采购意向一向是许多中大型公司的主营业务之一,因此,实时动态的掌握政府的采购信息能够更有效的帮助企业盈利,这次我们的目标是商洛市政府网下面的招标与中标公告两个板块,主要通过中标公告所提供的信息,我们将会从中抽取相关的实体:招标方、中标方、中标时间、中标金额、成交时间等并将其保存在mysql数据库中。

招标网python爬虫 招投标爬虫代码_github

网页分析

分析主页面,获取网页url,找到控制页数的参数:

控制台截图:

招标网python爬虫 招投标爬虫代码_git_02

查询字符串截图:

招标网python爬虫 招投标爬虫代码_招标网python爬虫_03


发现确实是第二页:

招标网python爬虫 招投标爬虫代码_招标网python爬虫_04


基础网页分析到此结束,关于内容网页的分析会在“技术实现”中说明

技术流程图

技术实现

历史数据爬取

一开始的技术方案是使用串行的方式爬取所有的历史数据,但是发现速度慢如乌龟,于是决定使用并行的方式进行爬取,所使用的模块为
“concurrent.futures.ThreadPoolExecutor”,即使用多线程技术来进行爬取,中间有一段阻塞的时间,正好可以作为一种规避反爬虫的手段,因为在实际试验中发现,此网站的反爬手段非常单一,即封禁访问频繁的IP,那么在产生大量访问之前,根据试验发现,休息一下可以产生一定的迷惑效果,减少被封的次数,此外我也在其他方面做了改进以应对这种策略,这部分放在“技术优势”来讲。数据库采用了mysql,所有爬取到的数据经过实体抽取以后将以字符创的形式存入。

爬虫主体

def spider(url, headers):
    success = False
    while not success:
        try:
            res = requests.get(url, headers=headers)
            success = True
        except:
            num = time.sleep(random.randint(5,20))
            print("糟糕,你的爬虫被发现了!!但是别担心,{}秒后我们就会重启!嘿嘿".format(num))
    # requests默认的编码是‘ISO-8859-1’,会出现乱码,这里重编码为utf-8
    res.encoding = 'utf-8'
    return res.text

spider函数负责处理请求与响应,将requests经过简单的封装以后,它拥有了一项新的功能,那就是不间断对某个链接尝试访问,如果出错,就间隔5到20s再发起请求。

获取网页url

在用并行处理这个问题的时候,受限于mapreduce框架,我只能获取到全部的url再进行请求处理,所以一共会有两段并发请求过程。

# 爬取历史数据
    # 打开数据库连接
    info = {
        "host": "localhost",
        "user": "root",
        "password": "haizeiwang",
        "db": "TESTDB",
        "charset": "utf8"  # 一定要加上负责中文无法显示
    }

    db = database(info)
    # 创建数据表,如果存在则删除
    db.create_table()
    st = time.time()
    # 定义原始页面url
    page_url = "http://www.shangluo.gov.cn/zwgk/szfgkmlxxgk.jsp?ainfolist1501t=24&ainfolist1501p={}&ainfolist1501c=15&urltype=egovinfo.EgovInfoList&subtype=2&wbtreeid=1232&sccode=zccg_zhbgg&gilevel=2"
    # 定义拼接字符串
    content_url = "http://www.shangluo.gov.cn"

    """并行方案: 用两遍多线程,一遍获得所有的url,一遍过得所有的实体列表"""
    # 生成所有页面url
    urls = [page_url.format(i) for i in range(1, 25)]
    """开启页面线程池子"""
    executer = ThreadPoolExecutor(max_workers=8)
    # 生成map对象
    concent_concurrent_url_list = executer.map(get_all_content_url, urls)
    # 实例化map对象,获得所有内容url
    _concent_concurrent_url_list_maped = list(concent_concurrent_url_list)
    # 将列表展开
    concent_concurrent_url_list_maped = []
    for j in _concent_concurrent_url_list_maped:
        for k in j:
            concent_concurrent_url_list_maped.append(content_url + k)

    # # ---阻塞---
    print("页面线程已全部结束,进入10秒睡眠")
    time.sleep(10)
    print("睡眠结束,进入内容线程阶段")

    # 生成map对象
    obj_concurrent_list = executer.map(get_obj_list, concent_concurrent_url_list_maped)
    # 实例化map对象,获得所有实体列表
    obj_concurrent_list_maped = list(obj_concurrent_list)

    # 将列表展开并保存
    count = 0
    for i in obj_concurrent_list_maped:
        # save("./Current_content_list.txt", i)
        # 将实体列表保存进数据库
        db.insert_data(i)
        print("第{}条数据插入完毕".format(count))
        count += 1
    et = time.time()
    print("并行用时:", (et - st))

实体抽取

首先我们定义要获取实体列表,我们采用“元素选择+正则表达式”的方法来从网页中获取这些信息:
因为我所做的是简单的实体抽取,所以我的正则表达式有限,只针对以下几种案例进行抽取:

案例

招标网python爬虫 招投标爬虫代码_git_05

招标网python爬虫 招投标爬虫代码_git_06

招标网python爬虫 招投标爬虫代码_git_07

招标网python爬虫 招投标爬虫代码_面试_08

下面是我所有使用来做实体抽取的的正则表达式

  • 招标方,关键词:“采购单位:…\n”,“采购人信息\n名 称:…\n”,“采购人名称:…\n”
["(?s)<P>1.采购人信息</P>\r\n<P>名 称:(.*?)</P>", '采购单位:(.*)</p>', "采购人名称:(.*)</P>","(?s)\r\n第一中标候选人(.*?)<BR>"]
  • 中标方,关键词:“中标单位:…\n”,“中标方:…\n”,“中标供应商:…\n”,“供应商名称:”
"中标单位:...\n","中标方:...\n","中标供应商:...\n","供应商名称:"
  • 成交时间,正则匹配第一次的时间,之后转换为sql格式(xxxx-xx-xx)
"(\d{4}年\d{1,2}月\d{1,2}日)"
  • 中标金额,关键词:“成交金额:…\n”,“中标金额:…\n”,“中标价:\n”
['成交金额:(.*)</P>', "中标金额:(.*)</P>", "中标价:(.*)<\P>"]

在这里的逻辑是:只匹配正则列表第一次出现的信息,因为许多文件会有重复的格式,造成爬取的困难与信息的紊乱。
代码实现:

# file_name:Regter.py
import regex as re


#获得招标方,关键词:"采购单位:...\n","采购人信息\n名 称:...\n","采购人名称:...\n"
def get_invitor(data):
   pattern_list = ["(?s)<P>1.采购人信息</P>\r\n<P>名 称:(.*?)</P>", '采购单位:(.*)</p>', "采购人名称:(.*)</P>","(?s)\r\n第一中标候选人(.*?)<BR>"]
   for i in pattern_list:
       res = re.findall(i, data)
       if res:
           invator = res[0]
           break  # 只要第一个条件满足就行
       else:
           invator = ""
   return invator


#获得中标方,关键词:"中标单位:...\n","中标方:...\n","中标供应商:...\n","供应商名称:"
def get_win(data):
   pattern_list = ['供应商名称:(.*)</P>', "中标单位:(.*)</P>", "中标方:(.*)</P>"]
   for i in pattern_list:
       if re.findall(i, data):
           win = re.findall(i, data)[0]
           break  # 只要第一个条件满足就行
       else:
           win = ""
   return win


#获得成交金额,关键词:"成交金额:...\n","中标金额:...\n","中标价:\n"
def get_money(data):
   pattern_list = ['成交金额:(.*)</P>', "中标金额:(.*)</P>", "中标价:(.*)<\P>"]
   for i in pattern_list:
       if re.findall(i, data):
           money = re.findall(i, data)[0]
           break  # 只要第一个条件满足就行
       else:
           money = ""
   return money


#获得成交时间,正则匹配第一次的时间,之后还要转换sql格式
def get_date(data):
   time = re.findall("(\d{4}年\d{1,2}月\d{1,2}日)", data)[0]
   time = time.replace("年", "-").replace("月", '-').replace("日", "")
   return time
  • 文件标题,借助beautifulsoup,根据观察发现,所有的标题所用的css样式一致,通过这一点就可以定位所有文件的标题:
# belongs Currentmain.py
content_soup = BeautifulSoup(content_text, 'html.parser')

    # try:

    title = content_soup.find_all("td",
                                  style="FONT-WEIGHT: bold;FONT-SIZE: 14pt;COLOR: #d52b2b;LINE-HEIGHT: 250%;FONT-FAMILY: 宋体;TEXT-ALIGN: center")[
        0].text.strip()

数据保存

在这里我使用了mysql作为保存手段,这里踩了一个坑,根据题目要求,将网页保存为字符串存入数据库中,但是由于网页拥有大量的“\”之类的符号,故需要进行转义再将其存入,可以使用 pymysql.escape_string()这一函数。
我将数据保存流程封装为了一个类,为了保证代码的复用性以及稳健性,我实现了以下功能

  • “创建连接”
  • “创建数据表,若已存在就删除”
  • “插入数据,若发生错误就回滚”
import pymysql


class database:
    def __init__(self, info=None):
        if info != None:
            self.info = info
        else:
            self.info = {
                "host": "localhost",
                "user": "root",
                "password": "Haizeiwang_123",
                "db": "TESTDB",
                "charset": "utf8"  # 一定要加上负责中文无法显示
            }

    def create_table(self):

        # 打开数据库链接
        db = pymysql.connect(**self.info)
        # 创建游标对象
        cursor = db.cursor()
        # 使用 execute() 方法执行 SQL,如果表存在则删除
        cursor.execute("DROP TABLE IF EXISTS 商洛市政府官网中标信息")

        # 使用预处理语句创建表
        sql = """CREATE TABLE 商洛市政府官网中标信息 (
        id INT AUTO_INCREMENT,
        文件类型 CHAR(50) NOT NULL,
        招标方  CHAR(20) NULL,
        中标方  CHAR(20) NULL,   
        成交时间 CHAR(20) NULL,
        成交金额 CHAR(20) NULL,
        文件标题 CHAR(50) NULL,
        网页内容 LONGTEXT NULL,
        PRIMARY KEY ( `id` )
        )"""
        cursor.execute(sql)

        # 关闭数据库连接
        db.close()

    def insert_data(self, content=None):
        # 链接数据库
        db = pymysql.connect(**self.info)
        # 创建游标对象
        cursor = db.cursor()
        # SQL 插入语句
        # 在插入网页之前,需要先对其进行转义
        content[-1] = pymysql.escape_string(content[-1])
        sql = "INSERT INTO 商洛市政府官网中标信息(文件类型, \
               招标方, 中标方, 成交时间, 成交金额, 文件标题, 网页内容) \
               VALUES ('%s', '%s',  '%s',  '%s',  '%s', '%s', '%s')" % \
              tuple(content)
        try:
            # 执行sql语句
            cursor.execute(sql)
            # 提交到数据库执行
            db.commit()
        except:
            # 如果发生错误则回滚
            db.rollback()

        # 关闭数据库连接
        db.close()


if __name__ == '__main__':
    info = {
        "host": "localhost",
        "user": "root",
        "password": "haizeiwang",
        "db": "TESTDB",
        "charset": "utf8"  # 一定要加上负责中文无法显示
    }
    db = database(info)
    db.create_table()
    with open("./content.html",'r',encoding='utf-8') as file:
        f = file.read()
        # f = f.replace("\r", "").replace('\n',"").replace("/","").replace("\\","")
        # f = pymysql.escape_string(f)
        print(type(f))
        print(f)

        db.insert_data(['a','c','d','f','x','d',f])
    print("finish")

爬取更新数据

由于此网站更新不定时,且不定量,所以原先准备采用定时部署的方案被放弃,转而采用手动更新的方式,即用户判断需要爬取页数以及数据条数,或者只确定需要爬取的条数,程序就可以实现爬取,处理,存储一条龙。代码如下:

import requests
from bs4 import BeautifulSoup
import time
from SQL import database
from Regter import *
import sys
def spider(url, headers):
    res = requests.get(url, headers=headers)
    # requests默认的编码是‘ISO-8859-1’,会出现乱码,这里重编码为utf-8
    res.encoding = 'utf-8'
    return res.text


def pagemade(text):  # 获取页数列表
    soup = BeautifulSoup(text, 'html.parser')
    reslis = soup.find_all('a', target="_blank")[3:-4:2]  # 固定的序列分割模式
    reslis = [i['href'] for i in reslis]
    return reslis


def save(file_name, text):
    with open(file_name, 'a',encoding='utf-8') as f:
        f.write(str(text)+'\n')


def contentmade(content_text):
    content_soup = BeautifulSoup(content_text, 'html.parser')

    # try:

    title = content_soup.find_all("td",
                                  style="FONT-WEIGHT: bold;FONT-SIZE: 14pt;COLOR: #d52b2b;LINE-HEIGHT: 250%;FONT-FAMILY: 宋体;TEXT-ALIGN: center")[
        0].text.strip()

# 对于文本的选择content_soup.find_all("p")[4:-4]

    invitation = get_invitor(content_text)
    win = get_win(content_text)
    money = get_money(content_text)
    win_time = get_date(content_text)
    sql_lis = ["中标文件", invitation, win, win_time, money, title, content_text]
    # except:
    #     sql_lis = ["中标文件", "", "", "", "", "", content_text]
    return sql_lis

def main(m,n=None):
    if n == None:
        n = m // 15 + 1# 保证比总条数多一页
    else:
        pass

    # 爬取更新书数据
    # 链接数据库
    db = database()
    # 创建数据表
    db.create_table()
    # 定义原始页面url
    page_url = "http://www.shangluo.gov.cn/zwgk/szfgkmlxxgk.jsp?ainfolist1501t=24&ainfolist1501p={}&ainfolist1501c=15&urltype=egovinfo.EgovInfoList&subtype=2&wbtreeid=1232&sccode=zccg_zhbgg&gilevel=2"
    # 定义拼接字符串
    content_url = "http://www.shangluo.gov.cn"
    # 定义头部信息
    headers = {
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
    }

    # 页面循环
    for i in range(1, n):
        # 获得页面数据
        pagetext = spider(page_url.format(i), headers)
        # 获得页面链接列表
        page_list = pagemade(pagetext)
        # 内容循环
        for j in page_list[:m]:
            # 获得内容数据
            content_text = spider(content_url + j, headers)
            # 获得内容实体列表
            content_list = contentmade(content_text)
            # 将实体列表保存进数据库
            db.insert_data(content_list)
            # 将实体列表保存
            # save("./update_content_list.txt",content_list)
        print('第{}页爬取完毕'.format(i))

if __name__ == '__main__':
    m = eval(input("请指定爬取条数:"))
    try:
        n = eval(input("请指定爬取页数(可以为空):"))
    except:
        n = None

    main(m,n)

技术参数

名称:使用mysql数据库存储的进行了简单实体抽取的多线程爬虫

使用模块

requests,BeautifulSoup,regter,time,random,pymysql,currently.future.ThreadPoolExecutor

主要功能

1.历史数据并行爬取 2.指定页数或条数进行爬取3,简单实体抽取4,mysql存储

效率

并行时间:25s,串行效率 57s

总共请求url数

360

线程池数量

8

并行时间:

招标网python爬虫 招投标爬虫代码_面试_09


串行时间:

招标网python爬虫 招投标爬虫代码_sql_10

技术优势

  1. 使用并行技术
  2. 遭到IP封禁随机睡眠机制,防止被封禁IP导致程序中断
  3. 数据库错误捕获机制,遇到错误自动回滚,减少操作量