惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Darknet – Hacking Tools, Hacker News & Cyber Security
The Register - Security
The Register - Security
The Hacker News
The Hacker News
F
Full Disclosure
Cyberwarzone
Cyberwarzone
T
The Exploit Database - CXSecurity.com
Spread Privacy
Spread Privacy
K
Kaspersky official blog
P
Privacy & Cybersecurity Law Blog
F
Fox-IT International blog
博客园_首页
D
DataBreaches.Net
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
G
Google Developers Blog
N
Netflix TechBlog - Medium
博客园 - 叶小钗
Recorded Future
Recorded Future
Malwarebytes
Malwarebytes
T
Threatpost
F
Future of Privacy Forum
量子位
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
腾讯CDC
The GitHub Blog
The GitHub Blog
P
Palo Alto Networks Blog
IT之家
IT之家
博客园 - 司徒正美
G
GRAHAM CLULEY
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
Securelist
云风的 BLOG
云风的 BLOG
M
Microsoft Research Blog - Microsoft Research
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
T
Tenable Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园 - 【当耐特】
C
CERT Recently Published Vulnerability Notes
美团技术团队
Last Week in AI
Last Week in AI
AWS News Blog
AWS News Blog
罗磊的独立博客
酷 壳 – CoolShell
酷 壳 – CoolShell
爱范儿
爱范儿
Latest news
Latest news
人人都是产品经理
人人都是产品经理
Project Zero
Project Zero
Stack Overflow Blog
Stack Overflow Blog
M
MIT News - Artificial intelligence
月光博客
月光博客
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint

椒盐豆豉

[时空] Wallace Falls 有氧从放弃到入门 工作的由俭入奢 身体健康机能测试 [时空] Franklin Falls [时空] 晚春的温哥华 关我辟事 Vol.52:春天成为我最爱的季节 [全景] Bridal Veil 也记录一下古早程序员的一周 Human powered enshitification long predates AI powered slop 反 P 人刻板印象 幸运天赋 改变生活方式的一些购物 [全景] Coal Creek Absolute Cinema 关我辟事 Vol.51:笑春风 我说了会挂人我就会挂人 一些最近让我快乐的小事 一些近期的消费主义试错和退货 你的 Claude code 提示音是什么? 博客二十二周年 生产力陷阱与无限增长幻象
静态博客三周年回顾 & Hugo 装修小记之五
2026-05-28 · via 椒盐豆豉
May 28, 2026

摄于 2026-5 franklin falls

本文总计 5.75k 字, 阅读约需要 14 分钟

时光飞逝岁月如梭,用现在这个静态博客已经三年了,竟然还有装修小记可写。不过随着现在 vibe coding democratize(or capitalize?) 写码估计装修也越来越少值得写了吧,刚好前阵子也确立了博客在 22 年前真正的纪念日可以改改回顾时间了,以后这个系列估计也会越来越多向博客回顾倾斜靠拢,这应该是最后一期用这个日子的过去一年博客内容回顾 + 装修小记啦。

内容 #

最受欢迎的博文 #

之前两年的流量回顾一般都是旧文靠着长尾占了前五里的大半,今年竟然全是过去一年的新文章,惊!

  • 「为什么不回中国」 - 非常惊讶,很个人也完全没有干货的一篇絮叨,竟然是过去一年浏览量最高的文章……
  • 「BILT 2.0 解读」 - 蹭上热度的一篇实用文,应该是为数不多大多数流量来自搜索引擎新读者的有机流量的一篇文章。
  • 「生产力陷阱与无限增长幻象」 - 精神状态不佳时候写的较为 emotional 的一篇 rant,当时没啥反响,没想到总浏览量在前几。当然也有可能是因为是 BlogBlog 同樂會投稿所以会接触到一些新的读者渠道。
  • 「2025 年终总结」 - 四平八稳中规中矩的一年年终总结,没想到流量还挺多。
  • 「我不再做的事」 - 这种命题每人都能写类的文章应该是互链的比较多。

我最喜欢的新博文 #

以往都是挑 5 篇,今年发现写了 65 篇数量较多(其实 24 年多达 95 篇,但毕竟当时不上班,而且可能 recency bias 觉得今年整体质量稍微满意一些)且喜欢的也蛮多的,反正是自己瞎总结,直接提升成 10 篇好了:

内容概括 #

扔给 AI 表示我的博客内容主线是:技术/博客折腾、消费主义与购物复盘、健康与运动、旅行摄影、以及一组持续更新的“关我辟事”生活观察,一边写 Hugo 和代码(感觉我没写那么多折腾呀,为啥 AI 们总觉得我有,还是说我自我认知有偏差……),一边写消费、健康、旅行和职业变化,而且整体风格偏复盘、吐槽、观察与记录。高频标签:

标签次数
关我辟事13
patreon12
消费主义陷阱12
random11
review, wellness, tutorial, rant, photography, 问卷, tech6

装修 #

一个常见的我会取关订阅的独立博客 RSS 的原因就是博主折腾然后改了文章 URL,导致一下给 RSS 里刷出几十上百篇旧文。一两次还算无心之过,三次以上基本上内容再好也会取关了(通常情况下经常来回折腾 URL 的内容也好不到值得被隔三差五刷屏的地步 d)。己所不欲勿施于人,所以我也会额外注意不要无意中污染别人的 RSS。

本站不会来回折腾 URL 所以本无这个需求,但因为搞了时空胶囊页面,初始 backfill 一下会更新十几篇,不想在 RSS 里刷屏,于是有了从 RSS 排除文章的需求。以后每次新的时空胶囊更新还是想放在 RSS 里的,所以也不能单纯用文章 type 排除。研究了一下 hugo 似乎只有全局 RSS 选项没有单篇排除选项,索性干脆把一直在用的模版默认 RSS template override 了之后手动加了一个 exclude_rss param,需要排除某篇文章的时候设置为true即可。

customize RSS 模版的话添加一个 themes/{theme_name}/layouts/_default/rss.xml 即可:

<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title>{{ .Site.Title }}</title>
    <link>{{ .Site.BaseURL }}?utm_source=rss</link>
    <description>Recent content on {{ .Site.Title }}</description>
    {{- $limit := .Site.Config.Services.RSS.Limit }}
    {{- $pages := .Site.RegularPages }}
    {{- $pages = where $pages ".Params.exclude_rss" "!=" true  | first $limit -}}
    {{ range $pages }}
      <item>
        <title>{{ if eq .Type "time-capsule"}}[时空] {{ end }}{{ .Title }}</title>
        <link>{{ .Permalink }}?utm_source=rss</link>
        <pubDate>{{ .Params.date.Format "Mon, 02 Jan 2006 15:04:05 MST" }} </pubDate>
        <guid>{{ .Permalink }}</guid>
        {{ with .Params.image }}
          <image url="{{ . }}" />
        {{ end }}
        <description>
            {{ .Summary | html }}...
            阅读全文 {{ .Permalink }}
        </description>
      </item>
    {{ end }}
  </channel>
</rss>

时空胶囊页面 #

先前单独写过一篇博客,不再赘述。不过一开始的启发点是放全景图,后来发现通常拍全景的地方也会写点微型游记,写都写了也放上去。这样一来也没必要限制在全景图了,平时非大型旅游也不会专门写游记博客,但小的本地 hiking 也不乏能写的,这个区域刚好成了完美载体。因为要做这个页面,也顺便引入了 panorama viewer 在博客里,大型游记的形式也可以更丰富了,比如南极这篇里就可以有坐着船在海冰里穿梭的身临其境感,班夫这篇基本每一个主要景点都有全景展现,再比如附近的小瀑布就可以写篇小记

后面的人不知道为什么要凑那么近突然形成了和路人的合影 XD

长毛象最近嘟文 #

年末休假在家里修博客和各种问题临时薅点羊毛 vibe code,正经东西都修完了就 vibe code 点这种没有 AI 肯定不会写的东西。当时的 AI 前端代码质量没法看又臭又长,但反正自己用的东西又不是上班不需要 maintainability,缝缝补补能用就行,也懒得优化了。效果扔在 now 页面里了:

Loading feed...

AI 代码又臭又长折叠了
{{ $profileUrl := .Get 0 }}
{{ $limit := 5 }}
{{ if .Get 1 }}
  {{ $limit = int (.Get 1) }}
{{ end }}

{{/* Convert profile URL to RSS feed URL */}}
{{ $feedUrl := printf "%s.rss" $profileUrl }}

{{/* Generate unique ID for this feed instance */}}
{{ $feedId := printf "mastodon-feed-%d" (now.Unix) }}

<div id="{{ $feedId }}" class="mastodon-feed" data-feed-url="{{ $feedUrl }}" data-profile-url="{{ $profileUrl }}" data-limit="{{ $limit }}">
  <div class="mastodon-loading">Loading feed...</div>
</div>

<script>
(function() {
  const feedContainer = document.getElementById('{{ $feedId }}');
  if (!feedContainer) return;
  
  const feedUrl = feedContainer.dataset.feedUrl;
  const profileUrl = feedContainer.dataset.profileUrl;
  const limit = parseInt(feedContainer.dataset.limit) || 5;
  
  // Fetch and parse RSS feed
  fetch(feedUrl)
    .then(response => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.text();
    })
    .then(xmlText => {
      const parser = new DOMParser();
      const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
      const items = xmlDoc.querySelectorAll('item');
      
      if (items.length === 0) {
        feedContainer.innerHTML = '<div class="mastodon-error">No items found in feed</div>';
        return;
      }
      
      let html = '';
      const itemsToShow = Math.min(items.length, limit);
      
      for (let i = 0; i < itemsToShow; i++) {
        const item = items[i];
        const link = item.querySelector('link')?.textContent?.trim() || '';
        const pubDate = item.querySelector('pubDate')?.textContent?.trim() || 
                       item.querySelector('dc\\:date')?.textContent?.trim() || '';
        let description = item.querySelector('description')?.textContent?.trim() || '';
        
        if (!description) continue;
        
        // Keep HTML structure - description is already HTML from RSS
        
        // If description has <strong> content, remove everything after <hr>
        const tempCheckDiv = document.createElement('div');
        tempCheckDiv.innerHTML = description;
        const hasStrong = tempCheckDiv.querySelector('strong');
        if (hasStrong) {
          // Find <hr> tag (handles various formats: <hr>, <hr/>, <hr />, <hr class="...">, etc.)
          const hrRegex = /<hr\s*[^>]*>/i;
          const hrMatch = description.match(hrRegex);
          if (hrMatch) {
            const hrIndex = description.indexOf(hrMatch[0]);
            if (hrIndex !== -1) {
              description = description.substring(0, hrIndex);
            }
          }
        }
        
        // Format date
        const formattedDate = formatDate(pubDate);
        
        // Process description: handle NeoDB links, hashtags, etc.
        const processed = processDescription(description, profileUrl);
        description = processed.description;
        const neodbUrl = processed.neodbUrl;
        
        // Build item HTML
        html += '<div class="mastodon-item">';
        if (neodbUrl) {
          // Insert placeholder for NeoDB card
          const parts = description.split('|NEODB_CARD_PLACEHOLDER|');
          html += '<div class="mastodon-description">';
          parts.forEach((part, idx) => {
            if (part) html += part;
            if (idx < parts.length - 1) {
              html += `<div class="mastodon-neodb-placeholder" data-neodb-url="${escapeHtml(neodbUrl)}"></div>`;
            }
          });
          html += '</div>';
        } else {
          html += '<div class="mastodon-description">' + description + '</div>';
        }
        html += '<div class="mastodon-meta">';
        if (formattedDate) {
          html += '<span class="mastodon-date">' + escapeHtml(formattedDate) + '</span>';
        }
        if (link) {
          html += '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener noreferrer" class="mastodon-link">View Toot</a>';
        }
        html += '</div>';
        html += '</div>';
        
        if (i < itemsToShow - 1) {
          html += '<div class="mastodon-separator"></div>';
        }
      }
      
      feedContainer.innerHTML = html;
      
      // Process NeoDB cards after rendering
      processNeoDBCards(feedContainer);
      
      // Process NeoDB placeholders
      const placeholders = feedContainer.querySelectorAll('.mastodon-neodb-placeholder');
      placeholders.forEach(placeholder => {
        const neodbUrl = placeholder.dataset.neodbUrl;
        if (!neodbUrl) return;
        
        // Extract dbType from URL - match pattern like neodb.social/book/123 or neodb.social/movie/456
        // Remove query parameters and fragments first
        const cleanUrl = neodbUrl.split('?')[0].split('#')[0];
        const match = cleanUrl.match(/neodb\.social\/(.+)$/);
        if (!match || !match[1]) {
          placeholder.remove();
          return;
        }
        
        const dbType = match[1];
        const apiUrl = `https://neodb.social/api/${dbType}`;
        
        fetch(apiUrl)
          .then(response => {
            if (!response.ok) {
              throw new Error(`HTTP ${response.status}`);
            }
            return response.json();
          })
          .then(data => {
            const cardHtml = `
              <div class="mastodon-neodb">
                <div class="db-card">
                  <div class="db-card-subject">
                    <div class="db-card-post">
                      <img loading="lazy" decoding="async" referrerpolicy="no-referrer" src="${escapeHtml(data.cover_image_url || '')}">
                    </div>
                    <div class="db-card-content">
                      <div class="db-card-title">
                        <a href="${escapeHtml(neodbUrl)}" class="cute" target="_blank" rel="noreferrer">「${escapeHtml(data.title || '')}」</a>
                      </div>
                      <div class="db-card-abstract">${escapeHtml(data.brief || '')}</div>
                    </div>
                    <div class="db-card-cate">${escapeHtml(data.category || '')}</div>
                  </div>
                </div>
              </div>
            `;
            placeholder.outerHTML = cardHtml;
          })
          .catch(error => {
            placeholder.remove();
          });
      });
    })
    .catch(error => {
      feedContainer.innerHTML = '<div class="mastodon-error">Error loading feed: ' + escapeHtml(error.message) + '</div>';
    });
  
  function formatDate(dateStr) {
    if (!dateStr) return '';
    
    try {
      const date = new Date(dateStr);
      if (isNaN(date.getTime())) return dateStr;
      
      const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
      const month = months[date.getMonth()];
      const day = date.getDate();
      const year = date.getFullYear();
      const hours = String(date.getHours()).padStart(2, '0');
      const minutes = String(date.getMinutes()).padStart(2, '0');
      
      return `${month} ${day}, ${year} ${hours}:${minutes}`;
    } catch (e) {
      return dateStr;
    }
  }
  
  function processDescription(desc, profileUrl) {
    let neodbUrl = null;
    
    // Use DOM manipulation for better HTML processing
    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = desc;
    
    // Check for NeoDB links before processing
    const neodbLinks = tempDiv.querySelectorAll('a[href*="neodb.social"]');
    if (neodbLinks.length > 0) {
      neodbUrl = neodbLinks[0].href;
      // Replace NeoDB links with placeholder
      neodbLinks.forEach(link => {
        const placeholder = document.createTextNode('|NEODB_CARD_PLACEHOLDER|');
        link.parentNode.replaceChild(placeholder, link);
      });
    }
    
    // Also check for plain text NeoDB URLs
    const textNodes = [];
    const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null, false);
    let node;
    while (node = walker.nextNode()) {
      if (node.textContent.includes('neodb.social')) {
        textNodes.push(node);
      }
    }
    textNodes.forEach(textNode => {
      textNode.textContent = textNode.textContent.replace(/https:\/\/neodb\.social\/[^\s<>"]+/g, '|NEODB_CARD_PLACEHOLDER|');
    });
    
    // Remove plain text URLs (but keep hashtag links and other anchor tags)
    const allLinks = tempDiv.querySelectorAll('a');
    const linkUrls = new Set();
    allLinks.forEach(link => linkUrls.add(link.href));
    
    // Remove plain text URLs that aren't in anchor tags
    const allTextNodes = [];
    const textWalker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null, false);
    let textNode;
    while (textNode = textWalker.nextNode()) {
      allTextNodes.push(textNode);
    }
    allTextNodes.forEach(node => {
      node.textContent = node.textContent.replace(/https?:\/\/[^\s<>]+/g, (url) => {
        // Only remove if it's not already in a link
        return linkUrls.has(url) ? url : '';
      });
    });
    
    // Remove date/time strings from text nodes
    allTextNodes.forEach(node => {
      node.textContent = node.textContent.replace(/\w+,\s+\d{1,2}\s+\w{3}\s+\d{4}\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{4}/g, '');
    });
    
    // Remove emoji shortcodes from text nodes
    allTextNodes.forEach(node => {
      node.textContent = node.textContent.replace(/:[a-zA-Z0-9_+-]+:/g, '');
    });
    
    // Remove img tags
    const imgs = tempDiv.querySelectorAll('img');
    imgs.forEach(img => img.remove());
    
    // Fix hashtag links - process existing hashtag links
    // Try multiple selectors to catch different formats
    const hashtagSelectors = [
      'a.mention.hashtag',
      'a[class*="mention hashtag"]',
      'a[class*="hashtag mention"]',
      'a.mention[class*="hashtag"]'
    ];
    
    hashtagSelectors.forEach(selector => {
      const hashtagLinks = tempDiv.querySelectorAll(selector);
      hashtagLinks.forEach(link => {
        let tagName = '';
        // Try to get tag from span or direct text
        const span = link.querySelector('span');
        if (span) {
          tagName = span.textContent.trim();
        } else {
          // Remove # and any whitespace
          tagName = link.textContent.replace(/^#\s*/, '').trim();
        }
        if (tagName) {
          const newLink = document.createElement('a');
          newLink.href = `${profileUrl}/tagged/${tagName}`;
          newLink.target = '_blank';
          newLink.rel = 'noopener noreferrer';
          newLink.textContent = `#${tagName}`;
          link.parentNode.replaceChild(newLink, link);
        }
      });
    });
    
    // Also handle hashtags that might be in text but not linked
    // Only process text nodes that are NOT inside anchor tags
    allTextNodes.forEach(node => {
      // Skip if this text node is inside an anchor tag
      let parent = node.parentNode;
      while (parent && parent !== tempDiv) {
        if (parent.tagName === 'A') {
          return; // Skip this node, it's inside a link
        }
        parent = parent.parentNode;
      }
      
      const text = node.textContent;
      const hashtagRegex = /#([\w\u4e00-\u9fff]+)/g; // Include Chinese characters
      if (hashtagRegex.test(text)) {
        const fragment = document.createDocumentFragment();
        let lastIndex = 0;
        hashtagRegex.lastIndex = 0;
        let match;
        while ((match = hashtagRegex.exec(text)) !== null) {
          // Add text before hashtag
          if (match.index > lastIndex) {
            fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
          }
          // Create link for hashtag
          const tagLink = document.createElement('a');
          tagLink.href = `${profileUrl}/tagged/${match[1]}`;
          tagLink.target = '_blank';
          tagLink.rel = 'noopener noreferrer';
          tagLink.textContent = match[0];
          fragment.appendChild(tagLink);
          lastIndex = match.index + match[0].length;
        }
        // Add remaining text
        if (lastIndex < text.length) {
          fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
        }
        node.parentNode.replaceChild(fragment, node);
      }
    });
    
    return {
      description: tempDiv.innerHTML.trim(),
      neodbUrl: neodbUrl
    };
  }
  
  function processNeoDBCards(container) {
    // This function is kept for compatibility but NeoDB is now handled via placeholders
  }
  
  function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }
})();
</script>

