ソースを参照

feat: implement hourly exchange rate sync to Notion

- Fetch exchange rates from Open Exchange Rates API
- Update Notion database with latest rates
- Add /sync endpoint for manual triggering
- Configure hourly cron schedule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kotoyuuko 3 日 前
コミット
c89209bf32
5 ファイル変更302 行追加60 行削除
  1. 6 0
      .dev.vars.example
  2. 70 0
      CLAUDE.md
  3. 12 0
      src/env.d.ts
  4. 212 26
      src/index.ts
  5. 2 34
      wrangler.jsonc

+ 6 - 0
.dev.vars.example

@@ -0,0 +1,6 @@
+# Copy this file to .dev.vars and fill in your values
+# For production, use: wrangler secret put <NAME>
+
+OXR_APP_ID=your_open_exchange_rates_app_id
+NOTION_TOKEN=your_notion_integration_token
+NOTION_DATABASE_ID=your_notion_database_id

+ 70 - 0
CLAUDE.md

@@ -0,0 +1,70 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+A Cloudflare Workers **Scheduled Worker** that fetches exchange rates from Open Exchange Rates API and updates a Notion database hourly.
+
+## Commands
+
+```bash
+npm run dev          # Start local dev server with scheduled trigger testing
+npm run deploy       # Deploy to Cloudflare Workers
+npm run cf-typegen   # Regenerate TypeScript types after changing bindings
+```
+
+### Testing Locally
+
+After `npm run dev`, test the cron handler:
+```bash
+curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
+```
+
+Or use the manual sync endpoint:
+```bash
+curl "http://localhost:8787/sync"
+```
+
+## Architecture
+
+- **Entry Point**: `src/index.ts` - exports an `ExportedHandler<Env>` with `fetch` and `scheduled` handlers
+- **Configuration**: `wrangler.jsonc` - defines cron triggers and compatibility flags
+- **TypeScript**: ES2024 target, strict mode, types in `worker-configuration.d.ts` and `src/env.d.ts`
+
+### Cron Schedule
+
+Hourly at minute 0 (`0 * * * *`).
+
+### API Endpoints
+
+| Endpoint | Description |
+|----------|-------------|
+| `/sync` | Manually trigger exchange rate sync (returns JSON result) |
+| `/` | Info page |
+
+### Environment Variables (Secrets)
+
+Set via `wrangler secret put <NAME>` or `.dev.vars` for local development:
+
+| Variable | Description |
+|----------|-------------|
+| `OXR_APP_ID` | Open Exchange Rates API App ID |
+| `NOTION_TOKEN` | Notion Integration Token |
+| `NOTION_DATABASE_ID` | Notion Database ID |
+
+### Notion Database Structure
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Code` | Title | Currency code (e.g., USD, EUR, CNY) |
+| `Rate` | Number | Exchange rate against USD |
+| `Updated At` | Date | Last update timestamp |
+
+## Node.js Compatibility
+
+The `nodejs_compat` flag is enabled, allowing use of Node.js runtime APIs in the worker.
+
+## Reference
+
+See `AGENTS.md` for Cloudflare Workers documentation links and error handling guidance.

+ 12 - 0
src/env.d.ts

@@ -0,0 +1,12 @@
+// Extend the Env interface with secrets
+// These are set via `wrangler secret put <NAME>` or in `.dev.vars` for local development
+
+declare global {
+	interface Env {
+		OXR_APP_ID: string;
+		NOTION_TOKEN: string;
+		NOTION_DATABASE_ID: string;
+	}
+}
+
+export {};

+ 212 - 26
src/index.ts

@@ -1,40 +1,226 @@
 /**
- * Welcome to Cloudflare Workers!
+ * Notion Exchange Rate Worker
  *
- * This is a template for a Scheduled Worker: a Worker that can run on a
- * configurable interval:
- * https://developers.cloudflare.com/workers/platform/triggers/cron-triggers/
+ * Fetches daily exchange rates from Open Exchange Rates API
+ * and updates a Notion database with the latest rates.
  *
  * - Run `npm run dev` in your terminal to start a development server
- * - Run `curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"` to see your Worker in action
+ * - Run `curl "http://localhost:8787/__scheduled?cron=0+0+*+*+*"` to test the scheduled handler
  * - Run `npm run deploy` to publish your Worker
- *
- * Bind resources to your Worker in `wrangler.jsonc`. After adding bindings, a type definition for the
- * `Env` object can be regenerated with `npm run cf-typegen`.
- *
- * Learn more at https://developers.cloudflare.com/workers/
  */
 
