MoreRSS

site iconLiHan | 李寒修改

研究生,中国传媒大学
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

LiHan | 李寒的 RSS 预览

使用 Foxy Proxy 插件实现优雅的浏览器网页流量代理

2026-04-21 17:40:00

Featured image of post 使用 Foxy Proxy 插件实现优雅的浏览器网页流量代理

问题

为了保持对 GPT Gemini 等的访问,电脑开着系统代理,导致影响对国内网站的访问(速度)的问题,个别情况甚至会因 IP 异常导致封号。

解决

  • 使用 Foxy Proxy 插件 来管理浏览器的代理设置,实现对不同网站的流量进行智能分流。

1. 配置电脑系统代理-以 Win11+Clash Verge 为例

在 Clash 中导入订阅后,进入 设置-Clash 设置-端口设置,将代理端口设置好(默认为 7897),并确保 允许局域网连接 已开启。

clash

2. 配置 Foxy Proxy 插件

  1. 在 Chrome 浏览器中安装 Foxy Proxy 插件 插件。
  2. 点击浏览器右上角的 Foxy Proxy 图标,选择 Options 进入设置页面。
  3. 进入 Proxies 页面,点击 Add 添加一个新的代理配置,填写以下信息(切记点击 Save 保存):
    • Title: Clash Verge
    • Type: SOCKS5
    • Hostname: 127.0.0.1
    • Port: 7897

foxy_proxy

3. 配置规则

  1. 在 Foxy Proxy 刚刚添加的 Clash Verge 代理配置下,点击 Proxy by Patterns 左边的 “+” 号添加新的规则。
  2. 首先配置 All 规则,允许所有流量走系统代理(即 Clash Verge),规则如下:
    • include
    • Wildcard
    • Title: All
    • Pattern: *
  3. 接着配置国内网站规则,允许国内网站直接访问,规则如下:
    • exclude
    • Wildcard
    • Title: Baidu
    • Pattern: *.baidu.com

foxy_proxy_2

注意,特定网站的流量并不止访问域名,还可能涉及到其他域名(如 CDN、API 等),因此需要根据实际情况添加更多的规则来覆盖这些相关域名。 可以通过 F12 开发者工具中的 NetworkSource-Page 面板来查看访问的域名,并将这些域名添加到 Foxy Proxy 的规则中。

baidu

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
// 一个过滤规则
[
 {
 "include": "include",
 "type": "wildcard",
 "title": "All (兜底规则)",
 "pattern": "*",
 "active": true
 },
 {
 "include": "exclude",
 "type": "regex",
 "title": "Intranet 10.x.x.x",
 "pattern": "^(http|ws)s?://10(\\.\\d+){3}(:\\d+)?(/.*)?$",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Schools",
 "pattern": "*.nwpu.edu.cn",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Schools",
 "pattern": "*.cuc.edu.cn",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "DeepSeek",
 "pattern": "*.deepseek.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Baidu Subdomains",
 "pattern": "*.baidu.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Baidu Root",
 "pattern": "baidu.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Baidu CDN",
 "pattern": "*.bdimg.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Baidu CDN",
 "pattern": "*.bdstatic.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Bilibili Subdomains",
 "pattern": "*.bilibili.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Bilibili Root",
 "pattern": "bilibili.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Bilibili CDN",
 "pattern": "*.hdslb.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Bilibili Video CDN",
 "pattern": "*.bilivideo.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Bilibili CDN",
 "pattern": "*.szbdyd.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Netease Subdomains",
 "pattern": "*.163.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Netease Root",
 "pattern": "163.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Netease CDN",
 "pattern": "*.127.net",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Netease Cloud",
 "pattern": "*.163yun.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Zhihu Subdomains",
 "pattern": "*.zhihu.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Zhihu Root",
 "pattern": "zhihu.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Zhihu CDN",
 "pattern": "*.zhimg.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "CSDN Subdomains",
 "pattern": "*.csdn.net",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "CSDN Root",
 "pattern": "csdn.net",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "CSDN CDN",
 "pattern": "*.csdnimg.cn",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin Subdomains",
 "pattern": "*.douyin.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin Root",
 "pattern": "douyin.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin/ByteDance API",
 "pattern": "*.snssdk.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin CDN (Image/Static)",
 "pattern": "*.byteimg.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin CDN (Image/Static)",
 "pattern": "*.pstatp.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin CDN (Video)",
 "pattern": "*.douyinvod.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Douyin CDN (Pic)",
 "pattern": "*.douyinpic.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Xiaohongshu Subdomains",
 "pattern": "*.xiaohongshu.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Xiaohongshu Root",
 "pattern": "xiaohongshu.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Xiaohongshu CDN (Image/Static)",
 "pattern": "*.xhscdn.com",
 "active": true
 },
 {
 "include": "exclude",
 "type": "wildcard",
 "title": "Xiaohongshu CDN (Video)",
 "pattern": "*.xhbvd.com",
 "active": true
 }
]

使用与快捷键

  1. 完成全部配置后,可以通过浏览器右上角的 Foxy Proxy 图标来切换不同的代理配置。
  2. 注意,配置了 Proxy by Patterns 规则后,要切换到 Proxy by Patterns 配置,才能让规则生效。

extension

  1. 可以在 Foxy Proxy 的设置页面中,进入 Options 页面,找到 Keyboard Shortcuts 部分,设置快捷键来快速切换代理配置.

shortcuts

shortcuts

链接

基于 Github Workflow 的多仓库脚本联动

2026-04-15 10:42:00

Featured image of post 基于 Github Workflow 的多仓库脚本联动

前言

一开始搭建出博客后就基于 Aplayer 制作了大多博客都有的页内音乐播放器,主要功能实现起来简单,但是有两个痛点:1. 音乐资源存储;2. 切换页面断点续播。

在 AI 编程工具日益强大的当前,上个月很快利用 Pjax 等基本实现了第二个音乐续播的问题(副作用是部分页面 JS 失效,可能需要多刷新一次 T_T),而第一个问题在我临时采用另一个仓库存储音乐资源后,就再也没管了。

在这套方案下,每当我需要更新若干首曲目到我的音乐列表时,不仅需要手动上传音乐到音乐资源仓库,还需要手动更新博客仓库中的 Aplayer 配置文件。因此,想要自动化整个流程,实现 push 音乐后,自动更新博客配置并重新部署的想法一直萦绕在我脑海里。

在了解到 CI/CD 的概念,并轻度使用了 Github Workflow 后,今天在 AI 的协助下,终于实现了整个流程。

方案设计

音乐资源仓库(music)和博客仓库(hugo)之间的数据流程如下:

用户提交更新音乐文件到music仓库后,触发 Github Workflow:1. 在music仓库中执行脚本,更新musicList.json文件;2. 将更新后的musicList.json文件提交到博客仓库(hugo)的特定分支;3. 博客仓库(hugo)的 Workflow 监听到该分支的更新后,自动部署博客。

workflow

实现过程

1. music 仓库

1.1 Workflow 配置

