Skip to content

Commit e1484b3

Browse files
authored
Social media automation: data sources and assets. (#1215)
Automation workflow: Zappier WebHooks -> Buffer. Those are the records which we can send to Zapier WebHook https://ep2025-buffer.ep-preview.click/api/buffer_posts
1 parent ce12d39 commit e1484b3

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed

src/pages/api/buffer_posts.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { getCollection, getEntry } from "astro:content";
2+
import type { APIRoute } from "astro";
3+
4+
// Get @username from Twitter URL
5+
function getTwitterUsername(url: string): string | undefined {
6+
if (!url) return undefined;
7+
const username = url.split("/").pop();
8+
return (username ?? url).startsWith("@") ? username : `@${username}`;
9+
}
10+
11+
// Get @username from Bluesky URL
12+
function getBlueskyUsername(url: string): string | undefined {
13+
if (!url) return undefined;
14+
const username = url.split("/").pop()?.replace(/^@/, "");
15+
return username ? `@${username}` : undefined;
16+
}
17+
18+
// Get Bluesky profile link from username
19+
function getBlueskyProfileLink(username: string): string {
20+
// Remove any leading @ if present
21+
const cleanUsername = username.replace(/^@/, "");
22+
return `https://bsky.app/profile/${cleanUsername}`;
23+
}
24+
25+
// Get @username@instance.tld from Mastodon URL
26+
function getMastodonUsername(url: string): string | undefined {
27+
if (!url) return undefined;
28+
const match = url.match(/https?:\/\/([^\/]+)\/@([^\/]+)(\/|\?|$)/);
29+
return match ? `@${match[2]}@${match[1]}` : undefined;
30+
}
31+
32+
function getLinkedInUsernameHandler(url: string): string | undefined {
33+
if (!url) return undefined;
34+
const match = url.match(/https?:\/\/([^\/]+)\/in\/([^\/]+)(\/|\?|$)/);
35+
if (match) {
36+
try {
37+
return `https://www.linkedin.com/in/${decodeURIComponent(match[2])}`;
38+
} catch {
39+
return `https://www.linkedin.com/in/${match[2]}`;
40+
}
41+
}
42+
return undefined;
43+
}
44+
45+
export const GET: APIRoute = async ({ params, request }) => {
46+
const limit = Infinity;
47+
const speakers = await getCollection("speakers");
48+
const exclude = [
49+
"sebastian-ramirez",
50+
"savannah-ostrowski",
51+
"nerea-luis",
52+
"petr-baudis",
53+
"brett-cannon",
54+
];
55+
const records: any[] = [];
56+
57+
const charLimits: Record<string, number> = {
58+
instagram: 2200,
59+
x: 280,
60+
linkedin: 3000,
61+
bsky: 300,
62+
fosstodon: 500,
63+
};
64+
65+
// Tailor message templates for each platform using appropriate handle formats
66+
const message_template = {
67+
instagram: ({ name, talkTitle, talkUrl }) =>
68+
`Join ${name} at EuroPython for "${talkTitle}".`,
69+
70+
x: ({ name, handle, talkTitle, talkUrl }) =>
71+
handle
72+
? `Join ${name} (${handle}) at EuroPython for "${talkTitle}". Talk: ${talkUrl}`
73+
: `Join ${name} at EuroPython for "${talkTitle}". Talk: ${talkUrl}`,
74+
75+
linkedin: ({ name, handle, talkTitle, talkUrl }) =>
76+
`Join ${name} at EuroPython for "${talkTitle}".`,
77+
78+
bsky: ({ name, handle, talkTitle, talkUrl }) =>
79+
handle
80+
? `Join ${name} (${handle}) at EuroPython for "${talkTitle}". Talk: ${talkUrl}`
81+
: `Join ${name} at EuroPython for "${talkTitle}". Talk: ${talkUrl}`,
82+
83+
fosstodon: ({ name, handle, talkTitle, talkUrl }) =>
84+
handle
85+
? `Join ${name} (${handle}) at EuroPython for "${talkTitle}". talk: ${talkUrl}`
86+
: `Join ${name} at EuroPython for "${talkTitle}". Talk: ${talkUrl}`,
87+
};
88+
89+
const trimToLimit = (text: string, limit: number) =>
90+
text.length <= limit ? text : text.slice(0, limit - 1) + "…";
91+
92+
for (const speaker of speakers) {
93+
if (records.length >= limit) break;
94+
if (exclude.includes(speaker.id)) continue;
95+
96+
const {
97+
name,
98+
twitter_url,
99+
linkedin_url,
100+
bluesky_url,
101+
mastodon_url,
102+
submissions,
103+
} = speaker.data;
104+
105+
const sessions = await Promise.all(
106+
submissions.map((session) => getEntry("sessions", session.id))
107+
);
108+
109+
const validSessions = sessions.filter(
110+
(session) => session && session.data.title
111+
);
112+
113+
if (validSessions.length === 0) continue;
114+
115+
const talkTitle = validSessions[0]?.data.title || "an exciting topic";
116+
const talkCode = validSessions[0]?.data.code;
117+
const talkUrl = `https://ep2025.europython.eu/${talkCode}`;
118+
const speakerImage = `https://ep2025.europython.eu/media/social-${speaker.id}.png`;
119+
const fallbackUrl = `https://ep2025.europython.eu/speaker/${speaker.id}`;
120+
121+
// Extract handles for each platform
122+
const handles = {
123+
x: getTwitterUsername(twitter_url || ""),
124+
linkedin: getLinkedInUsernameHandler(linkedin_url || ""),
125+
bsky: getBlueskyUsername(bluesky_url || ""),
126+
fosstodon: getMastodonUsername(mastodon_url || ""),
127+
};
128+
129+
// Generate appropriate messages for each platform
130+
const generateMessage = (platform: keyof typeof message_template) => {
131+
const templateFn = message_template[platform];
132+
const handle =
133+
platform === "instagram"
134+
? undefined
135+
: handles[platform as keyof typeof handles];
136+
137+
const full = templateFn({
138+
name,
139+
handle,
140+
talkTitle,
141+
talkUrl: platform === "instagram" ? fallbackUrl : talkUrl,
142+
});
143+
144+
const limit = charLimits[platform];
145+
return trimToLimit(full, limit);
146+
};
147+
148+
const record = {
149+
name,
150+
image: speakerImage,
151+
handles: handles,
152+
channel: {
153+
instagram: generateMessage("instagram"),
154+
x: generateMessage("x"),
155+
linkedin: generateMessage("linkedin"),
156+
bsky: generateMessage("bsky"),
157+
fosstodon: generateMessage("fosstodon"),
158+
},
159+
};
160+
161+
records.push(record);
162+
}
163+
164+
return new Response(JSON.stringify(records, null, 2), {
165+
status: 200,
166+
headers: {
167+
"Content-Type": "application/json",
168+
},
169+
});
170+
};

src/pages/media/social_media.csv.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { getCollection, getEntry } from "astro:content";
2+
export async function GET({ params, request }) {
3+
const speakers = await getCollection("speakers");
4+
5+
const header = [
6+
"Talk Title",
7+
"Speaker Name",
8+
"Speaker Photo URL",
9+
"Primary Social ULR",
10+
"X URL",
11+
"LinkedIn URL",
12+
"Bluesky URL",
13+
"Mastodon URL",
14+
];
15+
16+
const exclude = [
17+
"sebastian-ramirez",
18+
"savannah-ostrowski",
19+
"nerea-luis",
20+
"petr-baudis",
21+
"brett-cannon",
22+
];
23+
24+
const rows: string[][] = [];
25+
26+
for (const speaker of speakers) {
27+
if (exclude.includes(speaker.id)) continue;
28+
29+
const {
30+
name,
31+
twitter_url,
32+
linkedin_url,
33+
bluesky_url,
34+
mastodon_url,
35+
submissions,
36+
} = speaker.data;
37+
38+
const sessions = await Promise.all(
39+
submissions.map((session) => getEntry("sessions", session.id))
40+
);
41+
42+
for (const session of sessions) {
43+
if (session) {
44+
const speaker_page = `https://ep2025.europython.eu/speaker/${speaker.id}`;
45+
rows.push([
46+
session.data.title || "",
47+
name,
48+
`https://ep2025-buffer.ep-preview.click/media/social-${speaker.id}.png`,
49+
twitter_url || linkedin_url || mastodon_url || speaker_page,
50+
twitter_url ?? speaker_page,
51+
linkedin_url ?? speaker_page,
52+
bluesky_url ?? speaker_page,
53+
mastodon_url ?? speaker_page,
54+
]);
55+
}
56+
}
57+
}
58+
59+
const csvLines = [header, ...rows]
60+
.map((row) =>
61+
row
62+
.map((field) =>
63+
field.includes('"') || field.includes(",") || field.includes("\n")
64+
? `"${field.replace(/"/g, '""')}"`
65+
: field
66+
)
67+
.join(",")
68+
)
69+
.join("\r\n");
70+
71+
return new Response(csvLines, {
72+
status: 200,
73+
headers: {
74+
"Content-Type": "text/csv; charset=utf-8",
75+
"Content-Disposition": 'attachment; filename="social_media.csv"',
76+
},
77+
});
78+
}

0 commit comments

Comments
 (0)