был полезен в поиске странного кодека еще раз

я есть текстовый файл, который, как утверждает издатель (Комиссия по ценным бумагам США), зашифрован в UTF-8 (https://www.sec.gov/files/aqfs.pdf, раздел 4). Я обрабатываю строки следующим кодом:

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with codecs.open(filename, 'r', encoding='utf-8', errors='strict') as f:
        fields = f.readline().strip().split('\t')
        for line in f.readlines():
            yield process_tag_record(fields, line)

Я получаю следующую ошибку:

Traceback (most recent call last):
  File "/home/randm/Projects/finance/secxbrl.py", line 151, in <module>
    main()
  File "/home/randm/Projects/finance/secxbrl.py", line 143, in main
    all_tags = list(tags("tag.txt"))
  File "/home/randm/Projects/finance/secxbrl.py", line 109, in tags
    content = f.read()
  File "/home/randm/Libraries/anaconda3/lib/python3.6/codecs.py", line 698, in read
    return self.reader.read(size)
  File "/home/randm/Libraries/anaconda3/lib/python3.6/codecs.py", line 501, in read
    newchars, decodedbytes = self.decode(data, self.errors)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xad in position 3583587: invalid start byte

Учитывая, что я, вероятно, не могу вернуться к SEC и сказать им, что у них есть файлы, которые, кажется, не кодируются в UTF-8, как мне отладить и отловить эту ошибку?

Что я пробовал

Я сделал hexdump файла и обнаружил, что оскорбительным текстом был текст «ДОПОЛНИТЕЛЬНОЕ РАСКРЫТИЕ ИНВЕСТИЦИЙ НЕ НАЛИЧНЫМИ». Если я декодирую ошибочный байт как шестнадцатеричный код (то есть «U + 00AD»), это имеет смысл в контексте, поскольку это мягкий дефис. Но следующее, похоже, не работает:

Python 3.5.2 (default, Nov 17 2016, 17:05:23) 
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> b"\x41".decode("utf-8")
'A'
>>> b"\xad".decode("utf-8")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec cant decode byte 0xad in position 0: invalid start byte
>>> b"\xc2ad".decode("utf-8")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec cant decode byte 0xc2 in position 0: invalid continuation byte

Я использовалerrors='replace', который, кажется, проходит. Но я хотел бы понять, что произойдет, если я попытаюсь вставить это в базу данных.

Отредактировано, чтобы добавить hexdump:

0036ae40  31 09 09 09 09 53 55 50  50 4c 45 4d 45 4e 54 41  |1....SUPPLEMENTA|
0036ae50  4c 20 44 49 53 43 4c 4f  53 55 52 45 20 4f 46 20  |L DISCLOSURE OF |
0036ae60  4e 4f 4e ad 43 41 53 48  20 49 4e 56 45 53 54 49  |NON.CASH INVESTI|
0036ae70  4e 47 20 41 4e 44 20 46  49 4e 41 4e 43 49 4e 47  |NG AND FINANCING|
0036ae80  20 41 43 54 49 56 49 54  49 45 53 3a 09 0a 50 72  | ACTIVITIES:..Pr|
 Martijn Pieters♦12 сент. 2017 г., 17:54
Далее я бы не использовалreadline() а такжеreadlines() звонки либо; использованиеfields = next(f).strip().split('\t') а такжеfor line in f:, Это позволяет избежать чтения всего файла в память сразу перед обработкой каждой строки.
 Martijn Pieters♦12 сент. 2017 г., 17:50
Что показывает фактическая hexdump?В шестнадцатеричном, а не как ASCII плюс символ замены. Байт U + 00AD будет закодирован как два байта, 0xC2 0xAD, и поэтому вы пропустите байт 0xC2.
 Martijn Pieters♦12 сент. 2017 г., 17:48
В Python 3.6 не использоватьcodecs.open(), Стандартopen() Функция может обрабатывать закодированные данные лучше и быстрее.
 Martijn Pieters♦12 сент. 2017 г., 17:51
Как вы получили файл данных? Если там пропущен байт, может быть и другое повреждение данных.
 Martijn Pieters♦12 сент. 2017 г., 18:57
@ HåkenLid: за исключением того, что нет никакой известной кодировки, которая могла бы произвести вывод, произведенный SEC. Они создали неверный кодек.

Ответы на вопрос(1)

Решение Вопроса

должен бытьU + 00AD МЯГКИЙ ГИФЕНтогда вы пропускаете байт 0xC2:

>>> '\u00ad'.encode('utf8')
b'\xc2\xad'

Из всех возможных кодировок UTF-8, которые заканчиваются на 0xAD, мягкий дефис имеет смысл. Тем не менее, это указывает на набор данных, которыймай другие байты отсутствуют. Вы случайно попали в тот, который имеет значение.

Я бы вернулся к источнику этого набора данных и убедился, что файл не был поврежден при загрузке. В противном случае, используяerror='replace' является жизнеспособным обходным путем при условии отсутствия разделителей (табуляции, новых строк и т. д.).

Другая возможность заключается в том, что SEC действительно используетразные кодировка для файла; например, в кодовой странице Windows 1252 и Latin-1,0xAD правильная кодировка мягкого дефиса. И действительно, когда я загружаютот же набор данных напрямую (предупреждение, ссылка на большой файл ZIP)и открытьtags.txtЯ не могу декодировать данные как UTF-8:

>>> open('/tmp/2017q1/tag.txt', encoding='utf8').read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../lib/python3.6/codecs.py", line 321, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xad in position 3583587: invalid start byte
>>> from pprint import pprint
>>> f = open('/tmp/2017q1/tag.txt', 'rb')
>>> f.seek(3583550)
3583550
>>> pprint(f.read(100))
(b'1\t1\t\t\t\tSUPPLEMENTAL DISCLOSURE OF NON\xadCASH INVESTING AND FINANCING A'
 b'CTIVITIES:\t\nProceedsFromSaleOfIn')

В файле есть два таких не-ASCII символа:

>>> f.seek(0)
0
>>> pprint([l for l in f if any(b > 127 for b in l)])
[b'SupplementalDisclosureOfNoncashInvestingAndFinancingActivitiesAbstract\t0'
 b'001654954-17-000551\t1\t1\t\t\t\tSUPPLEMENTAL DISCLOSURE OF NON\xadCASH I'
 b'NVESTING AND FINANCING ACTIVITIES:\t\n',
 b'HotelKranichhheMember\t0001558370-17-001446\t1\t0\tmember\tD\t\tHotel Krani'
 b'chhhe [Member]\tRepresents information pertaining to Hotel Kranichh\xf6h'
 b'e.\n']

Hotel Kranichh\xf6he расшифровывается как латиница-1Отель Краниххёэ.

В файле также есть несколько пар 0xC1 / 0xD1:

>>> f.seek(0)
0
>>> quotes = [l for l in f if any(b in {0x1C, 0x1D} for b in l)]
>>> quotes[0].split(b'\t')[-1][50:130]
b'Temporary Payroll Tax Cut Continuation Act of 2011 (\x1cTCCA\x1d) recognized during th'
>>> quotes[1].split(b'\t')[-1][50:130]
b'ributory defined benefit pension plan (the \x1cAetna Pension Plan\x1d) to allow certai'

Бьюсь об заклад, это действительноU + 201C ЛЕВАЯ КВАДРАТНАЯ КАРТА а такжеU + 201D ПРАВА ДВОЙНОЙ ЦИТАТЫ персонажи; Обратите внимание1C а также1D частей. Такое ощущение, что их кодировщик взял UTF-16 и удалил все старшие байты, а не кодировал в UTF-8 должным образом!

Там нет доставки кодеков с Python, который будет кодировать'\u201C\u201D' вb'\x1C\x1D'что делает все более вероятным, что SEC где-то провалил процесс кодирования. На самом деле, есть также символы 0x13 и 0x14, которые, вероятно,ан а такжеЭм тире (U + 2013 а такжеU + 2014), а также 0x19 байтов, которые почти наверняка являются одинарными кавычками (U + 2019). Все, чего не хватает для завершения изображения, это 0x18 байт для представленияU + 2018.

Если мы предположим, что кодировка нарушена, мы можем попытаться восстановить. Следующий код будет читать файл и исправлять проблемы с кавычками, предполагая, что остальные данные не используют символы вне Latin-1, кроме кавычек:

_map = {
    # dashes
    0x13: '\u2013', 0x14: '\u2014',
    # single quotes
    0x18: '\u2018', 0x19: '\u2019',
    # double quotes
    0x1c: '\u201c', 0x1d: '\u201d',
}
def repair(line, _map=_map):
    """Repair mis-encoded SEC data. Assumes line was decoded as Latin-1"""
    return line.translate(_map)

затем примените это к строкам, которые вы прочитали:

with open(filename, 'r', encoding='latin-1') as f:
    repaired = map(repair, f)
    fields = next(repaired).strip().split('\t')
    for line in repaired:
        yield process_tag_record(fields, line)

Отдельно, обращаясь к вашему опубликованному коду, вы заставляете Python работать тяжелее, чем нужно. Не использоватьcodecs.open(); это устаревший код с известными проблемами, который медленнее, чем новый уровень ввода / вывода Python 3. Просто используйтеopen(), Не используйf.readlines(); Вам не нужно читать весь файл в список здесь. Просто переберите файл напрямую:

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with open(filename, 'r', encoding='utf-8', errors='strict') as f:
        fields = next(f).strip().split('\t')
        for line in f:
            yield process_tag_record(fields, line)

Еслиprocess_tag_record также разделяется на вкладки, используйтеcsv.reader() возражать и избегать разделения каждой строки вручную:

import csv

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with open(filename, 'r', encoding='utf-8', errors='strict') as f:
        reader = csv.reader(f, delimiter='\t')
        fields = next(reader)
        for row in reader:
            yield process_tag_record(fields, row)

Еслиprocess_tag_record объединяетfields список со значениями вrow чтобы сформировать словарь, просто используйтеcsv.DictReader() вместо:

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with open(filename, 'r', encoding='utf-8', errors='strict') as f:
        reader = csv.DictReader(f, delimiter='\t')
        # first row is used as keys for the dictionary, no need to read fields manually.
        yield from reader
 Martijn Pieters♦11 февр. 2019 г., 14:16
@tripleee: еще раз спасибо за эту страницу, этобыл полезен в поиске странного кодека еще раз.
 MikeRand12 сент. 2017 г., 18:11
Мне нужно сделать немного больше работы в process_tag_record, чем просто архивирование и возврат (например, преобразование данных в типы данных Python, создание экземпляра SQLAlchemy), но да, это сработало бы лучше, если бы это был только zip и return.
 Martijn Pieters♦12 сент. 2017 г., 19:20
@MarkTolonen: действительно; Я обнаружил 5 различных «аномалий», байты между 0x00 и 0x1F (исключая 0x09 и 0x0A, или перевод строки и табуляцию).
 Martijn Pieters♦17 янв. 2019 г., 12:25
@tripleee: интересно. Я вообще использую файл format.infoнабор символов а такжеюникода страницы для перекрестных ссылок символов; у них естьисчерпывающие списки кодов для каждого кода а такжеКодовые страницы Windows проверить против. А для других проблем с кодировкойftfy проект бесценен.
 Mark Tolonen12 сент. 2017 г., 18:59
Согласно вашему UTF-16 с разделенными старшими байтами, это именно то, на что это похоже. Есть также одинарные кавычки и em и en dash, которые следуют той же схеме.

Ваш ответ на вопрос