此处还有条当时写的关于此次 vibe coding 的吐槽:

先前本地 vibe 好就发上去了,然后发现远程 403 了,当天晚上 debug 了半天发现是 github pages action 会被 cloudflare bot fight mode 拦截(which 也合理),然后因为这个是免费版的东西无法自定义 ruleset,装插件用 hacky solution 都没解决。

就在我快要放弃准备用手动关闭 bot fight mode 笨办法的时候突然恍然大悟,就 AI 一开始给我写的是 build time fetch,因为我在 vibe 根本没动脑子就照着这个思路走了。但因为是要动态的展示嘟所以本来就不应该 build time fetch 否则读者看不到长毛象更新除非我更新博客。正确做法应该是用 js run time fetch 读者才能看到更新,这样一来也是读者的 organic traffic 根本不会触发 bot mode….让 AI 用 js 重写一遍改成 runtime render,立刻解决了。

简直是 vibe coding 在实际工作中的实际问题缩影了……就产量超级足,输出比谁都多,很多时候硬跑也能跑起来,但选的方案可能完全是错的(祭出下图经典 meme,当然这个 meme 本来是说人写的代码的,现在有了 vibe coding 又更进一步适用了),真要碰上问题可能得花更多时间指对方向,完全 vibe 真的不见得行。也就像前面的嘟说的,个人玩具能做,而且没这个生产力 boost 根本不会做所以也算是立功一件,但真的 robust 能用可能还要一段时间进步。

虽然从去年底到今年初其实 claude code 也进步了不少的(当年是用 cursor 写的),现在生产中也基本没有 tradcoding 传统手作了,99% 都是 AI 写的码了,但时不时还是会遇上这种判断问题,生产力数倍放大的同时偶尔会走错方向。难以想象在 vibe coding 时代刚入行的年轻人们要怎么打好基础……

Neodb 最近标记 #

也是受博友伊丽莎白猫启发半 vibe code 的。毕竟本来就在关我辟事月记里有 neodb 卡片了,做成近期的话只需要把自己的 API token 放上去 pull 自己的 shelf 然后稍作格式调整即可。暂时还在心理 debate 月记里要不要也干脆改改格式放这个自动版算了省得手动复制粘贴,但目前而言月记里还是有条目之外的碎碎念,有时候会有截图,也不光是都标记了的,就暂时还是只放在放在now 页面里了。

AI 代码又臭又长折叠了
<div class="neodb-recent-container">
  <div class="debug"></div>
  <div class="movies-section" style="display:none;">
      <h3>🎬 最近看过</h3>
      <div class="movies-grid recent-grid"></div>
    </div>

    <!-- Books Grid -->
    <div class="books-section" style="display:none;">
      <h3>📚 最近读过</h3>
      <div class="books-grid recent-grid"></div>
    </div>

     <!-- Games Grid -->
    <div class="games-section" style="display:none;">
      <h3>🎮 最近玩过</h3>
      <div class="games-grid recent-grid"></div>
    </div>

    <div class="no-data" style="display:none;">
      <p class="text-gray-400 text-sm italic">暂时无法获取 NeoDB 数据,请稍后再试。</p>
    </div>
