personal activity index (bluesky, leaflet, substack)
pai.desertthunder.dev
rss
bluesky
1use pai_core::{Item, ListFilter, SourceKind};
2use serde::{Deserialize, Serialize};
3use wasm_bindgen::JsValue;
4use worker::*;
5
6#[derive(Deserialize)]
7struct SyncConfig {
8 substack: Option<SubstackConfig>,
9 bluesky: Option<BlueskyConfig>,
10 leaflet: Vec<LeafletConfig>,
11 bearblog: Vec<BearBlogConfig>,
12}
13
14#[derive(Deserialize)]
15struct SubstackConfig {
16 base_url: String,
17}
18
19#[derive(Deserialize)]
20struct BlueskyConfig {
21 handle: String,
22}
23
24#[derive(Deserialize)]
25struct LeafletConfig {
26 id: String,
27 base_url: String,
28}
29
30#[derive(Deserialize)]
31struct BearBlogConfig {
32 id: String,
33 base_url: String,
34}
35
36#[derive(Deserialize)]
37struct FeedParams {
38 source_kind: Option<SourceKind>,
39 source_id: Option<String>,
40 limit: Option<usize>,
41 since: Option<String>,
42 q: Option<String>,
43}
44
45#[derive(Serialize)]
46struct FeedResponse {
47 items: Vec<Item>,
48}
49
50#[derive(Serialize)]
51struct StatusResponse {
52 status: &'static str,
53 version: &'static str,
54}
55
56#[event(fetch)]
57async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
58 let router = Router::new();
59 router
60 .get_async("/api/feed", |req, ctx| async move { handle_feed(req, ctx).await })
61 .get_async("/api/item/:id", |_req, ctx| async move {
62 let id = ctx
63 .param("id")
64 .ok_or_else(|| Error::RustError("Missing id parameter".into()))?;
65 handle_item(id, &ctx).await
66 })
67 .get("/status", |_req, _ctx| {
68 let version = env!("CARGO_PKG_VERSION");
69 let status = StatusResponse { status: "ok", version };
70 Response::from_json(&status)
71 })
72 .run(req, env)
73 .await
74}
75
76#[event(scheduled)]
77async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
78 if let Err(e) = run_sync(&env).await {
79 console_error!("Scheduled sync failed: {}", e);
80 }
81}
82
83async fn handle_feed(req: Request, ctx: RouteContext<()>) -> Result<Response> {
84 let url = req.url()?;
85 let params: FeedParams = serde_urlencoded::from_str(url.query().unwrap_or(""))
86 .map_err(|e| Error::RustError(format!("Invalid query parameters: {e}")))?;
87
88 let filter = ListFilter {
89 source_kind: params.source_kind,
90 source_id: params.source_id,
91 limit: Some(params.limit.unwrap_or(20)),
92 since: params.since,
93 query: params.q,
94 };
95
96 let db = ctx.env.d1("DB")?;
97 let items = query_items(&db, &filter).await?;
98
99 let response = FeedResponse { items };
100 Response::from_json(&response)
101}
102
103async fn handle_item(id: &str, ctx: &RouteContext<()>) -> Result<Response> {
104 let db = ctx.env.d1("DB")?;
105 let stmt = db.prepare("SELECT * FROM items WHERE id = ?1").bind(&[id.into()])?;
106
107 let result = stmt.first::<Item>(None).await?;
108
109 match result {
110 Some(item) => Response::from_json(&item),
111 None => Response::error("Item not found", 404),
112 }
113}
114
115async fn query_items(db: &D1Database, filter: &ListFilter) -> Result<Vec<Item>> {
116 let mut query = String::from(
117 "SELECT id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at FROM items WHERE 1=1"
118 );
119 let mut bindings = vec![];
120
121 if let Some(kind) = filter.source_kind {
122 query.push_str(" AND source_kind = ?");
123 bindings.push(kind.to_string().into());
124 }
125
126 if let Some(ref source_id) = filter.source_id {
127 query.push_str(" AND source_id = ?");
128 bindings.push(source_id.clone().into());
129 }
130
131 if let Some(ref since) = filter.since {
132 query.push_str(" AND published_at >= ?");
133 bindings.push(since.clone().into());
134 }
135
136 if let Some(ref q) = filter.query {
137 query.push_str(" AND (title LIKE ? OR summary LIKE ?)");
138 let pattern = format!("%{q}%");
139 bindings.push(pattern.clone().into());
140 bindings.push(pattern.into());
141 }
142
143 query.push_str(" ORDER BY published_at DESC");
144
145 if let Some(limit) = filter.limit {
146 query.push_str(" LIMIT ?");
147 bindings.push((limit as i64).into());
148 }
149
150 let mut stmt = db.prepare(&query);
151 for binding in bindings {
152 stmt = stmt.bind(&[binding])?;
153 }
154
155 let results = stmt.all().await?;
156 let items: Vec<Item> = results.results()?;
157
158 Ok(items)
159}
160
161async fn run_sync(env: &Env) -> Result<()> {
162 let config = load_sync_config(env)?;
163
164 let db = env.d1("DB")?;
165 let mut synced = 0;
166
167 if let Some(substack_config) = config.substack {
168 match sync_substack(&substack_config, &db).await {
169 Ok(count) => {
170 console_log!("Synced {} items from Substack", count);
171 synced += count;
172 }
173 Err(e) => console_error!("Substack sync failed: {}", e),
174 }
175 }
176
177 if let Some(bluesky_config) = config.bluesky {
178 match sync_bluesky(&bluesky_config, &db).await {
179 Ok(count) => {
180 console_log!("Synced {} items from Bluesky", count);
181 synced += count;
182 }
183 Err(e) => console_error!("Bluesky sync failed: {}", e),
184 }
185 }
186
187 for leaflet_config in config.leaflet {
188 match sync_leaflet(&leaflet_config, &db).await {
189 Ok(count) => {
190 console_log!("Synced {} items from Leaflet ({})", count, leaflet_config.id);
191 synced += count;
192 }
193 Err(e) => console_error!("Leaflet sync failed for {}: {}", leaflet_config.id, e),
194 }
195 }
196
197 for bearblog_config in config.bearblog {
198 match sync_bearblog(&bearblog_config, &db).await {
199 Ok(count) => {
200 console_log!("Synced {} items from BearBlog ({})", count, bearblog_config.id);
201 synced += count;
202 }
203 Err(e) => console_error!("BearBlog sync failed for {}: {}", bearblog_config.id, e),
204 }
205 }
206
207 console_log!("Sync completed: {} total items", synced);
208 Ok(())
209}
210
211fn load_sync_config(env: &Env) -> Result<SyncConfig> {
212 let substack = env
213 .var("SUBSTACK_URL")
214 .ok()
215 .map(|url| SubstackConfig { base_url: url.to_string() });
216
217 let bluesky = env
218 .var("BLUESKY_HANDLE")
219 .ok()
220 .map(|handle| BlueskyConfig { handle: handle.to_string() });
221
222 let leaflet = if let Ok(urls) = env.var("LEAFLET_URLS") {
223 urls.to_string()
224 .split(',')
225 .filter_map(|entry| {
226 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
227 if parts.len() == 2 {
228 Some(LeafletConfig { id: parts[0].to_string(), base_url: parts[1].to_string() })
229 } else {
230 None
231 }
232 })
233 .collect()
234 } else {
235 Vec::new()
236 };
237
238 let bearblog = if let Ok(urls) = env.var("BEARBLOG_URLS") {
239 urls.to_string()
240 .split(',')
241 .filter_map(|entry| {
242 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
243 if parts.len() == 2 {
244 Some(BearBlogConfig { id: parts[0].to_string(), base_url: parts[1].to_string() })
245 } else {
246 None
247 }
248 })
249 .collect()
250 } else {
251 Vec::new()
252 };
253
254 Ok(SyncConfig { substack, bluesky, leaflet, bearblog })
255}
256
257async fn sync_substack(config: &SubstackConfig, db: &D1Database) -> Result<usize> {
258 let feed_url = format!("{}/feed", config.base_url);
259
260 let mut req = Request::new(&feed_url, Method::Get)?;
261 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?;
262
263 let mut resp = Fetch::Request(req).send().await?;
264 let body = resp.text().await?;
265
266 let channel =
267 rss::Channel::read_from(body.as_bytes()).map_err(|e| Error::RustError(format!("Failed to parse RSS: {e}")))?;
268
269 let source_id = normalize_source_id(&config.base_url);
270 let mut count = 0;
271
272 for item in channel.items() {
273 let id = item.guid().map(|g| g.value()).unwrap_or(item.link().unwrap_or(""));
274 let url = item.link().unwrap_or(id);
275 let title = item.title();
276 let summary = item.description();
277 let author = item.author();
278 let content_html = item.content();
279
280 let published_at = item
281 .pub_date()
282 .and_then(|s| chrono::DateTime::parse_from_rfc2822(s).ok())
283 .map(|dt| dt.to_rfc3339())
284 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
285
286 let created_at = chrono::Utc::now().to_rfc3339();
287
288 let stmt = db.prepare(
289 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at)
290 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
291 );
292
293 stmt.bind(&[
294 id.into(),
295 "substack".into(),
296 source_id.clone().into(),
297 author.map(|s| s.into()).unwrap_or(JsValue::NULL),
298 title.map(|s| s.into()).unwrap_or(JsValue::NULL),
299 summary.map(|s| s.into()).unwrap_or(JsValue::NULL),
300 url.into(),
301 content_html.map(|s| s.into()).unwrap_or(JsValue::NULL),
302 published_at.into(),
303 created_at.into(),
304 ])?
305 .run()
306 .await?;
307
308 count += 1;
309 }
310
311 Ok(count)
312}
313
314async fn sync_bluesky(config: &BlueskyConfig, db: &D1Database) -> Result<usize> {
315 let api_url = format!(
316 "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor={}&limit=50",
317 config.handle
318 );
319
320 let mut req = Request::new(&api_url, Method::Get)?;
321 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?;
322
323 let mut resp = Fetch::Request(req).send().await?;
324 let json: serde_json::Value = resp.json().await?;
325
326 let feed = json["feed"]
327 .as_array()
328 .ok_or_else(|| Error::RustError("Invalid Bluesky response".into()))?;
329
330 let mut count = 0;
331
332 for item in feed {
333 let post = &item["post"];
334
335 if item.get("reason").is_some() {
336 continue;
337 }
338
339 let uri = post["uri"]
340 .as_str()
341 .ok_or_else(|| Error::RustError("Missing URI".into()))?;
342 let record = &post["record"];
343 let text = record["text"].as_str().unwrap_or("");
344
345 let post_id = uri.split('/').next_back().unwrap_or("");
346 let url = format!("https://bsky.app/profile/{}/post/{}", config.handle, post_id);
347
348 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() };
349
350 let published_at = record["createdAt"].as_str().unwrap_or("").to_string();
351 let created_at = chrono::Utc::now().to_rfc3339();
352
353 let stmt = db.prepare(
354 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at)
355 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
356 );
357
358 stmt.bind(&[
359 uri.into(),
360 "bluesky".into(),
361 config.handle.clone().into(),
362 config.handle.clone().into(),
363 title.into(),
364 text.into(),
365 url.into(),
366 JsValue::NULL,
367 published_at.into(),
368 created_at.into(),
369 ])?
370 .run()
371 .await?;
372
373 count += 1;
374 }
375
376 Ok(count)
377}
378
379async fn sync_leaflet(config: &LeafletConfig, db: &D1Database) -> Result<usize> {
380 let host = normalize_source_id(&config.base_url);
381 let subdomain = host.split('.').next().unwrap_or(&host);
382 let did = format!("{subdomain}.bsky.social");
383
384 let api_url = format!(
385 "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo={did}&collection=pub.leaflet.post&limit=50"
386 );
387
388 let mut req = Request::new(&api_url, Method::Get)?;
389 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?;
390
391 let mut resp = Fetch::Request(req).send().await?;
392 let json: serde_json::Value = resp.json().await?;
393
394 let records = json["records"]
395 .as_array()
396 .ok_or_else(|| Error::RustError("Invalid Leaflet response".into()))?;
397
398 let mut count = 0;
399
400 for record in records {
401 let uri = record["uri"]
402 .as_str()
403 .ok_or_else(|| Error::RustError("Missing URI".into()))?;
404 let value = &record["value"];
405
406 let title = value["title"].as_str().unwrap_or("Untitled");
407 let summary = value["summary"].as_str().or(value["content"].as_str()).unwrap_or("");
408 let slug = value["slug"].as_str().unwrap_or("");
409
410 let url = if !slug.is_empty() {
411 format!("{}/{}", config.base_url, slug)
412 } else {
413 format!("{}/post/{}", config.base_url, uri.split('/').next_back().unwrap_or(""))
414 };
415
416 let published_at = value["publishedAt"]
417 .as_str()
418 .or(value["createdAt"].as_str())
419 .unwrap_or("")
420 .to_string();
421
422 let created_at = chrono::Utc::now().to_rfc3339();
423
424 let stmt = db.prepare(
425 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at)
426 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
427 );
428
429 stmt.bind(&[
430 uri.into(),
431 "leaflet".into(),
432 config.id.clone().into(),
433 JsValue::NULL,
434 title.into(),
435 summary.into(),
436 url.into(),
437 JsValue::NULL,
438 published_at.into(),
439 created_at.into(),
440 ])?
441 .run()
442 .await?;
443
444 count += 1;
445 }
446
447 Ok(count)
448}
449
450async fn sync_bearblog(config: &BearBlogConfig, db: &D1Database) -> Result<usize> {
451 let feed_url = format!("{}/feed/", config.base_url.trim_end_matches('/'));
452
453 let mut req = Request::new(&feed_url, Method::Get)?;
454 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?;
455
456 let mut resp = Fetch::Request(req).send().await?;
457 let body = resp.text().await?;
458
459 let channel =
460 rss::Channel::read_from(body.as_bytes()).map_err(|e| Error::RustError(format!("Failed to parse RSS: {e}")))?;
461
462 let mut count = 0;
463
464 for item in channel.items() {
465 let id = item.guid().map(|g| g.value()).unwrap_or(item.link().unwrap_or(""));
466 let url = item.link().unwrap_or(id);
467 let title = item.title();
468 let summary = item.description();
469 let author = item.author();
470 let content_html = item.content();
471
472 let published_at = item
473 .pub_date()
474 .and_then(|s| chrono::DateTime::parse_from_rfc2822(s).ok())
475 .map(|dt| dt.to_rfc3339())
476 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
477
478 let created_at = chrono::Utc::now().to_rfc3339();
479
480 let stmt = db.prepare(
481 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at)
482 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"
483 );
484
485 stmt.bind(&[
486 id.into(),
487 "bearblog".into(),
488 config.id.clone().into(),
489 author.map(|s| s.into()).unwrap_or(JsValue::NULL),
490 title.map(|s| s.into()).unwrap_or(JsValue::NULL),
491 summary.map(|s| s.into()).unwrap_or(JsValue::NULL),
492 url.into(),
493 content_html.map(|s| s.into()).unwrap_or(JsValue::NULL),
494 published_at.into(),
495 created_at.into(),
496 ])?
497 .run()
498 .await?;
499
500 count += 1;
501 }
502
503 Ok(count)
504}
505
506fn normalize_source_id(base_url: &str) -> String {
507 base_url
508 .trim_start_matches("https://")
509 .trim_start_matches("http://")
510 .trim_end_matches('/')
511 .to_string()
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn test_normalize_source_id_https() {
520 assert_eq!(
521 normalize_source_id("https://patternmatched.substack.com"),
522 "patternmatched.substack.com"
523 );
524 }
525
526 #[test]
527 fn test_normalize_source_id_http() {
528 assert_eq!(normalize_source_id("http://example.com/"), "example.com");
529 }
530
531 #[test]
532 fn test_normalize_source_id_trailing_slash() {
533 assert_eq!(normalize_source_id("https://test.leaflet.pub/"), "test.leaflet.pub");
534 }
535
536 #[test]
537 fn test_normalize_source_id_no_protocol() {
538 assert_eq!(normalize_source_id("example.com"), "example.com");
539 }
540
541 #[test]
542 fn test_bluesky_title_truncation_short() {
543 let text = "Short post";
544 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() };
545 assert_eq!(title, "Short post");
546 }
547
548 #[test]
549 fn test_bluesky_title_truncation_long() {
550 let text = "a".repeat(150);
551 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() };
552 assert_eq!(title.len(), 100);
553 assert!(title.ends_with("..."));
554 }
555
556 #[test]
557 fn test_bluesky_title_truncation_boundary() {
558 let text = "a".repeat(100);
559 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() };
560 assert_eq!(title, text);
561 }
562
563 #[test]
564 fn test_leaflet_url_with_slug() {
565 let base_url = "https://test.leaflet.pub";
566 let slug = "my-post";
567 let url = if !slug.is_empty() {
568 format!("{base_url}/{slug}")
569 } else {
570 format!("{}/post/{}", base_url, "fallback")
571 };
572 assert_eq!(url, "https://test.leaflet.pub/my-post");
573 }
574
575 #[test]
576 fn test_leaflet_url_without_slug() {
577 let base_url = "https://test.leaflet.pub";
578 let slug = "";
579 let uri = "at://did:plc:abc123/pub.leaflet.post/xyz789";
580 let post_id = uri.split('/').next_back().unwrap_or("");
581 let url = if !slug.is_empty() { format!("{base_url}/{slug}") } else { format!("{base_url}/post/{post_id}") };
582 assert_eq!(url, "https://test.leaflet.pub/post/xyz789");
583 }
584
585 #[test]
586 fn test_bluesky_post_id_extraction() {
587 let uri = "at://did:plc:abc123/app.bsky.feed.post/3ld7xyqnvqk2a";
588 let post_id = uri.split('/').next_back().unwrap_or("");
589 assert_eq!(post_id, "3ld7xyqnvqk2a");
590 }
591
592 #[test]
593 fn test_bluesky_url_construction() {
594 let handle = "desertthunder.dev";
595 let post_id = "3ld7xyqnvqk2a";
596 let url = format!("https://bsky.app/profile/{handle}/post/{post_id}");
597 assert_eq!(url, "https://bsky.app/profile/desertthunder.dev/post/3ld7xyqnvqk2a");
598 }
599
600 #[test]
601 fn test_leaflet_config_parsing() {
602 let entry = "desertthunder:https://desertthunder.leaflet.pub";
603 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
604 assert_eq!(parts.len(), 2);
605 assert_eq!(parts[0], "desertthunder");
606 assert_eq!(parts[1], "https://desertthunder.leaflet.pub");
607 }
608
609 #[test]
610 fn test_leaflet_config_parsing_invalid() {
611 let entry = "invalid-entry-no-colon";
612 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
613 assert_ne!(parts.len(), 2);
614 }
615
616 #[test]
617 fn test_leaflet_config_parsing_multiple() {
618 let urls = "id1:https://pub1.leaflet.pub,id2:https://pub2.leaflet.pub";
619 let configs: Vec<_> = urls
620 .split(',')
621 .filter_map(|entry| {
622 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
623 if parts.len() == 2 {
624 Some((parts[0].to_string(), parts[1].to_string()))
625 } else {
626 None
627 }
628 })
629 .collect();
630
631 assert_eq!(configs.len(), 2);
632 assert_eq!(configs[0].0, "id1");
633 assert_eq!(configs[0].1, "https://pub1.leaflet.pub");
634 assert_eq!(configs[1].0, "id2");
635 assert_eq!(configs[1].1, "https://pub2.leaflet.pub");
636 }
637
638 #[test]
639 fn test_substack_feed_url_construction() {
640 let base_url = "https://patternmatched.substack.com";
641 let feed_url = format!("{base_url}/feed");
642 assert_eq!(feed_url, "https://patternmatched.substack.com/feed");
643 }
644
645 #[test]
646 fn test_bluesky_api_url_construction() {
647 let handle = "desertthunder.dev";
648 let api_url = format!("https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor={handle}&limit=50");
649 assert_eq!(
650 api_url,
651 "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=desertthunder.dev&limit=50"
652 );
653 }
654
655 #[test]
656 fn test_leaflet_did_construction() {
657 let subdomain = "desertthunder";
658 let did = format!("{subdomain}.bsky.social");
659 assert_eq!(did, "desertthunder.bsky.social");
660 }
661
662 #[test]
663 fn test_leaflet_api_url_construction() {
664 let did = "desertthunder.bsky.social";
665 let api_url = format!(
666 "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo={did}&collection=pub.leaflet.post&limit=50"
667 );
668 assert_eq!(
669 api_url,
670 "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo=desertthunder.bsky.social&collection=pub.leaflet.post&limit=50"
671 );
672 }
673
674 #[test]
675 fn test_bearblog_feed_url_construction() {
676 let base_url = "https://desertthunder.bearblog.dev";
677 let feed_url = format!("{}/feed/", base_url.trim_end_matches('/'));
678 assert_eq!(feed_url, "https://desertthunder.bearblog.dev/feed/");
679 }
680
681 #[test]
682 fn test_bearblog_config_parsing() {
683 let entry = "desertthunder:https://desertthunder.bearblog.dev";
684 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
685 assert_eq!(parts.len(), 2);
686 assert_eq!(parts[0], "desertthunder");
687 assert_eq!(parts[1], "https://desertthunder.bearblog.dev");
688 }
689
690 #[test]
691 fn test_bearblog_config_parsing_multiple() {
692 let urls = "id1:https://blog1.bearblog.dev,id2:https://blog2.bearblog.dev";
693 let configs: Vec<_> = urls
694 .split(',')
695 .filter_map(|entry| {
696 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect();
697 if parts.len() == 2 {
698 Some((parts[0].to_string(), parts[1].to_string()))
699 } else {
700 None
701 }
702 })
703 .collect();
704
705 assert_eq!(configs.len(), 2);
706 assert_eq!(configs[0].0, "id1");
707 assert_eq!(configs[0].1, "https://blog1.bearblog.dev");
708 assert_eq!(configs[1].0, "id2");
709 assert_eq!(configs[1].1, "https://blog2.bearblog.dev");
710 }
711}