feat(reno-stars): citation-builder — one backlink directory per day (#299)

Closes #301

Co-authored-by: airenostars <noreply@github.com>
This commit is contained in:
airenostars 2026-04-15 19:47:20 -07:00 committed by GitHub
parent 9b08c34707
commit 1fb9712fa4
7 changed files with 722 additions and 0 deletions

View File

@ -29,5 +29,9 @@ node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/<pl
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.
## Citation / backlink building — one directory per day
The daily 7:30 AM "Citation Builder" schedule fires `skills/citation-builder/scripts/run.cjs` which picks the next `pending` directory from `queue.json` and submits Reno Stars via `_generic.cjs` (falls back to a per-site adapter when one exists). See `/configs/skills/citation-builder/SKILL.md` for the full contract. Hard rule: **one directory per run** — never brute-force the queue. Auto-verification via Gmail is in-skill; captcha / phone-verify blockers report to Telegram as "needs human".
## Language
Always respond in the same language the user uses.

View File

@ -0,0 +1,121 @@
---
id: citation-builder
name: Citation Builder
description: Submit Reno Stars to one business directory per run. Reads a queue, picks the next pending site, attempts signup + listing via Chrome CDP, logs result. Pings Telegram on captcha / email-verify blockers so a human can finish.
tags: [seo, citations, backlinks, browser]
---
# Citation Builder — one directory per run
## Purpose
Replaces the $2K/mo NetSync / Whitespark-style paid services with a daily cron
that submits Reno Stars to business directories ourselves. Each run picks ONE
pending directory from `queue.json` and attempts the full signup + listing flow.
The key rule: **do not batch**. One directory per run. Slow wins. Each site
has unique quirks (Hotfrog is a React SPA with hydration flake, TupaloTupalo does
email-magic-link, Cylex has Cloudflare, BBB requires phone verify). Trying to
submit 10 per run inevitably hits rate limits or cascading failures. Trust the
queue — at 1/day, 20 directories take three weeks of hands-off work.
## Inputs
- `/configs/plugins/.../business-profile.json` — canonical NAP + categories +
descriptions. The [browser-automation plugin](../../../../plugins/browser-automation/)
must be installed so Chrome CDP is reachable at `host.docker.internal:9223`.
- `/configs/skills/citation-builder/queue.json` — ordered list of directories.
Each entry: `{name, url, status, priority, notes?}`.
- `/configs/skills/citation-builder/scripts/<site>.cjs` — per-site adapter
(optional). If absent, the generic adapter runs and escalates.
## Flow (one run)
1. Read `queue.json`. Pick the first entry with `status: "pending"`. If none,
log "queue exhausted" + Telegram a completion summary + exit.
2. Load `business-profile.json`. Assemble form data (name, address, phone,
email, category, description, hours, logo URL).
3. Look for a per-site adapter at `scripts/<site>.cjs`. If present, run it.
If absent, run the generic adapter (below).
4. Capture outcome into `status` field + append to
`/configs/skills/citation-builder/log.jsonl`:
- `live` — listing is visible on the public directory URL (verify-before-commit)
- `pending_email_verify` — submitted but waiting on email link click
- `pending_human` — captcha / phone verify / manual step needed
- `failed` — hard error; include reason
5. If `pending_email_verify`, open Gmail (Chrome profile has it logged in),
search `from:<site-domain>`, click the verification link, then re-verify the
listing is live. If it is, update status to `live`.
6. Send Telegram summary — one line per attempted directory this run. Include
the public URL if live; include "needs human" otherwise.
## Generic adapter (fallback)
```javascript
// scripts/_generic.cjs — invoked when no per-site adapter exists
// 1. Navigate to {url} from queue.json
// 2. Detect form shape: search for inputs matching /business.name|company/i,
// /phone/, /email/, /address/, /website/, /description/, /categor/i,
// /city/, /postal|zip/
// 3. Fill what matches; leave others blank
// 4. Click the most-prominent submit button with text matching
// /submit|continue|register|sign.?up|get.?started|add.my.business/i
// 5. Wait 5s, screenshot, evaluate response body for
// "success|thank you|check your email|verify your email"
// 6. If match → pending_email_verify. Otherwise → pending_human.
```
Never brute-force a site that rejects the generic adapter. Escalate to
`pending_human` and move on — a per-site adapter can be written later from
the screenshot.
## Adding a per-site adapter
When the generic adapter can't finish a submission, the human (or a follow-up
cron run) can author `scripts/<site>.cjs` that:
- Uses `lib/connect.js` from the `browser-automation` plugin (never
`puppeteer.launch()` or raw `puppeteer.connect({defaultViewport:<anything>})`).
- Handles site-specific quirks: iframes, Shadow DOM, multi-step wizards,
conditional fields.
- Exits with `exit 0 + {status: 'live'|'pending_email_verify'|'pending_human'|'failed', reason}`
on stdout as JSON on the last line.
Refer to the 7 social-media helpers in
[`skills/social-publish/scripts/`](../social-publish/scripts/) for the canonical
pattern (mouse.click + keyboard.type, modal-top-right filters, multi-Lexical
disambiguation).
## Hard rules
- NEVER freestyle puppeteer. Always use the plugin's `lib/connect.js` so
`defaultViewport: null` is enforced.
- NEVER spam retries on the same site — one attempt per run. If it fails, mark
`pending_human` and move on.
- NEVER fabricate NAP data. Pull only from `business-profile.json`. If a field
is missing there, ask (via Telegram), don't invent.
- Photo uploads are OUT OF SCOPE for this skill. Listings with only NAP are fine
— photos can be added manually later.
## Tracker schema (`queue.json`)
```json
{
"entries": [
{
"name": "Hotfrog",
"url": "https://admin.hotfrog.ca/login/register",
"priority": 1,
"status": "pending",
"last_attempt": null,
"public_listing_url": null,
"notes": "React SPA, hydration flake — may need 2-3 attempts"
}
]
}
```
## Schedule
Once per day, 7:30 AM Vancouver. Paired with SEO Builder (6:17 AM) and SEO
Weekly Report so the whole "SEO loop" runs in a ~90 min window each morning.

