Skip to content

feat: команда setup-event — создание и настройка события VK через браузер - #15

Open
djachenko wants to merge 35 commits into
masterfrom
feat/26.06.25.setup-event
Open

feat: команда setup-event — создание и настройка события VK через браузер#15
djachenko wants to merge 35 commits into
masterfrom
feat/26.06.25.setup-event

Conversation

@djachenko

Copy link
Copy Markdown
Owner

Команда setup-event: создание события VK и его настройка через браузер.

Ветка пролежала с июня и не запускалась: отстала от master на 89 коммитов (master
переехал на src/-layout), а VK за это время переверстал часть страниц настроек, и
код молча их не применял.

Что чинилось в разметке VK

  • Сообщения переехали на React — новые селекторы, выбор по значению скрытого
    select вместо английских подписей.
  • У кнопки Save пропал класс group_save — ищется по подписи, обеим (ru и en).
  • Поле даты стало readonly — заполняется через datepicker: год листается стрелками,
    месяц берётся по индексу (названия локализованы).
  • Доступ сабмитится из виджета, а не из скрытого поля. Запись в поле молча терялась;
    чтение после перезагрузки этого не показывало, потому что значение и так было нужным —
    дефект виден только переключением в противоположную сторону.
  • Город оказался селектором, а не текстовым полем: ввод текста не менял скрытый id.
  • Неизвестное состояние переключателя больше не считается включённым, а после клика
    проверяется, что состояние действительно сменилось.

Что добавилось

Паритет со старым SetupEventAction: тип события, организатор, доступ, все 13 секций и
главный блок (задаётся перетаскиванием секции наверх с подтверждением в диалоге).

Инвайт-ссылки — фичи нет в API, только через браузер: VKBrowser.create_invite_link.

Чистка

vk_browser.py — 399 строк → 138. Флаги _USE_SCHEMA и _USE_CREATION_SCHEMA были
константами, поэтому легаси-визард и легаси-настройка секций не исполнялись никогда.
Перед сносом из них вынесено то, чего в схемах не было: ожидание капчи, дамп страницы
при таймауте, паузы с джиттером, разбор id с внятной ошибкой.

setup-event переехала на PatternCommand — разбор паттернов, построение фотосета и
отсев не-фотосетов делает базовый класс. Папка задаётся позиционным аргументом вместо
--folder.

Тесты и CI

Контракт селекторов: проверяет, что элементы, на которые опираются схемы, ещё есть на
живых страницах, и падает с именем пропавшего.

Запросы к VK ужаты примерно вчетверо: декартово произведение параметров заменено методом
уникальных значений (56 тестов → 17), страница переиспользуется между тестами.

Живые и перф-тесты отобраны маркерами в addopts, id тестового события читается
фикстурой — на уровне модуля это роняло сборку тестов там, где нет .env.

requires-python поднят до честных 3.12 (PEP 695-дженерики, X | None без
future-импорта); ruff запинен на 0.15, как в остальных проектах экосистемы.

Все три проверки зелёные: pytest 91 passed, ruff check чисто, mypy src/ — 135 файлов
без ошибок.

Чего в этой ветке нет

  • Живьём не проверено то, что делалось во второй половине дня: VK душит аккаунт после
    интенсивных прогонов и отдаёт страницы с заголовком Error. Не проверены закрытие
    модалок по Escape, город через список, делегирование set_sections.
  • Клики остались на execute_script. Переход на настоящие — отдельная задача: они
    проверяют видимость и перекрытие, то есть падают там, где JS молча делал вид, и каждый
    из 19 нужно проверить на живом VK.

🤖 Generated with Claude Code

djachenko added 23 commits June 5, 2026 05:26
# Conflicts:
#	pyproject.toml
#	src/justin/shared/helpers/utils.py
#	src/justin/typer/setup_event_command.py
#	src/justin/typer/upload_command.py
The id of the event integration tests run against does not belong in the
repository: they overwrite its settings.
Live and performance tests are deselected in addopts: the former need a browser
with a VK session, the latter measure wall time and only add noise on a shared
runner. pytest-timeout guards against a hung browser test.
dump_page.py replaces the throwaway exploration scripts: testids, inputs and
buttons of any page, with an optional click before the dump.
The API has no such call, so it goes through the browser. Lifetime and use limit
are picked by the value VK puts into the option id rather than by the label:
labels are localised. The url is read from the list item's input — the same one
the copy button reads — and the new link is found by diffing against a snapshot
taken before creation.
VK re-laid out several pages and the code silently stopped applying settings:

- messages moved to React — new selectors, and the choice is made by the hidden
  select's value instead of English labels;
- the save button lost its group_save class — it is now matched by label (ru/en);
- the date field became readonly — it is filled through the datepicker, the year
  stepped with arrows and the month picked by index;
- access is submitted from the widget, not from the hidden field it mirrors:
  writing the field was silently dropped, so the option is clicked by position;
- the city turned out to be a selector rather than a text field;
- an unknown switch state is no longer read as enabled, and after a click the
  state is checked to have actually changed.

