Віддалене виконання системних команд за запитом через сокети на Python 3 або як я сайти скачував

Проект був написаний швидше в навчальних цілях (навчитися мережного програмування на Python), ніж у практичних. Таку ж роль несе і статися, адже зараз навряд чи хтось буде викачувати сайти, щоб прочитати пару статейок (за винятком деяких випадків, коли таке реально може стане в нагоді).

Не так давно якість мобільного інтернету в моєму місті стало поступово погіршуватися з-за зростаючої на мережі операторів навантаження і деякі сайти, що вимагають велику кількість сполук (залежні файли сторінки) стали завантажуватися ну ДУЖЕ повільно. Вечорами швидкість опускається на стільки, що деякі сайти можуть повністю завантажується в протягом декількох десятків секунд.

Є кілька способів вирішення цієї проблеми, але я вирішив обрати трохи незвичайний для нашого часу спосіб. Я вирішив завантажувати сайти. Звичайно, даних спосіб не підходить для великих сайтів, начебто Хабра, тут розумніше використовувати парсер, але можна завантажити і окремий хаб, список користувачів, або тільки свої публікації з допомогою HTTrack Website Copier, застосувавши фільтри. Наприклад, щоб завантажити хаб Python з Хабра потрібно застосувати фільтр "+habrahabr.ru/hub/python/*".

Цей спосіб можна використовувати ще в декількох цілях. Наприклад, щоб скачати сайт, або його частина, перед тим, як ви опинитеся без інтернет-з'єднання, наприклад, у літаку. Або для того, щоб скачати заблоковані на території РФ сайти, якщо завантажувати їх через Tor, що буде дуже повільно, або через комп'ютер в іншій країні, де дані сайт не заборонений, а потім передати його на комп'ютер, що знаходиться в РФ, що буде набагато швидше для багатосторінкових сайтів. Таким чином ми може завантажити, наприклад, xHamster Wikipedia через сервер в Німеччині чи Нідерландах і отримати сайт в стислому вигляді по SFTP, FTP, HTTP або іншим, зручним для вас, протоколу. Якщо, звичайно, місця вистачить, для такого великого сайту :)

Ну що, почнемо!? Додаток буде поступово ускладняться і в нього буде додаватися все нових функціонал, це дозволить зрозуміти що взагалі тут відбувається і як це все працює. Код я буду супроводжувати великим достатньою кількістю коментарів, щоб його міг зрозуміти навіть людина, що не знає Python, але повторно коментувати вже описані шматки коду і функції не буду, щоб не захаращувати код. І сервер і клієнт пишуться і перевіряються під Linux, але, теоретично, повинні працювати і під іншими платформами, якщо встановлені всі необхідні програми, а саме: httrack tar, а так само виставлений необхідний шлях в конфігураційному файлі, який ми створимо нижче. Якщо у вас з'являться проблеми з запусків під платформою, пишіть в коментарях

Для початку реалізуємо простий сервер який буде пересилати рядок клієнту.

# FILE: server.py
import socket

# Створюємо IPv4 сокет потокового типу
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Пов'язуємо сокет з адресою localhost і портом 65042
sock.bind(("localhost", 65042))
# Починаємо слухати
sock.listen(True)
# По мірі надходження
while True:
# При приєднанні клієнта створюємо дві змінні для управління з'єднання з клієнтом та адресу цього клієнта
conn, addr = sock.accept()
# Виводимо адресу цього клієнта
print('Connected by', addr)
# Читаємо переданих клієнтом дані, але не більше 1024 байт
data = conn.recv(1024)
# Відправляємо клієнту отриману від нього рядок
conn.sendall(data)

Тепер реалізуємо ще більш простий клієнт, який буде виводити прийняту (тобто відправлену їм же сервера) рядок.

# FILE: client.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Приєднуємося до сервера
sock.connect(("localhost", 65042))
sock.sendall(b"Hello, world")
# Читаємо дані від сервера, але не більше 1024 байт
data = sock.recv(1024)
# Закриваємо з'єднання
sock.close()
# Виводимо отримані дані
print(data.decode("utf-8"))

При виведенні ми використовували метод decode(original) щоб отримати з масиву байт рядка. Щоб розшифрувати масив байт потрібно вказати кодування, в нашому випадку це UTF-8.

Тепер потрібно ненадовго зупинитися і обдумати, яким чином ми будемо використовувати наш додаток, які команди будуть використовуватися і як взагалі буде виглядати спілкування між клієнтом і сервером.

Так як ми плануємо використовувати наш додаток зрідка, то з зручністю можна особливо не перейматися. Що ж повинно вміти робити наше додаток? В першу чергу, це завантажувати сайти. Добре, серверний додаток скачало наш сайт, що тепер? Адже Нам хочеться його подивитися, адже так? Для цього потрібно його передати з серверної машини на клієнтську, а так як кількість файлів дуже велике, а з часом встановлення з'єднання у нас великі проблеми, то непогано було ще й упакувати все це, бажано ще й гарненько стиснути. Ну і непогано було б мати можливість переглянути завантажені сайти, але про це трохи пізніше.

Команди, що передаються серверу, будуть мати наступний формат:
<command> [args]

Наприклад:
dl site.ru 0 gz
list
list during

Для початку трохи модифікуємо наш клієнт. Замінимо
sock.sendall(b"Hello, world")

на
sock.sendall(bytes(input(), encoding="utf-8"))

Тепер ми можемо передавати на сервер довільні команди, введені з клавіатури.

Перейдемо до сервера, тут все складніше.

Для початку створимо два файлу: httrack.py та config.py. Перший буде містити функції для управління HTTrack, другий — конфігурацію для клієнта і сервера (він буде спільним). При бажанні можете зробити конфігураційний файл для сервера і клієнта роздільним і використовувати не питоновский формат, а конфігураційний .ini, або щось подібне.

З другим файлом все просто і зрозуміло:
from import os path

host = 'localhost'
port = 65042
# Шлях для скачування сайтів. На даний момент - директорія <b>Sites</b> у домашньому каталозі. Змініть значення, якщо цей шлях вам не підходить, рекомендується вказати порожню або неіснуючу директорію.
sites_directory = path.expanduser("~") + "/Sites"

Перед тим як перейти до першого файлу, трохи розповім про функцію call із стандартної бібліотеки subprocess.
subprocess.call(args)

Функція виконує команду, передану у масиві args. Ця функція так само може приймати параметр cwd, який вказує каталог, в якому слід виконати команду з масиву args. Чекає завершення виконуваної команди (викликаної програми) і повертає код завершення.

Тепер напишемо нашу, поки єдину функцію управління HTTrack'ом, що дозволяє викачувати сайт в потрібну нам директорію:
# FILE: httrack.py
from subprocess import call
from import os makedirs

# Файл з конфігурацією в директорії з проектом
import config

def download(url):
# Позбавляємося від зазначеного протоколу (у рядку, зрозуміло), якщо він є.
if url.find("//"):
url = url[url.find("//")+2:]
# І від завершального слеша
if url[-1:] == '/':
url = url[:-1]
site = config.sites_directory + '/' + url
print("Downloading ", url, " started.")
# Створюємо папку, в яку будуть викачуватися всі сайти
makedirs(config.sites_directory, mode=0o755, exist_ok=True)
# Викликаємо HTTrack в потрібній нам директорії
call(["httrack", url], cwd=config.sites_directory)
print("Downloading is complete")

Змінимо server.py:

import socket
import threading

# Файли в директорії з проектом
import httrack
import config

def handle_commands(connection, command, params):
if command == "dl":
# Створення окремого треда (потоку, процесу, якщо хочете) для HTTrack'а
htt_thread = threading.Thread(target=httrack.download args=(params[0]))
# і його запуск
htt_thread.start()
connection.sendall(b'c Downloading has started')
else:
connection.sendall(b"Invalid request")

def args_analysis(connection, args):
# Розбиваємо рядок на масив на кожному прогалині. Наприклад "dl site.ru 0 gz" перетвориться в ["dl", "site.ua", "0", "gz"].
args = args.decode("utf-8").split()
# [1:] - зріз. В даному випадку, з першого до останнього елемента.
handle_commands(connection=connection, command=args[0], params=args[1:])

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((config.host, config.port))
sock.listen(True)
while True:
conn, addr = sock.accept()
print('Connected by ', addr)
data = conn.recv(1024)
args_analysis(connection=conn, args=data)

Тут, я думаю, все зрозуміло. Код трохи ускладнений функціями, які можна об'єднати і тим самим спростити код, але вони допоможуть нам у подальших змінах коду.

На даний момент можемо запустити спочатку server.py, client.py. У клієнтському додатку вводимо наступну команду:
dl http://verysimplesites.co.uk/

Приблизно через хвилину, залежно від вашого інтернет-з'єднання, серверна програма виведе "Downloading is complete" і у вас в домашньому каталозі з'явитися папка ", а в ній каталог verysimplesites.co.uk, в якому вже лежить скачаними сайт, який можна відкрити в браузері без інтернет-з'єднання.

Але нам цього мало, адже ми хочемо, щоб сайт можна було отримати у стисненому вигляді в архіві. Нехай тепер у команди dl буде три аргументи, а не один. Перший залишається таким же, це сайт, який потрібно завантажити. Другий — прапор, який показує, видаляти чи директорію по-завершення скачування. Третій — формат архіву, в який буде упакований сайт після скачування (до видалення, якщо воно вимагається).

