Googleレビュー - テンプレート2

上級

これは自動化ワークフローで、62個のノードを含みます。主にCode, Gmail, Merge, Slack, HttpRequestなどのノードを使用。 GPTによる洞察に基づくGoogle BusinessレポートをSlackとメールに自動送信

前提条件
  • Googleアカウント + Gmail API認証情報
  • Slack Bot Token または Webhook URL
  • ターゲットAPIの認証情報が必要な場合あり
  • Google Sheets API認証情報
  • OpenAI API Key

カテゴリー

-
ワークフロープレビュー
ノード接続関係を可視化、ズームとパンをサポート
ワークフローをエクスポート
以下のJSON設定をn8nにインポートして、このワークフローを使用できます
{
  "id": "ysIu0eYMKlOhtV8D",
  "meta": {
    "instanceId": "40f67202a0c7d6108d2560d7802e849a11ba1d2f1edc7c24c5fa903cab90a8f2",
    "templateCredsSetupCompleted": true
  },
  "name": "Google Review - Template 2",
  "tags": [],
  "nodes": [
    {
      "id": "c6e82665-fa63-4cbf-9e83-dcdc9d99bba3",
      "name": "OpenAI Chat Model4",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -832,
        2848
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5",
          "cachedResultName": "gpt-5"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "id": "0wxxfUy5p26AEoAX",
          "name": "OpenAi account"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a83547f7-dfac-4064-b0ab-a98c4d5668c4",
      "name": "企業情報を読み込む",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -4000,
        2160
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=0",
          "cachedResultName": "Companies"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "8d733b11-c5e6-4f3b-a065-3b5c7b44869a",
      "name": "週次ウィンドウを構築2",
      "type": "n8n-nodes-base.code",
      "position": [
        -3472,
        2160
      ],
      "parameters": {
        "jsCode": "// Builds \"this week\" (last 7 full days ending yesterday, UTC)\n// AND \"last week\" (the 7 days before that), per-company.\n//\n// Input items: rows from \"Read Companies\" with { company, accountId, locationId }\n// Output items: same company fields + week parts for API + ts for comparisons\n\nfunction atUtcMidnight(d = new Date()) {\n  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));\n}\n\n// This week: last 7 days ending yesterday (inclusive)\nconst todayMid = atUtcMidnight();\nconst endThis = new Date(todayMid); endThis.setUTCDate(endThis.getUTCDate() - 1);\nconst startThis = new Date(endThis); startThis.setUTCDate(startThis.getUTCDate() - 6);\n\n// Last week: the 7 days before \"this week\"\nconst endLast = new Date(startThis); endLast.setUTCDate(endLast.getUTCDate() - 1);\nconst startLast = new Date(endLast); startLast.setUTCDate(startLast.getUTCDate() - 6);\n\nconst parts = d => ({\n  Y: d.getUTCFullYear(),\n  M: d.getUTCMonth() + 1,\n  D: d.getUTCDate(),\n});\nconst isoDate = d => d.toISOString().slice(0,10);\n\nreturn items.map(({ json }) => {\n  const sT = parts(startThis), eT = parts(endThis);\n  const sL = parts(startLast), eL = parts(endLast);\n\n  return {\n    json: {\n      // carry company identifiers\n      company: json.company,\n      accountId: json.accountId,\n      locationId: json.locationId,\n\n      // THIS WEEK (for API)\n      startY: sT.Y, startM: sT.M, startD: sT.D,\n      endY:   eT.Y, endM: eT.M, endD: eT.D,\n      startISO: startThis.toISOString(),\n      endISO:   endThis.toISOString(),\n      week_start: isoDate(startThis),\n      week_end:   isoDate(endThis),\n\n      // LAST WEEK (extra fields so downstream nodes can compare)\n      last_startY: sL.Y, last_startM: sL.M, last_startD: sL.D,\n      last_endY:   eL.Y, last_endM: eL.M, last_endD: eL.D,\n      last_startISO: startLast.toISOString(),\n      last_endISO:   endLast.toISOString(),\n      last_week_start: isoDate(startLast),\n      last_week_end:   isoDate(endLast),\n\n      // Millisecond timestamps for code nodes that prefer numeric ranges\n      ts: {\n        startThis: startThis.getTime(),\n        endThis:   endThis.getTime() + 24*60*60*1000 - 1,  // inclusive guard\n        startLast: startLast.getTime(),\n        endLast:   endLast.getTime() + 24*60*60*1000 - 1,\n      },\n    },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "e982ec74-55f0-418b-9790-4d17cb929103",
      "name": "インプレッションデータを取得",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3280,
        2160
      ],
      "parameters": {
        "url": "={{\n(() => {\n  const base = `https://businessprofileperformance.googleapis.com/v1/locations/${$json.locationId}:fetchMultiDailyMetricsTimeSeries`;\n  const metrics = [\n    'WEBSITE_CLICKS',\n    'CALL_CLICKS',\n    'BUSINESS_DIRECTION_REQUESTS',\n    'BUSINESS_IMPRESSIONS_DESKTOP_SEARCH',\n    'BUSINESS_IMPRESSIONS_MOBILE_SEARCH',\n    'BUSINESS_IMPRESSIONS_DESKTOP_MAPS',\n    'BUSINESS_IMPRESSIONS_MOBILE_MAPS'\n  ];\n  const q = [\n    ...metrics.map(m => `dailyMetrics=${encodeURIComponent(m)}`),\n    `dailyRange.start_date.year=${$json.startY}`,\n    `dailyRange.start_date.month=${$json.startM}`,\n    `dailyRange.start_date.day=${$json.startD}`,\n    `dailyRange.end_date.year=${$json.endY}`,\n    `dailyRange.end_date.month=${$json.endM}`,\n    `dailyRange.end_date.day=${$json.endD}`\n  ].join('&');\n  return `${base}?${q}`;\n})()\n}}\n",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json"
            }
          }
        },
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleBusinessProfileOAuth2Api"
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "JBlnixnNTbXzI9WJ",
          "name": "Google Business Profile account 2"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "2c9d403b-d03d-43fd-900e-60089927fc03",
      "name": "フラット化",
      "type": "n8n-nodes-base.code",
      "position": [
        -3104,
        2160
      ],
      "parameters": {
        "jsCode": "// === Upstream node names (update if you renamed) ===\nconst META_NODE = 'Build Week Window2';\nconst HTTP_NODE = 'Get Impression data';\n\n// Pull aligned arrays\nconst metas = $items(META_NODE, 0, 0).map(i => i.json);\nconst httpItems = $items(HTTP_NODE, 0, 0).map(i => i.json || i);\n\nconst pad = n => String(n).padStart(2, '0');\n\nfunction sumPoints(arr) {\n  if (!Array.isArray(arr)) return 0;\n  return arr.reduce((acc, d) => acc + Number(d?.value ?? d?.count ?? 0), 0);\n}\n\nconst out = [];\n\nfor (let i = 0; i < metas.length; i++) {\n  const meta = metas[i] || {};\n  const http = httpItems[i] || {};\n  const body = http.body ?? http;\n\n  const series = body?.multiDailyMetricTimeSeries?.[0]?.dailyMetricTimeSeries || [];\n\n  const totals = {};\n  for (const s of series) {\n    const points = s?.timeSeries?.datedValues || s?.timeSeries?.dailyValues || [];\n    totals[s.dailyMetric] = sumPoints(points);\n  }\n\n  // splits\n  const DESKTOP_SEARCH = totals.BUSINESS_IMPRESSIONS_DESKTOP_SEARCH || 0;\n  const MOBILE_SEARCH  = totals.BUSINESS_IMPRESSIONS_MOBILE_SEARCH  || 0;\n  const DESKTOP_MAPS   = totals.BUSINESS_IMPRESSIONS_DESKTOP_MAPS   || 0;\n  const MOBILE_MAPS    = totals.BUSINESS_IMPRESSIONS_MOBILE_MAPS    || 0;\n\n  // combined\n  const impressions_search = DESKTOP_SEARCH + MOBILE_SEARCH;\n  const impressions_maps   = DESKTOP_MAPS  + MOBILE_MAPS;\n\n  out.push({\n    json: {\n      company: meta.company || '(unknown)',\n      locationId: meta.locationId || null,\n      week_start: `${meta.startY}-${pad(meta.startM)}-${pad(meta.startD)}`,\n      week_end:   `${meta.endY}-${pad(meta.endM)}-${pad(meta.endD)}`,\n\n      website_clicks: totals.WEBSITE_CLICKS || 0,\n      call_clicks: totals.CALL_CLICKS || 0,\n      direction_requests: totals.BUSINESS_DIRECTION_REQUESTS || 0,\n\n      impressions_search,\n      impressions_maps,\n\n      impressions_search_desktop: DESKTOP_SEARCH,\n      impressions_search_mobile:  MOBILE_SEARCH,\n      impressions_maps_desktop:   DESKTOP_MAPS,\n      impressions_maps_mobile:    MOBILE_MAPS,\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "0ac8d44a-7393-4bf8-9d59-364c5a4c7a2f",
      "name": "シートにインプレッションを追加",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2336,
        2160
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "company",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "company",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "locationId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "locationId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_start",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_start",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_end",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_end",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "website_clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "website_clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "call_clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "call_clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "direction_requests",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "direction_requests",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search_desktop",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search_desktop",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search_mobile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search_mobile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps_desktop",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps_desktop",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps_mobile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps_mobile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 467460706,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=467460706",
          "cachedResultName": "Impression Data"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "ef1c5e27-f336-4bbb-a668-a10abe6087c2",
      "name": "企業を設定(レビュー)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3296,
        2992
      ],
      "parameters": {
        "jsCode": "// Build items from the \"Read Companies\" node, not from the incoming payload\nconst companies = $items('Read Companies').map(i => i.json);\n\nreturn companies.map(c => ({\n  json: {\n    companyName: c.company,\n    // keep both the full path and the raw IDs for convenience\n    accountId: String(c.accountId),\n    locationId: String(c.locationId),\n    accountPath: `accounts/${c.accountId}`,\n    locationPath: `locations/${c.locationId}`,\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "887dd0d9-898e-4c32-adf3-bdbfeb580049",
      "name": "複数レビューを取得",
      "type": "n8n-nodes-base.googleBusinessProfile",
      "position": [
        -3104,
        2992
      ],
      "parameters": {
        "account": {
          "__rl": true,
          "mode": "list",
          "value": "accounts/102160578675379169778",
          "cachedResultName": "Peyton Leveillee"
        },
        "location": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $json.locationPath }}"
        },
        "resource": "review",
        "operation": "getAll",
        "returnAll": true,
        "requestOptions": {}
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "snpnWRsJVydiU7oU",
          "name": "Google Business Profile account"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "c0315534-5395-4ba9-b4d8-d2554455b156",
      "name": "週次ウィンドウを構築(レビュー)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3504,
        2992
      ],
      "parameters": {
        "jsCode": "// Builds \"this week\" (last 7 full days ending yesterday, UTC)\n// AND \"last week\" (the 7 days before that), as a single shared window item.\n\nfunction atUtcMidnight(d = new Date()) {\n  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));\n}\n\nconst todayMid = atUtcMidnight();\nconst endThis = new Date(todayMid); endThis.setUTCDate(endThis.getUTCDate() - 1);\nconst startThis = new Date(endThis); startThis.setUTCDate(startThis.getUTCDate() - 6);\n\nconst endLast = new Date(startThis); endLast.setUTCDate(endLast.getUTCDate() - 1);\nconst startLast = new Date(endLast); startLast.setUTCDate(startLast.getUTCDate() - 6);\n\nconst parts = d => ({\n  Y: d.getUTCFullYear(),\n  M: d.getUTCMonth() + 1,\n  D: d.getUTCDate(),\n});\nconst isoDate = d => d.toISOString().slice(0,10);\n\nconst sT = parts(startThis), eT = parts(endThis);\nconst sL = parts(startLast), eL = parts(endLast);\n\nreturn [{\n  json: {\n    // THIS WEEK\n    startY: sT.Y, startM: sT.M, startD: sT.D,\n    endY:   eT.Y, endM: eT.M, endD: eT.D,\n    startISO: startThis.toISOString(),\n    endISO:   endThis.toISOString(),\n    week_start: isoDate(startThis),\n    week_end:   isoDate(endThis),\n\n    // LAST WEEK\n    last_startY: sL.Y, last_startM: sL.M, last_startD: sL.D,\n    last_endY:   eL.Y, last_endM: eL.M, last_endD: eL.D,\n    last_startISO: startLast.toISOString(),\n    last_endISO:   endLast.toISOString(),\n    last_week_start: isoDate(startLast),\n    last_week_end:   isoDate(endLast),\n\n    // Numeric timestamps for easy filtering/comparison in later code nodes\n    ts: {\n      startThis: startThis.getTime(),\n      endThis:   endThis.getTime() + 24*60*60*1000 - 1,\n      startLast: startLast.getTime(),\n      endLast:   endLast.getTime() + 24*60*60*1000 - 1,\n    },\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8dfac27e-5b86-42da-8c83-d9170075e194",
      "name": "シートにレビューを追加",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2720,
        2992
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "companyName",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "companyName",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "locationId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "locationId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "reviewId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "reviewId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ratingNum",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ratingNum",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "createTime",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "createTime",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "comment",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "comment",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "sentiment_label",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "sentiment_label",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_start",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_start",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_end",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_end",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "key",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "key",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "sentimentCategory",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "sentimentCategory",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "hasReply",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "hasReply",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "replyLatencyHours",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "replyLatencyHours",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ratingText",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ratingText",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 307941098,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=307941098",
          "cachedResultName": "Review Data"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "30b1aa4a-255b-4d30-8ade-af80dabaab78",
      "name": "週次レビューを要約",
      "type": "n8n-nodes-base.code",
      "position": [
        -2528,
        2992
      ],
      "parameters": {
        "jsCode": "// Weekly Reviews Summary — Input: items from the weekly mapper above\nfunction avg(a){ return a.length ? +(a.reduce((x,y)=>x+y,0)/a.length).toFixed(2) : 0; }\nfunction median(a){\n  if (!a.length) return 0;\n  const s=[...a].sort((x,y)=>x-y), m=Math.floor(s.length/2);\n  return s.length%2 ? s[m] : +(((s[m-1]+s[m])/2).toFixed(2));\n}\n\nconst rows = $input.all().map(i => i.json);\n\n// group by company + week_start (use location too if you have multi-location per company)\nconst groups = new Map();\nfor (const r of rows) {\n  const key = `${r.companyName||'(unknown)'}__${r.week_start||''}`;\n  if (!groups.has(key)) groups.set(key, []);\n  groups.get(key).push(r);\n}\n\nconst out = [];\nfor (const [, list] of groups.entries()) {\n  const companyName = list[0].companyName || '(unknown)';\n  const locationId  = list[0].locationId || null;\n  const week_start  = list[0].week_start || null;\n  const week_end    = list[0].week_end || null;\n\n  const total = list.length;\n  const ratings = list.map(r => Number(r.ratingNum||0)).filter(n => Number.isFinite(n));\n  const avgRating = avg(ratings);\n\n  const pos = list.filter(r => r.sentiment_label === 'Positive').length;\n  const neu = list.filter(r => r.sentiment_label === 'Neutral').length;\n  const neg = list.filter(r => r.sentiment_label === 'Negative').length;\n  const positiveShare = total ? +((pos/total)*100).toFixed(1) : 0;\n\n  const replied = list.filter(r => r.hasReply === true).length;\n  const replyRatePct = total ? +((replied/total)*100).toFixed(1) : 0;\n\n  const latencies = list.map(r => (typeof r.replyLatencyHours === 'number') ? r.replyLatencyHours : null).filter(v => v !== null);\n  const medianReplyLatencyHours = median(latencies);\n\n  out.push({\n    json: {\n      companyName, locationId, week_start, week_end,\n      total_reviews: total,\n      positive: pos, neutral: neu, negative: neg,\n      positive_share_pct: positiveShare,\n      average_rating: avgRating,\n      reply_rate_pct: replyRatePct,\n      median_reply_latency_hours: medianReplyLatencyHours\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "f897b099-2e89-4537-84bd-fd74b381d453",
      "name": "週次要約を追加",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2336,
        2992
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "companyName",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "companyName",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "locationId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "locationId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_start",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_start",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_end",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_end",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "total_reviews",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "total_reviews",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "positive",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "positive",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "neutral",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "neutral",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "negative",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "negative",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "positive_share_pct",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "positive_share_pct",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "average_rating",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "average_rating",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "reply_rate_pct",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "reply_rate_pct",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "median_reply_latency_hours",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "median_reply_latency_hours",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 238935942,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=238935942",
          "cachedResultName": "Reviews Weekly Summary"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "50e504fd-5112-4f11-98c9-5f1b922d4a6a",
      "name": "企業を設定(総合レビュー)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3488,
        3616
      ],
      "parameters": {
        "jsCode": "// Build items from the \"Read Companies\" node, not from the incoming payload\nconst companies = $items('Read Companies').map(i => i.json);\n\nreturn companies.map(c => ({\n  json: {\n    companyName: c.company,\n    // keep both the full path and the raw IDs for convenience\n    accountId: String(c.accountId),\n    locationId: String(c.locationId),\n    accountPath: `accounts/${c.accountId}`,\n    locationPath: `locations/${c.locationId}`,\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "13e402fa-fbaa-4b98-93f3-22fc9c7dbf43",
      "name": "12週間ウィンドウを構築",
      "type": "n8n-nodes-base.code",
      "position": [
        -3488,
        2512
      ],
      "parameters": {
        "jsCode": "// Builds a date window covering the last 12 full ISO weeks ending yesterday\nconst now = new Date();\nconst end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));\nend.setUTCDate(end.getUTCDate() - 1); // yesterday\n\n// go back ~84 days (12 weeks)\nconst start = new Date(end);\nstart.setUTCDate(start.getUTCDate() - (7 * 12 - 1)); // inclusive start\n\nreturn $input.all().map(({ json }) => {\n  return {\n    json: {\n      ...json,\n      startY: start.getUTCFullYear(),\n      startM: start.getUTCMonth() + 1,\n      startD: start.getUTCDate(),\n      endY:   end.getUTCFullYear(),\n      endM:   end.getUTCMonth() + 1,\n      endD:   end.getUTCDate()\n    }\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "19b21559-b1e9-4803-8493-a8a55522162d",
      "name": "12週間インプレッション",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3296,
        2512
      ],
      "parameters": {
        "url": "={{\n(() => {\n  const base = `https://businessprofileperformance.googleapis.com/v1/locations/${$json.locationId}:fetchMultiDailyMetricsTimeSeries`;\n  const metrics = [\n    'WEBSITE_CLICKS',\n    'CALL_CLICKS',\n    'BUSINESS_DIRECTION_REQUESTS',\n    'BUSINESS_IMPRESSIONS_DESKTOP_SEARCH',\n    'BUSINESS_IMPRESSIONS_MOBILE_SEARCH',\n    'BUSINESS_IMPRESSIONS_DESKTOP_MAPS',\n    'BUSINESS_IMPRESSIONS_MOBILE_MAPS'\n  ];\n  const q = [\n    ...metrics.map(m => `dailyMetrics=${encodeURIComponent(m)}`),\n    `dailyRange.start_date.year=${$json.startY}`,\n    `dailyRange.start_date.month=${$json.startM}`,\n    `dailyRange.start_date.day=${$json.startD}`,\n    `dailyRange.end_date.year=${$json.endY}`,\n    `dailyRange.end_date.month=${$json.endM}`,\n    `dailyRange.end_date.day=${$json.endD}`\n  ].join('&');\n  return `${base}?${q}`;\n})()\n}}\n",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json"
            }
          }
        },
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleBusinessProfileOAuth2Api"
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "JBlnixnNTbXzI9WJ",
          "name": "Google Business Profile account 2"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "b954b33c-c0b8-426e-bdc7-127499c93f36",
      "name": "12週間データをフラット化",
      "type": "n8n-nodes-base.code",
      "position": [
        -3120,
        2512
      ],
      "parameters": {
        "jsCode": "// === Upstream node names (change if you renamed) ===\nconst META_NODE = 'Build 12-Week Window';\nconst HTTP_NODE = '12 week impressions';\n\n// Pull aligned arrays from the two upstream nodes\nconst metas = $items(META_NODE, 0, 0).map(i => i.json);\nconst httpItems = $items(HTTP_NODE, 0, 0).map(i => i.json || i);\n\n// helpers\nconst pad = n => String(n).padStart(2, '0');\n\nfunction sumPoints(arr) {\n  if (!Array.isArray(arr)) return 0;\n  return arr.reduce((acc, d) => acc + Number(d?.value ?? d?.count ?? 0), 0);\n}\n\nconst out = [];\n\nfor (let i = 0; i < metas.length; i++) {\n  const meta = metas[i] || {};\n  const http = httpItems[i] || {};\n  const body = http.body ?? http;\n\n  const series = body?.multiDailyMetricTimeSeries?.[0]?.dailyMetricTimeSeries || [];\n\n  // collect totals per metric name\n  const totals = {};\n  for (const s of series) {\n    const points = s?.timeSeries?.datedValues || s?.timeSeries?.dailyValues || [];\n    totals[s.dailyMetric] = sumPoints(points);\n  }\n\n  // raw splits\n  const DESKTOP_SEARCH = totals.BUSINESS_IMPRESSIONS_DESKTOP_SEARCH || 0;\n  const MOBILE_SEARCH  = totals.BUSINESS_IMPRESSIONS_MOBILE_SEARCH  || 0;\n  const DESKTOP_MAPS   = totals.BUSINESS_IMPRESSIONS_DESKTOP_MAPS   || 0;\n  const MOBILE_MAPS    = totals.BUSINESS_IMPRESSIONS_MOBILE_MAPS    || 0;\n\n  // combined\n  const impressions_search = DESKTOP_SEARCH + MOBILE_SEARCH;\n  const impressions_maps   = DESKTOP_MAPS  + MOBILE_MAPS;\n\n  out.push({\n    json: {\n      company: meta.company || '(unknown)',\n      locationId: meta.locationId || null,\n\n      week_start: `${meta.startY}-${pad(meta.startM)}-${pad(meta.startD)}`,\n      week_end:   `${meta.endY}-${pad(meta.endM)}-${pad(meta.endD)}`,\n\n      // clicks & requests\n      website_clicks: totals.WEBSITE_CLICKS || 0,\n      call_clicks: totals.CALL_CLICKS || 0,\n      direction_requests: totals.BUSINESS_DIRECTION_REQUESTS || 0,\n\n      // combined\n      impressions_search,\n      impressions_maps,\n\n      // granular splits\n      impressions_search_desktop: DESKTOP_SEARCH,\n      impressions_search_mobile:  MOBILE_SEARCH,\n      impressions_maps_desktop:   DESKTOP_MAPS,\n      impressions_maps_mobile:    MOBILE_MAPS,\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "ce979f8e-8b6b-4d3e-a07d-1646e59d2e52",
      "name": "シートに12週間インプレッションを追加",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2336,
        2512
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "company",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "company",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "locationId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "locationId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_start",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_start",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_end",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_end",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "website_clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "website_clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "call_clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "call_clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "direction_requests",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "direction_requests",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search_desktop",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search_desktop",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search_mobile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search_mobile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps_desktop",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps_desktop",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps_mobile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps_mobile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1992525174,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=1992525174",
          "cachedResultName": "Impression 12 week window"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "daa03d24-bc82-4a11-961e-e47777a5e8cd",
      "name": "全期間要約を追加",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2336,
        3616
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "companyName",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "companyName",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "locationId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "locationId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "total_reviews_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "total_reviews_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "positive_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "positive_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "neutral_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "neutral_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "negative_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "negative_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "positive_share_pct_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "positive_share_pct_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "average_rating_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "average_rating_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "reply_rate_pct_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "reply_rate_pct_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "median_reply_latency_hours_all_time",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "median_reply_latency_hours_all_time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "last_review_at",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "last_review_at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 44559940,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=44559940",
          "cachedResultName": "Total Review Summary"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "fae71ed4-93b7-4d38-b996-871dad806fc2",
      "name": "全レビューを取得",
      "type": "n8n-nodes-base.googleBusinessProfile",
      "position": [
        -3296,
        3616
      ],
      "parameters": {
        "account": {
          "__rl": true,
          "mode": "list",
          "value": "accounts/102160578675379169778",
          "cachedResultName": "Peyton Leveillee"
        },
        "location": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $json.locationPath }}"
        },
        "resource": "review",
        "operation": "getAll",
        "returnAll": true,
        "requestOptions": {}
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "snpnWRsJVydiU7oU",
          "name": "Google Business Profile account"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
      "name": "マージ2",
      "type": "n8n-nodes-base.merge",
      "position": [
        -1664,
        2560
      ],
      "parameters": {
        "numberInputs": 7
      },
      "typeVersion": 3.2
    },
    {
      "id": "1a372bcf-358e-42c3-b225-82690917e5e0",
      "name": "インプレッションを取得(先週)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -3280,
        2336
      ],
      "parameters": {
        "url": "={{\n(() => {\n  const base = `https://businessprofileperformance.googleapis.com/v1/locations/${$json.locationId}:fetchMultiDailyMetricsTimeSeries`;\n  const metrics = [\n    'WEBSITE_CLICKS',\n    'CALL_CLICKS',\n    'BUSINESS_DIRECTION_REQUESTS',\n    'BUSINESS_IMPRESSIONS_DESKTOP_SEARCH',\n    'BUSINESS_IMPRESSIONS_MOBILE_SEARCH',\n    'BUSINESS_IMPRESSIONS_DESKTOP_MAPS',\n    'BUSINESS_IMPRESSIONS_MOBILE_MAPS'\n  ];\n  const q = [\n    ...metrics.map(m => `dailyMetrics=${encodeURIComponent(m)}`),\n    `dailyRange.start_date.year=${$json.last_startY}`,\n    `dailyRange.start_date.month=${$json.last_startM}`,\n    `dailyRange.start_date.day=${$json.last_startD}`,\n    `dailyRange.end_date.year=${$json.last_endY}`,\n    `dailyRange.end_date.month=${$json.last_endM}`,\n    `dailyRange.end_date.day=${$json.last_endD}`\n  ].join('&');\n  return `${base}?${q}`;\n})()\n}}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json"
            }
          }
        },
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleBusinessProfileOAuth2Api"
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "JBlnixnNTbXzI9WJ",
          "name": "Google Business Profile account 2"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "f9ce0523-f032-4e67-9246-37bfd77310f6",
      "name": "フラット化(先週)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3104,
        2336
      ],
      "parameters": {
        "jsCode": "// === Upstream node names (change if yours differ) ===\nconst META_NODE = 'Build Week Window2';\nconst HTTP_NODE = 'Get Impressions (last week)'; // <- set to your last-week HTTP node name\n\n// Pull aligned arrays\nconst metas = $items(META_NODE, 0, 0).map(i => i.json);\nconst httpItems = $items(HTTP_NODE, 0, 0).map(i => i.json || i);\n\nconst pad = n => String(n).padStart(2, '0');\n\nfunction sumPoints(arr) {\n  if (!Array.isArray(arr)) return 0;\n  return arr.reduce((acc, d) => acc + Number(d?.value ?? d?.count ?? 0), 0);\n}\n\nconst out = [];\n\nfor (let i = 0; i < metas.length; i++) {\n  const meta = metas[i] || {};\n  const http = httpItems[i] || {};\n  const body = http.body ?? http;\n\n  const series = body?.multiDailyMetricTimeSeries?.[0]?.dailyMetricTimeSeries || [];\n\n  const totals = {};\n  for (const s of series) {\n    const points = s?.timeSeries?.datedValues || s?.timeSeries?.dailyValues || [];\n    totals[s.dailyMetric] = sumPoints(points);\n  }\n\n  // splits\n  const DESKTOP_SEARCH = totals.BUSINESS_IMPRESSIONS_DESKTOP_SEARCH || 0;\n  const MOBILE_SEARCH  = totals.BUSINESS_IMPRESSIONS_MOBILE_SEARCH  || 0;\n  const DESKTOP_MAPS   = totals.BUSINESS_IMPRESSIONS_DESKTOP_MAPS   || 0;\n  const MOBILE_MAPS    = totals.BUSINESS_IMPRESSIONS_MOBILE_MAPS    || 0;\n\n  // combined\n  const impressions_search_last = DESKTOP_SEARCH + MOBILE_SEARCH;\n  const impressions_maps_last   = DESKTOP_MAPS  + MOBILE_MAPS;\n\n  out.push({\n    json: {\n      company: meta.company || '(unknown)',\n      locationId: meta.locationId || null,\n\n      // last-week window (from meta.last_*)\n      last_week_start: `${meta.last_startY}-${pad(meta.last_startM)}-${pad(meta.last_startD)}`,\n      last_week_end:   `${meta.last_endY}-${pad(meta.last_endM)}-${pad(meta.last_endD)}`,\n\n      // actions\n      website_clicks_last: totals.WEBSITE_CLICKS || 0,\n      call_clicks_last: totals.CALL_CLICKS || 0,\n      direction_requests_last: totals.BUSINESS_DIRECTION_REQUESTS || 0,\n\n      // impressions (combined)\n      impressions_search_last,\n      impressions_maps_last,\n\n      // impressions (splits)\n      impressions_search_desktop_last: DESKTOP_SEARCH,\n      impressions_search_mobile_last:  MOBILE_SEARCH,\n      impressions_maps_desktop_last:   DESKTOP_MAPS,\n      impressions_maps_mobile_last:    MOBILE_MAPS,\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "ad7d3fb3-00ec-4f77-811d-5f34c6e5e14d",
      "name": "すべてを結合",
      "type": "n8n-nodes-base.code",
      "position": [
        -1424,
        2640
      ],
      "parameters": {
        "jsCode": "/***** Join All — robust comparisons (impressions + reviews + all-time)\n * Accepts mixed records per company/location:\n *  - This-week impressions (impressions_* + week_start/week_end)\n *  - Last-week impressions  (…_last + last_week_start/last_week_end)\n *  - 12-week impressions rollup\n *  - Weekly reviews (this week)  -> { total_reviews, positive, neutral, negative, …, week_start/week_end }\n *  - Weekly reviews (last week)  -> { … , isLastWeek:true } OR object with week_start == last_week_start\n *  - 12-week reviews summary     -> { total_reviews_12w, positive_share_pct_12w, …, weeks }\n *  - All-time reviews            -> { total_reviews_all_time, positive_share_pct_all_time, … }\n *\n * Emits one row per company/location with normalized blocks:\n *  impressions, impressions_lastWeek, impressions_12w (+avg fields), vs12wAvg_impressions, vsLast_impressions\n *  reviews, reviews_lastWeek, reviews_12w (+avg fields), vsLast_reviews, vs12wAvg_reviews\n *  allTimeReviews (mapped if provided upstream)\n *****/\n\nfunction n(v){ const x = Number(v); return Number.isFinite(x) ? x : 0; }\nfunction nn(v){ return v == null ? null : Number(v); }\nconst round1 = v => (v == null ? null : +(+v).toFixed(1));\nconst pct = (num, den) => (den ? +(((+num)/(+den))*100).toFixed(1) : 0);\nconst pp = (a, b) => (a == null || b == null) ? null : +(+a - +b).toFixed(1);\nconst diffPct = (cur, prev) => (prev ? +(((+cur - +prev) / +prev) * 100).toFixed(1) : 0);\n\nfunction daysBetween(aISO, bISO){\n  const a = Date.parse(aISO), b = Date.parse(bISO);\n  return (Number.isFinite(a) && Number.isFinite(b)) ? Math.abs((b-a)/86400000) : null;\n}\nfunction weekSpanCount(startISO, endISO){\n  const d = daysBetween(startISO, endISO);\n  if (d == null) return null;\n  return Math.max(1, Math.round((d + 1) / 7));\n}\n\nfunction packImpressions(src, isLast=false){\n  if (!src) return null;\n\n  const s  = isLast ? n(src.impressions_search_last) : n(src.impressions_search);\n  const m  = isLast ? n(src.impressions_maps_last)   : n(src.impressions_maps);\n  const sd = isLast ? n(src.impressions_search_desktop_last) : n(src.impressions_search_desktop);\n  const sm = isLast ? n(src.impressions_search_mobile_last)  : n(src.impressions_search_mobile);\n  const md = isLast ? n(src.impressions_maps_desktop_last)   : n(src.impressions_maps_desktop);\n  const mm = isLast ? n(src.impressions_maps_mobile_last)    : n(src.impressions_maps_mobile);\n  const w  = isLast ? n(src.website_clicks_last) : n(src.website_clicks);\n  const c  = isLast ? n(src.call_clicks_last)    : n(src.call_clicks);\n  const d  = isLast ? n(src.direction_requests_last) : n(src.direction_requests);\n\n  const total = s + m;\n  const actions_total = w + c + d;\n  const ctr_actions_pct = total ? +((actions_total / total) * 100).toFixed(1) : 0;\n\n  return {\n    week_start: isLast ? (src.last_week_start || src.week_start || null) : (src.week_start || null),\n    week_end:   isLast ? (src.last_week_end   || src.week_end   || null) : (src.week_end   || null),\n\n    search: s, maps: m, total,\n    search_desktop: sd, search_mobile: sm,\n    maps_desktop: md, maps_mobile: mm,\n\n    website_clicks: w, call_clicks: c, direction_requests: d,\n    actions_total, ctr_actions_pct\n  };\n}\n\n// -------- collect + group ----------\nconst rows = $input.all().map(i => i.json);\nconst groups = new Map();\nfor (const r of rows){\n  const company = r.companyName || r.company || '(unknown)';\n  const loc = r.locationId ?? null;\n  const key = `${company}__${loc ?? 'null'}`;\n  if (!groups.has(key)) groups.set(key, []);\n  groups.get(key).push(r);\n}\n\nconst out = [];\n\nfor (const [key, list] of groups.entries()){\n  const companyName = list[0].companyName || list[0].company || '(unknown)';\n  const locationId  = list[0].locationId ?? null;\n\n  let thisW_imp = null, lastW_imp = null, block12_imp = null;\n  let thisW_rev = null, lastW_rev = null, block12_rev = null;\n  let allTime = null;\n\n  let anchor_last_start = null, anchor_last_end = null;\n\n  for (const r of list){\n    // impressions shapes\n    const hasImp = (r.impressions_search !== undefined || r.impressions_maps !== undefined);\n    const hasImpLast = (r.impressions_search_last !== undefined || r.website_clicks_last !== undefined);\n\n    if (hasImp && r.week_start && r.week_end) {\n      const spanDays = daysBetween(r.week_start, r.week_end);\n      if (spanDays != null && spanDays >= 60) block12_imp = r;   // ~12w rollup\n      else thisW_imp = r;                                        // 1 week\n    }\n    if (hasImpLast && (r.last_week_start || r.last_week_end)) lastW_imp = r;\n\n    // weekly reviews (this week) — take most recent\n    if (r.total_reviews !== undefined && r.week_start) {\n      thisW_rev = (thisW_rev == null || r.week_start >= thisW_rev.week_start) ? r : thisW_rev;\n    }\n    // explicit last-week reviews\n    if (r.total_reviews !== undefined && r.isLastWeek === true) lastW_rev = r;\n\n    // 12-week reviews summary (any of these keys)\n    if (r.total_reviews_12w !== undefined || r.positive_share_pct_12w !== undefined || r.weeks !== undefined) {\n      block12_rev = r;\n    }\n\n    // all-time reviews (map common variants)\n    if (!allTime && (r.total_reviews_all_time !== undefined || r.average_rating_all_time !== undefined ||\n                     r.all_time_total !== undefined || r.avg_rating_all_time !== undefined)) {\n      allTime = {\n        total: n(r.total_reviews_all_time ?? r.all_time_total),\n        positiveShare: nn(r.positive_share_pct_all_time ?? r.all_time_positive_share_pct) ?? 0,\n        averageRating: nn(r.average_rating_all_time ?? r.avg_rating_all_time),\n        replyRatePct:  nn(r.reply_rate_pct_all_time ?? r.all_time_reply_rate_pct) ?? 0,\n        medianReplyLatencyHours: nn(r.median_reply_latency_hours_all_time ?? r.all_time_median_reply_latency_hours),\n        lastReviewAt: r.last_review_at ?? r.all_time_last_review_at ?? null\n      };\n    }\n\n    // anchors\n    if (!anchor_last_start && (r.last_week_start || r.last_startISO)) {\n      anchor_last_start = r.last_week_start || (r.last_startISO && r.last_startISO.slice(0,10)) || null;\n    }\n    if (!anchor_last_end && (r.last_week_end || r.last_endISO)) {\n      anchor_last_end = r.last_week_end || (r.last_endISO && r.last_endISO.slice(0,10)) || null;\n    }\n  }\n\n  // try find last-week reviews by date if not explicitly flagged\n  if (!lastW_rev && anchor_last_start) {\n    lastW_rev = list.find(x => x.total_reviews !== undefined && x.week_start === anchor_last_start) || null;\n  }\n\n  // ---- impressions packing ----\n  const impressions          = packImpressions(thisW_imp, false);\n  const impressions_lastWeek = packImpressions(lastW_imp,  true);\n\n  // 12w impressions totals & avgs (and CTR avg)\n  let impressions_12w = null, vs12wAvg_impressions = null;\n  if (block12_imp){\n    const search = n(block12_imp.impressions_search);\n    const maps   = n(block12_imp.impressions_maps);\n    const total  = search + maps;\n\n    const clicks = n(block12_imp.website_clicks);\n    const calls  = n(block12_imp.call_clicks);\n    const dirs   = n(block12_imp.direction_requests);\n    const actions_total_12w = clicks + calls + dirs;\n\n    const weeks = weekSpanCount(block12_imp.week_start, block12_imp.week_end) || 12;\n    const avg_week_total = weeks ? +(total / weeks).toFixed(1) : 0;\n    const avg_week_actions_total = weeks ? +((actions_total_12w / weeks).toFixed(1)) : 0;\n    const avg_week_ctr_pct = avg_week_total ? +( (avg_week_actions_total / avg_week_total) * 100 ).toFixed(1) : 0;\n\n    impressions_12w = {\n      start: block12_imp.week_start || null,\n      end:   block12_imp.week_end   || null,\n      search, maps, total,\n      website_clicks: clicks,\n      call_clicks: calls,\n      direction_requests: dirs,\n      search_desktop: n(block12_imp.impressions_search_desktop),\n      search_mobile:  n(block12_imp.impressions_search_mobile),\n      maps_desktop:   n(block12_imp.impressions_maps_desktop),\n      maps_mobile:    n(block12_imp.impressions_maps_mobile),\n      avg_week_total,\n      avg_week_actions_total,\n      avg_week_ctr_pct\n    };\n\n    if (impressions){\n      const diff = +(impressions.total - avg_week_total).toFixed(1);\n      const pctVs = avg_week_total ? +((impressions.total/avg_week_total)*100).toFixed(1) : 0;\n      vs12wAvg_impressions = {\n        weeks,\n        avg_week_total,\n        pct_vs_avg: pctVs,\n        diff_from_avg: diff,\n        arrow: diff > 0 ? '↑' : diff < 0 ? '↓' : '↔'\n      };\n    }\n  }\n\n  // vs last week (impressions) — include CTR delta\n  let vsLast_impressions = null, impressions_delta = null;\n  if (impressions && impressions_lastWeek){\n    const tThis = impressions.total, tLast = impressions_lastWeek.total;\n    const diffAbs = tThis - tLast;\n    impressions_delta = { arrow: diffAbs>0?'↑':diffAbs<0?'↓':'↔', diffAbs };\n    vsLast_impressions = {\n      last_total: tLast,\n      diff_total: diffAbs,\n      pct_change_total: diffPct(tThis, tLast),\n      ctr_this: impressions.ctr_actions_pct,\n      ctr_last: impressions_lastWeek.ctr_actions_pct,\n      ctr_diff: pp(impressions.ctr_actions_pct, impressions_lastWeek.ctr_actions_pct)\n    };\n  }\n\n  // ---- reviews (this & last) ----\n  const reviews = thisW_rev ? {\n    total: n(thisW_rev.total_reviews),\n    pos: n(thisW_rev.positive),\n    neu: n(thisW_rev.neutral),\n    neg: n(thisW_rev.negative),\n    positiveShare: nn(thisW_rev.positive_share_pct) ?? 0,\n    averageRating: nn(thisW_rev.average_rating),\n    replyRatePct:  nn(thisW_rev.reply_rate_pct) ?? 0,\n    medianReplyLatencyHours: nn(thisW_rev.median_reply_latency_hours)\n  } : { total:0, pos:0, neu:0, neg:0, positiveShare:0, averageRating:null, replyRatePct:0, medianReplyLatencyHours:null };\n\n  const reviews_lastWeek = lastW_rev ? {\n    total: n(lastW_rev.total_reviews),\n    pos: n(lastW_rev.positive),\n    neu: n(lastW_rev.neutral),\n    neg: n(lastW_rev.negative),\n    positiveShare: nn(lastW_rev.positive_share_pct) ?? 0,\n    averageRating: nn(lastW_rev.average_rating),\n    replyRatePct:  nn(lastW_rev.reply_rate_pct) ?? 0,\n    medianReplyLatencyHours: nn(lastW_rev.median_reply_latency_hours),\n    week_start: lastW_rev.week_start || null,\n    week_end:   lastW_rev.week_end   || null\n  } : null;\n\n  // ---- 12-week reviews rollup ----\n  let reviews_12w = null, vsLast_reviews = null, vs12wAvg_reviews = null;\n  if (block12_rev){\n    const weeks = n(block12_rev.weeks) || 12;\n    const total12 = n(block12_rev.total_reviews_12w);\n    const pos12   = n(block12_rev.positive_12w);\n    const neu12   = n(block12_rev.neutral_12w);\n    const neg12   = n(block12_rev.negative_12w);\n\n    const avg_total = weeks ? +(total12 / weeks).toFixed(1) : 0;\n    const avg_pos   = weeks ? +(pos12   / weeks).toFixed(1) : 0;\n    const avg_neu   = weeks ? +(neu12   / weeks).toFixed(1) : 0;\n    const avg_neg   = weeks ? +(neg12   / weeks).toFixed(1) : 0;\n\n    const avg_positiveShare = nn(block12_rev.positive_share_pct_12w) ?? 0;\n    const avg_rating        = nn(block12_rev.average_rating_12w);\n    const avg_replyRatePct  = nn(block12_rev.reply_rate_pct_12w) ?? 0;\n    const avg_medianReplyLatencyHours = nn(block12_rev.median_reply_latency_hours_12w);\n\n    reviews_12w = {\n      weeks,\n      total: total12,\n      pos: pos12, neu: neu12, neg: neg12,\n      avg_total, avg_pos, avg_neu, avg_neg,\n      avg_positiveShare, avg_rating, avg_replyRatePct, avg_medianReplyLatencyHours\n    };\n\n    if (reviews){\n      const diff = +(reviews.total - avg_total).toFixed(1);\n      const pctVs = avg_total ? +((reviews.total/avg_total)*100).toFixed(1) : 0;\n      vs12wAvg_reviews = {\n        pct_vs_avg: pctVs,\n        diff_from_avg: diff,\n        arrow: diff > 0 ? '↑' : diff < 0 ? '↓' : '↔'\n      };\n    }\n  }\n\n  if (reviews && reviews_lastWeek){\n    const diff = reviews.total - reviews_lastWeek.total;\n    vsLast_reviews = {\n      last_total: reviews_lastWeek.total,\n      diff_total: diff,\n      pct_change_total: diffPct(reviews.total, reviews_lastWeek.total)\n    };\n  }\n\n  // ---- emit ----\n  out.push({\n    json: {\n      companyName,\n      locationId,\n      week_start: impressions?.week_start || thisW_rev?.week_start || null,\n      week_end:   impressions?.week_end   || thisW_rev?.week_end   || null,\n\n      impressions: impressions || null,\n      impressions_lastWeek: impressions_lastWeek || null,\n      impressions_12w: impressions_12w || null,\n      vs12wAvg_impressions,\n      vsLast_impressions,\n      impressions_delta: vsLast_impressions ? { arrow: vsLast_impressions.diff_total>0?'↑':vsLast_impressions.diff_total<0?'↓':'↔', diffAbs: vsLast_impressions.diff_total } : null,\n\n      reviews,\n      reviews_lastWeek,\n      reviews_12w,\n      vsLast_reviews,\n      vs12wAvg_reviews,\n\n      // new: all-time (if present upstream)\n      allTimeReviews: allTime || {}\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "8d45eeac-97ce-4dbb-a6ec-7df9e3b5d05f",
      "name": "OpenAI Chat Model2",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -816,
        3264
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5",
          "cachedResultName": "gpt-5"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "id": "0wxxfUy5p26AEoAX",
          "name": "OpenAi account"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fee14269-084f-45d7-92e0-1a99da5cc2f6",
      "name": "レビューパーサー",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        -688,
        2832
      ],
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"required\": [\"companyKey\", \"oneLinerReviews\"],\n  \"properties\": {\n    \"companyKey\": { \"type\": \"string\", \"minLength\": 1 },\n    \"oneLinerReviews\": { \"type\": \"string\", \"minLength\": 6, \"maxLength\": 200 }\n  }\n}"
      },
      "typeVersion": 1.3
    },
    {
      "id": "54e4abfc-d9c1-4822-a3a1-87662c0b0c7d",
      "name": "インプレッションパーサー",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        -672,
        3264
      ],
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"required\": [\"companyKey\", \"oneLinerImpressions\"],\n  \"properties\": {\n    \"companyKey\": { \"type\": \"string\", \"minLength\": 1 },\n    \"oneLinerImpressions\": { \"type\": \"string\", \"minLength\": 6, \"maxLength\": 200 }\n  }\n}"
      },
      "typeVersion": 1.3
    },
    {
      "id": "aa759b90-5e5b-494f-9271-cc93e2e61523",
      "name": "全期間レビュー → KPI(LLMなし)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3104,
        3616
      ],
      "parameters": {
        "jsCode": "// All-time Reviews → KPIs (no LLM)\n// Inputs:\n//  - Get Many Reviews  (all reviews for each location)\n//  - Set Company (total reviews)  (company ↔ locationId mapping)\n// Output: 1 item per company+location with lifetime KPIs\n\n// --- helpers ---\nfunction normIso(s){ return s ? String(s).replace(/\\.(\\d{3})\\d*Z$/, '.$1Z') : null; }\nfunction msSafe(s){ const t = Date.parse(normIso(s)); return Number.isFinite(t) ? t : null; }\nfunction toNum(star){ const m={ONE:1,TWO:2,THREE:3,FOUR:4,FIVE:5}; return typeof star==='number'?star:(m[String(star||'').toUpperCase()] ?? 0); }\nfunction pickLocationId(j){\n  if (j.locationId) return String(j.locationId);\n  if (j.locationPath) return String(j.locationPath).split('/').pop();\n  if (j.location) return String(j.location).split('/').pop();\n  if (j.name) {\n    const p = String(j.name).split('/'); const i=p.indexOf('locations');\n    if (i>-1 && p[i+1]) return p[i+1];\n  }\n  return null;\n}\nfunction inferSentiment(j){\n  // rating-only heuristic (fast, consistent)\n  const n = Number(j.ratingNum || toNum(j.starRating));\n  if (!Number.isFinite(n) || n <= 0) return null;\n  if (n === 3) return 'Neutral';\n  if (n >= 4)  return 'Positive';\n  if (n <= 2)  return 'Negative';\n  return null;\n}\nfunction avg(a){ return a.length ? +(a.reduce((x,y)=>x+y,0)/a.length).toFixed(2) : 0; }\nfunction median(a){\n  if (!a.length) return 0;\n  const s=[...a].sort((x,y)=>x-y), m=Math.floor(s.length/2);\n  return s.length%2 ? s[m] : +(((s[m-1]+s[m])/2).toFixed(2));\n}\n\n// build company index from Set Company (total reviews)\nconst seed = $items('Set Company (total reviews)') || [];\nconst companyIndex = new Map();\nfor (const { json:s } of seed) {\n  const locId = (s.locationId && String(s.locationId))\n             || (s.locationPath && String(s.locationPath).split('/').pop())\n             || null;\n  if (locId) companyIndex.set(locId, s.companyName || s.company || '(unknown)');\n}\n\n// normalize all incoming reviews\nconst rows = [];\nfor (const { json } of $input.all()){\n  const locationId  = pickLocationId(json);\n  const companyName = json.companyName || companyIndex.get(locationId) || '(unknown)';\n  if (!locationId) continue; // skip ghosts\n\n  const ratingNum  = Number(json.ratingNum || toNum(json.starRating) || 0);\n  const createISO  = json.createTime || json.created || null;\n\n  // reply latency\n  let latency = (typeof json.replyLatencyHours === 'number') ? json.replyLatencyHours : null;\n  if (latency === null && json.reviewReply?.updateTime && createISO) {\n    const msC = msSafe(createISO), msR = msSafe(json.reviewReply.updateTime);\n    if (msC != null && msR != null) latency = Math.round(((msR - msC)/36e5)*100)/100;\n  }\n\n  rows.push({\n    companyName,\n    locationId,\n    ratingNum,\n    sentiment: inferSentiment(json),\n    hasReply: !!(json.hasReply || json.reviewReply?.comment),\n    replyLatencyHours: (typeof latency === 'number') ? latency : null,\n    createdMs: msSafe(createISO),\n  });\n}\n\n// aggregate per company+location\nconst byKey = new Map();\nfor (const r of rows) {\n  if (!r.sentiment) continue; // skip unrated/unknown\n  const key = `${r.companyName}__${r.locationId}`;\n  if (!byKey.has(key)) byKey.set(key, []);\n  byKey.get(key).push(r);\n}\n\nconst out = [];\nfor (const [, list] of byKey.entries()){\n  const companyName = list[0].companyName;\n  const locationId  = list[0].locationId;\n\n  const total = list.length;\n  const pos = list.filter(x => x.sentiment === 'Positive').length;\n  const neu = list.filter(x => x.sentiment === 'Neutral').length;\n  const neg = list.filter(x => x.sentiment === 'Negative').length;\n\n  const ratings = list.map(x => Number(x.ratingNum||0)).filter(Number.isFinite);\n  const average_rating = avg(ratings);\n  const positive_share_pct = total ? +((pos/total)*100).toFixed(1) : 0;\n\n  const replied = list.filter(x => x.hasReply === true).length;\n  const reply_rate_pct = total ? +((replied/total)*100).toFixed(1) : 0;\n\n  const latencies = list.map(x => (typeof x.replyLatencyHours === 'number') ? x.replyLatencyHours : null).filter(v=>v!==null);\n  const median_reply_latency_hours = median(latencies);\n\n  const lastReviewAt = list.map(x => x.createdMs).filter(Number.isFinite).sort((a,b)=>a-b).pop() || null;\n\n  out.push({\n    json: {\n      companyName,\n      locationId,\n      total_reviews_all_time: total,\n      positive_all_time: pos,\n      neutral_all_time: neu,\n      negative_all_time: neg,\n      positive_share_pct_all_time: positive_share_pct,\n      average_rating_all_time: average_rating,\n      reply_rate_pct_all_time: reply_rate_pct,\n      median_reply_latency_hours_all_time: median_reply_latency_hours,\n      last_review_at: lastReviewAt ? new Date(lastReviewAt).toISOString() : null\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "7dfc29b9-989e-4ded-8b41-bd21f99015c7",
      "name": "週次レビューをマッピング",
      "type": "n8n-nodes-base.code",
      "position": [
        -2896,
        2992
      ],
      "parameters": {
        "jsCode": "// Map + categorize Weekly Google Reviews in ONE node\n// Depends on:\n//   - Set Company (reviews): creates company index (companyName, locationPath)\n//   - Build Week Window (reviews): provides startISO/endISO (or it will fallback to Build Week Window2)\n\nconst companies = $items('Set Company (reviews)') || [];\nif (!companies.length) throw new Error('Set Company (reviews) not found or empty');\n\n// Get week window\nconst winItems = $items('Build Week Window (reviews)') || $items('Build Week Window2') || [];\nconst win = winItems[0]?.json || {};\nconst week_start = win.startISO?.slice(0,10) || win.week_start || null;\nconst week_end   = win.endISO?.slice(0,10)   || win.week_end   || null;\n\n// Build locationId -> companyName index\nconst companyIndex = new Map();\nfor (const {json:c} of companies) {\n  const locId = (c.locationPath || '').split('/').pop();\n  if (locId) companyIndex.set(String(locId), c.companyName || c.company || '(unknown)');\n}\n\n// Helpers\nfunction toNum(star){\n  const m = { ONE:1, TWO:2, THREE:3, FOUR:4, FIVE:5 };\n  return typeof star === 'number' ? star : (m[String(star||'').toUpperCase()] ?? 0);\n}\nfunction normIso(s){ return s ? String(s).replace(/\\.(\\d{3})\\d*Z$/, '.$1Z') : null; }\nfunction msSafe(s){ const t = s ? Date.parse(normIso(s)) : NaN; return Number.isFinite(t) ? t : null; }\nfunction withinWeek(ms){\n  if (!ms) return false;\n  const startMs = msSafe(week_start+'T00:00:00Z');\n  const endMs   = msSafe(week_end  +'T23:59:59Z'); // inclusive\n  return Number.isFinite(startMs) && Number.isFinite(endMs) && ms >= startMs && ms <= endMs;\n}\nfunction classify(ratingNum){\n  const n = Number(ratingNum || 0);\n  if (!Number.isFinite(n) || n <= 0) return null;\n  if (n === 3) return 'Neutral';\n  if (n >= 4) return 'Positive';\n  if (n <= 2) return 'Negative';\n  return null;\n}\n\nconst out = [];\nfor (const {json:r} of $input.all()){\n  // IDs\n  let locationId = r.locationId ? String(r.locationId) : null;\n  let reviewId   = r.reviewId   ? String(r.reviewId)   : null;\n\n  if ((!locationId || !reviewId) && r.name) {\n    const p = String(r.name).split('/');\n    const li = p.indexOf('locations'); if (li > -1 && p[li+1]) locationId ||= p[li+1];\n    const ri = p.indexOf('reviews');   if (ri > -1 && p[ri+1]) reviewId   ||= p[ri+1];\n  }\n\n  const createdMs = msSafe(r.createTime || r.created);\n  if (!withinWeek(createdMs)) continue; // keep only this week's reviews\n\n  const replyMs = msSafe(r.reviewReply?.updateTime);\n  const ratingNum = Number(r.ratingNum || toNum(r.starRating) || 0);\n  const sentiment_label = classify(ratingNum);\n\n  const companyName = r.companyName || companyIndex.get(locationId) || '(unknown)';\n\n  out.push({\n    json: {\n      companyName,\n      locationId,\n      reviewId,\n      createTime: createdMs ? new Date(createdMs).toISOString() : null,\n\n      ratingNum,\n      ratingText: r.ratingText || r.starRating || null,\n      comment: (r.comment || '').toString(),\n\n      hasReply: !!r.reviewReply?.comment,\n      replyLatencyHours: (replyMs && createdMs) ? Math.round(((replyMs - createdMs)/36e5)*100)/100 : null,\n\n      // weekly fields for grouping\n      week_start,\n      week_end,\n\n      // sentiment\n      sentiment_label,\n      sentimentCategory: sentiment_label,\n\n      // stable key if you need it\n      key: [companyName, locationId || 'noLocation', week_start || 'noStart'].join('__'),\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "b69c6393-95ac-4234-9129-9d12653b4919",
      "name": "スケジュールトリガー",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -4448,
        2160
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtHour": 18
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "42790f95-855c-439e-8ea2-b9a938a0ba55",
      "name": "メールを作成",
      "type": "n8n-nodes-base.code",
      "position": [
        1232,
        2912
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// n8n Code node — Build final HTML email from:\n// - Base metrics from node \"format\" (single item)\n// - Three one-liners from current input (Append Merge produced 3 items)\n// Output: one item with { subject, html }\n\nfunction num(n) { return Number(n) || 0; }\nfunction fmtInt(n) { return num(n).toLocaleString('en-US'); }\nfunction fmtPct(n, dp = 0) { return `${(Number(n) || 0).toFixed(dp)}%`; }\nfunction fmtHours(h) { const v = Number(h) || 0; return `${Math.round(v*10)/10}h`; }\nfunction nowStamp() { return new Date().toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' }); }\nfunction fmtDateISOtoMdy(iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleDateString('en-US'); }\n\n// ---------- 1) Load the base formatted item (match by locationId)\nconst me = $json; // current grouped company item\nconst myLoc = String(me.locationId || \"\").trim();\n\n// find the matching \"format\" item for this locationId\nconst baseList = $items('format', 0, 0).map(i => i.json || {});\nconst base = baseList.find(x => String(x.locationId || \"\").trim() === myLoc) || {};\nconst companyName = (base.companyName || me.company || '').trim();\nconst locationId  = myLoc;\nconst periodStart = base.week_start;\nconst periodEnd   = base.week_end;\n\n// Convenience: display numbers\nconst disp = base.display || {};\nconst dispImpr = disp.impressions || {};\nconst dispRev  = disp.reviews || {};\n\nconst thisImpr = dispImpr.this || {};\nconst lastImpr = dispImpr.last || {};\nconst avgImpr  = dispImpr.avg  || {};\n\nconst thisRev  = dispRev.this || {};\nconst lastRev  = dispRev.last || {};\nconst avgRev   = dispRev.avg  || {};\n\n// KPI deltas (vs 12w) from base.kpi\nconst kpi = base.kpi || {};\nconst kpiImprDelta = Number(kpi.impressions_vs12w_pct || 0);\nconst kpiActDelta  = Number(kpi.actions_vs12w_pct || 0);\nconst kpiCtrPp     = Number(kpi.ctr_vs12w_pp || 0);\nconst kpiRevCnt    = Number(kpi.reviews_vs12w_count || 0);\n\n// ---------- 2) Read three one-liners from current item (already grouped)\nconst liners = {\n  overall:     String(($json.lines && $json.lines.overall)     || '').trim(),\n  impressions: String(($json.lines && $json.lines.impressions) || '').trim(),\n  reviews:     String(($json.lines && $json.lines.reviews)     || '').trim(),\n};\n\n// ---------- 3) KPI badge helpers (vs 12w)\nfunction kpiBadgePct(val) {\n  const v = Number(val) || 0;\n  if (Math.abs(v) <= 1) return { text: '= vs 12w', cls:'pill pill-gray' };\n  if (v < 0) return { text: `▼ ${Math.abs(Math.round(v))}% vs 12w`, cls:'pill pill-red' };\n  return { text: `▲ ${Math.round(v)}% vs 12w`, cls:'pill pill-green' };\n}\nfunction kpiBadgePp(pp) {\n  const v = Number(pp) || 0;\n  if (Math.abs(v) < 0.5) return { text: '= vs 12w', cls:'pill pill-gray' };\n  if (v > 0) return { text: `▲ ${v.toFixed(1)} pp vs 12w`, cls:'pill pill-green' };\n  return { text: `▼ ${Math.abs(v).toFixed(1)} pp vs 12w`, cls:'pill pill-red' };\n}\nfunction kpiBadgeCount(delta) {\n  const v = Number(delta) || 0;\n  if (Math.abs(v) < 0.2) return { text: '= vs 12w', cls:'pill pill-gray' };\n  if (v < 0) return { text: `▼ ${Math.abs(v).toFixed(1)} vs 12w`, cls:'pill pill-red' };\n  return { text: `▲ ${v.toFixed(1)} vs 12w`, cls:'pill pill-green' };\n}\n\nconst badgeImpr = kpiBadgePct(kpiImprDelta);\nconst badgeAct  = kpiBadgePct(kpiActDelta);\nconst badgeCtr  = kpiBadgePp(kpiCtrPp);\nconst badgeRev  = kpiBadgeCount(kpiRevCnt);\n\n// ---------- 4) HTML (KPI pills won’t clip thanks to .pill class)\nconst reportingStr = `Reporting period: ${fmtDateISOtoMdy(periodStart)}–${fmtDateISOtoMdy(periodEnd)} • Generated: ${fmtDateISOtoMdy(new Date().toISOString())}, ${nowStamp()}`;\n\nconst html = `<!DOCTYPE html>\n<html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n  <title>Weekly Google Reviews & Impressions</title>\n  <style>\n    @media screen and (max-width:640px){\n      .container{width:100% !important}\n      .stack, .stack td{display:block !important; width:100% !important}\n      .col{display:block !important; width:100% !important}\n      .p-24{padding:16px !important}\n      .kpi{width:100% !important; display:block !important; margin-bottom:8px !important}\n    }\n    /* Anti-clipping chips */\n    .pill{display:inline-block; padding:3px 6px; border-radius:9999px; line-height:1; mso-line-height-rule:exactly;}\n    .pill-red{background:#fee2e2; color:#991b1b;}\n    .pill-gray{background:#e2e8f0; color:#334155;}\n    .pill-green{background:#dcfce7; color:#166534;}\n    .kpi td{overflow:visible !important;}\n  </style>\n</head>\n<body style=\"margin:0; padding:0; background:#f5f7fb;\">\n  <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"background:#f5f7fb;\">\n    <tr>\n      <td align=\"center\" style=\"padding:24px 12px;\">\n\n        <!-- Container -->\n        <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"680\" class=\"container\" style=\"width:680px; max-width:680px; background:#ffffff; border-radius:14px; overflow:hidden; font-family:Arial, Helvetica, sans-serif; color:#0f172a;\">\n\n          <!-- Header -->\n          <tr>\n            <td style=\"padding:22px 24px; background:#0f172a; color:#ffffff;\">\n              <div style=\"font-size:18px; font-weight:700;\">Weekly Google Reviews &amp; Impressions</div>\n              <div style=\"font-size:12px; opacity:0.9; margin-top:6px;\">${reportingStr}</div>\n              <div style=\"font-size:12px; opacity:0.9;\">Company: ${companyName}</div>\n            </td>\n          </tr>\n\n          <!-- KPI Row -->\n          <tr>\n            <td class=\"p-24\" style=\"padding:20px 24px 8px 24px;\">\n              <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">\n                <tr>\n\n                  <!-- Impressions -->\n                  <td class=\"kpi\" width=\"25%\" valign=\"top\" style=\"width:25%; padding:8px;\">\n                    <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"background:#fafafa; border:1px solid #e5e7eb; border-radius:10px;\">\n                      <tr><td style=\"padding:10px 12px; font-size:11px; color:#475569;\">Impressions</td></tr>\n                      <tr><td style=\"padding:0 12px 10px 12px; font-size:20px; font-weight:700;\">${fmtInt(thisImpr.impr)}</td></tr>\n                      <tr><td style=\"padding:0 12px 12px 12px; font-size:11px;\"><span class=\"${badgeImpr.cls}\">${badgeImpr.text}</span></td></tr>\n                    </table>\n                  </td>\n\n                  <!-- Actions -->\n                  <td class=\"kpi\" width=\"25%\" valign=\"top\" style=\"width:25%; padding:8px;\">\n                    <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"background:#fafafa; border:1px solid #e5e7eb; border-radius:10px;\">\n                      <tr><td style=\"padding:10px 12px; font-size:11px; color:#475569;\">Actions</td></tr>\n                      <tr><td style=\"padding:0 12px 10px 12px; font-size:20px; font-weight:700;\">${fmtInt(thisImpr.act)}</td></tr>\n                      <tr><td style=\"padding:0 12px 12px 12px; font-size:11px;\"><span class=\"${badgeAct.cls}\">${badgeAct.text}</span></td></tr>\n                    </table>\n                  </td>\n\n                  <!-- CTR -->\n                  <td class=\"kpi\" width=\"25%\" valign=\"top\" style=\"width:25%; padding:8px;\">\n                    <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"background:#fafafa; border:1px solid #e5e7eb; border-radius:10px;\">\n                      <tr><td style=\"padding:10px 12px; font-size:11px; color:#475569;\">CTR</td></tr>\n                      <tr><td style=\"padding:0 12px 10px 12px; font-size:20px; font-weight:700;\">${fmtPct(thisImpr.ctr)}</td></tr>\n                      <tr><td style=\"padding:0 12px 12px 12px; font-size:11px;\"><span class=\"${badgeCtr.cls}\">${badgeCtr.text}</span></td></tr>\n                    </table>\n                  </td>\n\n                  <!-- Reviews -->\n                  <td class=\"kpi\" width=\"25%\" valign=\"top\" style=\"width:25%; padding:8px;\">\n                    <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"background:#fafafa; border:1px solid #e5e7eb; border-radius:10px;\">\n                      <tr><td style=\"padding:10px 12px; font-size:11px; color:#475569;\">Reviews</td></tr>\n                      <tr><td style=\"padding:0 12px 10px 12px; font-size:20px; font-weight:700;\">${fmtInt(thisRev.total)}</td></tr>\n                      <tr><td style=\"padding:0 12px 12px 12px; font-size:11px;\"><span class=\"${badgeRev.cls}\">${badgeRev.text}</span></td></tr>\n                    </table>\n                  </td>\n\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <!-- GLOBAL ONE-LINER SUMMARY -->\n          <tr>\n            <td style=\"padding:0 24px 12px 24px;\">\n              <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px;\">\n                <tr>\n                  <td style=\"padding:12px 14px; font-size:13px; color:#334155;\">\n                    <strong>Overall:</strong> ${liners.overall || '—'}\n                  </td>\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <!-- Two Columns -->\n          <tr>\n            <td class=\"p-24\" style=\"padding:8px 24px 20px;\">\n              <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" class=\"stack\">\n                <tr>\n\n                  <!-- Impressions & Actions -->\n                  <td class=\"col\" width=\"50%\" valign=\"top\" style=\"width:50%; padding-right:10px;\">\n                    <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"border:1px solid #e5e7eb; border-radius:12px;\">\n                      <tr><td style=\"background:#f1f5f9; padding:10px 14px; font-size:13px; font-weight:700;\">Impressions &amp; Actions</td></tr>\n                      <tr>\n                        <td style=\"padding:12px 14px; font-size:13px; line-height:1.6;\">\n                          <div style=\"margin-bottom:10px; padding:10px 12px; background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px; font-size:12px; color:#334155;\"><strong>Summary:</strong> ${liners.impressions || '—'}</div>\n\n                          <div style=\"margin-bottom:8px;\"><strong style=\"color:#0f172a;\">This week:</strong> ${fmtInt(thisImpr.impr)} impr • ${fmtInt(thisImpr.act)} actions • ${fmtPct(thisImpr.ctr)}</div>\n\n                          <div style=\"margin-bottom:8px;\">\n                            <strong style=\"color:#0f172a;\">Last week:</strong> ${fmtInt(lastImpr.impr)} impr • ${fmtInt(lastImpr.act)} actions • ${fmtPct(lastImpr.ctr)}\n                            <span class=\"pill pill-red\" style=\"margin-left:6px;\">▼ ${Math.abs(Number(base.deltas?.impr_wow_pct || 0))}% impr</span>\n                            <span class=\"pill pill-red\" style=\"margin-left:4px;\">▼ ${Math.abs(Number(base.deltas?.act_wow_pct || 0))}% actions</span>\n                            <span class=\"${(Number(base.deltas?.ctr_wow_pp)||0)>=0? 'pill pill-green':'pill pill-red'}\" style=\"margin-left:4px;\">${(Number(base.deltas?.ctr_wow_pp)||0)>=0? '▲ ':'▼ '}${Math.abs(Number(base.deltas?.ctr_wow_pp||0)).toFixed(1)} pp CTR</span>\n                          </div>\n\n                          <div>\n                            <strong style=\"color:#0f172a;\">12-wk avg:</strong> ${fmtInt(avgImpr.impr)} impr • ${fmtInt(avgImpr.act)} actions • ${fmtPct(avgImpr.ctr)}\n                            <span class=\"pill pill-red\" style=\"margin-left:6px;\">▼ ${Math.abs(Number(base.deltas?.impr_vsavg_pct || 0))}% impr</span>\n                            <span class=\"pill pill-red\" style=\"margin-left:4px;\">▼ ${Math.abs(Number(base.deltas?.act_vsavg_pct || 0))}% actions</span>\n                            <span class=\"${Math.abs(Number(base.deltas?.ctr_vsavg_pp||0))<0.5 ? 'pill pill-gray' : ((Number(base.deltas?.ctr_vsavg_pp)||0)>0? 'pill pill-green':'pill pill-red')}\" style=\"margin-left:4px;\">\n                              ${Math.abs(Number(base.deltas?.ctr_vsavg_pp||0))<0.5 ? '= CTR' : ((Number(base.deltas?.ctr_vsavg_pp)||0)>0 ? '▲ '+Number(base.deltas?.ctr_vsavg_pp).toFixed(1)+' pp CTR' : '▼ '+Math.abs(Number(base.deltas?.ctr_vsavg_pp)).toFixed(1)+' pp CTR')}\n                            </span>\n                          </div>\n\n                        </td>\n                      </tr>\n                    </table>\n                  </td>\n\n                  <!-- Reviews -->\n                  <td class=\"col\" width=\"50%\" valign=\"top\" style=\"width:50%; padding-left:10px;\">\n                    <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"border:1px solid #e5e7eb; border-radius:12px;\">\n                      <tr><td style=\"background:#f1f5f9; padding:10px 14px; font-size:13px; font-weight:700;\">Reviews</td></tr>\n                      <tr>\n                        <td style=\"padding:12px 14px; font-size:13px; line-height:1.6;\">\n\n                          <div style=\"margin-bottom:10px; padding:10px 12px; background:#f8fafc; border:1px solid #e5e7eb; border-radius:10px; font-size:12px; color:#334155;\"><strong>Summary:</strong> ${liners.reviews || '—'}</div>\n\n                          <div style=\"margin-bottom:8px;\">\n                            <strong style=\"color:#0f172a;\">This week:</strong>\n                            ${fmtInt(thisRev.total)} reviews • ${fmtInt(thisRev.pos)}/0/0 (Pos/Neu/Neg) • 5.0★ • ${fmtPct(thisRev.pos)} positive • Replies ${fmtPct(thisRev.replies)} • Median reply ${fmtHours(thisRev.hours)}\n                          </div>\n\n                          <div style=\"margin-bottom:8px;\">\n                            <strong style=\"color:#0f172a;\">Last week:</strong>\n                            ${fmtInt(lastRev.total)} reviews • ${fmtInt(lastRev.pos)}/0/0 • 5.0★ • ${fmtPct(lastRev.pos)} positive • Replies ${fmtPct(lastRev.replies)} • Median reply ${fmtHours(lastRev.hours)}\n                            <span class=\"pill pill-red\" style=\"margin-left:6px;\">▼ ${Math.abs(Number(base.vsLast_reviews?.diff_total || 0))} review${Math.abs(Number(base.vsLast_reviews?.diff_total || 0))===1?'':'s'}</span>\n                            <span class=\"pill pill-gray\" style=\"margin-left:4px;\">= sentiment</span>\n                            <span class=\"pill pill-gray\" style=\"margin-left:4px;\">= pos%</span>\n                            <span class=\"pill pill-gray\" style=\"margin-left:4px;\">= replies</span>\n                            <span class=\"pill pill-gray\" style=\"margin-left:4px;\">= hours</span>\n                          </div>\n\n                          <div>\n                            <strong style=\"color:#0f172a;\">12-wk avg:</strong>\n                            ${Number(base.reviews_12w?.avg_total || 0).toFixed(1)} reviews • ${Number(base.reviews_12w?.avg_pos || 0).toFixed(1)}/${Number(base.reviews_12w?.avg_neu || 0).toFixed(1)}/${Number(base.reviews_12w?.avg_neg || 0).toFixed(1)} • ${Number(base.reviews_12w?.avg_rating || 0).toFixed(1)}★ • ${fmtPct(base.reviews_12w?.avg_positiveShare || 0)} positive • Replies ${fmtPct(base.reviews_12w?.avg_replyRatePct || 0)} • Median reply ${fmtHours(base.reviews_12w?.avg_medianReplyLatencyHours || 0)}\n                            <span class=\"pill pill-red\" style=\"margin-left:6px;\">▼ ${Math.abs(Number(base.vs12wAvg_reviews?.diff_from_avg || 0)).toFixed(1)} reviews</span>\n                            <span class=\"${(Number(base.deltas?.pos_vsavg_pp)||0)>=0 ? 'pill pill-green':'pill pill-red'}\" style=\"margin-left:4px;\">${(Number(base.deltas?.pos_vsavg_pp)||0)>=0?'▲ ':'▼ '}${Math.abs(Number(base.deltas?.pos_vsavg_pp||0)).toFixed(1)} pp pos%</span>\n                            <span class=\"${(Number(base.deltas?.replies_vsavg_pp)||0)>=0 ? 'pill pill-green':'pill pill-red'}\" style=\"margin-left:4px;\">${(Number(base.deltas?.replies_vsavg_pp)||0)>=0?'▲ ':'▼ '}${Math.abs(Number(base.deltas?.replies_vsavg_pp||0)).toFixed(1)} pp replies</span>\n                            <span class=\"pill pill-green\" style=\"margin-left:4px;\">▼ ${Math.abs(Number(base.deltas?.replyHours_vsavg || 0)).toFixed(1)}h reply time</span>\n                          </div>\n\n                        </td>\n                      </tr>\n                    </table>\n                  </td>\n\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <!-- All-Time (conditional) -->\n          ${base.allTimeReviews && (base.allTimeReviews.total || 0) > 0 ? `\n          <tr>\n            <td style=\"padding:0 24px 20px 24px;\">\n              <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"border:1px solid #e5e7eb; border-radius:12px;\">\n                <tr><td style=\"background:#f1f5f9; padding:10px 14px; font-size:13px; font-weight:700;\">All-Time Reviews</td></tr>\n                <tr>\n                  <td style=\"padding:12px 14px; font-size:13px; line-height:1.6;\">\n                    Total: ${fmtInt(base.allTimeReviews.total)} • Positive share: ${fmtPct(base.allTimeReviews.positiveShare)} • Avg rating: ${Number(base.allTimeReviews.averageRating || 0).toFixed(1)}★<br>\n                    Reply rate: ${fmtPct(base.allTimeReviews.replyRatePct)} • Median reply: ${fmtHours(base.allTimeReviews.medianReplyLatencyHours)}<br>\n                    Last review: ${base.allTimeReviews.lastReviewAt || ''}\n                  </td>\n                </tr>\n              </table>\n            </td>\n          </tr>\n          ` : ''}\n\n          <!-- Footer -->\n          <tr>\n            <td style=\"padding:16px 24px 24px 24px; font-size:11px; color:#64748b; text-align:center;\">\n              Sent automatically. Questions or corrections? Reply to this email.\n            </td>\n          </tr>\n\n        </table>\n      </td>\n    </tr>\n  </table>\n</body>\n</html>`;\n\n// Subject line\nconst subject = `Weekly Google Reviews & Impressions — ${companyName} (${fmtDateISOtoMdy(periodStart)}–${fmtDateISOtoMdy(periodEnd)})`;\n\n// per-item mode must return ONE object\nreturn { json: { ...$json, subject, html } };"
      },
      "typeVersion": 2
    },
    {
      "id": "0f96a41e-c575-4d21-81df-83274680903d",
      "name": "先週ウィンドウを構築",
      "type": "n8n-nodes-base.code",
      "position": [
        -3488,
        3200
      ],
      "parameters": {
        "jsCode": "// Build last-week-only window (Mon–Sun style: last 7 full days ending yesterday, UTC)\n\nfunction atUtcMidnight(d = new Date()) {\n  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));\n}\n\nconst todayMid = atUtcMidnight();                  // today 00:00Z\nconst endThis  = new Date(todayMid);               // yesterday 00:00Z will be end-of-this-week (exclusive)\nendThis.setUTCDate(endThis.getUTCDate() - 1);\n\nconst startThis = new Date(endThis);               // not used downstream; here for clarity only\nstartThis.setUTCDate(startThis.getUTCDate() - 6);\n\nconst endLast = new Date(startThis);               // last week ends the day before this-week starts\nendLast.setUTCDate(endLast.getUTCDate() - 1);\n\nconst startLast = new Date(endLast);\nstartLast.setUTCDate(startLast.getUTCDate() - 6);\n\nconst isoDate = d => d.toISOString().slice(0, 10);\n\nreturn [{\n  json: {\n    // last-week window only\n    startY: startLast.getUTCFullYear(),\n    startM: startLast.getUTCMonth() + 1,\n    startD: startLast.getUTCDate(),\n    endY:   endLast.getUTCFullYear(),\n    endM:   endLast.getUTCMonth() + 1,\n    endD:   endLast.getUTCDate(),\n\n    startISO: startLast.toISOString(),        // IMPORTANT: set to last week\n    endISO:   endLast.toISOString(),          // IMPORTANT: set to last week\n    week_start: isoDate(startLast),\n    week_end:   isoDate(endLast),\n\n    // convenience numeric timestamps (inclusive end)\n    ts: {\n      start: startLast.getTime(),\n      end:   endLast.getTime() + 24*60*60*1000 - 1\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "aa300513-a3fe-49e0-b89f-dac7c83fcf53",
      "name": "企業を設定(先週レビュー)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3296,
        3200
      ],
      "parameters": {
        "jsCode": "// Build items from the \"Read Companies\" node, not from the incoming payload\nconst companies = $items('Read Companies').map(i => i.json);\n\nreturn companies.map(c => ({\n  json: {\n    companyName: c.company,\n    // keep both the full path and the raw IDs for convenience\n    accountId: String(c.accountId),\n    locationId: String(c.locationId),\n    accountPath: `accounts/${c.accountId}`,\n    locationPath: `locations/${c.locationId}`,\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "89555b75-6474-40c6-a7e3-b31c01efec64",
      "name": "先週レビューを取得",
      "type": "n8n-nodes-base.googleBusinessProfile",
      "position": [
        -3104,
        3200
      ],
      "parameters": {
        "account": {
          "__rl": true,
          "mode": "list",
          "value": "accounts/102160578675379169778",
          "cachedResultName": "Peyton Leveillee"
        },
        "location": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $json.locationPath }}"
        },
        "resource": "review",
        "operation": "getAll",
        "returnAll": true,
        "requestOptions": {}
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "snpnWRsJVydiU7oU",
          "name": "Google Business Profile account"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "5a36f4ff-70f0-4cb3-b74e-4995050a159c",
      "name": "企業を設定(過去12週間レビュー)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3296,
        3376
      ],
      "parameters": {
        "jsCode": "// Build items from the \"Read Companies\" node, not from the incoming payload\nconst companies = $items('Read Companies').map(i => i.json);\n\nreturn companies.map(c => ({\n  json: {\n    companyName: c.company,\n    // keep both the full path and the raw IDs for convenience\n    accountId: String(c.accountId),\n    locationId: String(c.locationId),\n    accountPath: `accounts/${c.accountId}`,\n    locationPath: `locations/${c.locationId}`,\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "ec75d4f5-e9b4-48aa-a971-e0f5829dba91",
      "name": "過去12週間レビューを取得",
      "type": "n8n-nodes-base.googleBusinessProfile",
      "position": [
        -3104,
        3376
      ],
      "parameters": {
        "account": {
          "__rl": true,
          "mode": "list",
          "value": "accounts/102160578675379169778",
          "cachedResultName": "Peyton Leveillee"
        },
        "location": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $json.locationPath }}"
        },
        "resource": "review",
        "operation": "getAll",
        "returnAll": true,
        "requestOptions": {}
      },
      "credentials": {
        "googleBusinessProfileOAuth2Api": {
          "id": "snpnWRsJVydiU7oU",
          "name": "Google Business Profile account"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "725efc5d-a776-4a68-af5c-d04444ce6fae",
      "name": "過去12週間レビューをマッピングおよび要約",
      "type": "n8n-nodes-base.code",
      "position": [
        -2896,
        3376
      ],
      "parameters": {
        "jsCode": "// Map + categorize Google Reviews (12-WEEK window) — SAFE\n// Accepts upstream reviews as $input (preferred) OR pulls from a reviews node if present.\n// Tries multiple node names for company index + date window. No throws; proceeds best-effort.\n\n// ---------- helpers ----------\nfunction safeItems(name){\n  try {\n    const arr = $items(name, 0, 0);\n    return Array.isArray(arr) ? arr.map(i => i.json) : [];\n  } catch { return []; }\n}\nfunction normIso(s){ return s ? String(s).replace(/\\.(\\d{3})\\d*Z$/, '.$1Z') : null; }\nfunction msSafe(s){ const t = s ? Date.parse(normIso(s)) : NaN; return Number.isFinite(t) ? t : null; }\nfunction toNum(star){\n  const m = { ONE:1, TWO:2, THREE:3, FOUR:4, FIVE:5 };\n  return typeof star === 'number' ? star : (m[String(star||'').toUpperCase()] ?? 0);\n}\nfunction classify(n){\n  const x = Number(n || 0);\n  if (!Number.isFinite(x) || x <= 0) return null;\n  if (x === 3) return 'Neutral';\n  if (x >= 4) return 'Positive';\n  if (x <= 2) return 'Negative';\n  return null;\n}\n\n// ---------- get company index (locationId -> companyName) ----------\nconst companySeed =\n  safeItems('Set Company last 12 week reviews').length ? safeItems('Set Company last 12 week reviews') :\n  safeItems('Set Company (reviews)').length            ? safeItems('Set Company (reviews)') :\n  [];\n\nconst companyIndex = new Map();\nfor (const c of companySeed) {\n  const locId = (c.locationPath || '').split('/').pop() || String(c.locationId || '');\n  if (locId) companyIndex.set(String(locId), c.companyName || c.company || '(unknown)');\n}\n\n// ---------- get 12-week window (multiple fallbacks; default = last 12 weeks ending yesterday) ----------\nfunction default12w(){\n  const now = new Date();\n  const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));\n  end.setUTCDate(end.getUTCDate() - 1); // yesterday\n  const start = new Date(end);\n  start.setUTCDate(start.getUTCDate() - (7*12 - 1)); // inclusive start\n  return { startISO: start.toISOString(), endISO: end.toISOString(), weeks: 12 };\n}\n\nconst winNode =\n  safeItems('Build 12-week window (reviews)')[0]?.json ||\n  safeItems('Build 12-Week Window (reviews)')[0]?.json ||\n  safeItems('Build 12-Week Window')[0]?.json ||\n  default12w();\n\nconst startISO = winNode.startISO || winNode.week_start || default12w().startISO;\nconst endISO   = winNode.endISO   || winNode.week_end   || default12w().endISO;\nconst weeks    = Number(winNode.weeks || 12) || 12;\nconst startMs  = msSafe(startISO);\nconst endMsInc = (msSafe(endISO) ?? 0) + 24*60*60*1000 - 1;\n\n// ---------- gather reviews input ----------\nlet reviews = $input.all().map(i => i.json);\nif (!reviews.length) {\n  // fallback if this node isn’t wired after the reviews fetch\n  const fromGbp = safeItems('Get all reviews');\n  if (fromGbp.length) reviews = fromGbp;\n}\n\n// ---------- map ----------\nconst out = [];\nfor (const r of reviews) {\n  // IDs\n  let locationId = r.locationId ? String(r.locationId) : null;\n  let reviewId   = r.reviewId   ? String(r.reviewId)   : null;\n\n  if ((!locationId || !reviewId) && r.name){\n    const p = String(r.name).split('/');\n    const li = p.indexOf('locations'); if (li > -1 && p[li+1]) locationId ||= p[li+1];\n    const ri = p.indexOf('reviews');   if (ri > -1 && p[ri+1]) reviewId   ||= p[ri+1];\n  }\n\n  const createdMs = msSafe(r.createTime || r.created);\n  if (!(Number.isFinite(createdMs) && Number.isFinite(startMs) && Number.isFinite(endMsInc))) continue;\n  if (createdMs < startMs || createdMs > endMsInc) continue; // keep only 12-week window\n\n  const replyMs = msSafe(r.reviewReply?.updateTime);\n  const ratingNum = Number(r.ratingNum || toNum(r.starRating) || 0);\n  const sentiment_label = classify(ratingNum);\n\n  const companyName = r.companyName || companyIndex.get(locationId) || '(unknown)';\n\n  out.push({\n    json: {\n      companyName,\n      locationId,\n      reviewId,\n      createTime: createdMs ? new Date(createdMs).toISOString() : null,\n\n      ratingNum,\n      ratingText: r.ratingText || r.starRating || null,\n      comment: (r.comment || '').toString(),\n\n      hasReply: !!r.reviewReply?.comment,\n      replyLatencyHours: (replyMs && createdMs) ? Math.round(((replyMs - createdMs)/36e5)*100)/100 : null,\n\n      // window\n      startISO, endISO, weeks, is12w: true,\n\n      // sentiment\n      sentiment_label,\n      sentimentCategory: sentiment_label,\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "3c85818b-251f-4a00-9c8e-e15feb663765",
      "name": "レビューをマッピング+分類(先週、単一ノード)",
      "type": "n8n-nodes-base.code",
      "position": [
        -2896,
        3200
      ],
      "parameters": {
        "jsCode": "// Map + categorize Google Reviews (LAST WEEK)\n// Depends on:\n//  - \"Set Company last week reviews\" (company index)\n//  - \"Build last week window\" (startISO/endISO are last week)\n\nconst companies = $items('Set Company last week reviews') || [];\nif (!companies.length) throw new Error('Set Company last week reviews not found or empty');\n\nconst win = ($items('Build last week window')?.[0]?.json) || {};\nconst week_start = win.startISO?.slice(0,10) || win.week_start || null;\nconst week_end   = win.endISO?.slice(0,10)   || win.week_end   || null;\n\n// Build locationId -> companyName index\nconst companyIndex = new Map();\nfor (const {json:c} of companies) {\n  const locId = (c.locationPath || '').split('/').pop();\n  if (locId) companyIndex.set(String(locId), c.companyName || c.company || '(unknown)');\n}\n\n// Helpers\nfunction toNum(star){\n  const m = { ONE:1, TWO:2, THREE:3, FOUR:4, FIVE:5 };\n  return typeof star === 'number' ? star : (m[String(star||'').toUpperCase()] ?? 0);\n}\nfunction normIso(s){ return s ? String(s).replace(/\\.(\\d{3})\\d*Z$/, '.$1Z') : null; }\nfunction msSafe(s){ const t = s ? Date.parse(normIso(s)) : NaN; return Number.isFinite(t) ? t : null; }\nfunction within(ms){\n  const startMs = msSafe(week_start+'T00:00:00Z');\n  const endMs   = msSafe(week_end  +'T23:59:59Z'); // inclusive\n  return Number.isFinite(ms) && Number.isFinite(startMs) && Number.isFinite(endMs) && ms >= startMs && ms <= endMs;\n}\nfunction classify(n){\n  const x = Number(n || 0);\n  if (!Number.isFinite(x) || x <= 0) return null;\n  if (x === 3) return 'Neutral';\n  if (x >= 4) return 'Positive';\n  if (x <= 2) return 'Negative';\n  return null;\n}\n\nconst out = [];\nfor (const {json:r} of $input.all()){\n  // IDs\n  let locationId = r.locationId ? String(r.locationId) : null;\n  let reviewId   = r.reviewId   ? String(r.reviewId)   : null;\n\n  if ((!locationId || !reviewId) && r.name) {\n    const p = String(r.name).split('/');\n    const li = p.indexOf('locations'); if (li > -1 && p[li+1]) locationId ||= p[li+1];\n    const ri = p.indexOf('reviews');   if (ri > -1 && p[ri+1]) reviewId   ||= p[ri+1];\n  }\n\n  const createdMs = msSafe(r.createTime || r.created);\n  if (!within(createdMs)) continue; // keep only last-week reviews\n\n  const replyMs = msSafe(r.reviewReply?.updateTime);\n  const ratingNum = Number(r.ratingNum || toNum(r.starRating) || 0);\n  const sentiment_label = classify(ratingNum);\n\n  const companyName = r.companyName || companyIndex.get(locationId) || '(unknown)';\n\n  out.push({\n    json: {\n      companyName,\n      locationId,\n      reviewId,\n      createTime: createdMs ? new Date(createdMs).toISOString() : null,\n\n      ratingNum,\n      ratingText: r.ratingText || r.starRating || null,\n      comment: (r.comment || '').toString(),\n\n      hasReply: !!r.reviewReply?.comment,\n      replyLatencyHours: (replyMs && createdMs) ? Math.round(((replyMs - createdMs)/36e5)*100)/100 : null,\n\n      // weekly fields for grouping\n      week_start,\n      week_end,\n      isLastWeek: true,                 // <— tag for Join All\n\n      // sentiment\n      sentiment_label,\n      sentimentCategory: sentiment_label,\n\n      // stable key if you need it\n      key: [companyName, locationId || 'noLocation', week_start || 'noStart'].join('__'),\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "76cd4683-e038-4ede-a715-bec7321c09bb",
      "name": "週次レビューを要約(先週)",
      "type": "n8n-nodes-base.code",
      "position": [
        -2336,
        3200
      ],
      "parameters": {
        "jsCode": "// Weekly Reviews Summary (LAST WEEK) — input: mapped reviews from the node above\nfunction avg(a){ return a.length ? +(a.reduce((x,y)=>x+y,0)/a.length).toFixed(2) : 0; }\nfunction median(a){\n  if (!a.length) return 0;\n  const s=[...a].sort((x,y)=>x-y), m=Math.floor(s.length/2);\n  return s.length%2 ? s[m] : +(((s[m-1]+s[m])/2).toFixed(2));\n}\n\nconst rows = $input.all().map(i => i.json);\n\n// group by company + week_start (include location if needed)\nconst groups = new Map();\nfor (const r of rows){\n  const key = `${r.companyName||'(unknown)'}__${r.locationId||'null'}__${r.week_start||''}`;\n  if (!groups.has(key)) groups.set(key, []);\n  groups.get(key).push(r);\n}\n\nconst out = [];\nfor (const [, list] of groups.entries()){\n  const companyName = list[0].companyName || '(unknown)';\n  const locationId  = list[0].locationId || null;\n  const week_start  = list[0].week_start || null;\n  const week_end    = list[0].week_end || null;\n\n  const total = list.length;\n  const ratings = list.map(r => Number(r.ratingNum||0)).filter(Number.isFinite);\n  const avgRating = avg(ratings);\n\n  const pos = list.filter(r => r.sentiment_label === 'Positive').length;\n  const neu = list.filter(r => r.sentiment_label === 'Neutral').length;\n  const neg = list.filter(r => r.sentiment_label === 'Negative').length;\n  const positiveShare = total ? +((pos/total)*100).toFixed(1) : 0;\n\n  const replied = list.filter(r => r.hasReply === true).length;\n  const replyRatePct = total ? +((replied/total)*100).toFixed(1) : 0;\n\n  const latencies = list.map(r => typeof r.replyLatencyHours === 'number' ? r.replyLatencyHours : null).filter(v => v !== null);\n  const medianReplyLatencyHours = median(latencies);\n\n  out.push({\n    json: {\n      companyName, locationId, week_start, week_end,\n      total_reviews: total,\n      positive: pos, neutral: neu, negative: neg,\n      positive_share_pct: positiveShare,\n      average_rating: avgRating,\n      reply_rate_pct: replyRatePct,\n      median_reply_latency_hours: medianReplyLatencyHours,\n      isLastWeek: true                 // <— keep tag so Join can detect “last-week reviews”\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "8d710516-4d4c-4b13-87c2-1534861bb63a",
      "name": "12週間レビューを要約(合計+週平均)",
      "type": "n8n-nodes-base.code",
      "position": [
        -2336,
        3376
      ],
      "parameters": {
        "jsCode": "// Summarize 12-WEEK Google Reviews — SAFE\n// Input: rows from the 12-week mapper above (or any rows with companyName/locationId/ratingNum/sentiment/hasReply)\n\nfunction avg(a){ return a.length ? +(a.reduce((x,y)=>x+y,0)/a.length).toFixed(2) : 0; }\nfunction median(a){\n  if (!a.length) return 0;\n  const s=[...a].sort((x,y)=>x-y); const m=Math.floor(s.length/2);\n  return s.length%2 ? s[m] : +(((s[m-1]+s[m])/2).toFixed(2));\n}\n\nconst rows = $input.all().map(i => i.json).filter(Boolean);\n\n// group by company+location (12w window is already applied upstream)\nconst groups = new Map();\nfor (const r of rows) {\n  const key = `${r.companyName||'(unknown)'}__${r.locationId||'null'}`;\n  if (!groups.has(key)) groups.set(key, []);\n  groups.get(key).push(r);\n}\n\nconst out = [];\nfor (const [, list] of groups.entries()) {\n  const companyName = list[0].companyName || '(unknown)';\n  const locationId  = list[0].locationId || null;\n\n  const total = list.length;\n\n  const ratings = list.map(r => Number(r.ratingNum||0)).filter(Number.isFinite);\n  const avgRating = avg(ratings);\n\n  const pos = list.filter(r => r.sentiment_label === 'Positive').length;\n  const neu = list.filter(r => r.sentiment_label === 'Neutral').length;\n  const neg = list.filter(r => r.sentiment_label === 'Negative').length;\n  const positiveShare = total ? +((pos/total)*100).toFixed(1) : 0;\n\n  const replied = list.filter(r => r.hasReply).length;\n  const replyRatePct = total ? +((replied/total)*100).toFixed(1) : 0;\n\n  const latencies = list.map(r => (typeof r.replyLatencyHours === 'number') ? r.replyLatencyHours : null).filter(v => v !== null);\n  const medianReplyLatencyHours = median(latencies);\n\n  // also pass through the 12w window (start/end/weeks) if present\n  const startISO = list[0].startISO || null;\n  const endISO   = list[0].endISO   || null;\n  const weeks    = Number(list[0].weeks || 12) || 12;\n\n  out.push({\n    json: {\n      companyName, locationId,\n      startISO, endISO, weeks,\n\n      // 12w totals/KPIs\n      total_reviews_12w: total,\n      positive_12w: pos, neutral_12w: neu, negative_12w: neg,\n      positive_share_pct_12w: positiveShare,\n      average_rating_12w: avgRating,\n      reply_rate_pct_12w: replyRatePct,\n      median_reply_latency_hours_12w: medianReplyLatencyHours\n    }\n  });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "511fbcd7-1d48-47d6-be2c-63d1f99d80fe",
      "name": "12週間ウィンドウを構築(レビュー)",
      "type": "n8n-nodes-base.code",
      "position": [
        -3488,
        3376
      ],
      "parameters": {
        "jsCode": "// Build 12 full weeks ending yesterday (UTC), aligned to whole days.\n// Output is a single item with {startISO,endISO,week_start,week_end,weeks:12}\n\nconst WEEKS = 12;\n\nfunction atUtcMidnight(d = new Date()){\n  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));\n}\n\nconst todayMid = atUtcMidnight();          // today 00:00Z\nconst endISOd  = new Date(todayMid);       // yesterday 00:00Z\nendISOd.setUTCDate(endISOd.getUTCDate() - 1);\n\n// inclusive range: start = end - (7*WEEKS - 1) days\nconst startISOd = new Date(endISOd);\nstartISOd.setUTCDate(startISOd.getUTCDate() - (7*WEEKS - 1));\n\nconst isoDate = d => d.toISOString().slice(0,10);\n\nreturn [{\n  json: {\n    weeks: WEEKS,\n    startISO: startISOd.toISOString(),\n    endISO:   endISOd.toISOString(),\n    week_start: isoDate(startISOd),\n    week_end:   isoDate(endISOd),\n    ts: {\n      start: startISOd.getTime(),\n      end:   endISOd.getTime() + 24*60*60*1000 - 1,\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b9891c51-db0e-4e30-bdc9-639de247fc8a",
      "name": "フォーマット",
      "type": "n8n-nodes-base.code",
      "position": [
        -1200,
        2640
      ],
      "parameters": {
        "jsCode": "// n8n Code node (JavaScript)\n// Input: items[] from Join (one per company) with keys:\n// impressions, impressions_lastWeek, impressions_12w\n// reviews, reviews_lastWeek, reviews_12w\n// Optional: allTimeReviews\n// Output: same items, enriched with deltas + LLM one-liner prompts\n\nfunction toNum(x, def = 0) {\n  const n = Number(x);\n  return Number.isFinite(n) ? n : def;\n}\nfunction round(n, dp = 0) {\n  const f = Math.pow(10, dp);\n  return Math.round(toNum(n) * f) / f;\n}\nfunction safePct(numerator, denominator) {\n  numerator = toNum(numerator);\n  denominator = toNum(denominator);\n  if (denominator === 0) return 0;\n  return (numerator / denominator) * 100;\n}\nfunction pctDelta(thisVal, prevVal) {\n  // percent change vs previous: (this - prev) / prev * 100\n  const a = toNum(thisVal), b = toNum(prevVal);\n  if (b === 0) return 0;\n  return ((a - b) / b) * 100;\n}\nfunction ppDelta(thisPct, prevPct) {\n  return toNum(thisPct) - toNum(prevPct);\n}\nfunction hoursDelta(thisH, prevH) {\n  return toNum(thisH) - toNum(prevH);\n}\nfunction fmtPct(x) { return `${round(x, 0)}%`; }\nfunction fmtPp(x) { const s = round(x, 1); return (s >= 0 ? `+` : ``) + `${s} pp`; }\nfunction fmtHrs(x) { const s = round(x, 1); return `${s}h`; }\n\n// Treat small changes as flat for readability\nconst FLAT_PCT = 2;     // ±2% considered \"flat\" for volumes\nconst FLAT_PP  = 0.5;   // ±0.5 pp considered \"flat\" for rates\nconst FLAT_HRS = 0.3;   // ±0.3h considered \"flat\" for med reply hours\n\nfunction flatLabel(value, kind = 'pct') {\n  const v = toNum(value);\n  const th = kind === 'pp' ? FLAT_PP : kind === 'hrs' ? FLAT_HRS : FLAT_PCT;\n  if (Math.abs(v) <= th) return 'flat';\n  return v > 0 ? 'up' : 'down';\n}\n\nfunction sanitizeBlocks(i) {\n  // Provide safe defaults if any blocks are missing\n  i.impressions = i.impressions || {};\n  i.impressions_lastWeek = i.impressions_lastWeek || {};\n  i.impressions_12w = i.impressions_12w || {};\n  i.reviews = i.reviews || {};\n  i.reviews_lastWeek = i.reviews_lastWeek || {};\n  i.reviews_12w = i.reviews_12w || {};\n  i.allTimeReviews = i.allTimeReviews || {};\n  return i;\n}\n\nfunction buildOutputs(it) {\n  const company = it.companyName || it.company || 'Company';\n\n  // ---- Impressions (this, last, 12w) ----\n  const imprThis = toNum(it.impressions.total);\n  const actThis  = toNum(it.impressions.actions_total);\n  const ctrThis  = toNum(it.impressions.ctr_actions_pct);\n\n  const imprLW = toNum(it.impressions_lastWeek.total);\n  const actLW  = toNum(it.impressions_lastWeek.actions_total);\n  const ctrLW  = toNum(it.impressions_lastWeek.ctr_actions_pct);\n\n  const imprAvg = toNum(it.impressions_12w.avg_week_total);\n  const actAvg  = toNum(it.impressions_12w.avg_week_actions_total);\n  const ctrAvg  = toNum(it.impressions_12w.avg_week_ctr_pct);\n\n  // deltas vs last week\n  const imprWoW = pctDelta(imprThis, imprLW);\n  const actWoW  = pctDelta(actThis, actLW);\n  const ctrWoW  = ppDelta(ctrThis, ctrLW);\n\n  // deltas vs 12w average\n  const imprVsAvg = pctDelta(imprThis, imprAvg);\n  const actVsAvg  = pctDelta(actThis, actAvg);\n  const ctrVsAvg  = ppDelta(ctrThis, ctrAvg);\n\n  // ---- Reviews (this, last, 12w) ----\n  const revThis = toNum(it.reviews.total);\n  const posThis = toNum(it.reviews.positiveShare);\n  const repThis = toNum(it.reviews.replyRatePct);\n  const hrsThis = toNum(it.reviews.medianReplyLatencyHours);\n\n  const revLW   = toNum(it.reviews_lastWeek.total);\n  const posLW   = toNum(it.reviews_lastWeek.positiveShare);\n  const repLW   = toNum(it.reviews_lastWeek.replyRatePct);\n  const hrsLW   = toNum(it.reviews_lastWeek.medianReplyLatencyHours);\n\n  const revAvg  = toNum(it.reviews_12w.avg_total);\n  const posAvg  = toNum(it.reviews_12w.avg_positiveShare);\n  const repAvg  = toNum(it.reviews_12w.avg_replyRatePct);\n  const hrsAvg  = toNum(it.reviews_12w.avg_medianReplyLatencyHours);\n\n  // Review deltas\n  const revWoW = pctDelta(revThis, revLW);\n  const revVsAvg = pctDelta(revThis, revAvg);\n  const posWoWpp = ppDelta(posThis, posLW);\n  const posVsAvgpp = ppDelta(posThis, posAvg);\n  const repWoWpp = ppDelta(repThis, repLW);\n  const repVsAvgpp = ppDelta(repThis, repAvg);\n  const hrsWoW = hoursDelta(hrsThis, hrsLW);\n  const hrsVsAvg = hoursDelta(hrsThis, hrsAvg);\n\n  // ---- KPI chip text (optional) ----\n  it.kpi = {\n    impressions_vs12w_pct: round(imprVsAvg, 0),   // negative is below avg\n    actions_vs12w_pct:     round(actVsAvg, 0),\n    ctr_vs12w_pp:          round(ctrVsAvg, 1),\n    reviews_vs12w_count:   round(revThis - revAvg, 1) // absolute difference in count vs avg\n  };\n\n  // ---- LLM one-liner prompts ----\n  it.oneLinerOverall_prompt =\n`Write ONE sentence (≤26 words) on ${company}'s weekly Google performance vs last week and vs 12-week average.\nNumbers (whole %; CTR deltas in pp):\nThis: ${imprThis} impr, ${actThis} actions, CTR ${round(ctrThis,0)}%.\nLast: ${imprLW} impr, ${actLW} actions, CTR ${round(ctrLW,0)}%.\n12w: ${round(imprAvg,0)} impr, ${round(actAvg,0)} actions, CTR ${round(ctrAvg,0)}%.\nReviews: this ${revThis} (${round(posThis,0)}% positive), last ${revLW}, 12w avg ${round(revAvg,1)}.\nOutput only the sentence.`;\n\n  it.oneLinerImpressions_prompt =\n`One sentence (≤22 words) comparing impressions, actions, and CTR this week vs last week and the 12-week average; say up/down/flat in whole % or \"pp\".\nThis: ${imprThis} impr, ${actThis} actions, CTR ${round(ctrThis,0)}%.\nLast: ${imprLW} impr, ${actLW} actions, CTR ${round(ctrLW,0)}%.\n12w: ${round(imprAvg,0)} impr, ${round(actAvg,0)} actions, CTR ${round(ctrAvg,0)}%.`;\n\n  it.oneLinerReviews_prompt =\n`One sentence (≤22 words) comparing review volume, sentiment (positive share), and reply behavior this week vs last week and 12-week average.\nThis: ${revThis} total, ${round(posThis,0)}% positive, replies ${round(repThis,0)}%, median ${round(hrsThis,1)}h.\nLast: ${revLW} total, ${round(posLW,0)}% positive, replies ${round(repLW,0)}%, median ${round(hrsLW,1)}h.\n12w: ${round(revAvg,1)} avg/wk, ${round(posAvg,0)}% positive, replies ${round(repAvg,0)}%, median ${round(hrsAvg,1)}h.`;\n\n  // ---- Add friendly deltas for chips/labels (already rounded) ----\n  it.deltas = {\n    // vs last week\n    impr_wow_pct: round(imprWoW, 0),\n    act_wow_pct:  round(actWoW, 0),\n    ctr_wow_pp:   round(ctrWoW, 1),\n    // vs 12w avg\n    impr_vsavg_pct: round(imprVsAvg, 0),\n    act_vsavg_pct:  round(actVsAvg, 0),\n    ctr_vsavg_pp:   round(ctrVsAvg, 1),\n    // reviews\n    reviews_wow_pct: round(revWoW, 0),\n    reviews_vsavg_pct: round(revVsAvg, 0),\n    pos_wow_pp: round(posWoWpp, 1),\n    pos_vsavg_pp: round(posVsAvgpp, 1),\n    replies_wow_pp: round(repWoWpp, 1),\n    replies_vsavg_pp: round(repVsAvgpp, 1),\n    replyHours_wow: round(hrsWoW, 1),\n    replyHours_vsavg: round(hrsVsAvg, 1)\n  };\n\n  // ---- Flat flags (optional, for “=” badges) ----\n  it.flags = {\n    impr_wow_flat: flatLabel(imprWoW, 'pct') === 'flat',\n    act_wow_flat:  flatLabel(actWoW, 'pct') === 'flat',\n    ctr_wow_flat:  flatLabel(ctrWoW, 'pp')  === 'flat',\n    impr_vsavg_flat: flatLabel(imprVsAvg, 'pct') === 'flat',\n    act_vsavg_flat:  flatLabel(actVsAvg, 'pct') === 'flat',\n    ctr_vsavg_flat:  flatLabel(ctrVsAvg, 'pp')  === 'flat',\n    reviews_wow_flat: flatLabel(revWoW, 'pct') === 'flat',\n    reviews_vsavg_flat: flatLabel(revVsAvg, 'pct') === 'flat',\n    pos_wow_flat: flatLabel(posWoWpp, 'pp') === 'flat',\n    pos_vsavg_flat: flatLabel(posVsAvgpp, 'pp') === 'flat',\n    replies_wow_flat: flatLabel(repWoWpp, 'pp') === 'flat',\n    replies_vsavg_flat: flatLabel(repVsAvgpp, 'pp') === 'flat',\n    replyHours_wow_flat: flatLabel(hrsWoW, 'hrs') === 'flat',\n    replyHours_vsavg_flat: flatLabel(hrsVsAvg, 'hrs') === 'flat'\n  };\n\n  // Convenience fields for HTML bindings (this/last/avg, rounded for display)\n  it.display = {\n    impressions: {\n      this: { impr: imprThis, act: actThis, ctr: round(ctrThis,0) },\n      last: { impr: imprLW,   act: actLW,   ctr: round(ctrLW,0) },\n      avg:  { impr: round(imprAvg,0), act: round(actAvg,0), ctr: round(ctrAvg,0) }\n    },\n    reviews: {\n      this: { total: revThis, pos: round(posThis,0), replies: round(repThis,0), hours: round(hrsThis,1) },\n      last: { total: revLW,   pos: round(posLW,0),   replies: round(repLW,0),   hours: round(hrsLW,1) },\n      avg:  { total: round(revAvg,1), pos: round(posAvg,0), replies: round(repAvg,0), hours: round(hrsAvg,1) }\n    }\n  };\n\n  return it;\n}\n\nconst out = [];\nfor (const item of items) {\n  const i = sanitizeBlocks({ ...item.json });\n  out.push({ json: buildOutputs(i) });\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "223f8523-f064-494a-adfc-bf35457d288c",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -832,
        2496
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5",
          "cachedResultName": "gpt-5"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "id": "0wxxfUy5p26AEoAX",
          "name": "OpenAi account"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "6068f4e7-d0b1-4969-9630-a146b59edb8d",
      "name": "レビュー一行要約",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -832,
        2640
      ],
      "parameters": {
        "text": "=companyKey = \"{{$json.companyName}}__{{$json.locationId}}\"\n\n{{$json.oneLinerReviews_prompt}}",
        "options": {
          "systemMessage": "You are a marketing analyst. Return STRICT JSON per schema.\nOne sentence, plain English: compare review volume vs last week and 12-week average, note sentiment (positive share) and reply behavior (rate + speed).\nAvoid numeric dumps; use words like up/down/flat; hours as “slow/fast” unless a simple figure helps (“~3h”). No extra fields.",
          "enableStreaming": false
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "76d5fcbb-d9da-4c63-8f9d-cc4b9b9e43b5",
      "name": "インプレッション一行要約",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -816,
        3024
      ],
      "parameters": {
        "text": "=companyKey = \"{{$json.companyName}}__{{$json.locationId}}\"\n\n{{$json.oneLinerImpressions_prompt}}",
        "options": {
          "systemMessage": "You are a marketing analyst. Return STRICT JSON per schema.\nOne sentence, plain English: compare impressions, actions, and CTR vs last week and vs the 12-week average.\nPrefer words (up/down/flat); avoid numeric dumps; only use “pp” for CTR if needed. No extra fields.",
          "enableStreaming": false
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "27cd3fc0-266a-4657-9895-d43f2eb6b181",
      "name": "全体一行要約",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -832,
        2304
      ],
      "parameters": {
        "text": "=companyKey = \"{{$json.companyName}}__{{$json.locationId}}\"\n\n{{$json.oneLinerOverall_prompt}}",
        "options": {
          "systemMessage": "You are a marketing analyst. Return STRICT JSON per schema.\nWrite ONE concise sentence for executives, plain English, minimal numbers.\nSay whether traffic and actions are up/down/flat vs last week AND vs the 12-week average; mention CTR status; mention review volume + sentiment.\nAvoid numeric values except short cues like “12-week average” or “+2 pp” if essential. No emojis. No extra fields.",
          "enableStreaming": false
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "82ad46ba-481e-4e73-8136-fea2a915c344",
      "name": "全体パーサー",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        -688,
        2496
      ],
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"required\": [\"companyKey\", \"oneLinerOverall\"],\n  \"properties\": {\n    \"companyKey\": { \"type\": \"string\", \"minLength\": 1 },\n    \"oneLinerOverall\": { \"type\": \"string\", \"minLength\": 6, \"maxLength\": 220 }\n  }\n}"
      },
      "typeVersion": 1.3
    },
    {
      "id": "dbcb0445-bc6a-4609-9595-5ca76e07f3e5",
      "name": "受信者を読み込む",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        320,
        2912
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1425813895,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=1425813895",
          "cachedResultName": "Recipients"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "executeOnce": true,
      "typeVersion": 4.7
    },
    {
      "id": "4be9d84d-8407-492e-8106-a123cc1c4fbb",
      "name": "キーを正規化",
      "type": "n8n-nodes-base.code",
      "position": [
        512,
        2640
      ],
      "parameters": {
        "jsCode": "// Input item shape from Merge One Liners:\n// { output: { companyKey: \"Company__locationId\", oneLinerOverall?, oneLinerImpressions?, oneLinerReviews? } }\n\nconst items = $input.all();\n\nfunction nz(v){ return v == null ? \"\" : String(v).trim(); }\n\nfunction pickLine(o){\n  // prefer Overall, fallback to Impressions, then Reviews\n  return nz(o.oneLinerOverall || o.oneLinerImpressions || o.oneLinerReviews || \"\");\n}\n\nreturn items.map(({json}) => {\n  const out = json.output || {};\n  const key = nz(out.companyKey);\n  const parts = key.split(\"__\");               // [\"Company\", \"locationId\"]\n  const company = nz(parts[0] || \"\");\n  const locationId = nz(parts[1] || \"\");\n  const one_liner = pickLine(out);\n\n  return {\n    json: {\n      company,\n      locationId,\n      // if you also carry accountId elsewhere, you can add it here\n      join_key: key,            // use the exact companyKey to avoid name mismatches\n      one_liner\n    }\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "196c8c75-fc62-429d-9c0c-052cbd932fb5",
      "name": "受信者を正規化",
      "type": "n8n-nodes-base.code",
      "position": [
        512,
        2912
      ],
      "parameters": {
        "jsCode": "// Build join_key = company + \"__\" + locationId (strings), and rename headers.\n\nconst rows = $items('Read Recipients').map(i => i.json || {});\nfunction nz(v){ return v == null ? \"\" : String(v).trim(); }\n\nreturn rows.map(r => {\n  const company    = nz(r.company);\n  const accountId  = nz(r.accountId);\n  const locationId = nz(r.locationId);\n  const email_to   = nz(r[\"Email to\"]);\n  const email_cc   = nz(r[\"Email CC\"]);\n  const slack_channel = nz(r.slack_channel);\n  const slack_user    = nz(r.slack_user);\n\n  // match upstream join_key exactly (Company__locationId)\n  const join_key = company + \"__\" + locationId;\n\n  return {\n    json: {\n      company, accountId, locationId, join_key,\n      email_to, email_cc, slack_channel, slack_user\n    }\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "52a7d6c0-5347-4f17-a6aa-24eb618e41dc",
      "name": "受信者を添付",
      "type": "n8n-nodes-base.merge",
      "position": [
        832,
        2656
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "fieldsToMatchString": "join_key"
      },
      "typeVersion": 3.2
    },
    {
      "id": "c5c888b3-4a75-4983-ae8e-6dc94ed15bfc",
      "name": "全企業 Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1424,
        2400
      ],
      "webhookId": "617e6f70-8d00-4353-9e9b-b2788da3420e",
      "parameters": {
        "text": "={{$json.company + \"\\n\" + $json.period + \"\\n\" + $json.one_liner}}",
        "user": {
          "__rl": true,
          "mode": "list",
          "value": "U034CQH4PHR",
          "cachedResultName": "peyton"
        },
        "select": "user",
        "otherOptions": {
          "includeLinkToWorkflow": false
        },
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "id": "jUKlfOXBmqFckWUK",
          "name": "TS Slack"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "eea60dd1-d891-41c6-bb82-2d98156dfcac",
      "name": "企業別 Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1424,
        2656
      ],
      "webhookId": "132c2789-b731-44d0-ae69-f66bff3ca330",
      "parameters": {
        "text": "={{$json.company + \"\\n\" + $json.period + \"\\n\" + $json.one_liner}}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{$json.slack_channel}}"
        },
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "id": "qUR4mW2YUh1F1uDg",
          "name": "Slack account"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "3eb7c2e1-555a-4ffe-a890-0c24eaa363fe",
      "name": "企業別メール",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1424,
        2912
      ],
      "webhookId": "b31af8ae-f6be-4fec-a54d-a34de38875de",
      "parameters": {
        "sendTo": "={{$json.email_to}}",
        "message": "={{$json.html}}",
        "options": {
          "ccList": "={{$json.email_cc}}"
        },
        "subject": "={{$json.subject}}"
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "MvPzgYZlHt189mx3",
          "name": "Gmail account"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "acdb5642-075c-4f9d-b4c7-838bde6d01a9",
      "name": "一行要約をマージ",
      "type": "n8n-nodes-base.merge",
      "position": [
        96,
        2624
      ],
      "parameters": {
        "numberInputs": 3
      },
      "typeVersion": 3.2
    },
    {
      "id": "e7e1cc2c-0e3e-43c9-b2ee-55c9079eec75",
      "name": "企業ごとに3行をグループ化",
      "type": "n8n-nodes-base.code",
      "position": [
        1024,
        2656
      ],
      "parameters": {
        "jsCode": "// Input: mixed items per join_key (may be structured as output.oneLinerOverall/Impressions/Reviews,\n// or flat \"one_liner\") + recipient fields.\n// Also reads from node \"format\" to attach week_start/week_end by locationId.\n// Output: exactly 1 item per join_key with lines.{overall,impressions,reviews} (no nulls),\n// one_liner (joined), week_start, week_end, period, and recipient fields.\n\n// ---------- helpers ----------\nconst S = v => String(v ?? '').trim();\n\nfunction fallbackClassify(text = '') {\n  // only used if we don't have explicit labels\n  const t = S(text).toLowerCase();\n  if (t.includes('impressions') || t.includes('ctr') || t.includes('actions')) return 'impressions';\n  if (t.includes('review') || t.includes('sentiment') || t.includes('reply') || t.includes('rating')) return 'reviews';\n  return 'overall';\n}\n\n// Build a reliable join key from the item\nfunction getJoinKey(j) {\n  // Prefer explicit join_key\n  if (S(j.join_key)) return S(j.join_key);\n\n  // If structured output exists, it usually has companyKey like \"<name>__<locationId>\"\n  if (j.output && S(j.output.companyKey)) return S(j.output.companyKey);\n\n  // As a last resort, compose\n  const c = S(j.company);\n  const loc = S(j.locationId);\n  return c && loc ? `${c}__${loc}` : '';\n}\n\n// ---------- build lookup from \"format\" by locationId ----------\nconst fmtArray = ($items('format', 0, 0) || []).map(i => i.json || {});\nconst fmtByLoc = new Map(fmtArray.map(f => [S(f.locationId), f]));\n\n// ---------- group ----------\nconst groups = new Map();\n\nfor (const { json } of $input.all()) {\n  // Determine grouping key\n  const key = getJoinKey(json);\n  if (!key) continue;\n\n  // Start/restore group\n  const g = groups.get(key) || {\n    join_key: key,\n    company: S(json.company) || S((json.output || {}).companyName) || S(key.split('__')[0]),\n    locationId: S(json.locationId) || S((json.output || {}).locationId) || S(key.split('__')[1]),\n    lines: { overall: null, impressions: null, reviews: null }\n  };\n\n  // --- Bring in recipients/identity if present\n  g.accountId      = g.accountId      ?? json.accountId;\n  g.email_to       = g.email_to       ?? json.email_to;\n  g.email_cc       = g.email_cc       ?? json.email_cc;\n  g.slack_channel  = g.slack_channel  ?? json.slack_channel;\n  g.slack_user     = g.slack_user     ?? json.slack_user;\n\n  // --- Prefer structured one-liners when available\n  if (json.output) {\n    const o = json.output;\n    if (S(o.oneLinerOverall))     g.lines.overall     = S(o.oneLinerOverall);\n    if (S(o.oneLinerImpressions)) g.lines.impressions = S(o.oneLinerImpressions);\n    if (S(o.oneLinerReviews))     g.lines.reviews     = S(o.oneLinerReviews);\n  }\n\n  // --- If we only have a flat one_liner, classify and place\n  if (!json.output && S(json.one_liner)) {\n    const line = S(json.one_liner);\n    const cls = fallbackClassify(line);\n    if (!g.lines[cls]) g.lines[cls] = line;\n    else if (!g.lines.overall) g.lines.overall = line;           // spillover\n    else if (!g.lines.overall.includes(line)) g.lines.overall += ' ' + line;\n  }\n\n  // --- Attach week period from \"format\" by locationId\n  const f = fmtByLoc.get(S(g.locationId)) || {};\n  g.week_start = g.week_start || f.week_start || '';\n  g.week_end   = g.week_end   || f.week_end   || '';\n  g.period     = (S(g.week_start) && S(g.week_end)) ? `week period = ${g.week_start}–${g.week_end}` : '';\n\n  groups.set(key, g);\n}\n\n// ---------- finalize: ensure no nulls and stable order ----------\nconst out = [];\nfor (const g of groups.values()) {\n  // Fill any missing slots with sensible fallbacks\n  // If overall missing, derive from impressions + reviews; else mark em-dash.\n  if (!g.lines.overall) {\n    if (g.lines.impressions && g.lines.reviews) {\n      g.lines.overall = `${g.lines.impressions} ${g.lines.reviews}`;\n    } else if (g.lines.impressions) {\n      g.lines.overall = g.lines.impressions;\n    } else if (g.lines.reviews) {\n      g.lines.overall = g.lines.reviews;\n    } else {\n      g.lines.overall = '—';\n    }\n  }\n  if (!g.lines.impressions) g.lines.impressions = '—';\n  if (!g.lines.reviews)     g.lines.reviews     = '—';\n\n  // Join in this order: overall → impressions → reviews\n  const oneJoined = [g.lines.overall, g.lines.impressions, g.lines.reviews]\n    .filter(s => S(s).length > 0)\n    .join('\\n');\n\n  out.push({ json: { ...g, one_liner: oneJoined } });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "95279c49-402c-4d05-97d9-52c2ea0a5c07",
      "name": "シートに行を追加",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -2336,
        2336
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "company",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "company",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "locationId",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "locationId",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_start",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_start",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_end",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_end",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "website_clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "website_clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "call_clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "call_clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "direction_requests",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "direction_requests",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search_desktop",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search_desktop",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_search_mobile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_search_mobile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps_desktop",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps_desktop",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "impressions_maps_mobile",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "impressions_maps_mobile",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 2132032743,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit#gid=2132032743",
          "cachedResultName": "last week Impression Data"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1nLUdbky6ngWyEFXS-I-1dkmUDt5IeVbY75QwvKCQ-o0/edit?usp=drivesdk",
          "cachedResultName": "GoogleData - Peyton "
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "ssM8fSaJL5EtFqLs",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "8f3324e0-86a2-4c29-a874-016fb8709cf8",
      "name": "付箋",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5840,
        1936
      ],
      "parameters": {
        "width": 1280,
        "height": 1280,
        "content": "# 🧩 Setup Guide: Google Business → Slack + Weekly Pulse Email Workflow\n\nThis workflow automates your **Google Business performance reporting** by:\n\n- Pulling data from Google Business Profiles (impressions, actions, CTR, reviews)\n- Comparing results vs **last week** and the **12-week average**\n- Generating **LLM-powered summaries**\n- Sending formatted **Slack updates** and a **weekly pulse email**\n\n---\n\n## ✅ Prerequisites\n\nBefore activating the workflow, make sure you have the following ready:\n\n### 1. Google Sheet for Companies  \n👉 [**Copy this Template**](https://docs.google.com/spreadsheets/d/1u-87I9CUywZwiy9oDTUkMrVSADzeojk2UTTJRs3YOGY/edit?usp=sharing)\n\n**Steps:**\n- Go to **File → Make a copy**\n- Add one row per company\n- Include the following columns:\n  - `Company Name`\n  - `Google Business ID`\n  - `Slack Channel` (e.g. `#brads-service-center`)\n  - `Slack Recipients` (comma-separated Slack IDs)\n  - `Email Recipients` (for the weekly pulse)\n\n---\n\n### 2. Google Business API Credential\n- Create a **Google Cloud Project** with the **Business Profile API** enabled  \n- Connect in n8n via a **Google Business OAuth2** credential\n\n---\n\n### 3. Google Sheets Credential\n- Add a **Google Sheets OAuth2** credential in n8n  \n- Used to read the company data from your Google Sheet\n\n---\n\n### 4. Slack Credential\n- Add a **Slack OAuth2** credential in n8n  \n- Required permissions:\n  - `chat:write`\n  - `users:read`\n  - `channels:read`\n\n---\n\n### 5. Email Credential\n(Optional but recommended)  \n- Add a **Gmail** or **SMTP** credential in n8n for sending the weekly pulse email\n\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "72c2fca8-ba8a-409a-b5f3-42162f3d6168",
      "name": "付箋1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3600,
        1952
      ],
      "parameters": {
        "width": 1408,
        "height": 736,
        "content": "### 🧭 Impressions Data Collection\n\nThis section gathers **visibility metrics** from Google Business Profiles:\n\n- Collects data for **this week**, **last week**, and the **12-week average**\n- Tracks **impressions, actions, and CTR** — showing how often your business is seen and how effectively customers engage\n- Results are **flattened and written** back to the Google Sheet for use in weekly summaries, Slack messages, and pulse emails\n"
      },
      "typeVersion": 1
    },
    {
      "id": "628a5f2c-6ebb-48b0-b059-f34699ec919b",
      "name": "付箋2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -4160,
        1952
      ],
      "parameters": {
        "width": 528,
        "height": 736,
        "content": "### Gather company data from google sheets \n\nThis section gathers your company info from google sheets to later fill out the HTTP requests to the google business api\n"
      },
      "typeVersion": 1
    },
    {
      "id": "31974318-19cf-47c7-bfa9-51691787fa48",
      "name": "付箋3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3600,
        2704
      ],
      "parameters": {
        "width": 1408,
        "height": 1152,
        "content": "### 💬 Reviews Data Collection & Summaries\n\nThis section gathers **review data** from Google Business Profiles across multiple timeframes:\n\n- **Current week:** Captures new reviews, sentiment (positive/neutral/negative), and reply rate  \n- **Last week:** Provides a direct week-over-week comparison  \n- **12-week average:** Establishes a performance baseline to show longer-term trends  \n- **All-time summary:** Aggregates total reviews, overall rating, and reply metrics for historical context  \n\nEach batch is **mapped, categorized, and summarized** using n8n code nodes, then written to the Google Sheet.  \nThe data feeds into the **LLM summary and weekly pulse email**, helping identify shifts in reputation and engagement over time.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "a834ccd0-dd90-4e21-a201-a7de917ec9c3",
      "name": "付箋4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1712,
        1952
      ],
      "parameters": {
        "width": 1712,
        "height": 1888,
        "content": "### 🧠 LLM Summaries & One-Liners\n\nThis section combines and formats all key metrics before generating **AI-powered summaries**.\n\n- **Merge2 + Join All:** Collects results from the impressions, actions, CTR, and reviews pipelines  \n- **Format Node:** Structures the combined data into a clean JSON payload for the LLMs  \n\nThen, three **OpenAI Chat Model** nodes create short, natural-language insights:\n- **Overall one-liner:** A concise summary tying together visibility, engagement, and reputation  \n- **Reviews one-liner:** Focused on review volume, sentiment, and response quality  \n- **Impressions one-liner:** Highlights visibility and click-through performance week-over-week  \n\nThese summaries feed directly into the **Slack messages** and **weekly pulse email**, making complex performance data easy to read and actionable.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "3ae35762-9f4b-418d-8528-a77ca9b6e693",
      "name": "付箋5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        32,
        1952
      ],
      "parameters": {
        "width": 1712,
        "height": 1888,
        "content": "### 📬 Distribution: Slack & Weekly Pulse Emails\n\nThis section prepares and sends the **final summaries** to each company via Slack and email.\n\n- **Merge One Liners:** Combines all AI-generated one-liners (overall, impressions, and reviews)  \n- **Normalize Keys:** Cleans and aligns data field names across merged results  \n- **Read + Normalize Recipients:** Pulls recipient details (Slack channels and email addresses) from the Google Sheet  \n- **Attach Recipients:** Joins each company’s metrics with its communication targets  \n- **Group Three Lines Per Company:** Combines all summaries into a single formatted message per business  \n\nFrom here:\n- **All-companies Slack:** Sends a combined summary for all companies  \n- **Per Company Slack:** Sends an individual message to each company channel using Slack Blocks  \n- **Create Email → Per Company Email:** Builds and delivers the weekly **Google Business Pulse** email to each company’s contact list  \n\nThis is the final step where insights become **actionable communications**, automatically shared with each business every week.\n\n"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "pinData": {},
  "settings": {
    "callerPolicy": "workflowsFromSameOwner",
    "executionOrder": "v1",
    "timeSavedPerExecution": 40
  },
  "versionId": "478fd55e-2f2f-48f4-946d-e82217615f46",
  "connections": {
    "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3": {
      "main": [
        [
          {
            "node": "ad7d3fb3-00ec-4f77-811d-5f34c6e5e14d",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "b9891c51-db0e-4e30-bdc9-639de247fc8a": {
      "main": [
        [
          {
            "node": "27cd3fc0-266a-4657-9895-d43f2eb6b181",
            "type": "main",
            "index": 0
          },
          {
            "node": "6068f4e7-d0b1-4969-9630-a146b59edb8d",
            "type": "main",
            "index": 0
          },
          {
            "node": "76d5fcbb-d9da-4c63-8f9d-cc4b9b9e43b5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2c9d403b-d03d-43fd-900e-60089927fc03": {
      "main": [
        [
          {
            "node": "0ac8d44a-7393-4bf8-9d59-364c5a4c7a2f",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ad7d3fb3-00ec-4f77-811d-5f34c6e5e14d": {
      "main": [
        [
          {
            "node": "b9891c51-db0e-4e30-bdc9-639de247fc8a",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "42790f95-855c-439e-8ea2-b9a938a0ba55": {
      "main": [
        [
          {
            "node": "3eb7c2e1-555a-4ffe-a890-0c24eaa363fe",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "fee14269-084f-45d7-92e0-1a99da5cc2f6": {
      "ai_outputParser": [
        [
          {
            "node": "6068f4e7-d0b1-4969-9630-a146b59edb8d",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "4be9d84d-8407-492e-8106-a123cc1c4fbb": {
      "main": [
        [
          {
            "node": "52a7d6c0-5347-4f17-a6aa-24eb618e41dc",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "a83547f7-dfac-4064-b0ab-a98c4d5668c4": {
      "main": [
        [
          {
            "node": "8d733b11-c5e6-4f3b-a065-3b5c7b44869a",
            "type": "main",
            "index": 0
          },
          {
            "node": "c0315534-5395-4ba9-b4d8-d2554455b156",
            "type": "main",
            "index": 0
          },
          {
            "node": "13e402fa-fbaa-4b98-93f3-22fc9c7dbf43",
            "type": "main",
            "index": 0
          },
          {
            "node": "50e504fd-5112-4f11-98c9-5f1b922d4a6a",
            "type": "main",
            "index": 0
          },
          {
            "node": "0f96a41e-c575-4d21-81df-83274680903d",
            "type": "main",
            "index": 0
          },
          {
            "node": "511fbcd7-1d48-47d6-be2c-63d1f99d80fe",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "82ad46ba-481e-4e73-8136-fea2a915c344": {
      "ai_outputParser": [
        [
          {
            "node": "27cd3fc0-266a-4657-9895-d43f2eb6b181",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "b954b33c-c0b8-426e-bdc7-127499c93f36": {
      "main": [
        [
          {
            "node": "ce979f8e-8b6b-4d3e-a07d-1646e59d2e52",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "fae71ed4-93b7-4d38-b996-871dad806fc2": {
      "main": [
        [
          {
            "node": "aa759b90-5e5b-494f-9271-cc93e2e61523",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "dbcb0445-bc6a-4609-9595-5ca76e07f3e5": {
      "main": [
        [
          {
            "node": "196c8c75-fc62-429d-9c0c-052cbd932fb5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "887dd0d9-898e-4c32-adf3-bdbfeb580049": {
      "main": [
        [
          {
            "node": "7dfc29b9-989e-4ded-8b41-bd21f99015c7",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "acdb5642-075c-4f9d-b4c7-838bde6d01a9": {
      "main": [
        [
          {
            "node": "4be9d84d-8407-492e-8106-a123cc1c4fbb",
            "type": "main",
            "index": 0
          },
          {
            "node": "dbcb0445-bc6a-4609-9595-5ca76e07f3e5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "b69c6393-95ac-4234-9129-9d12653b4919": {
      "main": [
        [
          {
            "node": "a83547f7-dfac-4064-b0ab-a98c4d5668c4",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "52a7d6c0-5347-4f17-a6aa-24eb618e41dc": {
      "main": [
        [
          {
            "node": "e7e1cc2c-0e3e-43c9-b2ee-55c9079eec75",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "54e4abfc-d9c1-4822-a3a1-87662c0b0c7d": {
      "ai_outputParser": [
        [
          {
            "node": "76d5fcbb-d9da-4c63-8f9d-cc4b9b9e43b5",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "223f8523-f064-494a-adfc-bf35457d288c": {
      "ai_languageModel": [
        [
          {
            "node": "27cd3fc0-266a-4657-9895-d43f2eb6b181",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "27cd3fc0-266a-4657-9895-d43f2eb6b181": {
      "main": [
        [
          {
            "node": "acdb5642-075c-4f9d-b4c7-838bde6d01a9",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6068f4e7-d0b1-4969-9630-a146b59edb8d": {
      "main": [
        [
          {
            "node": "acdb5642-075c-4f9d-b4c7-838bde6d01a9",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "f897b099-2e89-4537-84bd-fd74b381d453": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "8d733b11-c5e6-4f3b-a065-3b5c7b44869a": {
      "main": [
        [
          {
            "node": "e982ec74-55f0-418b-9790-4d17cb929103",
            "type": "main",
            "index": 0
          },
          {
            "node": "1a372bcf-358e-42c3-b225-82690917e5e0",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "7dfc29b9-989e-4ded-8b41-bd21f99015c7": {
      "main": [
        [
          {
            "node": "8dfac27e-5b86-42da-8c83-d9170075e194",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8d45eeac-97ce-4dbb-a6ec-7df9e3b5d05f": {
      "ai_languageModel": [
        [
          {
            "node": "76d5fcbb-d9da-4c63-8f9d-cc4b9b9e43b5",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "c6e82665-fa63-4cbf-9e83-dcdc9d99bba3": {
      "ai_languageModel": [
        [
          {
            "node": "6068f4e7-d0b1-4969-9630-a146b59edb8d",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "19b21559-b1e9-4803-8493-a8a55522162d": {
      "main": [
        [
          {
            "node": "b954b33c-c0b8-426e-bdc7-127499c93f36",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "95279c49-402c-4d05-97d9-52c2ea0a5c07": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "f9ce0523-f032-4e67-9246-37bfd77310f6": {
      "main": [
        [
          {
            "node": "95279c49-402c-4d05-97d9-52c2ea0a5c07",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "e982ec74-55f0-418b-9790-4d17cb929103": {
      "main": [
        [
          {
            "node": "2c9d403b-d03d-43fd-900e-60089927fc03",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8dfac27e-5b86-42da-8c83-d9170075e194": {
      "main": [
        [
          {
            "node": "30b1aa4a-255b-4d30-8ade-af80dabaab78",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "daa03d24-bc82-4a11-961e-e47777a5e8cd": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 6
          }
        ]
      ]
    },
    "13e402fa-fbaa-4b98-93f3-22fc9c7dbf43": {
      "main": [
        [
          {
            "node": "19b21559-b1e9-4803-8493-a8a55522162d",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "196c8c75-fc62-429d-9c0c-052cbd932fb5": {
      "main": [
        [
          {
            "node": "52a7d6c0-5347-4f17-a6aa-24eb618e41dc",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "89555b75-6474-40c6-a7e3-b31c01efec64": {
      "main": [
        [
          {
            "node": "3c85818b-251f-4a00-9c8e-e15feb663765",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "76d5fcbb-d9da-4c63-8f9d-cc4b9b9e43b5": {
      "main": [
        [
          {
            "node": "acdb5642-075c-4f9d-b4c7-838bde6d01a9",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "ef1c5e27-f336-4bbb-a668-a10abe6087c2": {
      "main": [
        [
          {
            "node": "887dd0d9-898e-4c32-adf3-bdbfeb580049",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "0f96a41e-c575-4d21-81df-83274680903d": {
      "main": [
        [
          {
            "node": "aa300513-a3fe-49e0-b89f-dac7c83fcf53",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "0ac8d44a-7393-4bf8-9d59-364c5a4c7a2f": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ec75d4f5-e9b4-48aa-a971-e0f5829dba91": {
      "main": [
        [
          {
            "node": "725efc5d-a776-4a68-af5c-d04444ce6fae",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "30b1aa4a-255b-4d30-8ade-af80dabaab78": {
      "main": [
        [
          {
            "node": "f897b099-2e89-4537-84bd-fd74b381d453",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "c0315534-5395-4ba9-b4d8-d2554455b156": {
      "main": [
        [
          {
            "node": "ef1c5e27-f336-4bbb-a668-a10abe6087c2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1a372bcf-358e-42c3-b225-82690917e5e0": {
      "main": [
        [
          {
            "node": "f9ce0523-f032-4e67-9246-37bfd77310f6",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "50e504fd-5112-4f11-98c9-5f1b922d4a6a": {
      "main": [
        [
          {
            "node": "fae71ed4-93b7-4d38-b996-871dad806fc2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "e7e1cc2c-0e3e-43c9-b2ee-55c9079eec75": {
      "main": [
        [
          {
            "node": "c5c888b3-4a75-4983-ae8e-6dc94ed15bfc",
            "type": "main",
            "index": 0
          },
          {
            "node": "eea60dd1-d891-41c6-bb82-2d98156dfcac",
            "type": "main",
            "index": 0
          },
          {
            "node": "42790f95-855c-439e-8ea2-b9a938a0ba55",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "aa300513-a3fe-49e0-b89f-dac7c83fcf53": {
      "main": [
        [
          {
            "node": "89555b75-6474-40c6-a7e3-b31c01efec64",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "511fbcd7-1d48-47d6-be2c-63d1f99d80fe": {
      "main": [
        [
          {
            "node": "5a36f4ff-70f0-4cb3-b74e-4995050a159c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ce979f8e-8b6b-4d3e-a07d-1646e59d2e52": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "5a36f4ff-70f0-4cb3-b74e-4995050a159c": {
      "main": [
        [
          {
            "node": "ec75d4f5-e9b4-48aa-a971-e0f5829dba91",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "aa759b90-5e5b-494f-9271-cc93e2e61523": {
      "main": [
        [
          {
            "node": "daa03d24-bc82-4a11-961e-e47777a5e8cd",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "76cd4683-e038-4ede-a715-bec7321c09bb": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 4
          }
        ]
      ]
    },
    "725efc5d-a776-4a68-af5c-d04444ce6fae": {
      "main": [
        [
          {
            "node": "8d710516-4d4c-4b13-87c2-1534861bb63a",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3c85818b-251f-4a00-9c8e-e15feb663765": {
      "main": [
        [
          {
            "node": "76cd4683-e038-4ede-a715-bec7321c09bb",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8d710516-4d4c-4b13-87c2-1534861bb63a": {
      "main": [
        [
          {
            "node": "5ba35759-85e4-4fae-a4a0-b0b9946ec0b3",
            "type": "main",
            "index": 5
          }
        ]
      ]
    }
  }
}
よくある質問

このワークフローの使い方は?

上記のJSON設定コードをコピーし、n8nインスタンスで新しいワークフローを作成して「JSONからインポート」を選択、設定を貼り付けて認証情報を必要に応じて変更してください。

このワークフローはどんな場面に適していますか?

上級

有料ですか?

このワークフローは完全無料です。ただし、ワークフローで使用するサードパーティサービス(OpenAI APIなど)は別途料金が発生する場合があります。

ワークフロー情報
難易度
上級
ノード数62
カテゴリー-
ノードタイプ12
難易度説明

上級者向け、16ノード以上の複雑なワークフロー

外部リンク
n8n.ioで表示

このワークフローを共有

カテゴリー

カテゴリー: 34