The missing sections (services, chats, clips, articles, moments, products) and
the main block are covered too — the latter is set by dragging a section to the
top and confirming the dialog that follows.
Before dropping the dead create_event branch, what the schema lacked was moved
out of it: waiting for a captcha (VK answers event creation with one every now
and then), a page dump on timeout instead of a bare TimeoutException, jittered
pauses with a global PACE multiplier, and event id parsing with a clear error.

Step 3 is split into submit and event_id — the pair the tests expected.
_USE_SCHEMA and _USE_CREATION_SCHEMA were constants, so the legacy wizard and
the legacy section setup never ran. Their helpers and the duplicates that had
already moved into schemas go with them. set_sections now delegates to
apply_settings, leaving a single implementation of sections.

399 lines -> 138.
Parity with the old SetupEventAction: event type, organiser, access, all
13 sections and the main block, collected in _default_settings.
The contract suite checks that the selectors the schemas rely on still exist on
live pages, and fails naming the one that disappeared.

Requests to VK are cut down: the cartesian product of parameters is replaced by
each-choice coverage (56 tests -> 17), and a loaded page is reused between tests
through the reading/editing/modal context managers — cleanliness is dropped on
entry and restored only on a successful exit, so a failing test cannot hand the
next one a dirty page.

The test event id is read by a fixture: at module level it broke collection
wherever .env is absent.

Offline tests cover switch state, killing orphaned Chrome, the captcha and
each-choice coverage; startup speed is measured separately.
Radio and Dropdown declared value: object and silenced it with a type: ignore
that named the wrong error code and therefore did nothing. Both are now
parametrised by an enum, and every schema declares which enum its control takes.

get_attribute returns str | None: value is read through _value_of, which raises
the same meaningful error on a missing attribute as on a wrong value — otherwise
the diagnostics would be a TypeError.
Resolving patterns, building the photoset, running migrations and skipping
non-photosets is the base class's job — five unchecked Photoset | None
dereferences left along with the hand-rolled code. The manual mode stays a
separate branch of run().

The folder is a positional argument now, as in the other pattern commands,
instead of --folder.
requires-python promised 3.8 while the code needs 3.12 (PEP 695 generics in
section_schema, X | None without the future import all over). Ruff read that
floor and called our own syntax a parse error. Classifiers follow.

Ruff is pinned to 0.15: 0.16 widened the default rule set, so an unpinned
version turns CI red on code nobody touched — 532 errors against 15, 412 of
them in files this branch never opened.
The private method was called from nowhere. Its mechanic — click a control and
diff the DOM before and after, i.e. work out what the control does on its own —
is written down in the auto-explorer backlog entry; the code stays in history.
@djachenko
djachenko force-pushed the feat/26.06.25.setup-event branch from 5901cc3 to 6c53041 Compare September 1, 2026 13:40
Eighteen flat modules are laid out in packages: the schema and the settings of
one feature sit together because they change together — VK re-lays out a page,
and the pair is edited, not every schema at once.

    event_creation/   schema + settings
    event_settings/   schema + settings
    event_setup/      schema + settings
    sections/         schema + settings
    invite_link/      schema + settings
    explorers/        page, settings, event_edit
    shared/           custom_select, save_button, pacing, waiting

testpaths is limited to tests/, otherwise pytest picks up stray test_*.py from
the repository root.
A file name should be unique across the repository even when packages keep them
apart: five identical schema.py are impossible to find by name and collide as
soon as a file moves between packages.
CLAUDE.md still described the pre-src layout, listed shared/filesystem.py
that moved to justin_utils, and knew nothing about browser/.
@djachenko
djachenko force-pushed the feat/26.06.25.setup-event branch from 3cfe4a3 to 0cf24af Compare September 2, 2026 12:32
The floor said 0.0.1 while the code uses justin_utils.filesystem, .exif,
.sources and .pylinq — the same kind of lie requires-python told.
Under a debugger the browser window is visible and a timed-out wait dumps the
page; a plain run gets neither — headless, and no dump landing in the operator's
lap mid-shoot. create_event stays visible either way: a captcha needs a human.

Pauses come from per-purpose generators (BETWEEN_FIELDS, AFTER_ACTION,
PAGE_SETTLE) instead of bare numbers, so equally meaningful delays still differ
in length. The numbers themselves are empirical and now say so.

The captcha wait moved to shared/ and takes the url tail that means the action
did not go through, so it is no longer about event creation only.
Module-level generators were shared mutable state for no gain. varied() returns
a value near the given one, pause() sleeps for it; the named constants stay
plain numbers.
Every wait in the schemas was a bare time.sleep with a number nobody could
explain. They now go through pause() with names that say what is being waited
for; the drag-and-drop timings keep their own names because they are about how
VKUI recognises a grab, not about the page.

