• Сайт
  • Статус системы
  • API-документация
  • Блог
Telegram Icon Сообщество
RU
Русский
Português
English
中文 (中国)
Tiếng Việt
Войти Начать бесплатно
  • Сайт
  • Статус системы
  • API-документация
  • Блог
  • Сообщество








  • Russian









    Русский










    Português










    English










    中文 (中国)










    Tiếng Việt








Войти Начать бесплатно

Настраиваемые Python-скрипты

Автоматизируйте процессы с помощью Python и Multilogin API. Создавайте, управляйте и запускайте браузерные профили с полностью настраиваемыми скриптами.

search icon

Попробуйте другое ключевое слово или проверьте правильность написания. Нужна помощь? Обратитесь в поддержку.

  • Начало работы с автоматизацией
  • Базовая автоматизация с CLI
  • Автоматизация без кода в Postman
  • Script runner и готовые скрипты
  • Puppeteer, Selenium и Playwright
  • Настраиваемые Python-скрипты
  • Быстрые решения с DevTools
  • Внешние инструменты автоматизации
  • Главная
  • breadcrumb separator bar
  • Multilogin
  • breadcrumb separator bar
  • Автоматизация задач с API и скриптами
  • breadcrumb separator bar
  • Настраиваемые Python-скрипты
  • breadcrumb separator bar
  • Как автоматизировать публикацию в Pinterest на облачных телефонах с помощью Appium

Как автоматизировать публикацию в Pinterest на облачных телефонах с помощью Appium

Автор Anton L ( Обновлено 20 Июля 2026 г. )

Обновлено 20 Июля 2026 г.

 Облачные телефоны Multilogin поддерживают ADB, так что вы можете подключить их к инструментам автоматизации Android, таким как Appium.

В этом примере один скрипт на Python обрабатывает повторяющиеся задачи в Pinterest на нескольких облачных телефонах. Он подключается к каждому телефону, входит в аккаунт Pinterest, перемещает назначенные изображения, выполняет несколько разных действий с лентой и публикует изображения (которы в Pinterest называются «Пинами»).

➡️ Вам не нужно открывать каждый облачный телефон и повторять одни и те же действия вручную. Добавьте свои телефоны и аккаунты в devices.txt, запустите скрипт и позвольте ему работать с списком за вас.

Скрипт обрабатывает облачные телефоны по одному. Он завершает все действия на одном телефоне, прежде чем перейти к следующему.

Это пример скрипта. Он показывает, что вы можете создать с помощью Multilogin облачных телефонов, ADB и Appium. Автоматизация Pinterest не встроена в Multilogin.

 
Your browser does not support HTML5 video.

Что делает скрипт

Как только всё готово, скрипт:

  • Подключается к облачному телефону через ADB
  • Устанавливает Pinterest из APK файла
  • Перемещает изображения с вашего компьютера на облачный телефон
  • Входит в предоставленный вами аккаунт Pinterest
  • Выполняет несколько образцов взаимодействия с лентой, чтобы продемонстрировать автоматизированную навигацию в приложении
  • Публикует назначенные изображения
  • Переходит к следующему облачному телефону в списке

Каждый облачный телефон может использовать свой собственный аккаунт Pinterest и диапазон изображений. Это упрощает организацию контента для нескольких учетных записей.

Примеры взаимодействия с лентой также можно адаптировать для процесса разогрева аккаунта. Проверьте Как разогреть профиль в Multilogin для получения дополнительной информации.

Имейте в виду, что несколько автоматизированных взаимодействий не гарантируют доверие к аккаунту или предотвращают ограничения платформы. Всегда следуйте правилам Pinterest и автоматизируйте только те аккаунты, которыми вам разрешено пользоватьтся.

 

Перед началом

Вам потребуется:

  • Установленный Appium и UiAutomator2 на вашем компьютере: Документация Appium
  • Запущенный Multilogin облачный телефон с включенным ADB: Как использовать ADB в облачных телефонах
  • Тестовый аккаунт Pinterest и его данные для входа
  • Файл APK Pinterest
  • Папка с именем images с изображениями, которые вы хотите опубликовать
  • Текстовый файл с именем devices.txt

Оставьте скрипт, APK файл, devices.txt и images папку в одной папке. Это поможет скрипту автоматически находить всё.

 

Настройка devices.txt

Добавьте один облачный телефон в строку, используя этот формат:

IP:PORT ADB_PASSWORD PINTEREST_EMAIL PINTEREST_PASSWORD IMAGE_RANGE

Пример:

199.190.44.226:25922 j3fYxT email@email.com password 1-3

Вот что означает каждое значение:

  • IP:PORT: адрес ADB облачного телефона
  • ADB_PASSWORD: пароль, используемый для подключения через ADB
  • PINTEREST_EMAIL: электронная почта аккаунта Pinterest
  • PINTEREST_PASSWORD: пароль аккаунта Pinterest
  • IMAGE_RANGE: изображения, назначенные этому аккаунту

Например, 1-3 указывает скрипту использовать первые три изображения из папки images, отсортированной по алфавиту.

Вы можете разделить изображения между несколькими облачными телефонами:

199.190.44.226:25922 j3fYxT user1@email.com password1 1-3
199.190.44.227:25922 k9pQrS user2@email.com password2 4-5

Первый телефон получает изображения 1–3. Второй телефон получает изображения 4–5.

Оставьте диапазон изображений пустым, если хотите, чтобы телефон использовал каждое изображение в папке.

Храните свои данные для входа в безопасности. devices.txt хранит пароли и другую конфиденциальную информацию в открытом виде. Используйте тестовые аккаунты, держите файл закрытым, никогда не загружайте его в публичный репозиторий и удалите, когда он больше не нужен.

 

Запустите скрипт

  1. Запустите каждый облачный телефон, указанный в devices.txt
  2. Включите ADB на каждом облачном телефоне
  3. Запустите сервер Appium на вашем компьютере
  4. Убедитесь, что скрипт, APK файл, devices.txt и images папка находятся в одном месте
  5. Сохраните скрипт ниже как appium_script.py

appium_script.py

"""

How to use
-----
Run this script and include it in the folder with two files: devices.txt and images folder

devices.txt   -- one phone per line:  IP:PORT TOKEN USERNAME PASSWORD [IMAGE_RANGE]
                 IMAGE_RANGE is an amount of images which will be used for specific account, e.g. "1-3"
                 into the alphabetically-sorted list of images found in images/
                 Skip it to give that phone every image in the folder. Example, splitting 5 images across 2
                 phones (phone 1 gets images 1-3, phone 2 gets images 4-5):
                     199.190.44.226:25922 j3fYxT user1 pass1 1-3
                     199.190.44.227:25922 k9pQrS user2 pass2 4-5
images/       -- a folder of images (jpg/png), or a single image file.

Requires:  pip install Appium-Python-Client
           Appium server running locally (appium) and ANDROID adb on PATH.

The script installs the APK and images on the phone, opens Pinterest, enters the account credentials, runs a few sample feed interactions, and publishes the assigned images.
"""

import argparse
import random
import re
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import quote as urlquote

from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy

APPIUM_SERVER_URL = "http://localhost:4723"
PINTEREST_PACKAGE = "com.pinterest"

CONNECT_RETRIES = 6
CONNECT_RETRY_SLEEP = 2
INSTALL_RETRIES = 3

