阅读视图

发现新文章,点击刷新页面。

注册 Chrome Web Store 开发者

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

注册 WildCard

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

WildCard 开卡账单

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

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

\[\frac{2.77}{75.07} \times 100\% \approx 3.69\%\]

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

注册 Chrome Web Store 开发者

Chrome Web Store 账单

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

个人交易者声明

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

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

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

空腹骑行75公里

周六

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

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

倒挂白鹅

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

龙湖·北岸

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

龙湖·人工沙滩

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

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

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

周日

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

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

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

郑州·贾鲁河桥

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

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

就去买葡萄糖了

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

我前面排了八个人

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

对面就是清明上河园

御河桥洞下

御河桥洞下

正在乐钓的五星开封好市民

STRAVA 164.1km  爬升417m  时间8h 5m

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

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

之前我写过一篇《利用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
    如果 RSS_SOURCE = GITHUB,则可以放在项目中的 data/rss.txt
    如果 RSS_SOURCE = COS,就把它上传到某个 https://xxx.cos.ap-xxx.myqcloud.com/rss.txt

  2. 配置好环境变量:

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

     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
    


  3. 部署并运行

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

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

相关文档

骑行开封

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

到达开封鼓楼

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

Strava记录

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

与王哥的合照

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

干饭

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

鼓楼合影

郑开大道

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

Strava记录

Blog Function Update 2025 (2)

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)

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

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

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

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

刚吃完饭准备出发

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

到达桃花峪-炎黄越野公园

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

桃花峪最陡最烂的坡路

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

到达黄河文化公园 检票口

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

民族摇篮牌坊

炎黄二帝塑像

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

日晷

遇到一位好大哥帮我拍的照片

鼎

广场的某个角落

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

到达黄河岸边

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

星海湖 原相机没有P图 水质水太好了

黄河缆车门口 鸿运当头

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

五龙峰 其中一个山峰顶

五龙峰 黄河少年雕像

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

毛主席视察黄河纪念地下方的超市

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

浮天阁

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

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

东

西

南

北

东南

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

结束

骑行郑州三环

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

郑州龙湖公园

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

公园放养的白天鹅

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

人工沙滩

郑州龙湖公园

骑行郑州三环 结束

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

骑行 金融岛

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

骑行 中原福塔

沪上有明珠,中原有福塔

回家

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

郑州骑行记录

骑行西流湖

骑行西流湖

骑行西流湖

骑行西流湖

探金水河

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

骑行桃花峪

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

骑行桃花峪

骑行桃花峪

骑行桃花峪

骑行桃花峪

骑行桃花峪 结束

这里是:郑州 四环 非机动车道

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

骑行郑州四环

骑行郑州四环

骑行玩手机的下场

骑行郑州四环 结束

十月份看完的第一本书

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

微信读书 · 沧浪之水

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

一场说走就走的骑行

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

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

家门口的早餐店

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

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

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

椿树王庄的日出

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

太清宫外的香火小贩

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

到达 鹿邑·太清宫

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

太清宫·三清大殿

三清大殿后的广场

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

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

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

来奶茶店补个电

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

STRAVA记录

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

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

聊聊自行车

洋车子的起源

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

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

0-100的开始

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

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

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

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

上海永久ZA51-9

富裕的象征

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

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

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

短暂的辉煌

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

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

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

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

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

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

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

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

直到今天,大部分内地品牌在观念和行动上依然停留在过去的成功经验 ,靠坑蒙拐骗人民继续吃老本,最近又冒出一个内地品牌——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;
}

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;
}

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

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

笔记本内部堆满灰尘

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

过多的硅脂溢出

满是灰尘的风扇

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

风满是灰尘的风扇

一抹灰尘,手上黑漆漆的

满是污垢的盖板

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

清理干净,准备组装

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

假一赔命

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

硅脂涂抹过多

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

安装完毕

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

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

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

谈 Linux,Windows 和 Mac

点亮成功

写一个骑行页面

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

在设计这个页面的时候,参考了许多骑行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表单自动化插件

