普通视图

Received before yesterday游钓四方

生日骑遇“死亡摇摆”,差点成忌日骑

2025年6月25日 20:53

农历五月二十九,二十五岁生日到了

一边上班,一边创业,这样的活法让我骑车的时间越来越少。这不是我想要的状态,但为了三十岁之前出发环球骑行的梦想,我没有办法。

前几日申请了调休,可算睡个安稳觉。早上睡到十点钟,吃完午饭,我觉得今天不能再碰电脑了,我现在很想出去骑车。可只剩下一个下午的时间了,太远去不了,近的没意思。打开高德地图扒拉半天,去横店。

横店十里街 · 江南大桥方向
横店十里街 · 江南大桥方向
Loading EXIF data...
江南一镇(我觉得一般)
江南一镇(我觉得一般)
Loading EXIF data...
横店影视城 · 明清宫苑景区外
横店影视城 · 明清宫苑景区外
Loading EXIF data...
横店 · 明清西路
横店 · 明清西路
Loading EXIF data...

在我的印象中,横店是拍电影的地方,但我实际到了地方,感觉哪都没有意思,啥玩意?一个适合骑车的地方都没有,不如来时的路好玩。

东阳市 · S217
东阳市 · S217
Loading EXIF data...

实在没意思,已经在回去的途中了。

距离:80.43km | 爬升:533m | 时间:4h 28m
距离:80.43km | 爬升:533m | 时间:4h 28m

值得一提的是,骑行这些年,我第一次遇到“死亡摇摆”

在217省道放坡,点刹50码。行程到一半遭遇强逆风 + 侧风(大货车快速行驶向右推来的气流)

刚开始车把只有轻微晃动,我就轻微点刹减点速。

但没想到,几秒之后,车把开始剧烈左右甩动,大约持续十秒左右,幸好当时没慌,硬是拿捏住了!人和车都没摔!

回到家洗澡时还是懵的,不知道当时怎么解决的问题。如果当时没解决死亡摇摆:

向左摔,身后高速行驶的下坡车辆根本反应不过来,会直接从我身上碾压过去

向右倒,四五十码栽进排水沟?我估计也不会太好受。想想有些后怕,真刺激!

高速行驶下的死亡摇摆案例

高速行驶下的死亡摇摆案例
  •  

独立开发者创业了

2025年6月12日 13:07

我们生命中最美好的时刻,并非是那些接受给予、放松享受的时刻,而是那些为了完成一件困难而有价值的事情,自愿将身心发挥到极限的时刻。

像咱这种人,我觉得只有一种死法 —— 猝死。

从6月10日早上九点,一直干到6月11日下午一点半,眼睛都没怎么合过。足足三十多个小时,一口气用Go,把整个企业级的RESTful API给撸完了!

这可不是简单的CtrlCV,为了支持硬件,软件下了点功夫:MySQL做了主从复制架构 + Redis缓存 + 负载均衡。支持 Docker 一键部署。还没做压力测试,自我感觉并发二三十万如喝水。

上午测完接口,兴奋的饭都顾不上吃,更别说休息了。立马开始写文档。接口文档、数据库文档…

先用Cursor生成基本文档,再改改。每份大概3000字800行左右,足足写了八份。

还有商业计划书的思维导图,2M的大小,不放大到190%字体都看不清楚,内容密度可想而知。

截图
截图

真的要特别感谢 Cursor,这AI工具真是赶上了好时代!要是没有它,就凭这些工作量,外包团队没一个月起步都别想搞定。

不敢想象,我竟然在三十多个小时里,就搞定了这么多事,而且落地质量高!

接下来,还剩微信小程序对接接口,以及和硬件的联调。先给这些小卡拉米放放假,我要好好休息一个下午。

此刻,精力充沛去吃个早饭骑骑车。

6.12 注册公司:

实际上,我做的这些事情有没有公司都能推进,但没办法,就为了微信小程序后续的企业认证需求.

在浙江线上办理,全程可以不出门。

項目內容
企业名称浪泳科技(义乌)有限公司
自主申报预选号[2025]******
拟登记机关义乌市市场监督管理局
住所所在地******
生产经营地******
法定代表人******
从业人数2
联系电话186****7426
邮政编码322000
注册资本0.001万元
企业类型有限责任公司(自然人独资)
行业(6513) 应用软件开发
是否一照多址
经营范围写不下

被驳回

提交申请还不到俩小时,我就收到了驳回通知:

您申报的“浪泳科技(义乌)有限公司”设立登记申请,预审未通过,请补齐材料后再次申报。

    1. 注册资本显著过低,不符实际,请调整;
    1. 身份证照片裁剪旋转摆正上传, 要求照片只保留身份证、其余无关背景裁去并放正上传;
    1. 部门产权信息核验不通过,需要提供产权证或是前往地名办办理登记,若房屋登记用途为住宅的,仅能从事电子商务、计算机数据处理、软件和信息服务、网络技术、文化创意、动漫游戏开发、翻译服务、工业设计,审核人将在经营范围最后添加以上销售仅限网上销售,咨询电话:0579-85232920/85117280/85518797/85518798(业务咨询电话:0579-85232920)

注册资本一元行不通,调整到了一万

经营范围调整为:

  • 软件开发;信息系统集成服务;网络与信息安全软件开发;人工智能基础软件开发;数字技术服务;网络技术服务;软件外包服务;信息技术咨询服务;数字内容制作服务(不含出版发行);数据处理和存储支持服务;互联网销售(除销售需要许可的商品);软件销售(除依法须经批准的项目外,凭营业执照依法自主开展经营活动)。
  •  

EasyFill 重大更新,全面提升用户体验

2025年6月2日 23:27

版本:v1.1.1

经过两个月的偷懒,EasyFill 迎来了 v1.1.1 版本的重大更新。这次更新主要在匹配算法上进行大幅度优化,全面提升匹配效率

更新概览

  • 支持动态 Shadow DOM 和三种全新识别方式
  • 可自定义数据源,智能缓存机制
  • Markdown 文本异步并行加载
  • 自适应三级别日志,支持控制台调试
  • 更新隐私政策

全新识别方式

1. 动态 Shadow DOM 支持

我发现有些评论系统通过 Shadow DOM 来实现封装,导致 v1.0 版本无法识别 Shadow DOM 生成的表单。在 v1.1.1 中,EasyFill 新增了对动态创建的 Shadow DOM 的完整支持。

function traverseShadowDOM(root: Document | ShadowRoot | Element) {
  const inputs = root.querySelectorAll('input, textarea');
  elements.push(...Array.from(inputs));
  
  const allElements = root.querySelectorAll('*');
  allElements.forEach(element => {
    if (element.shadowRoot) {
      logger.info('发现 Shadow DOM,正在遍历', { 
        tagName: element.tagName, 
        shadowRootMode: element.shadowRoot.mode 
      });
      traverseShadowDOM(element.shadowRoot);
    }
  });
}

2. 三种识别方式全覆盖

在 v1.0 版本,EasyFill 只支持 name 字段识别。为了更加准确的匹配字段,引入了全新三种字段识别方式,确保在各种稀奇古怪的表单都可以识别:

Placeholder 识别

通过分析输入框的 placeholder 属性来识别字段类型:

<input placeholder="请输入您的姓名" />
<input placeholder="邮箱地址" />
<input placeholder="个人网站" />

Type 识别

基于 HTML5 标准的 type 属性进行智能识别:

<input type="email" />
<input type="url" />
<input type="text" name="username" />

ID 识别

通过元素的 id 属性进行精确匹配:

<input id="author" />
<input id="email" />
<input id="website" />

匹配策略:

inputs.forEach((input) => {
  const typeAttr = (input.getAttribute("type") || "").toLowerCase();
  const nameAttr = (input.getAttribute("name") || "").toLowerCase();
  const idAttr = (input.getAttribute("id") || "").toLowerCase();
  let valueToSet = ""; // 要填充的值
  let matchedBy = "";  // 匹配方式(id, name, type)
  let fieldType = "";  // 字段类型(name, email, url)

  // 匹配 URL 字段
  if (keywordSets.url.has(nameAttr) || keywordSets.url.has(`#${idAttr}`)) {
    valueToSet = url;
    matchedBy = keywordSets.url.has(`#${idAttr}`) ? "id" : "name";
    fieldType = "url";
  } else if (typeAttr === "url" && url) {
    valueToSet = url;
    matchedBy = "type";
    fieldType = "url";
  }

  // 匹配 Email 字段
  else if (keywordSets.email.has(nameAttr) || keywordSets.email.has(`#${idAttr}`)) {
    valueToSet = email;
    matchedBy = keywordSets.email.has(`#${idAttr}`) ? "id" : "name";
    fieldType = "email";
  } else if (typeAttr === "email" && email) {
    valueToSet = email;
    matchedBy = "type";
    fieldType = "email";
  }

  // 匹配 Name 字段
  else if ((keywordSets.name.has(nameAttr) || keywordSets.name.has(`#${idAttr}`)) && name) {
    valueToSet = name;
    matchedBy = keywordSets.name.has(`#${idAttr}`) ? "id" : "name";
    fieldType = "name";
  }

  // 没有匹配上就跳过
  if (!valueToSet) return;

  // 设置值并触发事件
  (input as HTMLInputElement).value = valueToSet;
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));

  // 记录日志
  logger.info('填充表单字段', {
    name: nameAttr || "",
    id: idAttr || "",
    type: typeAttr || "",
    matchedBy,
    valueToSet,
    inShadowDOM: isInShadowDOM(input)
  });
});

数据同步

1. 自定义数据源功能

v1.1.1 版本允许用户完全自定义关键字数据源。

该源来自我的腾讯云 COS,且由腾讯云境内 CDN 加速,基本上无延迟:

https://cos.lhasa.icu/EasyFill/keywords.json

自定义数据源格式示例:

{
  "name": ["name", "author", "username", "昵称", "姓名"],
  "email": ["email", "mail", "邮箱", "电子邮件"],
  "url": ["url", "website", "blog", "网站", "博客"]
}

2. 缓存机制

实现了基于 HTTP 标准的智能缓存系统,大幅减少不必要的网络请求:

  • 304 Not Modified 响应处理
  • 自动缓存有效期管理(24小时)
  • 网络失败时自动回退到缓存
  • 支持强制刷新机制

ETag 和 Last-Modified 支持:

if (etag && !forceSync) {
  headers['If-None-Match'] = etag;
}
if (lastModified && !forceSync) {
  headers['If-Modified-Since'] = lastModified;
}

性能优化

1. localStorage 持久化存储

实现 Markdown 内容的持久化机制:

const fetchMarkdown = async (url: string) => {
  try {
    // 检查 localStorage 是否已有缓存
    const cachedMarkdown = localStorage.getItem(url);
    if (cachedMarkdown) {
      logger.info(`从缓存加载 Markdown 文件: ${url}`);
      return cachedMarkdown;
    }

    // 如果没有缓存,从网络加载
    const response = await fetch(url);
    const markdown = await response.text();

    // 将加载的内容存入 localStorage
    localStorage.setItem(url, markdown);
    return marked(markdown);
  } catch (error) {
    logger.error(`加载 Markdown 文件失败: ${url}`, error);
  }
};

2. 异步并行加载优化

实现 Markdown 内容的异步并行加载:

const loadContent = async () => {
  const [aboutAuthor, recommendedPlugins, updateLog, privacyPolicy] = await Promise.all([
    fetchMarkdown('/markdowns/about-author.md'),
    fetchMarkdown('/markdowns/recommended-plugins.md'),
    fetchMarkdown('/markdowns/UpdateLog.md'),
    fetchMarkdown('/markdowns/privacy-policy.md'),
  ]);

  setAboutAuthorContent(aboutAuthor);
  setRecommendedPluginsContent(recommendedPlugins);
  setUpdateLogContent(updateLog);
  setPrivacyPolicyContent(privacyPolicy);
};

日志系统

1. 三级别日志架构

EasyFill v1.1.1 实现了单例日志系统,支持 INFO、WARN、ERROR 三个级别:

export enum LogLevel {
  INFO = 'INFO',
  WARN = 'WARN',
  ERROR = 'ERROR',
}

2. 智能环境适配

日志系统能够根据运行环境自动调整输出策略:

public configureByEnvironment(): Logger {
  const isProd = typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
  
  if (isProd) {
    // 生产环境:只显示警告和错误
    this.setLevel(LogLevel.WARN);
  } else {
    // 开发环境:显示所有日志,并启用彩色和时间戳
    this.setLevel(LogLevel.INFO)
        .useColors(true)
        .showTimestamp(true);
  }
  
  return this;
}

3. 控制台命令

生产状态下,日志默认关闭。所以,增加了命令调试:

// 启用日志系统
EasyFillLogger.enable()

// 关闭日志系统
EasyFillLogger.disable()

// 查看当前状态
EasyFillLogger.status()

命令绑定在全局 window 对象上,重启浏览器仍有效。

在浏览器扩展环境中,使用 chrome.storage.local 来存储,在普通网页环境中,使用 localStorage。

一样的是都用 easyfill_logger_enabled 这个键来存储

4. 链式配置接口

支持灵活的链式配置:

logger
  .setLevel(LogLevel.INFO)
  .useColors(true)
  .showTimestamp(true)
  .setPrefix('[EasyFill]')
  .setPrefixColor('color: #4CAF50; font-weight: bold');

配置选项:

  • 自定义日志前缀和颜色
  • 时间戳显示控制
  • 彩色输出开关
  • 级别过滤设置

隐私权政策

v1.1.1 版本对隐私权政策进行了全面更新:

主要更新内容:

  • 明确了关键字数据同步的目的和方式
  • 增加了用户控制权的说明

界面改进

1. 同步设置

新增了直观的同步设置界面,轻松管理数据同步:

  • 同步开关:一键启用/禁用自动同步
  • 同步频率:支持 1 小时到 1 周的灵活设置
  • 网络条件:可选择任何网络或仅 WiFi
  • 数据源管理:支持自定义 URL 和一键重置

2. 状态反馈优化

  • 实时显示同步状态和下次同步时间
  • 提供详细的操作成功/失败反馈
  • 使用 Snackbar 组件统一消息提示风格

短期计划

  • 实现黑白名单机制,控制填充权限
  • 支持多身份设置,满足不同用户需求
  • 完成在 Edge 和 Firefox 浏览器上的上架与兼容

长远计划

  • 使用 TensorFlow 训练机器学习模型,实现自动识别和评论补全功能
  • 在无性能开销的情况下,实现移动端跨平台支持(iOS 与 Android)
  • 依托 EasyFill 建立独立博客生态社区,面向计算机学生及爱好者提供系统性新手指南

致谢与支持

EasyFill 的每一次进步都离不开用户的支持和反馈。特别感谢:Mainbranch 的反馈与支持

如果您觉得 EasyFill 对您有帮助,欢迎:

请我喝一杯咖啡

立即体验

EasyFill v1.1.1 现已在 Chrome 应用商店正式发布,您可以:

  1. 新用户:直接在 Chrome 应用商店搜索 “EasyFill” 安装
  2. 现有用户:通过梯子 Chrome 扩展将自动更新到最新版本
  3. 开发者:访问 GitHub 仓库 查看源代码

EasyFill - 简易填充,让每一次评论更自然,与你的博友互动无缝连接

  •  

端午骑行:倍鱼线

2025年6月1日 00:01
倍鱼线 入口
倍鱼线 入口
Loading EXIF data...
柏峰水库 一级水源
柏峰水库 一级水源
Loading EXIF data...
横门殿桥
横门殿桥
Loading EXIF data...

养兵千日用兵一时,Samsung S Pen 触控笔有点作用了,遥控手机拍照

横门殿桥合影
横门殿桥合影
Loading EXIF data...
横门殿桥合影
横门殿桥合影
Loading EXIF data...
横门殿桥
横门殿桥
Loading EXIF data...
柏峰水库(横门殿桥视角)
柏峰水库(横门殿桥视角)
Loading EXIF data...
端午虽热,但来露营的人真不少
端午虽热,但来露营的人真不少
Loading EXIF data...
小溪旁的营地
小溪旁的营地
Loading EXIF data...
不知道在抓什么物种
不知道在抓什么物种
Loading EXIF data...

浙江的端午真不是盖的,室外像个桑拿房,给我一种快脱水的感觉。

我把车停靠在护栏上就下来了,咱也来来体验一下山泉水。

一级山泉水
一级山泉水
Loading EXIF data...
热热热
热热热
Loading EXIF data...
即将脱水
即将脱水
Loading EXIF data...
山腰上
山腰上
Loading EXIF data...
山顶(鱼曹头村方向)
山顶(鱼曹头村方向)
Loading EXIF data...
合影合影
合影合影
Loading EXIF data...

太幸福了!刚到达鱼曹头村,正好碰到一户人家在做午饭,花小钱办大事啊!吃吃饭再给手机充充电。

什么馅都有的馄饨
什么馅都有的馄饨
Loading EXIF data...

上山俩小时,下坡十分钟。速度达到 45 - 60 码!各种弯道,非常刺激!

准备返程回家了
准备返程回家了
Loading EXIF data...
爬升:1355m | 时间:4.15 | 距离:80.09km
爬升:1355m | 时间:4.15 | 距离:80.09km

离家最后三公里时突然暴雨,我滴天啊,该下的时候你不下。

到家后快递也到了,这三本书中我最期待石田裕辅写的”不去会死”

这本书是 Pluskid 2022 书单其中一本 看到他写的评语后,我就马上找这本书。偏小众些,年代久远不好找,都是二手货,全新没有简体版本,台版的八月开售(繁体)

C3环游记Ⅲ:加勒比没有那么远 | 徒步进藏 | 不去会死
C3环游记Ⅲ:加勒比没有那么远 | 徒步进藏 | 不去会死
Loading EXIF data...
  •  

基于 Astro Paper 的个人博客:深度定制和部署实践

2025年5月31日 03:12

今天是我独立博客走过的第八个年头。还记得那一年怀着对独立站的疑问,给孔令贤发邮件,询问是否可以使用他写的轮子,就是从他回复我那一刻起,我掉进了 WEB 深渊。

独立博客这个词,在 2025 年这个年代确实足够小众,但其中的快乐和对生活的态度,想必也只有博友能理解。

正是为了这第八个年头,才有了今天这全新的博客。从年初用 Jekyll 从零开始写,后来又用 Recat 写个半成品。最终阴差阳错选择了开源的 Astro Paper。

Astro Paper 这款主题性能极强,可拓展性也非常高,这也得益于 Astro 的静态特性和原作者优越设计。

经过一段时间的二次开发,这个博客差不多达到了我理想的样子。

在全站无缝刷新的基础下,我把博客全站的图片都做了懒加载,订阅和归档模块也做了滚动懒加载。

再加上页面内链的预加载处理,无论你点击哪个页面,都是一种享受。

Lighthouse 评分

Lighthouse 评分

下面就把新增的功能一一道来。

分类路由支持

Astro Paper 原生不支持平铺式 URL,也不能把文章进行分类:

改进后:

  1. 文章可按分类(技术、生活、运动等)划分路由。
  2. 支持按年份归档,且不会影响已有 URL 访问。
  3. URL 更加简洁:

分类路由结构如下,可在/src/pages/中按需创建:

blog/
  ├── _releases/
  ├── examples/
  ├── life/
   ├── 2024/
   └── 2025/
  ├── sports/
   ├── 2024/
   └── 2025/
  └── technology/
      ├── 2024/
      └── 2025/

路由中间件处理

在不用 Artalk 评论系统的情况下,这个功能其实可有可无。但 Artalk 在路径识别上,存在很大的问题(带斜杠与不带斜杠会被视为不同页面),存在一定的隐患。

其实使用 Nginx 会更方便一些。

import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware((context, next) => {
  const url = context.url;
  const pathname = url.pathname;
  
  const staticExtensions = [
    '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
    '.css', '.js', '.json', '.xml', '.txt', '.pdf',
    '.woff', '.woff2', '.ttf', '.eot', '.otf'
  ];
  
  const isStaticResource = staticExtensions.some(ext => 
    pathname.toLowerCase().endsWith(ext)
  );
  
  // 如果是静态资源,不处理尾部斜杠
  if (isStaticResource) {
    return next();
  }
  
  // 如果URL不以斜杠结尾且不是根路径,则重定向到带斜杠的版本
  if (pathname !== "/" && !pathname.endsWith("/")) {
    const newUrl = new URL(pathname + "/" + url.search + url.hash, url.origin);
    return Response.redirect(newUrl.toString(), 301);
  }
  
  return next();
}); 

运动数据可视化

接入 Strava Riding Api 做了运动数据的可视化。

  1. 光标悬浮可切换月数据
  2. 点击日期可查看当天运动详情
EasyFill·我的信息

EXIF 元数据显示

借助腾讯云数据万象 API,默认自动启用。

  1. 参数为 Boolean 类型,false 可禁用
  2. 光标悬浮显示两秒
  3. 解析失败会自动生成合理参数并缓存
import Img from "@/components/Img.astro";

<Img src={`${IMAGES}/${frontmatter.slug}/20250524003018.jpg`} />
EXIF 示例
Loading EXIF data...

图片标签

所有图片默认使用长标签,支持切换为短标签或禁用标签

义乌美术馆一角
义乌美术馆一角
Loading EXIF data...
义乌美术馆一角
义乌美术馆一角
Loading EXIF data...

若想在文章中启用 EXIF,需要将 .md 改为 .mdx,并引入组件:

import Img from "@/components/Img.astro";

<Img 
  src={`${IMAGES}/${frontmatter.slug}/20250530173339.jpg`}
  alt="义乌美术馆一角"
  caption="short" // false 表示不显示
/>

悬停提示(tooltip)效果

图片的 title 属性不必声明,只要有 alt 属性,Img.astro 组件就会自动读取并渲染到页面中。

数学公式支持

通过 KaTeX 集成 支持了数学公式。纯静态渲染,无性能问题。示例:

骑行里程=均速×时间\text{骑行里程} = \text{均速} \times \text{时间}
$$
\text{骑行里程} = \text{均速} \times \text{时间}
$$

Artalk 集成

说到评论系统,首先感谢 Disqus PHP API 开源作者 Fooleap,感谢好大哥这些年来帮我在境外挂着接口…

Artalk 官方提供了简单的配置文件,不过足够了

services:
  artalk:
    container_name: artalk
    image: artalk/artalk-go
    restart: unless-stopped
    ports:
      - 9998:23366
    volumes:
      - ./data:/data
    environment:
      - TZ=Asia/Shanghai
      - ATK_LOCALE=zh-CN
      - ATK_SITE_DEFAULT=游钓四方的博客
      - ATK_SITE_URL=https://lhasa.icu

创建容器运行 Artalk:

docker-compose up -d

# 执行命令创建管理员账户
docker exec -it artalk artalk admin

再使用 Nginx 反代 9998 端口就可以实现域名访问了。

由于我是 Disqus 迁移过来的,需要把格式转换为 Artrans,然后再导入 Artalk。

由于无缝刷新的存在,就单单评论来说,调试花了不少时间,踩了很多坑,这里还把 Artalk 随着主题变化适配了配色。

<script is:inline data-astro-rerun>
(function () {
  // 单例模式存储 Artalk 实例
  window.artalkInstance = window.artalkInstance || null;

  const artalkConfig = {
    el: "#Comments",
    server: "https://artalk.lhasa.icu",
    site: "游钓四方的博客",
    pageKey: window.location.pathname,
    vote: false,

  };

  function destroyArtalk() {
    if (window.artalkInstance) {
      try {
        window.artalkInstance.destroy();
        document
          .querySelectorAll(".atk-sidebar, .atk-layer-wrap")
          .forEach(el => el.remove());
        window.artalkInstance = null;
        console.log("Artalk 实例已销毁", window.location.pathname);
      } catch (err) {
        console.error("销毁失败:", err);
      }
    }
  }

  // 初始化 Artalk 实例
  function initArtalk() {
    const container = document.getElementById("Comments");
    if (!container || container.querySelector(".atk-app")) return;

    const isDark = document.documentElement.getAttribute("data-theme") === "dark";

    artalkConfig.pageKey = window.location.pathname;
    artalkConfig.darkMode = isDark;

    window.artalkInstance = Artalk.init(artalkConfig);
    console.log("Artalk 初始化完成", window.location.pathname);
  }

  function handleThemeChange() {
    const themeBtn = document.querySelector("#theme-btn");
    if (!themeBtn) return;

    const observer = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (mutation.attributeName === "aria-label") {
          const isDark =
            mutation.target.getAttribute("aria-label") === "dark";
          if (window.artalkInstance) {
            window.artalkInstance.setDarkMode(isDark);
          }
        }
      });
    });

    observer.observe(themeBtn, {
      attributes: true,
      attributeFilter: ["aria-label"],
    });
  }

  function handlePageLoad() {
    destroyArtalk();
    initArtalk();
    handleThemeChange();
  }

  function setupArtalk() {
    if (window._artalkInitialized) return;
    window._artalkInitialized = true;

    document.addEventListener("astro:before-swap", destroyArtalk);
    document.addEventListener("astro:after-swap", handlePageLoad);

    if (document.readyState === "complete") {
      handlePageLoad();
    } else {
      document.addEventListener("DOMContentLoaded", handlePageLoad);
    }

    // 监听主题
    window
      .matchMedia("(prefers-color-scheme: dark)")
      .addEventListener("change", ({ matches }) => {
        if (window.artalkInstance) {
          window.artalkInstance.setDarkMode(matches);
        }
      });
  }

  setupArtalk();
})();
</script>

Artalk 自带的验证码不好用,这里强烈推荐 Cloudflare Turnstile。无感验证,很省心。

在 Cloudflare 控制台主页可以看到 Turnstile,在填完域名后可以申请到Site KeySecret Key

随后打开 Artalk 控制中心,填入相应参数后,captcha_type选择turnstile即可。

本地开发

纯净版 Astro Paper:

# pnpm
pnpm create astro@latest --template satnaing/astro-paper

# npm
npm create astro@latest -- --template satnaing/astro-paper

# yarn
yarn create astro --template satnaing/astro-paper

或者直接使用我的扩展版本:

git clone https://github.com/achuanya/Blog.git

然后通过安装依赖启动开发环境

# 安装依赖
pnpm install

# 启动开发环境
pnpm dev

Docker 部署

用于生产环境的 Docker 配置已经写好了,可以直接构建镜像。

# 构建生产镜像
docker build -t astropaper .

# 启动生产环境,端口为 4321
docker run -p 4321:80 astropaper

配合 Nginx 反代:

