Building a Service Page Content Generator

|

Billie Geena

Part 3 of the AI-Powered SEO Workflows series

Article 2 covered metadata — the compact, constrained stuff. Title tags and meta descriptions have hard character limits, clear structural rules, and a fairly narrow brief. They’re a good place to start because the AI either hits the target or it doesn’t, and you can check in seconds.

This article is different. We’re generating body copy — page-length content that has to sound like a real brand, reference a specific location, handle a full FAQ section, and be genuinely useful to someone who’s stressed about finding a reliable tradesperson. The brief is wider, the outputs are longer, and the gap between “technically correct” and “actually usable” is larger than it was for title tags.

A word on output quality before we start.

AI-generated body copy at this scale is not a replacement for a copywriter. What it is — at its best — is a very solid first draft that knows the brand, hits the structure, and handles the local detail. You will still need to read it, edit it, and make calls about what sounds right. For TradeLink’s location-service pages, a well-prompted run typically gets you something that’s 70–80% of the way to publishable. The remaining 20–30% is where human judgement lives. The FAQ answers tend to be the strongest section — factual, structured, and close to ready. The intro and body sections usually need a pass for sentence variety and to remove anything that sounds slightly off. Getting from a good AI draft to a publishable page is meaningfully faster than writing from scratch — but it’s not zero work, and I’d rather tell you that upfront.

We’re continuing to build for TradeLink — our imaginary UK platform connecting homeowners with vetted local tradespeople — and this article generates full service page content: an H1, an intro section, a quality standards section, a trust and guarantee section, and a 12-question FAQ with answers.

There’s also a new feature here that wasn’t in Article 2: a two-phase generation approach. Before generating the page content, the script runs a separate, lightweight call to generate local property context — specific knowledge about the housing stock, common trade issues, and local landmarks for the target city. That context then feeds into the main content prompt, which is what gives the output its local texture rather than generic copy-pasted fluff.


What We’re Building

  1. A two-phase generator — local context first, full page content second
  2. A wider sheet structure that holds all the content fields for a service page
  3. Prompts designed to produce structured, multi-field JSON output in one call
  4. A cleaner JSON enforcement pattern using Gemini’s responseMimeType setting
  5. Export to Google Doc — so you can get the output into a format editors can actually work with
  6. Toast notifications so long runs don’t feel like they’ve frozen
📌 One thing Article 2 promised that isn’t in this article:

The Prompt Config Sheet pattern — pulling prompts from a dedicated sheet tab so you can update them without touching the script. Both prompts in this article are still hardcoded functions. The reason is scope: this article is already introducing two-phase generation, a new API feature, and a Google Docs export. Adding the Config Sheet on top would make it significantly harder to follow. It will be properly introduced in Article 4, as part of the content judge — where the rubric criteria need to be editable without a code change.


A Note on the Model

We’re upgrading from gemini-2.0-flash to gemini-2.5-flash for this article. Generating a coherent page across six structured fields plus 12 FAQ answers requires more reasoning than writing a 155-character meta description. gemini-2.5-flash handles that reliably; gemini-2.0-flash can struggle with longer structured outputs and occasionally drops fields or loses coherence across a large JSON object. The API call structure is identical — you’re just changing the model string in CONFIG.

📌 A note on model names:

The Gemini model lineup moves quickly. If gemini-2.5-flash doesn’t appear in your Google AI Studio model list when you set this up, check Google’s model documentation for the current recommended mid-tier option. The code structure stays identical — you’re just swapping the string.


Setting Up Your Google Sheet

TL;DR

Much wider than Article 2. Input columns on the left, output columns in the middle, FAQ pairs after that, status at the end in column AJ.

Col Letter Header What Goes Here
1AURLFull page URL (input)
2BService Namee.g. “Plumbers”, “Electricians” (input)
3CLocation Namee.g. “Manchester”, “Leeds” (input)
4DAdditional ContextLocal property context — written by Phase 1, read by Phase 2
5–11E–KPage H1 → FAQ TitleAI-generated content fields (output)
12–35L–AIQ1–Q12 / A1–A12FAQ pairs — Q in even cols, A in odd (output)
36AJStatusScript writes progress here (output)

Column D is the interesting one. Phase 1 generates local property context and writes it here. Phase 2 reads from it as an input. The same column acts as an output in Phase 1 and an input in Phase 2 — which means you can manually edit the context before running Phase 2 if you want to steer the local detail yourself.


The Config Block

/**
 * TradeLink SEO — Service Page Content Generator
 * @OnlyCurrentDoc
 */

const CONFIG = {
  sheetName:      'Sheet1',
  apiKey:         PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY'),
  modelName:      'gemini-2.5-flash',
  // Google Drive folder ID — find in the URL when you open the destination folder.
  // drive.google.com/drive/folders/YOUR_FOLDER_ID_HERE
  exportFolderId: 'YOUR_FOLDER_ID_HERE',
  cols: {
    url:             1,  standardsBody:   8,
    serviceName:     2,  trustHeader:     9,
    locationName:    3,  trustBody:       10,
    localContext:    4,  faqTitle:        11,
    pageH1:          5,  faqStart:        12,
    introBody:       6,  status:          36
    standardsHeader: 7,
  },
  timeLimitMs: (4.5 * 60) * 1000
};
📌 Keep IDs out of shared code.

If you’re copying this script across clients or sharing it with a team, don’t leave real folder or file IDs in the script body. Use the YOUR_FOLDER_ID_HERE placeholder pattern, or store the ID in Script Properties alongside your API key.


Phase 1: Generating Local Property Context

Before writing a single word of page copy, we ask the AI to research the location. The prompt is deliberately narrow — a 2–4 sentence briefing on the housing stock, common trade issues, and local landmarks. That context sits in column D and gets injected into the Phase 2 prompt.

Why does this matter? Because “Our plumbers serve Manchester homeowners” and “Our plumbers understand the hard water issues common in Victorian terraces throughout Didsbury and Chorlton” are the same information at completely different quality levels.

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('TradeLink SEO')
    .addItem('① Generate local context (run first)', 'generateLocalContext')
    .addItem('② Generate page content', 'generatePageContent')
    .addSeparator()
    .addItem('Export to Google Doc', 'exportToDoc')
    .addSeparator()
    .addItem('✔ Check progress', 'checkProgress')
    .addToUi();
}

function buildContextPrompt(serviceName, locationName) {
  return `You are a Senior Property Researcher for TradeLink.

Task: Write a 2–4 sentence local property context for homeowners in ${locationName} seeking a ${serviceName} expert.

Include:
- Typical housing stock in ${locationName}
- One or two common trade issues specific to ${locationName}
- Two real local neighbourhoods or landmarks

Tone: Knowledgeable, local, professional. British English.
Respond with ONLY valid JSON: {"local_context": "..."}`;
}

The numbered menu items — “① Generate local context”, “② Generate page content” — tell a non-technical teammate exactly what to run and in what order.


Phase 2: The System Prompt and Content Prompt

Article 1 taught the system/user split, and Article 2 implemented it. We’re keeping that same structure here — brand voice in the system prompt, page-specific task in the user message. The system prompt is built once before the loop and passed to every API call, consistent with how Article 2 handles it.

// System prompt — brand voice, same for every row
function getContentSystemPrompt() {
  return `You are a Senior SEO Copywriter for TradeLink, a premium UK platform connecting
homeowners with expert, vetted local tradespeople.

AUDIENCE:
Homeowners who are stressed, often in an emergency. Their biggest fear is a "cowboy"
tradesperson. They want local, vetted, and backed by a guarantee.

BRAND TONE:
- Reliable, expert, reassuring. Never salesy.
- Never use "cheap," "affordable," or "bargain."
- British English throughout.`;
}

