阅读视图

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

在 Docker 沙箱中运行 MCP Server

MCP 是今年 AI 开发行业很热门的一个协议,但是由于它的 C/S 架构,导致使用者必须在本地运行 MCP Server。

MCP Server 常见的运行方式有 npx(NPM 生态)、uvx(Python 生态)、Docker 等 stdio 方式和 HTTP(SSE/Streaming) 方式。但是 npx 和 uvx 运行命令有着极大的风险。如果不慎执行恶意软件包,可能会导致隐私数据泄露带来极大的安全风险。具体可以看 Invariant 的 MCP Security Notification: Tool Poisoning Attacks 这篇文章。

作为一个软件行业从业者,对安全的关注度极高。让 ChatGPT 整理了一下最近 5 年的 NPM 和 PyPI 供应链攻击事件,让人不寒而栗。

时间事件概要与影响范围
2021年2月“依赖混淆”漏洞披露安全研究员 Alex Birsan 利用依赖混淆(Dependency Confusion)技术,在 NPM/PyPI 上传与多家企业内部库同名的软件包,成功入侵了包括苹果、微软等35家大厂内部服务器 (PyPI flooded with 1,275 dependency confusion packages)。这一演示引发业内对供应链风险的高度关注。
2021年10月UAParser.js 库遭劫持NPM上每周下载量超700万的流行库 ua-parser-js 被攻击者通过维护者账户入侵发布恶意版本 (A Timeline of SSC Attacks, Curated by Sonatype)。受感染版本在安装时植入密码窃取木马加密货币挖矿程序,波及大量开发者系统。
2021年10月假冒 Roblox 库投毒攻击者在 NPM 上传多个假冒 Roblox API 的软件包(如 noblox.js-proxy),内含混淆的恶意代码,安装后会植入木马和勒索软件等Payload (A Timeline of SSC Attacks, Curated by Sonatype)。这些包下载数千次,显示出攻击者通过typosquatting手法诱骗游戏开发者。
2021年11月COA 与 RC 库连续劫持NPM上热门库 coa(每周下载数百万)和 rc(每周1400万下载)相继被入侵发布恶意版本。受害版本执行与 UAParser.js 案例类似的凭证窃取木马,一度导致全球众多使用 React 等框架的项目构建管线中断 (A Timeline of SSC Attacks, Curated by Sonatype) (A Timeline of SSC Attacks, Curated by Sonatype)。官方调查认定原因均为维护者账户被盗用。
2022年1月Colors/Faker 开源库“自杀”著名的颜色格式库 colors.js 和测试数据生成库 faker.js 的作者出于抗议,在最新版本中注入无限循环等破坏性代码,导致包括Meta(Facebook)和亚马逊等公司在内的数千项目崩溃 (A Timeline of SSC Attacks, Curated by Sonatype)(虽非外部攻击,但属于供应链投毒范畴)。
2022年1月PyPI 1,275个恶意包集中投放一名用户在1月23日一天内疯狂向 PyPI 发布了 1,275 个恶意软件包 (A Timeline of SSC Attacks, Curated by Sonatype)。这些包大多冒用知名项目或公司的名字(如 xcryptographySagepay 等),安装后收集主机名、IP等指纹信息并通过 DNS/HTTP 回传给攻击者 (PyPI flooded with 1,275 dependency confusion packages) (PyPI flooded with 1,275 dependency confusion packages)。PyPI 管理员在收到报告后一小时内即下架了所有相关包 (PyPI flooded with 1,275 dependency confusion packages)。
2022年3月Node-ipc “抗议软件”事件前端构建常用库 node-ipc 的作者在 v10.1.1–10.1.3 版本中加入恶意代码:检测到客户端 IP 属于俄罗斯或白俄罗斯时,就擦除文件系统、用爱心表情覆盖文件 (Corrupted open-source software enters the Russian battlefield | ZDNET) (Corrupted open-source software enters the Russian battlefield | ZDNET)。该库被 Vue CLI 等广泛依赖,导致大量用户系统遭破坏,并被赋予 CVE-2022-23812(CVSS 9.8) (Corrupted open-source software enters the Russian battlefield | ZDNET)。
2022年10月LofyGang 大规模投毒活动安全公司发现一个名为“LofyGang”的团伙在 NPM 上分发了将近 200 个恶意包 (LofyGang Distributed ~200 Malicious NPM Packages to Steal Credit Card Data)。这些包通过typosquatting和伪装常用库名称植入木马,窃取开发者的信用卡信息、Discord 账户以及游戏服务登录凭据,累计安装次数达数千次 (LofyGang Distributed ~200 Malicious NPM Packages to Steal Credit Card Data)。这是一起持续一年多的有组织网络犯罪活动。
2022年12月PyTorch-nightly 依赖链攻击知名深度学习框架 PyTorch 披露其夜间版在 12月25–30日间遭遇依赖混淆式供应链攻击 (Malicious PyTorch dependency ‘torchtriton’ on PyPI | Wiz Blog):攻击者在 PyPI 上注册了名为 torchtriton 的恶意包,与 PyTorch 夜ly 版所需的私有依赖同名,导致数千名通过 pip 安装 nightly 版的用户中招 (Malicious PyTorch dependency ‘torchtriton’ on PyPI | Wiz Blog)。恶意 torchtriton 包运行后收集系统上的环境变量和秘钥并上传至攻击者服务器,危及用户的云凭证安全。PyTorch 官方紧急发布警告并替换了该命名空间 (Malicious PyTorch dependency ‘torchtriton’ on PyPI | Wiz Blog)。
2023年3月“W4SP Stealer” 木马泛滥 PyPI安全研究员陆续发现 PyPI 上出现大量携带 W4SP Stealer 信息窃取木马的恶意包 (W4SP Stealer Discovered in Multiple PyPI Packages Under Various Names)。这些木马别名众多(如 ANGEL Stealer、PURE Stealer 等),但本质均为 W4SP 家族,专门窃取用户密码、加密货币钱包和 Discord 令牌等信息 (W4SP Stealer Discovered in Multiple PyPI Packages Under Various Names)。一次报告就揭示了16个此类恶意包(如 modulesecurityeasycordey 等) (W4SP Stealer Discovered in Multiple PyPI Packages Under Various Names)。PyPI 针对此类木马展开清理,并加强了上传检测。
2023年8月Lazarus 组织攻击 PyPIReversingLabs 报告称朝鲜黑客组织 Lazarus 的分支在 PyPI 发布了逾两打(24个以上)伪装热门库的恶意包(代号“VMConnect”行动) (Software Supply Chain Attacks: A (partial) History)。这些包企图针对特定行业(如金融)用户,植入远程访问木马。据称该攻击与此前针对 NuGet 的类似活动相关联,显示出国家级黑客对开源供应链的兴趣。
2024年及以后持续的供应链威胁2024年以来,NPM 与 PyPI 上仍不断爆出新的投毒事件。例如2024年初发现假冒VS Code相关NPM包内含远控间谍软件 (A Timeline of SSC Attacks, Curated by Sonatype)、假冒Solana库窃取加密钱包密钥的PyPI包 (A Timeline of SSC Attacks, Curated by Sonatype)等。这表明供应链攻击已成常态化威胁,需要生态系统持续提高警惕和防御能力。

发 Twitter 吐槽了一下,结果吐槽的时候就看到一个推友遇到了一起供应链攻击事件。

Twitter

所幸 @TBXark 推荐了他的 MCP Proxy 项目,可以很方便的将 MCP Server 运行在 Docker 中。他最初的目的是把 MCP Server 运行在服务器上,减少客户端压力和方便移动端调用。 然而由于 Docker 天然的隔离特性,与我期望有沙箱的诉求不谋而合。

MCP Proxy 会在 Docker 中运行 MCP Servers 并转换为 MCP SSE 的协议,这样用户就可以在 MCP 客户端中全部走 SSE 协议调用,这样可以大大减小 npx 和 uvx 直接运行带来的任意文件读取风险。如果部署在境外服务器, 还可以顺带解决网络的问题

但是当前还是可以读取到 /config/config.json 这个 MCP Proxy 的配置文件, 风险可控。同时也给开发者提了需求, config 文件配置 400 权限, npx 和 uvx 命令使用 nobody 用户运行。如果可以实现,将完美解决任意文件读取的问题。

运行 MCP Proxy

MCP Proxy

如果你自己有 VPS 部署了 Docker, 可以使用下面的命令运行 MCP Proxy。

docker run -d -p 9090:9090 -v /path/to/config.json:/config/config.json ghcr.io/tbxark/mcp-proxy:latest

如果你没有自己的 VPS, 可以使用 claw.cloud 提供的免费容器服务(每个月 $5 额度, GitHub 注册需满 180 天)。

由于 Claw 有容器大小的限制,我们需要使用下面的环境变量,配置 npx 和 uvx 的缓存目录,防止容器崩溃。

UV_CACHE_DIR=/cache/uv
npm_config_cache=/cache/npm

同时在 /cache 路径下挂载 10G 的存储。 配置参考我的配置: 0.5c CPU, 512M 内存, 10G 硬盘。

最终的配置如下:

Claw

配置 MCP Proxy

MCP Proxy 的配置文件需要挂载在 /config/config.json 路径下,完整配置请参考 https://github.com/TBXark/mcp-proxy?tab=readme-ov-file#configurationonfiguration

以下是我的配置,可以参考。

{
    "mcpProxy": {
        "baseURL": "https://mcp.miantiao.me",
        "addr": ":9090",
        "name": "MCP Proxy",
        "version": "1.0.0",
        "options": {
          "panicIfInvalid": false,
          "logEnabled": true,
          "authTokens": [
            "miantiao.me"
          ]
        }
    },
    "mcpServers": {
        "github": {
            "command": "npx",
            "args": [
                "-y",
                "@modelcontextprotocol/server-github"
            ],
            "env": {
                "GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
            }
        },
        "fetch": {
            "command": "uvx",
            "args": [
                "mcp-server-fetch"
            ]
        },
        "amap": {
            "url": "https://mcp.amap.com/sse?key=<YOUR_TOKEN>"
        }
    }
}

调用 MCP proxy

ChatWise 调用 fetch 为例,直接配置 SSE 协议即可。

fetch

是不是很简单,等 ChatWise 出了移动端这样调用也是完全可用的。

ChatWise

stat

使用 Cloudflare Workers 合并音频文件

最近把 Hacker News 中文播客 改成了双人对话的形式,由于目前的语音合成模型还不能很好地处理双人对话,所以需要把每个人的音频文件拼接起来。

由于项目之前运行在 Cloudflare Workflow 的 Worker Runtime, 众所周知 Worker Runtime 缺少不少 Node.JS 特性,无法调用 C++ 扩展。而且 Cloudflare Container 还没有正式上线,所以只能使用 Browser Rendering 来实现。

合并音频文件一般都使用 FFMpeg 来做,现在 FFMpeg 也可以通过 WASM 在浏览器内运行了。所以大体的技术方案是:

  1. 使用 Worker Binding 来启动浏览器实例
  2. 浏览器打开音频合并页面,合成语音文件,返回 Blob
  3. 将 Blob 返回给 Worker 后存入 R2

整体代码量不多,但是由于 Browser Rendering 只能远程调用,调试比较麻烦。

最终实现代码:

浏览器内音频合并代码

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Audio</title>
  </head>
  <body>
    <script>
      const concatAudioFilesOnBrowser = async (audioFiles) => {
        const script = document.createElement('script')
        script.src = 'https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js'
        document.head.appendChild(script)
        await new Promise((resolve) => (script.onload = resolve))

        const { createFFmpeg, fetchFile } = FFmpeg
        const ffmpeg = createFFmpeg({ log: true })

        await ffmpeg.load()

        // Download and write each file to FFmpeg's virtual file system
        for (const [index, audioFile] of audioFiles.entries()) {
          const audioData = await fetchFile(audioFile)
          ffmpeg.FS('writeFile', `input${index}.mp3`, audioData)
        }

        // Create a file list for ffmpeg concat
        const fileList = audioFiles.map((_, i) => `file 'input${i}.mp3'`).join('\n')
        ffmpeg.FS('writeFile', 'filelist.txt', fileList)

        // Execute FFmpeg command to concatenate files
        await ffmpeg.run(
          '-f',
          'concat',
          '-safe',
          '0',
          '-i',
          'filelist.txt',
          '-c:a',
          'libmp3lame',
          '-q:a',
          '5',
          'output.mp3',
        )

        // Read the output file
        const data = ffmpeg.FS('readFile', 'output.mp3')

        // Create a downloadable link
        const blob = new Blob([data.buffer], { type: 'audio/mp3' })

        // Clean up
        audioFiles.forEach((_, i) => {
          ffmpeg.FS('unlink', `input${i}.mp3`)
        })
        ffmpeg.FS('unlink', 'filelist.txt')
        ffmpeg.FS('unlink', 'output.mp3')

        return blob
      }
    </script>
  </body>
</html>

Worker 调用代码

export async function concatAudioFiles(audioFiles: string[], BROWSER: Fetcher, { workerUrl }: { workerUrl: string }) {
  const browser = await puppeteer.launch(BROWSER)
  const page = await browser.newPage()
  await page.goto(`${workerUrl}/audio`)

  console.info('start concat audio files', audioFiles)
  const fileUrl = await page.evaluate(async (audioFiles) => {
    // 此处 JS 运行在浏览器中
    // @ts-expect-error 浏览器内的对象
    const blob = await concatAudioFilesOnBrowser(audioFiles)

    const result = new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onloadend = () => resolve(reader.result)
      reader.onerror = reject
      reader.readAsDataURL(blob)
    })
    return await result
  }, audioFiles) as string

  console.info('concat audio files result', fileUrl.substring(0, 100))

  await browser.close()

  const response = await fetch(fileUrl)
  return await response.blob()
}

const audio = await concatAudioFiles(audioFiles, env.BROWSER, { workerUrl: env.HACKER_NEWS_WORKER_URL })
return new Response(audio)

上面的代码基本是 Cursor 写的,最终效果可以去 Hacker News 代码仓库 看。

stat

使用 AI 让博客排版更上一个台阶

AI 驱动的博客排版

使用人工智能技术重新定义博客文章的视觉呈现,从简约到精致,从平凡到卓越。

之前 Claude 3.7 发布,用它生成了一个邮箱 App 的设计图,效果还不错。

这周在 Twitter 上看到乔木老师分享的生成网页效果、生成PPT、生成3D教学动画、生成SVG海报的提示词,感觉很有意思。

技术栈更新

这个周末把博客引擎升级了一下,使用 Astro + TailwindCSS + MDX 重构了博客。由于 MDX 有更强的扩展性,所以博客文章正文几乎可以展示任何内容。

于是使用乔木老师分享的提示词,修改了以后,用来生成博客文章的排版。效果出乎意料,从此不再为博客文章的排版而烦恼。

对比图

HackerNews Podcast

修改前:

HackerNews 修改前

修改后:

HackerNews 修改后

RSS Beauty

修改前:

RSS Beauty 修改前

修改后:

RSS Beauty 修改后

Sink

修改前:

Sink 修改前

修改后:

Sink 修改后

URL Longer

修改前:

URL longer 修改前

修改后:

URL longer 修改后

提示词

源自 乔木老师分享

# 生成文章网页

你是一名专业的网页设计师和前端开发专家,对现代 Web 设计趋势和最佳实践有深入理解,尤其擅长创造具有极高审美价值的用户界面。你的设计作品不仅功能完备,而且在视觉上令人惊叹,能够给用户带来强烈的"Aha-moment"体验。

请根据最后提供的内容,设计一个**美观、现代、易读**的"中文"可视化网页。请充分发挥你的专业判断,选择最能体现内容精髓的设计风格、配色方案、排版和布局。

**设计目标:**

* **视觉吸引力:** 创造一个在视觉上令人印象深刻的网页,能够立即吸引用户的注意力,并激发他们的阅读兴趣。
* **可读性:** 确保内容清晰易读,无论在桌面端还是移动端,都能提供舒适的阅读体验。
* **信息传达:** 以一种既美观又高效的方式呈现信息,突出关键内容,引导用户理解核心思想。
* **情感共鸣:** 通过设计激发与内容主题相关的情感(例如,对于励志内容,激发积极向上的情绪;对于严肃内容,营造庄重、专业的氛围)。

**设计指导(请灵活运用,而非严格遵循):**

* **整体风格:**
  * 可以考虑杂志风格、出版物风格,或者其他你认为合适的现代 Web 设计风格。
  * 配色参考 shadcn ui 的 Zinc 主题配色。
  * 目标是创造一个既有信息量,又有视觉吸引力的页面,就像一本精心设计的数字杂志或一篇深度报道。
* **Hero 模块(可选,但强烈建议):**
  * 如果你认为合适,可以设计一个引人注目的 Hero 模块。
  * 它可以包含标题(h2)、副标题(p)、一段引人入胜的引言。
* **排版:**
  * 精心选择字体组合(衬线和无衬线),以提升中文阅读体验。
  * 利用不同的字号、字重、颜色和样式,创建清晰的视觉层次结构。
  * 可以考虑使用一些精致的排版细节(如首字下沉、悬挂标点)来提升整体质感。
  * Tabler icon 中有很多图标,选合适的点缀增加趣味性。 使用实例 `icon-[tabler--名称]`
* **配色方案:**
  * 选择一套既和谐又具有视觉冲击力的配色方案。
  * 考虑使用高对比度的颜色组合来突出重要元素。
  * 可以探索渐变、阴影等效果来增加视觉深度。
* **布局:**
  * 使用基于网格的布局系统来组织页面元素。
  * 充分利用负空间(留白),创造视觉平衡和呼吸感。
  * 可以考虑使用卡片、分割线、图标等视觉元素来分隔和组织内容。
* **调性:**整体风格精致, 营造一种高级感。

**技术规范:**

* 使用 tailwindCSS 定义样式。
* 不使用 JS , HTML 和 CSS 优先。
* 只生成正文区域,网页已经使用 `prose` 类包裹了, 可以使用 `not-prose` 突破限制。
* 实现完整的深色/浅色模式切换功能。
* 代码结构清晰、语义化,包含适当的注释。
* 实现完整的响应式,必须在所有设备上(手机、平板、桌面)完美展示。

**额外加分项:**

* **微交互:** 添加微妙而有意义的微交互效果来提升用户体验(例如,按钮悬停效果、卡片悬停效果、页面滚动效果)。
* **补充信息:** 可以主动搜索并补充其他重要信息或模块(例如,关键概念的解释、相关人物的介绍等),以增强用户对内容的理解。

**输出要求:**

* 输出一个独立的 MDX 文件,生成的语法符合 MDX 规范和 JSX 规范。
* 修改范围不要超出 mdx 文件。
* 不要在正文中出现标签,发布时间相关的信息。
* 外链增加 `nofollow`, 在新窗口打开, 保留 `title` 属性。
* 代码块不做任何修改,依旧使用 md 格式。
* 确保代码符合 W3C 标准,没有错误或警告。

请你像一个真正的设计师一样思考,充分发挥你的专业技能和创造力,打造一个令人惊艳的网页!

待处理内容:@miantiao_me
stat

用播客的方式听 Hacker News

用播客的方式听 Hacker News

将每日 Hacker News 热门内容转换为中文播客,让你随时随地收听科技资讯

项目背景

Hacker News 是我一直关注的重要资讯来源,它能持续提供新奇有趣的极客资讯。之前我每天都会花半小时左右的时间来浏览。

过年期间,我注意到 DeepSeek 非常火,就尝试使用 Cloudflare Workflow 编写了一个工作流。但生成的内容都很短,而且接口也不稳定。后来尝试 GPT 4.0 系列模型,效果也不理想,于是就暂时搁置了。

Gemini 2.0 发布后,我尝试了一下,发现生成文章的效果还不错。于是我便开发了一个 Web 界面,并加入了 RSS 订阅功能,这样就可以在上班路上用泛用型播客 App 收听 Hacker News 的资讯了。

Hacker News 播客项目预览

项目预览

主要特性

  • 自动抓取 Hacker News 每日热门文章
  • 使用 AI 智能总结文章内容和评论
  • 通过 Edge TTS 生成中文播报
  • 支持网页和播客 App 收听
  • 每日自动更新
  • 提供文章摘要和完整播报文本

技术栈

  • Next.js 应用框架
  • Cloudflare Workers 部署和运行环境
  • Edge TTS 语音合成
  • OpenAI API 内容生成
  • Tailwind CSS 样式处理
  • shadcn UI 组件库

工作流程

  1. 1

    定时抓取 Hacker News 热门文章

    每日自动收集 Hacker News 上最受欢迎的帖子

  2. 2

    使用 AI 生成中文摘要和播报文稿

    通过 Gemini 2.0 AI 将英文内容智能翻译并总结为中文

  3. 3

    通过 Edge TTS 转换为音频

    将生成的文本转换为自然流畅的语音播报

  4. 4

    存储到 Cloudflare R2 和 KV

    将生成的内容和音频存储在高效、低成本的云端存储系统中

  5. 5

    通过 RSS feed 和网页提供访问

    用户可以通过网页浏览或在任何播客应用中订阅收听

未来计划

目前 TTS 使用的是 Edge TTS,只有一个女声。理想情况下,使用男声和女声进行对话的形式可能会更好。豆包的 TTS 音色很不错,但它是收费的。等后续有时间,我会考虑改进这部分。

推荐工具

最后,推荐一下 Cloudflare Workflow,一个很棒的 Workflow 运行平台。

Cloudflare Workflow
Cloudflare Workflow
高效、低成本的云函数工作流平台
stat

RSS.Beauty - 让 RSS 变漂亮!

RSS.Beauty

让 RSS 订阅源焕发新生的现代工具

拖了快半年的工具终于做完了。

RSS.Beauty 是一个基于 XSLT 技术的 RSS 美化工具,可以将普通的 RSS/Atom 订阅源转换成美观的阅读界面。让信息流变得更加优雅,阅读体验更加舒适。

主要特性

精美的阅读界面

完全重新设计的视觉体验,让RSS阅读变得赏心悦目

支持 RSS 2.0 和 Atom 1.0

全面兼容主流的RSS标准,无需担心格式问题

响应式设计

完美适配各种设备,让移动端阅读同样舒适

一键订阅

快速添加到主流RSS阅读器,简化订阅流程

支持自部署

可部署到自己的服务器,保持完全的控制权

快速开始

访问 RSS.Beauty 并输入任意 RSS 订阅源链接即可体验。

开源项目

这是一个开源项目,欢迎贡献代码或提出建议。

RSS.Beauty GitHub 预览
开始使用 RSS.Beauty

让您的 RSS 阅读体验焕然一新

体验 RSS.Beauty 带来的全新阅读感受,将枯燥的信息流变成精美的阅读界面。

立即访问 RSS.Beauty

stat

在浏览器中轻松运行 Python 程序

最近,微软开源了一个名为 MarkItDown 的程序,可以将 Office 文件转换为 Markdown 格式。这个项目一经发布就迅速登上了 GitHub 热门榜。

然而,由于 MarkItDown 是一个 Python 程序,对于非技术用户来说使用起来可能有些困难。为了解决这个问题,我想到了利用 WebAssembly 技术在浏览器中直接运行 Python 代码。

在浏览器内运行 Python 的开源程序是 Pyodide,使用 WebAssembly 移植了 CPython,所以 Python 的语法都是支持的。 Cloudflare 的 Python Worker 也使用的 Pyodide。

Pyodide 是 CPython 的一个移植版本,用于 WebAssembly/Emscripten。

Pyodide 使得在浏览器中使用 micropip 安装和运行 Python 包成为可能。任何在 PyPi 上有可用 wheel 文件的纯 Python 包都被支持。

许多具有 C 扩展的包也已被移植以供 Pyodide 使用。这些包括许多通用包,如 regex、PyYAML、lxml,以及包括 NumPy、pandas、SciPy、Matplotlib 和 scikit-learn 在内的科学 Python 包。Pyodide 配备了强大的 JavaScript ⟺ Python 外部函数接口,使得您可以在代码中自由地混合这两种语言,几乎没有摩擦。这包括对错误处理、async/await 的全面支持,以及更多功能。

在浏览器中使用时,Python 可以完全访问 Web API。

尝试了一下运行 MarkItDown 没想到异常的顺利,看来 WebAssembly 真的是浏览器的未来。

遇到的主要挑战和解决方案:

  1. 文件传输问题:如何将用户选择的文件传递给 Worker 中的 Python 运行时?

    • 解决方案:利用 Pyodide 提供的方案,将浏览器文件转换为 ArrayBuffer,然后写入 Emscripten 文件系统的本地缓存。
  2. 依赖安装问题:PyPI 在中国大陆访问受限。

最终,我们成功实现了一个完全运行在浏览器中的 MarkItDown 工具。欢迎访问 Office File to Markdown 进行体验。

Office File to Markdown

最后放出一下 Worker 中运行 Python 的核心代码:

importScripts('https://testingcf.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js')

// npmmirror 支持 pyodide ,但是不支持 pyodide 下的 zip 包
// importScripts('https://registry.npmmirror.com/pyodide/0.26.4/files/pyodide.js')

async function loadPyodideAndPackages() {
  const pyodide = await loadPyodide()
  globalThis.pyodide = pyodide

  await pyodide.loadPackage('micropip')

  const micropip = pyodide.pyimport('micropip')

  // 需要支持 PEP 691 和跨域, 目前 tuna 支持 PEP 691,但不支持跨域 https://github.com/tuna/issues/issues/2092
  // micropip.set_index_urls([
  //   'https://pypi.your.domains/pypi/simple',
  // ])

  await micropip.install('markitdown==0.0.1a2')
}

const pyodideReadyPromise = loadPyodideAndPackages()

globalThis.onmessage = async (event) => {
  await pyodideReadyPromise

  const file = event.data
  try {
    console.log('file', file)
    const startTime = Date.now()
    globalThis.pyodide.FS.writeFile(`/${file.filename}`, file.buffer)

    await globalThis.pyodide.runPythonAsync(`
from markitdown import MarkItDown

markitdown = MarkItDown()

result = markitdown.convert("/${file.filename}")
print(result.text_content)

with open("/${file.filename}.md", "w") as file:
  file.write(result.text_content)
`)
    globalThis.postMessage({
      filename: `${file.filename}.md`,
      content: globalThis.pyodide.FS.readFile(`/${file.filename}.md`, { encoding: 'utf8' }),
      time: Date.now() - startTime,
    })
  }
  catch (error) {
    globalThis.postMessage({ error: error.message || 'convert error', filename: file.filename })
  }
}
stat

使用 Cloudflare Snippets 搭建一个不限流量的 Docker 镜像

Cloudflare Workers 搭建 Docker 镜像个人使用请求数小没啥问题。但是如果公开使用,大量的请求数还是会产生费用。

其实 Cloudflare 还有一个更轻量的 JS Runtime: Cloudflare Snippets, 但是也有更严格的限制:CPU 执行时间 5 ms,最大内存 2M, 最大代码量 32K。 不过拿来重写请求足够了。

遗憾的是 Cloudflare Snippets 目前还未对 Free 计划开放,不过他们博客说 Free 计划可以建 5 个 Snippets

如果你有 Pro 计划,拿 Cloudflare Workers 的代码稍微修改一下就可以运行, 支持 Docker Hub, Google Container Registry, GitHub Container Registry, Amazon Elastic Container Registry, Kubernetes Container Registry, Quay, Cloudsmith。

修改后的代码:

// 原代码: https://github.com/ciiiii/cloudflare-docker-proxy/blob/master/src/index.js

const CUSTOM_DOMAIN = 'your.domains'
const MODE = 'production'

const dockerHub = 'https://registry-1.docker.io'

const routes = {
  // production
  [`docker.${CUSTOM_DOMAIN}`]: dockerHub,
  [`quay.${CUSTOM_DOMAIN}`]: 'https://quay.io',
  [`gcr.${CUSTOM_DOMAIN}`]: 'https://gcr.io',
  [`k8s-gcr.${CUSTOM_DOMAIN}`]: 'https://k8s.gcr.io',
  [`k8s.${CUSTOM_DOMAIN}`]: 'https://registry.k8s.io',
  [`ghcr.${CUSTOM_DOMAIN}`]: 'https://ghcr.io',
  [`cloudsmith.${CUSTOM_DOMAIN}`]: 'https://docker.cloudsmith.io',
  [`ecr.${CUSTOM_DOMAIN}`]: 'https://public.ecr.aws',

  // staging
  [`docker-staging.${CUSTOM_DOMAIN}`]: dockerHub,
}

async function handleRequest(request) {
  const url = new URL(request.url)
  const upstream = routeByHosts(url.hostname)
  if (upstream === '') {
    return new Response(
      JSON.stringify({
        routes,
      }),
      {
        status: 404,
      },
    )
  }
  const isDockerHub = upstream === dockerHub
  const authorization = request.headers.get('Authorization')
  if (url.pathname === '/v2/') {
    const newUrl = new URL(`${upstream}/v2/`)
    const headers = new Headers()
    if (authorization) {
      headers.set('Authorization', authorization)
    }
    // check if need to authenticate
    const resp = await fetch(newUrl.toString(), {
      method: 'GET',
      headers,
      redirect: 'follow',
    })
    if (resp.status === 401) {
      return responseUnauthorized(url)
    }
    return resp
  }
  // get token
  if (url.pathname === '/v2/auth') {
    const newUrl = new URL(`${upstream}/v2/`)
    const resp = await fetch(newUrl.toString(), {
      method: 'GET',
      redirect: 'follow',
    })
    if (resp.status !== 401) {
      return resp
    }
    const authenticateStr = resp.headers.get('WWW-Authenticate')
    if (authenticateStr === null) {
      return resp
    }
    const wwwAuthenticate = parseAuthenticate(authenticateStr)
    let scope = url.searchParams.get('scope')
    // autocomplete repo part into scope for DockerHub library images
    // Example: repository:busybox:pull => repository:library/busybox:pull
    if (scope && isDockerHub) {
      const scopeParts = scope.split(':')
      if (scopeParts.length === 3 && !scopeParts[1].includes('/')) {
        scopeParts[1] = `library/${scopeParts[1]}`
        scope = scopeParts.join(':')
      }
    }
    return await fetchToken(wwwAuthenticate, scope, authorization)
  }
  // redirect for DockerHub library images
  // Example: /v2/busybox/manifests/latest => /v2/library/busybox/manifests/latest
  if (isDockerHub) {
    const pathParts = url.pathname.split('/')
    if (pathParts.length === 5) {
      pathParts.splice(2, 0, 'library')
      const redirectUrl = new URL(url)
      redirectUrl.pathname = pathParts.join('/')
      return Response.redirect(redirectUrl, 301)
    }
  }
  // foward requests
  const newUrl = new URL(upstream + url.pathname)
  const newReq = new Request(newUrl, {
    method: request.method,
    headers: request.headers,
    redirect: 'follow',
  })
  const resp = await fetch(newReq)
  if (resp.status === 401) {
    return responseUnauthorized(url)
  }
  return resp
}

function routeByHosts(host) {
  if (host in routes) {
    return routes[host]
  }
  if (MODE === 'debug') {
    return dockerHub
  }
  return ''
}

function parseAuthenticate(authenticateStr) {
  // sample: Bearer realm="https://auth.ipv6.docker.com/token",service="registry.docker.io"
  // match strings after =" and before "
  const re = /(?<==")(?:\\.|[^"\\])*(?=")/g
  const matches = authenticateStr.match(re)
  if (matches == null || matches.length < 2) {
    throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`)
  }
  return {
    realm: matches[0],
    service: matches[1],
  }
}

async function fetchToken(wwwAuthenticate, scope, authorization) {
  const url = new URL(wwwAuthenticate.realm)
  if (wwwAuthenticate.service.length) {
    url.searchParams.set('service', wwwAuthenticate.service)
  }
  if (scope) {
    url.searchParams.set('scope', scope)
  }
  const headers = new Headers()
  if (authorization) {
    headers.set('Authorization', authorization)
  }
  return await fetch(url, {
    method: 'GET',
    headers
  })
}

function responseUnauthorized(url) {
  const headers = new (Headers)()
  if (MODE === 'debug') {
    headers.set(
      'Www-Authenticate',
      `Bearer realm="http://${url.host}/v2/auth",service="cloudflare-docker-proxy"`,
    )
  }
  else {
    headers.set(
      'Www-Authenticate',
      `Bearer realm="https://${url.hostname}/v2/auth",service="cloudflare-docker-proxy"`,
    )
  }
  return new Response(JSON.stringify({
    message: 'UNAUTHORIZED'
  }), {
    status: 401,
    headers,
  })
}

export default {
  fetch: handleRequest,
}
stat

Cloudflare PyPI Mirror

Pyodide 是一个在 WebAssembly 中运行 Python 的工具库,使用 Micropip 通过 PyPI 来安装包。由于 WebAssembly 在浏览器内运行需要跨域和 PEP 691,但是清华的 tuna 又不支持 CORS 跨域。

PyPI 在中国大陆是无法正常访问的,但是有许多的 Mirror。清华、阿里云、腾讯云、华为云等不少网站都提供了镜像。这些镜像除了清华的 tuna,其他都不支持 JSON-based Simple API for Python (PEP 691)。

由于 WebAssembly 在浏览器内运行需要跨域和 PEP 691,但是清华的 tuna 又不支持 CORS 跨域。

所以在中国大陆可能没有 Micropip 可用的 PyPI 镜像。

基于这个背景,使用 Cloudflare 搭建了一个支持 PEP691 和 CORS 的 Mirror。

支持 Workers 或者 Snippets 都可以搭建,但各有优缺点:

Workers

优点:免费计划可用。

缺点:会产生很多 Worker 请求,可能超出免费计划后不可用或需要付费。

Snippets

优点:不产生 Worker 请求,支持大量使用。 缺点:Snippets 目前只有 Pro 以上计划使用,Free 不可用。

代码

对应代码已经开源,地址:

https://github.com/ccbikai/cloudflare-pypi-mirror

Cloudflare PyPI Mirror

stat

Vite SSR 项目 Docker 镜像最小化打包方案

最近准备把部署在 Cloudflare, Vercel, Netlify 上的项目迁移到自己的 VPS 通过 Docker 运行,就复习了一下 Docker 镜像打包。 但是一个很小的项目打包出来就是 1.05GB, 这显然是不能接受的。所以研究了一下 Node.JS 项目 Docker 镜像最小化打包方案, 将镜像大小从 1.06GB 缩小到了 135 MB。

示例项目是一个 Astro 项目, 使用 Vite 作为构建工具, SSR 模式运行。

第 0 版

主要思路是使用最小化系统镜像,选用 Alpine Linux 镜像。

按照 Astro 官方文档服务端渲染模式(SSR), 将基础镜像替换为 node:lts-alpine, NPM 替换为 PNPM, 打包出来的体积是 1.06 GB。 也就是最差的状态。

FROM node:lts-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app

COPY . .
RUN pnpm install --frozen-lockfile
RUN export $(cat .env.example) && pnpm run build

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v0 .
[+] Building 113.8s (11/11) FINISHED                                                                                                                                        docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 346B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:lts-alpine                                                                                                                     1.1s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 89B                                                                                                                                                       0.0s
 => [1/6] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45                                                               0.0s
 => [internal] load build context                                                                                                                                                      0.2s
 => => transferring context: 240.11kB                                                                                                                                                  0.2s
 => CACHED [2/6] RUN corepack enable                                                                                                                                                   0.0s
 => CACHED [3/6] WORKDIR /app                                                                                                                                                          0.0s
 => [4/6] COPY . .                                                                                                                                                                     2.0s
 => [5/6] RUN pnpm install --frozen-lockfile                                                                                                                                          85.7s
 => [6/6] RUN export $(cat .env.example) && pnpm run build                                                                                                      11.1s
 => exporting to image                                                                                                                                                                13.4s
 => => exporting layers                                                                                                                                                               13.4s
 => => writing image sha256:653236defcbb8d99d83dc550f1deb55e48b49d7925a295049806ebac8c104d4a                                                                                           0.0s
 => => naming to docker.io/library/v0

第 1 版

主要思路是先安装生产环境依赖,产生第一层。 再安装全量依赖,打包生成 JavaScript 产物,产生第二层。 最后将生产环境依赖和 JavaScript 产物复制到运行环境。

按照 多层构建(使用 SSR) 的方案, 将镜像大小缩小到了 306MB,缩小不小,但是这个方案有个缺点,需要明确的制定生产依赖,如果少指定了生产依赖,运行时会报错

FROM node:lts-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app
COPY package.json pnpm-lock.yaml ./

FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM build-deps AS build
COPY . .
RUN export $(cat .env.example) && pnpm run build

FROM base AS runtime
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v1 .
[+] Building 85.5s (15/15) FINISHED                                                                                                                                         docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                   0.1s
 => => transferring dockerfile: 680B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:lts-alpine                                                                                                                     1.8s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 89B                                                                                                                                                       0.0s
 => [base 1/4] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45                                                          0.0s
 => [internal] load build context                                                                                                                                                      0.3s
 => => transferring context: 240.44kB                                                                                                                                                  0.2s
 => CACHED [base 2/4] RUN corepack enable                                                                                                                                              0.0s
 => CACHED [base 3/4] WORKDIR /app                                                                                                                                                     0.0s
 => [base 4/4] COPY package.json pnpm-lock.yaml ./                                                                                                                                     0.2s
 => [prod-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile                                                                           35.1s
 => [build-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile                                                                                 65.5s
 => [runtime 1/2] COPY --from=prod-deps /app/node_modules ./node_modules                                                                                                               5.9s
 => [build 1/2] COPY . .                                                                                                                                                               0.8s
 => [build 2/2] RUN export $(cat .env.example) && pnpm run build                                                                                                                       7.5s
 => [runtime 2/2] COPY --from=build /app/dist ./dist                                                                                                                                   0.1s
 => exporting to image                                                                                                                                                                 4.2s
 => => exporting layers                                                                                                                                                                4.1s
 => => writing image sha256:8ae6b2bddf0a7ac5f8ad45e6abb7d36a633e384cf476e45fb9132bdf70ed0c5f                                                                                           0.0s
 => => naming to docker.io/library/v1

第 2 版

主要思路是将 node_modules 内联进 JavaScript 文件,最终只复制 JavaScript 文件到运行环境。

之前看 Next.JS 的时候,记得可以将 node_modules 内联进 JavaScript 文件,这样就不需要 node_modules 了。 所以就研究了一下,发现 Vite SSR 也是支持的,所以判断 Docker 环境就使用内联的方式,不需要复制 node_modules ,只复制最终的 dist 产物,将镜像大小缩小到 135MB 了。

打包脚本改动:

vite: {
  ssr: {
    noExternal: process.env.DOCKER ? !!process.env.DOCKER : undefined
  }
}

最终的 Dockerfile 如下

FROM node:lts-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app
COPY package.json pnpm-lock.yaml ./

# FROM base AS prod-deps
# RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM build-deps AS build
COPY . .
RUN export $(cat .env.example) && export DOCKER=true && pnpm run build

FROM base AS runtime
# COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
 docker build -t v2 .
[+] Building 24.9s (13/13) FINISHED                                                                                                                                         docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 708B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:lts-alpine                                                                                                                     1.7s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 89B                                                                                                                                                       0.0s
 => [base 1/4] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45                                                          0.0s
 => [internal] load build context                                                                                                                                                      0.3s
 => => transferring context: 240.47kB                                                                                                                                                  0.2s
 => CACHED [base 2/4] RUN corepack enable                                                                                                                                              0.0s
 => CACHED [base 3/4] WORKDIR /app                                                                                                                                                     0.0s
 => CACHED [base 4/4] COPY package.json pnpm-lock.yaml ./                                                                                                                              0.0s
 => CACHED [build-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile                                                                           0.0s
 => [build 1/2] COPY . .                                                                                                                                                               1.5s
 => [build 2/2] RUN export $(cat .env.example) && export DOCKER=true && pnpm run build                                                                                                15.0s
 => [runtime 1/1] COPY --from=build /app/dist ./dist                                                                                                                                   0.1s
 => exporting to image                                                                                                                                                                 0.1s
 => => exporting layers                                                                                                                                                                0.1s
 => => writing image sha256:0ed5c10162d1faf4208f5ea999fbcd133374acc0e682404c8b05220b38fd1eaf                                                                                           0.0s
 => => naming to docker.io/library/v2

最终对比,体积从 1.06GB 降低到 135MB, 构建时间从 113.8s 降低到 24.9s

docker images
REPOSITORY                         TAG         IMAGE ID       CREATED          SIZE
v2                                 latest      0ed5c10162d1   5 minutes ago    135MB
v1                                 latest      8ae6b2bddf0a   6 minutes ago    306MB
v0                                 latest      653236defcbb   11 minutes ago   1.06GB

示例项目是开源的,可以在 GitHub 查看。

BroadcastChannel

stat

BroadcastChannel - 将你的 Telegram Channel 转为微博客

BroadcastChannel

将你的 Telegram Channel 转为微博客

一个零JS的微博客解决方案,让你的Telegram Channel内容优雅地展示在网页上

之前一直在 X 分享一些有趣的工具,而且也会同步到 Telegram Channel 上。看到 Austin 说准备建立一个网站,把分享内容都收录进去。 刚好想到最近看到的一个模板 Sepia,就想到把 Telegram Channel 转成微博客。

难度不大,主功能一个周末就搞完了。过程中做到了浏览器端 0 JS, 分享一些有趣的技术点:

技术亮点

1

防剧透模式和移动端搜索框隐藏展示,使用的 CSS “:checked 伪类” 和 ”+ 紧邻兄弟组合器” 实现的,参考

2

过渡动画使用的 CSS View Transitions, 参考

3

图片灯箱用的 HTML 的 popover 属性,参考

4

返回顶部的展示和隐藏使用 CSS animation-timeline 实现 Chrome 115 以上版本专属,参考

5

多图瀑布流布局,使用 grid 布局实现,参考

6

访问统计使用一个 1px 的透明图片做 LOGO 背景,上古技术了,现在几乎没有访问统计软件支持了

7

禁止浏览器端 JS 运行,使用的 Content-Security-Policy 的 script-src ‘none’ 参考

搞完以后,就直接开源了,很意外有那么多人喜欢,一周就收获了 800+ 的 star 了。

如果你也有兴趣,可以去 GitHub 上看看。

GitHub 仓库
Star

BroadcastChannel

将你的 Telegram Channel 转为微博客,零JS实现,美观现代的设计

Astro

CSS

Telegram

800+ stars

查看仓库

stat

阿里云 AP8220 刷机教程

最近在准备给家用网络升级 2.5G, 在咸鱼收了一台阿里云 AP8220 来折腾。 但是这个机器的刷机资料太少了。折腾了2天才成功刷机。所以写一篇文档记录一下。

提醒

非必要,不刷机。

刷机有风险,请做好刷砖准备。

任何刷机问题与我无关,我只分享我的刷机过程。

准备工作

  1. 阿里云 AP8220 机器一台,咸鱼基本 200 上下。
  2. DC 12V2A 电源一个,原厂不带电源。
  3. USB 转 Console 线。
  4. tftp32.exe。
  5. Putty。
  6. OpenWrt 固件(目前只有 LEAN 的收费固件,我无法提供,希望后续有更多的人适配)。

进入 Uboot

使用 USB 转 Console 线连接到设备 Console 口, 网线连接到设备 LAN 口。

Putty 设置链接 COM3(取决于你电脑设备USB口,自己去设备管理看下), 波特率设置 115200

设备通电,立即按键盘的 shift+@ 中断启动,即可进入 Uboot, 不行就多试几次。

电脑设置静态IP: 192.168.10.1

刷入大分区

下载文件 mibib.bin 文件和 tftp32.exe 放入同目录,打开 tftp32 后,切换到 192.168.10.1

在 Putty 开始敲命令:

tftpboot mibib.bin
flash 0:MIBIB

刷完以后,断电。

刷入固件

设备重新通电,进入 Uboot。

将固件以 factory.bin 结尾的文件命名为 ap8220.bin 放入 tftp32.exe 同目录。

在 Putty 开始敲命令刷写固件:

tftpboot ap8220.bin
flash rootfs

set boot3 "set mtdparts mtdparts=nand0:0x8000000@0x0(fs)"
set boot4 "ubi part fs && ubi read 42000000 kernel"
set setup1 "partname=1 && setenv bootargs ubi.mtd=rootfs ${args_common}"
set setup2 "partname=2 && setenv bootargs ubi.mtd=rootfs ${args_common}"

saveenv

刷完以后,断电。

重新通电等待后可正常启动。

电脑改回 DHCP 获取 IP 地址, 访问 http://192.168.1.1 进入 Web 界面。

在 Web 界面升级刷入 sysupgrade.bin 文件。

刷机完成。

交流

这个机器的刷机资料很少,我想建一个微信群交流。有兴趣的加我微信 ccbikai

AP8220

stat

使用 Cloudflare Zero Trust 制作 Google Safe Browsing 平替

之前做 L(O*62).ONG 的第一版时,使用的服务端跳转,上线第二天就被 Google 警告了安全风险,只能改成本地跳转提醒后跳转再去申诉。

Google警告

对于这种场景最好的做法是使用 Google Safe Browsing 来做跳转,但是 Safe Browsing 有使用限制,每天只能调用 10000 次,而且不支持自定义名单。由于我只想依赖 Cloudflare 一个平台就没有使用 Google Safe Browsing。

前段时间和一个网友交流的时候,突然脑洞大开,想到使用带成人和非法网站过滤的安全 DNS 服务器来做域名安全性的检查。 于是使用 家庭版 1.1.1.1 做了一下尝试发现是可行的。 但是 1.1.1.1 不支持自定义名单,想到之前在 HomeLab 用过 Cloudflare Zero Trust Gateway 就去研究了一下,发现了这个几乎完美的方案。

Cloudflare Zero Trust 提供的 Gateway 自带 DNS(DoH) 服务器,而且可以配置防火墙规则,支持按域名风险等级、内容分类、自定义名单等多种规则屏蔽解析。而且它收集的数据源来自 Cloudflare 专有数据、30 多个开放情报源、机器学习模型、社区反馈等,可以说是相当齐全了。 更多的细节可以在官方文档查看。

我屏蔽了高风险分类、成人、赌博、政府、少儿不宜、新注册等高风险的域名,并且手动维护了一些黑名单和白名单。

风险名单

配置完成以后就可以得到一个 DoH 地址:

DoH

我们使用下面的代码接入到项目中:

async function isSafeUrl(
  url,
  DoH = 'https://family.cloudflare-dns.com/dns-query'
) {
  let safe = false
  try {
    const { hostname } = new URL(url)
    const res = await fetch(`${DoH}?type=A&name=${hostname}`, {
      headers: {
        accept: 'application/dns-json',
      },
      cf: {
        cacheEverything: true,
        cacheTtlByStatus: { '200-299': 86400 },
      },
    })
    const dnsResult = await res.json()
    if (dnsResult && Array.isArray(dnsResult.Answer)) {
      const isBlock = dnsResult.Answer.some(
        answer => answer.data === '0.0.0.0'
      )
      safe = !isBlock
    }
  }
  catch (e) {
    console.warn('isSafeUrl fail: ', url, e)
  }
  return safe
}

Cloudflare Zero Trust 管理面板也提供了一个可视化的界面,方便查屏蔽情况。可以看到一些成人网站和新注册域名都被屏蔽了。

可视化界面

如果域名被封了,还可以在日志查看原因。

日志

stat

浏览器本地使用 AI 移除图片背景

AI工具探索

浏览器本地使用 AI 移除图片背景

无需上传,零隐私担忧,利用WebGPU和AI技术在浏览器中实现高效的图片背景移除

最近在学习 AI 相关的前端知识,看到 Transformers.js 的一个实例很感兴趣就把它做成了一个工具。

通过在 WebWorker 中使用 Transformers.js 调用 WebGPU 运行 RMBG-1.4 模型,可以在浏览器本地使用 AI 移除图片背景。在 M1 PRO 上处理一张 4K 图片只需要 500ms。

AI 移除图片背景工具界面展示

技术实现参考

相关的源码在以下仓库,如果想自己实现,可以参考它:

xenova/transformers.js/examples/remove-background-client

提醒:要调用 WebGPU,需要切换到 Transformers.js V3 版本。

stat

用播客的方式听 Hacker News

import ClientTweetCard from "@components/magicui/client-tweet-card.jsx";

Hacker News 是我一直关注的重要资讯来源,它能持续提供新奇有趣的极客资讯。 之前我每天都会花半小时左右的时间来浏览。

过年期间,我注意到 DeepSeek 非常火,就尝试使用 Cloudflare Workflow 编写了一个工作流。 但生成的内容都很短,而且接口也不稳定。 后来尝试 GPT 4.0 系列模型,效果也不理想,于是就暂时搁置了。

Gemini 2.0 发布后,我尝试了一下,发现生成文章的效果还不错。 于是我便开发了一个 Web 界面,并加入了 RSS 订阅功能,这样就可以在上班路上用泛用型播客 App 收听 Hacker News 的资讯了。

预览地址: https://hacker-news.agi.li

订阅地址: https://hacker-news.agi.li/rss.xml

项目地址:https://github.com/ccbikai/hacker-news

Hacker News

主要特性

  • 🤖 自动抓取 Hacker News 每日热门文章
  • 🎯 使用 AI 智能总结文章内容和评论
  • 🎙️ 通过 Edge TTS 生成中文播报
  • 📱 支持网页和播客 App 收听
  • 🔄 每日自动更新
  • 📝 提供文章摘要和完整播报文本

技术栈

  • Next.js 应用框架
  • Cloudflare Workers 部署和运行环境
  • Edge TTS 语音合成
  • OpenAI API 内容生成
  • Tailwind CSS 样式处理
  • shadcn UI 组件库

工作流程

  1. 定时抓取 Hacker News 热门文章
  2. 使用 AI 生成中文摘要和播报文稿
  3. 通过 Edge TTS 转换为音频
  4. 存储到 Cloudflare R2 和 KV
  5. 通过 RSS feed 和网页提供访问

未来计划

目前 TTS 使用的是 Edge TTS,只有一个女声。 理想情况下,使用男声和女声进行对话的形式可能会更好。 豆包的 TTS 音色很不错,但它是收费的。 等后续有时间,我会考虑改进这部分。


最后,推荐一下 Cloudflare Workflow,一个很棒的 Workflow 运行平台。

<ClientTweetCard client:load id="1890606489091596586" />

stat

RSS.Beauty - 让 RSS 变漂亮!

拖了快半年的工具终于做完了。

RSS.Beauty 是一个基于 XSLT 技术的 RSS 美化工具, 可以将普通的 RSS/Atom 订阅源转换成美观的阅读界面。

主要特性

  • 🎨 精美的阅读界面
  • 🔄 支持 RSS 2.0 和 Atom 1.0
  • 📱 响应式设计, 支持移动端
  • 🔌 一键订阅到主流 RSS 阅读器
  • 🖥 支持部署到自己服务器

快速开始

访问 RSS.Beauty 并输入任意 RSS 订阅源链接即可体验。

技术栈

开源

GitHub: https://github.com/ccbikai/RSS.Beauty

RSS.Beauty

stat

在浏览器中轻松运行 Python 程序

最近,微软开源了一个名为 MarkItDown 的程序,可以将 Office 文件转换为 Markdown 格式。这个项目一经发布就迅速登上了 GitHub 热门榜。

然而,由于 MarkItDown 是一个 Python 程序,对于非技术用户来说使用起来可能有些困难。为了解决这个问题,我想到了利用 WebAssembly 技术在浏览器中直接运行 Python 代码。

在浏览器内运行 Python 的开源程序是 Pyodide,使用 WebAssembly 移植了 CPython,所以 Python 的语法都是支持的。 Cloudflare 的 Python Worker 也使用的 Pyodide。

Pyodide 是 CPython 的一个移植版本,用于 WebAssembly/Emscripten。

Pyodide 使得在浏览器中使用 micropip 安装和运行 Python 包成为可能。任何在 PyPi 上有可用 wheel 文件的纯 Python 包都被支持。

许多具有 C 扩展的包也已被移植以供 Pyodide 使用。这些包括许多通用包,如 regex、PyYAML、lxml,以及包括 NumPy、pandas、SciPy、Matplotlib 和 scikit-learn 在内的科学 Python 包。Pyodide 配备了强大的 JavaScript ⟺ Python 外部函数接口,使得您可以在代码中自由地混合这两种语言,几乎没有摩擦。这包括对错误处理、async/await 的全面支持,以及更多功能。

在浏览器中使用时,Python 可以完全访问 Web API。

尝试了一下运行 MarkItDown 没想到异常的顺利,看来 WebAssembly 真的是浏览器的未来。

遇到的主要挑战和解决方案:

  1. 文件传输问题:如何将用户选择的文件传递给 Worker 中的 Python 运行时?

    • 解决方案:利用 Pyodide 提供的方案,将浏览器文件转换为 ArrayBuffer,然后写入 Emscripten 文件系统的本地缓存。
  2. 依赖安装问题:PyPI 在中国大陆访问受限。

最终,我们成功实现了一个完全运行在浏览器中的 MarkItDown 工具。欢迎访问 Office File to Markdown 进行体验。

Office File to Markdown

最后放出一下 Worker 中运行 Python 的核心代码:

// eslint-disable-next-line no-undef
importScripts('https://testingcf.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js')

// npmmirror 支持 pyodide ,但是不支持 pyodide 下的 zip 包
// importScripts('https://registry.npmmirror.com/pyodide/0.26.4/files/pyodide.js')

async function loadPyodideAndPackages() {
  // eslint-disable-next-line no-undef
  const pyodide = await loadPyodide()
  globalThis.pyodide = pyodide

  await pyodide.loadPackage('micropip')

  const micropip = pyodide.pyimport('micropip')

  // 需要支持 PEP 691 和跨域, 目前 tuna 支持 PEP 691,但不支持跨域 https://github.com/tuna/issues/issues/2092
  // micropip.set_index_urls([
  //   'https://pypi.your.domains/pypi/simple',  
  // ])

  await micropip.install('markitdown==0.0.1a2')
}

const pyodideReadyPromise = loadPyodideAndPackages()

globalThis.onmessage = async (event) => {
  await pyodideReadyPromise

  const file = event.data
  try {
    console.log('file', file)
    const startTime = Date.now()
    globalThis.pyodide.FS.writeFile(`/${file.filename}`, file.buffer)

    await globalThis.pyodide.runPythonAsync(`
from markitdown import MarkItDown

markitdown = MarkItDown()

result = markitdown.convert("/${file.filename}")
print(result.text_content)

with open("/${file.filename}.md", "w") as file:
  file.write(result.text_content)
`)
    globalThis.postMessage({
      filename: `${file.filename}.md`,
      content: globalThis.pyodide.FS.readFile(`/${file.filename}.md`, { encoding: 'utf8' }),
      time: Date.now() - startTime,
    })
  }
  catch (error) {
    globalThis.postMessage({ error: error.message || 'convert error', filename: file.filename })
  }
}
stat

使用 Cloudflare Snippets 搭建一个不限流量的 Docker 镜像

Cloudflare Workers 搭建 Docker 镜像个人使用请求数小没啥问题。但是如果公开使用,大量的请求数还是会产生费用。

其实 Cloudflare 还有一个更轻量的 JS Runtime: Cloudflare Snippets, 但是也有更严格的限制:CPU 执行时间 5 ms,最大内存 2M, 最大代码量 32K。 不过拿来重写请求足够了。

遗憾的是 Cloudflare Snippets 目前还未对 Free 计划开放,不过他们博客说 Free 计划可以建 5 个 Snippets

如果你有 Pro 计划,拿 Cloudflare Workers 的代码稍微修改一下就可以运行, 支持 Docker Hub, Google Container Registry, GitHub Container Registry, Amazon Elastic Container Registry, Kubernetes Container Registry, Quay, Cloudsmith。

修改后的代码:

// 原代码: https://github.com/ciiiii/cloudflare-docker-proxy/blob/master/src/index.js

const CUSTOM_DOMAIN = 'your.domains'
const MODE = 'production'

const dockerHub = 'https://registry-1.docker.io'

const routes = {
    // production
    [`docker.${CUSTOM_DOMAIN}`]: dockerHub,
    [`quay.${CUSTOM_DOMAIN}`]: 'https://quay.io',
    [`gcr.${CUSTOM_DOMAIN}`]: 'https://gcr.io',
    [`k8s-gcr.${CUSTOM_DOMAIN}`]: 'https://k8s.gcr.io',
    [`k8s.${CUSTOM_DOMAIN}`]: 'https://registry.k8s.io',
    [`ghcr.${CUSTOM_DOMAIN}`]: 'https://ghcr.io',
    [`cloudsmith.${CUSTOM_DOMAIN}`]: 'https://docker.cloudsmith.io',
    [`ecr.${CUSTOM_DOMAIN}`]: 'https://public.ecr.aws',

    // staging
    [`docker-staging.${CUSTOM_DOMAIN}`]: dockerHub,
}

async function handleRequest(request) {
    const url = new URL(request.url)
    const upstream = routeByHosts(url.hostname)
    if (upstream === '') {
        return new Response(
            JSON.stringify({
                routes,
            }), {
                status: 404,
            },
        )
    }
    const isDockerHub = upstream === dockerHub
    const authorization = request.headers.get('Authorization')
    if (url.pathname === '/v2/') {
        const newUrl = new URL(`${upstream}/v2/`)
        const headers = new Headers()
        if (authorization) {
            headers.set('Authorization', authorization)
        }
        // check if need to authenticate
        const resp = await fetch(newUrl.toString(), {
            method: 'GET',
            headers,
            redirect: 'follow',
        })
        if (resp.status === 401) {
            return responseUnauthorized(url)
        }
        return resp
    }
    // get token
    if (url.pathname === '/v2/auth') {
        const newUrl = new URL(`${upstream}/v2/`)
        const resp = await fetch(newUrl.toString(), {
            method: 'GET',
            redirect: 'follow',
        })
        if (resp.status !== 401) {
            return resp
        }
        const authenticateStr = resp.headers.get('WWW-Authenticate')
        if (authenticateStr === null) {
            return resp
        }
        const wwwAuthenticate = parseAuthenticate(authenticateStr)
        let scope = url.searchParams.get('scope')
        // autocomplete repo part into scope for DockerHub library images
        // Example: repository:busybox:pull => repository:library/busybox:pull
        if (scope && isDockerHub) {
            const scopeParts = scope.split(':')
            if (scopeParts.length === 3 && !scopeParts[1].includes('/')) {
                scopeParts[1] = `library/${scopeParts[1]}`
                scope = scopeParts.join(':')
            }
        }
        return await fetchToken(wwwAuthenticate, scope, authorization)
    }
    // redirect for DockerHub library images
    // Example: /v2/busybox/manifests/latest => /v2/library/busybox/manifests/latest
    if (isDockerHub) {
        const pathParts = url.pathname.split('/')
        if (pathParts.length === 5) {
            pathParts.splice(2, 0, 'library')
            const redirectUrl = new URL(url)
            redirectUrl.pathname = pathParts.join('/')
            return Response.redirect(redirectUrl, 301)
        }
    }
    // foward requests
    const newUrl = new URL(upstream + url.pathname)
    const newReq = new Request(newUrl, {
        method: request.method,
        headers: request.headers,
        redirect: 'follow',
    })
    const resp = await fetch(newReq)
    if (resp.status === 401) {
        return responseUnauthorized(url)
    }
    return resp
}

function routeByHosts(host) {
    if (host in routes) {
        return routes[host]
    }
    if (MODE === 'debug') {
        return dockerHub
    }
    return ''
}

function parseAuthenticate(authenticateStr) {
    // sample: Bearer realm="https://auth.ipv6.docker.com/token",service="registry.docker.io"
    // match strings after =" and before "
    const re = /(?<==")(?:\\.|[^"\\])*(?=")/g
    const matches = authenticateStr.match(re)
    if (matches == null || matches.length < 2) {
        throw new Error(`invalid Www-Authenticate Header: ${authenticateStr}`)
    }
    return {
        realm: matches[0],
        service: matches[1],
    }
}

async function fetchToken(wwwAuthenticate, scope, authorization) {
    const url = new URL(wwwAuthenticate.realm)
    if (wwwAuthenticate.service.length) {
        url.searchParams.set('service', wwwAuthenticate.service)
    }
    if (scope) {
        url.searchParams.set('scope', scope)
    }
    const headers = new Headers()
    if (authorization) {
        headers.set('Authorization', authorization)
    }
    return await fetch(url, {
        method: 'GET',
        headers
    })
}

function responseUnauthorized(url) {
    const headers = new(Headers)()
    if (MODE === 'debug') {
        headers.set(
            'Www-Authenticate',
            `Bearer realm="http://${url.host}/v2/auth",service="cloudflare-docker-proxy"`,
        )
    } else {
        headers.set(
            'Www-Authenticate',
            `Bearer realm="https://${url.hostname}/v2/auth",service="cloudflare-docker-proxy"`,
        )
    }
    return new Response(JSON.stringify({
        message: 'UNAUTHORIZED'
    }), {
        status: 401,
        headers,
    })
}

export default {
    fetch: handleRequest,
}
stat

Cloudflare PyPI Mirror

Pyodide 是一个在 WebAssembly 中运行 Python 的工具库,使用 Micropip 通过 PyPI 来安装包。由于 WebAssembly 在浏览器内运行需要跨域和 PEP 691,但是清华的 tuna 又不支持 CORS 跨域。

PyPI 在中国大陆是无法正常访问的,但是有许多的 Mirror。清华、阿里云、腾讯云、华为云等不少网站都提供了镜像。这些镜像除了清华的 tuna,其他都不支持 JSON-based Simple API for Python (PEP 691)。

由于 WebAssembly 在浏览器内运行需要跨域和 PEP 691,但是清华的 tuna 又不支持 CORS 跨域。

所以在中国大陆可能没有 Micropip 可用的 PyPI 镜像。

基于这个背景,使用 Cloudflare 搭建了一个支持 PEP691 和 CORS 的 Mirror。

支持 Workers 或者 Snippets 都可以搭建,但各有优缺点:

Workers

优点:免费计划可用。

缺点:会产生很多 Worker 请求,可能超出免费计划后不可用或需要付费。

Snippets

优点:不产生 Worker 请求,支持大量使用。 缺点:Snippets 目前只有 Pro 以上计划使用,Free 不可用。

代码

对应代码已经开源,地址:

https://github.com/ccbikai/cloudflare-pypi-mirror

Cloudflare PyPI Mirror

stat

Vite SSR 项目 Docker 镜像最小化打包方案

最近准备把部署在 Cloudflare, Vercel, Netlify 上的项目迁移到自己的 VPS 通过 Docker 运行,就复习了一下 Docker 镜像打包。 但是一个很小的项目打包出来就是 1.05GB, 这显然是不能接受的。所以研究了一下 Node.JS 项目 Docker 镜像最小化打包方案, 将镜像大小从 1.06GB 缩小到了 135 MB。

示例项目是一个 Astro 项目, 使用 Vite 作为构建工具, SSR 模式运行。

第 0 版

主要思路是使用最小化系统镜像,选用 Alpine Linux 镜像。

按照 Astro 官方文档服务端渲染模式(SSR), 将基础镜像替换为 node:lts-alpine, NPM 替换为 PNPM, 打包出来的体积是 1.06 GB。 也就是最差的状态。

FROM node:lts-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app

COPY . .
RUN pnpm install --frozen-lockfile
RUN export $(cat .env.example) && pnpm run build

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v0 .
[+] Building 113.8s (11/11) FINISHED                                                                                                                                        docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 346B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:lts-alpine                                                                                                                     1.1s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 89B                                                                                                                                                       0.0s
 => [1/6] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45                                                               0.0s
 => [internal] load build context                                                                                                                                                      0.2s
 => => transferring context: 240.11kB                                                                                                                                                  0.2s
 => CACHED [2/6] RUN corepack enable                                                                                                                                                   0.0s
 => CACHED [3/6] WORKDIR /app                                                                                                                                                          0.0s
 => [4/6] COPY . .                                                                                                                                                                     2.0s
 => [5/6] RUN pnpm install --frozen-lockfile                                                                                                                                          85.7s
 => [6/6] RUN export $(cat .env.example) && pnpm run build                                                                                                      11.1s
 => exporting to image                                                                                                                                                                13.4s
 => => exporting layers                                                                                                                                                               13.4s
 => => writing image sha256:653236defcbb8d99d83dc550f1deb55e48b49d7925a295049806ebac8c104d4a                                                                                           0.0s
 => => naming to docker.io/library/v0

第 1 版

主要思路是先安装生产环境依赖,产生第一层。 再安装全量依赖,打包生成 JavaScript 产物,产生第二层。 最后将生产环境依赖和 JavaScript 产物复制到运行环境。

按照 多层构建(使用 SSR) 的方案, 将镜像大小缩小到了 306MB,缩小不小,但是这个方案有个缺点,需要明确的制定生产依赖,如果少指定了生产依赖,运行时会报错

FROM node:lts-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app
COPY package.json pnpm-lock.yaml ./

FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM build-deps AS build
COPY . .
RUN export $(cat .env.example) && pnpm run build

FROM base AS runtime
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v1 .
[+] Building 85.5s (15/15) FINISHED                                                                                                                                         docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                   0.1s
 => => transferring dockerfile: 680B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:lts-alpine                                                                                                                     1.8s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 89B                                                                                                                                                       0.0s
 => [base 1/4] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45                                                          0.0s
 => [internal] load build context                                                                                                                                                      0.3s
 => => transferring context: 240.44kB                                                                                                                                                  0.2s
 => CACHED [base 2/4] RUN corepack enable                                                                                                                                              0.0s
 => CACHED [base 3/4] WORKDIR /app                                                                                                                                                     0.0s
 => [base 4/4] COPY package.json pnpm-lock.yaml ./                                                                                                                                     0.2s
 => [prod-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile                                                                           35.1s
 => [build-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile                                                                                 65.5s
 => [runtime 1/2] COPY --from=prod-deps /app/node_modules ./node_modules                                                                                                               5.9s
 => [build 1/2] COPY . .                                                                                                                                                               0.8s
 => [build 2/2] RUN export $(cat .env.example) && pnpm run build                                                                                                                       7.5s
 => [runtime 2/2] COPY --from=build /app/dist ./dist                                                                                                                                   0.1s
 => exporting to image                                                                                                                                                                 4.2s
 => => exporting layers                                                                                                                                                                4.1s
 => => writing image sha256:8ae6b2bddf0a7ac5f8ad45e6abb7d36a633e384cf476e45fb9132bdf70ed0c5f                                                                                           0.0s
 => => naming to docker.io/library/v1

第 2 版

主要思路是将 node_modules 内联进 JavaScript 文件,最终只复制 JavaScript 文件到运行环境。

之前看 Next.JS 的时候,记得可以将 node_modules 内联进 JavaScript 文件,这样就不需要 node_modules 了。 所以就研究了一下,发现 Vite SSR 也是支持的,所以判断 Docker 环境就使用内联的方式,不需要复制 node_modules ,只复制最终的 dist 产物,将镜像大小缩小到 135MB 了。

打包脚本改动:

vite: {
  ssr: {
    noExternal: process.env.DOCKER ? !!process.env.DOCKER : undefined;
  }
}

最终的 Dockerfile 如下

FROM node:lts-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app
COPY package.json pnpm-lock.yaml ./

# FROM base AS prod-deps
# RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

FROM build-deps AS build
COPY . .
RUN export $(cat .env.example) && export DOCKER=true && pnpm run build

FROM base AS runtime
# COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
 docker build -t v2 .
[+] Building 24.9s (13/13) FINISHED                                                                                                                                         docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 708B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/node:lts-alpine                                                                                                                     1.7s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 89B                                                                                                                                                       0.0s
 => [base 1/4] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45                                                          0.0s
 => [internal] load build context                                                                                                                                                      0.3s
 => => transferring context: 240.47kB                                                                                                                                                  0.2s
 => CACHED [base 2/4] RUN corepack enable                                                                                                                                              0.0s
 => CACHED [base 3/4] WORKDIR /app                                                                                                                                                     0.0s
 => CACHED [base 4/4] COPY package.json pnpm-lock.yaml ./                                                                                                                              0.0s
 => CACHED [build-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile                                                                           0.0s
 => [build 1/2] COPY . .                                                                                                                                                               1.5s
 => [build 2/2] RUN export $(cat .env.example) && export DOCKER=true && pnpm run build                                                                                                15.0s
 => [runtime 1/1] COPY --from=build /app/dist ./dist                                                                                                                                   0.1s
 => exporting to image                                                                                                                                                                 0.1s
 => => exporting layers                                                                                                                                                                0.1s
 => => writing image sha256:0ed5c10162d1faf4208f5ea999fbcd133374acc0e682404c8b05220b38fd1eaf                                                                                           0.0s
 => => naming to docker.io/library/v2

最终对比,体积从 1.06GB 降低到 135MB, 构建时间从 113.8s 降低到 24.9s

docker images
REPOSITORY                         TAG         IMAGE ID       CREATED          SIZE
v2                                 latest      0ed5c10162d1   5 minutes ago    135MB
v1                                 latest      8ae6b2bddf0a   6 minutes ago    306MB
v0                                 latest      653236defcbb   11 minutes ago   1.06GB

示例项目是开源的,可以在 GitHub 查看。

BroadcastChannel

stat

BroadcastChannel - 将你的 Telegram Channel 转为微博客

之前一直在 X 分享一些有趣的工具,而且也会同步到 Telegram Channel 上。看到 Austin 说准备建立一个网站,把分享内容都收录进去。 刚好想到最近看到的一个模板 Sepia,就想到把 Telegram Channel 转成微博客。

难度不大,主功能一个周末就搞完了。过程中做到了浏览器端 0 JS, 分享一些有趣的技术点:

  1. 防剧透模式和移动端搜索框隐藏展示,使用的 CSS ":checked 伪类" 和 "+ 紧邻兄弟组合器" 实现的,参考

  2. 过渡动画使用的 CSS View Transitions, 参考

  3. 图片灯箱用的 HTML 的 popover 属性,参考

  4. 返回顶部的展示和隐藏使用 CSS animation-timeline 实现 Chrome 115 以上版本专属,参考

  5. 多图瀑布流布局,使用 grid 布局实现,参考

  6. 访问统计使用一个 1px 的透明图片做 LOGO 背景,上古技术了,现在几乎没有访问统计软件支持了

  7. 禁止浏览器端 JS 运行,使用的 Content-Security-Policy 的 script-src 'none' 参考

搞完以后,就直接开源了,很意外有那么多人喜欢,一周就收获了 800+ 的 star 了。

如果你也有兴趣,可以去 GitHub 上看看。

https://github.com/ccbikai/BroadcastChannel

GitHub 上的 BroadcastChannel 仓库

stat

阿里云 AP8220 刷机教程

最近在准备给家用网络升级 2.5G, 在咸鱼收了一台阿里云 AP8220 来折腾。 但是这个机器的刷机资料太少了。折腾了2天才成功刷机。所以写一篇文档记录一下。

提醒

非必要,不刷机。

刷机有风险,请做好刷砖准备。

任何刷机问题与我无关,我只分享我的刷机过程。

准备工作

  1. 阿里云 AP8220 机器一台,咸鱼基本 200 上下。
  2. DC 12V2A 电源一个,原厂不带电源。
  3. USB 转 Console 线。
  4. tftp32.exe。
  5. Putty。
  6. OpenWrt 固件(目前只有 LEAN 的收费固件,我无法提供,希望后续有更多的人适配)。

进入 Uboot

使用 USB 转 Console 线连接到设备 Console 口, 网线连接到设备 LAN 口。

Putty 设置链接 COM3(取决于你电脑设备USB口,自己去设备管理看下), 波特率设置 115200

设备通电,立即按键盘的 shift+@ 中断启动,即可进入 Uboot, 不行就多试几次。

电脑设置静态IP: 192.168.10.1

刷入大分区

下载文件 mibib.bin 文件和 tftp32.exe 放入同目录,打开 tftp32 后,切换到 192.168.10.1

在 Putty 开始敲命令:

tftpboot mibib.bin
flash 0:MIBIB

刷完以后,断电。

刷入固件

设备重新通电,进入 Uboot。

将固件以 factory.bin 结尾的文件命名为 ap8220.bin 放入 tftp32.exe 同目录。

在 Putty 开始敲命令刷写固件:

tftpboot ap8220.bin
flash rootfs

set boot3 "set mtdparts mtdparts=nand0:0x8000000@0x0(fs)"
set boot4 "ubi part fs && ubi read 42000000 kernel"
set setup1 "partname=1 && setenv bootargs ubi.mtd=rootfs ${args_common}"
set setup2 "partname=2 && setenv bootargs ubi.mtd=rootfs ${args_common}"

saveenv

刷完以后,断电。

重新通电等待后可正常启动。

电脑改回 DHCP 获取 IP 地址, 访问 http://192.168.1.1 进入 Web 界面。

在 Web 界面升级刷入 sysupgrade.bin 文件。

刷机完成。

交流

这个机器的刷机资料很少,我想建一个微信群交流。有兴趣的加我微信 ccbikai

AP8220

stat

使用 Cloudflare Zero Trust 制作 Google Safe Browsing 平替

之前做 L(O*62).ONG 的第一版时,使用的服务端跳转,上线第二天就被 Google 警告了安全风险,只能改成本地跳转提醒后跳转再去申诉。

Google警告

对于这种场景最好的做法是使用 Google Safe Browsing 来做跳转,但是 Safe Browsing 有使用限制,每天只能调用 10000 次,而且不支持自定义名单。由于我只想依赖 Cloudflare 一个平台就没有使用 Google Safe Browsing。

前段时间和一个网友交流的时候,突然脑洞大开,想到使用带成人和非法网站过滤的安全 DNS 服务器来做域名安全性的检查。 于是使用 家庭版 1.1.1.1 做了一下尝试发现是可行的。 但是 1.1.1.1 不支持自定义名单,想到之前在 HomeLab 用过 Cloudflare Zero Trust Gateway 就去研究了一下,发现了这个几乎完美的方案。

Cloudflare Zero Trust 提供的 Gateway 自带 DNS(DoH) 服务器,而且可以配置防火墙规则,支持按域名风险等级、内容分类、自定义名单等多种规则屏蔽解析。而且它收集的数据源来自 Cloudflare 专有数据、30 多个开放情报源、机器学习模型、社区反馈等,可以说是相当齐全了。 更多的细节可以在官方文档查看。

我屏蔽了高风险分类、成人、赌博、政府、少儿不宜、新注册等高风险的域名,并且手动维护了一些黑名单和白名单。

风险名单

配置完成以后就可以得到一个 DoH 地址:

DoH

我们使用下面的代码接入到项目中:

async function isSafeUrl(
  url,
  DoH = "https://family.cloudflare-dns.com/dns-query"
) {
  let safe = false;
  try {
    const { hostname } = new URL(url);
    const res = await fetch(`${DoH}?type=A&name=${hostname}`, {
      headers: {
        accept: "application/dns-json",
      },
      cf: {
        cacheEverything: true,
        cacheTtlByStatus: { "200-299": 86400 },
      },
    });
    const dnsResult = await res.json();
    if (dnsResult && Array.isArray(dnsResult.Answer)) {
      const isBlock = dnsResult.Answer.some(
        answer => answer.data === "0.0.0.0"
      );
      safe = !isBlock;
    }
  } catch (e) {
    console.warn("isSafeUrl fail: ", url, e);
  }
  return safe;
}

Cloudflare Zero Trust 管理面板也提供了一个可视化的界面,方便查屏蔽情况。可以看到一些成人网站和新注册域名都被屏蔽了。

可视化界面

如果域名被封了,还可以在日志查看原因。

日志

stat

浏览器本地使用 AI 移除图片背景

最近在学习 AI 相关的前端知识,看到 Transformers.js 的一个实例很感兴趣就把它做成了一个工具。

通过在 WebWorker 中使用 Transformers.js 调用 WebGPU 运行 RMBG-1.4 模型,可以在浏览器本地使用 AI 移除图片背景。在 M1 PRO 上处理一张 4K 图片只需要 500ms。

工具地址:https://html.zone/background-remover

AI 移除图片背景


相关的源码在 https://github.com/xenova/transformers.js/tree/main/examples/remove-background-client ,如果想自己实现,可以参考这个仓库。提醒一下向调用 WebGPU,需要切换到 Transformers.js V3 版本。

stat

Sink - 基于 Cloudflare 带访问统计的短链系统

之前在 Twitter 分享一些网站的时候都会使用短链,方便后续看下大家是否感兴趣。这些短链系统中 Dub 是使用体验最佳的一个,但是有个致命的缺点:月点击量超过 1000 就没法看统计分析了。

清明在家上网冲浪的时候,发现 Cloudlfare Workers Analytics Engine 支持数据写入和 API 查询数据,于是自己搞了一个 MVP 版本,支持每月 3,000,000 次访问的统计。Cloudflare 后端应该使用的是 Clickhouse,性能应该不会有太大问题。

五一在家完善了前端 UI,自己用了半个月感觉还行,开源出来给大家用。

特性

  • 链接缩短
  • 访问统计
  • Serverless 部署
  • 自定义 Slug
  • 🪄 AI 生成 Slug
  • 链接有效期

演示

Sink.Cool

Site Token: SinkCool

全站分析

全站分析

<details> <summary><b>链接管理</b></summary> <img alt="链接管理" src="https://static.miantiao.me/share/uQVX7Q/sink.cool_dashboard_links.png"/> </details>

<br/>

<details> <summary><b>单个链接分析</b></summary> <img alt="单个链接分析" src="https://static.miantiao.me/share/WfyCXT/sink.cool_dashboard_link_slug=0.png"/> </details>

开源

ccbikai/sink - GitHub

路线图(WIP)

  • 浏览器扩展
  • Raycast 扩展
  • Apple 捷径
  • 增强版链接管理 (基于 Cloudflare D1)
  • 分析增强(支持筛选)
  • 面板性能优化 (支持无限加载)
  • 其他平台支持(也许)

最后欢迎在 Twitter 关注我,我会更新开发进度和分享一些 Web 开发资讯。

stat

L(O*62).ONG - Make your URL longer

GitHub

这个小玩具上周就做好了。没几行代码。

部署过程中遇到不少问题,主要在 HTTPS 证书相关。

域名最长一段为 63 个字符。HTTPS 证书 commonName 最长为 64 个字符。

导致 Cloudflare, Vercel, Netlify 无法使用 Let's Encrypt 签署 HTTPS 证书(因为他们 commonName 使用了域名),但是 Zeabur 可以使用 Let's Encrypt 签署 HTTPS 证书。

最终将 Cloudflare 证书切换到 Google Trust Services LLC 才成功签署。

相关证书可以在 https://crt.sh/?q=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong 查看。

地址: https://loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong

stat

没有公网 IPv4 也能玩转 Homelab 远程访问

之前分享了那些常住在我 Homelab 的服务,有人对没有公网 IPv4 的情况下外网访问家庭内服务挺感兴趣,所以整理下自己 Homelab 远程访问的方案。

家用 Homelab 远程访问的场景大多有三个:

  1. 共享内部服务到外网访问 (比如 RSSHub);
  2. 外部设备连接家庭内网,访问内网的各种服务/设备;
  3. PT 连通性和上传速度。

常用的方案有 FRP 代理、IPv6、Zerotier 等,但都有各自的缺点:

  1. FRP 非直连,依赖代理服务器带宽,不一定跑慢带宽;
  2. IPv6 要求外部设备的网络也支持 IPv6,好多办公网络是不支持的;
  3. Zerotier 有时间不能直连,需要中转速度很慢;
  4. 都无法解决 PT 上传的问题。

我现在折腾的方案用了大概两年,稳定性很好,几乎可以媲美公网 IPv4。

但是也有一些前置条件:

  1. 宽带 NAT 为 NAT1(Full Cone);
  2. 路由拨号,路由器系统最好是 Openwrt (其他 Linux 发行版也行,但无法抄作业)。
  3. 有一个内网设备支持运行 Clash (运行在主路由也行)。

也有一些好处:

  1. 不需要代理服务器,可以跑满带宽。
  2. 不依赖 IPv6,有 IPv6 更好一些。
  3. 支持 PT 上传。

如果你的网络和设备满足上面的条件,就可以按照我的方案来尝试了。

网络结构图

先分享一下我网络的最小化结构和主机 IP,方便后续参考对比。

网络结构图

场景 1:共享内部服务到外网访问

我内网运行了一个 RSSHub,需要给外网的订阅器访问。

这类的场景比较简单,我选用的是 Cloudflare Tunnel。按照官方文档运行在 Docker 里边几乎没遇到问题。如果遇到连接 Cloudflare 数据中心的问题,可以尝试切换网络连接方式:HTTP2+IPv4/HTTP2+IPv6/QUIC+IPv4/QUIC+IPv6,都可以试一下。实在不行可以将这个 Docker 容器的流量重定向到 Clash 代理。

参考配置:

# $HOME/.cloudflared/config.yml
protocol: http2 # Available values are auto, http2, and quic.
# edge-ip-version: "6" # Available values are auto, 4, and 6.

场景 2:外部设备连接家庭内网

这个场景下,我选用到的是 Clash Meta 入站 + NATMap,然后将动态 IP 和端口上传到自建的 Vercel 服务,转为 Clash 订阅格式给外网设备订阅。

1. 配置路由器 home 后缀 DNS

1.1 DHCP 给常用设备分配固定 IP

我给路由器分配的 IP 10.10.10.10,OMV 是 10.10.10.100。

1.2 DHCP 将主机名映射到固定 IP

主机名映射

2. 配置服务端 Clash Meta 入站

创建一个或者多个 listener,并且将整个 home 后缀的域名设为直连 (home TLD 非有效 TLD,拦截它的流量人畜无害)。

参考文档:配置 Clash LISTENERS

参考配置:

# $HOME/.clash/config.yml

# hone 后缀使用路由器 DNS,注意修改
dns:
  nameserver-policy:
    "*.home": "10.10.10.10" # 或者 system 或者 dhcp://en0

# 监听入站端口
listeners:
  - name: shadowsocks-in-auto
    type: shadowsocks
    port: 8901 # 自定义,记住后面配置路由器防火墙需要使用
    listen: 0.0.0.0
    password: chimiantiaome # 自定义,记住后面配置订阅需要使用
    cipher: aes-128-gcm

rules:
  - DOMAIN-SUFFIX,home,DIRECT # HOME

3. 路由器防火墙增加端口转发

此方法会在路由器上监听一个端口 8901,然后将流量转发到 Clash 设备的 8901 端口,使用 Clash 设备访问内网。

端口转发

4. 上传到 IP 和端口信息到 Vercel 服务端

4.1 部署 Vercel 服务端存储 IP 和端口信息

Vercel 服务端是我自己写的一个小服务,你可以部署到 Vercel,绑定域名后,拿到 API 地址。

GitHub

部署完成你会有一个 API 地址,例如 https://magic.miantiao.me

修改下面脚本里边的端口地址后,上传到路由器 /usr/bin/diy/dip.sh,下一步配置通知使用。

# #!/bin/sh

# 脚本地址:https://github.com/ccbikai/without-ipv4/blob/master/shell/dip.sh

# DIP

outter_ip=$1
outter_port=$2
inner_ip=$3
inner_port=$4
protocol=$5

logger -t "DIP" "[DIP] start : ${protocol}: ${outter_ip}:${outter_port} to ${inner_ip}:${inner_port}"

if [ "${outter_port}" ]; then
  logger -t "DIP" "${outter_ip}:${outter_port}"
  curl -Ss -o /dev/null -X POST \
    -H 'Content-Type: application/json' \
    -d '{"ip": "'"${outter_ip}"'", "port": "'"${outter_port}"'", "key": "'"${inner_port}"'"}' \
    "https://magic.miantiao.me/dip"
fi

logger -t "DIP" "[DIP] ${inner_port} end"

5. 配置 NATMap

安装 NATMap 的 OpenWRT 插件。增加打洞配置。

NATMap

6. 手动增加防火墙转发

防火墙


7. 外部设备上订阅 Clash 节点

以上流程跑通以后,就可以在外部设备上订阅 Clash 节点了。 第 4 步部署的服务,可以提供 CLASH 订阅文件,地址是 https://magic.miantiao.me/dip?key=8901&password=chimiantiaomeAPI 地址、key、password 注意换成你的

8. Clash 客户端增加转发规则

rules:
  - DOMAIN-SUFFIX,home,HOME-8901 #节点名称可以查看上一步订阅的节点

配置完后重启 Clash,外部设备浏览器打开 http://OMV.home http://AX3600.home 等设备分配的主机名就可以访问内网设备了。

AX3600

我移动端设备使用的 QuantumX,订阅配置文件后 (需要资源解析器),也是可以正常连接家庭网络的。

AX3600

场景 3:PT 连通性和上传速度

PT 上传场景与场景2差不多,但是不需要将 IP 和端口上报到服务端。

1. 按照场景 2 的第 1 步,配置完静态 IP 和主机名

2. 上传更新 qBittorrent 脚本

上传脚本到路由器 /usr/bin/diy/natmap-update.sh

脚本支持在 NATMap 打洞成功后,更改防火墙转发,修改 qBittorrent 端口地址 (支持多实例,可按需修改),并且发送通知到 Bark (可选)。

注意修改脚本中 qBittorrent 的地址、端口、账号和密码。

#!/bin/sh

# 脚本地址:https://github.com/ccbikai/without-ipv4/blob/master/shell/natmap-update.sh

# NATMap

outter_ip=$1
outter_port=$2
inner_ip=$3
inner_port=$4
protocol=$5

logger -t "NATMap" "[NATMap] start NAT : ${protocol}: ${outter_ip}:${outter_port} to ${inner_port}"

case ${inner_port} in
  # qBittorrent
  9001)
    sleep 1
    qbv4="10.10.10.100"
    qbwebport="9091"
    qbusername="mt"
    qbpassword="chimiantiaome"
    # ipv4 redirect
    uci set firewall.redirectqbv41=redirect
    uci set firewall.redirectqbv41.name='qBittorrent9091'
    uci set firewall.redirectqbv41.proto='tcp'
    uci set firewall.redirectqbv41.src='wan'
    uci set firewall.redirectqbv41.dest='lan'
    uci set firewall.redirectqbv41.target='DNAT'
    uci set firewall.redirectqbv41.src_dport="${inner_port}"
    uci set firewall.redirectqbv41.dest_ip="${qbv4}"
    uci set firewall.redirectqbv41.dest_port="${outter_port}"
    # reload
    uci commit firewall
    /etc/init.d/firewall reload > /dev/null
    sleep 3
    # update port
    qbcookie=$(\
      curl -Ssi -X POST \
        -d "username=${qbusername}&password=${qbpassword}" \
        "http://${qbv4}:${qbwebport}/api/v2/auth/login" | \
      sed -n 's/.*\(SID=.\{32\}\);.*/\1/p' )
    curl -X POST \
      -s \
      -b "${qbcookie}" \
      -d 'json={"listen_port":"'${outter_port}'"}' \
      "http://${qbv4}:${qbwebport}/api/v2/app/setPreferences"
    text="[NATMap] qBittorrent TCP Port: ${outter_ip}:${outter_port} to ${inner_port} to $(uci get firewall.redirectqbv41.dest_ip):$(uci get firewall.redirectqbv41.dest_port)"
    ;;
  # qBittorrent
  # 9002)
  #   sleep 10
  #   qbv4="10.10.10.100"
  #   qbwebport="9092"
  #   qbusername="mt"
  #   qbpassword="chimiantiaome"
  #   # ipv4 redirect
  #   uci set firewall.redirectqbv42=redirect
  #   uci set firewall.redirectqbv42.name='qBittorrent9092'
  #   uci set firewall.redirectqbv42.proto='tcp'
  #   uci set firewall.redirectqbv42.src='wan'
  #   uci set firewall.redirectqbv42.dest='lan'
  #   uci set firewall.redirectqbv42.target='DNAT'
  #   uci set firewall.redirectqbv42.src_dport="${inner_port}"
  #   uci set firewall.redirectqbv42.dest_ip="${qbv4}"
  #   uci set firewall.redirectqbv42.dest_port="${outter_port}"
  #   # reload
  #   uci commit firewall
  #   /etc/init.d/firewall reload > /dev/null
  #   sleep 3
  #   # update port
  #   qbcookie=$(\
  #     curl -Ssi -X POST \
  #       -d "username=${qbusername}&password=${qbpassword}" \
  #       "http://${qbv4}:${qbwebport}/api/v2/auth/login" | \
  #     sed -n 's/.*\(SID=.\{32\}\);.*/\1/p' )
  #   curl -X POST \
  #     -s \
  #     -b "${qbcookie}" \
  #     -d 'json={"listen_port":"'${outter_port}'"}' \
  #     "http://${qbv4}:${qbwebport}/api/v2/app/setPreferences"
  #   text="[NATMap] qBittorrent TCP Port: ${outter_ip}:${outter_port} to ${inner_port} to $(uci get firewall.redirectqbv42.dest_ip):$(uci get firewall.redirectqbv42.dest_port)"
  #   ;;
  *)
    text="[NATMap] not NAT: ${protocol}: ${outter_ip}:${outter_port} to ${inner_port}"
    ;;
esac

if [ "${text}" ]; then
  logger -t "NATMap" "${text}"
  # 通知端口信息到 Bark, 不需要可以注释掉下面的 curl
  curl -Ss -o /dev/null -X POST \
    -H 'Content-Type: application/json' \
    -d '{"title": "NATMap", "body": "'"${text}"'"}' \
    "https://api.day.app/BARK_KEY" # Bark API
fi

logger -t "NATMap" "[NATMap] ${inner_port} NAT end"

3. 配置 NATMap

安装 NATMap 的 OpenWRT 插件。增加打洞配置 (注意更新脚本地址)。

NATMap

4. 增加防火墙 NAT 规则,禁用地址重写

如果不修改,qBittorrent 会发现入站 IP 都是路由器 IP,而不是公网 IP,影响统计。

NAT

效果展示

可以看见,分享率还是很客观的。

分享率


整体的方案,还是有一定的折腾难度,如果你遇到问题,可以联系我一起讨论。

stat

那些常住在我 Homelab 的服务

import ClientTweetCard from "@components/magicui/client-tweet-card.jsx";

从 2015 年第一块树莓派 2B 开始,到现在折腾 Selfhosting 类型的服务已经快 10 年了。中间硬件系统软件换来换去,但总有一些服务一直在我 Homelab 里。

今天就来分享下这些服务,后面也会年度更新一次。

基础

硬件

2017 年入的 NUC7i3BNH,目前服役良好,替换时间计划暂定 2027 年。

中间无数次想升级设备,但都被降本增效的原则给拦住了。

操作系统

基于 Debian 内核的 openmediavault。 选他的原因是 Debian 内核 + 开源免费。

OMV

网络

江苏电信 1000M/50M 宽带 EPON 接入。

无 IPv4 (NAT444 + NAT1 解决外网访问和 PT 上传问题)。IPv6 连接正常。

宽带测速

应用

网络类

Clash

承担在外时连接到家中网络和局域网设备代理服务 (旁路由) 的功能。必不可缺。

Tailscale

办公室和家中异地组网,目前使用 Clash 入站能力更多,Tailscale 作为备用方式。

Cloudflare Tunnel

局域网服务暴露到公网的首选选择。

连接 Cloudflare 数据中心使用 HTTP2 + IPv4 很稳定,速度可以跑满 50M 上传。

Cloudflare WARP

备用代理,拿来看视频无广告。

Smokeping

用于监控宽带连通性和延迟的工具。曾经帮我发现新光猫的性能问题。

<ClientTweetCard client:load id="1744534793562599531" />

智能家具

HomeAssistant

小米生态快速接入 HomeKit。

HomeKit

NodeRed

负责接入 HomeAssistant 数据,并对接第三方服务 (目前接入了空调设备)。

空调设备

媒体类

qBittorrent

承担 PT 下载功能,配合 natmap 解决无公网 IPv4 情况下 PT 上传速度的问题。

22G 下载也可以有 100G 上传,5 倍分享率不难。配合 flood 使用,Web UI 也很漂亮。

PT上传

MoviePilot

负责媒体刮梢,在线资源一键订阅自动下载。

MoviePilot

Jellyfin

在外时通过流媒体观看视频。

服务类

TeslaMate

车辆历史数据记录。

TeslaMate

RSSHub

自部署配合 IPv6,可解决不少因 IP 被限制不能使用的问题。

文件类

Syncthing

Mac,Windows,Linux 多设备之间文件同步和备份。

ALIST

多网盘聚合 + 局域网 WebDAV 服务器 + 文件管理。

aria2

普通资源下载。

系统类

Portainer

Docker Web UI,虽然有其他 UI,但我还是用它最习惯。

Uptime Kuma

因为之前 TeslaMate 丢数据,也在局域网部署了一份。

<ClientTweetCard client:load id="1731184617666089132" />

Uptime Kuma

stat
❌