166 lines
7.0 KiB
Python
166 lines
7.0 KiB
Python
"""Every platform selector, in one file.
|
|
|
|
When a platform ships a UI change, this is the only file that should need
|
|
touching (proposal, Risks table). Nothing here is guessed silently — anything
|
|
that came from a doc rather than a live FAFO session is marked, and every
|
|
selector a flow depends on is validated live before we trust it.
|
|
|
|
Convention:
|
|
*_SEL CSS selector string
|
|
*_JS a JS expression returning an element (for click_js)
|
|
"""
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Threads
|
|
# --------------------------------------------------------------------------
|
|
|
|
THREADS = {
|
|
"url": "https://www.threads.com/",
|
|
# New inline composer (2026-09 redesign) — no dialog.
|
|
"composer_input": '[placeholder="What\'s new?"]',
|
|
"composer_trigger_js": (
|
|
"document.querySelector('[placeholder=\"What\\'s new?\"]')"
|
|
" || [...document.querySelectorAll('span,div')]"
|
|
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?')"
|
|
),
|
|
"post_button_js": (
|
|
"[...document.querySelectorAll('button, [role=\"button\"]')]"
|
|
".find(b => b.innerText.trim() === 'Post')"
|
|
),
|
|
# Legacy dialog selectors (kept for fallback)
|
|
"dialog": '[role="dialog"]',
|
|
"dialog_textbox": '[role="dialog"] [role="textbox"]',
|
|
"attach_media": '[role="dialog"] [aria-label="Attach media"]',
|
|
"file_input": 'input[type="file"]',
|
|
"login_marker": 'input[name="username"], a[href*="/login"]',
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Facebook
|
|
# --------------------------------------------------------------------------
|
|
|
|
FACEBOOK = {
|
|
"url": "https://www.facebook.com/",
|
|
# ⚠️ Mouse-clicking the visible "What's on your mind" span opens Messenger.
|
|
# Walk up to the enclosing [role=button] and .click() it instead.
|
|
"composer_trigger_js": (
|
|
"(() => { const span = [...document.querySelectorAll('span')]"
|
|
".find(s => s.innerText && s.innerText.toLowerCase().includes('on your mind'));"
|
|
" return span ? span.closest('[role=\"button\"]') : null; })()"
|
|
),
|
|
"dialog": '[role="dialog"]',
|
|
"dialog_textbox": '[role="dialog"] [role="textbox"][contenteditable="true"]',
|
|
# TODO: VALIDATE LIVE — the Photo/video button inside the create-post dialog.
|
|
"photo_video_js": (
|
|
"[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"]')]"
|
|
".find(b => /photo\\/video/i.test(b.innerText || ''))"
|
|
),
|
|
"file_input": '[role="dialog"] input[type="file"][accept*="image"]',
|
|
# TODO: VALIDATE LIVE — audience/privacy selector inside the dialog.
|
|
"privacy_button_js": (
|
|
"[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"]')]"
|
|
".find(b => /public|friends|audience/i.test(b.innerText || ''))"
|
|
),
|
|
"post_button": '[role="dialog"] [aria-label="Post"]',
|
|
"post_button_js": (
|
|
"[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], "
|
|
"[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Post')"
|
|
),
|
|
"create_post_text": "Create post",
|
|
"login_marker": 'input[name="email"][type="text"], form[action*="login"]',
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Instagram
|
|
# --------------------------------------------------------------------------
|
|
|
|
INSTAGRAM = {
|
|
"url": "https://www.instagram.com/",
|
|
"new_post_svg": 'svg[aria-label="New post"]',
|
|
# JS click — more reliable than mouse on sidebar SVG icons
|
|
"new_post_js": (
|
|
"(() => { const svg = document.querySelector('svg[aria-label=\"New post\"]');"
|
|
" if (svg) { const btn = svg.closest('a, button, [role=\"button\"], div[role=\"button\"]')"
|
|
" || svg.parentElement; btn.click(); return true; } return false; })()"
|
|
),
|
|
"dialog": '[role="dialog"]',
|
|
"create_dialog_text": "Create new post",
|
|
"file_input": '[role="dialog"] input[type="file"]',
|
|
"next_button_js": (
|
|
"[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], "
|
|
"[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Next')"
|
|
),
|
|
"caption_box": '[role="dialog"] textarea, [role="dialog"] [contenteditable="true"]',
|
|
"share_button_js": (
|
|
"[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], "
|
|
"[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Share')"
|
|
),
|
|
"shared_text": "Your post has been shared",
|
|
"done_button_js": (
|
|
"[...document.querySelectorAll('[role=\"dialog\"] [role=\"button\"], "
|
|
"[role=\"dialog\"] button')].find(b => b.innerText.trim() === 'Done')"
|
|
),
|
|
"login_marker": 'input[name="username"]',
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# YouTube — community post
|
|
# --------------------------------------------------------------------------
|
|
|
|
YOUTUBE = {
|
|
"url": "https://www.youtube.com/",
|
|
"create_button": 'button[aria-label="Create"]',
|
|
# TODO: VALIDATE LIVE — the dropdown item is a tp-yt-paper-item with text.
|
|
"create_post_item_js": (
|
|
"[...document.querySelectorAll('tp-yt-paper-item, ytd-compact-link-renderer, "
|
|
"yt-formatted-string')].find(e => e.innerText && "
|
|
"e.innerText.trim().toLowerCase() === 'create post')"
|
|
),
|
|
"editor": "#contenteditable-root",
|
|
"post_button": 'button[aria-label="Post"]',
|
|
# TODO: VALIDATE LIVE — YouTube also renders Post as ytd-button-renderer text.
|
|
"post_button_js": (
|
|
"[...document.querySelectorAll('button, tp-yt-paper-button')]"
|
|
".find(b => b.innerText.trim().toLowerCase() === 'post')"
|
|
),
|
|
"login_marker": 'a[href*="accounts.google.com/ServiceLogin"]',
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# TikTok
|
|
# --------------------------------------------------------------------------
|
|
|
|
TIKTOK = {
|
|
"url": "https://www.tiktok.com/tiktokstudio/upload?from=upload",
|
|
"video_file_input": 'input[type="file"][accept*="video"]',
|
|
"photo_file_input": 'input[type="file"][accept*="image"]',
|
|
# TODO: VALIDATE LIVE — the Photos/Video tab switch on the upload page.
|
|
"photos_tab_js": (
|
|
"[...document.querySelectorAll('div, span, button')]"
|
|
".find(e => e.innerText && e.innerText.trim() === 'Photos')"
|
|
),
|
|
# TikTok's caption box is a DraftJS contenteditable.
|
|
"caption_box": 'div[contenteditable="true"]',
|
|
# TODO: VALIDATE LIVE — upload progress marker; used to know processing is done.
|
|
"uploaded_text": "Uploaded",
|
|
"post_button_js": (
|
|
"[...document.querySelectorAll('button')]"
|
|
".find(b => ['post','publish'].includes(b.innerText.trim().toLowerCase()) "
|
|
"&& !b.disabled)"
|
|
),
|
|
"success_text": "Your video is being uploaded",
|
|
"continue_post_js": (
|
|
"[...document.querySelectorAll('button, [role=\"button\"]')]"
|
|
".find(b => /continue to post|post now/i.test(b.innerText.trim()))"
|
|
),
|
|
"login_marker": 'a[href*="/login"]',
|
|
}
|
|
|
|
PLATFORMS = {
|
|
"threads": THREADS,
|
|
"fb": FACEBOOK,
|
|
"ig": INSTAGRAM,
|
|
"yt": YOUTUBE,
|
|
"tiktok": TIKTOK,
|
|
}
|