// User-turn prompt — page-specific task, changes per row
function buildContentPrompt(serviceName, locationName, localContext) {
  return `Generate full service page content for the following TradeLink page.

LOCAL CONTEXT:
${localContext || 'Standard UK residential property in ' + locationName + '.'}

SERVICE: ${serviceName} | LOCATION: ${locationName}

page_h1: Confident headline including "${serviceName}", "${locationName}", "Vetted". Max 70 chars.
intro_body: Empathetic 2–3 sentence opening. Reference the local context.
standards_header: Benefit-led H2 about quality. Max 60 chars.
standards_body: 2–3 sentences on what "vetted" means in practice.
trust_header: H2 about why TradeLink is the safest choice. Max 60 chars.
trust_body: 2–3 sentences on the Workmanship Guarantee and homeowner protection.
faq_title: H2 heading for the FAQ section.
faqs: Exactly 12 Q&A objects. MUST cover: vetting, Workmanship Guarantee, emergencies,
  pricing, insurance, job duration, qualifications, and 2+ specific to ${serviceName} in ${locationName}.

Respond with ONLY valid JSON:
{"page_h1":"...","intro_body":"...","standards_header":"...","standards_body":"...",
 "trust_header":"...","trust_body":"...","faq_title":"...","faqs":[{"question":"...","answer":"..."}]}`;
}

The local context is injected as a named section rather than mixed into the instructions. The FAQ “MUST cover” list trades some variety for reliability — without it, the model writes twelve generic questions that don’t necessarily hit the topics that actually convert a hesitant homeowner.


The Upgraded API Caller: responseMimeType

Setting responseMimeType: 'application/json' constrains the API to return only valid JSON — no preamble, no code fences. Enforced at the API level, not just by prompt instruction. The function signature matches Article 2 exactly, preserving the system/user split throughout the series.

