从GitHub图床迁移到Cloudflare R2

之前使用的一直是GitHub仓库作为图床,最近GitHub经常出现429错误,加上jsDelivr的部分CDN节点回源有问题,所以决定干脆迁移到Cloudflare R2。

Cloudflare R2是一个对象存储服务,类似于AWS S3,但是没有流量费用,并且绑定信用卡后有免费10GB空间,对于博客来说够用,非常适合用作图床。

1. 创建R2存储桶

在Cloudflare控制台中,选择R2,然后创建一个新的存储桶。可以选择一个合适的名称,比如mypicture

2. 获取R2的密钥

在R2控制台中,选择API Tokens,然后创建一个新的API Token,记录对应的所有信息(只会显示一次),包括Access Key ID和Secret Access Key,以及ENDPOINT。

3. 在GitHub图床仓库中配置Action

为了方便,我写了一个GitHub Action工作流,可以自动将图片上传到Cloudflare R2,并且发布到了GitHub Action Marketplace

将以下代码添加到你的GitHub图床仓库中的.github/workflows/upload-to-r2.yml文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: 部署图片到 R2

on:
workflow_dispatch:

jobs:
sync_to_r2:
runs-on: ubuntu-latest
steps:
- name: Checkout 代码
uses: actions/checkout@v4

- name: 同步图片到 R2
uses: DullJZ/sync-r2-action@v1
with:
# 使用 Secrets 传递凭证
r2_access_key_id: ${{ secrets.R2_ACCESS_KEY_ID }}
r2_secret_access_key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
r2_endpoint: ${{ secrets.R2_ENDPOINT }}
r2_bucket_name: ${{ secrets.R2_BUCKET_NAME }}

然后在GitHub仓库的Settings -> Secrets中添加以下密钥:

  • R2_ACCESS_KEY_ID:Cloudflare R2的Access Key ID
  • R2_SECRET_ACCESS_KEY:Cloudflare R2的Secret Access Key
  • R2_ENDPOINT:Cloudflare R2的Endpoint,例如https://<account_id>.r2.cloudflarestorage.com
  • R2_BUCKET_NAME:Cloudflare R2的存储桶名称,例如mypicture

4. 使用GitHub Action迁移图片

在GitHub图床仓库中,手动触发工作流,上传所有图片到Cloudflare R2。
可以在Actions页面中查看工作流的执行情况。

5. 创建Cloudflare R2的自定义域名

在Cloudflare控制台中,选择R2,然后选择刚刚创建的存储桶,点击Settings -> Custom Domain,添加一个自定义域名。这个域名必须是添加到Cloudflare管理的域名。

6. 修改图片链接

我写了一个python脚本,输入一个GitHub仓库名称和一个域名,自动遍历当前目录及其子目录下的所有md文件,替换其中的GitHub仓库直链地址以及jsdelivr.net加速地址为该域名。例如:

输入GitHub仓库为DullJZ/MyPicture@master
输入域名为ohmyimage.pp.ua
那么就替换 https://cdn.jsdelivr.net/gh/DullJZ/MyPicture@master/1745928870467.png 以及 https://raw.githubusercontent.com/DullJZ/MyPicture/master/1745928870467.png
https://ohmyimage.pp.ua/1745928870467.png

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
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import re
import argparse
import sys

def parse_repo_string(repo_string):
"""
Parses the repository string "user/repo@branch" into (user, repo, branch).

Args:
repo_string: The repository string in the format 'user/repo@branch'.

Returns:
A tuple containing (user, repo, branch).

Raises:
ValueError: If the string format is invalid.
"""
# Regex to match the 'user/repo@branch' format
match = re.match(r"([^/]+)/([^@]+)@(.+)", repo_string)
if match:
user, repo, branch = match.groups()
# Basic validation to avoid empty parts
if user and repo and branch:
return user, repo, branch
else:
raise ValueError("User, repository name, and branch cannot be empty.")
else:
raise ValueError("Repository string must be in the format 'user/repo@branch'.")

def replace_links_in_file(filepath, repo_user, repo_name, branch, custom_domain):
"""
Reads a file, replaces specific GitHub/jsDelivr links, and writes back if changes were made.

Args:
filepath: Path to the Markdown file.
repo_user: GitHub username.
repo_name: GitHub repository name.
branch: GitHub repository branch.
custom_domain: The custom domain to use for replacement.

Returns:
The number of replacements made in this file.
"""
replacements_made = 0
try:
# Read the file content using UTF-8 encoding
with open(filepath, 'r', encoding='utf-8') as f_read:
content = f_read.read()
original_content = content # Store original content for comparison

# --- Define Regex Patterns ---
# Escape user-provided parts to be safe in regex
escaped_user = re.escape(repo_user)
escaped_repo = re.escape(repo_name)
escaped_branch = re.escape(branch)

# Pattern for jsDelivr links (matches http or https)
# Captures the path after the branch name
jsdelivr_pattern = re.compile(
# Using rf-string for easier combination of regex and variables
rf"https?://cdn\.jsdelivr\.net/gh/{escaped_user}/{escaped_repo}@{escaped_branch}/([^\s\"\'\)<>]+)"
)

# Pattern for raw GitHub links (matches http or https)
# Captures the path after the branch name
github_raw_pattern = re.compile(
rf"https?://raw\.githubusercontent\.com/{escaped_user}/{escaped_repo}/{escaped_branch}/([^\s\"\'\)<>]+)"
)

# --- Define Replacement Logic ---
# The base URL for replacement
replacement_url_base = f"https://{custom_domain}"

# Replacement function: constructs the new URL using the captured path (group 1)
def replacer(match):
nonlocal replacements_made # Allow modification of the outer scope variable
path = match.group(1) # Get the captured path part
replacements_made += 1
# Ensure no double slashes if path starts with one (though unlikely with pattern)
return f"{replacement_url_base}/{path.lstrip('/')}"

# --- Perform Replacements ---
# Apply the replacement for jsDelivr links
content = jsdelivr_pattern.sub(replacer, content)
# Apply the replacement for raw GitHub links
content = github_raw_pattern.sub(replacer, content)

# --- Write Back if Changed ---
if content != original_content:
# Write the modified content back to the file using UTF-8
with open(filepath, 'w', encoding='utf-8') as f_write:
f_write.write(content)
# Use a positive indicator for successful replacement
print(f" ✅ Replaced {replacements_made} links.")
else:
# Indicate that no relevant links were found or needed changing
# print(f" - No matching links found or no changes needed.") # Can be uncommented if more verbosity is desired
pass # Keep output clean if no changes

except FileNotFoundError:
print(f" ❌ Error: File not found at {filepath}.")
except IOError as e:
print(f" ❌ Error reading/writing file {filepath}: {e}")
except Exception as e:
# Catch any other unexpected errors during file processing
print(f" ❌ An unexpected error occurred processing file {filepath}: {e}")

return replacements_made # Return the count for this file

def main():
"""
Main function to parse arguments and orchestrate the link replacement process.
"""
# Setup argument parser for command-line interface
parser = argparse.ArgumentParser(
description="Recursively scan Markdown files and replace GitHub/jsDelivr links with a custom domain.",
formatter_class=argparse.RawDescriptionHelpFormatter # Preserve formatting in help text
)

# Required arguments
parser.add_argument(
"repo",
help="Target GitHub repository in the format 'user/repo@branch' (e.g., DullJZ/MyPicture@master)"
)
parser.add_argument(
"domain",
help="Custom domain name to use for replacement (e.g., ohmyimage.pp.ua)"
)

# Optional arguments
parser.add_argument(
"-d", "--directory",
default=".", # Default to the current directory
help="The root directory to start scanning for Markdown files (default: current directory)"
)
parser.add_argument(
"-e", "--extensions",
default=".md",
help="Comma-separated list of file extensions to process (default: .md)"
)

# Parse the command-line arguments
args = parser.parse_args()

# --- Validate Inputs ---
try:
repo_user, repo_name, branch = parse_repo_string(args.repo)
except ValueError as e:
# Print error message and exit if repository format is invalid
print(f"Error: Invalid repository format provided ('{args.repo}'). {e}", file=sys.stderr)
sys.exit(1) # Exit with a non-zero status code indicates an error

custom_domain = args.domain
start_dir = args.directory
# Process extensions argument into a tuple of lowercased extensions
file_extensions = tuple(ext.strip().lower() for ext in args.extensions.split(',') if ext.strip())
if not file_extensions:
print(f"Error: No valid file extensions specified.", file=sys.stderr)
sys.exit(1)


# --- Start Processing ---
total_files_processed = 0
total_replacements = 0

# Print initial information about the task
print(f"🚀 Starting link replacement process...")
print(f" Repository: {args.repo}")
print(f" Custom Domain: {custom_domain}")
print(f" Scanning Directory: {os.path.abspath(start_dir)}")
print(f" File Extensions: {', '.join(file_extensions)}")
print("-" * 40) # Separator for clarity

# --- Walk Through Directory ---
# os.walk yields (current_directory, subdirectories, files_in_current)
for root, _, files in os.walk(start_dir):
for filename in files:
# Check if the file extension matches the ones specified
if filename.lower().endswith(file_extensions):
filepath = os.path.join(root, filename)
# Use relative path for cleaner output if possible
relative_path = os.path.relpath(filepath, start_dir)
print(f"Processing: {relative_path}")
total_files_processed += 1
try:
# Process the file and add the number of replacements to the total
count = replace_links_in_file(filepath, repo_user, repo_name, branch, custom_domain)
total_replacements += count
except Exception as e:
# Catch potential errors from replace_links_in_file if not handled internally
print(f" ❌ Failed to process {relative_path}: {e}")


# --- Print Summary ---
print("-" * 40)
print(f"🏁 Scan complete.")
if total_files_processed == 0:
print(f" No files with extensions ({', '.join(file_extensions)}) found in '{start_dir}'.")
else:
print(f" Processed {total_files_processed} file(s).")
print(f" Made a total of {total_replacements} replacement(s).")

# --- Script Entry Point ---
if __name__ == "__main__":
# Ensure the script runs the main function when executed directly
main()

放到replace_links.py文件中,修改repo和domain参数,然后运行脚本:

例如

1
python3 replace_links.py DullJZ/MyPicture@master ohmyimage.pp.ua

可以多次运行这个脚本,确保所有的图片都被替换掉。