Files
socialmag/selectors.py
T

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,
}