Функція перевірки статусу процесу httrack у server.py:
def dl_status_checker(thread, connection):
if thread.isAlive:
connection.sendall(b'c Downloading has started')
else:
connection.sendall(b'c Downloading has FAILED')

Команда dl server.py:
if command == "dl":
# Прапор видалення директорії піднятий, якщо аргумент не дорівнює <b>"0"</b>
if params[1] == '0':
params[1] = False
else:
params[1] = True
# Якщо директорію видаляти ми не збираємося і формат архіву ми не передали, то упаковувати ми не будемо
if not params[1] and len(params) == 2:
params.append(None)
htt_thread = threading.Thread(target=httrack.download args=(params[0], params[1], params[2]))
htt_thread.start()
# Через 2 секунди перевірити, чи працює все ще HTTrack
dl_status = threading.Timer(2.0, dl_status_checker, args=(htt_thread, connection))
dl_status.start()

httrack.py:
from subprocess import call
from import os makedirs
from shutil import rmtree

import config

def завантажити url, remove, archive_format):
if url.find("//"):
url = url[url.find("//")+2:]
if url[-1:] == '/':
url = url[:-1]
site = config.sites_directory + '/' + url
print("Downloading ", url, " started.")
makedirs(config.sites_directory, mode=0o755, exist_ok=True)
call(["httrack", url], cwd=config.sites_directory)
print("Downloading is complete")
if archive_format:
if archive_format == "gz":
# Наприклад: <b>tar -czf /home/user/Sites/site.ru.tar.gz -C /home/user/Sites /home/user/Sites/site.ua</b>
call(["tar", "-czf", config.sites_directory + '/' + url + ".tar.gz",
"-C", config.sites_directory, url], cwd=config.sites_directory)
elif archive_format == "bz2":
call(["tar", "-cjf", config.sites_directory + '/' + url + ".tar.bz2",
"-C", config.sites_directory, url], cwd=config.sites_directory)
elif archive_format == "tar":
call(["tar", "-cf", config.sites_directory + '/' + url + ".tar",
"-C", config.sites_directory, url], cwd=config.sites_directory)
else:
print("Archive format is wrong")
else:
print("The site is not packed")
if remove:
rmtree(site)
print("Removing is complete")
else:
print("Removing is canceled")

З'явилося багато нового коду, але нічого складного в ньому немає, просто з'явилося кілька нових умов. З нових функцій з'явилася тільки rmtree, яка видаляє передану їй директорію, включаючи все, що знаходилося в останній.

Можна додати функцію handle_commands просту команду list без параметрів:
elif command == "list":
# Отримуємо список файлів і директорій в директорії з сайтами
file_list = listdir(config.sites_directory)
folder_list = []
archive_list = []
# Перевіряємо в циклі, чи є у нас директорії або архіви, що містять сайти
for file in file_list:
if path.isdir(config.sites_directory + '/' + file) and file != "hts-cache":
folder_list.append(file)
if path.isfile(config.sites_directory + '/' + file) and \
(file[-7:] == ".tar.gz" or file[-8:] == ".tar.bz2" or file[-5:] == ".tar"):
archive_list.append(file)
site_string = ""
folder_found = False
# Перевірка на порожнечу масиву
if folder_list:
site_string += "List of folders:\n" + "\n".join(folder_list)
folder_found = True
if archive_list:
if folder_found:
site_string += "\n================================================================================\n"
site_string += "List of archives:\n" + "\n".join(archive_list)
if site_string == "":
site_string = "Sites not found!"
connection.sendall(bytes(site_string, encoding="utf-8"))

Підключивши до початку необхідну бібліотеку:
from import os listdir, path

Ще непогано було б збільшити максимальний розмір приймаються клієнтом даних від сервера client.py:
data = sock.recv(65536)

Тепер перезапустим server.py і запустимо client.py. Для початку накажемо скачати сервера який-небудь сайт і упакувати його в tar.gz архів, після чого видалити:
dl http://verysimplesites.co.uk/ 1 gz

Після цього завантажити інший сайт, але упаковувати його не будемо, видаляти, зрозуміло, теж:
dl http://example.com/ 0

І приблизно через хвилину перевіримо список сайтів:
list

Якщо ви вводили ті ж команди, то повинні отримати наступний відповідь від сервера:
List of folders:
example.com
================================================================================
List of archives:
verysimplesites.co.uk.tar.gz


На сьогодні це, пожалую, усе. Це, звичайно, далеко все, що можна і потрібно реалізувати, але, тим не менш, дозволяє зрозуміти загальний принцип роботи подібних додатків. Якщо вам цікава ця тема, то пишіть про це в коментарях, якщо такі знайдуться, то я постараюся виділити час і написати статтю про трохи більшому функціоналі програми, в тому числі і перегляд статусу скачування сайту, а так само покажу декілька способів захисту від проникнення на сервер сторонніх, в тому числі і захист від проникнення в оболонку.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.