案例研究——如何解析嵌套的JSON(附代码)

222 阅读10分钟

我被要求帮助解析一个由iTunes商店客户评论API JSON端点交付的JSON文件。这个API是如何工作的,或者是否有更好的API来处理这个问题,都不是那么重要。相反,让我们假设我们找到了我们最喜欢的API来工作,而且我们的请求非常有意义,现在我们必须处理API的响应,在这种情况下就是JSON。本文将指导你完成必要的步骤,将这个JSON响应解析成pandasDataFrame 。我将把重点放在概念和代码开发上,而不是解释每一行代码。理想情况下,你应该至少已经熟悉了一点Python和它的标准数据类型,最重要的是字典。

首先,我想了解我在处理什么,由于JSON响应的显示对原始URL来说不是那么好,我使用了JSON pretify工具,如jsonprettify.com/

这将给我以下重新格式化的JSON响应:

{
    "feed": {
        "author": {
            "name": {
                "label": "iTunes Store"
            },
            "uri": {
                "label": "http://www.apple.com/uk/itunes/"
            }
        },
        "entry": [
            {
                "author": {
                    "uri": {
                        "label": "https://itunes.apple.com/gb/reviews/id1413855597"
                    },
                    "name": {
                        "label": "VedantJM"
                    },
                    "label": ""
                },
                "updated": {
                    "label": "2022-05-31T14:20:49-07:00"
                },
                "im:rating": {
                    "label": "5"
                },
                "im:version": {
                    "label": "2.38"
                },
                "id": {
                    "label": "8727815893"
                },
                "title": {
                    "label": "Brilliant"
                },
                "content": {
                    "label": "Adonissss",
                    "attributes": {
                        "type": "text"
                    }
                },
                "link": {
                    "attributes": {
                        "rel": "related",
                        "href": "https://itunes.apple.com/gb/review?id=1500780518&type=Purple%20Software"
                    }
                },
                "im:voteSum": {
                    "label": "0"
                },
                "im:contentType": {
                    "attributes": {
                        "term": "Application",
                        "label": "Application"
                    }
                },
                "im:voteCount": {
                    "label": "0"
                }
            },
            ...

我只显示了entry 列表中的第一个author 对象。因此,JSON响应的结构是这样的:

  • 我们有一个单一的根元素 "feed"
  • 这个根元素只有两个子元素,"作者 "和 "条目",我只对 "条目 "感兴趣
  • "条目 "是一个对象的列表,每个对象都有一组属性,如 "作者"、"链接 "和 "im:rating"。
  • 每个属性都是一个JSON对象
  • 最简单的属性是一个只有 "标签 "键和一个值的对象。
  • 更复杂的属性如 "作者 "又是嵌套的。

在我深入研究如何解析这个嵌套结构之前,让我先试试pandasread_json() 方法:

import pandas as pd

url = "https://itunes.apple.com/gb/rss/customerreviews/id=1500780518/sortBy=mostRecent/json"
pd.read_json(url)

它的输出结果是下面的表格:

饲料
作者{'name':{'label': 'iTunes Store'}, 'uri':{'l...
词条[{'author':{'uri':{'标签':'https://itunes....
图标{'标签':'itunes.apple.com/favicon.ico…
id{'标签':'mzstoreservices-int-st.itun...
链接[{'属性':{'rel': 'alternate', 'type': '...
权利{'label': 'Copyright 2008 Apple Inc.'}.
标题{'标签':'iTunes商店。客户评论'}
更新{‘label’: ‘2022-06-02T11:44:53-07:00’}

这显然不是我所想的。我应该消除的第一个问题是,pandas不可能知道我只对 "条目 "列表感兴趣,所以我将首先获取JSON响应,将其解析为一个字典并访问 "条目 "值:

import requests

url = "https://itunes.apple.com/gb/rss/customerreviews/id=1500780518/sortBy=mostRecent/json"

r = requests.get(url)

data = r.json()
entries = data["feed"]["entry"]

因此,entries ,看起来像这样:

[{'author': {'label': '',
             'name': {'label': 'hdydgdbs'},
             'uri': {'label': 'https://itunes.apple.com/gb/reviews/id1351156521'}},
  'content': {'attributes': {'type': 'text'},
              'label': 'This meditation app is above all, it works and is '
                       'free, i reccomend it to everyone who wants to '
                       'meditate'},
  'id': {'label': '8730361700'},
  'im:contentType': {'attributes': {'label': 'Application',
                                    'term': 'Application'}},
  'im:rating': {'label': '5'},
  'im:version': {'label': '2.38'},
  'im:voteCount': {'label': '0'},
  'im:voteSum': {'label': '0'},
  'link': {'attributes': {'href': 'https://itunes.apple.com/gb/review?id=1500780518&type=Purple%20Software',
                          'rel': 'related'}},
  'title': {'label': 'Amazing app'},
  'updated': {'label': '2022-06-01T08:25:00-07:00'}},
  ...

现在,我可以再试试pandas。注意,我不再有一个 JSON 字符串,而是一个正常的 Python 列表,包含字典。因此,我可以直接使用pandasDataFrame 类:

df = pd.DataFrame(entries)

这个数据框的第一行看起来如下 (df.head(3)):

作者更新的im:评分im:版本id标题内容链接im:voteSumim:contentTypeim:voteCount
0{'URI':{'label': 'itunes.apple.com/gb...{‘label’: ‘2022-06-01T08:25:00-07:00’}{'标签':'5'}{'标签':'2.38'}{'标签':'8730361700'}{'label': 'Amazing app'}{'标签': '这个冥想应用程序高于一切,我...{'属性':{'rel': 'related', 'href': 'htt...{'标签':'0'}{'属性':{'术语': '应用', '标签'...{'标签': '0'}
1{'URI':{'标签':'itunes.apple.com/gb...{‘label’: ‘2022-05-31T14:20:49-07:00’}{'标签':'5'}{'标签':'2.38'}{'标签':'8727815893'}{'label': 'Brilliant'}{'标签': 'Adonissss', '属性':{'类型': ...{'属性':{'rel': 'related', 'href': ' htt...{'标签':'0'}{'属性':{'术语': '应用', '标签'...{'标签': '0'}
2{'URI':{'标签':'itunes.apple.com/gb...{‘label’: ‘2022-05-31T08:25:36-07:00’}{'标签':'5'}{'标签':'2.38'}{'标签':'8726950116'}{'标签':'完美'}{'label': 'This app is the one for meditations...{'属性':{'rel': 'related', 'href': 'htt...{'标签':'0'}{'属性':{'词条': '应用', '标签'...{'标签':'0'}

好多了,但仍然没有达到目的。我们有正确的列,每一行确实是条目列表中的一个条目。然而,所有的值都是字符串,更糟糕的是,是内部字典(有时是多个嵌套字典)的字符串表示。我无法处理这样的数据,所以我们必须手动解析条目列表,接下来我将解释。

再看一下条目的结构(见清单 "JSON响应"),策略很简单:浏览每个条目,只要值是一个字典,就把键连接到一个列名上,最后的值就是这个列和行的值。

现在,一个非常粗略的初次尝试可能是像这样硬编码所有的属性名称:

parsed_data = defaultdict(list)

for entry in entries:
    parsed_data["author_uri"].append(entry["author"]["uri"]["label"])
    parsed_data["author_name"].append(entry["author"]["name"]["label"])
    parsed_data["author_label"].append(entry["author"]["label"])
    parsed_data["content_label"].append(entry["content"]["label"])
    parsed_data["content_attributes_type"].append(entry["content"]["attributes"]["type"])
    ... 
                    

这个实现可能很天真,根本不能推广到任何其他的用例,但它仍然是一个非常有效的方法,因为它迫使你明确说明JSON结构,直到最后一个元素。可以用pandasDataFrame 类再次测试这种方法是否有效,该类可以从一个字典中创建一个数据框架,该字典中的每一列都有一个值的列表:

pd.DataFrame(parsed_data)

输出将是一个这样的数据框架:

author_uriauthor_nameauthor_labelcontent_labelContent_attributes_type
0itunes.apple.com/gb/reviews/…hdydgdbs这个冥想应用程序是高于一切的,它的工作和...文本
1itunes.apple.com/gb/reviews/…VedantJM阿多尼斯(Adonissss)文本
2itunes.apple.com/gb/reviews/…dtnvcgiifgh这款应用程序是用于冥想的,伟大的Sel...正文

然而,为了寻求一个更普遍的解决方案,能够在不知道结构的情况下自动处理所有的属性/特性(但依靠只有两层嵌套字典的事实,至少目前是这样),我得出了以下解决方案:

parsed_data = defaultdict(list)

for entry in entries:
    for key, val in entry.items():
        for subkey, subval in val.items():
            if not isinstance(subval, dict):
                parsed_data[f"{key}_{subkey}"].append(subval)
            else:
                for att_key, att_val in subval.items():
                    parsed_data[f"{key}_{subkey}_{att_key}"].append(att_val)

这段代码不是最漂亮的,但我将在后面讨论这个问题。现在让我们把注意力集中在这个方案上。对于每个条目,我看第一个键值对,知道那个值总是一个字典(JSON中的对象)。现在我必须处理两种不同的情况。在第一种情况下,value dictionary 是平面的,不包含另一个 dictionary,只有 key-value 对。这是一种简单的情况,我把外键和内键组合成一个列名,并把值作为每个对的列值。在第二种情况下,字典包含一个键-值对,其中的值又是一个字典。我依靠最多有两层嵌套字典的事实,所以我遍历了内部字典的键值对,并再次将外部键和最内部的键合并为一个列名,将内部值作为列值。

这个过程给了我一个字典,其中的键是数据框架的列名,每个键都有一个列表作为值,包含这一列的行值。这就是pandas DataFrame类创建数据框的完美格式:

df = pd.DataFrame(parsed_data)
df.head()

而第一行看起来是这样的:

author_uri_labelauthor_name_label作者标签更新的标签im:rating_labelim:version_labelid_labeltitle_label内容标签内容_属性_类型link_attributes_rel链接属性_hrefim:voteSum_labelim:contentType_attributes_termim:contentType_attributes_labelim:voteCount_label
0itunes.apple.com/gb/reviews/…hdydgdbs2022-06-01T08:25:00-07:0052.388730361700惊人的应用程序这个冥想应用程序是高于一切,它的工作和。相关itunes.apple.com/gb/review?i…...0应用程序申请书0
1itunes.apple.com/gb/reviews/…VedantJM2022-05-31T14:20:49-07:0052.388727815893辉煌的阿多尼斯(Adonissss文本相关itunes.apple.com/gb/review?i…...0申请书申请书0
2itunes.apple.com/gb/reviews/…dtnvcgiifgh2022-05-31T08:25:36-07:0052.388726950116完美这款应用是用于冥想的,伟大的Sel...文本相关itunes.apple.com/gb/review?i…...0应用程序申请书0

这就是了!我的列数比原先预计的要多一些,因为我决定通过将dict的嵌套结构扁平化为一个单一的dict来保留每一个信息,其中每个属性的组合都通过将不同的键串联成一个单一的列名来保留,用下划线"_"来分隔。这个数据框有50行和16列,这与原始的JSON响应相一致。如果你不喜欢列名中额外的 "标签 "部分,很容易就可以把它去掉。

df.columns = [col if not "label" in col else "_".join(col.split("_")[:-1]) for col in df.columns]

现在,所有的列都有数据类型object ,这在内存上并不理想,但只要数据集像这样小,就不会有很大影响。然而,我可以通过一个简单的单行代码来改变dtype:

df["im:rating"] = df["im:rating"].astype(int)

Pandas.info() 方法确认了cast:

RangeIndex: 50 entries, 0 to 49
Data columns (total 16 columns):
 #   Column                          Non-Null Count  Dtype 
---  ------                          --------------  ----- 
 0   author_uri                      50 non-null     object
 1   author_name                     50 non-null     object
 2   author                          50 non-null     object
 3   updated                         50 non-null     object
 4   im:rating                       50 non-null     int32 
 5   im:version                      50 non-null     object
 6   id                              50 non-null     object
 7   title                           50 non-null     object
 8   content                         50 non-null     object
 9   content_attributes_type         50 non-null     object
 10  link_attributes_rel             50 non-null     object
 11  link_attributes_href            50 non-null     object
 12  im:voteSum                      50 non-null     object
 13  im:contentType_attributes_term  50 non-null     object
 14  im:contentType_attributes       50 non-null     object
 15  im:voteCount                    50 non-null     object
dtypes: int32(1), object(15)
memory usage: 6.2+ KB

作为本文的结论,我想提高我的代码的可重用性。第一件显而易见的事情是将解析逻辑提取到一个或几个带有适当类型注释和文档串的函数中。然而,这并不是本文的重点,所以我将把这部分留给更有实践倾向的读者。

相反,我想强调的是,我的解决方案(列表中的 "高级实现")对于更深层次的嵌套JSON结构来说是失败的。这是因为我不得不明确地对内部字典进行迭代,每个字典都有一个for 循环。对于这样的问题,一个更好的解决方案是采用递归的方法,我们应用分而治之的范式来处理复杂性。换句话说,我真正要做的是,只要有内部字典,就进入每个字典,一旦到达终点,就把所有的值作为单独的列加入:

def recursive_parser(entry: dict, data_dict: dict, col_name: str = "") -> dict:
    """Recursive parser for a list of nested JSON objects
    
    Args:
        entry (dict): A dictionary representing a single entry (row) of the final data frame.
        data_dict (dict): Accumulator holding the current parsed data.
        col_name (str): Accumulator holding the current column name. Defaults to empty string.
    """
    for key, val in entry.items():
        extended_col_name = f"{col_name}_{key}" if col_name else key
        if isinstance(val, dict):
            recursive_parser(entry[key], data_dict, extended_col_name)
        else:
            data_dict[extended_col_name].append(val)

parsed_data = defaultdict(list)

for entry in entries:
    recursive_parser(entry, parsed_data, "")

df = pd.DataFrame(parsed_data)

这难道不是一种美吗?就像通常情况下,当递归方法对手头的任务更自然时,递归的实现更易读,而且往往比迭代的方法更短。你可以自己验证一下,通过这种方法得到的数据框与之前的迭代方案得到的数据框是相同的。

当然还有其他方法。一个常见的策略是通过像我们在这里做的非常类似的事情来平坦原始JSON:通过连接所有的键来拉出所有的嵌套对象,并保留最后的内部值。如果你像这样改变原始JSON,你会得到一个可以直接输入pandas的JSON。甚至有一个模块你可以直接使用:flatten_json。但是这里面的乐趣在哪里呢......

我希望你喜欢跟随我的这段小旅程,我也一如既往地欢迎你的评论、讨论和问题。

保持冷静,用Python编码