Расширяем возможности wget

    Здравствуйте.

    Вот я и стал полноправным пользователем хабра. Хочу поблагодарить человека, который дал мне инвайт за эту статью:

    Не так давно я начал постигать работу с Linux (в частности Ubuntu 8.10) и у меня появилась задача автоматического скачивания файлов по списку. «wget -i» конечно вещь хорошая, но мне хотелось большего, а именно:
    1. Скачивание списка ссылок из файла
    2. Скачивание одновременно нескольких файлов
    3. Перенос неудавшихся закачек в отдельный список для дальнейших повторных попыток.

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

    Результатом моей работы стал такой скрипт:

    Update: Благодаря совету zencd использовал команду wait для ожидания завершения закачек.
    Update 2: shulc указал на ошибку: заменил #!/binbash на #!/bin/sh. darkk подсказал о существовании mktemp для создания временных файлов.
    #!/bin/sh
    log_dir="${PWD}/log"
    list_dir="${PWD}/list"
    output_dir=${PWD}
    # download_list - файл ссылок для скачивания
    download_list="${list_dir}/download.lst"
    # В active_list записываются активные закачки
    active_list="${list_dir}/active.lst"
    # В done_list записываются скачанные ссылки
    done_list="${list_dir}/done.lst"
    # В error_list записываются неудавшиеся закачки
    error_list="${list_dir}/error.lst"
    # $timeout - время перед повторной попыткой скачивания неудавшейся закачки
    timeout=5

    # Перемещает строку $1 из файла $2 в файл $3, нужна для манипуляций со списками
    # move_line line source_file dest_file
    move_line()
    {
      tmp_file=`mktemp -t downloader.XX`
      echo $1 >> $3
      cat $2 | grep -v $1 > $tmp_file
      mv $tmp_file $2
    }

    # Функция скачивания, в $1 передается номер потока скачивания
    download_thread()
    {
      thread=$1
      # Цикл скачивания, пока файлы download.lst и error.lst не станут пустыми
      while [ -s $download_list ] || [ -s $error_list ]
      do  
        # Если download.lst пустой - переносим в него строку из error.lst
        if [ ! -s $download_list ]  
        then
          read url < $error_list
          move_line $url $error_list $download_list
          sleep $timeout
        fi
        read url < $download_list
        move_line $url $download_list $active_list
        echo "[Thread ${thread}]Starting download: $url"
        # Старт закачки
        wget -c -o "${log_dir}/wget_thread${thread}.log" -O "${output_dir}/$(basename "$url")" $url
        # Проверка кода завершения wget (Если 0 - закачка успешная)
        if [ $? -eq 0 ]
        then
          # Закачка файла завершилась удачно
          move_line $url $active_list $done_list
          echo "[Thread ${thread}]Download successful: $url"
        else
          # Ошибка закачки - перемещаем в файл с ошибочными ссылками
          move_line $url $active_list $error_list
          echo "[Thread ${thread}]Error download: $url"
        fi
      done
      return 0
    }

    # Завершает ранее запущенные процессы скрипта и закачки из active.lst
    stop_script()
    {
      # Убиваем все процессы этого скрипта кроме текущего
      kill -9 `ps ax | grep $0 | grep -v "grep" | awk '{print $1}' | grep -v $$`
      # Убиваем все процессы закачек из active.lst
      while [ -s $active_list ]
      do
        read url < $active_list
        move_line $url $active_list $download_list
        kill -9 `ps ax | grep $url | grep -v "grep" | awk '{print $1}'`
      done
    }

    case "$1" in
    "stop" )
      echo "Stoping downloader..."
      stop_script
      echo "Done..."
      ;;
    "start" )
      # Проверка наналичие файла со ссылками для скачивания
      if [ ! -e $download_list ];
      then
        echo "[Error] There is no ${list_dir}/download.lst file"
        exit
      fi
      echo "Starting downloader..."
      # На случай вторичного запуска скрипта останавливаем ранее запущенные процессы
      stop_script
      # Если не задано кол-во одновременных закачек в $2, устанавливаем 1 поток
      if [ -z $2 ]
      then
        threads=1
      else
        threads=$2
      fi
      # Запускаем в фоне закачки
      i=1
      while [ $i -le $threads ]
      do
        download_thread $i &
        downloader_pid="${downloader_pid} $!"
        sleep 1
        i=`expr $i + 1`
      done
      if [ ! -e $error_list ]; then touch $error_list; fi
      # Ждем окончания всех закачек
      wait $downloader_pid
      # Все скачали...
      echo "All completed"
      ;;
    * )
      echo "Usage:"
      echo "\t$0 start [number of threads]"
      echo "\t$0 stop"
      ;;
    esac

    return 0


    * This source code was highlighted with Source Code Highlighter.

    Для работы скрипта необходимо сделать его исполняемым и создать файл "./list/download.lst" со списком ссылок для скачивания.

    Запуск:
    sh downloader start [количество одновременных скачиваний]
    или, как правильно заметил Mezomish, так:
    ./downloader start [количество одновременных скачиваний]

    Параметр после 'start' необязательный (если его не указать — используется «1»).
    Т.е. `sh downloader start 2` запустит скрипт с одновременным скачиванием 2-х файлов.

    Остановка:
    sh downloader stop
    или
    ./downloader stop

    При завершении скрипта при помощи «Ctrl+C» закачки не завершаются, т.к. работают в фоне, поэтому необходимо выполнить вышеуказанную команду команду для остановки скачивания.

    Я решил не загромождать скрипт, но в принципе, не сложно реализуется работа со списками (show — вывод на экран, add — добавление закачки, wipe — очистка). А так он рабочий хоть и с минимальной функциональностью.

    Т.к. это мой первый bash-скрипт, то любые замечания/пожелания/рекомендации очень приветствуются.

    Дальше я вкратце опишу принципы работы скрипта, чтобы желающим легче было модифицировать его под свои нужды.

    В константах указаны:
    log_dir — папка с логами wget'a (по умолчанию "./log")
    list_dir — папка со списками download_list, active_list, done_list, error_list (по умолчанию "./list")
    output_dir — папка куда будут сохраняться скачиваемые файлы (по умолчанию ".")
    download_list список ссылок для скачивания
    active_list — список активных закачек
    done_list — список завершенных закачек
    error_list — список неудавшихся закачек
    timeout — время перед повторной попыткой скачивания неудавшейся закачки

    В начале работы скрипт останавливает ранее запущенные его копии, а также закачки из active_list (конечно если такие имеются) с переносом их в download_list. Это делается на случай повторного запуска скрипта до завершения скачивания ранее запущенным процессом. Дальше в цикле создается необходимое количество фоновых закачек. Каждый такой фоновый поток реализуется функцией download_thread(). Ее работа заключается в скачивании файлов из списка пока списки download_list и error_list не станут пустыми. Таким образом основная часть скрипта, проверяя эти файлы узнает закончилась ли скачка. Перед запуском wget'a ссылка переносится из файла download_list в файл active_list. После завершения работы wget'a ссылка переносится, либо в done_list (если код возврата был '0'), либо в error_list (если код возврата был не равен '0').
    После того как все скачано (списки download_list и error_list пусты) скрипт завершает свою работу.

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

    Комментарии 48

      0
      Тоже занимаюсь написанием удобного интерфейса для wget, пишу его под web на java.
        0
        о как раз ищу удобный web интерфейс для wget чтоб он понимал список ссылок и запоминал кукисы от рапидшары, как допишете выкладывайте на хабр!
          +1
          Выложу, конечно
          0
          Смотрели ли в сторону httrack/webhttrack?
            0
            Раньше не слышал, возьму на заметку, спасибо
          +2
          Думаю, команда wait с успехом заменит нижележайший код:

          # По наличию записей в active.lst error.lst проверяем идет ли закачка
          while [ -s $active_list ] || [ -s $error_list ]
          do
          sleep 1
          done
          # Все скачали...
          echo "All completed"
            +1
            Спасибо за подсказку, сейчас попробую ее использовать.
              +2
              Внес изменения в скрипт по Вашему совету. Вместо:
              while [ $i -le $threads ]
              do
                  download_thread $i &
                  sleep 1
                  i=`expr $i + 1`
              done
              if [ ! -e $error_list ]; then touch $error_list; fi
              # По наличию записей в active.lst error.lst проверяем идет ли закачка
              while [ -s $active_list ] || [ -s $error_list ]
              do
                  sleep 1
              done

              Вот этот код:
              while [ $i -le $threads ]
              do
                  download_thread $i &
                  downloader_pid="${downloader_pid} $!"
                  sleep 1
                  i=`expr $i + 1`
              done
              if [ ! -e $error_list ]; then touch $error_list; fi
              # Ждем окончания всех закачек
              wait $downloader_pid
              +5
              А почему бы не использовать aria2c? Можно будет убрать threads.
                +1
                Да, есть такая замечательная утилита — aria2c (поддерживает скачивание нескольких файлов одновремено, скачивание по списку, + еще скачивание сегментами одного файла). Правда о ее существовании я узнал только после начала работы над скриптом. Поэтому, чтобы оправдать проделанную мной работу, скажу: wget входит практически во все дистрибутивы *nix (можно сказать что это стандарт). Так что для тех у кого нет возможности/желания устанавливать дополнительные программы я написал этот скрипт =)
                  +1
                  Ну вообще то не во все, к примеру в FreeBSD по дефолту нет wget, придётся ставить из портов.
                    0
                    есть fetch.
                      +1
                      Не будем сравнивать fetch и wget, у них абсолютно разный функционал по возможностям, fetch'У очень далеко до wget'А
                      +1
                      Поэтому и написал: «практически». Хотя, если откровенно, то не знал что wget не входит в FreeBSD, думал, что он входит во все популярные дистрибутивы. Теперь думаю что это относится, скорее, к потомкам System V.
                        0
                        Формат инит-файлов никак не связан с наличием wget в дистрибутиве.
                          +1
                          Насколько я помню: System V и BSD отличаются не только форматом инит-файлов (с этой темой не очень знаком, поэтому спорить не буду).
                          Но мое упоминание здесь system V в отношении wget было не очень уместно, потому что linux дистрибутивы содержат особенности реализации разных веток unix (System V и BSD в том числе). Поэтому наличие/отсутствие wget можно отнести только к особенностям конкретного дистрибутива.
                          0
                          Предпологаю, что это связано с GNU.
                    +2
                    Ух ты, еще один полезный скрипт в копилку :)
                    Тоже хочу поблагодарить того человека, который пригласил полезного и интересного человека на хабр :)
                      +5
                      Ох нифига ж себе первый bash-скрипт! Если бы у всех первые скрипты были такими, думаю, вендекапца точно не избежать было бы :)
                        +3
                        Можно вместо wget использовать axel — еще и в несколько потоков грузить умеет «из коробки»
                          +1
                          в заголовке #!/bin/bash, а запускать советуете через sh.
                          а не проще сделать примерно так:

                          l=`wc -l $download_list`
                          la=`echo "$l/$n" | bc`
                          split -l $la download_list down_split
                          for $i in `ls down_split*`; do
                          wget -i $i &
                          done
                          wait
                            0
                            ошибочка:

                            l=`wc -l $download_list |awk '{print $1}'`
                            la=`echo "$l/$n" | bc`
                            echo $la
                            split -l $la $download_list down_split
                            for i in `ls down_split*`; do
                                    wget -p -i $i &
                            done
                            wait
                            rm down_split*
                            
                              +1
                              /bin/bash исправил на /bin/sh
                              А такой способ деления списка — тоже вариант. Просто я хотел чтобы список можно было пополнять и во время закачек. Это до конца не реализовано (если качается в несколько потоков, все кроме одного завершились, то при добавлении в список n ссылок они будут качаться одним потоком). Можно немного изменить скрипт чтобы потоки не завершались, а раз в n минут проверяли опять список. Тогда можно использовать его, например, на сервере: просто скидывать сслыки в файл (echo ... >> download.lst)и они сами будут скачиваться.
                                +1
                                А вы уверены, что у вас башизмов в коде нет, чтоб смело на /bin/sh исправлять? :-)
                                  +1
                                  Честно — не уверен. Если совсем откровенно, то еще не разобрался в их отличиях :) Не судите строго, повторюсь — это мой первый опыт. Просто тестил и использовал при помощи `sh downloader ...` и все работало.
                                    +1
                                    Я критику с одной целью пишу — знания распространить. :-)
                                    Вот теперь вы знаете, что bash умеет больше, чем обычный sh и между ними есть отличия. В отличие от большинства Linux-ов, на FreeBSD, например, sh != bash.
                            +3
                            >Ух ты, еще один полезный скрипт в копилку :)
                            а можно глянуть что в копилке?
                              +1
                              используйте mktemp для создания временных файлов.
                                +1
                                Спасибо, добавил в скрипт использование mktemp.
                                Плюсы:
                                — можно отказаться от переменной tmp_dir.
                                — создается случайное имя (вместо «XX» — буквы/цифры), что исключает возможности перезаписи одного временного файла при одновременном вхождении в функцию move_line() в разных потоках.
                                  +1
                                  — никто не сотрёт ваш /home/luser/.ssh/id_rsa симлинком из /tmp/staticfilename -> /home/…
                                    +1
                                    Не совсем понял о чем речь. Я имел ввиду, что ранее использованный код:
                                    1. tmp_file="${tmp_dir}/downloader.tmp"
                                    2. echo $1 >> $3
                                    3. cat $2 | grep -v $1 > $tmp_file
                                    4. mv $tmp_file $2

                                    дает вероятность (хоть и маленькую), что в двух потоках одновременно выполнится строка №3 (т.е. временный файл одного потока перезапишется данными другого потока).
                                      0
                                      Плюс к тому, возможно перед стартом программы существует "${tmp_dir}/downloader.tmp", который является символьной ссылкой на файл, принадлежащий пользователю.
                                      Таким образом, можно уничтожить файл пользователя, т.к. tmp_dir == /tmp а туда право на запись имеют все.

                                      Поэтому надо использовать mktemp, который создаст НОВЫЙ файл и, возможно, использовать set -o noclobber (но второе — это уже для по настоящему параноидально написанных скриптов)
                                        +1
                                        Данная атака имеет какое-то правильное название, но я его точно не помню. Ключевой момент, что файл в /tmp может быть символьной ссылкой и его может создать злоумышленник.

                                        Вообще, при написании скриптов никогда не стоит забывать, что UNIX — многопользовательская система, поэтому в kill стоит еще и UID проверять, а также по-хорошему стоит отправлять сначала TERM и только через некоторое время KILL.
                                        :-)
                                  0
                                  А у меня есть свой велосипед на баше для этого дела, забросил ввиду перехода на emacs-wget
                                    0
                                    Автору большое спасибо, выполнил хорошую автоматизацию отличной программы
                                      +1
                                      Вам спасибо за отзыв и остальным за конструктивную критику. Все это добавляет энтузиазма для работы над чем-нибудь полезным для общества ;)
                                    +1
                                    >Для работы скрипта необходимо сделать его исполняемым и создать файл "./list/download.lst" со списком ссылок для скачивания.

                                    Запуск:
                                    sh downloader start [количество одновременных скачиваний]


                                    Небольшое замечание:

                                    Если мы собираемся запускать скрипт именно так (передавая как параметр sh), то исполняемым его делать вовсе необязательно.
                                    Исполняемым его нужно делать для того, чтобы иметь возможность запустить непосредственно его самого:

                                    $ ./downloader [number of downloads]
                                      0
                                      Согласен. Написал в статье.
                                      +1
                                      я дописал еще такую штучку

                                      wget -c -o "${log_dir}/wget_thread${thread}.log" -O "${output_dir}/$(basename "$url")" $url 2>&1 | sed -u 's/.*\ \([0-9]\+%\)\ \+\([0-9.]\+\ [KMB\/s]\+\)$/\1\n# Downloading \2/' | zenity --progress --width=500 --title=«Downloading File: $(basename „$url“)»

                                      Мне удобно, когда в GNOME вылетает окошко с прогресс-баром
                                        +1
                                        как только мои shell скрипты переваливают за 20 строк, я бросаю шелл и перехожу на более удобный инструмент.

                                        в частности, ваш скрипт подвершен упомянутому race condition, т.к. потоки никак не синхронизируются и могут запросто перетереть и файлы списков и сами скачиваемые файлы.

                                        в образовательных целях шелл знать не помешает, но в работе нужны другие инструменты.
                                        если в шелл скрипте более 1 раза встретились grep и awk — скрипт пора переписывать.
                                          0
                                          Да, скорее делал это в самообразовательных целях, и потому что встала задача такая. Можно было использовать тот же aria2c…
                                          0
                                          mktemp явно ждёт ещё одного X в 20 строчке, покрайней мере mktemp (GNU coreutils) 7.4 точно не работает без.
                                            +1
                                            если не прибавить к параметрам запуска wget
                                            --user-agent=«какой вам больше нравится»

                                            многие-многие сайты будут футболить запросы потому что банлисты
                                              0
                                              Спасибо за совет
                                              0
                                              И вот это будет в тему

                                              --wait=секунды --random-wait

                                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                              Самое читаемое