server {
    listen 80;
    server_name lhasa.icu;

    # 404
    error_page 404 /404.html;
    location = /404.html {
        root /home/github/Blog/dist;
        internal;
    }

    location / {
        proxy_pass http://127.0.0.1:4321;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

不想折腾,建议安装宝塔Linux面板,随便点击几下,两分钟上线

if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh ed8484bec

Github + Vercel 部署

只需点击 Deploy 按钮,按提示一步步即可上线。

相关命令

CommandAction
pnpm install安装依赖项
pnpm run dev启动本地开发服务器,访问地址为 localhost:4321
pnpm run build将生产环境网站构建到 ./dist/ 目录
pnpm run preview本地预览生产环境构建的站点,部署前检查效果
pnpm run format:check使用 Prettier 检查代码格式
pnpm run format使用 Prettier 格式化代码
pnpm run sync为所有 Astro 模块生成 TypeScript 类型。 了解更多
pnpm run lint使用 ESLint 进行代码检查
docker compose up -d使用 Docker 运行 AstroPaper,可通过 dev 命令中相同的主机名和端口进行访问
docker compose run app npm install在 Docker 容器中执行任意上述命令
docker build -t astropaper .为 AstroPaper 构建 Docker 镜像
docker run -p 4321:80 astropaper在 Docker 中运行 AstroPaper。网站可通过 http://localhost:4321 访问

注意!
Windows PowerShell 用户如果想在开发期间运行诊断(例如 astro check --watch & astro dev),可能需要安装 concurrently 包
更多信息请参考 这个 issue

下一步

目前来说,还有很多地方没有完善,细节没有做到位:

  1. Strava Riding Api 还没有实现完全自动化,更新数据还是需要人工
  2. Img.astro 组件没有封装到位,还有细节需要把控
  3. Sports 在移动端时的表现还需要好好想想
  4. 给 Feeds 做个后台管理,先把头像显示问题解决了。当然,有邮箱最好
  5. 吃透 Astro Paper 无缝刷新机制

参考文档

  •  

雨骑枫赤线

2025年5月26日 13:20

近况

早上睡到九点钟,很困,睡不醒。直觉告诉我,我现在有种嗜睡的状态。实际上我这段时间都没有睡醒过,因为我时常编码到凌晨三四点,甚至通宵整晚,到了点直接去上班,也是常有的事。

傍晚下了班吃完饭,出去跑跑步,回到家已经十点钟了,直接脱光站在花洒下,任凭水滴打着脸,我很享受这种感觉,这让我觉得,只有现在的我是清醒的,回头望这一天,怎么想也没有觉得什么事是有意义的,想做的事总赶不上时间,原来人的精力和时间都是有限的。

我这个人对时间不敏感,可以说我是固执吧,我想,我很多时间我都花费在这性格上,但若是放在编码上也颇有钻研精神,为爱发电,成败与否不重要了,值了。

总的来说我这人很怪,我不知道该归类到哪种类型,大概可以用那句话来形容:“见人说人话,见鬼说鬼话。”

这两年,我的心态变了很多,也不知道从什么时候养成了一种无所谓的态度,大概是真的真躺平了,之前那种中年危机感也消失了,不再内耗了。

说到中年危机,比我年长的朋友总笑,毕竟我才二十四岁,距离二十五还有一个多月,但事实就是那样。

周五

失业第二天,暂时也不想找工作,只想出去走走,手里米不多,紧着过。我是一个比较容易满足的人,吃穿不讲究,哪怕吃口馒头住桥洞呢,毕竟也不是第一次了。

但这精神的苦,是真快忍不住了。来义乌二十天有余,就没有出过几次车,总想着跑步发泄一下吧,时间重要,骑车耽误时间,去你妈的时间。

G527 · 佛堂大道
G527 · 佛堂大道
Loading EXIF data...

这次出行,我多带了一件短袖骑行服外套,一是暴雨天气可以作为贴身保暖,二为了烟盒存放,我可不想背着包爬盘山公路。

刚骑两公里,浑身黏黏的,实在难受,外套直接脱了挂在 ASSOS 的背带裤上,像往常一样,上半身只穿一件 MBO 白色背心。

S218 · 赤岸大道
S218 · 赤岸大道
Loading EXIF data...

长时间不骑车,体力确实跟不上。而且我这个双腿隐约有些疼,一个月了未见好转,就因为那个半马,真让博友说中了。

S218 · 义武公路 · 永康方向
S218 · 义武公路 · 永康方向
Loading EXIF data...

通过高德得知,到了这个丁字路口,就算正式进入盘山公路的入口了,我也是第一次来骑枫赤线,随便搜的一个地方

义乌公路,丁字路口,即将到达山脚下
义乌公路,丁字路口,即将到达山脚下
Loading EXIF data...

中间的湖,属于饮用水源一级保护区

此刻,我想钓鱼
此刻,我想钓鱼
Loading EXIF data...

湖叫啥名我不清楚,只记得每隔几百米都有牌子竖立写着:“饮用水源地一级”

我停下车,往下望,看到一个浅水滩,岸边聚着一群大约四五公斤的锦鲤。

这些地方通常不缺鱼,缺的是饲料。还记得钓友口口相传的段子:

  • 无标识 = 空军警告

  • 禁止垂钓 = 有鱼,但不多

  • 严禁钓鱼,违者后果自负 = 鱼多,但掉水里我们不管

  • 禁止垂钓,违者罚款 500 = 鱼多,钓费 500

  • 饮用水源,禁止垂钓 = 鱼多,本地人可以钓

去的时候没看到有人钓鱼,返程时在山顶往下看,发现一个打路亚的,那位置绝了,不是本地人,你真不知道从哪下去。

浅水滩的锦鲤在觅食
浅水滩的锦鲤在觅食
Loading EXIF data...

照片里面的这段盘山公路是整条线的精髓,骑起来很舒服,可惜太短,还不够爽。

枫赤线上面的盘山公路
枫赤线上面的盘山公路
Loading EXIF data...

顶着细雨骑了一路,最近熬夜太多,精神状态很差。

黑眼圈很重
黑眼圈很重
Loading EXIF data...

突然暴雨,手机被雨砸的页面乱跳,镜头根本擦不干净,浑身湿透,很爽。

手机和我都遭受到了枫赤线的洗礼
手机和我都遭受到了枫赤线的洗礼
Loading EXIF data...

中场休息

骑行到达枫赤线终点,终点见到人家了,总算有百货店了,这一条街上只有两家店,另一家进去除了啤酒和水啥都没有,相比之下,这一家好多了。

其实,我主要是借东西来充电的,吃饭是次要的。不吃饭我可以骑回去,但是没有手机,我不知道可以骑哪去。

吃到东西了,还可以充电,太幸福了
吃到东西了,还可以充电,太幸福了
Loading EXIF data...

店面是个八零后阿姨开的,她给我泡了一碗面,边吃边聊,就这样过了一个多小时。

她家的位置真的很顶,可谓是:“采菊东篱下,悠然见南山”

庭院朝阳,面向群山,左边的庭院养了一池子锦鲤,右边的小庭院种满了花,

铁栏杆外的杆子上挂满了月季,吃着聊着,还给我传授了一些栽培经验,蛮有意思的。

阿姨种的花
阿姨种的花
Loading EXIF data...

暴雨骤停,我也停了下来,望着群山大喊,太美了!

暴雨骤停
暴雨骤停
Loading EXIF data...

不过,我现在写着博客,看着这些图片总觉得差了点什么,腾讯云的压缩太狠了

合影留念
合影留念
Loading EXIF data...
合影留念
合影留念
Loading EXIF data...
盘山公路旁的小瀑布
盘山公路旁的小瀑布
Loading EXIF data...

这时已经下山了,即将离开枫赤线。

此时此刻,山脚下
此时此刻,山脚下
Loading EXIF data...

回家

义乌市区方向
义乌市区方向
Loading EXIF data...
爬升:1376m · 距离:101.06 Km · 时间:5h 32m
爬升:1376m · 距离:101.06 Km · 时间:5h 32m

义乌这座宝藏小城市,蛮不错的。

绿水青山环绕,群山绵延,把整个城市都包裹了起来。像今天的枫赤线,我不知道义乌还有多少个这样的路线,太期待了!

还有一个特别加分的点!路上的车不多。之前下班六点出去骑车,非机动车道人车都很少,相反,我去公园跑步人挤人。

这一点很重要,是我目前去过的城市中,单飞的感受最好的,当然,我最爱的还是是宁波和舟山。

  •  

一路向南,骑见江南:一人、一车、一旅途

2025年5月11日 16:28

4月24日,递交完辞职报告的那一刻,我心中一阵轻松。我终于可以离开了,离开熟悉的一切,前往下一个未知但让我心跳的地方——义乌。

原本的计划是从郑州骑行到义乌,约一千公里。然而,我的长途骑行装备都还在老家淮阳,不得不先折返一趟,郑州到淮阳≈210公里。

第一站:淮阳

4月26日,天还没亮我就起床了,所有行李早已打包妥当,多亏了顺道的好大哥和双双姐姐,他会把行李直接送到义乌,精准投放到阿丽家门口。所以我只需要背上包、骑车走就行了。

临出门前,我把钥匙存放在小区门口的保安亭,与保安打了招呼,等双姐有空再来取。

05:42 AM 郑州·孙庄北院

出小区准备吃饭
出小区准备吃饭
Loading EXIF data...

在小区南门吃了碗大米红枣粥,这是我在郑州的最后一顿早餐。我吃得很慢,望着小区,心里百感交集。最不舍的,是我的双姐,直到离开,我们也没有一张合影。唉……今朝一别,不知何日再见。

离开河南前·最后一碗粥
离开河南前·最后一碗粥
Loading EXIF data...

10:49 AM 开封·张市桥

从出发到现在,已骑行近百公里(99.81公里),历时4小时15分。中途几乎没停,一直到开封张市桥,看到一位大爷坐在家门口听戏,我停下来敬了他一根烟,顺势在他门口歇了口气。我实在太饿了,得找点吃的。

开封·张市桥 不知名大爷家门口歇脚
开封·张市桥 不知名大爷家门口歇脚
Loading EXIF data...

骑行至此,除了背包里三根香蕉,还有昨晚和双姐吃剩的半盒牛肉,没有其他补给。吃完香蕉后抽了两根烟,心理上稍有缓解,重新背上包,继续赶路,准备找个地方补点碳水。

坐在大爷家门口板凳上,补个蕉
坐在大爷家门口板凳上,补个蕉
Loading EXIF data...

11:52 AM 周口·扶沟

点了一碗烩面和一个鸡爪,牛肉是我自带的。说实话,第一次见到餐桌上这么干净的盘子…鸡爪也很入味,咬一口我就扔了,太清真了

白潭镇人民政府旁边的清真小饭店
白潭镇人民政府旁边的清真小饭店
Loading EXIF data...

骑到汴岗镇时,看到一家超市,顺便买水时偶遇了渤哥。

牌子!班尼路儿~
牌子!班尼路儿~
Loading EXIF data...

经过东夏亭镇人民政府旁的一条支流,干涸得厉害,河床都裸露出来了。不知是人为抽干,还是自然干旱所致。在这段行程中,这样的场景我已经见过好几次。

干旱,河床都漏了出来
干旱,河床都漏了出来
Loading EXIF data...

5:39 PM 周口·淮阳

到家!在小区门口买了两杯最爱的粥
到家!在小区门口买了两杯最爱的粥
Loading EXIF data...

全程逆风,路上每一米都艰难,越过扶沟、西华,路过麦田,虫子爬满全身,骑得越快身上越多,还有像蛆一样的小东西全身都是,包括脸上,这六十多公里真的折磨,全程都是虫。

骑行 204.4 Km
骑行 204.4 Km

本次行程装备大致如下:

  • Challenge ELITE 700×25c 管胎 x1

  • Topeak 多功能工具组

  • 康比特盐丸 x10包

  • CUKTECH 15 SE 移动电源

  • 前后尾灯

  • 便携打气筒

  • 备用袜子 x4双

  • 三合一充电线 若干

左边青蓝色的包是我从郑州背回来的,背负系统不科学,透气性也差,只适合日常出行,不适合长途骑行,这次回来扔家里让他吃灰。这次换上了右边的迪卡侬骑行包,内置水袋设计很实用,咬咬吸嘴就能补水,长途骑行相当方便。

换个背包,准备补给
换个背包,准备补给
Loading EXIF data...

韶音耳机声音太小,周围稍有噪音就听不见。干脆把BOSE音箱扎带绑车上,没有音乐我不能活。

把音箱绑在了车上
把音箱绑在了车上
Loading EXIF data...

04-28 6:55 AM 淮阳·龙湖新城

奶奶身体最近不太好,高血压住院了,所以骑行晚走了一天。

这是我在淮阳的最后一顿早餐:胡辣汤、鸡蛋饼、两个鸡蛋。早上的蛋尤为重要,它决定了我今天能不能顺利完成骑行

在淮阳的最后一顿早餐
在淮阳的最后一顿早餐
Loading EXIF data...

刚吃完早餐,习惯性的捏捏车胎,发现车胎气压不足,我当时就觉得不对劲,很大概率已经爆胎了。当我拿打气筒充气时,气嘴滋滋漏气,我当时就服了。这次行程我就备了一个管胎,这种胎很贵,没那么多资金支持我买一堆备胎,管胎这种轮胎和其他胎不一样,换胎要除胶贴胶费时费力,你要说补胎,我估计一般的小品牌技师都没摸过管胎,手头宽裕了,我一定要把这轮组先换了,它不支持管胎以外的其他胎型。

换胎,重新贴胶
换胎,重新贴胶
Loading EXIF data...

真是服了,啥好事都让我碰上了

安装好了,最后抽条
安装好了,最后抽条
Loading EXIF data...
准备出发
准备出发
Loading EXIF data...

12:12 AM 安徽·阜阳·太和县

骑行105公里到达阜·太和县,途中遇到很多有意思的场景,就是没有拍照,天气实在太热了,骑行的过程中实在懒得下来拍照。

在阜阳吃个午饭
在阜阳吃个午饭
Loading EXIF data...

18:02 PM 安徽·淮南·凤台县

骑行202公里到达淮南·凤台,吃个晚饭,点了一晚大肉水饺还有一份鸭腿,这点饭量对骑行时的我是完全不够的,她们家的绿豆汤是免费的,不稀比较稠,而且还是免费的,我连着吃了三碗…

在淮南吃个晚饭
在淮南吃个晚饭
Loading EXIF data...

本来想着吃完饭继续骑,奈何自己看见宾馆走不动路,想想两天也到不了,不急这一会儿。

淮南·凤台 御唐宫
淮南·凤台 御唐宫
Loading EXIF data...

到了屋里第一时间扒个精光,实在太热了,热了干,干了湿,浑身都很黏,

这胳膊,熟悉的痛感
这胳膊,熟悉的痛感
Loading EXIF data...
骑行 202.6 Km
骑行 202.6 Km

04-29 6:55 AM 安徽·淮南·凤台县

早餐还是老三样,这次的粥我多加了糖,高糖分有助于我长途骑行

在凤台县的最后一顿早餐
在凤台县的最后一顿早餐
Loading EXIF data...

11:12 AM 安徽·淮南·田家庵区

路过淮南市区买了5根香蕉,这天实在太热了,隔着手套都能感觉到香蕉是热的

补个蕉
补个蕉
Loading EXIF data...

12:32 AM 安徽·合肥·长丰县

这个披萨是我吃过最难吃的,已经不能称为披萨了,速冻的薄饼加热了一下,这顿饭是在蜜雪冰城吃的,就为了只有他们店舍得在荒无人烟的地方开空调

吃个中午饭
吃个中午饭
Loading EXIF data...

当时看到这个场景真的很惊艳,一抹绿,在这里钓鱼露营一定很爽

一抹绿
一抹绿
Loading EXIF data...

太热了,找了阴凉地,坐在国道两波护栏歇会儿…

歇会儿
歇会儿
Loading EXIF data...

6:46 AM 安徽·合肥·肥东县

绝了,即出发后第二次爆胎,当时天已经黑了,还好不是在荒郊野外爆的,不然我就要提前体验田野生活了,补救的可能性很小,因为管胎里面的管子炸了,管胎这种东西不用考虑当地施救的可能性,实体店不会有管胎卖,修更别说了,几乎失传的手艺。

再次炸管胎,而且是后轮
再次炸管胎,而且是后轮
Loading EXIF data...

也怪我,左转速度太快,正好有个坡度看不到这缝,后轮当场巨响爆了!

就是在这里
就是在这里
Loading EXIF data...

爆胎后,我穿着锁鞋推行了两公里左右,找了一家宾馆住了下来,我也懒得检查哪漏气了,碳轮坏了没有,一切都不重要了,现在就想着咋带车去义乌,我没有车包,高铁肯定是上不了。

刚骑行 176 Km 当场退役
刚骑行 176 Km 当场退役

04-30 8:09 AM 安徽·淮南·瑶海区

经费紧张,去义乌我也有规划着时间,等不起在网上买管胎的代价,由于没有装车包,公路车无法上高铁,托运怕出事。在网上找了一个义乌直达大巴车,可以放下面,车费200,行李房120砍到88,合计费用比高铁还贵。

打包滚蛋
打包滚蛋
Loading EXIF data...

11:32 AM 江苏·南京·江宁区

到饭店了,客运公司开始收割了,大巴车把车停到了他们据点,周围几公里都没有商店,饭堂和商店的物价堪比上海浦东机场,有些人早有准备自带了泡面,但是人家的热水专供司机,不让旅客用,笑了。

客运公司开始收割
客运公司开始收割
Loading EXIF data...

3:16 PM 浙江·杭州·上城区·九堡大桥

看到这里差点掉小珍珠,之前在杭州上班时经常开车路过这里,Hi 杭州 好久不见。

杭州·九堡大桥
杭州·九堡大桥
Loading EXIF data...

时隔六年,再次为热爱脱皮。痛苦如影随形,却也因此更加坚定

晒伤脱皮的右臂
晒伤脱皮的右臂
Loading EXIF data...
晒伤脱皮的左臂
晒伤脱皮的左臂
Loading EXIF data...
黑白分明
黑白分明
Loading EXIF data...
  •  

新手跑步第五次:单人挑战不间断半马

2025年4月24日 12:50

从跑步小白,到不间断半马需要多久?答案是六天,五次!

Day 4:首次不间断 10 公里

2025年4月20日 · 跑步第四天。早上起床时心情很好,因为我发现大腿已经不疼了,想必是适应了十公里的运动量,我决定今天晚上下班后,开始挑战不间断十公里

Day 4 · 小区在放电影·智取威虎山
Day 4 · 小区在放电影·智取威虎山
Loading EXIF data...

晚上刚下班,我直接回家换了运动装,出了门正好看到篮球场在放电影《智取威虎山》,我喜欢露天电影的氛围,有空一定要去看一场

Day 4 · 中原区·西流湖公园
Day 4 · 中原区·西流湖公园
Loading EXIF data...

从小区北门起跑,沿着郑上路一路向南,经过郑州市实验小学、第一中学等地标,最后在T字形路口到达西流湖公园北侧门,目前里程为4公里左右,这条路被我骑车压过不知道多少次了,可这次是跑步,带给我的感觉不一样

Day 4 · 西流湖公园·外围下坡
Day 4 · 西流湖公园·外围下坡
Loading EXIF data...

进了公园右转是上坡,向左则为两条路可以选择:

  1. 公园外围下坡,路面宽阔、路灯明亮
  2. 岸边小道,下坡路况一般
Day 4 · 西流湖公园·不知名桥亭
Day 4 · 西流湖公园·不知名桥亭
Loading EXIF data...

这个桥亭设计了很多座位,栏杆也不高,下面是贾鲁河的支流,还做了一个闸口的设计,水流从上面流下来,下面是人工池,形成一个小瀑布的效果,非常适合路亚、溪流钓,台钓佬就省省吧,只能大跑铅

Day 4 · 西流湖公园·不知名桥亭
Day 4 · 西流湖公园·不知名桥亭
Loading EXIF data...

Day 4 · 完成不间断 10 公里
Day 4 · 完成不间断 10 公里

分段成绩

Km配速海拔心率 (bpm)
15′41″−2 m167
26′29″0 m174
36′57″+2 m169
47′54″0 m161
58′08″−12 m162
67′35″+13 m168
76′58″−4 m170
87′07″+1 m166
96′47″0 m173
106′42″+2 m170
0.16′05″−1 m175

10 km 综合数据

指标数值
距离10.14 km
平均配速7′01″ / km
最快分段5′41″ / km
平均经过配速7′05″ / km
移动时间1 h 11′ 08″
全程耗时1 h 11′ 47″
平均心率168 bpm
爬升35 m
消耗卡路里643 kcal

Day 6:新手跑步第五次,单人 挑战 不间断 半马!

2025年4月22日 · 跑步第六天 · 第五次

今天我,昨天晚上忙着搬家没有跑步,处于内心的愧疚,我决定今天晚上把昨天的补回来,在跑之前我还不知道半马是什么意思

Day 6 · 半马·五公里记录
Day 6 · 半马·五公里记录
Loading EXIF data...

像往常一样,再次来到西流湖,里程来到了五公里,这段距离的心率有些高,心率区间在165-180,往后的数据都没有这个高

Day 6 · 半马·十公里记录
Day 6 · 半马·十公里记录
Loading EXIF data...

饮水没控制住,已经喝了550ml,当时非常兴奋,因为我即将跑返程了

Day 6 · 半马·十五公里记录
Day 6 · 半马·十五公里记录
Loading EXIF data...

跑到这里时,体力消耗的差不多了,双腿感觉十分僵硬,停一秒钟感觉都会导致后面跑步下去

Day 6 · 半马·记录
Day 6 · 半马·记录
Loading EXIF data...

到小区门口了,我感到非常兴奋,因为我即将完成我的第一次半马挑战,而且是不间断,除了中途买水,期间几乎没有停过

Day 6 · 半马·记录
Day 6 · 半马·记录
Day 6 · 魔镜魔镜谁是最持久男人
Day 6 · 魔镜魔镜谁是最持久男人
Loading EXIF data...

跑完站在家里,小腿和大腿没有疼痛感,唯一不舒服的就是双腿的膝盖关节处,活动就会有些疼痛

半马·分段成绩

Km配速海拔心率 (bpm)
15′24″−1 m170
25′51″0 m178
36′23″+3 m180
46′40″−2 m177
57′45″−12 m165
68′28″+8 m161
77′52″0 m162
87′28″+1 m164
96′52″−3 m171
107′21″−8 m166
118′46″−3 m158
127′04″−1 m171
138′13″+1 m164
147′11″0 m170
157′17″+1 m170
167′43″+10 m167
177′28″+2 m170
187′26″0 m170
197′22″−1 m170
207′37″+2 m167
217′37″+2 m166
227′02″0 m172
0.96′34″+1 m176

半马 · 数据一览

指标数值
距离22.96 km
平均配速7′17″ / km
最快分段5′24″ / km
平均经过配速7′18″ / km
移动时间2 h 47′ 07″
全程耗时2 h 47′ 28″
平均心率169 bpm
爬升69 m
消耗卡路里1450 kcal
Day 6 · 标签
Day 6 · 标签
Loading EXIF data...

到家准备脱裤子时才发现,我中午买的第一条跑步用的紧身裤标签还没摘,现在已经被汗水浸湿烂掉了,让我有种破茧的感觉

  •  

记录我的前3次跑步:从陪跑到主动出发

2025年4月20日 15:25

从讨厌到上瘾,原来跑步也能这样有趣.

我一直是个不爱运动的人,尤其讨厌跑步。打小起,我对跑步总是敬而远之

这次之所以开始跑步,完全是被阿坤和阿丽带动的

起初只是想着陪他们减肥,没想到,从第三天开始,我居然有点跑上瘾了

Day 1:人生第一次 5 公里(其实只跑了 3 公里)

第一次跑步是阿坤叫我的,他想减肥,我就陪他出来遛弯。他说目标是 5 公里,结果我们大半时间都在走路,实际上只跑了 3 公里

他有点胖,体力跟不上,但我直到活动结束都没有什么感觉

Day 1 · 没有什么感觉
Day 1 · 没有什么感觉

跑步 Day 2:不间断 5 公里初体验

第二天我刚下晚班(19:00),我打电话问阿坤什么时候出发,他说八点半。我不想等太久,就先回家收拾一下便出门了

第一天穿板鞋和牛仔裤实在太难受,这次吸取了教训,只穿了短裤、速干背心和跑鞋

站在小区门口花两分钟热热身,把软件都打开便开始跑了

刚开始跑到 0.86 公里 时,心率就达到了 183,但呼吸还算平稳

跑到 2 公里时,心率稳定在 168–170,最终顺利完成不间断五公里,一点都不觉得累,只是非常口渴

跑完后在楼下买了瓶水,还给阿坤发了个微信。结果瓶盖还都没拧开,就下起了暴雨,就像是天上开了个花洒一样,很突然…

Day 2 · 不间断 5 公里,暴雨突降
Day 2 · 不间断 5 公里,暴雨突降
Loading EXIF data...
Day 2 · 首次不间断 5 公里
Day 2 · 首次不间断 5 公里

阿坤因为下雨就没出门,我们在老地方随便吃了点东西聊聊天。准备回家时,我才发现自己腿已经快站不直了,大腿疼得厉害


跑步 Day 3:加码挑战,十公里!

早上起床,大腿肌肉酸痛,走路都不太舒服,走路都一瘸一拐的,就像当初刚学骑自行车一样,这种酸爽的痛感,反倒让我有点兴奋

出门碰头时,阿坤说想骑我的自行车,我说你骑吧,我跑步

相比昨天,今天的心率平稳多了,基本维持在 150–160。跑到 6.59 公里时,心率才到 181,那一刻我只觉得跑步,真的爽!掌握节奏之后,压根不想停下来!

Day 3 · 徐佳莹在奥体开演唱会
Day 3 · 徐佳莹在奥体开演唱会
Loading EXIF data...

跑着跑着来到奥体,正好赶上徐佳莹的演唱会。场外摆摊的特别多,还有个露天KTV,这种我是第一回次见,他们的声音是真大,我在 2 公里外就听见了,没一会儿,三四个保安冲过来大喊:“里面在开演唱会呢!”结果一个大妈拿着话筒回了一句:“演唱会咋了,演唱会咋了!” 笑死我了

之后我们绕着奥体转了一圈,发现个室外健身区,有很多器械,比如健身单车,还支持联网进行在线竞赛,而且运动数据可同步app,最重要的是全部免费!

Day 3 · 阿坤骑着我的自行车
Day 3 · 阿坤骑着我的自行车
Loading EXIF data...

返程时演唱会刚结束,整个奥体路被堵得水泄不通,到处是人和出租车

Day 3 · 首次十公里
Day 3 · 首次十公里

其实,今天的十公里多少有些违心,因为我实际只跑了 8.25 公里,剩下的两公里是骑车,阿坤说骑不动了,让我骑车,他跑着…

  •  

Strava Riding Api 上线

2025年4月10日 00:23

该脚本基于 Strava API v3 获取指定用户当年的所有骑行活动数据,并将其保存为JSON格式

功能特性

Strava Riding Api 只实现了 OAuth 2.0 授权流程的部分自动化,由于技术限制,目前无法实现完全自动化:

已实现部分

  • 半自动 OAuth 2.0 授权流程,轻松访问您的 Strava 数据
  • 自动获取任意年份的所有骑行记录
  • 获取每个活动的完整运动数据
  • 智能令牌管理:自动保存和刷新过期的访问令牌
  • 数据自动转换:公里、时间、速度单位等数据格式化
  • 内置多重容错机制,确保数据获取的可靠性

使用前设置

重要: 在使用此脚本前,请确保在Strava开发者平台上正确配置您的应用:

  1. 访问 Strava开发者设置
  2. 将以下URL添加到”授权回调域”:
    localhost
    注意:只需输入 localhost 而不是完整的 http://localhost:8000
  3. 保存设置

使用方法

  1. 安装依赖:

    yarn install
  2. 获取并处理授权码:

    yarn auth

    获取授权后,您会收到一个授权码。将其粘贴到命令行中。

  3. 获取骑行数据:

    yarn start
  4. 查看输出的JSON文件,文件名格式为:strava_data.json

解决认证问题

如果您遇到API相关错误,请尝试以下解决方案:

  1. 更新令牌

    yarn auth

    重新获取授权并更新令牌

  2. 检查API状态

    访问 Strava API状态 确认服务是否正常

常见问题解决

  1. “protocol mismatch”错误

    • 此问题已在最新版本中解决,使用了原生HTTPS模块发送请求
    • 确保在Strava开发者设置中添加了localhost作为授权回调域

  2. 无法获取活动数据

    • 确认您的账户中确实有骑行活动
    • 检查筛选条件是否正确(默认只获取”Ride”类型活动)

  3. API错误或限流

    • Strava API有使用限制(每15分钟100次,每天1000次)
    • 数据量大时,脚本已添加延迟以避免触发限流

许可证

本项目采用 Mozilla 公共许可证 2.0 版发布

Strava API v3:https://developers.strava.com/docs/reference

Strava Riding Api:https://github.com/achuanya/Strava-Riding-Api

  •  

EasyFill 发布了

2025年4月7日 20:38

就在刚刚 EasyFill 终于通过了 Chrome Web Store 的审核,正式发布了!

Chrome 应用商店:产品已成功发布
Chrome 应用商店:产品已成功发布

功能特性

  • 智能填充:DOM 加载完后,自动读取表单插入数据。
  • 无缝集成:与主流博客平台和评论系统兼容。
  • 数据加密:通过 AES-GCM 加密和解密功能,保护用户数据安全。
  • 现代化界面:基于 Material-UI 和 React 提供用户友好的界面。

安装

  1. 打开 Chrome Web Store
  2. 搜索 EasyFill
  3. 点击 添加到浏览器 按钮完成安装。
  4. 安装完成后,浏览器工具栏会显示 EasyFill 图标。

更新日志

查看 更新日志 了解最新功能和修复。

问题反馈

如果你在使用过程中遇到问题,请在我的博客留言

支持作者

感谢您对我的支持,本人非程序员,忙里抽闲,为爱发电。

如果您觉得 EasyFill 对您有帮助,可以通过以下方式支持我继续创作:

感谢您的支持!
感谢您的支持!

许可证

本项目基于 Mozilla Public License Version 2.0

Github 仓库:https://github.com/achuanya/EasyFill

✨ EasyFill 只为向那些在浮躁时代,依然坚守独立博客精神的你们致敬!

  •  

产品被拒

2025年4月3日 00:05

晚上下了班打开电脑刚坐下就看到了一封 Google 邮件,首先看到了发件人 “Chrome Web Store”,当时就心想提交审核一个多星期了,终于看到一点音信了。点开后,还没等我高兴,便看到了:

Chrome 应用商店:EasyFill 被拒通知
Chrome 应用商店:EasyFill 被拒通知

解决BUG

被拒的原因非常低级,声明了但未使用的 scripting 权限。

scripting 权限是 Manifest V3 中引入的一个重要权限,主要用于动态脚本执行chrome.scripting.executeScript()和动态样式注入chrome.scripting.insertCSS()

而在EasyFill中,使用的是静态声明:

content_scripts: [
  {
    matches: ["<all_urls>"],
    js: ["content-scripts/content.js"],
  },
];

删除scripting参数后,重新打包并再次向 Chrome Web Store 提交了扩展。

就这么一个小BUG,浪费了我一个星期的审核时间,太耽误事了,当时为了解决 Shadow DOM 才使用 scripting,直到现在这个问题也没有解决,希望下个版本可以解决问题

产品谍照:

EasyFill·我的信息
EasyFill·我的信息
  •  

注册 Chrome Web Store 开发者

2025年3月20日 00:03

年前曾尝试过 Chrome 扩展开发,《写一个Chrome表单自动化插件》,但是由于没有注册 Chrome Web Store 开发者,无法上传到 Chrome 应用商店。

注册 WildCard

Chrome 注册开发者需要五美元,由于我没有境外信用卡就一直卡在这,2022 年我在杭州办过一张中信的双币卡,年费很高,后来经济紧张时注销了,现在急着用外币还挺麻烦,折腾一圈,最终无脑选择了 WildCard,尽管网上对它负面评论铺天盖地。

WildCard 开卡账单
WildCard 开卡账单

[WildCard][p3] 开卡费用是 10.99 美元/年,实际付款 79.71 人民币,按照今天的市场汇率 7.23,实际多付了 0.24,而且这只是开卡费用,充值另算。

开卡后我充值了 10 美元,支付宝付款 75.07,到账金额 10 美元:

2.7775.07×100%3.69%\frac{2.77}{75.07} \times 100\% \approx 3.69\%

四个点我能接受(接不接受都要受着),这个开卡费不便宜,毕竟钱不是大风刮来的,所以注册时,我创建了两个号,推荐注册返现两美金…

注册 Chrome Web Store 开发者

Chrome Web Store 账单
Chrome Web Store 账单

注册账号就很容易了,Google 绑卡付钱就行。但是如果要销售发布就很麻烦:

个人交易者声明

  • 您需要提供一个手机号码以验证是您本人在操作
    • 您将通过手机接收代码
  • 用于证明是您本人的身份证件
  • 可接受的文件包括:
    • 驾照
    • 护照
    • 州身份证明
    • 绿卡
  • 您需要提供一份显示您的姓名和当前地址的文件
  • 可接受的文件包括:
    • 由政府签发的文件或带照片的身份证件
    • 公共事业缴费单或话费账单(日期在过去 60 天内)
    • 银行对账单(日期在过去 60 天内)
    • 租赁合同或抵押贷款合同

因为 Google 已退出中国市场,不支持交易。而我是美国 Visa 卡,面对这样的要求不容易做到。

日后再说吧,往后这段时间,我打算把博客评论表单自动填充插件重构一下,然后上架 Chrome 应用商店。

  •  

空腹骑行75公里

2025年3月12日 15:36

周六

最近郑州天气突然转冷,骑行频率也降了下来,周六正好赶上休息,实在是憋坏了!今天不管刮风下雨,必须出去骑一趟

原计划直接奔开封,结果路过龙湖就停了下来。好久没来了,上次来还是鹅毛大雪天,如今雪没了,只剩下鹅

倒挂白鹅
倒挂白鹅
Loading EXIF data...

周六的公园人满为患,没法骑。我推着车沿湖边缓行,遥望着远处炸水的不知名鱼,不由自主的想蹲下摸摸湖水,真的很想钓鱼,自到郑州以来,我连最爱的路亚竿都没摸过

龙湖·北岸
龙湖·北岸
Loading EXIF data...

此时正值中午,小孩在沙滩上牵着风筝奔跑,大人排队买小吃,顿时勾起了不少儿时回忆,我也好想光着脚奔跑在沙滩上…

龙湖·人工沙滩
龙湖·人工沙滩
Loading EXIF data...

在龙湖公园出来后,我关掉了导航线瞎跑,根本不认识路,不知道自己在哪,扫大街呗

STRAVA 74.6km  爬升313m  时间4h 19m
STRAVA 74.6km 爬升313m 时间4h 19m

话说现在骑车很少拍照,不是不爱拍,而是懒得下车,即使趴到腰酸,感觉腰快要断了,也不想停下来

周日

拍这张照片时,已经快饿晕了,周六晚上吃得少,周日早上又空腹出门,体力消耗得厉害…

周日早晨睡到自然醒,一看表,我整个人都快立正了,居然八点半了。着急忙慌洗漱后,脱下内衣裤直接换上骑行服,背上包,拿了五块巧克力出发了。因为周一要上班,所以今天必须放纵一下,出发前大致算了算,来回返程再加上逛街的时间,早饭根本来不及吃…哎…

大约骑了25公里,在中石化买了瓶宝矿力水特。又骑行了二三十公里到了贾鲁河桥,饿的没劲,更别说爬坡了,挂上小盘,我慢悠悠到了桥中间,休息了几分钟,把五块巧克力补给全吃了

郑州·贾鲁河桥
郑州·贾鲁河桥
Loading EXIF data...

就这样空腹骑到了开封郊区,此时的里程已经来到了 75.38公里,用时3小时23分钟

到达开封后,心里那股坚持的信念瞬间消失了,又渴又饿,高德帮我找了最近一家名为三不炒(开封总店)的小炒店,我把车子靠着门店随便一放,就去买葡萄糖了

就去买葡萄糖了
就去买葡萄糖了
Loading EXIF data...

买完水出来发现要排队,人还不少,我是真的饿得快走不动了,但还是懒得换地方,抱着水坐在外面等了半小时。饿得快虚脱了,感觉此时此刻,就算把馒头挂我脖子上都能饿死

我前面排了八个人
我前面排了八个人
Loading EXIF data...

排队加吃饭花了一个半小时,吃得太撑,骑上车都趴不下去,推着车穿过老巷子走到了湖边

对面就是清明上河园
对面就是清明上河园
Loading EXIF data...
御河桥洞下
御河桥洞下
Loading EXIF data...
御河桥洞下
御河桥洞下
Loading EXIF data...
正在乐钓的五星开封好市民
正在乐钓的五星开封好市民
Loading EXIF data...
STRAVA 164.1km  爬升417m  时间8h 5m
STRAVA 164.1km 爬升417m 时间8h 5m

回到家已经八点半了,这条郑开大道路线真心推荐,毕竟二刷了,虽然沿途风景平平,但对于郑州来说,已经是顶级骑行路线了,一个人骑行在郑开大道,握着下把位,不用担心刹车,不用担心前方有没有人,听着歌,摇着车,也不枉来郑州走一遭

  •  

利用 Go + COS + GitHub 重构 RSS 爬虫

2025年3月12日 03:26

之前我写过一篇《利用Go+Github Actions写个定时RSS爬虫》来实现这一目的,主要是用 GitHub Actions + Go 进行持续的 RSS 拉取,再把结果上传到 GitHub Pages 站点

但是遇到一些网络延迟、TLS 超时问题,导致订阅页面访问速度奇慢,抓取的数据也不完整,后来时断时续半个月重构了代码,进一步增强了并发和容错机制

在此感谢 GPT o1 给予的帮助,我已经脱离老本行很多年了,重构的压力真不小,有空就利用下班的时间进行调试,在今天凌晨 03:00 我终于写完了

1. 为什么要重构

旧版本主要基于 GitHub Actions 的定时触发,抓取完后把结果存放进 _data/rss_data.json 然后 Jekyll 就可以直接引用这个文件来展示订阅,但是这个方案有诸多不足:

  1. 网络不稳定导致的抓取失败

    由于原先的重试机制不够完善,GitHub Actions 在国外,RSS 站点大多在国内,一旦连接超时就挂,一些 RSS 无法成功抓取

  2. 单线程串行,速度偏慢

    旧版本一次只能串行抓取 RSS,效率低,数量稍多就拉长整体执行时间,再加上外网到内地的延时,更显迟缓

  3. 日志不够完善

    出错时写到的日志文件只有大概的错误描述,无法区分是解析失败、头像链接失效还是RSS本身问题,排查不便

  4. 访问速度影响大

    这是主要的重构原因!在旧版本里,抓取后的 JSON 数据是要存储到 Github 仓库的,虽然有 CDN 加持,但 GitHub Pages 的定时任务会引起连锁反应,当新内容刷新时容易出现访问延迟,极端情况下网页都挂了

    重构后,在此基础上进行了大幅重构,引入了并发抓取 + 指数退避重试 + GitHub/COS 双端存储的能力,抓取稳定性和页面访问速度都得到显著提升

2. 主要思路

2.1 整体流程

先看个简单的流程图

        +--------------------------+
        | 1. 读取RSS列表(双端可选)  |
        +------------+-------------+
                     |
                     v
           +---------------------+
           | 2. 并发抓取RSS,限流   |
           |  (max concurrency)  |
           +-------+-------------+
                   |
                   v
        +------------------------------+
        | 3. 指数退避算法 (重试解析失败)  |
        +------------------------------+
                   |
                   v
           +-------------------+
           | 4. 结果整合排序    |
           +--------+----------+
                    |
                    v
        +-------------------------+
        | 5. 上传 RSS (双端可选)   |
        +-------------------------+
                    |
                    v
           +--------------------+
           | 6. 写日志到GitHub   |
           +--------------------+
  1. 并发抓取 + 限流
    通过 Go 的 goroutine 并发抓取 RSS,同时用一个 channel 来限制最大并发数

  2. 指数退避重试
    每个 RSS 如果第一次抓取失败,则会间隔几秒后再次重试,且间隔呈指数级递增(1s -> 2s -> 4s),最多重试三次,极大提高成功率

  3. 灵活存储
    RSS_SOURCE: 可以决定从 COS 读取一个远程 txt 文件(里面存放 RSS 列表),或直接从 GitHub 的 data/rss.txt 读取
    SAVE_TARGET: 可以把抓取结果上传到 GitHub,或者传到腾讯云 COS

  4. 日志自动清理
    每次成功写入日志后,会检查 logs/ 目录下的日志文件,若超过 7 天就自动删除,避免日志越积越多

2.2 指数退避

上一次写指数退避,还是在养老院写PHP的时候,时过境迁啊,这段算法我调试了很久,其实不难,也就是说失败一次,就等待更长的时间再重试,配置如下:

  • 最大重试次数: 3
  • 初始等待: 1秒
  • 等待倍数: 2.0

也就是说失败一次就加倍等待,下次若依然失败就再加倍,如果三次都失败则放弃处理

// fetchAllFeeds 并发抓取所有RSS链接,返回抓取结果及统计信息
//
// Description:
//
//   该函数读取传入的所有RSS链接,使用10路并发进行抓取
//   在抓取过程中对解析失败、内容为空等情况进行统计
//   若抓取的RSS头像缺失或无法访问,将替换为默认头像
//
// Parameters:
//   - ctx           : 上下文,用于控制网络请求的取消或超时
//   - rssLinks      : RSS链接的字符串切片,每个链接代表一个RSS源
//   - defaultAvatar : 备用头像地址,在抓取头像失败或不可用时使用
//
// Returns:
//   - []feedResult         : 每个RSS链接抓取的结果(包含成功的Feed及其文章或错误信息)
//   - map[string][]string  : 各种问题的统计记录(解析失败、内容为空、头像缺失、头像不可用)
func fetchAllFeeds(ctx context.Context, rssLinks []string, defaultAvatar string) ([]feedResult, map[string][]string) {
	// 设置最大并发量,以信道(channel)信号量的方式控制
	maxGoroutines := 10
	sem := make(chan struct{}, maxGoroutines)

	// 等待组,用来等待所有goroutine执行完毕
	var wg sync.WaitGroup

	resultChan := make(chan feedResult, len(rssLinks)) // 用于收集抓取结果的通道
	fp := gofeed.NewParser()                           // RSS解析器实例

	// 遍历所有RSS链接,为每个RSS链接开启一个goroutine进行抓取
	for _, link := range rssLinks {
		link = strings.TrimSpace(link)
		if link == "" {
			continue
		}
		wg.Add(1)         // 每开启一个goroutine,对应Add(1)
		sem <- struct{}{} // 向sem发送一个空结构体,表示占用了一个并发槽

		// 开启协程
		go func(rssLink string) {
			defer wg.Done()          // 协程结束时Done
			defer func() { <-sem }() // 函数结束时释放一个并发槽

			var fr feedResult
			fr.FeedLink = rssLink

			// 抓取RSS Feed, 无法解析时,使用指数退避算法进行重试, 有3次重试, 初始1s, 倍数2.0
			feed, err := fetchFeedWithRetry(rssLink, fp, 3, 1*time.Second, 2.0)
			if err != nil {
				fr.Err = wrapErrorf(err, "解析RSS失败: %s", rssLink)
				resultChan <- fr
				return
			}

			if feed == nil || len(feed.Items) == 0 {
				fr.Err = wrapErrorf(fmt.Errorf("该订阅没有内容"), "RSS为空: %s", rssLink)
				resultChan <- fr
				return
			}

			// 获取RSS的头像信息(若RSS自带头像则用RSS的,否则尝试从博客主页解析)
			avatarURL := getFeedAvatarURL(feed)
			fr.Article = &Article{
				BlogName: feed.Title,
			}

			// 检查头像可用性
			if avatarURL == "" {
				// 若头像链接为空,则标记为空字符串
				fr.Article.Avatar = ""
			} else {
				ok, _ := checkURLAvailable(avatarURL)
				if !ok {
					fr.Article.Avatar = "BROKEN" // 无法访问,暂记为BROKEN
				} else {
					fr.Article.Avatar = avatarURL // 正常可访问则记录真实URL
				}
			}

			// 只取最新一篇文章作为结果
			latest := feed.Items[0]
			fr.Article.Title = latest.Title
			fr.Article.Link = latest.Link

			// 解析发布时间,如果 RSS 解析器本身给出了 PublishedParsed 直接用,否则尝试解析 Published 字符串
			pubTime := time.Now()
			if latest.PublishedParsed != nil {
				pubTime = *latest.PublishedParsed
			} else if latest.Published != "" {
				if t, e := parseTime(latest.Published); e == nil {
					pubTime = t
				}
			}
			fr.ParsedTime = pubTime
			fr.Article.Published = pubTime.Format("02 Jan 2006")

			resultChan <- fr
		}(link)
	}

	// 开启一个goroutine等待所有抓取任务结束后,关闭resultChan
	go func() {
		wg.Wait()
		close(resultChan)
	}()

	// 用于统计各种问题
	problems := map[string][]string{
		"parseFails":   {}, // 解析 RSS 失败
		"feedEmpties":  {}, // 内容 RSS 为空
		"noAvatar":     {}, // 头像地址为空
		"brokenAvatar": {}, // 头像无法访问
	}
	// 收集抓取结果
	var results []feedResult

	for r := range resultChan {
		if r.Err != nil {
			errStr := r.Err.Error()
			switch {
			case strings.Contains(errStr, "解析RSS失败"):
				problems["parseFails"] = append(problems["parseFails"], r.FeedLink)
			case strings.Contains(errStr, "RSS为空"):
				problems["feedEmpties"] = append(problems["feedEmpties"], r.FeedLink)
			}
			results = append(results, r)
			continue
		}

		// 对于成功抓取的Feed,如果头像为空或不可用则使用默认头像
		if r.Article.Avatar == "" {
			problems["noAvatar"] = append(problems["noAvatar"], r.FeedLink)
			r.Article.Avatar = defaultAvatar
		} else if r.Article.Avatar == "BROKEN" {
			problems["brokenAvatar"] = append(problems["brokenAvatar"], r.FeedLink)
			r.Article.Avatar = defaultAvatar
		}
		results = append(results, r)
	}
	return results, problems
}

2.3 并发抓取 + 限流

为避免一下子开几十上百个协程导致阻塞,可以配合一个带缓存大小的 channel

maxGoroutines := 10
sem := make(chan struct{}, maxGoroutines)

for _, rssLink := range rssLinks {
    // 启动 goroutine 前先写入一个空 struct
    sem <- struct{}{}
    go func(link string) {
        // goroutine 执行结束后释放 <-sem
        defer func() { <-sem }()
        fetchFeedWithRetry(link, parser, 3, 1*time.Second, 2.0)
        // ...
    }(rssLink)
}

3. 对比旧版本的改进

  1. 容错率显著提升

    遇到网络抖动、超时等问题,能以10路并发限制式自动重试,很少出现直接拿不到数据

  2. 抓取速度更快

    以 10 路并发为例,对于数量多的 RSS,速度提升明显

  3. 日志分类更细

    分清哪条 RSS 是解析失败,哪条头像挂了,哪条本身有问题,后续维护比只给个403 Forbidden方便太多

  4. 支持 COS

    可将最终 data.json 放在 COS 上进行 CDN 加速;也能继续放在 GitHub,视自己需求而定

  5. 自动清理过期日志

    每次抓取后检查 logs/ 目录下 7 天之前的日志并删除,不用手工清理了

4. Go 生成的 JSON 和日志长啥样

4.1 RSS

抓取到的文章信息会按时间降序排列,示例:

{
  "items": [
    {
      "blog_name": "obaby@mars",
      "title": "品味江南(三)–虎丘塔 东方明珠",
      "published": "10 Mar 2025",
      "link": "https://oba.by/2025/03/19714",
      "avatar": "https://oba.by/wp-content/uploads/2020/09/icon-500-100x100.png"
    },
    {
      "blog_name": "风雪之隅",
      "title": "PHP8.0的Named Parameter",
      "published": "10 May 2022",
      "link": "https://www.laruence.com/2022/05/10/6192.html",
      "avatar": "https://www.laruence.com/logo.jpg"
    }
  ],
  "updated": "2025年03月11日 07:15:57"
}

4.2 日志

程序每次运行完毕后,把抓取统计和问题列表写到 GitHub 仓库 logs/YYYY-MM-DD.log:

[2025-03-11 07:15:57] 本次订阅抓取结果统计:
[2025-03-11 07:15:57] 共 25 条RSS, 成功抓取 24 条.
[2025-03-11 07:15:57] ✘ 有 1 条订阅解析失败:
[2025-03-11 07:15:57] - https://tcxx.info/feed
[2025-03-11 07:15:57] ✘ 有 1 条订阅头像无法访问, 已使用默认头像:
[2025-03-11 07:15:57] - https://www.loyhome.com/feed

5. 照葫芦画瓢

如果你也想玩玩 LhasaRSS

  1. 准备一份 RSS 列表(TXT):

    格式:每行一个 URL<br/>
    如果 RSS_SOURCE = GITHUB,则可以放在项目中的 data/rss.txt<br/>
    如果 RSS_SOURCE = COS,就把它上传到某个 https://xxx.cos.ap-xxx.myqcloud.com/rss.txt



  2. 配置好环境变量:

    默认所有数据保存到 Github,所以 COS API 环境变量不是必要的

    {% raw %} yml env: TOKEN: ${{ secrets.TOKEN }} # GitHub Token NAME: ${{ secrets.NAME }} # GitHub 用户名 REPOSITORY: ${{ secrets.REPOSITORY }} # GitHub 仓库名 TENCENT_CLOUD_SECRET_ID: ${{ secrets.TENCENT_CLOUD_SECRET_ID }} # 腾讯云 COS SecretID TENCENT_CLOUD_SECRET_KEY: ${{ secrets.TENCENT_CLOUD_SECRET_KEY }} # 腾讯云 COS SecretKey RSS: ${{ secrets.RSS }} # RSS 列表文件 DATA: ${{ secrets.DATA }} # 抓取后的数据文件 DEFAULT_AVATAR: ${{ secrets.DEFAULT_AVATAR }} # 默认头像 URL RSS_SOURCE ${{ secrets.RSS_SOURCE }} # 可选参数 GITHUB or COS SAVE_TARGET ${{ secrets.SAVE_TARGET }} # 可选参数 GITHUB or COS {% endraw %}

  3. 部署并运行

    只需 go run . 或在 GitHub Actions workflow_dispatch 触发 运行结束后,就会在 data 文件夹更新 data.json,日志则写进 GitHub logs/ 目录,并且自动清理旧日志

注:如果你依旧想完全托管在 COS 上,需要把 RSS_SOURCE 和 SAVE_TARGET 都写为 COS,然后使用 GitHub Actions 去调度

相关文档

  •  

骑行开封

2025年2月20日 16:14

我对于开封的印象,还停留在开封府尹·包拯。处于好奇和无处可去的想法,周六早上吃完饭,说走就走了

到达开封鼓楼
到达开封鼓楼
Loading EXIF data...

这里就到达开封了开封·鼓楼,郑开大道的路上很轻松,室外温度17°+,小风微微的吹着,不冷不热好不痛快

Strava记录
Strava记录

在郑开大道单飞的过程中偶遇骑友,王哥是开封本地的,骑行的路上跟我聊开封哪里好玩,哪里最具性价比,把我领进开封鼓楼后,又带我在景区逛了一圈带我认路,在此感谢大哥

与王哥的合照
与王哥的合照
Loading EXIF data...

早饭吃的比较仓促,真的很饿,在书店街附近买了些吃的

干饭
干饭
Loading EXIF data...

本来是想在开封呆一天,晚上去清明上河园玩,想到公司有事就提前回去了,怕耽误明天的行程

鼓楼合影
鼓楼合影
Loading EXIF data...
郑开大道
郑开大道
Loading EXIF data...

这次跨市骑行急了一些,时间太紧张了!再过几天休息,我想回一次家,骑行约200KM

Strava记录
Strava记录
  •  

Blog Function Update 2025 (2)

2025年2月6日 13:03

Update details

  • 移除红灯笼
  • 新增 sitemap.xmlsitemap.txt,自动生成,不再手动更新!

之前我一直使用 xml-sitemaps 手动生成sitemap.xml,但每当 URL 新增或变更都需要手动提交。实在麻烦!所以,今日用 Liquid 实现自动生成,一劳永逸

sitemap.xml 优化策略

  • 首页优先级最高 (1.0),其他页面次之 (0.8)
  • 新文章优先级高(30 天内 0.9,半年内 0.8,一年内 0.6),让新内容更容易被搜索引擎收录
  • 旧文章优先级降低(1 年以上 0.4,2 年以上 0.2),减少搜索引擎对老旧内容的爬取
  • 动态调整 changefreq,确保新内容频繁爬取,而老文章爬取频率降低
---
layout: null
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  {% assign now = site.time | date: "%s" | plus: 0 %}
  
  {% for page in site.pages %}
    {% if page.url == "/" %}
    <!-- 首页优先级最高 -->
      {% assign page_priority = "1.0" %}
    {% else %}
      {% assign page_priority = "0.8" %}
    {% endif %}
    
    <url>
      <loc>{{ site.url }}{{ page.url | replace:'index.html','' }}</loc>
      <lastmod>{{ site.time | date_to_xmlschema }}</lastmod>
      <changefreq>weekly</changefreq>
      <priority>{{ page_priority }}</priority>
    </url>
  {% endfor %}
  
  <!-- 根据发布时间动态调整 priority 和 changefreq -->
  {% for post in site.posts %}
    {% assign post_time = post.date | date: "%s" | plus: 0 %}
    {% assign diff = now | minus: post_time %}
    {% assign days_old = diff | divided_by: 86400 %}
    
    {% if days_old < 30 %}
      {% assign priority = "0.9" %}
      {% assign changefreq = "daily" %}
    {% elsif days_old < 180 %}
      {% assign priority = "0.8" %}
      {% assign changefreq = "weekly" %}
    {% elsif days_old < 365 %}
      {% assign priority = "0.6" %}
      {% assign changefreq = "monthly" %}
    {% elsif days_old < 730 %}
      {% assign priority = "0.4" %}
      {% assign changefreq = "yearly" %}
    {% else %}
      {% assign priority = "0.2" %}
      {% assign changefreq = "never" %}
    {% endif %}
    
    <url>
      <loc>{{ site.url }}{{ post.url }}</loc>
      <lastmod>{{ post.date | date_to_xmlschema }}</lastmod>
      <changefreq>{{ changefreq }}</changefreq>
      <priority>{{ priority }}</priority>
    </url>
  {% endfor %}
</urlset>

sitemap.txt 兼容旧版爬虫

sitemap.txt 适用于不支持 XML 的搜索引擎(如某些旧版爬虫)

---
layout: null
permalink: /sitemap.txt
---
{% for page in site.pages %}
{{ site.url }}{{ page.url | replace:'index.html','' }}
{% endfor %}

{% for post in site.posts %}
{{ site.url }}{{ post.url }}
{% endfor %}

在 robots.txt 里声明 Sitemap

确保搜索引擎能找到 Sitemap,需要在 robots.txt 文件中声明 sitemap.xmlsitemap.txt

User-agent: *
Allow: /

User-agent: MJ12bot
Disallow: /
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: dotbot
Disallow: /

Sitemap: https://lhasa.icu/sitemap.xml
Sitemap: https://lhasa.icu/sitemap.txt
  •  

Blog Function Update 2025 (1)

2025年1月26日 00:10

由于郑州最近的雨夹雪天气,已经一周没有骑行了,实在憋得不行,给自己找点事做,今天中午下班时更新了一下博客

Update details

  • 修复了柱形图显示错位
  • 移除了骑行页面的活动天数
  • 新增了全年骑行总时长、全年骑行总公里数
  • 柱形图的宽度不再由骑行时长来计算,而是由骑行公里数来计算显示
  • 新增春节快乐红灯笼(移动端不支持)
  • 移除 node-sass 包,由 sass 代替

Fix Bugs:柱形图显示错位

当前的柱形图仅为有骑行数据的周生成柱形图,导致柱形图与日历中的周对齐错位,所以即某周没有骑行数据时,柱形图也要生成一根柱子

function generateBarChart() {
    const barChartElement = document.getElementById('barChart');
    // 清空柱形图内容
    barChartElement.innerHTML = '';

    const today = getChinaTime();
    const startDate = getStartDate(today, 21);

    // 创建所有周的时间范围
    const weeklyData = {};
    let currentWeekStart = new Date(startDate);
    currentWeekStart.setUTCHours(0, 0, 0, 0);

    // 按周计算未来 4 周的日期范围
    for (let i = 0; i < 4; i++) {
        const weekStart = new Date(currentWeekStart);
        const weekEnd = new Date(weekStart);
        // 一周结束日期为开始日期 +6 天
        weekEnd.setUTCDate(weekStart.getUTCDate() + 6);
        const weekKey = `${weekStart.toISOString().split('T')[0]} - ${weekEnd.toISOString().split('T')[0]}`;

        // 初始化每周骑行数据为 0
        weeklyData[weekKey] = 0;
        // 移动到下一周
        currentWeekStart.setUTCDate(currentWeekStart.getUTCDate() + 7);
    }

    // 累加每周的骑行距离
    processedActivities.forEach(activity => {
        const activityDate = new Date(activity.activity_time);
        // 活动所在周的开始日期
        const weekStart = getWeekStartDate(activityDate);
        const weekEnd = new Date(weekStart);
        weekEnd.setUTCDate(weekStart.getUTCDate() + 6);

        const weekKey = `${weekStart.toISOString().split('T')[0]} - ${weekEnd.toISOString().split('T')[0]}`;
        if (weeklyData[weekKey] !== undefined) {
            weeklyData[weekKey] += parseFloat(activity.riding_distance);
        }
    });

    // 获取最大骑行距离(用于柱形图比例)
    const maxDistance = Math.max(...Object.values(weeklyData), 0);

    // 创建并显示每周的柱形图
    Object.keys(weeklyData).forEach(week => {
        // 当前周的骑行距离
        const distance = weeklyData[week];
        const barContainer = document.createElement('div');
        barContainer.className = 'bar-container';

        const bar = document.createElement('div');
        bar.className = 'bar';

        // 计算柱形图的宽度
        const width = maxDistance > 0 ? (distance / maxDistance) * 190 : 0;
        bar.style.setProperty('--bar-width', `${width}px`);

        const distanceText = document.createElement('div');
        distanceText.className = 'cycling-kilometer';
        distanceText.innerText = '0 km';

        const messageBox = createMessageBox();
        const clickMessageBox = createMessageBox();

        barContainer.style.position = 'relative';
        bar.appendChild(distanceText);
        barContainer.appendChild(bar);
        barContainer.appendChild(messageBox);
        barContainer.appendChild(clickMessageBox);
        barChartElement.appendChild(barContainer);

        // 动画效果:逐渐显示柱形图宽度
        bar.style.width = '0';
        bar.offsetHeight;
        bar.style.transition = 'width 1s ease-out';
        bar.style.width = `${width}px`;

        distanceText.style.opacity = '1';
        // 动态更新柱形图的数值
        animateText(distanceText, 0, distance, 1000, true);
        setupBarInteractions(bar, messageBox, clickMessageBox, distance);
    });
}

// 动态文本显示
function animateText(element, startValue, endValue, duration, isDistance = false) {
    const startTime = performance.now();
    function update() {
        const elapsed = performance.now() - startTime;
        const progress = Math.min(elapsed / duration, 1);
        const currentValue = (progress * endValue).toFixed(2);
        element.innerText = isDistance ? `${currentValue} km` : `${currentValue}h`;
        if (progress < 1) {
            requestAnimationFrame(update);
        } else {
            element.innerText = isDistance ? `${endValue.toFixed(2)} km` : `${endValue.toFixed(2)}h`;
        }
    }
    update();
}

New:全年骑行总时长、全年骑行总公里数

// 显示总活动数和总公里数
function displayTotalActivities(activities) {
    // 全年骑行时长
    const ridingTimeThisYear = document.getElementById('totalCount');
    // 全年骑行公里数
    const milesRiddenThisYear = document.getElementById('milesRiddenThisYear');
    // 动态年标题《2025 骑行总时长》
    const totalTitleElement = document.getElementById('totalTitle');

    if (!ridingTimeThisYear || !milesRiddenThisYear || !totalTitleElement) return;

    const ridingTimeThisYearValue = ridingTimeThisYear.querySelector('#ridingTimeThisYearValue');
    const milesRiddenThisYearValue = milesRiddenThisYear.querySelector('#milesRiddenThisYearValue');

    const totalCountSpinner = ridingTimeThisYear.querySelector('.loading-spinner');
    const milesRiddenThisYearSpinner = milesRiddenThisYear.querySelector('.loading-spinner');

    totalCountSpinner.classList.add('active');
    milesRiddenThisYearSpinner.classList.add('active');

    const currentYear = new Date().getFullYear();
    totalTitleElement.textContent = `${currentYear} 骑行总时长`;

    // 筛选全年活动数据
    const filteredActivities = activities.filter(activity => {
        const activityYear = new Date(activity.activity_time).getFullYear();
        return activityYear === currentYear;
    });

    // 计算全年活动时间的总和(单位:小时)
    const totalMovingTime = filteredActivities.reduce((total, activity) => {
        return total + parseFloat(activity.moving_time) || 0;
    }, 0);

    // 计算全年总公里数
    const totalKilometers = calculateTotalKilometers(filteredActivities);

    // 动画效果
    animateCount(ridingTimeThisYearValue, totalMovingTime, 1000, 50, false);
    animateCount(milesRiddenThisYearValue, totalKilometers, 1000, 50, true);

    setTimeout(() => {
        console.log(totalKilometers.toFixed(2));
        ridingTimeThisYearValue.textContent = `${totalMovingTime.toFixed(2)} h`;
        milesRiddenThisYearValue.textContent = `${totalKilometers.toFixed(2)} km`;
        totalCountSpinner.classList.remove('active');
        milesRiddenThisYearSpinner.classList.remove('active');
    }, 1000);
}

// 加载数据并生成日历
(async function() {
    const today = getChinaTime();
    const startDate = getStartDate(today, 21);

    const activities = await loadActivityData();
    // 显示4周的日历
    generateCalendar(activities, startDate, 4);

    // 显示全年骑行时长和公里数
    displayTotalActivities(activities);
})();

New:春节快乐红灯笼

两年前在冲浪时下载的,已经是第二次用了:

// default.html
include lantern.html

// main.scss
@use 'lantern'

Fix Bugs:移除 node-sass 包

node-sass 是基于 LibSass 库构建的,而 LibSass 从 2019 年就停止了更新。所以,Sass 团队放弃了这个项目,重构了 sass(Dart 编写)

sass 相对 node-sass 的优点

  1. 原生支持 Dart

    sass 是由 Dart 编写,它不再依赖 C++ 编译器,安装和构建速度更快

  2. 不再依赖编译

    node-sass 需要本地编译,会遇到编译问题,尤其是 Windows 系统上。而 sass 是纯 JavaScript 实现,跨平台时不会有编译问题

{
    "devDependencies": {
        "sass": "^1.83.4",
    }
}
Show
Show
Loading EXIF data...
  •  

骑行 桃花峪、黄河文化公园

2025年1月19日 03:57

周末假期被骑行占有,那种满足是一切都比不过

刚吃完饭准备出发
刚吃完饭准备出发
Loading EXIF data...

公园内设有多条越野骑行路线,坡度和难度多样,我这光胎的公路车进去根本生存不了。总的来说是一个不错的地方,集挑战、风景与文化于一体,是体验黄河沿岸自然与文化魅力的不二之选

到达桃花峪-炎黄越野公园
到达桃花峪-炎黄越野公园
Loading EXIF data...

这条路很窄,一次只能过一辆汽车,有上有下,而且路面陡峭,路非常烂很危险,目测坡度15°

桃花峪最陡最烂的坡路
桃花峪最陡最烂的坡路
Loading EXIF data...

去窗口交费取票,姓名都不需要留

到达黄河文化公园 检票口
到达黄河文化公园 检票口
Loading EXIF data...

黄河流域地势平坦,水资源丰富,土壤肥沃,非常适宜农业发展。古人利用黄河水系灌溉农田,使中原成为中国古代经济、人口最为集中的地区,为中华民族的形成和壮大提供了物质基础,所以把黄河称呼为母亲河、民族摇篮也不足为过

民族摇篮牌坊
民族摇篮牌坊
Loading EXIF data...
炎黄二帝塑像
炎黄二帝塑像
Loading EXIF data...

日晷是一种古老的计时器,利用太阳的影子来确定时间。它的起源可以追溯到公元前4000年的古埃及,后来广泛传播到世界各地。在中国,日晷被称为“圭表”,早在周朝时期便已使用,用于确定二十四节气和天文观测

日晷
日晷
Loading EXIF data...
遇到一位好大哥帮我拍的照片
遇到一位好大哥帮我拍的照片
Loading EXIF data...
鼎
Loading EXIF data...
广场的某个角落
广场的某个角落
Loading EXIF data...

终于到达了心心念念的黄河岸边,此处跨越黄河的建筑就是大名鼎鼎的京广铁路,它是一条纵贯中国南北的交通大动脉,途经黄河流域、长江流域和南岭山脉。旁边的郑太高铁则是中国“八纵八横”高速铁路网的重要主通道之一,大大缩短了晋东南、蒙西等地与中东部地区的时空距离

到达黄河岸边
到达黄河岸边
Loading EXIF data...

湖水清澈见底,微风拂过时,水面波光粼粼,宛如点点繁星洒在湖面上,因此得名“星海湖”

星海湖 原相机没有P图 水质水太好了
星海湖 原相机没有P图 水质水太好了
Loading EXIF data...
黄河缆车门口 鸿运当头
黄河缆车门口 鸿运当头
Loading EXIF data...

这辆车是我穿着锁鞋背上山的,如果没有体验过锁鞋的朋友,可以想象一下穿高跟鞋走山路,不过这里的高跟得反过来 —— 脚掌部分垫高,脚跟贴地

五龙峰 其中一个山峰顶
五龙峰 其中一个山峰顶
Loading EXIF data...
五龙峰 黄河少年雕像
五龙峰 黄河少年雕像
Loading EXIF data...

中午时在超市吃了一碗凉皮,和老板沟通一番后把车子暂存于此,爬楼梯的路上三步一回头

毛主席视察黄河纪念地下方的超市
毛主席视察黄河纪念地下方的超市
Loading EXIF data...

强烈推荐“浮天阁”,登顶后俯瞰整个黄河流域,东西南北尽收眼底

浮天阁
浮天阁
Loading EXIF data...

倒泻银河事有无,掀天浊浪只须臾

人间更有风涛险,翻说黄河是畏途

东
Loading EXIF data...
西
西
Loading EXIF data...
南
Loading EXIF data...
北
Loading EXIF data...
东南
东南
Loading EXIF data...

对于喜欢骑行、越野和民族文化的朋友来说,桃花峪和黄河文化公园很适合,两个地方紧挨着,要场地有场地,要难度有难度,完事后紧挨着黄河散散步,傍晚时登顶浮天阁看日落,想想都美哦

结束
结束
  •  

环行郑州·三环

2025年1月7日 20:39

上次我打算一天骑完郑州的三四环线,预计160km,没想到下午摔的手梆硬,昨天六号上午一雪前耻了

郑州龙湖公园
郑州龙湖公园
Loading EXIF data...

路过龙湖公园被吸引了,多了10km+里程

公园放养的白天鹅
公园放养的白天鹅
Loading EXIF data...

这里的天鹅挺不少,小百只了,小鸭子不计其数。岸边挤满了摄影发烧友,到处是大炮

人工沙滩
人工沙滩
Loading EXIF data...
郑州龙湖公园
郑州龙湖公园
Loading EXIF data...
骑行郑州三环 结束
骑行郑州三环 结束

到这里,骑行三环算是告一段落了,它比四环线少40km,难度低了几个档,三环红路灯非常多,而骑行四环时刹车都很少碰。三环道路也比较好,不像四环压根就没有路,什么路都没有。只能走菜地、翻墙、最后走高速绿化带,我提着车爬上机场高架,是用手爬,不是骑车爬…最后为了安全从高架上下来,走下水道过去了…

骑行 金融岛
骑行 金融岛

上午去了一次龙湖公园,但是中间的金融岛一直找不到地方上去,下午又去了一次,逛了 Specialized、colnago

骑行 中原福塔
骑行 中原福塔
Loading EXIF data...

沪上有明珠,中原有福塔

回家
回家

下午回了家,借同事电脑把博客整了下,前俩月博客处于失联状态,证书都过期了,开往都把我踢了,现在OK了,只是骑行页面每天要手动每天上传一下,Strava接口暂时没有精力搞了

  •  

环行郑州·四环

2025年1月7日 19:10
骑行西流湖
骑行西流湖
Loading EXIF data...
骑行西流湖
骑行西流湖
Loading EXIF data...
骑行西流湖
骑行西流湖
Loading EXIF data...
骑行西流湖
骑行西流湖
Loading EXIF data...
骑行西流湖
骑行西流湖
Loading EXIF data...

探金水河,溪流钓的天堂,台钓的地狱

骑行桃花峪
骑行桃花峪
Loading EXIF data...

郑州的面积比我预计小不少 明天准备绕圈 ,骑行四环(97km)和三环(57km) 在地图上画两个正方形,好事成双

骑行桃花峪
骑行桃花峪
Loading EXIF data...
骑行桃花峪
骑行桃花峪
Loading EXIF data...
骑行桃花峪
骑行桃花峪
Loading EXIF data...
骑行桃花峪
骑行桃花峪
Loading EXIF data...
骑行桃花峪 结束
骑行桃花峪 结束
这里是:郑州 四环 非机动车道
这里是:郑州 四环 非机动车道
Loading EXIF data...

14点饭后出发骑三环,没一会儿出事了,太遗憾了 总而言之,郑州的西北南四环路况一般,且由北四环入东四环后非常拉跨,你除了走机场高架外没有选择,当时为了安全避免走机场高架,过草地和下水道硬控了我大半个小时

骑行郑州四环
骑行郑州四环
Loading EXIF data...
骑行郑州四环
骑行郑州四环
Loading EXIF data...
骑行玩手机的下场
骑行玩手机的下场
Loading EXIF data...
骑行郑州四环 结束
骑行郑州四环 结束
  •  

十月份看完的第一本书

2024年10月3日 20:31

为这本书,我近两天几乎废寝忘食,心中感慨颇多,最深刻的收获便是对人性与形式的深刻理解。许多事情在当时看来似乎是理所当然,但如今回首,才发现那时的自己是多么稚嫩,甚至有些可笑

微信读书 · 沧浪之水
微信读书 · 沧浪之水

这是我第一次尝试电子书,有声加文字的双重体验感觉不错,美中不足的是,机械式的发音让情感显得苍白,在这个时代,做个拟人化的语音合成也不难啊,腾讯读书这块业务还是小众,资源太少了,很多我想看的书都找不到,涉及敏感话题的搜都搜不到,哎

  •  

一场说走就走的骑行

2024年9月26日 20:02

凌晨四点半,忽然醒了,辗转反侧,睡意全无,脑海中第一个蹦出的念头竟然是骑车

连衣服都没顾得上穿,立刻下床检查车子胎压,拿好盐丸和刚冲泡的蛋白粉,穿上骑行服,扛着车冲出家门,直奔早餐店

家门口的早餐店
家门口的早餐店
Loading EXIF data...

到早餐店的时间是4.50,来的太早了,选择不多,只有小米粥和茶叶蛋,包子还要等五分钟才熟,要了一碗小米粥、两个茶叶蛋、三个牛肉包。吃得有些急促,可以用赵本山的急头白脸吃一顿来表示

坐在店里,我心中还没想好骑去哪儿。随手打开地图,首页推荐了鹿邑的太清宫。还记得小时候常听到同学开玩笑说:老子是鹿邑的!

看了一下路线,沿着322省道,往返148km,这个省道我走过,去年去骑行去上海就是这个,路况烂的没法说,这次过去更是炸裂

椿树王庄的日出
椿树王庄的日出
Loading EXIF data...

路况越来越差了,柏油路被超载车辆压的细碎,我这25c管胎感觉随时都要爆,不禁怀念瓜车山地带来的安全感

太清宫外的香火小贩
太清宫外的香火小贩
Loading EXIF data...

8.20到达太清宫,但是车子的存放问题要解决一下,正好门口有卖香火的大妈,我付了她十块钱,让她帮我看着车子

到达 鹿邑·太清宫
到达 鹿邑·太清宫
Loading EXIF data...

太清宫的门票要60,有些贵了,相比之下,淮阳的太昊陵只要40,且规模和和设计都远胜太清宫

太清宫·三清大殿
太清宫·三清大殿
Loading EXIF data...
三清大殿后的广场
三清大殿后的广场
Loading EXIF data...

从三清大殿出来后看到这,我瞬间挂上痛苦面具,因为我今天出来穿的是骑行锁鞋,前脚跟高,后脚跟低,走路时一步两响,比高跟鞋都变态

出来后多少有些失望,毕竟这是老子的诞生地,但道教的气息却淡得几乎感觉不到,园林设计更是稀碎。相反,倒是社会主义的标语随处可见。我是感觉现在的寺庙和道观都没有其文化特色,如同各地方美食城的铁板鱿鱼

想必这和上层意识脱不开干系,组织对于宗教信仰很敏感

来奶茶店补个电
来奶茶店补个电
Loading EXIF data...

一路上导航和听歌消耗了不少电,在奶茶店喝了两杯,顺便给手机充了个电,不过鹿邑中午也太热了,我出发时还穿了棉背心

STRAVA记录
STRAVA记录

返程78km,全程逆风,出发前还吃多了,当我坐在车上那一刻,再次带上痛苦面具…

下午五点半点到家,回来得有些匆忙,因为这个省道很窄,晚上全是半挂,我不敢贪玩冒这个风险,图片还是有点少了,骑行时不可自拔,除了点烟会顺手会拿起手机拍照,其他时候不想断了骑行的节奏

  •  

聊聊自行车

2024年8月21日 03:02

洋车子的起源

在河南老家,人们通常称自行车为“洋车子”。确实,是洋鬼子的杰作,早在1791年,法国佬西夫拉克设计出了世界上第一辆自行车,据说他是受溜冰鞋的启发,整车为木制,上管由一根大横梁作为主体车架,下方装两个木制车轮,没有转向结构,拐弯全靠搬。当然,它也没有传动系统,骑多快这就真看腿了

河北 中国自行车博物馆 仿制藏品
河北 中国自行车博物馆 仿制藏品

0-100的开始

追溯历史,19世纪六七十年代,自行车由洋鬼子、华人带入中国,数量寥寥

随着时间的推移,20世纪二十年代,上海做自行车销售的同昌车行开始仿制生自行车零部件,再搭配进口件的基础上进行组装贴牌。从此内地第一家仿制、组装整车的自行车企业诞生,据注册登记记载有:飞马、飞鹰、飞人、飞轮等型号。直到新中国1956年国家队将其收编、创新再贴牌。由此诞生:上海凤凰

新中国1955-1957年期间,是内地自行车产业发展的关键时期,由主管自行车工业的工信部,组织上海、天津、沈阳的自行车厂进行标准化设计,确定了28寸自行车的规格和零配件的质量标准,为内地自行车行业制定了第一部行业标准,为后来几十年的自行车普及奠定了基础

到了新中国六七十年代,自行车在全国范围内迅速普及,尽管当时的产业链尚不完善,但许多品牌已逐渐崭露头角,下面有请八十年代国产五虎上将:上海永久、凤凰、天津飞鸽、红旗、江苏金狮

上海永久ZA51-9
上海永久ZA51-9

富裕的象征

新中国1981年9月,三流日报曾转载了一篇文章:湖北农民杨小运在超额完成国家交售指标后,县里问他想要什么奖励,他说想要一辆永久牌自行车。很多年后,杨小运回忆:“我是壮起胆子提出了自己的想法。须知道那时候能够骑上一辆永久牌自行车是多么得意的事情,因为据说只有凭什么“工业券”才能买到这种稀罕物,比现在坐小轿车还要显摆,简直就是一种身份的象征。在我的印象中,好像只有公社党委书记这样的人物才配有这么一辆自行车

由此可见,自行车不仅是代步工具,更是那个年代富裕与社会地位的象征,俗称小资三件套:缝纫机、手表,自行车

60年代初-80年代末发行的自行车券
60年代初-80年代末发行的自行车券

短暂的辉煌

然而,好景不长。随着改革开放的推进,外资品牌涌入中国市场,这些闭门仿车国产老品牌被外资的新工业设计所冲击,如:GIANT、Merida、Trek等等

此时的自行车带来最直接的就是视觉感官,功能多样的车架和丰富多彩的颜色,彻底颠覆了内地自行车的固有形象。相比之下,国产品牌的市场份额断崖式下跌,怕不是编制兜底,如今已全部倒闭,至此,沪上小资品牌殊荣不再

在新工业品牌中,最具代表性的是GIANT,尽管价格昂贵,但其时尚的设计和优越的性能激发了人民的购买欲望,迅速取代国产五虎上将,成为新一代的“高级自行车”,GIANT驻扎内地后年年蝉联市场第一,这一变革标志着中国内地自行车市场的一个重要转折点

较受欢迎的 GIANT ATX 760 图为90款
较受欢迎的 GIANT ATX 760 图为90款

从改革开放到21世纪初的几十年间,中国自行车运动突然就死在了襁褓里,由于汽车的普及和部分国人的观念癌症,自行车逐渐边缘化,成为人们眼中过时的交通工具。就此,内地再也没有形成新的自行车运动

而在此期间,外面的世界发生了天翻地覆的变化。UCI在瑞士洛桑成立了世界自行车中心和场地车训练场,致力于培训精英选手并为赛事提供支持。高卢鸡的环法赛愈演愈烈,成为全球最受瞩目的公路自行车赛事之一。与此同时,小日子的Shimano凭借革命性的STI技术,将刹车和变速融合,逐步通过专利,垄断全球自行车零部件市场

1998年环法路过一片向日葵地
1998年环法路过一片向日葵地

反观中国内地。自行车产业在这一波全球变革中停滞不前,国产品牌没有主动认清提升技术和产品价值的重要性,依然闭门造车,专注于下沉市场。没有技术,没有创新,短期图利坑蒙拐骗,论长期发展无异于自我毁灭

直到今天,大部分内地品牌在观念和行动上依然停留在过去的成功经验 ,靠坑蒙拐骗人民继续吃老本,最近又冒出一个内地品牌——Maserati玛莎拉蒂自行车,一个车架都要使用公模,搭配些工业垃圾套件组装。技术水平确实了不起,至少标贴得还算正。我都感到丢人,是不是人民好糊弄?都当成傻子?《这些,它们够用了》

昙花一现

进入21世纪后,随着资本经济的涌入,共享单车兴起,给中国的自行车运动带来了新的生机,环保意识的提升和健康生活方式的推广,使得越来越多的人将骑行作为健身方式和生活态度,骑行团体的壮大和各种赛事也愈发频繁

疫情期间,由于长时间的封闭管控,许多人渴望户外活动的机会,当管控解除后,户外运动迅速升温,体育及户外运动板块纷纷涨停板,骑行也因此成为了炙手可热的选择之一

然而,在各方面利好的同时,骑行运动也面临诸多挑战,在鄙人看来,部分国人对于新鲜事物的包容度较低,这与千百年来的文化属性密不可分

在这个二十一世纪的现代社会,有些人甚至认为骑行是“文化入侵”,这一观点显然过于狭隘,抱着自由言论的心态,鄙人不敢苟同

中国作为一个发展中国家,许多城市的道路基础设施尚未完善,尤其是在道路规划方面,机动车道与非机动车道的设计往往缺乏科学性和合理性,例如,原本就狭窄的非机动车道常常被汽车占用,加上逆行和鬼探头等行为,骑行安全难以保障,导致了骑行风险的增加。在这种情况下,所谓的“暴骑团”被迫驶入机动车道,进一步加剧了骑行者与机动车之间的矛盾,交通事故频发

此外,骑行者的素质问题也是不可忽视的因素。部分骑行者缺乏基本的交规意识、逆行、闯红灯等行为屡见不鲜。面对这种情况,我只能说,管好自己

非机动车道被抓
非机动车道被抓

关于最近发生的河北亲子骑行事件,引发了社会的广泛关注和讨论,不少网友竟表示同情,在视频中司机被殴打被迫下跪,什么死者为大?呸恶心。鄙人认为这是家长全责!缺乏对骑行的敬畏之心,忽视了安全因素。对于部分圣母指责辱骂司机我只想说

在这个环境内,你可以漠视周围发生的一切不公平,直到这种不公平在某一天以一种你无法预期的方式降临到你身上

  •  

写一个骑行页面(二)

2024年8月14日 20:40

在前几天写的数据展示页面中,日历与JSON数据的时间处理依赖于本地时区的getDay()和setDate()方法。然而,博客部署在GitHub Pages,时区的不同导致日历出现了显示偏差

本地时间异常

涉及函数:getStartDate

原代码:

这里的getDay()和setDate()方法是基于Github本地时区,不细心

function getStartDate(today, daysOffset) {
    const currentDayOfWeek = today.getDay();
    const daysToMonday = (currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1); 
    const startDate = new Date(today);
    startDate.setDate(today.getDate() - daysToMonday - daysOffset); 
    startDate.setDate(startDate.getDate() - (startDate.getDay() === 0 ? 6 : startDate.getDay() - 1)); 
    return startDate;
}

改进后:

修改后:采用getUTCDay()和setUTCDate()方法,使用UTC时间来保证时间处理的一致性

function getStartDate(today, daysOffset) {
    const currentDayOfWeek = today.getUTC11Day();
    const daysToMonday = (currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1);
    const startDate = new Date(today);
    startDate.setUTCDate(today.getUTCDate() - daysToMonday - daysOffset);
    return startDate;
}

JSON数据与日历数据两者时区不一致


// 涉及函数:generateCalendar

// 原代码:

// 在与JSON日期数据进行比较时,由于时区问题,日历的显示存在错位

const todayStr = getChinaTime().toISOString().split('T')[0];
let currentDate = new Date(startDate);

// 改进后:

// 将currentDate时间归零,避免由于时区差异导致的日期比较错误

const todayStr = getChinaTime().toISOString().split('T')[0];
let currentDate = new Date(startDate);
currentDate.setUTCHours(0, 0, 0, 0);

日期显示与更新逻辑异常

涉及函数:createDayContainer

同上,本地时间异常

// 原代码:
dateNumber.innerText = date.getDate();

// 改进后
dateNumber.innerText = date.getUTCDate();

异步打字机时区异常

涉及函数:displayCalendar

同上,本地时间异常

// 原代码:
currentDate.setDate(currentDate.getDate() + 1);

// 改进后
currentDate.setUTCDate(currentDate.getUTCDate() + 1);

UPDATE 日历交互动画

1.默认显示当天日期,不显示球体

2.光标悬浮其他日历时,隐藏当天日期,显示球体,反之亦然

3.整体主题色为:rgb(36, 36, 40)

4.日历的所有日期下添加2px厚下划线

function createDayContainer(date, activities) {
    const dayContainer = document.createElement('div');
    dayContainer.className = 'day-container';

    const dateNumber = document.createElement('span');
    dateNumber.className = 'date-number';
    dateNumber.innerText = date.getUTCDate();
    dayContainer.appendChild(dateNumber);

    const activity = activities.find(activity => activity.activity_time === date.toISOString().split('T')[0]);
    // console.log(processedActivities);
    if (activity) processedActivities.push(activity);

    // 根据骑行距离设置球的大小
    const ballSize = activity ? Math.min(parseFloat(activity.riding_distance) / 4, 24) : 2;

    const ball = document.createElement('div');
    ball.className = 'activity-indicator';
    ball.style.width = `${ballSize}px`;
    ball.style.height = `${ballSize}px`;
    if (!activity) ball.classList.add('no-activity');
    ball.style.left = '50%';
    ball.style.top = '50%';
    dayContainer.appendChild(ball);

    dayContainer.addEventListener('mouseenter', () => {
        if (date.toDateString() === new Date().toDateString()) {
            dateNumber.style.opacity = '0';
            ball.style.opacity = '1';
        } else {
            if (todayContainer) {
                todayContainer.querySelector('.date-number').style.opacity = '0';
                todayContainer.querySelector('.activity-indicator').style.opacity = '1';
            }
        }
    });
    dayContainer.addEventListener('mouseleave', () => {
        if (date.toDateString() === new Date().toDateString()) {
            dateNumber.style.opacity = '1';
            ball.style.opacity = '0';
        } else {
            if (todayContainer) {
                todayContainer.querySelector('.date-number').style.opacity = '1';
                todayContainer.querySelector('.activity-indicator').style.opacity = '0';
            }
        }
    });

    if (date.toDateString() === new Date().toDateString()) {
        todayContainer = dayContainer;
        dayContainer.classList.add('today');
        ball.style.backgroundColor = '#242428';
        dateNumber.style.color = '#242428';
        dateNumber.style.opacity = '1';
        ball.style.opacity = '0';
    }
    return dayContainer;
}
  •  

与时间抗衡:笔记本清灰换硅脂记

2024年8月11日 20:19

下午,笔记本忽然发出了咔哧咔哧的响声,仿佛是临死前的挣扎。我知道,又是哪零件坏了。大半年没清灰,这机器,似乎比人还脆弱

笔记本内部堆满灰尘
笔记本内部堆满灰尘
Loading EXIF data...

压CPU的螺丝多半滑丝,硅脂也是,抹得太多,溢得像过剩的腐败

过多的硅脂溢出
过多的硅脂溢出
Loading EXIF data...
满是灰尘的风扇
满是灰尘的风扇
Loading EXIF data...

风扇拆下来时洒下许多灰尘,这风扇犹如一台无力的革命机器,快要垮掉。

风满是灰尘的风扇
风满是灰尘的风扇
Loading EXIF data...

一抹灰尘,手上黑漆漆的

满是污垢的盖板
满是污垢的盖板
Loading EXIF data...

折腾了半个小时,总算洗干净了,接下来是抹硅脂

清理干净,准备组装
清理干净,准备组装
Loading EXIF data...

这硅脂还是一九年买的,小日子的信越X-23-7868-2D,导热效果还不错,不过这种产品很虚,需要时间来验证

假一赔命
假一赔命

涂了不少,想压住它的病症,但心里知道,这些努力也只是苟延残喘。机器,终究是要坏的

硅脂涂抹过多
硅脂涂抹过多
Loading EXIF data...

螺丝虽然滑丝了,但还勉强拧得上。我想,我该买些新螺丝,以免将来它彻底罢工

安装完毕
安装完毕
Loading EXIF data...

一次点亮。这台笔记本跟了我好多年,从上学时就陪着我,现在还能跑动。除了渲染不给力,写代码倒是没问题。

我觉得,电脑这东西,特别是Windows系统,得学会一些优化技巧。不然,即使配置再高,也会卡顿,这和底层设计脱不开关系,和Android有异曲同工之处。

相比之下,我更喜欢Linux。之前用Manjaro Linux做主力机有四年时间。不过,Linux的图形界面BUG让我很头疼,几乎每个版本都有各种关于GUI的BUG。刚接触Linux时,最让我害怕的就是系统更新。说到这里,不得不提一下王垠

谈 Linux,Windows 和 Mac
点亮成功
点亮成功
Loading EXIF data...
  •  

写一个骑行页面

2024年8月11日 16:02

作为一个爱好骑行的博主,总觉得博客里少了点什么,骑行骑行的,怎么能没有一个专门的骑行数据展示页呢

在设计这个页面的时候,参考了许多骑行APP,然而,国内的骑行数据页面设计真的是一言难尽…

我骑行看数据用Strava多一些,但是它的PC端交互体验,实在不敢苟同。除了用APP版本,我几乎不会去它的网页。不得不说,国外这些骑行数据端做的确实很到位,我个人觉得数据分析方面Strava比Garmin要好!

项目结构

老规矩,先放目录结构。由于网站的主样式文件main压缩后都超160kb了,为了避免堵塞加载,新开辟一条生产线

说起SCSS,还是受Fooleap的启发才接触到的,我非常喜欢这种方式,它允许嵌套CSS,让代码更加模块化、结构化,还支持变量、继承。比起传统CSS那真是有过之而无不及啊!

Blog
├─assets
│      cycling.min.css
│      cycling.min.js

├─pages
│      cycling.html

└─src
    │  cycling.js
    │  main.js

    ├─cycling
    │      cycling.scss
    │      _bar-chart.scss
    │      _base.scss
    │      _calendar.scss
    │      _message-box.scss
    │      _sports.scss

    └─sass

cycling.js

目前所有的逻辑都在这一个文件里完成,现在的功能还是个雏,因为没有打通Strava api,JSON数据是我手搓的..最近一直在搞Strava api,有好大哥懂吗?它们现在限制了每小时的请求次数,我本来就是半吊子水平,现在是雪上加霜

import './cycling/cycling.scss';

// 为了数据的统一性,generateCalendar处理后赋值供全局使用
let processedActivities = [];

// 日历
function generateCalendar(activities, startDate, numWeeks) {
    const calendarElement = document.getElementById('calendar');
    calendarElement.innerHTML = ''; 
    
    const daysOfWeek = ['', '', '', '', '', '', '']; 
    daysOfWeek.forEach(day => {
        const dayElement = document.createElement('div');
        dayElement.className = 'calendar-week-header';
        dayElement.innerText = day;
        calendarElement.appendChild(dayElement);
    });

    const todayStr = getChinaTime().toISOString().split('T')[0];
    // 起始日期
    let currentDate = new Date(startDate);

    processedActivities = [];

    // 创建日历
    function createDayContainer(date, activities) {
        const dayContainer = document.createElement('div');
        dayContainer.className = 'day-container';

        const dateNumber = document.createElement('span');
        dateNumber.className = 'date-number';
        dateNumber.innerText = date.getDate();
        dayContainer.appendChild(dateNumber);

        const activity = activities.find(activity => activity.activity_time === date.toISOString().split('T')[0]);
        if (activity) processedActivities.push(activity);

        // 根据骑行距离设置球的大小
        const ballSize = activity ? Math.min(parseFloat(activity.riding_distance) / 4, 24) : 2;

        const ball = document.createElement('div');
        ball.className = 'activity-indicator';
        ball.style.width = `${ballSize}px`;
        ball.style.height = `${ballSize}px`;
        if (!activity) ball.classList.add('no-activity');
        ball.style.left = '50%';
        ball.style.top = '50%';
        dayContainer.appendChild(ball);

        dayContainer.addEventListener('mouseenter', () => {
            dateNumber.style.opacity = '1';
            ball.style.opacity = '0';
        });
        dayContainer.addEventListener('mouseleave', () => {
            dateNumber.style.opacity = '0';
            ball.style.opacity = '1';
        });

        // 今天日期和球的颜色
        if (date.toDateString() === new Date().toDateString()) {
            dayContainer.classList.add('today');
            ball.style.backgroundColor = '#2ea9df';
            dateNumber.style.color = '#2ea9df';
        }

        return dayContainer;
    }

    // 异步显示,模仿打字机效果
    async function displayCalendar() {
        for (let week = 0; week < numWeeks; week++) {
            for (let day = 0; day < 7; day++) {
                const currentDateStr = currentDate.toISOString().split('T')[0];
                // 不再计算超过今天的日期
                if (currentDateStr > todayStr) return;

                const dayContainer = createDayContainer(currentDate, activities);
                calendarElement.appendChild(dayContainer);

                // 速度
                await new Promise(resolve => setTimeout(resolve, 30));
                currentDate.setDate(currentDate.getDate() + 1);
            }
        }
    }
    displayCalendar().then(() => {
        generateBarChart();
        displayTotalActivities();
    });
}

// 柱形图
function generateBarChart() {
    const barChartElement = document.getElementById('barChart');
    barChartElement.innerHTML = '';

    const today = getChinaTime();
    const startDate = getStartDate(today, 21);

    // 每周数据
    const weeklyData = {};

    // 每周总活动时间
    processedActivities.forEach(activity => {
        const activityDate = new Date(activity.activity_time);
        const weekStart = getWeekStartDate(activityDate);
        const weekEnd = new Date(weekStart);
        weekEnd.setDate(weekStart.getDate() + 6);

        const weekKey = `${weekStart.toISOString().split('T')[0]} - ${weekEnd.toISOString().split('T')[0]}`;
        weeklyData[weekKey] = (weeklyData[weekKey] || 0) + convertToHours(activity.moving_time);
    });

    // 最大时间
    const maxTime = Math.max(...Object.values(weeklyData), 0);

    // 创建柱形图
    Object.keys(weeklyData).forEach(week => {
        const barContainer = document.createElement('div');
        barContainer.className = 'bar-container';

        const bar = document.createElement('div');
        bar.className = 'bar';
        const width = (weeklyData[week] / maxTime) * 190;
        bar.style.setProperty('--bar-width', `${width}px`);

        const durationText = document.createElement('div');
        durationText.className = 'bar-duration';
        durationText.innerText = '0h';

        const messageBox = createMessageBox();
        const clickMessageBox = createMessageBox();

        barContainer.style.position = 'relative'; 
        bar.appendChild(durationText);
        barContainer.appendChild(bar);
        barContainer.appendChild(messageBox);
        barContainer.appendChild(clickMessageBox);
        barChartElement.appendChild(barContainer);

        bar.style.width = '0';
        bar.offsetHeight;
        // 动画效果
        bar.style.transition = 'width 1s ease-out';
        bar.style.width = `${width}px`;

        durationText.style.opacity = '1';
        // 动态文本
        animateText(durationText, 0, weeklyData[week], 1000);
        setupBarInteractions(bar, messageBox, clickMessageBox, weeklyData[week]);
    });
}

// 动态文本显示
function animateText(element, startValue, endValue, duration) {
    const startTime = performance.now();
    function update() {
        const elapsed = performance.now() - startTime;
        const progress = Math.min(elapsed / duration, 1);
        const currentValue = Math.floor(progress * endValue);
        element.innerText = `${currentValue}h`;
        if (progress < 1) {
            requestAnimationFrame(update);
        } else {
            element.innerText = `${endValue.toFixed(1)}h`;
        }
    }
    update();
}

// 计算总公里数
function calculateTotalKilometers(activities) {
    return activities.reduce((total, activity) => total + parseFloat(activity.riding_distance) || 0, 0);
}

// 显示总活动数和总公里数
function displayTotalActivities() {
    const totalCountElement = document.getElementById('totalCount');
    const totalDistanceElement = document.getElementById('totalDistance');

    if (!totalCountElement || !totalDistanceElement) return;

    const totalCountValue = totalCountElement.querySelector('#totalCountValue');
    const totalDistanceValue = totalDistanceElement.querySelector('#totalDistanceValue');

    const totalCountSpinner = totalCountElement.querySelector('.loading-spinner');
    const totalDistanceSpinner = totalDistanceElement.querySelector('.loading-spinner');

    totalCountSpinner.classList.add('active');
    totalDistanceSpinner.classList.add('active');

    const uniqueDays = new Set(processedActivities.map(activity => activity.activity_time));
    const totalCount = uniqueDays.size;
    const totalKilometers = calculateTotalKilometers(processedActivities);

    animateCount(totalCountValue, totalCount, 1000, 50);
    animateCount(totalDistanceValue, totalKilometers, 1000, 50, true);

    setTimeout(() => {
        totalDistanceValue.textContent = `${totalKilometers.toFixed(2)} km`;
        totalCountSpinner.classList.remove('active');
        totalDistanceSpinner.classList.remove('active');
    }, 1000);
}

// 获取一周的开始日期
function getWeekStartDate(date) {
    const day = date.getDay();
    const diff = (day === 0 ? -6 : 1) - day;
    const weekStart = new Date(date);
    weekStart.setDate(weekStart.getDate() + diff);
    return weekStart;
}

// 将JSON的时间数据转换为小时
function convertToHours(moving_time) {
    const [hours, minutes] = moving_time.split(':').map(Number);
    return hours + (minutes / 60);
}

// 博客托管Github Pages需要中国时间
function getChinaTime() {
    const now = new Date();
    const offset = 8 * 60 * 60 * 1000;
    return new Date(now.getTime() + offset);
}

// 手搓JSON
async function loadActivityData() {
    const response = await fetch('XXXXXX');
    return response.json();
}

(async function() {
    const today = getChinaTime();
    const startDate = getStartDate(today, 21);

    const activities = await loadActivityData();
    generateCalendar(activities, startDate, 4);
})();

// 创建消息盒子
function createMessageBox() {
    const messageBox = document.createElement('div');
    messageBox.className = 'message-box';
    return messageBox;
}

// 获取起始时间
function getStartDate(today, daysOffset) {
    const currentDayOfWeek = today.getDay();
    const daysToMonday = (currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1); 
    const startDate = new Date(today);
    startDate.setDate(today.getDate() - daysToMonday - daysOffset); 
    startDate.setDate(startDate.getDate() - (startDate.getDay() === 0 ? 6 : startDate.getDay() - 1)); 
    return startDate;
}

// 动态更新计数器
function animateCount(element, totalValue, duration, intervalDuration, isDistance = false) {
    const step = totalValue / (duration / intervalDuration);
    let count = 0;
    const interval = setInterval(() => {
        count += step;
        if (count >= totalValue) {
            count = totalValue;
            clearInterval(interval);
        }
        element.textContent = isDistance ? count.toFixed(2) : Math.round(count);
    }, intervalDuration);
}

// 骚话集合
function setupBarInteractions(bar, messageBox, clickMessageBox, weeklyData) {
    let mouseLeaveTimeout;
    let autoHideTimeout;

    bar.addEventListener('mouseenter', () => {
        clearTimeout(mouseLeaveTimeout);
        clearTimeout(autoHideTimeout);

        const message = weeklyData > 14 ? '这周干的还不错' : '偷懒了啊';
        messageBox.innerText = message;
        messageBox.classList.add('show');

        autoHideTimeout = setTimeout(() => {
            messageBox.classList.remove('show');
        }, 700);
    });

    bar.addEventListener('mouseleave', () => {
        mouseLeaveTimeout = setTimeout(() => {
            messageBox.classList.remove('show');
        }, 700);
    });

    bar.addEventListener('click', () => {
        clickMessageBox.innerText = '一起来运动吧!';
        clickMessageBox.classList.add('show');
        setTimeout(() => {
            clickMessageBox.classList.remove('show');
        }, 700);

        messageBox.classList.remove('show');
        clearTimeout(mouseLeaveTimeout);
        clearTimeout(autoHideTimeout);
    });
}

cycling.scss

骑行统计页面不会止步于此,接下来还会有很大的延申改动,我提前把变量接口留好了,定义了一些主样式变量,SCSS模块化继承了一些基础样式,二次开发会轻松很多

// 总次数和总距离字体
$primary-color: #2ea9df;
// 柱状图字体
$gray-color: #333;
// 柱状图颜色
$light-gray-color: #EBE6F2;
// 柱状图边框
$light-gray-border-color: #DFD7E9;
// 未活动日历
$no-activity-color: gray;
//------ 分类色
// 公路车
$cycling-color: #EBE6F2;
$cycling-border-color: #DFD7E9; 
// 跑步
$running-color: #D5E5D3;
$running-border-color: #BDD6BA;
// 背景和文本颜色
$background-color: #333;
$text-color: #fff;

@import 'base';
@import 'calendar';
@import 'bar-chart';
@import 'sports';
@import 'message-box';

webpack配置

html和scss没啥好看的,配置一下收工

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    mode: 'production',
    entry: {
        main: path.resolve(__dirname, 'src/main.js'),
        cycling: path.resolve(__dirname, 'src/cycling.js'),
    },
    output: {
        path: path.resolve(__dirname, 'assets'),
        filename: '[name].min.js',
        publicPath: '/'
    },
    stats: {
        entrypoints: false,
        children: false
    },
    module: {
        rules: [
            {
                test: /\.(scss|css)$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    'postcss-loader',
                    'sass-loader'
                ]
            },
            {
                test: /\.html$/,
                use: ['html-loader']
            }
        ],
    },
    resolve: {
        alias: {
            'iDisqus.css': 'disqus-php-api/dist/iDisqus.css',
        }
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: '[name].min.css'
        })
    ],
    optimization: {
        minimize: true,
        minimizer: [
            new TerserPlugin({
                parallel: true
            }),
            new CssMinimizerPlugin()
        ],
    }
};