</div>

<script>
(async () => {
  const container = document.querySelector('.neodb-recent-container');
  const token = '{{ .Site.Params.NeodbToken }}';
  
  if (!token) {
    container.querySelector('.debug').textContent = '缺少 NEODB_TOKEN';
    return;
  }

  // Fetch function matching your Astro code
  async function fetchNeoDB(category) {
    try {
      const res = await fetch(`https://neodb.social/api/me/shelf/complete?category=${category}&page=1`, {
        headers: {
          Authorization: `Bearer ${token}`,
          Accept: 'application/json',
        },
      });
      
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json();
      return json.data ? json.data.slice(0, 4) : [];
    } catch (error) {
      console.error(`NeoDB ${category} fetch failed:`, error);
      return [];
    }
  }

  // Fetch data
  const [movies, tvs, books, games] = await Promise.all([
    fetchNeoDB('movie'),
    fetchNeoDB('tv'),
    fetchNeoDB('book'),
    fetchNeoDB('game'),
  ]);

    // Movies
    const allMoviesTV = [...movies, ...tvs]
      .sort((a, b) => new Date(b.created_time) - new Date(a.created_time))
      .slice(0, 8); // Top 8 total

    // Render sections
    if (allMoviesTV.length) {
      container.querySelector('.movies-grid').append(
        ...allMoviesTV.slice(0, 4).map(createCard)
      );
      container.querySelector('.movies-section').style.display = 'block';
    }

    // Books  
    if (books.length) {
      const grid = container.querySelector('.books-grid');
      books.forEach(item => grid.appendChild(createCard(item)));
      container.querySelector('.books-section').style.display = 'block';
    }

    if (games.length) {
      container.querySelector('.games-grid').append(
        ...games.map(createCard)
      );
      container.querySelector('.games-section').style.display = 'block';
    }

    if (!allMoviesTV.length && !books.length && !games.length) {
      container.querySelector('.no-data').style.display = 'block';
    }

    function createCard(record) {
      const { item, rating_grade: rating, comment_text: comment, created_time } = record;
      const div = document.createElement('div');
      div.className = 'db-card';
      div.innerHTML = `
        <div class="db-card-subject">
          <div class="db-card-post">
            <img loading="lazy" decoding="async" referrerpolicy="no-referrer" 
                src="${item.cover_image_url}">
          </div>
          <div class="db-card-content">
            <div class="db-card-title">
              <a href="${item.id}" class="cute" target="_blank" rel="noreferrer" title="${item.title}"">
                ${item.title}
              </a>
            </div>
            <div class="rating">
              个人评分:<span class="allstardark">
                <span class="allstarlight" style="width:${rating * 10}%"></span>
              </span>
            </div>
            <div class="db-card-abstract">
              ${comment || item.brief || '暂无评论'}
            </div>
          </div>
          <div class="db-card-cate">${item.category}</div>
        </div>
      `;
      return div;
    }
  })();
</script>

