ZennoPoster + ADB: управление физическим Android в Windows без лишних приложений (для примера используем — Telegram)

kolina

Client
Регистрация
05.10.2019
Сообщения
184
Благодарностей
86
Баллы
28
Раньше описывал, как подружить ZennoPoster, Windows и эмулятор статья здесь ZennoPoster + Android Studio AVD. Но на практике эмулятор при свайпах съедает до ~2 ГБ видеопамяти на один экземпляр; у меня работали два, а локальная ИИ-модель тоже требует VRAM. Поэтому решил отказаться от эмулятора. Не ожидал, что так просто подключить физический телефон и задействовать его ресурсы: в итоге даже старый смартфон с разбитым стеклом за $30 разгружает ПК, освобождая те самые ~2 ГБ видеопамяти.

Мы подключаем физический Android-телефон по USB, запускаем Telegram, снимаем UI-дамп, парсим сообщения и управляем экраном — всё из C#-скрипта в ZennoPoster. Легко повторяемо.

Используем: телефон Samsung Galaxy A12

Что именно мы делаем
  • Поднимаем ADB-соединение с реальным телефоном по USB.
  • Будим экран, снимаем блокировку, удерживаем дисплей включённым.
  • Запускаем Telegram (оба варианта Activity), при необходимости проверяем фокус.
  • Снимаем XML-дамп интерфейса (uiautomator dump) и пишем его в переменную test.
  • Находим ленту сообщений (RecyclerView), кликаем «Перейти в конец», выполняем ровно svipe (количество заданное в переменной) свайпов вниз, после каждого — дамп и сбор текста.
  • Складываем уникальные сообщения в «Список».
Что мы используем
  • ADB (platform-tools) по пути, например:
    C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe (путь оставил как у меня, кому нужно поменяет)
  • USB-драйвер устройства (у Samsung — «Samsung USB Driver»).
  • На телефоне включаем Параметры разработчика → USB-отладка, подтверждаем RSA-ключ для ПК («Всегда разрешать»).
ADB: быстрая проверка
Подключаем телефон по USB. В PowerShell выполняем:
C#:
& "C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" kill-server
& "C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" start-server
& "C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" devices
Видим:
C#:
List of devices attached
R58R70EYHHW    device
Серийник (R58R70EYHHW) берём в код.
Базовый каркас: вызовы ADB из C#
Мы запускаем adb.exe напрямую и читаем вывод асинхронно, чтобы не ловить «пустой stdout».
C#:
// Жёсткие настройки
string adbPath      = @"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe";
string deviceSerial = "R58R70EYHHW";
const int CMD_TIMEOUT_MS = 10000;

string RunAdb(string args, int timeoutMs = CMD_TIMEOUT_MS)
{
    var psi = new System.Diagnostics.ProcessStartInfo {
        FileName = adbPath, Arguments = args,
        UseShellExecute = false, CreateNoWindow = true,
        RedirectStandardOutput = true, RedirectStandardError = true,
        StandardOutputEncoding = System.Text.Encoding.UTF8,
        StandardErrorEncoding  = System.Text.Encoding.UTF8
    };
    using (var p = new System.Diagnostics.Process { StartInfo = psi })
    {
        var so = new System.Text.StringBuilder();
        var se = new System.Text.StringBuilder();
        p.OutputDataReceived += (s, e) => { if (e.Data != null) so.AppendLine(e.Data); };
        p.ErrorDataReceived  += (s, e) => { if (e.Data != null) se.AppendLine(e.Data); };
        p.Start(); p.BeginOutputReadLine(); p.BeginErrorReadLine();
        if (!p.WaitForExit(timeoutMs)) { try { p.Kill(); } catch {} }
        if (!p.HasExited)              { try { p.Kill(); } catch {} }
        var err = se.ToString();
        if (!string.IsNullOrWhiteSpace(err)) project.SendWarningToLog("[adb-err] " + err.Trim());
        return so.ToString();
    }
}