效果

Fooleap的博客真的是相当不错,我特别喜欢他写的Jekyll主题,还有很大的折腾空间,比如全站PJAX、懒加载等等

这一周,我也着手用JQuery重新了写整站,完事后感觉真傻逼了,属于画蛇添足,多此一举。毕竟小站点,拖着一个磨盘挺累的。不上国内服务器的话,原生这条路死磕到底了,不过PJAX是必须要上的,预计下星期全站PJAX、懒加载上线

初稿
初稿

骑行:https://lhasa.icu/cycling.html

  •  

写一个Chrome表单自动化插件

2024年8月7日 14:26

在刷博客的时候,最麻烦的事情之一就是手动填写各种表单。为了提高我的冲浪体验,诞生了这款表单自动化插件。经过爬虫上百次调教,兼容95%博客,另外5%的网站正常人写不出来,autocomplete小伎俩都上不了台面,各种防止逆向、防调试测试,心累。

项目结构

插件纯绿色,无隐私可言。除images外,全部资源和代码文件都经过Webpack打包,下面是项目的目录结构以及各部分的说明:

Form-automation-plugin
│  index.html
│  LICENSE
│  manifest.json
│  package-lock.json
│  package.json
│  README.md
│  webpack.config.js

├─dist
│      33a80cb13f78b37acb39.woff2
│      8093dd36a9b1ec918992.ttf
│      8521c461ad88443142d9.woff
│      autoFill.min.js
│      eventHandler.min.js
│      formManager.min.js
│      main.min.css

└─src
    │  autoFill.js
    │  eventHandler.js
    │  formManager.js
    │  template.css
    │  template.html

    ├─fonts
    │      iconfont.css
    │      iconfont.ttf
    │      iconfont.woff
    │      iconfont.woff2

    └─images
            Appreciation-code.jpg
            icon128.png
            icon16.png
            icon48.png

webpack.config.js

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    autoFill: './src/autoFill.js',
    eventHandler: './src/eventHandler.js',
    formManager: './src/formManager.js',
  },
  output: {
    filename: '[name].min.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'main.min.css',
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'src', 'template.html'),
      filename: '../index.html',
      inject: 'body',
    }),
  ],
  resolve: {
    extensions: ['.js', '.css'],
  },
};

autoFill.js

// autoFill.js文件是插件的最重要的核心模块,涉及到了插件的主要输出功能
// 遍历当前页面所有input,将autocomplete值设置为on,监听Textarea输入时触发
function handleAutocomplete() {
  const inputs = document.querySelectorAll('input');
  inputs.forEach(input => {
    const autocompleteAttr = input.getAttribute('autocomplete');
    if (autocompleteAttr) {
      input.setAttribute('autocomplete', 'on');
    } else {
      input.setAttribute('autocomplete', 'on');
    }
  });
}

