107 lines
3.8 KiB
Python
107 lines
3.8 KiB
Python
"""YouTube community post flow.
|
|
|
|
1. navigate youtube.com
|
|
2. click button[aria-label="Create"]
|
|
3. click "Create post" in the dropdown
|
|
4. wait for the redirect to the channel posts page
|
|
5. type into #contenteditable-root
|
|
6. click Post
|
|
7. the editor emptying / the post appearing = success
|
|
|
|
Community posts require a channel that has them enabled. Video upload
|
|
(post_youtube_video in the proposal) is NOT built here — the brief scoped this
|
|
module to the community post.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
from ..cdp import CDPError, human_delay
|
|
from . import guard, require_text, validate_media
|
|
from ..selectors import YOUTUBE as S
|
|
|
|
PLATFORM = "yt"
|
|
|
|
|
|
async def post(cdp_client, text, media_path=None, options=None):
|
|
options = options or {}
|
|
require_text(PLATFORM, text)
|
|
validate_media(media_path)
|
|
if media_path:
|
|
# Community posts do take images, but that flow is unmapped. Refuse
|
|
# rather than drop the media on the floor.
|
|
raise CDPError(
|
|
"yt: media on community posts is not implemented (the image-attach "
|
|
"flow has not been mapped live). Post text-only, or use a platform "
|
|
"whose media flow is built."
|
|
)
|
|
|
|
tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com")
|
|
if reused:
|
|
await tab.eval("window.onbeforeunload = null")
|
|
await tab.navigate(S["url"])
|
|
return await guard(PLATFORM, tab, _flow(tab, text))
|
|
|
|
|
|
async def _flow(tab, text):
|
|
await tab.wait_for(S["create_button"])
|
|
await human_delay()
|
|
await tab.click(S["create_button"])
|
|
|
|
# TODO: VALIDATE LIVE — the dropdown item text and element type.
|
|
await _wait_for_js(tab, S["create_post_item_js"], "the 'Create post' menu item")
|
|
await tab.click_js(S["create_post_item_js"])
|
|
|
|
# Step 4: YouTube navigates to the channel's posts page.
|
|
await tab.wait_for_load(timeout=30.0)
|
|
await tab.wait_for(S["editor"], timeout=30.0)
|
|
|
|
# #contenteditable-root is a YouTube custom editor: it listens to real key
|
|
# events, so type per-char rather than insertText.
|
|
# TODO: VALIDATE LIVE — whether insertText alone enables the Post button.
|
|
await tab.type_text(S["editor"], text, per_char=True)
|
|
|
|
if await tab.exists(S["post_button"]):
|
|
await tab.click(S["post_button"])
|
|
else:
|
|
await tab.click_js(S["post_button_js"])
|
|
|
|
# TODO: VALIDATE LIVE — success marker. The editor going empty is the
|
|
# cheapest observable signal; the proposal says "post appears".
|
|
await _wait_for_editor_empty(tab)
|
|
|
|
|
|
async def _wait_for_js(tab, expression, what, timeout=20.0):
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
if await tab.eval("!!(%s)" % expression):
|
|
return
|
|
await asyncio.sleep(0.25)
|
|
raise CDPError(f"yt: {what} did not appear within {timeout}s")
|
|
|
|
|
|
async def _wait_for_editor_empty(tab, timeout=30.0):
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
text = await tab.eval(
|
|
"(() => { const e = document.querySelector('%s');"
|
|
" return e ? e.innerText.trim() : null; })()" % S["editor"]
|
|
)
|
|
if text == "":
|
|
return
|
|
await asyncio.sleep(0.5)
|
|
raise CDPError("yt: editor still holds text 30s after clicking Post")
|
|
|
|
|
|
async def check_login(cdp_client):
|
|
tab, reused = await cdp_client.get_or_open_tab(S["url"], "youtube.com")
|
|
if reused:
|
|
await tab.eval("window.onbeforeunload = null")
|
|
await tab.navigate(S["url"])
|
|
await asyncio.sleep(5)
|
|
if await tab.exists(S["login_marker"]):
|
|
return False
|
|
return await tab.eval(
|
|
"!!document.querySelector('#avatar-btn, button#avatar-btn,"
|
|
" ytd-topbar-menu-button-renderer #avatar-btn')"
|
|
)
|