v0.2: twitter module, feed/notifications/reply/open_link tools, dashboard

This commit is contained in:
2026-09-11 02:20:13 +08:00
parent a4f33f456e
commit eaedf4f409
6 changed files with 1368 additions and 24 deletions
+27 -16
View File
@@ -1,11 +1,8 @@
# socialmag # socialmag
MCP server that posts to Threads / Facebook / Instagram / YouTube / TikTok by MCP server that posts, reads, and replies across 6 platforms — Threads, Facebook,
driving abang's already-logged-in Brave over CDP. No API keys, no OAuth. Instagram, YouTube, TikTok, and Twitter/X — by driving abang's already-logged-in
Brave over CDP. No API keys, no OAuth.
Built to `PROPOSAL.md` (2026-09-10). **Nothing here has been run against a live
platform yet** — every step whose selector or timing needs a real page is
marked `# TODO: VALIDATE LIVE`.
## Run ## Run
@@ -26,24 +23,35 @@ Requires `websockets` (17.1 present on this box). Everything else is stdlib.
## Tools ## Tools
| Tool | Args | Returns | | Tool | Description |
|---|---|---| |------|-------------|
| `create_post` | `platforms[]`, `text`, `media`, `visibility` | `{platform: {status, detail}}` | | `create_post` | Post to one or more platforms (threads, fb, ig, yt, tiktok, twitter) |
| `check_login` | `platforms[]` (default all) | `{platform: true/false/"error: ..."}` | | `check_login` | Check which platforms are signed in |
| `get_status` | — | last run's results + queue history | | `get_status` | Last post results + open browser tabs |
| `get_feed` | Read Twitter timeline or search with X operators |
| `reply_tweet` | Reply to a tweet found by search |
| `get_notifications` | Check platform activity/notifications (Threads) |
| `open_link` | Navigate platform's browser tab to a URL (tab reuse) |
`text` is required for threads/fb/yt and used as the caption for ig/tiktok. `create_post` — `text` is required for threads/fb/yt and used as the caption for
`media` must be an **absolute path to an existing file**; it is required for ig/tiktok. `media` must be an **absolute path to an existing file**; required for
ig/tiktok, optional for threads/fb, and rejected for yt. ig/tiktok, optional for threads/fb, rejected for yt.
## Dashboard
`dashboard.html` is a local viewer — open it in a browser tab to see platform
status, post timeline, and Twitter intel. Reads from `dashboard.json` (written
by the server at runtime), auto-refreshes every 10 seconds.
## Layout ## Layout
``` ```
server.py MCP stdio JSON-RPC loop + the 3 tools server.py MCP stdio JSON-RPC loop + 7 tools
cdp.py CDP client: CDPClient (browser) + Tab (page verbs) cdp.py CDP client: CDPClient (browser) + Tab (page verbs)
queue.py PostQueue — strictly sequential, one tab at a time queue.py PostQueue — strictly sequential, one tab at a time
selectors.py every platform selector, one file selectors.py every platform selector + PLATFORMS dict, one file
platforms/ one module per platform: post() + check_login() platforms/ one module per platform: post() + check_login()
dashboard.html local status/timeline viewer (reads dashboard.json)
``` ```
Each platform module exposes: Each platform module exposes:
@@ -53,6 +61,9 @@ async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail
async def check_login(cdp_client) -> bool async def check_login(cdp_client) -> bool
``` ```
Twitter additionally exposes `get_feed`, `reply`, and `check_login` (reusing
the existing selector set). Threads exposes `get_notifications`.
`post` opens its own tab, drives the flow, closes the tab. A failure inside the `post` opens its own tab, drives the flow, closes the tab. A failure inside the
flow is caught by `platforms.guard`, which screenshots the page as it actually flow is caught by `platforms.guard`, which screenshots the page as it actually
looked into `/tmp/socialmag/` and puts the path in the result. looked into `/tmp/socialmag/` and puts the path in the result.
+748
View File
@@ -0,0 +1,748 @@
<title>socialmag console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;450;500;600&display=swap">
<style>
/* socialmag console — dark by commitment (the brief asks for dark), so every
surface and every color is painted explicitly and no theme block flips it. */
:root {
--bg: #0F1218;
--surface: #161A22;
--surface-2: #1C212B;
--line: #272D3A;
--line-soft: #1F2531;
--text: #E5E9F1;
--text-dim: #99A1B3;
--text-faint:#6B7488;
--accent: #D8A657; /* brass — belongs to no platform */
--accent-dim:#8A6B37;
--ok: #46BE84;
--bad: #F0555F;
--idle: #566072;
--threads: #FFFFFF; /* brief says #000000; invisible on this ground —
drawn as a black chip with a white ring instead */
--fb: #1877F2;
--ig: #E4405F;
--yt: #FF0000;
--tiktok: #00F2EA;
--twitter: #1DA1F2;
--r-sm: 4px;
--r-md: 8px;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0 16px 56px;
}
/* ── header ────────────────────────────────────────────────────────── */
header.top {
position: sticky;
top: 0;
z-index: 10;
background: linear-gradient(var(--bg) 72%, rgba(15, 18, 24, 0));
padding: 20px 0 14px;
}
.brandline {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.wordmark {
font-family: var(--mono);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.wordmark::before {
content: "$ ";
color: var(--accent-dim);
font-weight: 400;
}
.badge {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
border: 1px solid var(--accent-dim);
border-radius: 999px;
padding: 2px 9px;
}
.freshness {
margin-left: auto;
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
}
.pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(70, 190, 132, 0.55);
}
.pulse.live { animation: ping 2.6s ease-out infinite; }
.pulse.cold { background: var(--idle); animation: none; }
@keyframes ping {
0% { box-shadow: 0 0 0 0 rgba(70, 190, 132, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(70, 190, 132, 0); }
100% { box-shadow: 0 0 0 0 rgba(70, 190, 132, 0); }
}
@media (prefers-reduced-motion: reduce) {
.pulse.live { animation: none; }
}
/* ── platform strip ────────────────────────────────────────────────── */
.strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 14px 0 4px;
scrollbar-width: thin;
}
.pchip {
flex: 1 0 auto;
min-width: 116px;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
}
.glyph {
width: 22px; height: 22px;
flex: 0 0 22px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
color: #0B0E13;
letter-spacing: -0.03em;
}
.glyph.threads { background: #000; color: #fff; box-shadow: inset 0 0 0 1px var(--threads); }
.glyph.fb { background: var(--fb); color: #fff; }
.glyph.ig { background: var(--ig); color: #fff; }
.glyph.yt { background: var(--yt); color: #fff; }
.glyph.tiktok { background: var(--tiktok); color: #04201F; }
.glyph.twitter { background: var(--twitter); color: #06222F; }
.pchip .pname {
font-size: 12.5px;
font-weight: 500;
line-height: 1.2;
}
.pchip .pstate {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
}
.dot {
margin-left: auto;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--idle);
flex: 0 0 8px;
}
.dot.in { background: var(--ok); }
.dot.out { background: var(--bad); }
/* ── stats ─────────────────────────────────────────────────────────── */
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--line-soft);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
overflow: hidden;
margin-top: 18px;
}
.stat {
background: var(--surface);
padding: 14px 16px;
}
.stat .k {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.stat .v {
font-family: var(--mono);
font-size: 26px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1.25;
margin-top: 4px;
}
.stat .v.zero { color: var(--text-faint); }
/* ── layout ────────────────────────────────────────────────────────── */
main {
display: grid;
grid-template-columns: 1fr;
gap: 26px;
margin-top: 26px;
align-items: start;
}
@media (min-width: 900px) {
main { grid-template-columns: minmax(0, 1fr) 320px; gap: 30px; }
}
.shead {
display: flex;
align-items: baseline;
gap: 10px;
padding-bottom: 9px;
border-bottom: 1px solid var(--line);
margin-bottom: 14px;
}
.shead h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
text-wrap: balance;
}
.shead .tool {
font-family: var(--mono);
font-size: 10.5px;
color: var(--accent-dim);
}
.shead .count {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
/* ── timeline ──────────────────────────────────────────────────────── */
.timeline { display: flex; flex-direction: column; gap: 10px; }
.card {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 11px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-left: 2px solid var(--stripe, var(--line));
border-radius: 0 var(--r-md) var(--r-md) 0;
}
.card .body { min-width: 0; }
.card .meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
}
.card .who {
color: var(--text-dim);
font-weight: 500;
}
.card .text {
margin-top: 5px;
font-size: 13.5px;
color: var(--text);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.card .text.quiet { color: var(--text-dim); }
.kind {
text-transform: uppercase;
letter-spacing: 0.07em;
font-size: 9.5px;
padding: 1px 6px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
color: var(--text-dim);
}
.kind.sent { border-color: var(--accent-dim); color: var(--accent); }
.kind.error { border-color: #6C2B31; color: var(--bad); }
.engagement {
display: flex;
gap: 14px;
margin-top: 8px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.engagement span b {
color: var(--text-dim);
font-weight: 500;
}
.ts { margin-left: auto; white-space: nowrap; }
/* ── rail ──────────────────────────────────────────────────────────── */
.rail { display: flex; flex-direction: column; gap: 26px; }
.tweet {
padding: 11px 0;
border-bottom: 1px solid var(--line-soft);
}
.tweet:last-child { border-bottom: 0; }
.tweet .who {
font-size: 12.5px;
font-weight: 500;
color: var(--text);
overflow-wrap: anywhere;
}
.tweet .text {
font-size: 12.5px;
color: var(--text-dim);
margin-top: 3px;
overflow-wrap: anywhere;
}
.tweet .engagement { margin-top: 6px; }
/* ── empty / notice ────────────────────────────────────────────────── */
.empty {
padding: 22px 16px;
border: 1px dashed var(--line);
border-radius: var(--r-md);
color: var(--text-faint);
font-size: 12.5px;
}
.empty code {
font-family: var(--mono);
font-size: 11.5px;
color: var(--accent-dim);
}
.notice {
margin-top: 16px;
padding: 11px 14px;
border: 1px solid #5C3A1C;
background: #1E1810;
border-radius: var(--r-md);
color: #E3B679;
font-size: 12.5px;
}
.notice code { font-family: var(--mono); font-size: 11.5px; }
footer.foot {
margin-top: 34px;
padding-top: 14px;
border-top: 1px solid var(--line-soft);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
display: flex;
gap: 8px 18px;
flex-wrap: wrap;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
<div class="wrap">
<header class="top">
<div class="brandline">
<span class="wordmark">socialmag</span>
<span class="badge">6 platforms</span>
<span class="freshness">
<span class="pulse cold" id="pulse"></span>
<span id="fresh">connecting to store…</span>
</span>
</div>
<div class="strip" id="strip"></div>
</header>
<div class="stats">
<div class="stat">
<div class="k">Posts sent</div>
<div class="v zero" id="stat-posts">0</div>
</div>
<div class="stat">
<div class="k">Total views</div>
<div class="v zero" id="stat-views">—</div>
</div>
<div class="stat">
<div class="k">Platforms active</div>
<div class="v zero" id="stat-active">0<span style="color:var(--text-faint);font-size:15px">/6</span></div>
</div>
</div>
<div id="notice"></div>
<main>
<section>
<div class="shead">
<h2>Timeline</h2>
<span class="tool">create_post · get_notifications</span>
<span class="count" id="tl-count"></span>
</div>
<div class="timeline" id="timeline"></div>
</section>
<aside class="rail">
<section>
<div class="shead">
<h2>Twitter intel</h2>
<span class="tool">get_feed</span>
<span class="count" id="feed-count"></span>
</div>
<div id="feed"></div>
</section>
</aside>
</main>
<footer class="foot">
<span>display only — the posting loop writes, this page reads</span>
<span>collections: platforms · posts · notifications · feed</span>
</footer>
</div>
<script>
(() => {
"use strict";
// Platform registry — id, label, glyph mark, stripe color. Order is the
// order socialmag's server.py registers its modules.
const PLATFORMS = [
{ id: "threads", name: "Threads", mark: "T", color: "#FFFFFF" },
{ id: "fb", name: "Facebook", mark: "f", color: "#1877F2" },
{ id: "ig", name: "Instagram", mark: "IG", color: "#E4405F" },
{ id: "yt", name: "YouTube", mark: "YT", color: "#FF0000" },
{ id: "tiktok", name: "TikTok", mark: "TT", color: "#00F2EA" },
{ id: "twitter", name: "Twitter", mark: "X", color: "#1DA1F2" }
];
const BY_ID = Object.fromEntries(PLATFORMS.map(p => [p.id, p]));
// Aliases the writer loop might use instead of the canonical ids.
const ALIAS = {
facebook: "fb", instagram: "ig", youtube: "yt",
x: "twitter", tiktok: "tiktok", threads: "threads"
};
const norm = id => BY_ID[id] ? id : (ALIAS[String(id || "").toLowerCase()] || null);
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null };
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// ── time ────────────────────────────────────────────────────────────
// Rows carry ts / time / createdAt / checkedAt depending on which tool
// produced them; anything unparseable sorts last and renders as "—".
const parseTime = row => {
const raw = row.ts ?? row.time ?? row.createdAt ?? row.timestamp ?? null;
if (raw == null) return null;
if (typeof raw === "number") return raw > 1e12 ? raw : raw * 1000;
const t = Date.parse(raw);
return Number.isNaN(t) ? null : t;
};
const ago = ms => {
if (ms == null) return "—";
const s = Math.max(0, (Date.now() - ms) / 1000);
if (s < 45) return "just now";
if (s < 3600) return Math.round(s / 60) + "m ago";
if (s < 86400) return Math.round(s / 3600) + "h ago";
if (s < 604800) return Math.round(s / 86400) + "d ago";
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Engagement counts arrive as scraped strings ("1.2K") or numbers.
const countNum = v => {
if (typeof v === "number") return v;
const s = String(v || "").trim().replace(/,/g, "");
const m = s.match(/^([\d.]+)\s*([KMB])?$/i);
if (!m) return 0;
const mult = { k: 1e3, m: 1e6, b: 1e9 }[(m[2] || "").toLowerCase()] || 1;
return parseFloat(m[1]) * mult;
};
const showCount = v => (v === 0 || v === "0" || v == null) ? "0" : String(v);
const compact = n =>
n >= 1e6 ? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M" :
n >= 1e3 ? (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "K" : String(n);
// ── platform strip ──────────────────────────────────────────────────
function loginState(doc) {
if (!doc) return "unknown";
if (typeof doc.loggedIn === "boolean") return doc.loggedIn ? "in" : "out";
if (doc.error) return "unknown";
const s = String(doc.status ?? "").toLowerCase();
if (["true", "in", "ok", "logged_in", "logged in", "yes"].includes(s)) return "in";
if (["false", "out", "logged_out", "logged out", "no"].includes(s)) return "out";
return "unknown";
}
const STATE_LABEL = { in: "logged in", out: "signed out", unknown: "unknown" };
function renderStrip() {
$("strip").innerHTML = PLATFORMS.map(p => {
const st = loginState(state.platforms[p.id]);
return `<div class="pchip" title="${esc(p.name)} — ${STATE_LABEL[st]}">
<span class="glyph ${p.id}">${esc(p.mark)}</span>
<span>
<span class="pname">${esc(p.name)}</span><br>
<span class="pstate">${STATE_LABEL[st]}</span>
</span>
<span class="dot ${st === "unknown" ? "" : st}"></span>
</div>`;
}).join("");
}
// ── timeline ────────────────────────────────────────────────────────
function timelineRows() {
const rows = [];
for (const d of state.posts) {
const pid = norm(d.platform) || norm(d.id);
rows.push({
kind: (d.status && String(d.status).toLowerCase() !== "ok") ? "error" : "sent",
pid,
who: pid ? BY_ID[pid].name : (d.platform || "unknown platform"),
text: d.text || d.detail || "",
detail: d.detail && d.status && String(d.status).toLowerCase() !== "ok" ? d.detail : "",
t: parseTime(d),
stats: { views: d.views, likes: d.likes, replies: d.replies, reposts: d.reposts ?? d.retweets }
});
}
for (const d of state.notifications) {
const pid = norm(d.platform) || "threads";
rows.push({
kind: "activity",
pid,
who: BY_ID[pid] ? BY_ID[pid].name : String(d.platform),
text: d.text || "",
t: parseTime(d),
stats: {}
});
}
rows.sort((a, b) => (b.t ?? -Infinity) - (a.t ?? -Infinity));
return rows;
}
function engagementHTML(s) {
const parts = [];
if (s.views != null) parts.push(`<span><b>${esc(showCount(s.views))}</b> views</span>`);
if (s.likes != null) parts.push(`<span><b>${esc(showCount(s.likes))}</b> likes</span>`);
if (s.replies != null) parts.push(`<span><b>${esc(showCount(s.replies))}</b> replies</span>`);
if (s.reposts != null) parts.push(`<span><b>${esc(showCount(s.reposts))}</b> reposts</span>`);
return parts.length ? `<div class="engagement">${parts.join("")}</div>` : "";
}
const KIND_LABEL = { sent: "posted", error: "failed", activity: "activity" };
function renderTimeline() {
const rows = timelineRows();
$("tl-count").textContent = rows.length ? rows.length + " entries" : "";
if (!rows.length) {
$("timeline").innerHTML = `<div class="empty">
Nothing here yet. <code>create_post</code> writes to <code>posts</code>
and <code>get_notifications</code> writes to <code>notifications</code> —
both land in this timeline, newest first.
</div>`;
return;
}
$("timeline").innerHTML = rows.map(r => {
const p = r.pid ? BY_ID[r.pid] : null;
const stripe = p ? p.color : "var(--line)";
const glyph = p
? `<span class="glyph ${p.id}">${esc(p.mark)}</span>`
: `<span class="glyph" style="background:var(--surface-2);color:var(--text-faint)">?</span>`;
return `<article class="card" style="--stripe:${stripe}">
${glyph}
<div class="body">
<div class="meta">
<span class="who">${esc(r.who)}</span>
<span class="kind ${r.kind === "activity" ? "" : r.kind}">${KIND_LABEL[r.kind]}</span>
<span class="ts">${esc(ago(r.t))}</span>
</div>
<div class="text${r.kind === "activity" ? " quiet" : ""}">${esc(r.text) || "<span style='color:var(--text-faint)'>(no text)</span>"}</div>
${r.detail ? `<div class="text quiet" style="font-size:12px">${esc(r.detail)}</div>` : ""}
${engagementHTML(r.stats)}
</div>
</article>`;
}).join("");
}
// ── twitter intel ───────────────────────────────────────────────────
function renderFeed() {
const rows = state.feed.slice().sort((a, b) => (parseTime(b) ?? -Infinity) - (parseTime(a) ?? -Infinity));
$("feed-count").textContent = rows.length ? rows.length : "";
if (!rows.length) {
$("feed").innerHTML = `<div class="empty">
No tweets pulled yet. <code>get_feed</code> writes scraped tweets
(author, text, time, likes, retweets, replies) to <code>feed</code>.
</div>`;
return;
}
$("feed").innerHTML = rows.map(t => `<div class="tweet">
<div class="who">${esc(t.author || "unknown")}</div>
<div class="text">${esc(t.text || "")}</div>
<div class="meta engagement">
<span><b>${esc(showCount(t.likes))}</b> likes</span>
<span><b>${esc(showCount(t.retweets ?? t.reposts))}</b> RT</span>
<span><b>${esc(showCount(t.replies))}</b> replies</span>
<span class="ts">${esc(ago(parseTime(t)))}</span>
</div>
</div>`).join("");
}
// ── stats ───────────────────────────────────────────────────────────
function renderStats() {
const sent = state.posts.filter(d => !d.status || String(d.status).toLowerCase() === "ok").length;
const views = state.posts.reduce((n, d) => n + countNum(d.views), 0);
const active = PLATFORMS.filter(p => loginState(state.platforms[p.id]) === "in").length;
const set = (id, txt, zero) => {
const el = $(id);
el.innerHTML = txt;
el.classList.toggle("zero", zero);
};
set("stat-posts", String(sent), sent === 0);
set("stat-views", views > 0 ? compact(Math.round(views)) : "—", views === 0);
set("stat-active",
`${active}<span style="color:var(--text-faint);font-size:15px">/6</span>`, active === 0);
}
function renderAll() {
renderStrip();
renderStats();
renderTimeline();
renderFeed();
}
function setFreshness(text, live) {
$("fresh").textContent = text;
$("pulse").className = "pulse " + (live ? "live" : "cold");
}
function notice(html) {
$("notice").innerHTML = html ? `<div class="notice">${html}</div>` : "";
}
// ── first paint: the layout, with empty states, before any store call ─
renderAll();
// ── store ───────────────────────────────────────────────────────────
const docsOf = snap => snap.docs.map(d => Object.assign({ id: d.id }, d.data()));
(async () => {
const db = window.claude && await window.claude.use("db");
if (!db) {
setFreshness("store unavailable", false);
notice(`Not reading from the artifact store — this view can't run the
<code>db</code> capability. The layout below is the empty state.`);
return;
}
setFreshness("live", true);
const onErr = where => e => {
setFreshness("store error", false);
notice(`Lost the <code>${where}</code> subscription: <code>${esc(e.code)}</code>.
Reload the page to resubscribe.`);
};
const touch = () => { state.lastData = Date.now(); };
// One live subscription per collection. Snapshots deliver other writers'
// rows as they land, and fall back to a ~30s refresh on their own.
db.collection("platforms").onSnapshot(snap => {
state.platforms = {};
for (const d of snap.docs) {
const id = norm(d.id);
if (id) state.platforms[id] = d.data();
}
touch(); renderStrip(); renderStats();
}, onErr("platforms"));
db.collection("posts").limit(200).onSnapshot(snap => {
state.posts = docsOf(snap);
touch(); renderTimeline(); renderStats();
}, onErr("posts"));
db.collection("notifications").limit(200).onSnapshot(snap => {
state.notifications = docsOf(snap);
touch(); renderTimeline();
}, onErr("notifications"));
db.collection("feed").limit(60).onSnapshot(snap => {
state.feed = docsOf(snap);
touch(); renderFeed();
}, onErr("feed"));
})();
// Relative timestamps and the freshness line go stale on their own; retick
// them every 30s. The subscriptions above deliver the data itself.
setInterval(() => {
renderTimeline();
renderFeed();
if (state.lastData) {
const s = Math.round((Date.now() - state.lastData) / 1000);
setFreshness(s < 60 ? "live · updated " + s + "s ago" : "live · updated " + ago(state.lastData), true);
}
}, 30000);
})();
</script>
+70 -7
View File
@@ -1,15 +1,13 @@
"""Threads posting flow (2026-09 inline composer). """Threads posting and notifications flow (2026-09 inline composer).
1. navigate threads.com post — post via inline composer
2. click the inline "What's new?" composer check_login — verify signed in
3. type text get_notifications — scrape the Activity page
4. click Post button
5. text clearing / post appearing = success
""" """
import asyncio import asyncio
from ..cdp import human_delay from ..cdp import human_delay, CDPError
from . import guard, require_text, validate_media from . import guard, require_text, validate_media
from ..selectors import THREADS as S from ..selectors import THREADS as S
@@ -117,3 +115,68 @@ async def check_login(cdp_client):
" || [...document.querySelectorAll('span,div')]" " || [...document.querySelectorAll('span,div')]"
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))" ".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
) )
async def get_notifications(cdp_client, count=10):
"""Scrape the Threads Activity page."""
tab, reused = await cdp_client.get_or_open_tab(
"https://www.threads.com/activity", "threads.com"
)
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate("https://www.threads.com/activity")
await asyncio.sleep(4)
count = min(count, 20)
js = r"""
(() => {
const region = document.querySelector('[role="region"]');
if (!region) return [];
const ORIGIN = 'https://www.threads.com';
const abs = (h) => !h ? null
: (/^https?:/.test(h) ? h : ORIGIN + (h.startsWith('/') ? h : '/' + h));
// Row detection, from the live DOM (probed 2026-09-11): a
// notification row is an element that links somewhere, carries a
// relative-time marker ("3h", "6h", "2d"), and holds no smaller
// element that does the same. The length floor rejects the bare
// "6h" time chip, which is itself a link on some rows.
const isRow = (el) => {
const t = el.innerText ? el.innerText.trim() : '';
return t.length >= 12
&& /\b\d+[smhdw]\b/.test(t)
&& !!el.querySelector('a[href]');
};
const cands = [...region.querySelectorAll('*')].filter(isRow);
const rows = cands.filter(el => !cands.some(o => o !== el && el.contains(o)));
const results = [];
for (const row of rows) {
const text = row.innerText.trim().replace(/\n+/g, ' ');
if (text.length < 5) continue;
const hrefs = [...row.querySelectorAll('a[href]')]
.map(x => x.getAttribute('href'));
// Prefer the post permalink; otherwise whatever the row links to
// (settings notices link to /settings/... — a real destination).
const post = hrefs.find(h => /\/@[^\/]+\/post\//.test(h || ''));
results.push({ text: text, url: abs(post || hrefs[0]) });
}
// Fallback: no anchors at all (layout change, or a notification kind
// that doesn't link anywhere). Keep the old text-split rather than
// returning an empty list that would read as "no activity".
if (!results.length) {
const items = region.innerText.trim()
.split(/(?<=\b\d+[hdm]\b)\s*\n\s*/);
for (const p of items) {
const t = p.trim().replace(/\n+/g, ' ');
if (t.length < 5) continue;
results.push({ text: t, url: null });
}
}
return results.slice(0, COUNT);
})()
""".replace("COUNT", str(count))
return await tab.eval(js) or []
+262
View File
@@ -0,0 +1,262 @@
"""Twitter/X posting, reading, and replying via CDP.
post — tweet from the inline home composer
get_feed — scrape timeline (Following tab) or search results
reply — reply to a tweet found by search
check_login — verify the account is signed in
"""
import asyncio
import urllib.parse
from ..cdp import human_delay, CDPError
from . import guard, require_text, validate_media
from ..selectors import TWITTER as S
PLATFORM = "twitter"
async def post(cdp_client, text, media_path=None, options=None):
options = options or {}
require_text(PLATFORM, text)
validate_media(media_path)
tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
else:
await asyncio.sleep(2)
return await guard(PLATFORM, tab, _flow(tab, text, media_path))
async def _flow(tab, text, media_path):
await _wait_for_composer(tab)
await human_delay()
# Click the contenteditable to focus it
textbox = S["composer_textbox"]
if not await tab.exists(textbox):
# Fallback: try the broader testid
textbox = '[data-testid="tweetTextarea_0"]'
await tab.wait_for(textbox, timeout=10)
await tab.type_text(textbox, text)
await human_delay(0.5, 1.0)
if media_path:
file_input = 'input[type="file"]'
if await tab.exists(file_input):
await tab.set_file_input(file_input, media_path)
await human_delay(2.0, 4.0)
await human_delay(0.5, 1.0)
# Click the Post button
await tab.click_js(S["post_button_js"])
await _wait_for_post_complete(tab)
async def _wait_for_composer(tab, timeout=20.0):
"""Wait for the tweet compose box to appear."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
has_box = await tab.eval(
"!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]'))"
)
if has_box:
return
await asyncio.sleep(0.5)
from ..cdp import CDPError
raise CDPError("twitter: compose box not found after 20s")
async def _wait_for_post_complete(tab, timeout=30.0):
"""Wait for the tweet to go through — composer empties or toast appears."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
# Check for the success toast
toast = await tab.eval(
"!!([...document.querySelectorAll('[role=\"alert\"], [data-testid=\"toast\"]')]"
".find(e => e.innerText && e.innerText.includes('post')))"
)
if toast:
return
# Inline composer: text cleared back to empty
empty = await tab.eval(
"(() => {"
" const box = document.querySelector('[data-testid=\"tweetTextarea_0\"] [contenteditable=\"true\"]');"
" if (box && box.innerText.trim() === '') return true;"
" const box2 = document.querySelector('[data-testid=\"tweetTextarea_0\"]');"
" if (box2 && box2.innerText.trim() === '') return true;"
" return false;"
"})()"
)
if empty:
return
await asyncio.sleep(0.5)
from ..cdp import CDPError
raise CDPError("twitter: post didn't complete within 30s")
async def check_login(cdp_client):
tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(S["url"])
await asyncio.sleep(4)
# If we see login markers, not logged in
if await tab.exists(S["login_marker"]):
return False
# If we see the home timeline composer, we're in
return await tab.eval(
"!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]')"
" || document.querySelector('[data-testid=\"SideNav_NewTweet_Button\"]'))"
)
# ── Scrape tweets from a page ────────────────────────────────────────────────
SCRAPE_JS = r"""
(() => {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
const results = [];
for (const a of [...articles].slice(0, COUNT)) {
const nameEl = a.querySelector('[data-testid="User-Name"]');
const textEl = a.querySelector('[data-testid="tweetText"]');
const timeEl = a.querySelector('time');
const likes = a.querySelector('[data-testid="like"]');
const retweets = a.querySelector('[data-testid="retweet"]');
const reply = a.querySelector('[data-testid="reply"]');
const getCount = (el) => {
if (!el) return 0;
const span = el.querySelector('[data-testid$="Count"]')
|| el.querySelector('span[data-testid]')
|| el.querySelector('span');
return span ? span.innerText.trim() : '0';
};
// Permalink: the timestamp sits inside the canonical
// /<user>/status/<id> anchor. Verified live 2026-09-11 — the other
// status hrefs in an article are /analytics, /photo/N, /media_tags.
const permalink = (() => {
const link = timeEl ? timeEl.closest('a[href]') : null;
let href = link ? link.getAttribute('href') : null;
if (!href) {
href = [...a.querySelectorAll('a[href]')]
.map(x => x.getAttribute('href'))
.find(h => /^\/[^\/]+\/status\/\d+$/.test(h)) || null;
}
return href ? new URL(href, location.origin).href : null;
})();
results.push({
author: nameEl ? nameEl.innerText.replace(/\n/g, ' ') : '?',
text: textEl ? textEl.innerText.slice(0, 500) : '',
time: timeEl ? timeEl.getAttribute('datetime') : '?',
url: permalink,
likes: getCount(likes),
retweets: getCount(retweets),
replies: getCount(reply)
});
}
return results;
})()
"""
async def get_feed(cdp_client, mode="following", query=None, count=10):
"""Scrape tweets. mode='following' for timeline, 'search' for query."""
if mode == "search" and not query:
raise CDPError("twitter get_feed: 'query' required for search mode")
if mode == "search":
url = S["search_url"].format(query=urllib.parse.quote(query))
url_match = "x.com/search"
else:
url = S["url"]
url_match = "x.com"
tab, reused = await cdp_client.get_or_open_tab(url, url_match)
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(url)
await asyncio.sleep(4)
# For following mode, click the Following tab
if mode == "following":
await tab.eval(
"(() => { const t = %s; if (t) t.click(); })()" % S["following_tab_js"]
)
await asyncio.sleep(3)
# Scroll once to load more tweets
await tab.eval("window.scrollBy(0, 800)")
await asyncio.sleep(2)
await tab.eval("window.scrollTo(0, 0)")
await asyncio.sleep(1)
count = min(count, 20)
js = SCRAPE_JS.replace("COUNT", str(count))
tweets = await tab.eval(js)
return tweets or []
async def reply(cdp_client, search_query, reply_text):
"""Find a tweet by search and reply to it."""
require_text(PLATFORM, reply_text)
if not search_query:
raise CDPError("twitter reply: 'search_query' required to find the tweet")
url = S["search_url"].format(query=urllib.parse.quote(search_query))
tab, reused = await cdp_client.get_or_open_tab(url, "x.com/search")
if reused:
await tab.eval("window.onbeforeunload = null")
await tab.navigate(url)
await asyncio.sleep(5)
# Click reply on the first matching tweet
clicked = await tab.eval(r"""
(() => {
const tweet = document.querySelector('article[data-testid="tweet"]');
if (!tweet) return false;
const btn = tweet.querySelector('[data-testid="reply"]');
if (!btn) return false;
btn.click();
return true;
})()
""")
if not clicked:
raise CDPError("twitter reply: no tweet found for that search query")
await asyncio.sleep(3)
# Type into the reply box
for sel in ('[data-testid="tweetTextarea_0"] [contenteditable="true"]',
'[data-testid="tweetTextarea_0"]'):
if await tab.exists(sel):
await tab.type_text(sel, reply_text)
break
else:
raise CDPError("twitter reply: reply box not found")
await human_delay(0.5, 1.0)
# Post the reply
posted = await tab.eval(r"""
(() => {
const btn = document.querySelector('[data-testid="tweetButtonInline"]')
|| document.querySelector('[data-testid="tweetButton"]');
if (!btn) return false;
btn.click();
return true;
})()
""")
if not posted:
raise CDPError("twitter reply: could not find the reply post button")
await asyncio.sleep(3)
return {"status": "ok", "detail": "twitter: replied"}
+33
View File
@@ -156,10 +156,43 @@ TIKTOK = {
"login_marker": 'a[href*="/login"]', "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 = { PLATFORMS = {
"threads": THREADS, "threads": THREADS,
"fb": FACEBOOK, "fb": FACEBOOK,
"ig": INSTAGRAM, "ig": INSTAGRAM,
"yt": YOUTUBE, "yt": YOUTUBE,
"tiktok": TIKTOK, "tiktok": TIKTOK,
"twitter": TWITTER,
} }
+228 -1
View File
@@ -20,10 +20,12 @@ import json
import os import os
import sys import sys
import time import time
import urllib.parse
from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT
from .platforms import facebook, instagram, threads, tiktok, youtube from .platforms import facebook, instagram, threads, tiktok, twitter, youtube
from .queue import PostQueue from .queue import PostQueue
from .selectors import PLATFORMS as PLATFORM_SELECTORS
PROTOCOL_VERSION = "2025-06-18" PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "socialmag" SERVER_NAME = "socialmag"
@@ -37,8 +39,27 @@ MODULES = {
"ig": instagram, "ig": instagram,
"yt": youtube, "yt": youtube,
"tiktok": tiktok, "tiktok": tiktok,
"twitter": twitter,
} }
def _domain_of(platform):
"""The tab-match string for a platform, derived from its selectors URL —
'https://www.threads.com/' -> 'threads.com'. One source of truth: change a
platform's URL in selectors.py and tab matching follows it."""
host = urllib.parse.urlsplit(PLATFORM_SELECTORS[platform]["url"]).hostname
if not host:
raise RuntimeError(
f"selectors.PLATFORMS[{platform!r}]['url'] has no hostname: "
f"{PLATFORM_SELECTORS[platform]['url']!r}"
)
return host[4:] if host.startswith("www.") else host
# threads.com · facebook.com · instagram.com · youtube.com · tiktok.com · x.com
PLATFORM_DOMAINS = {p: _domain_of(p) for p in MODULES}
# The endpoint is a spec constant, not a fallback for missing config. Override # The endpoint is a spec constant, not a fallback for missing config. Override
# with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere. # with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere.
CDP_ENDPOINT = os.environ.get("SOCIALMAG_CDP_ENDPOINT", DEFAULT_HTTP_ENDPOINT) CDP_ENDPOINT = os.environ.get("SOCIALMAG_CDP_ENDPOINT", DEFAULT_HTTP_ENDPOINT)
@@ -113,6 +134,111 @@ TOOLS = [
"description": "Results of the last create_post run, plus queue history.", "description": "Results of the last create_post run, plus queue history.",
"inputSchema": {"type": "object", "properties": {}}, "inputSchema": {"type": "object", "properties": {}},
}, },
{
"name": "get_feed",
"description": (
"Read tweets from Twitter/X. Two modes: 'following' reads the "
"home timeline (Following tab), 'search' scrapes live results "
"for a query. Supports X search operators (from:, since:, "
"min_faves:, lang:). Returns up to 20 tweets with author, "
"text, time, and engagement counts."
),
"inputSchema": {
"type": "object",
"properties": {
"mode": {
"enum": ["following", "search"],
"description": "following = home timeline, search = keyword search.",
"default": "following",
},
"query": {
"type": "string",
"description": (
"Search query (required for search mode). Supports "
"X operators: from:user, since:2026-01-01, "
"min_faves:100, lang:en, etc."
),
},
"count": {
"type": "integer",
"description": "Max tweets to return (1-20, default 10).",
"default": 10,
},
},
},
},
{
"name": "reply_tweet",
"description": (
"Reply to a tweet found by search query. Finds the first "
"matching tweet and posts a reply to it."
),
"inputSchema": {
"type": "object",
"properties": {
"search_query": {
"type": "string",
"description": (
"Search query to find the tweet to reply to. Use "
"from:user + keywords for precision."
),
},
"text": {
"type": "string",
"description": "Reply text.",
},
},
"required": ["search_query", "text"],
},
},
{
"name": "get_notifications",
"description": (
"Check notifications/activity from social platforms. Currently "
"supports Threads (Activity page). Returns a list of recent "
"notifications with text, time, and post links where available."
),
"inputSchema": {
"type": "object",
"properties": {
"platform": {
"enum": ["threads"],
"description": "Platform to check. Currently only threads.",
},
"count": {
"type": "integer",
"description": "Max notifications to return (1-20, default 10).",
"default": 10,
},
},
"required": ["platform"],
},
},
{
"name": "open_link",
"description": (
"Open a URL in the platform's existing browser tab (reuses warm "
"tabs). Falls back to opening a new tab if none exists. The tab is "
"left open — that's what makes it reusable next time."
),
"inputSchema": {
"type": "object",
"properties": {
"platform": {
"enum": sorted(MODULES),
"description": (
"Which platform's tab to reuse. Matched by domain: "
+ ", ".join(f"{k} → {v}" for k, v in sorted(PLATFORM_DOMAINS.items()))
),
},
"url": {
"type": "string",
"description": "Full http(s) URL to navigate to.",
},
},
"required": ["platform", "url"],
},
},
] ]
@@ -237,10 +363,111 @@ async def tool_get_status(args, queue):
return state return state
async def tool_get_feed(args, queue):
mode = args.get("mode", "following")
query = args.get("query")
count = min(args.get("count", 10), 20)
cdp = CDPClient(CDP_ENDPOINT)
await cdp.connect()
try:
result = await twitter.get_feed(cdp, mode=mode, query=query, count=count)
finally:
await cdp.close()
return result
async def tool_reply_tweet(args, queue):
search_query = args.get("search_query")
text = args.get("text")
if not search_query or not text:
raise ValueError("reply_tweet: both 'search_query' and 'text' are required")
cdp = CDPClient(CDP_ENDPOINT)
await cdp.connect()
try:
result = await twitter.reply(cdp, search_query, text)
finally:
await cdp.close()
return result
async def tool_get_notifications(args, queue):
platform = args.get("platform")
count = min(args.get("count", 10), 20)
notif_modules = {
"threads": threads,
}
if platform not in notif_modules:
raise ValueError(
f"get_notifications: unsupported platform {platform!r}; "
f"supported: {sorted(notif_modules)}"
)
cdp = CDPClient(CDP_ENDPOINT)
await cdp.connect()
try:
result = await notif_modules[platform].get_notifications(cdp, count=count)
finally:
await cdp.close()
return result
async def tool_open_link(args, queue):
platform = args.get("platform")
url = args.get("url")
# Validate before touching the browser: a bad call should cost nothing.
if platform not in PLATFORM_DOMAINS:
raise ValueError(
f"open_link: unknown platform {platform!r}; "
f"known: {sorted(PLATFORM_DOMAINS)}"
)
if not url or not isinstance(url, str):
raise ValueError("open_link: 'url' is required and must be a string")
scheme = urllib.parse.urlsplit(url).scheme.lower()
if scheme not in ("http", "https"):
raise ValueError(
f"open_link: url must be http(s), got {scheme or 'no'} scheme: {url!r}"
)
domain = PLATFORM_DOMAINS[platform]
async def go():
cdp = CDPClient(CDP_ENDPOINT)
await cdp.connect()
try:
tab, reused = await cdp.get_or_open_tab(url, domain)
if reused:
# A warm tab may hold an unload prompt (a half-written composer);
# clear it, then send the tab to the requested URL.
await tab.eval("window.onbeforeunload = null")
await tab.navigate(url)
# Deliberately NOT closed — leaving it open is what makes the next
# open_link on this platform a reuse.
return {
"status": "ok",
"tab_reused": reused,
"platform": platform,
"matched_domain": domain,
"url": url,
}
finally:
await cdp.close()
# Through the queue like every other browser job: one tab at a time.
return await queue.submit(f"open:{platform}", go)
HANDLERS = { HANDLERS = {
"create_post": tool_create_post, "create_post": tool_create_post,
"check_login": tool_check_login, "check_login": tool_check_login,
"get_status": tool_get_status, "get_status": tool_get_status,
"get_feed": tool_get_feed,
"reply_tweet": tool_reply_tweet,
"get_notifications": tool_get_notifications,
"open_link": tool_open_link,
} }