// 这个函数有些臃肿,马上要去骑车,懒得搞了,现在的逻辑已经完善到9成了,大多数意外情况都卷了进去,但是一些防逆向防调试,我暂时无法解决,前端菜鸟,还望大哥指点一二
function fillInputFields() {
  chrome.storage.sync.get(["name", "email", "url"], (data) => {
    // console.log(data);

    const hasValidName = data.name !== undefined && data.name !== "";
    const hasValidEmail = data.email !== undefined && data.email !== "";
    const hasValidUrl = data.url !== undefined && data.url !== "";

    // 关键字
    const nameKeywords = [
      "name", "author", "display_name", "full-name", "username", "nick", "displayname", 
      "first-name", "last-name", "full name", "real-name", "given-name", 
      "family-name", "user-name", "pen-name", "alias", "name-field", "displayname"
    ];
    const emailKeywords = [
      "email", "mail", "contact", "emailaddress", "mailaddress", 
      "email-address", "mail-address", "email-addresses", "mail-addresses", 
      "emailaddresses", "mailaddresses", "contactemail", "useremail", 
      "contact-email", "user-mail"
    ];
    const urlKeywords = [
      "url", "link", "website", "homepage", "site", "web", "address", 
      "siteurl", "webaddress", "homepageurl", "profile", "homepage-link"
    ];

    const inputs = document.querySelectorAll("input, textarea");

    inputs.forEach((input) => {
      const typeAttr = input.getAttribute("type")?.toLowerCase() || "";
      const nameAttr = input.getAttribute("name")?.toLowerCase() || "";
      let valueToSet = "";

      // 处理 URL
      if (urlKeywords.some(keyword => nameAttr.includes(keyword))) {
        if (hasValidUrl) {
          valueToSet = data.url;
        }
      }
      // 处理邮箱
      else if (emailKeywords.some(keyword => nameAttr.includes(keyword))) {
        if (hasValidEmail) {
          valueToSet = data.email;
        }
      }
      // 处理名称
      else if (nameKeywords.some(keyword => nameAttr.includes(keyword))) {
        if (hasValidName) {
          valueToSet = data.name;
        }
      }

      // 处理没有 type 属性或者 type 为 text 的情况
      if ((typeAttr === "" || typeAttr === "text") && valueToSet === "") {
        if (nameAttr && nameKeywords.some(keyword => nameAttr.includes(keyword))) {
          if (hasValidName) {
            valueToSet = data.name;
          }
        } else if (nameAttr && urlKeywords.some(keyword => nameAttr.includes(keyword))) {
          if (hasValidUrl) {
            valueToSet = data.url;
          }
        }
      }

      // 处理 type 为 email
      if (typeAttr === "email" && valueToSet === "") {
        if (nameAttr && emailKeywords.some(keyword => nameAttr.includes(keyword))) {
          if (hasValidEmail) {
            valueToSet = data.email;
          }
        }
      }

      // 处理 type 为 url
      if (typeAttr === "url" && valueToSet === "") {
        if (nameAttr && urlKeywords.some(keyword => nameAttr.includes(keyword))) {
          if (hasValidUrl) {
            valueToSet = data.url;
          }
        }
      }

      // 填充输入字段
      if (valueToSet !== "") {
        input.value = valueToSet;
      }
    });
  });
}

