Как я делал парсинг расписания

от автора

Привет Habr!

Дорогой читатель! Если тебя интересует парсинг html и разработка под Android, то эта статья для тебя. Надеюсь ты найдешь в ней много интересного и полезного. В ней я хочу поделиться своим опытом в данной сфере.

Описание проблемы

Немного обо мне. Я студент третьего курса ИТА ЮФУ. Также как и всем студентам, мне нужно каждый день смотреть расписание занятий. Причём мне нужно знать расписание не только на следующий день, но и на одну-две недели вперёд.

Казалось бы, почему нельзя просто сохранить расписание и пользоваться им? К сожалению, есть ряд причин, которые этому препятствуют, а именно:

  • Расписание на одну неделю может сильно отличаться от расписания на другую
  • Расписание не постоянно и может меняться

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

  • Отображение: номера текущей недели, даты, дня недели и расписания на этот день
  • Возможность перелистывать расписание кнопками «назад» и «далее»
  • При отсутствии интернета показывать последнюю загруженную оффлайн версию расписания

Приступим к экзекуции

Итак, закатав рукава, я приступил к работе. Начать необходимо с малого. А именно — с редактирования файла манифеста. Стоит помнить, что наше приложение будет работать с интернетом и нам очень важно получить соответствующее разрешение:

Файл манифестра

Идём в manifests->AndroidManifest.xml. Добавляем permission. В итоге получается что-то типа этого:

<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android"     package="com.example.myapplication">      <uses-permission android:name="android.permission.INTERNET" />      ...  </manifest> 

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

Разметка Activity

<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"     xmlns:app="http://schemas.android.com/apk/res-auto"     xmlns:tools="http://schemas.android.com/tools"     android:layout_width="match_parent"     android:layout_height="match_parent"     tools:context=".MainActivity">      <TextView         android:id="@+id/WeekNumber"         android:layout_width="match_parent"         android:layout_height="wrap_content"         android:text="Номер недели"         app:layout_constraintTop_toTopOf="parent" />      <EditText         android:id="@+id/timetable"         android:layout_width="match_parent"         android:layout_height="match_parent"         android:layout_marginBottom="100dp"         android:ems="10"         android:inputType="textMultiLine"         android:text="Расписание"         app:layout_constraintTop_toBottomOf="@+id/WeekNumber"         tools:layout_editor_absoluteX="0dp" />      <Button         android:id="@+id/next"         android:layout_width="match_parent"         android:layout_height="wrap_content"         android:text="Далее"         app:layout_constraintBottom_toBottomOf="parent"         tools:layout_editor_absoluteX="0dp"></Button>      <Button         android:id="@+id/down"         android:layout_width="match_parent"         android:layout_height="wrap_content"         android:text="Назад"         app:layout_constraintBottom_toTopOf="@+id/next"         tools:layout_editor_absoluteX="0dp"></Button>  </androidx.constraintlayout.widget.ConstraintLayout> 

Теперь начнём писать парсинг. Тут нам поможет замечательный парсер с открытым исходным кодом Jsoup. Вариант с использованием WebView я сразу отмёл, так как я посчитал этот способ крайне неудобным. К тому же мне не очень хотелось использовать лишний виджет, без которого легко можно обойтись.

Подключение Jsoup

Добавляем зависимость в build.gradle:

implementation 'org.jsoup:jsoup:1.11.1' 

Не стоит забывать, что работа с web для Android — это тяжёлая задача. Чтобы приложение не висло, нужно чтобы работа с web располагалась вне потока UI. Поэтому будем использовать класс AsyncTask. В него мы и заложим основной функционал, а потом просто передадим данные в UI-поток.

Для тех, кто не знаком с AsyncTask, хочу сказать, что данный класс должен располагаться внутри класса вашего activity. Сам класс приведён ниже.

Код Activity с классом AsyncTask

