Часть 3. Разбор скрипта QuillBotBridge.ps1
Этот PowerShell-скрипт — сетевое ядро переводчика QuillBot. Он использует системный класс .NET System.Net.WebSockets.ClientWebSocket для работы по протоколу WebSocket.
Схема протокола QuillBot WebSocket:
Подключение к wss://stream.quillbot.com/?anonId=....
Ожидание от сервера события ping -> отправка в ответ pong.
Ожидание события авторизации auth_success -> отправка метаданных клиента (update_connection) и инициализация редактора переводчика (init_editor).
Подтверждение приема пакетов (система ack / квитирование).
При получении подтверждения об успешной инициализации редактора отправляется пакет on_content_change с исходным текстом для перевода.
Ожидание ответа с типом processing_success. Из него извлекаются данные автоопределения языка (detect-language) и массив переведенных предложений (translator).
Результат собирается и записывается в локальный JSON-файл.
Вот этот скрипт с подробным разбором:
code Powershell
# Останавливать выполнение скрипта при любой возникшей ошибке
$ErrorActionPreference = "Stop"
# Принудительная установка вывода консоли в UTF-8 без сигнатуры BOM
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
# ============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================================
/**
* Генерирует уникальный ID события для WebSocket протокола Quillbot.
* Состоит из текущего UNIX-времени в миллисекундах и случайной строки (хвост GUID).
*/
function New-EventId {
"{0}-{1}" -f [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(), ([guid]::NewGuid().ToString("N").Substring(0, 9))
}
/**
* Записывает финальный хеш-результат перевода/определения в JSON-файл обмена.
*/
function Write-BridgeResult {
param(
[hashtable]$Data
)
$path = $env:QTB_QUILLBOT_RESULT_FILE
$directory = [System.IO.Path]::GetDirectoryName($path)
# Создаем папку _temp, если она ещё не создана
if (-not [string]::IsNullOrEmpty($directory) -and -not [System.IO.Directory]::Exists($directory)) {
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
}
# Сериализуем данные в компактный JSON и сохраняем в файл (UTF-8 без BOM)
$json = $Data | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false))
}
/**
* Отправляет JSON-сообщение в WebSocket-канал.
*/
function Send-BridgeJson {
param(
[System.Net.WebSockets.ClientWebSocket]$Socket,
[object]$Data,
[System.Threading.CancellationToken]$Token
)
$json = $Data | ConvertTo-Json -Depth 12 -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$segment = [ArraySegment[byte]]::new($bytes)
# Синхронная отправка кадра типа Text
$null = $Socket.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $Token).GetAwaiter().GetResult()
}
/**
* Принимает входящее сообщение из WebSocket-канала.
* Собирает фрагментированные пакеты данных в единый массив байт и парсит JSON.
*/
function Receive-BridgeJson {
param(
[System.Net.WebSockets.ClientWebSocket]$Socket,
[System.Threading.CancellationToken]$Token
)
$buffer = New-Object byte[] 8192 # Буфер чтения размером 8 Кб
$stream = New-Object System.IO.MemoryStream
try {
do {
$segment = [ArraySegment[byte]]::new($buffer)
# Ожидание чтения порции данных
$result = $Socket.ReceiveAsync($segment, $Token).GetAwaiter().GetResult()
if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
throw "Socket closed"
}
# Пишем прочитанные байты в поток памяти
$stream.Write($buffer, 0, $result.Count)
} while (-not $result.EndOfMessage) # Цикл крутится, пока не придет флаг конца фрейма
$json = [System.Text.Encoding]::UTF8.GetString($stream.ToArray())
return $json | ConvertFrom-Json
} finally {
$stream.Dispose()
}
}
/**
* Отправка подтверждения о получении сообщения (Acknowledge) на сервер.
* Сервер QuillBot требует 'ack' на каждое значимое событие, иначе разорвет связь.
*/
function Send-BridgeAck {
param(
[System.Net.WebSockets.ClientWebSocket]$Socket,
[object]$Message,
[System.Threading.CancellationToken]$Token
)
# Не подтверждаем сообщения без ID события, а также системные ack / auth_success
if (-not $Message.eventId -or $Message.event -eq "ack" -or $Message.event -eq "auth_success") {
return
}
Send-BridgeJson $Socket @{
event = "ack"
data = @{
responseEventId = [string]$Message.eventId
eventId = New-EventId
eventTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
} $Token
}
/** Нормализация кодов языков */
function Normalize-DetectedLanguage {
param(
[string]$Code
)
if ($null -eq $Code) { $Code = "" }
switch ($Code.Trim()) {
"he" { return "iw" }
"he-IL" { return "iw" }
"zh" { return "zh-CN" }
"zh-CN" { return "zh-CN" }
"zh-Hans" { return "zh-CN" }
"en-US" { return "en" }
"en-GB" { return "en" }
"de-DE" { return "de" }
"de-CH" { return "de" }
default {
if ($Code -match "-") {
return $Code.Split("-")[0]
}
return $Code
}
}
}
/**
* Собирает разрозненные переведенные предложения обратно в текст в правильном порядке.
*/
function Join-TranslatedSentences {
param(
[object[]]$Sentences
)
if (-not $Sentences) { return "" }
# Сортировка по индексу предложения sentenceIndex и склейка в единую строку
return (($Sentences | Sort-Object sentenceIndex | ForEach-Object { [string]$_.translation }) -join "").Trim()
}
// ============================================================================
// ОСНОВНОЙ КОД ВЫПОЛНЕНИЯ
// ============================================================================
try {
# Чтение настроек из переменных окружения (переданы из JScript)
$mode = if ([string]::IsNullOrEmpty($env:QTB_QUILLBOT_MODE)) { "translate" } else { $env:QTB_QUILLBOT_MODE }
$mode = $mode.Trim().ToLowerInvariant()
$sourceLanguage = if ([string]::IsNullOrEmpty($env:QTB_QUILLBOT_SOURCE)) { "auto" } else { $env:QTB_QUILLBOT_SOURCE.Trim() }
$targetLanguage = if ([string]::IsNullOrEmpty($env:QTB_QUILLBOT_TARGET)) { "en-US" } else { $env:QTB_QUILLBOT_TARGET.Trim() }
# Декодирование текста из Base64
$textB64 = if ([string]::IsNullOrEmpty($env:QTB_QUILLBOT_TEXT_B64)) { "" } else { $env:QTB_QUILLBOT_TEXT_B64 }
$text = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($textB64))
# Генерация случайных ID сессии и редактора
$anonId = ([guid]::NewGuid().ToString("N")).Substring(0, 16)
$editorId = ([guid]::NewGuid().ToString("N")).Substring(0, 16)
$requestEventId = New-EventId
# Формирование строки подключения
$uri = [Uri]::new(("wss://stream.quillbot.com/?anonId={0}&abIdV2=8&platformType=webapp" -f $anonId))
# Настройка таймаута операции (30 секунд)
$tokenSource = [System.Threading.CancellationTokenSource]::new()
$tokenSource.CancelAfter(30000)
$token = $tokenSource.Token
# Инициализация и настройка WebSocket клиента
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
$socket.Options.SetRequestHeader("Origin", "https://quillbot.com")
# Подключение к серверу
$null = $socket.ConnectAsync($uri, $token).GetAwaiter().GetResult()
# Вспомогательные флаги состояний конечного автомата
$updateAck = $false
$initAck = $false
$requestSent = $false
$translatorPayload = $null
$detectedLanguage = ""
$done = $false
# Цикл чтения и обработки событий от сервера
while (-not $token.IsCancellationRequested -and -not $done) {
$message = Receive-BridgeJson $socket $token
# Шаг А: Обработка пинга (поддержание соединения активным)
if ($message.event -eq "ping") {
Send-BridgeJson $socket @{
event = "pong"
data = @{
eventId = New-EventId
eventTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
} $token
continue
}
# Шаг B: Успешная первичная авторизация -> отправка метаданных и инициализация редактора
if ($message.event -eq "auth_success") {
# 1. Отправляем фейковые метаданные браузера/ОС
Send-BridgeJson $socket @{
event = "update_connection"
data = @{
deviceMeta = @{
operatingSystem = "Windows 10"
browser = "Edge"
browserVersion = "147"
deviceLanguage = "de"
networkMeta = @{ connectionType = "4g"; downlink = 10 }
platformType = "webapp"
platformVersion = "v41.17.1"
type = "Desktop"
}
userMeta = @{ anonId = $anonId; abIdV2 = "8" }
eventId = New-EventId
eventTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}
} $token
# 2. Инициализируем редактор (модуль TRANSLATOR)
Send-BridgeJson $socket @{
event = "init_editor"
data = @{
editorGroupId = "tltr"
editorId = $editorId
editorVersion = "1"
hostUrl = "https://quillbot.com/translate"
product = "TRANSLATOR"
eventId = New-EventId
eventTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
retryCount = 0
}
} $token
continue
}
# Отправляем подтверждение получения любого другого пакета
Send-BridgeAck $socket $message $token
# Шаг C: Контроль последовательности рукопожатия
# Мы должны убедиться, что сервер QuillBot подтвердил (ack) как 'update_connection', так и 'init_editor'
if (-not $requestSent -and $message.event -eq "ack" -and $message.requestEventId) {
if (-not $updateAck) {
$updateAck = $true # Подтверждено обновление соединения
continue
}
if (-not $initAck) {
$initAck = $true # Подтверждена инициализации редактора -> отправляем текст на перевод!
Send-BridgeJson $socket @{
event = "on_content_change"
data = @{
editorGroupId = "tltr"
editorId = $editorId
editorContentVersion = "1"
processingType = "TRANSLATOR_TRANSLATE"
meta = @{
sourceLanguage = $sourceLanguage
targetLanguage = $targetLanguage
tone = "auto"
editorStartText = $text
}
changes = @(@{
text = $text
originalIndex = 0
processingType = "TRANSLATOR_TRANSLATE"
})
hostUrl = "https://quillbot.com/translate"
product = "TRANSLATOR"
changeType = "USER_CHANGE"
eventId = $requestEventId
eventTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
retryCount = 0
}
} $token
$requestSent = $true
continue
}
}
# Игнорируем сторонние события, не относящиеся к нашему запросу перевода
if ($message.requestEventId -ne $requestEventId -or $message.event -ne "processing_success") {
continue
}
# Шаг D: Разбор результатов обработки текста от сервера
switch ($message.data.type) {
"detect-language" {
# Сохраняем определенный сервером язык
$detectedLanguage = Normalize-DetectedLanguage ([string]$message.data.language.langCode)
if ($mode -eq "detect") {
Write-BridgeResult @{ sourceLanguage = $detectedLanguage }
$done = $true
}
}
"translator" {
# Сохраняем сырой массив перевода предложений
$translatorPayload = $message.data.translatedSentences
}
"request-complete" {
# Запрос полностью обработан сервером, можно завершать работу и отдавать данные
if ($mode -eq "detect") {
Write-BridgeResult @{ sourceLanguage = $detectedLanguage }
$done = $true
}
elseif ($translatorPayload) {
Write-BridgeResult @{
translation = (Join-TranslatedSentences $translatorPayload)
sourceLanguage = $detectedLanguage
targetLanguage = Normalize-DetectedLanguage $targetLanguage
}
$done = $true
}
}
}
}
# Безопасное закрытие WebSocket соединения
try {
if ($socket.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
$null = $socket.CloseOutputAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "done", [System.Threading.CancellationToken]::None).GetAwaiter().GetResult()
}
} catch {}
$socket.Dispose()
# Если за отведенный таймаут файл результата не появился — бросаем ошибку таймаута
if (-not (Test-Path -LiteralPath $env:QTB_QUILLBOT_RESULT_FILE)) {
Write-BridgeResult @{ error = "Timed out waiting for QuillBot response" }
}
} catch {
# В случае критического сбоя (.NET Exception) — записываем её текст в файл обмена, чтобы QTranslate отобразил ошибку
Write-BridgeResult @{ error = $_.Exception.Message }
}
Зачем это нужно для поддержки и написания новых сервисов?
При проектировании интеграции с новыми сайтами-переводчиками, которые работают по протоколу WebSocket (или используют сложную защиту Cloudflare, заголовки, ротацию куки), схема с внешним скриптом-мостом является единственным решением для QTranslate.
Вы можете скопировать логику Service.js и переписать QuillBotBridge.ps1 под новые сервисы (например, DeepL Pro, ChatGPT, Claude или Gemini API), изменив тело WebSocket/HTTP-запроса внутри PowerShell-скрипта [1, 2]. JScript-оболочка при этом останется практически без изменений, выполняя лишь роль связующего звена с QTranslate [1, 2].