v0.2: twitter module, feed/notifications/reply/open_link tools, dashboard
This commit is contained in:
@@ -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
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
// ── 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
@@ -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 []
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user