До списку уроків
8A · Урок 12 з 16

Пакетна конвертація скриптом і manifest.json

виконано0%
Крок 1

Що робимо сьогодні

Модуль I · IT Skills (Hardware & Multimedia) · тема 6, урок 2 з 2 · 120 хвилин

Мета. Навчитися обробляти цілу теку файлів однією командою. Таку роботу називають пакетною обробкою. І записувати результат у файл, який читає програма, а не тільки людина. Учора ти визначав тип десяти файлів руками. Сьогодні це робить один скрипт — і робить однаково щоразу.

Після уроку ти вмієш

Слова case, set -e і JSON поки нічого не означають — розберемо їх по черзі.

Куди все їде

Конвеєр уроку: два скрипти, один звіт media/lesson-06/ 10 файлів 3 з них брешуть розширенням ? ./detect.sh file --mime-type перейменовує хибні розширення detect.tsv ім'я · MIME · нове ім'я по рядку на файл ./convert.sh case: image / audio / text — три гілки твій процесор out/ — 10 сконвертованих файлів лишається у тебе manifest.json — 10 об'єктів їде на сервер на сервер їде тільки текст
Уся конвертація йде на твоєму комп'ютері. По FTP у кабінет підуть лише чотири легкі текстові файли: detect.sh, convert.sh, manifest.json і index.html.
Тека зветься lesson-06, хоч урок дванадцятий Теки практики названі за темою, а не за номером уроку. Тема 6 — це уроки 11 і 12, тому обидва працюють в одній теці media/lesson-06/ у себе і в ~/www/lesson-06/ на сервері. Не шукай lesson-12.

Хід уроку

ЕтапХвЩо робимо
Перевірка ДЗ і розминка10Очікувані сигнатури проти реальних
Від ручного списку до скрипта10Цикл, підстановка імен, case
Практика 1: detect.sh25Пишемо й запускаємо у своєму терміналі
Перерва10
Практика 2: convert.sh30Три гілки, ідемпотентність, два запуски
Практика 3: маніфест і сторінка15manifest.json, таблиця через fetch, FTP
check 06 і виправлення10Доводимо автоперевірку до зеленого
Підсумок і ДЗ10Втратне й невтратне, домашнє завдання
Разом120
Чесно про автоперевірку Чекер не запускає конвертацію. На сервері немає ані ImageMagick, ані ffmpeg — і це навмисно: важке рахує твій комп'ютер. Тому check 06 робить статичний розбір: перевіряє синтаксис скриптів через bash -n, шукає зашиті абсолютні шляхи й маркер ідемпотентності, а далі звіряє manifest.json сам із собою. Що скрипт справді працює, ти доводиш очно: вчитель на твоїй машині видаляє теку out/ і запускає ./convert.sh при тобі — двічі.
Крок 2

Одна команда замість сотні кліків

Скрипт — це файл, у якому записані команди. Запускаєш файл — виконуються всі.

Учора ти перевірив 10 файлів руками і склав detect.txt. Це зайняло хвилин п'ятнадцять. Тепер уяви 300 фото з телефона. Це вже не п'ятнадцять хвилин, а година кліків. І десь на 180-му файлі ти помилишся й не помітиш.

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

Три рядки, з яких починається будь-який скрипт

перший скрипт
$ nano hello.sh # усередині файлу: #!/usr/bin/env bash set -euo pipefail echo "Тека: ${1:-.}" $ chmod +x hello.sh $ ./hello.sh media/lesson-06 Тека: media/lesson-06
РядокЩо він робить
#!/usr/bin/env bashШебанг. Перші два символи файлу кажуть системі, якою програмою його виконувати. Без нього ./hello.sh може піти не тим інтерпретатором
chmod +xставить файлу право «виконуваний». Без нього буде Permission denied
./hello.shкрапка зі слешем — «файл із поточної теки». Без них оболонка шукає команду тільки в PATH
${1:-.}перший аргумент, а якщо його не дали — крапка, тобто поточна тека
Питання

Ти зберіг скрипт як convert.sh і зробив chmod +x. Набрав convert.sh . — а термінал відповідає command not found. Файл на місці, права стоять. Що не так?

Анатомія рядка циклу

