自研爬虫框架的经验总结(理论及方法)

背景:

        由于业务需要,承接一部分的数据采集工作。目前市场内的一些通用框架不太适合。故而进行了自研。

        对比自研和目前成熟的框架,自研更灵活适配,可以自己组装核心方法;后者对于新场景的适配需要对框架本身有较高的理解。

        读了此文,你可以对自研爬虫框架有一个架构层面的认知。

        新手,可以开始模块化其中提到的模块类,将它们基类起来。如其中的log类、proxy代理类、redis、redis锁、db连接池、yaml配置读取,这些都可以提前封装。日常使用亦可。仓库可以使用gitlab或者github,gitee亦可。

        刚好有实际业务的话,完全可以自建一个。没有的话,就看看思路交流一下有可以。如有需要,可私信交流。

基本信息

语言:python3

框架:flask+蓝图,服务化。

中间件:redis、mysql、gevent、threadpool

有哪些功能:redis过滤、redis任务锁、db并发写入、任务并发触发执行、db连接池、较好的日志管理、引入质数的定时任务、支持一般统计。

其它:从框架而言,它是一个有较好规范和模块话的一个平台设计。新接入平台会非常方便。任务支持并发和触发。能满足绝大多数的采集场景。

爬虫框架核心结构

        爬虫目前几乎存在于各个行业,是大数据下衍生出来的一门技术。对于一些业务,通过爬虫采集的数据几乎占到平台自有数据的100%。

        爬虫的本质是数据整合。他面对的对象是目标数据对象各种各样的数据源。一般来讲,爬虫框架的采集模板需要包含以下几个部分。
 

公共方法:
    日志模块:logger即可。在我博文中有已封装好的logging类。可直接使用。            https://blog.csdn.net/i_likechard/article/details/133696767?spm=1001.2014.3001.5501

db连接池:DBUtils==3.0

proxy代理类:对接proxy平台即可;可以将其写入redis中,通过redis key的时间有效性来写入数据。

redis操作:网上很多封装。

redis锁:可以网上找,也可以通过chatgpt生成。主要通过redis锁来进行控制单一任务,再采集过程中又被触发的问题。
# -*- coding: UTF-8 -*-
"""
#time:
#author:
#desc: 
"""
from utils.redis_base_helper import rediscn_local
import time


class RedisLock:
    def __init__(self, redis_client, key, expire=14400):
        self.redis_client = redis_client
        self.key = key
        self.expire = expire
        self.locked = False

    def acquire(self):
        # 尝试获取锁
        result = self.redis_client.setnx(self.key, 1)
        if result == 1:
            # 获取锁成功,设置过期时间
            self.redis_client.expire(self.key, self.expire)
            self.locked = True
            return True
        else:
            # 获取锁失败
            return False

    def release(self):
        if self.locked:
            # 释放锁
            self.redis_client.delete(self.key)
            self.locked = False


class LockManager:
    def __init__(self, redis_client):
        self.redis_client = redis_client
        self.locks = {}

    def acquire_lock(self, key, expire=43200):
        if key not in self.locks:
            self.locks[key] = RedisLock(self.redis_client, key, expire)
        lock = self.locks[key]
        if lock.acquire():
            return lock
        else:
            return None


rds_lock_man = LockManager(rediscn_local.redis)


config读写:使用yaml即可。建议存储一些默认值,不要将变化的值写入进去。维护部署比较麻烦。

告警通知:钉钉、飞书都支持webhook的方式发群消息、也支持打电话、发短信。实在不行邮箱通知也可以,这种就比较好弄了。

基础方法:(封装的request请求、数据拼接、md5、签名、jwt解析等等)

