{
  "name": "Check page speed weekly with PageSpeed Insights, flag slow pages by code and explain fixes with Claude",
  "nodes": [
    {
      "id": "1ef89125-8a6f-408d-8c23-725222427291",
      "name": "Every Monday at 8:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.4,
      "position": [
        0,
        -100
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      }
    },
    {
      "id": "1c7d6952-f148-4583-8fde-cf4db19d0cb6",
      "name": "Run now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        150
      ],
      "parameters": {}
    },
    {
      "id": "f8089aa6-20f1-490b-89a9-ee23b6b8082b",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "c01",
              "name": "pages",
              "value": "# one full URL per line\nhttps://example.com/\nhttps://example.com/pricing/",
              "type": "string"
            },
            {
              "id": "c02",
              "name": "strategies",
              "value": "mobile",
              "type": "string"
            },
            {
              "id": "c03",
              "name": "min_score",
              "value": 90,
              "type": "number"
            },
            {
              "id": "c04",
              "name": "max_lcp_ms",
              "value": 2500,
              "type": "number"
            },
            {
              "id": "c05",
              "name": "max_cls",
              "value": 0.1,
              "type": "number"
            },
            {
              "id": "c06",
              "name": "max_tbt_ms",
              "value": 200,
              "type": "number"
            },
            {
              "id": "c07",
              "name": "worse_score_points",
              "value": 10,
              "type": "number"
            },
            {
              "id": "c08",
              "name": "worse_lcp_ms",
              "value": 1000,
              "type": "number"
            },
            {
              "id": "c09",
              "name": "max_audits",
              "value": 5,
              "type": "number"
            },
            {
              "id": "c10",
              "name": "max_fixes",
              "value": 3,
              "type": "number"
            },
            {
              "id": "c11",
              "name": "language",
              "value": "en",
              "type": "string"
            },
            {
              "id": "c12",
              "name": "output_language",
              "value": "English",
              "type": "string"
            },
            {
              "id": "c13",
              "name": "site_notes",
              "value": "",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "03cfd9fb-2cbd-4946-8119-c02519643254",
      "name": "Read History tab",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        600,
        0
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "PageSpeed"
        },
        "options": {
          "outputFormatting": {
            "values": {
              "general": "UNFORMATTED_VALUE"
            }
          }
        }
      },
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "id": "8122d674-9000-4e31-8ca8-b7b61cf6b7f4",
      "name": "List pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        820,
        0
      ],
      "parameters": {
        "jsCode": "// ---- weekly PageSpeed check: all numbers by code ---------------------------------------------------------\nconst nfkc = (s) => String(s ?? '').normalize('NFKC');\nconst r1 = (x) => Math.round(x * 10) / 10;\nconst r2 = (x) => Math.round(x * 100) / 100;\n// metric audits are results, not things to fix\nconst PLATFORMS = ['WordPress', 'Shopify', 'Wix', 'Squarespace', 'Webflow', 'Drupal', 'Joomla', 'Jimdo', 'STUDIO', 'ペライチ', 'BASE', 'Next.js', 'Nuxt', 'Gatsby', 'Astro', 'Hugo', 'Jekyll', 'React', 'Vue', 'Angular', 'Svelte', 'jQuery', 'Bootstrap', 'Tailwind', 'Elementor', 'Cloudflare', 'Vercel', 'Netlify'];\nconst METRICS = new Set(['first-contentful-paint', 'largest-contentful-paint', 'speed-index', 'total-blocking-time', 'cumulative-layout-shift', 'interactive', 'max-potential-fid', 'first-meaningful-paint']);\n\nfunction pageList(text, strategies) {\n  const st = String(strategies ?? 'mobile').split(/[\\s,]+/).filter((s) => s === 'mobile' || s === 'desktop');\n  const out = [], problems = [];\n  for (const line of String(text ?? '').split(/\\r?\\n/)) {\n    const u = line.trim();\n    if (!u || u.startsWith('#')) continue;\n    if (!/^https?:\\/\\/[^\\s/]+\\.[^\\s]+$/.test(u)) { problems.push('not a full URL: ' + u); continue; }\n    for (const s of st) out.push({ url: u, strategy: s });\n  }\n  if (!st.length) problems.push('strategies must be mobile and/or desktop');\n  return { pages: out, problems };\n}\n\n// one PageSpeed answer -> the numbers we use; an error is kept as a page that could not be measured\nfunction summarize(j, url, strategy, maxAudits) {\n  const L = j?.lighthouseResult;\n  if (!L || !L.categories?.performance) return { url, strategy, ok: false, error: String(j?.error?.message ?? j?.message ?? 'no Lighthouse result').slice(0, 200) };\n  const a = L.audits ?? {};\n  const v = (id) => a[id]?.numericValue;\n  // PageSpeed scores an error page like any other page: the HTTP status is only in the network requests and a run warning\n  const reqs = a['network-requests']?.details?.items ?? [];\n  const doc = reqs.find((r) => r.resourceType === 'Document' && (r.url === L.mainDocumentUrl || r.url === L.finalUrl)) ?? reqs.find((r) => r.resourceType === 'Document');\n  const status = Number(doc?.statusCode ?? 0);\n  const warnings = (L.runWarnings ?? []).map(String);\n  if (status >= 400 || warnings.some((w) => /status code: [45]\\d\\d/i.test(w))) return { url, strategy, ok: false, error: 'the page answered HTTP ' + (status || (warnings.join(' ').match(/status code: (\\d{3})/i) ?? [])[1]) + ' (PageSpeed still gave it a score)' };\n  const final = L.finalDisplayedUrl ?? L.finalUrl ?? url;\n  const audits = Object.values(a)\n    .filter((x) => !METRICS.has(x.id) && typeof x.score === 'number' && x.score < 0.9 && !['informative', 'notApplicable', 'manual'].includes(x.scoreDisplayMode))\n    .map((x) => ({ id: x.id, title: String(x.title ?? x.id), display: String(x.displayValue ?? ''), save_lcp_ms: Math.round(x.metricSavings?.LCP ?? 0), save_fcp_ms: Math.round(x.metricSavings?.FCP ?? 0),\n      save_kib: x.details?.overallSavingsBytes ? Math.round(x.details.overallSavingsBytes / 1024) : 0 }))\n    .sort((p, q) => (q.save_lcp_ms + q.save_fcp_ms) - (p.save_lcp_ms + p.save_fcp_ms) || q.save_kib - p.save_kib)\n    .slice(0, maxAudits);\n  const fe = j.loadingExperience?.metrics ?? {};\n  const p75 = (k) => fe[k]?.percentile;\n  return { url, strategy, ok: true, score: Math.round(L.categories.performance.score * 100), lcp_ms: Math.round(v('largest-contentful-paint') ?? 0), cls: r2(v('cumulative-layout-shift') ?? 0), tbt_ms: Math.round(v('total-blocking-time') ?? 0),\n    fcp_ms: Math.round(v('first-contentful-paint') ?? 0), si_ms: Math.round(v('speed-index') ?? 0), lighthouse: L.lighthouseVersion ?? '', fetch_time: L.fetchTime ?? '',\n    redirected_to: final.replace(/\\/$/, '') !== url.replace(/\\/$/, '') ? final : '', warnings,\n    field: j.loadingExperience?.overall_category ? { category: j.loadingExperience.overall_category, lcp_ms: p75('LARGEST_CONTENTFUL_PAINT_MS') ?? null, inp_ms: p75('INTERACTION_TO_NEXT_PAINT') ?? null, cls: p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') != null ? p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') / 100 : null } : null,\n    audits };\n}\n\n// the limits are decided in Config, the flags by code\nfunction flagsOf(p, cfg) {\n  if (!p.ok) return ['could not measure: ' + p.error];\n  const f = [];\n  if (p.score < Number(cfg.min_score)) f.push('score ' + p.score + ' < ' + cfg.min_score);\n  if (p.lcp_ms > Number(cfg.max_lcp_ms)) f.push('LCP ' + r1(p.lcp_ms / 1000) + ' s > ' + r1(Number(cfg.max_lcp_ms) / 1000) + ' s');\n  if (p.cls > Number(cfg.max_cls)) f.push('CLS ' + p.cls + ' > ' + cfg.max_cls);\n  if (p.tbt_ms > Number(cfg.max_tbt_ms)) f.push('TBT ' + p.tbt_ms + ' ms > ' + cfg.max_tbt_ms + ' ms');\n  return f;\n}\n// last week's row for the same page and strategy (History rows, newest last)\nfunction previousOf(p, history) {\n  const rows = (history ?? []).filter((h) => h.url === p.url && h.strategy === p.strategy && String(h.error ?? '') === '' && h.score !== '' && h.score != null);\n  return rows.length ? rows[rows.length - 1] : null;\n}\nfunction changeOf(p, prev, cfg) {\n  if (!p.ok || !prev) return null;\n  const ds = p.score - Number(prev.score), dl = p.lcp_ms - Number(prev.lcp_ms);\n  return { score: ds, lcp_ms: dl, since: String(prev.checked_at ?? '').slice(0, 10), worse: ds <= -Number(cfg.worse_score_points) || dl >= Number(cfg.worse_lcp_ms) };\n}\n\n// numbered facts for Claude: one for the page's metrics, one per failing audit\nfunction factsOf(p, i) {\n  const facts = [{ id: 'P' + i + '-M', kind: 'metrics', label: p.url + ' (' + p.strategy + ')', values: { score: p.score, lcp_s: r1(p.lcp_ms / 1000), lcp_ms: p.lcp_ms, cls: p.cls, tbt_ms: p.tbt_ms, fcp_s: r1(p.fcp_ms / 1000) } }];\n  p.audits.forEach((a, k) => facts.push({ id: 'P' + i + '-A' + (k + 1), kind: 'audit', label: a.title + (a.display ? ' (' + a.display + ')' : ''), audit: a.id,\n    values: { save_lcp_ms: a.save_lcp_ms, save_lcp_s: r1(a.save_lcp_ms / 1000), save_fcp_ms: a.save_fcp_ms, save_fcp_s: r1(a.save_fcp_ms / 1000), save_kib: a.save_kib } }));\n  return facts;\n}\nfunction numbersOfFacts(facts) {\n  const s = new Set();\n  for (const f of facts) {\n    for (const v of Object.values(f.values)) if (typeof v === 'number' && Number.isFinite(v)) { s.add(String(v)); s.add(String(r1(v))); if (Number.isInteger(v)) s.add(v.toFixed(1)); }\n    for (const m of nfkc(f.label).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? []) s.add(m);\n  }\n  return s;\n}\n// Claude's fixes for one page; each must stand on audits that failed on that page\nfunction checkFixes(raw, facts, cfg) {\n  let ai = null;\n  try {\n    const t = String(raw ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n    const a = t.indexOf('{'), z = t.lastIndexOf('}');\n    ai = JSON.parse(a >= 0 && z > a ? t.slice(a, z + 1) : t);\n  } catch (e) { ai = null; }\n  if (!ai) return { ok: false, problem: 'the AI output could not be read as JSON', items: [] };\n  const byId = new Map(facts.map((f) => [f.id, f]));\n  const items = (Array.isArray(ai.fixes) ? ai.fixes : []).slice(0, Number(cfg.max_fixes)).map((o) => {\n    const text = String(o?.text ?? '').trim();\n    const ids = (Array.isArray(o?.fact_ids) ? o.fact_ids : []).map(String);\n    const problems = [];\n    if (!text) problems.push('empty');\n    const unknown = ids.filter((id) => !byId.has(id));\n    if (unknown.length) problems.push('unknown facts ' + unknown.join(', '));\n    const used = ids.filter((id) => byId.has(id)).map((id) => byId.get(id));\n    if (!used.some((f) => f.kind === 'audit')) problems.push('no failing audit cited');\n    const allowed = numbersOfFacts(used);\n    const nums = nfkc(text).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? [];\n    const bad = [...new Set(nums)].filter((n) => !allowed.has(n) && !allowed.has(String(Number(n))));\n    if (bad.length) problems.push('numbers not in the cited facts: ' + bad.join(', '));\n    // the AI must not assume how the site is built: a platform it names must be in site_notes\n    const notes = nfkc(cfg.site_notes).toLowerCase();\n    const says = (w) => /^[A-Za-z.]+$/.test(w) ? new RegExp('(^|[^A-Za-z])' + w.replace(/[.]/g, '[.]') + '([^A-Za-z]|$)', w === w.toUpperCase() ? '' : 'i').test(nfkc(text)) : nfkc(text).includes(w);\n    const named = PLATFORMS.filter((w) => says(w) && !notes.includes(w.toLowerCase()));\n    if (named.length) problems.push('assumes a platform not in site_notes: ' + named.join(', '));\n    return { text, fact_ids: ids, audits: used.filter((f) => f.kind === 'audit').map((f) => f.audit), ok: problems.length === 0, problems: problems.join('; ') };\n  });\n  return { ok: true, problem: '', items };\n}\n\nfunction slackText(pages, cfg, today) {\n  const ja = String(cfg.language).startsWith('ja');\n  const s = (ms) => r1(ms / 1000) + ' s';\n  const head = (ja ? '*ページ速度の週次チェック* ' : '*Weekly PageSpeed check* ') + today + (ja ? '（ラボ計測・1回）' : ' (lab, one run)');\n  const lines = [head];\n  for (const p of pages) {\n    const mark = !p.ok ? ':grey_question:' : p.flags.length ? ':warning:' : ':white_check_mark:';\n    let l = mark + ' ' + p.url + ' [' + p.strategy + ']';\n    if (p.ok) {\n      l += '  ' + (ja ? 'スコア ' : 'score ') + p.score + ' / LCP ' + s(p.lcp_ms) + ' / CLS ' + p.cls + ' / TBT ' + p.tbt_ms + ' ms';\n      if (p.change) l += (ja ? '  前回(' : '  vs ') + p.change.since + (ja ? ')比 ' : ': ') + (p.change.score >= 0 ? '+' : '') + p.change.score + (ja ? '点' : ' pts') + ', LCP ' + (p.change.lcp_ms >= 0 ? '+' : '') + r1(p.change.lcp_ms / 1000) + ' s' + (p.change.worse ? ' :small_red_triangle_down:' : '');\n      if (p.field) l += (ja ? '  実ユーザー: ' : '  real users: ') + p.field.category;\n      if (p.redirected_to) l += (ja ? '  転送先: ' : '  redirected to ') + p.redirected_to;\n    } else l += '  ' + p.error;\n    lines.push(l);\n    if (p.flags.length && p.ok) lines.push('    ' + p.flags.join(', '));\n    for (const f of p.fixes ?? []) lines.push('    ' + (f.ok ? '• ' : ':warning: ') + f.text + (f.ok ? '' : (ja ? '（確認が必要: ' : ' (check: ') + f.problems + (ja ? '）' : ')')));\n    if (p.fix_problem) lines.push('    :warning: ' + p.fix_problem);\n  }\n  return lines.join('\\n');\n}\n\nconst cfg = $('Config').first().json;\nconst { pages, problems } = pageList(cfg.pages, cfg.strategies);\nif (problems.length) throw new Error('Fix Config: ' + problems.join('; '));\nif (!pages.length) throw new Error('Config has no pages to check.');\nreturn pages.map((p) => ({ json: p }));"
      }
    },
    {
      "id": "a2f9f139-1089-4538-863c-3b6b798dc28d",
      "name": "Get PageSpeed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1040,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $json.url }}"
            },
            {
              "name": "strategy",
              "value": "={{ $json.strategy }}"
            },
            {
              "name": "category",
              "value": "performance"
            }
          ]
        },
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 2000
            }
          },
          "timeout": 120000
        }
      },
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000
    },
    {
      "id": "e14784ea-a907-4e58-8ff2-098a5e6cae64",
      "name": "Summarize pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1260,
        0
      ],
      "parameters": {
        "jsCode": "// ---- weekly PageSpeed check: all numbers by code ---------------------------------------------------------\nconst nfkc = (s) => String(s ?? '').normalize('NFKC');\nconst r1 = (x) => Math.round(x * 10) / 10;\nconst r2 = (x) => Math.round(x * 100) / 100;\n// metric audits are results, not things to fix\nconst PLATFORMS = ['WordPress', 'Shopify', 'Wix', 'Squarespace', 'Webflow', 'Drupal', 'Joomla', 'Jimdo', 'STUDIO', 'ペライチ', 'BASE', 'Next.js', 'Nuxt', 'Gatsby', 'Astro', 'Hugo', 'Jekyll', 'React', 'Vue', 'Angular', 'Svelte', 'jQuery', 'Bootstrap', 'Tailwind', 'Elementor', 'Cloudflare', 'Vercel', 'Netlify'];\nconst METRICS = new Set(['first-contentful-paint', 'largest-contentful-paint', 'speed-index', 'total-blocking-time', 'cumulative-layout-shift', 'interactive', 'max-potential-fid', 'first-meaningful-paint']);\n\nfunction pageList(text, strategies) {\n  const st = String(strategies ?? 'mobile').split(/[\\s,]+/).filter((s) => s === 'mobile' || s === 'desktop');\n  const out = [], problems = [];\n  for (const line of String(text ?? '').split(/\\r?\\n/)) {\n    const u = line.trim();\n    if (!u || u.startsWith('#')) continue;\n    if (!/^https?:\\/\\/[^\\s/]+\\.[^\\s]+$/.test(u)) { problems.push('not a full URL: ' + u); continue; }\n    for (const s of st) out.push({ url: u, strategy: s });\n  }\n  if (!st.length) problems.push('strategies must be mobile and/or desktop');\n  return { pages: out, problems };\n}\n\n// one PageSpeed answer -> the numbers we use; an error is kept as a page that could not be measured\nfunction summarize(j, url, strategy, maxAudits) {\n  const L = j?.lighthouseResult;\n  if (!L || !L.categories?.performance) return { url, strategy, ok: false, error: String(j?.error?.message ?? j?.message ?? 'no Lighthouse result').slice(0, 200) };\n  const a = L.audits ?? {};\n  const v = (id) => a[id]?.numericValue;\n  // PageSpeed scores an error page like any other page: the HTTP status is only in the network requests and a run warning\n  const reqs = a['network-requests']?.details?.items ?? [];\n  const doc = reqs.find((r) => r.resourceType === 'Document' && (r.url === L.mainDocumentUrl || r.url === L.finalUrl)) ?? reqs.find((r) => r.resourceType === 'Document');\n  const status = Number(doc?.statusCode ?? 0);\n  const warnings = (L.runWarnings ?? []).map(String);\n  if (status >= 400 || warnings.some((w) => /status code: [45]\\d\\d/i.test(w))) return { url, strategy, ok: false, error: 'the page answered HTTP ' + (status || (warnings.join(' ').match(/status code: (\\d{3})/i) ?? [])[1]) + ' (PageSpeed still gave it a score)' };\n  const final = L.finalDisplayedUrl ?? L.finalUrl ?? url;\n  const audits = Object.values(a)\n    .filter((x) => !METRICS.has(x.id) && typeof x.score === 'number' && x.score < 0.9 && !['informative', 'notApplicable', 'manual'].includes(x.scoreDisplayMode))\n    .map((x) => ({ id: x.id, title: String(x.title ?? x.id), display: String(x.displayValue ?? ''), save_lcp_ms: Math.round(x.metricSavings?.LCP ?? 0), save_fcp_ms: Math.round(x.metricSavings?.FCP ?? 0),\n      save_kib: x.details?.overallSavingsBytes ? Math.round(x.details.overallSavingsBytes / 1024) : 0 }))\n    .sort((p, q) => (q.save_lcp_ms + q.save_fcp_ms) - (p.save_lcp_ms + p.save_fcp_ms) || q.save_kib - p.save_kib)\n    .slice(0, maxAudits);\n  const fe = j.loadingExperience?.metrics ?? {};\n  const p75 = (k) => fe[k]?.percentile;\n  return { url, strategy, ok: true, score: Math.round(L.categories.performance.score * 100), lcp_ms: Math.round(v('largest-contentful-paint') ?? 0), cls: r2(v('cumulative-layout-shift') ?? 0), tbt_ms: Math.round(v('total-blocking-time') ?? 0),\n    fcp_ms: Math.round(v('first-contentful-paint') ?? 0), si_ms: Math.round(v('speed-index') ?? 0), lighthouse: L.lighthouseVersion ?? '', fetch_time: L.fetchTime ?? '',\n    redirected_to: final.replace(/\\/$/, '') !== url.replace(/\\/$/, '') ? final : '', warnings,\n    field: j.loadingExperience?.overall_category ? { category: j.loadingExperience.overall_category, lcp_ms: p75('LARGEST_CONTENTFUL_PAINT_MS') ?? null, inp_ms: p75('INTERACTION_TO_NEXT_PAINT') ?? null, cls: p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') != null ? p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') / 100 : null } : null,\n    audits };\n}\n\n// the limits are decided in Config, the flags by code\nfunction flagsOf(p, cfg) {\n  if (!p.ok) return ['could not measure: ' + p.error];\n  const f = [];\n  if (p.score < Number(cfg.min_score)) f.push('score ' + p.score + ' < ' + cfg.min_score);\n  if (p.lcp_ms > Number(cfg.max_lcp_ms)) f.push('LCP ' + r1(p.lcp_ms / 1000) + ' s > ' + r1(Number(cfg.max_lcp_ms) / 1000) + ' s');\n  if (p.cls > Number(cfg.max_cls)) f.push('CLS ' + p.cls + ' > ' + cfg.max_cls);\n  if (p.tbt_ms > Number(cfg.max_tbt_ms)) f.push('TBT ' + p.tbt_ms + ' ms > ' + cfg.max_tbt_ms + ' ms');\n  return f;\n}\n// last week's row for the same page and strategy (History rows, newest last)\nfunction previousOf(p, history) {\n  const rows = (history ?? []).filter((h) => h.url === p.url && h.strategy === p.strategy && String(h.error ?? '') === '' && h.score !== '' && h.score != null);\n  return rows.length ? rows[rows.length - 1] : null;\n}\nfunction changeOf(p, prev, cfg) {\n  if (!p.ok || !prev) return null;\n  const ds = p.score - Number(prev.score), dl = p.lcp_ms - Number(prev.lcp_ms);\n  return { score: ds, lcp_ms: dl, since: String(prev.checked_at ?? '').slice(0, 10), worse: ds <= -Number(cfg.worse_score_points) || dl >= Number(cfg.worse_lcp_ms) };\n}\n\n// numbered facts for Claude: one for the page's metrics, one per failing audit\nfunction factsOf(p, i) {\n  const facts = [{ id: 'P' + i + '-M', kind: 'metrics', label: p.url + ' (' + p.strategy + ')', values: { score: p.score, lcp_s: r1(p.lcp_ms / 1000), lcp_ms: p.lcp_ms, cls: p.cls, tbt_ms: p.tbt_ms, fcp_s: r1(p.fcp_ms / 1000) } }];\n  p.audits.forEach((a, k) => facts.push({ id: 'P' + i + '-A' + (k + 1), kind: 'audit', label: a.title + (a.display ? ' (' + a.display + ')' : ''), audit: a.id,\n    values: { save_lcp_ms: a.save_lcp_ms, save_lcp_s: r1(a.save_lcp_ms / 1000), save_fcp_ms: a.save_fcp_ms, save_fcp_s: r1(a.save_fcp_ms / 1000), save_kib: a.save_kib } }));\n  return facts;\n}\nfunction numbersOfFacts(facts) {\n  const s = new Set();\n  for (const f of facts) {\n    for (const v of Object.values(f.values)) if (typeof v === 'number' && Number.isFinite(v)) { s.add(String(v)); s.add(String(r1(v))); if (Number.isInteger(v)) s.add(v.toFixed(1)); }\n    for (const m of nfkc(f.label).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? []) s.add(m);\n  }\n  return s;\n}\n// Claude's fixes for one page; each must stand on audits that failed on that page\nfunction checkFixes(raw, facts, cfg) {\n  let ai = null;\n  try {\n    const t = String(raw ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n    const a = t.indexOf('{'), z = t.lastIndexOf('}');\n    ai = JSON.parse(a >= 0 && z > a ? t.slice(a, z + 1) : t);\n  } catch (e) { ai = null; }\n  if (!ai) return { ok: false, problem: 'the AI output could not be read as JSON', items: [] };\n  const byId = new Map(facts.map((f) => [f.id, f]));\n  const items = (Array.isArray(ai.fixes) ? ai.fixes : []).slice(0, Number(cfg.max_fixes)).map((o) => {\n    const text = String(o?.text ?? '').trim();\n    const ids = (Array.isArray(o?.fact_ids) ? o.fact_ids : []).map(String);\n    const problems = [];\n    if (!text) problems.push('empty');\n    const unknown = ids.filter((id) => !byId.has(id));\n    if (unknown.length) problems.push('unknown facts ' + unknown.join(', '));\n    const used = ids.filter((id) => byId.has(id)).map((id) => byId.get(id));\n    if (!used.some((f) => f.kind === 'audit')) problems.push('no failing audit cited');\n    const allowed = numbersOfFacts(used);\n    const nums = nfkc(text).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? [];\n    const bad = [...new Set(nums)].filter((n) => !allowed.has(n) && !allowed.has(String(Number(n))));\n    if (bad.length) problems.push('numbers not in the cited facts: ' + bad.join(', '));\n    // the AI must not assume how the site is built: a platform it names must be in site_notes\n    const notes = nfkc(cfg.site_notes).toLowerCase();\n    const says = (w) => /^[A-Za-z.]+$/.test(w) ? new RegExp('(^|[^A-Za-z])' + w.replace(/[.]/g, '[.]') + '([^A-Za-z]|$)', w === w.toUpperCase() ? '' : 'i').test(nfkc(text)) : nfkc(text).includes(w);\n    const named = PLATFORMS.filter((w) => says(w) && !notes.includes(w.toLowerCase()));\n    if (named.length) problems.push('assumes a platform not in site_notes: ' + named.join(', '));\n    return { text, fact_ids: ids, audits: used.filter((f) => f.kind === 'audit').map((f) => f.audit), ok: problems.length === 0, problems: problems.join('; ') };\n  });\n  return { ok: true, problem: '', items };\n}\n\nfunction slackText(pages, cfg, today) {\n  const ja = String(cfg.language).startsWith('ja');\n  const s = (ms) => r1(ms / 1000) + ' s';\n  const head = (ja ? '*ページ速度の週次チェック* ' : '*Weekly PageSpeed check* ') + today + (ja ? '（ラボ計測・1回）' : ' (lab, one run)');\n  const lines = [head];\n  for (const p of pages) {\n    const mark = !p.ok ? ':grey_question:' : p.flags.length ? ':warning:' : ':white_check_mark:';\n    let l = mark + ' ' + p.url + ' [' + p.strategy + ']';\n    if (p.ok) {\n      l += '  ' + (ja ? 'スコア ' : 'score ') + p.score + ' / LCP ' + s(p.lcp_ms) + ' / CLS ' + p.cls + ' / TBT ' + p.tbt_ms + ' ms';\n      if (p.change) l += (ja ? '  前回(' : '  vs ') + p.change.since + (ja ? ')比 ' : ': ') + (p.change.score >= 0 ? '+' : '') + p.change.score + (ja ? '点' : ' pts') + ', LCP ' + (p.change.lcp_ms >= 0 ? '+' : '') + r1(p.change.lcp_ms / 1000) + ' s' + (p.change.worse ? ' :small_red_triangle_down:' : '');\n      if (p.field) l += (ja ? '  実ユーザー: ' : '  real users: ') + p.field.category;\n      if (p.redirected_to) l += (ja ? '  転送先: ' : '  redirected to ') + p.redirected_to;\n    } else l += '  ' + p.error;\n    lines.push(l);\n    if (p.flags.length && p.ok) lines.push('    ' + p.flags.join(', '));\n    for (const f of p.fixes ?? []) lines.push('    ' + (f.ok ? '• ' : ':warning: ') + f.text + (f.ok ? '' : (ja ? '（確認が必要: ' : ' (check: ') + f.problems + (ja ? '）' : ')')));\n    if (p.fix_problem) lines.push('    :warning: ' + p.fix_problem);\n  }\n  return lines.join('\\n');\n}\n\nconst cfg = $('Config').first().json;\nconst history = $('Read History tab').all().map((i) => i.json).filter((h) => h.url);\nreturn $input.all().map((it, i) => {\n  const pg = $('List pages').itemMatching(i).json;\n  const p = summarize(it.json, pg.url, pg.strategy, Number(cfg.max_audits));\n  p.flags = flagsOf(p, cfg);\n  p.change = changeOf(p, previousOf(p, history), cfg);\n  p.needs_fixes = p.ok && p.flags.length > 0 && p.audits.length > 0;\n  p.facts = p.ok ? factsOf(p, i + 1) : [];\n  return { json: p };\n});"
      }
    },
    {
      "id": "ab6ea469-202e-4f17-8a97-e972d5b5a5b6",
      "name": "History rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1640,
        -400
      ],
      "parameters": {
        "jsCode": "const now = $now.toISO();\nreturn $input.all().map(({ json: p }) => ({ json: { row_key: now.slice(0, 10) + ' ' + p.strategy + ' ' + p.url, checked_at: now, url: p.url, strategy: p.strategy, score: p.ok ? p.score : '', lcp_ms: p.ok ? p.lcp_ms : '', cls: p.ok ? p.cls : '',\n  tbt_ms: p.ok ? p.tbt_ms : '', fcp_ms: p.ok ? p.fcp_ms : '', si_ms: p.ok ? p.si_ms : '', flags: p.flags.join('; '), field_category: p.field?.category ?? '', lighthouse: p.lighthouse ?? '', fetch_time: p.fetch_time ?? '', error: p.ok ? '' : p.error } }));"
      }
    },
    {
      "id": "7952b6ca-84a5-4c6b-8ad0-7a9fa9882694",
      "name": "Save to History tab",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1860,
        -400
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "PageSpeed"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "row_key"
          ],
          "schema": []
        },
        "options": {
          "cellFormat": "RAW"
        }
      }
    },
    {
      "id": "2ec3e33b-a02d-4f8d-8d0b-93f7e04b408b",
      "name": "Needs fixes?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1640,
        100
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "n1",
              "leftValue": "={{ $json.needs_fixes }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "3f88b9ce-8536-49d4-8aa9-6f95661e68f4",
      "name": "AI: Explain the fixes",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        1860,
        0
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5-20251001"
        },
        "messages": {
          "values": [
            {
              "content": "=You help a website owner fix a slow page. Below are facts measured by Google PageSpeed Insights for ONE page: its metrics and the audits that failed.\n\n# Rules\n- Write at most {{ $('Config').first().json.max_fixes }} fixes, the most useful first. Each fix is about one or two of the failed audits below.\n- Say in plain words what to change on the site (for someone who is not a developer), then why it helps.\n- Use only numbers that are in the facts you cite. Do not estimate new numbers. Do not promise a score.\n- If you are not sure what causes an audit on this page, say what to look at instead of guessing.\n- How the site is built: {{ $('Config').first().json.site_notes || 'unknown' }}. Do not name any CMS, framework or host that is not in this line.\n- Savings are estimates by PageSpeed: write them as estimates (\"up to about\"), not as facts.\n- Write in {{ $('Config').first().json.output_language }}.\n\n# Output (JSON only, no explanation, no code block)\n{\"fixes\": [{\"fact_ids\": [\"P1-A1\"], \"text\": \"...\"}]}\n\n# Facts\n{{ JSON.stringify($json.facts, null, 1) }}",
              "role": "user"
            }
          ]
        },
        "simplify": true,
        "options": {
          "maxTokens": 900,
          "temperature": 0
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "cff3fec6-a49d-4063-893b-954a022ff64c",
      "name": "Check the fixes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        0
      ],
      "parameters": {
        "jsCode": "// ---- weekly PageSpeed check: all numbers by code ---------------------------------------------------------\nconst nfkc = (s) => String(s ?? '').normalize('NFKC');\nconst r1 = (x) => Math.round(x * 10) / 10;\nconst r2 = (x) => Math.round(x * 100) / 100;\n// metric audits are results, not things to fix\nconst PLATFORMS = ['WordPress', 'Shopify', 'Wix', 'Squarespace', 'Webflow', 'Drupal', 'Joomla', 'Jimdo', 'STUDIO', 'ペライチ', 'BASE', 'Next.js', 'Nuxt', 'Gatsby', 'Astro', 'Hugo', 'Jekyll', 'React', 'Vue', 'Angular', 'Svelte', 'jQuery', 'Bootstrap', 'Tailwind', 'Elementor', 'Cloudflare', 'Vercel', 'Netlify'];\nconst METRICS = new Set(['first-contentful-paint', 'largest-contentful-paint', 'speed-index', 'total-blocking-time', 'cumulative-layout-shift', 'interactive', 'max-potential-fid', 'first-meaningful-paint']);\n\nfunction pageList(text, strategies) {\n  const st = String(strategies ?? 'mobile').split(/[\\s,]+/).filter((s) => s === 'mobile' || s === 'desktop');\n  const out = [], problems = [];\n  for (const line of String(text ?? '').split(/\\r?\\n/)) {\n    const u = line.trim();\n    if (!u || u.startsWith('#')) continue;\n    if (!/^https?:\\/\\/[^\\s/]+\\.[^\\s]+$/.test(u)) { problems.push('not a full URL: ' + u); continue; }\n    for (const s of st) out.push({ url: u, strategy: s });\n  }\n  if (!st.length) problems.push('strategies must be mobile and/or desktop');\n  return { pages: out, problems };\n}\n\n// one PageSpeed answer -> the numbers we use; an error is kept as a page that could not be measured\nfunction summarize(j, url, strategy, maxAudits) {\n  const L = j?.lighthouseResult;\n  if (!L || !L.categories?.performance) return { url, strategy, ok: false, error: String(j?.error?.message ?? j?.message ?? 'no Lighthouse result').slice(0, 200) };\n  const a = L.audits ?? {};\n  const v = (id) => a[id]?.numericValue;\n  // PageSpeed scores an error page like any other page: the HTTP status is only in the network requests and a run warning\n  const reqs = a['network-requests']?.details?.items ?? [];\n  const doc = reqs.find((r) => r.resourceType === 'Document' && (r.url === L.mainDocumentUrl || r.url === L.finalUrl)) ?? reqs.find((r) => r.resourceType === 'Document');\n  const status = Number(doc?.statusCode ?? 0);\n  const warnings = (L.runWarnings ?? []).map(String);\n  if (status >= 400 || warnings.some((w) => /status code: [45]\\d\\d/i.test(w))) return { url, strategy, ok: false, error: 'the page answered HTTP ' + (status || (warnings.join(' ').match(/status code: (\\d{3})/i) ?? [])[1]) + ' (PageSpeed still gave it a score)' };\n  const final = L.finalDisplayedUrl ?? L.finalUrl ?? url;\n  const audits = Object.values(a)\n    .filter((x) => !METRICS.has(x.id) && typeof x.score === 'number' && x.score < 0.9 && !['informative', 'notApplicable', 'manual'].includes(x.scoreDisplayMode))\n    .map((x) => ({ id: x.id, title: String(x.title ?? x.id), display: String(x.displayValue ?? ''), save_lcp_ms: Math.round(x.metricSavings?.LCP ?? 0), save_fcp_ms: Math.round(x.metricSavings?.FCP ?? 0),\n      save_kib: x.details?.overallSavingsBytes ? Math.round(x.details.overallSavingsBytes / 1024) : 0 }))\n    .sort((p, q) => (q.save_lcp_ms + q.save_fcp_ms) - (p.save_lcp_ms + p.save_fcp_ms) || q.save_kib - p.save_kib)\n    .slice(0, maxAudits);\n  const fe = j.loadingExperience?.metrics ?? {};\n  const p75 = (k) => fe[k]?.percentile;\n  return { url, strategy, ok: true, score: Math.round(L.categories.performance.score * 100), lcp_ms: Math.round(v('largest-contentful-paint') ?? 0), cls: r2(v('cumulative-layout-shift') ?? 0), tbt_ms: Math.round(v('total-blocking-time') ?? 0),\n    fcp_ms: Math.round(v('first-contentful-paint') ?? 0), si_ms: Math.round(v('speed-index') ?? 0), lighthouse: L.lighthouseVersion ?? '', fetch_time: L.fetchTime ?? '',\n    redirected_to: final.replace(/\\/$/, '') !== url.replace(/\\/$/, '') ? final : '', warnings,\n    field: j.loadingExperience?.overall_category ? { category: j.loadingExperience.overall_category, lcp_ms: p75('LARGEST_CONTENTFUL_PAINT_MS') ?? null, inp_ms: p75('INTERACTION_TO_NEXT_PAINT') ?? null, cls: p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') != null ? p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') / 100 : null } : null,\n    audits };\n}\n\n// the limits are decided in Config, the flags by code\nfunction flagsOf(p, cfg) {\n  if (!p.ok) return ['could not measure: ' + p.error];\n  const f = [];\n  if (p.score < Number(cfg.min_score)) f.push('score ' + p.score + ' < ' + cfg.min_score);\n  if (p.lcp_ms > Number(cfg.max_lcp_ms)) f.push('LCP ' + r1(p.lcp_ms / 1000) + ' s > ' + r1(Number(cfg.max_lcp_ms) / 1000) + ' s');\n  if (p.cls > Number(cfg.max_cls)) f.push('CLS ' + p.cls + ' > ' + cfg.max_cls);\n  if (p.tbt_ms > Number(cfg.max_tbt_ms)) f.push('TBT ' + p.tbt_ms + ' ms > ' + cfg.max_tbt_ms + ' ms');\n  return f;\n}\n// last week's row for the same page and strategy (History rows, newest last)\nfunction previousOf(p, history) {\n  const rows = (history ?? []).filter((h) => h.url === p.url && h.strategy === p.strategy && String(h.error ?? '') === '' && h.score !== '' && h.score != null);\n  return rows.length ? rows[rows.length - 1] : null;\n}\nfunction changeOf(p, prev, cfg) {\n  if (!p.ok || !prev) return null;\n  const ds = p.score - Number(prev.score), dl = p.lcp_ms - Number(prev.lcp_ms);\n  return { score: ds, lcp_ms: dl, since: String(prev.checked_at ?? '').slice(0, 10), worse: ds <= -Number(cfg.worse_score_points) || dl >= Number(cfg.worse_lcp_ms) };\n}\n\n// numbered facts for Claude: one for the page's metrics, one per failing audit\nfunction factsOf(p, i) {\n  const facts = [{ id: 'P' + i + '-M', kind: 'metrics', label: p.url + ' (' + p.strategy + ')', values: { score: p.score, lcp_s: r1(p.lcp_ms / 1000), lcp_ms: p.lcp_ms, cls: p.cls, tbt_ms: p.tbt_ms, fcp_s: r1(p.fcp_ms / 1000) } }];\n  p.audits.forEach((a, k) => facts.push({ id: 'P' + i + '-A' + (k + 1), kind: 'audit', label: a.title + (a.display ? ' (' + a.display + ')' : ''), audit: a.id,\n    values: { save_lcp_ms: a.save_lcp_ms, save_lcp_s: r1(a.save_lcp_ms / 1000), save_fcp_ms: a.save_fcp_ms, save_fcp_s: r1(a.save_fcp_ms / 1000), save_kib: a.save_kib } }));\n  return facts;\n}\nfunction numbersOfFacts(facts) {\n  const s = new Set();\n  for (const f of facts) {\n    for (const v of Object.values(f.values)) if (typeof v === 'number' && Number.isFinite(v)) { s.add(String(v)); s.add(String(r1(v))); if (Number.isInteger(v)) s.add(v.toFixed(1)); }\n    for (const m of nfkc(f.label).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? []) s.add(m);\n  }\n  return s;\n}\n// Claude's fixes for one page; each must stand on audits that failed on that page\nfunction checkFixes(raw, facts, cfg) {\n  let ai = null;\n  try {\n    const t = String(raw ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n    const a = t.indexOf('{'), z = t.lastIndexOf('}');\n    ai = JSON.parse(a >= 0 && z > a ? t.slice(a, z + 1) : t);\n  } catch (e) { ai = null; }\n  if (!ai) return { ok: false, problem: 'the AI output could not be read as JSON', items: [] };\n  const byId = new Map(facts.map((f) => [f.id, f]));\n  const items = (Array.isArray(ai.fixes) ? ai.fixes : []).slice(0, Number(cfg.max_fixes)).map((o) => {\n    const text = String(o?.text ?? '').trim();\n    const ids = (Array.isArray(o?.fact_ids) ? o.fact_ids : []).map(String);\n    const problems = [];\n    if (!text) problems.push('empty');\n    const unknown = ids.filter((id) => !byId.has(id));\n    if (unknown.length) problems.push('unknown facts ' + unknown.join(', '));\n    const used = ids.filter((id) => byId.has(id)).map((id) => byId.get(id));\n    if (!used.some((f) => f.kind === 'audit')) problems.push('no failing audit cited');\n    const allowed = numbersOfFacts(used);\n    const nums = nfkc(text).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? [];\n    const bad = [...new Set(nums)].filter((n) => !allowed.has(n) && !allowed.has(String(Number(n))));\n    if (bad.length) problems.push('numbers not in the cited facts: ' + bad.join(', '));\n    // the AI must not assume how the site is built: a platform it names must be in site_notes\n    const notes = nfkc(cfg.site_notes).toLowerCase();\n    const says = (w) => /^[A-Za-z.]+$/.test(w) ? new RegExp('(^|[^A-Za-z])' + w.replace(/[.]/g, '[.]') + '([^A-Za-z]|$)', w === w.toUpperCase() ? '' : 'i').test(nfkc(text)) : nfkc(text).includes(w);\n    const named = PLATFORMS.filter((w) => says(w) && !notes.includes(w.toLowerCase()));\n    if (named.length) problems.push('assumes a platform not in site_notes: ' + named.join(', '));\n    return { text, fact_ids: ids, audits: used.filter((f) => f.kind === 'audit').map((f) => f.audit), ok: problems.length === 0, problems: problems.join('; ') };\n  });\n  return { ok: true, problem: '', items };\n}\n\nfunction slackText(pages, cfg, today) {\n  const ja = String(cfg.language).startsWith('ja');\n  const s = (ms) => r1(ms / 1000) + ' s';\n  const head = (ja ? '*ページ速度の週次チェック* ' : '*Weekly PageSpeed check* ') + today + (ja ? '（ラボ計測・1回）' : ' (lab, one run)');\n  const lines = [head];\n  for (const p of pages) {\n    const mark = !p.ok ? ':grey_question:' : p.flags.length ? ':warning:' : ':white_check_mark:';\n    let l = mark + ' ' + p.url + ' [' + p.strategy + ']';\n    if (p.ok) {\n      l += '  ' + (ja ? 'スコア ' : 'score ') + p.score + ' / LCP ' + s(p.lcp_ms) + ' / CLS ' + p.cls + ' / TBT ' + p.tbt_ms + ' ms';\n      if (p.change) l += (ja ? '  前回(' : '  vs ') + p.change.since + (ja ? ')比 ' : ': ') + (p.change.score >= 0 ? '+' : '') + p.change.score + (ja ? '点' : ' pts') + ', LCP ' + (p.change.lcp_ms >= 0 ? '+' : '') + r1(p.change.lcp_ms / 1000) + ' s' + (p.change.worse ? ' :small_red_triangle_down:' : '');\n      if (p.field) l += (ja ? '  実ユーザー: ' : '  real users: ') + p.field.category;\n      if (p.redirected_to) l += (ja ? '  転送先: ' : '  redirected to ') + p.redirected_to;\n    } else l += '  ' + p.error;\n    lines.push(l);\n    if (p.flags.length && p.ok) lines.push('    ' + p.flags.join(', '));\n    for (const f of p.fixes ?? []) lines.push('    ' + (f.ok ? '• ' : ':warning: ') + f.text + (f.ok ? '' : (ja ? '（確認が必要: ' : ' (check: ') + f.problems + (ja ? '）' : ')')));\n    if (p.fix_problem) lines.push('    :warning: ' + p.fix_problem);\n  }\n  return lines.join('\\n');\n}\n\nconst cfg = $('Config').first().json;\nreturn $input.all().map((it, i) => {\n  const p = $('Needs fixes?').itemMatching(i).json;\n  const c = checkFixes(it.json.content?.[0]?.text ?? it.json.text, p.facts, cfg);\n  return { json: { ...p, fixes: c.items, fix_problem: c.ok ? '' : c.problem } };\n});"
      }
    },
    {
      "id": "f588919e-261b-4a25-820c-764425dc5237",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        2300,
        100
      ],
      "parameters": {
        "mode": "append"
      }
    },
    {
      "id": "72ee7afd-3fef-4d64-84a3-d5bc30f0115e",
      "name": "Build the message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2680,
        100
      ],
      "parameters": {
        "jsCode": "// ---- weekly PageSpeed check: all numbers by code ---------------------------------------------------------\nconst nfkc = (s) => String(s ?? '').normalize('NFKC');\nconst r1 = (x) => Math.round(x * 10) / 10;\nconst r2 = (x) => Math.round(x * 100) / 100;\n// metric audits are results, not things to fix\nconst PLATFORMS = ['WordPress', 'Shopify', 'Wix', 'Squarespace', 'Webflow', 'Drupal', 'Joomla', 'Jimdo', 'STUDIO', 'ペライチ', 'BASE', 'Next.js', 'Nuxt', 'Gatsby', 'Astro', 'Hugo', 'Jekyll', 'React', 'Vue', 'Angular', 'Svelte', 'jQuery', 'Bootstrap', 'Tailwind', 'Elementor', 'Cloudflare', 'Vercel', 'Netlify'];\nconst METRICS = new Set(['first-contentful-paint', 'largest-contentful-paint', 'speed-index', 'total-blocking-time', 'cumulative-layout-shift', 'interactive', 'max-potential-fid', 'first-meaningful-paint']);\n\nfunction pageList(text, strategies) {\n  const st = String(strategies ?? 'mobile').split(/[\\s,]+/).filter((s) => s === 'mobile' || s === 'desktop');\n  const out = [], problems = [];\n  for (const line of String(text ?? '').split(/\\r?\\n/)) {\n    const u = line.trim();\n    if (!u || u.startsWith('#')) continue;\n    if (!/^https?:\\/\\/[^\\s/]+\\.[^\\s]+$/.test(u)) { problems.push('not a full URL: ' + u); continue; }\n    for (const s of st) out.push({ url: u, strategy: s });\n  }\n  if (!st.length) problems.push('strategies must be mobile and/or desktop');\n  return { pages: out, problems };\n}\n\n// one PageSpeed answer -> the numbers we use; an error is kept as a page that could not be measured\nfunction summarize(j, url, strategy, maxAudits) {\n  const L = j?.lighthouseResult;\n  if (!L || !L.categories?.performance) return { url, strategy, ok: false, error: String(j?.error?.message ?? j?.message ?? 'no Lighthouse result').slice(0, 200) };\n  const a = L.audits ?? {};\n  const v = (id) => a[id]?.numericValue;\n  // PageSpeed scores an error page like any other page: the HTTP status is only in the network requests and a run warning\n  const reqs = a['network-requests']?.details?.items ?? [];\n  const doc = reqs.find((r) => r.resourceType === 'Document' && (r.url === L.mainDocumentUrl || r.url === L.finalUrl)) ?? reqs.find((r) => r.resourceType === 'Document');\n  const status = Number(doc?.statusCode ?? 0);\n  const warnings = (L.runWarnings ?? []).map(String);\n  if (status >= 400 || warnings.some((w) => /status code: [45]\\d\\d/i.test(w))) return { url, strategy, ok: false, error: 'the page answered HTTP ' + (status || (warnings.join(' ').match(/status code: (\\d{3})/i) ?? [])[1]) + ' (PageSpeed still gave it a score)' };\n  const final = L.finalDisplayedUrl ?? L.finalUrl ?? url;\n  const audits = Object.values(a)\n    .filter((x) => !METRICS.has(x.id) && typeof x.score === 'number' && x.score < 0.9 && !['informative', 'notApplicable', 'manual'].includes(x.scoreDisplayMode))\n    .map((x) => ({ id: x.id, title: String(x.title ?? x.id), display: String(x.displayValue ?? ''), save_lcp_ms: Math.round(x.metricSavings?.LCP ?? 0), save_fcp_ms: Math.round(x.metricSavings?.FCP ?? 0),\n      save_kib: x.details?.overallSavingsBytes ? Math.round(x.details.overallSavingsBytes / 1024) : 0 }))\n    .sort((p, q) => (q.save_lcp_ms + q.save_fcp_ms) - (p.save_lcp_ms + p.save_fcp_ms) || q.save_kib - p.save_kib)\n    .slice(0, maxAudits);\n  const fe = j.loadingExperience?.metrics ?? {};\n  const p75 = (k) => fe[k]?.percentile;\n  return { url, strategy, ok: true, score: Math.round(L.categories.performance.score * 100), lcp_ms: Math.round(v('largest-contentful-paint') ?? 0), cls: r2(v('cumulative-layout-shift') ?? 0), tbt_ms: Math.round(v('total-blocking-time') ?? 0),\n    fcp_ms: Math.round(v('first-contentful-paint') ?? 0), si_ms: Math.round(v('speed-index') ?? 0), lighthouse: L.lighthouseVersion ?? '', fetch_time: L.fetchTime ?? '',\n    redirected_to: final.replace(/\\/$/, '') !== url.replace(/\\/$/, '') ? final : '', warnings,\n    field: j.loadingExperience?.overall_category ? { category: j.loadingExperience.overall_category, lcp_ms: p75('LARGEST_CONTENTFUL_PAINT_MS') ?? null, inp_ms: p75('INTERACTION_TO_NEXT_PAINT') ?? null, cls: p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') != null ? p75('CUMULATIVE_LAYOUT_SHIFT_SCORE') / 100 : null } : null,\n    audits };\n}\n\n// the limits are decided in Config, the flags by code\nfunction flagsOf(p, cfg) {\n  if (!p.ok) return ['could not measure: ' + p.error];\n  const f = [];\n  if (p.score < Number(cfg.min_score)) f.push('score ' + p.score + ' < ' + cfg.min_score);\n  if (p.lcp_ms > Number(cfg.max_lcp_ms)) f.push('LCP ' + r1(p.lcp_ms / 1000) + ' s > ' + r1(Number(cfg.max_lcp_ms) / 1000) + ' s');\n  if (p.cls > Number(cfg.max_cls)) f.push('CLS ' + p.cls + ' > ' + cfg.max_cls);\n  if (p.tbt_ms > Number(cfg.max_tbt_ms)) f.push('TBT ' + p.tbt_ms + ' ms > ' + cfg.max_tbt_ms + ' ms');\n  return f;\n}\n// last week's row for the same page and strategy (History rows, newest last)\nfunction previousOf(p, history) {\n  const rows = (history ?? []).filter((h) => h.url === p.url && h.strategy === p.strategy && String(h.error ?? '') === '' && h.score !== '' && h.score != null);\n  return rows.length ? rows[rows.length - 1] : null;\n}\nfunction changeOf(p, prev, cfg) {\n  if (!p.ok || !prev) return null;\n  const ds = p.score - Number(prev.score), dl = p.lcp_ms - Number(prev.lcp_ms);\n  return { score: ds, lcp_ms: dl, since: String(prev.checked_at ?? '').slice(0, 10), worse: ds <= -Number(cfg.worse_score_points) || dl >= Number(cfg.worse_lcp_ms) };\n}\n\n// numbered facts for Claude: one for the page's metrics, one per failing audit\nfunction factsOf(p, i) {\n  const facts = [{ id: 'P' + i + '-M', kind: 'metrics', label: p.url + ' (' + p.strategy + ')', values: { score: p.score, lcp_s: r1(p.lcp_ms / 1000), lcp_ms: p.lcp_ms, cls: p.cls, tbt_ms: p.tbt_ms, fcp_s: r1(p.fcp_ms / 1000) } }];\n  p.audits.forEach((a, k) => facts.push({ id: 'P' + i + '-A' + (k + 1), kind: 'audit', label: a.title + (a.display ? ' (' + a.display + ')' : ''), audit: a.id,\n    values: { save_lcp_ms: a.save_lcp_ms, save_lcp_s: r1(a.save_lcp_ms / 1000), save_fcp_ms: a.save_fcp_ms, save_fcp_s: r1(a.save_fcp_ms / 1000), save_kib: a.save_kib } }));\n  return facts;\n}\nfunction numbersOfFacts(facts) {\n  const s = new Set();\n  for (const f of facts) {\n    for (const v of Object.values(f.values)) if (typeof v === 'number' && Number.isFinite(v)) { s.add(String(v)); s.add(String(r1(v))); if (Number.isInteger(v)) s.add(v.toFixed(1)); }\n    for (const m of nfkc(f.label).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? []) s.add(m);\n  }\n  return s;\n}\n// Claude's fixes for one page; each must stand on audits that failed on that page\nfunction checkFixes(raw, facts, cfg) {\n  let ai = null;\n  try {\n    const t = String(raw ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n    const a = t.indexOf('{'), z = t.lastIndexOf('}');\n    ai = JSON.parse(a >= 0 && z > a ? t.slice(a, z + 1) : t);\n  } catch (e) { ai = null; }\n  if (!ai) return { ok: false, problem: 'the AI output could not be read as JSON', items: [] };\n  const byId = new Map(facts.map((f) => [f.id, f]));\n  const items = (Array.isArray(ai.fixes) ? ai.fixes : []).slice(0, Number(cfg.max_fixes)).map((o) => {\n    const text = String(o?.text ?? '').trim();\n    const ids = (Array.isArray(o?.fact_ids) ? o.fact_ids : []).map(String);\n    const problems = [];\n    if (!text) problems.push('empty');\n    const unknown = ids.filter((id) => !byId.has(id));\n    if (unknown.length) problems.push('unknown facts ' + unknown.join(', '));\n    const used = ids.filter((id) => byId.has(id)).map((id) => byId.get(id));\n    if (!used.some((f) => f.kind === 'audit')) problems.push('no failing audit cited');\n    const allowed = numbersOfFacts(used);\n    const nums = nfkc(text).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '').match(/\\d+(?:\\.\\d+)?/g) ?? [];\n    const bad = [...new Set(nums)].filter((n) => !allowed.has(n) && !allowed.has(String(Number(n))));\n    if (bad.length) problems.push('numbers not in the cited facts: ' + bad.join(', '));\n    // the AI must not assume how the site is built: a platform it names must be in site_notes\n    const notes = nfkc(cfg.site_notes).toLowerCase();\n    const says = (w) => /^[A-Za-z.]+$/.test(w) ? new RegExp('(^|[^A-Za-z])' + w.replace(/[.]/g, '[.]') + '([^A-Za-z]|$)', w === w.toUpperCase() ? '' : 'i').test(nfkc(text)) : nfkc(text).includes(w);\n    const named = PLATFORMS.filter((w) => says(w) && !notes.includes(w.toLowerCase()));\n    if (named.length) problems.push('assumes a platform not in site_notes: ' + named.join(', '));\n    return { text, fact_ids: ids, audits: used.filter((f) => f.kind === 'audit').map((f) => f.audit), ok: problems.length === 0, problems: problems.join('; ') };\n  });\n  return { ok: true, problem: '', items };\n}\n\nfunction slackText(pages, cfg, today) {\n  const ja = String(cfg.language).startsWith('ja');\n  const s = (ms) => r1(ms / 1000) + ' s';\n  const head = (ja ? '*ページ速度の週次チェック* ' : '*Weekly PageSpeed check* ') + today + (ja ? '（ラボ計測・1回）' : ' (lab, one run)');\n  const lines = [head];\n  for (const p of pages) {\n    const mark = !p.ok ? ':grey_question:' : p.flags.length ? ':warning:' : ':white_check_mark:';\n    let l = mark + ' ' + p.url + ' [' + p.strategy + ']';\n    if (p.ok) {\n      l += '  ' + (ja ? 'スコア ' : 'score ') + p.score + ' / LCP ' + s(p.lcp_ms) + ' / CLS ' + p.cls + ' / TBT ' + p.tbt_ms + ' ms';\n      if (p.change) l += (ja ? '  前回(' : '  vs ') + p.change.since + (ja ? ')比 ' : ': ') + (p.change.score >= 0 ? '+' : '') + p.change.score + (ja ? '点' : ' pts') + ', LCP ' + (p.change.lcp_ms >= 0 ? '+' : '') + r1(p.change.lcp_ms / 1000) + ' s' + (p.change.worse ? ' :small_red_triangle_down:' : '');\n      if (p.field) l += (ja ? '  実ユーザー: ' : '  real users: ') + p.field.category;\n      if (p.redirected_to) l += (ja ? '  転送先: ' : '  redirected to ') + p.redirected_to;\n    } else l += '  ' + p.error;\n    lines.push(l);\n    if (p.flags.length && p.ok) lines.push('    ' + p.flags.join(', '));\n    for (const f of p.fixes ?? []) lines.push('    ' + (f.ok ? '• ' : ':warning: ') + f.text + (f.ok ? '' : (ja ? '（確認が必要: ' : ' (check: ') + f.problems + (ja ? '）' : ')')));\n    if (p.fix_problem) lines.push('    :warning: ' + p.fix_problem);\n  }\n  return lines.join('\\n');\n}\n\nconst cfg = $('Config').first().json;\n// back in the order of the Config list\nconst order = $('List pages').all().map((i) => i.json.url + ' ' + i.json.strategy);\nconst pages = $input.all().map((i) => i.json).sort((a, b) => order.indexOf(a.url + ' ' + a.strategy) - order.indexOf(b.url + ' ' + b.strategy));\nreturn [{ json: { text: slackText(pages, cfg, $now.toFormat('yyyy-MM-dd')), pages: pages.length, flagged: pages.filter((p) => p.flags.length).length } }];"
      }
    },
    {
      "id": "4785ad3a-aa6a-4f90-82e4-d30e65344d54",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        2900,
        100
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "={{ $json.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      }
    },
    {
      "id": "d5e3657a-c0e0-410c-8fa4-110980c66a96",
      "name": "Run once: set up the sheet",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        800
      ],
      "parameters": {}
    },
    {
      "id": "86dd60e1-9bb1-4a0d-8a73-91538e0fe878",
      "name": "Add the History tab",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        440,
        800
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $(\"Spreadsheet ID\").first().json.spreadsheet_id }}:batchUpdate",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={\"requests\":[{\"addSheet\":{\"properties\":{\"title\":\"PageSpeed\"}}}]}",
        "options": {}
      }
    },
    {
      "id": "c18a609b-c36d-4b34-8d23-06c60742ba7f",
      "name": "Write the header row",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        660,
        800
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $(\"Spreadsheet ID\").first().json.spreadsheet_id }}/values:batchUpdate",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={\"valueInputOption\":\"RAW\",\"data\":[{\"range\":\"'PageSpeed'!A1\",\"values\":[[\"row_key\",\"checked_at\",\"url\",\"strategy\",\"score\",\"lcp_ms\",\"cls\",\"tbt_ms\",\"fcp_ms\",\"si_ms\",\"flags\",\"field_category\",\"lighthouse\",\"fetch_time\",\"error\"]]}]}",
        "options": {}
      }
    },
    {
      "id": "b0b0b0b0-0000-4000-8000-0000000000b2",
      "name": "Spreadsheet ID",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        800
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "sid1",
              "name": "spreadsheet_id",
              "value": "PASTE_YOUR_SPREADSHEET_ID_HERE",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "parameters": {
        "content": "## 1. Every Monday at 8:00\nConfig: the pages (one full URL per line), mobile and/or desktop, the limits, what counts as worse than last week, and the languages.",
        "height": 630,
        "width": 420,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -310
      ],
      "id": "sticky-2-8b3394e5",
      "name": "Section 1"
    },
    {
      "parameters": {
        "content": "## 2. Measure\nOne PageSpeed request per page, one at a time (each takes 10-60 s). Code reads the score, LCP, CLS, TBT and the failing audits, flags the page and compares it with its last row in History.",
        "height": 354,
        "width": 860,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        540,
        -184
      ],
      "id": "sticky-3-4dae9fca",
      "name": "Section 2"
    },
    {
      "parameters": {
        "content": "## 3. Keep the history\nOne row per page per day; running twice on the same day updates the row.",
        "height": 354,
        "width": 420,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1580,
        -584
      ],
      "id": "sticky-4-de257031",
      "name": "Section 3"
    },
    {
      "parameters": {
        "content": "## 4. Claude explains, code checks\nOnly failing pages go to Claude. A fix that cites no failing audit, an unknown audit or a number that is not in its facts is marked in Slack.",
        "height": 428,
        "width": 860,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1580,
        -158
      ],
      "id": "sticky-5-f7eb94c8",
      "name": "Section 4"
    },
    {
      "parameters": {
        "content": "## 5. Report\nNumbers by code first, then the checked fixes.",
        "height": 328,
        "width": 420,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2620,
        -58
      ],
      "id": "sticky-6-53c7e0f4",
      "name": "Section 5"
    },
    {
      "parameters": {
        "content": "## Run once: set up the sheet\nPaste your spreadsheet ID and run once: it adds the PageSpeed tab with its header row.",
        "height": 328,
        "width": 860,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        642
      ],
      "id": "sticky-7-796637ba",
      "name": "Section 6"
    },
    {
      "parameters": {
        "content": "## Which pages got slow, and what to fix first\nEvery Monday the workflow runs **Google PageSpeed Insights** (Lighthouse) for the pages you list. **Code decides which pages fail** your limits (performance score, LCP, CLS, TBT) and compares each page with last week in a Google Sheet. For the failing pages only, Claude explains the fixes in plain words, and **code checks every fix**: it must stand on an audit that actually failed on that page, and every number must come from that audit or the page's metrics. Slack gets one message.\n\n### How it works\n1. One PageSpeed request per page and strategy (mobile by default), one after another.\n2. A page that cannot be measured stays in the report with the error.\n3. History is saved before the AI step, so a failed AI call never loses the numbers.\n\n### Setup\n1. Google Cloud: enable the PageSpeed Insights API and create an API key. In n8n, create a **Query Auth** credential (name `key`, value = the key).\n2. Blank Google Sheet: paste its ID in **Spreadsheet ID**, run **Run once**, select the sheet in both Google Sheets nodes.\n3. Credentials: Google Sheets, Anthropic, Slack (pick a channel). List your pages and limits in **Config**. Publish.\n\n### Note\nLab results come from one test run per page. Treat small changes as noise; the defaults only call a change \"worse\" at 10 points or 1 s of LCP.",
        "height": 940,
        "width": 780,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -130,
        -1664
      ],
      "id": "sticky-1-ff0405ab",
      "name": "Overview"
    }
  ],
  "connections": {
    "Every Monday at 8:00": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run now": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Read History tab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read History tab": {
      "main": [
        [
          {
            "node": "List pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List pages": {
      "main": [
        [
          {
            "node": "Get PageSpeed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get PageSpeed": {
      "main": [
        [
          {
            "node": "Summarize pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize pages": {
      "main": [
        [
          {
            "node": "History rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Needs fixes?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "History rows": {
      "main": [
        [
          {
            "node": "Save to History tab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs fixes?": {
      "main": [
        [
          {
            "node": "AI: Explain the fixes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "AI: Explain the fixes": {
      "main": [
        [
          {
            "node": "Check the fixes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the fixes": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Build the message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the message": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run once: set up the sheet": {
      "main": [
        [
          {
            "node": "Spreadsheet ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add the History tab": {
      "main": [
        [
          {
            "node": "Write the header row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Spreadsheet ID": {
      "main": [
        [
          {
            "node": "Add the History tab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