function clearInputFields() {
  const inputs = document.querySelectorAll("input");
  inputs.forEach((input) => {
    const typeAttr = input.getAttribute("type")?.toLowerCase();
    if (typeAttr === "text" || typeAttr === "email") {
      input.value = "";
    }
  });
}

// 监听 textarea 标签的输入事件
document.addEventListener("input", (event) => {
  if (event.target.tagName.toLowerCase() === "textarea") {
    handleAutocomplete();
    fillInputFields();
  }
});

formManager.js

该文件负责向Chrome本地存储和修改,就CURD,没啥含量

import './fonts/iconfont.css';
import './template.css';

document.getElementById("save").addEventListener("click", () => {
  const saveButton = document.getElementById("save");
  if (saveButton.textContent === "更改") {
    unlockInputFields();
    changeButtonText("保存");
    return;
  }

  const name = document.getElementById("name").value.trim();
  const email = document.getElementById("email").value.trim();
  const url = document.getElementById("url").value.trim();

  // 验证邮箱格式的正则表达式
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (name === "" || email === "") {
    alert("请填写必填字段:姓名和邮箱!");
    return;
  }

  if (!emailPattern.test(email)) {
    alert("请输入有效的邮箱地址!");
    return;
  }

  // 从 Chrome 存储中读取当前的值
  chrome.storage.sync.get(["name", "email", "url"], (data) => {
    const isNameAndEmailChanged = name !== data.name || email !== data.email;
    const isUrlChanged = url !== data.url;

    if (isNameAndEmailChanged || isUrlChanged) {
      chrome.storage.sync.set({ name, email, url }, () => {
        lockInputFields();
        changeButtonText("更改");
      });
    } else {
      lockInputFields();
      changeButtonText("更改");
    }
  });
});

// 页面加载完成时执行
document.addEventListener("DOMContentLoaded", () => {
  chrome.storage.sync.get(["name", "email", "url"], (data) => {
    document.getElementById("name").value = data.name || "";
    document.getElementById("email").value = data.email || "";
    document.getElementById("url").value = data.url || "";

    if (data.name || data.email || data.url) {
      lockInputFields();
      changeButtonText("更改");
    }
  });

  const menuItems = document.querySelectorAll('.dl-menu li a');
  const tabContents = document.querySelectorAll('.tab-content');

  menuItems.forEach(menuItem => {
    menuItem.addEventListener('click', (event) => {
      event.preventDefault();
      tabContents.forEach(tab => tab.classList.remove('active'));
      const targetId = menuItem.getAttribute('href').substring(1);
      document.getElementById(targetId).classList.add('active');
      menuItems.forEach(item => item.parentElement.classList.remove('active'));
      menuItem.parentElement.classList.add('active');
    });
  });
});

// 锁定输入框
function lockInputFields() {
  document.getElementById("name").setAttribute("disabled", "true");
  document.getElementById("email").setAttribute("disabled", "true");
  document.getElementById("url").setAttribute("disabled", "true");
}

// 解锁输入框
function unlockInputFields() {
  document.getElementById("name").removeAttribute("disabled");
  document.getElementById("email").removeAttribute("disabled");
  document.getElementById("url").removeAttribute("disabled");
}

// 更改按钮文本
function changeButtonText(text) {
  document.getElementById("save").textContent = text;
}

使用方法

git clone到本地,浏览器打开:chrome://extensions/,加载已解压的扩展程序

由于我没有注册Chrome应用商店开发者,目前只能本地运行,过几天上线应用商店,Tampermonkey等骑车回来再做

Github

Form-automation-plugin:https://github.com/achuanya/Form-automation-plugin

  •  

七月最后一天骑行,有氧100公里

2024年8月1日 01:37

今天是七月的最后一天,晚上必须来一次有氧小长途骑行,目标暂且定为100公里

出发前,先泡两瓶蛋白粉放进冰箱,一瓶550ML,一瓶750ML,我还是觉得不够用。骑车过程中不想下来,容易打断节奏。我打算坐尾再安装一个支架放一瓶750ML,只要室外温度不是特变态,百里油耗三瓶水没有问题。

奥普帝蒙 黑标分离乳清蛋白粉
奥普帝蒙 黑标分离乳清蛋白粉
Loading EXIF data...

检查一下前变、后拨、夹气和胎压。前胎由于自补液在气嘴处凝固,导致无法打气,不过目前胎压足够,不影响骑行,估计再骑一周胎压就不行了,由于管胎的特殊结构,我还没有合适的解决方案,除了换胎。

下午喝了两碗绿豆粥,吃了些核桃饼,开始做最后的准备,带了两件便携式螺丝刀,一小包纸巾,一包干湿巾,一包电解质盐丸,这些正好放进后尾包,占了大概60%空间,剩余空间还能放盒烟。

衣服就没啥好挑的,穿条ASSOS背带裤和速白干背心就行了,如果不是为了注意个人形象,我直接背带裤光膀子。在上海生活的时候也特热,多次骑行都是背带裤光膀子,有两次路过外滩被交警抓了,说外滩都是游客,我个人形象影响市容。

到地方了,这骑行路段是挺不错的,我有七年没来这里了,今天来到这里发现大变样了,还可以租皮划艇,改天一定玩玩。在这里绕圈骑了十五公里,又跑去周口公园绕圈,要说这夏天油耗确实高,机动车顶不住,人也顶不住啊!我的水有点不够了,今天才33°,河南的热和江浙沪的热真是不一样,回家后一直不适应,出公园后去蜜雪冰城买了一杯柠檬水,找店里的小妹妹白嫖了两瓶冰水,直奔淮阳区·龙湖

周口植物园
周口植物园
Loading EXIF data...

到龙湖后里程已经到了60公里,这时天也暗了,有些许疲惫,主要是颈椎疼,下把位骑多了。吃了两片盐丸,两手握把立边缘慢骑摆烂二十分钟,这个时候一定不能下来歇,推车都不行,下车体力直接归零,不知道别人咋样,我是这样的,与自己较劲,100公里?200公里又算个屁,出来了,就要干

奶奶煮的绿豆粥
奶奶煮的绿豆粥
Loading EXIF data...

在龙湖绕了三圈后达标,真是饿得不行了。到家已经十点,浑身湿透,黏糊糊的,洗了澡,洗了衣服,然后躺下看订阅,期待八月的骑行。

今日有氧100公里完成
今日有氧100公里完成
  •  

利用Go+Github Actions写个定时RSS爬虫

2024年7月27日 09:50

说起这事,还是受一位博友的启发“1900”他的左邻右舍页面很棒,决定模仿一下

起初,我打算使用 COS 和 GitHub Actions,但在测试过程中发现 GitHub 的延迟非常高,验证和文件写入速度极慢,频频失败。干脆直接上 GitHub 自产自销。

大致思路

main()

├── readFeedsFromGitHub()
│   ├── GitHub API 调用
│   │   ├── 读取 rss_feeds.txt 文件
│   │   └── 处理文件报错
│   └── Return

├── fetchRSS()
│   ├── 遍历 RSS
│   │   ├── HTTP GET 请求
│   │   └── 处理请求错误
│   ├── 解析 RSS
│   │   ├── 清理 XML 内容中的非法字符
│   │   ├── 提取域名
│   │   └── 格式化并排序
│   └── Return

└── saveToGitHub()
    ├── GitHub API 调用
    │   ├── 保存到 _data/rss_data.json 供 Jekyll 调用
    │   └── 处理错误
    └── Return

由于用 Go 搬砖,所有的包、类型和方法均可在 GitHub API 客户端库的第 39 版文档查询

关于 Github API 有一点需要注意,配置好环境变量后,Token 操作仓库需要有一定的权限,务必启用 Read and write permissions 读取和写入权限

go mod init github.com/achuanya/Grab-latest-RSS
// Go-GitHub v39
go get github.com/google/go-github/v39/github
// RSS 和 Atom feeds 解析库
go get github.com/mmcdole/gofeed
// OAuth2 认证和授权
go get golang.org/x/oauth2

Go RSS 爬虫 Code

package main

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"sort"
	"sync"
	"time"

	"github.com/google/go-github/v39/github"
	"github.com/mmcdole/gofeed"
	"golang.org/x/oauth2"
)

const (
	maxRetries    = 3                // 最大重试次数
	retryInterval = 10 * time.Second // 重试间隔时间
)

type Config struct {
	GithubToken      string // GitHub API 令牌
	GithubName       string // GitHub 用户名
	GithubRepository string // GitHub 仓库名
}

// 用于解析 avatar_data.json 文件的结构
type Avatar struct {
	Name   string `json:"name"`   // 用户名
	Avatar string `json:"avatar"` // 头像 URL
}

// 爬虫抓取的数据结构
type Article struct {
	DomainName string `json:"domainName"` // 域名
	Name       string `json:"name"`       // 博客名称
	Title      string `json:"title"`      // 文章标题
	Link       string `json:"link"`       // 文章链接
	Date       string `json:"date"`       // 格式化后的文章发布时间
	Avatar     string `json:"avatar"`     // 头像 URL
}

// 初始化并返回配置信息
func initConfig() Config {
	return Config{
		GithubToken:      os.Getenv("TOKEN"), // 从环境变量中获取 GitHub API 令牌
		GithubName:       "achuanya",         // GitHub 用户名
		GithubRepository: "lhasa.github.io",  // GitHub 仓库名
	}
}

// 清理 XML 内容中的非法字符
func cleanXMLContent(content string) string {
	re := regexp.MustCompile(`[\x00-\x1F\x7F-\x9F]`)
	return re.ReplaceAllString(content, "")
}

// 尝试解析不同格式的时间字符串
func parseTime(timeStr string) (time.Time, error) {
	formats := []string{
		time.RFC3339,
		time.RFC3339Nano,
		time.RFC1123Z,
		time.RFC1123,
	}

	for _, format := range formats {
		if t, err := time.Parse(format, timeStr); err == nil {
			return t, nil
		}
	}
	return time.Time{}, fmt.Errorf("unable to parse time: %s", timeStr)
}

