From 0c214ef9bdd438304a6ffbb1ae9f42988328a808 Mon Sep 17 00:00:00 2001 From: oleg Date: Sun, 9 Aug 2026 11:43:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 18 ++ LICENSE | 21 +++ README.md | 207 ++++++++++++++++++++++ output/.gitkeep | 0 scripts/Get-YouTubeAudio.ps1 | 123 +++++++++++++ scripts/Get-YouTubeVideo.ps1 | 120 +++++++++++++ scripts/Get-YouTubeVideoTranslate.ps1 | 245 ++++++++++++++++++++++++++ tools/.gitkeep | 0 8 files changed, 734 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 output/.gitkeep create mode 100644 scripts/Get-YouTubeAudio.ps1 create mode 100644 scripts/Get-YouTubeVideo.ps1 create mode 100644 scripts/Get-YouTubeVideoTranslate.ps1 create mode 100644 tools/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1892ab1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Результаты работы +output/* +!output/.gitkeep + +# Локальные зависимости +tools/* +!tools/.gitkeep + +# Временные файлы +temp/ +*.tmp +*.part +*.ytdl + +# PowerShell / Windows +*.log +Thumbs.db +Desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0d5175 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# YouTube Tools + +Набор PowerShell-скриптов для Windows для скачивания видео и аудио с YouTube, а также создания видео с закадровым переводом. + +> Используйте утилиты только для контента, который вы имеете право скачивать и обрабатывать. + +## Возможности + +В репозитории находятся три скрипта: + +| Скрипт | Назначение | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `Get-YouTubeAudio.ps1` | Скачивание лучшей аудиодорожки и конвертация в FLAC | +| `Get-YouTubeVideo.ps1` | Скачивание видео в максимально доступном качестве | +| `Get-YouTubeVideoTranslate.ps1` | Скачивание видео, получение закадрового перевода через `vot-cli` и смешивание перевода с оригинальным звуком | + +Все готовые файлы сохраняются в папку: + +```text +output/ +``` + +## Структура проекта + +```text +youtube-tools/ +├── scripts/ +│ ├── Get-YouTubeAudio.ps1 +│ ├── Get-YouTubeVideo.ps1 +│ └── Get-YouTubeVideoTranslate.ps1 +│ +├── tools/ +│ ├── yt-dlp.exe +│ ├── ffmpeg.exe +│ └── ... +│ +├── output/ +│ └── .gitkeep +│ +├── README.md +├── LICENSE +└── .gitignore +``` + +Бинарные зависимости в Git не добавляются. Папка `tools` предназначена для локального размещения необходимых программ. + +## Поиск зависимостей + +Каждый скрипт ищет необходимые программы в следующем порядке: + +1. В локальной папке `tools`. +2. В системной переменной `PATH`. +3. Если программа не найдена нигде, скрипт показывает ошибку и завершает работу. + +Таким образом, можно использовать как локальные версии утилит, так и программы, установленные в Windows. + +## Зависимости + +Скачать всё с облака: + +[Скачать (.zip)] (https://cloud.kolbox.duckdns.org/lib/b5d0b4e0-fd0c-40fe-b799-3ab0015550d0/file/Gitea%20%D1%80%D0%B5%D0%BF%D0%BE%D0%B7%D0%B8%D1%82%D0%BE%D1%80%D0%B8%D0%B8/youtube-tools/tools.zip?dl=1) + +### yt-dlp + +Используется для скачивания видео и аудио с YouTube. + +Последняя версия: + +https://github.com/yt-dlp/yt-dlp/releases/latest + +Для локального использования положите `yt-dlp.exe` в: + +```text +tools/yt-dlp.exe +``` + +### FFmpeg + +Используется для конвертации аудио, объединения потоков и обработки звуковых дорожек. + +Сборка FFmpeg для Windows: + +https://www.gyan.dev/ffmpeg/builds/ + +После распаковки архива возьмите из папки `bin` файл: + +```text +ffmpeg.exe +``` + +и положите его в: + +```text +tools/ffmpeg.exe +``` + +`ffprobe.exe` текущим скриптам напрямую не требуется. + +### vot-cli + +Используется только скриптом `Get-YouTubeVideoTranslate.ps1`. + +Репозиторий: + +https://github.com/FOSWLY/vot-cli + +Установка: + +```powershell +npm install -g vot-cli +``` + +После установки должна быть доступна команда: + +```powershell +vot-cli +``` + +Скрипт также умеет искать локальный `vot-cli.cmd`, `vot-cli.exe` или `vot-cli.ps1` в папке `tools`. + +### Node.js и npm + +Необходимы для установки и работы `vot-cli`. + +Проверка: + +```powershell +node --version +npm --version +``` + +Скрипт перевода проверяет `node` и `npm` как в папке `tools`, так и в системном `PATH`. + +## Зависимости по скриптам + +| Скрипт | yt-dlp | FFmpeg | Node.js / npm | vot-cli | +| ------------------------------- |:------:|:------:|:-------------:|:-------:| +| `Get-YouTubeAudio.ps1` | ✓ | ✓ | — | — | +| `Get-YouTubeVideo.ps1` | ✓ | ✓ | — | — | +| `Get-YouTubeVideoTranslate.ps1` | ✓ | ✓ | ✓ | ✓ | + +## Использование + +### Скачивание аудио + +Запустите: + +```powershell +.\scripts\Get-YouTubeAudio.ps1 +``` + +Введите ссылку на YouTube. Результат будет сохранён в `output` в формате FLAC. + +### Скачивание видео + +Запустите: + +```powershell +.\scripts\Get-YouTubeVideo.ps1 +``` + +Введите ссылку на YouTube. Скрипт скачает видео в максимально доступном качестве и сохранит результат в `output`. + +### Видео с закадровым переводом + +Запустите: + +```powershell +.\scripts\Get-YouTubeVideoTranslate.ps1 +``` + +Можно ввести одну ссылку или несколько ссылок через запятую. + +Скрипт: + +1. скачивает исходное видео; +2. получает переведённую дорожку через `vot-cli`; +3. уменьшает громкость оригинального звука до `20%`; +4. смешивает оригинальный звук с переводом; +5. сохраняет итоговый MP4 в `output`; +6. удаляет временные файлы. + +## Политика выполнения PowerShell + +Если Windows запрещает запуск `.ps1`, можно разрешить выполнение локальных скриптов для текущего пользователя: + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Изменяйте политику выполнения только если понимаете последствия этой настройки. + +## Выходные файлы + +Все результаты находятся в: + +```text +youtube-tools/output/ +``` + +Папка создаётся автоматически, если отсутствует. + +Содержимое `output` исключено из Git. + +## Лицензия + +MIT. См. файл `LICENSE`. diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/Get-YouTubeAudio.ps1 b/scripts/Get-YouTubeAudio.ps1 new file mode 100644 index 0000000..ed30c12 --- /dev/null +++ b/scripts/Get-YouTubeAudio.ps1 @@ -0,0 +1,123 @@ +#requires -Version 5.1 + +$ErrorActionPreference = "Stop" + +# Корень проекта определяется независимо от текущей папки PowerShell. +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$ToolsDir = Join-Path $ProjectRoot "tools" +$OutputDir = Join-Path $ProjectRoot "output" + +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +function Find-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$LocalNames = @() + ) + + foreach ($localName in $LocalNames) { + $localPath = Join-Path $ToolsDir $localName + if (Test-Path $localPath -PathType Leaf) { + return (Resolve-Path $localPath).Path + } + } + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + return $null +} + +function Require-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$LocalNames = @(), + + [string]$InstallHint = "" + ) + + $tool = Find-Tool -Name $Name -LocalNames $LocalNames + + if (-not $tool) { + Write-Host "" + Write-Host "ОШИБКА: не найдена зависимость '$Name'." -ForegroundColor Red + Write-Host "Проверены:" + Write-Host " 1. Папка: $ToolsDir" + Write-Host " 2. Системная переменная PATH" + + if ($InstallHint) { + Write-Host "" + Write-Host $InstallHint + } + + Write-Host "" + Read-Host "Нажмите Enter для выхода" + exit 1 + } + + return $tool +} + +$YtDlp = Require-Tool ` + -Name "yt-dlp" ` + -LocalNames @("yt-dlp.exe", "yt-dlp.cmd", "yt-dlp.ps1") ` + -InstallHint "Скачать yt-dlp: https://github.com/yt-dlp/yt-dlp/releases/latest" + +$Ffmpeg = Require-Tool ` + -Name "ffmpeg" ` + -LocalNames @("ffmpeg.exe") ` + -InstallHint "Скачать FFmpeg: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z" + +$FfmpegDir = Split-Path -Parent $Ffmpeg + +Write-Host "" +Write-Host "============================================================" +Write-Host " СКАЧИВАНИЕ АУДИО С YOUTUBE" +Write-Host "============================================================" +Write-Host "Результат: $OutputDir" +Write-Host "" + +$Url = Read-Host "Введите URL видео с YouTube" + +if ([string]::IsNullOrWhiteSpace($Url)) { + Write-Host "" + Write-Host "ОШИБКА: ссылка не введена." -ForegroundColor Red + Read-Host "Нажмите Enter для выхода" + exit 1 +} + +$OutputTemplate = Join-Path $OutputDir "%(title)s.%(ext)s" + +Write-Host "" +Write-Host "Скачивание и конвертация аудио в FLAC..." +Write-Host "" + +& $YtDlp ` + "-f" "ba" ` + "--extract-audio" ` + "--audio-format" "flac" ` + "--audio-quality" "0" ` + "--ffmpeg-location" $FfmpegDir ` + "-o" $OutputTemplate ` + $Url + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "ОШИБКА: yt-dlp завершил работу с кодом $LASTEXITCODE." -ForegroundColor Red + Read-Host "Нажмите Enter для выхода" + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "Готово." -ForegroundColor Green +Write-Host "Файл сохранён в: $OutputDir" +Write-Host "" +Read-Host "Нажмите Enter для выхода" diff --git a/scripts/Get-YouTubeVideo.ps1 b/scripts/Get-YouTubeVideo.ps1 new file mode 100644 index 0000000..9e322b1 --- /dev/null +++ b/scripts/Get-YouTubeVideo.ps1 @@ -0,0 +1,120 @@ +#requires -Version 5.1 + +$ErrorActionPreference = "Stop" + +# Корень проекта определяется независимо от текущей папки PowerShell. +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$ToolsDir = Join-Path $ProjectRoot "tools" +$OutputDir = Join-Path $ProjectRoot "output" + +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +function Find-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$LocalNames = @() + ) + + foreach ($localName in $LocalNames) { + $localPath = Join-Path $ToolsDir $localName + if (Test-Path $localPath -PathType Leaf) { + return (Resolve-Path $localPath).Path + } + } + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + return $null +} + +function Require-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$LocalNames = @(), + + [string]$InstallHint = "" + ) + + $tool = Find-Tool -Name $Name -LocalNames $LocalNames + + if (-not $tool) { + Write-Host "" + Write-Host "ОШИБКА: не найдена зависимость '$Name'." -ForegroundColor Red + Write-Host "Проверены:" + Write-Host " 1. Папка: $ToolsDir" + Write-Host " 2. Системная переменная PATH" + + if ($InstallHint) { + Write-Host "" + Write-Host $InstallHint + } + + Write-Host "" + Read-Host "Нажмите Enter для выхода" + exit 1 + } + + return $tool +} + +$YtDlp = Require-Tool ` + -Name "yt-dlp" ` + -LocalNames @("yt-dlp.exe", "yt-dlp.cmd", "yt-dlp.ps1") ` + -InstallHint "Скачать yt-dlp: https://github.com/yt-dlp/yt-dlp/releases/latest" + +$Ffmpeg = Require-Tool ` + -Name "ffmpeg" ` + -LocalNames @("ffmpeg.exe") ` + -InstallHint "Скачать FFmpeg: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z" + +$FfmpegDir = Split-Path -Parent $Ffmpeg + +Write-Host "" +Write-Host "============================================================" +Write-Host " СКАЧИВАНИЕ ВИДЕО С YOUTUBE" +Write-Host "============================================================" +Write-Host "Результат: $OutputDir" +Write-Host "" + +$Url = Read-Host "Введите URL видео с YouTube" + +if ([string]::IsNullOrWhiteSpace($Url)) { + Write-Host "" + Write-Host "ОШИБКА: ссылка не введена." -ForegroundColor Red + Read-Host "Нажмите Enter для выхода" + exit 1 +} + +$OutputTemplate = Join-Path $OutputDir "%(title)s.%(ext)s" + +Write-Host "" +Write-Host "Скачивание видео в максимально доступном качестве..." +Write-Host "" + +& $YtDlp ` + "-f" "bv*+ba/b" ` + "--ffmpeg-location" $FfmpegDir ` + "-o" $OutputTemplate ` + $Url + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "ОШИБКА: yt-dlp завершил работу с кодом $LASTEXITCODE." -ForegroundColor Red + Read-Host "Нажмите Enter для выхода" + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "Готово." -ForegroundColor Green +Write-Host "Файл сохранён в: $OutputDir" +Write-Host "" +Read-Host "Нажмите Enter для выхода" diff --git a/scripts/Get-YouTubeVideoTranslate.ps1 b/scripts/Get-YouTubeVideoTranslate.ps1 new file mode 100644 index 0000000..1e95446 --- /dev/null +++ b/scripts/Get-YouTubeVideoTranslate.ps1 @@ -0,0 +1,245 @@ +#requires -Version 5.1 + +$ErrorActionPreference = "Stop" + +# Корень проекта определяется независимо от текущей папки PowerShell. +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$ToolsDir = Join-Path $ProjectRoot "tools" +$OutputDir = Join-Path $ProjectRoot "output" + +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +function Find-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$LocalNames = @() + ) + + foreach ($localName in $LocalNames) { + $localPath = Join-Path $ToolsDir $localName + if (Test-Path $localPath -PathType Leaf) { + return (Resolve-Path $localPath).Path + } + } + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + return $null +} + +function Require-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$LocalNames = @(), + + [string]$InstallHint = "" + ) + + $tool = Find-Tool -Name $Name -LocalNames $LocalNames + + if (-not $tool) { + Write-Host "" + Write-Host "ОШИБКА: не найдена зависимость '$Name'." -ForegroundColor Red + Write-Host "Проверены:" + Write-Host " 1. Папка: $ToolsDir" + Write-Host " 2. Системная переменная PATH" + + if ($InstallHint) { + Write-Host "" + Write-Host $InstallHint + } + + Write-Host "" + Read-Host "Нажмите Enter для выхода" + exit 1 + } + + return $tool +} + +$YtDlp = Require-Tool ` + -Name "yt-dlp" ` + -LocalNames @("yt-dlp.exe", "yt-dlp.cmd", "yt-dlp.ps1") ` + -InstallHint "Скачать yt-dlp: https://github.com/yt-dlp/yt-dlp/releases/latest" + +$Ffmpeg = Require-Tool ` + -Name "ffmpeg" ` + -LocalNames @("ffmpeg.exe") ` + -InstallHint "Скачать FFmpeg: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z" + +$VotCli = Require-Tool ` + -Name "vot-cli" ` + -LocalNames @("vot-cli.cmd", "vot-cli.exe", "vot-cli.ps1") ` + -InstallHint "Установка vot-cli: npm install -g vot-cli" + +$Node = Require-Tool ` + -Name "node" ` + -LocalNames @("node.exe") ` + -InstallHint "Установите Node.js: https://nodejs.org/" + +$Npm = Require-Tool ` + -Name "npm" ` + -LocalNames @("npm.cmd", "npm.exe", "npm.ps1") ` + -InstallHint "npm устанавливается вместе с Node.js." + +$FfmpegDir = Split-Path -Parent $Ffmpeg + +function Get-SafeFileName { + param([string]$Name) + + $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() + foreach ($char in $invalidChars) { + $Name = $Name.Replace([string]$char, "_") + } + + return $Name.Trim() +} + +function Process-Video { + param( + [Parameter(Mandatory = $true)] + [string]$VideoLink, + + [double]$OriginalSoundRatio = 0.2 + ) + + $TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("youtube-tools-" + [Guid]::NewGuid().ToString("N")) + $TempVideoDir = Join-Path $TempRoot "video" + $TempAudioDir = Join-Path $TempRoot "audio" + + New-Item -ItemType Directory -Path $TempVideoDir -Force | Out-Null + New-Item -ItemType Directory -Path $TempAudioDir -Force | Out-Null + + try { + Write-Host "" + Write-Host "Получение названия видео..." + + $Title = (& $YtDlp "--get-title" "--no-playlist" $VideoLink | Select-Object -First 1) + + if ([string]::IsNullOrWhiteSpace($Title)) { + $Title = "YouTubeVideo" + } + + $SafeTitle = Get-SafeFileName $Title + $TempVideoTemplate = Join-Path $TempVideoDir "%(title)s.%(ext)s" + + Write-Host "Скачивание исходного видео..." + + & $YtDlp ` + "--no-playlist" ` + "-f" "bv*+ba/b" ` + "--ffmpeg-location" $FfmpegDir ` + "-o" $TempVideoTemplate ` + $VideoLink + + if ($LASTEXITCODE -ne 0) { + throw "yt-dlp завершил работу с кодом $LASTEXITCODE." + } + + $VideoFile = Get-ChildItem -Path $TempVideoDir -File | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $VideoFile) { + throw "Не найден скачанный видеофайл." + } + + Write-Host "Получение переведённой аудиодорожки..." + + & $VotCli $VideoLink "--output" $TempAudioDir + + if ($LASTEXITCODE -ne 0) { + throw "vot-cli завершил работу с кодом $LASTEXITCODE." + } + + $AudioFile = Get-ChildItem -Path $TempAudioDir -File | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $AudioFile) { + throw "vot-cli не создал аудиофайл." + } + + $OutputFile = Join-Path $OutputDir ($SafeTitle + "_перевод.mp4") + + Write-Host "Смешивание оригинального звука и перевода..." + + $Filter = "[0:a]volume=$OriginalSoundRatio[original];[original][1:a]amix=inputs=2:duration=longest[audio_out]" + + & $Ffmpeg ` + "-hide_banner" ` + "-i" $VideoFile.FullName ` + "-i" $AudioFile.FullName ` + "-c:v" "copy" ` + "-c:a" "aac" ` + "-b:a" "128k" ` + "-filter_complex" $Filter ` + "-map" "0:v:0" ` + "-map" "[audio_out]" ` + "-movflags" "+faststart" ` + "-y" ` + $OutputFile + + if ($LASTEXITCODE -ne 0) { + throw "FFmpeg завершил работу с кодом $LASTEXITCODE." + } + + Write-Host "" + Write-Host "Готово: $OutputFile" -ForegroundColor Green + } + finally { + if (Test-Path $TempRoot) { + Remove-Item -Path $TempRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Write-Host "" +Write-Host "============================================================" +Write-Host " СКАЧИВАНИЕ YOUTUBE С ЗАКАДРОВЫМ ПЕРЕВОДОМ" +Write-Host "============================================================" +Write-Host "Результат: $OutputDir" +Write-Host "" + +$VideoLinksInput = Read-Host "Введите ссылку или несколько ссылок через запятую" + +if ([string]::IsNullOrWhiteSpace($VideoLinksInput)) { + Write-Host "" + Write-Host "ОШИБКА: ссылки не введены." -ForegroundColor Red + Read-Host "Нажмите Enter для выхода" + exit 1 +} + +$VideoLinks = $VideoLinksInput -split "," | + ForEach-Object { $_.Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + +foreach ($VideoLink in $VideoLinks) { + Write-Host "" + Write-Host "------------------------------------------------------------" + Write-Host "Обработка: $VideoLink" + Write-Host "------------------------------------------------------------" + + try { + Process-Video -VideoLink $VideoLink -OriginalSoundRatio 0.2 + } + catch { + Write-Host "" + Write-Host "ОШИБКА: $($_.Exception.Message)" -ForegroundColor Red + } +} + +Write-Host "" +Write-Host "Обработка завершена." +Write-Host "Результаты находятся в: $OutputDir" +Write-Host "" +Read-Host "Нажмите Enter для выхода" diff --git a/tools/.gitkeep b/tools/.gitkeep new file mode 100644 index 0000000..e69de29