博客虫洞功能
13 min read

博客虫洞功能

介绍一下基于 Cloudflare Worker 的博客虫洞方案设计以及详细代码实现,包括随机文章入口功能、基于 D1 数据持久化功能、Telegram 消息通知功能等,最后附上最终方案实现前几次失败的尝试,这对于一个 web 入门者理解 ghost 后端模版引擎等概念非常有帮助,同时也引发了自己对于产品的反思
博客虫洞功能
Photo generated by Gemini

松烟阁在做内容设计的时候参考了 Breadcrumbs Design Principle ,除此之外还加了一些彩蛋留给有心人自己去探索和发现,希望有心之人在畅游我的数字花园时能有属于自己的发现。所以在松烟阁里面中有很多隐藏的文章,追求极简设计的松烟阁一直没有很好提示读者去发掘的地方,受到椒盐豆豉虫洞的启发打算在松烟阁中添加让读者能够发掘文章的入口。

基于 Cloudflare Worker 的文章随机入口

先看一下最终效果:

0:00
/0:13

方案交互设计

如图所示,松烟阁添加让读者能够发掘文章的入口的方式:

  1. 在页面中增加虫洞功能按钮;
  2. 点击按钮跳转到部署在 Cloudflare Pages 上的虫洞页面;
  3. 虫洞时光机页面会向 Cloudflare Worker 请求访问者计数统计数据;
  4. Cloudflare Worker 在 D1 中完成对访问者计数数据的处理;
  5. 虫洞时光机根据获取到的访问者统计数据判断是否触发彩蛋逻辑;
  6. 如果触发彩蛋逻辑,会在用户填写中奖信息之后发送通知到 Telegram Group;
  7. Cloudflare Worker 根据松烟阁的 sitemap 生成随机 post 的 URL;
  8. 虫洞时光机根据 Worker 返回的 URL 重定向到新的 post 页面;

静态页面

将托管在 Github 的静态页面部署到 Cloudflare Pages,操作相对简单参考手册即可:Cloudflare Pages docs

静态 HTML 主要显示提示、统计等相关信息:

<div class="container">
	<div id='progressbar' class='meter-snippet'>
    <span id='percentage' class='percentage' style="width: 0%;"></span>
	</div>
	<canvas id="c"></canvas>
	<div id="content">
    <h3>即将奔赴 <b id="name"></b> 的十年</h3>
    <p>您是第 <span id="refer"></span> 位通过虫洞穿梭到该博客的旅客!<br/><span style="color:#606c84">(统计日期始于2022年1月20日)</span></p>
    <div id="vortex"></div>
    <div class="meta">
        <p>穿梭时间: <span id="time"></span></p>
        <p id="message-header">博主寄语</p>
        <p id="message">
            <span class="message-left">“</span>
            <span class="text"></span>
            <span class="message-right">”</span>
        </p>
    </div>
    <div class="footer">
        Tips: <b>走心的留言更能打动人心</b><br/>
        <a href="https://www.foreverblog.cn/" target="_blank">
            <img id="logo" src="https://img.foreverblog.cn/logo_en_default.png">
        </a>
        <div class="time">Idea 源自 ©十年之约 2017 -
            <script>document.write((new Date()).getFullYear())</script>
        </div>
	    </div>
	</div>
	
	<div class="dialog-box" style="display: none;">
	    <p class="dialog-message">恭喜你,第 <span id="dialog-refer" class="dialog-message-refer"></span> 幸运儿!你被彩蛋砸中了,输入邮箱接收一份小礼品吧!</p>
	    <form id="user-form">
	        <input type="text" id="dialog-name" name="name" placeholder="请输入您的姓名" required>
	        <input type="email" id="dialog-email" name="email" placeholder="请输入您的邮箱" required>
	        <div class="button-container">
	            <button type="button" class="cancel-btn">取消</button>
	            <button type="submit" class="submit-btn">提交</button>
	        </div>
		</form>
	</div>
</div>

与 Cloudflare Worker 交互逻辑比较简单:

  1. 获取 D1 的计数统计,并且确认是否出发彩蛋逻辑;
  2. 如果没有触发彩蛋逻辑,则利用 worker 获取随机 post 并直接跳转;
  3. 如果触发彩蛋逻辑,则通知用户填写 form 并且由 worker 发送通知给管理员,然后再执行第2步的过程;

具体 javascript 代码如下(时间代码没有任何优化不是很优雅,轻喷~~):