# Warm up limit
WARM_UP_MIN_ACTIONS = 3
WARM_UP_MAX_ACTIONS = 5

# Folder we push images into
REMOTE_IMAGE_DIR = "/sdcard/Pictures/bulk_publisher"



# adb connection function

def adb(*args, serial=None, timeout=60):
    cmd = ["adb"] + (["-s", serial] if serial else []) + list(args)
    return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)


def connect_and_login(serial: str, token: str) -> bool:
    """ADB connection, uses the phone name/address and glogin."""
    print(f"   [CONNECTING] {serial}: connecting via adb...")
    for attempt in range(1, CONNECT_RETRIES + 1):
        adb("connect", serial)
        state = adb("get-state", serial=serial).stdout.strip()
        if state == "device":
            break
        print(f"   [CONNECTING] {serial}: not online yet (attempt {attempt}/{CONNECT_RETRIES}, state={state or 'unknown'}), retrying...")
        adb("disconnect", serial)
        time.sleep(CONNECT_RETRY_SLEEP)
    else:
        print(f"   [CONNECT_FAIL] {serial}")
        return False

    print(f"   [CONNECTED] {serial}: online, logging in (glogin)...")
    result = adb("shell", "glogin", token, serial=serial)
    output = (result.stdout + result.stderr).lower()
    if "success" in output or "already logged" in output:
        print(f"   [OK] {serial}: logged in")
        return True
    print(f"   [GLOGIN_FAIL] {serial}: {result.stdout or result.stderr}")
    return False


def install_apk(serial: str, apk_path: str) -> bool:
    for attempt in range(INSTALL_RETRIES):
        result = adb("install", "-r", apk_path, serial=serial, timeout=180)
        if "success" in result.stdout.lower():
            return True
        time.sleep(3)
    print(f"   [INSTALL_FAIL] {serial}: {result.stdout or result.stderr}")
    return False


def grant_full_media_access(serial: str):
    """Grants full photo/video library access via ADB """
    adb("shell", "pm", "grant", PINTEREST_PACKAGE, "android.permission.READ_MEDIA_IMAGES", serial=serial)
    adb("shell", "pm", "grant", PINTEREST_PACKAGE, "android.permission.READ_MEDIA_VIDEO", serial=serial)


def sanitize_filename(name: str) -> str:
    """Name mapping (if the script is not able to find the image by default)"""
    stem, suffix = Path(name).stem, Path(name).suffix
    return re.sub(r"[^A-Za-z0-9._-]", "_", stem) + suffix


def push_images(serial: str, image_paths, remote_dir=REMOTE_IMAGE_DIR) -> list:
    """Push local images to the phone and trigger a media scan"""
    adb("shell", "mkdir", "-p", remote_dir, serial=serial)
    remote_paths = []
    used_names = set()
    total = len(image_paths)
    for i, local_path in enumerate(image_paths, start=1):
        safe_name = sanitize_filename(Path(local_path).name)
        candidate, n = safe_name, 1
        while candidate in used_names:  # two originals sanitized to the same name
            candidate = f"{Path(safe_name).stem}_{n}{Path(safe_name).suffix}"
            n += 1
        used_names.add(candidate)
        remote_path = f"{remote_dir}/{candidate}"
        print(f"   [UPLOADING] {serial}: pushing image {i}/{total}: {Path(local_path).name}")
        push_result = adb("push", local_path, remote_path, serial=serial)
        if push_result.returncode != 0 or "error" in push_result.stdout.lower():
            print(f"   [PUSH_FAIL] {serial}: {Path(local_path).name}: "
                  f"{push_result.stdout.strip() or push_result.stderr.strip()}")
            continue

        scan_uri = "file://" + urlquote(remote_path)
        scan_result = adb(
            "shell", "am", "broadcast", "-a",
            "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
            "-d", scan_uri,
            serial=serial,
        )
        if scan_result.returncode != 0:
            print(f"   [SCAN_FAIL] {serial}: {Path(local_path).name}: "
                  f"{scan_result.stdout.strip() or scan_result.stderr.strip()}")
        remote_paths.append(remote_path)
    return remote_paths


def disconnect(serial: str):
    adb("disconnect", serial)



def build_driver(serial: str):
    """Start the Appium session then launch Pinterest. """
    capabilities = dict(
        platformName="Android",
        automationName="uiautomator2",
        udid=serial,
        newCommandTimeout=180,
        noReset=True,
        language="en",
        locale="US",
    )
    driver = webdriver.Remote(APPIUM_SERVER_URL, options=UiAutomator2Options().load_capabilities(capabilities))
    driver.activate_app(PINTEREST_PACKAGE)
    return driver


def find_any(driver, candidates, timeout=20, poll=0.5, require_enabled=False):
    """Try each (by, value) locator candidate in order until one resolves,
    polling until the timeout instead of failing on the first miss. """
    deadline = time.time() + timeout
    last_err = None
    while time.time() < deadline:
        for by, value in candidates:
            try:
                el = driver.find_element(by=by, value=value)
                if el.is_displayed() and (not require_enabled or el.get_attribute("enabled") == "true"):
                    return el
            except Exception as exc:  # noqa: BLE001 - trying multiple strategies on purpose
                last_err = exc
        time.sleep(poll)
    raise TimeoutError(f"No candidate locator matched within {timeout}s: {candidates}") from last_err


def tap_any(driver, candidates, timeout=20, require_enabled=False):
    find_any(driver, candidates, timeout=timeout, require_enabled=require_enabled).click()


def tap_if_present(driver, candidates, timeout=3) -> bool:
    """Like tap_any, but returns False instead of raising when nothing matches
    -- for popups that only sometimes appear."""
    try:
        tap_any(driver, candidates, timeout=timeout)
        return True
    except TimeoutError:
        return False

# Stage 1: First login screen email only.
EMAIL_ONLY_FIELD = [
    (AppiumBy.XPATH, "//*[@resource-id='com.pinterest:id/email_address']//android.widget.EditText"),
]
CONTINUE_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/continue_email_bt"),
]

# Stage 2: combined email + password screen, then "Log In".
LOGIN_EMAIL_FIELD = [
    (AppiumBy.XPATH, "//*[@resource-id='com.pinterest:id/email']//android.widget.EditText"),
]
LOGIN_PASSWORD_FIELD = [
    (AppiumBy.XPATH, "//*[@resource-id='com.pinterest:id/password']//android.widget.EditText"),
]
LOGIN_SUBMIT_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/login_bt"),
]

# Popups that can appear at unpredictable points after login.
EMAIL_UPDATE_DISMISS = [
    (AppiumBy.ID, "com.pinterest:id/actionPromptDismissButton"),  # "Is this your email?" -> close
]
NOTIFICATIONS_PERMISSION_ALLOW = [
    (AppiumBy.ID, "com.android.permissioncontroller:id/permission_allow_button"),
]
PHOTOS_PERMISSION_ALLOW_ALL = [
    (AppiumBy.ID, "com.android.permissioncontroller:id/permission_allow_all_button"),
]

# Opens the "Start creating now" bottom sheet. Scripts looking for the selectors depending on phone's resolution and size
CREATE_ICON_BUTTON = [
    (AppiumBy.XPATH,
     '//android.widget.Button[@content-desc="Create"]'
     '/android.widget.FrameLayout[@resource-id="com.pinterest:id/icon_button_container"]'
     '/android.widget.Button[@resource-id="com.pinterest:id/icon_button"]'),
    (AppiumBy.XPATH,
     '//android.widget.FrameLayout[@content-desc="Create"]'
     '/android.widget.LinearLayout[@resource-id="com.pinterest:id/tab_content"]'),
]

