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
MCP server that posts to Threads / Facebook / Instagram / YouTube / TikTok 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`.
MCP server that posts, reads, and replies across 6 platforms — Threads, Facebook,
Instagram, YouTube, TikTok, and Twitter/X — by driving abang's already-logged-in
Brave over CDP. No API keys, no OAuth.
## Run
@@ -26,24 +23,35 @@ Requires `websockets` (17.1 present on this box). Everything else is stdlib.
## Tools
| Tool | Args | Returns |
|---|---|---|
| `create_post` | `platforms[]`, `text`, `media`, `visibility` | `{platform: {status, detail}}` |
| `check_login` | `platforms[]` (default all) | `{platform: true/false/"error: ..."}` |
| `get_status` | — | last run's results + queue history |
| Tool | Description |
|------|-------------|
| `create_post` | Post to one or more platforms (threads, fb, ig, yt, tiktok, twitter) |
| `check_login` | Check which platforms are signed in |
| `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.
`media` must be an **absolute path to an existing file**; it is required for
ig/tiktok, optional for threads/fb, and rejected for yt.
`create_post` — `text` is required for threads/fb/yt and used as the caption for
ig/tiktok. `media` must be an **absolute path to an existing file**; required for
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
```
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)
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()
dashboard.html local status/timeline viewer (reads dashboard.json)
```
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
```
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
flow is caught by `platforms.guard`, which screenshots the page as it actually
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
2. click the inline "What's new?" composer
3. type text
4. click Post button
5. text clearing / post appearing = success
post — post via inline composer
check_login — verify signed in
get_notifications — scrape the Activity page
"""
import asyncio
from ..cdp import human_delay
from ..cdp import human_delay, CDPError
from . import guard, require_text, validate_media
from ..selectors import THREADS as S
@@ -117,3 +115,68 @@ async def check_login(cdp_client):
" || [...document.querySelectorAll('span,div')]"
".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"]',
}
# --------------------------------------------------------------------------
# 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,
}
+228 -1
View File
@@ -20,10 +20,12 @@ import json
import os
import sys
import time
import urllib.parse
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 .selectors import PLATFORMS as PLATFORM_SELECTORS
PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "socialmag"
@@ -37,8 +39,27 @@ MODULES = {
"ig": instagram,
"yt": youtube,
"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
# with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere.
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.",
"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
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 = {
"create_post": tool_create_post,
"check_login": tool_check_login,
"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,
}