<style>
/* Your existing global db-card styles assumed to be loaded */
.recent-grid {
  display: grid !important;
  grid-template-columns: 1fr;
  gap: 1rem 1.5rem;
  margin: 1rem 0;
}
@media (min-width: 768px) {
  .recent-grid { grid-template-columns: repeat(2, 1fr); }
}
.recent-grid .db-card { margin: 0 !important; width: 100%; }
</style>

博客从 github page 迁移到 cloudflare page #

(现在经常会手滑把 cloudflare 打成 claudeflare…)好像初衷是有个最近开始订阅也经常想留言的博主是 giscus 评论系统,又不想博客在互联网上裸奔(虽然我从来都不打草搞吧所以库里确实也都是公开内容啦),github page 要求仓库公开,外加最近 github reliability 巨差,索性动了迁移的心。然后意外发现速度比 github page 快了好几倍吧也算锦上添花了。

迁移本身挺简单的没有太多需要说的,唯一要注意的一点是迁移之后发现 twikoo 评论区的 emoji picker 用不了了,定睛一看之前设置的 emoji config CDN 是 github 的地址,现在转成 private 了自然用不了了。把那个 json file 传了一份自己的 S3 再在 twikoo 控台更新地址之后发现依然用不了,原因是 CORS 没设置。解决方法是在自己的 S3 settings 里加上自己博客 CORS Configurations 的 GET, HEAD,然后给那个文件 purge CDN cache 即可。

系列文章 #

我隔三差五就会写一些小系列,比如本文是装修小记系列,之前还有南极美西大环线割以永治各种 101等等。Hugo 自身的“相关文章”靠 tag 和 category 效果很烂,而且只能是当篇往前,这样从系列第一篇开始看的人就很难找到系列后面的文章,每次手动更新又很麻烦。受博友云五老师的启发,给博文加了一个系列的 param,这样每次系列文章就会自动更新在底部了。

懒得重写一个短代码了,直接加在现成的模版底部了:

{{ if $.Params.series }}
  {{ $seriesName := $.Params.series}}
  {{ $seriesPages := where site.RegularPages ".Params.series" $seriesName }}
  {{ if gt (len $seriesPages) 1 }}
    <h3>系列文章</h3>
    <ul>
      {{ range sort $seriesPages "Date" }}
        <li>
          <a href="{{ .Permalink }}">{{ if eq .Type "time-capsule"}}[时空] {{ end }}{{ .Title }}</a>
          {{range (first 3 (.Params.tags))}}
          {{ $tagColor := substr (md5 .) 0 6}}
          <div class="tag tag-small" style="--tag-color: #{{$tagColor}}1A" >{{ . }}</div>
        {{end }}
        </li>
      {{ end }}
    </ul>
  {{ end }}
{{ end }}

音频播放 #

纯粹是因为写 claude 提示音这篇的时候想要一个 single button 的音频播放器,然后惊讶地发现 html 自带的 <audio> tag 竟然不能隐藏控制板,比如:

于是半 vibe 了一个只显示播放键的 audio play button,效果如下:

<audio {{ if .Get 1}}id="{{ .Get 0 }}"{{else}}controls{{end}}>
  <source src="{{ .Get 0 }}" type="audio/mpeg">
</audio>

<button class="audio-play-btn" data-audio="{{ .Get 0 }}" 
  {{ if .Get 1 }} 
    style="background:#ddd; border:none; width:36px; height:36px; border-radius:50%; cursor:pointer;"
  {{ else }}
    style="display:none"
  {{ end }} >
</button>

博客纪念日 #

前阵子心血来潮追溯了我的博客历史确定了博客纪念日,我的 About 页面先前有一句“写了博客 20 多年“,索性写了个短代码自动更新这个”XX 年“。

{{ $dateStr := .Get 0 | default "2004-03-16" }}
{{ $target := time $dateStr }}
{{ $today := now }}
{{ $years := sub $today.Year $target.Year }}
{{ if or (lt $today.Month $target.Month) (and (eq $today.Month $target.Month) (lt $today.Day $target.Day)) }}
  {{ $years = sub $years 1 }}
{{ end }}
{{ $years }}

系列文章



如果您觉得本文对您有帮助,想支持我的博客创作,或者有特定的内容想要看到,或者想约 coffee chat 等,欢迎: 订阅 Patreon 参与博客选题和定制服务 在 Kofi 上给我买杯奶茶

❤️ 🤣 🤔 🤯