Layout follows the wizard schema: one argument per line, chained calls broken
up, blank lines around the steps, guard clauses instead of nested bodies.
A fixed ratio range made every pause equally uncertain regardless of length.
The deviation is now a share of the value passed in: 0.4 varies within
0.2..0.6, 2.0 within 1.0..3.0.
Shared pieces move out: page readiness, the save button, locator and wait
sugar so that present(wait, by_testid(x)) replaces the three-level tuple.

Sections go through one table instead of a chain of ifs; schemas are built
once. Helpers sit above their callers. The drag is verified right after it
happens, not only after the reload, and its steps vary in length and pace.
Comparisons assigned to a name get parentheses; the ternary is gone; cta is
spelled out.
The React select workaround is its own module with the reason written down;
the event id comes out of the url path instead of a regex; the two fillable
steps share a WizardStep base and run in one loop.
present(wait, by_testid(x)) replaces the three-level tuple across every schema
and the contract tests; locators are values, not strings. The page dump on
timeout lives in a WebDriverWait subclass that new_wait() hands out, so the
sugar gets it without a wrapper at each call site.

Helpers sit above their callers, no ternaries remain, and the invite-link
constants are locators too.
shared/ grew from four modules to nine; the debug/production split is worth
knowing before the first run; Folder lives in justin_utils since the src move.
def set_value(self, value: str, driver: WebDriver, wait: WebDriverWait) -> None:
el = wait_or_explore(driver, wait, EC.element_to_be_clickable(
(By.CSS_SELECTOR, f'[data-testid="{self.test_id}"]')))
ActionChains(driver).click(el).send_keys(value).perform()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А что, если это многострочником сделать?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сделано: цепочка ActionChains разбита построчно с \.

el = wait_or_explore(driver, wait, EC.element_to_be_clickable(
(By.CSS_SELECTOR, f'[data-testid="{self.test_id}"]')))
ActionChains(driver).click(el).send_keys(value).perform()
pause(0.5)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.5 -- это эмпирическое?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Да, подобрано вручную. Теперь это именованная константа AFTER_ACTION = 0.5 в shared/pacing.py, там же BETWEEN_FIELDS, AFTER_SCROLL, PAGE_SETTLE.

if not value:
return
select_el = driver.find_element(By.CSS_SELECTOR, '[name="access"]')
driver.execute_script("arguments[0].click()", select_el)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А почему не select_el.click()?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обычный click в этих местах перехватывал оверлей визарда — Selenium кидал ElementClickIntercepted. JS-клик обходит это, но и события не эмулирует по-человечески. Переезд на настоящие клики — отдельная задача в бэклоге (26.08.31.refactor_browser_actions, 19 мест), делать после живой проверки.

for aria_label, val in parts:
spinbuttons = driver.find_elements(By.CSS_SELECTOR, f'[aria-label="{aria_label}"]')
spinbutton = spinbuttons[self.n]
driver.execute_script("arguments[0].focus()", spinbutton)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Вообще, зачем все через arguments[0].action_name()?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

См. выше: там, где живой клик перехватывается, оставлен JS; остальное в бэклоге на переезд к настоящим действиям. У focus() та же причина — spinbutton в iframe не берёт фокус кликом.

(By.XPATH, "//*[contains(text(), 'Enter end date')]")
))
driver.execute_script("arguments[0].click()", end_date_btn)
pause(0.5)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Вертикальные отступы бы...

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сделано: пустые строки между логическими блоками во всех схемах, не только тут.

if config.posts is not None:
print(f" [posts] {config.posts}")
PostsSchema("wall")(config.posts, driver, wait)
if config.photos is not None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Может, тут отступы вставить?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сделано.

PostsSchema("wall")(config.posts, driver, wait)
if config.photos is not None:
print(f" [photos] {config.photos}")
PhotosSchema("photos")(config.photos, driver, wait)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я вот не знаю: насколько ок создавать схему, вызывать и сразу же отбрасывать?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Схемы — frozen dataclass без состояния, инстанс ничего не стоит. Но сам список «создать-вызвать-выбросить» ушёл: секции теперь в таблицах _MODAL_SECTIONS/_LIST_SECTIONS на уровне модуля, цикл идёт по ним.

print(f" [services] {config.services}")
ServicesSchema("services")(config.services, driver, wait)

for name, test_id in [("chats", "chats"), ("clips", "short_videos"),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Этот список просит константу.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сделано: _LIST_SECTIONS, рядом _MODAL_SECTIONS.

# ── cta ─────────────────────────────────────────────────────

@staticmethod
def _apply_cta(event_id: int, s: CtaSettings, driver: WebDriver, wait: WebDriverWait) -> None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Что есть cta?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call-to-action — кнопка на шапке сообщества («Написать», «Перейти на сайт» и т.п.). Написано в докстринге _apply_cta.

driver.get(f"https://vk.com/event{event_id}/settings/messages")
_wait_for_content(driver, wait)
_set_custom_select(driver, wait, _MESSAGES_SELECT,
_MESSAGES_ON if s.enabled else _MESSAGES_OFF)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тернарник

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Убран, if/else со значением.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant