Начинающий программист vs Избирком СПб

Это история о том, как я писал код на Python 3, который собирает и систематизирует данные по избирательным комиссиям в моём родном городе Санкт-Петербурге. Ну, и про то, что я там накопал в извлечённых данных.

Интродукция

С 2018 года я работаю в разных качествах в избирательных комиссиях от одной из наблюдательский организация Санкт-Петербурга. Вношу свой посильный вклад в построение гражданского общества, так скажем. И да, может с учётом контекста сегодняшнего времени, не очень я вовремя с этой статьёй, ну, а что поделать.

Интерес к тому, чтобы систематизировать данные по избирательным комиссиям появился у меня в тот момент, когда я участвовал в выборах 2021 года в качестве ЧПРГ ТИК№31. Учиться программировать я стал относительно недавно, 2 месяца в относительно ленивом темпе (на момент начала июня 2022).

3 июня я приступил к работе и начал осуществлять свою давнюю задумку.

S’il vous plait — хронология.

Глава 1. Сбор данных

Сайт, с которого я собирал данные выглядит так.

d5641ae4bb71e8db09eeffa04140ef83.png

Слева структура комиссий в открывающихся списках. Честно говоря, я пока что понятия не имею, как устроена веб-страница на практике, но заметил, что если открывать разные комиссии, то сайт остаётся один и тот же, меняется только длинный номер в конце адреса. Я попробовал понять, есть ли какая-нибудь связь между номером комиссии и её номером в адресной строке, но быстро понял, что никакой генеральной закономерности там нет, хоть фрагментами и можно так подумать.

Переписывать ссылки вручную — дело долгое и неблагодарное, поэтому полез в веб-инспектор сафари. Полу-наугад стал там искать, где есть ссылки, на которые ведут номера комиссий. Сначала копался в ресурсах и увидел, что если раскрыть список — появляется файл st-petersburg, в котором перечислены несколько айдишников. Уже неплохо, но всё ещё многовато действий.

c994b81772f1d044f7f6ff64cc18deb9.png