基础信息:一些user-agent,city,type,等固定的信息。写死的一些基本配置。通过列表、字典存储到py文件中。代替config文件来管理。
city={'北京市': '110100', '天津市': '120100', '石家庄市': '130100', '唐山市': '130200', '秦皇岛市': '130300', '邯郸市': '130400', '邢台市': '130500', '保定市': '130600', '张家口市': '130700', '承德市': '130800', '沧州市': '130900', '廊坊市': '131000', '衡水市': '131100', '雄安新区': '131200', '太原市': '140100', '大同市': '140200', '阳泉市': '140300', '长治市': '140400', '晋城市': '140500', '朔州市': '140600', '晋中市': '140700', '运城市': '140800', '忻州市': '140900', '临汾市': '141000', '吕梁市': '141100', '呼和浩特市': '150100', '包头市': '150200', '乌海市': '150300', '赤峰市': '150400', '通辽市': '150500', '鄂尔多斯市': '150600', '呼伦贝尔市': '150700', '巴彦淖尔市': '150800', '乌兰察布市': '150900', '兴安盟': '152200', '锡林郭勒盟': '152500', '阿拉善盟': '152900', '沈阳市': '210100', '大连市': '210200', '鞍山市': '210300', '抚顺市': '210400', '本溪市': '210500', '丹东市': '210600', '锦州市': '210700', '营口市': '210800', '阜新市': '210900', '辽阳市': '211000', '盘锦市': '211100', '铁岭市': '211200', '朝阳市': '211300', '葫芦岛市': '211400', '长春市': '220100', '吉林市': '220200', '四平市': '220300', '辽源市': '220400', '通化市': '220500', '白山市': '220600', '松原市': '220700', '白城市': '220800', '延边朝鲜族自治州': '222400', '哈尔滨市': '230100', '齐齐哈尔市': '230200', '鸡西市': '230300', '鹤岗市': '230400', '双鸭山市': '230500', '大庆市': '230600', '伊春市': '230700', '佳木斯市': '230800', '七台河市': '230900', '牡丹江市': '231000', '黑河市': '231100', '绥化市': '231200', '大兴安岭地区': '232700', '上海市': '310100', '南京市': '320100', '无锡市': '320200', '徐州市': '320300', '常州市': '320400', '苏州市': '320500', '南通市': '320600', '连云港市': '320700', '淮安市': '320800', '盐城市': '320900', '扬州市': '321000', '镇江市': '321100', '泰州市': '321200', '宿迁市': '321300', '杭州市': '330100', '宁波市': '330200', '温州市': '330300', '嘉兴市': '330400', '湖州市': '330500', '绍兴市': '330600', '金华市': '330700', '衢州市': '330800', '舟山市': '330900', '台州市': '331000', '丽水市': '331100', '合肥市': '340100', '芜湖市': '340200', '蚌埠市': '340300', '淮南市': '340400', '马鞍山市': '340500', '淮北市': '340600', '铜陵市': '340700', '安庆市': '340800', '黄山市': '341000', '滁州市': '341100', '阜阳市': '341200', '宿州市': '341300', '六安市': '341500', '亳州市': '341600', '池州市': '341700', '宣城市': '341800', '福州市': '350100', '厦门市': '350200', '莆田市': '350300', '三明市': '350400', '泉州市': '350500', '漳州市': '350600', '南平市': '350700', '龙岩市': '350800', '宁德市': '350900', '南昌市': '360100', '景德镇市': '360200', '萍乡市': '360300', '九江市': '360400', '新余市': '360500', '鹰潭市': '360600', '赣州市': '360700', '吉安市': '360800', '宜春市': '360900', '抚州市': '361000', '上饶市': '361100', '济南市': '370100', '青岛市': '370200', '淄博市': '370300', '枣庄市': '370400', '东营市': '370500', '烟台市': '370600', '潍坊市': '370700', '济宁市': '370800', '泰安市': '370900', '威海市': '371000', '日照市': '371100', '莱芜市': '371200', '临沂市': '371300', '德州市': '371400', '聊城市': '371500', '滨州市': '371600', '菏泽市': '371700', '郑州市': '410100', '开封市': '410200', '洛阳市': '410300', '平顶山市': '410400', '安阳市': '410500', '鹤壁市': '410600', '新乡市': '410700', '焦作市': '410800', '濮阳市': '410900', '许昌市': '411000', '漯河市': '411100', '三门峡市': '411200', '南阳市': '411300', '商丘市': '411400', '信阳市': '411500', '周口市': '411600', '驻马店市': '411700', '省直辖县级行政区划': '469000', '武汉市': '420100', '黄石市': '420200', '十堰市': '420300', '宜昌市': '420500', '襄阳市': '420600', '鄂州市': '420700', '荆门市': '420800', '孝感市': '420900', '荆州市': '421000', '黄冈市': '421100', '咸宁市': '421200', '随州市': '421300', '恩施土家族苗族自治州': '422800', '长沙市': '430100', '株洲市': '430200', '湘潭市': '430300', '衡阳市': '430400', '邵阳市': '430500', '岳阳市': '430600', '常德市': '430700', '张家界市': '430800', '益阳市': '430900', '郴州市': '431000', '永州市': '431100', '怀化市': '431200', '娄底市': '431300', '湘西土家族苗族自治州': '433100', '广州市': '440100', '韶关市': '440200', '深圳市': '440300', '珠海市': '440400', '汕头市': '440500', '佛山市': '440600', '江门市': '440700', '湛江市': '440800', '茂名市': '440900', '肇庆市': '441200', '惠州市': '441300', '梅州市': '441400', '汕尾市': '441500', '河源市': '441600', '阳江市': '441700', '清远市': '441800', '东莞市': '441900', '中山市': '442000', '东沙群岛': '442101', '潮州市': '445100', '揭阳市': '445200', '云浮市': '445300', '南宁市': '450100', '柳州市': '450200', '桂林市': '450300', '梧州市': '450400', '北海市': '450500', '防城港市': '450600', '钦州市': '450700', '贵港市': '450800', '玉林市': '450900', '百色市': '451000', '贺州市': '451100', '河池市': '451200', '来宾市': '451300', '崇左市': '451400', '海口市': '460100', '三亚市': '460200', '三沙市': '460300', '重庆市': '500100', '成都市': '510100', '自贡市': '510300', '攀枝花市': '510400', '泸州市': '510500', '德阳市': '510600', '绵阳市': '510700', '广元市': '510800', '遂宁市': '510900', '内江市': '511000', '乐山市': '511100', '南充市': '511300', '眉山市': '511400', '宜宾市': '511500', '广安市': '511600', '达州市': '511700', '雅安市': '511800', '巴中市': '511900', '资阳市': '512000', '阿坝藏族羌族自治州': '513200', '甘孜藏族自治州': '513300', '凉山彝族自治州': '513400', '贵阳市': '520100', '六盘水市': '520200', '遵义市': '520300', '安顺市': '520400', '铜仁市': '522200', '黔西南布依族苗族自治州': '522300', '毕节市': '522400', '黔东南苗族侗族自治州': '522600', '黔南布依族苗族自治州': '522700', '昆明市': '530100', '曲靖市': '530300', '玉溪市': '530400', '保山市': '530500', '昭通市': '530600', '丽江市': '530700', '普洱市': '530800', '临沧市': '530900', '楚雄彝族自治州': '532300', '红河哈尼族彝族自治州': '532500', '文山壮族苗族自治州': '532600', '西双版纳傣族自治州': '532800', '大理白族自治州': '532900', '德宏傣族景颇族自治州': '533100', '怒江傈僳族自治州': '533300', '迪庆藏族自治州': '533400', '拉萨市': '540100', '昌都地区': '542100', '山南地区': '542200', '日喀则地区': '542300', '那曲地区': '542400', '阿里地区': '542500', '林芝地区': '542600', '西安市': '610100', '铜川市': '610200', '宝鸡市': '610300', '咸阳市': '610400', '渭南市': '610500', '延安市': '610600', '汉中市': '610700', '榆林市': '610800', '安康市': '610900', '商洛市': '611000', '兰州市': '620100', '嘉峪关市': '620200', '金昌市': '620300', '白银市': '620400', '天水市': '620500', '武威市': '620600', '张掖市': '620700', '平凉市': '620800', '酒泉市': '620900', '庆阳市': '621000', '定西市': '621100', '陇南市': '621200', '临夏回族自治州': '622900', '甘南藏族自治州': '623000', '西宁市': '630100', '海东市': '632100', '海北藏族自治州': '632200', '黄南藏族自治州': '632300', '海南藏族自治州': '632500', '果洛藏族自治州': '632600', '玉树藏族自治州': '632700', '海西蒙古族藏族自治州': '632800', '银川市': '640100', '石嘴山市': '640200', '吴忠市': '640300', '固原市': '640400', '中卫市': '640500', '乌鲁木齐市': '650100', '克拉玛依市': '650200', '吐鲁番地区': '652100', '哈密地区': '652200', '昌吉回族自治州': '652300', '博尔塔拉蒙古自治州': '652700', '巴音郭楞蒙古自治州': '652800', '阿克苏地区': '652900', '克孜勒苏柯尔克孜自治州': '653000', '喀什地区': '653100', '和田地区': '653200', '伊犁哈萨克自治州': '654000', '塔城地区': '654200', '阿勒泰地区': '654300', '自治区直辖县级行政区划': '659000', '台北': '710100', '高雄': '710200', '台南': '710300', '台中': '710400', '金门': '710500', '南投': '710600', '基隆': '710700', '新竹': '711300', '嘉义': '711900', '新北': '711100', '宜兰': '711200', '桃园': '711400', '苗栗': '711500', '彰化': '711700', '云林': '712100', '屏东': '712400', '台东': '712500', '花莲': '712600', '澎湖': '712700', '连江': '712800', '香港岛': '810100', '九龙': '810200', '新界': '810300', '澳门半岛': '820100'}
平台模块:内部包含一个base基类,使用语法糖定义采集前后事务,如可以集成执行时长统计、采集结束通知、redis锁控制等; 其它平台可在base类基础上进行拓展。一般只需要改动解析和采集部分即可。

