python批量下载公众号历史文章(二)

【背景】

        在上一个版本里,实现了半自动的下载,需要通过手工进行抓包操作。通过fiddler截获历史文章raw文件保存到本地;通过python进行解析,获得文章信息(含访问url、标题、发布时间等)的列表;然后通过python调用这些url把需要的文章下载到本地;当然还有一些图片和转pdf的一些处理。

       上一版本博客链接:https://blog.csdn.net/MissYourKiss/article/details/100254510

       通过这几天帮助朋友处理其他问题的练习,已经学会了使用mitmproxy自动抓包,并将信息保存在数据库里。

【目标】

        这个版本的目标很简单,就是去除手工,不做太多的更新。目标有三个:

        1、使用mitmproxy,完成公众号基础信息的自动抓包。

             基础信息包括:访问“历史文章的首页”信息(包括url、headers(含cookie)等)

                                      访问“加载更多”链接的信息(包括url等)

                                      访问某篇文章的信息(包括url、公众号名称等,主要是为了获得公众号名称信息)

        2、使用mongodb把上述抓取到的基础信息包保存在数据库里。

        3、读取基础信息,拼装获取任意信息的url,将想要的文章信息保存到数据库里。

              文章信息包括:文章的访问url、文章的标题、文章的发布时间、文章发布序号(同一时间可发布多篇文章)

         PS:这个版本只是完成了文章信息保存的数据库里,并没有真正下载(不过有了下载url、headers、cookie,想下载随时可下载),具体的下载方法可以参考上一个版本。

         后续,准备做一个前端页面,增加检索功能,支持直接在线阅读,或者把文章下载到数据库里,导出pdf等功能。

        PS:随着版本的迭代,后面准备使用git进行版本管理

【思路】

        1、使用mitmporxy代替fiddler,编写插件,截获微信访问信息,保存到数据库。

              mitmproxy的使用见另一个博客实例:https://blog.csdn.net/MissYourKiss/article/details/100595382

              PS:本版本实现了自动在程序中启动mitmproxy,无需再手动操作

        2、通过python程序读取信息,分析“加载更多”链接的规则,拼装更多的加载更多链接url。

        3、通过python访问这些加载更多url,解析返回的json串,把解析出来的文章信息保存到数据库里。

【工具】

        1、mitmpoxy

        2、mongodb

        3、python3.7及对应的包

【编码】

        1、mitmproxy的插件编写 mitmaddons.py

# _*_ coding:utf-8 _*_

"""
mitmproxy 插件,拦截微信访问数据,并插入到数据库里
"""

import mitmproxy.http
from mitmproxy import ctx, http
from datetime import datetime
import json


from db import CollectionOperation
from loguru import logger

class WechatAddon:

    # 定义需要拦截的请求url
    def __init__(self):
        self.url_filter = {'load_more':'https://mp.weixin.qq.com/mp/profile_ext?action=getmsg', 
        'getappmsgext':'https://mp.weixin.qq.com/mp/getappmsgext?', 
        'appmsg_comment':'https://mp.weixin.qq.com/mp/appmsg_comment?action=getcomment', 
        'content':'https://mp.weixin.qq.com/s?', 
        'home':'https://mp.weixin.qq.com/mp/profile_ext?action=home'}

    """
    拦截url_filter中的请求参数 用wxuin.key.req的格式作为key name 存入数据库
    """
    @staticmethod
    def _extract_wxuin(req_data):
        """
        :param req_data:
        :return: 从cookie中解析出wxuin 作为公众号的唯一标识
        """
        wxuin = 'UNK'
        if 'Cookie' in req_data['requestOptions']['headers']:
            cookie_dict = str_to_dict(req_data['requestOptions']['headers']['Cookie'], ';', '=')
        else:
            cookie_dict = str_to_dict(req_data['requestOptions']['headers']['cookie'], ';', '=')
        if 'wxuin' in cookie_dict:
            wxuin = cookie_dict['wxuin']
        return wxuin
    
    def request(self, flow: mitmproxy.http.HTTPFlow):
        pass

    def response(self, flow: mitmproxy.http.HTTPFlow):
        # print("url:"+flow.request.url)
        # 截获返回数据的cookie保存在数据库里
        for ukey in self.url_filter:
            if self.url_filter[ukey] in flow.request.url:
                req_data, timestamp = AnalysisFlow.format_request_data(flow.request)
                wxuin = self._extract_wxuin(req_data)
                if wxuin == 'UNK':
                    return
                key_name = '%s.%s.req' % (wxuin, ukey)
                insert_helper(key_name, req_data,'req_data')

                if ukey == 'getappmsgext':
                    text_dict = json.loads(flow.response.text)
                    nick_name = 'UNK'
                    if 'nick_name' in text_dict:
                        nick_name = text_dict['nick_name']
                        if not nick_name == 'UNK':
                            insert_helper(nick_name + '.nick_name', wxuin, 'req_data')
                elif ukey == 'home':
                    html_text = flow.response.text
                    current_nickname = html_text.split('var nickname = "')[1].split('" || ""')[0]
                    insert_helper(wxuin+'.oaname', current_nickname, 'req_data')

class AnalysisFlow:
    """
    解析flow request数据
    """
	@staticmethod
    def format_request_data(request):
        req_data = {}
        req_data['protocol'] = request.scheme
        req_data['url'] = request.url
        req_data['requestOptions'] = {}
        req_data['requestOptions']['headers'] = AnalysisFlow.decode_headers(request.headers)
        req_data['requestOptions']['hostname'] = request.pretty_host
        req_data['requestOptions']['port'] = request.port
        req_data['requestOptions']['path'] = request.path
        req_data['requestOptions']['method'] = request.method
        req_data['requestData'] = request.text
        timestamp = int(request.timestamp_end * 1000)
        return (req_data, timestamp)

    @staticmethod
    def decode_headers(headers):
        headers_data = {}
        for i in headers.fields:
            headers_data[str(i[0], 'utf-8')] = str(i[1], 'utf-8')
        
        if ':authority' in headers_data:
            headers_data.pop(':authority')

        return headers_data

def str_to_dict(s, join_symbol='\n', split_symbol=':'):
    """
    字符串到字典 支持自定义键值间隔符和成员间隔符
    :param s: 原字符串
    :param join_symbol: 连接符
    :param split_symbol: 分隔符
    :return: 字典
    """
    s_list = s.split(join_symbol)
    data = dict()
    for item in s_list:
        item = item.strip()
        if item:
            k, v = item.split(split_symbol, 1)
            data[k] = v.strip()

    return data

def insert_helper(key, value, table):
    json_value = str(value).replace("'", '"')
    data = {'id':key,  'key':key,  'time':datetime.now(),  'value':json_value}
    CollectionOperation(table).insert('id', data)

"""
addons = [
    WechatAddon()
]
"""

         2、操作自动启动mitmproxy的程序 startmitm.py

"""
启动mitmproxy
"""
from mitmproxy import proxy, options
from mitmproxy.tools.dump import DumpMaster

from mitmaddons import WechatAddon

def start_proxy():
    wechat_addon = WechatAddon()
    opts = options.Options(listen_host='0.0.0.0', listen_port=8080)
    pconf = proxy.config.ProxyConfig(opts)
    m = DumpMaster(opts)
    m.server = proxy.server.ProxyServer(pconf)
    m.addons.add(wechat_addon)
    try:
        m.run()
    except KeyboardInterrupt:
        print('')
        m.shutdown()


if __name__ == '__main__':
    start_proxy()

         3、操作mongodb的公用方法编写 db.py

# -*- coding:utf-8 -*-

from pymongo import MongoClient
from config import cfg


# mongodb连接
MONGODB_HOST = cfg.get('mongodb','MONGODB_HOST')
MONGODB_PORT = int(cfg.get('mongodb','MONGODB_PORT'))
MONGODB_NAME = cfg.get('mongodb','MONGODB_NAME')
db_client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db_instance = db_client[MONGODB_NAME]