Продолжил поиски и во вкладке Аудит нашёл то, что искал, как на ладони (Result Data → data-domAttributes. Для того, чтобы там появилось всё, что мне надо, пришлось вручную пооткрывать все списки, но это не заняло много времени.

37b9d17fb89dd3a6fb2b7b711435e020.png

Экспорт был в файл с расширением json. Я что-то об этом слышал, поэтому решил не разбираться (может быть слишком долго), а просто скопировал из окошка строки с айдишниками в обычный текстовый файл.

Также с сайта втупую выделил и скопировал список комиссий в текстовый файл, они там в таком же порядке, как и их айди на картинке, поэтому можно будет составить словарь или типа того.

Глава 2. Очистка данных

Эффективнее было бы очистить числа от html-мусора в любом текстовом редакторе, но это неспортивно, я ж в конце концов программировать учусь, а не текстовым редактором пользоваться.

Немного освежил память, как там обращаться к файлам и написал незамысловатый код для очистки:

out_string_indexes = ''

file_name = 'Indexes List.txt'
with open(file_name, mode='r') as file:
    for line in file:
        if '<' in line:     #это чтоб отфильтровать нужные строки, они
          									# там странновато скопировались
            out_string_indexes += line.split('id=')[1].split('"')[1]
            out_string_indexes += '\n'

file_name = 'indexes_processed.txt'
with open(file_name, mode='w') as file:
    file.write(out_string_indexes)

Затем проверил, ничего ли не потерялось:

file_name = 'indexes_processed.txt'
with open(file_name, mode='r') as file:
    i = 0
    for line in file:
        i += 1
    print('There are {} indexes'.format(i))

file_name = 'commissions_list.txt'
with open(file_name, mode='r') as file:
    i = 0
    for line in file:
        i += 1
    print('There are {} commissions in the list’.format(i))

Всё оказалось хорошо, выдало по 2017 и тех и других.

Нашёл в интернете вот это, для тех, что проникся дзеном пайтона

sum(1 for line in open('file', ‘r’))

Но я ещё не проникся настолько, побаиваюсь вообще таких функций. Мне лучше для начала понятно и надёжно (как Tegridy farms®)

Глава 3. Общение с вебсайтом

С такой проблемой я ещё не сталкивался, поэтому стал читать, как вообще обратиться к вебсайту. Спустя пару минут выяснил, что с помощью urllib.request. Открыл, сразу же столкнулся с такой проблемой:

urllib.error.URLError:

Догадался скопировать ошибку в гугл и быстро нашёл, что нужно в папке пайтона тыкнуть на установку сертификата. Попробовал подсоединиться снова, получил вот это:

raise HTTPError (req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 403: Forbidden

Упс, кажется, мне тут не рады. А ещё похоже, что он меня по айпи забанил, потому что и через браузер перестал входить, а через терминал пингуется нормально. (апд: потом разбанил через сутки)

Ничего страшного, раздал интернет с телефона. Там, если я что-то в чём-то понимаю, айпи присваивается динамически при подключении, и такая блокировка не сработает, если переподключаться. В интернете я быстро вычитал, что к запросу надо добавить хедер, типа имитировать, что я с браузера захожу.

Если что, у меня ОЧЕНЬ поверхностные знания обо всём этом.

from urllib.request import Request, urlopen

req = Request('http://www.st-petersburg.vybory.izbirkom.ru/region/st-petersburg?action=ik&vrn=27820001006425',
              headers={'User-Agent': 'Mozilla/5.0'})
webpage = urlopen(req).read()
result = webpage.decode('utf-8', 'ignore')
print(result)

На этот раз получилось, но получилось всё ещё не то. Если я правильно понял, то страница-то загрузилась, но не выполнился скрипт, который подгружает на страницу все нужные мне данные.

Разумеется, я не единственный, кто с этой проблемой столкнулся, поэтому вновь углубился в чтение. За это время моим любимым сайтом стал Stack Overflow.

Собственно, загрузив нужный модуль requests_html, которых там почему-то сразу загрузилась целая гирлянда, я написал это, и оно наконец сработало! Лёд тронулся, господа присяжные.

from requests_html import HTMLSession
session = HTMLSession()

url = 'http://www.st-petersburg.vybory.izbirkom.ru/region/st-petersburg?action=ik&vrn=4784001269007'

r = session.get(url, headers={'User-Agent': 'Mozilla/5.0'})
r.html.render

result = r.text.encode('utf-8')
result = result.decode('utf-8')
print(result)

Кодировать и раскодировать пришлось по той причине, что вместо кириллицы он выдавал ерунду, а как ещё декодировать эту хрень — я не догадался, так что прошу прощения, если говнокод. Тем не менее, результата я достиг.

Voila, la HTML pageVoila, la HTML page

Дело осталось за малым: нужно теперь вытащить оттуда саму табличку и написать программу, которая прогонит этот алгоритм через все 2017 комиссий. Предварительно придумав, каким образом эти данные структурировать, чтобы потом можно было по ним всё что нужно искать.

Кстати, я тут подумал, может повытаскивать у них адреса и разметить на карте

6787f3a072ccc790682a9fbbec3a9067.jpeg

Глава 4. Построение программы

Сначала я писал команды в императивном стиле, чтобы понять, что мне именно нужно и как этого достичь, затем уже распихал всё это по функциям и слепил из них мастер-функцию.

Если кратко описать, то отрезал страницу по начало таблицы, удалил все <штуки>, некоторые заменив на разделители, затем командой split сделал из получившейся строки массив, из которого дальше сделал двумерный массив. Приблизительно так:

def text_cleanup(txt=text):
  	# Обрезаю таблицу
    start = txt.index('ФИО')   #начало таблицы
    txt = txt[start::]
    start = txt.find('1')   #начало первой нужной строки таблицы
    txt = txt[start - 4::]
    end = txt.index('', '')
    txt = txt.replace('', '...')   #строки
    txt = txt.replace('', ',,,')   #столбцы
    txt = txt.replace('', '')
    txt = txt.replace('', '')
    txt = txt.replace('', '')
    txt = txt.replace('
', '') txt = txt.replace('
', '') txt = txt.replace('\r', '') txt = txt.replace('\t', '') txt = txt.replace('\n', '') return txt

Дальше так

txt = text_cleanup()
result = txt.split(‘…’)  #разбиваю текст по строкам

for i in range(len(result)):  #разбиваю каждую строку на элементы
    result[i] = result[i].split(',,,')

return result

4ae2833c3ea84a7e9e4f26ce0d80c07d.jpeg

И лёгким движением руки таблица с сайта превращается… превращается таблица с сайта… в двумерный массив.

Дальше я создал функцию, которая берёт веб-страницу через session.get и render, как я писал выше, прогоняет полученное через функцию очистки, записывает в текстовый файл или эксель, и всё это в цикле, который из списка подгружает айдишники, которые до этого были в него записаны из файла ещё одной функцией. Как писать в эксель — это я прочитал статью про модуль pyopenxl, мне очень понравилась там функция append, которую я и использовал в своей программе.

Короче, сущностно происходит следующее:

  • Из файлов выгружается в двумерный список названия комиссий и их айди

  • Циклом по очереди из этого списка читаются айди

  • Добавляются к постоянной части адреса, загружается код страницы

  • Очищается от мусора и преобразуется в двумерный список

  • Построчно вместе с номером комиссии записывается в файл

Код получился здоровый, поэтому публиковать не буду, основные его элементы и логику я в принципе описал. Итого 2 минуты код отработал как часы и на выходе получилась здоровенная таблица!

204ab8411b5d79b6edc3da667866fc62.pngА вот и я тамА вот и я там

Тут я ненадолго остановлюсь и опишу свои ощущения:

Я хоть и продвинутый, но обычный юзер, поэтому когда вся эта штука сработала — я ощутил себя каким-то, блин, Нео. Вообще то, что я написал программу, которая сама в интернете ковыряется — это крайне странно. Ни разу не выходил в интернет не через браузер. Вот уж был действительно hello world!

И это пока что первая программа, которая выполняет что-то не абстрактное, а вполне конкретное. Собственно, испытываю гордость за себя:)

Глава 5. Анализ

В принципе, всю аналитику можно было бы сделать в экселе, но это неспортивно, я же программировать учусь. Какую-то сложную аналитику я производить не буду, меня интересуют довольно простые вещи. Гипотеза в том, что представителей крупных партий кроме Единой России непропорционально мало среди руководства комиссий, а может и в принципе среди всех членов комиссий.

Чтобы мой дорогой читатель не подумал, что я сразу выдумал всю программу, сначала я написал код несколько меня интересующих случаев, но потом решил, что нужно более универсальное и гибкое решение.

Написал сначала функцию analysis (*args), которая делает вот что:

  • создаёт пустой словарь

  • считывает из текстового файла строку

  • ищет в ней слова из *args

  • если находит, проверяет, есть ли в словаре название партии

  • если есть — делает +=1, если нет — добавляет со значением 1

  • выдаёт в итоге общее количество найденных строк и словарь, в котором напротив названия партии указано количество человек

Так это выглядело на выходе, если не фильтровать (только без процентов сначала):

Там ещё дальше много строкТам ещё дальше много строк

Фильтр работал нормально, но с таким результатом сделать что-то сложно. У меня возникли идеи: надо сделать отдельную функцию фильтр с аргументом, переключающим режимы И / ИЛИ, а также создать список основных партий и сверять с ним, потому что читать данный результат трудно.  Можно ещё отметить, что Единая Россия внимательнее всех относится к тому, чтобы написать именно конкретное отделение своей партии.

В итоге следующая версия выглядела так:
parties_list — это список более крупных партий, который я выделил

def filter_keywords(line='', und=False, *args):

    """Returns True if arguments are in line.
    Basically, und is and:
    If und=True -> every argument must be in line,
    If und=False -> at least one argument must be in line"""

    if und:
        for word in args:
            if word.lower() not in line.lower():
                return False
        return True
    else:
        for word in args:
            if word.lower() in line.lower():
                return True
        return False
      
def analysis(unite_minors=True, und=False, *args):

    """Returns vocabulary with parties and a number of members in it and overall quantity 	of members.
    unite_minors collects every minor party/association to keyword 'Остальные'.
    und is about filtering style 'and' or 'or', und is and in a nutshell.
    args is keywords for filtering"""

    result = {'Остальные': 0}
    counter = 0

    with open('master_table.txt', mode='r') as file:
        for line in file:
            if filter_keywords(line, und, *args):
                party = line.split(' : ')[6]
                if not unite_minors:  #если не объединять партии, не входящие в список
                    if party in result:
                        result[party] += 1
                    else:
                        result[party] = 1
                    counter += 1
                else:  #если объединять партии, не входящие в список
                    for major_party in parties_list: 
                        if filter_keywords(line, False, major_party):
                            if major_party in result:
                                result[major_party] += 1
                            else:
                                result[major_party] = 1
                            counter += 1
                            break
                    else: #если за цикл не нашлось совпадений
                        counter += 1
                        result['Остальные'] += 1
                        
		# сортировка словаря, скопировал из интернета дзен-функцию
    # на этот раз было лень писать самому
    result = dict(sorted(result.items(), key=lambda item: item[1], reverse=True))
    return result, counter

Я решил отправить в return помимо словаря сколько всего строчек обработано. Для учёта и чтоб сразу проценты можно было высчитывать, хотя не знаю, насколько это целесообразно. Но если что — легко переделывается.

73dfffa34ae5fff24c4dcf9686c2b97a.png

Так гораздо лучше, но спустя некоторое количество запросов я понял, что такая функция не позволяет мне узнать, например, кто выдвинул Председателя, Зама и Секретаря только в Территориальных комиссиях. Поэтому решил сделать новую, которая будет фильтровать отдельно по уровню комиссий и должностям. Она стала концептуально проще и принимает строки с ключевыми словами через пробел.

def filter_or(line, *args):
    for word in args:
        if word.lower() in line.lower():
            return True
    return False


def analysis2(level='', position='', unite_minors=True):
    """Returns vocabulary with parties and a number of members in it and overall quantity of members.
    unite_minors collects every minor party/association to keyword 'Остальные'.
    level is keywords for level filter, type with spaces between keywords!
    position is keywords for position filter, type with spaces between keywords!"""

    result = {'Остальные': 0}
    counter = 0
    level = level.split(' ')
    position = position.split(' ')

    with open('master_table.txt', mode='r') as file:
        for line in file:
            t_party = line.split(' : ')[6]
            t_position = line.split(' : ')[5]
            t_level = line.split(' : ')[0]
            if filter_or(t_level, *level) and filter_or(t_position, *position):
                if not unite_minors:
                    if t_party in result:
                        result[t_party] += 1
                    else:
                        result[t_party] = 1
                    counter += 1
                else:
                    for major_party in parties_list:
                        if filter_or(t_party, major_party):
                            if major_party in result:
                                result[major_party] += 1
                            else:
                                result[major_party] = 1
                            counter += 1
                            break
                    else:
                        counter += 1
                        result['Остальные'] += 1

    result = dict(sorted(result.items(), key=lambda item: item[1], reverse=True))
    return result, counter

На этот раз я получил весь функционал, который хотел. И да, немного кода, который всё это выводит на консоль. А затем ещё и в графики вместе с модулем matplotlib.pyplot, о котором я только что прочитал.

voc, quantity = analysis2(level='спбик тик уик',
                          position='председатель зам секретарь член', 
                          unite_minors=True)
print('****Всего {}****\n'.format(quantity))
for item in voc:
    print('{} or {}% : {}'.format(voc[item],
                                  round(voc[item] / quantity * 100, 1),
                                  item))
    
values, keys = list(voc.values()), list(voc.keys())
plt.pie(values, labels=keys, autopct='%.1f%%')  #так неочевидно процент выводится
plt.title('Винни-Пух и все, все, все')
plt.show()

Глава 6. То, ради чего это всё задумывалось

Тут будут таблички и графики с минимальными комментариями. 

Если есть желание понять субъект анализа, типа как эти все комиссии устроены и кто в них что делает, то лучше об этом почитать подробнее в любом разделе «Обучение» наблюдательский организаций или даже официальных порталов. А я опишу кратко:

УИК — участковая избирательная комиссия, обеспечивает выборы на местах непосредственно. Принимает всё что нужно для работы от ТИК, отчитывается туда же.

ТИК — территориальная избирательная комиссия, выполняет административно-хозяйственные функции, то есть, грубо говоря, материальная база и вопросы формирования, назначения/освобождения членов нижестоящих комиссий (разумеется через заявления).

Комиссия — коллегиальный орган, право голоса имеют все, вопросы решаются через голосование большинством. Кворум для открытия заседания в общем случае — больше половины.

Председатель — по сути спикер комиссии, должностное лицо
Секретарь — думаю, более-менее и так понятно

Поскольку у комиссий разного уровня сильно разные функции, то и обобщать их особого смысла я не вижу.

Для начала посмотрим, что мы имеем по Участковым избирательным комиссиям:

УИК, все

5ad83b57777eedb9065e0e1681aa4322.pngab50ae8ad36ee4debd7dc75d517f793f.png

По месту работы — это по сути работники бюджетных организаций. По месту жительства в большинстве случаев тоже, ну или близкие к. Возможно среди них есть и просто активные жители, но очень сомневаюсь, что таких там хоть сколько-то много. Думаю, где-то описаны схемы набора таковых, да и догадаться несложно

Остальные — это представители многочисленных организаций, о которых в основном никто никогда и не слышал. Обычно около-административные. (Личная оценка)

Как видно, средняя комиссия наполовину состоит из этих трёх категорий, а партии распределены более-менее ровно с предпочтением к самым крупным.

Теперь посмотрим, а что там в руководстве УИКов.

УИК, Руководство (Председатель, Зам.Председателя и Секретарь)

0d852ed94347aecee141085077604d50.png02d58b6b0884882afaf2791d94c35922.png

Ой, а что это у нас тут случилось? Я думаю, что комментарии тут излишни, а изменения очевидны. Ради интереса можно посмотреть, кто такие эти трудовые партии, союзы труда и партия «За женщин России». У-ух!

Теперь посмотрим, что там по Территориальным комиссиям.

ТИК, Все члены

421a22195c94eef652d55e5477b3db1a.pngfde18fdf85323e5cf34528499f64080c.png

Да тут прям почти полноценный плюрализм, мамочки родные!

А теперь руководство ТИК:

Руководство ТИК

c70bc45047c4f98877e4a896f9f9ec51.png6e4822a8aad2dcb7f091b1995d02c06e.png

…Ну, что тут сказать можно, картинка достаточно красноречива

Очень большой сегмент «Остальные», я бы посмотрел подробнее, кто у нас там.

voc, quantity = analysis2(level='тик',
                          position='председатель зам секретарь',
                          unite_minors=False)
print('****Всего {}****\n'.format(quantity))
for item in voc:
    print('{} or {}% : {}'.format(voc[item],
                                  round(voc[item] / quantity * 100, 1),
                                  item))

32 or 16.9% : собрание избирателей по месту работы
31 or 16.4% : собрание избирателей по месту жительства
29 or 15.3% : территориальная избирательная комиссия предыдущего состава
16 or 8.5% : Региональное отделение ВСЕРОССИЙСКОЙ ПОЛИТИЧЕСКОЙ ПАРТИИ «РОДИНА» в городе Санкт-Петербурге
16 or 8.5% : Санкт-Петербургское региональное отделение Всероссийской политической партии «ЕДИНАЯ РОССИЯ»
12 or 6.3% : представительный орган муниципального образования
11 or 5.8% : Региональное отделение в Санкт-Петербурге Политической партии «Российская экологическая партия «Зелёные»
6 or 3.2% : Санкт-Петербургское региональное отделение политической партии «ПАТРИОТЫ РОССИИ»
4 or 2.1% : Политическая партия «ПАТРИОТЫ РОССИИ»
3 or 1.6% : Региональная общественная организация поддержки и развития молодежного творчества «Гаудеамус»
3 or 1.6% : Санкт-Петербургское региональное отделение Политической партии ЛДПР — Либерально-демократической партии России
3 or 1.6% : ВСЕРОССИЙСКАЯ ПОЛИТИЧЕСКАЯ ПАРТИЯ «РОДИНА»
3 or 1.6% : Политическая партия «Российская экологическая партия »Зелёные»
2 or 1.1% : Межрегиональная общественная организация «Ассоциация ветеранов, инвалидов и пенсионеров»
2 or 1.1% : Региональное отделение в Санкт-Петербурге Всероссийской политической партии «ПАРТИЯ РОСТА»
2 or 1.1% : Региональная общественная организация поддержки и развития молодежного творчества «Гуадеамус»
2 or 1.1% : Региональное отделение в Санкт-Петербурге политической партии «НОВЫЕ ЛЮДИ»
1 or 0.5% : Межрегиональная общественная организация «Центр содействия реализации социальных инициатив «Живой Питер»
1 or 0.5% : Региональное отделение в городе Санкт-Петербурге Политической партии «Гражданская Платформа»
1 or 0.5% : Политическая партия «Российская экологическая партия »Зеленые»
1 or 0.5% : Санкт-Петербургская региональная общественная организация содействия детям сиротам «Радуга»
1 or 0.5% : САНКТ-ПЕТЕРБУРГСКОЕ ГОРОДСКОЕ ОТДЕЛЕНИЕ политической партии «КОММУНИСТИЧЕСКАЯ ПАРТИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ»
1 or 0.5% : Санкт-Петербургская Региональная Общественная Организация инвалидов «Радонежец»
1 or 0.5% : Местное отделение Санкт-Петербургской общественной организации ветеранов (пенсионеров, инвалидов) войны, труда, Вооруженных сил и правоохранительных органов «Кировское» на территории муниципального округа «Дачное»
1 or 0.5% : Региональная общественная организация инвалидов «Радонежец»
1 or 0.5% : Санкт-Петербургская Общественная Организация в поддержку молодежи «МИР МОЛОДЕЖИ»
1 or 0.5% : Санкт-Петербургская общественная организация «Жители блокадного Ленинграда»
1 or 0.5% : Политическая партия СОЦИАЛЬНОЙ ЗАЩИТЫ
1 or 0.5% : Санкт-Петербургская ассоциация общественных объединений родителей детей-инвалидов «ГАООРДИ»

Я подчеркнул тех, что вошёл как «Остальные». И ещё заметил, что партия Зелёные вошла тоже туда, потому что для компьютера Е и Ë — разные символы. Надо будет учесть этот момент, хоть он принципиально ни на что и не влияет.

Конечно, я подтолкнул к мысли о том, что не так с этой системой. На самом деле не я, а данные, я их лишь обнажил.

Заключение

Мне очень понравилось, что спустя уже небольшое время я смог применить на практике то, чему научился. Я очень рад, если было хоть немного интересно это занудство читать, если статья открыла какое-то новое виденье, вдохновила, или повлияла ещё каким-то образом.

Открыт для любых дискуссий, пожеланий, советов, критики или чего там ещё.

© Habrahabr.ru