Merge pull request #292 from Molecule-AI/feat/reno-stars-social-publish-helpers

feat(reno-stars): social-publish skill with 7 battle-tested helpers
This commit is contained in:
Hongming Wang 2026-04-15 17:53:58 -07:00 committed by GitHub
commit bd51ea6190
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1078 additions and 2 deletions

View File

@ -17,5 +17,15 @@ The only exception is Business Intelligence (the root agent) which delegates to
| `send_message_to_user` | Push progress updates to the user |
| `list_peers` | Only to understand team structure, NOT to delegate |
## Social publishing — use the helpers, never freestyle puppeteer
Before posting to any social platform (Facebook, Instagram, X, LinkedIn, TikTok, YouTube, Google Business Profile), **read `/configs/skills/social-publish/SKILL.md`** (on the host this lives at `org-templates/reno-stars/marketing-leader/skills/social-publish/SKILL.md`). Invoke the matching helper:
```
node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs <video> "<caption>"
```
Never re-derive puppeteer selectors inline — the helpers bake in hours of debugging (Lexical editor mirrors, modal-Next disambiguation, GBP iframe scoping, post-publish upsells). If a helper breaks, patch the helper and commit.
## Language
Always respond in the same language the user uses.

View File

@ -2,6 +2,8 @@
Search platforms for relevant posts about renovation and Vancouver. Draft replies for approval, then publish approved replies.
> **HARD RULE — NEVER FREESTYLE PUPPETEER.** When a reply escalates into publishing a new post (reel, story, video comment with attached media), delegate to the `social-publish` skill: `node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs <media> "<caption>"`. Exit codes and lessons baked in: `org-templates/reno-stars/marketing-leader/skills/social-publish/SKILL.md`. For text-only comments (the common case), the existing per-platform comment flows in this skill still apply.
## HONESTY RULE (CRITICAL)
All engagement replies must be truthful. Only reference real data from the website, database, or owner-provided information.
- Do NOT guess prices, timelines, or project details. If you don't have real data, say "it varies" or "hard to say without seeing the space".

View File

@ -7,6 +7,8 @@ When responding to DMs or comments on behalf of Reno Stars, only state facts you
> **READ FIRST**: `~/.claude/skills/social-media-post/SKILL.md` and memory `feedback_social_media_platforms.md` for all platform quirks. **Reddit is PAUSED until 2026-04-21** (see Reddit section below) — skip it entirely.
>
> **For any reply publish that triggers a full new post** (e.g. responding to a DM by publishing a fresh video): use the `social-publish` helpers — `node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs`. See that skill's SKILL.md for exit codes. Never freestyle puppeteer for social publishing.
>
> **Telegram approval flow note**: when you receive an ambiguous short message ("reply all", "approve", "yes"), ALWAYS check `~/.openclaw/workspace/social/pending-replies.json` and the most recent log in `~/reno-star-business-intelligent/data/cron-logs/` BEFORE asking the user "what?". Telegram Bot API has no message history; the cron's outbound message lives only on disk. (Confirmed user frustration with this on 2026-04-07.)
## Config

View File

@ -435,7 +435,25 @@ Or: APPROVE [post_id] facebook,instagram to publish specific platforms only
## STEP 3: Platform Posting (when publishing approved posts)
> **READ FIRST**: `~/.claude/skills/social-media-post/SKILL.md` — comprehensive playbook for every platform with the quirks learned the hard way on 2026-04-07. Also see memory `feedback_social_media_platforms.md` for the failure-mode index. The instructions below are the cron-specific shortcuts; the skill is the source of truth.
> **HARD RULE — NEVER FREESTYLE PUPPETEER.**
> For every platform below, invoke the matching helper from the `social-publish` skill:
>
> ```
> node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs <video-or-image> "<caption>"
> ```
>
> The helpers live alongside this file (or mirrored at `~/reno-star-business-intelligent/scripts/social-helpers/`). Full playbook + exit codes: `org-templates/reno-stars/marketing-leader/skills/social-publish/SKILL.md`. If a helper fails, **fix the helper and re-run** — do not re-derive puppeteer code inline; you will lose several hours rediscovering the Lexical / Next-button / iframe lessons already baked in.
>
> Platform → helper mapping:
> - Facebook Reel → `fb-publish-reel.cjs`
> - Instagram Reel → `ig-publish-reel.cjs`
> - X / Twitter → `x-publish.cjs`
> - LinkedIn (company page) → `li-publish.cjs`
> - TikTok → `tt-publish.cjs`
> - YouTube Shorts → `yt-publish.cjs`
> - Google Business Profile → `gbp-publish.cjs`
>
> The legacy recipes below remain as **reference only** for debugging a broken helper — they are NOT the invocation path for normal runs. Also see `~/.claude/skills/social-media-post/SKILL.md` for cross-platform background on the 2026-04-07 / 2026-04-15 debugging incidents, and memory `feedback_social_media_platforms.md` for the failure-mode index.
**Universal pre-flight (do BEFORE touching any platform):**
- Files for upload MUST be under `/Users/renostars/`. Copy first if elsewhere.

View File

@ -0,0 +1,108 @@
# Skill: social-publish
Battle-tested helper scripts for publishing video posts to Reno Stars' social accounts. Each `.cjs` script under `scripts/` encapsulates hours of debugging against one platform's real DOM — Lexical editors, Next-button disambiguation, post-publish upsells, iframe scoping on Google Business Profile, and so on.
**Platforms covered:** Facebook (Reel), Instagram (Reel), X/Twitter, LinkedIn (company page), TikTok, YouTube (Shorts), Google Business Profile.
---
## HARD RULE — NEVER FREESTYLE PUPPETEER FOR SOCIAL POSTS
If you find yourself typing `puppeteer.connect`, `document.querySelector('div[role="dialog"]')`, Lexical editor queries, or "Next button" heuristics inside a social-posting task — **stop**. You are re-deriving, wrong, everything these helpers already solved.
Always invoke the helper:
```bash
node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs <video-path> "<caption>"
```
(The helpers are also mirrored in `~/reno-star-business-intelligent/scripts/social-helpers/` on the host — use whichever path resolves in your workspace.)
If a helper fails (non-zero exit), read the exit code below first, screenshot at `/tmp/<platform>-fail.png`, and either:
1. fix the helper in THIS file and commit (so next run benefits),
2. or escalate to the operator via Telegram — **do not** silently fall back to hand-rolled puppeteer.
---
## Pre-flight (all platforms)
1. Chrome must be running with CDP exposed on `http://127.0.0.1:9222`:
```bash
open -na "Google Chrome" --args --user-data-dir="/Users/renostars/.openclaw/chrome-profile" --remote-debugging-port=9222
```
2. Video path must be under `/Users/renostars/`, ASCII-only filename, no spaces / CJK / emoji.
3. The relevant platform must already be logged in inside that Chrome profile. (The helpers **connect** to the existing Chrome — they never launch a fresh Chromium, which is why "session expired" false positives disappear.)
4. Chrome window width ≥ 1200px for Facebook Reel (the composer hides the Post button at narrow widths).
---
## Helpers and exit codes
All helpers take `<video-path>` and `<caption>` as positional args. Exit `0` = success; `1` = fatal uncaught error.
### `fb-publish-reel.cjs` — Facebook Page Reel
- `0` composer closed, post committed (still feed-verify)
- `2` viewport <1200px wide
- `3` no on-screen Lexical caption box found
- `4` caption typing produced <50 chars (focus missed)
- `5` Post button not visible / composer never closed
### `ig-publish-reel.cjs` — Instagram Reel
- `0` Share clicked, sharing spinner done
- `3` no file input / no caption box
- `4` caption typing failed
- `5` no Share button
### `x-publish.cjs` — X / Twitter
- `0` posted (URL → x.com/home)
- `3` no file input / no composer
- `4` caption typing missed
- `5` post click intercepted
### `li-publish.cjs` — LinkedIn company page
- `0` posted
- `3` file chooser timeout
- `4` no `.ql-editor`
- `5` caption typing missed (<100 chars)
- `6` no Post button
### `tt-publish.cjs` — TikTok Studio
- `0` posted (URL → tiktokstudio/content)
- `3` no video input
- `4` no caption editor
- `5` caption typing missed (<50 chars)
- `6` Post button never enabled
### `yt-publish.cjs` — YouTube Shorts (studio.youtube.com)
- `0` Publish clicked
- `3` no file input
- `5` Publish button disabled
### `gbp-publish.cjs` — Google Business Profile "Add update"
- `0` Publish clicked
- `3` no GBP iframe found (not logged in as operator account?)
---
## Lessons baked in (do not re-learn)
- **Connect, never launch.** `puppeteer.connect({browserURL, defaultViewport: null})` reuses real Chrome sessions. `puppeteer.launch()` spawns fresh Chromium with no cookies — that is the "all sessions expired" false positive.
- **Facebook Lexical has 46 mirror DOM instances**, most off-screen. Pick the one with visible viewport rect, width > 200, and not the comment box.
- **Lexical rejects `execCommand` / clipboard paste.** Use `page.mouse.click(target)` to focus then `page.keyboard.type()` for real keystrokes.
- **Facebook Reel flow is Next → Next → Post**, not Next → Post. First Next advances Upload → Edit; second Next advances Edit → Reel settings; Post button only exists on Settings.
- **After Facebook Post**, Meta shows upsell modals ("Add WhatsApp button", "Boost", etc). Dismiss each or the next navigation triggers a `beforeunload` "Leave site?" dialog that blocks the script. Register `page.on('dialog', d => d.dismiss())` BEFORE clicking Post.
- **Verify success by composer disappearance**, not upsell modal appearance.
- **TikTok description editor is Lexical**`execCommand insertText` throws `NotFoundError: Failed to execute 'removeChild'` and loses the upload. Click-to-focus + real `keyboard.type()` only.
- **X Post button is covered by an invisible overlay** for normal clicks. Use `document.querySelector('[data-testid="tweetButton"]').click()`.
- **GBP opens in an iframe** at `/local/business/<id>/promote/updates`. Scope every DOM query to that frame; the outer google.com page has a decoy "Add update" in the knowledge panel.
- **YouTube Studio title/description are faceplate-textarea web components**`execCommand insertText` works fine here (unlike TikTok / FB). Don't over-generalize the Lexical rule.
- **LinkedIn company composer** needs header verification reading "Reno Stars Construction Inc." — if it shows the personal profile, switch accounts first or the post goes to the wrong place.
- **Instagram reels show a "Video posts are now shared as reels" info dialog** — click OK; it is not an error.
---
## When a helper actually breaks
1. Re-run once — transient CDP flake is common.
2. If it fails twice: read `/tmp/<platform>-fail.png`, identify the new DOM pattern, patch the helper in this directory, commit, and re-run.
3. Never replace the helper with a fresh hand-rolled puppeteer block. That path ends in re-discovering every lesson above.

View File