# Bottom nav "Home" tab 
HOME_TAB_BUTTON = [
    (AppiumBy.ACCESSIBILITY_ID, "Home"),
    (AppiumBy.ID, "com.pinterest:id/bottom_nav_home_icon"),
]

# "Start creating now" sheet that appears after tapping Create -- pick "Pin"
PIN_ACTION_BUTTON = [
    (AppiumBy.XPATH, '//android.widget.Button[@content-desc="Pin"]'),
]

# Media picker "Next" button (enabled once a photo is selected).
GALLERY_NEXT_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/end_container_text_button"),
]

# Final submit on the Create Pin details screen -- labelled "Create".
CREATE_PIN_SUBMIT_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/create_gestalt_button"),
]

# --- Warm-up locators (home feed browsing: open a pin, like/save/comment) ---

# Generic home-feed pin card. The script finds any pins on screen for that phone/account/session.
PIN_CARD = [
    # Finds an actually-clickable match. Fall back to any match if no clickable one is found, in case a
    (AppiumBy.XPATH, '//*[contains(@content-desc, "Pin from") and @clickable="true"]'),
    (AppiumBy.XPATH, '//*[contains(@content-desc, "Pin from")]'),
]
REACT_BUTTON = [
    (AppiumBy.XPATH, '//android.widget.ImageView[@content-desc="React to this Pin"]'),
]
SAVE_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/save_pinit_bt"),
    (AppiumBy.XPATH, '//android.widget.Button[@content-desc="Save"]'),
]
COMMENTS_MODULE = [
    (AppiumBy.ID, "com.pinterest:id/new_comments_module_container"),
]
CLOSEUP_BACK_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/closeup_back_button"),
    (AppiumBy.ACCESSIBILITY_ID, "Back"),
]


def dismiss_popups(driver):
    """Clears the 'Is this your email?' prompt and both Android permission
    dialogs (notifications, photo access) if any of them are on screen."""
    tap_if_present(driver, EMAIL_UPDATE_DISMISS, timeout=2)
    tap_if_present(driver, NOTIFICATIONS_PERMISSION_ALLOW, timeout=2)
    tap_if_present(driver, PHOTOS_PERMISSION_ALLOW_ALL, timeout=2)


def ensure_on_home_feed(driver, max_attempts=6, settle_seconds=1.5):
    """Confirms the Create icon (home feed) is visible, actively navigating
    back if not."""
    for _ in range(max_attempts):
        try:
            find_any(driver, CREATE_ICON_BUTTON, timeout=4)
            return
        except TimeoutError:
            pass
        if not tap_if_present(driver, HOME_TAB_BUTTON, timeout=3):
            try:
                driver.back()
            except Exception:  # noqa: BLE001 - best-effort recovery, keep retrying
                pass
        time.sleep(settle_seconds)

    # Sanity check if the app is minimized
    driver.activate_app(PINTEREST_PACKAGE)
    try:
        find_any(driver, CREATE_ICON_BUTTON, timeout=10)
        return
    except TimeoutError:
        pass
    raise TimeoutError("Could not get back to the Pinterest home feed (Create icon never reappeared)")


def login(driver, username: str, password: str):

    try:
        find_any(driver, EMAIL_ONLY_FIELD, timeout=15).send_keys(username)
        tap_any(driver, CONTINUE_BUTTON)
    except TimeoutError:
        pass  # already on the combined email+password screen

    email_field = find_any(driver, LOGIN_EMAIL_FIELD, timeout=20)
    email_field.clear()
    email_field.send_keys(username)
    find_any(driver, LOGIN_PASSWORD_FIELD).send_keys(password)
    tap_any(driver, LOGIN_SUBMIT_BUTTON)
    dismiss_popups(driver)
    ensure_on_home_feed(driver)
    dismiss_popups(driver)


def publish_pin(driver, remote_image_path: str):

    ensure_on_home_feed(driver)
    dismiss_popups(driver)
    tap_any(driver, CREATE_ICON_BUTTON, timeout=10)
    dismiss_popups(driver)
    # Some sessions may skip the "Start creating now" sheet and go straight
    # to the photo picker, so a missing sheet here isn't fatal.
    tap_if_present(driver, PIN_ACTION_BUTTON, timeout=6)
    dismiss_popups(driver)  # photo-access permission dialog typically appears here

    # Match on "<upload folder>/<filename>", not filename alone -- a phone that
    # already has a same-named file elsewhere (e.g. Download/).
    unique_suffix = f"{Path(REMOTE_IMAGE_DIR).name}/{Path(remote_image_path).name}"
    thumbnail = [(AppiumBy.XPATH, f"//*[contains(@content-desc, '{unique_suffix}')]")]
    tap_any(driver, thumbnail, timeout=25)  # gallery grid can take a while to populate
    # "Next" starts disabled until the selection registers -- wait for enabled=true,
    # not just present, or the click silently no-ops on a disabled button.
    tap_any(driver, GALLERY_NEXT_BUTTON, timeout=15, require_enabled=True)

    # 45s here because Pinterest has to finish uploading/processing
    # the image before this button renders.
    tap_any(driver, CREATE_PIN_SUBMIT_BUTTON, timeout=45)
    ensure_on_home_feed(driver)
    dismiss_popups(driver)


def _open_random_pin(driver) -> bool:
    """Taps a random visible pin card on the home feed."""
    for by, value in PIN_CARD:
        try:
            cards = [c for c in driver.find_elements(by=by, value=value) if c.is_displayed()]
        except Exception:  # noqa: BLE001 - try the next candidate
            continue
        if not cards:
            continue
        try:
            random.choice(cards).click()
            return True
        except Exception:  # noqa: BLE001 - a missed tap just means "try something else"
            continue
    return False


def _close_pin(driver):
    """Closes the pin closeup view."""
    try:
        if find_any(driver, CREATE_ICON_BUTTON, timeout=1).is_displayed():
            return
    except TimeoutError:
        pass
    if not tap_if_present(driver, CLOSEUP_BACK_BUTTON, timeout=3):
        try:
            driver.back()
        except Exception:  # noqa: BLE001 - best-effort recovery
            pass


def _like_current_pin(driver) -> bool:
    return tap_if_present(driver, REACT_BUTTON, timeout=4)


def _save_current_pin(driver) -> bool:
    return tap_if_present(driver, SAVE_BUTTON, timeout=4)


def _view_comments(driver) -> bool:
    if not tap_if_present(driver, COMMENTS_MODULE, timeout=4):
        return False
    time.sleep(random.uniform(1, 2))
    try:
        driver.back()
    except Exception:  # noqa: BLE001 - best-effort recovery
        pass
    return True


def _scroll_feed(driver):
    """Swipes the feed by a random amount, mimicking idle scrolling rather than
    only ever tapping the first pin that happens to be visible."""
    size = driver.get_window_size()
    x = int(size["width"] * 0.5)
    start_y = int(size["height"] * random.uniform(0.55, 0.7))
    end_y = int(size["height"] * random.uniform(0.2, 0.35))
    driver.swipe(x, start_y, x, end_y, random.randint(400, 900))


WARM_UP_PIN_ACTIONS = [_like_current_pin, _save_current_pin, _view_comments]


