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:
parent
9b08c34707
commit
1fb9712fa4
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -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}`);
|
||||
})();
|
||||
@ -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);
|
||||
});
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user