base基类详解

初始化:init一些基本参数,如平台编号,延时、关联redis 键(这里主要是)等。读取配置文件(后期建议下掉,使用其它方式来动态配置。)、tokon队列

解析(带伪装):这一步主要从处理数据-->清洗--》数据入库。
    伪装很重要,决定了采集的安全和可靠,如果是业务长期需要的数据源,这一步中的伪装需要好好设计。除了常见的延时、定时、接口请求header伪装。再升级伪装可以使用proxy、完整化请求链路、业务层逻辑伪装。其中逻辑层的伪装,需要结合业务场景来做,如果对方有安全部门,一般会以数量过滤到可疑对象,再逐步进行分析,过滤到异常用户。这里不同业务场景,风控的过滤不一样。就不延伸了。
    调研:提前通过工具制定采集步骤和流程。这一步也可以直接确定request 参数了。推荐postman或者apipost工具进行接口分析。另外还有爬虫工具库:spidertools.cn 等工具网站。
    过滤:过滤有些是前置、有些是后置的。前置的过滤一般是id或者关键词文本等唯一指标。后置过滤一般是更详细的一些规则。过滤的工具,推荐使用redis,速度快,效率高。带索引的db过滤亦可。
    
    
统计:统计失败,成功,进行结果通知的步骤。除了邮件等方式,目前飞书和钉钉亦支持机器人或者电话、短信等方法。种类较多。

flask支撑

flask主要对采集平台做了两个层面的支撑。

#第一个是,
#定时任务的处理。使用flask-schedule; 定时的时间用的质数,如53。可以防止一段时间内重复。
class Config:
    JOBS = [
        # 半小时触发一次 1小时一次
        {'id': 'task1', 'func': "run:job1", 'trigger': 'interval', 'minutes': 53},
        {'id': 'task4', 'func': "run:job4", 'trigger': 'cron', 'day': '*', 'hour': '00', 'minute': '00', 'second': '10'},
    ]
    SCHEDULER_TIMEZONE = 'Asia/Shanghai'  # 服务器设置时区
    SCHEDULER_EXECUTORS = {"default": {"type": "threadpool", "max_workers": 20}}
    SCHEDULER_JOB_DEFAULTS = {"coalesce": False, "max_instances": 3}
    SCHEDULER_API_ENABLED = True

#第二个是接口触发采集。
这也是服务化的优点。可以在不动代码的情况下,完成一些操作,如控制触发采集全部或指定平台的采集。
@test.route("/ilikecharts/", methods=["GET"])
def do_job():
    if request.method == 'GET':
        data = json.loads(request.get_data(as_text=True))  # request.json.get("data", {})
        brand_name = str(data.get("brand", "demo"))
        #do somthing

基本运行逻辑

        差点忘了一个重要的part,程序是如何执行的。是如何把这些模块和组件结合在一起的。
       

运行flask任务后,程序会将所以plat类进行初始化。所以平台单个调试采集时,本质上就是实例化plat类,而后调用入口函数,这个入口函数一般会包含一个循环;后续对某个平台的采集其实就是调用它的这个类的入口函数。

第一种接口方式,flask本身就是一个轻量级的后端,定义接口,调用指定脚本即可。如何调用后面讲

第二种定时方式,使用flask-schedule通过cron和interval来实现定时刻,定间隔来执行。
from flask_apscheduler import APScheduler
两种方式触发后,程序通过,传入的平台编号,映射到响应的已实例的plat类的入口函数中。入口函数即是采集的入口。并将映射到的函数丢给已初始化的threadpool线程池,这样就实现了采集,并发采集。
MAPPING_PLATS = {
    "demo": demo.loop_fuc,
    "kk": m11.loop_fuc,
}
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(50)
for x in task_list:  #task_list=["demo","kk"]
    executor.submit(MAPPING_PLATS.get(x, demo.loop_by_page)) #这种方式也支持带参数调用,可以自研下。

 新平台如何接入       

        当触发机制(即运行逻辑)确定后,那如何接入新的平台呢?对于这个采集平台(自研爬虫框架)而言,前面的初始化、后续的过滤,上传、告警都在基类中实现了。变化的不同平台的解析方式。

        因为数据最后是需要落库db的。解析这一步各个平台不同,有通过api接口获取,有通过html解析。最后按页或者单条上传给 公共方法中的上传数据的方法中即可。

#伪代码
def 入口函数:
    #初始化一些中间变量
    stop_flag= true 采集标志位
    error_time = 10 错误次数统计。
    for page in range(1, 100) #总页码可通过 其它方式获取。
        data_json_list = self.get_data(page)  #按页码或者按条来解析出来数据
        data_upload(data_json_list) #上传数据。

def get_data(page):
    #这里需要分目标信息在page列表页还是详情页。

    #过滤页可以在这一步来完成。通过id或者其他唯一标识。 
    #如何实现过滤呢? 可以将已写入的数据,写入redis的set集合中。而后通过sismember判断id是否在其中,从而实现过滤。

    
    #解析出data_json_list
    return data_json_list

def data_upload(data): 
    #这个是一个继承父类的方法,在基类中。内部会调用一个公共方法,用于上传数据-写入db等。
    return true,#一些统计标识

至此,一个大致的框架基本就介绍完毕了。至于一些细节,如果需要再交流。

你可能感兴趣的:(爬虫)