package com.example.myapplication;  import androidx.appcompat.app.AppCompatActivity;  import android.os.AsyncTask; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast;  import org.jsoup.Jsoup; import org.jsoup.nodes.Document;  import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date;  public class MainActivity extends AppCompatActivity { public boolean offline; public String request; public String WeekNumber; public int count; //Виджеты     public TextView weeknumber;     public EditText timetable;     public Button next;     public Button down;          @Override     protected void onCreate(Bundle savedInstanceState) {         super.onCreate(savedInstanceState);         setContentView(R.layout.activity_main);         offline = false;// Работаем а онлайн режиме         count = 0;//Описание этой переменной будет дано ниже         weeknumber = findViewById(R.id.WeekNumber);         timetable = findViewById(R.id.timetable);         next = findViewById(R.id.next);         down = findViewById(R.id.down);         getting AsyncTask = new getting();        AsyncTask.execute();     }     class getting extends AsyncTask<String, String, String> {         @Override         protected void onPreExecute() {             super.onPreExecute();             //В этом методе код перед началом выполнения фонового процесса         }          @Override         protected String doInBackground(String... params) {             /*Этот метод выполняется в фоне             Тут мы обращаемся к сайту и вытаскиваем его html код             */             String answer = "";// В эту переменную мы будем класть ответ от сайта. Пока что она пустая             String url = "https://ictis.sfedu.ru/rasp/HTML/82.htm";// Адрес сайта с расписанием             Document document = null;             try {                 document = Jsoup.connect(url).get();// Коннектимся и получаем страницу                 answer = document.body().html();// Получаем код из тега body страницы             } catch (IOException e) {                 // Если произошла ошибка, значит вероятнее всего, отсутствует соединение с интернетом                 // Загружаем в переменную answer оффлайн версию из txt файла                 try {                     BufferedReader read = new BufferedReader(new InputStreamReader(openFileInput("timetable.txt")));                     String str = "";                     while ((str = read.readLine())!=null){                         answer +=str;                     }                     read.close();                     offline = true;//работаем в оффлайн режиме                 } catch (FileNotFoundException ex) {                     //Если файла с сохранённым расписанием нет, то записываем в answer пустоту                     answer = "";                     ex.printStackTrace();                 } catch (IOException ex) {                     ex.printStackTrace();                 }             }             //Убираю лишний текст из html             //Заменяю html код отсутствия пары на запись nolessone             //Убираю двойные пробелы             answer = answer.replace("Пары","")                     .replace("Время","")                     .replace("<br>","br")                     .replace("<font face=\"Arial\" size=\"1\"></font><p align=\"CENTER\"><font face=\"Arial\" size=\"1\"></font>","nolessone")//Заменяем "сигнатуру" пустой пары на nolessone                      .replace("  ","");             return Jsoup.parse(answer).text();//Вытаскиваем текст из кода в переменной answer и передаём в UI-поток         }          @Override         protected void onPostExecute(String result) {             super.onPostExecute(result);             /*Этот метод выполняется при завершении фонового кода             Сюда возвращаются данные из потока              */             request = "";//Начинаем формировать ответ             String temp = result.toString();//Делаём временную строку             // Записываем содержимое, в файл timetable.txt, в котором будем хранить оффлайн версию расписания             try {                 BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(openFileOutput("timetable.txt",MODE_PRIVATE)));                 writer.write(temp);                 writer.close();             } catch (FileNotFoundException e) {                 e.printStackTrace();             } catch (IOException e) {                 e.printStackTrace();             }              boolean start = false;             for(String str:temp.split("Неделя: ")){                 if(start) {                     //В начало каждой недели добавляем слово newweek и добавляем в request                     request += "newweek"+str.split("Расписание")[0] + "\n";                 }                 start = true;             }             // Добавляем к дням недели приставку newday, для дальнейшей разбивки строки             request = request.replace("Пнд","newdayПнд").replace("Втр","newdayВтр")                     .replace("Срд","newdayСрд").replace("Чтв","newdayЧтв")                     .replace("Птн","newdayПтн").replace("Сбт","newdayСбт");             /*Получаем дату дня             Если count = 0, то вернётся дата сегодняшнего дня             Если count = -1, то вчерашнего             Если count = 1, то завтрашнего и т.д              */             Calendar calendar = Calendar.getInstance();             calendar.add(Calendar.DAY_OF_YEAR,count);             Date dayformat = calendar.getTime();             SimpleDateFormat format = new SimpleDateFormat("dd MMMM");             //Выводим результат             timetable.setText(request);             if(offline && !temp.equals("")){                 //Уведомляем пользователя, что загружена оффлайн версия расписания                 Toast.makeText(getApplicationContext(),"Загружена оффлайн версия расписания!",Toast.LENGTH_LONG).show();             }             //Если наш ответ равен пустоте, значит произошла ошибка                 if(temp.equals("")){                     Toast.makeText(getApplicationContext(),"Произошла ошибка!",Toast.LENGTH_LONG).show();                 }          }     } } 

В итоге мы получим данные вот в таком виде:

Расписание на одну неделю

Разберём методы, которые мы использовали:

Создаём элемент типа Document

Document document = null; 

Получаем страницу

document = Jsoup.connect(url).get(); 

Теперь достаём содержимое тега body

answer = document.body().html(); 

В Jsoup также можно получить содержимое других основных тегов. Например можно получить заголовок страницы, используя метод title() и т.д. Метод html() Возвращает html код, а text() — обычный текст без html тегов.

Получив html код, можно преобразовать его в обычный текст, убрав все теги. Это можно сделать с помощью parse(htmlcode).text():

return Jsoup.parse(answer).text(); 

Хотелось бы поделиться ещё полезными методами Jsoup, которые не были использованы:

Element link = document.select("tag");//выберет элемент с тегом String url = link.attr("attribute"); // выдаст атрибут тега 

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

Доведение до ума

Итак, что мы имеем? Мы научились приводить html код страницы в строку, которую можно легко распарсить. Это легко можно сделать используя строковые методы .split() и .replace().

В общем случае алгоритм будет выглядеть так.

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

Полный код Activity

package com.example.myapplication;  import androidx.appcompat.app.AppCompatActivity;  import android.os.AsyncTask; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast;  import org.jsoup.Jsoup; import org.jsoup.nodes.Document;  import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date;  public class MainActivity extends AppCompatActivity { public boolean offline; public String request; public String WeekNumber; public int count; //Виджеты     public TextView weeknumber;     public EditText timetable;     public Button next;     public Button down;     public void formating(String day){         String DayTimetable = "";         String[] weeks = request.split("newweek");         String DayData = "";//Тут будет день недели и дата         /*         Переменные ниже будут содержать информацию о каждой паре         Всего в день может быть семь пар         массив less содержит названия каждого предмета         массив tich содержит ФИО преподавателя каждого предмета         массив aud содержит аудиторию, в которой будет проходить предмет (например Д-230)          */         String less[] = new String[7];         String tich[] = new String[7];         String aud[] = new String[7];         for(String thisweek:weeks){//пробегаемся по неделям             if(thisweek.indexOf(day) != -1) {//Если нужный нам день найден в этой неделе то...                 WeekNumber = thisweek.split(" ")[0];//Достаём номер недели                 for(String thisday:thisweek.split("newday")){//Теперь пробегаемся по дням этой недели                     if(thisday.indexOf(day) != -1) {//Если данный день совпадает с нужным нам днём, то...                         //Делаем так, тобы перед каждой парой была приставка newless                         //пара всегда начинается с соответствующей приставки пр. лек. лаб. и пр.                         thisday = thisday.replace("no","newless")                                 .replace("пр.","newlessпр.")                                 .replace("лек.","newlessлек.")                                 .replace("лаб.","newlessлаб.");                         int i = 0;                         for(String thislessone:thisday.split("newless")) {//Теперь пробегаемся по предметам данного дня                             if(i != 0) {                                 String[] ScienceInformation = thislessone.replace("br ","").split("br");                                 String science = ScienceInformation[0];                                 science = science.replace("lessone","Окно");                                 String ticher = "";                                 if(ScienceInformation.length > 1)                                     ticher = ScienceInformation[1];                                 DayTimetable += i + "-ая: Предмет - " + science+"\n"+ticher+"\n\n";                                 ticher = ticher.replace("А-","@А-").replace("А-","@Б-")                                         .replace("В-","@В-").replace("Г-","@Г-")                                         .replace("Д-","@Д-").replace("Е-","@Е-")                                         .replace("И-","@И-").replace("K-","@K-");                                 String Auditory;                                 if(ticher.split("@").length == 2){                                     Auditory = "Аудитория: "+ticher.split("@")[1];                                 }else                                     Auditory = "Аудитория: Дома";//На случай если пары нет                                 ticher = ticher.split("@")[0];                                 if(ticher.length() >0){                                     ticher = "Преподаватель: "+ticher;                                 }else{                                     ticher = "Самоподготовка";                                 }                                 if(i==1){                                     less[i-1] = "1-ая (8:00-9:35) "+science;                                 }                                 if(i==2){                                     less[i-1] = "2-ая (9:50-11:25) "+science;                                 }                                 if(i==3){                                     less[i-1] = "3-ая (11:55-13:30) "+science;                                 }                                 if(i==4){                                     less[i-1] = "4-ая (13:45-15:20) "+science;                                 }                                 if(i==5){                                     less[i-1] = "5-ая (15:50-17:25) "+science;                                 }                                 if(i==6){                                     less[i-1] = "6-ая (17:40-19:15) "+science;                                 }                                 if(i==7){                                     less[i-1] = "7-ая (19:30-21:05) "+science;                                 }                                 tich[i-1] = ticher;                                 aud[i-1] = Auditory;                             }else                                 DayData = thislessone;//При i=0 в thislessone будет дата текущего дня                             i++;                         }                     }                 }              }         }         timetable.setText(DayData);//Выводим дату         for(int i = 0; i <=6; i++){             timetable.setText(timetable.getText()+"\n"+less[i]+tich[i]+aud[i]);//Вывод пары, препода и аудитории каждой пары (от нулевой до шестой)         }             weeknumber.setText("Сейчас "+ WeekNumber + " неделя");//Выводим номер неддели     }     @Override     protected void onCreate(Bundle savedInstanceState) {         super.onCreate(savedInstanceState);         setContentView(R.layout.activity_main);         offline = false;// Работаем в онлайн режиме         count = 0;         weeknumber = findViewById(R.id.WeekNumber);         timetable = findViewById(R.id.timetable);         next = findViewById(R.id.next);         down = findViewById(R.id.down);         getting getting = new getting();         getting.execute();         //События для кнопок назад и вперёд         next.setOnClickListener(new View.OnClickListener() {             @Override             public void onClick(View v) {                 count++;                 Calendar calendar = Calendar.getInstance();                 calendar.add(Calendar.DAY_OF_YEAR,count);                 Date dayformat = calendar.getTime();                 SimpleDateFormat format = new SimpleDateFormat("dd MMMM");                 formating(format.format(dayformat));             }         });         down.setOnClickListener(new View.OnClickListener() {             @Override             public void onClick(View v) {                 count--;                 Calendar calendar = Calendar.getInstance();                 calendar.add(Calendar.DAY_OF_YEAR,count);                 Date dayformat = calendar.getTime();                 SimpleDateFormat format = new SimpleDateFormat("dd MMMM");                 formating(format.format(dayformat));             }         });     }     class getting extends AsyncTask<String, String, String> {         @Override         protected void onPreExecute() {             super.onPreExecute();             //В этом методе код перед началом выполнения фонового процесса             getSupportActionBar().setTitle("Загрузка...");         }          @Override         protected String doInBackground(String... params) {             /*Этот метод выполняется в фоне             Тут мы обращаемся к сайту и вытаскиваем его html код             */             String answer = "";// В эту переменную мы будем класть ответ от сайта. Пока что она пустая             String url = "https://ictis.sfedu.ru/rasp/HTML/82.htm";// Адрес сайта с расписанием             Document document = null;             try {                 document = Jsoup.connect(url).get();// Коннектимся и получаем страницу                 answer = document.body().html();// Получаем код из тега body страницы             } catch (IOException e) {                 // Если произошла ошибка, значит вероятнее всего, отсутствует соединение с интернетом                 // Загружаем в переменную answer офлайн версию из txt файла                 try {                     BufferedReader read = new BufferedReader(new InputStreamReader(openFileInput("timetable.txt")));                     String str = "";                     while ((str = read.readLine())!=null){                         answer +=str;                     }                     read.close();                     offline = true;//работаем в оффлайн режиме                 } catch (FileNotFoundException ex) {                     //Если файла с сохранённым расписанием нет, то записываем в answer пустоту                     answer = "";                     ex.printStackTrace();                 } catch (IOException ex) {                     ex.printStackTrace();                 }             }             //Убираю лишний текст из html             //Заменяю html код отсутствия пары на запись nolessone             //Убираю двойные пробелы             answer = answer.replace("Пары","")                     .replace("Время","")                     .replace("<br>","br")                     .replace("<font face=\"Arial\" size=\"1\"></font><p align=\"CENTER\"><font face=\"Arial\" size=\"1\"></font>","nolessone")                     .replace("  ","");             return Jsoup.parse(answer).text();//Вытаскиваем текст из кода в переменной answer и передаём в UI-поток         }          @Override         protected void onPostExecute(String result) {             super.onPostExecute(result);             /*Этот метод выполняется при завершении фонового кода             Сюда возвращаются данные из потока              */             request = "";//Начинаем формировать ответ             String temp = result.toString();//Делаём временную строку             // Записываем содержимое, в файл timetable.txt, в котором будем хранить оффлайн версию расписания             try {                 BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(openFileOutput("timetable.txt",MODE_PRIVATE)));                 writer.write(temp);                 writer.close();             } catch (FileNotFoundException e) {                 e.printStackTrace();             } catch (IOException e) {                 e.printStackTrace();             }              boolean start = false;             for(String str:temp.split("Неделя: ")){                 if(start) {                     //В начало каждой недели добавляем слово newweek и добавляем в request                     request += "newweek"+str.split("Расписание")[0] + "\n";                 }                 start = true;             }             // Добавляем к дням недели приставку newday, для дальнейшей разбивки строки             request = request.replace("Пнд","newdayПнд").replace("Втр","newdayВтр")                     .replace("Срд","newdayСрд").replace("Чтв","newdayЧтв")                     .replace("Птн","newdayПтн").replace("Сбт","newdayСбт");             /*Получаем дату дня             Если count = 0, то вернётся дата сегодняшнего дня             Если count = -1, то вчерашнего             Если count = 1, то завтрашнего и т.д              */             Calendar calendar = Calendar.getInstance();             calendar.add(Calendar.DAY_OF_YEAR,count);             Date dayformat = calendar.getTime();             SimpleDateFormat format = new SimpleDateFormat("dd MMMM");             //Вызываем функцию, которая будет заниматься представлением данных             formating(format.format(dayformat));             if(offline && !temp.equals("")){                 //Уведомляем пользователя, что загружена оффлайн версия расписания                 Toast.makeText(getApplicationContext(),"Загружена оффлайн версия расписания!",Toast.LENGTH_LONG).show();             }             //Если наш ответ равен пустоте, значит произошла ошибка                 if(temp.equals("")){                     Toast.makeText(getApplicationContext(),"Произошла ошибка!",Toast.LENGTH_LONG).show();                 }             getSupportActionBar().setTitle("Готово");          }     } }  

выборка расписания происходит в методе formating(). Подав на вход методу дату, мы получим расписание на данный день. Так мы легко можем реализовать код для кнопок «назад» и «далее»

Код кнопки «Далее»:

count++; Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DAY_OF_YEAR,count); Date dayformat = calendar.getTime(); SimpleDateFormat format = new SimpleDateFormat("dd MMMM"); formating(format.format(dayformat));// Вызываем formating 

С помощью Calendar мы получаем сегодняшнюю дату. С помощью метода add мы прибавляем к сегодняшней дате количество дней, записанных в count. Код кнопки «назад» будет аналогичен, только из count нужно будет убавлять значение.

Скриншот

Заключение

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

Скриншот приложения


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


Комментарии

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

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