From 46ecb93b77eb15fc41fd1bac5395b41c57dc57fa Mon Sep 17 00:00:00 2001 From: "Dr. Weyers" Date: Fri, 15 May 2026 13:58:03 -0400 Subject: [PATCH 1/4] feat(domain-skills): add Mastodon engagement skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds agent-workspace/domain-skills/mastodon/ with two playbooks: - discovery.md — hashtag timeline scraping across federated instances (mastodon.social, fosstodon.org, hachyderm.io), bot-flag respect, lazy-load via scroll, login-wall detection. - engagement.md — reply / favourite / follow via the inline composer, React-input setter bypass for the publish button, locked-account follow semantics, rate-limit signals. Skills anchor on upstream Mastodon class names (.status, .display-name__account, .status__action-bar) which are stable across the per-instance theming Rails emits. Field-tested 2026-05-15. Closes the Mastodon side of #356. --- .../domain-skills/mastodon/discovery.md | 130 +++++++++++++ .../domain-skills/mastodon/engagement.md | 182 ++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 agent-workspace/domain-skills/mastodon/discovery.md create mode 100644 agent-workspace/domain-skills/mastodon/engagement.md diff --git a/agent-workspace/domain-skills/mastodon/discovery.md b/agent-workspace/domain-skills/mastodon/discovery.md new file mode 100644 index 00000000..04ee9329 --- /dev/null +++ b/agent-workspace/domain-skills/mastodon/discovery.md @@ -0,0 +1,130 @@ +# Mastodon — Hashtag Discovery + +Field-tested against `mastodon.social`, `fosstodon.org`, and `hachyderm.io` on 2026-05-15. +Vouched by SmartSocial — used in production by Bob (https://github.com/drmweyers/SmartSocial/tree/main/agents/bob). + +**Federation caveat.** Mastodon is not one site. Every instance ships its own (server-side rendered) theme on top of the same `tootsuite/mastodon` upstream, so DOM hashes and CSS class names diverge between instances. Anchor on the **semantic class names** (`.status`, `.account`, `.detailed-status`) and `data-*` attributes that the upstream Rails app emits — those are stable across instances. Avoid layout-shell selectors (sidebar widths, navigation chrome) — those are themed per-instance. + +## URL patterns + +| What | URL | +|------|-----| +| Hashtag timeline (federated) | `https:///tags/` | +| Hashtag timeline (local-only) | `https:///tags/?local=true` | +| Single status (canonical, instance-local) | `https:///@/` | +| Remote status (forwards through home instance) | `https:///@@/` | +| Public local timeline | `https:///public/local` | +| Account profile | `https:///@` | + +`` is the hashtag **without** the `#`. `#climate` → `/tags/climate`. + +## DOM anchors + +| Target | Selector | Notes | +|---|---|---| +| Status container | `article.status, article.detailed-status` | Detailed = the focused status on a permalink page. Both carry `data-id=""`. | +| Status text body | `.status__content, .e-content` | Server-rendered HTML — `

