生日骑遇“死亡摇摆”,差点成忌日骑
农历五月二十九,二十五岁生日到了
一边上班,一边创业,这样的活法让我骑车的时间越来越少。这不是我想要的状态,但为了三十岁之前出发环球骑行的梦想,我没有办法。
前几日申请了调休,可算睡个安稳觉。早上睡到十点钟,吃完午饭,我觉得今天不能再碰电脑了,我现在很想出去骑车。可只剩下一个下午的时间了,太远去不了,近的没意思。打开高德地图扒拉半天,去横店。

农历五月二十九,二十五岁生日到了
一边上班,一边创业,这样的活法让我骑车的时间越来越少。这不是我想要的状态,但为了三十岁之前出发环球骑行的梦想,我没有办法。
前几日申请了调休,可算睡个安稳觉。早上睡到十点钟,吃完午饭,我觉得今天不能再碰电脑了,我现在很想出去骑车。可只剩下一个下午的时间了,太远去不了,近的没意思。打开高德地图扒拉半天,去横店。
我们生命中最美好的时刻,并非是那些接受给予、放松享受的时刻,而是那些为了完成一件困难而有价值的事情,自愿将身心发挥到极限的时刻。
像咱这种人,我觉得只有一种死法 —— 猝死。
从6月10日早上九点,一直干到6月11日下午一点半,眼睛都没怎么合过。足足三十多个小时,一口气用Go,把整个企业级的RESTful API给撸完了!
这可不是简单的CtrlCV,为了支持硬件,软件下了点功夫:MySQL做了主从复制架构 + Redis缓存 + 负载均衡。支持 Docker 一键部署。还没做压力测试,自我感觉并发二三十万如喝水。
上午测完接口,兴奋的饭都顾不上吃,更别说休息了。立马开始写文档。接口文档、数据库文档…
先用Cursor生成基本文档,再改改。每份大概3000字800行左右,足足写了八份。
还有商业计划书的思维导图,2M的大小,不放大到190%字体都看不清楚,内容密度可想而知。
真的要特别感谢 Cursor,这AI工具真是赶上了好时代!要是没有它,就凭这些工作量,外包团队没一个月起步都别想搞定。
不敢想象,我竟然在三十多个小时里,就搞定了这么多事,而且落地质量高!
接下来,还剩微信小程序对接接口,以及和硬件的联调。先给这些小卡拉米放放假,我要好好休息一个下午。
此刻,精力充沛去吃个早饭骑骑车。
实际上,我做的这些事情有没有公司都能推进,但没办法,就为了微信小程序后续的企业认证需求.
在浙江线上办理,全程可以不出门。
項目 | 內容 |
---|---|
企业名称 | 浪泳科技(义乌)有限公司 |
自主申报预选号 | [2025]****** |
拟登记机关 | 义乌市市场监督管理局 |
住所所在地 | ****** |
生产经营地 | ****** |
法定代表人 | ****** |
从业人数 | 2 |
联系电话 | 186****7426 |
邮政编码 | 322000 |
注册资本 | 0.001万元 |
企业类型 | 有限责任公司(自然人独资) |
行业 | (6513) 应用软件开发 |
是否一照多址 | 否 |
经营范围 | 写不下 |
提交申请还不到俩小时,我就收到了驳回通知:
您申报的“浪泳科技(义乌)有限公司”设立登记申请,预审未通过,请补齐材料后再次申报。
注册资本一元行不通,调整到了一万
经营范围调整为:
版本:v1.1.1
经过两个月的偷懒,EasyFill 迎来了 v1.1.1 版本的重大更新。这次更新主要在匹配算法上进行大幅度优化,全面提升匹配效率
我发现有些评论系统通过 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);
}
});
}
在 v1.0 版本,EasyFill 只支持 name 字段识别。为了更加准确的匹配字段,引入了全新三种字段识别方式,确保在各种稀奇古怪的表单都可以识别:
通过分析输入框的 placeholder
属性来识别字段类型:
<input placeholder="请输入您的姓名" />
<input placeholder="邮箱地址" />
<input placeholder="个人网站" />
基于 HTML5 标准的 type
属性进行智能识别:
<input type="email" />
<input type="url" />
<input type="text" name="username" />
通过元素的 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)
});
});
v1.1.1 版本允许用户完全自定义关键字数据源。
该源来自我的腾讯云 COS,且由腾讯云境内 CDN 加速,基本上无延迟:
https://cos.lhasa.icu/EasyFill/keywords.json
自定义数据源格式示例:
{
"name": ["name", "author", "username", "昵称", "姓名"],
"email": ["email", "mail", "邮箱", "电子邮件"],
"url": ["url", "website", "blog", "网站", "博客"]
}
实现了基于 HTTP 标准的智能缓存系统,大幅减少不必要的网络请求:
ETag 和 Last-Modified 支持:
if (etag && !forceSync) {
headers['If-None-Match'] = etag;
}
if (lastModified && !forceSync) {
headers['If-Modified-Since'] = lastModified;
}
实现 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);
}
};
实现 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);
};
EasyFill v1.1.1 实现了单例日志系统,支持 INFO、WARN、ERROR 三个级别:
export enum LogLevel {
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
}
日志系统能够根据运行环境自动调整输出策略:
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;
}
生产状态下,日志默认关闭。所以,增加了命令调试:
// 启用日志系统
EasyFillLogger.enable()
// 关闭日志系统
EasyFillLogger.disable()
// 查看当前状态
EasyFillLogger.status()
命令绑定在全局 window 对象上,重启浏览器仍有效。
在浏览器扩展环境中,使用 chrome.storage.local 来存储,在普通网页环境中,使用 localStorage。
一样的是都用 easyfill_logger_enabled 这个键来存储
支持灵活的链式配置:
logger
.setLevel(LogLevel.INFO)
.useColors(true)
.showTimestamp(true)
.setPrefix('[EasyFill]')
.setPrefixColor('color: #4CAF50; font-weight: bold');
配置选项:
v1.1.1 版本对隐私权政策进行了全面更新:
主要更新内容:
新增了直观的同步设置界面,轻松管理数据同步:
EasyFill 的每一次进步都离不开用户的支持和反馈。特别感谢:Mainbranch 的反馈与支持
如果您觉得 EasyFill 对您有帮助,欢迎:
EasyFill v1.1.1 现已在 Chrome 应用商店正式发布,您可以:
EasyFill - 简易填充,让每一次评论更自然,与你的博友互动无缝连接
今天是我独立博客走过的第八个年头。还记得那一年怀着对独立站的疑问,给孔令贤发邮件,询问是否可以使用他写的轮子,就是从他回复我那一刻起,我掉进了 WEB 深渊。
独立博客这个词,在 2025 年这个年代确实足够小众,但其中的快乐和对生活的态度,想必也只有博友能理解。
正是为了这第八个年头,才有了今天这全新的博客。从年初用 Jekyll 从零开始写,后来又用 Recat 写个半成品。最终阴差阳错选择了开源的 Astro Paper。
Astro Paper 这款主题性能极强,可拓展性也非常高,这也得益于 Astro 的静态特性和原作者优越设计。
经过一段时间的二次开发,这个博客差不多达到了我理想的样子。
在全站无缝刷新的基础下,我把博客全站的图片都做了懒加载,订阅和归档模块也做了滚动懒加载。
再加上页面内链的预加载处理,无论你点击哪个页面,都是一种享受。
下面就把新增的功能一一道来。
Astro Paper 原生不支持平铺式 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 做了运动数据的可视化。
借助腾讯云数据万象 API,默认自动启用。
import Img from "@/components/Img.astro";
<Img src={`${IMAGES}/${frontmatter.slug}/20250524003018.jpg`} />
早上睡到九点钟,很困,睡不醒。直觉告诉我,我现在有种嗜睡的状态。实际上我这段时间都没有睡醒过,因为我时常编码到凌晨三四点,甚至通宵整晚,到了点直接去上班,也是常有的事。
傍晚下了班吃完饭,出去跑跑步,回到家已经十点钟了,直接脱光站在花洒下,任凭水滴打着脸,我很享受这种感觉,这让我觉得,只有现在的我是清醒的,回头望这一天,怎么想也没有觉得什么事是有意义的,想做的事总赶不上时间,原来人的精力和时间都是有限的。
我这个人对时间不敏感,可以说我是固执吧,我想,我很多时间我都花费在这性格上,但若是放在编码上也颇有钻研精神,为爱发电,成败与否不重要了,值了。
总的来说我这人很怪,我不知道该归类到哪种类型,大概可以用那句话来形容:“见人说人话,见鬼说鬼话。”
这两年,我的心态变了很多,也不知道从什么时候养成了一种无所谓的态度,大概是真的真躺平了,之前那种中年危机感也消失了,不再内耗了。
说到中年危机,比我年长的朋友总笑,毕竟我才二十四岁,距离二十五还有一个多月,但事实就是那样。
失业第二天,暂时也不想找工作,只想出去走走,手里米不多,紧着过。我是一个比较容易满足的人,吃穿不讲究,哪怕吃口馒头住桥洞呢,毕竟也不是第一次了。
但这精神的苦,是真快忍不住了。来义乌二十天有余,就没有出过几次车,总想着跑步发泄一下吧,时间重要,骑车耽误时间,去你妈的时间。
4月24日,递交完辞职报告的那一刻,我心中一阵轻松。我终于可以离开了,离开熟悉的一切,前往下一个未知但让我心跳的地方——义乌。
原本的计划是从郑州骑行到义乌,约一千公里。然而,我的长途骑行装备都还在老家淮阳,不得不先折返一趟,郑州到淮阳≈210公里。
4月26日,天还没亮我就起床了,所有行李早已打包妥当,多亏了顺道的好大哥和双双姐姐,他会把行李直接送到义乌,精准投放到阿丽家门口。所以我只需要背上包、骑车走就行了。
临出门前,我把钥匙存放在小区门口的保安亭,与保安打了招呼,等双姐有空再来取。
05:42 AM 郑州·孙庄北院
从跑步小白,到不间断半马需要多久?答案是六天,五次!
2025年4月20日 · 跑步第四天。早上起床时心情很好,因为我发现大腿已经不疼了,想必是适应了十公里的运动量,我决定今天晚上下班后,开始挑战不间断十公里
从讨厌到上瘾,原来跑步也能这样有趣.
我一直是个不爱运动的人,尤其讨厌跑步。打小起,我对跑步总是敬而远之
这次之所以开始跑步,完全是被阿坤和阿丽带动的
起初只是想着陪他们减肥,没想到,从第三天开始,我居然有点跑上瘾了
第一次跑步是阿坤叫我的,他想减肥,我就陪他出来遛弯。他说目标是 5 公里,结果我们大半时间都在走路,实际上只跑了 3 公里
他有点胖,体力跟不上,但我直到活动结束都没有什么感觉
第二天我刚下晚班(19:00),我打电话问阿坤什么时候出发,他说八点半。我不想等太久,就先回家收拾一下便出门了
第一天穿板鞋和牛仔裤实在太难受,这次吸取了教训,只穿了短裤、速干背心和跑鞋
站在小区门口花两分钟热热身,把软件都打开便开始跑了
刚开始跑到 0.86 公里 时,心率就达到了 183,但呼吸还算平稳
跑到 2 公里时,心率稳定在 168–170,最终顺利完成不间断五公里,一点都不觉得累,只是非常口渴
跑完后在楼下买了瓶水,还给阿坤发了个微信。结果瓶盖还都没拧开,就下起了暴雨,就像是天上开了个花洒一样,很突然…
该脚本基于 Strava API v3 获取指定用户当年的所有骑行活动数据,并将其保存为JSON格式
Strava Riding Api 只实现了 OAuth 2.0 授权流程的部分自动化,由于技术限制,目前无法实现完全自动化:
已实现部分
重要: 在使用此脚本前,请确保在Strava开发者平台上正确配置您的应用:
localhost
注意:只需输入 localhost
而不是完整的 http://localhost:8000
安装依赖:
yarn install
获取并处理授权码:
yarn auth
获取授权后,您会收到一个授权码。将其粘贴到命令行中。
获取骑行数据:
yarn start
查看输出的JSON文件,文件名格式为:strava_data.json
如果您遇到API相关错误,请尝试以下解决方案:
更新令牌:
yarn auth
重新获取授权并更新令牌
检查API状态:
访问 Strava API状态 确认服务是否正常
“protocol mismatch”错误:
localhost
作为授权回调域
无法获取活动数据:
API错误或限流:
本项目采用 Mozilla 公共许可证 2.0 版发布
Strava API v3:https://developers.strava.com/docs/reference
Strava Riding Api:https://github.com/achuanya/Strava-Riding-Api
就在刚刚 EasyFill 终于通过了 Chrome Web Store 的审核,正式发布了!
EasyFill
。查看 更新日志 了解最新功能和修复。
如果你在使用过程中遇到问题,请在我的博客留言。
感谢您对我的支持,本人非程序员,忙里抽闲,为爱发电。
如果您觉得 EasyFill 对您有帮助,可以通过以下方式支持我继续创作:
本项目基于 Mozilla Public License Version 2.0。
Github 仓库:https://github.com/achuanya/EasyFill
✨ EasyFill 只为向那些在浮躁时代,依然坚守独立博客精神的你们致敬!
晚上下了班打开电脑刚坐下就看到了一封 Google 邮件,首先看到了发件人 “Chrome Web Store”,当时就心想提交审核一个多星期了,终于看到一点音信了。点开后,还没等我高兴,便看到了:
被拒的原因非常低级,声明了但未使用的 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,直到现在这个问题也没有解决,希望下个版本可以解决问题
产品谍照:
年前曾尝试过 Chrome 扩展开发,《写一个Chrome表单自动化插件》,但是由于没有注册 Chrome Web Store 开发者,无法上传到 Chrome 应用商店。
Chrome 注册开发者需要五美元,由于我没有境外信用卡就一直卡在这,2022 年我在杭州办过一张中信的双币卡,年费很高,后来经济紧张时注销了,现在急着用外币还挺麻烦,折腾一圈,最终无脑选择了 WildCard,尽管网上对它负面评论铺天盖地。
[WildCard][p3] 开卡费用是 10.99 美元/年,实际付款 79.71 人民币,按照今天的市场汇率 7.23,实际多付了 0.24,而且这只是开卡费用,充值另算。
开卡后我充值了 10 美元,支付宝付款 75.07,到账金额 10 美元:
75.072.77×100%≈3.69%四个点我能接受(接不接受都要受着),这个开卡费不便宜,毕竟钱不是大风刮来的,所以注册时,我创建了两个号,推荐注册返现两美金…
注册账号就很容易了,Google 绑卡付钱就行。但是如果要销售发布就很麻烦:
因为 Google 已退出中国市场,不支持交易。而我是美国 Visa 卡,面对这样的要求不容易做到。
日后再说吧,往后这段时间,我打算把博客评论表单自动填充插件重构一下,然后上架 Chrome 应用商店。
之前我写过一篇《利用Go+Github Actions写个定时RSS爬虫》来实现这一目的,主要是用 GitHub Actions + Go 进行持续的 RSS 拉取,再把结果上传到 GitHub Pages 站点
但是遇到一些网络延迟、TLS 超时问题,导致订阅页面访问速度奇慢,抓取的数据也不完整,后来时断时续半个月重构了代码,进一步增强了并发和容错机制
在此感谢 GPT o1 给予的帮助,我已经脱离老本行很多年了,重构的压力真不小,有空就利用下班的时间进行调试,在今天凌晨 03:00 我终于写完了
旧版本主要基于 GitHub Actions 的定时触发,抓取完后把结果存放进 _data/rss_data.json 然后 Jekyll 就可以直接引用这个文件来展示订阅,但是这个方案有诸多不足:
网络不稳定导致的抓取失败
由于原先的重试机制不够完善,GitHub Actions 在国外,RSS 站点大多在国内,一旦连接超时就挂,一些 RSS 无法成功抓取
单线程串行,速度偏慢
旧版本一次只能串行抓取 RSS,效率低,数量稍多就拉长整体执行时间,再加上外网到内地的延时,更显迟缓
日志不够完善
出错时写到的日志文件只有大概的错误描述,无法区分是解析失败、头像链接失效还是RSS本身问题,排查不便
访问速度影响大
这是主要的重构原因!在旧版本里,抓取后的 JSON 数据是要存储到 Github 仓库的,虽然有 CDN 加持,但 GitHub Pages 的定时任务会引起连锁反应,当新内容刷新时容易出现访问延迟,极端情况下网页都挂了
重构后,在此基础上进行了大幅重构,引入了并发抓取 + 指数退避重试 + GitHub/COS 双端存储的能力,抓取稳定性和页面访问速度都得到显著提升
先看个简单的流程图
+--------------------------+
| 1. 读取RSS列表(双端可选) |
+------------+-------------+
|
v
+---------------------+
| 2. 并发抓取RSS,限流 |
| (max concurrency) |
+-------+-------------+
|
v
+------------------------------+
| 3. 指数退避算法 (重试解析失败) |
+------------------------------+
|
v
+-------------------+
| 4. 结果整合排序 |
+--------+----------+
|
v
+-------------------------+
| 5. 上传 RSS (双端可选) |
+-------------------------+
|
v
+--------------------+
| 6. 写日志到GitHub |
+--------------------+
并发抓取 + 限流
通过 Go 的 goroutine 并发抓取 RSS,同时用一个 channel 来限制最大并发数
指数退避重试
每个 RSS 如果第一次抓取失败,则会间隔几秒后再次重试,且间隔呈指数级递增(1s -> 2s -> 4s),最多重试三次,极大提高成功率
灵活存储
RSS_SOURCE: 可以决定从 COS 读取一个远程 txt 文件(里面存放 RSS 列表),或直接从 GitHub 的 data/rss.txt 读取
SAVE_TARGET: 可以把抓取结果上传到 GitHub,或者传到腾讯云 COS
日志自动清理
每次成功写入日志后,会检查 logs/ 目录下的日志文件,若超过 7 天就自动删除,避免日志越积越多
上一次写指数退避,还是在养老院写PHP的时候,时过境迁啊,这段算法我调试了很久,其实不难,也就是说失败一次,就等待更长的时间再重试,配置如下:
也就是说失败一次就加倍等待,下次若依然失败就再加倍,如果三次都失败则放弃处理
// 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
}
为避免一下子开几十上百个协程导致阻塞,可以配合一个带缓存大小的 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)
}
容错率显著提升
遇到网络抖动、超时等问题,能以10路并发限制式自动重试,很少出现直接拿不到数据
抓取速度更快
以 10 路并发为例,对于数量多的 RSS,速度提升明显
日志分类更细
分清哪条 RSS 是解析失败,哪条头像挂了,哪条本身有问题,后续维护比只给个403 Forbidden方便太多
支持 COS
可将最终 data.json 放在 COS 上进行 CDN 加速;也能继续放在 GitHub,视自己需求而定
自动清理过期日志
每次抓取后检查 logs/ 目录下 7 天之前的日志并删除,不用手工清理了
抓取到的文章信息会按时间降序排列,示例:
{
"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"
}
程序每次运行完毕后,把抓取统计和问题列表写到 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
如果你也想玩玩 LhasaRSS
准备一份 RSS 列表(TXT):
格式:每行一个 URL<br/>
如果 RSS_SOURCE = GITHUB,则可以放在项目中的 data/rss.txt<br/>
如果 RSS_SOURCE = COS,就把它上传到某个 https://xxx.cos.ap-xxx.myqcloud.com/rss.txt
配置好环境变量:
默认所有数据保存到 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 %}
部署并运行
只需 go run . 或在 GitHub Actions workflow_dispatch 触发 运行结束后,就会在 data 文件夹更新 data.json,日志则写进 GitHub logs/ 目录,并且自动清理旧日志
注:如果你依旧想完全托管在 COS 上,需要把 RSS_SOURCE 和 SAVE_TARGET 都写为 COS,然后使用 GitHub Actions 去调度
sitemap.xml
和 sitemap.txt
,自动生成,不再手动更新!之前我一直使用 xml-sitemaps
手动生成sitemap.xml
,但每当 URL 新增或变更都需要手动提交。实在麻烦!所以,今日用 Liquid 实现自动生成,一劳永逸
1.0
),其他页面次之 (0.8
)0.9
,半年内 0.8
,一年内 0.6
),让新内容更容易被搜索引擎收录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 适用于不支持 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 %}
确保搜索引擎能找到 Sitemap
,需要在 robots.txt
文件中声明 sitemap.xml
和 sitemap.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
由于郑州最近的雨夹雪天气,已经一周没有骑行了,实在憋得不行,给自己找点事做,今天中午下班时更新了一下博客
node-sass
包,由 sass
代替当前的柱形图仅为有骑行数据的周生成柱形图,导致柱形图与日历中的周对齐错位,所以即某周没有骑行数据时,柱形图也要生成一根柱子
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();
}
// 显示总活动数和总公里数
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);
})();
两年前在冲浪时下载的,已经是第二次用了:
// default.html
include lantern.html
// main.scss
@use 'lantern'
node-sass 是基于 LibSass 库构建的,而 LibSass 从 2019 年就停止了更新。所以,Sass 团队放弃了这个项目,重构了 sass(Dart 编写)
原生支持 Dart
sass 是由 Dart 编写,它不再依赖 C++ 编译器,安装和构建速度更快
不再依赖编译
node-sass 需要本地编译,会遇到编译问题,尤其是 Windows 系统上。而 sass 是纯 JavaScript 实现,跨平台时不会有编译问题
{
"devDependencies": {
"sass": "^1.83.4",
}
}
周末假期被骑行占有,那种满足是一切都比不过
为这本书,我近两天几乎废寝忘食,心中感慨颇多,最深刻的收获便是对人性与形式的深刻理解。许多事情在当时看来似乎是理所当然,但如今回首,才发现那时的自己是多么稚嫩,甚至有些可笑
这是我第一次尝试电子书,有声加文字的双重体验感觉不错,美中不足的是,机械式的发音让情感显得苍白,在这个时代,做个拟人化的语音合成也不难啊,腾讯读书这块业务还是小众,资源太少了,很多我想看的书都找不到,涉及敏感话题的搜都搜不到,哎
在河南老家,人们通常称自行车为“洋车子”。确实,是洋鬼子的杰作,早在1791年,法国佬西夫拉克设计出了世界上第一辆自行车,据说他是受溜冰鞋的启发,整车为木制,上管由一根大横梁作为主体车架,下方装两个木制车轮,没有转向结构,拐弯全靠搬。当然,它也没有传动系统,骑多快这就真看腿了
追溯历史,19世纪六七十年代,自行车由洋鬼子、华人带入中国,数量寥寥
随着时间的推移,20世纪二十年代,上海做自行车销售的同昌车行开始仿制生自行车零部件,再搭配进口件的基础上进行组装贴牌。从此内地第一家仿制、组装整车的自行车企业诞生,据注册登记记载有:飞马、飞鹰、飞人、飞轮等型号。直到新中国1956年国家队将其收编、创新再贴牌。由此诞生:上海凤凰
新中国1955-1957年期间,是内地自行车产业发展的关键时期,由主管自行车工业的工信部,组织上海、天津、沈阳的自行车厂进行标准化设计,确定了28寸自行车的规格和零配件的质量标准,为内地自行车行业制定了第一部行业标准,为后来几十年的自行车普及奠定了基础
到了新中国六七十年代,自行车在全国范围内迅速普及,尽管当时的产业链尚不完善,但许多品牌已逐渐崭露头角,下面有请八十年代国产五虎上将:上海永久、凤凰、天津飞鸽、红旗、江苏金狮
新中国1981年9月,三流日报曾转载了一篇文章:湖北农民杨小运在超额完成国家交售指标后,县里问他想要什么奖励,他说想要一辆永久牌自行车。很多年后,杨小运回忆:“我是壮起胆子提出了自己的想法。须知道那时候能够骑上一辆永久牌自行车是多么得意的事情,因为据说只有凭什么“工业券”才能买到这种稀罕物,比现在坐小轿车还要显摆,简直就是一种身份的象征。在我的印象中,好像只有公社党委书记这样的人物才配有这么一辆自行车
由此可见,自行车不仅是代步工具,更是那个年代富裕与社会地位的象征,俗称小资三件套:缝纫机、手表,自行车
然而,好景不长。随着改革开放的推进,外资品牌涌入中国市场,这些闭门仿车国产老品牌被外资的新工业设计所冲击,如:GIANT、Merida、Trek等等
此时的自行车带来最直接的就是视觉感官,功能多样的车架和丰富多彩的颜色,彻底颠覆了内地自行车的固有形象。相比之下,国产品牌的市场份额断崖式下跌,怕不是编制兜底,如今已全部倒闭,至此,沪上小资品牌殊荣不再
在新工业品牌中,最具代表性的是GIANT,尽管价格昂贵,但其时尚的设计和优越的性能激发了人民的购买欲望,迅速取代国产五虎上将,成为新一代的“高级自行车”,GIANT驻扎内地后年年蝉联市场第一,这一变革标志着中国内地自行车市场的一个重要转折点
从改革开放到21世纪初的几十年间,中国自行车运动突然就死在了襁褓里,由于汽车的普及和部分国人的观念癌症,自行车逐渐边缘化,成为人们眼中过时的交通工具。就此,内地再也没有形成新的自行车运动
而在此期间,外面的世界发生了天翻地覆的变化。UCI在瑞士洛桑成立了世界自行车中心和场地车训练场,致力于培训精英选手并为赛事提供支持。高卢鸡的环法赛愈演愈烈,成为全球最受瞩目的公路自行车赛事之一。与此同时,小日子的Shimano凭借革命性的STI技术,将刹车和变速融合,逐步通过专利,垄断全球自行车零部件市场
反观中国内地。自行车产业在这一波全球变革中停滞不前,国产品牌没有主动认清提升技术和产品价值的重要性,依然闭门造车,专注于下沉市场。没有技术,没有创新,短期图利坑蒙拐骗,论长期发展无异于自我毁灭
直到今天,大部分内地品牌在观念和行动上依然停留在过去的成功经验 ,靠坑蒙拐骗人民继续吃老本,最近又冒出一个内地品牌——Maserati玛莎拉蒂自行车,一个车架都要使用公模,搭配些工业垃圾套件组装。技术水平确实了不起,至少标贴得还算正。我都感到丢人,是不是人民好糊弄?都当成傻子?《这些,它们够用了》
进入21世纪后,随着资本经济的涌入,共享单车兴起,给中国的自行车运动带来了新的生机,环保意识的提升和健康生活方式的推广,使得越来越多的人将骑行作为健身方式和生活态度,骑行团体的壮大和各种赛事也愈发频繁
疫情期间,由于长时间的封闭管控,许多人渴望户外活动的机会,当管控解除后,户外运动迅速升温,体育及户外运动板块纷纷涨停板,骑行也因此成为了炙手可热的选择之一
然而,在各方面利好的同时,骑行运动也面临诸多挑战,在鄙人看来,部分国人对于新鲜事物的包容度较低,这与千百年来的文化属性密不可分
在这个二十一世纪的现代社会,有些人甚至认为骑行是“文化入侵”,这一观点显然过于狭隘,抱着自由言论的心态,鄙人不敢苟同
中国作为一个发展中国家,许多城市的道路基础设施尚未完善,尤其是在道路规划方面,机动车道与非机动车道的设计往往缺乏科学性和合理性,例如,原本就狭窄的非机动车道常常被汽车占用,加上逆行和鬼探头等行为,骑行安全难以保障,导致了骑行风险的增加。在这种情况下,所谓的“暴骑团”被迫驶入机动车道,进一步加剧了骑行者与机动车之间的矛盾,交通事故频发
此外,骑行者的素质问题也是不可忽视的因素。部分骑行者缺乏基本的交规意识、逆行、闯红灯等行为屡见不鲜。面对这种情况,我只能说,管好自己
关于最近发生的河北亲子骑行事件,引发了社会的广泛关注和讨论,不少网友竟表示同情,在视频中司机被殴打被迫下跪,什么死者为大?呸恶心。鄙人认为这是家长全责!缺乏对骑行的敬畏之心,忽视了安全因素。对于部分圣母指责辱骂司机我只想说
在这个环境内,你可以漠视周围发生的一切不公平,直到这种不公平在某一天以一种你无法预期的方式降临到你身上
在前几天写的数据展示页面中,日历与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;
}
// 涉及函数: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);
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;
}
下午,笔记本忽然发出了咔哧咔哧的响声,仿佛是临死前的挣扎。我知道,又是哪零件坏了。大半年没清灰,这机器,似乎比人还脆弱
作为一个爱好骑行的博主,总觉得博客里少了点什么,骑行骑行的,怎么能没有一个专门的骑行数据展示页呢
在设计这个页面的时候,参考了许多骑行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
目前所有的逻辑都在这一个文件里完成,现在的功能还是个雏,因为没有打通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);
});
}
骑行统计页面不会止步于此,接下来还会有很大的延申改动,我提前把变量接口留好了,定义了一些主样式变量,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';
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、懒加载上线
在刷博客的时候,最麻烦的事情之一就是手动填写各种表单。为了提高我的冲浪体验,诞生了这款表单自动化插件。经过爬虫上百次调教,兼容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
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文件是插件的最重要的核心模块,涉及到了插件的主要输出功能
// 遍历当前页面所有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();
}
});
该文件负责向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等骑车回来再做
Form-automation-plugin:https://github.com/achuanya/Form-automation-plugin
今天是七月的最后一天,晚上必须来一次有氧小长途骑行,目标暂且定为100公里
出发前,先泡两瓶蛋白粉放进冰箱,一瓶550ML,一瓶750ML,我还是觉得不够用。骑车过程中不想下来,容易打断节奏。我打算坐尾再安装一个支架放一瓶750ML,只要室外温度不是特变态,百里油耗三瓶水没有问题。
说起这事,还是受一位博友的启发“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
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!")
}
[
{
"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"
}
]
[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
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
看到这张图的时候,我很震惊。这个CDN流量包是我昨天凌晨刚买的,直到此刻才发现我的CDN流量被恶意盗刷了。
事情是这样的,前天23号我在写新功能,本地调试调用了很多资源,当时看到消耗了90G的流量,我没有在意,以为是调试的问题。因为那天我写了一天代码,不停地调用Tencent COS,而COS还套了一层Tencent CDN。当时我以为是正常消耗,眼看流量不够,我又充了一个CDN加速包。
然而就在今晚22:45,我骑行回来关闭了免打扰模式,邮箱忽然弹出通知,腾讯云提示我CDN流量不足?我当时非常震惊,因为这是我24号凌晨刚买的流量包啊!
看到这张图时,我火了,在独立博客圈彻底火了,2天内请求数42万?赶超月光博客!
回想过去,我在博客圈认识的人一只手能数过来,更谈不上得罪谁。这事也怪我,之前COS没有任何防护,几乎处于裸奔状态。
由于我的博客托管在Github Pages,主机问题大可不必考虑,我能做的只有设置黑白名单和周期限流。
不再裸奔,已老实。
知道怎么回事了,24年后,大陆境内出现一窝狗,利用PCDN恶意流量攻击!
攻击的主要IP来源于山西、江苏和安徽联通等地的固定网段
攻击时间非常规律,集中在19:50到23:00之间
攻击者会针对体积较大的静态文件进行持续攻击
自7月初以来,已转头无差别地对大陆中小型网站展开攻击。
建议将山西等地的IP段暂时屏蔽,减少恶意流量的影响。
目前,GitHub上已经有相关项目 ban-pcdn-ip 用于收集这些恶意IP段。
管胎被扎了,还是后轮,我心如刀绞啊,太贵了,换不起,大多数技师都不会修的
得这辆车纯属缘分,前段时间在网上认识一个宁波的好大哥,没想到去年我们一起参加过同一个比赛,大哥是在宁波鄞州区开自行车店的,聊了许久大哥给我推荐一辆神车Wilier Cento 10SL!这是他朋友的爱车,财富自由润加拿大了,一些不方便带走的东西就卖掉了,这辆车刚到店里第一天,机缘巧合我就赶上了!
算上平踏、水杯架 整车重为7.45KG,一对平踏重0.3KG,换DA夹气分分钟上6!
这台车最吸引我的地方就是,圈刹!SL后最后一代顶级圈刹车,我在网上找了许久都找不到同款,这台车已经停产几年了,太稀有了,