Facebook JSON 编码有误。

55

我下载了我的Facebook Messenger数据(在你的Facebook账户中,进入设置,然后进入你的Facebook信息,然后下载你的信息,创建一个至少选中了消息框的文件)以进行一些酷炫的统计工作。

但是有一个小问题是编码。我不确定,但看起来像是Facebook对这些数据使用了错误的编码。当我用文本编辑器打开它时,我看到类似于这样的内容:Rados\u00c5\u0082aw。当我尝试用Python(UTF-8)打开它时,我得到了RadosÅ\x82aw。然而我应该得到:Radosław

我的Python脚本:

text = open(os.path.join(subdir, file), encoding='utf-8')
conversations.append(json.load(text))

我尝试了几种最常见的编码方式。示例数据如下:

{
  "sender_name": "Rados\u00c5\u0082aw",
  "timestamp": 1524558089,
  "content": "No to trzeba ostatnie treningi zrobi\u00c4\u0087 xD",
  "type": "Generic"
}

为什么您假设数据是UTF-8编码的?如果您不知道它的编码方式,是否尝试过其他合理的可能性,例如Windows 1250或ISO 8859-2? - Peteris
没有什么确切的线索,但似乎 Facebook API 中的表情符号编码出现了问题:https://dev59.com/9XnZa4cB1Zd3GeqPn0JR - Patrick Artner
1
@JakubJendryka:没错,我对那个系统不熟悉,也许确实存在乱码问题;UTF-8数据被解码为Latin-1,然后编码为JSON。 - Martijn Pieters
@Patrick:那已经是古老的历史了。我们不再使用那种编码(而且那只适用于表情符号)。 - Martijn Pieters
对于那些使用.NET C#解决方案的人 - Zyo
显示剩余2条评论
9个回答