` paragraphs, ``, ``. Read `.innerText` for plain text, or `.innerHTML` if you need links/mentions. | +| Author handle | `.display-name__account` | Always the federated form (`@user@instance.tld`). Even on the home instance, this is rendered with the full handle once the status is from a remote actor. | +| Author display name | `.display-name__html` | May contain custom emojis as `` — strip if you need plain text. | +| Status timestamp | `time.formatted-date` | The `datetime` attribute is an ISO 8601 string; the visible text is a relative-time renderer (e.g. "3h"). Always read `datetime`, never `innerText`. | +| Status permalink | `a.status__relative-time` | `href` is the canonical URL of the status on its origin instance. | +| Reply button | `button.status__action-bar__button.icon-button[aria-label="Reply"]` | aria-label is localized — match by `title` or by position in `.status__action-bar` (first icon button) if the user's locale isn't English. | +| Favourite button | `button.icon-button.star-icon[aria-label="Favourite"]` | Aria-pressed flips to `true` after click. | +| Boost button | `button.icon-button.reblog-icon[aria-label="Boost"]` | Same pattern as favourite. Do not boost as part of automated engagement — boosts amplify into your followers' timelines and look spammy. | +| Bot badge | `.display-name .bot, .account__header__bot` | Present when `account.bot === true`. **Skip these accounts.** See gotcha below. | + +## Bot accounts + +**Skip statuses authored by accounts flagged as bots.** Mastodon convention is that bot operators set `bot: true` on their account; engaging back creates bot-on-bot loops that the community considers low-quality. The UI surfaces this as a small "BOT" badge next to the display name (`.account__header__bot` on profile pages, `.display-name .bot` in timelines). + +```python +is_bot = js("""(() => { + const el = document.querySelector('article.status .display-name .bot'); + return !!el; +})()""") +if is_bot: + # skip — do not extract this status for engagement + pass +``` + +## Pulling a hashtag timeline + +The federated timeline at `/tags/` is the canonical discovery surface — it includes posts from the entire fediverse that the home instance has cached, not just locals. The local-only variant (`?local=true`) is fine for niche instances (`fosstodon.org/tags/python`) where you want only that community's voice. + +```python +new_tab("https://mastodon.social/tags/climate") +wait_for_load() +wait(1.5) # SSR is fast but action-bar buttons hydrate after first paint + +statuses = js(r""" + Array.from(document.querySelectorAll('article.status')).map(el => { + const time = el.querySelector('time.formatted-date'); + const link = el.querySelector('a.status__relative-time'); + const body = el.querySelector('.status__content'); + const acct = el.querySelector('.display-name__account'); + const isBot = !!el.querySelector('.display-name .bot'); + return { + status_id: el.getAttribute('data-id'), + url: link?.href || null, + author: acct?.innerText?.trim() || null, + created_at: time?.getAttribute('datetime') || null, + text: body?.innerText?.trim() || '', + is_bot: isBot, + }; + }).filter(s => s.status_id && !s.is_bot) +""") +``` + +## Lazy load — scroll, don't paginate + +The timeline is an infinite scroll. There is no "Next page" control. The DOM keeps mounted statuses (~50 visible at a time) and prepends new ones at the top via a small "Show new toots" banner when polling discovers them. + +```python +# Collect up to TARGET statuses by scrolling +TARGET = 30 +seen = {} +for _ in range(10): # cap scrolls + batch = js(...) # the JS block above + for s in batch: + seen.setdefault(s["status_id"], s) + if len(seen) >= TARGET: + break + scroll(640, 400, dy=900) + wait(1.0) +``` + +`wait(1.0)` is enough on `mastodon.social` and `fosstodon.org`. On smaller instances with slower hardware (single VPS), bump to `wait(2.0)` if the batch returns the same IDs twice. + +## Login wall on remote-status pages + +When you click a status that originated on a **different** instance from the one you're browsing, Mastodon may render a federated-actor stub page rather than the full thread. If you are logged in, a "Sign in to participate" interstitial does **not** appear — the page renders with the action bar enabled. If you are anonymous, every action button is replaced with a "You need to be logged in" tooltip and clicking does nothing. + +Detect with: + +```python +auth_wall = js(""" + (() => { + const interstitial = document.querySelector('.sign-in-banner, .columns-area__panels__main .activity-stream-tabs'); + const replyBtn = document.querySelector('button[aria-label="Reply"]:not([disabled])'); + return !!interstitial && !replyBtn; + })() +""") +if auth_wall: + # stop and ask the user to log in + pass +``` + +## Rate-limit signals + +Per the upstream Mastodon REST contract, anonymous browse has soft per-IP limits (varies by instance, typically ~300 requests / 5min). The web UI surfaces overage as a generic red toast: "Failed to fetch — please try again." Screenshot to verify; the toast lives in `.notification-bar` and auto-dismisses after 5s. + +Don't try to "fix" this with retries — back off and revisit the hashtag in 5-10 minutes. Browsing the same hashtag every <60s for an extended period is a heuristic the operator may flag. + +## Gotchas + +- **Federation lag.** A status posted on `fosstodon.org` 30 seconds ago may not appear in `mastodon.social/tags/python` for another 30-90 seconds. If you are polling for fresh content, query the origin instance directly when you know it. +- **`time.formatted-date.innerText` is relative**, not ISO. Always read the `datetime` attribute for sortable timestamps. +- **Hashtag is case-insensitive in the URL but case-preserved in the DOM.** `/tags/Python` and `/tags/python` resolve to the same timeline; matched-tag text in the status body keeps the author's original casing. +- **Custom emoji in display names.** `.display-name__html` may render `:emoji_shortcode:` as ``. Strip these for plain-text author labels. +- **Detailed-status pages have a different selector.** The permalink page (`/@user/status_id`) uses `article.detailed-status` for the focused status and `article.status` for the surrounding thread context. Match both with `article.status, article.detailed-status` if you want to scrape a thread. +- **Don't boost from automation.** The boost button works the same as favourite mechanically, but boosting amplifies into your own followers' timelines — it's a different social contract. Stick to reply + favourite for engagement workflows. diff --git a/agent-workspace/domain-skills/mastodon/engagement.md b/agent-workspace/domain-skills/mastodon/engagement.md new file mode 100644 index 00000000..afd5f290 --- /dev/null +++ b/agent-workspace/domain-skills/mastodon/engagement.md @@ -0,0 +1,182 @@ +# Mastodon — Reply, Favourite, Follow + +Field-tested against `mastodon.social` and `fosstodon.org` on 2026-05-15 using a logged-in browser session. +Vouched by SmartSocial — used in production by Bob (https://github.com/drmweyers/SmartSocial/tree/main/agents/bob). + +**Prereq:** logged in on the instance you are engaging from. The action bar buttons render but no-op when anonymous. Use `interaction-skills/profile-sync.md` to bring up a logged-in browser if running remote. + +**Scope:** reply + favourite + follow. **Boost is intentionally excluded** — boosting amplifies into your followers' timelines and is a different social contract than a quiet reply or fav. If you need boost behaviour, ask first. + +## Replying to a status + +The reply button on every status opens an inline compose form. The form is the same composer the homepage uses, just pre-populated with the parent author's handle as the first mention. + +```python +# Assumes you have already navigated to a status permalink or a timeline +# entry. Click the Reply button on the target article. +clicked = js(r""" + (() => { + const art = document.querySelector('article.status[data-id="STATUS_ID_HERE"]'); + if (!art) return false; + const btn = art.querySelector('button.icon-button[title="Reply"], button.icon-button[aria-label="Reply"]'); + if (!btn) return false; + btn.click(); + return true; + })() +""") +if not clicked: + raise RuntimeError("reply button not found — wrong status id or not yet hydrated") + +wait(0.8) # composer expands inline; ~500ms of CSS transition + +# Compose +js(r""" + (() => { + const ta = document.querySelector('textarea.autosuggest-textarea__textarea, textarea#status-textarea'); + if (!ta) throw new Error('compose textarea not found'); + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; + setter.call(ta, 'YOUR_REPLY_TEXT_HERE'); + ta.dispatchEvent(new Event('input', { bubbles: true })); + })() +""") + +# Publish +clicked = js(r""" + (() => { + const btn = document.querySelector('button.button.button--block, button[type="submit"].button[class*="primary"]'); + if (!btn || btn.disabled) return false; + btn.click(); + return true; + })() +""") +``` + +**Why the setter dance.** Mastodon's compose box is a React-controlled textarea. Setting `textarea.value = "..."` directly does not fire React's onChange — the publish button stays disabled because internal state still says "empty." Using the native `HTMLTextAreaElement.value` setter + dispatching `input` is the standard React-input bypass. + +### Verifying the reply landed + +After the publish click, the composer collapses and a new `article.status` is prepended into the thread descendants below the parent. Verify by either: + +```python +# Path 1 — wait for the composer to disappear (cheap, instance-agnostic) +import time +for _ in range(15): + open_composer = js("document.querySelector('textarea#status-textarea') !== null") + if not open_composer: + break + time.sleep(0.2) + +# Path 2 — pull the most recent status in the thread and check its author. +# The composer collapses after publish, so read the logged-in handle from the +# top-of-page navigation bar (or the column-link "/@me" href) instead. +my_handle = js(r""" + (() => { + const a = document.querySelector('a.column-link[href^="/@"]'); + return a ? a.getAttribute('href').replace(/^\//, '') : null; + })() +""") +latest = js(r""" + (() => { + const arts = Array.from(document.querySelectorAll('article.status')); + const last = arts[arts.length - 1]; + if (!last) return null; + return { + id: last.getAttribute('data-id'), + author: last.querySelector('.display-name__account')?.innerText?.trim() || null, + text: last.querySelector('.status__content')?.innerText?.trim() || '', + }; + })() +""") +# latest.author should match my_handle and latest.text should contain your reply. +``` + +Path 1 is sufficient for fire-and-forget engagement. Path 2 matters if you need the new `status_id` to persist (e.g. for tracking the engagement outcome 6h later). + +## Favourite a status + +The favourite (star) icon is the second action-bar button on every status. The button's `aria-pressed` attribute toggles to `true` when the API call succeeds. + +```python +ok = js(r""" + (() => { + const art = document.querySelector('article.status[data-id="STATUS_ID_HERE"]'); + if (!art) return false; + const btn = art.querySelector('button.icon-button.star-icon, button[aria-label="Favourite"]'); + if (!btn) return false; + btn.click(); + return true; + })() +""") +wait(0.5) + +pressed = js(r""" + (() => { + const art = document.querySelector('article.status[data-id="STATUS_ID_HERE"]'); + const btn = art?.querySelector('button.icon-button.star-icon, button[aria-label="Favourite"]'); + return btn?.getAttribute('aria-pressed') === 'true'; + })() +""") +``` + +Favouriting is idempotent in the UI — clicking an already-favourited status un-favourites it. Read `aria-pressed` **before** you click if you only want to set it (not toggle). + +## Follow an account + +From a profile page (`/@user` on the home instance, or `/@user@remote_instance` for a federated actor): + +```python +new_tab("https://mastodon.social/@example_user") +wait_for_load() +wait(1.2) + +# The follow button text varies by current state: +# "Follow" (not following) → click to follow +# "Following" (already following) → already done, skip +# "Cancel follow request" (locked account, request pending) → already requested +# "Unblock" / blocked / muted → stop, account is in a bad state + +state = js(r""" + (() => { + const btn = document.querySelector('.account__header__tabs__buttons button.button, button.logo-button'); + if (!btn) return 'no-button'; + return (btn.innerText || '').trim().toLowerCase(); + })() +""") +# 'follow' → safe to click +# 'following' → no-op, you are done +# 'cancel follow request' → pending, no-op +# anything else → stop and inspect +``` + +### Locked accounts + +Accounts with `🔒 Locked` next to their display name require the operator to approve the follow request manually. Clicking Follow on a locked account silently changes the button to "Cancel follow request" — there is no toast. The actual follow doesn't happen until the operator approves it on their end, which can take hours or never. For automation: treat "Cancel follow request" as a soft success but do not assume you can DM the account, see their followers list, etc. + +## Confidence threshold for auto-engagement + +For autonomous engagement workflows (no human in the loop), only send a reply if your generated draft scores `confidence ≥ 0.85` against the parent post's intent. Below that, queue for human approval. Mastodon's culture is small-instance, slow-social — low-quality drive-by replies get reported quickly, and reports stick to your handle, not your IP. This is the threshold Bob uses in production. + +Favourite is lower-stakes — favouriting a status that passed your discovery filter is reasonable at any confidence. The signal cost of a wrong favourite is near zero. + +## Rate limits + +The web UI enforces the same per-account API limits as the REST endpoint (`/api/v1/statuses`, `/api/v1/statuses/:id/favourite`, etc.). On a default `mastodon.social` account that's roughly **300 status creates per 5 hours** and **300 favourites per 5 hours**. Per-instance limits vary; smaller community instances often configure tighter caps. + +Symptoms of rate limit: +- Publish button stays disabled after click + a red toast "Failed to publish — please try again." +- Favourite button's `aria-pressed` does not flip to `true` after a click. + +When this happens, the response carries an `X-RateLimit-Reset` header (an ISO timestamp). The web UI does not expose it — you'll need to wait the cool-down (5 minutes is enough for most overages, hours for sustained abuse). + +For sustained engagement Bob caps at **~10 replies / hour and 50 / day** per account, well under the upstream limit. The cap is account-level, not IP-level, so multiple tabs share one bucket. + +## Gotchas + +- **Composer setter must use the React bypass.** Plain `textarea.value =` leaves the publish button disabled because the React-controlled state still says empty. +- **Reply landed but no `status_id` in the URL.** The composer does not navigate after publish — it collapses inline. To capture the new status's id you have to scrape the prepended `article.status` in the thread (Path 2 above) or follow up with a profile-page visit. +- **`aria-label` is localized.** Match the action buttons by class (`.star-icon`, `.reblog-icon`, `.icon-button[title="Reply"]`) when supporting non-English locales — the `aria-label` text is translated. +- **Favourite is a toggle.** If you don't read `aria-pressed` first, you may un-favourite something you already favourited from a previous run. +- **Locked accounts swallow follows silently.** Don't assume `button.innerText === "Following"` means the request succeeded — "Cancel follow request" is a different terminal state. +- **Federation latency on replies.** A reply you send from `mastodon.social` to a `fosstodon.org` author will land in fosstodon's notification feed within seconds, but their reply back may not appear in your `mastodon.social` mentions for up to a minute. Don't poll for outcomes faster than once per 60s. +- **Skip `account.bot === true` accounts.** Engaging back creates bot-on-bot loops the community frowns on. See `discovery.md` for the bot-badge selector. +- **Don't boost from automation.** Mechanically identical to favourite, socially very different — boosting puts the post into your own followers' timelines. Stick to reply + favourite. From d6f6759dffd3686c224745890c7e16bd3c829ddd Mon Sep 17 00:00:00 2001 From: "Dr. Weyers" Date: Thu, 18 Jun 2026 23:21:28 -0400 Subject: [PATCH 2/4] skill(instagram): engagement selector playbook (like/follow/comment/dm) Grounds Bob's IG engager: identity-first, like/follow click-toggle aria-state post-condition, comment/dm text-input, IG block markers. Mirrors the DOM contract in SmartSocial agents/bob/runtime/stagehand-executor.ts. Co-Authored-By: Claude Opus 4.8 --- .../domain-skills/instagram/engagement.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 agent-workspace/domain-skills/instagram/engagement.md diff --git a/agent-workspace/domain-skills/instagram/engagement.md b/agent-workspace/domain-skills/instagram/engagement.md new file mode 100644 index 00000000..317df10b --- /dev/null +++ b/agent-workspace/domain-skills/instagram/engagement.md @@ -0,0 +1,103 @@ +# Instagram — Engagement (like · follow · comment · DM) + +Selector playbook for **engaging** on instagram.com web (not posting). Attach to a +Chrome profile already logged into the target account. Two action *shapes*: + +- **Text-input shape** (comment, DM): focus an editable input → type → submit. +- **Click-toggle shape** (like, follow): click a button whose `aria-label`/text + flips state after the action (`Like`→`Unlike`, `Follow`→`Following`). + +> This file documents the same DOM contract the SmartSocial stagehand executor +> pins to (`agents/bob/runtime/stagehand-executor.ts`): identity-first, fixed +> selector allowlist, editable/clickable verification, block scan, fail-closed. + +## Identity (assert BEFORE any action) + +The logged-in handle is read from the **session chrome**, never from page text +(a post comment containing the handle could spoof an LLM read). Left-nav own-profile +entry renders the session avatar: + +```js +// returns the logged-in handle, or "" (fail closed) +(() => { + const scope = document.querySelector('div[role="navigation"], nav') || document; + const img = scope.querySelector('img[alt$="profile picture"]'); + if (!img) return ""; + const a = img.closest('a[href^="/"]'); + const m = a && (a.getAttribute("href") || "").match(/^\/([A-Za-z0-9._]+)\/?$/); + if (m) return m[1]; + const am = (img.getAttribute("alt") || "").match(/^(.+?)'s profile picture$/); + return am ? am[1] : ""; +})() +``` + +If this returns `""` → treat as logged-out, do nothing. + +## Selectors + +| Action | Element | Selector / signal | +|---|---|---| +| **Like** | heart button on a post | `svg[aria-label="Like"]` (liked → `svg[aria-label="Unlike"]`); click the enclosing `div[role="button"]`/`button` | +| **Comment** | comment input under a post | `textarea[aria-label="Add a comment…"]` (note the ellipsis char `…`, U+2026) | +| **Comment submit** | Post button | adjacent `[role="button"]` with text `Post`, or press `Enter` in the textarea | +| **Follow** | profile-header button | `button` whose trimmed text is `Follow` (followed → `Following` / `Requested`) | +| **DM input** | conversation composer | `div[role="textbox"][contenteditable="true"]` (aria-label `Message`) | +| **DM send** | — | press `Enter` in the composer | + +### Like — click-toggle + +```python +# Verify it's a real button, not a link/report-flow, then click the heart. +# Success = aria-label flips Like -> Unlike (read AFTER the click). +js(r'''(() => { + const svg = document.querySelector('svg[aria-label="Like"], svg[aria-label="Unlike"]'); + if (!svg) return null; + const btn = svg.closest('div[role="button"], button'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return JSON.stringify({x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2), + state: svg.getAttribute("aria-label")}); +})()''') +# click_at_xy(...) then re-read: state must now be "Unlike" +``` + +### Follow — click-toggle + +```python +# Click the header Follow button. Success = text becomes "Following" or "Requested" +# (private accounts return "Requested"; treat both as success). +js(r'''(() => { + const b = [...document.querySelectorAll('button')] + .find(b => ["Follow"].includes((b.textContent||"").trim())); + if (!b) return null; + const r = b.getBoundingClientRect(); + return JSON.stringify({x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2)}); +})()''') +``` + +### Comment — text-input + +```python +js('document.querySelector(\'textarea[aria-label="Add a comment…"]\').focus()') +type_text("your brand-voice comment") +press_key("Enter") # or click the adjacent "Post" role=button +# Success = the comment text appears in the page AND was not pre-existing. +``` + +## Block / safety markers (any present → stop, do not retry) + +Soft-block toast text (case-insensitive): `Action Blocked`, `Try Again Later`, +`We limit how often`, `restrict certain activity`. + +URL fragments that mean "not on target / logged out": `/accounts/login`, `/login`, +`/challenge/`, `/checkpoint/`. + +## Gotchas + +- The comment textarea aria-label uses a real **ellipsis** `…` (U+2026), not three dots. +- **Like is an SVG inside a button** — verify `div[role="button"]`/`button`, never click the bare `` or a wrapping `` (could be a report/profile link). +- A second un-acted **`Like` may exist** (e.g. nested reels/suggested) — pin to the first under the target post container; the stagehand observe instruction scopes "under this post". +- **Follow/unfollow churn is a known IG flag.** No auto-unfollow. Keep follow conservative. +- `Following` vs `Requested`: private accounts return `Requested` — both are success. +- Read like/follow state **after** the click for the post-condition; reading before proves nothing (idempotent re-runs would false-positive). +- All engagement is human-paced + rate-capped + (during ramp) approval-gated upstream; this file is selectors only. From 6084100739ef3316611b22ce836d073031e37d79 Mon Sep 17 00:00:00 2001 From: "Dr. Weyers" Date: Fri, 19 Jun 2026 09:42:56 -0400 Subject: [PATCH 3/4] skill(tiktok): engagement selector playbook (like/follow/comment) Mirror of the instagram engagement skill. Documents the DOM contract the SmartSocial stagehand executor pins to for TikTok: aria-pressed like toggle, Follow->Following button text, comment composer, identity read. DM is intentionally unsupported (fails closed). --- .../domain-skills/tiktok/engagement.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 agent-workspace/domain-skills/tiktok/engagement.md diff --git a/agent-workspace/domain-skills/tiktok/engagement.md b/agent-workspace/domain-skills/tiktok/engagement.md new file mode 100644 index 00000000..bec34a97 --- /dev/null +++ b/agent-workspace/domain-skills/tiktok/engagement.md @@ -0,0 +1,91 @@ +# TikTok — Engagement (like · follow · comment) + +Selector playbook for **engaging** on tiktok.com web (not posting — see +`upload.md` for TikTok Studio uploads). Attach to a Chrome profile already +logged into the target account. Two action *shapes*: + +- **Text-input shape** (comment): focus an editable input → type → submit. +- **Click-toggle shape** (like, follow): click a button whose state flips after + the action (like `aria-pressed` `false`→`true`; follow text `Follow`→`Following`). + +> This file documents the same DOM contract the SmartSocial stagehand executor +> pins to (`agents/bob/runtime/stagehand-executor.ts`): identity-first, fixed +> selector allowlist, editable/clickable verification, block scan, fail-closed. +> **DM is intentionally NOT supported** — TikTok gates DM to followed/eligible +> accounts with no stable unsolicited-DM web flow, so `tiktok:dm` fails closed. + +## Identity (assert BEFORE any action) + +The logged-in handle is read from the **session chrome** (the header profile +link), never from page text. Returns `""` (fail closed) if not logged in: + +```js +(() => { + const a = document.querySelector('a[data-e2e="nav-profile"], a[data-e2e="profile-icon"]'); + const href = a ? a.getAttribute("href") : null; + const m = href ? href.match(/\/@([A-Za-z0-9._]+)/) : null; + return m ? m[1] : ""; +})() +``` + +## Selectors + +| Action | Element | Selector / signal | +|---|---|---| +| **Like** | like button on a video | `button[data-e2e="like-icon"]` (in-feed: `browse-like-icon`); state via `aria-pressed` (`"false"` → `"true"`) | +| **Follow** | profile-header / card button | `button[data-e2e="follow-button"]` whose trimmed text is `Follow` (followed → `Following`) | +| **Comment** | comment composer under a video | `div[contenteditable="true"][data-e2e="comment-input"]` (or the `[data-e2e="comment-text"]` editable) | +| **Comment submit** | Post button | `[data-e2e="comment-post"]`, or press `Enter` in the composer | +| **DM** | — | **unsupported** (fail closed) | + +### Like — click-toggle (read `aria-pressed`) + +TikTok's like button is itself the toggle and carries `aria-pressed`. This is +more robust than scraping the icon's red-fill swap. **Idempotency: if +`aria-pressed` is already `"true"`, DO NOT click — a second click un-likes.** + +```python +# Verify it's a real, enabled button, read aria-pressed, click only if "false", +# then re-read: state must become "true". +js(r'''(() => { + const b = document.querySelector('button[data-e2e="like-icon"], button[data-e2e="browse-like-icon"]'); + if (!b) return null; + const r = b.getBoundingClientRect(); + return JSON.stringify({x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2), + pressed: b.getAttribute("aria-pressed")}); +})()''') +# if pressed === "true": already liked → done, no click. +# else click_at_xy(...) then re-read aria-pressed; success when it flips to "true". +``` + +### Follow — click-toggle (read button text) + +```python +# Click the profile Follow button. Success = text becomes "Following". +# Already "Following" → done, no click (no auto-unfollow — churn is a ban signal). +js(r'''(() => { + const b = document.querySelector('button[data-e2e="follow-button"]'); + if (!b) return null; + const r = b.getBoundingClientRect(); + return JSON.stringify({x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2), + text: (b.textContent||"").trim()}); +})()''') +``` + +### Comment — text-input + +```python +# Focus the composer, type, submit. Post-condition: the typed text appears in +# the page after submit (and was not pre-existing). +js('document.querySelector(\'div[contenteditable="true"][data-e2e="comment-input"]\').focus()') +type_text("your comment here") +press_key("Enter") # or click [data-e2e="comment-post"] +``` + +## Notes / drift + +- Selectors are **supervised-first**: confirm each `aria-pressed` / `data-e2e` + read against the live UI before unsupervised runs (TikTok rotates `data-e2e` + values less than IG rotates class names, but still verify). +- Block / rate-limit markers ("Something went wrong", captcha) and a redirect + to `/login` map to fail-closed outcomes upstream — never retry-spam. From 1d50d5669000b2f86cfcbf5953d86ddcc7e09ad7 Mon Sep 17 00:00:00 2001 From: "Dr. Weyers" Date: Fri, 19 Jun 2026 14:03:54 -0400 Subject: [PATCH 4/4] skill(facebook): engagement selector playbook (like + comment only) Documents the DOM contract the SmartSocial stagehand executor pins to for Facebook: c_user-cookie identity, aria-label Like->Remove Like toggle, comment composer. DM and follow/friend intentionally unsupported (fail closed). Acts as a dedicated brand profile; supervised-first; Meta-ban risk noted. --- .../domain-skills/facebook/engagement.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 agent-workspace/domain-skills/facebook/engagement.md diff --git a/agent-workspace/domain-skills/facebook/engagement.md b/agent-workspace/domain-skills/facebook/engagement.md new file mode 100644 index 00000000..aa6ae2fa --- /dev/null +++ b/agent-workspace/domain-skills/facebook/engagement.md @@ -0,0 +1,77 @@ +# Facebook — Engagement (like · comment) + +Selector playbook for **engaging** on facebook.com web — **like and comment only**. +This brand acts as a **dedicated personal profile** (not a Page). **DM (Messenger) +and follow/friend are intentionally NOT supported** — they are the highest Meta-ban +actions and fail closed. + +> This file documents the DOM contract the SmartSocial stagehand executor pins to +> (`agents/bob/runtime/stagehand-executor.ts`): identity-first, fixed selector +> allowlist, editable/clickable verification, block scan, fail-closed. Facebook +> obfuscates its DOM heavily and rotates class names constantly — these are +> **supervised-first**: confirm every read against the live UI before unsupervised +> runs, and expect to re-verify after Facebook redesigns. + +## Identity (assert BEFORE any action) + +Facebook does not expose a clean handle in the chrome. The most stable logged-in +signal is the **`c_user` cookie** — the numeric profile id, not httpOnly. So set +the brand's `account_handle` to that numeric id. Fallback: the top-nav "Your +profile" shortcut's vanity username. Returns `""` (fail closed) if not logged in: + +```js +(() => { + const m = document.cookie.match(/(?:^|;\s*)c_user=(\d+)/); + if (m) return m[1]; // numeric profile id (preferred) + const a = document.querySelector('a[aria-label="Your profile"], [aria-label="Your profile"] a[href]'); + const href = a ? a.getAttribute("href") : null; + const um = href ? href.match(/facebook\.com\/([A-Za-z0-9.]+)\/?(?:$|\?)/) : null; + return um ? um[1] : ""; // vanity username fallback +})() +``` + +## Selectors + +| Action | Element | Selector / signal | +|---|---|---| +| **Like** | post Like control | `div[aria-label="Like"][role="button"]` — aria-label flips to `Remove Like` once liked | +| **Comment** | comment composer | `div[contenteditable="true"][role="textbox"]` (aria-label `Write a comment…`) | +| **Comment submit** | — | press `Enter` in the composer | +| **DM / follow / friend** | — | **unsupported** (fail closed) | + +### Like — click-toggle (read `aria-label`) + +Facebook's Like button has no reliable `aria-pressed`; the signal is the button's +own `aria-label` flipping `Like` → `Remove Like`. **Idempotency: if it already reads +`Remove Like`, DO NOT click — a second click un-likes (or opens the reaction +picker).** Click triggers a plain Like (don't hover — hovering opens Love/Haha/etc). + +```python +js(r'''(() => { + const b = document.querySelector('div[aria-label="Like"][role="button"], div[aria-label="Remove Like"][role="button"]'); + if (!b) return null; + const r = b.getBoundingClientRect(); + return JSON.stringify({x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2), + label: b.getAttribute("aria-label")}); +})()''') +# if label === "Remove Like": already liked → done, no click. +# else click_at_xy(...) then re-read: success when label becomes "Remove Like". +``` + +### Comment — text-input + +```python +js('document.querySelector(\'div[contenteditable="true"][role="textbox"]\').focus()') +type_text("your comment here") +press_key("Enter") +``` + +## Notes / risk + +- **Meta ban risk is real.** Use a warmed, dedicated brand profile on a residential + IP, conservative caps, slow ramp — the same posture as Instagram. A flagged + profile can take the linked Instagram account down too. +- Block / checkpoint markers ("You're Temporarily Blocked", captcha) and any + redirect to `/login` or `/checkpoint/` map to fail-closed outcomes upstream. +- Facebook redesigns frequently — treat these selectors as a starting point to + re-confirm, not a guarantee.