function callGeminiApi(userPrompt, systemPrompt = '') {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${CONFIG.modelName}:generateContent?key=${CONFIG.apiKey}`;

  const payload = {
    system_instruction: systemPrompt
      ? { parts: [{ text: systemPrompt }] }
      : undefined,
    contents: [{ parts: [{ text: userPrompt }] }],
    generationConfig: {
      temperature:      0.8,              // Higher than Article 2 — body copy benefits from variation
      responseMimeType: 'application/json'  // Enforces JSON at the API level
    }
  };

  const response   = UrlFetchApp.fetch(url, {
    method: 'post', contentType: 'application/json',
    payload: JSON.stringify(payload), muteHttpExceptions: true
  });
  const statusCode = response.getResponseCode();
  if (statusCode !== 200) throw new Error(`API error ${statusCode}: ${response.getContentText().substring(0, 200)}`);

  const data = JSON.parse(response.getContentText());
  if (!data.candidates || data.candidates.length === 0) {
    throw new Error(`No candidates. Block reason: ${data.promptFeedback?.blockReason || 'unknown'}`);
  }
  let parsed;
  try { parsed = JSON.parse(data.candidates[0].content.parts[0].text.trim()); }
  catch (e) { throw new Error(`JSON parse failed: ${data.candidates[0].content.parts[0].text.substring(0, 300)}`); }
  return parsed;
}

temperature: 0.8 is deliberately higher than the 0.4 in Article 2. For metadata, consistency matters more than creativity. For body copy, the opposite is true — at 0.4, the output becomes formulaic across 50 location pages. 0.8 gives each page enough variation to feel like it was written for that specific location.


Exporting to Google Doc

This function takes all the content from completed rows and writes it into structured Google Docs. The key addition over a naive implementation: it checks whether a document with that name already exists in the folder before creating one. Without this, running the export twice creates duplicate files with identical names — and Google Drive won’t warn you.

// Inside exportToDoc() — the duplicate check before creating each doc:

const folder  = DriveApp.getFolderById(CONFIG.exportFolderId);
const docName = `TradeLink — ${serviceName} in ${locationName}`;

// Google Drive allows multiple files with the same name — it will NOT warn you.
// Check first to prevent duplicates on re-runs.
const existing = folder.getFilesByName(docName);
if (existing.hasNext()) { skipped++; return; }

const doc  = DocumentApp.create(docName);
DriveApp.getFileById(doc.getId()).moveTo(folder); // Move out of Drive root immediately
📌 Permissions note:

The first time you run exportToDoc(), Apps Script will request additional permissions for Google Drive and Docs access. This is expected — click through. Each team member needs to grant permissions once from their own account.


The Complete Script

The annotated walkthrough above explains what each section does. Here’s the full script in one block — paste this into Apps Script without any assembly required.

/**
 * TradeLink SEO — Service Page Content Generator
 * @OnlyCurrentDoc
 */

const CONFIG = {
  sheetName:      'Sheet1',
  apiKey:         PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY'),
  modelName:      'gemini-2.5-flash',
  exportFolderId: 'YOUR_FOLDER_ID_HERE',
  cols: {
    url: 1, serviceName: 2, locationName: 3, localContext: 4,
    pageH1: 5, introBody: 6, standardsHeader: 7, standardsBody: 8,
    trustHeader: 9, trustBody: 10, faqTitle: 11, faqStart: 12, status: 36
  },
  timeLimitMs: (4.5 * 60) * 1000
};

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('TradeLink SEO')
    .addItem('① Generate local context (run first)', 'generateLocalContext')
    .addItem('② Generate page content', 'generatePageContent')
    .addSeparator()
    .addItem('Export to Google Doc', 'exportToDoc')
    .addSeparator()
    .addItem('✔ Check progress', 'checkProgress')
    .addToUi();
}

function buildContextPrompt(serviceName, locationName) {
  return `You are a Senior Property Researcher for TradeLink.
Task: Write a 2–4 sentence local property context for homeowners in ${locationName} seeking a ${serviceName} expert.
Include typical housing stock, 1–2 common trade issues, and 2 real local neighbourhoods/landmarks.
British English. Respond with ONLY valid JSON: {"local_context": "..."}`;
}

function getContentSystemPrompt() {
  return `You are a Senior SEO Copywriter for TradeLink, a premium UK platform connecting
homeowners with expert, vetted local tradespeople.
AUDIENCE: Homeowners who are stressed, fear "cowboy" tradespeople, want vetted professionals.
BRAND TONE: Reliable, expert, reassuring. No "cheap/affordable/bargain." British English.`;
}

function buildContentPrompt(serviceName, locationName, localContext) {
  return `Generate full service page content for the following TradeLink page.
LOCAL CONTEXT: ${localContext || 'Standard UK residential property in ' + locationName + '.'}
SERVICE: ${serviceName} | LOCATION: ${locationName}
page_h1: Include "${serviceName}", "${locationName}", "Vetted". Max 70 chars.
intro_body: Empathetic 2–3 sentences. Reference local context.
standards_header: Benefit-led H2. Max 60 chars.
standards_body: 2–3 sentences on what "vetted" means in practice.
trust_header: H2 on why TradeLink is safest. Max 60 chars.
trust_body: 2–3 sentences on Workmanship Guarantee and homeowner protection.
faq_title: H2 FAQ heading.
faqs: Exactly 12 Q&As. MUST cover: vetting, Guarantee, emergencies, pricing, insurance,
  duration, qualifications, and 2+ specific to ${serviceName} in ${locationName}.
Respond with ONLY valid JSON:
{"page_h1":"...","intro_body":"...","standards_header":"...","standards_body":"...",
 "trust_header":"...","trust_body":"...","faq_title":"...","faqs":[{"question":"...","answer":"..."}]}`;
}

function callGeminiApi(userPrompt, systemPrompt = '') {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${CONFIG.modelName}:generateContent?key=${CONFIG.apiKey}`;
  const payload = {
    system_instruction: systemPrompt ? { parts: [{ text: systemPrompt }] } : undefined,
    contents: [{ parts: [{ text: userPrompt }] }],
    generationConfig: { temperature: 0.8, responseMimeType: 'application/json' }
  };
  const response = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true });
  if (response.getResponseCode() !== 200) throw new Error(`API error ${response.getResponseCode()}: ${response.getContentText().substring(0, 200)}`);
  const data = JSON.parse(response.getContentText());
  if (!data.candidates || data.candidates.length === 0) throw new Error(`No candidates. Block: ${data.promptFeedback?.blockReason || 'unknown'}`);
  let parsed;
  try { parsed = JSON.parse(data.candidates[0].content.parts[0].text.trim()); }
  catch (e) { throw new Error(`JSON parse failed: ${data.candidates[0].content.parts[0].text.substring(0, 300)}`); }
  return parsed;
}