// 将时间格式化为 "January 2, 2006"
func formatTime(t time.Time) string {
	return t.Format("January 2, 2006")
}

// 从 URL 中提取域名,并添加 https:// 前缀
func extractDomain(urlStr string) (string, error) {
	u, err := url.Parse(urlStr)
	if err != nil {
		return "", err
	}
	domain := u.Hostname()
	protocol := "https://"
	if u.Scheme != "" {
		protocol = u.Scheme + "://"
	}
	fullURL := protocol + domain

	return fullURL, nil
}

// 获取当前的北京时间
func getBeijingTime() time.Time {
	beijingTimeZone := time.FixedZone("CST", 8*3600)
	return time.Now().In(beijingTimeZone)
}

// 记录错误信息到 error.log 文件
func logError(config Config, message string) {
	logMessage(config, message, "error.log")
}

// 记录信息到指定的文件
func logMessage(config Config, message string, fileName string) {
	ctx := context.Background()
	client := github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
		AccessToken: config.GithubToken,
	})))

	filePath := "_data/" + fileName
	fileContent := []byte(message + "\n\n")

	file, _, resp, err := client.Repositories.GetContents(ctx, config.GithubName, config.GithubRepository, filePath, nil)
	if err != nil && resp.StatusCode == http.StatusNotFound {
		_, _, err := client.Repositories.CreateFile(ctx, config.GithubName, config.GithubRepository, filePath, &github.RepositoryContentFileOptions{
			Message: github.String("Create " + fileName),
			Content: fileContent,
			Branch:  github.String("master"),
		})
		if err != nil {
			fmt.Printf("error creating %s in GitHub: %v\n", fileName, err)
		}
		return
	} else if err != nil {
		fmt.Printf("error checking %s in GitHub: %v\n", fileName, err)
		return
	}

	decodedContent, err := file.GetContent()
	if err != nil {
		fmt.Printf("error decoding %s content: %v\n", fileName, err)
		return
	}

	updatedContent := append([]byte(decodedContent), fileContent...)

	_, _, err = client.Repositories.UpdateFile(ctx, config.GithubName, config.GithubRepository, filePath, &github.RepositoryContentFileOptions{
		Message: github.String("Update " + fileName),
		Content: updatedContent,
		SHA:     github.String(*file.SHA),
		Branch:  github.String("master"),
	})
	if err != nil {
		fmt.Printf("error updating %s in GitHub: %v\n", fileName, err)
	}
}

// 从 GitHub 仓库中获取 JSON 文件内容
func fetchFileFromGitHub(config Config, filePath string) (string, error) {
	ctx := context.Background()
	client := github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
		AccessToken: config.GithubToken,
	})))

	file, _, resp, err := client.Repositories.GetContents(ctx, config.GithubName, config.GithubRepository, filePath, nil)
	if err != nil {
		if resp.StatusCode == http.StatusNotFound {
			return "", fmt.Errorf("file not found: %s", filePath)
		}
		return "", fmt.Errorf("error fetching file %s from GitHub: %v", filePath, err)
	}

	content, err := file.GetContent()
	if err != nil {
		return "", fmt.Errorf("error decoding file %s content: %v", filePath, err)
	}

	return content, nil
}

// 从 GitHub 仓库中读取头像配置
func loadAvatarsFromGitHub(config Config) (map[string]string, error) {
	content, err := fetchFileFromGitHub(config, "_data/avatar_data.json")
	if err != nil {
		return nil, err
	}

	var avatars []Avatar
	if err := json.Unmarshal([]byte(content), &avatars); err != nil {
		return nil, err
	}

	avatarMap := make(map[string]string)
	for _, a := range avatars {
		avatarMap[a.Name] = a.Avatar
	}

	return avatarMap, nil
}

// 从 RSS 列表中抓取最新的文章,并按发布时间排序
func fetchRSS(config Config, feeds []string) ([]Article, error) {
	var articles []Article
	var mu sync.Mutex     // 用于保证并发安全
	var wg sync.WaitGroup // 用于等待所有 goroutine 完成

	avatars, err := loadAvatarsFromGitHub(config)
	if err != nil {
		logError(config, fmt.Sprintf("[%s] [Load avatars error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), err))
		return nil, err
	}

	fp := gofeed.NewParser()
	httpClient := &http.Client{
		Timeout: 10 * time.Second,
	}

	for _, feedURL := range feeds {
		wg.Add(1)
		go func(feedURL string) {
			defer wg.Done()
			var resp *http.Response
			var bodyString string
			var fetchErr error

			for i := 0; i < maxRetries; i++ {
				resp, fetchErr = httpClient.Get(feedURL)
				if fetchErr == nil {
					bodyBytes := new(bytes.Buffer)
					bodyBytes.ReadFrom(resp.Body)
					bodyString = bodyBytes.String()
					resp.Body.Close()
					break
				}
				logError(config, fmt.Sprintf("[%s] [Get RSS error] %s: Attempt %d/%d: %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), feedURL, i+1, maxRetries, fetchErr))
				time.Sleep(retryInterval)
			}

			if fetchErr != nil {
				logError(config, fmt.Sprintf("[%s] [Failed to fetch RSS] %s: %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), feedURL, fetchErr))
				return
			}

			cleanBody := cleanXMLContent(bodyString)

			var feed *gofeed.Feed
			var parseErr error
			for i := 0; i < maxRetries; i++ {
				feed, parseErr = fp.ParseString(cleanBody)
				if parseErr == nil {
					break
				}
				logError(config, fmt.Sprintf("[%s] [Parse RSS error] %s: Attempt %d/%d: %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), feedURL, i+1, maxRetries, parseErr))
				time.Sleep(retryInterval)
			}

			if parseErr != nil {
				logError(config, fmt.Sprintf("[%s] [Failed to parse RSS] %s: %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), feedURL, parseErr))
				return
			}

			mainSiteURL := feed.Link
			domainName, err := extractDomain(mainSiteURL)
			if err != nil {
				logError(config, fmt.Sprintf("[%s] [Extract domain error] %s: %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), mainSiteURL, err))
				domainName = "unknown"
			}

			name := feed.Title
			avatarURL := avatars[name]
			if avatarURL == "" {
				avatarURL = "https://cos.lhasa.icu/LinksAvatar/default.png"
			}

			if len(feed.Items) > 0 {
				item := feed.Items[0]

				publishedTime, err := parseTime(item.Published)
				if err != nil && item.Updated != "" {
					publishedTime, err = parseTime(item.Updated)
				}

				if err != nil {
					logError(config, fmt.Sprintf("[%s] [Getting article time error] %s: %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), item.Title, err))
					publishedTime = time.Now()
				}

				originalName := feed.Title
				// 该长的地方短,该短的地方长
				nameMapping := map[string]string{
					"obaby@mars": "obaby",
					"青山小站 | 一个在帝都搬砖的新时代农民工":       "青山小站",
					"Homepage on Miao Yu | 于淼":    "于淼",
					"Homepage on Yihui Xie | 谢益辉": "谢益辉",
				}

				validNames := make(map[string]struct{})
				for key := range nameMapping {
					validNames[key] = struct{}{}
				}

				_, valid := validNames[originalName]
				if !valid {
					for key := range validNames {
						if key == originalName {
							logError(config, fmt.Sprintf("[%s] [Name mapping not found] %s", getBeijingTime().Format("Mon Jan 2 15:04:2006"), originalName))
							break
						}
					}
				} else {
					name = nameMapping[originalName]
				}

				mu.Lock()
				articles = append(articles, Article{
					DomainName: domainName,
					Name:       name,
					Title:      item.Title,
					Link:       item.Link,
					Avatar:     avatarURL,
					Date:       formatTime(publishedTime),
				})
				mu.Unlock()
			}
		}(feedURL)
	}

	wg.Wait()
	sort.Slice(articles, func(i, j int) bool {
		date1, _ := time.Parse("January 2, 2006", articles[i].Date)
		date2, _ := time.Parse("January 2, 2006", articles[j].Date)
		return date1.After(date2)
	})

	return articles, nil
}

// 将爬虫抓取的数据保存到 GitHub
func saveToGitHub(config Config, data []Article) error {
	ctx := context.Background()
	client := github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
		AccessToken: config.GithubToken,
	})))

	manualArticles := []Article{
		{
			DomainName: "https://foreverblog.cn",
			Name:       "十年之约",
			Title:      "穿梭虫洞-随机访问十年之约友链博客",
			Link:       "https://foreverblog.cn/go.html",
			Date:       "January 01, 2000",
			Avatar:     "https://cos.lhasa.icu/LinksAvatar/foreverblog.cn.png",
		},
		{
			DomainName: "https://www.travellings.cn",
			Name:       "开往",
			Title:      "开往-友链接力",
			Link:       "https://www.travellings.cn/go.html",
			Date:       "January 01, 2000",
			Avatar:     "https://cos.lhasa.icu/LinksAvatar/www.travellings.png",
		},
	}

	data = append(data, manualArticles...)
	jsonData, err := json.Marshal(data)
	if err != nil {
		return err
	}

	filePath := "_data/rss_data.json"
	file, _, resp, err := client.Repositories.GetContents(ctx, config.GithubName, config.GithubRepository, filePath, nil)
	if err != nil && resp.StatusCode == http.StatusNotFound {
		_, _, err := client.Repositories.CreateFile(ctx, config.GithubName, config.GithubRepository, filePath, &github.RepositoryContentFileOptions{
			Message: github.String("Create rss_data.json"),
			Content: jsonData,
			Branch:  github.String("master"),
		})
		if err != nil {
			return fmt.Errorf("error creating rss_data.json in GitHub: %v", err)
		}
		return nil
	} else if err != nil {
		return fmt.Errorf("error checking rss_data.json in GitHub: %v", err)
	}

	_, _, err = client.Repositories.UpdateFile(ctx, config.GithubName, config.GithubRepository, filePath, &github.RepositoryContentFileOptions{
		Message: github.String("Update rss_data.json"),
		Content: jsonData,
		SHA:     github.String(*file.SHA),
		Branch:  github.String("master"),
	})
	if err != nil {
		return fmt.Errorf("error updating rss_data.json in GitHub: %v", err)
	}

	return nil
}

// 从 GitHub 仓库中获取 RSS 文件
func readFeedsFromGitHub(config Config) ([]string, error) {
	ctx := context.Background()
	client := github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
		AccessToken: config.GithubToken,
	})))

	filePath := "_data/rss_feeds.txt"
	file, _, resp, err := client.Repositories.GetContents(ctx, config.GithubName, config.GithubRepository, filePath, nil)
	if err != nil && resp.StatusCode == http.StatusNotFound {
		errMsg := fmt.Sprintf("Error: %s not found in GitHub repository", filePath)
		logError(config, fmt.Sprintf("[%s] [Read RSS file error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), errMsg))
		return nil, fmt.Errorf(errMsg)
	} else if err != nil {
		errMsg := fmt.Sprintf("Error fetching %s from GitHub: %v", filePath, err)
		logError(config, fmt.Sprintf("[%s] [Read RSS file error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), errMsg))
		return nil, fmt.Errorf(errMsg)
	}

	content, err := file.GetContent()
	if err != nil {
		errMsg := fmt.Sprintf("Error decoding %s content: %v", filePath, err)
		logError(config, fmt.Sprintf("[%s] [Read RSS file error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), errMsg))
		return nil, fmt.Errorf(errMsg)
	}

	var feeds []string
	scanner := bufio.NewScanner(bytes.NewReader([]byte(content)))

	for scanner.Scan() {
		feeds = append(feeds, scanner.Text())
	}

	if err := scanner.Err(); err != nil {
		errMsg := fmt.Sprintf("Error reading RSS file content: %v", err)
		logError(config, fmt.Sprintf("[%s] [Read RSS file error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), errMsg))
		return nil, fmt.Errorf(errMsg)
	}

	return feeds, nil
}

func main() {
	config := initConfig()

	// 从 GitHub 仓库中读取 RSS feeds 列表
	rssFeeds, err := readFeedsFromGitHub(config)
	if err != nil {
		logError(config, fmt.Sprintf("[%s] [Read RSS feeds error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), err))
		fmt.Printf("Error reading RSS feeds from GitHub: %v\n", err)
		return
	}

	// 抓取 RSS feeds
	articles, err := fetchRSS(config, rssFeeds)
	if err != nil {
		logError(config, fmt.Sprintf("[%s] [Fetch RSS error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), err))
		fmt.Printf("Error fetching RSS feeds: %v\n", err)
		return
	}

	// 将抓取的数据保存到 GitHub 仓库
	err = saveToGitHub(config, articles)
	if err != nil {
		logError(config, fmt.Sprintf("[%s] [Save data to GitHub error] %v", getBeijingTime().Format("Mon Jan 2 15:04:2006"), err))
		fmt.Printf("Error saving data to GitHub: %v\n", err)
		return
	}
	fmt.Println("Stop writing code and go ride a road bike now!")
}

Go 生成的 json 数据

[
    {
        "domainName": "https://yihui.org",
        "name": "谢益辉",
        "title": "Rd2roxygen",
        "link": "https://yihui.org/rd2roxygen/",
        "date": "April 14, 2024",
        "avatar": "https://cos.lhasa.icu/LinksAvatar/yihui.org.png"
    },
    {
        "domainName": "https://www.laruence.com",
        "name": "风雪之隅",
        "title": "PHP8.0的Named Parameter",
        "link": "https://www.laruence.com/2022/05/10/6192.html",
        "date": "May 10, 2022",
        "avatar": "https://cos.lhasa.icu/LinksAvatar/www.laruence.com.png"
    }
]

Go 生成的日志

[Sat Jul 27 08:42:2024] [Parse RSS error] https://lhasa.icu: Failed to detect feed type

[Sat Jul 27 08:41:2024] [Get RSS error] https://lhasa.icu: Get "https://lhasa.icu": net/http: TLS handshake timeout

Github Actons 1h/次

name: ScheduledRssRawler

on:
  schedule:
    - cron: '0 * * * *'
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: '1.22.5'

    - name: Install dependencies
      run: go mod tidy
      working-directory: ./api

    - name: Build
      run: go build -o main
      working-directory: ./api

    - name: Run Go program
      env:
        TOKEN: ${{ secrets.KEY }}
      run: ./main
      working-directory: ./api

腾讯 COS 也写了一份,Github 有延迟问题就没用,也能用,逻辑上和 Go 是没啥区别

Grab-latest-RSS:https://github.com/achuanya/Grab-latest-RSS

COS Go SDK:https://cloud.tencent.com/document/product/436/31215

效果页:https://lhasa.icu/links.html

  •  

Tencent CDN 流量被恶意盗刷

2024年7月25日 00:44
来自腾讯云的邮件
来自腾讯云的邮件

看到这张图的时候,我很震惊。这个CDN流量包是我昨天凌晨刚买的,直到此刻才发现我的CDN流量被恶意盗刷了。

事情是这样的,前天23号我在写新功能,本地调试调用了很多资源,当时看到消耗了90G的流量,我没有在意,以为是调试的问题。因为那天我写了一天代码,不停地调用Tencent COS,而COS还套了一层Tencent CDN。当时我以为是正常消耗,眼看流量不够,我又充了一个CDN加速包。

然而就在今晚22:45,我骑行回来关闭了免打扰模式,邮箱忽然弹出通知,腾讯云提示我CDN流量不足?我当时非常震惊,因为这是我24号凌晨刚买的流量包啊!

腾讯云 数据分析控制台
腾讯云 数据分析控制台

看到这张图时,我火了,在独立博客圈彻底火了,2天内请求数42万?赶超月光博客!

腾讯云 访问分布
腾讯云 访问分布
TOP ONE 60.221.195.144
TOP ONE 60.221.195.144

回想过去,我在博客圈认识的人一只手能数过来,更谈不上得罪谁。这事也怪我,之前COS没有任何防护,几乎处于裸奔状态。

由于我的博客托管在Github Pages,主机问题大可不必考虑,我能做的只有设置黑白名单和周期限流。

不再裸奔,已老实。

UPDATE 凌晨 02:32

知道怎么回事了,24年后,大陆境内出现一窝狗,利用PCDN恶意流量攻击!

  • 攻击的主要IP来源于山西、江苏和安徽联通等地的固定网段

  • 攻击时间非常规律,集中在19:50到23:00之间

  • 攻击者会针对体积较大的静态文件进行持续攻击

自7月初以来,已转头无差别地对大陆中小型网站展开攻击。

建议将山西等地的IP段暂时屏蔽,减少恶意流量的影响。

目前,GitHub上已经有相关项目 ban-pcdn-ip 用于收集这些恶意IP段。

  •  

公路车管胎被扎,怎么补胎

2024年7月19日 14:38

管胎被扎了,还是后轮,我心如刀绞啊,太贵了,换不起,大多数技师都不会修的

先拆后轮
先拆后轮
Loading EXIF data...

解刨管胎

解刨管胎
解刨管胎
Loading EXIF data...

玻璃渣子把管胎扎透了

扎眼
扎眼
Loading EXIF data...

拿砂纸打磨一下,涂完胶等风干

打磨涂胶
打磨涂胶
Loading EXIF data...

胶风干后贴片,按按揉揉

贴片
贴片
Loading EXIF data...

好多年没做针线活了,没想到今天给管胎缝闭口

穿针引线
穿针引线
Loading EXIF data...
管胎外皮真厚
管胎外皮真厚
Loading EXIF data...

线头有些遭了,扯断好多次

穿针走线
穿针走线
Loading EXIF data...

闭合管胎,涂胶加固

闭合管胎,涂胶加固
闭合管胎,涂胶加固
Loading EXIF data...

安装后轮打气

安装后轮打气
安装后轮打气
Loading EXIF data...

管胎缺点就优点就是轻,比开口胎、真空胎都轻,常用于专业竞赛和环法,就算车胎破了也能继续骑的,而开口胎和真空胎不行。

缺点就是破了就废了,各大车店都是不修管胎的,只能换,这用管胎的成本真是太高了,不是富哥真用不起,这款是意大利产的Challenge Elite Pro 25c,零售价三百多/条,就这还全网缺货,八月初才到货。我的轮组不支持另外两种胎,缝缝补补吧

7-18 晚 测试补充

再次经历三过家门而不入,就是为了凑这个整,今天管胎补的可以经测试一百公里没有问题。现在我心率还不是稳不住,恢复到之前的状态好难啊

测试管胎
测试管胎
  •  

喜提新车 Wilier Cento 10SL

2024年7月12日 22:33

得这辆车纯属缘分,前段时间在网上认识一个宁波的好大哥,没想到去年我们一起参加过同一个比赛,大哥是在宁波鄞州区开自行车店的,聊了许久大哥给我推荐一辆神车Wilier Cento 10SL!这是他朋友的爱车,财富自由润加拿大了,一些不方便带走的东西就卖掉了,这辆车刚到店里第一天,机缘巧合我就赶上了!

配置如下:

  • Wilier Cento 10SL RIM
  • STEMMA SL 把立
  • BARRA SL 弯把
  • WIND BREAK 50框 碳辐条
  • SHIMANO 105 R7000夹气,其他全车Ultegra 8000

算上平踏、水杯架 整车重为7.45KG,一对平踏重0.3KG,换DA夹气分分钟上6!

这台车最吸引我的地方就是,圈刹!SL后最后一代顶级圈刹车,我在网上找了许久都找不到同款,这台车已经停产几年了,太稀有了,

上图

车到了
车到了
Loading EXIF data...
我装车的时候把刹车线芯插坏了
我装车的时候把刹车线芯插坏了
Loading EXIF data...
装好了
装好了
Loading EXIF data...
寄回来之前,技师称重
寄回来之前,技师称重
骑行照
骑行照
Loading EXIF data...
  •  

搞个公众号

2024年3月10日 15:51

改名记录:

  • 2024年02月08日 “阿川的博客”改名“游钓四方的博客”
  • 2019年05月28日 “阿川的个人博客”改名“阿川的博客”
  • 2018年10月26日 注册“阿川的个人博客”

今天捡回了18年注册的公众号,数据重新导了一遍,这手动整理几年的文章数据,我多少有些疲惫

这次熬的有点久了,明歇一天,再写个脚本让 Github Pages 文章自动同步到微信公众号,得想个办法

公众号还需要做个人认证,不然内置超链接是个麻烦事

公众号前端页面也需要重新写一个,腾讯自带UI限制文章数量

任重而道远啊!加油。

  •  

湖畔幽悠

2024年2月16日 00:45

湖畔独坐烟雾缭绕,古典旋律婉转悠扬

一毛钱贫瘠无关乐,心中富足任徜徉

湖畔幽悠
湖畔幽悠
Loading EXIF data...
  •  
❌