在刷博客的时候,最麻烦的事情之一就是手动填写各种表单。为了提高我的冲浪体验,诞生了这款表单自动化插件。经过爬虫上百次调教,兼容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公里

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

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

奥普帝蒙 黑标分离乳清蛋白粉

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

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

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

到地方了,这骑行路段是挺不错的,我有七年没来这里了,今天来到这里发现大变样了,还可以租皮划艇,改天一定玩玩。在这里绕圈骑了十五公里,又跑去周口公园绕圈,要说这夏天油耗确实高,机动车顶不住,人也顶不住啊!我的水有点不够了,今天才33°,河南的热和江浙沪的热真是不一样,回家后一直不适应,出公园后去蜜雪冰城买了一杯柠檬水,找店里的小妹妹白嫖了两瓶冰水,直奔淮阳区·龙湖

周口植物园

到龙湖后里程已经到了60公里,这时天也暗了,有些许疲惫,主要是颈椎疼,下把位骑多了。吃了两片盐丸,两手握把立边缘慢骑摆烂二十分钟,这个时候一定不能下来歇,推车都不行,下车体力直接归零,不知道别人咋样,我是这样的,与自己较劲,100公里?200公里又算个屁,出来了,就要干

奶奶煮的绿豆粥

在龙湖绕了三圈后达标,真是饿得不行了。到家已经十点,浑身湿透,黏糊糊的,洗了澡,洗了衣服,然后躺下看订阅,期待八月的骑行。

今日有氧100公里完成

利用Go+Github Actions写个定时RSS爬虫

说起这事,还是受一位博友的启发“1900”他的左邻右舍页面很棒,决定模仿一下。我平时也用 Inoreader,但我还是喜欢直接打开博客的感觉,心血来潮,搞。

起初,我打算使用 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: $
      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 流量被恶意盗刷

来自腾讯云的邮件

看到这张图的时候,我很震惊。这个CDN流量包是我昨天凌晨刚买的,直到此刻才发现我的CDN流量被恶意盗刷了。

事情是这样的,前天23号我在写新功能,本地调试调用了很多资源,当时看到消耗了90G的流量,我没有在意,以为是调试的问题。因为那天我写了一天代码,不停地调用Tencent COS,而COS还套了一层Tencent CDN。当时我以为是正常消耗,眼看流量不够,我又充了一个CDN加速包。

然而就在今晚22:45,我骑行回来关闭了免打扰模式,邮箱忽然弹出通知,腾讯云提示我CDN流量不足?我当时非常震惊,因为这是我24号凌晨刚买的流量包啊!

腾讯云 数据分析控制台

看到这张图时,我火了,在独立博客圈彻底火了,2天内请求数42万?赶超月光博客!

腾讯云 访问分布

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段。

公路车管胎被扎,怎么补胎

管胎被扎了,还是后轮,我心如刀绞啊,太贵了,换不起,外面技师都不修管胎的

先拆后轮

解刨管胎

解刨管胎

玻璃渣子把管胎扎透了

扎眼

拿砂纸打磨一下,涂完胶等风干

打磨涂胶

胶风干后贴片,按按揉揉

贴片

好多年没做针线活了,没想到今天给管胎缝闭口

穿针引线

管胎外皮真厚

线头有些遭了,扯断好多次

穿针走线

闭合管胎,涂胶加固

闭合管胎,涂胶加固

安装后轮打气

安装后轮打气

管胎缺点就优点就是轻,比开口胎、真空胎都轻,常用于专业竞赛和环法,就算车胎破了也能继续骑的,而开口胎和真空胎不行。

缺点就是破了就废了,各大车店都是不修管胎的,只能换,这用管胎的成本真是太高了,不是富哥真用不起,这款是意大利产的Challenge Elite Pro 25c,零售价三百多/条,就这还全网缺货,八月初才到货。我的轮组不支持另外两种胎,缝缝补补吧

7-18 晚 测试补充

再次经历三过家门而不入,就是为了凑这个整,今天管胎补的可以经测试一百公里没有问题。现在我心率还不是稳不住,恢复到之前的状态好难啊

测试

❌