Skip to content

Instantly share code, notes, and snippets.

@brianellin
Last active January 23, 2025 07:33
Show Gist options
  • Save brianellin/c3f1e542a686f092d621632547a95c35 to your computer and use it in GitHub Desktop.
Save brianellin/c3f1e542a686f092d621632547a95c35 to your computer and use it in GitHub Desktop.
Portland Public School Lunch Bluesky Bot
import { NextResponse } from 'next/server';
import { anthropic } from '@ai-sdk/anthropic';
import { generateObject, GenerateObjectResult } from 'ai';
import { z } from 'zod';
import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js';
import { Bot } from '@skyware/bot';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
const firecrawl = new FirecrawlApp({
apiKey: process.env.FIRECRAWL_API_KEY
});
// Define Zod schemas for our responses
const MenuPdfSchema = z.object({
url: z.string().url()
});
const LunchMenuSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
menuItems: z.array(z.string()),
emojiMenu: z.string(),
isSchoolClosed: z.boolean()
});
type MenuPdfResponse = z.infer<typeof MenuPdfSchema>;
type LunchMenuResponse = z.infer<typeof LunchMenuSchema>;
async function fetchMenuPage() {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
console.log(`Attempt ${attempt} to scrape menu page...`);
const scrapeResult = await firecrawl.scrapeUrl('https://www.pps.net/Page/214', {
formats: ['markdown'],
timeout: 60000
}) as ScrapeResponse;
if (!scrapeResult.success) {
throw new Error(`Failed to scrape: ${scrapeResult.error}`);
}
if (!scrapeResult.markdown) {
throw new Error('No markdown content found');
}
console.log(`Successfully scraped menu page on attempt ${attempt}`);
return scrapeResult.markdown;
} catch (error) {
lastError = error as Error;
console.error(`Attempt ${attempt} failed:`, error);
if (attempt < 3) {
console.log('Waiting 5 seconds before next attempt...');
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
// If we get here, all attempts failed
throw lastError || new Error('Failed to scrape menu page after 3 attempts');
}
async function findMenuPdfUrl(content: string): Promise<MenuPdfResponse> {
const completion = await generateObject({
messages: [
{
role: 'system',
content: 'You are a helpful assistant that extracts URLs from markdown content.'
},
{
role: 'user',
content: `Extract the URL to the elementary school lun ch calendar (K5/K8/MS) for the current month from this markdown content:\n\n${content}`
}
],
model: anthropic('claude-3-sonnet-20240229'),
schema: MenuPdfSchema
}) as GenerateObjectResult<MenuPdfResponse>;
return completion.object;
}
async function extractTodaysLunch(pdfUrl: string): Promise<LunchMenuResponse> {
// Download the PDF
const pdfResponse = await fetch(pdfUrl);
if (!pdfResponse.ok) {
throw new Error(`Failed to fetch PDF: ${pdfResponse.statusText}`);
}
const pdfBuffer = await pdfResponse.arrayBuffer();
// Get today's date in Pacific time
const today = new Date();
const pacificDate = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(today);
// Convert from MM/DD/YYYY to YYYY-MM-DD
const [month, day, year] = pacificDate.split('/');
const todayFormatted = `${year}-${month}-${day}`;
console.log("Today's date:", todayFormatted);
const completion = await generateObject({
messages: [
{
role: 'system',
content: 'You are a helpful assistant that reads lunch menus. Extract the menu items for the specified date from the PDF. The date format used is YYYY-MM-DD.'
},
{
role: 'user',
content: [
{
type: 'text',
text: `What is on the lunch menu for ${todayFormatted} (format: YYYY-MM-DD)? Return a list of menu items and an emoji-only representation of the menu.
Use multiple emojis and get creative with how they describe the menu items.
If there no lunch served for the day because school is closed for a holiday or other reason, specify schoolClosed: true. Otherwise specify schoolClosed: false;`
},
{
type: 'file',
data: Buffer.from(pdfBuffer),
mimeType: 'application/pdf',
experimental_providerMetadata: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
}
]
}
],
model: anthropic('claude-3-5-sonnet-20241022'),
schema: LunchMenuSchema
}) as GenerateObjectResult<LunchMenuResponse>;
// Override the date in the response with our local date
return {
...completion.object,
date: todayFormatted
};
}
async function postToBluesky(menu: LunchMenuResponse) {
const bot = new Bot();
await bot.login({
identifier: process.env.LUNCHBOT_BSKY_USERNAME!,
password: process.env.LUNCHBOT_BSKY_PASSWORD!,
});
const menuItems = menu.menuItems.map(item => `${item}`).join(', ');
const text = `Today's lunch: ${menuItems} ${menu.emojiMenu}`;
const post = await bot.post({
text: text
});
console.log(post.uri);
}
export async function GET(request: Request) {
try {
// Get the 'post' query parameter
const { searchParams } = new URL(request.url);
const shouldPost = searchParams.get('post') === 'true';
const content = await fetchMenuPage();
const { url: pdfUrl } = await findMenuPdfUrl(content);
if (!pdfUrl) {
return NextResponse.json({ error: "Could not find menu PDF URL" }, { status: 404 });
}
console.log("PDF URL:", pdfUrl);
const lunchMenu = await extractTodaysLunch(pdfUrl);
// Only post to Bluesky if shouldPost is true AND school is not closed
if (shouldPost && !lunchMenu.isSchoolClosed) {
await postToBluesky(lunchMenu);
}
return NextResponse.json({
date: lunchMenu.date,
menu: lunchMenu.menuItems,
emojiMenu: lunchMenu.emojiMenu,
isSchoolClosed: lunchMenu.isSchoolClosed,
posted: shouldPost && !lunchMenu.isSchoolClosed
});
} catch (error) {
console.error('Error processing lunch menu:', error);
return NextResponse.json({ error: "Failed to process lunch menu" }, { status: 500 });
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment