diff --git a/.claude/commands/auto-post-cycle.md b/.claude/commands/auto-post-cycle.md new file mode 100644 index 0000000..97441a7 --- /dev/null +++ b/.claude/commands/auto-post-cycle.md @@ -0,0 +1,3 @@ +Read and follow the instructions in skills/auto-post-cycle.md + +The user's arguments are: $ARGUMENTS diff --git a/CLAUDE.md b/CLAUDE.md index a5f91c5..bdfa4a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,12 +26,16 @@ RealRate/ │ ├── deep-dive.md ← Build a deep dive LinkedIn document │ ├── content-calendar.md ← Plan a content calendar (company page or Holger) │ ├── outreach-campaign.md ← Write Instantly campaigns or LinkedIn DM sequences -│ └── market-research.md ← Run competitor or market research +│ ├── market-research.md ← Run competitor or market research +│ └── auto-post-cycle.md ← Generate full bizdev cycle (all 8 post types) + upload to dashboard │ └── SOP/ ← Multi-step process references ├── ranking-publication-protocol.md ← Full ranking launch sequence (Day 0 → Day +10) ├── outreach-sop.md ← LinkedIn DM + Instantly detailed process └── research-sop.md ← Market and competitor research process +│ +├── scripts/ ← Automation scripts +│ └── pull_seal_posts.py ← Download seal post images + captions from Google Drive ``` --- @@ -57,6 +61,7 @@ RealRate/ | Plan a content calendar | `skills/content-calendar.md` | — | | Write an Instantly campaign or sequence | `skills/outreach-campaign.md` | — | | Run competitor or market research | `skills/market-research.md` | — | +| Generate full bizdev cycle + upload | `skills/auto-post-cycle.md` | All skills + context (auto-loaded) | | Full ranking launch | `SOP/ranking-publication-protocol.md` | `skills/insight-post.md` · `skills/deep-dive.md` | --- diff --git a/scripts/pull_seal_posts.py b/scripts/pull_seal_posts.py new file mode 100644 index 0000000..e7dbb25 --- /dev/null +++ b/scripts/pull_seal_posts.py @@ -0,0 +1,218 @@ +"""Download seal post images and captions from Google Drive. + +Usage: + python scripts/pull_seal_posts.py --industry us_air --output-dir output/us_air/2026 + +Authenticates with a Google service account and downloads matching files +from the shared seal posts Drive folder. + +Environment: + GOOGLE_SERVICE_ACCOUNT_KEY — path to service account JSON key file, + or the raw JSON content itself +""" +import argparse +import io +import json +import os +import re +import sys + +# # Drive folder ID containing seal post assets +DRIVE_FOLDER_ID = "1i1m4hsmOKzYrXrsPYxGrtuwMMOrQHH2T" + + +def get_credentials(): + """Load Google service account credentials from env var.""" + key_source = os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY", "") + if not key_source: + print("ERROR: GOOGLE_SERVICE_ACCOUNT_KEY not set") + sys.exit(1) + + from google.oauth2 import service_account + + # # If it's a file path, load from file; otherwise parse as raw JSON + if os.path.isfile(key_source): + return service_account.Credentials.from_service_account_file( + key_source, + scopes=["https://www.googleapis.com/auth/drive.readonly"], + ) + else: + info = json.loads(key_source) + return service_account.Credentials.from_service_account_info( + info, + scopes=["https://www.googleapis.com/auth/drive.readonly"], + ) + + +def build_drive_service(credentials): + """Build Google Drive API v3 service.""" + from googleapiclient.discovery import build + return build("drive", "v3", credentials=credentials) + + +def list_folder_contents(service, folder_id): + """List all files in a Drive folder (non-recursive).""" + files = [] + page_token = None + while True: + response = service.files().list( + q="'%s' in parents and trashed = false" % folder_id, + fields="nextPageToken, files(id, name, mimeType)", + pageToken=page_token, + pageSize=100, + ).execute() + files.extend(response.get("files", [])) + page_token = response.get("nextPageToken") + if not page_token: + break + return files + + +def download_file(service, file_id): + """Download a file's content from Drive.""" + from googleapiclient.http import MediaIoBaseDownload + request = service.files().get_media(fileId=file_id) + buffer = io.BytesIO() + downloader = MediaIoBaseDownload(buffer, request) + done = False + while not done: + _, done = downloader.next_chunk() + buffer.seek(0) + return buffer.read() + + +def slug_from_industry(industry): + """Convert industry slug to human-readable folder name patterns. + + us_air → ['US Air', 'us_air', 'US_Air', 'Air'] + """ + clean = industry.replace("us_", "") + return [ + "US " + clean.title(), + industry, + "US_" + clean.title(), + clean.title(), + clean, + ] + + +def find_subfolder(files, industry): + """Find a subfolder matching the industry name (Layout A).""" + patterns = slug_from_industry(industry) + for f in files: + if f["mimeType"] == "application/vnd.google-apps.folder": + for pattern in patterns: + if f["name"].lower() == pattern.lower(): + return f + return None + + +def match_flat_files(files, industry): + """Match files by prefix naming convention (Layout B).""" + prefix_patterns = slug_from_industry(industry) + matched = [] + for f in files: + if f["mimeType"] == "application/vnd.google-apps.folder": + continue + name_lower = f["name"].lower() + for prefix in prefix_patterns: + if name_lower.startswith(prefix.lower() + "_"): + matched.append(f) + break + return matched + + +def extract_company_slug(filename, industry): + """Extract company slug from seal post filename. + + seal_company_name.png → company_name + us_air_company_name_seal.png → company_name + """ + # # Pattern: seal_.png + m = re.match(r"seal_(.+)\.png$", filename, re.IGNORECASE) + if m: + return m.group(1).lower().replace(" ", "_") + + # # Pattern: __seal.png + for prefix in slug_from_industry(industry): + pattern = re.escape(prefix) + r"_(.+?)_seal\.png$" + m = re.match(pattern, filename, re.IGNORECASE) + if m: + return m.group(1).lower().replace(" ", "_") + + # # Fallback: strip extension and use full name + base = os.path.splitext(filename)[0].lower().replace(" ", "_") + return base + + +def main(): + parser = argparse.ArgumentParser(description="Download seal posts from Google Drive") + parser.add_argument("--industry", required=True, help="Industry slug e.g. us_air") + parser.add_argument("--output-dir", required=True, help="Directory to save downloaded files") + args = parser.parse_args() + + os.makedirs(args.output_dir, exist_ok=True) + + credentials = get_credentials() + service = build_drive_service(credentials) + + print("Listing Drive folder contents...") + root_files = list_folder_contents(service, DRIVE_FOLDER_ID) + + # # Try Layout A: look for a subfolder matching the industry + subfolder = find_subfolder(root_files, args.industry) + if subfolder: + print("Found subfolder: %s" % subfolder["name"]) + seal_files = list_folder_contents(service, subfolder["id"]) + else: + # # Layout B: match files by prefix in the root folder + print("No subfolder found, trying flat file matching...") + seal_files = match_flat_files(root_files, args.industry) + + if not seal_files: + print("No seal post files found for industry: %s" % args.industry) + print(json.dumps({"downloaded": []})) + return + + # # Separate images and captions + images = [f for f in seal_files if f["name"].lower().endswith((".png", ".jpg", ".jpeg"))] + captions = [f for f in seal_files if f["name"].lower().endswith(".txt")] + + downloaded = [] + + for img_file in images: + company_slug = extract_company_slug(img_file["name"], args.industry) + print("Downloading: %s → seal_%s.png" % (img_file["name"], company_slug)) + + # # Download image + img_data = download_file(service, img_file["id"]) + img_out = os.path.join(args.output_dir, "seal_%s.png" % company_slug) + with open(img_out, "wb") as f: + f.write(img_data) + + # # Find matching caption file + caption_out = os.path.join(args.output_dir, "seal_%s_caption.txt" % company_slug) + caption_found = False + for cap_file in captions: + cap_name = cap_file["name"].lower() + if company_slug.replace("_", " ") in cap_name or company_slug in cap_name: + print(" Caption: %s" % cap_file["name"]) + cap_data = download_file(service, cap_file["id"]) + with open(caption_out, "wb") as f: + f.write(cap_data) + caption_found = True + break + + downloaded.append({ + "company": company_slug, + "image": img_out, + "caption": caption_out if caption_found else None, + }) + + print("\nDownloaded %d seal post(s)" % len(downloaded)) + # # Print JSON summary for the orchestrator to parse + print(json.dumps({"downloaded": downloaded})) + + +if __name__ == "__main__": + main() diff --git a/skills/auto-post-cycle.md b/skills/auto-post-cycle.md new file mode 100644 index 0000000..dd043f1 --- /dev/null +++ b/skills/auto-post-cycle.md @@ -0,0 +1,213 @@ +--- +description: Generate and upload LinkedIn content for a full bizdev cycle +arguments: industry_slug year [task1 task2 ...] +--- + +# Auto-Post Cycle — Orchestrator Skill + +Generate all (or selected) LinkedIn deliverables for an industry/year and upload them as drafts to the LinkedIn Scheduler dashboard. + +## Arguments + +- `$ARGUMENTS` is parsed as: `{industry_slug} {year} [task1 task2 ...]` +- `industry_slug` (required) — e.g., `us_air`, `us_construction` +- `year` (required) — marketing year (balance sheet year = year - 1) +- Tasks (optional) — space-separated task IDs. Defaults to all. `insights` expands to `insight1 insight2 insight3 insight4`. + +Valid task IDs: `top10`, `article`, `seal`, `insight1`, `insight2`, `insight3`, `insight4`, `deepdive`, `insights`, `all` + +## Step 0 — Pull Latest Skills + +```bash +cd C:\Users\User\Claude && git pull +``` + +This ensures all skill files, context files, and design system rules are up to date. + +## Step 1 — Parse Arguments and Set Up Output Directory + +Parse `$ARGUMENTS` into `industry_slug`, `year`, and `selected_tasks`. +- If no tasks specified, select all 8: `top10 article seal insight1 insight2 insight3 insight4 deepdive` +- If `insights` is specified, expand to `insight1 insight2 insight3 insight4` +- If `all` is specified, select all 8 + +Create output directory: +``` +C:\Users\User\Claude\output\{industry_slug}\{year}\ +``` + +Set variables: +- `archive_slug` = `industry_slug` with `us_` prefix removed (e.g., `us_air` → `air`) +- `bsy` = `year - 1` (balance sheet year) +- `ranking_url` = `https://realrate-archive.com/{archive_slug}/{bsy}/website-ranking.json` +- `prior_url` = `https://realrate-archive.com/{archive_slug}/{bsy - 1}/website-ranking.json` +- `output_dir` = `C:\Users\User\Claude\output\{industry_slug}\{year}` + +## Step 2 — Fetch Ranking Data + +Fetch the ranking JSON: +``` +GET {ranking_url} +``` + +Parse and extract: +- Full company list with rank, ECR value, trend, top_rated flag, report_text +- List of top-rated companies (where `top_rated === true`) +- Industry name from the data + +If `insight2` is in selected tasks, also fetch `{prior_url}` for YoY comparison. + +**If fetch fails, STOP the pipeline and report the error.** Ranking data is required for all tasks. + +## Step 3 — Check Angle History (if generating insight3 or insight4) + +If `insight3` or `insight4` is in selected tasks: + +```bash +curl -H "Authorization: Bearer $ADMIN_SECRET" \ + "https://realrate-linkedin-scheduler.vercel.app/api/angles?slug={industry_slug}" +``` + +Parse the response to find angles used in the last 2 cycles. These angles are **excluded** from selection. + +## Step 4 — Generate Each Selected Task + +For each task, load the relevant skill file and follow its exact instructions. Do NOT hardcode captions or design rules — they come from the skill files. + +### Task: `top10` +1. Read `skills/top10ranking.md` +2. Run: `python generate_infographic.py {archive_slug}` +3. Copy output to `{output_dir}/top10_{industry_slug}_{year}.png` +4. Write caption following the template in `top10ranking.md` → save to `{output_dir}/top10_caption.txt` + +### Task: `article` +1. Read `skills/linkedin-post.md` + `context/brand-core.md` + `context/brand-voice.md` + `context/design-system.md` +2. Build HTML following the design system (1200×1200px canvas, Manrope font, dark background) +3. Save HTML to `{output_dir}/article_announce.html` +4. Export: `node posts/export.mjs` → `{output_dir}/article_announce.png` +5. Write caption using the storytelling formula → save to `{output_dir}/article_announce_caption.txt` + +### Task: `seal` +1. Run the Drive download script: + ```bash + python scripts/pull_seal_posts.py --industry {industry_slug} --output-dir {output_dir} + ``` +2. For each downloaded seal post: + - Read the existing caption from `seal_{company}_caption.txt` + - Web search for the company's CEO and CFO LinkedIn profiles + - Append C-level mentions to the caption: `cc @FirstName LastName — CEO, @FirstName LastName — CFO` + - Save updated caption back to `seal_{company}_caption.txt` +3. If Drive download fails (no `GOOGLE_SERVICE_ACCOUNT_KEY`), skip seal posts and report the error. + +### Task: `insight1` +1. Read `skills/insight-post.md` (Insight 1 section) + all context files +2. Select an angle from the Insight 1 angle bank: **Reputable NTR**, **Unknown TR**, or **The Surprise** +3. Pick a company that fits the angle. Verify its ECR data at `realrate-archive.com/{archive_slug}/{bsy}/` +4. Build HTML using the L4 layout from `design-system.md` +5. Save HTML to `{output_dir}/insight1_{company_slug}.html` +6. Export PNG: `node posts/export.mjs` → `{output_dir}/insight1_{company_slug}.png` +7. Write caption per the approved Insight 1 structure → save to `{output_dir}/insight1_caption.txt` + +### Task: `insight2` +1. Read `skills/insight-post.md` (Insight 2 section) + all context files +2. Choose: **Industry-Wide** or **Company-Level YoY Shift** based on the data (compare current vs prior year) +3. Build up to 3 HTML slides using the L2 layout +4. Save as `{output_dir}/insight2_slide1_{slug}.html`, `insight2_slide2_{slug}.html`, etc. +5. Export each slide to PNG +6. Write one caption for all slides → save to `{output_dir}/insight2_caption.txt` + +### Task: `insight3` +1. Read `skills/insight-post.md` (Insight 3 section) + all context files +2. Select a **rotating angle** from the bank: Industry Blind Spot, Market vs Balance Sheet, Industry Trend, Methodology Moment, Investor Signal, Macro Lens +3. **Exclude** angles used in the last 2 cycles (from Step 3 angle history) +4. Build HTML (L4 layout), export PNG +5. Save to `{output_dir}/insight3_{angle_slug}.html` and `.png` +6. Write caption → save to `{output_dir}/insight3_caption.txt` + +### Task: `insight4` +1. Read `skills/insight-post.md` (Insight 4 section) + all context files +2. Select a **rotating NTR angle** from the bank: Almost There, The One Thing, The Fallen, Sector Drag, Close But Not Rated, Due Diligence Gap +3. **Exclude** angles used in the last 2 cycles (from Step 3 angle history) +4. Build HTML, export PNG +5. Save to `{output_dir}/insight4_{angle_slug}.html` and `.png` +6. Write caption with mandatory ECR disclaimer → save to `{output_dir}/insight4_caption.txt` + +### Task: `deepdive` +1. Read `skills/deep-dive.md` + all context files +2. Build 8–10 slide PDF: cover → snapshot → top tier → biggest mover → surprise → warning → separators → implications → methodology → CTA +3. Save to `{output_dir}/deepdive.pdf` +4. Write caption → save to `{output_dir}/deepdive_caption.txt` + +## Step 5 — Save Angle Selections (if insight3 or insight4 was generated) + +If insight3 or insight4 was generated, save the selected angles to the dashboard: + +```bash +curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"industrySlug":"{industry_slug}","year":{year},"angles":{"insight3":"{selected_insight3_angle}","insight4":"{selected_insight4_angle}"}}' \ + https://realrate-linkedin-scheduler.vercel.app/api/angles +``` + +Only include the angles that were actually generated (e.g., if only insight3 was requested, only send insight3). + +## Step 6 — Upload to Dashboard + +Run the upload script: + +```bash +python C:\Users\User\realrate-linkedin-scheduler\upload_to_scheduler.py \ + --industry {industry_slug} --year {year} \ + --output-dir {output_dir} --token $ADMIN_SECRET +``` + +The script will find all generated files, encode them, and upload as drafts. + +## Step 7 — Summary + +Print a summary table: + +``` +╔══════════════════════════════════════════════╗ +║ Auto-Post Cycle Complete ║ +╠══════════════════════════════════════════════╣ +║ Industry: U.S. Air ║ +║ Year: 2026 ║ +║ Output: C:\Users\User\Claude\output\... ║ +╠══════════════════════════════════════════════╣ +║ ✓ top10 — Top 10 Ranking ║ +║ ✓ article — Article Announcement ║ +║ ✓ seal (3) — Seal Posts ║ +║ ✓ insight1 — Company Editorial ║ +║ ✓ insight2 — Industry YoY (2 slides) ║ +║ ✓ insight3 — Macro Lens ║ +║ ✓ insight4 — The Fallen ║ +║ ✓ deepdive — Deep Dive Document ║ +╠══════════════════════════════════════════════╣ +║ Uploaded: 11 drafts ║ +║ Dashboard: realrate-linkedin-scheduler... ║ +╚══════════════════════════════════════════════╝ +``` + +Report any skipped tasks and errors. + +**Remind:** "Review drafts in the dashboard before approving for publish." + +## Error Handling + +- **Ranking data fetch fails:** STOP — data is required for all tasks +- **Individual task fails:** Skip that task, continue with remaining tasks, report in summary +- **Drive not accessible:** Skip seal posts, report "Set up GOOGLE_SERVICE_ACCOUNT_KEY" +- **Puppeteer not installed:** Skip HTML-rendered tasks, report "Run `npm install` in `posts/`" +- **Upload fails:** Files persist in output folder — team can retry or upload manually +- **C-level search fails:** Keep caption without mentions, flag for manual review + +## Standing Rules (from brand-core.md) + +These rules apply to ALL generated captions and images: +- Never use hashtags (except Top 10 which uses exactly 8) +- Maximum 2 emoji per caption +- Never use "we" — always "RealRate" +- Ranking URL goes at the end of every caption +- The archive is internal only — never link directly to it +- Font minimum 18pt body, 46pt+ headlines (mobile readability)