74
我可以确认Facebook下载数据存在错误编码,即乱码。原始数据采用UTF-8编码,但被解码为Latin-1编码。我会确保提交一个错误报告。
这意味着字符串数据中的任何非ASCII字符都被编码了两次。首先是UTF-8编码,然后将UTF-8字节作为Latin-1编码数据再次编码(将256个字符完全映射到256个可能的字节值),使用\uHHHH JSON转义符号(因此是一个文字反斜杠,一个小写字母u,后跟4个十六进制数字,0-9和a-f)。由于第二步编码的字节值在0-255范围内,因此结果是一系列的\u00HH序列(一个文字反斜杠,一个小写字母u,两个0零数字和两个十六进制数字)。
例如,在名称Radosław中的Unicode字符U+0142 LATIN SMALL LETTER L WITH STROKE被编码为UTF-8字节值C5和82(以十六进制表示),然后再次编码为\u00c5\u0082
您可以通过两种方式修复此问题:
  1. Decode the data as JSON, then re-encode any string values as Latin-1 binary data, and then decode again as UTF-8:

     >>> import json
     >>> data = r'"Rados\u00c5\u0082aw"'
     >>> json.loads(data).encode('latin1').decode('utf8')
     'Radosław'
    

    This would require a full traversal of your data structure to find all those strings, of course.

  2. Load the whole JSON document as binary data, replace all \u00hh JSON sequences with the byte the last two hex digits represent, then decode as JSON:

     import re
     from functools import partial
    
     fix_mojibake_escapes = partial(
         re.compile(rb'\\u00([\da-f]{2})').sub,
         lambda m: bytes.fromhex(m[1].decode()),
     )
    
     with open(os.path.join(subdir, file), 'rb') as binary_data:
         repaired = fix_mojibake_escapes(binary_data.read())
     data = json.loads(repaired)
    

    (If you are using Python 3.5 or older, you'll have to decode the repaired bytes object from UTF-8, so use json.loads(repaired.decode())).

    From your sample data this produces:

     {'content': 'No to trzeba ostatnie treningi zrobić xD',
      'sender_name': 'Radosław',
      'timestamp': 1524558089,
      'type': 'Generic'}
    

    The regular expression matches against all \u00HH sequences in the binary data and replaces those with the bytes they represent, so that the data can be decoded correctly as UTF-8. The second decoding is taken care of by the json.loads() function when given binary data.


我在Python 3.8.8版本中得到了“Nedirbsiu\U0001f972”,但在3.10.2版本中得到了“Nedirbsiu”,所以我想你是正确的。感谢您的解释! - brikas
@brikas:\U0001f972U+1F972带泪笑脸的转义序列;Python 3.8支持Unicode 12.0.0,并使用\xHH / \uHHHH / \UHHHHHHHH转义(加上\n\t\r)来表示标准中未标记为“可打印”的任何代码点。由于U+1F972是在Unicode 13.0.0中定义的,因此Python 3.8不知道它是可打印的代码点。 - Martijn Pieters

13
这是一个使用jq和iconv的命令行解决方案。在Linux上进行了测试。 cat message_1.json | jq . | iconv -f utf8 -t latin1 > m1.json

为什么需要 jq .?它只是将原始文件格式化输出。 - jjmerelo
2
@jjmerelo 你需要将转义字符转换为它们的原始格式。 - che

8

我想扩展@Geekmoss的答案,以下是我用来解码Facebook数据的递归代码段。

import json

def parse_obj(obj):
    if isinstance(obj, str):
        return obj.encode('latin_1').decode('utf-8')

    if isinstance(obj, list):
        return [parse_obj(o) for o in obj]

    if isinstance(obj, dict):
        return {key: parse_obj(item) for key, item in obj.items()}

    return obj

decoded_data = parse_obj(json.loads(file))

我注意到这种方式更好,因为你下载的 Facebook 数据可能包含字典列表,在这种情况下,那些字典会按原样返回,因为使用了 lambda 标识函数。

6

我对解析对象的解决方案是使用 parse_hook 回调函数在 load/loads 函数中:

import json


def parse_obj(dct):
    for key in dct:
        dct[key] = dct[key].encode('latin_1').decode('utf-8')
        pass
    return dct


data = '{"msg": "Ahoj sv\u00c4\u009bte"}'

# String
json.loads(data)  
# Out: {'msg': 'Ahoj svÄ\x9bte'}
json.loads(data, object_hook=parse_obj)  
# Out: {'msg': 'Ahoj světe'}

# File
with open('/path/to/file.json') as f:
     json.load(f, object_hook=parse_obj)
     # Out: {'msg': 'Ahoj světe'}
     pass

更新:

解析包含字符串的列表的解决方案无法正常工作。因此,这里是更新后的解决方案:

import json


def parse_obj(obj):
    for key in obj:
        if isinstance(obj[key], str):
            obj[key] = obj[key].encode('latin_1').decode('utf-8')
        elif isinstance(obj[key], list):
            obj[key] = list(map(lambda x: x if type(x) != str else x.encode('latin_1').decode('utf-8'), obj[key]))
        pass
    return obj

非常感谢!这个问题让我疯狂了,你的解决方案完美地解决了它。 - laurencevs

1
Facebook程序员似乎混淆了Unicode编码和转义序列的概念,可能是在实现他们自己的特定序列化器时出现的。更多细节请参见Facebook数据导出中的无效Unicode编码
试一下这个:
import json
import io

class FacebookIO(io.FileIO):
    def read(self, size: int = -1) -> bytes:
        data: bytes = super(FacebookIO, self).readall()
        new_data: bytes = b''
        i: int = 0
        while i < len(data):
            # \u00c4\u0085
            # 0123456789ab
            if data[i:].startswith(b'\\u00'):
                u: int = 0
                new_char: bytes = b''
                while data[i+u:].startswith(b'\\u00'):
                    hex = int(bytes([data[i+u+4], data[i+u+5]]), 16)
                    new_char = b''.join([new_char, bytes([hex])])
                    u += 6

                char : str = new_char.decode('utf-8')
                new_chars: bytes = bytes(json.dumps(char).strip('"'), 'ascii')
                new_data += new_chars
                i += u
            else:
                new_data = b''.join([new_data, bytes([data[i]])])
                i += 1

        return new_data

if __name__ == '__main__':
    f = FacebookIO('data.json','rb')
    d = json.load(f)
    print(d)

1

基于 @Martijn Pieters 的解决方案,我用 Java 写了类似的东西。

public String getMessengerJson(Path path) throws IOException {
    String badlyEncoded = Files.readString(path, StandardCharsets.UTF_8);
    String unescaped = unescapeMessenger(badlyEncoded);
    byte[] bytes = unescaped.getBytes(StandardCharsets.ISO_8859_1);
    String fixed = new String(bytes, StandardCharsets.UTF_8);
    return fixed;
}

unescape方法的灵感来自org.apache.commons.lang.StringEscapeUtils。

private String unescapeMessenger(String str) {
    if (str == null) {
        return null;
    }
    try {
        StringWriter writer = new StringWriter(str.length());
        unescapeMessenger(writer, str);
        return writer.toString();
    } catch (IOException ioe) {
        // this should never ever happen while writing to a StringWriter
        throw new UnhandledException(ioe);
    }
}

private void unescapeMessenger(Writer out, String str) throws IOException {
    if (out == null) {
        throw new IllegalArgumentException("The Writer must not be null");
    }
    if (str == null) {
        return;
    }
    int sz = str.length();
    StrBuilder unicode = new StrBuilder(4);
    boolean hadSlash = false;
    boolean inUnicode = false;
    for (int i = 0; i < sz; i++) {
        char ch = str.charAt(i);
        if (inUnicode) {
            unicode.append(ch);
            if (unicode.length() == 4) {
                // unicode now contains the four hex digits
                // which represents our unicode character
                try {
                    int value = Integer.parseInt(unicode.toString(), 16);
                    out.write((char) value);
                    unicode.setLength(0);
                    inUnicode = false;
                    hadSlash = false;
                } catch (NumberFormatException nfe) {
                    throw new NestableRuntimeException("Unable to parse unicode value: " + unicode, nfe);
                }
            }
            continue;
        }
        if (hadSlash) {
            hadSlash = false;
            if (ch == 'u') {
                inUnicode = true;
            } else {
                out.write("\\");
                out.write(ch);
            }
            continue;
        } else if (ch == '\\') {
            hadSlash = true;
            continue;
        }
        out.write(ch);
    }
    if (hadSlash) {
        // then we're in the weird case of a \ at the end of the
        // string, let's output it anyway.
        out.write('\\');
    }
}

所以我花了一些时间尝试你的Java解决方案,只需要调试和学习,在更大的unescapeMessenger例程中,在for循环的顶部,你有一个if(inUnicode),在循环开始之前将其设置为false...所以什么都没有被处理...这是怎么回事? - Michael Sims
但是for循环块不会在第一个条件块结束。如果我们处于'\u'前缀的'u'字符上,则inUnicode变量将在第二个条件块中设置为true。 - Ondrej Sotolar
嗯,这对我从来没有起作用过,我以一种粗糙但有效的方式解析了字符串。 - Michael Sims

0

这是我针对Node 17.0.1的方法,基于@hotigeftas的递归代码,使用iconv-lite包。

import iconv from 'iconv-lite';

function parseObject(object) {
  if (typeof object == 'string') {
    return iconv.decode(iconv.encode(object, 'latin1'), 'utf8');;
  }

  if (typeof object == 'object') {
    for (let key in object) {
      object[key] = parseObject(object[key]);
    }
    return object;
  }

  return object;
}

//usage
let file = JSON.parse(fs.readFileSync(fileName));
file = parseObject(file);

你的回答可以通过添加更多关于代码的信息以及它如何帮助提问者来改进。 - Tyler2P

0
这是@Geekmoss的答案,但适用于Python 3:
def parse_facebook_json(json_file_path):
    def parse_obj(obj):
        for key in obj:
            if isinstance(obj[key], str):
                obj[key] = obj[key].encode('latin_1').decode('utf-8')
            elif isinstance(obj[key], list):
                obj[key] = list(map(lambda x: x if type(x) != str else x.encode('latin_1').decode('utf-8'), obj[key]))
            pass
        return obj
    with json_file_path.open('rb') as json_file:
        return json.load(json_file, object_hook=parse_obj)

# Usage
parse_facebook_json(Path("/.../message_1.json"))

0
扩展Martijn的解决方案#1,我发现它可以导致递归对象处理(起初肯定会引导我):
如果您不使用ensure_ascii,则可以将其应用于整个json对象字符串。
json.dumps(obj, ensure_ascii=False, indent=2).encode('latin-1').decode('utf-8')

然后将其写入文件或其他地方。

附注:这应该是对@Martijn答案的评论:https://dev59.com/xlUL5IYBdhLWcg3wqZeb#50011987(但我无法添加评论)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接