bool IsDeviceOnline(out bool unauthorized)
{
    unauthorized = false;
    var s = RunAdb("devices");
    foreach (var line in (s ?? "").Replace("\r","").Split('\n"))
    {
        var l = (line ?? "").Trim();
        if (string.IsNullOrEmpty(l) || l.StartsWith("List of devices")) continue;
        var parts = l.Split(new[]{'\t',' '}, StringSplitOptions.RemoveEmptyEntries);
        if (parts.Length < 2) continue;
        if (parts[0] == deviceSerial)
        {
            var st = parts[1].ToLowerInvariant();
            if (st.Contains("unauthorized")) { unauthorized = true; return false; }
            return st == "device";
        }
    }
    return false;
}
Управление экраном и запуск Telegram
Мы будим устройство, снимаем блокировку и удерживаем экран включённым; далее стартуем Telegram.
C#:
// Экран
RunAdb($"-s {deviceSerial} shell input keyevent KEYCODE_WAKEUP");
RunAdb($"-s {deviceSerial} shell wm dismiss-keyguard");
RunAdb($"-s {deviceSerial} shell svc power stayon usb");

// Telegram: пробуем оба варианта Activity
var start = RunAdb($"-s {deviceSerial} shell am start -n org.telegram.messenger/.ui.LaunchActivity");
if (start.ToLowerInvariant().Contains("error"))
    RunAdb($"-s {deviceSerial} shell am start -n org.telegram.messenger/org.telegram.ui.LaunchActivity");
Снятие дампа UI и запись в test
Сначала пытаемся exec-out /dev/tty, при пустом результате — фолбэк через файл.
C#:
string ExtractXml(string raw)
{
    if (string.IsNullOrWhiteSpace(raw)) return null;
    int s = raw.IndexOf("<hierarchy", StringComparison.OrdinalIgnoreCase);
    if (s < 0) return null;
    int e = raw.LastIndexOf("</hierarchy>", StringComparison.OrdinalIgnoreCase);
    if (e < 0) return null;
    e += "</hierarchy>".Length;
    if (e > raw.Length) e = raw.Length;
    return raw.Substring(s, e - s);
}

string DumpUI()
{
    string raw = RunAdb($"-s {deviceSerial} exec-out uiautomator dump /dev/tty");
    string xml = ExtractXml(raw);

    if (string.IsNullOrWhiteSpace(xml))
    {
        RunAdb($"-s {deviceSerial} shell uiautomator dump /sdcard/window_dump.xml");
        string fileRaw = RunAdb($"-s {deviceSerial} exec-out cat /sdcard/window_dump.xml");
        xml = ExtractXml(fileRaw);
    }

    if (string.IsNullOrWhiteSpace(xml))
        throw new Exception("uiautomator не вернул XML");

    project.Variables["test"].Value = xml; // всегда держим актуальный дамп
    return xml;
}
Как мы парсим Telegram
  • Лента — androidx.recyclerview.widget.RecyclerView.
  • Сообщения — дочерние android.view.ViewGroup с непустым @text.
  • Кнопка «Перейти в конец» — @clickable='true' и @Content-desc с ключевой фразой.
Полезные хелперы:
C#:
(int w, int h) GetScreenSize()
{
    string s = RunAdb($"-s {deviceSerial} shell wm size");
    var m = System.Text.RegularExpressions.Regex.Match(s ?? "", @"Physical\s+size:\s*(\d+)\s*x\s*(\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
    return m.Success ? (int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value)) : (720,1527);
}

int[] ParseBounds(string b)
{
    var m = System.Text.RegularExpressions.Regex.Match(b ?? "", @"\[(\d+),(\d+)\]\[(\d+),(\d+)\]");
    if (!m.Success) return null;
    return new[]{ int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[3].Value), int.Parse(m.Groups[4].Value) };
}

int[] FindRightChatRecyclerBounds(string xml)
{
    var doc = new System.Xml.XmlDocument(); doc.LoadXml(xml);
    var nodes = doc.SelectNodes("//node[@class='androidx.recyclerview.widget.RecyclerView']");
    if (nodes == null || nodes.Count == 0) return null;

    var (W,H) = GetScreenSize();
    int[] best = null;
    foreach (System.Xml.XmlNode n in nodes)
    {
        var bb = ParseBounds(n.Attributes?["bounds"]?.Value);
        if (bb == null) continue;
        if (bb[0] >= W/2) { best = bb; break; }
    }
    if (best == null)
    {
        var last = nodes[nodes.Count-1];
        best = ParseBounds(last.Attributes?["bounds"]?.Value);
    }
    return best;
}

System.Collections.Generic.List<(string text,int top,int bottom)> ParseMessages(string xml)
{
    var res = new System.Collections.Generic.List<(string text,int top,int bottom)>();
    var doc = new System.Xml.XmlDocument(); doc.LoadXml(xml);
    var nodes = doc.SelectNodes("//node[@class='androidx.recyclerview.widget.RecyclerView']/node[@class='android.view.ViewGroup' and string-length(@text)>0]");
    if (nodes != null)
    {
        foreach (System.Xml.XmlNode n in nodes)
        {
            var a = n.Attributes;
            string t = a?["text"]?.Value ?? "";
            if (string.IsNullOrWhiteSpace(t)) continue;
            var bb = ParseBounds(a?["bounds"]?.Value);
            if (bb == null) continue;
            res.Add((t, bb[1], bb[3]));
        }
    }
    res.Sort((x,y)=> x.bottom.CompareTo(y.bottom)); // снизу — новее
    return res;
}

(bool ok,int cx,int cy) FindBottomButton(string xml)
{
    var doc = new System.Xml.XmlDocument(); doc.LoadXml(xml);
    var node = doc.SelectSingleNode("//node[@clickable='true' and (@content-desc='Перейти в конец' or contains(@content-desc,'в конец'))]");
    if (node == null) return (false,0,0);
    var bb = ParseBounds(node.Attributes?["bounds"]?.Value);
    if (bb == null) return (false,0,0);
    int cx = Math.Max(bb[0]+5, Math.Min((bb[0]+bb[2])/2, bb[2]-5));
    int cy = Math.Max(bb[1]+5, Math.Min((bb[1]+bb[3])/2, bb[3]-5));
    return (true,cx,cy);
}
Главная задача: «перейти в конец → svipe свайпов → сбор»
Мы сохраняем исходную логику: если кнопка не найдена — свайпы не делаем; каждый цикл — свайп → дамп → парс.
C#:
// Выбор количества свайпов из переменной проекта
string svipeStr = (project.Variables["svipe"]?.Value ?? "0").Trim();
int svipeCount = 0; int.TryParse(svipeStr, out svipeCount); if (svipeCount < 0) svipeCount = 0;

// Тайминги
const int SWIPE_DURATION_MS  = 1000;
const int SWIPE_SLEEP_MS     = 400;
const int DUMP_SETTLE_MS     = 320;
const int INITIAL_SETTLE_MS  = 900;
const int TAP_RETRY          = 2;
const int PAUSE_AFTER_TAP_MS = 350;

// Сбор в «Список 1»
IZennoList outList = project.Lists["Список 1"];
outList.Clear();

try
{
    bool una;
    if (!IsDeviceOnline(out una))
        throw new Exception(una ? "Телефон unauthorized — подтверждаем RSA на экране." : "Телефон не в состоянии device — проверяем кабель/драйвер/отладку.");

    // Экран
    RunAdb($"-s {deviceSerial} shell input keyevent KEYCODE_WAKEUP");
    RunAdb($"-s {deviceSerial} shell wm dismiss-keyguard");
    RunAdb($"-s {deviceSerial} shell svc power stayon usb");
    if (INITIAL_SETTLE_MS > 0) System.Threading.Thread.Sleep(INITIAL_SETTLE_MS);

    var pool = new System.Collections.Generic.List<(string text,int top,int bottom)>();

    // Первый дамп и сбор
    string xml0 = DumpUI();
    pool.AddRange(ParseMessages(xml0));

    // Геометрия свайпа
    var rv = FindRightChatRecyclerBounds(xml0);
    var (W,H) = GetScreenSize();
    int SAFE_X, SW_START_Y, SW_END_Y;

    if (rv != null)
    {
        SAFE_X     = Math.Max(rv[0]+10, rv[2]-10);
        SW_START_Y = rv[1] + 200;
        SW_END_Y   = rv[3] - 250;
        if (SW_END_Y <= SW_START_Y) { SW_START_Y = rv[1]+120; SW_END_Y = rv[3]-120; }
    }
    else
    {
        SAFE_X     = Math.Max(10, W - 20);
        SW_START_Y = (int)(H * 0.35);
        SW_END_Y   = (int)(H * 0.44);
    }

    void SwipeDownRight()
    {
        RunAdb($"-s {deviceSerial} shell input swipe {SAFE_X} {SW_START_Y} {SAFE_X} {SW_END_Y} {SWIPE_DURATION_MS}");
        System.Threading.Thread.Sleep(SWIPE_SLEEP_MS);
    }

    // Переход в конец
    var btn = FindBottomButton(xml0);
    if (btn.ok)
    {
        bool atBottom = false;
        for (int tr=0; tr<=TAP_RETRY; tr++)
        {
            RunAdb($"-s {deviceSerial} shell input tap {btn.cx} {btn.cy}");
            System.Threading.Thread.Sleep(PAUSE_AFTER_TAP_MS);
            string after = DumpUI();
            pool.AddRange(ParseMessages(after));
            var still = FindBottomButton(after);
            if (!still.ok) { atBottom = true; break; }
        }

        // Свайпы только если реально попали в конец
        if (atBottom && svipeCount > 0)
        {
            for (int i = 0; i < svipeCount; i++)
            {
                SwipeDownRight();
                string xml = DumpUI();
                pool.AddRange(ParseMessages(xml));
            }
        }
    }
    else
    {
        project.SendInfoToLog("Кнопка 'Перейти в конец' не найдена — свайпы не выполняем.");
    }

    // Уникализация (снизу — новее)
    pool.Sort((a,b)=> a.bottom.CompareTo(b.bottom));
    var seen = new System.Collections.Generic.HashSet<string>();
    foreach (var m in pool)
    {
        var t = (m.text ?? "").Trim();
        if (t.Length == 0) continue;
        if (seen.Add(t)) outList.Add(t);
    }

    project.SendInfoToLog($"[DONE] Уникальных сообщений: {outList.Count}. svipe={svipeCount}");
    return 0;
}
catch (Exception ex)
{
    project.SendErrorToLog("[ERR] " + ex.Message);
    return -1;
}
Настройки и устойчивость
Мы держим консервативные тайминги: SWIPE_DURATION_MS=1000, SWIPE_SLEEP_MS=400, DUMP_SETTLE_MS=320. На быстрых устройствах можем ускоряться (220–350 мс жест и ~200 мс паузы).
Если локализация отличается — расширяем XPath для @Content-desc кнопки «Перейти в конец». Если Telegram открыт не в ленте — запускаем его и входим в нужный чат заранее (интентом tg://resolve?...).
Итог
Мы управляем физическим телефоном напрямую через ADB из ZennoPoster, запускаем Telegram, снимаем XML-дамп интерфейса, парсим сообщения и прокручиваем ленту с контролем шага. Без сторонних приложений на телефоне и компьтере, а главное экономим ресурсы.
Написал полный сценарий — вставляем, настраиваем adbPath и deviceSerial, задаём svipe и запускаем.

 

Для запуска проектов требуется программа ZennoPoster.
Это основное приложение, предназначенное для выполнения автоматизированных шаблонов действий (ботов).
Подробнее...

Для того чтобы запустить шаблон, откройте программу ZennoPoster. Нажмите кнопку «Добавить», и выберите файл проекта, который хотите запустить.
Подробнее о том, где и как выполняется проект.

code

Administrator
Регистрация
04.06.2025
Сообщения
248
Благодарностей
134
Баллы
43
Раньше описывал, как подружить ZennoPoster, Windows и эмулятор статья здесь ZennoPoster + Android Studio AVD. Но на практике эмулятор при свайпах съедает до ~2 ГБ видеопамяти на один экземпляр; у меня работали два, а локальная ИИ-модель тоже требует VRAM. Поэтому решил отказаться от эмулятора. Не ожидал, что так просто подключить физический телефон и задействовать его ресурсы: в итоге даже старый смартфон с разбитым стеклом за $30 разгружает ПК, освобождая те самые ~2 ГБ видеопамяти.

Мы подключаем физический Android-телефон по USB, запускаем Telegram, снимаем UI-дамп, парсим сообщения и управляем экраном — всё из C#-скрипта в ZennoPoster. Легко повторяемо.

Используем: телефон Samsung Galaxy A12
Хорошая статья ;-)
Подскажите, пожалуйста, вы приготовите что-то подобное, но более массивное в будущих конкурсах?)
 
  • Спасибо
Реакции: kolina

kolina

Client
Регистрация
05.10.2019
Сообщения
184
Благодарностей
86
Баллы
28
Не участвовал в конкурсах никогда.
Если буду решать какие задачи буду делится и дополнять.
На выходных буду пробовать ферму из 4 телефонов отпишусь по результату.
Так просто решил задачу, подумал интересно будет другим)))
 
Последнее редактирование:

Кто просматривает тему: (Всего: 1, Пользователи: 0, Гости: 1)