function writeContentToRow(sheet, rowNum, content) {
  // Main fields (cols 5–11) are contiguous — one setValues() call
  const mainFields = [
    content.page_h1 || '', content.intro_body || '',
    content.standards_header || '', content.standards_body || '',
    content.trust_header || '', content.trust_body || '',
    content.faq_title || ''
  ];
  sheet.getRange(rowNum, CONFIG.cols.pageH1, 1, mainFields.length).setValues([mainFields]);

  // FAQ pairs (cols 12–35) are contiguous — one setValues() call
  if (content.faqs && Array.isArray(content.faqs)) {
    const faqValues = [];
    content.faqs.slice(0, 12).forEach(function(faq) {
      faqValues.push(faq.question || '');
      faqValues.push(faq.answer   || '');
    });
    while (faqValues.length < 24) faqValues.push('');
    sheet.getRange(rowNum, CONFIG.cols.faqStart, 1, 24).setValues([faqValues]);
  }
}

function generateLocalContext() {
  const ss    = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.sheetName);
  if (!sheet) { SpreadsheetApp.getUi().alert(`Sheet "${CONFIG.sheetName}" not found.`); return; }
  const startTime = Date.now(), lastRow = sheet.getLastRow();
  if (lastRow < 2) { SpreadsheetApp.getUi().alert('No data rows found.'); return; }
  const allData = sheet.getRange(2, 1, lastRow - 1, CONFIG.cols.status).getValues();
  let processed = 0, skipped = 0, errors = 0;
  allData.forEach(function(rowData, i) {
    const rowNum = i + 2;
    if (Date.now() - startTime > CONFIG.timeLimitMs) return;
    if ((rowData[CONFIG.cols.localContext - 1] || '').toString().trim()) { skipped++; return; }
    const svc = (rowData[CONFIG.cols.serviceName - 1] || '').toString().trim();
    const loc = (rowData[CONFIG.cols.locationName - 1] || '').toString().trim();
    if (!svc || !loc) { skipped++; return; }
    try {
      sheet.getRange(rowNum, CONFIG.cols.status).setValue('Generating context...'); SpreadsheetApp.flush();
      const result = callGeminiApi(buildContextPrompt(svc, loc));
      sheet.getRange(rowNum, CONFIG.cols.localContext).setValue(result.local_context);
      sheet.getRange(rowNum, CONFIG.cols.status).setValue('Context done'); SpreadsheetApp.flush();
      processed++; Utilities.sleep(1000);
    } catch (e) {
      sheet.getRange(rowNum, CONFIG.cols.status).setValue(`Error: ${e.message.substring(0, 100)}`);
      SpreadsheetApp.flush(); errors++; Logger.log(`Row ${rowNum}: ${e.message}`);
    }
  });
  SpreadsheetApp.getUi().alert(`Phase 1 complete.\n\n✔ Generated: ${processed}\n↷ Skipped: ${skipped}\n✖ Errors: ${errors}\n\nReview column D before running Phase 2.`);
}