Один рядок, чотири частини for f in "$DIR"/*; do 1 2 3 4 magick "$f" "$OUT/${f%.*}.webp" done 4 тіло циклу: виконується окремо для кожного файлу межі тіла: do … done
Зірочку розкриває оболонка, а не цикл — увесь перелік імен готується один раз, ще до першого кроку. Тому файли, створені всередині циклу, у цей самий прохід не потраплять.
ЧастинаЩо робитьТипова помилка
1for … inключове слово: почати циклзабути done — оболонка мовчки чекає продовження
2fзмінна кроку: сюди по черзі лягає повний шлях кожного файлу, а не саме ім'ячекати в $f голе ім'я — для цього є basename
3"$DIR"/*список: оболонка розкриває * у перелік іменписати $DIR/* без лапок або $(ls) — ламається на пробілах
4do … doneмежі тіла циклуписати do одразу після списку без ; перед ним
Якщо в теці нічого немає Порожня тека — і * нікуди не розкривається: цикл один раз отримає буквальний рядок media/lesson-06/*. Тому перший рядок у тілі завжди [ -f "$f" ] || continue — «не файл, іди далі».

Підстановка імен: як відрізати розширення

Оболонка вміє різати рядки без жодних зовнішніх програм. Ось увесь набір, що знадобиться сьогодні. Приклад — файл media/my.photo.2024.jpg.

ЗаписЩо робитьРезультат
${f##*.}зрізати найдовше з початку до крапки — лишиться розширенняjpg
${f#*.}зрізати найкоротше з початкуphoto.2024.jpg
${f%.*}зрізати найкоротше з кінця — лишиться шлях без розширенняmedia/my.photo.2024
${f%%.*}зрізати найдовше з кінцяmedia/my
$(basename "$f")лише ім'я, без текиmy.photo.2024.jpg
$(basename "${f%.*}")«стебло»: ані теки, ані розширенняmy.photo.2024
$(dirname "$f")лише текаmedia

Правило запам'ятовується так: решітка ріже спереду, відсоток — ззаду. На клавіатурі # лівіше за %. Подвоєний символ жадібніший за одинарний.

Питання

Файл зветься my.photo.2024.jpg. Ти пишеш mv "$f" "${f%%.*}.webp" і отримуєш my.webp — дата й слово «photo» зникли. Що замінити?

Навіщо лапки навколо $f

У теці цілком може лежати літній табір 2024.jpg. Без лапок оболонка розіб'є це ім'я на три окремі аргументи. Команда отримає три неіснуючі файли.

те саме ім'я, два різні результати
# без лапок: mv бачить три аргументи $ mv $f ${f%.*}.webp mv: cannot stat 'літній': No such file or directory # з лапками: один аргумент, як і задумано $ mv -- "$f" "${f%.*}.webp"

Подвійний дефіс -- каже команді: далі йдуть імена файлів, а не ключі. Він рятує, коли файл починається з дефіса. Без нього -report.txt сприймається як невідомий ключ.

Питання

У теці є файл літній табір.jpg. Скрипт із рядком mv $f ${f%.*}.webp обробляє решту файлів нормально. А на цьому щоразу лається. Чому саме на ньому?

Крок 3

Розгалуження case: три гілки конвертації

Один цикл, різні дії для різних типів. Тип беремо не з розширення — воно бреше — а з file --mime-type.

Учора ти встановив: розширення — це підказка, а не факт. Справжній тип видає сигнатура в перших байтах, і file читає саме її. Сьогодні цей самий MIME-рядок вирішує, що з файлом робити далі.

case: перша гілка, що збіглася
$ case "$mime" in $ image/*) dst="$OUT/$stem.webp" ;; $ audio/*) dst="$OUT/$stem.mp3" ;; $ text/*) dst="$OUT/$stem.html" ;; $ *) echo "гілки немає для $mime" >&2; continue ;; $ esac

Три гілки — три різні долі файлу. Але є питання, спільне для всіх трьох: чи повернеться файл таким, як був. На уроці 6 ми розбирали це на звуці, тепер те саме стосується картинок і тексту.

Конвертація без втрат зберігає весь зміст до останнього символу: з результату завжди можна дістати оригінал назад. Конвертація з втратами частину даних викидає, щоб файл важив менше, і взяти їх назад уже нізвідки. Остання колонка таблиці каже, що саме відбувається в кожній гілці.

ГілкаЧим конвертуємоЩо на виходіВтратна?
image/*
image/jpeg, image/png
magick "$cur" -quality 82 "$dst" .webp так — WebP з якістю 82 викидає частину даних
audio/*
audio/mpeg
ffmpeg -i "$cur" -c:a libmp3lame -b:a 192k "$dst" .mp3 192 кбіт/с так, і двічі — MP3 уже було стиснуто, тепер кодуємо ще раз
text/*
text/plain, text/markdown
обгортка навколо тексту через printf і sed .html ні — жоден символ не втрачено, файл навіть побільшав
Чому текстовий файл після конвертації більший До нього дописалися <!doctype>, <title> і <pre> — це десятки зайвих байтів. У маніфесті saved_percent вийде від'ємним, і це правильно: економія мінус дванадцять відсотків означає, що файл виріс на 12%. Не «виправляй» це нулем — чекер перерахує відсоток сам і побачить розбіжність.
Пастка: *) має бути останньою case бере першу гілку, що збіглася, і на цьому зупиняється. Шаблон *) збігається геть з усім, тому поставлена першою, вона з'їсть усі файли, а справжні гілки не спрацюють жодного разу.
Питання

Скрипт відпрацьовує без жодної помилки й повертає код 0. Але тека out/ лишається порожньою, а в терміналі десять рядків «гілки немає». case виглядає так: *) … ;; image/*) … ;; audio/*) … ;;. Де помилка?

Обережно: text/plain — це і .txt, і .md

Команда file --mime-type дивиться на вміст, а всередині Markdown — це звичайний текст. Тому readme.md майже завжди буде text/plain, а не text/markdown. Гілка в них спільна, і це нормально. Обидва стають HTML, а імена вихідних файлів різні: readme.html і notes.html.

Практичний наслідок для detect.sh: для text/* не чіпай розширення. Інакше скрипт перейменує readme.md на readme.txt і зіпсує те, що не було зламане.

Крок 4

Щоб скрипт падав чесно

За замовчуванням bash — оптиміст: команда впала, а скрипт іде далі й псує дані наступним кроком.

Уяви: magick не встановлено. Без захисту скрипт спокійно піде далі. Запише в маніфест out_bytes: 0 і порахує економію 100%. А ти дізнаєшся про це аж від чекера. Один рядок на початку це вимикає.

перший робочий рядок будь-якого скрипта
$ set -euo pipefail
ЛітераЩо вмикаєЩо ловить сьогодні
-eперша ж команда з ненульовим кодом зупиняє скриптmagick не встановлено — падаємо одразу, а не пишемо порожні файли
-uзвертання до незаданої змінної — помилкаописався $OUTDIR замість $OUT — не запишеш файл у корінь диска
-o pipefailконвеєр вважається невдалим, якщо впала будь-яка його частинаfile … | grep …: без цього код виходу дає лише grep
Побічний ефект -e, про який треба знати Рядок на кшталт grep -c щось file нічого не знайшов — це код 1, і скрипт із -e помре. Якщо невдача очікувана, гаси її явно: grep -c щось file || true. Це не милиця, а спосіб сказати «тут нуль результатів — нормально».

Перевірки, без яких скрипт ламається на чужій машині

ПеревіркаЧитається якНавіщо
[ -f "$f" ]це звичайний файлвідсіяти теки й нерозкриту зірочку
[ -d "$DIR" ]це текасказати «немає теки», а не сипати помилками 40 рядків
[ -e "$t" ]щось із таким ім'ям уже існуєне затерти чужий файл під час перейменування
[ -n "$s" ] / [ -z "$s" ]рядок непорожній / порожнійпропустити порожні рядки у звіті
[ "$a" -nt "$b" ]файл a новіший за bсерце ідемпотентності: чи треба конвертувати заново
command -v magickтака команда взагалі є в системічесне «встанови ImageMagick» замість command not found ×10

Код виходу — це відповідь скрипта

Кожен скрипт після себе лишає число. 0 — усе добре, будь-що інше — біда. Його не видно на екрані. Але його бачить чекер, і за ним же працює &&. Перевірити свій код виходу можна одразу після запуску.

перевірити, чим закінчився скрипт
$ ./detect.sh media/lesson-06 $ echo $? 0 # у PowerShell те саме число зветься інакше: $LASTEXITCODE
Про нескінченні цикли — до того, як ти на них наступиш Класична пастка: скрипт читає * і кладе результат у ту саму теку. Сам цикл не зациклиться — оболонка розкрила зірочку один раз. Але наступний запуск побачить уже й твої .webp, і зробить із них .webp.webp. Третій — .webp.webp.webp. Тека росте до заповнення диска. Ліки — окрема тека out/ і перевірка «вже готово».
Питання

Ти запустив скрипт двічі, і в теці з'явилися photo.webp і photo.webp.webp. ImageMagick цілий, синтаксис без помилок. Де помилка?

Крок 5

Другий запуск нічого не ламає

Запустив двічі — результат той самий. Запустив десять разів — той самий. Ідея коротка, слово для неї довге.

Операція ідемпотентна, якщо повторення нічого не змінює. Натиснути кнопку ліфта вдруге — ідемпотентно. Додати ложку цукру вдруге — ні. Твій convert.sh має бути кнопкою ліфта.

Той самий скрипт, два запуски поспіль Перший запуск out/ порожня — конвертуємо все + sunset.webp + notes.html + theme.mp3 … ще 7 створено 10 файлів · маніфест: 10 об'єктів Другий запуск out/ повна і свіжа — пропускаємо все = sunset.webp уже свіжий = notes.html уже свіжий = theme.mp3 уже свіжий … ще 7 створено 0 файлів · маніфест: 10 об'єктів, байт у байт той самий Без перевірки «вже сконвертовано» другий запуск бере власний вихід за вхід: photo.webp → photo.webp.webp → photo.webp.webp.webp
Правий стовпчик — те, що вчитель побачить на очній перевірці. Спершу він видалить out/ і запустить скрипт при тобі, потім запустить його ще раз і подивиться, що нічого не змінилося.

Три маркери ідемпотентності

МаркерЩо перевіряєКоли підводить
[ -f "$dst" ] && continueвихідний файл узагалі існуєти виправив оригінал — результат лишиться старим
[ "$dst" -nt "$cur" ]вихід новіший за вхідмайже не підводить; саме це ми й беремо
sha256sum вхідного файлу в окремому файлівміст не змінивсянайнадійніше, але треба зберігати хеші й читати їх — зайва складність для 10 файлів

Для detect.sh маркер інший: дописуй лише те, чого ще немає. Файл detect.tsv не переписується з нуля, а доповнюється. Перед дописом скрипт питає, чи це ім'я вже в ньому є. Це важливо. Після перейменування оригінальне ім'я існує тільки в цьому файлі — на диску його вже нема.

Ідемпотентність ≠ «нічого не робити двічі» Скрипт має право перезаписати результат, якщо оригінал змінився. Ідемпотентність вимагає, щоб кінцевий стан був однаковий, а не щоб дій було нуль. Тому правильний маркер порівнює час, а не просто наявність файлу.
Питання

Ти поставив у скрипт перевірку [ -f "$dst" ] && continue. Потім обрізав photo.jpg у редакторі й перезапустив скрипт. А out/photo.webp лишився старим, з необрізаною картинкою. Що змінити в перевірці?

Крок 6

Симулятор скрипта

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

Спочатку ./detect.sh. Він читає перші байти, називає справжній тип і виправляє хибні розширення. Потім ./convert.sh. Він розкладає файли по трьох гілках і збирає маніфест. Потім запусти ./convert.sh ще раз і подивись, що змінилося.

media/lesson-06/

    media/lesson-06/out/

      твій термінал
      manifest.json
      
        

      У симуляторі 8 файлів, а у твоєму наборі їх 10 — тут просто менше, щоб усе вміщалося на екран. Числа ілюстративні: вони підібрані так, щоб було видно арифметику економії. На своїй машині ти отримаєш власні, і саме їх запише скрипт.

      Що варто помітити

      Питання

      У однокласника після другого запуску convert.sh у маніфесті стало 20 об'єктів замість 10. Жодного нового файлу при цьому не створилося. Чекер вимагає рівно 10. Що він зробив не так у скрипті?

      Крок 7

      Обидва скрипти повністю (bash)

      Це робочі скрипти, а не уривки. Набирай їх руками — так конструкції запам'ятовуються. Розбір кожного рядка — у таблиці під кодом.

      detect.sh

      detect.sh · 46 рядків
      1. #!/usr/bin/env bash
      2. # detect.sh — визначає справжній тип кожного файлу
      3. # і виправляє хибні розширення. Запуск: ./detect.sh <тека>
      4. set -euo pipefail
      5. DIR="${1:-.}"
      6. TSV="$DIR/detect.tsv"
      7. [ -d "$DIR" ] || { echo "Немає теки: $DIR" >&2; exit 1; }
      8. [ -f "$TSV" ] || touch "$TSV"
      9. for f in "$DIR"/*; do
      10. [ -f "$f" ] || continue
      11. base="$(basename "$f")"
      12. case "$base" in detect.tsv|manifest.json) continue ;; esac
      13. if awk -F'\t' -v n="$base" '$1==n || $3==n {e=1} END{exit !e}' "$TSV"; then
      14. echo "= $base уже описаний — пропускаю"
      15. continue
      16. fi
      17. ext="${base##*.}"
      18. stem="${base%.*}"
      19. mime="$(file --mime-type -b "$f")"
      20. case "$mime" in
      21. image/jpeg) want=jpg ;;
      22. image/png) want=png ;;
      23. audio/mpeg) want=mp3 ;;
      24. *) want="$ext" ;;
      25. esac
      26. renamed=""
      27. if [ "$ext" != "$want" ]; then
      28. if [ -e "$DIR/$stem.$want" ]; then
      29. echo "! $stem.$want уже існує — не чіпаю $base" >&2
      30. else
      31. mv -- "$f" "$DIR/$stem.$want"
      32. renamed="$stem.$want"
      33. echo "> $base -> $renamed ($mime)"
      34. fi
      35. fi
      36. printf '%s\t%s\t%s\n' "$base" "$mime" "$renamed" >> "$TSV"
      37. done
      38. echo "Рядків у $TSV: $(wc -l < "$TSV")"
      РядкиЩо там відбувається
      1шебанг: система знає, чим виконувати файл
      4вмикаємо чесні падіння: -e, -u, pipefail
      6ключовий рядок для чекера. Тека береться з аргументу. Жодного cd /home/… — скрипт працює з будь-якою текою
      8–9тека є? звіт існує? Якщо ні — створюємо порожній, але не очищаємо наявний
      11–12цикл по всьому, що в теці; усе, що не файл, пропускаємо
      13–14беремо голе ім'я і виключаємо власні службові файли, щоб скрипт не обробляв сам себе
      16–19маркер ідемпотентності. awk шукає це ім'я і в першій колонці (як було), і в третій (як стало). Знайшов — пропускаємо. Тому повторний запуск не дублює рядків
      21–22розширення і стебло — та сама підстановка з кроку 2
      23справжній тип. -b прибирає ім'я файлу з виводу, лишається чистий MIME
      25–29яким має бути розширення для цього типу. Для всього іншого — *) — лишаємо як є: текст ми не чіпаємо
      32–41перейменування, але тільки якщо ціль ще не зайнята. Інакше можна затерти чужий файл — і без попередження
      43рядок звіту. У першій колонці — оригінальне ім'я: на диску його вже немає, і це єдине місце, де воно збереглося
      46підсумок для людини. Код виходу при цьому 0 — чекер бачить успіх

      convert.sh

      convert.sh · 59 рядків
      1. #!/usr/bin/env bash
      2. # convert.sh — конвертує все, що описав detect.sh,
      3. # і збирає manifest.json. Запуск: ./convert.sh <тека>
      4. set -euo pipefail
      5. DIR="${1:-.}"
      6. TSV="$DIR/detect.tsv"
      7. OUT="$DIR/out"
      8. MAN="$DIR/manifest.json"
      9. [ -f "$TSV" ] || { echo "Спершу запусти ./detect.sh \"$DIR\"" >&2; exit 1; }
      10. command -v magick >/dev/null || { echo "Немає ImageMagick" >&2; exit 1; }
      11. command -v ffmpeg >/dev/null || { echo "Немає ffmpeg" >&2; exit 1; }
      12. mkdir -p "$OUT"
      13. tmp="$(mktemp)"
      14. printf '[\n' > "$tmp"
      15. first=1
      16. while IFS=$'\t' read -r src mime renamed; do
      17. [ -n "$src" ] || continue
      18. cur="$DIR/${renamed:-$src}"
      19. [ -f "$cur" ] || { echo "! немає файлу $cur" >&2; continue; }
      20. stem="$(basename "${cur%.*}")"
      21. case "$mime" in
      22. image/*) dst="$OUT/$stem.webp" ;;
      23. audio/*) dst="$OUT/$stem.mp3" ;;
      24. text/*) dst="$OUT/$stem.html" ;;
      25. *) echo "! гілки немає для $mime" >&2; continue ;;
      26. esac
      27. if [ -f "$dst" ] && [ "$dst" -nt "$cur" ]; then
      28. echo "= $(basename "$dst") уже свіжий — пропускаю"
      29. else
      30. case "$mime" in
      31. image/*) magick "$cur" -quality 82 "$dst" ;;
      32. audio/*) ffmpeg -nostdin -loglevel error -y -i "$cur" \
      33. -c:a libmp3lame -b:a 192k "$dst" ;;
      34. text/*) { printf '<!doctype html>\n<meta charset="utf-8">\n<title>%s</title>\n<pre>\n' "$stem"
      35. sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' "$cur"
      36. printf '</pre>\n'; } > "$dst" ;;
      37. esac
      38. echo "+ $(basename "$dst")"
      39. fi
      40. sb=$(wc -c < "$cur" | tr -d ' ')
      41. ob=$(wc -c < "$dst" | tr -d ' ')
      42. saved=$(awk -v s="$sb" -v o="$ob" 'BEGIN{printf "%.1f", (1 - o/s) * 100}')
      43. [ "$first" -eq 1 ] || printf ',\n' >> "$tmp"
      44. first=0
      45. printf ' {"src":"%s","src_bytes":%s,"detected_mime":"%s","renamed_to":"%s","out":"out/%s","out_bytes":%s,"saved_percent":%s}' \
      46. "$src" "$sb" "$mime" "$renamed" "$(basename "$dst")" "$ob" "$saved" >> "$tmp"
      47. done < "$TSV"
      48. printf '\n]\n' >> "$tmp"
      49. mv "$tmp" "$MAN"
      50. echo "$MAN: $(grep -c '"src"' "$MAN") об'єктів"
      РядкиЩо там відбувається
      6–9усі шляхи — від $DIR. Змінив аргумент — скрипт працює в іншій теці й нічого не треба правити всередині
      11без detect.tsv робити нічого: convert.sh не вгадує типи сам
      12–13чесна відмова, якщо інструментів немає. Інакше set -e обірве скрипт на першому файлі з незрозумілим текстом
      16–18маніфест збирається у тимчасовий файл. Так на диску ніколи не лежить наполовину записаний JSON
      20IFS=$'\t' — розділяти рядок саме табуляцією, тому імена з пробілами не розваляться. -r — не тлумачити зворотні слеші
      22${renamed:-$src} — «нове ім'я, а якщо його немає, то старе». Один запис замість if
      26–30вибір гілки за MIME, а не за розширенням. Розширення вже виправлене, але вирішує все одно тип
      33серце ідемпотентності: результат є і новіший за джерело — пропускаємо. Оригінал змінився — переробимо
      36–42три гілки. Текстова не викликає нічого зовнішнього: printf дає обгортку, sed екранує &, <, >, щоб код усередині тексту не поламав HTML
      47–48розміри читаємо з диска, а не з голови. tr -d ' ' прибирає пробіли, які додає wc на macOS
      49економію рахує awk з одним знаком після коми. Ти її не вводиш руками — саме цього вимагає чекер
      51–54кома ставиться перед наступним об'єктом, а не після попереднього. Так у JSON не з'явиться зайва кома в кінці — найчастіша причина «invalid JSON»
      55ось звідки цикл читає рядки: done < "$TSV". Файл підключається до циклу цілком, один раз
      57–58закриваємо масив і одним рухом замінюємо старий маніфест новим. Якщо скрипт упаде раніше — старий файл лишиться цілим
      Пастка, на якій зупиняється половина класу: ffmpeg з'їдає список Цикл while read читає файл зі свого стандартного входу. ffmpeg теж читає стандартний вхід — і мовчки проковтує решту detect.tsv. Симптом: скрипт обробив один файл і закінчився без помилки. Ліки — ключ -nostdin у рядку 38 (або < /dev/null після команди).
      magick чи convert? В ImageMagick 7 команда зветься magick, у старій шостій версії — convert. Перевір своєю magick -version. На Windows convert ще й збігається з системною командою форматування диска, тому magick — єдиний безпечний варіант.
      Питання

      Твій скрипт починається рядком cd /home/petrenko/media/lesson-06 і далі працює з *. У тебе все зелено. На очній перевірці вчитель копіює скрипт у свою тестову теку й запускає. Скрипт мовчки обробляє твої файли. Що написати замість цього рядка?

      Крок 8

      Ті самі два скрипти для PowerShell

      Якщо в тебе Windows і немає Git Bash чи WSL — працюй цими. Логіка та сама, слова інші.

      Одна принципова відмінність: у PowerShell немає команди file. Тому справжній тип ми визначаємо самі. Читаємо перші чотири байти й порівнюємо з сигнатурами. З тими самими, що ти записав на минулому уроці.

      bashPowerShellПро що це
      set -e$ErrorActionPreference = "Stop"падати на першій помилці
      DIR="${1:-.}"param([string]$Dir = ".")тека з аргументу
      for f in "$DIR"/*foreach ($f in Get-ChildItem $Dir -File)цикл по файлах
      case … in … esacif/elseif з -likeрозгалуження за MIME
      ${f%.*}[IO.Path]::GetFileNameWithoutExtension($f)відрізати розширення
      mv -- a bRename-Item -LiteralPath a -NewName bперейменувати
      wc -c < f(Get-Item f).Lengthрозмір у байтах
      [ a -nt b ](Get-Item a).LastWriteTime -ge (Get-Item b).LastWriteTimeчи свіжий результат
      echo $?$LASTEXITCODEкод виходу

      detect.ps1

      detect.ps1 · 53 рядки
      1. # detect.ps1 — те саме, що detect.sh, але для Windows PowerShell.
      2. # Запуск: .\detect.ps1 media\lesson-06
      3. param([string]$Dir = ".")
      4. $ErrorActionPreference = "Stop" # аналог set -e
      5. $tsv = Join-Path $Dir "detect.tsv"
      6. if (-not (Test-Path $Dir)) { throw "Немає теки: $Dir" }
      7. if (-not (Test-Path $tsv)) { New-Item -ItemType File -Path $tsv | Out-Null }
      8. # усі імена, що вже описані — і старі, і нові
      9. $known = @()
      10. foreach ($line in (Get-Content $tsv -Encoding utf8)) {
      11. $p = $line -split "`t"
      12. if ($p[0]) { $known += $p[0] }
      13. if ($p.Count -ge 3 -and $p[2]) { $known += $p[2] }
      14. }
      15. function Get-RealType([string]$path) {
      16. $b = Get-Content -LiteralPath $path -Encoding Byte -TotalCount 4
      17. $hex = ($b | ForEach-Object { $_.ToString("X2") }) -join ""
      18. if ($hex.StartsWith("FFD8FF")) { return @("image/jpeg","jpg") }
      19. if ($hex -eq "89504E47") { return @("image/png","png") }
      20. if ($hex.StartsWith("494433")) { return @("audio/mpeg","mp3") } # мітка ID3
      21. if ($hex.StartsWith("FFFB") -or $hex.StartsWith("FFF3")) { return @("audio/mpeg","mp3") }
      22. return @("text/plain", "") # порожнє = розширення не чіпаємо
      23. }
      24. foreach ($f in Get-ChildItem -LiteralPath $Dir -File) {
      25. if ($f.Name -eq "detect.tsv" -or $f.Name -eq "manifest.json") { continue }
      26. if ($known -contains $f.Name) { Write-Host "= $($f.Name) уже описаний"; continue }
      27. $t = Get-RealType $f.FullName
      28. $mime = $t[0]
      29. $want = $t[1]
      30. $orig = $f.Name # запам'ятовуємо ДО перейменування
      31. $ext = $f.Extension.TrimStart(".")
      32. $renamed = ""
      33. if ($want -and $ext -ne $want) {
      34. $newName = $f.BaseName + "." + $want
      35. if (Test-Path (Join-Path $Dir $newName)) {
      36. Write-Warning "$newName уже існує — не чіпаю $orig"
      37. } else {
      38. Rename-Item -LiteralPath $f.FullName -NewName $newName
      39. $renamed = $newName
      40. Write-Host "> $orig -> $renamed ($mime)"
      41. }
      42. }
      43. "$orig`t$mime`t$renamed" | Out-File -FilePath $tsv -Append -Encoding utf8
      44. }
      45. Write-Host "Рядків у ${tsv}: $((Get-Content $tsv | Measure-Object -Line).Lines)"
      РядкиЩо там відбувається
      3param має стояти першою виконуваною інструкцією файлу — коментарі перед ним можна, код ні
      10–16той самий маркер ідемпотентності: збираємо всі вже описані імена — і в колонці «було», і в колонці «стало»
      18–26власний file: чотири байти → hex → сигнатура. FFD8FF — JPEG, 89504E47 — PNG, 494433 — мітка ID3 на початку MP3
      19-Encoding Byte працює у PowerShell 5.1. У PowerShell 7 цей ключ прибрали — там пиши -AsByteStream
      35ім'я запам'ятовується до перейменування. Після Rename-Item об'єкт $f уже може вказувати на нове ім'я
      50`t — це табуляція. У PowerShell символ утечі не зворотний слеш, а зворотний апостроф

      convert.ps1

      convert.ps1 · 64 рядки
      1. # convert.ps1 — конвертує описане в detect.tsv і збирає manifest.json.
      2. # Запуск: .\convert.ps1 media\lesson-06
      3. param([string]$Dir = ".")
      4. $ErrorActionPreference = "Stop"
      5. $tsv = Join-Path $Dir "detect.tsv"
      6. $outDir = Join-Path $Dir "out"
      7. $man = Join-Path $Dir "manifest.json"
      8. if (-not (Test-Path $tsv)) { throw "Спершу запусти .\detect.ps1 $Dir" }
      9. if (-not (Get-Command magick -ErrorAction SilentlyContinue)) { throw "Немає ImageMagick" }
      10. if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { throw "Немає ffmpeg" }
      11. if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
      12. $items = @()
      13. foreach ($line in (Get-Content $tsv -Encoding utf8)) {
      14. if (-not $line.Trim()) { continue }
      15. $p = $line -split "`t"
      16. $src = $p[0]
      17. $mime = $p[1]
      18. $renamed = if ($p.Count -ge 3) { $p[2] } else { "" }
      19. $cur = Join-Path $Dir $(if ($renamed) { $renamed } else { $src })
      20. if (-not (Test-Path $cur)) { Write-Warning "немає файлу $cur"; continue }
      21. $stem = [IO.Path]::GetFileNameWithoutExtension($cur)
      22. if ($mime -like "image/*") { $dst = Join-Path $outDir "$stem.webp" }
      23. elseif ($mime -like "audio/*") { $dst = Join-Path $outDir "$stem.mp3" }
      24. elseif ($mime -like "text/*") { $dst = Join-Path $outDir "$stem.html" }
      25. else { Write-Warning "гілки немає для $mime"; continue }
      26. $fresh = (Test-Path $dst) -and
      27. ((Get-Item $dst).LastWriteTime -ge (Get-Item $cur).LastWriteTime)
      28. if ($fresh) {
      29. Write-Host "= $(Split-Path $dst -Leaf) уже свіжий — пропускаю"
      30. } else {
      31. if ($mime -like "image/*") { magick $cur -quality 82 $dst }
      32. elseif ($mime -like "audio/*") { ffmpeg -nostdin -loglevel error -y -i $cur -c:a libmp3lame -b:a 192k $dst }
      33. else {
      34. $body = (Get-Content -LiteralPath $cur -Raw -Encoding utf8) -replace '&','&amp;' -replace '<','&lt;' -replace '>','&gt;'
      35. $html = "<!doctype html>`n<meta charset=""utf-8"">`n<title>$stem</title>`n<pre>`n$body`n</pre>"
      36. [IO.File]::WriteAllText($dst, $html, (New-Object Text.UTF8Encoding $false))
      37. }
      38. Write-Host "+ $(Split-Path $dst -Leaf)"
      39. }
      40. $sb = (Get-Item $cur).Length
      41. $ob = (Get-Item $dst).Length
      42. $items += [pscustomobject]@{
      43. src = $src
      44. src_bytes = $sb
      45. detected_mime = $mime
      46. renamed_to = $renamed
      47. out = "out/" + (Split-Path $dst -Leaf)
      48. out_bytes = $ob
      49. saved_percent = [math]::Round((1 - $ob / $sb) * 100, 1)
      50. }
      51. }
      52. # масив збираємо руками, а файл пишемо БЕЗ BOM — інакше jq упреться
      53. $json = "[`n" + (($items | ForEach-Object { " " + ($_ | ConvertTo-Json -Compress) }) -join ",`n") + "`n]"
      54. [IO.File]::WriteAllText($man, $json, (New-Object Text.UTF8Encoding $false))
      55. Write-Host "${man}: $($items.Count) об'єктів"

      Чотири помилки PowerShell, на які наступають щороку

      ГрабліСимптомЩо робити
      > пише UTF-16файл на вигляд правильний, а jq каже, що це не JSONніколи не … > manifest.json; тільки Out-File -Encoding utf8, а для JSON — [IO.File]::WriteAllText
      Out-File -Encoding utf8 у 5.1 додає BOMjq: error: Invalid numeric literal у першому ж символіписати через [IO.File]::WriteAllText(… , (New-Object Text.UTF8Encoding $false))
      немає &&mkdir out && magick … — помилка розборуmkdir out; if ($?) { magick … } або просто окремими рядками
      ConvertTo-Json на одному елементіу файлі {…} замість [{…}], чекер каже «не масив»збирати масив рядком, як у 62-му рядку, або -AsArray у PowerShell 7
      Ще одна: continue всередині switch У PowerShell continue у блоці switch виходить із самого switch, а не з циклу навколо. Через це половина файлів мовчки пропускається. Саме тому в цьому скрипті замість switch — звичайні if/elseif.
      Питання

      Скрипт на PowerShell 5.1 записав manifest.json через Out-File -Encoding utf8. У блокноті файл виглядає бездоганно, браузер його показує. А jq empty manifest.json відповідає Invalid numeric literal at line 1. Що з файлом?

      Крок 9

      manifest.json — звіт, який читає машина

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

      JSON — це звичайний текст, який уміє читати програма. Правил у ньому мало, зате всі суворі. Подивимось на них на прикладі свого ж маніфесту.

      Опис одного файлу беруть у фігурні дужки. Усередині — пари «ключ: значення», як у картці бібліотечного каталогу. Така картка зветься об'єктом. Десять карток поспіль складають у квадратні дужки — це вже масив, тобто просто список.

      Значення в картці бувають двох головних видів. Текст беруть у подвійні лапки: "image/jpeg". Число пишуть без лапок узагалі: 2418176. Є ще true і false для «так» і «ні» та null для «значення немає».

      Трьох речей JSON не пробачає. Одинарні лапки замість подвійних він вважає помилкою. Кома після останнього елемента ламає файл так само. А коментар усередину не вставити ніяк — у JSON їх немає.

      Сім полів на кожен файл

      КлючТипЩо містить
      srcрядокім'я файлу до будь-якого перейменування
      src_bytesчислорозмір оригіналу в байтах, без лапок
      detected_mimeрядоксправжній тип, як його назвав file --mime-type
      renamed_toрядокнове ім'я або порожній рядок, якщо розширення було правдиве
      outрядокшлях результату відносно теки: out/…
      out_bytesчислорозмір результату в байтах
      saved_percentчисло(1 − out_bytes ÷ src_bytes) × 100, один знак після коми; може бути від'ємним
      приклад: перші три об'єкти з десяти
      [
        {"src":"sunset.jpg","src_bytes":2418176,"detected_mime":"image/jpeg","renamed_to":"","out":"out/sunset.webp","out_bytes":512044,"saved_percent":78.8},
        {"src":"notes.txt","src_bytes":3214,"detected_mime":"text/plain","renamed_to":"","out":"out/notes.html","out_bytes":3620,"saved_percent":-12.6},
        {"src":"photo.mp3","src_bytes":1887436,"detected_mime":"image/jpeg","renamed_to":"photo.jpg","out":"out/photo.webp","out_bytes":402190,"saved_percent":78.7}
      ]

      Числа тут — ілюстративні, ті самі, що в симуляторі вище. У твоєму маніфесті будуть власні: їх запише скрипт із реальних файлів.

      Прочитай третій рядок уважно. Файл звався photo.mp3, а всередині виявився JPEG. Скрипт перейменував його на photo.jpg, і результат пішов у графічну гілку — photo.webp. Одне поле renamed_to пояснює всю історію файлу.

      Як перевірити маніфест до того, як його перевірить чекер

      чотири перевірки поспіль
      # 1. це взагалі валідний JSON? мовчання = так $ jq empty manifest.json # 2. рівно десять елементів? $ jq 'length' manifest.json 10 # 3. у кожного всі сім ключів? має бути рівно [7] $ jq '[.[] | keys | length] | unique' manifest.json [ 7 ] # 4. усі три спіймані файли мають нове ім'я? $ jq -r '.[] | select(.renamed_to != "") | .src' manifest.json diagram.txt song.pdf photo.mp3

      Найцікавіша перевірка — п'ята. Чекер не вірить полю saved_percent. Він рахує відсоток сам із двох інших полів і порівнює з твоїм. Якщо розбіжність більша за 0,5 — цей критерій поки не зарахують. Прожени цю перевірку сам, перш ніж здавати: так виправляти буде швидше.

      несуперечливість: твоє число проти перерахованого
      $ jq -r '.[] | [.src, .saved_percent, ((1 - .out_bytes/.src_bytes)*1000|round/10)] | @tsv' manifest.json sunset.jpg 78.8 78.8 notes.txt -12.6 -12.6 photo.mp3 78.7 78.7
      Числа — без лапок Запис "src_bytes": "2418176" — це рядок, а не число. jq empty промовчить: з погляду синтаксису все гаразд. А перерахунок економії впаде або дасть дурницю, бо ділити текст на текст не можна. У маніфесті число має бути число.
      Питання

      У твоєму маніфесті "src_bytes": "2418176" і "out_bytes": "512044" — у лапках. jq empty мовчить, jq length дає 10. Усі сім ключів на місці. Чому чекер усе одно валить критерій «несуперечливість»?

      Таблиця на сторінці будується з маніфесту, а не руками

      Десять рядків таблиці можна набити в index.html руками. Тоді після кожного запуску скрипта їх доведеться правити руками ж. Зробимо інакше: у файлі лишається порожня таблиця, а рядки в неї дописує сама сторінка, коли її відкривають.

      Робить це маленький скрипт унизу сторінки. Спершу він просить у сервера файл manifest.json. Такий запит і зветься fetch — англійською «принеси». Відповідь приходить не миттєво, тому далі стоїть .then: «а коли принесеш — зроби ось це».

      Принесений текст перетворюють на список карток — це r.json(). Порожню таблицю скрипт знаходить за її іменем #t, і це querySelector. Далі він іде по списку й на кожну картку додає в таблицю новий рядок.

      Рядок роблять із трьох дій. createElement('tr') створює порожній рядок, textContent кладе в клітинку значення, appendChild чіпляє готове до таблиці. Отак десять карток стають десятьма рядками на екрані.

      Коли сторінка будує себе з даних просто в браузері, це називають рендером. Саме тому чекер рахує рядки двічі. У самому файлі index.html їх менше трьох, а після рендера — одинадцять: заголовок і десять файлів. Це й доводить, що таблицю справді малює fetch, а не твої руки.

      шматок index.html
      1. <table id="t">
      2. <thead><tr><th>Файл</th><th>Тип</th><th>Було</th>
      3. <th>Стало</th><th>Економія</th></tr></thead>
      4. <tbody></tbody>
      5. </table>
      6. <script>
      7. fetch('manifest.json')
      8. .then(r => r.json())
      9. .then(rows => {
      10. const tb = document.querySelector('#t tbody');
      11. rows.forEach(r => {
      12. const tr = document.createElement('tr');
      13. if (r.renamed_to) tr.className = 'caught'; // спіймані підсвічуємо
      14. [r.src, r.detected_mime, r.src_bytes, r.out_bytes,
      15. r.saved_percent + '%'].forEach(v => {
      16. const td = document.createElement('td');
      17. td.textContent = v;
      18. tr.appendChild(td);
      19. });
      20. tb.appendChild(tr);
      21. });
      22. });
      23. </script>
      Чому textContent, а не innerHTML Імена файлів приходять із маніфесту. Якщо хтось назве файл <script>…, innerHTML виконає це як код сторінки. textContent покаже його як текст. Звичка, яка колись урятує тобі сайт.
      Крок 10

      Практика

      Скрипти пишуться й запускаються на твоєму комп'ютері. На сервер поїдуть лише чотири текстові файли. Галочки зберігаються — сторінку можна закрити.

      Що куди У себе: тека media/lesson-06/ — набір, out/, detect.tsv, обидва скрипти. По FTP у ~/www/lesson-06/: detect.sh, convert.sh, manifest.json, index.html. Ані набору, ані теки out/ на сервері бути не повинно — критерій no_media це перевіряє.
      Крок 11

      Автоперевірка check 06

      Перевірка статична: конвертація на сервері не запускається жодного разу. Спроби не обмежені.

      Що чекер може, а чого не може На сервері немає ані ImageMagick, ані ffmpeg — важке рахує твоя машина, і це правило курсу, а не тимчасова незручність. Тому чекер: читає скрипти й ганяє їх через bash -n, шукає в них зашиті абсолютні шляхи і маркер ідемпотентності, звіряє manifest.json сам із собою і з еталонним списком набору, який знає наперед. Він не бачить ані твоїх WebP, ані MP3 і не може сказати, чи скрипт справді працює. Це доводиться очно.

      Демонстраційний режим: результат згенеровано для показу. На сервері ця кнопка викликає POST /api/check, який запускає чекер уроку від імені учня.

      Перевір себе тими самими інструментами до здачі

      те, що зробить чекер — тільки в тебе
      # синтаксис без запуску: мовчання = все добре $ bash -n detect.sh && bash -n convert.sh # зашиті абсолютні шляхи: має бути порожньо $ grep -nE 'cd +/|/home/|C:\\\\' detect.sh convert.sh # маркер ідемпотентності: має щось знайтися $ grep -nE '\-nt |\-f "\$dst"|sha256sum' convert.sh 33: if [ -f "$dst" ] && [ "$dst" -nt "$cur" ]; then

      Що вчитель дивиться на твоєму екрані

      Крок 12

      Шкала виконання

      Це те, що бачить учитель, коли виставляє оцінку.

      СкладникВагаРезультат
      Виконано
      0%
      Рекомендований бал за 12-бальною

      Три речі, які треба винести з уроку

      1. Скрипт — це твої власні команди, записані по порядку. Один раз написав — і десять файлів обробляються так само легко, як один.
      2. Що робити з файлом, вирішує його вміст, а не ім'я. Гілку обирає MIME-рядок від file, тому підроблене розширення скрипт не обдурить.
      3. Другий запуск має бути тихим. Скрипт спершу дивиться, чи результат уже готовий, і мовчки пропускає зроблене. Це і є ідемпотентність.

      Втратне і невтратне — коротко

      ЛанцюжокВтратиЧому
      PNG → PNGнемаєPNG стискає без втрат: розпакував — отримав ті самі пікселі
      TXT → HTMLнемаєжоден символ не зник, файл лише побільшав на розмітку
      FLAC → WAVнемаєFLAC — невтратний архів звуку, WAV — той самий звук без стиснення
      JPEG → WebP q82єдруге втратне кодування поверх першого
      MP3 → WAV → MP3є, двічіWAV посередині нічого не повертає: викинуте на першому кроці вже не існує

      Головне правило: оригінал не видаляй. Втратна конвертація незворотна, і жодна кількість проміжних невтратних форматів її не скасує.

      Домашнє завдання

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

      Обидва терміни — «з втратами» і «без втрат» — мають бути в тексті: їх окремо шукає чекер. Мінімум 200 символів.

      Джерела

      Звідки взято конструкції й числа

      Синтаксис — з офіційних довідників, а не з пам'яті. Перевіряти дозволено й корисно.

      Повний список із поясненнями, що саме звідки взято, — у файлі urok-12-джерела.md поруч із цією сторінкою.