def warm_up(driver, min_actions=WARM_UP_MIN_ACTIONS, max_actions=WARM_UP_MAX_ACTIONS):
    """Runs a randomized handful of home-feed browsing actions (open a pin,
    then like/save/view its comments, then back) right after login and before
    any pin gets published"""
    ensure_on_home_feed(driver)
    dismiss_popups(driver)

    action_count = random.randint(min_actions, max_actions)
    print(f"   [WARMUP] running {action_count} warm-up action(s)")

    done = 0
    attempts = 0
    while done < action_count and attempts < action_count * 3:
        attempts += 1
        if random.random() < 0.3:
            _scroll_feed(driver)
            time.sleep(random.uniform(1, 2))
            continue
        if not _open_random_pin(driver):
            _scroll_feed(driver)  # nothing tappable yet -- load more of the feed
            time.sleep(1)
            continue
        time.sleep(random.uniform(1.5, 2.5))  # let the closeup view finish loading
        dismiss_popups(driver)
        random.choice(WARM_UP_PIN_ACTIONS)(driver)
        time.sleep(random.uniform(1, 2))
        _close_pin(driver)
        dismiss_popups(driver)
        # Re-check after every iteration.
        try:
            ensure_on_home_feed(driver)
        except TimeoutError as exc:
            print(f"   [WARMUP] lost the home feed mid warm-up, stopping early: {exc}")
            break
        time.sleep(random.uniform(0.5, 1.5))
        done += 1

    ensure_on_home_feed(driver)
    dismiss_popups(driver)

# Image upload

def parse_image_range(range_str: str, line_no: int):
    """Parses a range like '1-3' or a single index like '4'."""
    try:
        if "-" in range_str:
            start_str, end_str = range_str.split("-", 1)
            start, end = int(start_str), int(end_str)
        else:
            start = end = int(range_str)
    except ValueError:
        raise ValueError(f"devices.txt line {line_no}: invalid image range {range_str!r} (expected e.g. '1-3' or '4')")
    if start < 1 or end < start:
        raise ValueError(f"devices.txt line {line_no}: invalid image range {range_str!r} (expected e.g. '1-3' or '4')")
    return start, end


def parse_devices(path: str):
    devices = []
    for line_no, raw_line in enumerate(Path(path).read_text().splitlines(), start=1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split()
        if len(parts) == 4:
            serial, token, username, password = parts
            image_range = None  # -> gets every image in the folder, see main()
        elif len(parts) == 5:
            serial, token, username, password, range_str = parts
            image_range = parse_image_range(range_str, line_no)
        else:
            raise ValueError(
                f"devices.txt line {line_no}: expected 4 or 5 fields "
                f"(IP:PORT TOKEN USERNAME PASSWORD [IMAGE_RANGE]), got {len(parts)}: {raw_line!r}"
            )
        devices.append(dict(serial=serial, token=token, username=username, password=password, image_range=image_range))
    return devices


def resolve_images(images_arg: str):
    """Returns every image found, sorted alphabetically. Per-device IMAGE_RANGE
    fields in devices.txt index into this same sorted list."""
    p = Path(images_arg)
    if p.is_file():
        return [str(p)]
    return sorted([str(f) for f in p.iterdir() if f.suffix.lower() in (".jpg", ".jpeg", ".png")])

# Auto-discovery: if an argument is omitted, look for it next to the script

def autodiscover_devices_file(base: Path) -> Path:
    candidate = base / "devices.txt"
    if candidate.is_file():
        return candidate
    raise FileNotFoundError(f"No devices.txt found in {base}")


def autodiscover_apk(base: Path) -> Path:
    apks = sorted(base.glob("*.apk"))
    if not apks:
        raise FileNotFoundError(f"No .apk file found in {base}")
    if len(apks) > 1:
        print(f">> multiple .apk files found in {base}, using {apks[0].name}")
    return apks[0]


def autodiscover_images(base: Path) -> Path:
    images_dir = base / "images"
    if images_dir.is_dir():
        return images_dir
    loose = [f for f in base.iterdir() if f.suffix.lower() in (".jpg", ".jpeg", ".png")]
    if loose:
        return base
    raise FileNotFoundError(f"No images/ folder or .jpg/.png files found in {base}")


def run_device(device: dict, apk_path: str, image_paths: list):
    serial = device["serial"]
    print(f"\n=== {serial} ===")

    if not connect_and_login(serial, device["token"]):
        return
    if not install_apk(serial, apk_path):
        disconnect(serial)
        return

    grant_full_media_access(serial)
    remote_images = push_images(serial, image_paths)

    driver = None
    try:
        driver = build_driver(serial)
        login(driver, device["username"], device["password"])
        try:
            warm_up(driver)
        except Exception as exc:  # noqa: BLE001 - warm-up is best-effort, publishing still matters
            print(f"   [WARMUP_FAILED] {serial}: {exc}")
        published = 0
        total = len(remote_images)
        for i, remote_image in enumerate(remote_images, start=1):
            print(f"   [PUBLISHING] {serial}: pin {i}/{total}: {Path(remote_image).name}")
            try:
                publish_pin(driver, remote_image)
                published += 1
            except Exception as exc:  # noqa: BLE001 - one slow/failed pin shouldn't cost the rest
                print(f"   [PIN_FAILED] {serial}: {Path(remote_image).name}: {exc}")
            time.sleep(2)
        print(f"   [DONE] {serial}: published {published}/{len(remote_images)} pin(s)")
    except Exception as exc:  # noqa: BLE001 - keep going to the next phone either way
        print(f"   [ERROR] {serial}: {exc}")
    finally:
        if driver:
            driver.quit()
        disconnect(serial)


def main():
    script_dir = Path(__file__).resolve().parent

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("devices_file", nargs="?", default=None,
                         help=f"IP:PORT TOKEN USERNAME PASSWORD [IMAGE_RANGE], one per line "
                              f"(default: devices.txt next to this script, i.e. {script_dir})")
    parser.add_argument("apk_path", nargs="?", default=None,
                         help="Path to the Pinterest APK to install (default: the .apk found next to this script)")
    parser.add_argument("images", nargs="?", default=None,
                         help="Folder of images, or a single image file "
                              "(default: an images/ folder, or loose .jpg/.png files, next to this script)")
    args = parser.parse_args()

    try:
        devices_file = Path(args.devices_file) if args.devices_file else autodiscover_devices_file(script_dir)
        apk_path = Path(args.apk_path) if args.apk_path else autodiscover_apk(script_dir)
        images_arg = Path(args.images) if args.images else autodiscover_images(script_dir)
    except FileNotFoundError as exc:
        sys.exit(f"{exc}\nPass devices_file / apk_path / images explicitly, or place them next to the script.")

    print(f">> devices file: {devices_file}")
    print(f">> apk: {apk_path}")
    print(f">> images: {images_arg}")

    try:
        devices = parse_devices(devices_file)
    except ValueError as exc:
        sys.exit(str(exc))

    all_images = resolve_images(images_arg)
    if not all_images:
        sys.exit(f"No images found at {images_arg}")

    print(f">> {len(devices)} phone(s) queued, {len(all_images)} image(s) available:")
    for i, image_path in enumerate(all_images, start=1):
        print(f"     {i}: {Path(image_path).name}")

    for device in devices:
        start, end = device["image_range"] or (1, len(all_images))
        if end > len(all_images):
            print(f"   [SKIP] {device['serial']}: image range {start}-{end} exceeds "
                  f"the {len(all_images)} image(s) available")
            continue
        image_subset = all_images[start - 1:end]
        run_device(device, str(apk_path), image_subset)


if __name__ == "__main__":
    main()"""

How to use
-----
Run this script and include it in the folder with two files: devices.txt and images folder

devices.txt   -- one phone per line:  IP:PORT TOKEN USERNAME PASSWORD [IMAGE_RANGE]
                 IMAGE_RANGE is an amount of images which will be used for specific account, e.g. "1-3"
                 into the alphabetically-sorted list of images found in images/
                 Skip it to give that phone every image in the folder. Example, splitting 5 images across 2
                 phones (phone 1 gets images 1-3, phone 2 gets images 4-5):
                     199.190.44.226:25922 j3fYxT user1 pass1 1-3
                     199.190.44.227:25922 k9pQrS user2 pass2 4-5
images/       -- a folder of images (jpg/png), or a single image file.

Requires:  pip install Appium-Python-Client
           Appium server running locally (appium) and ANDROID adb on PATH.

The script installs the APK and images on the phone, opens Pinterest, enters the account credentials, runs a few sample feed interactions, and publishes the assigned images.
"""

import argparse
import random
import re
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import quote as urlquote

from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy

APPIUM_SERVER_URL = "http://localhost:4723"
PINTEREST_PACKAGE = "com.pinterest"

CONNECT_RETRIES = 6
CONNECT_RETRY_SLEEP = 2
INSTALL_RETRIES = 3

# Warm up limit
WARM_UP_MIN_ACTIONS = 3
WARM_UP_MAX_ACTIONS = 5

# Folder we push images into
REMOTE_IMAGE_DIR = "/sdcard/Pictures/bulk_publisher"



# adb connection function

def adb(*args, serial=None, timeout=60):
    cmd = ["adb"] + (["-s", serial] if serial else []) + list(args)
    return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)


