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

kolina

Client
Регистрация
05.10.2019
Сообщения
188
Благодарностей
92
Баллы
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
Сообщения
251
Благодарностей
138
Баллы
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
Сообщения
188
Благодарностей
92
Баллы
28
Не участвовал в конкурсах никогда.
Если буду решать какие задачи буду делится и дополнять.
На выходных буду пробовать ферму из 4 телефонов отпишусь по результату.
Так просто решил задачу, подумал интересно будет другим)))
 
Последнее редактирование:

kolina

Client
Регистрация
05.10.2019
Сообщения
188
Благодарностей
92
Баллы
28
Вообще можно подключать сколько угодно телефонов. У меня 3 работает сейчас без проблем, и не особо грузят комп, так как используются ресурсы телефона.
 
  • Спасибо
Реакции: Dmitriy_Zenno

Asmus003

Client
Регистрация
25.03.2018
Сообщения
331
Благодарностей
75
Баллы
28
Вообще можно подключать сколько угодно телефонов. У меня 3 работает сейчас без проблем, и не особо грузят комп, так как используются ресурсы телефона.
а вы пробовали подменять параметры телефонов? насколько это реализуемо без ЗД?
 

n0n3mi1y

Client
Регистрация
08.03.2017
Сообщения
1 408
Благодарностей
731
Баллы
113
а вы пробовали подменять параметры телефонов? насколько это реализуемо без ЗД?
ZD заменяет десятки часов сбора и сбора воедино информации (способы) и данных (значения параметров) подмены.
+ реализованы методы защиты от немалого количества детектов рута/отладки.
А вообще, всё можно сделать самому, ведь разработчики ZD же смогли, что мешает вам?)
 
  • Спасибо
Реакции: brun0

kolina

Client
Регистрация
05.10.2019
Сообщения
188
Благодарностей
92
Баллы
28
Где то здесь на форуме читал, стоимость содержания фермы телефонов, и как я понял из статьи физические телефоны нужно вроде прошивать для смены параметров. Если нужно часто менять отпечатки, можно использовать эмулятор. Я писал статью как работать с эмуляторами использую андроид студию. Но эмуляторы забирают много видео (зависит от размера эмулятора который Вы соберёте)
 

Asmus003

Client
Регистрация
25.03.2018
Сообщения
331
Благодарностей
75
Баллы
28
ZD заменяет десятки часов сбора и сбора воедино информации (способы) и данных (значения параметров) подмены.
+ реализованы методы защиты от немалого количества детектов рута/отладки.
А вообще, всё можно сделать самому, ведь разработчики ZD же смогли, что мешает вам?)
я как раз и пользуюсь ЗД, потому что не представляю как это все менять :-) но интересно вообще, смог ли кто-то...
 

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