class CollectionOperation:
    """
    操作mongodb的类
    """
    def __init__(self, table):
        """
        :param table: 实际上table在mongodb中被称为 collection 为了名称统一此处仍成为table
        table 的数据结果见data_scheme
        """
        self.table = db_instance[table]
    
    def count(self, **kwargs):
        """
        :return:返回符合条件数据的总数
        """
        return self.table.find(kwargs).count()

    def delete(self, **kwargs):
        """
        :param kwargs: 用字典表示的过滤器
        :return: 根据match中提供的符合信息删除文章 支持全部删除
        """
        self.table.delete_many(kwargs)

    def get(self, **kwargs):
        """
        :param kwargs:
        :return: 返回符要求的数据生成器
        """
        data = self.table.find(kwargs)
        return data

    def insert(self, key, data, check_exist=True):
        """
        :param data: []多个数据,或单个数据{}
        :param key: 更新模式下判重的依据
        :param check_exist:是否需要检查存在(更新模式)
        :return: 插入一条数据或多个数据 在进行数据写入 基本上只需使用这一个API
        """
        res = 'INSERT'
        if check_exist:
            if type(data) == dict:
                res = self._update_one(key, data)
            else:
                if type(data) == list:
                    res = self._update_many(key, data)
        else:
            if type(data) == dict:
                self._insert_one(data)
            else:
                if type(data) == list:
                    self._insert_many(data)
        return res

    def _insert_one(self, data):
        """
        :param data: {}
        :return: 插入一条数据
        """
        return self.table.insert_one(data).inserted_id

    def _insert_many(self, data):
        """
        :param data: []
        :return: 插入多条数据
        """
        self.table.insert_many(data)
        return len(data)

    def _update_one(self, key, data):
        """
        :param key: 判存字段
        :param data: {}
        :return: 更新或插入一条数据 用data中的字段更新key作为判断是否存在 存在更新 不存在就插入
        """
        result = self.table.find_one({key: data[key]})
        if type(result) is dict:
            self.table.update_one({key: data[key]}, {'$set': data})
            op_result = 'UPDATE'
        else:
            self._insert_one(data)
            op_result = 'INSERT'
        return op_result

    def _update_many(self, key, data):
        """
        :param key: 判存字段
        :param data: []
        :return: 更新或插入多个数据 只要有一个数据是更新模式 返回UPDATE否则返回INSERT
        """
        res = 'INSERT'
        for d in data:
            if self._update_one(key, d) == 'UPDATE':
                res = 'UPDATE'

        return res

    def custom(self):
        """
        :return: 返回table 方便用户自定义操作
        """
        return self.table

def test():
    data = {'id':wechat, 
     'key':wechat,  'value':{'id':'wechat','url':'http://mp.wexin.com','title':'this is a test'}}
    col = CollectionOperation('test')
    col.insert('id', data)

        3、读取配置文件的方法编写 config.py

# -*- coding:utf-8 -*-

import os
from configparser import ConfigParser

cfg = ConfigParser()

cfg.read(os.path.join("conf","config.ini"))

        4、配置文件(config.ini)信息

[mongodb]
MONGODB_NAME = XDT
MONGODB_HOST = localhost
MONGODB_PORT = 27017

        5、读取数据库里保存的公众号“加载更多”基础信息,获取文章信息并保存到数据库里  insertarticle.py

# -*- coding:utf-8 -*-

import requests
from datetime import datetime

from db import CollectionOperation
from anajson import get_article_list

def get_load_more():
    db_tb = CollectionOperation('req_data').custom()
    db_data = db_tb.find({'id':{"$regex":"load_more.req"}})
    oa_info_list = []
    article_info_list = []
    for x in db_data:
        oa_info = {}
        oa_info['id'] = x['id']
        json_value = json.loads(x['value'])
        oa_info['protocol'] = json_value['protocol']
        oa_info['url'] = json_value['url']
        oa_info['headers'] = json_value['requestOptions']['headers']
        #oa_info['cookie'] = json_value['requestOptions']['headers']['Cookie']
        oa_info['wxuin'] = x['id'].split('.')[0]
        oa_info['oaname'] = db_tb.find_one({'id':oa_info['wxuin']+'.oaname'})['value']
        oa_info_list.append(oa_info)
        print("url:"+oa_info['url'])
        print(oa_info['headers'])
        rsps = get_response(oa_info['url'],oa_info['headers'])
        oa_atc_list = get_article_list(rsps.text)
        for atc in oa_atc_list:
            atc_info = {}
            atc_info['url'] = atc.url
            atc_info['title'] = atc.title
            atc_info['pubtime'] = atc.atc_datetime
            atc_info['idx_num'] = atc.idx_num
            atc_info['oaname'] = oa_info['oaname']
            atc_info['wxuin'] = oa_info['wxuin']
            article_info_list.append(atc_info)

    for oa_i in oa_info_list:
        oa_key = oa_i['wxuin']+".oainfo"
        insert_helper(oa_key,oa_i,"oa_info")
        
    for atc_i in article_info_list:
        atc_key = atc_i['wxuin']+"."+str(atc_i['pubtime'])+"."+str(atc_i['idx_num'])+".atcinfo"
        insert_helper(atc_key,atc_i,"atc_info")