function generatePageContent() {
  const ss    = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.sheetName);
  if (!sheet) { SpreadsheetApp.getUi().alert(`Sheet "${CONFIG.sheetName}" not found.`); return; }
  const startTime = Date.now(), lastRow = sheet.getLastRow();
  if (lastRow < 2) { SpreadsheetApp.getUi().alert('No data rows found.'); return; }
  const allData = sheet.getRange(2, 1, lastRow - 1, CONFIG.cols.status).getValues();
  const systemPrompt = getContentSystemPrompt(); // built once
  let processed = 0, skipped = 0, errors = 0;
  for (let i = 0; i < allData.length; i++) {
    const rowNum = i + 2, rowData = allData[i];
    if (Date.now() - startTime > CONFIG.timeLimitMs) { SpreadsheetApp.getUi().alert(`Time limit after ${processed} rows.`); return; }
    const status = (rowData[CONFIG.cols.status - 1] || '').toString();
    if (status.startsWith('Done')) { skipped++; continue; }
    const svc = (rowData[CONFIG.cols.serviceName - 1] || '').toString().trim();
    const loc = (rowData[CONFIG.cols.locationName - 1] || '').toString().trim();
    const ctx = (rowData[CONFIG.cols.localContext - 1] || '').toString().trim();
    if (!svc || !loc) { skipped++; continue; }
    try {
      sheet.getRange(rowNum, CONFIG.cols.status).setValue('Generating...'); SpreadsheetApp.flush();
      const content = callGeminiApi(buildContentPrompt(svc, loc, ctx), systemPrompt);
      writeContentToRow(sheet, rowNum, content);
      sheet.getRange(rowNum, CONFIG.cols.status).setValue('Done'); SpreadsheetApp.flush();
      ss.toast(`Row ${rowNum} done (${processed + 1} so far)`, 'TradeLink SEO', 3);
      processed++; Utilities.sleep(1500);
    } catch (e) {
      sheet.getRange(rowNum, CONFIG.cols.status).setValue(`Error: ${e.message.substring(0, 100)}`);
      SpreadsheetApp.flush(); errors++; Logger.log(`Row ${rowNum}: ${e.message}`);
    }
  }
  SpreadsheetApp.getUi().alert(`Phase 2 complete.\n\n✔ Processed: ${processed}\n↷ Skipped: ${skipped}\n✖ Errors: ${errors}` + (errors > 0 ? '\n\nCheck column AJ for details.' : ''));
}

function exportToDoc() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.sheetName);
  if (!sheet) { SpreadsheetApp.getUi().alert(`Sheet "${CONFIG.sheetName}" not found.`); return; }
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) { SpreadsheetApp.getUi().alert('No data rows found.'); return; }
  const allData = sheet.getRange(2, 1, lastRow - 1, CONFIG.cols.status).getValues();
  let exported = 0, skipped = 0;
  allData.forEach(function(rowData) {
    if (!((rowData[CONFIG.cols.status - 1] || '').toString()).startsWith('Done')) return;
    const svc = (rowData[CONFIG.cols.serviceName - 1] || '').toString().trim();
    const loc = (rowData[CONFIG.cols.locationName - 1] || '').toString().trim();
    const url = (rowData[CONFIG.cols.url - 1] || '').toString().trim();
    if (!svc || !loc) return;
    const folder = DriveApp.getFolderById(CONFIG.exportFolderId);
    const docName = `TradeLink — ${svc} in ${loc}`;
    // Skip if doc already exists — prevents duplicates on re-runs
    const existing = folder.getFilesByName(docName);
    if (existing.hasNext()) { skipped++; return; }
    const doc  = DocumentApp.create(docName);
    const body = doc.getBody();
    DriveApp.getFileById(doc.getId()).moveTo(folder);
    body.appendParagraph('URL: ' + url).setFontSize(10).setForegroundColor('#888888');
    body.appendParagraph('');
    const fields = [
      [CONFIG.cols.pageH1, DocumentApp.ParagraphHeading.HEADING1],
      [CONFIG.cols.introBody, null],
      [CONFIG.cols.standardsHeader, DocumentApp.ParagraphHeading.HEADING2],
      [CONFIG.cols.standardsBody, null],
      [CONFIG.cols.trustHeader, DocumentApp.ParagraphHeading.HEADING2],
      [CONFIG.cols.trustBody, null],
      [CONFIG.cols.faqTitle, DocumentApp.ParagraphHeading.HEADING2]
    ];
    fields.forEach(function([col, heading]) {
      const val = rowData[col - 1];
      if (!val) return;
      const p = body.appendParagraph(val.toString());
      if (heading) p.setHeading(heading); else body.appendParagraph('');
    });
    for (let q = 0; q < 12; q++) {
      const question = rowData[CONFIG.cols.faqStart + (q * 2) - 1];
      const answer   = rowData[CONFIG.cols.faqStart + (q * 2)];
      if (question) { body.appendParagraph(question.toString()).setBold(true); if (answer) body.appendParagraph(answer.toString()); body.appendParagraph(''); }
    }
    doc.saveAndClose(); exported++;
  });
  SpreadsheetApp.getUi().alert(`Export complete.\n\n✔ Created: ${exported}\n↷ Already existed: ${skipped}`);
}