+interface OXRResponse {
+	base: string;
+	rates: Record<string, number>;
+	timestamp: number;
+}
+
+interface NotionQueryResponse {
+	results: Array<{
+		id: string;
+		properties: {
+			Code: { title: Array<{ plain_text: string }> };
+		};
+	}>;
+	has_more: boolean;
+	next_cursor: string | null;
+}
+
+interface UpdateResult {
+	currency: string;
+	success: boolean;
+	error?: string;
+}
+
+interface SyncResult {
+	success: boolean;
+	currenciesFound: number;
+	ratesFetched: number;
+	succeeded: number;
+	failed: number;
+	errors: string[];
+}
+
+async function fetchExchangeRates(appId: string): Promise<OXRResponse> {
+	const url = `https://openexchangerates.org/api/latest.json?app_id=${appId}`;
+	const response = await fetch(url);
+
+	if (!response.ok) {
+		throw new Error(`OXR API error: ${response.status} ${response.statusText}`);
+	}
+
+	return response.json() as Promise<OXRResponse>;
+}
+
+async function fetchAllNotionPages(
+	databaseId: string,
+	token: string
+): Promise<Map<string, string>> {
+	const currencyMap = new Map<string, string>();
+	let hasMore = true;
+	let nextCursor: string | null = null;
+
+	while (hasMore) {
+		const body: { page_size: number; start_cursor?: string } = { page_size: 100 };
+		if (nextCursor) {
+			body.start_cursor = nextCursor;
+		}
+
+		const response = await fetch(`https://api.notion.com/v1/databases/${databaseId}/query`, {
+			method: 'POST',
+			headers: {
+				'Authorization': `Bearer ${token}`,
+				'Content-Type': 'application/json',
+				'Notion-Version': '2022-06-28',
+			},
+			body: JSON.stringify(body),
+		});
+
+		if (!response.ok) {
+			const errorText = await response.text();
+			throw new Error(`Notion query error: ${response.status} - ${errorText}`);
+		}
+
+		const data = (await response.json()) as NotionQueryResponse;
+
+		for (const page of data.results) {
+			const code = page.properties.Code.title[0]?.plain_text;
+			if (code) {
+				currencyMap.set(code, page.id);
+			}
+		}
+
+		hasMore = data.has_more;
+		nextCursor = data.next_cursor;
+	}
+
+	return currencyMap;
+}
+
+async function updateNotionPage(
+	pageId: string,
+	token: string,
+	rate: number
+): Promise<void> {
+	const now = new Date().toISOString();
+
+	const response = await fetch(`https://api.notion.com/v1/pages/${pageId}`, {
+		method: 'PATCH',
+		headers: {
+			'Authorization': `Bearer ${token}`,
+			'Content-Type': 'application/json',
+			'Notion-Version': '2022-06-28',
+		},
+		body: JSON.stringify({
+			properties: {
+				Rate: {
+					number: rate,
+				},
+				'Updated At': {
+					date: {
+						start: now,
+					},
+				},
+			},
+		}),
+	});
+
+	if (!response.ok) {
+		const errorText = await response.text();
+		throw new Error(`Notion update error: ${response.status} - ${errorText}`);
+	}
+}
+
+async function syncExchangeRates(env: Env): Promise<SyncResult> {
+	const result: SyncResult = {
+		success: false,
+		currenciesFound: 0,
+		ratesFetched: 0,
+		succeeded: 0,
+		failed: 0,
+		errors: [],
+	};
+
+	// Validate environment variables
+	if (!env.OXR_APP_ID || !env.NOTION_TOKEN || !env.NOTION_DATABASE_ID) {
+		result.errors.push('Missing required environment variables');
+		return result;
+	}
+
+	try {
+		// Step 1: Fetch all existing pages from Notion database
+		console.log('Fetching existing pages from Notion...');
+		const currencyMap = await fetchAllNotionPages(env.NOTION_DATABASE_ID, env.NOTION_TOKEN);
+		result.currenciesFound = currencyMap.size;
+		console.log(`Found ${currencyMap.size} currencies in Notion database`);
+
+		if (currencyMap.size === 0) {
+			result.success = true;
+			console.log('No currencies found in Notion database. Nothing to update.');
+			return result;
+		}
+
+		// Step 2: Fetch exchange rates from OXR
+		console.log('Fetching exchange rates from OXR...');
+		const oxrData = await fetchExchangeRates(env.OXR_APP_ID);
+		result.ratesFetched = Object.keys(oxrData.rates).length;
+		console.log(`Fetched ${result.ratesFetched} currency rates. Base: ${oxrData.base}`);
+
+		// Step 3: Update only currencies that exist in Notion
+		const results: UpdateResult[] = [];
+
+		for (const [currencyCode, pageId] of currencyMap) {
+			const rate = oxrData.rates[currencyCode];
+
+			if (rate === undefined) {
+				console.warn(`No rate found for ${currencyCode} in OXR data`);
+				continue;
+			}
+
+			try {
+				await updateNotionPage(pageId, env.NOTION_TOKEN, rate);
+				results.push({ currency: currencyCode, success: true });
+			} catch (error) {
+				const errorMessage = error instanceof Error ? error.message : String(error);
+				results.push({ currency: currencyCode, success: false, error: errorMessage });
+				result.errors.push(`${currencyCode}: ${errorMessage}`);
+				console.error(`Failed to update ${currencyCode}: ${errorMessage}`);
+			}
+		}
+
+		// Summary
+		result.succeeded = results.filter((r) => r.success).length;
+		result.failed = results.filter((r) => !r.success).length;
+		result.success = true;
+		console.log(`Sync completed: ${result.succeeded} succeeded, ${result.failed} failed`);
+	} catch (error) {
+		const errorMessage = error instanceof Error ? error.message : String(error);
+		result.errors.push(errorMessage);
+		console.error(`Exchange rate sync failed: ${errorMessage}`);
+	}
+
+	return result;
+}
+
 export default {
-	async fetch(req) {
+	async fetch(req, env, ctx): Promise<Response> {
 		const url = new URL(req.url);
-		url.pathname = '/__scheduled';
-		url.searchParams.append('cron', '* * * * *');
-		return new Response(`To test the scheduled handler, ensure you have used the "--test-scheduled" then try running "curl ${url.href}".`);
+
+		// Manual trigger endpoint
+		if (url.pathname === '/sync') {
+			const result = await syncExchangeRates(env);
+			return new Response(JSON.stringify(result, null, 2), {
+				headers: { 'Content-Type': 'application/json' },
+			});
+		}
+
+		return new Response('Notion Exchange Rate Worker\n\nUse /sync to manually trigger sync.', {
+			headers: { 'Content-Type': 'text/plain' },
+		});
 	},
 
-	// The scheduled handler is invoked at the interval set in our wrangler.jsonc's
-	// [[triggers]] configuration.
 	async scheduled(event, env, ctx): Promise<void> {
-		// A Cron Trigger can make requests to other endpoints on the Internet,
-		// publish to a Queue, query a D1 Database, and much more.
-		//
-		// We'll keep it simple and make an API call to a Cloudflare API:
-		let resp = await fetch('https://api.cloudflare.com/client/v4/ips');
-		let wasSuccessful = resp.ok ? 'success' : 'fail';
-
-		// You could store this result in KV, write to a D1 Database, or publish to a Queue.
-		// In this template, we'll just log the result:
-		console.log(`trigger fired at ${event.cron}: ${wasSuccessful}`);
+		console.log(`Exchange rate sync started at ${event.cron}`);
+		await syncExchangeRates(env);
 	},
 } satisfies ExportedHandler<Env>;

+ 2 - 34
wrangler.jsonc

@@ -1,7 +1,3 @@
-/**
- * For more details on how to configure Wrangler, refer to:
- * https://developers.cloudflare.com/workers/wrangler/configuration/
- */
 {
 	"$schema": "node_modules/wrangler/config-schema.json",
 	"name": "notion-exrate-worker",
@@ -12,38 +8,10 @@
 	},
 	"triggers": {
 		"crons": [
-			"* * * * *"
+			"0 * * * *"
 		]
 	},
 	"compatibility_flags": [
 		"nodejs_compat"
 	]
-	/**
-	 * Smart Placement
-	 * https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
-	 */
-	// "placement": {  "mode": "smart" }
-	/**
-	 * Bindings
-	 * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
-	 * databases, object storage, AI inference, real-time communication and more.
-	 * https://developers.cloudflare.com/workers/runtime-apis/bindings/
-	 */
-	/**
-	 * Environment Variables
-	 * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
-	 * Note: Use secrets to store sensitive data.
-	 * https://developers.cloudflare.com/workers/configuration/secrets/
-	 */
-	// "vars": {  "MY_VARIABLE": "production_value" }
-	/**
-	 * Static Assets
-	 * https://developers.cloudflare.com/workers/static-assets/binding/
-	 */
-	// "assets": {  "directory": "./public/",  "binding": "ASSETS" }
-	/**
-	 * Service Bindings (communicate between multiple Workers)
-	 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
-	 */
-	// "services": [  {   "binding": "MY_SERVICE",   "service": "my-service"  } ]
-}
+}