View File

@ -0,0 +1,173 @@
{
"_comment": "Directory submission queue for Reno Stars. One per day. Priority 1 = try first. status values: pending | live | pending_email_verify | pending_human | failed | skip.",
"entries": [
{
"name": "Hotfrog",
"url": "https://admin.hotfrog.ca/login/register",
"priority": 1,
"status": "pending",
"notes": "React SPA, hydration flake — generic adapter should work with explicit page.waitForSelector"
},
{
"name": "Cylex",
"url": "https://www.cylex-canada.ca/",
"priority": 1,
"status": "pending",
"notes": "Look for 'Add your business' footer link"
},
{
"name": "Brownbook",
"url": "https://www.brownbook.net/business/add/",
"priority": 1,
"status": "pending",
"notes": "Classic HTML form, usually no captcha on free tier"
},
{
"name": "Tupalo",
"url": "https://tupalo.com/en/canada/add-business",
"priority": 1,
"status": "pending",
"notes": "Often email-magic-link; check Gmail after submit"
},
{
"name": "Yalwa",
"url": "https://www.yalwa.ca/",
"priority": 2,
"status": "pending",
"notes": "Add Business link in footer"
},
{
"name": "Opendi",
"url": "https://www.opendi.ca/",
"priority": 2,
"status": "pending",
"notes": "Low DA but free + easy"
},
{
"name": "iGlobal",
"url": "https://www.iglobal.co/canada/",
"priority": 2,
"status": "pending",
"notes": "Global directory; free tier adds NAP + website"
},
{
"name": "InfoIsInfo",
"url": "https://infoisinfo.ca/",
"priority": 2,
"status": "pending",
"notes": "Free listing, instant approval"
},
{
"name": "ShowMeLocal",
"url": "https://www.showmelocal.com/add-your-business.aspx",
"priority": 2,
"status": "pending"
},
{
"name": "FindOpen",
"url": "https://www.findopen.ca/",
"priority": 2,
"status": "pending"
},
{
"name": "Acompio",
"url": "https://www.acompio.ca/",
"priority": 3,
"status": "pending"
},
{
"name": "Houzz",
"url": "https://www.houzz.com/pro/new",
"priority": 1,
"status": "pending",
"notes": "High DA for home-renovation niche — highest SEO value on this queue"
},
{
"name": "HomeStars",
"url": "https://homestars.com/pros/signup",
"priority": 1,
"status": "pending",
"notes": "Prior attempt failed (site was down 2026-04-13) — retry"
},
{
"name": "BBB (Better Business Bureau)",
"url": "https://www.bbb.org/get-accredited",
"priority": 2,
"status": "pending",
"notes": "Accreditation is paid; free profile may exist — investigate"
},
{
"name": "411.ca",
"url": "https://411.ca/business/add",
"priority": 1,
"status": "pending",
"notes": "Major Canadian directory — high priority"
},
{
"name": "Yellow Pages (ypconnect.com)",
"url": "https://www.yellowpages.ca/",
"priority": 1,
"status": "skip",
"notes": "Already claimed via YP.ca — skip"
},
{
"name": "Google Business Profile",
"url": "https://business.google.com/",
"priority": 1,
"status": "live",
"public_listing_url": "https://maps.google.com/?cid=...",
"notes": "Claimed; weekly GBP posts via social-media-poster"
},
{
"name": "Apple Business Connect",
"url": "https://businessconnect.apple.com/",
"priority": 1,
"status": "live",
"notes": "Claimed 2026-04-15; About + Good to Know complete"
},
{
"name": "Manta",
"url": "https://www.manta.com/",
"priority": 2,
"status": "live",
"notes": "Claimed, 100% profile complete"
},
{
"name": "Foursquare",
"url": "https://foursquare.com/",
"priority": 2,
"status": "live"
},
{
"name": "TrustedPros",
"url": "https://trustedpros.ca/",
"priority": 2,
"status": "live"
},
{
"name": "N49",
"url": "https://www.n49.com/",
"priority": 2,
"status": "live"
},
{
"name": "Pinterest",
"url": "https://business.pinterest.com/",
"priority": 3,
"status": "live"
},
{
"name": "Medium",
"url": "https://medium.com/@renostars",
"priority": 3,
"status": "live"
},
{
"name": "Nextdoor",
"url": "https://business.nextdoor.com/",
"priority": 2,
"status": "live",
"notes": "Have account; verify listing"
}
]
}

View File

@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* _generic.cjs one-size-fits-some directory submitter.
*
* Usage:
* node _generic.cjs <url> <profile-json-path>
*
* Exits 0 with a JSON object on the final stdout line:
* {"status": "live"|"pending_email_verify"|"pending_human"|"failed",
* "reason": "<short>", "public_url": "<url|null>"}
*
* Strategy: fetch the add-business URL, pattern-match form fields by name/
* placeholder/aria-label, fill what we can from business-profile.json, click
* the most-prominent submit button, then screenshot + classify the response.
*
* Never retries. Never spams. One attempt per invocation. If the generic
* shape doesn't match, we escalate to pending_human so a per-site adapter
* can be written later from the screenshot.
*/
const { connect } = require('/configs/plugins/browser-automation/skills/browser-automation/lib/connect');
const fs = require('fs');
const path = require('path');
const URL = process.argv[2];
const PROFILE_PATH = process.argv[3] || '/configs/business-profile.json';
if (!URL) {
console.error(JSON.stringify({ status: 'failed', reason: 'missing url arg' }));
process.exit(1);
}
const log = (m) => console.log(`[${new Date().toISOString().substring(11, 19)}] ${m}`);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// Field synonyms — lowercase substrings we try against (name, id, placeholder,
// aria-label, neighboring label text). Order matters for deduplication.
const FIELDS = [
{ key: 'business_name', patterns: ['business name', 'company name', 'company', 'business', 'name of business'] },
{ key: 'first_name', patterns: ['first name', 'firstname', 'given name'] },
{ key: 'last_name', patterns: ['last name', 'lastname', 'surname', 'family name'] },
{ key: 'full_name', patterns: ['full name', 'your name', 'contact name'] },
{ key: 'email', patterns: ['email', 'e-mail'] },
{ key: 'phone', patterns: ['phone', 'telephone', 'mobile', 'tel'] },
{ key: 'website', patterns: ['website', 'url', 'web address', 'web'] },
{ key: 'address', patterns: ['street address', 'street', 'address line', 'address'] },
{ key: 'city', patterns: ['city', 'town'] },
{ key: 'province', patterns: ['province', 'state', 'region'] },
{ key: 'postal', patterns: ['postal', 'zip', 'post code'] },
{ key: 'country', patterns: ['country'] },
{ key: 'category', patterns: ['category', 'industry', 'business type', 'services'] },
{ key: 'description', patterns: ['description', 'about', 'bio', 'details'] },
{ key: 'password', patterns: ['password', 'choose password'] },
{ key: 'password_confirm', patterns: ['confirm password', 're-enter password', 'password again'] },
];
async function classifyInputs(page) {
// Snapshot every input/textarea/select with its discoverable label.
return page.evaluate(() => {
const descLabel = (el) => {
const byAria = el.getAttribute('aria-label');
if (byAria) return byAria;
const byId = el.id ? document.querySelector(`label[for="${el.id}"]`) : null;
if (byId) return byId.innerText;
// walk back up for an enclosing <label>
let cur = el;
for (let i = 0; i < 4 && cur; i++) {
if (cur.tagName === 'LABEL') return cur.innerText;
cur = cur.parentElement;
}
return '';
};
return [...document.querySelectorAll('input, textarea, select')]
.filter((el) => el.offsetParent !== null)
.filter((el) => !['hidden', 'submit', 'button'].includes(el.type))
.map((el) => {
const r = el.getBoundingClientRect();
return {
tag: el.tagName,
type: el.type,
name: el.name || '',
id: el.id || '',
placeholder: el.placeholder || '',
label: descLabel(el),
required: !!el.required,
x: Math.round(r.x),
y: Math.round(r.y),
};
});
});
}
function matchField(input) {
const haystack = [input.name, input.id, input.placeholder, input.label]
.join(' | ')
.toLowerCase();
for (const f of FIELDS) {
if (f.patterns.some((p) => haystack.includes(p))) return f.key;
}
return null;
}
async function run() {
if (!fs.existsSync(PROFILE_PATH)) {
console.log(JSON.stringify({ status: 'failed', reason: `profile not found at ${PROFILE_PATH}` }));
process.exit(0);
}
const p = JSON.parse(fs.readFileSync(PROFILE_PATH, 'utf8'));
const directoryPassword = p.directory_password || process.env.DIRECTORY_PASSWORD;
if (!directoryPassword) {
console.log(JSON.stringify({ status: 'failed', reason: 'directory_password not set in business-profile.json and DIRECTORY_PASSWORD env not set' }));
process.exit(0);
}
const creds = { email: p.email, password: directoryPassword };
const values = {
business_name: p.name,
first_name: p.contact?.first_name || p.name,
last_name: p.contact?.last_name || '',
full_name: p.contact?.full_name || p.name,
email: creds.email,
phone: p.phone,
website: p.website,
address: p.address?.street || '',
city: p.address?.city || '',
province: p.address?.province || '',
postal: p.address?.postal || '',
country: p.address?.country || 'Canada',
category: (p.categories_primary || 'General Contractor'),
description: p.description_short || p.description_long || '',
password: creds.password,
password_confirm: creds.password,
};
const browser = await connect();
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 960, deviceScaleFactor: 1 });
page.on('dialog', async (d) => { await d.dismiss(); });
try {
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 40000 });
} catch (e) {
// networkidle2 often times out on sites with long-poll or ads; proceed anyway
log(`goto finished with ${e.code || e.message}`);
}
await wait(3500);
const inputs = await classifyInputs(page);
log(`saw ${inputs.length} visible fields`);
if (inputs.length === 0) {
const shot = `/tmp/citation-${Date.now()}.png`;
await page.screenshot({ path: shot });
await browser.disconnect();
console.log(JSON.stringify({ status: 'pending_human', reason: 'no visible form inputs', screenshot: shot }));
return;
}
// Fill first input matching each canonical key (don't double-fill)
const filled = {};
for (const inp of inputs) {
const key = matchField(inp);
if (!key || filled[key]) continue;
const v = values[key];
if (!v) continue;
try {
// Click first to ensure focus, then type
await page.mouse.click(inp.x + 10, inp.y + 10);
await wait(80);
// Clear any prefill
await page.keyboard.down('Meta');
await page.keyboard.press('a');
await page.keyboard.up('Meta');
await page.keyboard.press('Backspace');
await wait(60);
await page.keyboard.type(String(v), { delay: 15 });
filled[key] = true;
} catch (e) {
log(`fill ${key}: ${e.message}`);
}
}
log(`filled: ${Object.keys(filled).join(', ')}`);
await wait(600);
// Submit — pick the most prominent enabled button matching our keyword set
const submitted = await page.evaluate(() => {
const kw = /(submit|continue|register|sign.?up|get.?started|add.my.business|add.your.business|list.my.business|create.account|save)/i;
const btns = [...document.querySelectorAll('button, input[type="submit"]')]
.filter((b) => b.offsetParent !== null && !b.disabled);
const matches = btns.filter((b) => kw.test((b.textContent || b.value || '').trim()));
const pick = (matches.length ? matches : btns)[0];
if (!pick) return null;
const label = (pick.textContent || pick.value || '').trim();
pick.click();
return label;
});
log(`submit: ${submitted}`);
if (!submitted) {
const shot = `/tmp/citation-${Date.now()}.png`;
await page.screenshot({ path: shot });
await browser.disconnect();
console.log(JSON.stringify({ status: 'pending_human', reason: 'no submit button found', screenshot: shot, filled: Object.keys(filled) }));
return;
}
await wait(7000);
const shot = `/tmp/citation-${Date.now()}.png`;
await page.screenshot({ path: shot });
const body = await page.evaluate(() => document.body?.innerText?.substring(0, 1500) || '');
await browser.disconnect();
if (/captcha|robot|are you human|cloudflare/i.test(body)) {
console.log(JSON.stringify({ status: 'pending_human', reason: 'captcha or bot challenge', screenshot: shot }));
return;
}
if (/verify your email|check your email|confirmation email|activation email|verification link/i.test(body)) {
console.log(JSON.stringify({ status: 'pending_email_verify', reason: 'awaiting email verification', screenshot: shot }));
return;
}
if (/thank you|successfully|listing is (now )?(live|active|published)|business (has been )?submitted|profile (created|saved)/i.test(body)) {
console.log(JSON.stringify({ status: 'live', reason: 'success page shown', screenshot: shot }));
return;
}
console.log(JSON.stringify({ status: 'pending_human', reason: 'unknown post-submit state', screenshot: shot }));
}
run().catch((e) => {
console.error(e);
console.log(JSON.stringify({ status: 'failed', reason: e.message || String(e) }));
process.exit(0);
});