function checkProgress() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.sheetName);
  if (!sheet) { SpreadsheetApp.getUi().alert(`Sheet "${CONFIG.sheetName}" not found.`); return; }
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) { SpreadsheetApp.getUi().alert('No data rows found.'); return; }
  const statuses = sheet.getRange(2, CONFIG.cols.status, lastRow - 1, 1).getValues();
  let contentDone = 0, contextDone = 0, errors = 0, pending = 0;
  statuses.forEach(function(r) {
    const s = r[0].toString();
    if (s === 'Done')             contentDone++;
    else if (s === 'Context done')  contextDone++;
    else if (s.startsWith('Error'))  errors++;
    else                         pending++;
  });
  SpreadsheetApp.getUi().alert(
    `Progress Summary\n\n✔ Content done: ${contentDone}\n◑ Context only: ${contextDone}\n✖ Errors: ${errors}\n○ Pending: ${pending}\nTotal: ${lastRow - 1}`
  );
}

Running It: What to Expect

  1. Paste the complete script into Extensions > Apps Script
  2. Save and refresh your spreadsheet
  3. Replace YOUR_FOLDER_ID_HERE in CONFIG with your actual Drive folder ID
  4. Add your input data to columns A, B, and C
  5. Click ① Generate local context — review column D before continuing
  6. Click ② Generate page content
  7. Once complete, click Export to Google Doc

On the first run, Apps Script will request additional permissions for Drive and Docs access. This is expected — click through.

A note on cost at this scale:

This is substantially more expensive per row than the title tag generator in Article 2. The prompts are longer, the responses are longer, and gemini-2.5-flash is a more capable — and more expensive — model than 2.0-flash. Token counts per page are roughly 10–15x higher than Article 2. A 500-page run will still be within a few pounds at current pricing, but check Google’s pricing page before committing to a large batch.

What to expect from the output quality: The local context is usually accurate and usable with minimal editing. The body copy sections typically land around 70–80% of the way to publishable — right structure, right brand references, genuine local texture, but usually needing a pass for sentence variety. The FAQ answers tend to be the strongest section. Read everything before it goes near a CMS.


What’s Coming in Article 4

Now that we’re generating full body copy, the natural next question is: how do you know if it’s any good? Article 4 builds a content judge — a script that takes generated copy and scores it against your own rubric. Quality, tone-of-voice adherence, brand compliance, character limits, FAQ coverage. You write the criteria, the AI marks the work. It’s the editorial layer that turns a generation pipeline into a quality-controlled production workflow.

It’s also where the Prompt Config Sheet pattern finally gets properly introduced — pulling both the generation prompts and the judging rubric from a dedicated sheet tab so the whole system can be tuned without touching the script.


This series is part of an ongoing set of practical AI workflow guides for working SEOs. Tools referenced: Sitebulb, Google Sheets, Google AI Studio, SE Ranking, Google Search Console, Google Analytics, SEO Testing.

Leave a comment