def connect_and_login(serial: str, token: str) -> bool:
    """ADB connection, uses the phone name/address and glogin."""
    print(f"   [CONNECTING] {serial}: connecting via adb...")
    for attempt in range(1, CONNECT_RETRIES + 1):
        adb("connect", serial)
        state = adb("get-state", serial=serial).stdout.strip()
        if state == "device":
            break
        print(f"   [CONNECTING] {serial}: not online yet (attempt {attempt}/{CONNECT_RETRIES}, state={state or 'unknown'}), retrying...")
        adb("disconnect", serial)
        time.sleep(CONNECT_RETRY_SLEEP)
    else:
        print(f"   [CONNECT_FAIL] {serial}")
        return False

    print(f"   [CONNECTED] {serial}: online, logging in (glogin)...")
    result = adb("shell", "glogin", token, serial=serial)
    output = (result.stdout + result.stderr).lower()
    if "success" in output or "already logged" in output:
        print(f"   [OK] {serial}: logged in")
        return True
    print(f"   [GLOGIN_FAIL] {serial}: {result.stdout or result.stderr}")
    return False


def install_apk(serial: str, apk_path: str) -> bool:
    for attempt in range(INSTALL_RETRIES):
        result = adb("install", "-r", apk_path, serial=serial, timeout=180)
        if "success" in result.stdout.lower():
            return True
        time.sleep(3)
    print(f"   [INSTALL_FAIL] {serial}: {result.stdout or result.stderr}")
    return False


def grant_full_media_access(serial: str):
    """Grants full photo/video library access via ADB """
    adb("shell", "pm", "grant", PINTEREST_PACKAGE, "android.permission.READ_MEDIA_IMAGES", serial=serial)
    adb("shell", "pm", "grant", PINTEREST_PACKAGE, "android.permission.READ_MEDIA_VIDEO", serial=serial)


def sanitize_filename(name: str) -> str:
    """Name mapping (if the script is not able to find the image by default)"""
    stem, suffix = Path(name).stem, Path(name).suffix
    return re.sub(r"[^A-Za-z0-9._-]", "_", stem) + suffix


def push_images(serial: str, image_paths, remote_dir=REMOTE_IMAGE_DIR) -> list:
    """Push local images to the phone and trigger a media scan"""
    adb("shell", "mkdir", "-p", remote_dir, serial=serial)
    remote_paths = []
    used_names = set()
    total = len(image_paths)
    for i, local_path in enumerate(image_paths, start=1):
        safe_name = sanitize_filename(Path(local_path).name)
        candidate, n = safe_name, 1
        while candidate in used_names:  # two originals sanitized to the same name
            candidate = f"{Path(safe_name).stem}_{n}{Path(safe_name).suffix}"
            n += 1
        used_names.add(candidate)
        remote_path = f"{remote_dir}/{candidate}"
        print(f"   [UPLOADING] {serial}: pushing image {i}/{total}: {Path(local_path).name}")
        push_result = adb("push", local_path, remote_path, serial=serial)
        if push_result.returncode != 0 or "error" in push_result.stdout.lower():
            print(f"   [PUSH_FAIL] {serial}: {Path(local_path).name}: "
                  f"{push_result.stdout.strip() or push_result.stderr.strip()}")
            continue

        scan_uri = "file://" + urlquote(remote_path)
        scan_result = adb(
            "shell", "am", "broadcast", "-a",
            "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
            "-d", scan_uri,
            serial=serial,
        )
        if scan_result.returncode != 0:
            print(f"   [SCAN_FAIL] {serial}: {Path(local_path).name}: "
                  f"{scan_result.stdout.strip() or scan_result.stderr.strip()}")
        remote_paths.append(remote_path)
    return remote_paths


def disconnect(serial: str):
    adb("disconnect", serial)



def build_driver(serial: str):
    """Start the Appium session then launch Pinterest. """
    capabilities = dict(
        platformName="Android",
        automationName="uiautomator2",
        udid=serial,
        newCommandTimeout=180,
        noReset=True,
        language="en",
        locale="US",
    )
    driver = webdriver.Remote(APPIUM_SERVER_URL, options=UiAutomator2Options().load_capabilities(capabilities))
    driver.activate_app(PINTEREST_PACKAGE)
    return driver


def find_any(driver, candidates, timeout=20, poll=0.5, require_enabled=False):
    """Try each (by, value) locator candidate in order until one resolves,
    polling until the timeout instead of failing on the first miss. """
    deadline = time.time() + timeout
    last_err = None
    while time.time() < deadline:
        for by, value in candidates:
            try:
                el = driver.find_element(by=by, value=value)
                if el.is_displayed() and (not require_enabled or el.get_attribute("enabled") == "true"):
                    return el
            except Exception as exc:  # noqa: BLE001 - trying multiple strategies on purpose
                last_err = exc
        time.sleep(poll)
    raise TimeoutError(f"No candidate locator matched within {timeout}s: {candidates}") from last_err


def tap_any(driver, candidates, timeout=20, require_enabled=False):
    find_any(driver, candidates, timeout=timeout, require_enabled=require_enabled).click()


def tap_if_present(driver, candidates, timeout=3) -> bool:
    """Like tap_any, but returns False instead of raising when nothing matches
    -- for popups that only sometimes appear."""
    try:
        tap_any(driver, candidates, timeout=timeout)
        return True
    except TimeoutError:
        return False