let easterEggs = false;
function randomRgbaColor() {
    var r = Math.floor(Math.random() * (255 - 50 + 1) + 50);
    var g = Math.floor(Math.random() * (255 - 50 + 1) + 50);
    var b = Math.floor(Math.random() * (255 - 50 + 1) + 50);
    return 'rgb(' + r +', ' + g + ', ' + b + ', .95)';
}
function showDialog(data) {
    $('.dialog-box').fadeIn().css('display', 'block');
    $('#dialog-refer').text(data);
    easterEggs = true;
    console.log("easter eggs");
    document.getElementById('user-form').addEventListener('submit', function(event) {
        event.preventDefault();
        var name = document.getElementById('dialog-name').value;
        var email = document.getElementById('dialog-email').value;
        // 关闭对话框
        document.querySelector('.dialog-box').style.display = 'none';
        // 发送通知
        $.ajax({
            url: "https://webhook.worker.edony.ink/notify?X-Telegram-Bot-Api-Secret-Token="+TOKEN,
            type: "POST",
            headers: {
                'Access-Control-Allow-Origin': "*",
                'Access-Control-Allow-Methods': "*",
                'Access-Control-Allow-Headers': '*',
                'Content-Type': "application/json"
            },
            data: JSON.stringify({
                "name": name,
                "email": email,
                "description": data,
            }),
            dataType: 'json',
            success: function() {
                console.log("send notification successfully");
            },
            error: function() {
                alert('出错啦,发邮件到 [email protected] 试试吧~')
            }
        })
        // ajax request wormhole
        $.ajax({
            url: 'https://webhook.worker.edony.ink/wormhole',
            type: "GET",
            headers: {
                'Access-Control-Allow-Origin': "*",
                'Access-Control-Allow-Methods': "*",
                'Access-Control-Allow-Headers': '*',
                'Content-Type': "text/xml"
            },
            success: function (data) {
                if (data) {
                    var urls = $('url', data);
                    const rand = Math.floor(Math.random() * urls.length);
                    var url = $('loc', urls[rand-1]);
                    var time = $('lastmod', urls[rand-1]);
                    $('#name').text("Shadow Walker 松烟阁");
                    $('#time').text(time[0].innerHTML);
                    $('#message .text').css('color', randomRgbaColor()).text(`虫洞是一种神秘而令人着迷的现象,它可以让人通过时空的裂隙进行时光穿梭。想象一下,在某个夜晚,我们不再担心输送位置,因为我们可以通过随机访问任何地方。`);
                    $('#content').fadeIn().css('display', 'flex');
                    setTimeout(function () {
                        window.location = url[0].innerHTML;
                    }, 5000);
                } else {
                    alert(response.message)
                }
            },
            error: function () {
                alert('出错啦,请稍后再试~')
            }
        })
    });
    document.querySelector('.cancel-btn').addEventListener('click', function() {
        document.querySelector('.dialog-box').style.display = 'none';
        // ajax request
        $.ajax({
            url: 'https://webhook.worker.edony.ink/wormhole',
            type: "GET",
            headers: {
                'Access-Control-Allow-Origin': "*",
                'Access-Control-Allow-Methods': "*",
                'Access-Control-Allow-Headers': '*',
                'Content-Type': "text/xml"
            },
            success: function (data) {
                if (data) {
                    var urls = $('url', data);
                    const rand = Math.floor(Math.random() * urls.length);
                    var url = $('loc', urls[rand-1]);
                    var time = $('lastmod', urls[rand-1]);
                    $('#name').text("Shadow Walker 松烟阁");
                    $('#time').text(time[0].innerHTML);
                    $('#message .text').css('color', randomRgbaColor()).text(`虫洞是一种神秘而令人着迷的现象,它可以让人通过时空的裂隙进行时光穿梭。想象一下,在某个夜晚,我们不再担心输送位置,因为我们可以通过随机访问任何地方。`);
                    $('#content').fadeIn().css('display', 'flex');
                    setTimeout(function () {
                        window.location = url[0].innerHTML;
                    }, 5000);
                } else {
                    alert(response.message)
                }
            },
            error: function () {
                alert('出错啦,请稍后再试~')
            }
        })
    
    });
}
$.ajax({
    url: 'https://webhook.worker.edony.ink/counter',
    type: 'POST',
    headers: {
        'Access-Control-Allow-Origin': "*",
        'Access-Control-Allow-Methods': "*",
        'Access-Control-Allow-Headers': '*',
        'Content-Type': "text/xml"
    },
    success: function(data) {
        if (data) {
            $('#refer').text(data);
            if (data === '1024') {
                // 1024th use get his lucky
                showDialog(data);
            }
        }
    },
    error: function(err) {
        alert("获取计数失败~")
    }
})
if (!easterEggs) {
    $.ajax({
        url: 'https://webhook.worker.edony.ink/wormhole',
        type: "GET",
        headers: {
            'Access-Control-Allow-Origin': "*",
            'Access-Control-Allow-Methods': "*",
            'Access-Control-Allow-Headers': '*',
            'Content-Type': "text/xml"
        },
        success: function (data) {
            if (data) {
                var urls = $('url', data);
                const rand = Math.floor(Math.random() * urls.length);
                var url = $('loc', urls[rand-1]);
                var time = $('lastmod', urls[rand-1]);
                $('#name').text("Shadow Walker 松烟阁");
                $('#time').text(time[0].innerHTML);
                $('#message .text').css('color', randomRgbaColor()).text(`虫洞是一种神秘而令人着迷的现象,它可以让人通过时空的裂隙进行时光穿梭。想象一下,在某个夜晚,我们不再担心输送位置,因为我们可以通过随机访问任何地方。`);
                $('#content').fadeIn().css('display', 'flex');
                setTimeout(function () {
                    window.location = url[0].innerHTML;
                }, 5000);
            } else {
                alert(response.message)
            }
        },
        error: function () {
            alert('出错啦,请稍后再试~')
        }
    })
};