@ -0,0 +1,191 @@
#!/usr/bin/env node
/**
* FB Reel publisher battle-tested 2026-04-15 after ~3h debugging.
*
* USAGE:
* node fb-publish-reel.cjs <video-path> "<caption>"
*
* RETURNS:
* exit 0 composer closed (post committed). Caller should still feed-verify.
* exit 1 fatal puppeteer error
* exit 2 viewport too small (Chrome window <1200px wide)
* exit 3 no on-screen Lexical caption box found
* exit 4 caption typing produced <50 chars (focus missed)
* exit 5 Post button not visible
*
* LESSONS BAKED IN:
* 1. CONNECT, never LAUNCH `puppeteer.connect({browserURL, defaultViewport: null})`
* uses real Chrome window dims. `puppeteer.launch()` spawns fresh Chromium with
* no cookies that's the "all sessions expired" false positive.
* 2. FB has 4-6 Lexical mirror instances, most off-screen at negative x or y > 1000.
* Pick by: visible viewport rect + width > 200 + non-comment-box.
* 3. Lexical doesn't accept execCommand/clipboard. Use mouse.click(target) to focus,
* then page.keyboard.type() REAL keystrokes the Lexical input handlers fire on.
* 4. Reel composer flow: Upload Next (advance to Edit) Next (advance to Settings)
* Post button is at (~291, 802) in a 1920-wide window (left side).
* 5. After Post, Meta shows post-publish UPSELLS ("Add WhatsApp button",
* "Speak With People Directly", "Boost"). Always click "Not now"/"Skip"/etc.
* Failure to dismiss Chrome beforeunload triggers on next navigation
* "Leave site? Changes may not be saved" dialog blocks the script.
* 6. Register a `page.on('dialog', d => d.dismiss())` BEFORE clicking Post so
* any beforeunload that does fire is auto-cancelled.
* 7. Verify success by composer-disappearance (selectors on `[aria-label="Edit reel"]`
* / `[aria-label="Reel settings"]`), NOT by upsell modal appearance.
* Then feed-verify separately (post text + recent timestamp).
*/
const puppeteer = require('puppeteer-core');
const path = require('path');
const VIDEO = process.argv[2];
const CAPTION = process.argv[3];
const PROFILE_URL = process.env.FB_PROFILE_URL || 'https://www.facebook.com/profile.php?id=100068876523966';
const CDP_URL = process.env.CDP_URL || 'http://127.0.0.1:9222';
if (!VIDEO || !CAPTION) {
console.error('Usage: fb-publish-reel.cjs <video-path> "<caption>"');
process.exit(1);
}
const log = (m) => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
const wait = (ms) => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: null });
const pages = await browser.pages();
let page = pages.find(p => p.url().includes('facebook.com/profile.php'));
if (!page) {
page = pages[0];
await page.goto(PROFILE_URL, { waitUntil: 'domcontentloaded', timeout: 25000 });
await wait(3500);
}
await page.bringToFront();
await wait(800);
// LESSON 6: register beforeunload dismisser BEFORE any state changes
page.on('dialog', async d => {
log(`native dialog auto-dismissed: ${d.type()} "${d.message().substring(0, 60)}"`);
await d.dismiss();
});
// LESSON 1: verify viewport
const vp = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }));
log(`viewport ${vp.w}x${vp.h}`);
if (vp.w < 1200) { log('ABORT: viewport <1200px wide'); await browser.disconnect(); process.exit(2); }
// STEP 1: open Reel composer
let dialog = await page.evaluate(() => !!document.querySelector('[role="dialog"]'));
if (!dialog) {
log('clicking Reel button');
await page.evaluate(() => {
const sp = [...document.querySelectorAll('span')].find(s => s.textContent.trim() === 'Reel');
sp?.closest('[role="button"]')?.click();
});
await wait(3000);
}
// STEP 2: upload video into the video-accepting hidden file input
const inputs = await page.$$('input[type="file"]');
let videoIn = null;
for (const i of inputs) {
const accept = await i.evaluate(el => el.accept || '');
if (accept.includes('video/')) { videoIn = i; break; }
}
if (videoIn) {
log(`uploading ${path.basename(VIDEO)}`);
await videoIn.uploadFile(VIDEO);
await wait(8000);
} else {
log('no video input — assuming already uploaded');
}
// STEP 3: Next to Edit reel + Next to Reel settings (2 clicks)
for (let n = 1; n <= 2; n++) {
const target = await page.evaluate(() => {
const btns = [...document.querySelectorAll('[role="button"]')].filter(b => {
const r = b.getBoundingClientRect();
return b.textContent.trim() === 'Next' && r.x > 0 && r.y > 0 && r.height > 20 && r.x < window.innerWidth && r.y < window.innerHeight;
});
if (!btns.length) return null;
const r = btns[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
});
if (!target) { log(`Next ${n}: not found, aborting`); await browser.disconnect(); process.exit(5); }
await page.mouse.click(target.x, target.y);
log(`Next ${n} clicked at (${target.x}, ${target.y})`);
await wait(5000);
}
// STEP 4: LESSON 2 + 3 — fill caption via mouse.click + keyboard.type on the
// widest on-screen Lexical textbox
const target = await page.evaluate(() => {
const candidates = [...document.querySelectorAll('div[role="textbox"][data-lexical-editor]')]
.filter(b => {
const r = b.getBoundingClientRect();
return r.x >= 0 && r.x < window.innerWidth && r.y >= 0 && r.y < window.innerHeight && r.width > 200;
})
.sort((a, b) => b.getBoundingClientRect().width - a.getBoundingClientRect().width);
if (!candidates.length) return null;
const r = candidates[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
});
if (!target) { log('ABORT: no caption box'); await browser.disconnect(); process.exit(3); }
await page.mouse.click(target.x, target.y);
await wait(800);
await page.keyboard.type(CAPTION, { delay: 5 });
await wait(2000);
const len = await page.evaluate(() => {
const b = [...document.querySelectorAll('div[role="textbox"][data-lexical-editor]')]
.find(b => b.getBoundingClientRect().width > 200 && b.innerText.trim().length > 0);
return b ? b.innerText.length : 0;
});
log(`caption inserted: ${len} chars`);
if (len < 50) { log('ABORT: typing missed'); await browser.disconnect(); process.exit(4); }
// STEP 5: click Post (at ~291, 802 in 1920-wide window — bottom-left of Reel settings)
const post = await page.evaluate(() => {
const btns = [...document.querySelectorAll('[role="button"]')].filter(b => {
const r = b.getBoundingClientRect();
return ['Post', 'Publish', 'Share now'].includes(b.textContent.trim()) && r.x > 0 && r.y > 0 && r.height > 20 && r.x < window.innerWidth && r.y < window.innerHeight;
});
if (!btns.length) return null;
const r = btns[0].getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), text: btns[0].textContent.trim() };
});
if (!post) { log('ABORT: Post button not visible'); await browser.disconnect(); process.exit(5); }
await page.mouse.click(post.x, post.y);
log(`Post clicked: ${post.text}`);
// STEP 6: wait for Reel composer to close (real success signal)
let closed = false;
for (let i = 0; i < 45; i++) {
await wait(2000);
closed = await page.evaluate(() =>
!document.querySelector('[aria-label="Edit reel"]') &&
!document.querySelector('[aria-label="Reel settings"]')
);
if (closed) { log(`composer closed after ${(i+1)*2}s`); break; }
}
if (!closed) { log('FAIL: composer never closed'); await browser.disconnect(); process.exit(5); }
// STEP 7: LESSON 5 — dismiss any post-publish upsell ("Add WhatsApp button", etc.)
for (let i = 0; i < 4; i++) {
const dismissed = await page.evaluate(() => {
const btns = [...document.querySelectorAll('[role="button"], button')].filter(b => {
const r = b.getBoundingClientRect();
const t = (b.textContent || '').trim();
return ['Not now', 'Skip', 'Maybe later', 'No thanks'].includes(t) && r.x >= 0 && r.y >= 0 && r.height > 20 && r.x < window.innerWidth && r.y < window.innerHeight;
});
if (!btns.length) return null;
btns[0].click();
return btns[0].textContent.trim();
});
if (!dismissed) break;
log(`dismissed upsell #${i+1}: ${dismissed}`);
await wait(1500);
}
log('DONE');
await browser.disconnect();
process.exit(0);
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });

View File

