From 1fb9712fa4a10e91180603d83b7d5b974740ed24 Mon Sep 17 00:00:00 2001 From: airenostars Date: Wed, 15 Apr 2026 19:47:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(reno-stars):=20citation-builder=20?= =?UTF-8?q?=E2=80=94=20one=20backlink=20directory=20per=20day=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #301 Co-authored-by: airenostars --- .../reno-stars/marketing-leader/CLAUDE.md | 4 + .../skills/citation-builder/SKILL.md | 121 +++++++++ .../skills/citation-builder/queue.json | 173 +++++++++++++ .../citation-builder/scripts/_generic.cjs | 233 ++++++++++++++++++ .../skills/citation-builder/scripts/run.cjs | 106 ++++++++ .../scripts/verify-email-link.cjs | 81 ++++++ org-templates/reno-stars/org.yaml | 4 + 7 files changed, 722 insertions(+) create mode 100644 org-templates/reno-stars/marketing-leader/skills/citation-builder/SKILL.md create mode 100644 org-templates/reno-stars/marketing-leader/skills/citation-builder/queue.json create mode 100755 org-templates/reno-stars/marketing-leader/skills/citation-builder/scripts/_generic.cjs create mode 100755 org-templates/reno-stars/marketing-leader/skills/citation-builder/scripts/run.cjs create mode 100755 org-templates/reno-stars/marketing-leader/skills/citation-builder/scripts/verify-email-link.cjs diff --git a/org-templates/reno-stars/marketing-leader/CLAUDE.md b/org-templates/reno-stars/marketing-leader/CLAUDE.md index cbda6800..b7e721ac 100644 --- a/org-templates/reno-stars/marketing-leader/CLAUDE.md +++ b/org-templates/reno-stars/marketing-leader/CLAUDE.md @@ -29,5 +29,9 @@ node org-templates/reno-stars/marketing-leader/skills/social-publish/scripts/.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/.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:`, 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/.cjs` that: + +- Uses `lib/connect.js` from the `browser-automation` plugin (never + `puppeteer.launch()` or raw `puppeteer.connect({defaultViewport:})`). +- 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. diff --git a/org-templates/reno-stars/marketing-leader/skills/citation-builder/queue.json b/org-templates/reno-stars/marketing-leader/skills/citation-builder/queue.json new file mode 100644 index 00000000..f6952fd7 --- /dev/null +++ b/org-templates/reno-stars/marketing-leader/skills/citation-builder/queue.json @@ -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" + } + ] +} diff --git a/org-templates/reno-stars/marketing-leader/skills/citation-builder/scripts/_generic.cjs b/org-templates/reno-stars/marketing-leader/skills/citation-builder/scripts/_generic.cjs new file mode 100755 index 00000000..d5342b0c --- /dev/null +++ b/org-templates/reno-stars/marketing-leader/skills/citation-builder/scripts/_generic.cjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * _generic.cjs — one-size-fits-some directory submitter. + * + * Usage: + * node _generic.cjs + * + * Exits 0 with a JSON object on the final stdout line: + * {"status": "live"|"pending_email_verify"|"pending_human"|"failed", + * "reason": "", "public_url": ""} + * + * 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