Post

Google Docs Watcher with Git-Style Diff via Cloudflare Worker

Google Docs Watcher with Git-Style Diff via Cloudflare Worker

Monitor changes to a shared Google Doc (even with read-only access) and post Git-style diffs to a Discord channel—powered entirely by Cloudflare Workers and Google Docs API.

✨ Features

  • 🕒 Runs every 12 hours (cron trigger)
  • 📑 Reads content from Google Docs API
  • 🧠 Calculates Git-like line-by-line diffs
  • 📢 Sends alerts to Discord via webhook
  • ⚡ Fully serverless and free (Cloudflare + Google free tiers)

🛠️ Prerequisites

Google Cloud

  • Create a project at Google Cloud Console
  • Enable Google Docs API
  • Create a Service Account with a JSON key
  • Share your Google Doc with the service account email

Cloudflare

  • A Cloudflare account with access to Workers & Pages

🧑‍💻 Cloudflare Worker Setup

1. Create a New Worker

In your Cloudflare dashboard:

  • Go to Workers & Pages
  • Create a new Worker
  • Use the code below (see worker.js)

2. Add KV Storage

In your Worker’s Settings → scroll to KV Namespaces:

  • Create a new namespace
  • Bind it with the variable name: DOC_CACHE

3. Set Environment Variables

Add these under Settings → Environment Variables:

VariableTypeDescription
DOC_IDTextThe Google Doc ID from its URL
DISCORD_WEBHOOKSecretDiscord webhook URL
GCP_SERVICE_ACCOUNTSecretPaste your full service account JSON

4. Add a Cron Trigger

Go to the Triggers tab and add this:

0 */12 * * *   (every 12 hours)

🧾 Worker Code (worker.js)

Paste this entire script into the Cloudflare Worker editor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
const DEBUG=false;
const COOLDOWN_TIME=5000;

async function run(env) {
  const token = await getAccessToken(env);
    const docId = env.DOC_ID;
    const webhook = env.DISCORD_WEBHOOK;

    const res = await fetch(`https://docs.googleapis.com/v1/documents/${docId}`, {
      headers: { Authorization: `Bearer ${token}` }
    });

    if (!res.ok) {
      console.error("Failed to fetch doc:", await res.text());
      return "ERROR";
    }

    const data = await res.json();
    const text = data.body.content
      .map(e => e.paragraph?.elements?.map(el => el.textRun?.content || "").join("") || "")
      .join("");

    const now = new Date().toISOString();
    const last = await env.DOC_CACHE.get("last");
    const diff = getGitDiff(last || "", text);
    if (diff && diff.length > 0 || DEBUG) {  
      const diff_chunks = chunkArray(diff, 5);
      for (let i=0; i < diff_chunks.length; i++){
        var diff_chunk = diff_chunks[i];
        var diff_chunk_str = diff_chunk.join("\n");
        var response = await fetch(webhook, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ content: `📄 The document updated at ${now} [${i+1}/${diff_chunks.length}]\n\`\`\`diff\n${diff_chunk_str.slice(0, 1800)}\n\`\`\`` }) // Discord limit: 2000 chars
        });
        await sleep(100);
        if (response.status == 429){
          await sleep(COOLDOWN_TIME);
          console.warn(`Rate limit, retry in ${COOLDOWN_TIME}`);
        }
        if (i % 30 == 0){
          await sleep(1000);
        }
      } 
      await env.DOC_CACHE.put("last", text);
      return `${now} | OK, something changed!\n${diff.join("\n")}`;
    }
    else {
      return `${now} | OK, nothing changed.`
    }     
}

function getDiff(oldText, newText) {
  const oldLines = oldText.split("\n");
  const newLines = newText.split("\n");
  const changes = [];

  for (let i = 0; i < newLines.length; i++) {
    if (oldLines[i] !== newLines[i]) {
      changes.push(`🔸 Line ${i + 1} changed:\n- ${oldLines[i] || ""}\n+ ${newLines[i]}`);
    }
  }
  return changes.length ? changes : null;
}

function getGitDiff(oldText, newText) {
  const oldLines = oldText.split("\n");
  const newLines = newText.split("\n");
  const diff = [];

  const max = Math.max(oldLines.length, newLines.length);
  for (let i = 0; i < max; i++) {
    const oldLine = oldLines[i] || "";
    const newLine = newLines[i] || "";

    if (oldLine === newLine) {
      if (DEBUG){
        diff.push(`${oldLine}`);
      }
      continue;
    } else {
      if (oldLine) diff.push(`- ${oldLine}`);
      if (newLine) diff.push(`+ ${newLine}`);
    }
  }

  return diff;
}

function sleep(n) {
  return new Promise(resolve => setTimeout(resolve, n));
}

function chunkArray(array, size) {
  const chunks = [];
  let index = 0;
  while (index < array.length) {
    chunks.push(array.slice(index, index + size));
    index += size;
  }
  return chunks;
}

async function getAccessToken(env) {
  const cached = await env.DOC_CACHE.get("access_token", { type: "json" });
  const now = Math.floor(Date.now() / 1000);
  if (cached && cached.exp > now + 60) return cached.token;

  const key = JSON.parse(env.GCP_SERVICE_ACCOUNT);
  const iat = now;
  const exp = now + 3600;

  const header = {
    alg: "RS256",
    typ: "JWT"
  };

  const payload = {
    iss: key.client_email,
    scope: "https://www.googleapis.com/auth/documents.readonly",
    aud: "https://oauth2.googleapis.com/token",
    iat,
    exp
  };

  const enc = new TextEncoder();
  const toBase64 = obj => btoa(JSON.stringify(obj)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
  const unsigned = `${toBase64(header)}.${toBase64(payload)}`;

  const keyData = str2ab(key.private_key);
  const cryptoKey = await crypto.subtle.importKey(
    "pkcs8",
    keyData,
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", cryptoKey, enc.encode(unsigned));
  const jwt = `${unsigned}.${btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_")}`;

  const res = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`
  });

  const { access_token, expires_in } = await res.json();
  await env.DOC_CACHE.put("access_token", JSON.stringify({ token: access_token, exp: now + expires_in }));
  return access_token;
}

function str2ab(pem) {
  const b64 = pem.replace(/-----[^-]+-----|\n/g, "");
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}

export default {
  async fetch(request, env, ctx) {
    const result = await run(env);
    console.log(result);
    return new Response("✅ Ran Worker logic: " + result);
  },
  async scheduled(event, env, ctx) {
    const result = await run(env);
    console.log(result);
  }
}


✅ Done

Within 12 hours, if your doc changes, your Discord channel will light up with a message like:

1
2
3
📄 Google Doc updated at 2025-06-18T17:45:00+07:00
- Old content removed
+ New content added

🎉 Congrats—you’re now monitoring Google Docs like a Git-savvy ninja.


📎 License

MIT.

This post is licensed under CC BY 4.0 by the author.