@ -0,0 +1,99 @@
const puppeteer = require('/opt/homebrew/lib/node_modules/puppeteer-core');
const VIDEO = process.argv[2] || '/Users/renostars/dreamina-richmond-whole-house-20260415.mp4';
const TEXT = `Richmond Whole House Renovation ✨
Budget-friendly transformation of a Richmond townhouse re-tiled first floor, full repaint, and updated light fixtures throughout. A whole-house refresh without the demolition.
📞 Free consultation: 778-960-7999
🌐 reno-stars.com`;
const wait = ms => new Promise(r => setTimeout(r, ms));
const log = m => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
(async () => {
const browser = await puppeteer.connect({browserURL:'http://127.0.0.1:9222', defaultViewport:null});
const page = await browser.newPage();
await page.setViewport({width: 1900, height: 950, deviceScaleFactor: 1});
await page.goto('https://www.google.com/search?q=Reno+Stars+-+Local+Renovation+Company&authuser=0', {waitUntil:'load', timeout:40000}).catch(()=>{});
await wait(5000);
await page.bringToFront();
page.on('dialog', async d => { log(`dialog: ${d.message().substring(0,80)}`); await d.dismiss(); });
await page.evaluate(() => {
const b = [...document.querySelectorAll('div[role="button"], button, a')].find(e => e.innerText?.trim() === 'Add update' && e.offsetParent !== null);
b?.click();
});
await wait(5000);
// Wait for the iframe to load
let gbpFrame;
for (let i=0; i<10; i++) {
gbpFrame = page.frames().find(f => f.url().includes('/local/business/') && f.url().includes('/promote/updates'));
if (gbpFrame) break;
await wait(1500);
}
if (!gbpFrame) { log('no gbp frame'); await browser.disconnect(); process.exit(3); }
log(`frame: ${gbpFrame.url().substring(0,120)}`);
// Wait for compose ready
await wait(3000);
// Inspect what's in the frame
const inv = await gbpFrame.evaluate(() => {
const txt = [...document.querySelectorAll('textarea, div[contenteditable="true"]')].map(e => ({tag: e.tagName, ph: e.getAttribute('placeholder'), aria: e.getAttribute('aria-label'), w: Math.round(e.getBoundingClientRect().width), visible: e.offsetParent !== null}));
const inputs = [...document.querySelectorAll('input[type="file"]')].map(e => ({accept: e.accept}));
const buttons = [...document.querySelectorAll('button')].map(b => b.innerText?.trim()).filter(t => t && t.length < 40);
return {txt, inputs, buttons: buttons.slice(0, 30)};
});
log(`frame inv: ${JSON.stringify(inv)}`);
// Type text
const typed = await gbpFrame.evaluate((text) => {
const el = [...document.querySelectorAll('textarea')].find(e => e.offsetParent !== null);
if (!el) return null;
el.focus();
el.value = text;
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
return el.value.length;
}, TEXT);
log(`typed: ${typed}`);
// Try uploading photo (videos may not be supported on GBP posts — fallback to image_url's hero)
// Try video first
const fileInputs = await gbpFrame.$$('input[type="file"]').catch(() => []);
log(`file inputs in frame: ${fileInputs.length}`);
if (fileInputs.length) {
try {
// Use hero image if video isn't acceptable - GBP often rejects video
// Download hero image first
const heroPath = '/tmp/gbp-hero.jpg';
await page.evaluate(async (url) => { /* no-op - download via shell */ }, '');
// Actually try the hero PNG already on disk. The pending-posts has image_url R2.
// For now try the video; if fails, fallback to existing image
await fileInputs[0].uploadFile(VIDEO);
log('video uploaded to GBP');
await wait(10000);
} catch (e) {
log(`video upload err: ${e.message}`);
}
}
await page.screenshot({path:'/tmp/gbp-composed.png'});
// Look for Post button
const posted = await gbpFrame.evaluate(() => {
const b = [...document.querySelectorAll('button')].find(b => /^Post$/i.test(b.innerText?.trim() || '') && b.offsetParent !== null && !b.disabled);
if (!b) return null;
b.click();
return true;
});
log(`Post: ${posted}`);
if (!posted) {
// Maybe the button label is different
const all = await gbpFrame.evaluate(() => [...document.querySelectorAll('button')].filter(b => b.offsetParent !== null && !b.disabled).map(b => b.innerText?.trim()).filter(Boolean));
log(`buttons: ${JSON.stringify(all)}`);
}
await wait(6000);
await page.screenshot({path:'/tmp/gbp-final.png'});
await browser.disconnect();
})().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,152 @@
const puppeteer = require('/opt/homebrew/lib/node_modules/puppeteer-core');
const wait = ms => new Promise(r => setTimeout(r, ms));
const log = m => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
const VIDEO = '/Users/renostars/dreamina-richmond-whole-house-20260415.mp4';
const CAPTION = `Same room. Same angle. New everything that mattered.
Richmond townhouse re-tiled the first floor, full repaint, new lighting. Budget-friendly whole house refresh proving you don't always need to gut to transform.
#BeforeAndAfter #WholeHouseRenovation #RichmondHomes #VancouverRenovation #HomeRenovation #TownhouseReno #RenovationDesign #HomeTransform`;
(async () => {
const browser = await puppeteer.connect({browserURL:'http://127.0.0.1:9222', defaultViewport:null});
const pages = await browser.pages();
let page = pages.find(p => p.url().includes('instagram.com')) || pages[0];
await page.bringToFront();
await wait(400);
page.on('dialog', async d => { log(`dialog: ${d.message().substring(0,80)}`); await d.dismiss(); });
// Close any existing modal first
await page.evaluate(() => {
const close = document.querySelector('svg[aria-label="Close"]');
close?.closest('[role="button"], div[tabindex]')?.click();
});
await wait(1000);
// Confirm discard if asked
await page.evaluate(() => {
const discard = [...document.querySelectorAll('button, div[role="button"]')].find(b => /Discard/i.test(b.textContent || '') && b.offsetParent !== null);
discard?.click();
});
await wait(1500);
// Reload to clean state
await page.goto('https://www.instagram.com/', {waitUntil:'domcontentloaded',timeout:25000});
await wait(4000);
await page.setViewport({width: 1900, height: 950, deviceScaleFactor: 1});
await wait(500);
const vp = await page.evaluate(() => ({w: innerWidth, h: innerHeight}));
log(`viewport ${vp.w}x${vp.h}`);
// STEP 1: Open New post → Post
await page.evaluate(() => {
const np = document.querySelector('svg[aria-label="New post"]');
np?.closest('a, [role="button"], div[tabindex]')?.click();
});
await wait(2000);
await page.evaluate(() => {
const opt = [...document.querySelectorAll('span, a, div')].find(e => e.textContent?.trim() === 'Post' && e.offsetParent !== null);
opt?.click();
});
await wait(2500);
log('opened composer');
// STEP 2: upload
const inputs = await page.$$('input[type="file"]');
if (!inputs.length) { log('no input'); await browser.disconnect(); process.exit(3); }
await inputs[0].uploadFile(VIDEO);
log('uploaded');
await wait(10000);
// Dismiss reels modal
await page.evaluate(() => {
const ok = [...document.querySelectorAll('button')].find(b => ['OK','Ok','Got it'].includes(b.textContent?.trim()) && b.offsetParent !== null);
ok?.click();
});
await wait(2500);
// STEP 3: Next x2 — modal-top-right filter
for (let n=1; n<=2; n++) {
const r = await page.evaluate(() => {
const c = [...document.querySelectorAll('div[role="button"]')]
.filter(b => b.textContent?.trim() === 'Next' && b.offsetParent !== null)
.map(b => ({el: b, r: b.getBoundingClientRect()}))
.filter(o => o.r.y < 200 && o.r.x > 800);
if (!c.length) return null;
c.sort((a,b) => a.r.y - b.r.y);
c[0].el.click();
return {x: Math.round(c[0].r.x), y: Math.round(c[0].r.y)};
});
log(`Next ${n} ${JSON.stringify(r)}`);
await wait(4500);
}
// STEP 4: Caption — find ON-SCREEN visible+sized lexical box
const target = await page.evaluate(() => {
const candidates = [...document.querySelectorAll('div[aria-label="Write a caption..."][data-lexical-editor]')]
.filter(b => {
const r = b.getBoundingClientRect();
return r.x >= 0 && r.x < innerWidth && r.y >= 0 && r.y < innerHeight && r.width > 100 && r.height > 30;
})
.sort((a,b) => b.getBoundingClientRect().width - a.getBoundingClientRect().width);
if (!candidates.length) return null;
const r = candidates[0].getBoundingClientRect();
return {x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2), count: candidates.length};
});
if (!target) { log('no caption box'); await page.screenshot({path:'/tmp/ig-fail.png'}); await browser.disconnect(); process.exit(3); }
log(`caption ${JSON.stringify(target)}`);
await page.mouse.click(target.x, target.y);
await wait(800);
const focused = await page.evaluate(() => {
const a = document.activeElement;
return {tag: a?.tagName, aria: a?.getAttribute('aria-label'), ce: a?.getAttribute('contenteditable')};
});
log(`focused: ${JSON.stringify(focused)}`);
await page.keyboard.type(CAPTION, {delay: 5});
await wait(2000);
const len = await page.evaluate(() => {
const els = [...document.querySelectorAll('div[aria-label="Write a caption..."]')];
return els.map(e => e.textContent?.length || 0);
});
log(`caption lens: ${JSON.stringify(len)}`);
if (Math.max(...len, 0) < 100) {
log('caption typing missed');
await page.screenshot({path:'/tmp/ig-typed-fail.png'});
await browser.disconnect();
process.exit(4);
}
// STEP 5: Share
const share = await page.evaluate(() => {
const c = [...document.querySelectorAll('div[role="button"]')]
.filter(b => b.textContent?.trim() === 'Share' && b.offsetParent !== null)
.map(b => ({el: b, r: b.getBoundingClientRect()}))
.filter(o => o.r.y < 200 && o.r.x > 800);
if (!c.length) return null;
c.sort((a,b) => a.r.y - b.r.y);
c[0].el.click();
return {x: Math.round(c[0].r.x), y: Math.round(c[0].r.y)};
});
if (!share) { log('no Share'); await browser.disconnect(); process.exit(5); }
log(`Share ${JSON.stringify(share)}`);
for (let i=0; i<40; i++) {
await wait(3000);
const state = await page.evaluate(() => {
const txt = document.body.innerText;
return {
sharing: /Sharing/i.test(txt),
shared: /Your reel has been shared|Your post has been shared|Reel shared|reel has been shared/i.test(txt),
captionStill: !!document.querySelector('div[aria-label="Write a caption..."]'),
};
});
log(`t+${i*3}s ${JSON.stringify(state)}`);
if (state.shared) { log('SHARED'); break; }
if (!state.captionStill && !state.sharing && i > 4) { log('composer gone'); break; }
}
await page.screenshot({path:'/tmp/ig-final.png'});
await browser.disconnect();
})().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,137 @@
/**
* LinkedIn company-page video post.
* Flow: /company/103326696/admin/dashboard "Create" "Start a post" "Add media" (legacy picker) upload Next caption Post.
*/
const puppeteer = require('/opt/homebrew/lib/node_modules/puppeteer-core');
const VIDEO = process.argv[2] || '/Users/renostars/dreamina-richmond-whole-house-20260415.mp4';
const CAPTION = process.argv[3] || `A common ask we get: "how much can I really change without gutting?"
This Richmond townhouse is the answer. We re-tiled the first floor, repainted the entire home, and swapped out the lighting. No structural work, no plumbing relocation but the finished result reads as a completely different home.
For owners weighing renovate-vs-sell-vs-do-nothing, scope discipline like this is usually the highest-ROI play.
Free consultation 778-960-7999 | reno-stars.com
#Renovation #Vancouver #HomeImprovement #WholeHouseRenovation`;
const wait = ms => new Promise(r => setTimeout(r, ms));
const log = m => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
(async () => {
const browser = await puppeteer.connect({browserURL:'http://127.0.0.1:9222', defaultViewport:null});
const page = await browser.newPage();
await page.setViewport({width: 1900, height: 950, deviceScaleFactor: 1});
await page.goto('https://www.linkedin.com/company/103326696/admin/dashboard/', {waitUntil:'load', timeout:40000}).catch(()=>{});
await wait(6000);
await page.bringToFront();
page.on('dialog', async d => { log(`dialog: ${d.message().substring(0,80)}`); await d.dismiss(); });
log(`url ${page.url()}`);
// STEP 1: click "Create" button
const c1 = await page.evaluate(() => {
const b = [...document.querySelectorAll('button')].find(b => b.innerText?.trim() === 'Create' && b.offsetParent !== null);
if (!b) return null;
const r = b.getBoundingClientRect();
b.click();
return {x: Math.round(r.x), y: Math.round(r.y)};
});
log(`Create: ${JSON.stringify(c1)}`);
await wait(1500);
// STEP 2: click "Start a post"
const c2 = await page.evaluate(() => {
const candidates = [...document.querySelectorAll('*')].filter(e => {
for (const node of e.childNodes) if (node.nodeType === 3 && /^Start a post$/i.test(node.textContent.trim())) return true;
return false;
});
for (const cand of candidates) {
let cur = cand;
for (let i=0; i<6 && cur; i++) {
if (cur.tagName === 'A' || cur.tagName === 'BUTTON' || cur.getAttribute?.('role') === 'button') {
if (cur.offsetParent !== null) {
cur.click();
return {tag: cur.tagName};
}
}
cur = cur.parentElement;
}
}
return null;
});
log(`Start a post: ${JSON.stringify(c2)}`);
await wait(3000);
// STEP 3: click "Add media" (registers file chooser first)
const fcPromise = page.waitForFileChooser({timeout: 8000});
const c3 = await page.evaluate(() => {
const b = [...document.querySelectorAll('button[aria-label="Add media"], button')]
.filter(b => b.getAttribute('aria-label') === 'Add media' && b.offsetParent !== null);
if (!b.length) return null;
const r = b[0].getBoundingClientRect();
b[0].click();
return {x: Math.round(r.x), y: Math.round(r.y)};
});
log(`Add media: ${JSON.stringify(c3)}`);
let chooser;
try { chooser = await fcPromise; } catch (e) { log('file chooser timeout'); await page.screenshot({path:'/tmp/li-fail.png'}); await browser.disconnect(); process.exit(3); }
await chooser.accept([VIDEO]);
log('file accepted');
await wait(20000); // allow upload
// STEP 4: click Next
for (let n=1; n<=2; n++) {
const nxt = await page.evaluate(() => {
const b = [...document.querySelectorAll('button')].find(b => /^Next$/i.test(b.innerText?.trim() || '') && b.offsetParent !== null && !b.disabled);
if (!b) return null;
b.click();
return true;
});
log(`Next ${n}: ${nxt}`);
if (!nxt) break;
await wait(3500);
}
// STEP 5: type caption — .ql-editor
const cap = await page.evaluate(() => {
const el = document.querySelector('.ql-editor');
if (!el) return null;
const r = el.getBoundingClientRect();
return {x: Math.round(r.x + r.width/2), y: Math.round(r.y + 20)};
});
if (!cap) { log('no .ql-editor'); await page.screenshot({path:'/tmp/li-fail.png'}); await browser.disconnect(); process.exit(4); }
await page.mouse.click(cap.x, cap.y);
await wait(500);
await page.keyboard.type(CAPTION, {delay: 6});
await wait(2000);
const len = await page.evaluate(() => document.querySelector('.ql-editor')?.innerText?.length || 0);
log(`caption length ${len}`);
if (len < 100) { log('caption typing missed'); await page.screenshot({path:'/tmp/li-fail.png'}); await browser.disconnect(); process.exit(5); }
// STEP 6: Post button
const posted = await page.evaluate(() => {
const b = [...document.querySelectorAll('button')].find(b => b.innerText?.trim() === 'Post' && b.offsetParent !== null && !b.disabled);
if (!b) return null;
b.click();
return true;
});
log(`Post: ${posted}`);
if (!posted) { log('no Post button'); await page.screenshot({path:'/tmp/li-fail.png'}); await browser.disconnect(); process.exit(6); }
// Verify
for (let i=0; i<30; i++) {
await wait(2000);
const state = await page.evaluate(() => {
const txt = document.body.innerText;
return {
url: location.pathname,
success: /Post successful|Your post has been shared/i.test(txt),
editorGone: !document.querySelector('.ql-editor'),
};
});
log(`t+${i*2}s ${JSON.stringify(state)}`);
if (state.success || (state.editorGone && i > 3)) { log('SUCCESS'); break; }
}
await page.screenshot({path:'/tmp/li-final.png'});
await browser.disconnect();
})().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,105 @@
/**
* TikTok video upload via web. Uses fresh tab.
* Flow: studio.tiktok.com/upload?lang=en file picker caption Post.
*/
const puppeteer = require('/opt/homebrew/lib/node_modules/puppeteer-core');
const VIDEO = process.argv[2] || '/Users/renostars/dreamina-richmond-whole-house-20260415.mp4';
const CAPTION = process.argv[3] || `Same room. Same angle. Different vibe. 🏡
Richmond townhouse whole house refresh new floor tile, full repaint, fresh lighting. No demolition drama. Just smart updates that read across every room.
#BeforeAndAfter #WholeHouseRenovation #RichmondRenovation #VancouverRenovation #HomeReno`;
const wait = ms => new Promise(r => setTimeout(r, ms));
const log = m => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
(async () => {
const browser = await puppeteer.connect({browserURL:'http://127.0.0.1:9222', defaultViewport:null});
const page = await browser.newPage();
await page.setViewport({width: 1900, height: 950, deviceScaleFactor: 1});
await page.goto('https://www.tiktok.com/tiktokstudio/upload?from=upload&lang=en', {waitUntil:'load', timeout:40000}).catch(()=>{});
await wait(8000);
await page.bringToFront();
page.on('dialog', async d => { log(`dialog: ${d.message().substring(0,80)}`); await d.dismiss(); });
log(`url ${page.url()}`);
// Wait for and find file input
let videoIn;
for (let i=0; i<10; i++) {
const inputs = await page.$$('input[type="file"]');
for (const fi of inputs) {
const acc = await fi.evaluate(el => el.accept || '');
if (acc.includes('video') || acc === '') { videoIn = fi; break; }
}
if (videoIn) break;
await wait(1500);
}
if (!videoIn) { log('no video input'); await page.screenshot({path:'/tmp/tt-fail.png'}); await browser.disconnect(); process.exit(3); }
await videoIn.uploadFile(VIDEO);
log('uploaded — waiting for processing');
await wait(35000); // TikTok takes time
// Caption — DraftEditor or contenteditable
const cap = await page.evaluate(() => {
const sels = ['div[data-e2e="post-editor-textarea"] div[contenteditable="true"]', '.public-DraftEditor-content', 'div[contenteditable="true"][role="textbox"]', 'div[contenteditable="true"]'];
for (const s of sels) {
const els = [...document.querySelectorAll(s)].filter(e => {
const r = e.getBoundingClientRect();
return r.width > 200 && r.height > 20 && e.offsetParent !== null;
});
if (els.length) {
const r = els[0].getBoundingClientRect();
return {sel: s, x: Math.round(r.x + r.width/2), y: Math.round(r.y + 20)};
}
}
return null;
});
if (!cap) { log('no caption editor'); await page.screenshot({path:'/tmp/tt-fail.png'}); await browser.disconnect(); process.exit(4); }
log(`caption editor: ${JSON.stringify(cap)}`);
await page.mouse.click(cap.x, cap.y);
await wait(500);
// Select all + delete pre-fill (TikTok pre-fills filename)
await page.keyboard.down('Meta'); await page.keyboard.press('a'); await page.keyboard.up('Meta');
await page.keyboard.press('Backspace');
await wait(300);
await page.keyboard.type(CAPTION, {delay: 8});
await wait(2500);
const len = await page.evaluate(() => {
const el = document.querySelector('div[data-e2e="post-editor-textarea"], .public-DraftEditor-content, div[contenteditable="true"][role="textbox"]');
return el?.innerText?.length || 0;
});
log(`caption length: ${len}`);
if (len < 50) { log('typing missed'); await page.screenshot({path:'/tmp/tt-fail.png'}); await browser.disconnect(); process.exit(5); }
// Wait for upload finish — Post button enabled
let postedTry = null;
for (let i=0; i<30; i++) {
postedTry = await page.evaluate(() => {
const candidates = [...document.querySelectorAll('button')].filter(b => /^Post$/i.test(b.innerText?.trim() || '') && b.offsetParent !== null);
const enabled = candidates.find(b => !b.disabled && b.getAttribute('aria-disabled') !== 'true');
return {found: candidates.length, enabled: !!enabled};
});
log(`post-btn poll: ${JSON.stringify(postedTry)}`);
if (postedTry.enabled) break;
await wait(3000);
}
if (!postedTry?.enabled) { log('post button never enabled'); await page.screenshot({path:'/tmp/tt-fail.png'}); await browser.disconnect(); process.exit(6); }
await page.evaluate(() => {
const b = [...document.querySelectorAll('button')].find(b => /^Post$/i.test(b.innerText?.trim() || '') && !b.disabled && b.offsetParent !== null);
b?.click();
});
log('Post clicked');
for (let i=0; i<60; i++) {
await wait(2000);
const state = await page.evaluate(() => {
const txt = document.body.innerText;
return {url: location.pathname, success: /Your video is being uploaded|Your post is being processed|posted successfully|Manage your posts/i.test(txt)};
});
log(`t+${i*2}s ${JSON.stringify(state)}`);
if (state.success || state.url.includes('/manage')) { log('SUCCESS'); break; }
}
await page.screenshot({path:'/tmp/tt-final.png'});
await browser.disconnect();
})().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,109 @@
/**
* X (Twitter) post publisher with video.
* Flow: x.com/home "Post" composer (Drafts modal) upload video type caption "Post" button.
*/
const puppeteer = require('/opt/homebrew/lib/node_modules/puppeteer-core');
const path = require('path');
const VIDEO = process.argv[2] || '/Users/renostars/dreamina-richmond-whole-house-20260415.mp4';
const CAPTION = process.argv[3] || 'Whole house refresh in Richmond — re-tiled first floor, full repaint, new fixtures. Same townhouse, modern living. Before → after 🏡';
const wait = ms => new Promise(r => setTimeout(r, ms));
const log = m => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
(async () => {
const browser = await puppeteer.connect({browserURL:'http://127.0.0.1:9222', defaultViewport:null});
const pages = await browser.pages();
let page = pages.find(p => p.url().includes('x.com') || p.url().includes('twitter.com'));
if (!page) {
page = pages[0];
await page.goto('https://x.com/home', {waitUntil:'domcontentloaded', timeout:25000});
await wait(5000);
} else {
await page.goto('https://x.com/home', {waitUntil:'domcontentloaded', timeout:25000});
await wait(4000);
}
await page.setViewport({width: 1900, height: 950, deviceScaleFactor: 1});
await page.bringToFront();
await wait(800);
page.on('dialog', async d => { log(`dialog: ${d.message().substring(0,80)}`); await d.dismiss(); });
const vp = await page.evaluate(() => ({w: innerWidth, h: innerHeight}));
log(`viewport ${vp.w}x${vp.h}`);
// STEP 1: Find the inline composer textbox on /home
const composerInfo = await page.evaluate(() => {
const els = [...document.querySelectorAll('div[data-testid="tweetTextarea_0"], div[role="textbox"][contenteditable="true"]')]
.map(el => ({el, r: el.getBoundingClientRect()}))
.filter(o => o.r.width > 100 && o.r.x >= 0 && o.r.y >= 0);
if (!els.length) return null;
const t = els[0];
return {x: Math.round(t.r.x + t.r.width/2), y: Math.round(t.r.y + t.r.height/2)};
});
if (!composerInfo) {
log('no inline composer — clicking post-button to open modal');
await page.evaluate(() => {
const b = document.querySelector('a[data-testid="SideNav_NewTweet_Button"]');
b?.click();
});
await wait(2500);
} else {
await page.mouse.click(composerInfo.x, composerInfo.y);
await wait(500);
}
// STEP 2: upload video via the file input near composer
const fileInputs = await page.$$('input[type="file"]');
let videoIn = null;
for (const fi of fileInputs) {
const acc = await fi.evaluate(el => el.accept || '');
if (acc.includes('video') || acc.includes('image') || acc === '') videoIn = fi;
}
if (!videoIn && fileInputs.length) videoIn = fileInputs[0];
if (!videoIn) { log('no file input'); await browser.disconnect(); process.exit(3); }
await videoIn.uploadFile(VIDEO);
log('video uploaded');
await wait(15000); // wait for video processing/preview
// STEP 3: Click composer + type caption
const target = await page.evaluate(() => {
const el = document.querySelector('div[data-testid="tweetTextarea_0"]');
if (!el) return null;
const r = el.getBoundingClientRect();
return {x: Math.round(r.x + r.width/2), y: Math.round(r.y + 20)};
});
if (!target) { log('no composer'); await browser.disconnect(); process.exit(3); }
await page.mouse.click(target.x, target.y);
await wait(500);
await page.keyboard.type(CAPTION, {delay: 8});
await wait(2000);
const len = await page.evaluate(() => document.querySelector('div[data-testid="tweetTextarea_0"]')?.textContent?.length || 0);
log(`caption length: ${len}`);
if (len < 30) { log('caption typing missed'); await page.screenshot({path:'/tmp/x-fail.png'}); await browser.disconnect(); process.exit(4); }
// STEP 4: Click "Post" — testid="tweetButtonInline" or "tweetButton"
const posted = await page.evaluate(() => {
const btn = document.querySelector('button[data-testid="tweetButton"], button[data-testid="tweetButtonInline"]');
if (!btn) return null;
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'disabled';
btn.click();
return 'clicked';
});
log(`Post button: ${posted}`);
if (posted !== 'clicked') { log('post failed'); await page.screenshot({path:'/tmp/x-fail.png'}); await browser.disconnect(); process.exit(5); }
// Verify by composer disappearing or success message
for (let i=0; i<30; i++) {
await wait(2000);
const state = await page.evaluate(() => {
const txt = document.body.innerText;
const composer = !!document.querySelector('div[data-testid="tweetTextarea_0"]');
const url = location.pathname;
return {composer, posted: /Your post was sent|Your tweet was sent/i.test(txt), url};
});
log(`t+${i*2}s ${JSON.stringify(state)}`);
if (state.posted) { log('POSTED'); break; }
if (!state.composer && i > 3) { log('composer gone — likely posted'); break; }
}
await page.screenshot({path:'/tmp/x-final.png'});
await browser.disconnect();
})().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,132 @@
/**
* YouTube Short upload via studio.youtube.com.
* Flow: studio.youtube.com Create button Upload videos file picker wait for processing set title/description Next×3 Publish.
*/
const puppeteer = require('/opt/homebrew/lib/node_modules/puppeteer-core');
const VIDEO = process.argv[2] || '/Users/renostars/dreamina-richmond-whole-house-20260415.mp4';
const TITLE = 'Whole House Refresh — Richmond Townhouse Transformation #shorts';
const DESC = process.argv[3] || `Whole house renovation doesn't always mean tearing the place apart.
This Richmond townhouse client wanted a budget-friendly refresh not a gut job. We re-tiled the first floor, repainted every room, and replaced the light fixtures throughout. The footprint, the layout, the plumbing all stayed the same.
What changed was the FEEL. Better lighting alone can make a room read 5 years younger. Tile across an open-concept first floor unifies what used to feel chopped up. Fresh paint hides a decade of small wear-and-tear.
Sometimes the most powerful renovation is the one that doesn't need permits. 🏡
#WholeHouseRenovation #RichmondRenovation #VancouverRenovation #HomeImprovement #BeforeAndAfter #shorts`;
const wait = ms => new Promise(r => setTimeout(r, ms));
const log = m => console.log(`[${new Date().toISOString().substring(11,19)}] ${m}`);
(async () => {
const browser = await puppeteer.connect({browserURL:'http://127.0.0.1:9222', defaultViewport:null});
const page = await browser.newPage();
await page.setViewport({width: 1900, height: 950, deviceScaleFactor: 1});
await page.goto('https://studio.youtube.com/', {waitUntil:'load', timeout:40000}).catch(()=>{});
await wait(8000);
await page.bringToFront();
page.on('dialog', async d => { log(`dialog: ${d.message().substring(0,80)}`); await d.dismiss(); });
log(`url ${page.url()}`);
// STEP 1: click Create then Upload videos
await page.evaluate(() => {
const b = document.querySelector('ytcp-button#create-icon, button[aria-label="Create"]');
b?.click();
});
await wait(1500);
await page.evaluate(() => {
// Menu items: "Upload videos" / "Go live"
const items = [...document.querySelectorAll('tp-yt-paper-item, [role="menuitem"]')];
const upload = items.find(e => /Upload videos/i.test(e.innerText || ''));
upload?.click();
});
await wait(2500);
// STEP 2: file input
const inputs = await page.$$('input[type="file"]');
if (!inputs.length) { log('no file input'); await page.screenshot({path:'/tmp/yt-fail.png'}); await browser.disconnect(); process.exit(3); }
await inputs[0].uploadFile(VIDEO);
log('file uploaded');
await wait(15000); // wait for upload modal to appear
// STEP 3: Set title — first textbox
const titleSet = await page.evaluate((title) => {
// Find Title contenteditable — usually the first ytcp-mention-textbox div[contenteditable]
const editors = [...document.querySelectorAll('ytcp-mention-textbox div[contenteditable="true"], div#textbox[contenteditable="true"]')];
if (!editors.length) return null;
const titleEl = editors[0];
titleEl.focus();
// Clear and set
document.execCommand('selectAll', false, null);
document.execCommand('insertText', false, title);
return editors.length;
}, TITLE);
log(`title editors: ${titleSet}`);
await wait(1000);
// STEP 4: Description = second editor
const descSet = await page.evaluate((desc) => {
const editors = [...document.querySelectorAll('ytcp-mention-textbox div[contenteditable="true"], div#textbox[contenteditable="true"]')];
if (editors.length < 2) return null;
const el = editors[1];
el.focus();
document.execCommand('selectAll', false, null);
document.execCommand('insertText', false, desc);
return true;
}, DESC);
log(`desc set: ${descSet}`);
await wait(1500);
// STEP 5: "Made for kids" → No (radio name="VIDEO_MADE_FOR_KIDS_NOT_MFK")
await page.evaluate(() => {
const radios = [...document.querySelectorAll('tp-yt-paper-radio-button')];
const noKids = radios.find(r => /No, it's not made for kids/i.test(r.innerText || ''));
noKids?.click();
});
await wait(800);
// STEP 6: Next × 3 (Details → Video elements → Checks → Visibility)
for (let n=1; n<=3; n++) {
const ok = await page.evaluate(() => {
const b = document.querySelector('ytcp-button#next-button');
if (!b || b.hasAttribute('disabled')) return null;
b.click();
return true;
});
log(`Next ${n}: ${ok}`);
if (!ok) break;
await wait(2500);
}
// STEP 7: Visibility = Public
await page.evaluate(() => {
const radios = [...document.querySelectorAll('tp-yt-paper-radio-button[name="PUBLIC"]')];
radios[0]?.click();
});
await wait(800);
// STEP 8: Publish button
const pub = await page.evaluate(() => {
const b = document.querySelector('ytcp-button#done-button');
if (!b || b.hasAttribute('disabled')) return null;
b.click();
return true;
});
log(`Publish: ${pub}`);
if (!pub) { log('publish disabled'); await page.screenshot({path:'/tmp/yt-fail.png'}); await browser.disconnect(); process.exit(5); }
// Verify — modal closes
for (let i=0; i<60; i++) {
await wait(2000);
const state = await page.evaluate(() => {
const txt = document.body.innerText;
return {
success: /Video published|published successfully|Your video has been published/i.test(txt),
modalGone: !document.querySelector('ytcp-uploads-dialog'),
};
});
log(`t+${i*2}s ${JSON.stringify(state)}`);
if (state.success || state.modalGone) { log('PUBLISHED'); break; }
}
await page.screenshot({path:'/tmp/yt-final.png'});
await browser.disconnect();
})().catch(e => { console.error(e); process.exit(1); });

View File

@ -2,6 +2,8 @@
Search platforms for relevant posts about renovation and Vancouver. Draft replies for approval, then publish approved replies.
> **HARD RULE — NEVER FREESTYLE PUPPETEER.** When a reply escalates into publishing a new post (reel, story, video comment with attached media), delegate to the `social-publish` skill: `node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs <media> "<caption>"`. Exit codes and lessons baked in: `org-templates/reno-stars/marketing-leader/skills/social-publish/SKILL.md`. For text-only comments (the common case), the existing per-platform comment flows in this skill still apply.
## HONESTY RULE (CRITICAL)
All engagement replies must be truthful. Only reference real data from the website, database, or owner-provided information.
- Do NOT guess prices, timelines, or project details. If you don't have real data, say "it varies" or "hard to say without seeing the space".

View File

@ -7,6 +7,8 @@ When responding to DMs or comments on behalf of Reno Stars, only state facts you
> **READ FIRST**: `~/.claude/skills/social-media-post/SKILL.md` and memory `feedback_social_media_platforms.md` for all platform quirks. **Reddit is PAUSED until 2026-04-21** (see Reddit section below) — skip it entirely.
>
> **For any reply publish that triggers a full new post** (e.g. responding to a DM by publishing a fresh video): use the `social-publish` helpers — `node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs`. See that skill's SKILL.md for exit codes. Never freestyle puppeteer for social publishing.
>
> **Telegram approval flow note**: when you receive an ambiguous short message ("reply all", "approve", "yes"), ALWAYS check `~/.openclaw/workspace/social/pending-replies.json` and the most recent log in `~/reno-star-business-intelligent/data/cron-logs/` BEFORE asking the user "what?". Telegram Bot API has no message history; the cron's outbound message lives only on disk. (Confirmed user frustration with this on 2026-04-07.)
## Config

View File

@ -435,7 +435,14 @@ Or: APPROVE [post_id] facebook,instagram to publish specific platforms only
## STEP 3: Platform Posting (when publishing approved posts)
> **READ FIRST**: `~/.claude/skills/social-media-post/SKILL.md` — comprehensive playbook for every platform with the quirks learned the hard way on 2026-04-07. Also see memory `feedback_social_media_platforms.md` for the failure-mode index. The instructions below are the cron-specific shortcuts; the skill is the source of truth.
> **HARD RULE — NEVER FREESTYLE PUPPETEER.**
> For every platform below, invoke the matching helper from the `social-publish` skill:
>
> ```
> node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<platform>-publish.cjs <video-or-image> "<caption>"
> ```
>
> Full playbook + exit codes: `org-templates/reno-stars/marketing-leader/skills/social-publish/SKILL.md`. If a helper fails, **fix the helper and re-run** — do not re-derive puppeteer code inline. The legacy recipes below remain as reference only for debugging a broken helper. Also see `~/.claude/skills/social-media-post/SKILL.md` and memory `feedback_social_media_platforms.md` for the failure-mode index.
**Universal pre-flight (do BEFORE touching any platform):**
- Files for upload MUST be under `/Users/renostars/`. Copy first if elsewhere.