@@ -28,6 +28,7 @@ class EuroPythonSpeaker(BaseModel):
2828 twitter_url : str | None = None
2929 mastodon_url : str | None = None
3030 linkedin_url : str | None = None
31+ bluesky_url : str | None = None
3132 gitx : str | None = None
3233
3334 @computed_field
@@ -58,6 +59,11 @@ def extract_answers(cls, values) -> dict:
5859 answer .answer_text .strip ().split ()[0 ]
5960 )
6061
62+ if answer .question_text == SpeakerQuestion .bluesky :
63+ values ["bluesky_url" ] = cls .extract_bluesky_url (
64+ answer .answer_text .strip ().split ()[0 ]
65+ )
66+
6167 if answer .question_text == SpeakerQuestion .linkedin :
6268 values ["linkedin_url" ] = cls .extract_linkedin_url (
6369 answer .answer_text .strip ().split ()[0 ]
@@ -114,6 +120,36 @@ def extract_linkedin_url(text: str) -> str:
114120
115121 return linkedin_url .split ("?" )[0 ]
116122
123+ @staticmethod
124+ def extract_bluesky_url (text : str ) -> str :
125+ """
126+ Returns a normalized BlueSky URL in the form https://bsky.app/profile/<USERNAME>.bsky.social,
127+ or uses the entire domain if it's custom (e.g., .dev).
128+ """
129+ text = text .split ("?" , 1 )[0 ].strip ()
130+
131+ if text .startswith ("https://" ):
132+ text = text [8 :]
133+ elif text .startswith ("http://" ):
134+ text = text [7 :]
135+
136+ if text .startswith ("www." ):
137+ text = text [4 :]
138+
139+ for marker in ("bsky.app/profile/" , "bsky/" ):
140+ if marker in text :
141+ text = text .split (marker , 1 )[1 ]
142+ break
143+ # case custom domain
144+ else :
145+ text = text .rsplit ("/" , 1 )[- 1 ]
146+
147+ # if there's no dot, assume it's a non-custom handle and append '.bsky.social'
148+ if '.' not in text :
149+ text += ".bsky.social"
150+
151+ return f"https://bsky.app/profile/{ text } "
152+
117153
118154class EuroPythonSession (BaseModel ):
119155 """
0 commit comments