# Stage 1: First login screen email only.
EMAIL_ONLY_FIELD = [
    (AppiumBy.XPATH, "//*[@resource-id='com.pinterest:id/email_address']//android.widget.EditText"),
]
CONTINUE_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/continue_email_bt"),
]

# Stage 2: combined email + password screen, then "Log In".
LOGIN_EMAIL_FIELD = [
    (AppiumBy.XPATH, "//*[@resource-id='com.pinterest:id/email']//android.widget.EditText"),
]
LOGIN_PASSWORD_FIELD = [
    (AppiumBy.XPATH, "//*[@resource-id='com.pinterest:id/password']//android.widget.EditText"),
]
LOGIN_SUBMIT_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/login_bt"),
]

# Popups that can appear at unpredictable points after login.
EMAIL_UPDATE_DISMISS = [
    (AppiumBy.ID, "com.pinterest:id/actionPromptDismissButton"),  # "Is this your email?" -> close
]
NOTIFICATIONS_PERMISSION_ALLOW = [
    (AppiumBy.ID, "com.android.permissioncontroller:id/permission_allow_button"),
]
PHOTOS_PERMISSION_ALLOW_ALL = [
    (AppiumBy.ID, "com.android.permissioncontroller:id/permission_allow_all_button"),
]

# Opens the "Start creating now" bottom sheet. Scripts looking for the selectors depending on phone's resolution and size
CREATE_ICON_BUTTON = [
    (AppiumBy.XPATH,
     '//android.widget.Button[@content-desc="Create"]'
     '/android.widget.FrameLayout[@resource-id="com.pinterest:id/icon_button_container"]'
     '/android.widget.Button[@resource-id="com.pinterest:id/icon_button"]'),
    (AppiumBy.XPATH,
     '//android.widget.FrameLayout[@content-desc="Create"]'
     '/android.widget.LinearLayout[@resource-id="com.pinterest:id/tab_content"]'),
]

# Bottom nav "Home" tab 
HOME_TAB_BUTTON = [
    (AppiumBy.ACCESSIBILITY_ID, "Home"),
    (AppiumBy.ID, "com.pinterest:id/bottom_nav_home_icon"),
]

# "Start creating now" sheet that appears after tapping Create -- pick "Pin"
PIN_ACTION_BUTTON = [
    (AppiumBy.XPATH, '//android.widget.Button[@content-desc="Pin"]'),
]

# Media picker "Next" button (enabled once a photo is selected).
GALLERY_NEXT_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/end_container_text_button"),
]

# Final submit on the Create Pin details screen -- labelled "Create".
CREATE_PIN_SUBMIT_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/create_gestalt_button"),
]

# --- Warm-up locators (home feed browsing: open a pin, like/save/comment) ---

# Generic home-feed pin card. The script finds any pins on screen for that phone/account/session.
PIN_CARD = [
    # Finds an actually-clickable match. Fall back to any match if no clickable one is found, in case a
    (AppiumBy.XPATH, '//*[contains(@content-desc, "Pin from") and @clickable="true"]'),
    (AppiumBy.XPATH, '//*[contains(@content-desc, "Pin from")]'),
]
REACT_BUTTON = [
    (AppiumBy.XPATH, '//android.widget.ImageView[@content-desc="React to this Pin"]'),
]
SAVE_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/save_pinit_bt"),
    (AppiumBy.XPATH, '//android.widget.Button[@content-desc="Save"]'),
]
COMMENTS_MODULE = [
    (AppiumBy.ID, "com.pinterest:id/new_comments_module_container"),
]
CLOSEUP_BACK_BUTTON = [
    (AppiumBy.ID, "com.pinterest:id/closeup_back_button"),
    (AppiumBy.ACCESSIBILITY_ID, "Back"),
]


def dismiss_popups(driver):
    """Clears the 'Is this your email?' prompt and both Android permission
    dialogs (notifications, photo access) if any of them are on screen."""
    tap_if_present(driver, EMAIL_UPDATE_DISMISS, timeout=2)
    tap_if_present(driver, NOTIFICATIONS_PERMISSION_ALLOW, timeout=2)
    tap_if_present(driver, PHOTOS_PERMISSION_ALLOW_ALL, timeout=2)


def ensure_on_home_feed(driver, max_attempts=6, settle_seconds=1.5):
    """Confirms the Create icon (home feed) is visible, actively navigating
    back if not."""
    for _ in range(max_attempts):
        try:
            find_any(driver, CREATE_ICON_BUTTON, timeout=4)
            return
        except TimeoutError:
            pass
        if not tap_if_present(driver, HOME_TAB_BUTTON, timeout=3):
            try:
                driver.back()
            except Exception:  # noqa: BLE001 - best-effort recovery, keep retrying
                pass
        time.sleep(settle_seconds)

    # Sanity check if the app is minimized
    driver.activate_app(PINTEREST_PACKAGE)
    try:
        find_any(driver, CREATE_ICON_BUTTON, timeout=10)
        return
    except TimeoutError:
        pass
    raise TimeoutError("Could not get back to the Pinterest home feed (Create icon never reappeared)")


def login(driver, username: str, password: str):

    try:
        find_any(driver, EMAIL_ONLY_FIELD, timeout=15).send_keys(username)
        tap_any(driver, CONTINUE_BUTTON)
    except TimeoutError:
        pass  # already on the combined email+password screen

    email_field = find_any(driver, LOGIN_EMAIL_FIELD, timeout=20)
    email_field.clear()
    email_field.send_keys(username)
    find_any(driver, LOGIN_PASSWORD_FIELD).send_keys(password)
    tap_any(driver, LOGIN_SUBMIT_BUTTON)
    dismiss_popups(driver)
    ensure_on_home_feed(driver)
    dismiss_popups(driver)


def publish_pin(driver, remote_image_path: str):

    ensure_on_home_feed(driver)
    dismiss_popups(driver)
    tap_any(driver, CREATE_ICON_BUTTON, timeout=10)
    dismiss_popups(driver)
    # Some sessions may skip the "Start creating now" sheet and go straight
    # to the photo picker, so a missing sheet here isn't fatal.
    tap_if_present(driver, PIN_ACTION_BUTTON, timeout=6)
    dismiss_popups(driver)  # photo-access permission dialog typically appears here

    # Match on "<upload folder>/<filename>", not filename alone -- a phone that
    # already has a same-named file elsewhere (e.g. Download/).
    unique_suffix = f"{Path(REMOTE_IMAGE_DIR).name}/{Path(remote_image_path).name}"
    thumbnail = [(AppiumBy.XPATH, f"//*[contains(@content-desc, '{unique_suffix}')]")]
    tap_any(driver, thumbnail, timeout=25)  # gallery grid can take a while to populate
    # "Next" starts disabled until the selection registers -- wait for enabled=true,
    # not just present, or the click silently no-ops on a disabled button.
    tap_any(driver, GALLERY_NEXT_BUTTON, timeout=15, require_enabled=True)

    # 45s here because Pinterest has to finish uploading/processing
    # the image before this button renders.
    tap_any(driver, CREATE_PIN_SUBMIT_BUTTON, timeout=45)
    ensure_on_home_feed(driver)
    dismiss_popups(driver)


def _open_random_pin(driver) -> bool:
    """Taps a random visible pin card on the home feed."""
    for by, value in PIN_CARD:
        try:
            cards = [c for c in driver.find_elements(by=by, value=value) if c.is_displayed()]
        except Exception:  # noqa: BLE001 - try the next candidate
            continue
        if not cards:
            continue
        try:
            random.choice(cards).click()
            return True
        except Exception:  # noqa: BLE001 - a missed tap just means "try something else"
            continue
    return False


def _close_pin(driver):
    """Closes the pin closeup view."""
    try:
        if find_any(driver, CREATE_ICON_BUTTON, timeout=1).is_displayed():
            return
    except TimeoutError:
        pass
    if not tap_if_present(driver, CLOSEUP_BACK_BUTTON, timeout=3):
        try:
            driver.back()
        except Exception:  # noqa: BLE001 - best-effort recovery
            pass


def _like_current_pin(driver) -> bool:
    return tap_if_present(driver, REACT_BUTTON, timeout=4)


def _save_current_pin(driver) -> bool:
    return tap_if_present(driver, SAVE_BUTTON, timeout=4)


def _view_comments(driver) -> bool:
    if not tap_if_present(driver, COMMENTS_MODULE, timeout=4):
        return False
    time.sleep(random.uniform(1, 2))
    try:
        driver.back()
    except Exception:  # noqa: BLE001 - best-effort recovery
        pass
    return True


def _scroll_feed(driver):
    """Swipes the feed by a random amount, mimicking idle scrolling rather than
    only ever tapping the first pin that happens to be visible."""
    size = driver.get_window_size()
    x = int(size["width"] * 0.5)
    start_y = int(size["height"] * random.uniform(0.55, 0.7))
    end_y = int(size["height"] * random.uniform(0.2, 0.35))
    driver.swipe(x, start_y, x, end_y, random.randint(400, 900))


WARM_UP_PIN_ACTIONS = [_like_current_pin, _save_current_pin, _view_comments]


def warm_up(driver, min_actions=WARM_UP_MIN_ACTIONS, max_actions=WARM_UP_MAX_ACTIONS):
    """Runs a randomized handful of home-feed browsing actions (open a pin,
    then like/save/view its comments, then back) right after login and before
    any pin gets published"""
    ensure_on_home_feed(driver)
    dismiss_popups(driver)

    action_count = random.randint(min_actions, max_actions)
    print(f"   [WARMUP] running {action_count} warm-up action(s)")

    done = 0
    attempts = 0
    while done < action_count and attempts < action_count * 3:
        attempts += 1
        if random.random() < 0.3:
            _scroll_feed(driver)
            time.sleep(random.uniform(1, 2))
            continue
        if not _open_random_pin(driver):
            _scroll_feed(driver)  # nothing tappable yet -- load more of the feed
            time.sleep(1)
            continue
        time.sleep(random.uniform(1.5, 2.5))  # let the closeup view finish loading
        dismiss_popups(driver)
        random.choice(WARM_UP_PIN_ACTIONS)(driver)
        time.sleep(random.uniform(1, 2))
        _close_pin(driver)
        dismiss_popups(driver)
        # Re-check after every iteration.
        try:
            ensure_on_home_feed(driver)
        except TimeoutError as exc:
            print(f"   [WARMUP] lost the home feed mid warm-up, stopping early: {exc}")
            break
        time.sleep(random.uniform(0.5, 1.5))
        done += 1

    ensure_on_home_feed(driver)
    dismiss_popups(driver)

# Image upload

def parse_image_range(range_str: str, line_no: int):
    """Parses a range like '1-3' or a single index like '4'."""
    try:
        if "-" in range_str:
            start_str, end_str = range_str.split("-", 1)
            start, end = int(start_str), int(end_str)
        else:
            start = end = int(range_str)
    except ValueError:
        raise ValueError(f"devices.txt line {line_no}: invalid image range {range_str!r} (expected e.g. '1-3' or '4')")
    if start < 1 or end < start:
        raise ValueError(f"devices.txt line {line_no}: invalid image range {range_str!r} (expected e.g. '1-3' or '4')")
    return start, end