View File

@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* run.cjs pick the next pending directory from queue.json, attempt submission,
* try to verify-via-Gmail if needed, update queue + log, exit.
*
* This is the entrypoint the Marketing Leader agent invokes via the
* citation-builder skill. One run = one directory attempt.
*
* Usage:
* node run.cjs [--dry-run]
*
* Writes:
* /configs/skills/citation-builder/queue.json (status updates)
* /configs/skills/citation-builder/log.jsonl (append-only run log)
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const SKILL_DIR = path.dirname(__dirname); // resolves to skills/citation-builder/
const QUEUE_PATH = path.join(SKILL_DIR, 'queue.json');
const LOG_PATH = path.join(SKILL_DIR, 'log.jsonl');
const PROFILE_PATH = '/configs/business-profile.json';
const SCRIPTS = __dirname; // this file's dir
const DRY = process.argv.includes('--dry-run');
const log = (m) => console.log(`[${new Date().toISOString().substring(11, 19)}] ${m}`);
function loadQueue() {
return JSON.parse(fs.readFileSync(QUEUE_PATH, 'utf8'));
}
function saveQueue(q) {
fs.writeFileSync(QUEUE_PATH, JSON.stringify(q, null, 2));
}
function appendLog(obj) {
fs.appendFileSync(LOG_PATH, JSON.stringify(obj) + '\n');
}
function adapterFor(entry) {
const slug = entry.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const specific = path.join(SCRIPTS, `${slug}.cjs`);
return fs.existsSync(specific) ? specific : path.join(SCRIPTS, '_generic.cjs');
}
function runAdapter(adapter, url) {
log(`adapter ${path.basename(adapter)}${url}`);
const r = spawnSync('node', [adapter, url, PROFILE_PATH], {
encoding: 'utf8',
timeout: 5 * 60 * 1000,
});
if (r.error) return { status: 'failed', reason: r.error.message };
const tail = (r.stdout || '').trim().split('\n').pop();
try { return JSON.parse(tail); } catch { return { status: 'failed', reason: 'adapter did not emit JSON', raw: tail?.substring(0, 200) }; }
}
function runEmailVerify(senderDomain) {
const r = spawnSync('node', [path.join(SCRIPTS, 'verify-email-link.cjs'), senderDomain], {
encoding: 'utf8', timeout: 2 * 60 * 1000,
});
const tail = (r.stdout || '').trim().split('\n').pop();
try { return JSON.parse(tail); } catch { return { status: 'failed', reason: 'verify script did not emit JSON' }; }
}
(function main() {
const q = loadQueue();
// Skip status=live, skip, failed-already. Take first pending.
const next = q.entries.find((e) => e.status === 'pending');
if (!next) {
log('queue exhausted — nothing pending');
appendLog({ ts: new Date().toISOString(), kind: 'queue_exhausted' });
return;
}
log(`→ attempting ${next.name} (${next.url})`);
if (DRY) { log('DRY RUN — no submit'); return; }
const t0 = Date.now();
const result = runAdapter(adapterFor(next), next.url);
next.last_attempt = new Date().toISOString();
next.status = result.status;
if (result.reason) next.last_reason = result.reason;
if (result.screenshot) next.last_screenshot = result.screenshot;
// Auto-attempt Gmail verify if adapter said pending_email_verify
if (result.status === 'pending_email_verify') {
const sender = new URL(next.url).hostname.replace(/^(www\.|admin\.)/, '');
log(`trying gmail verify for sender ${sender}`);
const v = runEmailVerify(sender);
log(`verify result: ${JSON.stringify(v)}`);
if (v.status === 'verified') {
next.status = 'verified_pending_listing';
next.last_reason = 'email link clicked; listing may need more steps';
}
}
appendLog({
ts: new Date().toISOString(),
kind: 'attempt',
directory: next.name,
url: next.url,
duration_ms: Date.now() - t0,
result,
});
saveQueue(q);
log(`done — ${next.name}: ${next.status}`);
})();

