Dobré odpoledne. Jsou to 2 roky, co byla napsána. poslední článek o analýze Habra a některé body se změnily.
Když jsem chtěl mít kopii Habra, rozhodl jsem se napsat parser, který by uložil veškerý obsah autorů do databáze. Jak se to stalo a na jaké chyby jsem narazil - si můžete přečíst pod střihem.
První verze analyzátoru. Jedno vlákno, mnoho problémů
Pro začátek jsem se rozhodl vytvořit prototyp skriptu, ve kterém by byl článek analyzován a umístěn do databáze ihned po stažení. Bez přemýšlení jsem použil sqlite3, protože. bylo to méně náročné na práci: není potřeba mít lokální server, vytvořený-vypadal-vymazaný a podobně.
one_thread.py
from bs4 import BeautifulSoup
import sqlite3
import requests
from datetime import datetime
def main(min, max):
conn = sqlite3.connect('habr.db')
c = conn.cursor()
c.execute('PRAGMA encoding = "UTF-8"')
c.execute("CREATE TABLE IF NOT EXISTS habr(id INT, author VARCHAR(255), title VARCHAR(255), content TEXT, tags TEXT)")
start_time = datetime.now()
c.execute("begin")
for i in range(min, max):
url = "https://m.habr.com/post/{}".format(i)
try:
r = requests.get(url)
except:
with open("req_errors.txt") as file:
file.write(i)
continue
if(r.status_code != 200):
print("{} - {}".format(i, r.status_code))
continue
html_doc = r.text
soup = BeautifulSoup(html_doc, 'html.parser')
try:
author = soup.find(class_="tm-user-info__username").get_text()
content = soup.find(id="post-content-body")
content = str(content)
title = soup.find(class_="tm-article-title__text").get_text()
tags = soup.find(class_="tm-article__tags").get_text()
tags = tags[5:]
except:
author,title,tags = "Error", "Error {}".format(r.status_code), "Error"
content = "При парсинге этой странице произошла ошибка."
c.execute('INSERT INTO habr VALUES (?, ?, ?, ?, ?)', (i, author, title, content, tags))
print(i)
c.execute("commit")
print(datetime.now() - start_time)
main(1, 490406)
Vše je klasické - používáme Krásnou polévku, požadavky a rychlý prototyp je hotový. To je jen…
Stahování stránky je v jednom vlákně
Pokud přerušíte provádění skriptu, celá databáze nikam nepůjde. Potvrzení se totiž provádí až po všech analýzách.
Samozřejmě můžete provést změny v databázi po každém vložení, ale pak se výrazně prodlouží doba provádění skriptu.
Analýza prvních 100 000 článků mi zabrala 8 hodin.
Dále najdu článek uživatele kointegrované, který jsem si přečetl a našel jsem pár životních hacků, jak tento proces urychlit:
Použití multithreadingu občas urychlí stahování.
Nemůžete získat plnou verzi habr, ale jeho mobilní verzi.
Pokud například kointegrovaný článek v desktopové verzi váží 378 KB, tak v mobilní verzi je to již 126 KB.
Druhá verze. Mnoho vláken, dočasný zákaz od Habr
Když jsem prohledal internet na téma multithreading v pythonu, zvolil jsem nejjednodušší možnost s multiprocessing.dummy, všiml jsem si, že problémy se objevily spolu s multithreadingem.
SQLite3 nechce pracovat s více než jedním vláknem.
pevný check_same_thread=False, ale tato chyba není jediná, při pokusu o vložení do databáze se občas vyskytnou chyby, které jsem nedokázal vyřešit.
Rozhoduji se proto upustit od okamžitého vkládání článků přímo do databáze a s ohledem na kointegrované řešení se rozhoduji pro použití souborů, protože s vícevláknovým zápisem do souboru nejsou žádné problémy.
Habr začíná banovat za použití více než tří vláken.
Zvláště horlivé pokusy dostat se k Habrovi mohou skončit zákazem ip na několik hodin. Musíte tedy použít pouze 3 vlákna, ale to už je dobré, protože čas na iteraci více než 100 článků se zkrátí z 26 na 12 sekund.
Stojí za zmínku, že tato verze je poněkud nestabilní a stahování u velkého počtu článků pravidelně klesá.
async_v1.py
from bs4 import BeautifulSoup
import requests
import os, sys
import json
from multiprocessing.dummy import Pool as ThreadPool
from datetime import datetime
import logging
def worker(i):
currentFile = "files\{}.json".format(i)
if os.path.isfile(currentFile):
logging.info("{} - File exists".format(i))
return 1
url = "https://m.habr.com/post/{}".format(i)
try: r = requests.get(url)
except:
with open("req_errors.txt") as file:
file.write(i)
return 2
# Запись заблокированных запросов на сервер
if (r.status_code == 503):
with open("Error503.txt", "a") as write_file:
write_file.write(str(i) + "n")
logging.warning('{} / 503 Error'.format(i))
# Если поста не существует или он был скрыт
if (r.status_code != 200):
logging.info("{} / {} Code".format(i, r.status_code))
return r.status_code
html_doc = r.text
soup = BeautifulSoup(html_doc, 'html5lib')
try:
author = soup.find(class_="tm-user-info__username").get_text()
timestamp = soup.find(class_='tm-user-meta__date')
timestamp = timestamp['title']
content = soup.find(id="post-content-body")
content = str(content)
title = soup.find(class_="tm-article-title__text").get_text()
tags = soup.find(class_="tm-article__tags").get_text()
tags = tags[5:]
# Метка, что пост является переводом или туториалом.
tm_tag = soup.find(class_="tm-tags tm-tags_post").get_text()
rating = soup.find(class_="tm-votes-score").get_text()
except:
author = title = tags = timestamp = tm_tag = rating = "Error"
content = "При парсинге этой странице произошла ошибка."
logging.warning("Error parsing - {}".format(i))
with open("Errors.txt", "a") as write_file:
write_file.write(str(i) + "n")
# Записываем статью в json
try:
article = [i, timestamp, author, title, content, tm_tag, rating, tags]
with open(currentFile, "w") as write_file:
json.dump(article, write_file)
except:
print(i)
raise
if __name__ == '__main__':
if len(sys.argv) < 3:
print("Необходимы параметры min и max. Использование: async_v1.py 1 100")
sys.exit(1)
min = int(sys.argv[1])
max = int(sys.argv[2])
# Если потоков >3
# то хабр банит ipшник на время
pool = ThreadPool(3)
# Отсчет времени, запуск потоков
start_time = datetime.now()
results = pool.map(worker, range(min, max))
# После закрытия всех потоков печатаем время
pool.close()
pool.join()
print(datetime.now() - start_time)
Třetí verze. Finále
Při ladění druhé verze jsem zjistil, že Habr má najednou API, ke kterému přistupuje mobilní verze webu. Načítá se rychleji než mobilní verze, protože je to jen json, který ani není třeba analyzovat. Nakonec jsem se rozhodl svůj scénář znovu přepsat.
Takže po nalezení odkaz API, můžete jej začít analyzovat.
async_v2.py
import requests
import os, sys
import json
from multiprocessing.dummy import Pool as ThreadPool
from datetime import datetime
import logging
def worker(i):
currentFile = "files\{}.json".format(i)
if os.path.isfile(currentFile):
logging.info("{} - File exists".format(i))
return 1
url = "https://m.habr.com/kek/v1/articles/{}/?fl=ru%2Cen&hl=ru".format(i)
try:
r = requests.get(url)
if r.status_code == 503:
logging.critical("503 Error")
return 503
except:
with open("req_errors.txt") as file:
file.write(i)
return 2
data = json.loads(r.text)
if data['success']:
article = data['data']['article']
id = article['id']
is_tutorial = article['is_tutorial']
time_published = article['time_published']
comments_count = article['comments_count']
lang = article['lang']
tags_string = article['tags_string']
title = article['title']
content = article['text_html']
reading_count = article['reading_count']
author = article['author']['login']
score = article['voting']['score']
data = (id, is_tutorial, time_published, title, content, comments_count, lang, tags_string, reading_count, author, score)
with open(currentFile, "w") as write_file:
json.dump(data, write_file)
if __name__ == '__main__':
if len(sys.argv) < 3:
print("Необходимы параметры min и max. Использование: asyc.py 1 100")
sys.exit(1)
min = int(sys.argv[1])
max = int(sys.argv[2])
# Если потоков >3
# то хабр банит ipшник на время
pool = ThreadPool(3)
# Отсчет времени, запуск потоков
start_time = datetime.now()
results = pool.map(worker, range(min, max))
# После закрытия всех потоков печатаем время
pool.close()
pool.join()
print(datetime.now() - start_time)
Obsahuje pole týkající se jak článku samotného, tak autora, který jej napsal.
API.png
Nevypsal jsem celý json každého článku, ale uložil jsem pouze pole, která jsem potřeboval:
id
is_tutorial
time_published
titul
obsah
počet_komentářů
lang je jazyk, ve kterém je článek napsán. Zatím má jen en a ru.
tags_string – všechny tagy z příspěvku
počet_čtení
autor
skóre — hodnocení článku.
Pomocí API jsem tedy zkrátil dobu provádění skriptu na 8 sekund na 100 URL.
Poté, co jsme si stáhli potřebná data, musíme je zpracovat a vložit do databáze. Ani s tímto jsem neměl problém:
parser.py
import json
import sqlite3
import logging
from datetime import datetime
def parser(min, max):
conn = sqlite3.connect('habr.db')
c = conn.cursor()
c.execute('PRAGMA encoding = "UTF-8"')
c.execute('PRAGMA synchronous = 0') # Отключаем подтверждение записи, так скорость увеличивается в разы.
c.execute("CREATE TABLE IF NOT EXISTS articles(id INTEGER, time_published TEXT, author TEXT, title TEXT, content TEXT,
lang TEXT, comments_count INTEGER, reading_count INTEGER, score INTEGER, is_tutorial INTEGER, tags_string TEXT)")
try:
for i in range(min, max):
try:
filename = "files\{}.json".format(i)
f = open(filename)
data = json.load(f)
(id, is_tutorial, time_published, title, content, comments_count, lang,
tags_string, reading_count, author, score) = data
# Ради лучшей читаемости базы можно пренебречь читаемостью кода. Или нет?
# Если вам так кажется, можно просто заменить кортеж аргументом data. Решать вам.
c.execute('INSERT INTO articles VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (id, time_published, author,
title, content, lang,
comments_count, reading_count,
score, is_tutorial,
tags_string))
f.close()
except IOError:
logging.info('FileNotExists')
continue
finally:
conn.commit()
start_time = datetime.now()
parser(490000, 490918)
print(datetime.now() - start_time)
Statistika
Tradičně nakonec můžete z dat získat nějaké statistiky:
Z očekávaných 490 406 stažení bylo staženo pouze 228 512 článků. Ukazuje se, že více než polovina (261894) článků o Habrém byla skryta nebo smazána.
Celá databáze skládající se z téměř půl milionu článků váží 2.95 GB. V komprimované podobě - 495 MB.
Celkem je autory Habrého 37804 lidí. Připomínám, že tyto statistiky jsou pouze z živých příspěvků.
Nejproduktivnější autor na Habré - alizar - 8774 článků.