def parse_devices(path: str):
    devices = []
    for line_no, raw_line in enumerate(Path(path).read_text().splitlines(), start=1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split()
        if len(parts) == 4:
            serial, token, username, password = parts
            image_range = None  # -> gets every image in the folder, see main()
        elif len(parts) == 5:
            serial, token, username, password, range_str = parts
            image_range = parse_image_range(range_str, line_no)
        else:
            raise ValueError(
                f"devices.txt line {line_no}: expected 4 or 5 fields "
                f"(IP:PORT TOKEN USERNAME PASSWORD [IMAGE_RANGE]), got {len(parts)}: {raw_line!r}"
            )
        devices.append(dict(serial=serial, token=token, username=username, password=password, image_range=image_range))
    return devices


def resolve_images(images_arg: str):
    """Returns every image found, sorted alphabetically. Per-device IMAGE_RANGE
    fields in devices.txt index into this same sorted list."""
    p = Path(images_arg)
    if p.is_file():
        return [str(p)]
    return sorted([str(f) for f in p.iterdir() if f.suffix.lower() in (".jpg", ".jpeg", ".png")])

# Auto-discovery: if an argument is omitted, look for it next to the script

def autodiscover_devices_file(base: Path) -> Path:
    candidate = base / "devices.txt"
    if candidate.is_file():
        return candidate
    raise FileNotFoundError(f"No devices.txt found in {base}")


def autodiscover_apk(base: Path) -> Path:
    apks = sorted(base.glob("*.apk"))
    if not apks:
        raise FileNotFoundError(f"No .apk file found in {base}")
    if len(apks) > 1:
        print(f">> multiple .apk files found in {base}, using {apks[0].name}")
    return apks[0]


def autodiscover_images(base: Path) -> Path:
    images_dir = base / "images"
    if images_dir.is_dir():
        return images_dir
    loose = [f for f in base.iterdir() if f.suffix.lower() in (".jpg", ".jpeg", ".png")]
    if loose:
        return base
    raise FileNotFoundError(f"No images/ folder or .jpg/.png files found in {base}")


def run_device(device: dict, apk_path: str, image_paths: list):
    serial = device["serial"]
    print(f"\n=== {serial} ===")

    if not connect_and_login(serial, device["token"]):
        return
    if not install_apk(serial, apk_path):
        disconnect(serial)
        return

    grant_full_media_access(serial)
    remote_images = push_images(serial, image_paths)

    driver = None
    try:
        driver = build_driver(serial)
        login(driver, device["username"], device["password"])
        try:
            warm_up(driver)
        except Exception as exc:  # noqa: BLE001 - warm-up is best-effort, publishing still matters
            print(f"   [WARMUP_FAILED] {serial}: {exc}")
        published = 0
        total = len(remote_images)
        for i, remote_image in enumerate(remote_images, start=1):
            print(f"   [PUBLISHING] {serial}: pin {i}/{total}: {Path(remote_image).name}")
            try:
                publish_pin(driver, remote_image)
                published += 1
            except Exception as exc:  # noqa: BLE001 - one slow/failed pin shouldn't cost the rest
                print(f"   [PIN_FAILED] {serial}: {Path(remote_image).name}: {exc}")
            time.sleep(2)
        print(f"   [DONE] {serial}: published {published}/{len(remote_images)} pin(s)")
    except Exception as exc:  # noqa: BLE001 - keep going to the next phone either way
        print(f"   [ERROR] {serial}: {exc}")
    finally:
        if driver:
            driver.quit()
        disconnect(serial)


def main():
    script_dir = Path(__file__).resolve().parent

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("devices_file", nargs="?", default=None,
                         help=f"IP:PORT TOKEN USERNAME PASSWORD [IMAGE_RANGE], one per line "
                              f"(default: devices.txt next to this script, i.e. {script_dir})")
    parser.add_argument("apk_path", nargs="?", default=None,
                         help="Path to the Pinterest APK to install (default: the .apk found next to this script)")
    parser.add_argument("images", nargs="?", default=None,
                         help="Folder of images, or a single image file "
                              "(default: an images/ folder, or loose .jpg/.png files, next to this script)")
    args = parser.parse_args()

    try:
        devices_file = Path(args.devices_file) if args.devices_file else autodiscover_devices_file(script_dir)
        apk_path = Path(args.apk_path) if args.apk_path else autodiscover_apk(script_dir)
        images_arg = Path(args.images) if args.images else autodiscover_images(script_dir)
    except FileNotFoundError as exc:
        sys.exit(f"{exc}\nPass devices_file / apk_path / images explicitly, or place them next to the script.")

    print(f">> devices file: {devices_file}")
    print(f">> apk: {apk_path}")
    print(f">> images: {images_arg}")

    try:
        devices = parse_devices(devices_file)
    except ValueError as exc:
        sys.exit(str(exc))

    all_images = resolve_images(images_arg)
    if not all_images:
        sys.exit(f"No images found at {images_arg}")

    print(f">> {len(devices)} phone(s) queued, {len(all_images)} image(s) available:")
    for i, image_path in enumerate(all_images, start=1):
        print(f"     {i}: {Path(image_path).name}")

    for device in devices:
        start, end = device["image_range"] or (1, len(all_images))
        if end > len(all_images):
            print(f"   [SKIP] {device['serial']}: image range {start}-{end} exceeds "
                  f"the {len(all_images)} image(s) available")
            continue
        image_subset = all_images[start - 1:end]
        run_device(device, str(apk_path), image_subset)


if __name__ == "__main__":
    main()
 
 
  1. Откройте ваш IDE или терминал и выполните:
    python appium_script.py

Скрипт будет показывать свой прогресс в терминале. Он подключится к первому телефону, завершит рабочий процесс Pinterest, отключится и продолжит с следующим телефоном.

Хотите попробовать другое Android приложение? Это демонстрационный скрипт, а не готовый инструмент для каждой службы. Вы можете адаптировать его, но каждое приложение использует разные кнопки и локаторы элементов. Используйте Appium Inspector для поиска нужных локаторов и обновления скрипта для вашего приложения.

 

Эта статья была полезной?

Оставить отзыв об этой статье

В этой статье:

  • Что делает скрипт
  • Перед началом
  • Настройка devices.txt
  • Запустите скрипт
  • appium_script.py

Сообщество Multilogin

Будьте в курсе, делитесь своими мыслями и общайтесь с другими!

Telegram Icon Multilogin в Telegram
Related Article Title Icon

Похожие статьи

  • Как войти в аккаунт Multilogin с помощью Python
  • Основы веб-скрейпинга с помощью Selenium
  • Как получить токены API с помощью Python
  • Как создать шаблоны профилей с помощью Python

Облачный телефон

  • Облачный телефон
  • Виртуальный телефон
  • Удалённый телефон
  • Ферма из телефонов
  • Облачный мобильный телефон
  • Облачный Андройд эмулятор
  • AI автоматизация

Мультиаккаунтинг

  • Мультиаккаунтинг в Instagram
  • Мультиаккаунтинг в TikTok
  • Мультиаккаунтинг в Reddit
  • Мультиаккаунтинг в Telegram
  • Мультиаккаунтинг в Facebook
  • Мультиаккаунтинг в Youtube
  • Мультиаккаунтинг в LinkedIn

сравнение

  • Multilogin vs. MoreLogin
  • Multilogin vs. FlashID
  • Multilogin vs. DuoPlus
  • Multilogin vs. VMOS cloud
  • Multilogin vs. Octo Browser
  • Multilogin vs. AdsPower
  • Multilogin vs. GoLogin

ПРОКСИ ДЛЯ платформ

  • Мобильные прокси
  • Прокси для Reddit
  • Прокси для TikTok
  • Прокси для Instagram
  • Прокси для Facebook
  • Прокси для YouTube
  • Прокси для LinkedIn

Применения

  • Облачный телефон для TikTok
  • Облачный телефон для Instagram
  • Облачный телефон для Reddit
  • Облачный телефон для Facebook
  • Облачный телефон для YouTube

ресурсы

  • База знаний
  • API документация
  • Глоссарий
  • Блог
  • Статус системы
  • Журнал изменений

БЕСПЛАТНЫЕ УТИЛИТЫ

  • Калькулятор дохода на YouTube
  • Калькулятор дохода в Instagram
  • URL конвертер в текст
  • Проверка локального Google SERP
  • Генератор случайных адресов

Связаться с нами

  • Служба поддержки 24/7
    support@multilogin.com
  • Реферальная программа
  • Партнерская программа
  • Цены
  • Вакансии
GDPR Compliant

© 2026 Multilogin. All rights reserved.

  • Privacy policy
  • Terms of service
  • Cookie policy
Multilogin watermark
  • ОБЛАЧНЫЙ ТЕЛЕФОН

    • Облачный телефон
    • Виртуальный телефон
    • Удалённый телефон
    • Ферма из телефонов
    • Облачный мобильный телефон
    • Облачный Андройд эмулятор
    • AI автоматизация
  • МУЛЬТИАККАУНТИНГ

    • Мультиаккаунтинг в Instagram
    • Мультиаккаунтинг в TikTok
    • Мультиаккаунтинг в Reddit
    • Мультиаккаунтинг в Telegram
    • Мультиаккаунтинг в Facebook
    • Мультиаккаунтинг в Youtube
    • Мультиаккаунтинг в LinkedIn
  • СРАВНЕНИЕ

    • Multilogin vs. MoreLogin
    • Multilogin vs. FlashID
    • Multilogin vs. DuoPlus
    • Multilogin vs. VMOS cloud
    • Multilogin vs. Octo Browser
    • Multilogin vs. AdsPower
    • Multilogin vs. GoLogin
  • ПРОКСИ ДЛЯ ПЛАТФОРМ

    • Мобильные прокси
    • Прокси для Reddit
    • Прокси для TikTok
    • Прокси для Instagram
    • Прокси для Facebook
    • Прокси для YouTube
    • Прокси для LinkedIn
  • ПРИМЕНЕНИЯ

    • Облачный телефон для TikTok
    • Облачный телефон для Instagram
    • Облачный телефон для Reddit
    • Облачный телефон для Facebook
    • Облачный телефон для YouTube
  • РЕСУРСЫ

    • База знаний
    • API документация
    • Глоссарий
    • Блог
    • Статус системы
    • Журнал изменений
  • БЕСПЛАТНЫЕ УТИЛИТЫ

    • Калькулятор дохода на YouTube
    • Калькулятор дохода в Instagram
    • URL конвертер в текст
    • Проверка локального Google SERP
    • Генератор случайных адресов
  • СВЯЗАТЬСЯ С НАМИ

    • Служба поддержки 24/7
      support@multilogin.com
    • Реферальная программа
    • Партнерская программа
    • Цены
    • Вакансии
GDPR Compliant
  • Privacy policy
  • Terms of service
  • Cookie policy

© 2026 Multilogin. All rights reserved.

Expand