"""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"]', } # -------------------------------------------------------------------------- # Twitter / X # -------------------------------------------------------------------------- TWITTER = { "url": "https://x.com/home", "compose_url": "https://x.com/compose/post", # The main tweet box is a DraftJS contenteditable inside the compose dialog "composer_textbox": '[data-testid="tweetTextarea_0"] [contenteditable="true"]', # Fallback: the inline composer on the home timeline "inline_composer": '[data-testid="tweetTextarea_0"]', "post_button_js": ( "document.querySelector('[data-testid=\"tweetButtonInline\"]')" " || document.querySelector('[data-testid=\"tweetButton\"]')" ), "file_input": 'input[type="file"][accept*="image"]', "media_button": '[data-testid="fileInput"]', # After posting, the compose dialog closes or the timeline updates "toast_text": "Your post was sent", "login_marker": 'a[href="/login"], a[href*="flow/login"], input[name="text"][autocomplete="username"]', # For reading feeds "tweet_article": 'article[data-testid="tweet"]', "tweet_text": '[data-testid="tweetText"]', "tweet_user": '[data-testid="User-Name"]', "reply_button": '[data-testid="reply"]', "following_tab_js": ( "[...document.querySelectorAll('[role=\"tab\"], a[role=\"tab\"]')]" ".find(t => t.innerText.trim() === 'Following')" ), "search_url": "https://x.com/search?q={query}&src=typed_query&f=live", } PLATFORMS = { "threads": THREADS, "fb": FACEBOOK, "ig": INSTAGRAM, "yt": YOUTUBE, "tiktok": TIKTOK, "twitter": TWITTER, }