def insert_helper(key, value, table):
    json_value = str(value).replace("'", '"')
    data = {'id':key,  'key':key,  'time':datetime.now(),  'value':json_value}
    CollectionOperation(table).insert('id', data)

def get_response(url,headers):
    return requests.get(url,headers = headers)

        6. 定义解析返回的json页面的方法 anajson.py

# -*- coding:utf-8 -*-

"""
解析json字符串
"""
import json

from xtime import seconds_to_time

def get_article_list(resptext):
    """
    传入保存文章列表信息的json页面,返回文章信息列表
    """
    #print(resptext)
    article_list = [] # 用来保存所有文章的列表
    json_cont = json.loads(resptext)
    general_msg_list = json_cont['general_msg_list']
    json_list = json.loads(general_msg_list)
    #print(json_list['list'][0]['comm_msg_info']['datetime'])
    for lst in json_list['list']:
        atc_idx = 0 # 每个时间可以发多篇文章 为了方便后续图片命名
        seconds_datetime = lst['comm_msg_info']['datetime']
        atc_datetime = seconds_to_time(seconds_datetime)
        if lst['comm_msg_info']['type'] == 49: # 49为普通的图文
            atc_idx+=1
            url = lst['app_msg_ext_info']['content_url']
            title = lst['app_msg_ext_info']['title']
            atc_info = ArticleInfo(url,title,atc_idx,atc_datetime)
            article_list.append(atc_info)
        if 1 == lst['app_msg_ext_info']['is_multi']: # 一次发多篇
            multi_app_msg_item_list = lst['app_msg_ext_info']['multi_app_msg_item_list']
            for multi in multi_app_msg_item_list:
                atc_idx+=1
                url = multi['content_url']
                title = multi['title']
                mul_act_info = ArticleInfo(url,title,atc_idx,atc_datetime)
                article_list.append(mul_act_info)
    return article_list

class ArticleInfo():
    """
    文章信息数据结构
    @title 文章标题
    @url 文章链接
    @atc_datetime 文章发布时间
    @idx_num 同一时间可能会发布多篇文章,计数用
    """
    def __init__(self,url,title,idx_num,atc_datetime): #idx_num是为了方便保存图片命名
        self.url = url
        self.title = title
        self.idx_num = idx_num
        self.atc_datetime = atc_datetime

【运行方法】

        >准备工作

        1、上述文件放在同一个文件夹下,笔者是有标准的工程目录的,但不知道怎么上传到csdn,不过为了是各位看官能随时保证有一个可运行的版本(传说中的敏捷迭代),程序的包都是做过调整的,直接扔在一个目录就能执行。

        2、启动mongodb数据库,默认的用户名和密码为空,如果有配置,需要修改下db.py的程序。

            db.py 

            db_client = MongoClient(MONGODB_HOST, MONGODB_PORT)

            修改为:

            dburl = 'mongodb://' + user + ':' + pwd + '@' + MONGODB_HOST+ ':' + MONGODB_PORT

            db_client  = MongoClient(dburl)

        3、设置mitmproxy的代理并安装证书,具体方法看上一篇文章(必须安装,因为是拦截的微信app数据,无法通过快捷方式打开chrome那样)

        >运行

        1、(cmd)>python startmitm.py

        2、访问微信(手机、电脑版、模拟器均可,但是一定要设置mitm代理并安装证书),点击任一公众号,打开历史信息页面,下划至少一次加载更多(多了也没用,数据库只会保存一次,有重复 判断)。

        3、(cmd)>python insertarticle.py

        即可在数据库理查看到相应的数据了,有三个表:

        req_data存放的是公众号的基础数据,一个公众号三条记录;

        oa_info存放的是公众号的基础信息,一个公众号一条记录;(这个表,是从上个表里抽取的数据)

        atc_info存放的是所有的文章信息,很多很多

【运行效果截图】

       

 

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值