.github/workflows/music-sync.yml 中配置 Workflow:

  • 触发条件:监听 main 分支的 push 事件,且仅当特定路径(如 musics/**, lrc/**, test.py, .github/workflows/music-sync.yml)发生变化时触发;同时支持手动触发。
  • 操作:
    1. 检出代码并设置 Python 环境。
    2. 执行test.py脚本生成 musicList.json
    3. 检查 musicList.json 是否有变化,如果有则提交更新到 music 仓库。
    4. 如果有更新且存在有效的 dispatch token,则触发 repository dispatch 事件,通知博客仓库进行同步。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
name: Music List Sync

on:
 push:
 branches:
 - main
 paths:
 - "musics/**"
 - "lrc/**"
 - "test.py"
 - ".github/workflows/music-sync.yml"
 workflow_dispatch:

permissions:
 contents: write

concurrency:
 group: music-list-sync
 cancel-in-progress: true

jobs:
 generate-and-dispatch:
 runs-on: ubuntu-latest
 env:
 DISPATCH_TOKEN: ${{ secrets.BLOG_REPO_DISPATCH_TOKEN }}

 steps:
 - name: Checkout
 uses: actions/checkout@v6

 - name: Setup Python
 uses: actions/setup-python@v6
 with:
 python-version: "3.11"

 - name: Generate musicList.json
 env:
 CI: "true"
 SKIP_LRC_GENERATION: "true"
 REQUIRE_LRC: "true"
 DISABLE_BAK: "true"
 run: python test.py

 - name: Check diff
 id: diff
 shell: bash
 run: |
 if git diff --quiet -- musicList.json; then
 echo "changed=false" >> "$GITHUB_OUTPUT"
 else
 echo "changed=true" >> "$GITHUB_OUTPUT"
 fi

 - name: Commit and push musicList.json
 if: steps.diff.outputs.changed == 'true'
 shell: bash
 run: |
 git config user.name "github-actions[bot]"
 git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
 git add musicList.json
 git commit -m "chore: auto update musicList.json"
 git push

 - name: Get current source sha
 if: steps.diff.outputs.changed == 'true'
 id: source_sha
 shell: bash
 run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

 - name: Dispatch to blog repository
 if: steps.diff.outputs.changed == 'true' && env.DISPATCH_TOKEN != ''
 uses: peter-evans/repository-dispatch@v3
 with:
 token: ${{ env.DISPATCH_TOKEN }}
 repository: lihan3238/lihan3238.github.io
 event-type: music_list_updated
 client-payload: >-
 {
 "source_repo": "${{ github.repository }}",
 "source_sha": "${{ steps.source_sha.outputs.sha }}",
 "json_raw_url": "https://raw.githubusercontent.com/lihan3238/music/main/musicList.json"
 }

 - name: Skip dispatch when token missing
 if: steps.diff.outputs.changed == 'true' && env.DISPATCH_TOKEN == ''
 run: echo "BLOG_REPO_DISPATCH_TOKEN is not set, skip repository_dispatch."

1.2 脚本实现

test.py 脚本主要功能是扫描 musics/ 目录下的音乐文件,生成对应的 musicList.json 文件,并根据需要生成歌词文件(lrc/)。脚本会根据环境变量控制是否跳过歌词生成、是否要求必须有歌词等逻辑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
import os
import json
import datetime
import shutil
import subprocess


def env_true(name: str, default: bool = False) -> bool:
 value = os.getenv(name)
 if value is None:
 return default
 return value.strip().lower() in {"1", "true", "yes", "on"}

# 指定目录路径
directory_path = "./musics/" # 请将路径替换为实际的目录路径
lrc_txt_path = "./lrc/" # 歌词文本文件目录
bak_path = "./Baks/" # 备份目录
json_filename = "musicList.json"

is_ci = env_true("CI")
skip_lrc_generation = env_true("SKIP_LRC_GENERATION", default=is_ci)
require_lrc = env_true("REQUIRE_LRC", default=is_ci)
disable_backup = env_true("DISABLE_BAK", default=is_ci)

audio_exts = {".mp3", ".m4a", ".flac", ".wav", ".ogg", ".aac"}

# 构建URL的基本部分
base_url = "https://github.com/lihan3238/music/raw/main/musics/"
base_lrc_url = "https://raw.githubusercontent.com/lihan3238/music/main/musics/"

# 确保lrc目录存在
if not os.path.exists(lrc_txt_path):
 os.makedirs(lrc_txt_path)

# 确保音乐目录存在
if not os.path.exists(directory_path):
 raise FileNotFoundError(f"Music directory not found: {directory_path}")

# 确保备份目录存在(仅在启用备份时)
if not disable_backup and not os.path.exists(bak_path):
 os.makedirs(bak_path)

# 获取目录中的所有文件名
file_names = [
 file
 for file in os.listdir(directory_path)
 if os.path.isfile(os.path.join(directory_path, file))
]
file_names.sort(key=lambda name: name.lower())

# 构建歌词文件映射(key 使用小写,兼容 Linux 大小写敏感文件系统)
lrc_name_map = {
 file_name.lower(): file_name
 for file_name in file_names
 if os.path.splitext(file_name)[1].lower() == ".lrc"
}

music_list = []
missing_lrc_files = []

# 遍历文件名
for file_name in file_names:
 ext = os.path.splitext(file_name)[1].lower()
 if ext not in audio_exts:
 continue

 file_path = os.path.join(directory_path, file_name)
 file_base_name = os.path.splitext(file_name)[0]
 lrc_file_name = file_base_name + ".lrc"
 lrc_file_path = os.path.join(directory_path, lrc_file_name)

 if os.path.exists(lrc_file_path):
 resolved_lrc_file_name = lrc_file_name
 else:
 resolved_lrc_file_name = lrc_name_map.get(lrc_file_name.lower())

 # 检查是否需要生成LRC(CI默认跳过)
 if not resolved_lrc_file_name and not skip_lrc_generation:
 txt_file_path = os.path.join(lrc_txt_path, file_base_name + ".txt")
 print(f"Generating LRC for {file_name}...")
 try:
 # 调用 lrcgen
 # 有歌词文本则使用 --lyrics-file,否则直接生成
 # 使用绝对路径以防万一
 abs_audio = os.path.abspath(file_path)
 abs_lrc = os.path.abspath(lrc_file_path)

 if os.path.exists(txt_file_path):
 abs_txt = os.path.abspath(txt_file_path)
 cmd = ["lrcgen", abs_audio, abs_lrc, "--lyrics-file", abs_txt, "--model", "large-v3"]
 else:
 cmd = ["lrcgen", abs_audio, abs_lrc, "--model", "large-v3"]

 subprocess.run(cmd, check=True)
 print(f"Successfully generated {lrc_file_name}")
 resolved_lrc_file_name = lrc_file_name
 lrc_name_map[lrc_file_name.lower()] = lrc_file_name
 except subprocess.CalledProcessError as e:
 print(f"Failed to generate LRC for {file_name}: {e}")
 except FileNotFoundError:
 print("Error: lrcgen command not found. Please ensure lrcgen is installed and in PATH.")

 has_lrc = bool(resolved_lrc_file_name)

 if not has_lrc:
 print(f"Warning: missing matching LRC for {file_name} -> expected {lrc_file_name}")
 missing_lrc_files.append(file_name)

 # 构建信息
 lrc_url = base_lrc_url + resolved_lrc_file_name if has_lrc else ""

 name_part = file_base_name
 if "-" in name_part:
 parts = name_part.split("-")
 name = parts[0].strip()
 artists = "-".join(parts[1:]).strip()
 else:
 name = name_part
 artists = ""

 url = f"{base_url}{file_name}"

 music_obj = {
 "name": name,
 "url": url,
 "artist": artists,
 "cover": "https://user-images.githubusercontent.com/140466644/266218167-0a08d24b-2f75-4a6b-9253-227612dffa98.png"
 }
 if lrc_url:
 music_obj["lrc"] = lrc_url

 music_list.append(music_obj)


if require_lrc and missing_lrc_files:
 print("\nMissing matching .lrc files for these tracks:")
 for file_name in missing_lrc_files:
 print(f"- {file_name}")
 raise SystemExit(1)


# 写入 JSON 文件
print(f"Updating {json_filename}...")
with open(json_filename, "w", encoding="utf-8") as f:
 json.dump(music_list, f, ensure_ascii=False, indent=4)


# 备份逻辑
if not disable_backup:
 bak_files = [f for f in os.listdir(bak_path) if f.startswith("musicList_") and f.endswith(".json")]
 if len(bak_files) > 5:
 print("备份文件超过6个,正在删除最旧的备份文件...")
 oldest_file = min(
 bak_files, key=lambda x: os.path.getctime(os.path.join(bak_path, x))
 )
 os.remove(os.path.join(bak_path, oldest_file))

 current_date_time = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
 backup_file = f"musicList_{current_date_time}.json"
 if os.path.exists(json_filename):
 shutil.copy(json_filename, os.path.join(bak_path, backup_file))
 print(f"已备份为 {backup_file}")
else:
 print("Backup is disabled by DISABLE_BAK.")

print(f"数据已写入到 {json_filename} 文件中。")

1.3 Github 仓库与权限配置

1.3.1 Github Personal Access Token 配置 - BLOG_REPO_DISPATCH_TOKEN
  • Github PAT 用于授权 Workflow 在 music 仓库中执行 Git 操作(如提交更新)以及触发博客仓库的 dispatch 事件。
  1. 打开 Github 右上角头像,进入 Settings -> Developer settings -> Personal access tokens

  2. 点击 Personal access tokens,然后点击 Fine-grained tokens

  3. 点击 Generate new token,选择 Generate new token (fine-grained)

  4. 配置 Token:

    • Name: BLOG_REPO_DISPATCH_TOKEN
    • Expiration: 根据需要选择(建议设置合理的过期时间)
    • Repository access: 选择 Only select repositories,然后选择 lihan3238/lihan3238.github.io (hugo博客)仓库。
    • Permissions:
      • Repository permissions: Contents 设置为 Read and write
  5. 生成 Token 后,复制 Token 的值。

  6. 进入 music 仓库的 Settings -> Secrets and variables -> Actions,点击 New repository secret

  7. Name: BLOG_REPO_DISPATCH_TOKEN,Value: 粘贴刚才复制的 Token,保存。

2. hugo 仓库

2.1 Workflow 配置

  • 触发条件:监听 repository dispatch 事件(music_list_updated)和手动触发。
  • 操作:
    1. 拉取并更新 layouts/partials/music.html 的歌单
    2. 用 create-pull-request 创建 PR(v8)
    3. 用 BLOG_REPO_AUTOMATION_TOKEN 自动审批
    4. 直接执行 squash merge 并删分支
    5. 最后再发一个 hugo_deploy_requested 的 dispatch 事件,触发部署流程。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
name: Sync Music Player List

on:
 repository_dispatch:
 types: [music_list_updated]
 workflow_dispatch:

permissions:
 contents: write
 pull-requests: write

concurrency:
 group: blog-music-sync
 cancel-in-progress: true

jobs:
 sync-music-list:
 runs-on: ubuntu-latest
 env:
 AUTOMATION_TOKEN: ${{ secrets.BLOG_REPO_AUTOMATION_TOKEN }}

 steps:
 - name: Checkout blog repository
 uses: actions/checkout@v6
 with:
 persist-credentials: false
 fetch-depth: 0

 - name: Setup Python
 uses: actions/setup-python@v6
 with:
 python-version: "3.11"

 - name: Download updater script
 shell: bash
 run: |
 mkdir -p scripts
 curl -fsSL \
 https://raw.githubusercontent.com/lihan3238/music/main/integration/blog/update_aplayer_music.py \
 -o scripts/update_aplayer_music.py

 - name: Update player list in partial
 env:
 TARGET_FILE: layouts/partials/music.html
 MUSIC_URL: https://raw.githubusercontent.com/lihan3238/music/main/musicList.json
 run: python scripts/update_aplayer_music.py

 - name: Create pull request
 id: cpr
 uses: peter-evans/create-pull-request@v8
 with:
 # Use GITHUB_TOKEN to create PRs so a separate PAT identity can approve.
 token: ${{ github.token }}
 branch: chore/sync-music-list
 # Always create a fresh PR branch to avoid updating legacy PRs
 # created by a different author identity.
 branch-suffix: short-commit-hash
 base: main
 delete-branch: true
 title: "chore: sync music list from music repository"
 body: |
 Auto-generated by workflow.

 - Event: repository_dispatch (music_list_updated)
 - Source: ${{ github.event.client_payload.source_repo || 'manual' }}
 - SHA: ${{ github.event.client_payload.source_sha || 'manual' }}
 commit-message: "chore: sync music list"
 add-paths: |
 layouts/partials/music.html

 - name: Auto approve pull request
 if: steps.cpr.outputs.pull-request-number != ''
 env:
 GH_TOKEN: ${{ env.AUTOMATION_TOKEN }}
 run: gh pr review "${{ steps.cpr.outputs.pull-request-number }}" --approve --repo "${{ github.repository }}"

 - name: Merge pull request
 if: steps.cpr.outputs.pull-request-number != ''
 env:
 GH_TOKEN: ${{ env.AUTOMATION_TOKEN }}
 run: gh pr merge "${{ steps.cpr.outputs.pull-request-number }}" --squash --delete-branch --repo "${{ github.repository }}"

 - name: Trigger Hugo deployment
 if: steps.cpr.outputs.pull-request-number != ''
 env:
 GH_TOKEN: ${{ env.AUTOMATION_TOKEN }}
 run: |
 gh api \
 repos/${{ github.repository }}/dispatches \
 -f event_type=hugo_deploy_requested

2.2 Github 仓库与权限配置

2.2.1 Github Personal Access Token 配置 - BLOG_REPO_AUTOMATION_TOKEN
  • #1.3.1,选择 lihan3238/lihan3238.github.io 仓库,并且权限需要包含 Pull requests: WriteContents: Write,以便 Workflow 可以自动审批和合并 PR。
2.2.2 仓库权限配置
  • hugo 仓库的 Settings -> Actions -> General 中,确保 Workflow permissions 设置为 Read and write permissions,以允许 Workflow 执行写操作(如提交更改、创建 PR 等)。

总结

Github 的 workflow 功能非常强大,可以实现跨仓库的自动化操作,极大提升了开发效率和自动化水平。通过合理设计 Workflow 的触发条件和操作步骤,我们可以实现复杂的多仓库联动场景,如本次的音乐列表同步和博客部署流程。

由此看到,包括 K8s、github workflow 等 CI/CD 工具在内的现代开发工具链,已经成为提升开发效率和自动化水平的关键组成部分。学习和掌握 CI/CD 十分重要。

随想260331二零二五

2026-03-31 22:37:00

Featured image of post 随想260331二零二五

卧铺又谈去一年

春天都快过去了,这时候才提笔 2025 的总结,确是晚了,巧在又是在开往武汉的列车上,蜷缩在卧铺里,气氛到这里了,不写点什么是说不过去了。

光与暗

再无这般 CUC

2025 的六月清晰地把全年一分为二,上半年还是校园里年轻的白杨,下半年就深锁秦岭了。

说实话,现在回想起来,还是不敢相信短短半年时间里,居然留下了那么多回忆。一月和 lcy 的 粤旅250111算是毕业前狂欢的预告;三月和 cwt 吃遍 CUC 周边面馆、逛遍超市;四月的王府井、雍和宫还有万恶之源的青边口长城让我爱上了旅行;五月除了驻京办计划,珠江绿洲的顶层才是我心中的广州塔;速通完天津,六月还有东猴顶的日出……

珠江绿洲楼顶的夜里,我记得和 cwt 感叹为何直到毕业前一个月才发现了这个好地方,穿梭在老陕面馆和超市间,我们也后悔太迟开始享受生活,4年的本科生活除却失败的恋爱,真成了头轻尾巴尖重了。

我将我的魂魄留在了通惠河畔,这里也是 cwt 面试上如今令我羡慕的工作的地方,7月在武汉与重庆的小聚,26年初在河南的重逢,也让我的青春打赢了两场复活赛。

可是冰冷的现实总在每一次重温毕业随想时提醒起我: 再无青春,再无这般 CUC

恰如所料的 NWPU

在最后的日子里,对未来的恐惧也牢牢地占据了脑袋的一小片。从保研后的调研来看,在 NWPU 的日子非但不会好过,甚至可谓“龙场悟道”的一场修行。随着开学日子的逼近,纵使同 lcy 打了一个假期三角洲,暂时麻痹了青春的怀念,总得面对研究生的现实了。

记得在通惠河畔最后一次痛哭时,我料想硕士三年是剥夺一切娱乐与情感的苦修。不幸的是,现实恰如我料的一般,离地铁站的17km 拦住了我几乎所有的娱乐,一个接一个的横向与宛如铁笼的校园管理,更是让我彻底窒息:我非旦没有想错,甚至乐观了不止一点

仅仅三个月时间就打破了 CUC 四年带给我的自信,仅仅一个学期的煎熬,让我对西安的滤镜跌得粉碎。我再也不去幻想读博当教授,我只想早点就业脱离魔窟,找个轻松安逸的工作赶赶青春的尾巴。

忆往昔

外公的去世在高二那年,也许宇宙的内存容量有限,自那以后我获得了他释放的内存,自我意识清晰了起来。

今天出门的时候,看着姥姥不舍的样子,幸好我的内心还有几分波纹。记得姥姥说过,每次我和父母离开西安时,她都会守在窗台望向出小区门的必经之路,这两年倒是很少走这个门出了,我们都习惯从地下直穿到地铁站,我想她该好久没看到我们的背影了吧。

于是按下了电梯一楼的按钮,我站在楼下拼命地朝着十楼的角度挥手,房间里关着灯,街边的路灯也没有过年时候亮了,我更卖力地挥着:学历越来越高,也许前途越来越好,为什么连多陪姥姥几日都做不到呢?

我讨厌这个世界。

N谈未来

没啥好谈的,结论很清晰了,刷力扣、做项目、背面经、抢实习,早点就业,早点自由,早点完成所有的遗憾。

刚入学的时候,带着 CUC 给我的自信,我选了班长又选研会主席, openvpn 晾了一年终究是给我整会了,十月国庆在绿树上号三角洲时,上月游戏时长只有 6h。

lyx 带着我开始搞科研,说真的,他的努力我是真的佩服,从早干到凌晨,一连着就是几个礼拜,可惜我确实不是这块的料,也许又可能是努力晚了,随着我决定就业,科研和读博当教授之路算是彻底放弃了。

十月还拉着 lyx 爬了山,秦岭连绵,本想着后面两周一座山,脚伤让我又是半年走不好路。1月的时候,硬撑着和 1228 河南相见,险些腿断在龙门石窟,直到半个月前才算好了彻底。

经历了人生第一次挂科,也意味着我研究生生活彻底的崩坏,lihan 不得不现实起来,放弃了读博】放弃了恋爱、放弃了打游戏(好吧晚上还会打打),能偷偷学学技术,找个互联网私企外企都不自信了。

玩好

玩就玩好吧,总不能真让 NWPU 毁了青春,多少挣扎一下吧。

————2026.3.31 午夜再于返鄂列车上

Clash Verge 在局域网内通过端口代理流量

2026-03-28 16:40:00

Featured image of post Clash Verge 在局域网内通过端口代理流量

问题

Clash Verge 在局域网内通过端口代理流量时,虚拟机或局域网内设备无法 ping 通代理端口。

原因

  1. 防火墙未放行
  2. 代理端口选择错误
  3. 未打开 Clash Verge 的局域网访问权限
  4. 订阅配置覆盖了本地配置,导致始终绑定在 127.0.0.1 而非 0.0.0.0

解决

  1. 确保防火墙已放行 Clash Verge 的代理端口(默认为 7897 或 7890)。
  2. 检查 Clash Verge 的配置文件,确认代理端口设置正确
  3. 在 Clash Verge 的设置中,打开局域网连接,确保允许来自局域网的连接。
  4. 右键订阅,选择编辑文件,找到bind-address,将其值改为0.0.0.0,保存后重启 Clash Verge。
  5. 在目标设备的命令行中修改代理,命令为export http_proxy=http://<Clash Verge所在设备的IP地址>:<代理端口>,并 ping google.com 来测试是否成功。

Python 笔试备忘

2026-03-13 22:24:00

Featured image of post Python 笔试备忘

Python LeetCode & ACM笔试 极简速查表

一、 核心容器:初始化、拼接与类型转换

1. 列表 (List) -> 动态数组 / 栈

  • 创建与初始化:
    • 空列表: arr = []
    • 定长初始化: arr = [0] * n
    • 二维初始化: dp = [[0] * n for _ in range(m)] (严禁使用 [[0]*n]*m)
    • 推导式: arr = [x**2 for x in range(10) if x % 2 == 0]
  • 拼接与扩展:
    • + 运算符: [1, 2] + [3, 4] -> [1, 2, 3, 4] (产生新列表,耗时)
    • 原地扩展: arr.extend([3, 4]) (等价于按顺序 append,$O(K)$)
  • 操作: append(x) $O(1)$, pop() $O(1)$, arr[::-1] 翻转 $O(N)$, sort() 原地排序 $O(N \log N)$。

2. 集合 (Set) -> 哈希去重

  • 创建与初始化:
    • 空集合: s = set() (严禁使用 {},那是空字典)
    • 字面量: s = {1, 2, 3}
  • 拼接与运算:
    • 并集: s1 | s2s1.union(s2)
    • 交集: s1 & s2s1.intersection(s2)
    • 差集: s1 - s2
  • 操作: add(x) $O(1)$, remove(x) $O(1)$ (不存在会报错), discard(x) $O(1)$ (不存在不报错)。

3. 字典 (Dict) -> 哈希表

  • 创建与初始化:
    • 空字典: d = {}dict()
    • 默认字典: d = defaultdict(int) (访问不存在的键返回默认值)(from collections import defaultdict
    • 键值对: d = {'a': 1, 'b': 2}
  • 拼接与合并:
    • Python 3.9+: d3 = d1 | d2 (合并字典,同名键覆盖)
    • 原地更新: d1.update(d2)
  • 操作: d.get(key, default) (安全读取), key in d $O(1)$。

4. 字符串 (String) -> 不可变字符序列

  • 切片 (极其高频,极速操作):
    • s[:4] # “leet” (前 4 个字符)
    • s[-4:] # “code” (后 4 个字符)
    • s[::-1] # “edocteel” ($O(N)$ 极速反转)
  • 查找与判断:
    • s.find("etc") # 返回 2 (子串起始索引,找不到返回 -1,笔试首选)
    • s.startswith("le")# True
    • s.endswith("de") # True
    • s.isalnum() # True (判断是否只包含字母和数字,双指针判断回文时必用)
    • s.isdigit() # False (判断是否纯数字)
  • 替换与清理 (注意:必须重新赋值):
    • s2 = " a b c "
    • s2.strip() # “a b c” (默认袪除首尾所有空白符)
    • s2.replace(" ", "") # “abc” (极其好用的全量替换)
  • 大小写转换:
    • s.lower() # 全部转小写;如果本来就是小写、数字或符号,通常保持不变,例如 "abc123!?".lower() 还是 "abc123!?"
    • s.upper() # 全部转大写;数字和符号不变
    • s.capitalize() # 首字母大写,其余小写
    • s.title() # 每个单词首字母大写
    • s.swapcase() # 大小写互换
    • s.casefold() # 更强的大小写归一化,适合不区分大小写比较

5. 元组 (Tuple) -> 不可变序列 / 可哈希键

  • 创建与初始化:
    • 空元组: t = ()
    • 单元素元组: t = (1,) (⚠️ 必须带逗号,否则 (1) 会被当作整数运算)
    • 常规元组: t = (1, 2, 3)
  • 核心特性与用途:
    • 可哈希 (Hashable): 因为绝对不可变,它是唯一能作为 Dict 键 (Key) 或存入 Set 的序列(List 绝对不行)。
    • 多维状态记录: BFS 搜索时记录坐标 visited.add((r, c));DP 记忆化 memo[(idx, weight)] = res
  • 操作与解包:
    • 极速解包: r, c = (0, 1) (多变量同时赋值)
    • 索引与切片: t[0], t[::-1] (与列表行为一致,但不能修改元素)
1
2
3
4
# enumarate 同时获取索引和值
arr = ['a', 'b', 'c']
for i, val in enumerate(arr):
 print(f"Index: {i}, Value: {val}")

6. 容器互相转换矩阵

转换方向 语法 说明
List -> Set set(arr) 瞬间去重,常用于降维打击 $O(N)$ 查找
Set -> List list(s) 集合转回数组,常用于去重后排序
String -> List list("abc") 变为 ['a', 'b', 'c'],因为字符串不可变,需转列表修改
List -> String "".join(arr) 高效拼接,arr 内部必须全是字符串元素
Dict -> List list(d.keys())
list(d.values())
分别提取字典的键或值构成列表
List <-> Tuple tuple(arr)
list(t)
列表转元组以使其可哈希(存入 Set/Dict),或元组转列表以修改内容

二、 ACM 模式标准输入输出

处理国内笔试题(如牛客网)必备,避免 input() 导致超时或读取异常。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import sys

# 1. 万能读取法 (应对空格、换行混乱的输入)
# 将所有输入全部读入,打平为一个一维列表,后续通过迭代器或指针逐步提取
data = sys.stdin.read().split()
if not data: exit()
iterator = iter(data)
n = int(next(iterator)) # 提取第一个数

# 2. 单行读取 (极速版)
def get_ints():
 return list(map(int, sys.stdin.readline().split()))

# 3. 矩阵读取 (读取 n 行 m 列)
n, m = get_ints()
matrix = [get_ints() for _ in range(n)]

# 4. 高效输出
# print 在大量输出时很慢,改用 sys.stdout.write
sys.stdout.write(" ".join(map(str, result_list)) + "\n")

三、 高频核心数据结构 (collections & heapq)

Python 循环结构极简速查

1. for 循环 (基于 range)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 基础遍历 [0, n-1]
for i in range(n): pass

# 区间遍历 [1, n-1]
for i in range(1, n): pass

# 倒序遍历 [n-1, 0] (左闭右开,步长-1)
for i in range(n - 1, -1, -1): pass

# 步长遍历 (每次+2)
for i in range(0, n, 2): pass

避坑:Python 中在 for 循环内修改 i 无效,会被下一轮自动重置。如需动态调整指针必须用 while

2. 数据遍历 (无索引依赖)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 1. 仅遍历值
for val in arr: pass

# 2. 同时获取索引和值 (高频)
for i, val in enumerate(arr): pass

# 3. 多序列并行遍历 (以最短为准)
for v1, v2 in zip(arr1, arr2): pass

# 4. 集合遍历 (无序)
for x in my_set: pass

# 5. 字典遍历
for k in my_dict: pass # 仅遍历键 (默认行为)
for v in my_dict.values(): pass # 仅遍历值
for k, v in my_dict.items(): pass # 同时遍历键值 (高频)
# 注意:严禁在遍历 Set/Dict 时修改其大小 (add/remove/pop),否则报错

3. while 循环

1
2
3
4
5
6
7
# 1. 标准边界控制 (如二分查找)
while left <= right: pass

# 2. 容器判空 (代替 C++ 的 !q.empty())
# 空列表 []、空集合 set() 均等价于 False
while q:
 node = q.popleft()

4. 控制流特有语法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# for...else / while...else 结构 (省去 bool 标记)
for x in arr:
 if x == target:
 break # 触发 break,直接跳出,不执行 else
else:
 # 循环自然跑完(未被 break 中断)时执行
 return "Not Found"

# 基础控制符
continue # 跳过本次循环剩余代码,进入下一次
break # 强制跳出当前所在的一层循环
pass # 空语句占位符 (等价于 C++ 的 {})

5. 常用数据结构操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. 双端队列 (BFS 核心)
from collections import deque
q = deque([1, 2])
q.append(3) # 尾部入队 O(1)
curr = q.popleft() # 头部出队 O(1) 

# 2. 自动初始化字典 (图论建图、归类核心)
from collections import defaultdict
adj = defaultdict(list) # 默认值为 []
adj[0].append(1) # 免去 if 0 not in adj 的判断

# 3. 词频统计器
from collections import Counter
cnt = Counter("anagram") # {'a': 3, 'n': 1, 'g': 1, 'r': 1, 'm': 1}

# 4. 堆 / 优先队列 (Dijkstra、TopK 核心)
import heapq
arr = [3, 1, 2]
heapq.heapify(arr) # 原地建小顶堆 O(N)
heapq.heappush(arr, 0) # 插入 O(log N)
min_val = heapq.heappop(arr) # 弹出最小值 O(log N)
heapq.heappushpop(arr, 4) # 插入后弹出最小值 O(log N)
# 大顶堆技巧:压入 -x,弹出时再取负

四、 内置函数与语法糖

  • 极值与数学: float('inf'), float('-inf')pow(a, b, mod) 快速幂取模算法(比自己手写快)。divmod(a, b) 同时返回商和余数。
  • ASCII 转换: ord('a') -> 97, chr(97) -> ‘a’。
  • 字符串高频处理:
    • s.split() (默认处理所有连续空白符并丢弃空串)。
    • s.isalnum() (判断是否全为字母或数字,回文串常用)。
    • s.find(sub) (找子串,找不到返回 -1,严禁使用 index() 因为会抛异常报错)。
  • 高阶遍历:
    • for i, val in enumerate(arr): (同时拿索引和值)。
    • for x, y in zip(arr1, arr2): (双数组齐头并进)。
  • 排序:
    • arr.sort() # 默认升序,原地排序,适用于数字/字符串
    • arr.sort(reverse=True) # 降序排序
    • arr.sort(key=lambda x: x[0]) # 按元素的第一个字段排序(如区间、元组、二维数组)
    • arr.sort(key=lambda x: (x[0], -x[1])) # 多条件排序:优先按第一元素升序,相同则按第二元素降序
    • sorted(arr) # 返回新排序后的列表,不改变原数组
    • sorted(arr, key=..., reverse=...) # 一切排序技巧均可用
    • 例:intervals.sort(key=lambda x: x[0]) # 区间按左端点排序,区间合并/覆盖问题高频
  • 三元运算符(条件表达式):
    • 语法:a if 条件 else b (等价于 C/C++/Java 的 条件 ? a : b
    • 示例:res = x if x > 0 else -x # 取绝对值

五、 常用算法代码骨架

1. 二分查找 (标准库版)

自带的 bisect 库是 $O(\log N)$,直接替代手写二分。

1
2
3
4
5
6
import bisect
arr = [1, 2, 2, 4]
# 找第一个 >= x 的位置 (左侧插入点)
idx_left = bisect.bisect_left(arr, 2) # 返回 1
# 找第一个 > x 的位置 (右侧插入点)
idx_right = bisect.bisect_right(arr, 2) # 返回 3

2. 图论 BFS 模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
step = 0
visited = set([start])
q = deque([start])
while q:
 size = len(q)
 for _ in range(size): # 按层扩展
 curr = q.popleft()
 if curr == target: return step
 for nxt in graph[curr]:
 if nxt not in visited:
 visited.add(nxt)
 q.append(nxt)
 step += 1

3. 并查集 (Union-Find)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class UnionFind:
 def __init__(self, size):
 # 初始化:每个人的老总都是自己
 self.parent = [i for i in range(size)]
 # 记录以 i 为根的集合的大小,初始都是 1
 self.size = [1] * size
 # 连通分量的数量(有多少个独立的帮派)
 self.count = size

 def find(self, i):
 # 路径压缩 (Path Compression)
 if self.parent[i] != i:
 self.parent[i] = self.find(self.parent[i])
 return self.parent[i]

 def union(self, i, j):
 root_i = self.find(i)
 root_j = self.find(j)

 # 已经是同一个老总,无需合并
 if root_i == root_j:
 return False

 # 按大小合并 (Union by Size):小树挂在大树下面
 if self.size[root_i] < self.size[root_j]:
 self.parent[root_i] = root_j
 self.size[root_j] += self.size[root_i]
 else:
 self.parent[root_j] = root_i
 self.size[root_i] += self.size[root_j]

 # 合并成功,独立帮派数量减一
 self.count -= 1
 return True

 def is_connected(self, i, j):
 # 判断两人是否连通
 return self.find(i) == self.find(j)

4. DPS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def dfs_template(node, state_var):
 # 1. 递归终止条件 (Base Case)
 # 遇到空节点、越界,或者满足特定结算条件时,立刻向上层返回
 if not node:
 return
 if is_target_reached(node):
 process_result(node, state_var)
 return

 # 2. 处理当前层逻辑 (Process Current Node)
 # 根据题意,修改状态变量或记录当前节点的信息
 update_state(state_var, node)

 # 3. 递归下探 (Drill Down)
 # 遍历所有可能的下一个节点(如树的左右子树,图的邻居节点)
 # 注意:下探时必须传递更新后的状态变量
 dfs_template(node.left, next_state(state_var))
 dfs_template(node.right, next_state(state_var))

 # 4. 恢复当前层状态 (Restore State / Backtrack) - 可选
 # 如果 state_var 是全局变量或可变对象(如列表),在回溯时需要撤销第2步的修改
 # 如果 state_var 是按值传递(如数字相加),则不需要此步
 revert_state(state_var, node)

5. 01背包

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def zero_one_knapsack(weights, values, capacity):
 n = len(weights)

 # 1. 定义与初始化 DP 数组
 # dp[j] 表示:当背包容量为 j 时,能获得的最大价值(或最小代价)
 # 如果求最大值,初始化为 0;如果求最小值,初始化为 float('inf')
 dp = [0] * (capacity + 1)

 # base case: 容量为 0 时,价值为 0
 dp[0] = 0

 # 2. 外层循环:遍历每一个物品
 for i in range(n):
 w = weights[i]
 v = values[i]

 # 3. 内层循环:遍历背包容量(核心难点:必须倒序!)
 # 倒序的原因:dp[j] 的更新依赖于上一层状态的 dp[j-w]。
 # 如果正序遍历,dp[j-w] 会在当前物品这一层被提前更新,导致同一个物品被重复放入(这就变成了完全背包问题)。
 for j in range(capacity, w - 1, -1):

 # 4. 状态转移方程 (State Transition)
 # 决策:是不放这个物品(保持 dp[j]),还是腾出 w 的空间放入这个物品(dp[j - w] + v)?
 # 根据题意取 max 或 min
 dp[j] = max(dp[j], dp[j - w] + v)

 # 5. 返回最终状态
 return dp[capacity]

树图什么的

现实校准:LeetCode 考什么,不考什么

在你听到“红黑树”、“B+树”等高大上的名词时,需要首先明确工业界底层开发与 LeetCode 算法应试的绝对分界线

  • 红黑树 (Red-Black Tree) / AVL 树: 它们是自平衡二叉搜索树,为了解决普通二叉搜索树退化成链表的问题而生。但在 LeetCode 面试中,几乎绝对不会让你从零手写一棵红黑树(代码量极大且极易出错,通常需要几百行严密的旋转逻辑)。如果你在题目中需要用到自平衡树的特性(即保持动态有序并支持 $O(\log N)$ 查找),在 C++ 中直接使用 std::set / std::map,在 Java 中使用 TreeMap,在 Python 中则依赖第三方库 sortedcontainers 或使用 bisect 模块结合列表模拟。
  • LeetCode 的核心靶点: 是利用基础的二叉树、二叉搜索树 (BST)、字典树 (Trie) 以及基础图论(无向图/有向图的遍历、拓扑排序),来考察你的递归思维 (DFS)层级思维 (BFS)

以下是为你剥离冗余后,针对 LeetCode 场景的树与图 Cheat Sheet


1. 二叉搜索树 (BST - Binary Search Tree)

辩证本质: 树形结构中的“二分查找”。它将线性数组的查找效率从 $O(N)$ 降维至 $O(\log N)$,但代价是每次插入/删除都需要动态维护其严格的大小关系。

  • 核心物理规则: 对于任何一个节点,其左子树所有节点的值必小于该节点;其右子树所有节点的值必大于该节点。
  • 最强推论(必考点): 对 BST 进行中序遍历 (Inorder Traversal:左-根-右),得到的必定是一个严格递增的有序数组
  • 经典题型: 验证二叉搜索树(98)、二叉搜索树中第K小的元素(230)、修剪二叉搜索树(669)。

Python 模板:利用中序遍历验证 BST

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class TreeNode:
 def __init__(self, val=0, left=None, right=None):
 self.val = val
 self.left = left
 self.right = right

def isValidBST(root: TreeNode) -> bool:
 # 辩证点:不要单纯只判断左孩子小于根、右孩子大于根。
 # 必须传递一个不断收紧的上下界,确保整棵子树都符合规则。
 def dfs(node, lower, upper):
 if not node:
 return True
 if node.val <= lower or node.val >= upper:
 return False
 # 往左走,上限变成当前节点的值;往右走,下限变成当前节点的值
 return dfs(node.left, lower, node.val) and dfs(node.right, node.val, upper)

 return dfs(root, float('-inf'), float('inf'))

2. 字典树 / 前缀树 (Trie)

辩证本质: 典型的“空间换时间”。通过将字符串拆解为字符并构建多叉树,将海量字符串的匹配时间复杂度,从 $O(N \times L)$ 极致压缩到 $O(L)$($L$ 为单个单词的长度),代价是需要极其庞大的节点内存。

  • 核心特征词: “前缀匹配”、“搜索联想”、“单词搜索”、“异或最大值(01字典树)”。
  • 物理结构: 根节点为空,每个节点包含一个字典(或长度为 26 的数组)指向下一个字符,以及一个布尔标志位表示“是否为单词结尾”。
  • 经典题型: 实现 Trie (前缀树)(208)、单词搜索 II(212 - Trie + DFS 的终极杀器)。

Python 模板:Trie 的标准实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class TrieNode:
 def __init__(self):
 self.children = {} # 存储子节点,例如 {'a': TrieNode, 'b': TrieNode}
 self.is_end = False # 标识此处是否构成一个完整的单词

class Trie:
 def __init__(self):
 self.root = TrieNode()

 def insert(self, word: str) -> None:
 node = self.root
 for char in word:
 if char not in node.children:
 node.children[char] = TrieNode()
 node = node.children[char]
 node.is_end = True

 def search(self, word: str) -> bool:
 node = self.root
 for char in word:
 if char not in node.children:
 return False
 node = node.children[char]
 return node.is_end # 必须是单词结尾才算找到

3. 图 (Graph) 基础:无向图 vs 有向图

辩证本质: 树其实是图的一种极度受限的特例(树是没有环的连通无向图)。图抛弃了“父子关系”的严格层级,允许数据之间存在任意的网状联系。

  • 无向图 (Undirected Graph): 边是双向的。A 认识 B,B 必然认识 A。
    • 核心陷阱: 极易形成死循环死锁(如 A 走向 B,B 又走向 A)。
    • 破解之道: 无论 DFS 还是 BFS,必须使用 visited 集合来记录走过的节点,坚决不走回头路。
  • 有向图 (Directed Graph): 边是单向的。A 关注了 B,B 不一定关注 A。
    • 核心考点: 成环检测拓扑排序

4. 拓扑排序 (Topological Sort) - 针对有向无环图 (DAG)

辩证本质: 将一个存在依赖关系的网状图,拉平为一条线性的执行序列。如果图中存在环(如 A 依赖 B,B 依赖 A),则拓扑排序必然失败(发生死锁)。

  • 核心特征词: “课程表”、“编译依赖”、“任务调度”、“是否存在先后矛盾”。
  • 物理模型 (Kahn 算法):
    1. 统计所有节点的入度 (In-degree)(即有多少条边指向它,代表它被多少个前置条件卡着)。
    2. 将所有入度为 0 的节点(没有任何前置依赖的任务)放入队列。
    3. 弹出节点执行,并将其指向的所有邻居节点的入度减 1。
    4. 如果邻居入度降为 0,则加入队列。
  • 经典题型: 课程表(207)、课程表 II(210)。

Python 模板:利用 BFS (Kahn算法) 实现拓扑排序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from collections import deque, defaultdict

def canFinish(numCourses: int, prerequisites: list[list[int]]) -> bool:
 # 1. 初始化邻接表和入度数组
 graph = defaultdict(list)
 in_degree = [0] * numCourses

 # 2. 构建图 (prerequisites[i] = [a, b] 表示选 a 必须先学 b,即 b -> a)
 for course, pre in prerequisites:
 graph[pre].append(course)
 in_degree[course] += 1

 # 3. 将所有入度为 0 的节点(即不需要任何先修课的课程)入队
 queue = deque([i for i in range(numCourses) if in_degree[i] == 0])

 # 4. 记录已完成的课程数量
 completed_count = 0

 while queue:
 node = queue.popleft()
 completed_count += 1

 # 将依赖该课程的所有后续课程入度减 1
 for neighbor in graph[node]:
 in_degree[neighbor] -= 1
 # 一旦前置依赖全部解除,加入队列准备学习
 if in_degree[neighbor] == 0:
 queue.append(neighbor)

 # 辩证点:如果完成的数量等于总课程数,说明不存在环,可以学完。
 # 如果最后还有课程没学完(入度死活降不到0),说明图里必定存在互相依赖的“死锁环”。
 return completed_count == numCourses

六、 根据数据范围推断时间复杂度 (防超时指南)

机试时,先看题目给定的变量范围 $N$,直接反推该用什么算法:

  • $N \le 20$: 回溯法、状态压缩 DP $\rightarrow O(2^N)$ 或 $O(N!)$
  • $N \le 400$: 三重循环、Floyd 算法 $\rightarrow O(N^3)$
  • $N \le 2000$: 两重循环、普通 DP $\rightarrow O(N^2)$
  • $N \le 10^5$: 排序、堆、二分查找、线段树 $\rightarrow O(N \log N)$
  • $N \le 10^6$: 双指针、滑动窗口、单调栈、哈希表 $\rightarrow O(N)$
  • $N \ge 10^9$: 数学规律、快速幂、二分答案 $\rightarrow O(\log N)$ 或 $O(1)$

七、Leetcode Python 数据结构速成

已完成联网核对。前文提到的“有效的字母异位词”题号有误(LeetCode 24 实际为“两两交换链表中的节点”,242 才是“有效的字母异位词”),下表已修正。

这是一份纯粹用于检验 Python 数据结构熟练度的闭卷通关表:

题目号及名称 核心知识点 闭卷 AC 验收标准
125. 验证回文串 字符串过滤、切片翻转 熟练使用推导式 [c.lower() for c in s if c.isalnum()],用切片 s == s[::-1] 一行判断,不写双指针。
349. 两个数组的交集 Set 的初始化与交集运算 严禁手写两层循环,必须一句话搞定:return list(set(nums1) & set(nums2))
128. 最长连续序列 Set 的 $O(1)$ 极速查找 将数组转为 set,彻底理解并利用 num in num_set 的 $O(1)$ 特性防超时。
242. 有效的字母异位词 词频统计 (Counter) 严禁手写循环统计,直接引入 Counter,使用 return Counter(s) == Counter(t) 一行秒杀。
49. 字母异位词分组 defaultdict、Tuple 作为哈希键 熟练写出 d = defaultdict(list),深刻理解必须将排序后的字符串转为 tuple 才能作为字典的 key。
169. 多数元素 字典遍历 / API 获取极值 熟练手写字典遍历寻找最大值,或直接使用 Counter(nums).most_common(1)[0][0]
20. 有效的括号 List 作为栈的使用 熟练使用 append()pop(),并用静态字典 {')': '(', ']': '[', '}': '{'} 优雅映射替代大量 if-else
102. 二叉树的层序遍历 deque 实现 BFS 倒背如流 q = deque([root])q.popleft(),严禁在此处使用 list.pop(0)
200. 岛屿数量 多维坐标的 Tuple 打包与 Set 去重 在二维 BFS/DFS 中,极其熟练地将坐标打包成元组存入访问记录:visited.add((r, c))
215. 数组中的第K个最大元素 heapq 核心 API 熟练使用 heapq.heapify 以及维护大小为 $K$ 的小顶堆(heappushheappop)。
347. 前 K 个高频元素 大顶堆技巧与复杂结构嵌套 结合 Counter 统计频率,并熟练写出“取负数塞入小顶堆模拟大顶堆”的操作:heappush(hp, (-freq, num))
56. 合并区间 列表的高阶排序 (lambda) 不看资料直接写出按左端点升序排列的定制规则:intervals.sort(key=lambda x: x[0])

【本文为 AI 生成】Hugo + Pjax 实现无刷新博客体验:从音乐播放中断谈起

2026-03-11 14:23:00

Featured image of post 【本文为 AI 生成】Hugo + Pjax 实现无刷新博客体验:从音乐播放中断谈起

前言

在搭建个人博客的过程中,我一直有一个执念:希望网页底部的音乐播放器能够像网易云音乐那样,在页面切换时永不中断

传统的静态博客(如 Hugo 生成的站点)每一次点击链接,浏览器都会重新加载整个页面 (Full Page Reload)。这意味着:

  1. DOM 树被销毁重建。
  2. 所有 JavaScript 状态丢失。
  3. 音频/视频标签被重置 —— 这就是为什么音乐会停。

为了解决这个问题,我们需要引入 SPA (Single Page Application) 的概念,或者更轻量级的方案 —— Pjax (PushState + Ajax)

核心技术方案:Pjax

Pjax 的工作原理非常直观:

  1. 拦截 <a> 标签的点击事件。
  2. 使用 Ajax 请求新页面的 HTML 内容。
  3. 解析新 HTML,只提取我们需要更新的部分(例如主要内容区 .main-container)。
  4. 使用 history.pushState 修改浏览器的 URL地址栏,使其看起来像正常跳转。
  5. 替换 DOM 中的内容区。

通过这种方式,页脚 (Footer)侧边栏 (Sidebar) 可以保持不变,驻留在其中的音乐播放器自然也就不会中断了。

1. 引入 Pjax

首先在 <head> 中引入 Pjax 库(推荐使用 pjax 库而非老旧的 jquery-pjax):

1
<script src="https://cdn.jsdelivr.net/npm/pjax/pjax.min.js"></script>

2. 定制化配置 (The Tricky Part)

这是最关键的一步。为了保证 Stack 主题的正常渲染,如果你直接替换整个 body,播放器还是会挂掉。我们需要精准打击

我在 layouts/partials/head/custom.html 中进行了如下配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var pjax = new Pjax({
 selectors: [
 "title",
 ".main-container", // 只替换内容区!
 "body" // 这里的处理很有讲究,见下文
 ],
 switches: {
 "body": function(oldEl, newEl, options) {
 // 我们只更新 body 的 class (用于切换暗色模式或页面特定样式)
 // 绝不替换 body 的 innerHTML,否则页脚脚本会被杀掉
 oldEl.className = newEl.className;
 },
 ".main-container": Pjax.switches.innerHTML, // 标准替换
 "title": Pjax.switches.outerHTML
 }
});

关键点:不仅要通过 CSS 选择器指定更新区域,还要自定义 switch 函数,确保 body 标签只更新属性而不重置内容。

踩坑与填坑

实现 Pjax 只是第一步,真正的挑战在于副作用

坑一:脚本不执行 (Mastodon 动态消失)

现象:跳转到 Timeline 页面,Mastodon 动态加载不出来。 原因:通过 innerHTML 插入的 HTML 片段中如果包含 <script> 标签,浏览器出于安全和规范考虑,通常不会执行它们

解决方案: 我们将初始化代码封装为全局函数,并在 Pjax 完成事件 (pjax:complete) 中手动调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Check & Init Mastodon Logic
window.initMastodon = function() {
 if (!document.getElementById('mt-container')) return;
 // ... 初始化代码 ...
};

// 监听 Pjax 完成
document.addEventListener('pjax:complete', function () {
 window.initMastodon();
 // 其他需要重载的脚本,如 Google Analytics
 if (typeof gtag === 'function') {
 gtag('config', 'MEASUREMENT_ID', {'page_path': location.pathname});
 }
});

坑二:播放器状态丢失

现象:虽然使用了 Pjax,但用户有时会习惯性按 F5 刷新,或者 Pjax 请求超时回退到普通跳转,这时候音乐还是会断,且进度归零。

解决方案:状态持久化 (State Persistence)。

利用 localStorage 在播放器每秒更新时记录状态:

1
2
3
4
5
6
7
setInterval(() => {
 if (!ap.audio.paused) {
 localStorage.setItem('aplayer_time', ap.audio.currentTime);
 localStorage.setItem('aplayer_index', ap.list.index);
 localStorage.setItem('aplayer_paused', 'false');
 }
}, 1000);

在页面加载时(无论是 Pjax 还是普通加载),尝试恢复状态:

1
2
3
4
5
const savedTime = localStorage.getItem('aplayer_time');
if (savedTime) {
 ap.seek(parseFloat(savedTime));
 if (savedPaused === 'false') ap.play();
}

这里还有一个细节:audio 元素必须在元数据加载后才能 seek,所以需要监听 loadedmetadatacanplay 事件。

总结

通过引入 Pjax 并配合精细的生命周期管理,我们成功在静态博客上实现了类似 SPA 的流畅体验:

  1. 音乐不间断:Footer 区域脱离了页面刷新的生命周期。
  2. 加载极速:只请求部分 HTML,带宽消耗更低。
  3. 体验降级:即使 Pjax 失败,完善的状态恢复机制也能保证用户体验不割裂。

折腾博客的乐趣往往不在于写文章本身,而在于通过解决这些具体的技术问题,窥探现代 Web 开发的冰山一角。