后端 worker

利用 Cloudflare worker 实现后端需要的能力:

1. 随机获取文章入口

由于松烟阁支持 RSS 订阅的功能,所以在 sitemap 中提供了 post 列表,所以问题就转换成了随机获取数组元素的问题,代码如下:

	if (request.method === 'OPTIONS') {
        const corsHeaders = {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
        "Access-Control-Max-Age": "86400",
        "Access-Control-Allow-Headers":"*",
        }
        let allowHeader = request.headers.get("Access-Control-Request-Headers");
        if (!allowHeader) {
          allowHeader = "*";
        }
        let respHeaders: Headers = new Headers({
          ...corsHeaders,
          // Allow all future content Request headers to go back to browser
          // such as Authorization (Bearer) or X-Client-Name-Version
        });
        return new Response(null, {
          headers: respHeaders,
        })
      }
      // URL of the XML file you want to proxy
      const xmlUrl = 'https://www.edony.ink/sitemap-posts.xml';
      const newRequest = new Request(xmlUrl, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, OPTIONS, POST, PUT',
          'Access-Control-Allow-Headers': '*',
          'Content-Type': "text/xml"
        }
      });
      // Fetch the resource and return the response
      const response = await fetch(newRequest, {
        method: 'GET',
      });

      const body = await response.text()
      // Return the response with the added CORS headers
      let newResp = new Response(body, {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
      });
      newResp.headers.set("Access-Control-Allow-Origin", "*");
      newResp.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");

      return newResp;

2. 访问数据持久化

有赛博女菩萨之称的 Cloudflare 提供了免费的 D1 用于持久化数据,worker 的访问计数统计数据就是基于 D1 实现的,具体代码如下:

try {
  const list = await env.counter.list();
  console.log(list);
  const value = await env.counter.get("blog_refer");        
  if (value === null) {
    return new Response("Value not found", { status: 404 });        
  }
  let count = Number(value);
  count++;
  await env.counter.put("blog_refer", count.toString());    
  let newResp = new Response(count.toString());
  newResp.headers.set("Access-Control-Allow-Origin", "*");
  newResp.headers.set("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
  newResp.headers.set("Access-Control-Max-Age", "86400");
  newResp.headers.set("Access-Control-Allow-Headers", "*");
  return newResp;
} catch (err) {
  // In a production application, you could instead choose to retry your KV
  // read or fall back to a default code path.
  console.error(`KV returned error: ${err}`)
  return new Response(`KV returned error: ${err}`, { status: 500 })
} 

3. 消息通知

worker 利用 Telegram Bot 向管理员发送彩蛋用户相关的通知,代码如下:

if (request.method === 'OPTIONS') {
  const corsHeaders = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
    "Access-Control-Max-Age": "86400",
    "Access-Control-Allow-Headers":"*",
  }
  let allowHeader = request.headers.get("Access-Control-Request-Headers");
  if (!allowHeader) {
    allowHeader = "*";
  }
  let respHeaders: Headers = new Headers({
    ...corsHeaders,
    // Allow all future content Request headers to go back to browser
    // such as Authorization (Bearer) or X-Client-Name-Version
  });
  return new Response(null, {
      headers: respHeaders,
    })
  }

  // parse request as json object
  const notify: Notify = await request.json()
  const notification: string = `<b>🔔[notification]🔔</b>
[<b><i>name</i></b>]: ${notify.name}
[<b><i>email</i></b>]: ${notify.email}
[<b><i>description</i></b>]: ${notify.description}`
  // Deal with response asynchronously
  let query = '?' + new URLSearchParams({
        chat_id: ${ID},
        parse_mode: 'HTML',
        text: notification,
    }).toString()

  const res = (await fetch(`https://api.telegram.org/bot${TOKEN}/sendMessage${query}`)).json()

  let newResp = new Response(JSON.stringify(res, null, 2));
  newResp.headers.set("Access-Control-Allow-Origin", "*");
  newResp.headers.set("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
  newResp.headers.set("Access-Control-Max-Age", "86400");
  newResp.headers.set("Access-Control-Allow-Headers", "*");
  return newResp;

几个不成功的尝试

handlerbars helper 实现随机文章入口

在实现当前博客虫洞方案之前还是走了一点弯路的,开始的时候想尝试通过修改主题 NDawn 的模版来实现 post 随机入口,大致的思路就是创建一个 random-post.hbs(模版),然后利用这个模版创建一个 page,示意代码如下:

{{#get "posts" limit="all"}}
  {{#foreach posts random=1 limit=6}}
    . . .
  {{/foreach}}
{{/get}}

顺着这个思路深入下去的时候发现,ghost handlesbar helper 函数没有 random 的功能支持,所以需要在 ghost core 中增加 helper 函数。但是就算有支持 random helper 函数也无法实现我的需求,问题就出在 ghost 是后端模版引擎,这就意味 random helper 是在后端执行完成好了由前端渲染,所以前端拿到的 post 并不是随机的而是固定的,因为 random post 相关的内容已经在后端生成好了,在前端只是渲染。

code injection 实现随机文章入口

ghost 提供了 content API,所以可以利用 content API SDK 来实现随机文章的入口,如下代码所示(其中 key 就是 ghost admin 管理中自己创建的 content API key):

<script src="https://unpkg.com/@tryghost/[email protected]/umd/content-api.min.js"></script>
<script type="text/javascript">
    const api = new GhostContentAPI({
        url: 'https://example.ghost.test',
        key: '86f8c06bb62e02383b5272206d',
        version: 'v2'
    });
    const shuffle = (array) => {
		var currentIndex = array.length, temporaryValue, randomIndex;
		// While there remain elements to shuffle...
	    while (0 !== currentIndex) {
	        // Pick a remaining element...
	        randomIndex = Math.floor(Math.random() * currentIndex);
	        currentIndex -= 1;
	        // And swap it with the current element.
	        temporaryValue = array[currentIndex];
	        array[currentIndex] = array[randomIndex];
		    array[randomIndex] = temporaryValue;
	    }
	    return array;
	};
	api.posts
    .browse({limit: 'all', fields: 'url, title'})
    .then((posts) => {
        var randomPosts = shuffle(posts);
        $(".random-post-link").replaceWith("<li><a href='" + randomPosts[0].url + "'>" + randomPosts[0].title + "</a></li>");
        })
</script>

将上述代码通过 ghost code injection 的方式插入到指定的 page/post 中就可以实现文章随机入口了。这个方法最大的问题就是在代码中暴露了 Content API Key,这会导致很高的安全风险的 —— ghost post 的数据是可以通过 Content API Key 进行增删改查的,所以这个方法也不行。

失败尝试总结

上面两个不成功的尝试,其实是我这个前端门外汉没弄清楚模版引擎是前后端的:

  • 前端模板引擎是在客户端(浏览器)运行的模板引擎,例如:Mustache、EJS等。它们通常在 JavaScript 中实现,用于生成 HTML 页面或其他文本格式的内容。
  • 后端模板引擎在服务器端运行,例如:Jinja2、Django 模板引擎、Twig 等。它们在服务器端生成页面,并将其发送到客户端。

两者的区别在于,前端模板引擎通常与客户端代码一同工作,而后端模板引擎则与服务器端代码一同工作。因此,前端模板引擎更适合用于动态数据呈现,而后端模板引擎则更适合用于生成静态页面。

第一种方法不可行是因为我搞错了 ghost 其实是一个后端模版引擎,所以前端拿到的渲染数据是后端已经生成好了的,并不是动态随机的;第二种方法其实就是前端模版引擎,但是有致命的安全缺陷。

一些思考

  • 现在是做一些东西的好时候,能力和需要的资源都非常充足,唯一欠缺的是能给人创造价值的产品/服务;
  • 虽然我还是依然认同这句话:「我一直觉得一个产品无论好坏,创造它的人如果用心了,使用它的人一定是可以感受到的」,但是不得不警惕自嗨型产品,所幸产品与理想主义不冲突;
  • 目前上线的虫洞功能代码和设计非常的粗糙,从满足需求的角度来讲我不打算过早的打磨细节,因为虫洞是为了增加与读者的互动让其发现更多的内容,所以目前这样已经够了;

References

  1. 给 Hugo 博客添加随机文章入口
  2. 虫洞-随机访问十年之约成员博客
  3. Ghost Handlebars Functional Helpers

Public discussion

足迹