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

от автора

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

Интродукция

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

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

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

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

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

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

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

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

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

Экспорт был в файл с расширением 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(r))

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

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

urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)>

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

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 page
Voila, la HTML page

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

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

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

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

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

def text_cleanup(txt=text):   # Обрезаю таблицу     start = txt.index('ФИО')   #начало таблицы     txt = txt[start::]     start = txt.find('1')   #начало первой нужной строки таблицы     txt = txt[start - 4::]     end = txt.index('</table')   #конец таблицы     txt = txt[:end:]      #Удаляю следы html     txt = txt.replace('</tr>', '')     txt = txt.replace('<tr>', '...')   #строки     txt = txt.replace('<td>', ',,,')   #столбцы     txt = txt.replace('</td>', '')     txt = txt.replace('<nobr>', '')     txt = txt.replace('</nobr>', '')     txt = txt.replace('<br>', '')     txt = txt.replace('</br>', '')     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

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

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

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

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

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

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

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

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

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

А вот и я там
А вот и я там

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

Я хоть и продвинутый, но обычный юзер, поэтому когда вся эта штука сработала — я ощутил себя каким-то, блин, Нео. Вообще то, что я написал программу, которая сама в интернете ковыряется — это крайне странно. Ни разу не выходил в интернет не через браузер. Вот уж был действительно 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 помимо словаря сколько всего строчек обработано. Для учёта и чтоб сразу проценты можно было высчитывать, хотя не знаю, насколько это целесообразно. Но если что — легко переделывается.

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

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. То, ради чего это всё задумывалось

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

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

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

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

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

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

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

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


УИК, все

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

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

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

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


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

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

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


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

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

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


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

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


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

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% : Санкт-Петербургская ассоциация общественных объединений родителей детей-инвалидов “ГАООРДИ»

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

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

Заключение

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

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


ссылка на оригинал статьи https://habr.com/ru/post/671832/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *