公众号文章的示意图(1080 宽)和带内联样式的 HTML 都由代码生成,粘贴进公众号编辑器后格式不丢。
| 文件 | 作用 |
|---|---|
make_images.py | 用 Pillow 画封面、流程图、终端截图风格的配图 |
build_html.py | 把 article.md 转成全部内联样式的 HTML(标题、代码块、图片、列表) |
crop.py | 把打过码的真实截图裁剪并加橙色标注框 |
pip install pillow
python3 make_images.py # 输出 images/
python3 build_html.py # 读取 article.md + images/,输出 article.htmlarticle.md 里用 【图N:说明】 占位,build_html.py 会按文件名前两位数字找到 images/NN-*.png。
完整源码
01-cloudflare-onboard/build_html.py
33 行 · pythonimport re,html
md=open('article.md').read()
imgs={'1':'01-cover.png','2':'02-request-path.png','3':'03-onboard-steps.png','4':'04-terminal-verify.png','5':'05-ssl-modes.png'}
P='margin:0 0 16px;font-size:15px;line-height:1.85;color:#333;letter-spacing:0.5px;'
def inline(s):
s=html.escape(s,quote=False)
s=re.sub(r'\*\*(.+?)\*\*',r'<strong style="color:#F38020;">\1</strong>',s)
s=re.sub(r'`([^`]+)`',r'<code style="background:#FFF3E8;color:#C2410C;padding:1px 5px;border-radius:3px;font-family:Menlo,Consolas,monospace;font-size:13px;">\1</code>',s)
return s
out=['<section style="max-width:677px;margin:0 auto;padding:8px;font-family:-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;">']
lines=md.split('\n'); i=0; ul=False
while i<len(lines):
l=lines[i]
if l.startswith('```'):
i+=1; buf=[]
while not lines[i].startswith('```'): buf.append(lines[i]); i+=1
out.append('<pre style="background:#0F172A;color:#E2E8F0;padding:14px 16px;border-radius:8px;overflow-x:auto;font-size:12.5px;line-height:1.7;margin:0 0 18px;"><code style="font-family:Menlo,Consolas,monospace;white-space:pre;">'+html.escape('\n'.join(buf))+'</code></pre>')
i+=1; continue
if ul and not l.startswith('- '): out.append('</ul>'); ul=False
m=re.match(r'【图(\d):(.+)】',l)
if l.startswith('# '): out.append(f'<h1 style="font-size:22px;color:#1F2937;margin:0 0 8px;">{inline(l[2:])}</h1>')
elif l.startswith('## '): out.append(f'<h2 style="font-size:18px;color:#1F2937;margin:28px 0 14px;padding-left:10px;border-left:4px solid #F38020;">{inline(l[3:])}</h2>')
elif m: out.append(f'<p style="text-align:center;margin:0 0 18px;"><img src="images/{imgs[m.group(1)]}" alt="{m.group(2)}" style="width:100%;display:block;border-radius:6px;"/></p>')
elif l.startswith('- '):
if not ul: out.append('<ul style="padding-left:22px;margin:0 0 16px;">'); ul=True
out.append(f'<li style="{P}margin-bottom:6px;">{inline(l[2:])}</li>')
elif l.startswith('作者:'): out.append(f'<p style="font-size:13px;color:#999;margin:0 0 18px;">{inline(l)}</p>')
elif l.strip(): out.append(f'<p style="{P}">{inline(l)}</p>')
i+=1
if ul: out.append('</ul>')
out.append('</section>')
open('article.html','w').write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>把域名接入 Cloudflare</title></head><body style="background:#fff;">'+'\n'.join(out)+'</body></html>')01-cloudflare-onboard/make_images.py
127 行 · pythonfrom PIL import Image, ImageDraw, ImageFont
F='/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'
FB='/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
def f(s,b=False): return ImageFont.truetype(FB if b else F,s,index=2)
def mono(s,b=False): return ImageFont.truetype(FB if b else F,s,index=7)
OR='#F38020'; DK='#1F2937'; GR='#6B7280'; BG='#FAFAF7'; LT='#FFF3E8'; BL='#2563EB'; GN='#16A34A'; RD='#DC2626'; YL='#D97706'
W=1080
def ctext(d,xy,t,font,fill=DK,anchor='mm'): d.text(xy,t,font=font,fill=fill,anchor=anchor)
def box(d,x0,y0,x1,y1,fill='#fff',outline=DK,w=3,r=22): d.rounded_rectangle([x0,y0,x1,y1],r,fill=fill,outline=outline,width=w)
def arrow(d,x0,y0,x1,y1,fill=DK,w=5,dash=False):
import math
if dash:
L=math.hypot(x1-x0,y1-y0); n=int(L//24)
for i in range(n):
if i%2==0:
a=i/n; b=min((i+1)/n,1)
d.line([x0+(x1-x0)*a,y0+(y1-y0)*a,x0+(x1-x0)*b,y0+(y1-y0)*b],fill=fill,width=w)
else: d.line([x0,y0,x1,y1],fill=fill,width=w)
ang=math.atan2(y1-y0,x1-x0); s=18
p=[(x1,y1),(x1-s*math.cos(ang-0.45),y1-s*math.sin(ang-0.45)),(x1-s*math.cos(ang+0.45),y1-s*math.sin(ang+0.45))]
d.polygon(p,fill=fill)
def cloud(d,cx,cy,s,fill):
for dx,dy,r in [(-0.9,0.15,0.55),(-0.25,-0.25,0.75),(0.55,-0.05,0.6),(1.05,0.25,0.4)]:
d.ellipse([cx+(dx-r)*s,cy+(dy-r)*s,cx+(dx+r)*s,cy+(dy+r)*s],fill=fill)
d.rounded_rectangle([cx-1.3*s,cy+0.05*s,cx+1.35*s,cy+0.65*s],int(0.3*s),fill=fill)
def header(d,title,sub=None):
d.rectangle([0,0,W,8],fill=OR)
ctext(d,(W/2,70),title,f(44,True))
if sub: ctext(d,(W/2,125),sub,f(26),GR)
def footer(d,H): ctext(d,(W-40,H-30),'正切AI实验室 · TangentLab.cn',f(20),'#9CA3AF',anchor='rm')
# 1 cover
H=460; im=Image.new('RGB',(W,H),'#111827'); d=ImageDraw.Draw(im)
cloud(d,860,210,120,OR); cloud(d,960,380,40,'#F6A04D')
d.rectangle([70,110,78,330],fill=OR)
d.text((100,105),'把域名接入',font=f(64,True),fill='#fff')
d.text((100,185),'Cloudflare',font=f(74,True),fill=OR)
d.text((100,290),'改 NS · 小橙云 · SSL 模式 · 缓存 · 免费玩法',font=f(26),fill='#D1D5DB')
d.text((100,380),'正切AI实验室 TangentLab.cn',font=f(22),fill='#9CA3AF')
im.save('images/01-cover.png')
# 2 path
H=900; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'接入前后,请求走的路不一样','示例域名 example.com | 源站 IP 192.0.2.1')
def row(y,label,col):
d.rounded_rectangle([40,y,W-40,y+330],26,fill='#fff',outline='#E5E7EB',width=2)
d.rounded_rectangle([60,y+20,220,y+64],14,fill=col); ctext(d,(140,y+42),label,f(24,True),'#fff')
row(170,'接入前',GR)
box(d,80,290,300,400); ctext(d,(190,328),'访客',f(30,True)); ctext(d,(190,368),'浏览器',f(22),GR)
box(d,780,290,1000,400); ctext(d,(890,328),'源站',f(30,True)); ctext(d,(890,368),'192.0.2.1',f(22),GR)
arrow(d,305,345,772,345,DK)
ctext(d,(540,315),'DNS 直接返回真实 IP,直连源站',f(24))
ctext(d,(540,445),'源站 IP 公开 · 攻击流量直达 · 所有请求都打到源站',f(22),RD)
row(540,'接入后',OR)
box(d,80,660,270,770); ctext(d,(175,698),'访客',f(30,True)); ctext(d,(175,738),'浏览器',f(22),GR)
box(d,385,640,695,790,fill=LT,outline=OR,w=4); ctext(d,(540,685),'Cloudflare 边缘',f(30,True),OR); ctext(d,(540,730),'HTTPS · 缓存 · 防护',f(22),DK); ctext(d,(540,762),'DNS 返回的是它的 IP',f(20),GR)
box(d,810,660,1000,770); ctext(d,(905,698),'源站',f(30,True)); ctext(d,(905,738),'192.0.2.1',f(22),GR)
arrow(d,275,715,378,715,OR); arrow(d,700,715,803,715,OR)
ctext(d,(540,830),'缓存命中直接返回,未命中才回源;IP 藏在 Cloudflare 后面',f(22),GN)
footer(d,H); im.save('images/02-request-path.png')
# 3 flow
steps=[('添加域名','Domains → Onboard a domain','输入根域名 example.com,选 Free 套餐'),
('核对 DNS 记录','自动扫描不保证找全','逐条对照旧记录:根域名、www、MX/TXT'),
('注册商改 NS','换成分配的两个 *.ns.cloudflare.com','开着 DNSSEC 的先关掉,一个字母都别抄错'),
('等待生效并验证','最长约 24 小时,状态变为 Active','dig / nslookup / curl -I 自己确认')]
H=200+len(steps)*210+20; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'接入 Cloudflare:四步走完','不用改一行网站代码')
y=180
for i,(a,b,c) in enumerate(steps):
d.rounded_rectangle([60,y,W-60,y+160],24,fill='#fff',outline='#E5E7EB',width=2)
d.ellipse([90,y+40,170,y+120],fill=OR); ctext(d,(130,y+80),str(i+1),f(40,True),'#fff')
d.text((205,y+22),a,font=f(34,True),fill=DK)
d.text((205,y+74),b,font=f(24),fill=BL)
d.text((205,y+112),c,font=f(22),fill=GR)
if i<len(steps)-1: arrow(d,130,y+165,130,y+205,OR,w=5)
y+=210
footer(d,H); im.save('images/03-onboard-steps.png')
# 4 terminal
lines=[('$ ','dig ns example.com @1.1.1.1 +short',None),
('','ada.ns.cloudflare.com.','out'),('','bob.ns.cloudflare.com.','out'),
('','# 看到两行 *.ns.cloudflare.com,说明 NS 已生效','c'),
('',' ',None),
('$ ','curl -sI https://example.com | grep -iE "server|cf-"',None),
('','server: cloudflare','out'),('','cf-ray: 8a1b2c3d4e5f6789-HKG','out'),('','cf-cache-status: DYNAMIC','out'),
('','# 末尾 HKG 是机房代码;HTML 默认 DYNAMIC(不缓存)','c'),
('',' ',None),
('$ ','curl -sI https://example.com/app.js | grep -i cf-cache',None),
('','cf-cache-status: HIT','out'),
('','# 静态资源第二次请求变成 HIT,缓存生效','c')]
H=150+len(lines)*44+60; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
d.rounded_rectangle([40,30,W-40,H-50],20,fill='#0F172A')
d.rounded_rectangle([40,30,W-40,86],20,fill='#1E293B'); d.rectangle([40,66,W-40,86],fill='#1E293B')
for i,c in enumerate(['#EF4444','#F59E0B','#22C55E']): d.ellipse([70+i*34,48,88+i*34,66],fill=c)
ctext(d,(W/2,58),'验证接入(示例输出,example.com)',f(22),'#94A3B8')
y=110; M=mono(25)
for p,t,k in lines:
x=75
if p: d.text((x,y),p,font=M,fill='#22C55E'); x+=d.textlength(p,font=M)
col={'out':'#E2E8F0','c':'#64748B',None:'#FBBF24'}[k]
if t.startswith('server') or t.startswith('cf-'): col='#F6A04D'
d.text((x,y),t,font=M,fill=col); y+=44
footer(d,H+10); im.save('images/04-terminal-verify.png')
# 5 SSL modes
H=1150; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'SSL/TLS 加密模式怎么选','访客 ⇄ Cloudflare ⇄ 源站,两段连接分开看')
modes=[('Flexible','HTTPS','HTTP 明文',RD,True,'源站不用证书;源站若强制跳 HTTPS → 重定向死循环','不推荐'),
('Full','HTTPS','HTTPS · 不校验',YL,False,'自签名证书也能用,但无法防止中间人','凑合'),
('Full (strict)','HTTPS','HTTPS · 校验',GN,False,'需公共 CA 证书(如 Let\'s Encrypt)或 Origin CA','推荐')]
y=185
for name,a,b,col,dash,note,tag in modes:
d.rounded_rectangle([40,y,W-40,y+265],24,fill='#fff',outline=col,width=3)
d.text((70,y+20),name,font=f(34,True),fill=DK)
tw=d.textlength(name,font=f(34,True))
d.rounded_rectangle([90+tw,y+28,90+tw+90,y+66],12,fill=col); ctext(d,(90+tw+45,y+47),tag,f(22,True),'#fff')
by=y+95
for x0,x1,t in [(70,250,'访客'),(440,640,'Cloudflare'),(830,1010,'源站')]:
box(d,x0,by,x1,by+70,w=2,r=16,outline='#9CA3AF'); ctext(d,((x0+x1)/2,by+35),t,f(26,True),OR if t=='Cloudflare' else DK)
arrow(d,255,by+35,433,by+35,GN,w=6); ctext(d,(345,by+5),a,f(20),GN)
arrow(d,645,by+35,823,by+35,col,w=6,dash=dash); ctext(d,(735,by+5),b,f(20),col)
ctext(d,(W/2,y+218),note,f(22),GR)
y+=290
ctext(d,(W/2,y+20),'配合 Always Use HTTPS 在边缘统一跳转,源站别再自己跳',f(24,True),BL)
footer(d,H); im.save('images/05-ssl-modes.png')02-cloudflare-tunnel/build_html.py
33 行 · pythonimport re,html
md=open('article.md').read()
imgs={'1':'01-cover.png','2':'02-direction.png','3':'03-six-steps.png','4':'04-ingress.png','5':'05-terminal.png'}
P='margin:0 0 16px;font-size:15px;line-height:1.85;color:#333;letter-spacing:0.5px;'
def inline(s):
s=html.escape(s,quote=False)
s=re.sub(r'\*\*(.+?)\*\*',r'<strong style="color:#F38020;">\1</strong>',s)
s=re.sub(r'`([^`]+)`',r'<code style="background:#FFF3E8;color:#C2410C;padding:1px 5px;border-radius:3px;font-family:Menlo,Consolas,monospace;font-size:13px;">\1</code>',s)
return s
out=['<section style="max-width:677px;margin:0 auto;padding:8px;font-family:-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;">']
lines=md.split('\n'); i=0; ul=False
while i<len(lines):
l=lines[i]
if l.startswith('```'):
i+=1; buf=[]
while not lines[i].startswith('```'): buf.append(lines[i]); i+=1
out.append('<pre style="background:#0F172A;color:#E2E8F0;padding:14px 16px;border-radius:8px;overflow-x:auto;font-size:12.5px;line-height:1.7;margin:0 0 18px;"><code style="font-family:Menlo,Consolas,monospace;white-space:pre;">'+html.escape('\n'.join(buf))+'</code></pre>')
i+=1; continue
if ul and not l.startswith('- '): out.append('</ul>'); ul=False
m=re.match(r'【图(\d):(.+)】',l)
if l.startswith('# '): out.append(f'<h1 style="font-size:22px;color:#1F2937;margin:0 0 8px;">{inline(l[2:])}</h1>')
elif l.startswith('## '): out.append(f'<h2 style="font-size:18px;color:#1F2937;margin:28px 0 14px;padding-left:10px;border-left:4px solid #F38020;">{inline(l[3:])}</h2>')
elif m: out.append(f'<p style="text-align:center;margin:0 0 18px;"><img src="images/{imgs[m.group(1)]}" alt="{m.group(2)}" style="width:100%;display:block;border-radius:6px;"/></p>')
elif l.startswith('- '):
if not ul: out.append('<ul style="padding-left:22px;margin:0 0 16px;">'); ul=True
out.append(f'<li style="{P}margin-bottom:6px;">{inline(l[2:])}</li>')
elif l.startswith('作者:'): out.append(f'<p style="font-size:13px;color:#999;margin:0 0 18px;">{inline(l)}</p>')
elif l.strip(): out.append(f'<p style="{P}">{inline(l)}</p>')
i+=1
if ul: out.append('</ul>')
out.append('</section>')
open('article.html','w').write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Cloudflare Tunnel 实操</title></head><body style="background:#fff;">'+'\n'.join(out)+'</body></html>')02-cloudflare-tunnel/make_images.py
144 行 · pythonfrom PIL import Image, ImageDraw, ImageFont
F='/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'
FB='/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
def f(s,b=False): return ImageFont.truetype(FB if b else F,s,index=2)
def mono(s,b=False): return ImageFont.truetype(FB if b else F,s,index=7)
OR='#F38020'; DK='#1F2937'; GR='#6B7280'; BG='#FAFAF7'; LT='#FFF3E8'; BL='#2563EB'; GN='#16A34A'; RD='#DC2626'; YL='#D97706'
W=1080
def ctext(d,xy,t,font,fill=DK,anchor='mm'): d.text(xy,t,font=font,fill=fill,anchor=anchor)
def box(d,x0,y0,x1,y1,fill='#fff',outline=DK,w=3,r=22): d.rounded_rectangle([x0,y0,x1,y1],r,fill=fill,outline=outline,width=w)
def arrow(d,x0,y0,x1,y1,fill=DK,w=5,dash=False):
import math
if dash:
L=math.hypot(x1-x0,y1-y0); n=int(L//24)
for i in range(n):
if i%2==0:
a=i/n; b=min((i+1)/n,1)
d.line([x0+(x1-x0)*a,y0+(y1-y0)*a,x0+(x1-x0)*b,y0+(y1-y0)*b],fill=fill,width=w)
else: d.line([x0,y0,x1,y1],fill=fill,width=w)
ang=math.atan2(y1-y0,x1-x0); s=18
p=[(x1,y1),(x1-s*math.cos(ang-0.45),y1-s*math.sin(ang-0.45)),(x1-s*math.cos(ang+0.45),y1-s*math.sin(ang+0.45))]
d.polygon(p,fill=fill)
def cloud(d,cx,cy,s,fill):
for dx,dy,r in [(-0.9,0.15,0.55),(-0.25,-0.25,0.75),(0.55,-0.05,0.6),(1.05,0.25,0.4)]:
d.ellipse([cx+(dx-r)*s,cy+(dy-r)*s,cx+(dx+r)*s,cy+(dy+r)*s],fill=fill)
d.rounded_rectangle([cx-1.3*s,cy+0.05*s,cx+1.35*s,cy+0.65*s],int(0.3*s),fill=fill)
def header(d,title,sub=None):
d.rectangle([0,0,W,8],fill=OR)
ctext(d,(W/2,70),title,f(44,True))
if sub: ctext(d,(W/2,125),sub,f(26),GR)
def footer(d,H): ctext(d,(W-40,H-30),'正切AI实验室 · TangentLab.cn',f(20),'#9CA3AF',anchor='rm')
# 1 cover
H=460; im=Image.new('RGB',(W,H),'#111827'); d=ImageDraw.Draw(im)
cloud(d,860,210,120,OR); cloud(d,960,380,40,'#F6A04D')
d.rectangle([70,110,78,330],fill=OR)
d.text((100,105),'把域名接入',font=f(64,True),fill='#fff')
d.text((100,185),'Cloudflare',font=f(74,True),fill=OR)
d.text((100,290),'改 NS · 小橙云 · SSL 模式 · 缓存 · 免费玩法',font=f(26),fill='#D1D5DB')
d.text((100,380),'正切AI实验室 TangentLab.cn',font=f(22),fill='#9CA3AF')
im.save('images/01-cover.png')
# ---------- 1 cover
H=460; im=Image.new('RGB',(W,H),'#111827'); d=ImageDraw.Draw(im)
# tunnel motif on right
cx,cy=890,230
for i,r in enumerate([150,118,86]):
d.arc([cx-r,cy-r,cx+r,cy+r],200,340,fill=[OR,'#F6A04D','#FBC58A'][i],width=14)
cloud(d,cx,cy-30,34,OR)
d.rounded_rectangle([cx-150,cy+40,cx-60,cy+100],10,fill='#1F2937',outline='#9CA3AF',width=3); ctext(d,(cx-105,cy+70),'家里',f(22,True),'#fff')
d.rounded_rectangle([cx+60,cy+40,cx+150,cy+100],10,fill='#1F2937',outline=OR,width=3); ctext(d,(cx+105,cy+70),'边缘',f(22,True),OR)
arrow(d,cx-55,cy+70,cx+52,cy+70,OR,5)
d.rectangle([70,110,78,340],fill=OR)
d.text((100,100),'没有公网 IP 也能上线',font=f(52,True),fill='#fff')
d.text((100,175),'Cloudflare Tunnel',font=f(66,True),fill=OR)
d.text((100,275),'Quick Tunnel · 命令行六步 · ingress · Access',font=f(26),fill='#D1D5DB')
d.text((100,385),'正切AI实验室 TangentLab.cn',font=f(22),fill='#9CA3AF')
im.save('images/01-cover.png')
# ---------- 2 direction
H=980; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'连接方向反过来了','端口映射是「等人连进来」,Tunnel 是「主动连出去」')
def row(y,h,label,col):
d.rounded_rectangle([40,y,W-40,y+h],26,fill='#fff',outline='#E5E7EB',width=2)
d.rounded_rectangle([60,y+20,260,y+64],14,fill=col); ctext(d,(160,y+42),label,f(24,True),'#fff')
row(170,330,'端口映射',GR)
box(d,80,290,280,400); ctext(d,(180,328),'访客',f(30,True)); ctext(d,(180,368),'浏览器',f(22),GR)
box(d,430,280,650,410,outline=RD,w=4); ctext(d,(540,322),'路由器',f(30,True)); ctext(d,(540,365),'开放 80/443',f(22),RD)
box(d,800,290,1000,400); ctext(d,(900,328),'家里主机',f(28,True)); ctext(d,(900,368),'192.0.2.10',f(22),GR)
arrow(d,285,345,422,345,RD); arrow(d,655,345,792,345,RD)
ctext(d,(540,450),'需要公网 IP · 要开入站端口 · 端口暴露在公网上',f(22),RD)
row(540,400,'Tunnel',OR)
box(d,60,680,250,790); ctext(d,(155,718),'访客',f(30,True)); ctext(d,(155,758),'浏览器',f(22),GR)
box(d,300,660,640,810,fill=LT,outline=OR,w=4); ctext(d,(470,700),'Cloudflare 边缘',f(30,True),OR); ctext(d,(470,745),'nas.example.com',f(22),DK); ctext(d,(470,780),'CNAME 指向 UUID.cfargotunnel.com',f(17),GR)
box(d,800,640,1030,830); ctext(d,(915,680),'家里主机',f(28,True))
d.rounded_rectangle([818,705,1012,750],10,fill='#FFF7ED',outline=OR,width=2); ctext(d,(915,727),'cloudflared',f(22,True),OR)
ctext(d,(915,790),'localhost:5000',f(22),GR)
arrow(d,255,735,292,735,OR)
arrow(d,792,705,648,705,GN,5,dash=True)
ctext(d,(720,675),'① 主动连出',f(19,True),GN)
arrow(d,648,765,792,765,OR)
ctext(d,(720,797),'② 流量回传',f(19,True),OR)
ctext(d,(540,880),'不需要公网 IP · 入站端口可以全关 · 出站放行 7844 即可',f(22),GN)
footer(d,H); im.save('images/02-direction.png')
# ---------- 3 steps
H=1110; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'本地管理隧道:六步走完','示例隧道名 home-lab | 域名 nas.example.com')
steps=[('安装','apt / brew / exe','apt-get install cloudflared'),
('登录','生成 cert.pem','cloudflared tunnel login'),
('创建隧道','得到 UUID 和凭据 json','cloudflared tunnel create home-lab'),
('写配置','~/.cloudflared/config.yml','ingress: hostname → service'),
('建 DNS','自动加 CNAME','tunnel route dns home-lab nas…'),
('运行检查','run 之后看 info','cloudflared tunnel run home-lab')]
for i,(t,sub,cmd) in enumerate(steps):
c=i%2; r=i//2
x0=60+c*500; y0=180+r*280; x1=x0+460; y1=y0+240
box(d,x0,y0,x1,y1,outline='#E5E7EB',w=2)
d.ellipse([x0+24,y0+24,x0+84,y0+84],fill=OR); ctext(d,(x0+54,y0+54),str(i+1),f(30,True),'#fff')
d.text((x0+104,y0+28),t,font=f(32,True),fill=DK)
d.text((x0+104,y0+76),sub,font=f(22),fill=GR)
d.rounded_rectangle([x0+24,y0+140,x1-24,y0+200],10,fill='#0F172A')
d.text((x0+40,y0+170),cmd,font=mono(20),fill='#E2E8F0',anchor='lm')
ctext(d,(W/2,1035),'装成系统服务:sudo cloudflared --config <路径> service install',f(22),DK)
footer(d,H); im.save('images/03-six-steps.png')
# ---------- 4 ingress
H=990; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'ingress 规则:从上往下,命中即停','编号从 0 开始 · 最后一条必须是兜底规则')
rules=[('#0','nas.example.com','localhost:5000'),('#1','blog.example.com path ^/api','localhost:3000'),('#2','blog.example.com','localhost:8080'),('#3','*.example.com','hello_world'),('#4','(兜底,不写 hostname)','http_status:404')]
for i,(n,h,s) in enumerate(rules):
y=180+i*130; col=RD if i==4 else OR
box(d,500,y,1030,y+100,outline=col,w=3,fill='#fff' if i<4 else '#FEF2F2')
d.text((525,y+50),n,font=f(28,True),fill=col,anchor='lm')
d.text((590,y+32),h,font=f(22,True),fill=DK,anchor='lm')
d.text((590,y+70),'→ '+s,font=f(22),fill=GR,anchor='lm')
reqs=[('blog.example.com/api/users',1),('blog.example.com/about',2),('test.example.com',3),('other.test',4)]
for j,(u,k) in enumerate(reqs):
y=210+j*150
d.rounded_rectangle([40,y,400,y+64],12,fill='#EFF6FF',outline=BL,width=2)
d.text((220,y+32),u,font=f(20),fill=BL,anchor='mm')
ty=180+k*130+50
arrow(d,404,y+32,492,ty,BL,4)
ctext(d,(W/2,860),'左边是请求,右边是规则;越具体的规则越要往前放',f(24),DK)
ctext(d,(W/2,905),'path 只用来匹配,转发时路径原样保留',f(22),GR)
footer(d,H); im.save('images/04-ingress.png')
# ---------- 5 terminal
lines=[('$ cloudflared --version','p'),('cloudflared version 2026.10.1 (built 2026-10-15-17:08 UTC)','o'),('',''),
('$ cloudflared tunnel --config config.yml ingress validate','p'),('Validating rules from config.yml','o'),('OK','g'),('',''),
('$ cloudflared tunnel --config config.yml ingress rule https://blog.example.com/api/users','p'),('Using rules from config.yml','o'),('Matched rule #1','g'),(' hostname: blog.example.com','o'),(' path: ^/api','o'),(' service: http://localhost:3000','o'),('',''),
('# 故意删掉最后的兜底规则再校验','c'),('$ cloudflared tunnel --config bad.yml ingress validate','p'),('Validating rules from bad.yml','o'),('Validation failed: The last ingress rule must match all URLs','r'),('(i.e. it should not have a hostname or path filter)','r')]
lh=40; H=180+len(lines)*lh+140
im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'本机实测:校验和匹配规则','离线即可运行,不需要连上 Cloudflare')
d.rounded_rectangle([40,160,W-40,H-70],18,fill='#0F172A')
for k,cl in enumerate(['#EF4444','#F59E0B','#22C55E']): d.ellipse([70+k*34,182,90+k*34,202],fill=cl)
cols={'p':'#E2E8F0','o':'#94A3B8','g':'#4ADE80','r':'#F87171','c':'#FBBF24','':'#fff'}
y=235
for t,k in lines:
fnt=mono(18) if k!='c' else f(19)
d.text((70,y),t,font=fnt,fill=cols[k]); y+=lh
footer(d,H); im.save('images/05-terminal.png')
print('ok')03-free-domain/build_html.py
37 行 · pythonimport re,html,os
md=open('article.md').read()
files=sorted(os.listdir('images')); imgs={str(int(f[:2])):f for f in files}
P='margin:0 0 16px;font-size:15px;line-height:1.85;color:#333;letter-spacing:0.5px;'
def inline(s):
s=html.escape(s,quote=False)
s=re.sub(r'\*\*(.+?)\*\*',r'<strong style="color:#F38020;">\1</strong>',s)
s=re.sub(r'`([^`]+)`',r'<code style="background:#FFF3E8;color:#C2410C;padding:1px 5px;border-radius:3px;font-family:Menlo,Consolas,monospace;font-size:13px;">\1</code>',s)
return s
out=['<section style="max-width:677px;margin:0 auto;padding:8px;font-family:-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;">']
lines=md.split('\n'); i=0; ul=False
while i<len(lines):
l=lines[i]
if l.startswith('```'):
i+=1; buf=[]
while not lines[i].startswith('```'): buf.append(lines[i]); i+=1
out.append('<pre style="background:#0F172A;color:#E2E8F0;padding:14px 16px;border-radius:8px;overflow-x:auto;font-size:12.5px;line-height:1.7;margin:0 0 18px;"><code style="font-family:Menlo,Consolas,monospace;white-space:pre;">'+html.escape('\n'.join(buf))+'</code></pre>')
i+=1; continue
if ul and not l.startswith('- '): out.append('</ul>'); ul=False
m=re.match(r'【图(\d+):(.+)】',l)
if l.startswith('# '): out.append(f'<h1 style="font-size:22px;color:#1F2937;margin:0 0 8px;">{inline(l[2:])}</h1>')
elif l.startswith('## '): out.append(f'<h2 style="font-size:18px;color:#1F2937;margin:28px 0 14px;padding-left:10px;border-left:4px solid #F38020;">{inline(l[3:])}</h2>')
elif m:
n=m.group(1); cap=m.group(2)
out.append(f'<p style="text-align:center;margin:0 0 6px;"><img src="images/{imgs[n]}" alt="{html.escape(cap)}" style="width:100%;display:block;border-radius:6px;border:1px solid #E5E7EB;"/></p>')
if n!='1': out.append(f'<p style="text-align:center;font-size:12px;color:#9CA3AF;margin:0 0 18px;">{html.escape(cap)}</p>')
elif l.startswith('- '):
if not ul: out.append('<ul style="padding-left:22px;margin:0 0 16px;">'); ul=True
out.append(f'<li style="{P}margin-bottom:6px;">{inline(l[2:])}</li>')
elif l.startswith('作者:'): out.append(f'<p style="font-size:13px;color:#999;margin:0 0 18px;">{inline(l)}</p>')
elif l.strip(): out.append(f'<p style="{P}">{inline(l)}</p>')
i+=1
if ul: out.append('</ul>')
out.append('</section>')
open('article.html','w').write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>0 元免费域名接入 Cloudflare</title></head><body style="background:#fff;">'+'\n'.join(out)+'</body></html>')
print('ok',len(imgs))03-free-domain/crop.py
19 行 · pythonfrom PIL import Image
T='./material/'
C=[('s01-digitalplat-github-only','02-digitalplat-dpdns/masked/17-kyc-dropdown-github-oauth-only.png',(360,80,940,390)),
('s02-github-restricted','03-github-signup-blocked/masked/01-access-temporarily-restricted.png',(420,90,1020,710)),
('s03-dnshe-rejected','04-dnshe-email-rejected/masked/03-email-domain-rejected.png',(610,120,1130,560)),
('s04-katabump-signup','05-katabump-signup/masked/03-create-account-form.png',(500,190,940,750)),
('s05-order-free-external-ns','06-katabump-order-kdns/masked/03-name-tangentlab-free.png',(430,180,1230,590)),
('s06-domain-created','06-katabump-order-kdns/masked/04-domain-created.png',(230,160,1330,580)),
('s07-cf-free-plan','07-cloudflare-add-domain-nameservers/masked/04-select-free-plan.png',(195,120,1000,560)),
('s08-cf-assigned-ns','07-cloudflare-add-domain-nameservers/masked/07-assigned-nameservers.png',(430,90,1000,600)),
('s09-katabump-ns-updated','08-katabump-set-ns/masked/03-nameservers-updated.png',(230,170,1330,560)),
('s10-cf-active','09-cloudflare-active/masked/02-domain-active-protected.png',(230,120,1080,560)),
('s11-ssl-modes','10-cloudflare-dns-ssl/masked/05-encryption-mode-options.png',(330,110,1300,700)),
('s12-edge-cert','10-cloudflare-dns-ssl/masked/06-edge-certificates-universal-active.png',(410,170,1220,600))]
import json,sys
for n,f,b in C:
Image.open(T+f).convert('RGB').crop(b).save(f'crops/{n}.png')
json.dump(C,open('crops/crops.json','w'))03-free-domain/make_images.py
96 行 · pythonfrom PIL import Image, ImageDraw, ImageFont
import json, math
F='/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'
FB='/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
def f(s,b=False): return ImageFont.truetype(FB if b else F,s,index=2)
MONO='/usr/share/fonts/truetype/IBM Plex Mono/IBMPlexMono-Regular.ttf'
def mono(s): return ImageFont.truetype(MONO,s)
OR='#F38020'; DK='#1F2937'; GR='#6B7280'; BG='#FAFAF7'; LT='#FFF3E8'; BL='#2563EB'; GN='#16A34A'; RD='#DC2626'
W=1080
def ctext(d,xy,t,font,fill=DK,anchor='mm'): d.text(xy,t,font=font,fill=fill,anchor=anchor)
def box(d,x0,y0,x1,y1,fill='#fff',outline=DK,w=3,r=22): d.rounded_rectangle([x0,y0,x1,y1],r,fill=fill,outline=outline,width=w)
def arrow(d,x0,y0,x1,y1,fill=DK,w=5):
d.line([x0,y0,x1,y1],fill=fill,width=w); ang=math.atan2(y1-y0,x1-x0); s=18
d.polygon([(x1,y1),(x1-s*math.cos(ang-0.45),y1-s*math.sin(ang-0.45)),(x1-s*math.cos(ang+0.45),y1-s*math.sin(ang+0.45))],fill=fill)
def cloud(d,cx,cy,s,fill):
for dx,dy,r in [(-0.9,0.15,0.55),(-0.25,-0.25,0.75),(0.55,-0.05,0.6),(1.05,0.25,0.4)]:
d.ellipse([cx+(dx-r)*s,cy+(dy-r)*s,cx+(dx+r)*s,cy+(dy+r)*s],fill=fill)
d.rounded_rectangle([cx-1.3*s,cy+0.05*s,cx+1.35*s,cy+0.65*s],int(0.3*s),fill=fill)
def header(d,title,sub=None):
d.rectangle([0,0,W,8],fill=OR); ctext(d,(W/2,70),title,f(44,True))
if sub: ctext(d,(W/2,125),sub,f(26),GR)
def footer(d,H): ctext(d,(W-40,H-30),'正切AI实验室 · TangentLab.cn',f(20),'#9CA3AF',anchor='rm')
# 01 cover (1080x460 ≈ 2.35:1)
H=460; im=Image.new('RGB',(W,H),'#111827'); d=ImageDraw.Draw(im)
cloud(d,880,190,95,OR)
d.rounded_rectangle([735,300,1035,370],35,fill='#1F2937',outline=OR,width=3)
ctext(d,(885,335),'yourname.kdns.fr',ImageFont.truetype(MONO,28),'#fff')
ctext(d,(885,405),'€0 · Active · SSL ✓',f(24,True),'#4ADE80')
d.rectangle([70,100,78,340],fill=OR)
d.text((100,92),'0 元拿一个免费域名',font=f(56,True),fill='#fff')
d.text((100,170),'接入 Cloudflare',font=f(66,True),fill=OR)
d.text((100,272),'实测踩坑全记录 · 三个坑 · KataBump 跑通',font=f(26),fill='#D1D5DB')
d.text((100,385),'正切AI实验室 TangentLab.cn',font=f(22),fill='#9CA3AF')
im.save('images/01-cover.png')
# 02 delegation chain + PSL
H=1300; im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'一个域名是怎么被「交给」Cloudflare 的','以下 NS 都是 2026-10-19 用 DoH 实际查到的')
rows=[('根','.','管理 fr 等顶级域','#E5E7EB',DK),
('顶级域','fr','NS:d.nic.fr 等(法国注册局)','#E5E7EB',DK),
('公共后缀','kdns.fr','NS:w/x/y/z.kdns.fr(KataBump 运营)· 在 PSL 名单里','#FFF7ED',OR),
('你的域名','tangentlab.kdns.fr','NS:bob / ada.ns.cloudflare.com','#FFF3E8',OR),
('解析记录','@ / www …','在 Cloudflare 后台添加 A、CNAME 等记录','#EFF6FF',BL)]
y=180
for i,(lab,name,desc,fill,col) in enumerate(rows):
box(d,60,y,W-60,y+130,fill=fill,outline=col,w=3)
d.rounded_rectangle([84,y+38,234,y+92],14,fill=col); ctext(d,(159,y+65),lab,f(24,True),'#fff')
d.text((262,y+40),name,font=mono(34),fill=DK,anchor='lm')
d.text((262,y+92),desc,font=f(23),fill=GR,anchor='lm')
if i<len(rows)-1:
arrow(d,W/2,y+132,W/2,y+178,OR if i>=1 else GR,5)
ctext(d,(W/2+150,y+155),['委派','委派','你在 KataBump 里改 NS','Cloudflare 负责解析'][i],f(20,True),OR if i>=2 else GR)
y+=180
box(d,60,y+10,W-60,y+160,fill='#fff',outline='#E5E7EB',w=2)
d.text((90,y+50),'Cloudflare 免费版的规则:域名必须正好比 PSL 后缀多一级',font=f(26,True),fill=DK,anchor='lm')
d.text((90,y+100),'tangentlab.kdns.fr → 可以接入 | a.tangentlab.kdns.fr → 不行(企业版才能)',font=f(23),fill=GR,anchor='lm')
footer(d,H); im.save('images/02-dns-chain.png')
# 12 terminal (real output, captured 2026-10-19)
lines=[("$ curl -s 'https://dns.google/resolve?name=tangentlab.kdns.fr&type=NS' | jq -r '.Answer[].data'",'p'),
('ada.ns.cloudflare.com.','g'),('bob.ns.cloudflare.com.','g'),('',''),
('# 往上一层:kdns.fr 本身由 KataBump 的 NS 负责','c'),
("$ curl -s 'https://dns.google/resolve?name=kdns.fr&type=NS' | jq -r '.Answer[].data'",'p'),
('y.kdns.fr.','o'),('z.kdns.fr.','o'),('x.kdns.fr.','o'),('w.kdns.fr.','o'),('',''),
('# 查后缀是否在公共后缀列表(PSL)里','c'),
("$ curl -s https://publicsuffix.org/list/public_suffix_list.dat | grep -x 'kdns.fr'",'p'),('kdns.fr','g')]
lh=36; H=180+len(lines)*lh+140
im=Image.new('RGB',(W,H),BG); d=ImageDraw.Draw(im)
header(d,'本机实测:NS 委派已经生效','返回 Cloudflare 的两个 NS,说明改 NS 成功')
d.rounded_rectangle([40,160,W-40,H-70],18,fill='#0F172A')
for k,cl in enumerate(['#EF4444','#F59E0B','#22C55E']): d.ellipse([70+k*34,182,90+k*34,202],fill=cl)
cols={'p':'#E2E8F0','o':'#94A3B8','g':'#4ADE80','r':'#F87171','c':'#FBBF24','':'#fff'}
y=235
for t,k in lines:
fnt=mono(16) if k!='c' else f(19)
d.text((70,y),t,font=fnt,fill=cols[k]); y+=lh
footer(d,H); im.save('images/12-terminal-doh.png')
# annotated screenshots
HL={'s01':[(178,246,542,292)],'s02':[(190,222,410,256)],'s03':[(50,326,498,362)],'s04':[],
's05':[(403,234,776,304),(29,175,113,196)],'s06':[(126,240,584,312)],'s07':[(99,113,343,285)],
's08':[(58,398,298,468)],'s09':[(596,84,675,109),(126,231,584,301)],'s10':[(36,126,390,162)],
's11':[(25,262,482,345)],'s12':[(24,318,790,382)]}
NUM={'s01':3,'s02':4,'s03':5,'s04':6,'s05':7,'s06':8,'s07':9,'s08':10,'s09':11,'s10':13,'s12':14,'s11':15}
C=json.load(open('crops/crops.json'))
for n,src,b in C:
k=n[:3]; im=Image.open(f'crops/{n}.png').convert('RGB')
# add 2px light border frame
d=ImageDraw.Draw(im)
for r in HL[k]: d.rounded_rectangle(r,8,outline=OR,width=4)
if im.width<760:
c=im.getpixel((2,im.height-3)); pad=Image.new('RGB',(900,im.height+40),c); pad.paste(im,((900-im.width)//2,20)); im=pad
im.save(f'images/{NUM[k]:02d}-{n[4:]}.png')
print('ok')