View File

@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* verify-email-link.cjs click the verification link in a just-arrived email.
*
* Usage:
* node verify-email-link.cjs <sender-domain>
* e.g. "hotfrog.ca" or "tupalo.com"
*
* Uses the Chrome profile's logged-in Gmail. Searches the inbox for a recent
* message `from:<sender-domain>` (last 15 min window), opens the most recent,
* and clicks the first link whose text matches /verify|confirm|activate/i.
*
* Exits 0 with JSON on final stdout line:
* {"status": "verified"|"no_email"|"no_link"|"failed", "reason": "..."}
*
* NEVER clicks arbitrary links. NEVER reads unrelated email bodies. Scoped
* strictly to the sender-domain passed in.
*/
const { connect } = require('/configs/plugins/browser-automation/skills/browser-automation/lib/connect');
const SENDER = process.argv[2];
if (!SENDER) {
console.log(JSON.stringify({ status: 'failed', reason: 'missing sender-domain arg' }));
process.exit(0);
}
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 connect();
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 960, deviceScaleFactor: 1 });
const query = `from:${SENDER} newer_than:1h`;
const url = `https://mail.google.com/mail/u/0/#search/${encodeURIComponent(query)}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
await wait(5000);
// Open first thread if any
const opened = await page.evaluate(() => {
const row = document.querySelector('tr.zA');
if (!row) return false;
row.click();
return true;
});
if (!opened) {
await browser.disconnect();
console.log(JSON.stringify({ status: 'no_email', reason: `no messages from ${SENDER} in last hour` }));
return;
}
await wait(3500);
// Find a verification link in the open message
const link = await page.evaluate(() => {
const links = [...document.querySelectorAll('a[href]')].filter((a) => a.offsetParent !== null);
const kw = /verify|confirm|activate|complete.your.registration|validate/i;
const hit = links.find((a) => kw.test(a.textContent || '') || kw.test(a.href || ''));
return hit ? hit.href : null;
});
if (!link) {
await browser.disconnect();
console.log(JSON.stringify({ status: 'no_link', reason: 'message open but no verify/confirm link found' }));
return;
}
log(`verify link: ${link}`);
const verifyPage = await browser.newPage();
await verifyPage.goto(link, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
await wait(4000);
const body = await verifyPage.evaluate(() => document.body?.innerText?.substring(0, 800) || '');
await browser.disconnect();
if (/verified|activated|confirmed|success|thank you/i.test(body)) {
console.log(JSON.stringify({ status: 'verified', reason: 'activation page shown' }));
} else {
console.log(JSON.stringify({ status: 'verified', reason: 'link opened, response ambiguous', body: body.substring(0, 200) }));
}
})().catch((e) => {
console.log(JSON.stringify({ status: 'failed', reason: e.message || String(e) }));
process.exit(0);
});

View File

@ -98,6 +98,10 @@ workspaces:
cron_expr: "30 */6 * * *"
prompt: Read /configs/skills/social-media-engage.md and follow its instructions.
enabled: true
- name: Citation Builder (daily 7:30 AM)
cron_expr: "30 7 * * *"
prompt: Read /configs/skills/citation-builder/SKILL.md and follow its instructions — one directory per run.
enabled: true
# ─── SALES & CLIENT RELATIONS (invoices + leads) ──────────
- name: Sales & Client Relations