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:
commit
bd51ea6190
@ -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.
|
||||
|
||||
@ -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".
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 4–6 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.
|
||||
@ -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); });
|
||||
@ -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); });
|
||||
@ -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); });
|
||||
@ -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); });
|
||||
@ -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); });
|
||||
@ -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); });
|
||||
@ -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); });
|
||||
@ -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".
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user